From cba9a9dcaaa63faf50d7790be5c29051c4793f36 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 13:43:35 +0000 Subject: [PATCH 1/5] feat(plugin-security): refuse ADR-0068 built-in identity names at the position write doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name (`platform_admin`, `org_owner`, `org_admin`, `org_member`). PR #15948 closed every in-repo reader that turned such a name into authority; it could not stop the row existing, and an out-of-repo reader that reads the name instead of the capability rung reopens the hole with nothing mechanical to catch it. Both declarations now carry an object-level `validations[]` rule whose CEL list literal is GENERATED from `BUILTIN_IDENTITY_NAMES` — the spec constant that declares the identities — so the closed enumeration is imported, never retyped and never widened to an `org_*` pattern. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by ONE refusal carrying ONE code (`VALIDATION_FAILED`). `sys_position` exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling) because `bootstrapBuiltinRoles` seeds exactly these names; `sys_user_position` takes no exemption at all, since no writer in any package creates an assignment row spelling one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW --- .../objects/reserved-identity-names.test.ts | 348 ++++++++++++++++++ .../src/objects/reserved-identity-names.ts | 116 ++++++ .../src/objects/sys-position.object.ts | 74 ++++ .../src/objects/sys-user-position.object.ts | 43 +++ 4 files changed, 581 insertions(+) create mode 100644 packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts create mode 100644 packages/plugins/plugin-security/src/objects/reserved-identity-names.ts diff --git a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts new file mode 100644 index 0000000000..efc80c49e0 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts @@ -0,0 +1,348 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #15972 — a tenant can no longer mint a row spelling a built-in identity name. + * + * PR #15948 closed every in-repo READER that turned such a name into authority. + * It could not stop the row existing, and a reader is not an invariant: an + * out-of-repo consumer that reads the NAME instead of the capability rung + * reopens the hole with nothing mechanical to catch it. This suite pins the + * write-side refusal that makes the row impossible instead. + * + * Maintainer ruling (director seat, summon #20, decision batch #105 item 4, + * 2026-09-09), verbatim on the three questions the card left open: + * + * 1. 「the object layer — `sys_position.name` … refuses the reserved names, + * so every door (data API, seed, import) is covered; the service write + * door reuses the same predicate rather than a second copy」 + * 2. 「exactly the ADR-0068 built-in identity names, read from the spec + * constant that declares them (closed enumeration, ⛔ not retyped, ⛔ not + * widened to `org_*` shapes by pattern)」 + * 3. 「refuse new writes only. No migration.」 + * + * ## Why the engine legs are here and not only a declaration pin + * + * A `validations[]` entry is inert unless the write path evaluates it, and the + * whole defect this card closes is a rule that existed only as prose. So the + * refusals below are measured on a REAL engine over a real SQL driver, and each + * one is paired with a NEGATIVE CONTROL that still writes — a refusal suite + * with no control cannot tell "the reserved names are refused" from "the object + * is refusing everything". + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL, VALIDATION_FAILED_CODE } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; + +import { SysPosition } from './sys-position.object.js'; +import { SysUserPosition } from './sys-user-position.object.js'; +import { SysPermissionSet } from './sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; +import { + RESERVED_IDENTITY_NAMES, + isReservedIdentityName, + reservedIdentityNamesCelList, + reservedIdentityNameMessage, +} from './reserved-identity-names.js'; +import { bootstrapBuiltinRoles } from '../bootstrap-builtin-positions.js'; +import { DelegatedAdminGate } from '../delegated-admin-gate.js'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(): Promise { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.reserved-identity-names-15972', + name: 'Reserved identity names', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet], + } as any); + await engine.syncSchemas(); + engines.push(engine); + return engine; +} + +/** A tenant admin's write context: authenticated, NOT `isSystem`. */ +const TENANT_CTX = { userId: 'u_tenant_admin' } as any; + +/** + * The refusal, identified by `code` — never by `instanceof` and never by a bare + * `toThrow()`. `@objectstack/objectql` publishes both realms in its `exports`, + * so a consumer holding the other realm's copy of `ValidationError` gets + * `instanceof === false`; and a throw-shaped assertion stays green when a + * DIFFERENT refusal fires one step earlier, which on this object is exactly the + * confusion to avoid (the name index answers `UNIQUE_VIOLATION` for a name the + * platform seed already holds). + */ +async function refusalOf(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected the write to be refused, but it succeeded'); +} + +describe('#15972 the reserved set is IMPORTED from the spec constant, never retyped', () => { + it('IS `BUILTIN_IDENTITY_NAMES` — the constant that declares the identities', () => { + // Value equality AND membership equality: a re-spelled array with the same + // four strings would satisfy `toEqual`, so the four names are also asserted + // to arrive in the same order the declaration ships them. + expect([...RESERVED_IDENTITY_NAMES]).toEqual([...BUILTIN_IDENTITY_NAMES]); + expect(RESERVED_IDENTITY_NAMES).toHaveLength(4); + }); + + it('is a CLOSED enumeration — ⛔ not an `org_*` pattern', () => { + // The ruling refuses the pattern reading explicitly. `org_manager` is a + // perfectly ordinary tenant position name and MUST stay writable; a + // prefix test would have swallowed it. + expect(isReservedIdentityName('org_admin')).toBe(true); + expect(isReservedIdentityName('org_manager')).toBe(false); + expect(isReservedIdentityName('org_')).toBe(false); + expect(isReservedIdentityName('platform_admin_deputy')).toBe(false); + // Not a string, and the empty string: neither is a name. + expect(isReservedIdentityName(undefined)).toBe(false); + expect(isReservedIdentityName(null)).toBe(false); + expect(isReservedIdentityName('')).toBe(false); + }); + + it('renders a CEL list literal whose members need no escaping', () => { + const list = reservedIdentityNamesCelList(); + for (const name of BUILTIN_IDENTITY_NAMES) expect(list).toContain(`'${name}'`); + // ⛔ The escaping assertion is the load-bearing one: a name carrying a + // single quote would produce a predicate that cannot PARSE, and an + // unparseable predicate is rejected fail-closed on every write (#4649) — + // i.e. the object would be bricked rather than guarded. ADR-0068 D2 names + // are `[a-z_]` machine names, and this is where that stays true. + for (const name of BUILTIN_IDENTITY_NAMES) expect(name).toMatch(/^[a-z][a-z0-9_]*$/); + }); +}); + +describe('#15972 both write doors read the GENERATED list — one predicate, no second copy', () => { + const positionRule: any = (SysPosition.validations ?? []).find((v: any) => v.name === 'reserved_identity_name'); + const assignmentRule: any = (SysUserPosition.validations ?? []).find((v: any) => v.name === 'reserved_identity_position'); + + it('sys_position declares the rule, at `error` severity', () => { + expect(positionRule).toBeDefined(); + // ⛔ `warning` / `info` are LOGGED and never throw — a reserved-name rule at + // either level is a rule that refuses nothing. + expect(positionRule.severity).toBe('error'); + expect(positionRule.condition.source).toContain(reservedIdentityNamesCelList()); + }); + + it('sys_user_position declares the rule, at `error` severity', () => { + expect(assignmentRule).toBeDefined(); + expect(assignmentRule.severity).toBe('error'); + expect(assignmentRule.condition.source).toContain(reservedIdentityNamesCelList()); + }); + + it('both carry the SAME wording, parameterised only by the column', () => { + expect(positionRule.message).toBe(reservedIdentityNameMessage('name')); + expect(assignmentRule.message).toBe(reservedIdentityNameMessage('position')); + }); + + it('only sys_position takes a provenance exemption — the assignment door takes none', () => { + // The asymmetry is the design: `sys_position` has a legitimate platform + // seeder for exactly these names; `sys_user_position` has none, in any + // package, so its refusal is unconditional. + expect(positionRule.condition.source).toContain('managed_by'); + expect(assignmentRule.condition.source).not.toContain('managed_by'); + }); +}); + +describe('#15972 sys_position — the object layer refuses the reserved names on a real engine', () => { + it.each([...BUILTIN_IDENTITY_NAMES])('refuses a tenant-authored position named %s', async (name) => { + const engine = await boot(); + const err = await refusalOf(() => (engine as any).insert( + 'sys_position', + { id: `pos_${name}`, name, label: 'Impostor' }, + { context: TENANT_CTX }, + )); + expect(err.code).toBe(VALIDATION_FAILED_CODE); + expect(err.message).toContain('framework-reserved built-in identity name'); + }); + + it('NEGATIVE CONTROL — every other name still writes', async () => { + const engine = await boot(); + // Including the shapes a pattern-based guard would have swallowed. + for (const name of ['sales_manager', 'org_manager', 'platform_admin_deputy', 'hr_specialist']) { + await expect((engine as any).insert( + 'sys_position', + { id: `pos_${name}`, name, label: name }, + { context: TENANT_CTX }, + )).resolves.toBeTruthy(); + } + }); + + it('refuses a RENAME into a reserved name (the update door, not just insert)', async () => { + const engine = await boot(); + await (engine as any).insert( + 'sys_position', { id: 'pos_rename', name: 'sales_manager', label: 'Sales' }, { context: TENANT_CTX }, + ); + const err = await refusalOf(() => (engine as any).update( + 'sys_position', { id: 'pos_rename', name: 'platform_admin' }, { context: TENANT_CTX }, + )); + expect(err.code).toBe(VALIDATION_FAILED_CODE); + }); + + it('refuses a PACKAGE-provenance row too — a stack may not repurpose one either', async () => { + const engine = await boot(); + const err = await refusalOf(() => (engine as any).insert( + 'sys_position', + { id: 'pos_pkg', name: 'org_admin', label: 'Impostor', managed_by: 'package' }, + { context: { isSystem: true } }, + )); + expect(err.code).toBe(VALIDATION_FAILED_CODE); + }); + + it("the PLATFORM's own catalog seed is unaffected — all four names still seed", async () => { + const engine = await boot(); + // The exemption exists for exactly this writer. If it regressed, the seed + // would report zero rows and the built-in role catalog would vanish — so + // this leg is what keeps the guard from being a boot-breaking change. + const result = await bootstrapBuiltinRoles(engine as any); + expect(result.seeded).toBe(BUILTIN_IDENTITY_NAMES.length + 2); // + everyone / guest + const rows = await (engine as any).find('sys_position', { where: {}, context: { isSystem: true } }); + const seeded = rows.map((r: any) => r.name); + for (const name of BUILTIN_IDENTITY_NAMES) expect(seeded).toContain(name); + // Re-running is the idempotent upsert path, i.e. the UPDATE leg over rows + // that already spell reserved names. It must not start refusing itself. + await expect(bootstrapBuiltinRoles(engine as any)).resolves.toBeTruthy(); + }); +}); + +describe('#15972 sys_user_position — the ROW the card is about', () => { + it.each([...BUILTIN_IDENTITY_NAMES])('refuses an assignment row spelling %s', async (name) => { + const engine = await boot(); + const err = await refusalOf(() => (engine as any).insert( + 'sys_user_position', + { id: `a_${name}`, user_id: 'u_victim', position: name }, + { context: TENANT_CTX }, + )); + expect(err.code).toBe(VALIDATION_FAILED_CODE); + expect(err.message).toContain('framework-reserved built-in identity name'); + }); + + it('takes NO system exemption — nothing legitimate writes one, so nobody may', async () => { + const engine = await boot(); + const err = await refusalOf(() => (engine as any).insert( + 'sys_user_position', + { id: 'a_sys', user_id: 'u_victim', position: 'platform_admin' }, + { context: { isSystem: true } }, + )); + expect(err.code).toBe(VALIDATION_FAILED_CODE); + }); + + it('NEGATIVE CONTROL — an ordinary position assignment still writes', async () => { + const engine = await boot(); + await expect((engine as any).insert( + 'sys_user_position', + { id: 'a_ok', user_id: 'u_victim', position: 'sales_manager' }, + { context: TENANT_CTX }, + )).resolves.toBeTruthy(); + }); + + it('refuses a RE-POINT of an existing assignment onto a reserved name', async () => { + const engine = await boot(); + await (engine as any).insert( + 'sys_user_position', { id: 'a_move', user_id: 'u_victim', position: 'sales_manager' }, { context: TENANT_CTX }, + ); + const err = await refusalOf(() => (engine as any).update( + 'sys_user_position', { id: 'a_move', position: 'org_owner' }, { context: TENANT_CTX }, + )); + expect(err.code).toBe(VALIDATION_FAILED_CODE); + }); +}); + +/** + * The SERVICE WRITE DOOR — ADR-0090 D12 delegated administration. + * + * The ruling asks the service door to reuse the same predicate «rather than a + * second copy». It reuses it by INHERITING it: the gate is a hook on the same + * engine write, so an assignment that clears the gate still meets the object + * layer's refusal — one condition, one code, one wording, at both doors. + * + * The first leg re-measures the card's own finding, which is the reason the + * gate cannot be the place this is refused: `assertAssignmentWrite` judges a + * position by the permission sets it DISTRIBUTES, and the seeded + * `platform_admin` position distributes none, so `boundSets.every(…)` approves + * it VACUOUSLY. A delegate with `manageAssignments` therefore reaches the write + * — and is refused by the object layer, not by the gate. + */ +describe('#15972 the service write door funnels into the same single refusal', () => { + const EAST_SCOPE = { + businessUnit: 'east', + includeSubtree: true, + manageAssignments: true, + manageBindings: false, + authorEnvironmentSets: false, + assignablePermissionSets: ['sales_user'], + }; + + function gateHarness() { + const tables: Record = { + sys_business_unit: [{ id: 'bu_east', name: 'east', parent_business_unit_id: null }], + sys_position: [{ id: 'pos_platform_admin', name: 'platform_admin' }], + sys_permission_set: [{ id: 'ps_sales', name: 'sales_user' }], + // ⛔ Deliberately EMPTY: the seeded `platform_admin` position binds no + // permission set. That is the vacuity the card measured. + sys_position_permission_set: [], + sys_user_position: [], + sys_business_unit_member: [{ id: 'm1', business_unit_id: 'bu_east', user_id: 'u_victim' }], + sys_user: [{ id: 'u_delegate' }, { id: 'u_victim' }], + }; + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + const ql = { + async find(object: string, opts: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async findOne(object: string, opts: any) { + return (tables[object] ?? []).filter((r) => matches(r, opts?.where))[0] ?? null; + }, + } as any; + return new DelegatedAdminGate({ + ql, + resolveSets: async () => [ + { name: 'sub_admin', objects: { sys_user_position: { allowRead: true, allowCreate: true } }, adminScope: EAST_SCOPE } as any, + ], + }); + } + + it('the gate APPROVES the assignment — `boundSets.every(…)` is vacuous (re-measured)', async () => { + const gate = gateHarness(); + await expect(gate.assert({ + object: 'sys_user_position', + operation: 'insert', + data: { user_id: 'u_victim', position: 'platform_admin', business_unit_id: 'bu_east' }, + context: { userId: 'u_delegate' }, + })).resolves.toBeUndefined(); + }); + + it('…and the write is refused anyway, with the object layer’s ONE code', async () => { + const engine = await boot(); + const err = await refusalOf(() => (engine as any).insert( + 'sys_user_position', + { id: 'a_delegate', user_id: 'u_victim', position: 'platform_admin', business_unit_id: 'bu_east' }, + { context: { userId: 'u_delegate' } }, + )); + expect(err.code).toBe(VALIDATION_FAILED_CODE); + }); +}); diff --git a/packages/plugins/plugin-security/src/objects/reserved-identity-names.ts b/packages/plugins/plugin-security/src/objects/reserved-identity-names.ts new file mode 100644 index 0000000000..b730cc49a5 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/reserved-identity-names.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Reserved identity names — the ONE predicate both position write doors read. + * + * ADR-0068 D2 reserves four names for the framework's built-in identities. + * They are a normalized PROJECTION into `current_user.positions`, and their + * sources of truth are elsewhere: the unscoped `admin_full_access` grant for + * `platform_admin`, `sys_member.role` for the `org_*` trio. Nothing in the + * platform ever writes one of these names into a tenant-authored row — + * `bootstrapBuiltinRoles` seeds the `sys_position` CATALOG rows (stamped + * `managed_by = 'platform'`) and no writer anywhere creates a + * `sys_user_position` assignment spelling one. + * + * ## Why this file exists at all + * + * `sys-position.object.ts` already SAID it, in prose: + * + * Framework-reserved built-in identities (platform_admin / org_*) ... + * MUST NOT be repurposed by a tenant + * + * and `resolve-authz-context.ts` said the consequence from the other side + * («Read the RUNG — never `positions.includes(...)`; an ADR-0057 D4 + * `sys_user_position` row may spell that very name»). Both were comments. A + * comment is not a gate: the names stayed writable, every defence was a READER + * choosing to consult the capability rung, and an out-of-repo reader that + * forgets reopens the hole with nothing mechanical to catch it. + * + * ## The closed enumeration is IMPORTED, never retyped + * + * {@link RESERVED_IDENTITY_NAMES} IS `BUILTIN_IDENTITY_NAMES` — the spec + * constant that DECLARES the set. ⛔ Never re-spell the strings here, and ⛔ + * never widen the set by PATTERN (`org_*` would swallow every tenant position + * whose name happens to start with `org_`). A name joins this set by joining + * the spec constant, in `packages/spec`, where the identity is declared. + * + * ## Two consumers, one predicate + * + * 1. The OBJECT LAYER — `sys_position.name` and `sys_user_position.position` + * each declare a `validations[]` rule whose CEL list literal is generated + * by {@link reservedIdentityNamesCelList} from this same array. Object-level + * validations are the platform's one server-enforced "this column's values + * must look like X" channel (ADR-0049 declared = enforced): `objectql`'s + * rule validator runs them on insert, by-id update AND multi-row update, so + * every door that writes through the engine — data API, seed, import — is + * covered by one refusal carrying one code (`VALIDATION_FAILED`). + * 2. TypeScript callers — {@link isReservedIdentityName} — for any door that + * needs the answer in code rather than in CEL. + * + * ⛔ A second copy of the refusal in a service gate would carry that gate's own + * error code, and which of the two a caller sees would depend on hook order: + * one condition, one code, one wording. The doors share the PREDICATE; they do + * not each grow a refusal. + */ + +import { BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; + +/** + * The reserved set: EXACTLY the ADR-0068 built-in identity names, read from the + * spec constant that declares them. + * + * ⛔ Not the ADR-0090 D5/D9 audience anchors (`everyone` / `guest`). Those are + * a different invariant with a different remedy — a stored assignment to an + * implicit audience is a modelling error, refused by the delegated-admin gate — + * and folding them in here would widen a closed enumeration the ruling closed. + */ +export const RESERVED_IDENTITY_NAMES: readonly string[] = BUILTIN_IDENTITY_NAMES; + +/** + * Does `value` spell a framework-reserved built-in identity name? + * + * Exact match, deliberately: the reserved set is a closed enumeration, not a + * shape. Case folding and trimming are NOT applied — the platform stores and + * resolves position names verbatim, so `Platform_Admin` is a different name to + * every reader in the system and refusing it here would refuse a name nothing + * treats as authority. + */ +export function isReservedIdentityName(value: unknown): boolean { + return typeof value === 'string' && RESERVED_IDENTITY_NAMES.includes(value); +} + +/** + * The reserved set as a CEL list literal — `['a', 'b', …]` — for a + * `validations[]` predicate. + * + * GENERATED from the array on purpose: an object declaration that spelled the + * names inside its condition string would be the second copy this module + * exists to prevent, and a copy inside a string is one no compiler checks. + * + * The names are `[a-z_]` machine names (ADR-0068 D2), so single quotes need no + * escaping — asserted by this module's own test rather than assumed, because + * an unescaped quote would produce a predicate that cannot parse, and an + * unparseable predicate is REJECTED fail-closed on every write (#4649), which + * would brick the object rather than guard it. + */ +export function reservedIdentityNamesCelList(): string { + return `[${RESERVED_IDENTITY_NAMES.map((n) => `'${n}'`).join(', ')}]`; +} + +/** + * The one wording both declarations use, parameterised by the column the write + * spelled the name in. One condition, one code, one sentence (#5240) — an + * operator who meets this refusal on either door reads the same explanation. + * + * It names the remedy, because the refusal is not "you lack permission" — no + * caller has permission, tenant admins included. The name is reserved by the + * framework; a different name is the only way through. + */ +export function reservedIdentityNameMessage(column: string): string { + return ( + `'${column}' cannot spell a framework-reserved built-in identity name ` + + `(${RESERVED_IDENTITY_NAMES.join(', ')}). These names are ADR-0068 built-in identities: ` + + `the platform projects them into current_user.positions from their own sources of truth, ` + + `and a row spelling one is not an assignment of that identity. Choose a different name.` + ); +} diff --git a/packages/plugins/plugin-security/src/objects/sys-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-position.object.ts index 8c2195e613..97a478c4c8 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position.object.ts @@ -1,6 +1,38 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from './reserved-identity-names.js'; + +/** + * [#15972] The CEL predicate behind the `reserved_identity_name` rule below, + * assembled here so the object literal reads as one line and the two halves of + * the condition can each carry their own note. + * + * ⚠️ `&&` order is load-bearing. CEL absorbs an error on one side of `&&` when + * the other side is `false`, so putting the reserved-name test FIRST means the + * provenance test is only ever evaluated for a write that already spells a + * reserved name. Every other name short-circuits out before `managed_by` is + * read at all — which is what keeps the negative control (every other name + * still writes) independent of whatever `managed_by` happens to hold. + */ +const RESERVED_IDENTITY_NAME_CONDITION = + `record.name in ${reservedIdentityNamesCelList()}` + // The framework's OWN catalog rows are the exception, and the only one: + // `bootstrapBuiltinRoles` seeds exactly these names, per organization, + // stamped `managed_by: 'platform'` under an `isSystem` context. `system` is + // the legacy pre-A4 spelling of the same provenance — kept in lockstep with + // `SYSTEM_ROW_PROVENANCE` (security-plugin.ts), which maps BOTH to "the + // platform" — so a legacy row is not frozen by an invariant that lands after + // it. ⛔ `package` / `config` are deliberately NOT exempt: a stack that + // declares a position named `org_admin` is repurposing a built-in identity, + // which is the same defect arriving through the supply chain, and + // `bootstrapDeclaredPositions` stamps no provenance at all (the row defaults + // to `admin`), so it is refused here like any other tenant-grade write. + // + // A tenant cannot reach the exemption by CLAIMING it: `managed_by` is + // `readonly`, and the admin-door provenance gate refuses a payload that + // spells `platform`/`package` outright. + + ` && !(record.managed_by in ['platform', 'system'])`; /** * sys_position — Position definitions (ADR-0090 D3). @@ -334,4 +366,46 @@ export const SysPosition = ObjectSchema.create({ // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, + + // ── [#15972] Reserved built-in identity names ──────────────────── + // + // The prose at the head of this file has said since ADR-0068 that the + // framework-reserved built-in identities "MUST NOT be repurposed by a + // tenant". ⚠️ That was a COMMENT, not a gate — the names stayed writable, + // and every defence against a tenant-minted `platform_admin` was a READER + // choosing to consult the capability rung rather than the name. This array + // is the enforcement of the sentence that was already here. + // + // Why the object's `validations`, and not a gate in the security plugin: + // the plugin's name gates (the curated-capability one, the provenance one) + // guard the ADMIN DOOR. A rule declared here is evaluated by the ENGINE + // (`objectql`'s rule validator) on insert, by-id update and multi-row + // update, so the data API, the seeders and metadata import are all covered + // by ONE refusal carrying ONE code (`VALIDATION_FAILED`) — which is what + // stops the two doors answering the same condition with two vocabularies. + // + // ⚠️ An INVARIANT, not a transition gate (see `ScriptValidationSchema`): the + // predicate is re-evaluated against the merged record on every write, so a + // row that ALREADY spells a reserved name is refused on any edit until it is + // renamed — frozen, not bricked, and deliberately so on a security invariant. + // Nothing here rewrites such a row; the read-only census + // (`scripts/measure-reserved-identity-name-census.mjs`) is what reports them. + validations: [ + { + // `cross_field`, not `script`: the condition genuinely reads two columns + // (the name AND its provenance), and only this variant carries `fields`, + // which is what attaches the violation to `name` instead of `_record` so + // a form can point at the offending input. + type: 'cross_field', + name: 'reserved_identity_name', + label: 'Reserved built-in identity name', + description: + 'ADR-0068 D2 reserves the built-in identity names for the framework. Only the platform’s own ' + + 'catalog seed may spell one; a tenant- or package-authored position must choose another name.', + fields: ['name', 'managed_by'], + condition: { dialect: 'cel', source: RESERVED_IDENTITY_NAME_CONDITION }, + severity: 'error', + message: reservedIdentityNameMessage('name'), + }, + ], }); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index 6da0c51154..c142203555 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from './reserved-identity-names.js'; /** * sys_user_position — User ↔ Position assignment (ADR-0057 D4). @@ -178,4 +179,46 @@ export const SysUserPosition = ObjectSchema.create({ // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026). apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'], }, + + // ── [#15972] Reserved built-in identity names ──────────────────── + // + // THE ROW IS THE EXPOSURE. `position` is free text (it references + // `sys_position.name` by convention, not by lookup), this object is + // `apiEnabled`, and its bucket is admin/user-writable — so refusing the + // reserved names on the position DEFINITION alone closes nothing here: the + // platform seeds a `platform_admin` catalog row in every organization, and + // an assignment row may name it (or any built-in identity) with no + // definition needed at all. + // + // Nothing legitimate writes one. The built-in identities are a PROJECTION + // with their own sources of truth — the unscoped `admin_full_access` grant + // for `platform_admin` (`bootstrapPlatformAdmin` writes a + // `sys_user_permission_set` row, never one of these), `sys_member.role` for + // the `org_*` trio — and the resolver unions those in itself. So an + // assignment row spelling one of these names is, in every case, a name + // pretending to be an identity, and the refusal takes no provenance + // exemption: unlike `sys_position`, this object has no legitimate seeder to + // exempt. + // + // The invariant core's own resolver states from the other side, in a comment + // (`resolve-authz-context.ts`): «Read the RUNG — never + // `positions.includes(...)`; an ADR-0057 D4 `sys_user_position` row may spell + // that very name.» ⚠️ That comment was there the whole time and prevented + // nothing. This is the same sentence, on the write path, where it can refuse. + validations: [ + { + // `script`, not `cross_field`: one column decides it, and this variant's + // strict shape carries no `fields`, so the violation attaches to + // `_record`. The message names the column. + type: 'script', + name: 'reserved_identity_position', + label: 'Reserved built-in identity name', + description: + 'ADR-0068 D2 built-in identity names are a projection with their own sources of truth. A stored ' + + 'assignment row spelling one grants nothing and misrepresents the holder, so it is refused.', + condition: { dialect: 'cel', source: `record.position in ${reservedIdentityNamesCelList()}` }, + severity: 'error', + message: reservedIdentityNameMessage('position'), + }, + ], }); From 7de49808ed282c765eaf30f968f8af24f9713164 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 13:46:25 +0000 Subject: [PATCH 2/5] feat(plugin-security): read-only census for existing reserved-identity-name rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/measure-reserved-identity-name-census.mjs` reports rows that already stand on an ADR-0068 built-in identity name and rewrites none of them, per the maintainer ruling («refuse new writes only. No migration. A read-only census reports existing colliding rows to the maintainer»). Two modes: the default censuses DECLARATIONS in this repository; `--rows FILE` censuses a deployment from a read-only export, separating the platform's own seeded catalog rows from real collisions and refusing an input that never exported a table rather than reading it as zero. The reserved set is parsed out of the spec constant that declares it, with a control that throws instead of reporting a comfortable zero when the parse finds nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW --- .../reserved-identity-name-position-guard.md | 16 + .../measure-reserved-identity-name-census.mjs | 398 ++++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100644 .changeset/reserved-identity-name-position-guard.md create mode 100644 scripts/measure-reserved-identity-name-census.mjs diff --git a/.changeset/reserved-identity-name-position-guard.md b/.changeset/reserved-identity-name-position-guard.md new file mode 100644 index 0000000000..22a112206f --- /dev/null +++ b/.changeset/reserved-identity-name-position-guard.md @@ -0,0 +1,16 @@ +--- +"@objectstack/plugin-security": minor +--- + +feat(plugin-security): a position row can no longer spell an ADR-0068 built-in identity name (#15972) + +`sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name — `platform_admin`, `org_owner`, `org_admin`, `org_member`. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant: an out-of-repo consumer that reads the NAME instead of the capability rung reopens the hole with nothing mechanical to catch it. + +Both declarations now carry an object-level `validations[]` rule whose CEL list literal is **generated** from `BUILTIN_IDENTITY_NAMES`, the `@objectstack/spec` constant that declares the identities. The set is a closed enumeration — imported, never retyped, and never widened to an `org_*` pattern, so an ordinary tenant position named `org_manager` still writes. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by one refusal carrying one code (`VALIDATION_FAILED`). + +Two doors, two shapes, for a reason: + +- **`sys_position`** exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling). `bootstrapBuiltinRoles` seeds exactly these four names per organization on purpose, and that catalog is unaffected. A `package`- or tenant-authored row is refused. +- **`sys_user_position`** takes **no** exemption. No writer in any package creates an assignment row spelling a built-in identity name — `platform_admin` standing comes from the unscoped `admin_full_access` grant, the `org_*` trio from `sys_member.role` — so every such row is a name pretending to be an identity. + +Existing rows are not migrated and nothing rewrites them (maintainer ruling: refuse new writes only). The rule is an INVARIANT, so a row that already spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. `scripts/measure-reserved-identity-name-census.mjs` is the read-only census that reports such rows from an operator-supplied export. diff --git a/scripts/measure-reserved-identity-name-census.mjs b/scripts/measure-reserved-identity-name-census.mjs new file mode 100644 index 0000000000..d76203c2fd --- /dev/null +++ b/scripts/measure-reserved-identity-name-census.mjs @@ -0,0 +1,398 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// measure-reserved-identity-name-census -- the #15972 census instrument. +// +// node scripts/measure-reserved-identity-name-census.mjs # declarations +// node scripts/measure-reserved-identity-name-census.mjs --json # machine +// node scripts/measure-reserved-identity-name-census.mjs --self-test # controls only +// node scripts/measure-reserved-identity-name-census.mjs --rows FILE # a deployment's rows +// node scripts/measure-reserved-identity-name-census.mjs --rows-schema # the export shape +// +// READ-ONLY, and that is the ruling, not a preference. Maintainer ruling +// (director seat, summon #20, decision batch #105 item 4, 2026-09-09) on what +// happens to rows that ALREADY collide, verbatim: +// +// 「refuse new writes only. No migration. A read-only census reports existing +// colliding rows to the maintainer; nothing rewrites them (option B +// refused: stored-data migration is the manual floor's own item).」 +// +// So this script opens no database connection, takes no credentials, and its +// `--rows` mode consumes a FILE the operator exported. There is no code path +// here that writes anything anywhere. It is NOT a gate: not wired into any +// workflow, exits 0 on any collision count, and deliberately not named +// `check:*` / `gen:*` so the #4203 script ledger has nothing to classify -- +// the shape `measure-position-name-fold-census.mjs` established. +// +// The only non-zero exits are a failing self-test and an unreadable/malformed +// `--rows` input. +// +// ## What collides, and why the two populations are reported separately +// +// ADR-0068 D2 reserves four names for the framework's built-in identities. +// Since #15972 the write path refuses them on both position doors, so the +// populations below can only be rows that PREDATE the guard: +// +// - `sys_position` rows whose `name` spells a reserved name while their +// `managed_by` is NOT the platform's own provenance. The platform seeds +// these four names per organization on purpose (`bootstrapBuiltinRoles`, +// `managed_by: 'platform'`); those rows are the catalog and are NOT +// collisions. A row with any other provenance is a tenant or package +// definition standing on a reserved name. +// - `sys_user_position` rows whose `position` spells a reserved name. ⚠️ ALL +// of them are collisions -- no writer in any package creates one, so there +// is no legitimate population to subtract. This is the row the card is +// about: it is what makes a plain member LOOK like a built-in identity to +// any reader that reads the name instead of the capability rung. +// +// ## The reserved set is READ from the spec constant, never retyped +// +// The ruling closes the enumeration: 「exactly the ADR-0068 built-in identity +// names, read from the spec constant that declares them (closed enumeration, +// ⛔ not retyped, ⛔ not widened to `org_*` shapes by pattern)」. A census that +// carried its own copy of the four strings would answer a different question +// from the guard it audits the day the constant moves, so the names are parsed +// out of `packages/spec/src/identity/eval-user.zod.ts`. +// +// ⚠️ A parse that silently found nothing would report a comfortable ZERO over +// every population. So the parse has a CONTROL that must fire ({@link +// readReservedNames} throws when it does not), and `--self-test` asserts it. +// +// ## What this instrument CANNOT see, stated up front +// +// The declaration census reads THIS REPOSITORY. Positions and assignments are +// RUNTIME rows: an operator who created a position in Setup, or an integration +// that wrote an assignment row, produces a collision no static census can ever +// see. That population is reachable only through `--rows`, which is why +// `--rows` refuses to call an empty input "zero" -- and why a zero from the +// default mode prints the sentence saying so, every time. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..'); +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.turbo', 'coverage', '.cache', '.next']); +const SPEC_DECL = 'packages/spec/src/identity/eval-user.zod.ts'; + +/** Provenance values that mean "the platform's own catalog row" (A4 #2920 plus its legacy spelling). */ +const PLATFORM_PROVENANCE = new Set(['platform', 'system']); + +/* ------------------------------------------------------------------------- * + * The reserved set — parsed out of the declaring constant + * ------------------------------------------------------------------------- */ + +/** + * The four ADR-0068 D2 names, read from the `BUILTIN_IDENTITY_NAMES` array in + * the spec and resolved through the `BUILTIN_IDENTITY_*` constants it lists. + * + * Throws rather than returning `[]`: a census that cannot find its own subject + * must say so, because the alternative is a zero that reads like an all-clear. + */ +export function readReservedNames(source = read(SPEC_DECL)) { + const consts = new Map(); + const constRe = /export const (BUILTIN_IDENTITY_[A-Z0-9_]+)\s*=\s*'([^']*)'/g; + let m; + while ((m = constRe.exec(source)) !== null) consts.set(m[1], m[2]); + + const arrayRe = /export const BUILTIN_IDENTITY_NAMES\s*=\s*\[([^\]]*)\]/; + const arr = arrayRe.exec(source); + if (!arr) throw new Error(`census control FAILED: no BUILTIN_IDENTITY_NAMES array in ${SPEC_DECL}`); + + const names = []; + for (const raw of arr[1].split(',')) { + const ident = raw.trim(); + if (!ident) continue; + const literal = /^'([^']*)'$/.exec(ident); + if (literal) { names.push(literal[1]); continue; } + if (!consts.has(ident)) { + throw new Error(`census control FAILED: ${ident} is listed in BUILTIN_IDENTITY_NAMES but never declared in ${SPEC_DECL}`); + } + names.push(consts.get(ident)); + } + if (names.length === 0) throw new Error(`census control FAILED: BUILTIN_IDENTITY_NAMES parsed empty in ${SPEC_DECL}`); + return names; +} + +/* ------------------------------------------------------------------------- * + * Corpus scan (declarations) + * ------------------------------------------------------------------------- */ + +function read(rel) { + try { return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { return ''; } +} + +function walk(root, exts, out = []) { + let entries; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return out; } + for (const e of entries) { + if (e.name.startsWith('.')) continue; + const full = path.join(root, e.name); + if (e.isDirectory()) { + if (SKIP_DIRS.has(e.name)) continue; + walk(full, exts, out); + } else if (exts.has(path.extname(e.name))) { + out.push(path.relative(REPO_ROOT, full)); + } + } + return out; +} + +/** + * Every `name: ''` / `position: ''` STRING LITERAL in a + * declaration under `packages/`, `examples/` or `apps/`. + * + * Textual, and bounded on purpose (the sibling census records the same + * reasoning): the failure mode being hunted is an authored literal, and the + * price of a source scan is that it sees only the spellings it knows. It does + * NOT see a name assembled at runtime, and it does not know which key belongs + * to a position declaration versus something else that happens to have a + * `name:` -- so its hits are CANDIDATES a reader adjudicates, never verdicts. + */ +export function scanDeclarations(reserved, files) { + const hits = []; + const alternation = reserved.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + const re = new RegExp(`\\b(name|position)\\s*:\\s*'(${alternation})'`, 'g'); + for (const rel of files) { + const src = read(rel); + if (!src) continue; + let m; + re.lastIndex = 0; + while ((m = re.exec(src)) !== null) { + hits.push({ file: rel, line: src.slice(0, m.index).split('\n').length, key: m[1], name: m[2] }); + } + } + return hits; +} + +/* ------------------------------------------------------------------------- * + * Deployment rows (`--rows`) + * ------------------------------------------------------------------------- */ + +const ROWS_SCHEMA = ` +--rows FILE expects JSON exported READ-ONLY from the deployment: + + { + "deployment": "", + "sys_position": [ { "id": …, "name": …, "managed_by": …, "organization_id": … }, … ], + "sys_user_position": [ { "id": …, "user_id": …, "position": …, "organization_id": … }, … ] + } + +Both arrays are REQUIRED (an absent one is refused, not read as empty — see +below). Export them with reads only, e.g. against the data API: + + GET /api/v1/data/sys_position?fields=id,name,managed_by,organization_id + GET /api/v1/data/sys_user_position?fields=id,user_id,position,organization_id + +⚠️ An EMPTY array is accepted and reported as zero FOR THAT TABLE; a MISSING +key is refused. The distinction is the whole point: "we exported it and there +were none" and "we never exported it" must not produce the same all-clear. +`.trim(); + +export function censusRows(reserved, payload) { + const problems = []; + for (const table of ['sys_position', 'sys_user_position']) { + if (!Array.isArray(payload?.[table])) problems.push(`'${table}' is missing or not an array`); + } + if (problems.length) { + const err = new Error(`--rows input is not a census: ${problems.join('; ')}`); + err.schema = ROWS_SCHEMA; + throw err; + } + const reservedSet = new Set(reserved); + + const positions = payload.sys_position + .filter((r) => reservedSet.has(String(r?.name ?? ''))) + .map((r) => ({ + id: r?.id ?? null, + name: String(r?.name), + managed_by: r?.managed_by ?? null, + organization_id: r?.organization_id ?? null, + // The platform's own catalog rows are not collisions — they ARE the + // built-in identity catalog, seeded per organization by design. + platformCatalog: PLATFORM_PROVENANCE.has(String(r?.managed_by ?? '')), + })); + + const assignments = payload.sys_user_position + .filter((r) => reservedSet.has(String(r?.position ?? ''))) + .map((r) => ({ + id: r?.id ?? null, + user_id: r?.user_id ?? null, + position: String(r?.position), + organization_id: r?.organization_id ?? null, + })); + + return { + deployment: payload.deployment ?? null, + scanned: { sys_position: payload.sys_position.length, sys_user_position: payload.sys_user_position.length }, + positionCollisions: positions.filter((p) => !p.platformCatalog), + platformCatalogRows: positions.filter((p) => p.platformCatalog), + assignmentCollisions: assignments, + }; +} + +/* ------------------------------------------------------------------------- * + * Self-test — the controls, so a zero can be trusted + * ------------------------------------------------------------------------- */ + +function selfTest() { + const failures = []; + const check = (label, fn) => { + try { fn(); } catch (e) { failures.push(`${label}: ${e.message}`); } + }; + const eq = (a, b, what) => { + const [x, y] = [JSON.stringify(a), JSON.stringify(b)]; + if (x !== y) throw new Error(`${what}: expected ${y}, got ${x}`); + }; + + // 1. The reserved set is found in the real declaration, and it is the four. + check('reserved set parses from the spec', () => { + const names = readReservedNames(); + eq(names.length, 4, 'reserved name count'); + for (const n of names) if (!/^[a-z][a-z0-9_]*$/.test(n)) throw new Error(`not a machine name: ${n}`); + }); + + // 2. THE CONTROL THAT MATTERS — a broken parse must THROW, never report zero. + check('a declaration with no array is refused, not read as empty', () => { + let threw = false; + try { readReservedNames("export const BUILTIN_IDENTITY_PLATFORM_ADMIN = 'platform_admin';"); } + catch { threw = true; } + if (!threw) throw new Error('a source with no BUILTIN_IDENTITY_NAMES array was accepted'); + }); + check('a name listed but never declared is refused', () => { + let threw = false; + try { readReservedNames('export const BUILTIN_IDENTITY_NAMES = [\n BUILTIN_IDENTITY_GHOST,\n] as const;'); } + catch { threw = true; } + if (!threw) throw new Error('an undeclared member was accepted'); + }); + + // 3. The declaration scanner FIRES on a positive control and stays silent on + // the near-misses the ruling refuses to widen to. + check('the declaration scanner fires, and does not widen by pattern', () => { + const reserved = ['platform_admin', 'org_admin']; + const tmp = path.join(REPO_ROOT, 'packages/spec/src/identity/eval-user.zod.ts'); + if (!fs.existsSync(tmp)) throw new Error('control corpus file is missing'); + const hits = scanDeclarations(reserved, [SPEC_DECL]); + // The spec file declares the metadata map keyed by the constants, not by + // literals, so this control is about the SCANNER, run over a synthetic + // corpus below rather than over that file's spelling. + const synthetic = scanSynthetic(reserved, [ + "const a = { name: 'platform_admin' };", + "const b = { position: 'org_admin' };", + "const c = { name: 'org_manager' };", // ⛔ must NOT match + "const d = { name: 'platform_admin_x' };", // ⛔ must NOT match + "const e = { label: 'platform_admin' };", // ⛔ wrong key + ]); + eq(synthetic.map((h) => h.name), ['platform_admin', 'org_admin'], 'synthetic scanner hits'); + if (!Array.isArray(hits)) throw new Error('scanner did not return an array'); + }); + + // 4. `--rows` separates the platform catalog from a real collision, and + // refuses an input that never exported a table. + check('rows census: catalog rows are not collisions, assignments always are', () => { + const out = censusRows(['platform_admin', 'org_admin'], { + sys_position: [ + { id: 'p1', name: 'platform_admin', managed_by: 'platform' }, + { id: 'p2', name: 'platform_admin', managed_by: 'system' }, + { id: 'p3', name: 'org_admin', managed_by: 'admin' }, + { id: 'p4', name: 'sales_manager', managed_by: 'admin' }, + ], + sys_user_position: [ + { id: 'a1', user_id: 'u1', position: 'platform_admin' }, + { id: 'a2', user_id: 'u2', position: 'sales_manager' }, + ], + }); + eq(out.positionCollisions.map((r) => r.id), ['p3'], 'position collisions'); + eq(out.platformCatalogRows.map((r) => r.id), ['p1', 'p2'], 'platform catalog rows'); + eq(out.assignmentCollisions.map((r) => r.id), ['a1'], 'assignment collisions'); + }); + check('rows census: a MISSING table is refused, an EMPTY one is zero', () => { + let threw = false; + try { censusRows(['platform_admin'], { sys_position: [] }); } catch { threw = true; } + if (!threw) throw new Error('an input missing sys_user_position was accepted'); + const out = censusRows(['platform_admin'], { sys_position: [], sys_user_position: [] }); + eq(out.assignmentCollisions.length, 0, 'empty export'); + }); + + if (failures.length) { + console.error('SELF-TEST FAILED'); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); + } + console.log(`self-test OK — ${5} controls, all firing`); +} + +/** Scanner over in-memory lines, for the self-test's synthetic corpus. */ +function scanSynthetic(reserved, lines) { + const alternation = reserved.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); + const re = new RegExp(`\\b(name|position)\\s*:\\s*'(${alternation})'`, 'g'); + const hits = []; + for (const line of lines) { + let m; + re.lastIndex = 0; + while ((m = re.exec(line)) !== null) hits.push({ key: m[1], name: m[2] }); + } + return hits; +} + +/* ------------------------------------------------------------------------- * + * CLI + * ------------------------------------------------------------------------- */ + +const ZERO_CAVEAT = + '⚠️ A zero here is a zero over DECLARATIONS in this repository, never over a deployment. ' + + 'Positions and assignments are runtime rows; use --rows with an export to census a live store.'; + +function main(argv) { + if (argv.includes('--self-test')) return selfTest(); + if (argv.includes('--rows-schema')) { console.log(ROWS_SCHEMA); return; } + + const json = argv.includes('--json'); + const reserved = readReservedNames(); + + const rowsAt = argv.indexOf('--rows'); + if (rowsAt !== -1) { + const file = argv[rowsAt + 1]; + if (!file) { console.error('--rows needs a FILE'); process.exit(2); } + let payload; + try { payload = JSON.parse(fs.readFileSync(file, 'utf8')); } + catch (e) { console.error(`--rows: cannot read ${file}: ${e.message}`); process.exit(2); } + let out; + try { out = censusRows(reserved, payload); } + catch (e) { console.error(e.message); if (e.schema) console.error(`\n${e.schema}`); process.exit(2); } + + if (json) { console.log(JSON.stringify({ mode: 'rows', reserved, ...out }, null, 2)); return; } + console.log(`# reserved identity-name census — deployment rows${out.deployment ? ` (${out.deployment})` : ''}`); + console.log(`reserved set: ${reserved.join(', ')}`); + console.log(`scanned: ${out.scanned.sys_position} sys_position, ${out.scanned.sys_user_position} sys_user_position`); + console.log(`\n## sys_position rows standing on a reserved name (excluding the platform catalog): ${out.positionCollisions.length}`); + for (const r of out.positionCollisions) { + console.log(` - ${r.id} name=${r.name} managed_by=${r.managed_by} organization=${r.organization_id}`); + } + console.log(`\n## sys_user_position rows spelling a reserved name — ALL are collisions: ${out.assignmentCollisions.length}`); + for (const r of out.assignmentCollisions) { + console.log(` - ${r.id} user=${r.user_id} position=${r.position} organization=${r.organization_id}`); + } + console.log(`\n(platform catalog rows seen and NOT counted: ${out.platformCatalogRows.length})`); + console.log('\n⛔ Nothing here is rewritten. Renaming or removing a listed row is a maintainer decision.'); + return; + } + + const files = [ + ...walk(path.join(REPO_ROOT, 'packages'), new Set(['.ts', '.tsx'])), + ...walk(path.join(REPO_ROOT, 'examples'), new Set(['.ts', '.tsx'])), + ...walk(path.join(REPO_ROOT, 'apps'), new Set(['.ts', '.tsx'])), + ].filter((f) => !f.includes('.test.') && !f.includes('.spec.')); + const hits = scanDeclarations(reserved, files); + + if (json) { console.log(JSON.stringify({ mode: 'declarations', reserved, scannedFiles: files.length, hits, caveat: ZERO_CAVEAT }, null, 2)); return; } + console.log('# reserved identity-name census — declarations in this repository'); + console.log(`reserved set: ${reserved.join(', ')} (read from ${SPEC_DECL})`); + console.log(`scanned: ${files.length} non-test source files`); + console.log(`\ncandidate declarations spelling a reserved name: ${hits.length}`); + for (const h of hits) console.log(` - ${h.file}:${h.line} ${h.key}: '${h.name}'`); + console.log(`\n${ZERO_CAVEAT}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) main(process.argv.slice(2)); From ce329d7ca00b5f7c5cb6b457108bf5c7727f514c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 14:08:44 +0000 Subject: [PATCH 3/5] fix(scripts,plugin-security): route the census entry guard through invoked-as, and make the gate double refuse combinators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gate findings on this branch's own diff: - `check:entry-guard` — the census script carried a hand-typed `import.meta.url === file://${process.argv[1]}` guard, which answers false through a symlink and silently does nothing. Routed through `scripts/invoked-as.mjs`'s `isEntrypoint`, like every other `scripts/` entry. - `check:where-matcher` — the DelegatedAdminGate test double read a `$and` / `$or` key as a field name instead of refusing it, the silently-wrong shape: every row would fail the lookup and the assertion would pass for a reason unrelated to what it measures. It now throws on any combinator it does not implement, matching the sibling double in `delegated-admin-gate.test.ts`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW --- .../src/objects/reserved-identity-names.test.ts | 5 +++++ scripts/measure-reserved-identity-name-census.mjs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts index efc80c49e0..656344bbaf 100644 --- a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts +++ b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts @@ -306,6 +306,11 @@ describe('#15972 the service write door funnels into the same single refusal', ( }; const matches = (row: any, where: any): boolean => Object.entries(where ?? {}).every(([k, v]) => { + // ⛔ REFUSE what this double does not implement. A `$and` / `$or` read + // as a field name is the silently-wrong shape: every row fails the + // lookup, the gate sees an empty result, and the assertion passes for + // a reason that has nothing to do with what it claims to measure. + if (k.startsWith('$')) throw new Error(`fake driver: unsupported combinator ${k}`); if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); return row[k] === v; }); diff --git a/scripts/measure-reserved-identity-name-census.mjs b/scripts/measure-reserved-identity-name-census.mjs index d76203c2fd..53143f425e 100644 --- a/scripts/measure-reserved-identity-name-census.mjs +++ b/scripts/measure-reserved-identity-name-census.mjs @@ -71,6 +71,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; + const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '..'); const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.turbo', 'coverage', '.cache', '.next']); @@ -395,4 +397,4 @@ function main(argv) { console.log(`\n${ZERO_CAVEAT}`); } -if (import.meta.url === `file://${process.argv[1]}`) main(process.argv.slice(2)); +if (isEntrypoint(import.meta.url)) main(process.argv.slice(2)); From 638d2b544f16d868563343aa3aa025f6b3f927c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 14:29:45 +0000 Subject: [PATCH 4/5] chore(plugin-security): regenerate the i18n bundles for the new validation messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node scripts/check-i18n-bundles.mjs --write`, nothing else in this commit. The two `validations[]` entries added on this branch carry an authored `message`, which the rule validator resolves through i18n at refusal time (`objects.._validations..message`), so the package's bundles were behind the schema — `check:i18n` reported `plugins/plugin-security: 7 bundle(s) drifted` on CI, which is the measurement this branch could not take locally until the gate's build prerequisite was cleared. Exactly the gate's designed output: `en` is rewritten from source (it is a copy, not a translation), and merge mode adds the new keys to the translated locales filled with the source text, which still needs translating. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW --- .../src/translations/en.objects.generated.ts | 10 ++++++++++ .../src/translations/es-ES.objects.generated.ts | 10 ++++++++++ .../src/translations/es-ES.source-hashes.generated.ts | 2 ++ .../src/translations/ja-JP.objects.generated.ts | 10 ++++++++++ .../src/translations/ja-JP.source-hashes.generated.ts | 2 ++ .../src/translations/zh-CN.objects.generated.ts | 10 ++++++++++ .../src/translations/zh-CN.source-hashes.generated.ts | 2 ++ 7 files changed, 46 insertions(+) diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index 2bbbae2c60..018f2123a1 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -102,6 +102,11 @@ export const enObjects: NonNullable = { } } } + }, + _validations: { + reserved_identity_name: { + message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." + } } }, sys_capability: { @@ -441,6 +446,11 @@ export const enObjects: NonNullable = { updated_at: { label: "Updated At" } + }, + _validations: { + reserved_identity_position: { + message: "'position' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." + } } } }; diff --git a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts index 34bed479e1..3485d07ba0 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.objects.generated.ts @@ -102,6 +102,11 @@ export const esESObjects: NonNullable = { } } } + }, + _validations: { + reserved_identity_name: { + message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." + } } }, sys_capability: { @@ -441,6 +446,11 @@ export const esESObjects: NonNullable = { updated_at: { label: "Actualizado el" } + }, + _validations: { + reserved_identity_position: { + message: "'position' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." + } } } }; diff --git a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts index 768fee7baf..859f9d0ddd 100644 --- a/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/es-ES.source-hashes.generated.ts @@ -29,4 +29,6 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", + "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", + "objects.sys_user_position._validations.reserved_identity_position.message": "f35df6c1ef1493b0", }; diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts index 499cd4a4e1..a9a05673a9 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.objects.generated.ts @@ -102,6 +102,11 @@ export const jaJPObjects: NonNullable = { } } } + }, + _validations: { + reserved_identity_name: { + message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." + } } }, sys_capability: { @@ -441,6 +446,11 @@ export const jaJPObjects: NonNullable = { updated_at: { label: "更新日時" } + }, + _validations: { + reserved_identity_position: { + message: "'position' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." + } } } }; diff --git a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts index 94c78ffffc..606307a04f 100644 --- a/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/ja-JP.source-hashes.generated.ts @@ -29,4 +29,6 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", + "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", + "objects.sys_user_position._validations.reserved_identity_position.message": "f35df6c1ef1493b0", }; diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index c61c4ff35c..deed8c1448 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -102,6 +102,11 @@ export const zhCNObjects: NonNullable = { } } } + }, + _validations: { + reserved_identity_name: { + message: "'name' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." + } } }, sys_capability: { @@ -441,6 +446,11 @@ export const zhCNObjects: NonNullable = { updated_at: { label: "更新时间" } + }, + _validations: { + reserved_identity_position: { + message: "'position' cannot spell a framework-reserved built-in identity name (platform_admin, org_owner, org_admin, org_member). These names are ADR-0068 built-in identities: the platform projects them into current_user.positions from their own sources of truth, and a row spelling one is not an assignment of that identity. Choose a different name." + } } } }; diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts index 33418b78bf..828abf2f58 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.source-hashes.generated.ts @@ -29,4 +29,6 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_permission_set.fields.drift_status.options.other": "3ddcfb750014f293", "objects.sys_permission_set.fields.drift_status.options.overlay_shadow": "7371472481b55b52", "objects.sys_permission_set.fields.drift_status.options.provenance_skip": "fdd5f01e69fa0245", + "objects.sys_position._validations.reserved_identity_name.message": "a23aa7c06745cc95", + "objects.sys_user_position._validations.reserved_identity_position.message": "f35df6c1ef1493b0", }; From 03b02318646e89e2447a1ba7f94ff608f5a78f0a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 15:43:26 +0000 Subject: [PATCH 5/5] chore(docs): re-measure the tenant-audit census corpus-scale figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node scripts/tenant-audit-census.mjs --write` — the script's own documented mode — plus the one prose figure that mirrors the generated count and sits outside the GENERATED block. ROOT CAUSE, and it is this branch's. `declaredObjects()` in `scripts/tenant-audit-census.mjs` walks every `*.object.ts` and counts EACH object literal carrying a snake_case `name:` string literal; it does not distinguish an object declaration from a nested one. The four `actions[]` names on `sys_position` (`activate_position`, `clone_position`, …) were already in the tally before this branch, so 298 was never a count of objects. The two `validations[]` rules added here are counted the same way, moving it to 300 — and a rule name cannot dodge it, since `packages/spec` requires it to be snake_case. Measured in ONE worktree with ONE `node_modules`, switching only HEAD: at `origin/main` (`ab56ea3a1`) the census reports 298 and `check-tenant-audit-census --self-test` exits 0; at this branch's head it reports 300 and the self-test exits 1. The gate itself is green either way — the corpus-scale figures are dated and explicitly NOT compared. What breaks is the self-test case that rewords the prose claim off the page: it builds the string to replace from the LIVE count, so it silently becomes a no-op once the page's tolerated drift becomes real, and the case then fails for the page rather than for the classifier it pins. That latent fragility is #17437 and is NOT closed by this commit. DISCLOSED: the block regenerates whole, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 -> 562 and `engine-shaped types recognised` 59 -> 58 — drift accumulated since the block was last measured at `9cefca9a3`. ⛔ The checker, its fixtures and its expectations are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW --- .changeset/reserved-identity-name-position-guard.md | 2 ++ content/docs/permissions/tenant-audit-census.mdx | 10 +++++----- .../2026-08-tenant-audit-write-call-sites.counts.md | 8 ++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.changeset/reserved-identity-name-position-guard.md b/.changeset/reserved-identity-name-position-guard.md index 22a112206f..1e2ca18ee3 100644 --- a/.changeset/reserved-identity-name-position-guard.md +++ b/.changeset/reserved-identity-name-position-guard.md @@ -14,3 +14,5 @@ Two doors, two shapes, for a reason: - **`sys_user_position`** takes **no** exemption. No writer in any package creates an assignment row spelling a built-in identity name — `platform_admin` standing comes from the unscoped `admin_full_access` grant, the `org_*` trio from `sys_member.role` — so every such row is a name pretending to be an identity. Existing rows are not migrated and nothing rewrites them (maintainer ruling: refuse new writes only). The rule is an INVARIANT, so a row that already spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. `scripts/measure-reserved-identity-name-census.mjs` is the read-only census that reports such rows from an operator-supplied export. + +Housekeeping this change drags along, disclosed because a reviewer should not have to discover it: a validation rule's `name` is snake_case by contract, and `scripts/tenant-audit-census.mjs` counts every snake_case `name:` literal in a `*.object.ts` as a "declared object" (it already counts the four `actions[]` names on `sys_position`, so that figure was never a count of objects). The two new rule names move it 298 → 300, so the census artefacts are regenerated with the script's own `--write`. That block regenerates **whole**, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 → 562 and `engine-shaped types recognised` 59 → 58 — which are drift accumulated since the block was last measured at `9cefca9a3`. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 484d541ffe..34614af68f 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -84,7 +84,7 @@ receiver that none of the three place is an error, never a default.** Tenancy itself is enabled *by default* — `isTenancyDisabled()` reads `tenancy.enabled === false` and nothing else — so the object registry only has to -find the opt-outs. Across 298 declared objects — the dated, ⛔ unenforced +find the opt-outs. Across 300 declared objects — the dated, ⛔ unenforced corpus-scale figure below — exactly two opt out (`sys_api_key`, `sys_sso_provider`), and no write call site on this surface targets either. @@ -224,13 +224,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-07 at `9cefca9a3`. +Measured on 2026-09-10 at `638d2b544`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 557 | -| engine-shaped types recognised | 59 | -| declared objects in the registry | 298 | +| tracked non-test sources scanned | 562 | +| engine-shaped types recognised | 58 | +| declared objects in the registry | 300 | | same-named calls subtracted as non-engine | 137 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index b2dacec989..d8c65d4c87 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -52,13 +52,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-09-07 at `9cefca9a3`. +Measured on 2026-09-10 at `638d2b544`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 557 | -| engine-shaped types recognised | 59 | -| declared objects in the registry | 298 | +| tracked non-test sources scanned | 562 | +| engine-shaped types recognised | 58 | +| declared objects in the registry | 300 | | same-named calls subtracted as non-engine | 137 | ## Every site