Skip to content

Commit 37b7f83

Browse files
os-zhuangclaude
andauthored
docs(adr): ADR-0068 — unified user-context contract & built-in identity roles (#2257)
Consolidates the role/permission-model convergence (v1): - D1: one `EvalUser` contract in @objectstack/spec; canonical `current_user` variable (aliases user/ctx.user) identical across formula, RLS, and client; `roles[]` canonical (drop singular `role`); add `roles[]` to formula scope; objectui stops defining its own AuthUser. - D2: identities are built-in sys_roles (platform_admin unscoped; org_owner/admin/member org-scoped), one-way projection from existing sources; `isPlatformAdmin` becomes a derived alias — no bespoke identity booleans. - D3: role-definition authority follows the isolation boundary (global roles = super-admin/packages only, namespaced+managed; per-env tenants define inside their own env; ad-hoc org-level definition in a shared kernel forbidden). - D4: v1 gating — platform-operator actions use isPlatformAdmin; AI in-app gates use the env's sys_role catalog (closed enum + server validation, incl. system-predefined roles); capability-gating deferred to cloud#474. Builds on ADR-0057 (sys_role vs better-auth; ExecutionContext.roles union) and ADR-0066 (capability/assignment/requirement). Status: Proposed. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 16e8dad commit 37b7f83

1 file changed

Lines changed: 122 additions & 0 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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

Comments
 (0)