|
| 1 | +# ADR-0068: Unified user-context contract & built-in identity roles — one `current_user` shape across formula/RLS/client, identities as roles not booleans |
| 2 | + |
| 3 | +**Status**: Proposed (2026-06-24) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (`sys_role` is platform-native, decoupled from better-auth; `ExecutionContext.roles` = union of `sys_member.role` + `sys_user_role.role`), [ADR-0066](./0066-unified-authorization-model.md) (capability/assignment/requirement separation; resources declare a capability, never "who"), [ADR-0058](./0058-expression-and-predicate-surface.md) (CEL predicate surface) |
| 6 | +**Consumers**: `@objectstack/spec` (the `EvalUser` contract), `@objectstack/plugin-security` (`resolve-execution-context`, RLS), `@objectstack/plugin-auth` (`customSession` bridge), `@objectstack/formula` (`buildScope`), `@objectstack/runtime`, `../objectui` (AuthProvider / ExpressionProvider / predicate scope), `../cloud` (control-plane metadata — consumer only) |
| 7 | + |
| 8 | +**Premise**: pre-launch — specify the target end-state. This ADR is a **consolidation**: the platform already has the right model (ADR-0057 role union, ADR-0066 capability split); it fills the gaps that make the *user-facing surface* (what a formula / RLS policy / UI predicate — and the AI that authors them — actually sees) inconsistent. Items are tagged **[existing]** / **[new]**. |
| 9 | + |
| 10 | +> **Trigger**: a platform-admin-only action (`sys_environment` "Change Plan (admin)", gated on `ctx.user.isPlatformAdmin == true`) was silently hidden in the cloud console (objectui#1928). Root cause was a *predicate-context* gap, not a permission gap. Investigation surfaced four general, related defects: |
| 11 | +> 1. **Three names for the same user**: server formulas expose `os.user.role` (**singular**), RLS exposes `current_user.roles` (**plural array**), the client exposes `ctx.user.role` / `user.roles` / `user.isPlatformAdmin`. No predicate can be written once and evaluated everywhere. |
| 12 | +> 2. **`role` (singular) vs `roles` (array)** coexist with subtly different semantics (`role` is overwritten to `'admin'` on promotion; `roles` is the list) — a footgun for humans and a hazard for AI authoring. |
| 13 | +> 3. **Identity-as-boolean**: `isPlatformAdmin` is a bespoke flag orthogonal to `roles[]`; org membership tiers (`owner/admin/member`) leak in as raw better-auth strings. Identities are modeled three different ways. |
| 14 | +> 4. **The client owns the user shape**: objectui defines its own `AuthUser` and strips it 5 independent ways (FALLBACK_USER ×3, `expressionUser`, preview) — the contract is duplicated and drifts. |
| 15 | +
|
| 16 | +## TL;DR |
| 17 | + |
| 18 | +1. **[new] One contract**: define `EvalUser` in `@objectstack/spec`. Expose the signed-in user under the canonical name **`current_user`** (aliases `user`, `ctx.user`) with an **identical shape and variable name** in formulas, RLS, and the client. `roles: string[]` is canonical; singular `role` is at most a derived "primary" alias. |
| 19 | +2. **[new] Identities are roles, not booleans**: seed built-in `sys_role` rows — `platform_admin` (unscoped), `org_owner` / `org_admin` / `org_member` (org-scoped) — managed, reserved, with `label`/`description`. They appear uniformly in `current_user.roles`. `isPlatformAdmin` becomes a **derived alias** of `'platform_admin' in roles`. |
| 20 | +3. **[existing→ruled] Role-definition authority follows the isolation boundary**: global roles are defined only by the **super-admin or packages** (namespaced, managed); a per-environment tenant may define roles **inside its own env**; ad-hoc role definition in a *shared* kernel's cross-tenant namespace is **forbidden**. |
| 21 | +4. **[ruled] v1 gating**: platform-operator actions gate on `isPlatformAdmin` (sole operator); AI-authored in-app gates use the **env's `sys_role` catalog** (closed enum + server validation). **Capability-gating is deferred** → cloud#474. |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +## Context: what each surface exposes today |
| 26 | + |
| 27 | +| Surface | User variable | Role field | Source | Status | |
| 28 | +|---|---|---|---|---| |
| 29 | +| Server formula (`@objectstack/formula`) | `os.user` | `role` (**singular**) | `EvalContext.user.role` (`stdlib.buildScope`) | **[existing]** | |
| 30 | +| Server RLS (`plugin-security`) | `current_user` | `roles` (**array**) | `ExecutionContext.roles` (union of `sys_member.role` + `sys_user_role.role`, ADR-0057 D4) | **[existing]** | |
| 31 | +| Client predicates (objectui) | `ctx.user` / `user` | `role` + `roles` + `isPlatformAdmin` | objectui-defined `AuthUser` | **[existing]** | |
| 32 | + |
| 33 | +The model underneath is already correct: `sys_role` is platform-native (ADR-0057), `ExecutionContext.roles` unions membership + RBAC, authz is capability/permission-set driven (ADR-0066). **Only the author-facing projection is inconsistent.** |
| 34 | + |
| 35 | +--- |
| 36 | + |
| 37 | +## Decision |
| 38 | + |
| 39 | +### D1 — One user-context contract (`EvalUser`), one variable name `current_user` [new] |
| 40 | + |
| 41 | +- **`EvalUser` is defined once in `@objectstack/spec`** and imported by every evaluator. Shape: |
| 42 | + ```ts |
| 43 | + interface EvalUser { |
| 44 | + id: string; |
| 45 | + name?: string; |
| 46 | + email?: string; |
| 47 | + roles: string[]; // CANONICAL. scope-resolved (see D3). |
| 48 | + isPlatformAdmin?: boolean; // DERIVED alias of `roles.includes('platform_admin')` (D2). deprecated surface. |
| 49 | + organizationId?: string | null; |
| 50 | + // role (singular) is NOT part of the contract. If retained for back-compat it is a derived "primary role" alias only. |
| 51 | + } |
| 52 | + ``` |
| 53 | +- **Canonical variable `current_user`** in all three surfaces, with `user` and `ctx.user` as **aliases pointing at the same object**. A predicate `current_user.roles.exists(r, r == 'org_admin')` (or `'org_admin' in current_user.roles`) evaluates identically in a formula, an RLS policy, and a client `visible` gate. The legacy `os.user` formula namespace also exposes `current_user`/`user`. |
| 54 | +- **`roles` is the only canonical role field.** Singular `role` is removed from the author surface (kept, if at all, as a derived alias) — its "overwritten to `'admin'` on promotion" behavior is the footgun this eliminates. |
| 55 | +- **Conformance** (each owns its build site): |
| 56 | + - `@objectstack/formula` `buildScope` — mount `current_user` (+ aliases) from `EvalUser`; **add `roles[]`** (today only `role`). **[new]** |
| 57 | + - `plugin-security` RLS / `resolve-execution-context` — emit `EvalUser` shape; `current_user.roles` already exists. **[existing→align]** |
| 58 | + - objectui — **delete the local `AuthUser`**, import `EvalUser` from spec; `ExpressionProvider` exposes `current_user`/`user`/`ctx.user` (same object); collapse the 5 fallback/guest definitions into a single `createEvalUser()` factory. **[new]** |
| 59 | + |
| 60 | +### D2 — Identities are built-in roles, not bespoke booleans [new] |
| 61 | + |
| 62 | +- **Seed built-in `sys_role` rows** (managed, reserved namespace, carry `label` + `description`): |
| 63 | + | name | scope | source of truth (unchanged) | description (for humans + AI grounding) | |
| 64 | + |---|---|---|---| |
| 65 | + | `platform_admin` | unscoped (`org_id = null`) | unscoped `sys_user_permission_set` → `admin_full_access` | "Platform operator (SaaS admin). NOT a tenant user role." | |
| 66 | + | `org_owner` | org-scoped | `sys_member.role = owner` | "Organization owner within a tenant." | |
| 67 | + | `org_admin` | org-scoped | `sys_member.role = admin` | "Organization administrator within a tenant." | |
| 68 | + | `org_member` | org-scoped | `sys_member.role = member` | "Organization member within a tenant." | |
| 69 | +- **One-way projection, sources never change**: `sys_member.role` stays the source for membership; the unscoped `admin_full_access` link stays the source for platform admin; `sys_user_role` stays the source for business roles. The built-in role **names** are a normalized projection into `current_user.roles` (extends `auto-org-admin-grant`'s membership→grant pattern to the role-name layer). Nobody hand-edits the built-in rows. |
| 70 | +- **`org_*` name normalization**: `resolve-execution-context` and the `customSession` bridge emit `org_owner/admin/member` (not the raw better-auth `owner/admin/member`) so the array is unambiguous and self-documenting. |
| 71 | +- **`isPlatformAdmin` → derived alias** (`roles.includes('platform_admin')`). Kept during migration (so the merged objectui#1928 fix and existing predicates keep working), marked deprecated. **No new identity booleans.** |
| 72 | + |
| 73 | +### D3 — Role-definition authority follows the isolation boundary [ruled] |
| 74 | + |
| 75 | +- **Global / cross-tenant roles**: defined **only** by the super-admin or by **packages** (declared metadata, namespaced `<packageId>.<role>`, `managed_by: 'package'`, auto-created on install via `bootstrapDeclaredRoles`, updated/removed with the package). [existing mechanism, made a rule] |
| 76 | +- **Per-environment tenant roles**: an env's own admin may define roles **inside that env** (separate kernel/DB ⇒ naturally isolated namespace). This is *env-scoped authoring*, not the forbidden case. |
| 77 | +- **Forbidden**: ad-hoc role **definition** by an org inside a *shared* kernel's cross-tenant (`org_id = null`) `sys_role` namespace — it collides/leaks. (Org admins **assign** existing roles; they do not define new ones in a shared namespace.) This tightens the current "tenants may add custom rows" note on `sys_role`. |
| 78 | +- **Out of scope (deferred)**: org-*scoped* role **definitions** in a shared kernel (would require `organization_id` on `sys_role`). Only add if a concrete "many orgs share one kernel and each needs custom role types" need appears. |
| 79 | + |
| 80 | +### D4 — Gating policy for v1 [ruled] |
| 81 | + |
| 82 | +- **Platform-operator actions** (e.g. `sys_environment` lifecycle, billing override) gate on `current_user.isPlatformAdmin` (≡ `'platform_admin' in roles`). Sole operator = the founder; multi-tenant-safe (a platform-scope identity, not a tenant role name). |
| 83 | +- **AI-authored in-app gates** use the **env's `sys_role` catalog** as a **closed enum**, given to the AI as grounding *including the system-predefined roles* (`platform_admin`, `org_*`) with their `label`/`description` so it disambiguates (`org_admin` = tenant admin vs `platform_admin` = SaaS operator). The server **validates** authored predicates against the catalog and **rejects unknown role names** (anti-hallucination). Roles — concrete, enumerable, tenant-local vocabulary — are the right grounding for AI in-app authoring. |
| 84 | +- **Capability-gating is deferred** to **cloud#474** (platform capability catalog inventory + migration of platform/cross-tenant gating to `requiredPermissions`). Per ADR-0066, *shippable cross-tenant* metadata must ultimately gate on capabilities (stable platform vocabulary), never tenant role names — but that is not needed for the one-operator v1. |
| 85 | + |
| 86 | +--- |
| 87 | + |
| 88 | +## Scope |
| 89 | + |
| 90 | +**v1 (this ADR):** D1 + D2 + D3 + D4. Foundational *contracts* — cheapest to land before the AI generates a large body of metadata against the inconsistent shapes. |
| 91 | + |
| 92 | +**Non-goals / deferred:** |
| 93 | +- Capability catalog inventory + capability-gating migration → **cloud#474**. |
| 94 | +- Org-*scoped* custom role definitions in a shared kernel (D3 out-of-scope). |
| 95 | +- Role visibility **hierarchy** (lives on `sys_business_unit`, ADR-0057 D5 — unchanged). |
| 96 | +- Deeper formula/RLS unification beyond the `current_user` object (e.g. `record`/`org`/`env` namespaces) — only the user object is in scope here. |
| 97 | + |
| 98 | +--- |
| 99 | + |
| 100 | +## Consequences |
| 101 | + |
| 102 | +**Good** |
| 103 | +- A predicate is written **once** and means the same thing in a formula, an RLS policy, and a UI gate — and the AI learns **one** pattern (`current_user.roles`), grounded in a closed, validated enum. |
| 104 | +- Identities are uniform: one `roles[]` array (incl. `platform_admin`, `org_*`, package, business), **zero bespoke identity booleans** going forward. |
| 105 | +- The contract is **framework-owned** (`@objectstack/spec`); objectui and cloud *conform/consume* instead of each re-inventing the user shape. |
| 106 | +- Forward-compatible with capabilities (cloud#474): a role simply *bundles* capabilities; gating can migrate from role/`isPlatformAdmin` to `requiredPermissions` without touching the user-context contract. |
| 107 | + |
| 108 | +**Bad / costs** |
| 109 | +- A breaking-ish change to the predicate surface: `os.user.role` (formula) and bare singular `role` are deprecated. Mitigated by keeping aliases (`user`, `ctx.user`, `os.user`) and the `isPlatformAdmin` alias during migration; pre-launch ⇒ small consumer set. |
| 110 | +- One-way projection adds a small reconcile (membership/admin → role-name in `current_user.roles`) — but it reuses the existing `auto-org-admin-grant` pattern. |
| 111 | + |
| 112 | +--- |
| 113 | + |
| 114 | +## Migration / conformance checklist |
| 115 | + |
| 116 | +1. **`@objectstack/spec`** — add `EvalUser`; document `current_user` (+ aliases) as the canonical predicate variable; mark singular `role` deprecated. |
| 117 | +2. **`@objectstack/formula`** — `buildScope`: mount `current_user`/`user` from `EvalUser`; add `roles[]`. |
| 118 | +3. **`plugin-security`** — `resolve-execution-context`: project `platform_admin` into `roles`; normalize `org_*` names; emit `EvalUser`. Seed built-in role rows (sibling to `bootstrap-declared-roles` / `bootstrap-system-capabilities`) with labels/descriptions. |
| 119 | +4. **`plugin-auth`** — `customSession`: emit the same normalized `roles[]` (incl. `platform_admin`) + `isPlatformAdmin` alias; stop leaking raw `owner/admin/member`. |
| 120 | +5. **`../objectui`** — delete local `AuthUser`, import `EvalUser`; `ExpressionProvider` exposes `current_user`/`user`/`ctx.user`; one `createEvalUser()` factory replaces the 5 fallback definitions. |
| 121 | +6. **`../cloud`** — **consumer only**: no contract changes; existing `ctx.user.isPlatformAdmin` gates keep working via the alias; capability migration tracked in cloud#474. |
| 122 | +7. **Anti-hallucination** — AI authoring is handed the env `sys_role` catalog (incl. system-predefined, with descriptions) as a closed enum; server rejects predicates referencing unknown role names. |
0 commit comments