From f2dee042395200863fcf2d555db5d85f110abee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:07:29 +0000 Subject: [PATCH 1/7] test(plugin-security): measuring pin, explain against enforcement over every verdict position (red on the unfixed tree) One table drives each explain verdict position (object-level allowed and readFilter, record-grained visible for read / update / delete, the principal's permission sets) against the same principal's own request through the real SecurityPlugin, ObjectQL and better-sqlite3, and asserts enforcement's own outcome as well. Measured on 889139ce, before any fix (45 of 103 rows red): - object-level read / update / delete / create under a cross-class row-level predicate answer allowed: true while find refuses INVALID_FILTER / 400 (both orderings of the pair); - a missing record id under that predicate answers visible: false with no decider while find refuses INVALID_FILTER / 400; - a current member explained by an administrator: denied a tenant object under isolated (their find reads it), and an organization-scoped permission set missing under isolated, group and single. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/explain-enforce-parity.test.ts | 820 ++++++++++++++++++ 1 file changed, 820 insertions(+) create mode 100644 packages/plugins/plugin-security/src/explain-enforce-parity.test.ts diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts new file mode 100644 index 00000000000..9bbbbca7567 --- /dev/null +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -0,0 +1,820 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `security.explain` answers what enforcement does — every verdict position, + * one table. + * + * ## The invariant + * + * For one principal, one policy set and one object, explain's answer is the + * answer the principal's own request gets from enforcement, or it is + * enforcement's refusal: + * + * - enforcement refuses with `INVALID_FILTER` / 400 (a predicate the driver + * will not compile) ⇒ explain refuses with the same envelope; + * - enforcement refuses otherwise (a 403) ⇒ explain refuses with + * `INVALID_FILTER` / 400, or its verdict at that position is a denial; + * - enforcement answers ⇒ explain answers too, and its verdict at that + * position is enforcement's: the same row set for the object-level + * `allowed` / `readFilter`, the same row for `record.visible`, the same + * permission sets for the principal. + * + * There is no third, quieter answer: an explanation that reports `allowed: + * true`, or a record's `visible: false` with no decider, for a request + * enforcement refuses, fails its row. So does an explanation that refuses what + * enforcement answers. + * + * ## The table + * + * Every row drives BOTH faces through the real stack — the real + * `SecurityPlugin`, a real `ObjectQL` over a real SQL driver (better-sqlite3, + * one in-memory database per rig) — and asserts enforcement's own outcome as + * well, so a row cannot go green by both faces drifting together. The rows are + * the explain-versus-enforce family's shapes so far: the record-grained write + * verdict's inputs (#19963), the record-grained read verdict's read depth + * (#19986), a shared dependency that throws (#20002), the record matcher under + * a cross-class field comparison (#20431), the explained user's organization + * claim (#20580), and this card's three positions (#20604): + * + * 1. the object-level pass under a predicate the find refuses answered + * `allowed: true`, the `rls` layer `narrows` and the predicate as + * `readFilter`; + * 2. a record id that does not exist, under the same predicate, answered its + * missing-record shape (`visible: false`, no decider); + * 3. the user explained by another carried no organization of its own, so a + * current member was denied a tenant object their own find reads, and a + * permission set scoped to their organization did not load. + * + * A new explain position, or a new way enforcement can refuse, is a new row + * here. The next divergence then fails a row instead of becoming another card. + * It is a test, not a gate. + * + * `@objectstack/core` and `@objectstack/plugin-sharing` resolve through their + * built `dist/` here, as this package's other suites read them. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveAuthzContext, assembleExecutionContext } from '@objectstack/core'; +import { SharingService, buildSharingMiddleware, SysRecordShare } from '@objectstack/plugin-sharing'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import type { ExplainDecision, TenancyPosture } from '@objectstack/spec/security'; +import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; + +import { SysPosition } from './objects/sys-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { context: { isSystem: true } } as never; +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; + +// ── the two faces ───────────────────────────────────────────────────────── + +type Envelope = { code: string; status: number }; +const INVALID: Envelope = { code: 'INVALID_FILTER', status: 400 }; +const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 }; + +/** What enforcement answered the principal's own request with. */ +type Enforced = + | { kind: 'rows'; ids: string[] } + | { kind: 'admitted' } + | ({ kind: 'refused' } & Envelope) + | { kind: 'sets'; names: string[] }; + +/** What explain answered for the same principal, object and operation. */ +type Explained = + | ({ kind: 'refused' } & Envelope & { message: string }) + | { kind: 'decision'; decision: ExplainDecision }; + +/** Which verdict of the explanation the row compares. */ +type Position = + | 'object.allowed' + | 'object.readFilter' + | 'record.visible' + | 'principal.permissionSets'; + +const envelopeOf = (e: unknown): Envelope => { + const x = e as { code?: string; status?: number; statusCode?: number }; + return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) }; +}; + +const explained = (p: Promise): Promise => + p.then( + (decision) => ({ kind: 'decision' as const, decision }), + (e: unknown) => ({ kind: 'refused' as const, ...envelopeOf(e), message: String((e as Error)?.message) }), + ); + +const rowsOf = (p: Promise): Promise => + p.then( + (rows) => ({ + kind: 'rows' as const, + ids: (Array.isArray(rows) ? rows : []).map((r) => String((r as { id?: unknown }).id)).sort(), + }), + (e: unknown) => ({ kind: 'refused' as const, ...envelopeOf(e) }), + ); + +const landed = (p: Promise): Promise => + p.then( + () => ({ kind: 'admitted' as const }), + (e: unknown) => ({ kind: 'refused' as const, ...envelopeOf(e) }), + ); + +/** A short reading of an explanation for a failure message. */ +function describeExplained(x: Explained): string { + if (x.kind === 'refused') return `refused ${x.code} / ${x.status}`; + const d = x.decision; + const rls = d.layers.find((l) => l.layer === 'rls')?.verdict; + return `allowed: ${d.allowed}, rls: ${rls}` + + (d.record ? `, record: ${JSON.stringify(d.record)}` : '') + + (d.readFilter !== undefined ? `, readFilter: ${JSON.stringify(d.readFilter)}` : ''); +} + +/** + * THE invariant, once. `readAs` applies an explained `readFilter` as a system + * read, so the object-level filter is compared by the rows it admits. + */ +async function expectParity( + row: string, + position: Position, + explain: Explained, + enforce: Enforced, + ctx: { recordId?: string; readAs?: (filter: unknown) => Promise } = {}, +): Promise { + const where = `${row} · ${position}: explain answered ${describeExplained(explain)}; enforcement ${JSON.stringify(enforce)}`; + if (enforce.kind === 'refused') { + if (enforce.code === INVALID.code) { + expect(explain.kind === 'refused' ? { code: explain.code, status: explain.status } : 'answered', where) + .toEqual(INVALID); + return; + } + if (explain.kind === 'refused') { + expect({ code: explain.code, status: explain.status }, where).toEqual(INVALID); + return; + } + const d = explain.decision; + if (position === 'record.visible') expect(d.record?.visible, where).toBe(false); + else expect(d.allowed, where).toBe(false); + return; + } + expect(explain.kind, `${where} — explain refused what enforcement answered`).toBe('decision'); + if (explain.kind !== 'decision') return; + const d = explain.decision; + switch (position) { + case 'principal.permissionSets': + expect(enforce.kind, where).toBe('sets'); + if (enforce.kind === 'sets') expect([...d.principal.permissionSets].sort(), where).toEqual([...enforce.names].sort()); + return; + case 'record.visible': { + const reached = enforce.kind === 'admitted' || (enforce.kind === 'rows' && enforce.ids.includes(String(ctx.recordId))); + expect(d.record?.visible, where).toBe(reached); + return; + } + case 'object.allowed': { + const reached = enforce.kind === 'admitted' || (enforce.kind === 'rows' && enforce.ids.length > 0); + expect(d.allowed, where).toBe(reached); + return; + } + case 'object.readFilter': { + expect(enforce.kind, where).toBe('rows'); + if (enforce.kind !== 'rows' || !ctx.readAs) throw new Error(`${row}: a readFilter row needs rows and readAs`); + expect(d.allowed, where).toBe(true); + expect(await ctx.readAs(d.readFilter), where).toEqual(enforce.ids); + return; + } + } +} + +// ── rig 1: one row-level policy, the caller explaining themselves ──────────── + +let seq = 0; +const next = () => `${process.pid}_${++seq}`; + +/** The caller the RLS rig explains: the set's only holder, asking about themselves. */ +const RLS_CALLER = { userId: 'usr_member', positions: ['qa_pos'], permissions: ['qa_deal_guard'], posture: 'MEMBER' }; + +/** + * A `public_read_write` object (no OWD narrowing, so the row-level policy is + * the only thing between the caller and a row), one permission set holding one + * `operation: 'all'` policy, and rows `r1` / `r2`. + */ +async function bootRls(predicate: string) { + const OBJ = `qa_parity_deal_${next()}`; + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: `com.objectstack.qa.explain-parity-rls-${seq}`, + name: 'Explain parity: one row-level policy', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { + name: OBJ, + label: 'Deal', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + status: { name: 'status', type: 'text' }, + title: { name: 'title', type: 'text' }, + amount: { name: 'amount', type: 'number' }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + await engine.insert(OBJ, [ + { id: 'r1', status: 'open', title: 'x', amount: 5 }, + { id: 'r2', status: 'open', title: 'open', amount: 7 }, + ], SYS); + + const set = PermissionSetSchema.parse({ + name: 'qa_deal_guard', + objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [{ name: 'deal_guard', object: OBJ, operation: 'all', using: predicate }], + }); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER_DEFAULT, set], + }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + 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: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + // The driver logs the refused comparison it withholds from the caller. + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + const caller = { ...RLS_CALLER }; + type Op = 'read' | 'create' | 'update' | 'delete'; + return { + explain: (operation: Op, recordId?: string) => + explained(plugin.explainAccessForCaller({ object: OBJ, operation, ...(recordId ? { recordId } : {}) }, caller)), + find: (where?: Record) => + rowsOf(engine.find(OBJ, { ...(where ? { where } : {}), context: caller } as never)), + update: (id: string) => + landed(engine.update(OBJ, { title: 'y' }, { where: { id }, context: caller } as never)), + remove: (id: string) => landed(engine.delete(OBJ, { where: { id }, context: caller } as never)), + insert: () => + landed(engine.insert(OBJ, { id: `r_new_${next()}`, status: 'open', title: 'z', amount: 1 }, { context: caller } as never)), + readAs: async (filter: unknown) => + ((await engine.find(OBJ, { ...(filter ? { where: filter } : {}), context: { isSystem: true } } as never)) as Array<{ id: string }>) + .map((r) => String(r.id)).sort(), + teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } }, + }; +} + +/** `record.status != record.amount`: text against number, two comparison classes. */ +const CROSS_CLASS = 'record.status != record.amount'; +/** The same pair, the other way round. */ +const CROSS_CLASS_REVERSED = 'record.amount > record.status'; +/** `record.status != record.title`: text against text — the control. */ +const SAME_CLASS = 'record.status != record.title'; + +// ── rig 2: an administrator explaining another user ───────────────────────── + +const ALPHA = 'org_alpha'; +const BETA = 'org_beta'; +/** An `org_alpha` member holding `manage_users` there: the caller who explains. */ +const USER_ADMIN = 'usr_alpha_admin'; +/** A current `org_alpha` member. */ +const USER_MEMBER = 'usr_alpha_member'; +/** Removed from `org_alpha`, still a member of `org_beta`; the `org_alpha` grants were left behind. */ +const USER_REMOVED = 'usr_alpha_removed'; + +/** Declared in metadata, granted in `org_alpha`: reads the tenant ledger and the global probe. */ +const READER_SET = 'qa_parity_reader'; +/** + * Authored only as a `sys_permission_set` row OF `org_alpha` (no metadata + * declaration), granted in `org_alpha`: it opens the global note object. + */ +const ALPHA_ONLY_SET = 'qa_parity_alpha_notes'; + +/** How the deployment names its tenancy posture. */ +type PostureSource = + /** A `tenancy` service, as plugin-auth registers it: admission and the plugin read the same posture. */ + | { tenancy: TenancyPosture } + /** Only `org-scoping`, no `tenancy` service: admission reads no posture, the plugin probes `isolated`. */ + | { orgScopingOnly: true }; + +/** + * Real platform identity objects, three users, an `org_alpha`-scoped grant of + * each set to the two `org_alpha` users, and three objects: + * + * - `LEDGER` — a tenant object (the tenant wall applies), rows in both + * organizations; + * - `PROBE` — platform-global (`tenancy: { enabled: false }`), so only the + * grants decide; + * - `NOTES` — platform-global, opened only by {@link ALPHA_ONLY_SET}. + * + * Enforcement's face is `resolveAuthzContext` with a session that claims + * `org_alpha`, handed the posture admission hands it, assembled by + * `assembleExecutionContext`, then the request through the engine. + */ +async function bootPrincipal(source: PostureSource) { + const n = next(); + const LEDGER = `qa_parity_ledger_${n}`; + const PROBE = `qa_parity_probe_${n}`; + const NOTES = `qa_parity_notes_${n}`; + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + const textFields = { id: { name: 'id', type: 'text', primaryKey: true }, name: { name: 'name', type: 'text' } }; + engine.registerApp({ + id: `com.objectstack.qa.explain-parity-principal-${seq}`, + name: 'Explain parity: an administrator explaining another user', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet, + SysOrganization, SysUser, SysMember, + { name: LEDGER, label: 'Ledger', sharingModel: 'public_read_write', fields: textFields }, + { name: PROBE, label: 'Probe', sharingModel: 'public_read_write', tenancy: { enabled: false }, fields: textFields }, + { name: NOTES, label: 'Notes', sharingModel: 'public_read_write', tenancy: { enabled: false }, fields: textFields }, + ], + } as never); + await engine.syncSchemas(); + + const e = engine as unknown as { insert: (o: string, row: Record, opts: never) => Promise }; + for (const id of [ALPHA, BETA]) await e.insert('sys_organization', { id, name: id, slug: id }, SYS); + for (const id of [USER_ADMIN, USER_MEMBER, USER_REMOVED]) { + await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); + } + await e.insert('sys_member', { id: `mem_admin_${n}`, user_id: USER_ADMIN, organization_id: ALPHA, role: 'member' }, SYS); + await e.insert('sys_member', { id: `mem_member_${n}`, user_id: USER_MEMBER, organization_id: ALPHA, role: 'member' }, SYS); + await e.insert('sys_member', { id: `mem_removed_${n}`, user_id: USER_REMOVED, organization_id: BETA, role: 'member' }, SYS); + const catalogue = (id: string, name: string, organization: string | null, objects: Record) => + e.insert('sys_permission_set', { + id, name, label: name, organization_id: organization, managed_by: 'admin', active: true, + object_permissions: JSON.stringify(objects), system_permissions: JSON.stringify([]), + }, SYS); + await catalogue(`ps_user_admin_${n}`, 'qa_user_admin', null, {}); + await catalogue(`ps_reader_${n}`, READER_SET, null, {}); + await catalogue(`ps_alpha_notes_${n}`, ALPHA_ONLY_SET, ALPHA, { [NOTES]: { allowRead: true } }); + const grant = (user: string, set: string) => + e.insert('sys_user_permission_set', { + id: `ups_${user}_${set}_${n}`, user_id: user, permission_set_id: set, organization_id: ALPHA, + }, SYS); + await grant(USER_ADMIN, `ps_user_admin_${n}`); + for (const u of [USER_MEMBER, USER_REMOVED]) { + await grant(u, `ps_reader_${n}`); + await grant(u, `ps_alpha_notes_${n}`); + } + await e.insert(LEDGER, { id: 'l_alpha', name: 'alpha row', organization_id: ALPHA }, SYS); + await e.insert(LEDGER, { id: 'l_beta', name: 'beta row', organization_id: BETA }, SYS); + await e.insert(PROBE, { id: 'p1', name: 'probe row' }, SYS); + await e.insert(NOTES, { id: 'n1', name: 'note row' }, SYS); + + const userAdminSet = PermissionSetSchema.parse({ name: 'qa_user_admin', objects: {}, systemPermissions: ['manage_users'] }); + const readerSet = PermissionSetSchema.parse({ + name: READER_SET, + objects: { [LEDGER]: { allowRead: true, readScope: 'org' }, [PROBE]: { allowRead: true } }, + }); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER_DEFAULT, userAdminSet, readerSet], + }, + ...('tenancy' in source ? { tenancy: { posture: source.tenancy } } : { 'org-scoping': {} }), + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + // The lifecycle hooks are collected and never fired: nothing on either + // face depends on them, and a bootstrap left running would race teardown. + hook: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + const security = services.security as { resolvePermissionSetsForContext: (c: unknown) => Promise> }; + /** The posture admission hands the resolver: the `tenancy` service's, or none when it is not registered. */ + const admissionPosture = 'tenancy' in source ? source.tenancy : undefined; + + const enforced = async (userId: string) => { + const authz = await resolveAuthzContext({ + ql: engine, + headers: {}, + getSession: async () => ({ + user: { id: userId }, + session: { id: `sess_${userId}_${n}`, userId, activeOrganizationId: ALPHA }, + }), + tenancyPosture: admissionPosture, + }); + return assembleExecutionContext({ + authz, oauth: undefined, localization: undefined, requestLocale: undefined, accessToken: undefined, + } as never)!; + }; + const admin = await enforced(USER_ADMIN); + const objects = { LEDGER, PROBE, NOTES } as const; + type Obj = keyof typeof objects; + + return { + objects, + /** The service method `POST /api/v1/security/explain` calls: the `org_alpha` admin explaining `userId`. */ + explain: (userId: string, object: Obj, recordId?: string) => + explained(plugin.explainAccessForCaller( + { object: objects[object], operation: 'read', userId, ...(recordId ? { recordId } : {}) }, + admin, + )), + /** The explained user's own read, through the real resolver and the real middleware. */ + find: async (userId: string, object: Obj) => + rowsOf(engine.find(objects[object], { context: await enforced(userId) } as never)), + /** The permission sets enforcement resolves for the explained user's own requests. */ + sets: async (userId: string): Promise => ({ + kind: 'sets', + names: (await security.resolvePermissionSetsForContext(await enforced(userId))).map((s) => s.name), + }), + readAs: async (object: Obj, filter: unknown) => + ((await engine.find(objects[object], { ...(filter ? { where: filter } : {}), context: { isSystem: true } } as never)) as Array<{ id: string }>) + .map((r) => String(r.id)).sort(), + tenantOf: async (userId: string) => (await enforced(userId)).tenantId, + teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } }, + }; +} + +// ── rig 3: a private object behind the real sharing service ───────────────── + +/** Read depth `org`: every line, whoever owns it. */ +const SHARING_READER = { userId: 'u_reader', positions: ['qa_pos'], permissions: ['qa_line_reader'], posture: 'MEMBER' }; +/** Write depth `org`: edits every line, whoever owns it. */ +const SHARING_WRITER = { userId: 'u_writer', positions: ['qa_pos'], permissions: ['qa_line_writer'], posture: 'MEMBER' }; +/** Read and write depth `own`: the control. */ +const SHARING_OWN = { userId: 'u_own', positions: ['qa_pos'], permissions: ['qa_line_own'], posture: 'MEMBER' }; + +/** + * A `private`-OWD object with an `owner_id`, the real `SharingService` as the + * kernel's `sharing` service and its middleware on the engine after this + * plugin's, as the platform boots them. Row `l_other` is owned by nobody the + * rig explains. `faultReadFilter` makes the sharing service's read filter + * reject, as a share store that cannot be read does. + */ +async function bootSharing(opts: { faultReadFilter?: boolean } = {}) { + const LINE = `qa_parity_line_${next()}`; + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: `com.objectstack.qa.explain-parity-sharing-${seq}`, + name: 'Explain parity: a private object behind the sharing service', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysRecordShare, + { + name: LINE, + label: 'Line', + sharingModel: 'private', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + owner_id: { name: 'owner_id', type: 'text' }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + await engine.insert(LINE, [ + { id: 'l_other', name: 'owned by someone else', owner_id: 'u_somebody' }, + { id: 'l_own', name: 'owned by the control', owner_id: 'u_own' }, + ], SYS); + + const sets = [ + MEMBER_DEFAULT, + PermissionSetSchema.parse({ name: 'qa_line_reader', objects: { [LINE]: { allowRead: true, readScope: 'org' } } }), + PermissionSetSchema.parse({ + name: 'qa_line_writer', + objects: { [LINE]: { allowRead: true, allowEdit: true, readScope: 'org', writeScope: 'org' } }, + }), + PermissionSetSchema.parse({ + name: 'qa_line_own', + objects: { [LINE]: { allowRead: true, allowEdit: true, readScope: 'own', writeScope: 'own' } }, + }), + ]; + let security: unknown; + let sharing: SharingService | undefined; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => sets, + }, + get sharing() { return sharing; }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { if (name === 'security') security = svc; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + sharing = new SharingService({ engine: engine as never, securityService: () => security as never }); + if (opts.faultReadFilter) { + sharing.buildReadFilter = async () => { throw new Error('share store unavailable'); }; + } + engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' }); + + return { + explain: (caller: object, operation: 'read' | 'update', recordId: string) => + explained(plugin.explainAccessForCaller({ object: LINE, operation, recordId }, { ...caller })), + find: (caller: object, id: string) => rowsOf(engine.find(LINE, { where: { id }, context: { ...caller } } as never)), + update: (caller: object, id: string) => + landed(engine.update(LINE, { name: 'renamed' }, { where: { id }, context: { ...caller } } as never)), + teardown: async () => { try { await engine.destroy(); } catch { /* noop */ } }, + }; +} + +// ── the table ─────────────────────────────────────────────────────────────── + +type RlsRig = Awaited>; +type PrincipalRig = Awaited>; + +interface Row { + /** The family card whose shape this row is. */ + card: string; + /** What is compared, in words. */ + shape: string; + position: Position; + /** Enforcement's own answer, asserted too: a row cannot go green by both faces drifting. */ + enforced: Enforced | 'rows' | 'admitted'; + run: RowRun; +} + +/** A row's run: boots its own rig, and hands back the teardown the table calls once the row is judged. */ +type RowRun = () => Promise Promise }>; +interface RowFaces { + explain: Explained; + enforce: Enforced; + recordId?: string; + /** Applies an explained `readFilter` as a system read — live until the row's teardown. */ + readAs?: (filter: unknown) => Promise; +} + +const withRls = (predicate: string, f: (rig: RlsRig) => Promise): RowRun => async () => { + const rig = await bootRls(predicate); + try { return { ...(await f(rig)), teardown: rig.teardown }; } catch (e) { await rig.teardown(); throw e; } +}; +const withSharing = ( + opts: { faultReadFilter?: boolean }, + f: (rig: Awaited>) => Promise, +): RowRun => async () => { + const rig = await bootSharing(opts); + try { return { ...(await f(rig)), teardown: rig.teardown }; } catch (e) { await rig.teardown(); throw e; } +}; +const withPrincipal = (source: PostureSource, f: (rig: PrincipalRig) => Promise): RowRun => async () => { + const rig = await bootPrincipal(source); + try { return { ...(await f(rig)), teardown: rig.teardown }; } catch (e) { await rig.teardown(); throw e; } +}; + +const REFUSED_INVALID: Enforced = { kind: 'refused', ...INVALID }; +const REFUSED_DENIED: Enforced = { kind: 'refused', ...DENIED }; + +/** Rows over one row-level policy: every verdict position, for a predicate the find refuses and for the control. */ +function rlsRows(card: string, label: string, predicate: string, refused: boolean): Row[] { + const rows: Row[] = [ + { + card, shape: `${label}: object-level read`, position: 'object.allowed', + enforced: refused ? REFUSED_INVALID : 'rows', + run: withRls(predicate, async (r) => ({ explain: await r.explain('read'), enforce: await r.find() })), + }, + { + card, shape: `${label}: object-level read filter`, position: 'object.readFilter', + enforced: refused ? REFUSED_INVALID : 'rows', + run: withRls(predicate, async (r) => ({ explain: await r.explain('read'), enforce: await r.find(), readAs: r.readAs })), + }, + { + card, shape: `${label}: object-level update, against a by-id update`, position: 'object.allowed', + enforced: refused ? REFUSED_DENIED : 'admitted', + run: withRls(predicate, async (r) => ({ explain: await r.explain('update'), enforce: await r.update('r1') })), + }, + { + card, shape: `${label}: object-level delete, against a by-id delete`, position: 'object.allowed', + enforced: refused ? REFUSED_DENIED : 'admitted', + run: withRls(predicate, async (r) => ({ explain: await r.explain('delete'), enforce: await r.remove('r1') })), + }, + { + card, shape: `${label}: object-level create, against an insert`, position: 'object.allowed', + enforced: refused ? REFUSED_INVALID : 'admitted', + run: withRls(predicate, async (r) => ({ explain: await r.explain('create'), enforce: await r.insert() })), + }, + { + card, shape: `${label}: record r1, read`, position: 'record.visible', + enforced: refused ? REFUSED_INVALID : 'rows', + run: withRls(predicate, async (r) => ({ + explain: await r.explain('read', 'r1'), enforce: await r.find({ id: 'r1' }), recordId: 'r1', + })), + }, + { + card, shape: `${label}: record r2, read`, position: 'record.visible', + enforced: refused ? REFUSED_INVALID : 'rows', + run: withRls(predicate, async (r) => ({ + explain: await r.explain('read', 'r2'), enforce: await r.find({ id: 'r2' }), recordId: 'r2', + })), + }, + { + card, shape: `${label}: record r1, update`, position: 'record.visible', + enforced: refused ? REFUSED_DENIED : 'admitted', + run: withRls(predicate, async (r) => ({ explain: await r.explain('update', 'r1'), enforce: await r.update('r1') })), + }, + { + card, shape: `${label}: record r2, update`, position: 'record.visible', + enforced: REFUSED_DENIED, + run: withRls(predicate, async (r) => ({ explain: await r.explain('update', 'r2'), enforce: await r.update('r2') })), + }, + { + card, shape: `${label}: record r1, delete`, position: 'record.visible', + enforced: refused ? REFUSED_DENIED : 'admitted', + run: withRls(predicate, async (r) => ({ explain: await r.explain('delete', 'r1'), enforce: await r.remove('r1') })), + }, + ]; + return rows; +} + +/** Position 2: a record id no row carries, under the same predicate. */ +function missingRecordRows(card: string, label: string, predicate: string, refused: boolean): Row[] { + return [ + { + card, shape: `${label}: a record id that does not exist, read`, position: 'record.visible', + enforced: refused ? REFUSED_INVALID : 'rows', + run: withRls(predicate, async (r) => ({ + explain: await r.explain('read', 'r_missing'), enforce: await r.find({ id: 'r_missing' }), recordId: 'r_missing', + })), + }, + ]; +} + +/** + * Rows over the principal rig: the `org_alpha` administrator explains a user, + * and the user's own request is the other face. `expect` names enforcement's + * outcome per (user, object) for this posture source. + */ +function principalRows( + card: string, + source: PostureSource, + label: string, + expectFor: Record<'removed' | 'member', Partial>>, +): Row[] { + const rows: Row[] = []; + const users = { removed: USER_REMOVED, member: USER_MEMBER } as const; + for (const who of ['removed', 'member'] as const) { + const userId = users[who]; + rows.push({ + card, shape: `${label}: the ${who} user's permission sets`, position: 'principal.permissionSets', + enforced: { kind: 'sets', names: [] }, + run: withPrincipal(source, async (r) => ({ explain: await r.explain(userId, 'PROBE'), enforce: await r.sets(userId) })), + }); + for (const object of ['LEDGER', 'PROBE', 'NOTES'] as const) { + const enforced = expectFor[who][object]; + if (!enforced) continue; + rows.push({ + card, shape: `${label}: the ${who} user, object-level read of ${object}`, position: 'object.allowed', + enforced, + run: withPrincipal(source, async (r) => ({ explain: await r.explain(userId, object), enforce: await r.find(userId, object) })), + }); + if (enforced === 'rows') { + rows.push({ + card, shape: `${label}: the ${who} user, ${object} read filter`, position: 'object.readFilter', + enforced, + run: withPrincipal(source, async (r) => ({ + explain: await r.explain(userId, object), + enforce: await r.find(userId, object), + readAs: (f: unknown) => r.readAs(object, f), + })), + }); + } + } + if (expectFor[who].LEDGER) { + for (const recordId of ['l_alpha', 'l_beta']) { + rows.push({ + card, shape: `${label}: the ${who} user, LEDGER record ${recordId}`, position: 'record.visible', + enforced: expectFor[who].LEDGER!, + run: withPrincipal(source, async (r) => ({ + explain: await r.explain(userId, 'LEDGER', recordId), enforce: await r.find(userId, 'LEDGER'), recordId, + })), + }); + } + } + } + return rows; +} + +const TABLE: Row[] = [ + // #20604 position 1, and #20431's record-grained twin, over both orderings of one cross-class pair. + ...rlsRows('#20604 P1 · #20431', 'cross-class `status != amount`', CROSS_CLASS, true), + ...rlsRows('#20604 P1 · #20431', 'cross-class `amount > status`', CROSS_CLASS_REVERSED, true), + ...rlsRows('control', 'same-class `status != title`', SAME_CLASS, false), + // #20604 position 2. + ...missingRecordRows('#20604 P2', 'cross-class `status != amount`', CROSS_CLASS, true), + ...missingRecordRows('control', 'same-class `status != title`', SAME_CLASS, false), + // #19986: the record read verdict asks the sharing read filter with the caller's read depth. + { + card: '#19986', shape: 'private OWD, an `org` reader, a row owned by someone else, read', position: 'record.visible', + enforced: 'rows', + run: withSharing({}, async (r) => ({ + explain: await r.explain(SHARING_READER, 'read', 'l_other'), enforce: await r.find(SHARING_READER, 'l_other'), recordId: 'l_other', + })), + }, + { + card: '#19986 control', shape: 'private OWD, an `own` reader, a row owned by someone else, read', position: 'record.visible', + enforced: 'rows', + run: withSharing({}, async (r) => ({ + explain: await r.explain(SHARING_OWN, 'read', 'l_other'), enforce: await r.find(SHARING_OWN, 'l_other'), recordId: 'l_other', + })), + }, + // #19963: the record write verdict hands the per-record gate the caller's write depth. + { + card: '#19963', shape: 'private OWD, an `org` writer, a row owned by someone else, update', position: 'record.visible', + enforced: 'admitted', + run: withSharing({}, async (r) => ({ + explain: await r.explain(SHARING_WRITER, 'update', 'l_other'), enforce: await r.update(SHARING_WRITER, 'l_other'), + })), + }, + { + card: '#19963 control', shape: 'private OWD, an `own` writer, a row owned by someone else, update', position: 'record.visible', + // The sharing middleware's by-id write refusal. + enforced: { kind: 'refused', code: 'FORBIDDEN', status: 403 }, + run: withSharing({}, async (r) => ({ + explain: await r.explain(SHARING_OWN, 'update', 'l_other'), enforce: await r.update(SHARING_OWN, 'l_other'), + })), + }, + // #20002: a dependency enforcement shares with explain throws. + { + card: '#20002', shape: 'the sharing read filter throws, the caller\'s own row, read', position: 'record.visible', + // The find fails with the service's own error, which carries no envelope. + enforced: { kind: 'refused', code: 'undefined', status: Number.NaN }, + run: withSharing({ faultReadFilter: true }, async (r) => ({ + explain: await r.explain(SHARING_OWN, 'read', 'l_own'), enforce: await r.find(SHARING_OWN, 'l_own'), recordId: 'l_own', + })), + }, + // #20580 (the removed member) and #20604 position 3 (the current member), under each walled posture. + ...principalRows('#20580 · #20604 P3', { tenancy: 'isolated' }, '`isolated`', { + removed: { LEDGER: REFUSED_DENIED, PROBE: REFUSED_DENIED, NOTES: REFUSED_DENIED }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + }), + ...principalRows('#20580 · #20604 P3', { tenancy: 'group' }, '`group`', { + removed: { LEDGER: REFUSED_DENIED, PROBE: REFUSED_DENIED, NOTES: REFUSED_DENIED }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + }), + ...principalRows('#20580 control · #20604 P3', { tenancy: 'single' }, '`single`', { + removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + }), + ...principalRows('#20604 A4', { orgScopingOnly: true }, '`org-scoping` with no `tenancy` service', { + removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + }), +]; + +describe('security.explain answers what enforcement does — the enumeration', () => { + for (const row of TABLE) { + it(`${row.card} · ${row.shape} · ${row.position}`, async () => { + const { explain, enforce, recordId, readAs, teardown } = await row.run(); + try { + if (row.enforced === 'rows' || row.enforced === 'admitted') expect(enforce.kind, `${row.shape}: enforcement`).toBe(row.enforced); + else if (row.enforced.kind === 'sets') expect(enforce.kind, `${row.shape}: enforcement`).toBe('sets'); + else expect(enforce, `${row.shape}: enforcement`).toEqual(row.enforced); + await expectParity(`${row.card} · ${row.shape}`, row.position, explain, enforce, { recordId, readAs }); + } finally { + await teardown(); + } + }, 60_000); + } +}); From aabc025c3457d8ad92e0663cb835fc94cb059592 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:14:23 +0000 Subject: [PATCH 2/7] fix(plugin-security): security/explain answers enforcement's refusal at object level, and resolves the explained user in their organization - The object-level pass refuses a composed row filter the find cannot run (a field-to-field comparison of no shared comparison class) with the record matcher's own refusal, INVALID_FILTER / 400, the one the record-grained pass already gives. It used to answer allowed: true, rls narrows and the predicate as readFilter, for every operation. The refusal runs before the record-grained pass, so a record id no row carries is refused too, as its find is. A request the capability or CRUD gate denies is still answered as denied there. - explainAccessForCaller sets the explained context's tenantId to the organization vetOrganizationClaim resolved the user in, as resolveDelegatorContext sets a delegator's. A current member was explained with no organization: denied a tenant object under isolated, and missing a permission set their organization authored. - One reading of an object's declared columns (declaredComparisonColumns) for the row-level write check and for explain. - The removed-member keep-pin now asserts parity with enforcement for a current member, instead of an unchanged explanation. - The parity table marks two measured divergences it does not fix. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/declared-comparison-columns.ts | 38 ++++++ .../src/explain-enforce-parity.test.ts | 85 +++++++++++- .../plugin-security/src/explain-engine.ts | 124 ++++++++++++++---- .../explain-removed-member-principal.test.ts | 24 ++-- .../plugin-security/src/security-plugin.ts | 27 ++-- 5 files changed, 251 insertions(+), 47 deletions(-) create mode 100644 packages/plugins/plugin-security/src/declared-comparison-columns.ts diff --git a/packages/plugins/plugin-security/src/declared-comparison-columns.ts b/packages/plugins/plugin-security/src/declared-comparison-columns.ts new file mode 100644 index 00000000000..a75c45a685a --- /dev/null +++ b/packages/plugins/plugin-security/src/declared-comparison-columns.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { MatchesFilterOptions } from '@objectstack/formula'; + +/** + * The declared columns of one object declaration, in the shape the record + * matcher's comparison-class rule reads (`MatchesFilterOptions['fields']`, the + * spec's `crossFieldComparisonVerdict` over each column's `type` and + * `multiple`). + * + * ONE reading, used by the two judges that hand the matcher an object's + * columns: the row-level write check (#20355), which judges the image a write + * would store, and `security/explain` (#20431, #20604), which answers with the + * refusal enforcement gives the same predicate. Where each gets the + * declaration from is its own question; what the declaration SAYS about a + * column is this function's, so the two cannot read one declaration two ways. + * + * A field map keyed by name and a list of `{ name, … }` entries read the same. + * A declaration with no field map hands over no columns (`undefined`), and the + * matcher then judges values only: a missing declaration never manufactures a + * refusal. A column whose `type` is not a string is left out, so it is not + * judged. + */ +export function declaredComparisonColumns(declaration: unknown): MatchesFilterOptions | undefined { + const declared = (declaration as { fields?: unknown } | null | undefined)?.fields; + if (!declared || typeof declared !== 'object') return undefined; + const entries: Array<[string, unknown]> = Array.isArray(declared) + ? (declared as Array<{ name?: unknown }>).filter((f) => f?.name).map((f) => [String(f.name), f]) + : Object.entries(declared as Record); + const fields: Record = {}; + for (const [name, decl] of entries) { + if (!decl || typeof decl !== 'object') continue; + const { type, multiple } = decl as { type?: unknown; multiple?: unknown }; + if (typeof type !== 'string') continue; + fields[name] = { type, multiple: multiple === true }; + } + return { fields }; +} diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index 9bbbbca7567..78237778cf4 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -49,6 +49,21 @@ * here. The next divergence then fails a row instead of becoming another card. * It is a test, not a gate. * + * ## Measured divergences + * + * Two disagreements this table measured are findings of this card, reported + * and not fixed here. Their rows carry the finding's name and assert the + * disagreement itself, so they turn red the day either face moves, and the row + * then joins the invariant: + * + * - `NATIVE_SCOPING_UNDER_SINGLE` (explain's side): under `single` the engine + * still scopes a tenant object's read to the context's organization, which + * explain's tenant layer does not report. + * - `CLAIM_KEPT_UNDER_A_WALL` (enforcement's side): with `org-scoping` and no + * `tenancy` service, admission reads no posture and never drops a removed + * member's organization claim, while this plugin walls Layer 0 at + * `isolated`. Explain vets the claim under the posture the plugin walls with. + * * `@objectstack/core` and `@objectstack/plugin-sharing` resolve through their * built `dist/` here, as this package's other suites read them. */ @@ -202,7 +217,7 @@ const RLS_CALLER = { userId: 'usr_member', positions: ['qa_pos'], permissions: [ * the only thing between the caller and a row), one permission set holding one * `operation: 'all'` policy, and rows `r1` / `r2`. */ -async function bootRls(predicate: string) { +async function bootRls(predicate: string, opts: { grantCrud?: boolean } = {}) { const OBJ = `qa_parity_deal_${next()}`; const engine = new ObjectQL(); engine.registerDriver( @@ -238,7 +253,9 @@ async function bootRls(predicate: string) { const set = PermissionSetSchema.parse({ name: 'qa_deal_guard', - objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + objects: opts.grantCrud === false + ? {} + : { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, rowLevelSecurity: [{ name: 'deal_guard', object: OBJ, operation: 'all', using: predicate }], }); const services: Record = { @@ -575,6 +592,14 @@ interface Row { position: Position; /** Enforcement's own answer, asserted too: a row cannot go green by both faces drifting. */ enforced: Enforced | 'rows' | 'admitted'; + /** Where the invariant leaves explain two answers, the one it must give. */ + explainKind?: Explained['kind']; + /** + * A divergence this table MEASURES and does not fix: the finding it was + * reported as. The row asserts the disagreement, so it turns red the day + * either face moves — and the row then joins the invariant. + */ + divergence?: string; run: RowRun; } @@ -588,8 +613,8 @@ interface RowFaces { readAs?: (filter: unknown) => Promise; } -const withRls = (predicate: string, f: (rig: RlsRig) => Promise): RowRun => async () => { - const rig = await bootRls(predicate); +const withRls = (predicate: string, f: (rig: RlsRig) => Promise, opts: { grantCrud?: boolean } = {}): RowRun => async () => { + const rig = await bootRls(predicate, opts); try { return { ...(await f(rig)), teardown: rig.teardown }; } catch (e) { await rig.teardown(); throw e; } }; const withSharing = ( @@ -691,14 +716,18 @@ function principalRows( source: PostureSource, label: string, expectFor: Record<'removed' | 'member', Partial>>, + /** `who` → the row keys (`sets`, `allowed:`, `readFilter:`, `record:`) that diverge, and why. */ + divergent: Partial>> = {}, ): Row[] { const rows: Row[] = []; const users = { removed: USER_REMOVED, member: USER_MEMBER } as const; for (const who of ['removed', 'member'] as const) { const userId = users[who]; + const divergence = (key: string) => divergent[who]?.[key]; rows.push({ card, shape: `${label}: the ${who} user's permission sets`, position: 'principal.permissionSets', enforced: { kind: 'sets', names: [] }, + divergence: divergence('sets'), run: withPrincipal(source, async (r) => ({ explain: await r.explain(userId, 'PROBE'), enforce: await r.sets(userId) })), }); for (const object of ['LEDGER', 'PROBE', 'NOTES'] as const) { @@ -707,12 +736,14 @@ function principalRows( rows.push({ card, shape: `${label}: the ${who} user, object-level read of ${object}`, position: 'object.allowed', enforced, + divergence: divergence(`allowed:${object}`), run: withPrincipal(source, async (r) => ({ explain: await r.explain(userId, object), enforce: await r.find(userId, object) })), }); if (enforced === 'rows') { rows.push({ card, shape: `${label}: the ${who} user, ${object} read filter`, position: 'object.readFilter', enforced, + divergence: divergence(`readFilter:${object}`), run: withPrincipal(source, async (r) => ({ explain: await r.explain(userId, object), enforce: await r.find(userId, object), @@ -726,6 +757,7 @@ function principalRows( rows.push({ card, shape: `${label}: the ${who} user, LEDGER record ${recordId}`, position: 'record.visible', enforced: expectFor[who].LEDGER!, + divergence: divergence(`record:${recordId}`), run: withPrincipal(source, async (r) => ({ explain: await r.explain(userId, 'LEDGER', recordId), enforce: await r.find(userId, 'LEDGER'), recordId, })), @@ -736,6 +768,26 @@ function principalRows( return rows; } +/** + * Under `single` there is no tenant wall, yet the engine still scopes a tenant + * object's read to the context's organization (driver-native tenant scoping, + * any posture). Explain's tenant layer contributes nothing under `single`, so + * it reports the other organization's row readable. Explain's side; a finding + * of this card, not fixed here. + */ +const NATIVE_SCOPING_UNDER_SINGLE = + 'under `single`, the engine scopes the read to the context organization; explain reports the other organization\'s row'; +/** + * With `org-scoping` and no `tenancy` service, admission hands the resolver no + * posture, so a removed member's organization claim is never dropped, while + * this plugin probes `org-scoping` and walls Layer 0 at `isolated`. Explain + * vets the claim under the posture this plugin walls with. Enforcement's side + * (a claim never dropped under a walled Layer 0); a finding of this card, not + * fixed here. + */ +const CLAIM_KEPT_UNDER_A_WALL = + 'with no `tenancy` service, admission keeps a removed member\'s claim while Layer 0 walls at `isolated`'; + const TABLE: Row[] = [ // #20604 position 1, and #20431's record-grained twin, over both orderings of one cross-class pair. ...rlsRows('#20604 P1 · #20431', 'cross-class `status != amount`', CROSS_CLASS, true), @@ -744,6 +796,13 @@ const TABLE: Row[] = [ // #20604 position 2. ...missingRecordRows('#20604 P2', 'cross-class `status != amount`', CROSS_CLASS, true), ...missingRecordRows('control', 'same-class `status != title`', SAME_CLASS, false), + // #20604 position 1's boundary: a request the CRUD gate denies is denied there, by both faces. + { + card: '#20604 P1 boundary', shape: 'cross-class `status != amount`, no CRUD grant: object-level read', position: 'object.allowed', + enforced: REFUSED_DENIED, + explainKind: 'decision', + run: withRls(CROSS_CLASS, async (r) => ({ explain: await r.explain('read'), enforce: await r.find() }), { grantCrud: false }), + }, // #19986: the record read verdict asks the sharing read filter with the caller's read depth. { card: '#19986', shape: 'private OWD, an `org` reader, a row owned by someone else, read', position: 'record.visible', @@ -796,22 +855,36 @@ const TABLE: Row[] = [ ...principalRows('#20580 control · #20604 P3', { tenancy: 'single' }, '`single`', { removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + }, { + removed: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE, 'record:l_beta': NATIVE_SCOPING_UNDER_SINGLE }, + member: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE, 'record:l_beta': NATIVE_SCOPING_UNDER_SINGLE }, }), + // The posture-source asymmetry: the member's rows hold; the removed member's are enforcement's finding. ...principalRows('#20604 A4', { orgScopingOnly: true }, '`org-scoping` with no `tenancy` service', { removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, member: { LEDGER: 'rows', PROBE: 'rows', NOTES: 'rows' }, + }, { + removed: Object.fromEntries( + ['sets', 'allowed:LEDGER', 'readFilter:LEDGER', 'record:l_alpha', 'allowed:PROBE', 'readFilter:PROBE', + 'allowed:NOTES', 'readFilter:NOTES'].map((k) => [k, CLAIM_KEPT_UNDER_A_WALL]), + ), }), ]; describe('security.explain answers what enforcement does — the enumeration', () => { for (const row of TABLE) { - it(`${row.card} · ${row.shape} · ${row.position}`, async () => { + const title = `${row.card} · ${row.shape} · ${row.position}` + + (row.divergence ? ` · MEASURED DIVERGENCE, reported and not fixed here: ${row.divergence}` : ''); + it(title, async () => { const { explain, enforce, recordId, readAs, teardown } = await row.run(); try { if (row.enforced === 'rows' || row.enforced === 'admitted') expect(enforce.kind, `${row.shape}: enforcement`).toBe(row.enforced); else if (row.enforced.kind === 'sets') expect(enforce.kind, `${row.shape}: enforcement`).toBe('sets'); else expect(enforce, `${row.shape}: enforcement`).toEqual(row.enforced); - await expectParity(`${row.card} · ${row.shape}`, row.position, explain, enforce, { recordId, readAs }); + if (row.explainKind) expect(explain.kind, `${row.shape}: explain answered ${describeExplained(explain)}`).toBe(row.explainKind); + const parity = expectParity(`${row.card} · ${row.shape}`, row.position, explain, enforce, { recordId, readAs }); + if (row.divergence) await expect(parity, `${row.shape}: the measured divergence no longer holds`).rejects.toThrow(); + else await parity; } finally { await teardown(); } diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index c997a632134..fe299ae87b2 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -48,6 +48,7 @@ import type { PermissionEvaluator } from './permission-evaluator.js'; import { superuserBypassBitForOperation } from './permission-evaluator.js'; import { ExplainObjectNotFoundError } from './errors.js'; import { RLS_DENY_FILTER, compiledPolicyNameOf } from './rls-compiler.js'; +import { declaredComparisonColumns } from './declared-comparison-columns.js'; import { unresolvedPostureExplainDetail, type UnresolvedPostureCause, @@ -574,7 +575,8 @@ async function collectGrantProvenance( * function passes on what it is handed and vets nothing itself. * Omitted, the context is the user with NO active organization. The returned * context still carries no `tenantId` of its own; a caller that needs one sets - * it, as {@link resolveDelegatorContext} does. + * it, as {@link resolveDelegatorContext} does, and as the explain API does + * (#20604) with the organization it resolved the user in. */ export async function buildContextForUser( ql: any, @@ -864,29 +866,45 @@ function describeOwd(schema: any): { model: string; declared: boolean; effect: ' * ObjectQL registry is the declaration the find's driver compiles against. A * schema that cannot be read hands over no columns, and the matcher then judges * values only, as it did before — a missing schema never manufactures a refusal. + * + * [#20604] What the declaration says about each column is + * {@link declaredComparisonColumns}, the reading the RLS write check hands the + * same matcher, so the two judges of one policy cannot read one declaration + * two ways. */ function declaredColumnsOf(schema: any): MatchesFilterOptions | undefined { - const fields = schema?.fields; - if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return undefined; - return { fields }; + return declaredComparisonColumns(schema); +} + +/** + * The members of a composed row filter that can each be one policy's compiled + * filter: a `$or` of policies (Layer 1 with several applicable policies), and + * the `$and` that puts the tenant wall, or a delegator's filter, beside it. + * A node `compiledPolicyNameOf` recognises is a member however it is shaped. + */ +function policyMembersOf(node: unknown): unknown[] { + if (node === null || typeof node !== 'object' || compiledPolicyNameOf(node) !== undefined) return [node]; + const keys = Object.keys(node as Record); + const list = keys.length === 1 && (keys[0] === '$and' || keys[0] === '$or') + ? (node as Record)[keys[0]] + : undefined; + return Array.isArray(list) ? list.flatMap(policyMembersOf) : [node]; } /** * [#20431] The names of the policies in a compiled business-RLS filter that * carry a refused field-to-field comparison — the attribution the RLS write - * check logs for the same refusal. The composed filter is one policy's filter or - * `{ $or: [...] }` of them, and `compiledPolicyNameOf` recognises each by - * identity. + * check logs for the same refusal. The composed filter is one policy's filter, + * `{ $or: [...] }` of them, or (the object-level pass, #20604) either one + * `$and`-composed beside the tenant wall or a delegator's filter; + * `compiledPolicyNameOf` recognises each policy by identity + * ({@link policyMembersOf}). */ function refusedPolicyNamesOf( filter: Record, fields: NonNullable, ): string[] { - const members = - compiledPolicyNameOf(filter) === undefined && Array.isArray((filter as { $or?: unknown }).$or) - ? ((filter as { $or: unknown[] }).$or) - : [filter]; - const names = members + const names = policyMembersOf(filter) .filter((m) => findCrossFieldClassRefusal(m as Record, fields) !== null) .map((m) => compiledPolicyNameOf(m) ?? '(unnamed)'); return [...new Set(names)]; @@ -923,13 +941,71 @@ function crossFieldRefusalForExplain( const err = new Error( `${subject} cannot be evaluated: ${refusal.diagnostic}. Enforcement refuses every request this filter ` + 'scopes instead of judging a record (the find answers INVALID_FILTER / 400), so explain answers with the ' + - 'same refusal and reports no record verdict. Compare a field only with a field of the same class, or fix ' + + 'same refusal and reports no verdict. Compare a field only with a field of the same class, or fix ' + 'the declaration of the one that is declared with the wrong type.', ); const { code, status } = cause as { code?: string; status?: number }; return Object.assign(err, { code, status, cause }); } +/** + * [#20431] The record matcher, handed the object's declared columns, with its + * refusal answered the way explain answers it: a field-to-field comparison of + * no shared comparison class becomes {@link crossFieldRefusalForExplain}, and + * any other refusal of the matcher's propagates as the matcher raised it. + */ +function matchUnderDeclaredColumns( + record: Record, + filter: unknown, + object: string, + declaredColumns: MatchesFilterOptions | undefined, +): boolean { + try { + return matchesFilterCondition(record, filter as any, declaredColumns); + } catch (e) { + const refusal = crossFieldClassRefusalCarriedBy(e); + if (refusal && declaredColumns?.fields) { + throw crossFieldRefusalForExplain(e, refusal, object, filter, declaredColumns.fields); + } + throw e; + } +} + +/** + * [#20604] Refuse, as enforcement does, a composed row filter the find cannot + * run — before any verdict is computed from it. + * + * The object-level pass used to publish the composed filter as a fact: `rls` + * `narrows`, the predicate as `readFilter`, `allowed: true`. For a filter + * comparing two columns of no shared comparison class, enforcement runs no + * such request: driver-sql refuses to compile the read, so the find answers + * `INVALID_FILTER` / 400 whatever the rows, a by-id write whose pre-image read + * is that filter fails closed (403), and an insert whose check judges it is + * refused (400). Measured on the real stack: the report said `allowed: true` + * for every operation, for both orderings of one pair, and a record id no row + * carries was reported `visible: false` with no decider, because the record + * matcher never ran. + * + * The judgement is the record matcher's own, not a second rule: the matcher + * judges the declared columns before it reads a record ("refused for every + * record or for none"), so it is asked with no row, and its refusal is + * answered by {@link matchUnderDeclaredColumns}, exactly as the record-grained + * pass answers it. ⛔ No second refusal dialect: the envelope, the message and + * the `cause` are the record-grained pass's. It is asked only when the spec's + * classification finds a refused comparison, so no filter the find runs is + * evaluated here. No declared columns → no judgement, as before. + */ +function refuseWhatTheMatcherRefuses( + filter: unknown, + object: string, + declaredColumns: MatchesFilterOptions | undefined, +): void { + const fields = declaredColumns?.fields; + if (!fields || filter === null || typeof filter !== 'object') return; + if (findCrossFieldClassRefusal(filter as Record, fields) === null) return; + matchUnderDeclaredColumns({}, filter, object, declaredColumns); +} + /** * [C2 / ADR-0095] Inputs the record-grained augmentation needs from the already * computed object-level pass — the row story is decomposed FROM the same facts, @@ -1001,15 +1077,7 @@ async function applyRecordAttribution( const matches = (filter: unknown): boolean | undefined => { if (!recordExists) return undefined; if (filter == null) return true; - try { - return matchesFilterCondition(record as Record, filter as any, declaredColumns); - } catch (e) { - const refusal = crossFieldClassRefusalCarriedBy(e); - if (refusal && declaredColumns?.fields) { - throw crossFieldRefusalForExplain(e, refusal, object, filter, declaredColumns.fields); - } - throw e; - } + return matchUnderDeclaredColumns(record as Record, filter, object, declaredColumns); }; // The composition enforcement runs before the query: when it throws, neither @@ -1455,6 +1523,7 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput } let schema: any = null; try { schema = deps.ql?.getSchema?.(object) ?? null; } catch { schema = null; } + const declaredColumns = declaredColumnsOf(schema); // ── 2. required_permissions AND-gate ────────────────────────────────── const required = deps.requiredCaps(secMeta.requiredPermissions, dataOp); @@ -1708,6 +1777,15 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput } } const filterParts = [agentFilter, delegatorFilter].filter(Boolean) as Record[]; + // [#20604] A composed filter the find cannot run is refused here, as + // enforcement refuses it, and never published as the filter a read runs + // under. Before the record-grained pass, so a record id no row carries is + // refused too, as its find is. Only for a request that reaches the filter: + // one the capability or CRUD gate denies is refused there first, by + // enforcement and by this report alike. + if (!capsDeny && crudAllowed) { + for (const part of filterParts) refuseWhatTheMatcherRefuses(part, object, declaredColumns); + } let readFilter: Record | null | undefined = filterParts.length === 0 ? undefined : filterParts.length === 1 ? filterParts[0] : { $and: filterParts }; const denyAll = filterParts.some(isDenyAll); @@ -1739,7 +1817,7 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput if (input.recordId) { const out = await applyRecordAttribution({ deps, object, recordId: input.recordId, engineOp: dataOp, context, sets, layers, owd, capsDeny, crudAllowed, - vamaEffective, vamaSets, declaredColumns: declaredColumnsOf(schema), + vamaEffective, vamaSets, declaredColumns, }); recordVerdict = out.record; posture = out.posture; diff --git a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts index 6db1941fa93..8f7d29a023b 100644 --- a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts +++ b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts @@ -33,7 +33,13 @@ * * - Walled (`isolated`, `group`): the removed member's explanation lists no * `org_alpha`-scoped set and its grant-driven verdict is enforcement's - * refusal. A current member's explanation is unchanged. + * refusal. A current member's explanation matches enforcement's answer for + * them. [#20604] That keep-pin said "unchanged" until the explained context + * was given the organization the member is resolved in, which changes a + * current member's explanation wherever the organization decides; what it + * keeps is the parity, and the enumeration in + * `explain-enforce-parity.test.ts` holds the tenant-object and + * organization-authored-set rows. * - `single`: there is no wall, enforcement keeps the claim, and so does the * explanation. That is the posture condition of the same check, which is how * this file tells "the check enforcement runs" from a rule of the explainer's @@ -53,9 +59,10 @@ * * The probe object is platform-global (`tenancy: { enabled: false }`, * ADR-0066), so Layer 0 contributes nothing on either face and the verdict - * compared is the one the grants decide. The explained context carries no organization of its own, which on - * a tenant object under `isolated` is a separate explain-versus-enforce - * position this card does not change. + * compared is the one the grants decide. [#20604] The explained context now + * carries the organization the user is resolved in, as enforcement's does; the + * positions that turns on (a tenant object under `isolated`, a permission set + * an organization authored) are rows of `explain-enforce-parity.test.ts`. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; @@ -82,7 +89,7 @@ const ALPHA = 'org_alpha'; const BETA = 'org_beta'; /** An `org_alpha` member holding `manage_users` there: the caller who explains. */ const USER_ADMIN = 'usr_alpha_admin'; -/** A current `org_alpha` member: the keep-pin. */ +/** A current `org_alpha` member: the keep-pin (their explanation matches enforcement). */ const USER_MEMBER = 'usr_alpha_member'; /** Removed from `org_alpha`, still a member of `org_beta`; the grant scoped to `org_alpha` was left behind. */ const USER_REMOVED = 'usr_alpha_removed'; @@ -293,19 +300,20 @@ describe.each(DRIVERS)('[#20580] %s', (_driver, makeDriver) => { expect(d.allowed).toBe(false); }); - it('KEEP · a current member\'s explanation is unchanged: the org_alpha-scoped set is listed, as enforcement resolves it', async () => { + it('KEEP · a current member\'s explanation matches enforcement: the permission sets it lists are the ones enforcement resolves', async () => { const d = await r.rig().explain(USER_MEMBER); const enforcement = await r.rig().enforce(USER_MEMBER); + expect(enforcement.tenantId).toBe(ALPHA); expect(d.principal.permissionSets).toContain(PROBE_SET); expect(sorted(d.principal.permissionSets)).toEqual(sorted(enforcement.sets)); }); - it('KEEP · a current member\'s verdict is granted on both faces', async () => { + it('KEEP · a current member\'s verdict matches enforcement: their own read is admitted, and so is the explanation', async () => { const d = await r.rig().explain(USER_MEMBER); const enforcement = await r.rig().enforce(USER_MEMBER); expect(enforcement.read).toEqual({ admitted: 1 }); expect(crudOf(d)).toBe('grants'); - expect(d.allowed).toBe(true); + expect(d.allowed).toBe('admitted' in enforcement.read); }); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 6a86472402f..faa10fb1e8a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -36,6 +36,7 @@ import { intersectDelegatedScope, d10NarrowingStatement, } from './explain-engine.js'; +import { declaredComparisonColumns } from './declared-comparison-columns.js'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -4732,6 +4733,16 @@ export class SecurityPlugin implements Plugin { if (explainedTenantId !== callerTenantId) { explained = await buildContextForUser(this.ql, request.userId, nowMs, explainedTenantId); } + // [#20604] ...and the organization the user is resolved in is the one + // their requests run in: enforcement's context for them carries it as + // `tenantId`, which the tenant wall, the organization-scoped + // permission-set catalogue and the engine all read. `buildContextForUser` + // returns none of its own, so it is set here from the SAME vetted value, + // as `resolveDelegatorContext` sets a delegator's. Without it a current + // member was explained with NO organization: denied a tenant object + // their own find reads under `isolated`, and without a permission set + // their organization authored. + if (explainedTenantId !== undefined) explained.tenantId = explainedTenantId; targetContext = explained; } @@ -8835,6 +8846,11 @@ export class SecurityPlugin implements Plugin { * a schema that cannot be loaded must not manufacture refusals (the field * guard's rule in `rls-compiler.ts`, `RlsFieldGuard`). A field without a string `type` is * left out, so it is never judged. + * + * [#20604] What the declaration says about each column is + * {@link declaredComparisonColumns}, the one reading `security/explain` + * hands the same matcher, so the two judges of one policy read one + * declaration one way. */ private async writeCheckFieldOptions(object: string): Promise { let obj: any; @@ -8844,16 +8860,7 @@ export class SecurityPlugin implements Plugin { } catch { return undefined; } - if (!obj || !obj.fields || typeof obj.fields !== 'object') return undefined; - const fields: Record = {}; - const entries: Array<[string, any]> = Array.isArray(obj.fields) - ? (obj.fields as any[]).filter((f) => f?.name).map((f) => [String(f.name), f]) - : Object.entries(obj.fields as Record); - for (const [name, decl] of entries) { - if (!decl || typeof decl !== 'object' || typeof decl.type !== 'string') continue; - fields[name] = { type: decl.type, multiple: decl.multiple === true }; - } - return { fields }; + return declaredComparisonColumns(obj); } private async loadObjectFieldNames( From 6d23ba1ca0e8c2c7ffdc13efbffcebd2b21fa8eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:15:17 +0000 Subject: [PATCH 3/7] refactor(core): the API-key arm of resolveAuthzContext asks vetOrganizationClaim for its membership rule No behaviour change. The key arm spelled the rule inline (a walled posture, and the key's organization absent from accessible_org_ids); it now asks the function the session arm and security/explain ask. Only the consequence stays the arm's own: an unbacked key is refused, where a session's claim is dropped. The key-arm cases pass unchanged. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../core/src/security/resolve-authz-context.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 716229549b3..cc4b63ba7b1 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -451,9 +451,13 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise Date: Tue, 29 Sep 2026 10:18:12 +0000 Subject: [PATCH 4/7] chore(changeset): patch plugin-security and core for the explain/enforce closeout Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .changeset/20604-explain-enforce-closeout.md | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/20604-explain-enforce-closeout.md diff --git a/.changeset/20604-explain-enforce-closeout.md b/.changeset/20604-explain-enforce-closeout.md new file mode 100644 index 00000000000..b90f9cc2c0a --- /dev/null +++ b/.changeset/20604-explain-enforce-closeout.md @@ -0,0 +1,23 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/core': patch +--- + +fix(plugin-security): `security/explain` answers enforcement's refusal at the object level too, and explains another user in the organization they are resolved in (#20604) + +Clause-②: no + +Two answers of `POST /api/v1/security/explain` disagreed with what the same principal's own request gets from enforcement. + +**A row-level policy that compares two fields of no shared comparison class** (text against a number, or any field against a file field, a formula field, or a field that holds a list or an object). The SQL driver refuses to compile such a read, so the find answers `INVALID_FILTER` / 400. A by-id update or delete fails closed at its row-level gate, and an insert whose check judges the policy is refused with `INVALID_FILTER` / 400. An object-level explanation (no `recordId`) still answered `allowed: true`, the `rls` layer `narrows`, and the predicate as `readFilter`, for every operation. A `recordId` that no row carries was answered `visible: false` with no deciding layer. Both are now refused with the envelope a record-grained explanation already gives: `INVALID_FILTER` / 400, with the message that names the policy and both fields. A request that the capability gate or the CRUD grant denies is still explained as denied there. + +**Another user explained by an administrator.** The explanation now carries the organization the user is resolved in, as enforcement's context for that user does. Before, a current member of the administrator's organization was explained with no organization. Under `isolated`, that member was reported denied on a tenant object their own find reads. Under every posture, a permission set that their organization authored (a `sys_permission_set` row scoped to that organization) was missing from the explanation and from the verdicts it decides. + +`@objectstack/core`: the API-key arm of `resolveAuthzContext` asks `vetOrganizationClaim` for its membership rule, as the session arm does. This is a refactor with no behaviour change. A key whose owner is no longer a member of its organization is still refused. + +Unchanged: + +- Enforcement admits and refuses exactly what it did before. +- A comparison between two fields of one class keeps its verdicts, at the object level and per record. +- Explaining yourself. +- A removed member's explanation (no organization, as enforcement resolves them). From 571cf85e331dbc59f560ef00a2356f0fc45c9cc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:24:03 +0000 Subject: [PATCH 5/7] test(plugin-security): the parity pin reads the optional permission-set list as the type declares it Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../plugins/plugin-security/src/explain-enforce-parity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index 78237778cf4..e5b45beca56 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -182,7 +182,7 @@ async function expectParity( switch (position) { case 'principal.permissionSets': expect(enforce.kind, where).toBe('sets'); - if (enforce.kind === 'sets') expect([...d.principal.permissionSets].sort(), where).toEqual([...enforce.names].sort()); + if (enforce.kind === 'sets') expect([...(d.principal.permissionSets ?? [])].sort(), where).toEqual([...enforce.names].sort()); return; case 'record.visible': { const reached = enforce.kind === 'admitted' || (enforce.kind === 'rows' && enforce.ids.includes(String(ctx.recordId))); From 6434c52ab6065f7b33ca07d42fb2e8eedabc4038 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:11:42 +0000 Subject: [PATCH 6/7] test(plugin-security): the parity pin models an absent error envelope as absence The #20002 row spelled enforcement's un-enveloped failure as a code string, which the error-code casing guard reads as a lowercase code in a code position. Envelope's code and status are now optional, envelopeOf answers undefined for an error that carries none, the row states no code and no status, and the comparison treats no envelope as its own value: enforcement's envelope is compared with both halves spelled, and an explain refusal answering an un-enveloped failure must carry none either. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/explain-enforce-parity.test.ts | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index e5b45beca56..77774fb62c6 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -14,6 +14,11 @@ * will not compile) ⇒ explain refuses with the same envelope; * - enforcement refuses otherwise (a 403) ⇒ explain refuses with * `INVALID_FILTER` / 400, or its verdict at that position is a denial; + * - enforcement fails with an error that carries NO envelope (a dependency's + * own error, thrown as raised) ⇒ explain fails the same way, with no + * envelope, or its verdict at that position is a denial. No envelope is its + * own value, compared as absence: `code` and `status` are absent, never a + * spelled-out placeholder; * - enforcement answers ⇒ explain answers too, and its verdict at that * position is enforcement's: the same row set for the object-level * `allowed` / `readFilter`, the same row for `record.visible`, the same @@ -90,7 +95,12 @@ const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_defa // ── the two faces ───────────────────────────────────────────────────────── -type Envelope = { code: string; status: number }; +/** + * An error's ADR-0112 envelope. Both halves are optional because a failure can + * carry neither: {@link envelopeOf} then answers `undefined`, and the refusal + * has no `code` and no `status` at all. + */ +type Envelope = { code?: string; status?: number }; const INVALID: Envelope = { code: 'INVALID_FILTER', status: 400 }; const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 }; @@ -113,11 +123,19 @@ type Position = | 'record.visible' | 'principal.permissionSets'; -const envelopeOf = (e: unknown): Envelope => { - const x = e as { code?: string; status?: number; statusCode?: number }; - return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) }; +/** The envelope an error carries, or `undefined` when it carries none. */ +const envelopeOf = (e: unknown): Envelope | undefined => { + const x = e as { code?: unknown; status?: unknown; statusCode?: unknown } | null | undefined; + const code = x?.code == null ? undefined : String(x.code); + const rawStatus = x?.statusCode ?? x?.status; + const status = rawStatus == null || Number.isNaN(Number(rawStatus)) ? undefined : Number(rawStatus); + if (code === undefined && status === undefined) return undefined; + return { ...(code !== undefined ? { code } : {}), ...(status !== undefined ? { status } : {}) }; }; +/** An envelope with both halves spelled, so absence compares as absence. */ +const envelopeKeysOf = (x: Envelope) => ({ code: x.code, status: x.status }); + const explained = (p: Promise): Promise => p.then( (decision) => ({ kind: 'decision' as const, decision }), @@ -168,7 +186,10 @@ async function expectParity( return; } if (explain.kind === 'refused') { - expect({ code: explain.code, status: explain.status }, where).toEqual(INVALID); + // No envelope is its own value: a failure that carries none is matched + // only by explain failing without one; anything else by the refusal. + const noEnvelope = enforce.code === undefined && enforce.status === undefined; + expect(envelopeKeysOf(explain), where).toStrictEqual(envelopeKeysOf(noEnvelope ? {} : INVALID)); return; } const d = explain.decision; @@ -837,8 +858,9 @@ const TABLE: Row[] = [ // #20002: a dependency enforcement shares with explain throws. { card: '#20002', shape: 'the sharing read filter throws, the caller\'s own row, read', position: 'record.visible', - // The find fails with the service's own error, which carries no envelope. - enforced: { kind: 'refused', code: 'undefined', status: Number.NaN }, + // The find fails with the sharing service's own error, which carries no + // envelope: no code, no status. + enforced: { kind: 'refused', code: undefined }, run: withSharing({ faultReadFilter: true }, async (r) => ({ explain: await r.explain(SHARING_OWN, 'read', 'l_own'), enforce: await r.find(SHARING_OWN, 'l_own'), recordId: 'l_own', })), @@ -880,7 +902,12 @@ describe('security.explain answers what enforcement does — the enumeration', ( try { if (row.enforced === 'rows' || row.enforced === 'admitted') expect(enforce.kind, `${row.shape}: enforcement`).toBe(row.enforced); else if (row.enforced.kind === 'sets') expect(enforce.kind, `${row.shape}: enforcement`).toBe('sets'); - else expect(enforce, `${row.shape}: enforcement`).toEqual(row.enforced); + else if (row.enforced.kind === 'refused') { + expect(enforce.kind, `${row.shape}: enforcement`).toBe('refused'); + if (enforce.kind === 'refused') { + expect(envelopeKeysOf(enforce), `${row.shape}: enforcement's envelope`).toStrictEqual(envelopeKeysOf(row.enforced)); + } + } else expect(enforce, `${row.shape}: enforcement`).toEqual(row.enforced); if (row.explainKind) expect(explain.kind, `${row.shape}: explain answered ${describeExplained(explain)}`).toBe(row.explainKind); const parity = expectParity(`${row.card} · ${row.shape}`, row.position, explain, enforce, { recordId, readAs }); if (row.divergence) await expect(parity, `${row.shape}: the measured divergence no longer holds`).rejects.toThrow(); From b8fe3069cbb780b5686984677357126741f6bea4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 11:48:03 +0000 Subject: [PATCH 7/7] test(plugin-security): the parity pin's RLS and sharing rigs collect lifecycle hooks unfired, so no bootstrap read races teardown Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../plugin-security/src/explain-enforce-parity.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index 77774fb62c6..002ce6a91bf 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -290,6 +290,9 @@ async function bootRls(predicate: string, opts: { grantCrud?: boolean } = {}) { const ctx = { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, registerService: vi.fn(), + // As in rig 2: the lifecycle hooks are collected and never fired, so no + // bootstrap read is left running into the engine teardown. + hook: () => undefined, getService: (name: string) => { if (!(name in services)) throw new Error(`service not registered: ${name}`); return services[name]; @@ -576,6 +579,7 @@ async function bootSharing(opts: { faultReadFilter?: boolean } = {}) { const ctx = { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, registerService: (name: string, svc: unknown) => { if (name === 'security') security = svc; }, + hook: () => undefined, getService: (name: string) => { if (!(name in services)) throw new Error(`service not registered: ${name}`); return services[name];