Skip to content

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

NamePurposePlaneKey fields
acl_rule (UMS row)The authz artifact, compiled by the BFF and reconciled into UMSruntimeapplication_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/deleteruntimeaction, entity_type, application_id, grantee_type, grantee_id, constraints
SpiceDB membership tupleThe ONLY cross-app shared fact (org per-ancestor + role/tier group)runtimeresource_type, resource_id, principal (user or application:kc_id), member
organization_membershipSingle authz source of truth (ADR-0002); role=admin on root org becomes a tiertenantuser_id, organization(FK), role, is_active
user (ontology)One row per person per tenant, keyed by KC sub; the shared principaltenantid, subject_ref(KC sub), username, email
UMS user (registry)Maps a JWT to its SpiceDB subject; username MUST equal kc idruntimeusername(=kc id), email
application_entity_membershipOperator-authored (application, entity_type, surfacing_pattern); Strategy E settemplateapplication(FK), entity_type_code, surfacing_pattern
tenantid/code/name; NO stored root_org_id (convention-derived)vocabularyid, code, name, kc_realm
tier groupsplatform_admins / tenant_admins_(code): control-plane tiers from root-org adminsruntimegroup 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 (organization rel = direct; metadata.bound_via = indirect/parent-join; neither = global) + add to application_entity_membership; rules emit under that app's namespace — no BFF branch.
  • New scoping dimension: extend SCOPING_DIMENSIONS with op + template, author via the acl_rules: sheet block.
  • New control-plane substrate type: set metadata.control_plane: trueresolve_application (fetch_is_control_plane) routes it to admin-bff; no hardcoded-set edit.
  • New application namespace: write an application row; ApplicationWriteHooks provision namespace + types + KC client; reconcile_app_namespace emits per-app rules.
  • New service principal: add to SERVICE_PRINCIPAL_GROUPS (memberships.py:352); invariant — every apply:[role] action it reaches implies membership in admin-bff_(role).
  • New tier capability: extend CONTROL_PLANE_PLATFORM_GRANTS / ADMIN_BFF_TIER_GRANTS_* — meta-entities (application, acl_rule) MUST be authored under ums-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 via PrincipalUnresolvable (grants.py:797-865), NOT 503 — permanent, not an outage.
  • Tenant identity comes from the JWT tenant_id claim; inbound X-Tenant-Id is 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_of subject contract) — a username=email row silently denies every staff read.
  • Membership writes are write-before-delete (partial failure leaves it broader, reconcilable). tenant_id is immutable on a rule — reconcile is delete-then-recreate.
  • organization_membership is the single authz SoT; the proxy path never reads realm_access.roles.
  • Meta-entities resolve under ums-v2, never admin-bff; they have no tenant_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:write resolve (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 per config.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:67 honoring a consumer-supplied app-id — Path X vs Path Y deferred to whoever owns that timeline.
  • is_service bypass still stands: entity_proxy._should_enforce returns False for service callers and service _resolved_tenant is verbatim from inbound X-Tenant-Id — the shared Martha secret reads/writes any tenant. #390 added per-tenant Vault binding but require_per_tenant_secret is 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 gRPC Or() path; ~84 httpx sites unmigrated (#162).
  • Triple-namespace: admin-bff, ums-v2 (UMS internal, for application/acl_rule), and the ontology engine's own ums_app_id (ONTO src/app/authz/ums.py, resolver.py) each evaluate independently. nav_section/nav_item have 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_only strip is retired but kept as a defensive net through Phase-6 — the claim most likely to break at runtime.
  • tenant has 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; the CK_platform_global DB-CHECK sentinel is design, not enforced.
  • Action apply enforcement is still direct JWT realm-role reads in validation.py; the resolve_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.

Atelier — Platform Specification. Internal canonical reference.