Authorization & Identity — UMS, per-app namespaces, role tiers, control plane
Atelier generates a multi-tenant experience where every entity, action, and surface is declared, not coded. Authorization follows the same rule: declaring a new entity type, application, or role must not require a permission branch in Python. The admin-bff is a Policy Enforcement Point (PEP), not a policy store — a UMS-v2 consumer application. At every entity-proxy read/write it calls resolve_grants(application_id="admin-bff", principal, entity_type, action) and compiles the response into an ontology filter (list), an in-memory predicate (detail), or a write guard (POST/PATCH/DELETE) — admin-api/app/authz/grants.py. Identity collapses to four signals: the JWT tenant_id claim, the JWT azp (which app namespace), SpiceDB org/group membership, and the per-app namespace. Two failure modes are locked: fail-closed (503) when UMS is unreachable, deny-all (1=0) when no grants come back. Because policy is data and identity is graph membership, one path serves anonymous citizens, staff, and service principals — the load-bearing guarantee under the generated portal, staff admin, and control plane.
Data model
| Name | Purpose | Plane | Key fields |
|---|---|---|---|
acl_rule (UMS row) | The authz artifact, compiled by the BFF and reconciled into UMS | runtime | application_id, action, entity_type, grantee_type, grantee_id, tenant_id, constraints |
DesiredRule (BFF) | Compiler output (ums_acl_rules.py:251), diffed vs UMS into create/update/delete | runtime | action, entity_type, application_id, grantee_type, grantee_id, constraints |
| SpiceDB membership tuple | The ONLY cross-app shared fact (org per-ancestor + role/tier group) | runtime | resource_type, resource_id, principal (user or application:kc_id), member |
organization_membership | Single authz source of truth (ADR-0002); role=admin on root org becomes a tier | tenant | user_id, organization(FK), role, is_active |
user (ontology) | One row per person per tenant, keyed by KC sub; the shared principal | tenant | id, subject_ref(KC sub), username, email |
UMS user (registry) | Maps a JWT to its SpiceDB subject; username MUST equal kc id | runtime | username(=kc id), email |
application_entity_membership | Operator-authored (application, entity_type, surfacing_pattern); Strategy E set | template | application(FK), entity_type_code, surfacing_pattern |
tenant | id/code/name; NO stored root_org_id (convention-derived) | vocabulary | id, code, name, kc_realm |
| tier groups | platform_admins / tenant_admins_(code): control-plane tiers from root-org admins | runtime | group id, member tuples |
How it's declared
Never as BFF code branches — declared as data and compiled. Four surfaces. (1) ACL rules go direct to UMS REST (the per-app-architecture.md 2026-06-08 reset removed the BFF as rule-writing middleman). (2) Role membership is organization_membership rows (user, org, role); reconcilers flatten them into SpiceDB tuples, and role=admin on a root org grants a control-plane tier. (3) Action apply-gating is authored in the action editor (action_type.permissions_policy.apply + action_submission_criteria), bridged by authz/action_apply_rules.py (see The Action Engine). (4) Citizen/public surfaces via public_entity_surface / portal declarations (see The Surface Plane and Citizen Portal). The compiler (ums_acl_rules.py) derives CRUD rules from declarative inputs only: schema (an organization rel gives the org-scoped/direct bucket; metadata.bound_via gives indirect/no-rules; neither gives a global catalogue), application_entity_membership ownership, and the static ROLE_ACTIONS matrix (classify_entity_types:382, build_desired_rules:465). New scoping dimensions are declarable via the acl_rules: sheet block over SCOPING_DIMENSIONS (ums_acl_rules.py:106). Cross-tenant authoring uses X-Author-Tenant, honored only for platform_admins / tenant_admins_(T) (auth.py:authoring_update_allowed, read directly from SpiceDB tier membership).
How it's provisioned
Three converging surfaces. (1) BFF startup metadata sync registers admin-bff + per-app namespaces + entity types from the binding-source tenant template_municipality (NOT system), seeds CRUD + tier rules (ensure_acl_rules_seeded, build_admin_role_tier_rules), runs reconcilers (ensure_service_principal_memberships, reconcile_role_tier_memberships). (2) Runtime write-hooks: an org-membership write flattens into ancestor org tuples + a role-group tuple (authz/memberships.py); action_type writes reconcile apply rules; application create provisions a UMS namespace + KC client (azp mapper). (3) Reconcile scripts (scripts/reconcile_role_tiers.py, reconcile_service_principals.py) re-assert tuples without restart. On fork, application / application_entity_membership / admin_entity_config / action rows copy + FK-rewire; the tenant's first root-org admin membership seeds via the saga MembershipStep and becomes tenant_admins_(T) (see The Fork Pipeline). The platform tenant + root org are provisioned once (scripts/provision_platform_tenant.py); its root-org admins become platform_admins, replacing the is_superuser bootstrap. Citizens self-provision a user row + per-tenant tuple on first authenticated intake (identity.py:upsert_identity).
Extension points
- New proxied entity type: declare schema (
organizationrel = direct;metadata.bound_via= indirect/parent-join; neither = global) + add toapplication_entity_membership; rules emit under that app's namespace — no BFF branch. - New scoping dimension: extend
SCOPING_DIMENSIONSwith op + template, author via theacl_rules:sheet block. - New control-plane substrate type: set
metadata.control_plane: true—resolve_application(fetch_is_control_plane) routes it to admin-bff; no hardcoded-set edit. - New application namespace: write an
applicationrow;ApplicationWriteHooksprovision namespace + types + KC client;reconcile_app_namespaceemits per-app rules. - New service principal: add to
SERVICE_PRINCIPAL_GROUPS(memberships.py:352); invariant — everyapply:[role]action it reaches implies membership inadmin-bff_(role). - New tier capability: extend
CONTROL_PLANE_PLATFORM_GRANTS/ADMIN_BFF_TIER_GRANTS_*— meta-entities (application,acl_rule) MUST be authored underums-v2, not admin-bff.
Invariants
- UMS unreachable gives 503 fail-closed (
grants.py:_resolve, ~line 876). The BFF never serves a request it cannot grant-resolve. - No grants gives deny-all (
("1=0", [])/ always-false predicate). A member-less principal whose{{caller.organization_memberships}}is empty is deny-all viaPrincipalUnresolvable(grants.py:797-865), NOT 503 — permanent, not an outage. - Tenant identity comes from the JWT
tenant_idclaim; inboundX-Tenant-Idis advisory and must agree (else 401); header-only is rejected for session tokens (#391,authz/tenant.py). - SpiceDB tuples are the only cross-app fact; ACL rules are per-app. Org tuples are flattened per ancestor (SpiceDB does not auto-expand); group ids use underscore not colon.
- UMS username MUST equal the kc user id (the
on_behalf_ofsubject contract) — ausername=emailrow silently denies every staff read. - Membership writes are write-before-delete (partial failure leaves it broader, reconcilable).
tenant_idis immutable on a rule — reconcile is delete-then-recreate. organization_membershipis the single authz SoT; the proxy path never readsrealm_access.roles.- Meta-entities resolve under
ums-v2, never admin-bff; they have notenant_id, so control-plane grants are platform-only. - Act-as is authorized from an independent SpiceDB tier-membership attestation, not the data-plane
admin_entity_config:writeresolve (confused-deputy guard).
Gaps & open edges
- BFF-side vs ontology-native (
unified-identity-role-model.md, ADR-0001): the per-app read-path flip shipped and was decommissioned (#424 merged, #431 closed; flags retired perconfig.py:54/application.py:129). The settled model is Pattern A — the BFF resolves under the per-app namespace (on_behalf_of=user JWT), compiles to a filter, calls the ontology with a service-account token; forwarding the user token to the engine is rejected by ADR-0001. Rule compilation deliberately stays in the BFF as a derived-artifact reconciler (it relocates, never vanishes — spec §5); composite/BFF-native types (kc_*,admin_entity_config, joins) enforce BFF-side by design. The only further consolidation (engine resolves under a consumer app-id) is gated on one upstream change the BFF cannot make:resolver.py:67honoring a consumer-supplied app-id — Path X vs Path Y deferred to whoever owns that timeline. is_servicebypass still stands:entity_proxy._should_enforcereturns False for service callers and service_resolved_tenantis verbatim from inboundX-Tenant-Id— the shared Martha secret reads/writes any tenant. #390 added per-tenant Vault binding butrequire_per_tenant_secretis not flipped on (§2.6.1 CRITICAL).- Legacy HTTP LIST translator cannot OR distinct grants — 503s on genuine multi-grant, surviving via a dedup + org-subsumes-status heuristic (
grants.py:173-244). Only 4 citizen-list sites use the gRPCOr()path; ~84 httpx sites unmigrated (#162). - Triple-namespace: admin-bff,
ums-v2(UMS internal, for application/acl_rule), and the ontology engine's ownums_app_id(ONTO src/app/authz/ums.py,resolver.py) each evaluate independently.nav_section/nav_itemhave 0 rules in both planes — blank sidebar is a provisioning gap, not a read-pattern bug. - Citizen CODE-to-UUID repoint is a three-sided lockstep (tuple + rule grantee + binding); the
_uuid_onlystrip is retired but kept as a defensive net through Phase-6 — the claim most likely to break at runtime. tenanthas no stored root_org_id — convention-derived (tenant_code.upper().replace('-','_')) + looked up by code; a non-conforming code breaks citizens and tier provisioning. Audited, not structurally guaranteed.- Org-subtree closure is BFF-side (
_expand_org_subtree, 60s TTL, fail-open) — a §6 ontology-team deliverable until a SpiceDB parent-to-access schema ships. - platform-global is still
tenant_id-NULL; theCK_platform_globalDB-CHECK sentinel is design, not enforced. - Action apply enforcement is still direct JWT realm-role reads in
validation.py; theresolve_grants(action=apply)cutover is unlanded (#54). - Indirect-bucket types get zero rules, read via a BFF parent-id-IN subquery — carved OUT of the migration; a "service owns all policy" cutover would silently break every child read.