From 49cf6a00864681f2e3b9438fed6d5a55b39e4e76 Mon Sep 17 00:00:00 2001 From: zhuangjianguo Date: Thu, 25 Jun 2026 09:38:28 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(identity):=20implement=20ADR-0068=20un?= =?UTF-8?q?ified=20user-context=20contract=20(D1=E2=80=93D4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/references/identity/eval-user.mdx | 61 +++++++++ content/docs/references/identity/index.mdx | 1 + content/docs/references/identity/meta.json | 1 + packages/formula/src/stdlib.ts | 43 +++++- packages/formula/src/types.ts | 15 ++- packages/formula/src/validate.ts | 53 +++++++- .../plugin-auth/src/auth-manager.test.ts | 19 +-- .../plugins/plugin-auth/src/auth-manager.ts | 50 +++---- .../src/bootstrap-builtin-roles.ts | 71 ++++++++++ .../src/objects/sys-role.object.ts | 14 ++ .../plugin-security/src/security-plugin.ts | 8 ++ .../src/security/resolve-execution-context.ts | 32 ++++- packages/spec/src/identity/eval-user.zod.ts | 124 ++++++++++++++++++ packages/spec/src/identity/index.ts | 1 + packages/spec/src/index.ts | 15 +++ 15 files changed, 474 insertions(+), 34 deletions(-) create mode 100644 content/docs/references/identity/eval-user.mdx create mode 100644 packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts create mode 100644 packages/spec/src/identity/eval-user.zod.ts diff --git a/content/docs/references/identity/eval-user.mdx b/content/docs/references/identity/eval-user.mdx new file mode 100644 index 0000000000..bef6604d4f --- /dev/null +++ b/content/docs/references/identity/eval-user.mdx @@ -0,0 +1,61 @@ +--- +title: Eval User +description: Eval User protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs go in content/docs/guides/. */} + +EvalUser — the one user-context contract (ADR-0068 D1). + +The signed-in user exposed to every predicate surface (server formula, server + +RLS, client UI gates) under the canonical variable name `current_user` + +(aliases `user`, `ctx.user`) with an **identical shape**. A predicate such as + +`current_user.roles.exists(r, r == 'org_admin')` (or + +`'org_admin' in current_user.roles`) therefore evaluates identically wherever + +it is written. + +`roles: string[]` is the **only canonical** role field. Singular `role` is NOT + +part of this contract — its legacy "overwritten to 'admin' on promotion" + +behavior is the footgun this eliminates. + +@see docs/adr/0068-unified-user-context-and-built-in-identity-roles.md + + +**Source:** `packages/spec/src/identity/eval-user.zod.ts` + + +## TypeScript Usage + +```typescript +import { EvalUser } from '@objectstack/spec/identity'; +import type { EvalUser } from '@objectstack/spec/identity'; + +// Validate data +const result = EvalUser.parse(data); +``` + +--- + +## EvalUser + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | User ID | +| **name** | `string` | optional | Display name | +| **email** | `string` | optional | Email address | +| **roles** | `string[]` | ✅ | Canonical role names assigned to the user (scope-resolved) | +| **isPlatformAdmin** | `boolean` | optional | DERIVED alias of 'platform_admin' in roles. Deprecated. | +| **organizationId** | `string \| null` | optional | Active organization ID (null = platform/unscoped) | + + +--- + diff --git a/content/docs/references/identity/index.mdx b/content/docs/references/identity/index.mdx index d4e8b140a2..20acb8642e 100644 --- a/content/docs/references/identity/index.mdx +++ b/content/docs/references/identity/index.mdx @@ -6,6 +6,7 @@ description: Complete reference for all identity protocol schemas This section contains all protocol schemas for the identity layer of ObjectStack. + diff --git a/content/docs/references/identity/meta.json b/content/docs/references/identity/meta.json index 782d0bb300..89019b65e1 100644 --- a/content/docs/references/identity/meta.json +++ b/content/docs/references/identity/meta.json @@ -1,6 +1,7 @@ { "title": "Identity Protocol", "pages": [ + "eval-user", "identity", "organization", "role", diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index b409ef2e46..23e93c3be4 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -14,6 +14,7 @@ import type { Environment } from '@marcbachmann/cel-js'; import type { EvalContext } from './types'; +import { createEvalUser, type EvalUser } from '@objectstack/spec'; /** * Calendar-day parts (y/m/d) of an instant *as seen in a timezone* @@ -266,6 +267,34 @@ export function registerNumericCoercions(env: Environment): Environment { return env; } +/** + * Normalize the loosely-typed EvalContext user into the canonical EvalUser + * (ADR-0068). `roles` is preferred; a legacy singular `role` is folded in so + * existing call sites keep working. + */ +function toEvalUser(u: NonNullable): EvalUser { + const legacyRole = typeof u.role === 'string' && u.role ? [u.role] : []; + const roles = Array.isArray(u.roles) ? (u.roles as string[]) : []; + const canonical = createEvalUser({ + id: u.id, + name: typeof u.name === 'string' ? u.name : undefined, + email: typeof u.email === 'string' ? u.email : undefined, + roles: [...roles, ...legacyRole], + organizationId: + typeof u.organizationId === 'string' || u.organizationId === null + ? (u.organizationId as string | null) + : undefined, + }); + // Back-compat: keep the DEPRECATED singular `role` readable so existing + // predicates (`os.user.role`, `current_user.role`) keep resolving during the + // ADR-0068 migration window. `roles[]` is the canonical surface; the footgun + // ADR-0068 removes is the server-side OVERWRITE of `role`, not read access. + if (typeof u.role === 'string' && u.role) { + (canonical as EvalUser & { role?: string }).role = u.role; + } + return canonical; +} + /** * Build the variable scope for a single evaluation. Absent fields are simply * not bound — CEL macros (`has(record.foo)`) handle missing-key safely. @@ -279,7 +308,19 @@ export function buildScope(ctx: EvalContext): Record { // Namespaced data — written as `os.user.id`, `os.env`, etc. in CEL. const os: Record = {}; - if (ctx.user !== undefined) os.user = ctx.user; + if (ctx.user !== undefined) { + // ADR-0068: one canonical EvalUser under every alias (`current_user`, + // `user`, `ctx.user`, `os.user`) — the SAME object, so a predicate + // evaluates identically wherever it is authored. + const currentUser = toEvalUser(ctx.user); + scope.current_user = currentUser; + scope.user = currentUser; + scope.ctx = { + ...(typeof scope.ctx === 'object' && scope.ctx !== null ? scope.ctx : {}), + user: currentUser, + }; + os.user = currentUser; + } if (ctx.org !== undefined) os.org = ctx.org; if (ctx.env !== undefined) os.env = ctx.env; if (Object.keys(os).length > 0) scope.os = os; diff --git a/packages/formula/src/types.ts b/packages/formula/src/types.ts index d743f972c0..70a2569829 100644 --- a/packages/formula/src/types.ts +++ b/packages/formula/src/types.ts @@ -30,9 +30,22 @@ export interface EvalContext { * Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive. */ timezone?: string; - /** Current authenticated subject (hook / action / view contexts). */ + /** + * Current authenticated subject (hook / action / view contexts). + * + * ADR-0068: the canonical user contract is {@link EvalUser} from + * `@objectstack/spec`, surfaced to predicates as `current_user` (aliases + * `user`, `ctx.user`). `roles: string[]` is the only canonical role field; + * the singular `role` is deprecated (its "overwritten to 'admin' on + * promotion" behavior is the footgun ADR-0068 eliminates). + */ user?: { id: string; + /** CANONICAL (ADR-0068). Scope-resolved role names assigned to the user. */ + roles?: string[]; + /** Active organization ID (null = platform / unscoped). */ + organizationId?: string | null; + /** @deprecated ADR-0068 — use {@link roles}. Retained for back-compat only. */ role?: string; email?: string; [key: string]: unknown; diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index c5aac84d08..f0803a08b0 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -53,6 +53,14 @@ export interface ExprSchemaHint { * did-you-mean *warning*. (Default.) */ scope?: 'record' | 'flattened'; + /** + * ADR-0068 D4 — the closed catalog of valid role names (built-in + declared). + * When supplied, a role-membership predicate testing a role NOT in this set + * (e.g. `'org_admni' in current_user.roles`) is flagged as an error. Closes + * the AI-hallucination hole where a model invents a plausible-but-nonexistent + * role that then silently never matches. Absent => role checks are skipped. + */ + roleCatalog?: readonly string[]; } export interface ExprValidationError { @@ -146,6 +154,46 @@ function levenshtein(a: string, b: string): number { return dp[m]; } +// ADR-0068 D4 — role-membership predicate heads: a role NAME literal used in a +// membership test against a user subject's `.roles` (or the deprecated singular +// `.role`). Matched names are validated against the closed role catalog. +const ROLE_IN_RE = /(['"])([a-z0-9_]+)\1\s+in\s+(?:current_user|user|ctx\.user)\.roles\b/g; +const ROLE_CONTAINS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*contains\(\s*(['"])([a-z0-9_]+)\1\s*\)/g; +const ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*exists\s*\([^,]*,\s*[^)]*?==\s*(['"])([a-z0-9_]+)\1/g; +const ROLE_EQ_RE = /(?:current_user|user|ctx\.user)\.role\s*==\s*(['"])([a-z0-9_]+)\1/g; + +/** + * Flag role-membership predicates referencing a role outside the closed catalog + * (ADR-0068 D4 — anti-hallucination). No-op when no `roleCatalog` is supplied. + */ +function checkRoleCatalog( + source: string, + schema: ExprSchemaHint | undefined, + errors: ExprValidationError[], +): void { + const catalog = schema?.roleCatalog; + if (!catalog || catalog.length === 0) return; + const known = new Set(catalog); + const seen = new Set(); + for (const re of [ROLE_IN_RE, ROLE_CONTAINS_RE, ROLE_EXISTS_RE, ROLE_EQ_RE]) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(source)) !== null) { + const name = m[2]; + if (known.has(name) || seen.has(name)) continue; + seen.add(name); + const suggestion = nearest(name, catalog); + errors.push({ + source, + message: + `unknown role \`${name}\` — not a defined role` + + (suggestion ? `; did you mean \`${suggestion}\`?` : '.') + + ` Valid roles: ${catalog.join(', ')}.`, + }); + } + } +} + /** * Validate one expression for a given field role. Never throws — returns a * structured result. Call sites decide whether to throw (build/registration) @@ -194,6 +242,7 @@ export function validateExpression( }); } else { checkFieldExistence(source, schema, errors); + checkRoleCatalog(source, schema, errors); if (schema?.scope === 'record') { // In a `record`-scoped site a bare top-level identifier is a silent bug — // it must be `record.` (#1928). Hard error. @@ -246,12 +295,14 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): { dialect: 'cel' | 'template'; fields: string[]; roots: string[]; + roles: string[]; functions: string[]; } { return { dialect: expectedDialect(role), fields: [...(schema?.fields ?? [])], - roots: ['record', 'previous', 'input', 'os', 'vars'], + roots: ['record', 'previous', 'input', 'os', 'current_user', 'user', 'vars'], + roles: [...(schema?.roleCatalog ?? [])], functions: CEL_STDLIB_FUNCTIONS, }; } diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 1ab0900e19..be30ffd0a4 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1319,26 +1319,29 @@ describe('AuthManager', () => { expect(result.user.roles).toEqual(['manager']); }); - it('keeps stored business roles in roles[] when promoting a platform admin', async () => { + it('appends platform_admin to roles[] without overwriting role when promoting a platform admin', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true })); const result = await callback({ user: { id: 'u-1', email: 'a@b.com', role: 'manager' }, session: {}, }); - // Promotion replaces `role` (existing semantics) but the stored - // business role survives in the array. - expect(result.user.role).toBe('admin'); - expect(result.user.roles).toEqual(['manager', 'admin']); + // ADR-0068: NO `role:'admin'` overwrite footgun. The deprecated scalar + // keeps its stored value; the canonical platform_admin identity is added + // to roles[], and isPlatformAdmin is a derived alias. + expect(result.user.role).toBe('manager'); + expect(result.user.roles).toEqual(['manager', 'platform_admin']); + expect(result.user.isPlatformAdmin).toBe(true); }); - it('does not duplicate admin in roles[] when the stored role already includes it', async () => { + it('splits a multi-token stored role and appends platform_admin without duplicates', async () => { const callback = await getSessionCallback(makeDataEngine({ platformAdmin: true })); const result = await callback({ user: { id: 'u-1', email: 'a@b.com', role: 'admin,manager' }, session: {}, }); - expect(result.user.role).toBe('admin'); - expect(result.user.roles).toEqual(['admin', 'manager']); + expect(result.user.role).toBe('admin,manager'); + expect(result.user.roles).toEqual(['admin', 'manager', 'platform_admin']); + expect(result.user.isPlatformAdmin).toBe(true); }); it('returns the payload untouched when the user has no id', async () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 2721a9cdef..f17699a42e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -13,6 +13,7 @@ import type { import type { IDataEngine } from '@objectstack/core'; import type { IEmailService } from '@objectstack/spec/contracts'; import { readEnvWithDeprecation } from '@objectstack/types'; +import { mapMembershipRole, BUILTIN_ROLE_PLATFORM_ADMIN } from '@objectstack/spec'; import { createObjectQLAdapterFactory } from './objectql-adapter.js'; import { AUTH_USER_CONFIG, @@ -967,10 +968,10 @@ export class AuthManager { // `admin`. Org owners/admins are entitled to manage org-scoped // metadata such as saved list views, dashboards, etc. // - // Either path synthesizes `user.role = 'admin'` so the frontend can gate - // metadata-edit affordances uniformly. The raw membership role remains - // available via the `organization` plugin's `member` payload for - // finer-grained checks. + // ADR-0068 D2: rather than synthesizing `user.role = 'admin'`, both paths now + // contribute CANONICAL role names to `user.roles` (platform_admin / org_*), + // and `user.isPlatformAdmin` is a derived alias. The raw membership role + // remains available via the `organization` plugin's `member` payload. const dataEngine = this.config.dataEngine; if (dataEngine) { const { customSession } = await import('better-auth/plugins/custom-session'); @@ -1000,37 +1001,42 @@ export class AuthManager { } }; - const isActiveOrgAdmin = async (): Promise => { + // ADR-0068 D2 — surface CANONICAL org_* role names (not a boolean flag): + // a membership owner/admin/member maps to org_owner/org_admin/org_member. + const activeOrgRoles = async (): Promise => { try { const orgId = (session as any)?.activeOrganizationId; - if (!orgId) return false; + if (!orgId) return []; const members = await dataEngine.find('sys_member', { where: { user_id: user.id, organization_id: orgId }, limit: 5, }); - return (Array.isArray(members) ? members : []).some((m: any) => { - // better-auth org plugin stores roles as either a single string - // or a comma-separated list (e.g. `"owner,admin"`). Treat any - // membership that includes `owner` or `admin` as administrative. + const out: string[] = []; + for (const m of (Array.isArray(members) ? members : [])) { const raw = typeof m?.role === 'string' ? m.role : ''; - const roles = raw.split(',').map((s: string) => s.trim().toLowerCase()); - return roles.includes('owner') || roles.includes('admin'); - }); + for (const r of raw.split(',').map((s: string) => s.trim()).filter(Boolean)) { + const mapped = mapMembershipRole(r); + if (!out.includes(mapped)) out.push(mapped); + } + } + return out; } catch { - return false; + return []; } }; + // ADR-0068 D1/D2 — emit ONE canonical roles[] (identities-as-roles), with + // NO `role:'admin'` overwrite. isPlatformAdmin is a DERIVED alias of + // `'platform_admin' in roles`, retained for back-compat clients. const platformAdmin = await isPlatformAdmin(); - const promote = platformAdmin || (await isActiveOrgAdmin()); + const orgRoles = await activeOrgRoles(); const storedRole = typeof (user as any).role === 'string' ? (user as any).role : ''; - const roles = storedRole - .split(',') - .map((s: string) => s.trim()) - .filter(Boolean); - if (promote && !roles.includes('admin')) roles.push('admin'); - if (!promote) return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session }; - return { user: { ...user, role: 'admin', roles, isPlatformAdmin: platformAdmin }, session }; + const roles = Array.from(new Set([ + ...storedRole.split(',').map((s: string) => s.trim()).filter(Boolean), + ...orgRoles, + ...(platformAdmin ? [BUILTIN_ROLE_PLATFORM_ADMIN] : []), + ])); + return { user: { ...user, roles, isPlatformAdmin: platformAdmin }, session }; })); } diff --git a/packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts b/packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts new file mode 100644 index 0000000000..90a212049c --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-builtin-roles.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapBuiltinRoles — seed the framework's reserved built-in identity roles + * (ADR-0068 D2) into `sys_role`. + * + * The four built-in roles (`platform_admin`, `org_owner`, `org_admin`, + * `org_member`) are a normalized PROJECTION surfaced in `current_user.roles`. + * Seeding their `sys_role` rows makes the role catalog (consumed by role-bound + * permission sets, sharing-rule recipients, and the ADR-0068 D4 role-catalog + * validator) self-describing and AI-groundable. Their SOURCES OF TRUTH — + * `sys_member.role` for the org_* roles and the unscoped `admin_full_access` + * grant for platform_admin — are NEVER changed by this seed. + * + * Idempotent upsert-by-name, no prune. Rows are stamped `managed_by = 'system'` + * so tenants can see (but not repurpose) them. Runs on `kernel:ready` alongside + * the platform-admin and declared-role bootstraps. + */ + +import { BUILTIN_ROLE_NAMES, BUILTIN_ROLE_METADATA } from '@objectstack/spec'; + +const SYSTEM_CTX = { isSystem: true }; + +function genId(prefix: string): string { + const rand = Math.random().toString(36).slice(2, 10); + const ts = Date.now().toString(36); + return `${prefix}_${ts}${rand}`; +} + +async function tryFind(ql: any, object: string, where: any, limit = 100): Promise { + try { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : []; + } catch { return []; } +} +async function tryInsert(ql: any, object: string, data: any): Promise { + try { return await ql.insert(object, data, { context: SYSTEM_CTX }); } catch { return null; } +} +async function tryUpdate(ql: any, object: string, data: any): Promise { + try { await ql.update(object, data, { context: SYSTEM_CTX }); return true; } catch { return false; } +} + +interface SeedOptions { + logger?: { info: (m: string, meta?: Record) => void; warn: (m: string, meta?: Record) => void }; +} + +export async function bootstrapBuiltinRoles( + ql: any, + options: SeedOptions = {}, +): Promise<{ seeded: number; updated: number }> { + if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { + return { seeded: 0, updated: 0 }; + } + let seeded = 0; + let updated = 0; + for (const name of BUILTIN_ROLE_NAMES) { + const meta = BUILTIN_ROLE_METADATA[name]; + const fields = { label: meta.label, description: meta.description, managed_by: 'system' }; + const existing = await tryFind(ql, 'sys_role', { name }, 1); + if (existing[0]?.id) { + if (await tryUpdate(ql, 'sys_role', { id: existing[0].id, ...fields })) updated += 1; + } else { + const created = await tryInsert(ql, 'sys_role', { + id: genId('role'), name, ...fields, active: true, is_default: false, + }); + if (created) seeded += 1; + } + } + options.logger?.info?.('[security] built-in identity roles seeded into sys_role', { seeded, updated, total: BUILTIN_ROLE_NAMES.length }); + return { seeded, updated }; +} diff --git a/packages/plugins/plugin-security/src/objects/sys-role.object.ts b/packages/plugins/plugin-security/src/objects/sys-role.object.ts index 4ae967bffa..ac0fd9e229 100644 --- a/packages/plugins/plugin-security/src/objects/sys-role.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-role.object.ts @@ -19,6 +19,10 @@ export const SysRole = ObjectSchema.create({ managedBy: 'config', // ADR-0010 §3.7 — RBAC primitive; tenants may add custom rows // (created via UI / API) but the schema itself is locked. + // ADR-0068 D3: role-DEFINITION authority follows the isolation boundary. + // Framework-reserved built-in roles (platform_admin / org_*) are seeded with + // `managed_by = 'system'` and MUST NOT be repurposed by a tenant; ad-hoc role + // definitions in a shared cross-tenant kernel namespace are forbidden. protection: { lock: 'no-overlay', reason: 'RBAC schema is platform-defined — see ADR-0010.', @@ -197,6 +201,16 @@ export const SysRole = ObjectSchema.create({ }), // ── System ─────────────────────────────────────────────────── + // ADR-0068 D2/D3 — provenance of this row. `system` = a framework-reserved + // built-in identity role (seeded by bootstrapBuiltinRoles); `config` = + // stack-declared; null / `user` = tenant-created. Built-in rows are read-only. + managed_by: Field.text({ + label: 'Managed By', + readonly: true, + description: 'Provenance: system (built-in) / config (declared) / user (tenant)', + group: 'System', + }), + id: Field.text({ label: 'Role ID', required: true, diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index fa6ca5f763..1bd24e5897 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -4,6 +4,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; import { PermissionEvaluator } from './permission-evaluator.js'; import { bootstrapDeclaredRoles } from './bootstrap-declared-roles.js'; +import { bootstrapBuiltinRoles } from './bootstrap-builtin-roles.js'; import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { matchesFilterCondition } from '@objectstack/formula'; @@ -719,6 +720,13 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] declared-role seeding failed', { error: (e as Error).message }); } + // [ADR-0068 D2] Seed the framework's reserved built-in identity roles + // (platform_admin / org_*) so the role catalog is self-describing. + try { + await bootstrapBuiltinRoles(ql, { logger: ctx.logger }); + } catch (e) { + ctx.logger.warn('[security] built-in role seeding failed', { error: (e as Error).message }); + } // [ADR-0066 D1] Back-compat seed the capability registry (sys_capability) // from the curated platform set + the default grants' systemPermissions. try { diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 4a1a0dee81..1a193fadc8 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -22,6 +22,11 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { resolveApiKeyPrincipal } from './api-key.js'; +import { + mapMembershipRole, + BUILTIN_ROLE_PLATFORM_ADMIN, + ADMIN_FULL_ACCESS, +} from '@objectstack/spec'; interface ResolveOptions { /** Function returning a service from the active kernel (or undefined). */ @@ -237,7 +242,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise s.trim()).filter(Boolean)) { + for (const raw of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) { + // ADR-0068 D2: normalize better-auth owner/admin/member to the canonical + // org_owner/org_admin/org_member built-in role names. + const r = mapMembershipRole(raw); if (!ctx.roles!.includes(r)) ctx.roles!.push(r); } } @@ -308,6 +316,18 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise( + upsRows + .filter((r) => ((r.organization_id ?? r.organizationId) ?? null) === null) + .map((r) => r.permission_set_id ?? r.permissionSetId) + .filter(Boolean), + ); + let hasPlatformAdminGrant = false; + // Resolve role-bound permission sets. if (ctx.roles!.length > 0) { const roleRows = await tryFind(ql, 'sys_role', { name: { $in: ctx.roles } }, 100); @@ -346,6 +366,10 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise platform admin. + if (ps.name === ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id)) { + hasPlatformAdminGrant = true; + } // System permissions may be stored as JSON string in DB rows. const sysPerms = typeof ps.system_permissions === 'string' ? safeJsonParse(ps.system_permissions, []) @@ -375,6 +399,12 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise `admin_full_access`. + */ +export const BUILTIN_ROLE_PLATFORM_ADMIN = 'platform_admin'; +/** Organization owner within a tenant. Source: `sys_member.role = owner`. */ +export const BUILTIN_ROLE_ORG_OWNER = 'org_owner'; +/** Organization administrator within a tenant. Source: `sys_member.role = admin`. */ +export const BUILTIN_ROLE_ORG_ADMIN = 'org_admin'; +/** Organization member within a tenant. Source: `sys_member.role = member`. */ +export const BUILTIN_ROLE_ORG_MEMBER = 'org_member'; + +/** + * The reserved, framework-seeded role names (ADR-0068 D2). These are a + * normalized **projection** into `current_user.roles`; their sources of truth + * (membership rows, the unscoped admin link) are never changed by the projection. + */ +export const BUILTIN_ROLE_NAMES = [ + BUILTIN_ROLE_PLATFORM_ADMIN, + BUILTIN_ROLE_ORG_OWNER, + BUILTIN_ROLE_ORG_ADMIN, + BUILTIN_ROLE_ORG_MEMBER, +] as const; + +export type BuiltinRoleName = (typeof BUILTIN_ROLE_NAMES)[number]; + +/** + * Permission-set name whose unscoped grant is the source of truth for + * `platform_admin` (ADR-0068 D2). + */ +export const ADMIN_FULL_ACCESS = 'admin_full_access'; + +/** Human-readable metadata for the built-in roles (seeded into `sys_role`; AI grounding). */ +export const BUILTIN_ROLE_METADATA: Record = { + [BUILTIN_ROLE_PLATFORM_ADMIN]: { label: 'Platform Admin', description: 'Platform operator (SaaS admin). NOT a tenant user role.' }, + [BUILTIN_ROLE_ORG_OWNER]: { label: 'Organization Owner', description: 'Organization owner within a tenant.' }, + [BUILTIN_ROLE_ORG_ADMIN]: { label: 'Organization Admin', description: 'Organization administrator within a tenant.' }, + [BUILTIN_ROLE_ORG_MEMBER]: { label: 'Organization Member', description: 'Organization member within a tenant.' }, +}; + +/** Normalize a raw better-auth membership role (owner/admin/member) to its canonical + * built-in role name (org_owner/org_admin/org_member). Unknown values pass through. */ +export function mapMembershipRole(raw: string): string { + switch (raw.trim().toLowerCase()) { + case 'owner': return BUILTIN_ROLE_ORG_OWNER; + case 'admin': return BUILTIN_ROLE_ORG_ADMIN; + case 'member': return BUILTIN_ROLE_ORG_MEMBER; + default: return raw.trim(); + } +} + +// ========================================== +// Contract +// ========================================== + +export const EvalUserSchema = lazySchema(() => + z.object({ + id: z.string().describe('User ID'), + name: z.string().optional().describe('Display name'), + email: z.string().optional().describe('Email address'), + /** CANONICAL. Scope-resolved (ADR-0068 D3); includes built-in + business roles. */ + roles: z.array(z.string()).default([]).describe('Canonical role names assigned to the user (scope-resolved)'), + /** DERIVED alias of roles.includes(platform_admin) (ADR-0068 D2). Deprecated surface. */ + isPlatformAdmin: z.boolean().optional().describe("DERIVED alias of 'platform_admin' in roles. Deprecated."), + organizationId: z.string().nullable().optional().describe('Active organization ID (null = platform/unscoped)'), + }) +); + +export type EvalUser = z.infer; +/** Authoring input for EvalUser — defaulted fields are optional. */ +export type EvalUserInput = z.input; + +/** + * Build a canonical EvalUser from loosely-typed source fields. The single factory + * every surface uses (server buildScope, the customSession bridge, objectui + * fallback/guest/preview users) so the user shape — and the isPlatformAdmin + * derivation — never drifts. isPlatformAdmin is always derived from roles. + */ +export function createEvalUser(input: { + id: string; + name?: string | null; + email?: string | null; + roles?: readonly string[] | null; + organizationId?: string | null; +}): EvalUser { + const roles = Array.from( + new Set((input.roles ?? []).map((r) => String(r).trim()).filter(Boolean)) + ); + return { + id: input.id, + ...(input.name != null ? { name: input.name } : {}), + ...(input.email != null ? { email: input.email } : {}), + roles, + isPlatformAdmin: roles.includes(BUILTIN_ROLE_PLATFORM_ADMIN), + ...(input.organizationId !== undefined ? { organizationId: input.organizationId } : {}), + }; +} diff --git a/packages/spec/src/identity/index.ts b/packages/spec/src/identity/index.ts index cd9c375dee..1730b247fd 100644 --- a/packages/spec/src/identity/index.ts +++ b/packages/spec/src/identity/index.ts @@ -6,3 +6,4 @@ export * from './role.zod'; export { roleForm } from './role.form'; export * from './organization.zod'; export * from './scim.zod'; +export * from './eval-user.zod'; diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 17c153b41f..6fc7cabdef 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -138,3 +138,18 @@ export type { PredicateInput, } from './shared/expression.zod'; + +// ADR-0068: unified user-context contract (EvalUser) + built-in identity roles. +export { + createEvalUser, + mapMembershipRole, + EvalUserSchema, + BUILTIN_ROLE_NAMES, + BUILTIN_ROLE_METADATA, + BUILTIN_ROLE_PLATFORM_ADMIN, + BUILTIN_ROLE_ORG_OWNER, + BUILTIN_ROLE_ORG_ADMIN, + BUILTIN_ROLE_ORG_MEMBER, + ADMIN_FULL_ACCESS, +} from './identity/eval-user.zod'; +export type { EvalUser, EvalUserInput, BuiltinRoleName } from './identity/eval-user.zod'; From dab2b06582eb3517b3be08aaf8f5baff9d87ef4c Mon Sep 17 00:00:00 2001 From: zhuangjianguo Date: Thu, 25 Jun 2026 11:44:57 +0800 Subject: [PATCH 2/4] 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. --- packages/spec/src/ui/app.zod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 0a70329e14..0ce79fc453 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -46,7 +46,7 @@ const BaseNavItemSchema = z.object({ * Formula expression returning boolean. * e.g. "user.is_admin || user.department == 'sales'" */ - visible: ExpressionInputSchema.optional().describe('Visibility predicate (CEL). e.g. P`os.user.role == "admin"`'), + visible: ExpressionInputSchema.optional().describe('Visibility predicate (CEL). e.g. P`\'org_admin\' in current_user.roles`'), /** Permissions required to see/access this navigation item */ requiredPermissions: z.array(z.string()).optional().describe('Permissions required to access this item'), From 6e9acf3b282a2607fe59e72822549b88982d29ad Mon Sep 17 00:00:00 2001 From: zhuangjianguo Date: Thu, 25 Jun 2026 13:08:27 +0800 Subject: [PATCH 3/4] 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. --- packages/spec/api-surface.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 78b495f336..c5d3d5ed09 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1,6 +1,14 @@ { ".": [ + "ADMIN_FULL_ACCESS (const)", "Agent (type)", + "BUILTIN_ROLE_METADATA (const)", + "BUILTIN_ROLE_NAMES (const)", + "BUILTIN_ROLE_ORG_ADMIN (const)", + "BUILTIN_ROLE_ORG_MEMBER (const)", + "BUILTIN_ROLE_ORG_OWNER (const)", + "BUILTIN_ROLE_PLATFORM_ADMIN (const)", + "BuiltinRoleName (type)", "ComposeStacksOptions (type)", "ComposeStacksOptionsSchema (const)", "ConflictStrategy (type)", @@ -9,6 +17,9 @@ "DatasourceMappingRule (type)", "DatasourceMappingRuleSchema (const)", "DefineStackOptions (interface)", + "EvalUser (type)", + "EvalUserInput (type)", + "EvalUserSchema (const)", "ExpandedViewItem (interface)", "Expression (type)", "ExpressionDialect (type)", @@ -46,6 +57,7 @@ "Tool (type)", "cel (function)", "composeStacks (function)", + "createEvalUser (function)", "cron (function)", "defineAction (function)", "defineAgent (function)", @@ -81,6 +93,7 @@ "formatZodError (function)", "formatZodIssue (function)", "isAggregatedViewContainer (function)", + "mapMembershipRole (function)", "normalizeMetadataCollection (function)", "normalizePluginMetadata (function)", "normalizeStackInput (function)", @@ -4118,6 +4131,7 @@ "TestSuiteSchema (const)" ], "./identity": [ + "ADMIN_FULL_ACCESS (const)", "AUTH_CONSTANTS (const)", "AUTH_ERROR_CODES (const)", "Account (type)", @@ -4127,6 +4141,16 @@ "AuthError (interface)", "AuthHeaders (interface)", "AuthResponse (interface)", + "BUILTIN_ROLE_METADATA (const)", + "BUILTIN_ROLE_NAMES (const)", + "BUILTIN_ROLE_ORG_ADMIN (const)", + "BUILTIN_ROLE_ORG_MEMBER (const)", + "BUILTIN_ROLE_ORG_OWNER (const)", + "BUILTIN_ROLE_PLATFORM_ADMIN (const)", + "BuiltinRoleName (type)", + "EvalUser (type)", + "EvalUserInput (type)", + "EvalUserSchema (const)", "Invitation (type)", "InvitationSchema (const)", "InvitationStatus (type)", @@ -4183,7 +4207,9 @@ "UserSchema (const)", "VerificationToken (type)", "VerificationTokenSchema (const)", + "createEvalUser (function)", "defineRole (function)", + "mapMembershipRole (function)", "roleForm (const)" ], "./shared": [ From 54c92861570708b83239cda768cc13900949d4b8 Mon Sep 17 00:00:00 2001 From: zhuangjianguo Date: Thu, 25 Jun 2026 13:17:14 +0800 Subject: [PATCH 4/4] fix(formula): harden D4 role-exists regex against ReDoS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/formula/src/validate.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index f0803a08b0..fcdcbd2d92 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -159,7 +159,12 @@ function levenshtein(a: string, b: string): number { // `.role`). Matched names are validated against the closed role catalog. const ROLE_IN_RE = /(['"])([a-z0-9_]+)\1\s+in\s+(?:current_user|user|ctx\.user)\.roles\b/g; const ROLE_CONTAINS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*contains\(\s*(['"])([a-z0-9_]+)\1\s*\)/g; -const ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*exists\s*\([^,]*,\s*[^)]*?==\s*(['"])([a-z0-9_]+)\1/g; +// Bounded quantifiers ({0,N}, not * / *?) keep this linear: a CEL `exists` +// body is tiny in practice, and unbounded greedy/lazy scanners here backtrack +// polynomially (O(n^2)) on adversarial input like repeated `user.roles.exists(` +// (ADR-0068 D4 ReDoS hardening). The pre-`==` class excludes `=` so the bounded +// run stops cleanly before the operator without a lazy quantifier. +const ROLE_EXISTS_RE = /(?:current_user|user|ctx\.user)\.roles\s*\.\s*exists\s*\([^,)]{0,64},[^)=]{0,128}==\s*(['"])([a-z0-9_]+)\1/g; const ROLE_EQ_RE = /(?:current_user|user|ctx\.user)\.role\s*==\s*(['"])([a-z0-9_]+)\1/g; /**