---
status: accepted
date: 2026-04-29
decision-makers: R&D Lead
tags: [security, data]
---

# Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID

## Context and Problem Statement

The portal exposes no public content — every interaction requires authentication. Two audiences are foreseen:

- **Workforce.** Employees of the host organization, plus employees of partner organizations who must access the portal.
- **Customers.** External end-users (clients) without a corporate IdP — covered by Microsoft Entra External ID (the CIAM product, formerly Azure AD B2C).

Only workforce is needed for v1. Customers are a probable future need with no current users on the horizon. We must therefore decide three intertwined questions: which identity provider, which tenant model, and how much of the customer-side architecture to put in place _before_ there are customers.

A separate complication: the host organization has no Microsoft 365 or Azure subscription yet. The production Entra tenant has to be provisioned, which is org-level IT work that must not block development.

## Decision Drivers

- Authentication is security-critical — the technical bar is "no bricolage", not "good enough".
- Workforce identity belongs in a managed, enterprise-grade IdP — the org's chosen direction is Microsoft Entra.
- Customer (CIAM) infrastructure has a real cost (separate tenant provisioning, branding, IDPs, self-service signup, conditional access policies). Don't pay it before there are customers.
- Adding the customer audience later must not require a security-audit refactor of authorization, sessions, or data — the structural cost of being audience-aware is much lower up-front than retrofitted.
- Development must not be blocked on org-level provisioning of the production tenant.
- Authorization must be claims-based and audience-aware from day one — the implicit assumption "authenticated == internal employee" is exactly the failure mode that turns into a privilege-escalation incident the day external users are introduced.

## Considered Options

Three independent axes, decided together because they constrain each other:

### Tenant model (production)

- **Single-tenant strict** — only employees of our own Entra tenant can sign in.
- **Multi-tenant with B2B invitation.** (Chosen.) Employees of partner organizations are explicitly invited as B2B guests in our tenant; they appear in our directory and are subject to our Conditional Access policies.
- **Multi-tenant open** — any user from any Entra tenant can sign in; tenant allowlisting handled in the application.
- **Workforce + Entra External ID from day one** — full dual-IdP architecture provisioned now.

### Code and data architecture

- **A. Audience-aware design from v1, workforce-only implementation.** (Chosen.) The session, claims, DB schemas, RLS policies, and authorization layer all carry an `audience` value. In v1 the only valid value is `'workforce'`; `'customer'` exists in the type system and code paths but is gated off (no External ID authority registered). When External ID is later activated, no schema migration or authz refactor is needed.
- **B. No audience-awareness — build for workforce only, refactor later.**

### Development tenant (while prod is being provisioned)

- Wait for the prod tenant — accept the dev block.
- **Microsoft 365 Developer Program tenant.** (Chosen.) Free, full-featured, renewable every 3 months.
- Self-hosted IdP for dev (e.g. Keycloak) — explicitly rejected as bricolage and as a divergence from the prod code path.

## Decision Outcome

**Production identity model:** Microsoft Entra ID, **multi-tenant app registration**, **B2B guest invitation** for partner-org users. No External ID tenant in v1. License tier ≥ **Entra ID P1** (required for Conditional Access, B2B governance, sign-in logs).

**Code and data architecture:** **dual-audience design** from v1. Concretely:

- A shared type `Audience = 'workforce' | 'customer'` lives in a `libs/shared/data-access` lib.
- Every authenticated session carries `audience: Audience`. A `CurrentUser` decorator exposes `{ id, audience, claims, ... }` to every controller.
- Every persistent table whose rows can be visible to either audience carries an `audience` column (Postgres `audience` enum), default `'workforce'`. Tables that are exclusively workforce-internal can omit the column but must be marked as such with a comment, so a future audit can identify them.
- Postgres **Row-Level Security** policies are written from day one, scoped on `audience`, with the BFF setting `app.audience` per request via a Prisma middleware that runs `SET LOCAL app.audience = '<value>'` at the start of each transaction.
- Authorization is claims-based — guards never assume "logged in == workforce". Tests include a synthetic `audience: 'customer'` user that must be denied/empty where it should be.

**Multi-tenant token validation:** because the app is multi-tenant, the `iss` claim varies by the user's home tenant. The BFF maintains an **allowlist of accepted tenants** (our prod/dev tenant + each partner tenant for which B2B has been configured). Tokens whose `iss` does not match an allowlisted tenant are rejected. The mechanics live in the future auth-flow ADR.

**Development environment:** a **Microsoft 365 Developer Program tenant** is provisioned and used for all non-prod environments until the prod tenant is delivered. The same multi-tenant + B2B configuration is mirrored. MSAL Node config is environment-driven — `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET` (or certificate), and the tenant allowlist are all read from env, never hardcoded.

**Future External ID activation** is explicitly out of scope of v1 but in scope of the design. When it is activated:

- A second tenant (Entra External ID) is provisioned — separate product, separate tenant, separate issuer.
- A second authority is registered with MSAL Node.
- The session sets `audience: 'customer'` for users authenticated via that authority.
- Existing data is untouched (still `audience: 'workforce'`); customer-visible data is created with `audience: 'customer'`.
- No application refactor, no schema migration.

### Consequences

- Good, because workforce gets the full Entra ID enterprise feature set: Conditional Access, MFA, sign-in risk, B2B governance, audit logs.
- Good, because B2B invitation gives controlled access to partner-org employees — they appear in our directory and are subject to our policies, not theirs.
- Good, because deferring External ID saves multiple weeks of CIAM provisioning, branding, and policy work that would deliver no v1 value.
- Good, because the dual-audience design costs almost nothing up-front (a column per relevant table, a claim in the session, a discipline in authorization) and prevents an expensive retrofit + security audit later.
- Good, because the dev tenant strategy unblocks development while prod provisioning happens at IT pace.
- Good, because future External ID activation is a switch-flip — provision the second tenant, register a second authority, ship — not a re-architecture.
- Bad, because the organization needs to acquire **Entra ID P1 licensing** for production — a business decision with a real cost. Mitigation: documented up-front so it can be planned, not surprised.
- Bad, because Microsoft 365 Developer Program tenants must be **renewed every ~3 months** (Microsoft's policy). Mitigation: a recurring task on the IT side; data in dev is non-sensitive by definition.
- Bad, because dual-audience discipline must be applied consistently — every new table, query, and authz check must respect it. Mitigation: helpers, code review, and a future CI lint rule that flags untagged tables.
- Bad, because multi-tenant token validation requires maintaining a tenant allowlist — operational item, not a one-shot. Mitigation: list maintained in config (versioned), reviewed during partner onboarding.
- Bad, because there is no formal v1 user for the `'customer'` code path — meaning it is not exercised in production traffic. Mitigation: dedicated automated tests with a synthetic customer principal so the path stays alive and correct.

### Confirmation

- A `libs/shared/data-access` lib exports `type Audience = 'workforce' | 'customer'` and a `CurrentUser` decorator.
- BFF env: `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET` (or certificate path), `ENTRA_ACCEPTED_TENANT_IDS` (comma-separated allowlist). No tenant ID is hardcoded in source.
- The Prisma schema has a `Audience` enum and uses it on every shared table; tables that are exclusively workforce-internal carry a comment explaining why.
- A Prisma middleware sets `SET LOCAL app.audience` at transaction start.
- Every Postgres table containing user-touching data has an `ENABLE ROW LEVEL SECURITY` and a policy that filters on the session's audience.
- Authorization tests include a `customer`-audience principal and confirm that no workforce data is returned.
- `apps/portal-bff` configuration documents the prod and dev tenants distinctly; both are configured multi-tenant + B2B.
- A scheduled task or runbook reminds IT to renew the M365 Developer tenant on cadence.

## Pros and Cons of the Options

### Tenant model

#### Single-tenant strict

- Good, because simplest issuer validation (one accepted issuer).
- Good, because users are all in our directory, fully under our policies.
- Bad, because partner-org employees can't sign in unless we provision them as guest accounts (overhead) or assign them ours (security risk).

#### Multi-tenant with B2B invitation (chosen)

- Good, because partner-org employees use their own Entra credentials but are visible in our directory and subject to our Conditional Access.
- Good, because B2B governance (access reviews, expiry) is built-in.
- Good, because Microsoft's recommended pattern for inter-org workforce access.
- Bad, because requires invitation flow per guest user — operational item.

#### Multi-tenant open (no invitation)

- Good, because lowest friction for any workforce user from any Entra tenant.
- Bad, because no control over who signs in unless an application-level allowlist is implemented and maintained.
- Bad, because guests aren't in our directory — limited governance, no Conditional Access from our side.

#### Workforce + External ID from day one

- Good, because the full dual-audience capability is live.
- Bad, because the org has neither M365/Azure nor any customer user identified — provisioning a CIAM tenant now has zero v1 yield and significant ongoing cost.

### Code and data architecture

#### A. Audience-aware design from v1 (chosen)

- Good, because the cost is small upfront — one column per shared table, one claim in the session, a discipline in authz.
- Good, because adding External ID later is a configuration change, not a refactor.
- Good, because authorization tests can already exercise both audiences.
- Bad, because a discipline that must be respected on every new table — mitigated by lint and review.

#### B. No audience-awareness — workforce only, refactor later

- Good, because the v1 codebase is slightly simpler.
- Bad, because retrofitting audience-awareness after the fact requires schema migration on every relevant table, refactoring of every authorization check, and a full data-leak audit. The cost is asymmetrically larger than option A.

### Development tenant

#### Wait for the prod tenant

- Good, because the dev environment is identical to prod.
- Bad, because the development of every auth-touching feature is blocked until org-level provisioning is complete.

#### Microsoft 365 Developer Program tenant (chosen)

- Good, because free, full-featured, mirrors the prod configuration.
- Good, because completely decoupled from the prod tenant timeline.
- Bad, because requires periodic renewal (every ~3 months).

#### Self-hosted IdP (Keycloak or equivalent) for dev

- Good, because fully under our control, no Microsoft account dependency.
- Bad, because the dev code path differs from prod (different OIDC quirks, different claim shapes) — exactly the kind of bricolage that creates "works in dev, breaks in prod" surprises.
- Bad, because adds a moving part to local dev (a running IdP container) for no real gain.

## More Information

- Multi-tenant Entra apps: https://learn.microsoft.com/azure/active-directory/develop/howto-convert-app-to-be-multi-tenant
- Entra B2B collaboration: https://learn.microsoft.com/entra/external-id/what-is-b2b
- Entra ID licensing tiers: https://learn.microsoft.com/entra/fundamentals/licensing
- Microsoft 365 Developer Program: https://developer.microsoft.com/microsoft-365/dev-program
- PostgreSQL Row Level Security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html
- Related ADRs: [ADR-0006](0006-persistence-postgresql-prisma.md) (Postgres + Prisma — RLS used by this ADR), and the future phase-2 ADRs for auth flow (OIDC Auth Code + PKCE via MSAL Node), session management (Redis), MFA enforcement (Conditional Access claims), downstream API access (On-Behalf-Of), and audit trail.
