From c54a9f310f8d804a928c38f220e2f48a27700e07 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:42:51 +0000 Subject: [PATCH 1/9] feat(plugin-security): declare the six built-in positions as position metadata (wip) ADR-0131 C2 stage S2. The four identity names and the everyone/guest anchors are declared once (builtin-positions.ts), registered with the engine registry under the plugin's package id, and read by the built-in seeder; the declared-positions seeder unions its two sources and skips the six. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../src/bootstrap-builtin-positions.ts | 39 ++---- .../src/bootstrap-declared-positions.ts | 63 +++++++-- .../plugin-security/src/builtin-positions.ts | 131 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 22 +++ 4 files changed, 218 insertions(+), 37 deletions(-) create mode 100644 packages/plugins/plugin-security/src/builtin-positions.ts diff --git a/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts b/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts index 9577c167dd6..799ca5f8762 100644 --- a/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts @@ -32,9 +32,17 @@ * A `single`-posture deployment keeps exactly one organization-less pass. See * `per-organization-catalog.ts` for the doctrine and for the loud guard that * stands in place of a reap. + * + * ## The names come from the declarations + * + * The six are declared position metadata of this plugin (ADR-0131 D2; + * `builtin-positions.ts` holds the one list, which the plugin also registers + * with the engine registry). This seed reads that list — it has no list of its + * own — and writes the same rows it always has. `bootstrapDeclaredPositions` + * skips the six, so this remains their only writer. */ -import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA, EVERYONE_POSITION, GUEST_POSITION } from '@objectstack/spec'; +import { securityBuiltinPositions } from './builtin-positions.js'; import { createSeedWriteRefusals, resolveOwnOrganizationRow, @@ -46,25 +54,6 @@ import { type SeedWriteRefusals, } from './per-organization-catalog.js'; -/** - * [ADR-0090 D5/D9] Audience anchors seeded alongside the identity names. - * `everyone` — implicit for every authenticated member; its bindings are the - * tenant's default grants. `guest` — implicit for unauthenticated principals. - * Both are system-managed and undeletable like the identity rows. - */ -const AUDIENCE_ANCHOR_METADATA: Record = { - [EVERYONE_POSITION]: { - label: 'Everyone', - description: - 'Built-in audience anchor: every authenticated member holds this position implicitly. Permission sets bound to it are the default grants for the tenant (ADR-0090 D5). High-privilege sets cannot be bound here.', - }, - [GUEST_POSITION]: { - label: 'Guest', - description: - 'Built-in audience anchor: unauthenticated principals hold this position implicitly and exclusively. Bindings face the strictest checks — named objects only, read-mostly, never a wildcard (ADR-0090 D9).', - }, -}; - function genId(prefix: string): string { const rand = Math.random().toString(36).slice(2, 10); const ts = Date.now().toString(36); @@ -129,11 +118,11 @@ export async function bootstrapBuiltinRoles( const residue: string[] = []; // One log per pass, not per refused row (see the sibling seeders). const refusals = createSeedWriteRefusals(); - const rows: Array<[string, { label: string; description: string }]> = [ - ...BUILTIN_IDENTITY_NAMES.map((n) => [n, BUILTIN_IDENTITY_METADATA[n]] as [string, { label: string; description: string }]), - ...Object.entries(AUDIENCE_ANCHOR_METADATA), - ]; - for (const [name, meta] of rows) { + // [ADR-0131 C2 S2] The ONE list of the six — the same declarations the + // plugin registers as position metadata (`builtin-positions.ts`). This seed + // writes exactly what it wrote from its own code list before. + const rows = securityBuiltinPositions; + for (const { name, ...meta } of rows) { // [A4 #2920] Unified provenance vocab: built-in identity/anchor positions are // PLATFORM-shipped (formerly stamped 'system'). Re-upserted every boot, so // legacy 'system' rows self-heal to 'platform' on the next kernel:ready. diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts index 10e28753838..548951497d7 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -26,6 +26,7 @@ */ import { buildExistingByName } from './seed-name-lookup.js'; +import { isBuiltinPositionName } from './builtin-positions.js'; import { createSeedWriteRefusals, seedCtx, @@ -83,10 +84,8 @@ interface SeedOptions { } /** - * Read declared metadata items of a type. The engine's SchemaRegistry - * (populated by `manifest.register` from the stack's `positions`/`sharingRules` - * arrays) is the reliable source in every boot path; the metadata-service - * facade only surfaces these once the compiled-artifact loader runs (serve.ts). + * Read the items of a type the engine's SchemaRegistry holds — one of the two + * sources {@link readDeclaredPositions} unions. * * [#8378] No `{ name, content }` unwrap: the registered item IS the authoring * document. `PositionSchema` declares no `content` key and rejects one as @@ -103,6 +102,52 @@ function readDeclared(engine: any, type: string): any[] { return []; } +/** + * The positions this seeder projects into rows: every declared position the + * engine registry or the metadata service holds, one entry per name, MINUS the + * six built-ins. + * + * ## A union, not "the registry, else the metadata service" + * + * Neither source holds every declared position. The metadata service holds the + * stack-declared ones an app registers in memory; the engine registry holds the + * built-ins this plugin declares (`builtin-positions.ts`) and every position a + * metadata author saved through the metadata door. This read used to take the + * registry ALONE whenever it held any position, and consult the metadata + * service only when the registry was empty — so the first position to reach + * the registry silenced every stack-declared one. Measured at objectstack + * `51290bca2c`, walled showcase: after one `PUT /api/v1/meta/position/`, + * an organization created next was seeded the six built-ins and the + * door-authored position, and none of the stack's ten. A name both sources + * hold takes the registry's body, the order the security catalog read + * (`createSecurityCatalogReader`) answers in. + * + * ## Why the six built-ins are skipped + * + * They are declared metadata now, so they are among the registry's positions — + * but their rows are `bootstrapBuiltinRoles`'s, seeded from the same list with + * the `platform` provenance this seeder never writes. Taking them here would + * insert a copy without that provenance ahead of the built-in pass on a fresh + * organization (refused outright for a reserved identity name), which the + * built-in pass then restamps: a second writer for six rows that have one. + */ +async function readDeclaredPositions(engine: any, metadataService: any): Promise { + const registered = readDeclared(engine, 'position'); + let listed: any[] = []; + try { + const result = metadataService?.list?.('position'); + const resolved = typeof (result as any)?.then === 'function' ? await result : result; + listed = Array.isArray(resolved) ? resolved : []; + } catch { listed = []; } + const byName = new Map(); + for (const item of [...registered, ...listed]) { + const name = item?.name; + if (typeof name !== 'string' || name === '' || byName.has(name)) continue; + byName.set(name, item); + } + return [...byName.values()].filter((item) => !isBuiltinPositionName(item.name)); +} + /** * The columns a re-seed writes. Position IDENTITY + display only: the record * side (bindings, `active`, `is_default`, `delegatable`, `managed_by`) belongs @@ -126,14 +171,8 @@ export async function bootstrapDeclaredPositions( if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') { return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }; } - let positions: any[] = readDeclared(ql, 'position'); - if (positions.length === 0) { - try { - const listed = metadataService?.list?.('position'); - positions = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); - } catch { positions = []; } - } - if (!Array.isArray(positions) || positions.length === 0) return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }; + const positions = await readDeclaredPositions(ql, metadataService); + if (positions.length === 0) return { seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }; // [#10946] ONE existence read for the whole declaration, before the loop. // See `seed-name-lookup.ts` for why a read that cannot ANSWER must never be diff --git a/packages/plugins/plugin-security/src/builtin-positions.ts b/packages/plugins/plugin-security/src/builtin-positions.ts new file mode 100644 index 00000000000..91d5081e4ee --- /dev/null +++ b/packages/plugins/plugin-security/src/builtin-positions.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The platform's built-in positions — the four identity names (ADR-0068 D2) and + * the two audience anchors (ADR-0090 D5/D9) — declared ONCE, as position + * metadata this plugin owns. + * + * ## One list, two consumers + * + * {@link securityBuiltinPositions} is the only place the six are listed: + * + * 1. `SecurityPlugin.init` declares them to the engine registry + * ({@link registerBuiltinPositions}), under this plugin's package id, so + * the security catalog read (`createSecurityCatalogReader`, + * `@objectstack/core`) and the metadata door (`GET /api/v1/meta/position`) + * list them beside every other declared position (ADR-0131 D2: built-ins + * and audience anchors are declared metadata — the C3 slice the C2 + * execution card pulls forward). + * 2. `bootstrapBuiltinRoles` seeds their `sys_position` rows from this same + * list, exactly as it seeded them from its own code list before: the same + * names, the same organizations, `managed_by: 'platform'`, `active: true`, + * `is_default: false`. + * + * And one exclusion: `bootstrapDeclaredPositions` skips every name here + * ({@link isBuiltinPositionName}). The six are declared now, so they appear + * among the declared positions; their rows belong to `bootstrapBuiltinRoles`, + * and a second seeder taking them would write a copy without the `platform` + * provenance before the built-in pass restamps it. + * + * ## Why the engine registry, and not the manifest's `positions` key + * + * The manifest registration carries `permissions` into the engine registry, + * but a manifest `positions` key reaches no reader: the engine's stack-collection + * loop has no `positions` entry (a waived row in `check:stack-collection-maps`), + * and `ManifestSchema` declares no such key. Measured on a booted showcase in + * three postures: a `positions` list on this plugin's manifest left the engine + * registry, the metadata service, the metadata door and the catalog read + * without a single one of its names. So the declarations go to the registry by + * the registry's own seam (`registerItem`), with this plugin as the owning + * package — the provenance the manifest stamps on the platform's permission + * sets. + * + * ## What a declaration here does NOT carry + * + * Only identity and display: `name`, `label`, `description` — what + * `PositionSchema` declares and what the rows have always been stamped with. + * Activation (`active`), the default flag (`is_default`) and provenance + * (`managed_by`) stay on the catalog ROW; the six bind no permission set in + * their definition (the binding stays on its junction rows until the position + * definition can carry it). The sources of truth behind the identity names — + * `sys_member.role` for the `org_*` trio, the unscoped `admin_full_access` + * grant for `platform_admin` — are untouched: these remain a projection. + */ + +import { + AUDIENCE_ANCHOR_POSITIONS, + BUILTIN_IDENTITY_METADATA, + BUILTIN_IDENTITY_NAMES, + EVERYONE_POSITION, + GUEST_POSITION, +} from '@objectstack/spec'; + +/** One built-in position declaration: identity and display only (module doc). */ +export interface BuiltinPositionDeclaration { + readonly name: string; + readonly label: string; + readonly description: string; +} + +/** + * [ADR-0090 D5/D9] The display text of the two audience anchors. `everyone` — + * implicit for every authenticated member; its bindings are the tenant's + * default grants. `guest` — implicit for unauthenticated principals. Both are + * system-managed and undeletable like the identity rows. + */ +const AUDIENCE_ANCHOR_METADATA: Readonly> = { + [EVERYONE_POSITION]: { + label: 'Everyone', + description: + 'Built-in audience anchor: every authenticated member holds this position implicitly. Permission sets bound to it are the default grants for the tenant (ADR-0090 D5). High-privilege sets cannot be bound here.', + }, + [GUEST_POSITION]: { + label: 'Guest', + description: + 'Built-in audience anchor: unauthenticated principals hold this position implicitly and exclusively. Bindings face the strictest checks — named objects only, read-mostly, never a wildcard (ADR-0090 D9).', + }, +}; + +/** + * The six built-in positions, identity names first, then the audience anchors — + * the order the built-in seeder has always written them in. + */ +export const securityBuiltinPositions: readonly BuiltinPositionDeclaration[] = Object.freeze([ + ...BUILTIN_IDENTITY_NAMES.map((name) => Object.freeze({ name, ...BUILTIN_IDENTITY_METADATA[name] })), + ...AUDIENCE_ANCHOR_POSITIONS.map((name) => Object.freeze({ name, ...AUDIENCE_ANCHOR_METADATA[name] })), +]); + +const BUILTIN_POSITION_NAMES: ReadonlySet = new Set(securityBuiltinPositions.map((p) => p.name)); + +/** Is `name` one of the six built-in positions this plugin declares? Exact match. */ +export function isBuiltinPositionName(name: unknown): boolean { + return typeof name === 'string' && BUILTIN_POSITION_NAMES.has(name); +} + +/** + * The engine registry's registration seam, as this module uses it — ObjectQL's + * `SchemaRegistry` satisfies it as it stands. Not on the engine's published + * registry view, which is read-only; the manifest registration calls the same + * method for the platform's permission sets. + */ +export interface BuiltinPositionRegistry { + registerItem(type: string, item: Record, keyField: string, packageId?: string): void; +} + +/** + * Declare the six built-in positions to the engine registry, owned by + * `packageId` (the plugin passes its own `SECURITY_PLUGIN_ID`). Each + * registration gets a fresh copy: the registry stamps provenance onto the + * object it is handed, and the list above is shared. + * + * @returns how many were registered — 0 when `registry` cannot register + * anything, which the caller reports (absence is loud). + */ +export function registerBuiltinPositions(registry: unknown, packageId: string): number { + const target = registry as Partial | null | undefined; + if (!target || typeof target.registerItem !== 'function') return 0; + for (const declaration of securityBuiltinPositions) { + target.registerItem('position', { ...declaration }, 'name', packageId); + } + return securityBuiltinPositions.length; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 3fb0c694a6b..f5021001fe6 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -169,7 +169,9 @@ import { securityObjects, securityDefaultPermissionSets, securityPluginManifestHeader, + SECURITY_PLUGIN_ID, } from './manifest.js'; +import { registerBuiltinPositions } from './builtin-positions.js'; /** * [ADR-0095 D3 / Finding 2 / #2937] Platform-admin-EXCLUSIVE capabilities — the @@ -1622,6 +1624,26 @@ export class SecurityPlugin implements Plugin { this.metadata = metadata; this.ql = ql; + // [ADR-0131 D2, C3's built-ins slice pulled into C2] The six built-in + // positions — the identity names and the `everyone` / `guest` anchors — are + // declared metadata of THIS plugin, as its permission sets are. Not through + // the manifest registration in `init()`: measured, a manifest `positions` + // key reaches no registry (see `builtin-positions.ts`), so they go to the + // engine registry by the registry's own seam, under this plugin's package + // id — the provenance the manifest stamps on `permissions`. Here, on the + // engine handle `start()` already holds, before `kernel:ready` runs the + // seeders and before any request reaches the metadata door. Rows keep + // seeding from the same list (`bootstrapBuiltinRoles`); nothing here writes + // a row or moves a grant. + if (registerBuiltinPositions((ql as { registry?: unknown }).registry, SECURITY_PLUGIN_ID) === 0) { + ctx.logger.warn( + '[security] the built-in positions (platform_admin, org_owner, org_admin, org_member, everyone, guest) ' + + 'were NOT declared as position metadata: the ObjectQL engine exposes no registry that can register ' + + 'them. Their sys_position rows still seed and no grant changes, but the security catalog read and ' + + 'GET /api/v1/meta/position will not list them.', + ); + } + // [#18682] Answer the engine's create/update gate question for `validate()`. // // `validate()` runs no middleware for its target object, so without this the From 76ad3b8410604686b7723c6097c39cf6aa432cfa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:56:49 +0000 Subject: [PATCH 2/9] test(plugin-security): grant-equivalence golden for the built-in positions stage Four principals in two postures, through the plugin's real kernel:ready bootstrap, held to a golden recorded against the pre-change sources. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- ...uiltin-positions.grant-equivalence.test.ts | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 packages/plugins/plugin-security/src/builtin-positions.grant-equivalence.test.ts diff --git a/packages/plugins/plugin-security/src/builtin-positions.grant-equivalence.test.ts b/packages/plugins/plugin-security/src/builtin-positions.grant-equivalence.test.ts new file mode 100644 index 00000000000..b1187ca69e9 --- /dev/null +++ b/packages/plugins/plugin-security/src/builtin-positions.grant-equivalence.test.ts @@ -0,0 +1,354 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D2, C2 stage S2] Grant equivalence: declaring the six built-in + * positions as position metadata changes no principal's grants. + * + * The stage moves where the six come from — one declaration list, registered + * with the engine registry and read by the built-in seeder — and changes what + * the declared-positions seeder reads. No resolver reads a definition yet, so + * the only ways a grant could move are a catalog row that is no longer seeded, + * seeded differently, or seeded twice: above all `everyone`, which carries every + * authenticated principal's baseline through its binding. So unlike a + * resolver-only harness, this one runs the plugin's REAL `kernel:ready` + * bootstrap (catalog seeding and the baseline binding to `everyone` included) + * before it resolves anyone, and the catalog holds a stack-declared position + * beside the six. + * + * The four principals, resolved by the REAL resolver in two postures, against + * a golden recorded from the tree BEFORE the declarations existed (the PR record + * carries that run): + * + * - **platform administrator** — `single`: the first authenticable user, + * promoted by the bootstrap; walled: the declared `OS_PLATFORM_OWNER_EMAIL` + * owner (configuration, no row); + * - **organization administrator** — the owner membership, granted by + * `reconcileOrgAdminGrant`; + * - **member** — a member granted a set through the data door by an + * administrator's non-system write; + * - **agent** — an OAuth agent acting for the organization administrator with + * the actions consent (`assembleExecutionContext`, ADR-0090 D10). + * + * What is compared is the resolver's whole envelope per principal, arrays + * sorted so a reorder is not read as a change. `member_default` reaching every + * human principal is the `everyone` binding at work. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + assembleExecutionContext, + resetPlatformAdminEmailMemo, + resolveUserAuthzGrants, +} from '@objectstack/core'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; + +import { SecurityPlugin } from './security-plugin.js'; +import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; +import { securityObjects } from './manifest.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const ORG = 'org_eq'; + +/** The non-system administrator whose writes are the data door's (superuser wildcard). */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +/** A stack-declared position, as an app registers it with the metadata service. */ +const DECLARED_POSITIONS = [{ name: 'field_rep', label: 'Field Rep', description: 'Works the territory' }]; + +/** + * The app's own default set — the deployment's declared baseline, bound to + * `everyone` by the boot. Owned by a package, so the walled boot materializes + * the organization's copy of it and binds THAT, the way an installed app's + * default set reaches every organization's `everyone`. + */ +const APP_DEFAULT = { + name: 'field_default', + label: 'Field Default', + isDefault: true, + objects: { sys_user: { allowRead: true } }, + _packageId: 'com.example.field', +} as unknown as PermissionSet; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +type Posture = 'single' | 'isolated'; + +const sortDeep = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortDeep).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value as object).sort().map((k) => [k, sortDeep((value as any)[k])])); + } + return value; +}; + +async function grantsByPrincipal(posture: Posture): Promise> { + if (posture === 'isolated') { + process.env[POSTURE_ENV] = 'isolated'; + process.env[OWNER_ENV] = 'admin@eq.example'; + } + resetPlatformAdminEmailMemo(); + + 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.qa.builtin-position-equivalence', + name: 'Built-in position equivalence', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects], + } as any); + await engine.syncSchemas(); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async (type: string) => { + if (type === 'position') return DECLARED_POSITIONS.map((p) => ({ ...p })); + if (type === 'permission') return [...defaultPermissionSets, QA_ADMIN, APP_DEFAULT]; + return []; + }, + }, + ...(posture === 'isolated' + ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture: 'isolated' } } + : {}), + }; + const hooks = new Map unknown>>(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: (name: string, handler: (...args: unknown[]) => unknown) => { + hooks.set(name, [...(hooks.get(name) ?? []), handler]); + }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'field_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + // Everything the boot sweep reads exists BEFORE `kernel:ready`: the + // organization (the walled sweep seeds one catalog per organization) and + // the users (the `single` sweep promotes the first authenticable one). + const orgCtx = { isSystem: true, tenantId: ORG }; + await engine.insert('sys_organization', { id: ORG, name: 'Eq Org', slug: 'eq' }, { context: SYS } as any); + const user = async (id: string, email: string, createdAt: string) => { + await engine.insert('sys_user', { id, email, name: id, created_at: createdAt, email_verified: true }, { context: SYS } as any); + await engine.insert( + 'sys_account', { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, { context: SYS } as any, + ); + }; + await user('usr_admin', 'admin@eq.example', '2025-01-01T00:00:00.000Z'); + await user('usr_orgadmin', 'orgadmin@eq.example', '2025-02-01T00:00:00.000Z'); + await user('usr_member', 'member@eq.example', '2025-03-01T00:00:00.000Z'); + await engine.insert('sys_member', [ + { id: 'm_owner', user_id: 'usr_orgadmin', organization_id: ORG, role: 'owner', created_at: '2025-02-01T00:00:00.000Z' }, + { id: 'm_member', user_id: 'usr_member', organization_id: ORG, role: 'member', created_at: '2025-03-01T00:00:00.000Z' }, + ], { context: orgCtx } as any); + + // The plugin's own boot: seeders, the baseline binding to `everyone`, the + // organization-admin backfill — in registration order, as the kernel fires them. + for (const handler of hooks.get('kernel:ready') ?? []) await handler(); + + // PRECONDITION: the catalog the grants ride on was really seeded — the six, + // and the stack-declared position beside them. + const seededNames = ((await engine.find('sys_position', { where: {}, context: SYS } as any)) as any[]) + .map((r) => r.name).sort(); + expect(seededNames).toEqual( + ['everyone', 'field_rep', 'guest', 'org_admin', 'org_member', 'org_owner', 'platform_admin'], + ); + + // Walled: the organization's own copies of the sets its grants point at + // (the per-organization catalog), cloned from the platform bucket the boot + // just wrote — as the name-column equivalence suite does. + if (posture === 'isolated') { + for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { + const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS }); + const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; + await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); + } + } + // `single`: the boot's organization-admin backfill already granted it, so + // the reconcile finds nothing to do; walled: the organization's copy exists + // only now, so the reconcile grants. + const reconciled = await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG, { posture }); + expect(reconciled.action).toBe(posture === 'single' ? 'noop' : 'granted'); + + // The member's set, granted through the data door by an administrator. + const [viewer] = await engine.find('sys_permission_set', { + where: { name: 'viewer_readonly', ...(posture === 'isolated' ? { organization_id: ORG } : {}) }, + context: SYS, + }); + await engine.insert( + 'sys_user_permission_set', + { user_id: 'usr_member', permission_set_id: (viewer as any).id }, + { context: { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG } } as any, + ); + + const resolve = (userId: string) => resolveUserAuthzGrants(engine, userId, { tenantId: ORG }); + const admin = await resolve('usr_admin'); + const orgAdmin = await resolve('usr_orgadmin'); + const member = await resolve('usr_member'); + const agentCtx = assembleExecutionContext({ + authz: { ...orgAdmin, userId: 'usr_orgadmin', tenantId: ORG } as any, + oauth: { + userId: 'usr_orgadmin', + scopes: ['data:read', 'actions:execute'], + clientId: 'agent_client_eq', + scopePermissions: ['mcp_agent_data_read'], + delegatesActions: true, + } as any, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + }); + const agent = { + principalKind: (agentCtx as any)?.principalKind, + positions: (agentCtx as any)?.positions, + permissions: (agentCtx as any)?.permissions, + systemPermissions: (agentCtx as any)?.systemPermissions, + onBehalfOf: (agentCtx as any)?.onBehalfOf, + }; + return sortDeep({ platformAdmin: admin, organizationAdmin: orgAdmin, member, agent }) as Record; +} + +describe('[ADR-0131 D2] grant equivalence — the built-in positions as declared metadata change no principal’s grants', () => { + for (const posture of ['single', 'isolated'] as const) { + it(`${posture}: platform administrator, organization administrator, member and agent resolve to the recorded golden`, async () => { + const actual = await grantsByPrincipal(posture); + expect(actual).toEqual(GOLDEN[posture]); + }); + } +}); + +/** + * Recorded from the tree BEFORE the declarations existed (objectstack + * `51290bca2c`, this file run against that tree's sources), and unchanged + * after them. `field_default` on every human principal is the `everyone` + * binding; `single` also binds the platform's `member_default` (its + * organization-less catalog holds that set), while the walled organization's + * catalog holds only its own copy of the app's set. The organization + * administrator's set is the walled `organization_admin` against the wall-less + * `organization_admin_no_bypass` (ADR-0105 D4). + */ +const GOLDEN: Record = { + single: { + agent: { + onBehalfOf: { + principalKind: 'human', + userId: 'usr_orgadmin', + }, + permissions: ['mcp_agent_data_read'], + positions: [], + principalKind: 'agent', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + member: { + accessible_org_ids: ['org_eq'], + email: 'member@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['field_default', 'member_default', 'viewer_readonly'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + }, + organizationAdmin: { + accessible_org_ids: ['org_eq'], + email: 'orgadmin@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['field_default', 'member_default', 'organization_admin_no_bypass'], + positions: ['everyone', 'org_owner'], + posture: 'TENANT_ADMIN', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + platformAdmin: { + accessible_org_ids: [], + email: 'admin@eq.example', + org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], + permissions: ['admin_full_access', 'field_default', 'member_default'], + positions: ['everyone', 'platform_admin'], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', 'setup.access', + 'setup.write', 'studio.access', 'view_all_audit_log', + ], + }, + }, + isolated: { + agent: { + onBehalfOf: { + principalKind: 'human', + userId: 'usr_orgadmin', + }, + permissions: ['mcp_agent_data_read'], + positions: [], + principalKind: 'agent', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + member: { + accessible_org_ids: ['org_eq'], + email: 'member@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['field_default', 'viewer_readonly'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + }, + organizationAdmin: { + accessible_org_ids: ['org_eq'], + email: 'orgadmin@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['field_default', 'organization_admin'], + positions: ['everyone', 'org_owner'], + posture: 'TENANT_ADMIN', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + platformAdmin: { + accessible_org_ids: [], + email: 'admin@eq.example', + org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], + permissions: ['admin_full_access', 'field_default'], + positions: ['everyone', 'platform_admin'], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', 'setup.access', + 'setup.write', 'studio.access', 'view_all_audit_log', + ], + }, + }, +}; From 8b1aad3954c468991c2a507beba85ed23f90841f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 00:58:25 +0000 Subject: [PATCH 3/9] test(plugin-security): pin the built-in declaration list and the declared seeder's input Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../src/audience-anchors.test.ts | 10 +++ .../src/bootstrap-declared-positions.test.ts | 57 +++++++++++++++++ .../src/builtin-positions.test.ts | 63 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 packages/plugins/plugin-security/src/builtin-positions.test.ts diff --git a/packages/plugins/plugin-security/src/audience-anchors.test.ts b/packages/plugins/plugin-security/src/audience-anchors.test.ts index 94d41db7b07..9a4a8726a3c 100644 --- a/packages/plugins/plugin-security/src/audience-anchors.test.ts +++ b/packages/plugins/plugin-security/src/audience-anchors.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect } from 'vitest'; import { describeAnchorForbiddenBits } from '@objectstack/spec/security'; import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions'; +import { securityBuiltinPositions } from './builtin-positions'; import { describeHighPrivilegeBits } from './security-plugin'; function makeQl() { @@ -43,6 +44,15 @@ describe('audience anchors (ADR-0090 D5/D9)', () => { for (const r of ql.tables.sys_position) expect(r.managed_by).toBe('platform'); }); + // [ADR-0131 C2 S2] The seed reads the declarations — the same list the + // plugin registers as position metadata — and has no list of its own. + it('seeds exactly the declared built-in positions, with their declared label and description', async () => { + const ql = makeQl(); + await bootstrapBuiltinRoles(ql); + const seeded = ql.tables.sys_position.map((r: any) => ({ name: r.name, label: r.label, description: r.description })); + expect(seeded).toEqual(securityBuiltinPositions.map(({ name, label, description }) => ({ name, label, description }))); + }); + it('re-seed is idempotent (updates, no duplicates)', async () => { const ql = makeQl(); await bootstrapBuiltinRoles(ql); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts index 33c6e6f946b..f1b52354a24 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect } from 'vitest'; import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; +import { securityBuiltinPositions } from './builtin-positions.js'; /** Minimal in-memory ql for sys_position seeding. */ function makeQl(declared: any[] = []) { @@ -106,3 +107,59 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)', expect(ql.rows).toHaveLength(2); }); }); + +/** + * [ADR-0131 C2 S2] What the seeder reads: the engine registry AND the metadata + * service, one entry per name, minus the six built-in positions. + * + * The plugin declares the six to the engine registry, so the registry now + * holds positions on every boot. Two things must survive that: the + * stack-declared positions the metadata service holds still seed (a read that + * took the registry ALONE whenever it held anything dropped them all), and the + * six stay `bootstrapBuiltinRoles`'s rows — this seeder writes no copy of + * them and refreshes none of their columns. + */ +describe('bootstrapDeclaredPositions — its input is both sources, minus the built-ins', () => { + const builtins = () => securityBuiltinPositions.map((p) => ({ ...p })); + const metadataListing = (items: any[]) => ({ + list: async (type: string) => (type === 'position' ? items.map((i) => ({ ...i })) : []), + }); + + it('seeds the metadata service’s positions while the registry holds the built-ins and a door-authored one', async () => { + const ql = makeQl([...builtins(), { name: 'door_authored', label: 'Door Authored' }]); + const r = await bootstrapDeclaredPositions( + ql, + metadataListing([{ name: 'field_rep', label: 'Field Rep' }, { name: 'auditor', label: 'Auditor' }]), + ); + expect(ql.rows.map((row) => row.name).sort()).toEqual(['auditor', 'door_authored', 'field_rep']); + expect(r).toEqual({ seeded: 3, updated: 0, unchanged: 0, unreadable: 0 }); + }); + + it('a name both sources hold is seeded once, from the registry’s body', async () => { + const ql = makeQl([{ name: 'field_rep', label: 'Field Rep (registry)' }]); + await bootstrapDeclaredPositions(ql, metadataListing([{ name: 'field_rep', label: 'Field Rep (metadata)' }])); + expect(ql.rows.map((row) => [row.name, row.label])).toEqual([['field_rep', 'Field Rep (registry)']]); + }); + + it('writes nothing for the six on a fresh organization — no copy ahead of the built-in pass', async () => { + const ql = makeQl(builtins()); + const r = await bootstrapDeclaredPositions(ql, metadataListing(builtins()), { organizationId: 'org_a' }); + expect(ql.rows).toEqual([]); + expect(r).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }); + }); + + it('refreshes nothing on the six’s rows, whatever a declaration of the same name says', async () => { + // The rows as `bootstrapBuiltinRoles` leaves them… + const seeded = securityBuiltinPositions.map((p, i) => ({ + id: `pos_builtin_${i}`, name: p.name, label: p.label, description: p.description, + managed_by: 'platform', active: true, is_default: false, + })); + // …and a declaration of each name whose display text differs from them. + const drifted = securityBuiltinPositions.map((p) => ({ name: p.name, label: `${p.label} (redeclared)` })); + const ql = makeQl(drifted); + ql.rows.push(...seeded.map((row) => ({ ...row }))); + const r = await bootstrapDeclaredPositions(ql, metadataListing(drifted)); + expect(ql.rows).toEqual(seeded); + expect(r).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }); + }); +}); diff --git a/packages/plugins/plugin-security/src/builtin-positions.test.ts b/packages/plugins/plugin-security/src/builtin-positions.test.ts new file mode 100644 index 00000000000..eb681fe7222 --- /dev/null +++ b/packages/plugins/plugin-security/src/builtin-positions.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D2, C2 stage S2] The six built-in positions, declared once. + * + * The list is read off the spec constants that name the six (ADR-0068 D2's + * identity names, ADR-0090 D5/D9's audience anchors) — the expected values + * below come from those constants, never from the list under test — and + * registered with the engine registry under the owning package's id. + */ + +import { describe, it, expect } from 'vitest'; +import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_METADATA, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; +import { PositionSchema } from '@objectstack/spec/identity'; +import { isBuiltinPositionName, registerBuiltinPositions, securityBuiltinPositions } from './builtin-positions.js'; + +describe('securityBuiltinPositions', () => { + it('declares the four identity names, then the two audience anchors, and nothing else', () => { + expect(securityBuiltinPositions.map((p) => p.name)).toEqual([...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]); + }); + + it('carries the spec’s label and description for every identity name', () => { + for (const name of BUILTIN_IDENTITY_NAMES) { + const declared = securityBuiltinPositions.find((p) => p.name === name); + expect(declared).toEqual({ name, ...BUILTIN_IDENTITY_METADATA[name] }); + } + }); + + it('is a valid position definition, every one of the six', () => { + for (const declaration of securityBuiltinPositions) { + expect(PositionSchema.safeParse(declaration).success, declaration.name).toBe(true); + } + }); + + it('answers membership by exact name only', () => { + for (const name of [...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]) expect(isBuiltinPositionName(name)).toBe(true); + for (const name of ['Everyone', 'org_admins', 'field_rep', '', undefined, 42]) expect(isBuiltinPositionName(name)).toBe(false); + }); +}); + +describe('registerBuiltinPositions', () => { + it('registers each of the six as a `position`, keyed by name, under the owning package', () => { + const calls: Array<[string, Record, string, string | undefined]> = []; + const registry = { + registerItem(type: string, item: Record, keyField: string, packageId?: string) { + // The real registry stamps provenance onto the object it is handed. + item._packageId = packageId; + calls.push([type, item, keyField, packageId]); + }, + }; + expect(registerBuiltinPositions(registry, 'com.example.owner')).toBe(6); + expect(calls.map(([type, item, keyField, packageId]) => [type, item.name, keyField, packageId])).toEqual( + [...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS].map((name) => ['position', name, 'name', 'com.example.owner']), + ); + // Each registration got its own copy: the shared declarations carry no stamp. + for (const declaration of securityBuiltinPositions) expect(declaration).not.toHaveProperty('_packageId'); + }); + + it('registers nothing, and says so with 0, when there is no registration seam', () => { + expect(registerBuiltinPositions(undefined, 'com.example.owner')).toBe(0); + expect(registerBuiltinPositions({ listItems: () => [] }, 'com.example.owner')).toBe(0); + }); +}); From 489f7b1581093e58ffdc58bc1bb135d56153387e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:18:17 +0000 Subject: [PATCH 4/9] fix(plugin-security): the declared-positions seeder keeps its two-step, with the six built-ins out of its decision and its result Replaces the union read: the registry-first / metadata-service fallback is unchanged, and the six declared built-in positions neither count as the registry holding a position nor reach the seeding loop. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../src/bootstrap-declared-positions.test.ts | 41 +++++------ .../src/bootstrap-declared-positions.ts | 71 +++++++++---------- .../plugin-security/src/builtin-positions.ts | 32 +++++---- 3 files changed, 75 insertions(+), 69 deletions(-) diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts index f1b52354a24..c2460a0aa32 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts @@ -109,36 +109,37 @@ describe('bootstrapDeclaredPositions (#2909 T2 — seed-only semantics locked)', }); /** - * [ADR-0131 C2 S2] What the seeder reads: the engine registry AND the metadata - * service, one entry per name, minus the six built-in positions. + * [ADR-0131 C2 S2] What the seeder reads now that the six built-in positions + * are declared metadata: the same registry-first two-step as before, with the + * six taken out of both its decision and its result. * - * The plugin declares the six to the engine registry, so the registry now - * holds positions on every boot. Two things must survive that: the - * stack-declared positions the metadata service holds still seed (a read that - * took the registry ALONE whenever it held anything dropped them all), and the - * six stay `bootstrapBuiltinRoles`'s rows — this seeder writes no copy of - * them and refreshes none of their columns. + * The plugin registers the six with the engine registry on every boot. Two + * things must survive that: the stack-declared positions the metadata service + * holds still seed (six registered names must not make the registry "hold a + * position" and silence them), and the six stay `bootstrapBuiltinRoles`'s rows + * — this seeder writes no copy of them and refreshes none of their columns. */ -describe('bootstrapDeclaredPositions — its input is both sources, minus the built-ins', () => { +describe('bootstrapDeclaredPositions — the six built-in positions are not this seeder’s', () => { const builtins = () => securityBuiltinPositions.map((p) => ({ ...p })); const metadataListing = (items: any[]) => ({ list: async (type: string) => (type === 'position' ? items.map((i) => ({ ...i })) : []), }); - it('seeds the metadata service’s positions while the registry holds the built-ins and a door-authored one', async () => { - const ql = makeQl([...builtins(), { name: 'door_authored', label: 'Door Authored' }]); + it('reads the metadata service when the registry holds the six and nothing else', async () => { + const ql = makeQl(builtins()); const r = await bootstrapDeclaredPositions( ql, metadataListing([{ name: 'field_rep', label: 'Field Rep' }, { name: 'auditor', label: 'Auditor' }]), ); - expect(ql.rows.map((row) => row.name).sort()).toEqual(['auditor', 'door_authored', 'field_rep']); - expect(r).toEqual({ seeded: 3, updated: 0, unchanged: 0, unreadable: 0 }); + expect(ql.rows.map((row) => row.name).sort()).toEqual(['auditor', 'field_rep']); + expect(r).toEqual({ seeded: 2, updated: 0, unchanged: 0, unreadable: 0 }); }); - it('a name both sources hold is seeded once, from the registry’s body', async () => { - const ql = makeQl([{ name: 'field_rep', label: 'Field Rep (registry)' }]); - await bootstrapDeclaredPositions(ql, metadataListing([{ name: 'field_rep', label: 'Field Rep (metadata)' }])); - expect(ql.rows.map((row) => [row.name, row.label])).toEqual([['field_rep', 'Field Rep (registry)']]); + it('keeps the two-step otherwise: a registry holding another position answers alone, minus the six', async () => { + const ql = makeQl([...builtins(), { name: 'door_authored', label: 'Door Authored' }]); + const r = await bootstrapDeclaredPositions(ql, metadataListing([{ name: 'field_rep', label: 'Field Rep' }])); + expect(ql.rows.map((row) => row.name)).toEqual(['door_authored']); + expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 }); }); it('writes nothing for the six on a fresh organization — no copy ahead of the built-in pass', async () => { @@ -156,10 +157,10 @@ describe('bootstrapDeclaredPositions — its input is both sources, minus the bu })); // …and a declaration of each name whose display text differs from them. const drifted = securityBuiltinPositions.map((p) => ({ name: p.name, label: `${p.label} (redeclared)` })); - const ql = makeQl(drifted); + const ql = makeQl([...drifted, { name: 'door_authored', label: 'Door Authored' }]); ql.rows.push(...seeded.map((row) => ({ ...row }))); const r = await bootstrapDeclaredPositions(ql, metadataListing(drifted)); - expect(ql.rows).toEqual(seeded); - expect(r).toEqual({ seeded: 0, updated: 0, unchanged: 0, unreadable: 0 }); + expect(ql.rows.filter((row) => row.name !== 'door_authored')).toEqual(seeded); + expect(r).toEqual({ seeded: 1, updated: 0, unchanged: 0, unreadable: 0 }); }); }); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts index 548951497d7..b01a3963f7c 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -84,8 +84,7 @@ interface SeedOptions { } /** - * Read the items of a type the engine's SchemaRegistry holds — one of the two - * sources {@link readDeclaredPositions} unions. + * Read declared metadata items of a type from the engine's SchemaRegistry. * * [#8378] No `{ name, content }` unwrap: the registered item IS the authoring * document. `PositionSchema` declares no `content` key and rejects one as @@ -102,50 +101,48 @@ function readDeclared(engine: any, type: string): any[] { return []; } +/** A declared position this seeder owns: any name but the six built-ins. */ +function isSeededHere(item: any): boolean { + return !isBuiltinPositionName(item?.name); +} + /** - * The positions this seeder projects into rows: every declared position the - * engine registry or the metadata service holds, one entry per name, MINUS the - * six built-ins. + * The positions this seeder projects into rows: the engine registry's, else + * the metadata service's — the same two-step as before — with the six built-in + * positions taken out of both the decision and the result. * - * ## A union, not "the registry, else the metadata service" + * ## Why the six are taken out (ADR-0131 C2 stage S2) * - * Neither source holds every declared position. The metadata service holds the - * stack-declared ones an app registers in memory; the engine registry holds the - * built-ins this plugin declares (`builtin-positions.ts`) and every position a - * metadata author saved through the metadata door. This read used to take the - * registry ALONE whenever it held any position, and consult the metadata - * service only when the registry was empty — so the first position to reach - * the registry silenced every stack-declared one. Measured at objectstack - * `51290bca2c`, walled showcase: after one `PUT /api/v1/meta/position/`, - * an organization created next was seeded the six built-ins and the - * door-authored position, and none of the stack's ten. A name both sources - * hold takes the registry's body, the order the security catalog read - * (`createSecurityCatalogReader`) answers in. + * The six are declared position metadata of this plugin now + * (`builtin-positions.ts`), registered with the engine registry on every boot. + * Left in, they would change this read twice over: * - * ## Why the six built-ins are skipped + * - **the decision.** The registry is read first and the metadata service only + * when the registry holds no position, so six registered names would make + * the registry answer every boot — and the stack-declared positions, which + * only the metadata service holds, would never be read again. + * - **the result.** Their rows are `bootstrapBuiltinRoles`'s, seeded from the + * same list with the `platform` provenance this seeder never writes. Taking + * them here would put a copy without that provenance ahead of the built-in + * pass on a fresh organization (refused outright for a reserved identity + * name), which the built-in pass then restamps: a second writer for six rows + * that have one. * - * They are declared metadata now, so they are among the registry's positions — - * but their rows are `bootstrapBuiltinRoles`'s, seeded from the same list with - * the `platform` provenance this seeder never writes. Taking them here would - * insert a copy without that provenance ahead of the built-in pass on a fresh - * organization (refused outright for a reserved identity name), which the - * built-in pass then restamps: a second writer for six rows that have one. + * So the registry "holds a position" only when it holds one besides the six, + * and the six never reach the loop. With the six out, both the decision and the + * result are exactly what they were before the six were declared — that is the + * whole of this change; the two-step itself is not touched. */ async function readDeclaredPositions(engine: any, metadataService: any): Promise { const registered = readDeclared(engine, 'position'); - let listed: any[] = []; - try { - const result = metadataService?.list?.('position'); - const resolved = typeof (result as any)?.then === 'function' ? await result : result; - listed = Array.isArray(resolved) ? resolved : []; - } catch { listed = []; } - const byName = new Map(); - for (const item of [...registered, ...listed]) { - const name = item?.name; - if (typeof name !== 'string' || name === '' || byName.has(name)) continue; - byName.set(name, item); + let positions: unknown = registered; + if (!registered.some(isSeededHere)) { + try { + const listed = metadataService?.list?.('position'); + positions = typeof (listed as any)?.then === 'function' ? await listed : (listed ?? []); + } catch { positions = []; } } - return [...byName.values()].filter((item) => !isBuiltinPositionName(item.name)); + return Array.isArray(positions) ? positions.filter(isSeededHere) : []; } /** diff --git a/packages/plugins/plugin-security/src/builtin-positions.ts b/packages/plugins/plugin-security/src/builtin-positions.ts index 91d5081e4ee..74018460e5c 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.ts @@ -9,7 +9,7 @@ * * {@link securityBuiltinPositions} is the only place the six are listed: * - * 1. `SecurityPlugin.init` declares them to the engine registry + * 1. `SecurityPlugin.start` declares them to the engine registry * ({@link registerBuiltinPositions}), under this plugin's package id, so * the security catalog read (`createSecurityCatalogReader`, * `@objectstack/core`) and the metadata door (`GET /api/v1/meta/position`) @@ -22,23 +22,31 @@ * `is_default: false`. * * And one exclusion: `bootstrapDeclaredPositions` skips every name here - * ({@link isBuiltinPositionName}). The six are declared now, so they appear - * among the declared positions; their rows belong to `bootstrapBuiltinRoles`, - * and a second seeder taking them would write a copy without the `platform` - * provenance before the built-in pass restamps it. + * ({@link isBuiltinPositionName}), in its registry-first decision and in its + * result. The six are declared now, so they appear among the registry's + * positions; counted there, they would make the registry answer alone and + * silence the stack-declared positions only the metadata service holds, and + * taken there, they would get a copy without the `platform` provenance ahead + * of the built-in pass, which then restamps it. * * ## Why the engine registry, and not the manifest's `positions` key * * The manifest registration carries `permissions` into the engine registry, * but a manifest `positions` key reaches no reader: the engine's stack-collection * loop has no `positions` entry (a waived row in `check:stack-collection-maps`), - * and `ManifestSchema` declares no such key. Measured on a booted showcase in - * three postures: a `positions` list on this plugin's manifest left the engine - * registry, the metadata service, the metadata door and the catalog read - * without a single one of its names. So the declarations go to the registry by - * the registry's own seam (`registerItem`), with this plugin as the owning - * package — the provenance the manifest stamps on the platform's permission - * sets. + * and `ManifestSchema` declares no such key. Measured: `registerApp` with a + * `positions` and a `permissions` list registers the permission set under the + * package's id and no position at all; on a booted showcase a `positions` list + * on this plugin's manifest left the engine registry, the metadata service, the + * metadata door and the catalog read without a single one of its names. So the + * declarations go to the registry by the registry's own seam (`registerItem`), + * with this plugin as the owning package — the provenance the manifest stamps + * on the platform's permission sets. + * + * That provenance is also what keeps the six un-repurposable at the metadata + * door: a packaged item of a type with no organization overlay (`position`) is + * refused an in-place `PUT /api/v1/meta/position/:name` there, as the + * platform's packaged permission sets are. * * ## What a declaration here does NOT carry * From 1b3717499330654a114652afc4d7699b07d7d13c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:22:42 +0000 Subject: [PATCH 5/9] test(plugin-security): one real-boot harness for the built-in positions stage (goldens pending) Catalog listing, sys_position row census and write ledger, grant envelopes and tenant position-write refusals, over four boot scenarios. Replaces the grant-equivalence file. Goldens are recorded from the pre-declaration tree in the next commit. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../src/builtin-positions.boot.test.ts | 443 ++++++++++++++++++ ...uiltin-positions.grant-equivalence.test.ts | 354 -------------- 2 files changed, 443 insertions(+), 354 deletions(-) create mode 100644 packages/plugins/plugin-security/src/builtin-positions.boot.test.ts delete mode 100644 packages/plugins/plugin-security/src/builtin-positions.grant-equivalence.test.ts diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts new file mode 100644 index 00000000000..b5f881b4a1f --- /dev/null +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -0,0 +1,443 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D2, C2 stage S2] The six built-in positions as declared metadata, + * read off a REAL boot of this plugin: what the catalog read lists, what the + * boot writes into `sys_position`, what every principal is granted, and which + * position writes are still refused. + * + * The stage moves where the six come from — one declaration list, registered + * with the engine registry and read by the built-in seeder — and keeps the + * declared-positions seeder off them. No resolver reads a definition yet, so + * the only ways a grant could move are a catalog row that is no longer seeded, + * seeded differently, or seeded twice: above all `everyone`, which carries + * every authenticated principal's baseline through its binding. So this boot + * is the plugin's own — `init`, `start`, then its `kernel:ready` handlers in + * registration order (catalog seeding and the baseline binding to `everyone` + * included) — over a real `ObjectQL` engine on SQLite, with a stack-declared + * position in the metadata service beside the six. + * + * ## The scenarios + * + * - `single` — the organization-less pass, no organization at boot; + * - `single + organization` — the same pass with an organization and its + * memberships present (the shape a `single` deployment with the Default + * Organization has); + * - `walled` — one pass per organization at boot, then one more organization + * created after it (the organization-creation path); + * - `walled, a door-authored position in the registry` — the same, with one + * position already registered the way a metadata author's saved definition + * is hydrated (no package). It is the state in which the declared-positions + * seeder's registry-first two-step takes the registry, so it is where a + * seeder that took the six would show it. + * + * ## The goldens + * + * The row census, the `sys_position` write ledger and the grant envelopes + * were recorded from the tree BEFORE the declarations existed (objectstack + * `51290bca2c`, this file run against that tree's sources; the PR record + * carries the run) and are unchanged after them. The catalog listing is the + * one reading that changes: before, the catalog read listed the stack's + * position and nothing else. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { + assembleExecutionContext, + createSecurityCatalogReader, + resetPlatformAdminEmailMemo, + resolveUserAuthzGrants, +} from '@objectstack/core'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; + +import { SecurityPlugin } from './security-plugin.js'; +import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; +import { securityObjects, SECURITY_PLUGIN_ID } from './manifest.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const ORG = 'org_eq'; +const LATE_ORG = 'org_late'; +const BUILTIN_NAMES = ['everyone', 'guest', 'org_admin', 'org_member', 'org_owner', 'platform_admin']; + +/** The non-system administrator whose writes are the data door's (superuser wildcard). */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +/** A stack-declared position, as an app registers it with the metadata service. */ +const DECLARED_POSITIONS = [{ name: 'field_rep', label: 'Field Rep', description: 'Works the territory' }]; + +/** + * The app's own default set — the deployment's declared baseline, bound to + * `everyone` by the boot. Owned by a package, so the walled boot materializes + * the organization's copy of it and binds THAT, the way an installed app's + * default set reaches every organization's `everyone`. + */ +const APP_DEFAULT = { + name: 'field_default', + label: 'Field Default', + isDefault: true, + objects: { sys_user: { allowRead: true } }, + _packageId: 'com.example.field', +} as unknown as PermissionSet; + +type ScenarioName = 'single' | 'single + organization' | 'walled' | 'walled, a door-authored position in the registry'; +interface Scenario { + readonly posture: 'single' | 'isolated'; + /** Organizations (with the memberships) present before `kernel:ready`. */ + readonly organizationAtBoot: boolean; + /** One more organization, created after the boot. */ + readonly lateOrganization: boolean; + /** A package-less position in the registry before the boot (a hydrated door-authored definition). */ + readonly authoredPosition: boolean; +} +const SCENARIOS: Record = { + single: { posture: 'single', organizationAtBoot: false, lateOrganization: false, authoredPosition: false }, + 'single + organization': { posture: 'single', organizationAtBoot: true, lateOrganization: false, authoredPosition: false }, + walled: { posture: 'isolated', organizationAtBoot: true, lateOrganization: true, authoredPosition: false }, + 'walled, a door-authored position in the registry': { + posture: 'isolated', organizationAtBoot: true, lateOrganization: true, authoredPosition: true, + }, +}; + +const sortDeep = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortDeep).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value as object).sort().map((k) => [k, sortDeep((value as any)[k])])); + } + return value; +}; + +/** One refused or admitted write, read off its ADR-0112 envelope. */ +interface WriteOutcome { + ok: boolean; + code?: string; + status?: number; + fields?: string[]; +} +const outcome = async (write: Promise): Promise => { + try { + await write; + return { ok: true }; + } catch (e: any) { + const fields = Array.isArray(e?.fields) ? e.fields.map((f: any) => `${f?.field}:${f?.code}`) : undefined; + const status = e?.status ?? e?.statusCode; + return { ok: false, code: e?.code, ...(typeof status === 'number' ? { status } : {}), ...(fields ? { fields } : {}) }; + } +}; + +interface Booted { + engine: ObjectQL; + metadata: { get(type: string, name: string): unknown; list(type: string): unknown }; + /** Every `sys_position` write the boot made, in order: op, name, organization, provenance written. */ + ledger: string[]; + /** The rows the boot left, one string per row. */ + census: string[]; +} + +async function boot(scenario: Scenario): Promise { + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + if (scenario.posture === 'isolated') { + process.env[POSTURE_ENV] = 'isolated'; + process.env[OWNER_ENV] = 'admin@eq.example'; + } + resetPlatformAdminEmailMemo(); + + 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.qa.builtin-position-boot', + name: 'Built-in position boot', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects], + } as any); + await engine.syncSchemas(); + if (scenario.authoredPosition) { + // How a metadata author's saved definition is hydrated: no package. + (engine as any).registry.registerItem('position', { name: 'door_authored', label: 'Door Authored' }, 'name'); + } + + // The write ledger: every `sys_position` insert and update, refused ones + // included, named by the row they write. + const ledger: string[] = []; + const idToName = new Map(); + let recording = true; + const org = (opts: any) => opts?.context?.tenantId ?? '-'; + const provenance = (row: any) => (row && 'managed_by' in row ? ` managed_by=${row.managed_by}` : ''); + const insert = engine.insert.bind(engine); + const update = engine.update.bind(engine); + (engine as any).insert = async (object: string, data: any, opts?: any) => { + if (!recording || object !== 'sys_position') return insert(object, data, opts); + const rows = (Array.isArray(data) ? data : [data]) as any[]; + const note = (op: string) => { + for (const row of rows) { + if (row?.id) idToName.set(String(row.id), String(row.name)); + ledger.push(`${op} ${row?.name}@${org(opts)}${provenance(row)}`); + } + }; + try { + const result = await insert(object, data, opts); + note('insert'); + return result; + } catch (e) { + note('insert REFUSED'); + throw e; + } + }; + (engine as any).update = async (object: string, data: any, opts?: any) => { + if (!recording || object !== 'sys_position') return update(object, data, opts); + const name = idToName.get(String(data?.id)) ?? `id:${String(data?.id)}`; + ledger.push(`update ${name}@${org(opts)} ${Object.keys(data ?? {}).filter((k) => k !== 'id').sort().join(',')}${provenance(data)}`); + return update(object, data, opts); + }; + + const metadata = { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async (type: string) => { + if (type === 'position') return DECLARED_POSITIONS.map((p) => ({ ...p })); + if (type === 'permission') return [...defaultPermissionSets, QA_ADMIN, APP_DEFAULT]; + return []; + }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata, + ...(scenario.posture === 'isolated' + ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture: 'isolated' } } + : {}), + }; + const hooks = new Map unknown>>(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: (name: string, handler: (...args: unknown[]) => unknown) => { + hooks.set(name, [...(hooks.get(name) ?? []), handler]); + }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'field_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + // Everything the boot sweep reads exists BEFORE `kernel:ready`: the + // organization (the walled sweep seeds one catalog per organization) and + // the users (the `single` sweep promotes the first authenticable one). + const user = async (id: string, email: string, createdAt: string) => { + await engine.insert('sys_user', { id, email, name: id, created_at: createdAt, email_verified: true }, { context: SYS } as any); + await engine.insert( + 'sys_account', { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, { context: SYS } as any, + ); + }; + if (scenario.organizationAtBoot) { + await engine.insert('sys_organization', { id: ORG, name: 'Eq Org', slug: 'eq' }, { context: SYS } as any); + } + await user('usr_admin', 'admin@eq.example', '2025-01-01T00:00:00.000Z'); + await user('usr_orgadmin', 'orgadmin@eq.example', '2025-02-01T00:00:00.000Z'); + await user('usr_member', 'member@eq.example', '2025-03-01T00:00:00.000Z'); + if (scenario.organizationAtBoot) { + await engine.insert('sys_member', [ + { id: 'm_owner', user_id: 'usr_orgadmin', organization_id: ORG, role: 'owner', created_at: '2025-02-01T00:00:00.000Z' }, + { id: 'm_member', user_id: 'usr_member', organization_id: ORG, role: 'member', created_at: '2025-03-01T00:00:00.000Z' }, + ], { context: { isSystem: true, tenantId: ORG } } as any); + } + + // The plugin's own boot: seeders, the baseline binding to `everyone`, the + // organization-admin backfill — in registration order, as the kernel fires them. + for (const handler of hooks.get('kernel:ready') ?? []) await handler(); + if (scenario.lateOrganization) { + await engine.insert('sys_organization', { id: LATE_ORG, name: 'Late Org', slug: 'late' }, { context: SYS } as any); + } + recording = false; + + const rows = (await engine.find('sys_position', { where: {}, limit: 5000, context: SYS } as any)) as any[]; + const census = rows + .map((r) => [r.name, r.organization_id ?? '-', r.managed_by, r.active, r.is_default, r.label, r.description ?? '-'].join(' | ')) + .sort(); + return { engine, metadata, ledger, census }; +} + +/** The four principals' resolved grants — needs the organization and its memberships. */ +async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated'): Promise> { + const orgCtx = { isSystem: true, tenantId: ORG }; + // Walled: the organization's own copies of the sets its grants point at + // (the per-organization catalog), cloned from the platform bucket the boot + // just wrote — as the name-column equivalence suite does. + if (posture === 'isolated') { + for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { + const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS } as any); + const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; + await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); + } + } + // `single`: the boot's organization-admin backfill already granted it, so + // the reconcile finds nothing to do; walled: the organization's copy exists + // only now, so the reconcile grants. + const reconciled = await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG, { posture }); + expect(reconciled.action).toBe(posture === 'single' ? 'noop' : 'granted'); + + // The member's set, granted through the data door by an administrator. + const [viewer] = await engine.find('sys_permission_set', { + where: { name: 'viewer_readonly', ...(posture === 'isolated' ? { organization_id: ORG } : {}) }, + context: SYS, + } as any); + await engine.insert( + 'sys_user_permission_set', + { user_id: 'usr_member', permission_set_id: (viewer as any).id }, + { context: { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG } } as any, + ); + + const resolve = (userId: string) => resolveUserAuthzGrants(engine, userId, { tenantId: ORG }); + const admin = await resolve('usr_admin'); + const orgAdmin = await resolve('usr_orgadmin'); + const member = await resolve('usr_member'); + const agentCtx = assembleExecutionContext({ + authz: { ...orgAdmin, userId: 'usr_orgadmin', tenantId: ORG } as any, + oauth: { + userId: 'usr_orgadmin', + scopes: ['data:read', 'actions:execute'], + clientId: 'agent_client_eq', + scopePermissions: ['mcp_agent_data_read'], + delegatesActions: true, + } as any, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + authGate: undefined, + }); + const agent = { + principalKind: (agentCtx as any)?.principalKind, + positions: (agentCtx as any)?.positions, + permissions: (agentCtx as any)?.permissions, + systemPermissions: (agentCtx as any)?.systemPermissions, + onBehalfOf: (agentCtx as any)?.onBehalfOf, + }; + return sortDeep({ platformAdmin: admin, organizationAdmin: orgAdmin, member, agent }) as Record; +} + +/** + * The position writes a tenant's administrator is refused, plus the controls + * that show the refusals are not blanket: a write through the data door by the + * organization administrator (superuser wildcard, not a system context). + */ +async function tenantPositionWrites(engine: ObjectQL): Promise> { + const tenant = { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG }; + const own = async (name: string) => { + const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } } as any)) as any[]; + // The organization's own row when it has one (walled), else the organization-less one (`single`). + return rows.find((r) => r.organization_id === ORG) ?? rows.find((r) => r.organization_id == null); + }; + const everyone = await own('everyone'); + const platformAdmin = await own('platform_admin'); + return { + 'assign a position no catalog row carries': await outcome(engine.insert( + 'sys_user_position', { user_id: 'usr_member', position: 'zz_dangling' }, { context: tenant } as any, + )), + 'control: create a position': await outcome(engine.insert( + 'sys_position', { name: 'zz_tenant_post', label: 'Tenant Post' }, { context: tenant } as any, + )), + 'control: assign it': await outcome(engine.insert( + 'sys_user_position', { user_id: 'usr_member', position: 'zz_tenant_post' }, { context: tenant } as any, + )), + 'delete the everyone row': await outcome(engine.delete('sys_position', { where: { id: everyone?.id }, context: tenant } as any)), + 'relabel the platform_admin row': await outcome(engine.update( + 'sys_position', { id: platformAdmin?.id, label: 'Repurposed' }, { context: tenant } as any, + )), + }; +} + +const booted = new Map(); +afterAll(async () => { + vi.restoreAllMocks(); + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + for (const b of booted.values()) { + try { await b.engine.destroy(); } catch { /* noop */ } + } +}); + +for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { + const scenario = SCENARIOS[name]; + describe(`[ADR-0131 D2] the built-in positions as declared metadata — ${name}`, () => { + beforeAll(async () => { + booted.set(name, await boot(scenario)); + }, 60_000); + + // P2.1 — the catalog read lists the six, from the registry, owned by this + // plugin, beside the stack's own position. + it('the security catalog read lists the six built-in positions beside the declared one', async () => { + const { engine, metadata } = booted.get(name)!; + const reader = createSecurityCatalogReader({ registry: engine.registry as any, metadata: metadata as any }); + const listed = (await reader.list('position')).map((e) => `${e.name}@${e.source}${e.packageId ? `:${e.packageId}` : ''}`).sort(); + expect(listed).toEqual([ + ...(scenario.authoredPosition ? ['door_authored@registry'] : []), + ...BUILTIN_NAMES.map((n) => `${n}@registry:${SECURITY_PLUGIN_ID}`), + 'field_rep@metadata', + ].sort()); + }); + + // P2.2 — the rows, and who wrote them, are what they were before. + it('seeds the same sys_position rows, written by the same seeders, as before the declarations', () => { + const { census, ledger } = booted.get(name)!; + expect({ census, ledger }).toEqual(CATALOG_GOLDEN[name]); + }); + + if (scenario.organizationAtBoot && !scenario.authoredPosition) { + it('grants every principal what it was granted before the declarations', async () => { + const { engine } = booted.get(name)!; + expect(await grantsByPrincipal(engine, scenario.posture)).toEqual(GRANT_GOLDEN[scenario.posture]); + }); + + it('still refuses a tenant a dangling position name, and deleting or relabelling a built-in row', async () => { + const { engine } = booted.get(name)!; + expect(await tenantPositionWrites(engine)).toEqual(REFUSAL_GOLDEN); + }); + } + }); +} + +/** + * The tenant position writes — the same in both postures. The dangling name is + * the position catalog refusal's envelope (`reference_not_found` at + * `position`); the two built-in rows are refused on their `platform` + * provenance. + */ +const REFUSAL_GOLDEN: Record = {}; + +/** Recorded before the declarations (module doc). */ +const CATALOG_GOLDEN: Record = {} as never; + +/** + * Recorded before the declarations (module doc). `field_default` on every + * human principal is the `everyone` binding; `single` also binds the + * platform's `member_default` (its organization-less catalog holds that set), + * while the walled organization's catalog holds only its own copy of the app's + * set. The organization administrator's set is the walled `organization_admin` + * against the wall-less `organization_admin_no_bypass` (ADR-0105 D4). + */ +const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = {} as never; diff --git a/packages/plugins/plugin-security/src/builtin-positions.grant-equivalence.test.ts b/packages/plugins/plugin-security/src/builtin-positions.grant-equivalence.test.ts deleted file mode 100644 index b1187ca69e9..00000000000 --- a/packages/plugins/plugin-security/src/builtin-positions.grant-equivalence.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D2, C2 stage S2] Grant equivalence: declaring the six built-in - * positions as position metadata changes no principal's grants. - * - * The stage moves where the six come from — one declaration list, registered - * with the engine registry and read by the built-in seeder — and changes what - * the declared-positions seeder reads. No resolver reads a definition yet, so - * the only ways a grant could move are a catalog row that is no longer seeded, - * seeded differently, or seeded twice: above all `everyone`, which carries every - * authenticated principal's baseline through its binding. So unlike a - * resolver-only harness, this one runs the plugin's REAL `kernel:ready` - * bootstrap (catalog seeding and the baseline binding to `everyone` included) - * before it resolves anyone, and the catalog holds a stack-declared position - * beside the six. - * - * The four principals, resolved by the REAL resolver in two postures, against - * a golden recorded from the tree BEFORE the declarations existed (the PR record - * carries that run): - * - * - **platform administrator** — `single`: the first authenticable user, - * promoted by the bootstrap; walled: the declared `OS_PLATFORM_OWNER_EMAIL` - * owner (configuration, no row); - * - **organization administrator** — the owner membership, granted by - * `reconcileOrgAdminGrant`; - * - **member** — a member granted a set through the data door by an - * administrator's non-system write; - * - **agent** — an OAuth agent acting for the organization administrator with - * the actions consent (`assembleExecutionContext`, ADR-0090 D10). - * - * What is compared is the resolver's whole envelope per principal, arrays - * sorted so a reorder is not read as a change. `member_default` reaching every - * human principal is the `everyone` binding at work. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -import { - assembleExecutionContext, - resetPlatformAdminEmailMemo, - resolveUserAuthzGrants, -} from '@objectstack/core'; -import type { PermissionSet } from '@objectstack/spec/security'; -import { SysUser, SysAccount, SysMember, SysOrganization } from '@objectstack/platform-objects/identity'; - -import { SecurityPlugin } from './security-plugin.js'; -import { reconcileOrgAdminGrant } from './auto-org-admin-grant.js'; -import { securityObjects } from './manifest.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; - -const SYS = { isSystem: true } as const; -const POSTURE_ENV = 'OS_TENANCY_POSTURE'; -const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; -const ORG = 'org_eq'; - -/** The non-system administrator whose writes are the data door's (superuser wildcard). */ -const QA_ADMIN = { - name: 'qa_admin', - label: 'QA Admin', - objects: { - '*': { - allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, - viewAllRecords: true, modifyAllRecords: true, - }, - }, -} as unknown as PermissionSet; - -/** A stack-declared position, as an app registers it with the metadata service. */ -const DECLARED_POSITIONS = [{ name: 'field_rep', label: 'Field Rep', description: 'Works the territory' }]; - -/** - * The app's own default set — the deployment's declared baseline, bound to - * `everyone` by the boot. Owned by a package, so the walled boot materializes - * the organization's copy of it and binds THAT, the way an installed app's - * default set reaches every organization's `everyone`. - */ -const APP_DEFAULT = { - name: 'field_default', - label: 'Field Default', - isDefault: true, - objects: { sys_user: { allowRead: true } }, - _packageId: 'com.example.field', -} as unknown as PermissionSet; - -const engines: ObjectQL[] = []; -afterEach(async () => { - vi.restoreAllMocks(); - delete process.env[POSTURE_ENV]; - delete process.env[OWNER_ENV]; - resetPlatformAdminEmailMemo(); - while (engines.length) { - try { await engines.pop()?.destroy(); } catch { /* noop */ } - } -}); - -type Posture = 'single' | 'isolated'; - -const sortDeep = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(sortDeep).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); - if (value && typeof value === 'object') { - return Object.fromEntries(Object.keys(value as object).sort().map((k) => [k, sortDeep((value as any)[k])])); - } - return value; -}; - -async function grantsByPrincipal(posture: Posture): Promise> { - if (posture === 'isolated') { - process.env[POSTURE_ENV] = 'isolated'; - process.env[OWNER_ENV] = 'admin@eq.example'; - } - resetPlatformAdminEmailMemo(); - - 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.qa.builtin-position-equivalence', - name: 'Built-in position equivalence', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects], - } as any); - await engine.syncSchemas(); - engines.push(engine); - - const services: Record = { - manifest: { register: vi.fn() }, - objectql: engine, - metadata: { - get: async (_type: string, name: string) => engine.getSchema(name) ?? null, - list: async (type: string) => { - if (type === 'position') return DECLARED_POSITIONS.map((p) => ({ ...p })); - if (type === 'permission') return [...defaultPermissionSets, QA_ADMIN, APP_DEFAULT]; - return []; - }, - }, - ...(posture === 'isolated' - ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture: 'isolated' } } - : {}), - }; - const hooks = new Map unknown>>(); - const ctx: any = { - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, - hook: (name: string, handler: (...args: unknown[]) => unknown) => { - hooks.set(name, [...(hooks.get(name) ?? []), handler]); - }, - registerService: vi.fn(), - getService: (name: string) => { - if (!(name in services)) throw new Error(`service not registered: ${name}`); - return services[name]; - }, - }; - const plugin = new SecurityPlugin({ fallbackPermissionSet: 'field_default' }); - await plugin.init(ctx); - await plugin.start(ctx); - vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); - - // Everything the boot sweep reads exists BEFORE `kernel:ready`: the - // organization (the walled sweep seeds one catalog per organization) and - // the users (the `single` sweep promotes the first authenticable one). - const orgCtx = { isSystem: true, tenantId: ORG }; - await engine.insert('sys_organization', { id: ORG, name: 'Eq Org', slug: 'eq' }, { context: SYS } as any); - const user = async (id: string, email: string, createdAt: string) => { - await engine.insert('sys_user', { id, email, name: id, created_at: createdAt, email_verified: true }, { context: SYS } as any); - await engine.insert( - 'sys_account', { id: `acc_${id}`, user_id: id, account_id: email, provider_id: 'credential' }, { context: SYS } as any, - ); - }; - await user('usr_admin', 'admin@eq.example', '2025-01-01T00:00:00.000Z'); - await user('usr_orgadmin', 'orgadmin@eq.example', '2025-02-01T00:00:00.000Z'); - await user('usr_member', 'member@eq.example', '2025-03-01T00:00:00.000Z'); - await engine.insert('sys_member', [ - { id: 'm_owner', user_id: 'usr_orgadmin', organization_id: ORG, role: 'owner', created_at: '2025-02-01T00:00:00.000Z' }, - { id: 'm_member', user_id: 'usr_member', organization_id: ORG, role: 'member', created_at: '2025-03-01T00:00:00.000Z' }, - ], { context: orgCtx } as any); - - // The plugin's own boot: seeders, the baseline binding to `everyone`, the - // organization-admin backfill — in registration order, as the kernel fires them. - for (const handler of hooks.get('kernel:ready') ?? []) await handler(); - - // PRECONDITION: the catalog the grants ride on was really seeded — the six, - // and the stack-declared position beside them. - const seededNames = ((await engine.find('sys_position', { where: {}, context: SYS } as any)) as any[]) - .map((r) => r.name).sort(); - expect(seededNames).toEqual( - ['everyone', 'field_rep', 'guest', 'org_admin', 'org_member', 'org_owner', 'platform_admin'], - ); - - // Walled: the organization's own copies of the sets its grants point at - // (the per-organization catalog), cloned from the platform bucket the boot - // just wrote — as the name-column equivalence suite does. - if (posture === 'isolated') { - for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { - const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS }); - const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; - await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); - } - } - // `single`: the boot's organization-admin backfill already granted it, so - // the reconcile finds nothing to do; walled: the organization's copy exists - // only now, so the reconcile grants. - const reconciled = await reconcileOrgAdminGrant(engine, 'usr_orgadmin', ORG, { posture }); - expect(reconciled.action).toBe(posture === 'single' ? 'noop' : 'granted'); - - // The member's set, granted through the data door by an administrator. - const [viewer] = await engine.find('sys_permission_set', { - where: { name: 'viewer_readonly', ...(posture === 'isolated' ? { organization_id: ORG } : {}) }, - context: SYS, - }); - await engine.insert( - 'sys_user_permission_set', - { user_id: 'usr_member', permission_set_id: (viewer as any).id }, - { context: { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG } } as any, - ); - - const resolve = (userId: string) => resolveUserAuthzGrants(engine, userId, { tenantId: ORG }); - const admin = await resolve('usr_admin'); - const orgAdmin = await resolve('usr_orgadmin'); - const member = await resolve('usr_member'); - const agentCtx = assembleExecutionContext({ - authz: { ...orgAdmin, userId: 'usr_orgadmin', tenantId: ORG } as any, - oauth: { - userId: 'usr_orgadmin', - scopes: ['data:read', 'actions:execute'], - clientId: 'agent_client_eq', - scopePermissions: ['mcp_agent_data_read'], - delegatesActions: true, - } as any, - localization: undefined, - requestLocale: undefined, - accessToken: undefined, - authGate: undefined, - }); - const agent = { - principalKind: (agentCtx as any)?.principalKind, - positions: (agentCtx as any)?.positions, - permissions: (agentCtx as any)?.permissions, - systemPermissions: (agentCtx as any)?.systemPermissions, - onBehalfOf: (agentCtx as any)?.onBehalfOf, - }; - return sortDeep({ platformAdmin: admin, organizationAdmin: orgAdmin, member, agent }) as Record; -} - -describe('[ADR-0131 D2] grant equivalence — the built-in positions as declared metadata change no principal’s grants', () => { - for (const posture of ['single', 'isolated'] as const) { - it(`${posture}: platform administrator, organization administrator, member and agent resolve to the recorded golden`, async () => { - const actual = await grantsByPrincipal(posture); - expect(actual).toEqual(GOLDEN[posture]); - }); - } -}); - -/** - * Recorded from the tree BEFORE the declarations existed (objectstack - * `51290bca2c`, this file run against that tree's sources), and unchanged - * after them. `field_default` on every human principal is the `everyone` - * binding; `single` also binds the platform's `member_default` (its - * organization-less catalog holds that set), while the walled organization's - * catalog holds only its own copy of the app's set. The organization - * administrator's set is the walled `organization_admin` against the wall-less - * `organization_admin_no_bypass` (ADR-0105 D4). - */ -const GOLDEN: Record = { - single: { - agent: { - onBehalfOf: { - principalKind: 'human', - userId: 'usr_orgadmin', - }, - permissions: ['mcp_agent_data_read'], - positions: [], - principalKind: 'agent', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], - }, - member: { - accessible_org_ids: ['org_eq'], - email: 'member@eq.example', - org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['field_default', 'member_default', 'viewer_readonly'], - positions: ['everyone', 'org_member'], - posture: 'MEMBER', - systemPermissions: [], - }, - organizationAdmin: { - accessible_org_ids: ['org_eq'], - email: 'orgadmin@eq.example', - org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['field_default', 'member_default', 'organization_admin_no_bypass'], - positions: ['everyone', 'org_owner'], - posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], - }, - platformAdmin: { - accessible_org_ids: [], - email: 'admin@eq.example', - org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], - permissions: ['admin_full_access', 'field_default', 'member_default'], - positions: ['everyone', 'platform_admin'], - posture: 'PLATFORM_ADMIN', - systemPermissions: [ - 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', 'setup.access', - 'setup.write', 'studio.access', 'view_all_audit_log', - ], - }, - }, - isolated: { - agent: { - onBehalfOf: { - principalKind: 'human', - userId: 'usr_orgadmin', - }, - permissions: ['mcp_agent_data_read'], - positions: [], - principalKind: 'agent', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], - }, - member: { - accessible_org_ids: ['org_eq'], - email: 'member@eq.example', - org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['field_default', 'viewer_readonly'], - positions: ['everyone', 'org_member'], - posture: 'MEMBER', - systemPermissions: [], - }, - organizationAdmin: { - accessible_org_ids: ['org_eq'], - email: 'orgadmin@eq.example', - org_user_ids: ['usr_member', 'usr_orgadmin'], - permissions: ['field_default', 'organization_admin'], - positions: ['everyone', 'org_owner'], - posture: 'TENANT_ADMIN', - systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], - }, - platformAdmin: { - accessible_org_ids: [], - email: 'admin@eq.example', - org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], - permissions: ['admin_full_access', 'field_default'], - positions: ['everyone', 'platform_admin'], - posture: 'PLATFORM_ADMIN', - systemPermissions: [ - 'manage_metadata', 'manage_platform_settings', 'manage_sharing', 'manage_users', 'setup.access', - 'setup.write', 'studio.access', 'view_all_audit_log', - ], - }, - }, -}; From 41b3447dc3db2c4498a784dc3cf56084c53496e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:25:09 +0000 Subject: [PATCH 6/9] test(plugin-security): the boot harness's goldens, recorded from the pre-declaration tree Recorded by running this file against the sources at 51290bca2c (the built-in seeder's own code list, main's declared-positions seeder, no registration); every census, ledger, grant and refusal reading there equals the reading after the declarations. The catalog listing is the one widening: before, it listed the stack's position alone. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../src/builtin-positions.boot.test.ts | 257 +++++++++++++++++- 1 file changed, 254 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index b5f881b4a1f..bcadafef359 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -427,10 +427,164 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { * `position`); the two built-in rows are refused on their `platform` * provenance. */ -const REFUSAL_GOLDEN: Record = {}; +const REFUSAL_GOLDEN: Record = { + 'assign a position no catalog row carries': { + ok: false, + code: 'VALIDATION_FAILED', + fields: ['position:reference_not_found'], + }, + 'control: create a position': { + ok: true, + }, + 'control: assign it': { + ok: true, + }, + 'delete the everyone row': { + ok: false, + code: 'PERMISSION_DENIED', + status: 403, + }, + 'relabel the platform_admin row': { + ok: false, + code: 'PERMISSION_DENIED', + status: 403, + }, +}; + +/** + * Each recorded row's `label | description`, as the census spells it — one + * entry per name, the same in every organization. + */ +const TEXT: Record = { + everyone: + 'Everyone | Built-in audience anchor: every authenticated member holds this position implicitly. Permission sets bound to it are the default grants for the tenant (ADR-0090 D5). High-privilege sets cannot be bound here.', + field_rep: + 'Field Rep | Works the territory', + guest: + 'Guest | Built-in audience anchor: unauthenticated principals hold this position implicitly and exclusively. Bindings face the strictest checks — named objects only, read-mostly, never a wildcard (ADR-0090 D9).', + org_admin: + 'Organization Admin | Organization administrator within a tenant.', + org_member: + 'Organization Member | Organization member within a tenant.', + org_owner: + 'Organization Owner | Organization owner within a tenant.', + platform_admin: + 'Platform Admin | Platform operator (SaaS admin). NOT a tenant user role.', + door_authored: + 'Door Authored | -', +}; /** Recorded before the declarations (module doc). */ -const CATALOG_GOLDEN: Record = {} as never; +const CATALOG_GOLDEN: Record = { + single: { + census: [ + `everyone | - | platform | true | false | ${TEXT.everyone}`, + `field_rep | - | admin | true | false | ${TEXT.field_rep}`, + `guest | - | platform | true | false | ${TEXT.guest}`, + `org_admin | - | platform | true | false | ${TEXT.org_admin}`, + `org_member | - | platform | true | false | ${TEXT.org_member}`, + `org_owner | - | platform | true | false | ${TEXT.org_owner}`, + `platform_admin | - | platform | true | false | ${TEXT.platform_admin}`, + ], + ledger: [ + 'insert field_rep@-', + 'insert platform_admin@- managed_by=platform', + 'insert org_owner@- managed_by=platform', + 'insert org_admin@- managed_by=platform', + 'insert org_member@- managed_by=platform', + 'insert everyone@- managed_by=platform', + 'insert guest@- managed_by=platform', + ], + }, + 'single + organization': { + census: [ + `everyone | - | platform | true | false | ${TEXT.everyone}`, + `field_rep | - | admin | true | false | ${TEXT.field_rep}`, + `guest | - | platform | true | false | ${TEXT.guest}`, + `org_admin | - | platform | true | false | ${TEXT.org_admin}`, + `org_member | - | platform | true | false | ${TEXT.org_member}`, + `org_owner | - | platform | true | false | ${TEXT.org_owner}`, + `platform_admin | - | platform | true | false | ${TEXT.platform_admin}`, + ], + ledger: [ + 'insert field_rep@-', + 'insert platform_admin@- managed_by=platform', + 'insert org_owner@- managed_by=platform', + 'insert org_admin@- managed_by=platform', + 'insert org_member@- managed_by=platform', + 'insert everyone@- managed_by=platform', + 'insert guest@- managed_by=platform', + ], + }, + walled: { + census: [ + `everyone | org_eq | platform | true | false | ${TEXT.everyone}`, + `everyone | org_late | platform | true | false | ${TEXT.everyone}`, + `field_rep | org_eq | admin | true | false | ${TEXT.field_rep}`, + `field_rep | org_late | admin | true | false | ${TEXT.field_rep}`, + `guest | org_eq | platform | true | false | ${TEXT.guest}`, + `guest | org_late | platform | true | false | ${TEXT.guest}`, + `org_admin | org_eq | platform | true | false | ${TEXT.org_admin}`, + `org_admin | org_late | platform | true | false | ${TEXT.org_admin}`, + `org_member | org_eq | platform | true | false | ${TEXT.org_member}`, + `org_member | org_late | platform | true | false | ${TEXT.org_member}`, + `org_owner | org_eq | platform | true | false | ${TEXT.org_owner}`, + `org_owner | org_late | platform | true | false | ${TEXT.org_owner}`, + `platform_admin | org_eq | platform | true | false | ${TEXT.platform_admin}`, + `platform_admin | org_late | platform | true | false | ${TEXT.platform_admin}`, + ], + ledger: [ + 'insert field_rep@org_eq', + 'insert platform_admin@org_eq managed_by=platform', + 'insert org_owner@org_eq managed_by=platform', + 'insert org_admin@org_eq managed_by=platform', + 'insert org_member@org_eq managed_by=platform', + 'insert everyone@org_eq managed_by=platform', + 'insert guest@org_eq managed_by=platform', + 'insert field_rep@org_late', + 'insert platform_admin@org_late managed_by=platform', + 'insert org_owner@org_late managed_by=platform', + 'insert org_admin@org_late managed_by=platform', + 'insert org_member@org_late managed_by=platform', + 'insert everyone@org_late managed_by=platform', + 'insert guest@org_late managed_by=platform', + ], + }, + 'walled, a door-authored position in the registry': { + census: [ + `door_authored | org_eq | admin | true | false | ${TEXT.door_authored}`, + `door_authored | org_late | admin | true | false | ${TEXT.door_authored}`, + `everyone | org_eq | platform | true | false | ${TEXT.everyone}`, + `everyone | org_late | platform | true | false | ${TEXT.everyone}`, + `guest | org_eq | platform | true | false | ${TEXT.guest}`, + `guest | org_late | platform | true | false | ${TEXT.guest}`, + `org_admin | org_eq | platform | true | false | ${TEXT.org_admin}`, + `org_admin | org_late | platform | true | false | ${TEXT.org_admin}`, + `org_member | org_eq | platform | true | false | ${TEXT.org_member}`, + `org_member | org_late | platform | true | false | ${TEXT.org_member}`, + `org_owner | org_eq | platform | true | false | ${TEXT.org_owner}`, + `org_owner | org_late | platform | true | false | ${TEXT.org_owner}`, + `platform_admin | org_eq | platform | true | false | ${TEXT.platform_admin}`, + `platform_admin | org_late | platform | true | false | ${TEXT.platform_admin}`, + ], + ledger: [ + 'insert door_authored@org_eq', + 'insert platform_admin@org_eq managed_by=platform', + 'insert org_owner@org_eq managed_by=platform', + 'insert org_admin@org_eq managed_by=platform', + 'insert org_member@org_eq managed_by=platform', + 'insert everyone@org_eq managed_by=platform', + 'insert guest@org_eq managed_by=platform', + 'insert door_authored@org_late', + 'insert platform_admin@org_late managed_by=platform', + 'insert org_owner@org_late managed_by=platform', + 'insert org_admin@org_late managed_by=platform', + 'insert org_member@org_late managed_by=platform', + 'insert everyone@org_late managed_by=platform', + 'insert guest@org_late managed_by=platform', + ], + }, +}; /** * Recorded before the declarations (module doc). `field_default` on every @@ -440,4 +594,101 @@ const CATALOG_GOLDEN: Record = {} as never; +const GRANT_GOLDEN: Record<'single' | 'isolated', unknown> = { + single: { + agent: { + onBehalfOf: { + principalKind: 'human', + userId: 'usr_orgadmin', + }, + permissions: ['mcp_agent_data_read'], + positions: [], + principalKind: 'agent', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + member: { + accessible_org_ids: ['org_eq'], + email: 'member@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['field_default', 'member_default', 'viewer_readonly'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + }, + organizationAdmin: { + accessible_org_ids: ['org_eq'], + email: 'orgadmin@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['field_default', 'member_default', 'organization_admin_no_bypass'], + positions: ['everyone', 'org_owner'], + posture: 'TENANT_ADMIN', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + platformAdmin: { + accessible_org_ids: [], + email: 'admin@eq.example', + org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], + permissions: ['admin_full_access', 'field_default', 'member_default'], + positions: ['everyone', 'platform_admin'], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', + 'manage_platform_settings', + 'manage_sharing', + 'manage_users', + 'setup.access', + 'setup.write', + 'studio.access', + 'view_all_audit_log', + ], + }, + }, + isolated: { + agent: { + onBehalfOf: { + principalKind: 'human', + userId: 'usr_orgadmin', + }, + permissions: ['mcp_agent_data_read'], + positions: [], + principalKind: 'agent', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + member: { + accessible_org_ids: ['org_eq'], + email: 'member@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['field_default', 'viewer_readonly'], + positions: ['everyone', 'org_member'], + posture: 'MEMBER', + systemPermissions: [], + }, + organizationAdmin: { + accessible_org_ids: ['org_eq'], + email: 'orgadmin@eq.example', + org_user_ids: ['usr_member', 'usr_orgadmin'], + permissions: ['field_default', 'organization_admin'], + positions: ['everyone', 'org_owner'], + posture: 'TENANT_ADMIN', + systemPermissions: ['manage_org_users', 'setup.access', 'setup.write'], + }, + platformAdmin: { + accessible_org_ids: [], + email: 'admin@eq.example', + org_user_ids: ['usr_admin', 'usr_member', 'usr_orgadmin'], + permissions: ['admin_full_access', 'field_default'], + positions: ['everyone', 'platform_admin'], + posture: 'PLATFORM_ADMIN', + systemPermissions: [ + 'manage_metadata', + 'manage_platform_settings', + 'manage_sharing', + 'manage_users', + 'setup.access', + 'setup.write', + 'studio.access', + 'view_all_audit_log', + ], + }, + }, +}; From c424443ca3f7f1402efc9be6ad762d3116604988 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:45:43 +0000 Subject: [PATCH 7/9] chore(changeset): plugin-security minor for the built-in positions as declared metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-② yes (narrowing): two metadata-door reads widen, and an in-place PUT of one of the six names at the metadata door is now refused. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- ...15196-builtin-positions-declared-metadata.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/15196-builtin-positions-declared-metadata.md diff --git a/.changeset/15196-builtin-positions-declared-metadata.md b/.changeset/15196-builtin-positions-declared-metadata.md new file mode 100644 index 00000000000..9fe59e3fe8c --- /dev/null +++ b/.changeset/15196-builtin-positions-declared-metadata.md @@ -0,0 +1,17 @@ +--- +"@objectstack/plugin-security": minor +--- + +feat(plugin-security)!: the six built-in positions are declared position metadata of the plugin (ADR-0131 D2) + +Clause-②: yes (narrowing) + + + +**BREAKING** on one write door, shipped as `minor` under the repo's launch-window convention for breaking changes. + +- **What is declared.** The identity positions `platform_admin`, `org_owner`, `org_admin` and `org_member` (ADR-0068 D2) and the audience anchors `everyone` and `guest` (ADR-0090 D5/D9) are registered with the engine registry as `position` metadata owned by `com.objectstack.plugin-security`, the way the plugin's own permission sets are. Their names, labels and descriptions come from one list built from `@objectstack/spec`'s `BUILTIN_IDENTITY_NAMES` and `BUILTIN_IDENTITY_METADATA` and the anchor text, and the built-in seeder reads that same list. +- **Rows and grants do not move.** `sys_position` is seeded exactly as before: the same names, the same organizations (one copy per organization under a walled posture, one organization-less pass under `single`), `managed_by: 'platform'`, `active: true`, `is_default: false`, and the same labels and descriptions. No principal's grants change. +- **What a client can now read.** `GET /api/v1/meta/position` lists the six beside the stack's own positions, and `GET /api/v1/meta/position/:name` answers `200` with each one's definition, where it answered `404 RESOURCE_NOT_FOUND`. The security catalog read (`createSecurityCatalogReader` in `@objectstack/core`) lists them from the engine registry. +- **What a client can no longer write.** `PUT /api/v1/meta/position/:name` naming one of the six is refused `403 NOT_OVERRIDABLE`: the name is provided by a code package, and `position` has no overlay. It was saved as an environment-wide definition before. Give an authored position a different name. `DELETE /api/v1/meta/position/:name` on one of the six still answers `200` and removes nothing. +- **The declared-positions seeder** keeps reading the engine registry first and the metadata service only when the registry holds no position. The six are left out of that decision and out of what it seeds, so stack-declared positions keep seeding and the six keep the built-in seeder as their one writer. From 85b5c7df0421e270a2e585634d4ab7662b5a0037 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:11:09 +0000 Subject: [PATCH 8/9] test(plugin-security): the boot harness's engine reads keep their typed query options Four find() calls had their options erased to any, growing the query-options erasure ratchet's test surface; the signatures infer without it. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../plugin-security/src/builtin-positions.boot.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index bcadafef359..a852d2828d5 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -274,7 +274,7 @@ async function boot(scenario: Scenario): Promise { } recording = false; - const rows = (await engine.find('sys_position', { where: {}, limit: 5000, context: SYS } as any)) as any[]; + const rows = (await engine.find('sys_position', { where: {}, limit: 5000, context: SYS })) as any[]; const census = rows .map((r) => [r.name, r.organization_id ?? '-', r.managed_by, r.active, r.is_default, r.label, r.description ?? '-'].join(' | ')) .sort(); @@ -289,7 +289,7 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated // just wrote — as the name-column equivalence suite does. if (posture === 'isolated') { for (const name of ['organization_admin', 'organization_admin_no_bypass', 'viewer_readonly']) { - const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS } as any); + const [bucket] = await engine.find('sys_permission_set', { where: { name }, context: SYS }); const { id: _id, created_at: _c, updated_at: _u, ...rest } = bucket as any; await engine.insert('sys_permission_set', { ...rest, id: `ps_${name}_${ORG}`, organization_id: ORG }, { context: orgCtx } as any); } @@ -304,7 +304,7 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated const [viewer] = await engine.find('sys_permission_set', { where: { name: 'viewer_readonly', ...(posture === 'isolated' ? { organization_id: ORG } : {}) }, context: SYS, - } as any); + }); await engine.insert( 'sys_user_permission_set', { user_id: 'usr_member', permission_set_id: (viewer as any).id }, @@ -347,7 +347,7 @@ async function grantsByPrincipal(engine: ObjectQL, posture: 'single' | 'isolated async function tenantPositionWrites(engine: ObjectQL): Promise> { const tenant = { userId: 'usr_orgadmin', positions: [], permissions: ['qa_admin'], tenantId: ORG }; const own = async (name: string) => { - const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } } as any)) as any[]; + const rows = (await engine.find('sys_position', { where: { name }, limit: 5, context: { isSystem: true, tenantId: ORG } })) as any[]; // The organization's own row when it has one (walled), else the organization-less one (`single`). return rows.find((r) => r.organization_id === ORG) ?? rows.find((r) => r.organization_id == null); }; From 4e969bae6e92673ea6c064c315c64fe3426e4b8d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:53:48 +0000 Subject: [PATCH 9/9] test(runtime,dogfood): two pins read the six built-in positions plugin-security now declares S1's showcase catalog pin expects the six beside the stack's positions, and the runtime registry-copy row expects the registry's positions to be exactly the six built-ins and none of the stack's; both read the names from @objectstack/spec. Declared cross-lane on the CLI lane post. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude --- .../test/security-catalog-showcase.dogfood.test.ts | 7 +++++-- .../src/standalone-stack-seeder-declaration-copy.test.ts | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts index 3b240cbed24..07c077e7389 100644 --- a/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts +++ b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts @@ -7,7 +7,8 @@ // What it pins: per catalog type, the names the read lists are EXACTLY the // names the declarations declare — the showcase stack's `positions`, // `permissions` and `capabilities`, plus the platform's own bootstrap -// permission sets that `plugin-security` ships — and exactly the names the +// permission sets and the six built-in positions that `plugin-security` +// ships (ADR-0131 D2) — and exactly the names the // metadata door lists (`GET /api/v1/meta/:type`). Every listed name resolves, // by name, to the entry the list gave for it. // @@ -34,6 +35,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import showcaseStack from '@objectstack/example-showcase'; import { securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { createSecurityCatalogReader, type SecurityCatalogType } from '@objectstack/core'; +import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; type Named = { name?: unknown }; const namesOf = (items: unknown): string[] => @@ -50,7 +52,8 @@ const stack = showcaseStack as unknown as { /** The declared catalog, read off the producers — never off a registry. */ const DECLARED: Record = { - position: namesOf(stack.positions), + // [ADR-0131 D2] plus the six built-in positions `plugin-security` declares. + position: [...new Set([...namesOf(stack.positions), ...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS])].sort(), permission: [...new Set([...namesOf(stack.permissions), ...namesOf(securityDefaultPermissionSets)])].sort(), capability: namesOf(stack.capabilities), }; diff --git a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts index a87281dcffa..f64a1ea7da1 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -73,6 +73,7 @@ import { ObjectKernel } from '@objectstack/core'; import { Runtime } from './runtime.js'; import { createStandaloneStack } from './standalone-stack.js'; import { SecurityPlugin } from '@objectstack/plugin-security'; +import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec'; import { SharingServicePlugin } from '@objectstack/plugin-sharing'; // [#10126] Pay the first transform of these dist-resolved workspace deps at @@ -277,12 +278,15 @@ describe('#14491 — the third copy exists, and both seeder spellings read it', expect(doorCopy.sharing_rule).toHaveLength(3); }); - it('`positions` is absent from METADATA_ARRAY_KEYS, so `position` has no registry copy and always comes from the door', async () => { + it('`positions` is absent from METADATA_ARRAY_KEYS, so no stack-declared `position` has a registry copy and each comes from the door', async () => { // The card's asymmetry, re-measured. `roles:` is not in // `PLURAL_TO_SINGULAR` either, so nothing lands under `roles` in the // registry: both reads are empty from both spellings. const ql = kernel.getService('objectql'); - expect((ql.registry.listItems('position') ?? []).filter(Boolean)).toEqual([]); + // [ADR-0131 D2] The registry's positions are exactly the six built-ins + // `SecurityPlugin` declares under its own package id — none of the stack's. + expect((ql.registry.listItems('position') ?? []).filter(Boolean).map((i: any) => i.name).sort()) + .toEqual([...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS].sort()); expect((ql.registry.listItems('roles') ?? []).filter(Boolean)).toEqual([]); }); });