ADR 0006: Use Entra ID for administrators only¶
Status: Accepted Decision date: 2026-07-22 Owners: Workload owner and architecture owner
Context¶
The public site is anonymous, while the administrative portal changes content, integrations, media, and operational configuration. Firebase Auth must be removed without creating unnecessary public-user identity scope.
Purpose and decision drivers¶
Protect privileged workflows with the tenant's workforce identity while keeping public access simple.
Decision¶
Use one Entra SPA registration, one API registration, and an admin app role or group. Public content remains anonymous. Every admin API route validates issuer, audience, expiry, and authorization claims.
Consequences and accepted risks¶
- Admin authorization moves from Firebase claims/allowlists to Entra governance.
- The admin bundle remains public; APIs protect all privileged data and actions.
- Public labs require quota and abuse controls rather than a public-user identity system.
- Bootstrap and break-glass access require explicit procedures.
Alternatives considered¶
- Entra External ID for all visitors: rejected because public accounts are not required.
- Retain Firebase Auth: rejected because it preserves a GCP production dependency.
- Cloudflare Access as sole authorization: rejected because Azure APIs must enforce authorization.
Validation and revisit triggers¶
Validate admin, non-admin, expired, wrong-audience, and revoked-token paths. Revisit if the site later requires persistent public-user accounts.
Validated 2026-09-12 (#522) — the tenant now matches this decision¶
This ADR has specified "one Entra SPA registration, one API registration" since it was accepted.
The tenant did not: the cutover put an SPA platform on the API's own registration, and three other
places in the repository (verify-token.js DECISION 3, infra/variables.tf, and
scripts/lib/deploy-console.ps1) described the two-registration model while the tenant ran one.
The divergence was reasoned, not accidental — one registration means the SPA requests a scope on its
own app, which consents automatically and removes the risk of a client id and an audience that
disagree. That risk is now caught before it ships: assertDeployConfig refuses a deploy build whose
Entra ids are not GUIDs (#516). The rationale is kept, struck through, in
scripts/cutover/02-entra-spa-client.ps1.
- API (resource): exposes
access_as_admin, defines theAdminandLabAgentapp roles, holds the role assignments, and is whatENTRA_API_AUDIENCEnames. Unchanged by the split — its app id is not written anywhere in the repository:scripts/cutover/01-entra-api.ps1and the other Entra scripts look it up by the registration's name,HCWSite API, in the signed-in tenant (scripts/lib/Resolve-EntraApiAppId.ps1), and the redaction gate keeps a real id out of every tracked file (#1540). - SPA (client): a separate registration, public client only, whose app id is the
VITE_ENTRA_CLIENT_IDrepository variable. It exposes nothing and holds no credentials. - Dev client: a third registration carrying
http://localhostredirect URIs, so the production one carries none (#521).
Microsoft's stated reason for the separation is permission inheritance: "if the web API has a higher set of permissions, then the client app doesn't inherit them." One registration means one service principal, so any credential or Graph permission added for API use is simultaneously available to a browser-delivered public client.
Observable afterwards: azp (the client that asked) differs from aud (the API it is for) in
every access token. Admin → Health asserts it, so pointing VITE_ENTRA_CLIENT_ID back at the API
turns a verdict red on a page an admin already visits, rather than silently reverting the decision.
Validated 2026-09-12 (#514)¶
MFA is enforced by security defaults, not Conditional Access. Checked against the live tenant:
security defaults are enabled in tenant properties, and Conditional Access is unavailable because the
tenant is not licensed for Entra ID P1. Three files previously stated Conditional Access —
AdminAuthGuard.jsx, entraAuth.js and this ADR — and all three were corrected.
Two consequences worth writing down rather than rediscovering:
- Security defaults cannot be scoped or excepted. There is no way to exempt a break-glass account, so a break-glass path has to survive MFA rather than bypass it.
- Entra disables security defaults automatically the moment any Conditional Access policy is created. If this tenant is ever licensed for P1 and someone writes their first policy, MFA stops being enforced everywhere it currently is unless that policy covers it. That is the revisit trigger: licensing P1 is not a free upgrade here, it is a change that requires replacing the MFA control before it takes effect.
Continuous Access Evaluation is not available to this API and must not be declared. CAE requires
that both the client and the resource be CAE-enabled, and CAE-enabled resources are Microsoft
first-party services; a custom Azure Functions API cannot be one. The revocation SLA is therefore the
60-second admins/{oid} cache in lib/auth/roles.js, which is tighter than CAE for the events this
application actually cares about. See the citation in that file.