Skip to content

Commit 17d0ed4

Browse files
feat(identity): implement ADR-0068 unified user-context contract (D1–D4) (#2312)
* feat(identity): implement ADR-0068 unified user-context contract (D1–D4) Implements the unified EvalUser contract so a predicate authored once evaluates identically across server-formula, server-RLS, and client. Identities are modelled as roles (roles: string[]), not booleans. D1 — EvalUser contract (@objectstack/spec) - New eval-user.zod.ts: EvalUser schema, canonical roles[], deprecated singular `role`, derived isPlatformAdmin alias, built-in role constants/metadata, mapMembershipRole(), createEvalUser() factory. - formula: buildScope mounts current_user / user / ctx.user / os.user as the SAME EvalUser object; deprecated `role` scalar kept read-only for back-compat during migration. D2 — Built-in roles + normalization - plugin-security: bootstrap-builtin-roles seeds sys_role rows. - plugin-auth: customSession emits one canonical roles[]; removes the `role='admin'` overwrite footgun; platform_admin derived. - runtime: normalize better-auth owner/admin/member -> org_*; derive platform_admin from an UNSCOPED admin_full_access grant. D3 — Role-definition authority - sys_role gains a `managed_by` provenance field (system/declared/user). D4 — Role-catalog validation (anti-hallucination) - formula/validate.ts: closed-enum roleCatalog check rejects unknown role names referenced in predicates. Tests: formula 206, plugin-auth 120, plugin-security 143, runtime 432. Full `turbo build` 77/77 (incl. all DTS typechecks). * docs(spec): use canonical roles[] form in app visibility-predicate example ADR-0068 D4 follow-up. The embedded `describe()` example shipped the non-canonical, non-catalog pattern `os.user.role == "admin"`; users copy-paste these. Switch to `'org_admin' in current_user.roles` so the shipped example matches the canonical contract and passes role-catalog validation. * chore(spec): regenerate api-surface snapshot for ADR-0068 exports The TypeScript Type Check CI job runs check:api-surface, which flagged 26 additive exports (EvalUser contract, built-in role constants/metadata, createEvalUser/mapMembershipRole) with 0 breaking. Regenerate the committed snapshot so the guard passes. * fix(formula): harden D4 role-exists regex against ReDoS CodeQL flagged js/polynomial-redos (high) on ROLE_EXISTS_RE: the unbounded greedy [^,]* + lazy [^)]*? scanners backtrack O(n^2) on adversarial input like repeated 'user.roles.exists('. Bound both quantifiers ({0,64}/{0,128} — CEL exists bodies are tiny) and exclude '=' from the pre-operator class so the run is linear and unambiguous. Capture groups unchanged; 206 formula tests pass; a 720k-char pathological probe now returns in ~14ms. --------- Co-authored-by: zhuangjianguo <zhuangjianguo@steedos.com>
1 parent 9a810f8 commit 17d0ed4

17 files changed

Lines changed: 506 additions & 35 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
title: Eval User
3+
description: Eval User protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */}
7+
8+
EvalUser — the one user-context contract (ADR-0068 D1).
9+
10+
The signed-in user exposed to every predicate surface (server formula, server
11+
12+
RLS, client UI gates) under the canonical variable name `current_user`
13+
14+
(aliases `user`, `ctx.user`) with an **identical shape**. A predicate such as
15+
16+
`current_user.roles.exists(r, r == 'org_admin')` (or
17+
18+
`'org_admin' in current_user.roles`) therefore evaluates identically wherever
19+
20+
it is written.
21+
22+
`roles: string[]` is the **only canonical** role field. Singular `role` is NOT
23+
24+
part of this contract — its legacy "overwritten to 'admin' on promotion"
25+
26+
behavior is the footgun this eliminates.
27+
28+
@see docs/adr/0068-unified-user-context-and-built-in-identity-roles.md
29+
30+
<Callout type="info">
31+
**Source:** `packages/spec/src/identity/eval-user.zod.ts`
32+
</Callout>
33+
34+
## TypeScript Usage
35+
36+
```typescript
37+
import { EvalUser } from '@objectstack/spec/identity';
38+
import type { EvalUser } from '@objectstack/spec/identity';
39+
40+
// Validate data
41+
const result = EvalUser.parse(data);
42+
```
43+
44+
---
45+
46+
## EvalUser
47+
48+
### Properties
49+
50+
| Property | Type | Required | Description |
51+
| :--- | :--- | :--- | :--- |
52+
| **id** | `string` || User ID |
53+
| **name** | `string` | optional | Display name |
54+
| **email** | `string` | optional | Email address |
55+
| **roles** | `string[]` || Canonical role names assigned to the user (scope-resolved) |
56+
| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in roles. Deprecated. |
57+
| **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) |
58+
59+
60+
---
61+

content/docs/references/identity/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ description: Complete reference for all identity protocol schemas
66
This section contains all protocol schemas for the identity layer of ObjectStack.
77

88
<Cards>
9+
<Card href="/docs/references/identity/eval-user" title="Eval User" description="Source: packages/spec/src/identity/eval-user.zod.ts" />
910
<Card href="/docs/references/identity/identity" title="Identity" description="Source: packages/spec/src/identity/identity.zod.ts" />
1011
<Card href="/docs/references/identity/organization" title="Organization" description="Source: packages/spec/src/identity/organization.zod.ts" />
1112
<Card href="/docs/references/identity/role" title="Role" description="Source: packages/spec/src/identity/role.zod.ts" />

content/docs/references/identity/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"title": "Identity Protocol",
33
"pages": [
4+
"eval-user",
45
"identity",
56
"organization",
67
"role",

packages/formula/src/stdlib.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import type { Environment } from '@marcbachmann/cel-js';
1515

1616
import type { EvalContext } from './types';
17+
import { createEvalUser, type EvalUser } from '@objectstack/spec';
1718

1819
/**
1920
* Calendar-day parts (y/m/d) of an instant *as seen in a timezone*
@@ -266,6 +267,34 @@ export function registerNumericCoercions(env: Environment): Environment {
266267
return env;
267268
}
268269

270+
/**
271+
* Normalize the loosely-typed EvalContext user into the canonical EvalUser
272+
* (ADR-0068). `roles` is preferred; a legacy singular `role` is folded in so
273+
* existing call sites keep working.
274+
*/
275+
function toEvalUser(u: NonNullable<EvalContext['user']>): EvalUser {
276+
const legacyRole = typeof u.role === 'string' && u.role ? [u.role] : [];
277+
const roles = Array.isArray(u.roles) ? (u.roles as string[]) : [];
278+
const canonical = createEvalUser({
279+
id: u.id,
280+
name: typeof u.name === 'string' ? u.name : undefined,
281+
email: typeof u.email === 'string' ? u.email : undefined,
282+
roles: [...roles, ...legacyRole],
283+
organizationId:
284+
typeof u.organizationId === 'string' || u.organizationId === null
285+
? (u.organizationId as string | null)
286+
: undefined,
287+
});
288+
// Back-compat: keep the DEPRECATED singular `role` readable so existing
289+
// predicates (`os.user.role`, `current_user.role`) keep resolving during the
290+
// ADR-0068 migration window. `roles[]` is the canonical surface; the footgun
291+
// ADR-0068 removes is the server-side OVERWRITE of `role`, not read access.
292+
if (typeof u.role === 'string' && u.role) {
293+
(canonical as EvalUser & { role?: string }).role = u.role;
294+
}
295+
return canonical;
296+
}
297+
269298
/**
270299
* Build the variable scope for a single evaluation. Absent fields are simply
271300
* not bound — CEL macros (`has(record.foo)`) handle missing-key safely.
@@ -279,7 +308,19 @@ export function buildScope(ctx: EvalContext): Record<string, unknown> {
279308

280309
// Namespaced data — written as `os.user.id`, `os.env`, etc. in CEL.
281310
const os: Record<string, unknown> = {};
282-
if (ctx.user !== undefined) os.user = ctx.user;
311+
if (ctx.user !== undefined) {
312+
// ADR-0068: one canonical EvalUser under every alias (`current_user`,
313+
// `user`, `ctx.user`, `os.user`) — the SAME object, so a predicate
314+
// evaluates identically wherever it is authored.
315+
const currentUser = toEvalUser(ctx.user);
316+
scope.current_user = currentUser;
317+
scope.user = currentUser;
318+
scope.ctx = {
319+
...(typeof scope.ctx === 'object' && scope.ctx !== null ? scope.ctx : {}),
320+
user: currentUser,
321+
};
322+
os.user = currentUser;
323+
}
283324
if (ctx.org !== undefined) os.org = ctx.org;
284325
if (ctx.env !== undefined) os.env = ctx.env;
285326
if (Object.keys(os).length > 0) scope.os = os;

packages/formula/src/types.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,22 @@ export interface EvalContext {
3030
* Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
3131
*/
3232
timezone?: string;
33-
/** Current authenticated subject (hook / action / view contexts). */
33+
/**
34+
* Current authenticated subject (hook / action / view contexts).
35+
*
36+
* ADR-0068: the canonical user contract is {@link EvalUser} from
37+
* `@objectstack/spec`, surfaced to predicates as `current_user` (aliases
38+
* `user`, `ctx.user`). `roles: string[]` is the only canonical role field;
39+
* the singular `role` is deprecated (its "overwritten to 'admin' on
40+
* promotion" behavior is the footgun ADR-0068 eliminates).
41+
*/
3442
user?: {
3543
id: string;
44+
/** CANONICAL (ADR-0068). Scope-resolved role names assigned to the user. */
45+
roles?: string[];
46+
/** Active organization ID (null = platform / unscoped). */
47+
organizationId?: string | null;
48+
/** @deprecated ADR-0068 — use {@link roles}. Retained for back-compat only. */
3649
role?: string;
3750
email?: string;
3851
[key: string]: unknown;

packages/formula/src/validate.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ export interface ExprSchemaHint {
5353
* did-you-mean *warning*. (Default.)
5454
*/
5555
scope?: 'record' | 'flattened';
56+
/**
57+
* ADR-0068 D4 — the closed catalog of valid role names (built-in + declared).
58+
* When supplied, a role-membership predicate testing a role NOT in this set
59+
* (e.g. `'org_admni' in current_user.roles`) is flagged as an error. Closes
60+
* the AI-hallucination hole where a model invents a plausible-but-nonexistent
61+
* role that then silently never matches. Absent => role checks are skipped.
62+
*/
63+
roleCatalog?: readonly string[];
5664
}
5765

5866
export interface ExprValidationError {
@@ -146,6 +154,51 @@ function levenshtein(a: string, b: string): number {
146154
return dp[m];
147155
}
148156

157+
// ADR-0068 D4 — role-membership predicate heads: a role NAME literal used in a
158+
// membership test against a user subject's `.roles` (or the deprecated singular
159+
// `.role`). Matched names are validated against the closed role catalog.
160+
const ROLE_IN_RE = /(['"])([a-z0-9_]+)\1\s+in\s+(?:current_user|user|ctx\.user)\.roles\b/g;
161+
const ROLE_CONTAINS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*contains\(\s*(['"])([a-z0-9_]+)\1\s*\)/g;
162+
// Bounded quantifiers ({0,N}, not * / *?) keep this linear: a CEL `exists`
163+
// body is tiny in practice, and unbounded greedy/lazy scanners here backtrack
164+
// polynomially (O(n^2)) on adversarial input like repeated `user.roles.exists(`
165+
// (ADR-0068 D4 ReDoS hardening). The pre-`==` class excludes `=` so the bounded
166+
// run stops cleanly before the operator without a lazy quantifier.
167+
const ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*exists\s*\([^,)]{0,64},[^)=]{0,128}==\s*(['"])([a-z0-9_]+)\1/g;
168+
const ROLE_EQ_RE = /(?:current_user|user|ctx\.user)\.role\s*==\s*(['"])([a-z0-9_]+)\1/g;
169+
170+
/**
171+
* Flag role-membership predicates referencing a role outside the closed catalog
172+
* (ADR-0068 D4 — anti-hallucination). No-op when no `roleCatalog` is supplied.
173+
*/
174+
function checkRoleCatalog(
175+
source: string,
176+
schema: ExprSchemaHint | undefined,
177+
errors: ExprValidationError[],
178+
): void {
179+
const catalog = schema?.roleCatalog;
180+
if (!catalog || catalog.length === 0) return;
181+
const known = new Set(catalog);
182+
const seen = new Set<string>();
183+
for (const re of [ROLE_IN_RE, ROLE_CONTAINS_RE, ROLE_EXISTS_RE, ROLE_EQ_RE]) {
184+
re.lastIndex = 0;
185+
let m: RegExpExecArray | null;
186+
while ((m = re.exec(source)) !== null) {
187+
const name = m[2];
188+
if (known.has(name) || seen.has(name)) continue;
189+
seen.add(name);
190+
const suggestion = nearest(name, catalog);
191+
errors.push({
192+
source,
193+
message:
194+
`unknown role \`${name}\` — not a defined role` +
195+
(suggestion ? `; did you mean \`${suggestion}\`?` : '.') +
196+
` Valid roles: ${catalog.join(', ')}.`,
197+
});
198+
}
199+
}
200+
}
201+
149202
/**
150203
* Validate one expression for a given field role. Never throws — returns a
151204
* structured result. Call sites decide whether to throw (build/registration)
@@ -194,6 +247,7 @@ export function validateExpression(
194247
});
195248
} else {
196249
checkFieldExistence(source, schema, errors);
250+
checkRoleCatalog(source, schema, errors);
197251
if (schema?.scope === 'record') {
198252
// In a `record`-scoped site a bare top-level identifier is a silent bug —
199253
// it must be `record.<field>` (#1928). Hard error.
@@ -246,12 +300,14 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
246300
dialect: 'cel' | 'template';
247301
fields: string[];
248302
roots: string[];
303+
roles: string[];
249304
functions: string[];
250305
} {
251306
return {
252307
dialect: expectedDialect(role),
253308
fields: [...(schema?.fields ?? [])],
254-
roots: ['record', 'previous', 'input', 'os', 'vars'],
309+
roots: ['record', 'previous', 'input', 'os', 'current_user', 'user', 'vars'],
310+
roles: [...(schema?.roleCatalog ?? [])],
255311
functions: CEL_STDLIB_FUNCTIONS,
256312
};
257313
}

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,26 +1319,29 @@ describe('AuthManager', () => {
13191319
expect(result.user.roles).toEqual(['manager']);
13201320
});
13211321

1322-
it('keeps stored business roles in roles[] when promoting a platform admin', async () => {
1322+
it('appends platform_admin to roles[] without overwriting role when promoting a platform admin', async () => {
13231323
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true }));
13241324
const result = await callback({
13251325
user: { id: 'u-1', email: 'a@b.com', role: 'manager' },
13261326
session: {},
13271327
});
1328-
// Promotion replaces `role` (existing semantics) but the stored
1329-
// business role survives in the array.
1330-
expect(result.user.role).toBe('admin');
1331-
expect(result.user.roles).toEqual(['manager', 'admin']);
1328+
// ADR-0068: NO `role:'admin'` overwrite footgun. The deprecated scalar
1329+
// keeps its stored value; the canonical platform_admin identity is added
1330+
// to roles[], and isPlatformAdmin is a derived alias.
1331+
expect(result.user.role).toBe('manager');
1332+
expect(result.user.roles).toEqual(['manager', 'platform_admin']);
1333+
expect(result.user.isPlatformAdmin).toBe(true);
13321334
});
13331335

1334-
it('does not duplicate admin in roles[] when the stored role already includes it', async () => {
1336+
it('splits a multi-token stored role and appends platform_admin without duplicates', async () => {
13351337
const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true }));
13361338
const result = await callback({
13371339
user: { id: 'u-1', email: 'a@b.com', role: 'admin,manager' },
13381340
session: {},
13391341
});
1340-
expect(result.user.role).toBe('admin');
1341-
expect(result.user.roles).toEqual(['admin', 'manager']);
1342+
expect(result.user.role).toBe('admin,manager');
1343+
expect(result.user.roles).toEqual(['admin', 'manager', 'platform_admin']);
1344+
expect(result.user.isPlatformAdmin).toBe(true);
13421345
});
13431346

13441347
it('returns the payload untouched when the user has no id', async () => {

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
import type { IDataEngine } from '@objectstack/core';
1414
import type { IEmailService } from '@objectstack/spec/contracts';
1515
import { readEnvWithDeprecation } from '@objectstack/types';
16+
import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec';
1617
import { createObjectQLAdapterFactory } from './objectql-adapter.js';
1718
import {
1819
AUTH_USER_CONFIG,
@@ -967,10 +968,10 @@ export class AuthManager {
967968
// `admin`. Org owners/admins are entitled to manage org-scoped
968969
// metadata such as saved list views, dashboards, etc.
969970
//
970-
// Either path synthesizes `user.role = 'admin'` so the frontend can gate
971-
// metadata-edit affordances uniformly. The raw membership role remains
972-
// available via the `organization` plugin's `member` payload for
973-
// finer-grained checks.
971+
// ADR-0068 D2: rather than synthesizing `user.role = 'admin'`, both paths now
972+
// contribute CANONICAL role names to `user.roles` (platform_admin / org_*),
973+
// and `user.isPlatformAdmin` is a derived alias. The raw membership role
974+
// remains available via the `organization` plugin's `member` payload.
974975
const dataEngine = this.config.dataEngine;
975976
if (dataEngine) {
976977
const { customSession } = await import('better-auth/plugins/custom-session');
@@ -1000,37 +1001,42 @@ export class AuthManager {
10001001
}
10011002
};
10021003

1003-
const isActiveOrgAdmin = async (): Promise<boolean> => {
1004+
// ADR-0068 D2 — surface CANONICAL org_* role names (not a boolean flag):
1005+
// a membership owner/admin/member maps to org_owner/org_admin/org_member.
1006+
const activeOrgRoles = async (): Promise<string[]> => {
10041007
try {
10051008
const orgId = (session as any)?.activeOrganizationId;
1006-
if (!orgId) return false;
1009+
if (!orgId) return [];
10071010
const members = await dataEngine.find('sys_member', {
10081011
where: { user_id: user.id, organization_id: orgId },
10091012
limit: 5,
10101013
});
1011-
return (Array.isArray(members) ? members : []).some((m: any) => {
1012-
// better-auth org plugin stores roles as either a single string
1013-
// or a comma-separated list (e.g. `"owner,admin"`). Treat any
1014-
// membership that includes `owner` or `admin` as administrative.
1014+
const out: string[] = [];
1015+
for (const m of (Array.isArray(members) ? members : [])) {
10151016
const raw = typeof m?.role === 'string' ? m.role : '';
1016-
const roles = raw.split(',').map((s: string) => s.trim().toLowerCase());
1017-
return roles.includes('owner') || roles.includes('admin');
1018-
});
1017+
for (const r of raw.split(',').map((s: string) => s.trim()).filter(Boolean)) {
1018+
const mapped = mapMembershipRole(r);
1019+
if (!out.includes(mapped)) out.push(mapped);
1020+
}
1021+
}
1022+
return out;
10191023
} catch {
1020-
return false;
1024+
return [];
10211025
}
10221026
};
10231027

1028+
// ADR-0068 D1/D2 — emit ONE canonical roles[] (identities-as-roles), with
1029+
// NO `role:'admin'` overwrite. isPlatformAdmin is a DERIVED alias of
1030+
// `'platform_admin' in roles`, retained for back-compat clients.
10241031
const platformAdmin = await isPlatformAdmin();
1025-
const promote = platformAdmin || (await isActiveOrgAdmin());
1032+
const orgRoles = await activeOrgRoles();
10261033
const storedRole = typeof (user as any).role === 'string' ? (user as any).role : '';
1027-
const roles = storedRole
1028-
.split(',')
1029-
.map((s: string) => s.trim())
1030-
.filter(Boolean);
1031-
if (promote && !roles.includes('admin')) roles.push('admin');
1032-
if (!promote) return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
1033-
return { user: { ...user, role: 'admin', roles, isPlatformAdmin: platformAdmin }, session };
1034+
const roles = Array.from(new Set([
1035+
...storedRole.split(',').map((s: string) => s.trim()).filter(Boolean),
1036+
...orgRoles,
1037+
...(platformAdmin ? [BUILTIN_ROLE_PLATFORM_ADMIN] : []),
1038+
]));
1039+
return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session };
10341040
}));
10351041
}
10361042

0 commit comments

Comments
 (0)