# ADR-037 -- Entra Authenticates, Bilko Authorises; Single-Role v1; Multi-Org Deferred

## ADR-037 — Entra Authenticates, Bilko Authorises; Single-Role v1; Multi-Org Deferred

<table id="bkmrk-fieldvalue-adr-numbe"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody><tr><td>ADR number</td><td>ADR-037</td></tr><tr><td>Date</td><td>2026-06-08</td></tr><tr><td>Status</td><td>Accepted</td></tr><tr><td>Author</td><td>John (AI Director, ALAI Holding AS)</td></tr><tr><td>CEO decision</td><td>Alem Basic — confirmed 2026-06-07 (CEO resolution addendum, MC #103075)</td></tr><tr><td>Related MCs</td><td>MC #103075 (Entra migration plan), MC #103141–103146 (WP1–WP6 execution), MC #103089 (multi-org, parked)</td></tr><tr><td>Supersedes</td><td>Existing inline requireRole() pattern (pre-WP1)</td></tr></tbody></table>

## Context

Bilko had a custom email/password authentication system and a simple numeric role hierarchy (`requireRole()` inline in route handlers). No permission catalog, no RBAC tables, no admin UI for user management. The CEO decision (June 2026) was to:

1. Replace email/password authentication with Microsoft Entra External ID (CIAM) — hard REPLACE, not phased coexist
2. Build a real permission-catalog RBAC system with a DB-backed role-to-permission mapping

Multiple design forks were evaluated by a multi-agent panel (Parisa Tabriz, Martin Kleppmann, Petter Graff, Bruce Momjian, Devils Advocate — MC #103075 forged prompt). Key unresolved tensions: web direct-bearer vs exchange, email-match JIT vs pre-provision-by-oid, roles-in-Entra-claims vs roles-in-Bilko-DB.

## Decision

### D1 — Identity Provider Boundary

**Entra External ID (CIAM) authenticates. Bilko authorises.**

- Entra issues tokens; Bilko backend validates JWKS RS256 signature, issuer, audience
- Bilko reads `oid` from the Entra token as the sole identity anchor (`sub` is pairwise-pseudonymous per app and must NOT be used)
- Bilko issues its own access + refresh tokens after Entra token exchange; downstream services consume Bilko tokens, not Entra tokens directly
- Role and permission data live in `users.role` + `role_permissions` (Bilko DB). No role or permission claims are read from Entra tokens

### D2 — Single Role per User per Organisation (v1)

One role per user per org: `owner | admin | accountant | viewer`. The role is stored in `users.role` (single column). Multi-role per user and multi-org membership are explicitly deferred to a separate epic (MC #103089).

**Rationale:** zero live clients; single-org Entra tenant; keep scope tightly bounded; multi-org requires a `organization_members` join table and CIAM tenant model decisions that are not yet resolved.

### D3 — Permission Catalog in DB; Flat Inheritance Seed

A `permissions` catalog table (52 keys, `resource:verb` format enforced by CHECK) and a `role_permissions` mapping table (V67) replace the inline `requireRole()` calls. Seed strategy: flat exhaustive rows per role (Strategy A) — no runtime hierarchy derivation. The seed exactly reproduces existing behaviour (no regression — verified by 204 RbacMatrixTest cases).

### D4 — Live DB Permission Resolution; Fail-Closed

`PermissionService.resolve(role)` queries `role_permissions` at request time. Unknown role resolves to `emptySet()` (no permissions). `BilkoPrincipal` carries the resolved permission set. All route-level checks use `requirePermission("resource:verb")`.

### D5 — Multi-Org Deferred

Entra CIAM is provisioned as a single tenant. JIT provisioning assigns a new Entra user to one Bilko organisation. Multi-org (one user in multiple orgs) requires: a `organization_members` join table, per-org permission resolution, and CIAM tenant model decisions. All deferred to MC #103089.

## Consequences

### Positive

- Authentication complexity moved to Microsoft (password policies, MFA, SSPR, account lifecycle)
- Bilko no longer stores password hashes for new users (`password_hash` is nullable)
- Permission model is auditable and admin-configurable without code changes (role-to-permission seed is data)
- Authz decisions are logged (`AuthzAuditLogger`) for incident investigation
- Admin UI for user + role management (no more raw SQL for role changes)

### Negative / Trade-offs

- Entra CIAM has MAU-based pricing; cost gate was raised (OC#1, MC #103075) — free tier starts June 2026
- 7-day refresh token revocation window: a disabled Entra account remains valid in Bilko for up to 7 days (documented risk OC#4; mitigation: admin disables user in Bilko DB)
- Email-match JIT carries race risk if email is mutable or duplicated (martin-kleppmann + bruce-momjian dissent on record); serializable transaction is a partial mitigation; pre-provision-by-OID is the recommended production path
- Single-role v1 limits fine-grained delegation scenarios (e.g. "viewer + approve-only on specific documents") — documented as out of scope

## Alternatives Considered

<table id="bkmrk-alternativerejected-"><thead><tr><th>Alternative</th><th>Rejected reason</th></tr></thead><tbody><tr><td>Roles in Entra claims (Entra app roles)</td><td>Couples authorisation to IdP; role changes require Entra admin action not Bilko admin action; prevents clean multi-IdP future. Rejected per petter-graff + parisa-tabriz panel consensus.</td></tr><tr><td>Phased coexist (email/password + Entra in parallel for 2+ weeks)</td><td>CEO confirmed hard REPLACE. Panel devils-advocate raised phased coexist as safer; CEO re-confirmed hard REPLACE given zero live users. AuthProvider interface (D5 MC #103075) technically enables a revert if needed.</td></tr><tr><td>Denormalised entra\_oid on users table (bruce-momjian alternative)</td><td>Separate-table V64 model kept; enables multi-IdP future; join cost is negligible at current scale. Fork preserved but not resolved — separate-table remains.</td></tr><tr><td>ABAC / policy engine (v1)</td><td>Premature for current scale and requirements; adds complexity; deferred as explicit out-of-scope with comment in plan.</td></tr></tbody></table>

## Open Decisions Not Resolved by This ADR

- **OC#4 — Refresh revalidation vs risk acceptance:** Option A (revalidate Entra account status on refresh, ~50ms) vs Option B (7-day window, documented risk). Requires CEO/Securion explicit decision. Code stub for Option A is in `AuthService.kt` referencing MC #103075.
- **OC#2 — Hard REPLACE confirmed** but AuthProvider interface (D5, MC #103075) enables reversion if needed.
- **Web direct-bearer vs mobile exchange (parisa-tabriz dissent LIVE):** Web: MSAL acquires Entra access token, sends as Bearer to API. Mobile: id\_token exchange at `POST /auth/entra/session`. Web direct-bearer is implemented; exchange path preserved as commented stub per spec.

## Document Links

- [Bilko Authentication — Entra External ID (CIAM)](/books/bilko-balkan-accounting-saas/page/bilko-authentication-entra-external-id-ciam)
- [Bilko RBAC — Users / Roles / Permissions](/books/bilko-balkan-accounting-saas/page/bilko-rbac-users-roles-permissions)
- [Bilko Auth Migration Runbook + Admin Guide](/books/bilko-balkan-accounting-saas/page/bilko-auth-migration-runbook-admin-guide)
- Source plan: `/Users/makinja/system/specs/bilko-web-entra-cutover-and-rbac-plan-2026-06-08.md`
- Forged prompt (panel dissent log): `/Users/makinja/system/prompts/forged/103075.md`
- Phase 0 config: `/tmp/evidence-103076/phase0-config.md`