diff --git a/.changeset/21908-principal-less-strict-mode.md b/.changeset/21908-principal-less-strict-mode.md new file mode 100644 index 00000000000..81eb9364b3c --- /dev/null +++ b/.changeset/21908-principal-less-strict-mode.md @@ -0,0 +1,35 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/metadata-protocol': patch +--- + +feat(plugin-security)!: a data-engine context that carries no principal and is not a system context is refused (ADR-0096 D5 strict mode) + +Clause-②: no (narrowing) + + + +**BREAKING** for in-process code: an accept-set narrowing of the data engine's security middleware, shipped as `minor` under the launch-window convention for breaking changes. + +**What is refused now.** A data-engine operation whose execution context carries no principal (no user id, no position, no permission set) and is not a system context. That covers a call that passes no context at all, an empty one, one with only a tenant id, and one with only provenance fields. Every layer answers it the same way: + +- the engine middleware throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb (find, findOne, count, aggregate, insert, update, delete), before the operation runs; +- `canReadObject`, `canExport` and `canWriteObject` answer `false`; +- `getReadFilter` answers the deny filter (zero rows). + +It used to be handed straight through, with no CRUD gate, no row-level security, no field mask and no tenant wall. That contradicted the published contract for an empty tool-execution context, "unauthenticated (RLS-on, sees-nothing)", which this change now keeps. The field projections (`getReadableFields` and its siblings) answer such a context as they answer any caller that resolves no permission set. + +**What is unchanged.** + +- A system context (`isSystem: true`) is admitted everywhere, as before. +- A context that carries a principal without a user id is decided by what it carries: a named permission set, the guest principal, or the public-form grant. +- An unauthenticated HTTP request is answered as before: a door that requires a session answers `401 UNAUTHENTICATED` before the engine is asked anything, and an endpoint an application declared open runs it as the guest principal. + +**What to do.** Code that calls the data engine in-process must state who it acts for. FROM: an engine call with no context, or with a context that names nobody. TO: either + +- pass the caller's execution context, so the caller's own permissions decide; or +- for platform plumbing whose own door already authorized the caller (a store read or write the platform owns), pass the explicit system opt-in, `context: { isSystem: true }`. + +The one-line fix: give every in-process engine call a context, the caller's or `{ isSystem: true }`. The refusal message names both. + +`@objectstack/metadata-protocol`: the protocol's own platform-store reads and writes behind the publish, history, audit, diff, commit, migration and code-only delete doors now pass the explicit system opt-in, like its other store calls. Nothing those doors answer changes. diff --git a/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts b/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts index 43154d9ea11..ec14d6f1c2c 100644 --- a/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts +++ b/packages/metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts @@ -329,3 +329,64 @@ describe('[#21908] row 23 — the repository reads the engine-lane slice left ca }); }); }); + +describe('[#21908] the deny round — the platform-store calls still reaching the engine principal-less', () => { + // ADR-0096 D5 strict mode makes the security middleware REFUSE a context + // with no principal and no `isSystem`. Each producer below read or wrote + // the store that way behind a door that had already authorized its caller + // (publish, history, audit, diff, commits, migration, the legacy delete), + // so each carries the explicit opt-in now, like the calls above. + + it('the publish path: the promotion draft read and the org-scoped publish probes', async () => { + const { engine, calls } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine) as any; + await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'draft' }); + + // No package stated: the promotion reads the draft row's own binding. + await expectSystemOptIn(calls, 'publishMetaItem', async () => { + await protocol.publishMetaItem({ type: 'view', name: 'proj_task_grid' }); + }); + await expectSystemOptIn(calls, 'promoteDraftForPublish', () => + protocol.promoteDraftForPublish({ type: 'view', name: 'proj_task_grid' }).catch(() => undefined)); + await expectSystemOptIn(calls, 'resolveDraftOrgScopeForPublish', () => + protocol.resolveDraftOrgScopeForPublish('view', 'proj_task_grid', 'org_a')); + }); + + it('the readers behind the history, audit, diff and commit doors', async () => { + const { engine, calls } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine) as any; + await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'publish' }); + + await expectSystemOptIn(calls, 'resolveMetaItemOrgScope', () => + protocol.resolveMetaItemOrgScope('view', 'proj_task_grid', 'org_a')); + await expectSystemOptIn(calls, 'auditMetaItem', () => + protocol.auditMetaItem({ type: 'view', name: 'proj_task_grid' })); + const diffCalls = await expectSystemOptIn(calls, 'diffMetaItem', () => + protocol.diffMetaItem({ type: 'view', name: 'proj_task_grid' })); + expect(diffCalls.some((c) => c.table === 'sys_metadata_history' && c.verb === 'find')).toBe(true); + await expectSystemOptIn(calls, 'listCommits', () => protocol.listCommits({ packageId: 'app.pin' })); + await expectSystemOptIn(calls, 'revertCommit', () => + protocol.revertCommit({ commitId: 'c_absent' }).catch(() => undefined)); + await expectSystemOptIn(calls, 'rollbackToPackageCommit', () => + protocol.rollbackToPackageCommit({ commitId: 'c_absent' }).catch(() => undefined)); + }); + + it('the namespace probe, the migration scan and the legacy (code-only) delete', async () => { + const { engine, calls } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine) as any; + await protocol.saveMetaItem({ type: 'view', name: 'proj_task_grid', item: viewBody('proj_task_grid'), mode: 'publish' }); + + await expectSystemOptIn(calls, 'metaTypeNamespaceExists', () => protocol.metaTypeNamespaceExists('view')); + await expectSystemOptIn(calls, 'migrateStoredMetadata', () => protocol.migrateStoredMetadata({})); + + // A code-only row, as a control-plane kernel holds one: the delete + // reaches the legacy path, which reads it and deletes it by id. + await engine.insert('sys_metadata', { + type: 'job', name: 'nightly_job', organization_id: null, package_id: null, state: 'active', + metadata: JSON.stringify({ name: 'nightly_job' }), + }, { context: { isSystem: true } }); + const deleteCalls = await expectSystemOptIn(calls, 'deleteMetaItem (legacy path)', () => + protocol.deleteMetaItem({ type: 'job', name: 'nightly_job' })); + expect(deleteCalls.some((c) => c.verb === 'delete' && c.table === 'sys_metadata')).toBe(true); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 64b88259848..3c2f7d11afc 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7114,12 +7114,18 @@ export class ObjectStackProtocolImplementation implements requestOrgId: string | null, ): Promise { if (requestOrgId === null) return null; + // [#21908, ADR-0096 D5] The explicit system opt-in on both probes: a + // platform store read the door that asked already authorized, scoped by + // the protocol itself (`organization_id` in each `where`). Principal- + // less, the engine refuses it. const inOrg = await this.engine.findOne('sys_metadata_history', { where: { organization_id: requestOrgId, type: singularType, name }, + context: { isSystem: true }, }); if (inOrg) return requestOrgId; const inEnv = await this.engine.findOne('sys_metadata_history', { where: { organization_id: null, type: singularType, name }, + context: { isSystem: true }, }); return inEnv ? null : requestOrgId; } @@ -7212,12 +7218,18 @@ export class ObjectStackProtocolImplementation implements // package, and these probes keep asking that same question. See the // docblock above for the ruling commit c74aefe63 records and its accepted narrowing. const packageDim = packageId !== undefined ? { package_id: packageId } : {}; + // [#21908, ADR-0096 D5] The explicit system opt-in on both probes: a + // platform store read the door that asked already authorized, scoped by + // the protocol itself (`organization_id` in each `where`). Principal- + // less, the engine refuses it. const inOrg = await this.engine.findOne('sys_metadata', { where: { organization_id: requestOrgId, type: singularType, name, state: 'draft', ...packageDim }, + context: { isSystem: true }, }); if (inOrg) return requestOrgId; const inEnv = await this.engine.findOne('sys_metadata', { where: { organization_id: null, type: singularType, name, state: 'draft', ...packageDim }, + context: { isSystem: true }, }); return inEnv ? null : requestOrgId; } @@ -11298,10 +11310,15 @@ export class ObjectStackProtocolImplementation implements // life and never its recent changes (#4674). The `as any` is gone // for the same reason: `EngineQueryOptionsParsed` rejects the wrong key, // and erasing the type is what let it through. + // [#21908, ADR-0096 D5] The explicit system opt-in: the audit + // trail is a platform store the audit door already authorized the + // caller to read; the protocol builds the `where` itself. + // Principal-less, the engine refuses it and this door answered 503. const rows = await this.engine.find('sys_metadata_audit', { where, orderBy: [{ field: 'occurred_at', order: 'desc' }], limit, + context: { isSystem: true }, }); const events = (Array.isArray(rows) ? rows : []).map((r: any) => ({ id: r.id, @@ -19774,7 +19791,9 @@ export class ObjectStackProtocolImplementation implements */ private async metaTypeNamespaceExists(type: string): Promise { try { - const row = await this.engine.findOne('sys_metadata', { where: { type } }); + // [#21908, ADR-0096 D5] The explicit system opt-in: an existence + // probe of the platform store, never a read on a caller's behalf. + const row = await this.engine.findOne('sys_metadata', { where: { type }, context: { isSystem: true } }); return row != null; } catch (error) { this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); @@ -21392,7 +21411,9 @@ export class ObjectStackProtocolImplementation implements // metadata. Archived bodies are never even read. const rows: any[] = []; for (const state of ['active', 'draft'] as const) { - rows.push(...await this.engine.find('sys_metadata', { where: { state } })); + // [#21908, ADR-0096 D5] The explicit system opt-in: the migration + // door already authorized an operator to scan the whole store. + rows.push(...await this.engine.find('sys_metadata', { where: { state }, context: { isSystem: true } })); } for (const row of rows) { @@ -22232,8 +22253,16 @@ export class ObjectStackProtocolImplementation implements // is the 503 the lock read raised before this read moved above it. let draftRow: unknown; try { + // [#21908, ADR-0096 D5] The explicit system opt-in: a platform + // store read, which no caller's grants scope. The publish door + // already authorized the caller, the request carries no + // execution context to forward (only its `actor`), and the + // protocol scopes the row itself (`organization_id` above), + // exactly as its sibling store reads do. Principal-less, the + // engine now refuses it. draftRow = await this.engine.findOne('sys_metadata', { where: { type: singularType, name: request.name, organization_id: orgId, state: 'draft' }, + context: { isSystem: true }, }); } catch (error) { this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); @@ -25164,9 +25193,13 @@ export class ObjectStackProtocolImplementation implements { organization_id: null }, ]; } + // [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a + // platform store the door already authorized; the protocol scopes the + // `where` by organization itself. Principal-less, the engine refuses it. const rows = (await this.engine.find('sys_metadata_commit', { where, ...(request.limit ? { limit: request.limit } : {}), + context: { isSystem: true }, })) as any[]; const mapped = rows.map((r) => ({ id: r.id, @@ -25296,7 +25329,10 @@ export class ObjectStackProtocolImplementation implements { organization_id: null }, ]; } - const row = (await this.engine.findOne('sys_metadata_commit', { where })) as any; + // [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a + // platform store the door already authorized; the protocol scopes the + // `where` by organization itself. Principal-less, the engine refuses it. + const row = (await this.engine.findOne('sys_metadata_commit', { where, context: { isSystem: true } })) as any; if (!row) { const err: any = new Error(`No commit '${request.commitId}'.`); err.code = 'COMMIT_NOT_FOUND'; @@ -25827,7 +25863,10 @@ export class ObjectStackProtocolImplementation implements { organization_id: null }, ]; } - const target = (await this.engine.findOne('sys_metadata_commit', { where })) as any; + // [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a + // platform store the door already authorized; the protocol scopes the + // `where` by organization itself. Principal-less, the engine refuses it. + const target = (await this.engine.findOne('sys_metadata_commit', { where, context: { isSystem: true } })) as any; if (!target) { const err: any = new Error(`No commit '${request.commitId}'.`); err.code = 'COMMIT_NOT_FOUND'; @@ -26268,12 +26307,18 @@ export class ObjectStackProtocolImplementation implements const histRows: Array<{ version: number; body: Record | null }> = []; try { const engineAny = this.engine as any; + // [#21908, ADR-0096 D5] The explicit system opt-in: the history + // lineage is a platform store the diff door already authorized; + // the protocol scopes it by organization itself. Principal-less, + // the engine refuses it, and the `catch` below would have answered + // an empty diff for an item that has history. const rows = await engineAny.find('sys_metadata_history', { where: { organization_id: orgId, type: singularType, name: request.name, }, + context: { isSystem: true }, }); rows.sort((a: any, b: any) => (a.version ?? 0) - (b.version ?? 0)); for (const r of rows) { @@ -26360,8 +26405,11 @@ export class ObjectStackProtocolImplementation implements // compared. ⛔ Do not recover a number by matching bodies or hashes // against history: a publish and a revert both write rows whose // bodies repeat earlier ones. + // [#21908, ADR-0096 D5] The explicit system opt-in, as the history + // read above. const current = (await this.engine.findOne('sys_metadata', { where: { organization_id: orgId, type: singularType, name: request.name, state: 'active' }, + context: { isSystem: true }, })) as { metadata?: unknown; version?: unknown } | null; toBody = current?.metadata == null ? null @@ -26871,7 +26919,12 @@ export class ObjectStackProtocolImplementation implements }; try { - const existing = await this.engine.findOne('sys_metadata', { where: scopedWhere }); + // [#21908, ADR-0096 D5] The explicit system opt-in on the read and + // the delete: a platform store row the delete door already + // authorized the caller to remove, addressed by the protocol's own + // `scopedWhere` and then by id. Principal-less, the engine refuses + // both. + const existing = await this.engine.findOne('sys_metadata', { where: scopedWhere, context: { isSystem: true } }); if (!existing) { return { success: true, @@ -26882,7 +26935,7 @@ export class ObjectStackProtocolImplementation implements : `No ${singularTypeForRepo} '${request.name}' found — nothing to delete.`, }; } - await this.engine.delete('sys_metadata', { where: { id: existing.id } }); + await this.engine.delete('sys_metadata', { where: { id: existing.id }, context: { isSystem: true } }); // Storage teardown (opt-in) — see the repo-path branch above. { diff --git a/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts b/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts index ef6d7ebd08c..645b3f99bf0 100644 --- a/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts +++ b/packages/plugins/plugin-security/src/metadata-unresolvable-posture.test.ts @@ -145,15 +145,20 @@ describe('[#3545] unresolvable object metadata — security posture fails closed await expect(h.run(memberRead())).resolves.toBeDefined(); }); - it('an anonymous request is unaffected — it short-circuits before the posture read', async () => { + it('a principal-less request never reaches the posture read — ADR-0096 D5 strict mode refuses it first', async () => { + // [#21908] It used to be handed through ahead of the posture read. It is + // now refused at that same point, so the outage is still not what + // decides it: the refusal is the strict-mode one, and the posture + // outage is never consulted (nothing is logged for it). const h = await boot({ resolvable: false }); - const anon: any = { + const principalLess: any = { object: 'task', operation: 'find', ast: { where: undefined }, - context: { positions: [], permissions: [] }, // no userId + context: { positions: [], permissions: [] }, // no userId, not isSystem }; - await expect(h.run(anon)).resolves.toBeDefined(); + await expect(h.run(principalLess)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(h.logger.error).not.toHaveBeenCalled(); }); it('a system/boot operation is unaffected — isSystem short-circuits the middleware', async () => { diff --git a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts index e74b2204fa8..7c651c72921 100644 --- a/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts +++ b/packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts @@ -345,24 +345,38 @@ describe('#18682 — a related user the caller cannot read makes the rule fault }); /** - * A caller that is not SYSTEM is bound whether or not it carries a `userId`: the - * public-form submitter (the grant the REST form route builds) and a - * principal-less context (the middleware's fall-open) included. + * A caller that is not SYSTEM is bound whether or not it carries a `userId`: + * the public-form submitter (the grant the REST form route builds) included. + * [#21908] A principal-less context (the middleware's former fall-open) is no + * longer bound by the related read at all: ADR-0096 D5 strict mode refuses it + * at admission, on every door, before anything related is read. */ describe('#18682 — a caller with no userId that is not system is bound like a user', () => { const PUBLIC_FORM = (object: string) => ({ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }); it('org Y’s line: every door refuses identically, whatever that row holds', async () => { - for (const caller of [PUBLIC_FORM('qa_note'), { positions: [], permissions: [] }]) { - const secret = await observe('secret', 'line_y', caller, 'isolated', 'qa_note'); - const open = await observe('public', 'line_y', caller, 'isolated', 'qa_note'); - - expect(open.seen).toEqual(secret.seen); - expect(secret.seen.refusal?.code).toBe('VALIDATION_FAILED'); - expect(secret.seen.committed).toBe(0); - expect(secret.seen.preview.valid).toBe(false); - expect(secret.readsOfLine).toEqual([]); - } + const caller = PUBLIC_FORM('qa_note'); + const secret = await observe('secret', 'line_y', caller, 'isolated', 'qa_note'); + const open = await observe('public', 'line_y', caller, 'isolated', 'qa_note'); + + expect(open.seen).toEqual(secret.seen); + expect(secret.seen.refusal?.code).toBe('VALIDATION_FAILED'); + expect(secret.seen.committed).toBe(0); + expect(secret.seen.preview.valid).toBe(false); + expect(secret.readsOfLine).toEqual([]); + }); + + it('[#21908] a principal-less caller: every door refuses at admission, identically, and the line is never read', async () => { + const caller = { positions: [], permissions: [] }; + const secret = await observe('secret', 'line_y', caller, 'isolated', 'qa_note'); + const open = await observe('public', 'line_y', caller, 'isolated', 'qa_note'); + + expect(open.seen).toEqual(secret.seen); + expect(secret.seen.refusal?.code).toBe('PERMISSION_DENIED'); + expect(secret.seen.update).toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(secret.seen.committed).toBe(0); + expect(secret.seen.preview.valid).toBe(false); + expect(secret.readsOfLine).toEqual([]); }); it('a user only org Y holds, under no wall: every door refuses, whatever its value — the write as not readable, the preview at its write gate', async () => { diff --git a/packages/plugins/plugin-security/src/principal-less-strict-mode.test.ts b/packages/plugins/plugin-security/src/principal-less-strict-mode.test.ts new file mode 100644 index 00000000000..ec7895a03be --- /dev/null +++ b/packages/plugins/plugin-security/src/principal-less-strict-mode.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21908] ADR-0096 D5 STRICT MODE — a context that carries no principal and is + * not a system one is REFUSED, at every layer that used to hand it through. + * + * The published contract sentence (`ChatWithToolsOptions.toolExecutionContext`) + * says an empty context runs "unauthenticated (RLS-on, sees-nothing)". The + * engine middleware used to hand such a context straight to `next()` — no CRUD + * gate, no RLS, no FLS mask, no tenant Layer 0 — and the object-admission + * probes and the row scope agreed with the hand-off. Now: + * + * - the middleware refuses EVERY engine operation with `PermissionDeniedError` + * (`PERMISSION_DENIED` / 403) before the operation runs; + * - `canReadObject` / `canExport` / `canWriteObject` answer `false`; + * - `getReadFilter` answers the deny sentinel (zero rows). + * + * The class is pinned by every spelling a producer hands the engine: no context + * at all, an empty one, empty principal arrays, an explicit `isSystem: false`, + * a tenant id alone, and a provenance-only context. None of them is a + * principal. + * + * The two routes that remain are explicit, and both are pinned unchanged: an + * explicit system context (`isSystem: true`, with or without a principal) is + * admitted everywhere, and a context that carries a principal without a user id + * (a named permission set, the guest position, the public-form grant) is decided + * by what it carries — the deny is keyed on carrying NO principal, never on the + * absence of a user id. + * + * Fixtures are synthetic. Harness mirrors `zero-set-deny-baseline.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { RLS_DENY_FILTER } from './rls-compiler.js'; + +const SCHEMAS: Record = { + ledger: { name: 'ledger', fields: { title: { type: 'text', label: 'Title' } } }, +}; + +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true }; + +/** The member baseline grants the object outright, so a refusal below is never a missing grant. */ +const MEMBER_SET = { name: 'member_default', label: 'Synthetic member', objects: { ledger: CRUD } } as unknown as PermissionSet; +/** A set a user-less context can name, registered by the deployment. */ +const NAMED_SET = { name: 'synth_named', label: 'Synthetic named set', objects: { ledger: CRUD } } as unknown as PermissionSet; +/** What a deployment binds its guest audience to. */ +const GUEST_SET = { name: 'synth_guest', label: 'Synthetic guest set', objects: { ledger: CRUD } } as unknown as PermissionSet; + +async function boot() { + const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; + const registered: Record = {}; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { + registerMiddleware: (mw: any) => middlewares.push(mw), + getSchema: (name: string) => SCHEMAS[name], + findOne: vi.fn(async () => null), + }, + metadata: { + get: async (_type: string, name: string) => SCHEMAS[name], + list: async () => [MEMBER_SET, NAMED_SET, GUEST_SET], + }, + }; + const ctx: Record = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { registered[name] = 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 any); + await plugin.start(ctx as any); + if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware'); + const security = registered['security'] as { + canReadObject(object: string, context?: unknown): Promise; + canExport(object: string, context?: unknown): Promise; + getReadFilter(object: string, context?: unknown): Promise | undefined>; + }; + return { plugin, middleware: middlewares[0], security }; +} + +type Verdict = { admitted: true } | { admitted: false; ran: boolean; code?: unknown; status?: unknown }; + +/** Drive the registered middleware; `ran` says whether the operation itself was reached. */ +async function run( + middleware: (opCtx: any, next: () => Promise) => Promise, + operation: string, + context: Record | undefined, +): Promise { + let ran = false; + const opCtx: Record = { + object: 'ledger', operation, context: context === undefined ? undefined : { ...context }, options: {}, ast: { where: {} }, + }; + if (operation === 'insert' || operation === 'update') opCtx.data = { title: 'SYNTH' }; + if (operation === 'aggregate') opCtx.ast = { aggregations: [{ function: 'count', alias: 'n' }] }; + try { + await middleware(opCtx, async () => { ran = true; }); + return { admitted: true }; + } catch (e) { + const err = e as { code?: unknown; status?: unknown; statusCode?: unknown }; + return { admitted: false, ran, code: err.code, status: err.status ?? err.statusCode }; + } +} + +/** Every probe a door that bypasses the middleware asks, on the one object. */ +async function probes(booted: Awaited>, context: Record | undefined) { + const { plugin, security } = booted; + const c = () => (context === undefined ? undefined : { ...context }); + return { + canReadObject: await security.canReadObject('ledger', c()), + canExport: await security.canExport('ledger', c()), + canInsert: await plugin.canWriteObject('ledger', 'insert', c()), + canInsertPayload: await plugin.canWriteObject('ledger', 'insert', c(), { title: 'SYNTH' }), + canUpdatePayload: await plugin.canWriteObject('ledger', 'update', c(), { title: 'SYNTH' }), + }; +} + +const REFUSED_BEFORE_IT_RUNS = { admitted: false, ran: false, code: 'PERMISSION_DENIED', status: 403 } as const; +const ALL_FALSE = { canReadObject: false, canExport: false, canInsert: false, canInsertPayload: false, canUpdatePayload: false }; +const ALL_TRUE = { canReadObject: true, canExport: true, canInsert: true, canInsertPayload: true, canUpdatePayload: true }; +const READS = ['find', 'findOne', 'count', 'aggregate'] as const; +const WRITES = ['insert', 'update', 'delete'] as const; + +/** Every spelling of "no principal" a producer hands the engine. */ +const PRINCIPAL_LESS: Array<{ label: string; context: Record | undefined }> = [ + { label: 'no context at all', context: undefined }, + { label: 'an empty context', context: {} }, + { label: 'empty principal arrays', context: { positions: [], permissions: [] } }, + { label: 'an explicit isSystem: false', context: { isSystem: false } }, + { label: 'a tenant id alone', context: { tenantId: 'org-1' } }, + { label: 'a provenance-only context', context: { runId: 'run_synth', source: 'synth' } }, +]; + +describe('[#21908] a principal-less, non-system context is refused on read and write (ADR-0096 D5)', () => { + for (const c of PRINCIPAL_LESS) { + describe(c.label, () => { + it('the middleware refuses every read before it runs: PERMISSION_DENIED / 403', async () => { + const { middleware } = await boot(); + for (const operation of READS) { + expect(await run(middleware, operation, c.context), operation).toEqual(REFUSED_BEFORE_IT_RUNS); + } + }); + + it('the middleware refuses every write before it runs: PERMISSION_DENIED / 403', async () => { + const { middleware } = await boot(); + for (const operation of WRITES) { + expect(await run(middleware, operation, c.context), operation).toEqual(REFUSED_BEFORE_IT_RUNS); + } + }); + + it('the object-admission probes answer no, and the row scope is the deny sentinel', async () => { + const booted = await boot(); + expect(await probes(booted, c.context)).toEqual(ALL_FALSE); + expect(await booted.security.getReadFilter('ledger', c.context === undefined ? undefined : { ...c.context })) + .toEqual({ ...RLS_DENY_FILTER }); + }); + }); + } +}); + +describe('[#21908] an explicit system context is unchanged', () => { + for (const context of [{ isSystem: true }, { isSystem: true, userId: 'usr_system' }]) { + it(`${JSON.stringify(context)}: every operation runs, the probes admit, and no row scope applies`, async () => { + const booted = await boot(); + for (const operation of [...READS, ...WRITES]) { + expect(await run(booted.middleware, operation, context), operation).toEqual({ admitted: true }); + } + expect(await probes(booted, context)).toEqual(ALL_TRUE); + expect(await booted.security.getReadFilter('ledger', { ...context })).toBeUndefined(); + }); + } +}); + +describe('[#21908] the deny is keyed on carrying NO principal, never on the absence of a user id', () => { + it('a user-less context naming a registered set is decided by that set', async () => { + const booted = await boot(); + const named = { permissions: ['synth_named'], anonymous: true }; + expect(await run(booted.middleware, 'find', named)).toEqual({ admitted: true }); + expect(await run(booted.middleware, 'insert', named)).toEqual({ admitted: true }); + expect(await booted.security.canReadObject('ledger', { ...named })).toBe(true); + }); + + it('the guest principal is decided by the set its deployment binds to it', async () => { + const booted = await boot(); + const guest = { positions: ['guest'], permissions: ['synth_guest'], principalKind: 'guest', isSystem: false }; + expect(await run(booted.middleware, 'find', guest)).toEqual({ admitted: true }); + expect(await booted.security.canReadObject('ledger', { ...guest })).toBe(true); + }); + + it('the public-form grant admits its declared create on its declared object, and nothing else', async () => { + const booted = await boot(); + const grant = { publicFormGrant: { object: 'ledger' }, permissions: ['synth_named'], anonymous: true }; + expect(await run(booted.middleware, 'insert', grant)).toEqual({ admitted: true }); + expect(await run(booted.middleware, 'delete', grant)).toMatchObject({ admitted: false, ran: false, code: 'PERMISSION_DENIED' }); + }); + + it('CONTROL: a signed-in member is admitted to what its set grants', async () => { + const booted = await boot(); + const member = { userId: 'u_member', tenantId: 'org-1', positions: [], permissions: [] }; + for (const operation of [...READS, ...WRITES]) { + expect(await run(booted.middleware, operation, member), operation).toEqual({ admitted: true }); + } + expect(await probes(booted, member)).toEqual(ALL_TRUE); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 903994ce7af..e7b10b841a3 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1596,13 +1596,13 @@ describe('SecurityPlugin', () => { expect(filter).toBeUndefined(); }); - it('anonymous (no userId/roles/permissions) → undefined (authn gated elsewhere)', async () => { + it('[#21908] principal-less (no userId/positions/permissions, not system) → the deny sentinel (ADR-0096 D5)', async () => { const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); await plugin.init(harness.ctx); await plugin.start(harness.ctx); const filter = await plugin.getReadFilter('task', { positions: [], permissions: [] }); - expect(filter).toBeUndefined(); + expect(filter).toEqual({ ...RLS_DENY_FILTER }); }); it('fail-closed: a permission-resolution throw yields the deny sentinel (never allow-all)', async () => { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b5a151ed058..11ba8acdc94 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -347,10 +347,23 @@ const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze( /** * [#20995] A context that carries NO principal: no position, no named - * permission set and no user id. The engine middleware hands an operation - * under such a context straight to `next()` before it resolves anything, and - * the field projections answer the full field set for it — both read THIS - * predicate, so they cannot disagree about who is handed through. + * permission set and no user id. + * + * [#21908] ADR-0096 D5 — STRICT MODE. A non-system context of this class is + * REFUSED, at every layer that used to hand it through: the engine middleware + * throws {@link principalLessDenial} before it resolves anything, the + * object-admission probes (`canReadObject` / `canWriteObject` / `canExport`) + * answer `false`, and its row scope is the deny sentinel (`getReadFilter`). + * Every one of them reads THIS predicate, so they cannot disagree about who is + * refused. It used to be the hand-off (ADR-0096 E1) — the one exception an + * empty set list had — and the published contract sentence it contradicted + * (`ChatWithToolsOptions.toolExecutionContext`: an empty context is + * "unauthenticated (RLS-on, sees-nothing)") is the one it now keeps. + * + * The two ways to reach the engine are explicit, never a missing field: carry + * the caller's principal, or — for platform plumbing whose own door already + * authorized the caller — the explicit system opt-in (`isSystem: true`), which + * short-circuits every layer ahead of this predicate. * * Every other non-system context reaches the gates, including one whose * positions or named sets resolve to nothing: that caller resolves no @@ -362,10 +375,9 @@ const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze( * grants nothing: it is asked the same grant questions as any caller, over the * empty list, and every one answers no. Object admission refuses it (the * middleware's step 2 CRUD gate, and `canReadObject` / `canWriteObject` / - * `canExport`), and its row scope is the deny sentinel (`getReadFilter`). This - * predicate is the ONE exception an empty list has, everywhere: the - * principal-less hand-off above, which ADR-0096 stages separately. ⛔ No door, - * probe or layer carries a second spelling of "no sets means no restriction". + * `canExport`), and its row scope is the deny sentinel (`getReadFilter`). With + * strict mode the empty list has NO exception left. ⛔ No door, probe or layer + * carries a spelling of "no sets means no restriction". */ function isPrincipalLessContext(context: any): boolean { const positions = context?.positions ?? []; @@ -373,6 +385,25 @@ function isPrincipalLessContext(context: any): boolean { return positions.length === 0 && explicitPermissionSets.length === 0 && !context?.userId; } +/** + * [#21908] ADR-0096 D5 strict mode — the refusal the engine middleware throws + * for a principal-less, non-system context ({@link isPrincipalLessContext}): + * `PermissionDeniedError`, `403 PERMISSION_DENIED`, the engine's one deny + * vocabulary. ⛔ Not the doors' anonymous pair (`401 UNAUTHENTICATED`): every + * HTTP door answers that one first, so what reaches this line is in-process + * plumbing, for which "authenticate" is a false remedy. The text names the two + * that are true. + */ +function principalLessDenial(operation: string, object: string): PermissionDeniedError { + return new PermissionDeniedError( + `[Security] Access denied: operation '${operation}' on object '${object}' was called with a ` + + `context that carries no principal (no user, no position, no permission set) and is not a ` + + `system context. Pass the caller's execution context, or, for platform plumbing whose own ` + + `door already authorized the caller, the explicit system opt-in (isSystem: true).`, + { operation, object }, + ); +} + /** * [#5492] Knobs on the layered RLS computation. Both are COMPOSITION * instructions rather than policy switches: the caller has already consulted an @@ -2436,9 +2467,10 @@ export class SecurityPlugin implements Plugin { // grant's context (no user id, so no baseline: the deployment's guest // set when it registers one, otherwise none) and the posture those sets // read (`resolveCallerPosture`, the zero-set stand-in included). A - // grant context is never the principal-less hand-off below, whatever - // it carries: the submitter is a non-system caller, which is who every - // `maskingRule` declares itself for. + // grant context never reaches the principal-less refusal below + // ([#21908]), whatever it carries: the grant is a declaration-derived + // admission, decided here, and the submitter is a non-system caller, + // which is who every `maskingRule` declares itself for. const formGrant = opCtx.context?.publicFormGrant; if (formGrant && typeof formGrant === 'object' && (formGrant as { object?: string }).object) { const grantObject = (formGrant as { object: string }).object; @@ -2531,7 +2563,7 @@ export class SecurityPlugin implements Plugin { // `allowOrgOverride: false`) a CODE-DECLARED set is refused there with // 403 `NOT_OVERRIDABLE` — the 2026-07-14 "customize / reset via an env // overlay" direction this comment used to state is RETIRED. - // Placed BEFORE the empty-principal fall-open and the CRUD check so the + // Placed BEFORE the principal-less refusal and the CRUD check so the // forging boundary holds even for a principal-less context and a // superuser with modifyAllRecords. System/boot writes carry `isSystem` // and already short-circuited the whole middleware above. @@ -2545,7 +2577,7 @@ export class SecurityPlugin implements Plugin { // Unlike sys_permission_set there is NO ADR-0094 overlay write-through // for these objects, so the refusal must hold for update/delete here // rather than deferring to a downstream translation. Runs BEFORE the - // empty-principal fall-open and the CRUD check so the boundary holds even + // principal-less refusal and the CRUD check so the boundary holds even // for a principal-less context and a superuser with modifyAllRecords. // System/boot writes carry `isSystem` and already short-circuited above, // so the seeder and package publish are unaffected. @@ -2558,7 +2590,7 @@ export class SecurityPlugin implements Plugin { // create and rename-to both — rather than answered 200 and then // declined by the seeder on every boot. Runs AFTER the provenance gate // above (a platform/package row's rename is already refused there on - // provenance) and, like it, BEFORE the empty-principal fall-open and + // provenance) and, like it, BEFORE the principal-less refusal and // the CRUD check. System/boot writes carry `isSystem` and // short-circuited above, so the curated seeder itself is unaffected. await this.assertCuratedCapabilityNameGate(opCtx); @@ -2578,8 +2610,9 @@ export class SecurityPlugin implements Plugin { // members (RBAC link tables, prefs, messaging config) — which declare // userActions opening their verbs — pass through to the DelegatedAdminGate // / RLS below. System/boot writes carry `isSystem` and short-circuited - // above; context-less service writes carry no userId and pass by - // construction. Runs BEFORE the empty-principal fall-open so engine-owned + // above; context-less service writes carry no userId and pass this + // guard by construction, to be refused below ([#21908] ADR-0096 D5). + // Runs BEFORE the principal-less refusal so engine-owned // tables fail CLOSED for principal-less-but-user-context callers. assertEngineOwnedWriteAllowed( // [#12481] The contract's getSchema answers `ServiceObject | @@ -2600,7 +2633,7 @@ export class SecurityPlugin implements Plugin { // through to the ordinary CRUD/RLS checks; delegates need a covering // adminScope (BU subtree + allowlist + strict containment); everyone // else — including holders of plain CRUD grants on these tables — is - // denied. Runs BEFORE the empty-principal fall-open below so RBAC + // denied. Runs BEFORE the principal-less refusal below so RBAC // tables fail CLOSED for principal-less non-system contexts. if (this.delegatedAdminGate) { await this.delegatedAdminGate.assert(opCtx); @@ -2609,12 +2642,16 @@ export class SecurityPlugin implements Plugin { const positions = opCtx.context?.positions ?? []; const explicitPermissionSets = opCtx.context?.permissions ?? []; - // Skip security checks if no positions AND no explicit permission sets - // AND no userId (anonymous/unauthenticated). The auth middleware - // should handle authentication separately. [#20995] The field - // projections read the same predicate for the same hand-off. + // [#21908] ADR-0096 D5 strict mode. No position, no explicit permission + // set and no user id, on a context that is not a system one: REFUSED, + // for every verb, before anything resolves and before the operation + // runs. This used to be the hand-off (`return next()`, ADR-0096 E1): no + // CRUD gate, no RLS, no FLS mask and no tenant Layer 0. The gates above + // still run first and keep their own refusals; the object-admission + // probes and the row scope read the same predicate + // ({@link isPrincipalLessContext}). if (isPrincipalLessContext(opCtx.context)) { - return next(); + throw principalLessDenial(opCtx.operation, opCtx.object); } // 1. Resolve permission sets from BOTH role names and explicit @@ -2708,7 +2745,8 @@ export class SecurityPlugin implements Plugin { // failure above and the dangling-delegator checks already take. // // Blast radius is bounded to exactly the risky case: system/boot writes - // (`isSystem`) and principal-less/anonymous contexts short-circuited above, + // (`isSystem`) short-circuited above and principal-less contexts were + // refused there ([#21908] ADR-0096 D5), // so reaching here means a principal asking for an object whose // declaration is missing — one with resolved grants, or [#20995] one // resolving none, whose masking rules and [#21063] field capability @@ -2921,7 +2959,7 @@ export class SecurityPlugin implements Plugin { // here with the same `PERMISSION_DENIED` any ungranted caller gets. The // guard this replaced admitted that caller to every object no set // grants. A principal-less context never reaches this line — it was - // handed through above ({@link isPrincipalLessContext}, ADR-0096). + // refused above ({@link isPrincipalLessContext}, [#21908] ADR-0096 D5). if (!referentialFieldClearWrite) { const allowed = this.permissionEvaluator.checkObjectPermission( opCtx.operation, @@ -5181,8 +5219,9 @@ export class SecurityPlugin implements Plugin { * it enforces the SAME row scoping the engine middleware applies on `find`. * * Returns: - * - `undefined` → no scope applies (system context, or an unauthenticated - * request with no userId/positions/permissions — authn is gated elsewhere). + * - `undefined` → no scope applies (a system context). [#21908] A context + * with no userId/positions/permissions is not this case: ADR-0096 D5 + * strict mode answers it the `RLS_DENY_FILTER` sentinel below. * - a `FilterCondition` → AND it into the object's scan (the join's `ON`/ * `WHERE` for analytics; the where clause for a plain find). * - the `RLS_DENY_FILTER` sentinel → policies applied but none compiled, or @@ -5833,9 +5872,10 @@ export class SecurityPlugin implements Plugin { * [#21079] And it answers the deny sentinel, not a composed scope, for a * non-system caller who carries a principal and resolves NO permission set — * the ADR-0056 D2 deny baseline, which the middleware enforces by refusing - * that caller at object admission. Only the principal-less context - * ({@link isPrincipalLessContext}, ADR-0096) keeps the sharing predicate - * alone. + * that caller at object admission. [#21908] So does a principal-less one + * ({@link isPrincipalLessContext}, ADR-0096 D5 strict mode), which the + * middleware refuses before it resolves anything: it no longer keeps the + * sharing predicate alone. */ async getReadFilter( object: string, @@ -5843,6 +5883,10 @@ export class SecurityPlugin implements Plugin { ): Promise | undefined> { // System operations bypass scoping (mirrors the middleware's isSystem skip). if (context?.isSystem) return undefined; + // [#21908] ADR-0096 D5 strict mode — the middleware's principal-less + // refusal, mirrored: zero rows, read off the same predicate, before any + // layer resolves. This used to answer the sharing predicate alone. + if (isPrincipalLessContext(context)) return { ...RLS_DENY_FILTER }; // [#4467] The OWD/sharing predicate is resolved for EVERY non-system caller, // ahead of the RLS branches below, because it is a SEPARATE middleware in // the chain this method mirrors: none of the RLS stand-downs below is a @@ -5860,21 +5904,6 @@ export class SecurityPlugin implements Plugin { ); return { ...RLS_DENY_FILTER }; } - // Unauthenticated + position-less + permission-less → no RLS scope (the auth - // layer, not RLS, gates anonymous access; the analytics REST endpoint - // already 401s without a token). Mirrors the middleware's early `return next()` - // — which is the RLS middleware's early exit only, so the sharing predicate - // resolved above still applies. - // [#5815] The controlled_by_parent derivation is deliberately NOT resolved - // ahead of this branch the way the sharing predicate is: it stands down - // without a `userId` (`computeControlledByParentFilter` returns null), and - // this branch is reached only when there is none — so the middleware adds - // nothing here either. Agreement holds by both sides standing down. - // [#21079] The same predicate the middleware hands through on — the one - // exception the deny baseline has (ADR-0096); see below. - if (isPrincipalLessContext(context)) { - return sharingFilter ?? undefined; - } // [#2852] D10 delegator intersection is NOT implemented on this path. // The engine middleware (find/count/aggregate) intersects an on-behalf-of // read with the DELEGATOR's own RLS (resolveDelegatorContext, ~L689), but @@ -5900,7 +5929,7 @@ export class SecurityPlugin implements Plugin { try { const permissionSets = await this.resolvePermissionSetsForContext(context); // [#21079] ADR-0056 D2 — the deny baseline at the row scope. A caller who - // carries a principal (the principal-less hand-off returned above) and + // carries a principal (a principal-less one was refused above) and // resolves NO permission set is refused at the middleware's object // admission, so the scope that agrees with it is zero rows. Composed // from the layers instead, it was the sharing predicate alone — every @@ -6059,10 +6088,11 @@ export class SecurityPlugin implements Plugin { * * The settled answers are the projection's ({@link resolveProjectionFieldMask}): * `undefined` when the schema cannot be resolved; the full set for a system - * context and for a principal-less one (the middleware hands both straight - * through); `[]` on an unresolvable posture or a dangling delegator (fail - * closed). [#20995] A caller who resolves NO permission set is not settled - * early: it holds no capability, so every masking rule reaches it and its + * context (the middleware hands it straight through); `[]` on an + * unresolvable posture or a dangling delegator (fail closed). [#20995] A + * caller who resolves NO permission set — [#21908] a principal-less one + * included — is not settled early: it holds no capability, so every masking + * rule reaches it and its * masked fields are not queryable, as both guards refuse them * ({@link resolveCallerPosture}); [#21063] nor are its capability-gated * fields, which it is not served at all. @@ -6117,15 +6147,15 @@ export class SecurityPlugin implements Plugin { // the same two-step `/auth/me/permissions` performs. permissionSets = await this.resolveFallbackPermissionSets(context); } - // [#20995] A principal-less context is handed straight through by the - // middleware, so no field gate reaches it: the full set, read off the SAME - // predicate. A caller who resolved no set but carries a principal is NOT - // this case — it reaches the gates, and the object's masking rules and - // [#21063] per-field capability contract reach it (resolveCallerPosture), - // so it falls through to the mask below. - if (permissionSets.length === 0 && isPrincipalLessContext(context)) { - return { kind: 'answer', fields: allFields }; - } + // [#21908] ADR-0096 D5 strict mode. A principal-less context used to be + // settled here with the full field set, because the middleware handed it + // through and no field gate reached it. The middleware now refuses it + // before any field gate, as it refuses the read of any caller who resolves + // no set ([#21079]), so it is no longer a case of its own: like every + // caller who resolves no set, the object's masking rules and [#21063] + // per-field capability contract reach it (resolveCallerPosture) and it + // falls through to the mask below. Its object admission is not decided + // here — `canReadObject` / `canWriteObject` answer it `false`. const secMeta = await this.resolveCallerPosture(objectName, permissionSets); // [#3545] Posture unresolvable → expose no columns, the same fail-closed @@ -6179,9 +6209,10 @@ export class SecurityPlugin implements Plugin { * published field projections cannot answer one caller two ways. * * A caller who resolved at least one permission set reads the posture as it - * is ({@link getObjectSecurityMeta}). A caller who resolved NONE — and still - * carries a principal; a principal-less context ({@link isPrincipalLessContext}) - * never reaches a gate — reads a stand-in: every grant-based narrowing at + * is ({@link getObjectSecurityMeta}). A caller who resolved NONE — whether + * it carries a principal or, [#21908] read only by the field projections + * since the middleware refuses it first, none at all + * ({@link isPrincipalLessContext}) — reads a stand-in: every grant-based narrowing at * rest (no object capability contract, `isPrivate` and the tenancy flags * `false`), EXCEPT three things carried from the posture: * @@ -6257,10 +6288,11 @@ export class SecurityPlugin implements Plugin { * one verdict" a property of the code rather than a promise: * * 1. `isSystem` → admit (the middleware's total bypass); - * 2. [#21079] no permission sets resolved → the ADR-0056 D2 deny baseline: - * DENY, as the middleware's CRUD gate answers an empty list; ADMIT only - * a principal-less context, which the middleware hands through before - * any gate ({@link isPrincipalLessContext}, ADR-0096); + * 2. [#21908] a principal-less context ({@link isPrincipalLessContext}) → + * DENY: ADR-0096 D5 strict mode, the middleware's refusal before any + * gate; then [#21079] no permission sets resolved → the ADR-0056 D2 + * deny baseline: DENY, as the middleware's CRUD gate answers an empty + * list; * 3. `secMeta.unresolved` → DENY (#3545 — `isPrivate` would default to * `false`, which is exactly what lets a plain `'*'` wildcard reach an * object ADR-0066 D2 says it must not); @@ -6289,14 +6321,15 @@ export class SecurityPlugin implements Plugin { if (!objectName) return false; // 1. System operations bypass (mirrors the middleware's isSystem skip). if (context?.isSystem) return true; + // 2. [#21908] ADR-0096 D5 strict mode — the middleware refuses a + // principal-less context before it resolves anything, so this does too. + if (isPrincipalLessContext(context)) return false; try { const permissionSets = await this.resolvePermissionSetsForContext(context); - // 2. [#21079] No sets resolved → the deny baseline (ADR-0056 D2): an - // empty list grants nothing, which is the middleware's step 2 answer - // for a caller who carries a principal. Only the principal-less - // context is admitted, as the middleware hands it through. - if (permissionSets.length === 0) return isPrincipalLessContext(context); + // [#21079] No sets resolved → the deny baseline (ADR-0056 D2): an + // empty list grants nothing, which is the middleware's step 2 answer. + if (permissionSets.length === 0) return false; const { isPrivate, unresolved, requiredPermissions } = await this.getObjectSecurityMeta(objectName); @@ -6385,16 +6418,16 @@ export class SecurityPlugin implements Plugin { * the middleware's own primitive, ⛔ never a second reading of * `resolveCrudAffordances`: a user-context write to an `engine-owned` / * `append-only` object whose `userActions` do not open the verb DENIES, - * ahead of the fall-open below and of every resolution; + * ahead of the principal-less refusal below and of every resolution; * 3. ADR-0090 D12 `delegatedAdminGate.assert` — the same gate object the * middleware calls, handed `object`, `operation`, `context` and the rows * of `data`. A plain-CRUD holder on an RBAC link table DENIES; a tenant * admin passes to the arms below; - * 4. [#21079] no permission sets resolved → the ADR-0056 D2 deny baseline: - * DENY, as the middleware's CRUD gate answers an empty list; ADMIT only - * a principal-less context, which the middleware hands through before - * any gate ({@link isPrincipalLessContext}, ADR-0096) and which carries - * no user id for arm 10 to ask about; + * 4. [#21908] a principal-less context ({@link isPrincipalLessContext}) → + * DENY: ADR-0096 D5 strict mode, the middleware's refusal at this point + * in its order; then [#21079] no permission sets resolved → the + * ADR-0056 D2 deny baseline: DENY, as the middleware's CRUD gate + * answers an empty list; * 5. `secMeta.unresolved` → DENY (#3545); * 6. ADR-0066 D3/⑤ `requiredPermissions` capability AND-gate for the WRITE * CRUD class, checked BEFORE the grant, for the caller AND (D10) the @@ -6509,7 +6542,7 @@ export class SecurityPlugin implements Plugin { try { // 2-3. The middleware's two PRE-RESOLUTION caller-class refusals, called // as the middleware calls them and at the same point in its order: - // after the `isSystem` bypass, BEFORE the principal-less fall-open + // after the `isSystem` bypass, BEFORE the principal-less refusal // (arm 4) and before any permission set resolves. Both answer about // the CALLER and the OBJECT, so a caller they refuse cannot reach // the write by omitting a value or naming a different row — which @@ -6562,6 +6595,11 @@ export class SecurityPlugin implements Plugin { throw e; } + // 4. [#21908] ADR-0096 D5 strict mode — the middleware refuses a + // principal-less context at this point in its order, after its + // caller-class gates and before anything resolves, so this does too. + if (isPrincipalLessContext(context)) return false; + const permissionSets = await this.resolvePermissionSetsForContext(context); // 10. [ADR-0123 D2] The no-active-organization write wall — the // middleware's own verdict, `organizationWallRefusal`, under its step @@ -6575,14 +6613,12 @@ export class SecurityPlugin implements Plugin { && (await this.organizationWallRefusal( permissionSets, objectName, operation, context, delegatorSets, delegatorContext, ))); - // 4. [#21079] No sets resolved → the deny baseline (ADR-0056 D2): an + // [#21079] No sets resolved → the deny baseline (ADR-0056 D2): an // empty list grants no create or edit, which is the middleware's step // 2 answer for a caller who carries a principal — refused there // before its field gate or its organization wall is reached, whatever - // the payload. Only the principal-less context is admitted, as the - // middleware hands it through before any gate; it names no user, so - // arm 10 would not be asked about it either. - if (permissionSets.length === 0) return isPrincipalLessContext(context); + // the payload. + if (permissionSets.length === 0) return false; const { isPrivate, unresolved, requiredPermissions, fieldRequiredPermissions } = await this.getObjectSecurityMeta(objectName); @@ -6670,22 +6706,23 @@ export class SecurityPlugin implements Plugin { * Fails CLOSED (an access-narrowing answer): a dangling on-behalf-of delegator * denies, and callers must treat a throw as a denial. `isSystem` bypasses. * [#21079] An empty set resolution is the ADR-0056 D2 deny baseline — `false`, - * as the middleware's CRUD gate refuses the read an export is — except for a - * principal-less context, which the middleware hands through before any gate - * ({@link isPrincipalLessContext}, ADR-0096). + * as the middleware's CRUD gate refuses the read an export is — and [#21908] + * a principal-less context is `false` before anything resolves (ADR-0096 D5 + * strict mode, {@link isPrincipalLessContext}), as the middleware refuses it. */ async canExport(object: string, context?: any): Promise { const objectName = String(object ?? ''); if (!objectName) return false; // System operations bypass (mirrors the middleware's isSystem skip). if (context?.isSystem) return true; + // [#21908] ADR-0096 D5 strict mode — the middleware refuses a + // principal-less context before it resolves anything, so this does too. + if (isPrincipalLessContext(context)) return false; const permissionSets = await this.resolvePermissionSetsForContext(context); // [#21079] No sets resolved → the deny baseline (ADR-0056 D2): an empty - // list grants nothing, which is the middleware's step 2 answer for a caller - // who carries a principal. Only the principal-less context is admitted, as - // the middleware hands it through. - if (permissionSets.length === 0) return isPrincipalLessContext(context); + // list grants nothing, which is the middleware's step 2 answer. + if (permissionSets.length === 0) return false; const { isPrivate, unresolved } = await this.getObjectSecurityMeta(objectName); // [#3545] Posture unresolvable → deny. `isPrivate` would default to `false`, diff --git a/packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts b/packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts index b3668154f9b..61ef89bf5c1 100644 --- a/packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts +++ b/packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts @@ -37,7 +37,9 @@ * Controls: a caller holding the capability is served the stored value and may * query and write the field; a caller holding a set without the capability * reads the same answers as before this card. The last block pins the class's - * boundary: a principal-less context is handed straight through. + * former boundary: a principal-less context used to be handed straight through; + * [#21908] ADR-0096 D5 strict mode refuses it at object admission, and its + * field answers are the zero-set ones. * * Fixtures are synthetic. Harness mirrors `zero-set-masking.test.ts`. */ @@ -300,32 +302,34 @@ describe('[#21063] the controls: a caller who resolves a set reads the answers t }); }); -describe('[#21063] the class boundary: a principal-less context is handed through untouched, and the projections agree', () => { +describe('[#21908] the former class boundary: a principal-less context is refused at object admission (ADR-0096 D5), and the projections give it the zero-set answers', () => { const PRINCIPAL_LESS = { positions: [], permissions: [] }; - it('the middleware serves the field as stored, and admits a query on it and a write naming it', async () => { + it('the middleware refuses the read, every query on the field and every write naming it at object admission; the write admission agrees', async () => { const { plugin, middleware } = await boot(); - expect(await served(middleware, PRINCIPAL_LESS)).toEqual(ROW); + expect( + await run(middleware, { object: 'ledger', operation: 'find', context: { ...PRINCIPAL_LESS }, options: {}, ast: { where: {} } }), + ).toEqual(REFUSED_AT_ADMISSION); for (const [position, operation, ast] of PROBES) { expect( await run(middleware, { object: 'ledger', operation, context: { ...PRINCIPAL_LESS }, options: {}, ast: ast(GATED) }), `${GATED} as ${position}`, - ).toEqual({ admitted: true }); + ).toEqual(REFUSED_AT_ADMISSION); } for (const operation of ['insert', 'update'] as const) { const data = { [GATED]: PAYLOAD_VALUE[GATED] }; expect( await run(middleware, { object: 'ledger', operation, context: { ...PRINCIPAL_LESS }, options: {}, ast: { where: {} }, data }), `${operation} naming ${GATED}`, - ).toEqual({ admitted: true }); - expect(await plugin.canWriteObject('ledger', operation, { ...PRINCIPAL_LESS }, data)).toBe(true); + ).toEqual(REFUSED_AT_ADMISSION); + expect(await plugin.canWriteObject('ledger', operation, { ...PRINCIPAL_LESS }, data)).toBe(false); } }); - it('the read, query and write projections answer the full field set for it', async () => { + it('the read, query and write projections leave the capability-gated field out, as for any caller who resolves no set', async () => { const { plugin } = await boot(); - expect(await plugin.getReadableFields('ledger', { ...PRINCIPAL_LESS })).toEqual(FIELDS); - expect(await plugin.getQueryableFields('ledger', { ...PRINCIPAL_LESS })).toEqual(FIELDS); - expect(await plugin.getWritableFields('ledger', { ...PRINCIPAL_LESS })).toEqual(FIELDS); + expect(await plugin.getReadableFields('ledger', { ...PRINCIPAL_LESS })).toEqual(WITHOUT_GATED); + expect(await plugin.getQueryableFields('ledger', { ...PRINCIPAL_LESS })).toEqual(WITHOUT_GATED); + expect(await plugin.getWritableFields('ledger', { ...PRINCIPAL_LESS })).toEqual(WITHOUT_GATED); }); }); diff --git a/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts b/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts index 547f6342aa2..3c5ed356c81 100644 --- a/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts +++ b/packages/plugins/plugin-security/src/zero-set-deny-baseline.test.ts @@ -32,9 +32,10 @@ * * Controls: a signed-in member who resolves a set is admitted to the object the * set grants (and refused the one it does not), and a deployment that - * registers the named set is decided by that set. The boundary: a - * principal-less context (no position, no named set, no user id) is handed - * through exactly as before (ADR-0096 stages it separately). + * registers the named set is decided by that set. The former boundary: a + * principal-less context (no position, no named set, no user id) used to be + * handed through; [#21908] ADR-0096 D5 strict mode gives it the same answers, + * ahead of resolution (`principal-less-strict-mode.test.ts` pins the class). * * And the same answer for the second principal of a delegated request: an * agent acting for a delegator who resolves no set is refused by the probes as @@ -233,16 +234,16 @@ describe('[#21079] the controls: a caller who resolves a set is decided by that }); }); -describe('[#21079] the boundary: a principal-less context is handed through, unchanged (ADR-0096)', () => { +describe('[#21908] the former boundary: a principal-less context gets the same answers (ADR-0096 D5 strict mode)', () => { const PRINCIPAL_LESS = { positions: [], permissions: [] }; - it('the middleware admits it, the probes admit it, and its row scope is not the deny sentinel', async () => { + it('the middleware refuses every engine operation before it runs, the probes answer no, and its row scope is the deny sentinel', async () => { const booted = await boot({ sets: [MEMBER_SET] }); for (const operation of ENGINE_OPERATIONS) { - expect(await run(booted.middleware, 'ledger', operation, PRINCIPAL_LESS), operation).toEqual({ admitted: true }); + expect(await run(booted.middleware, 'ledger', operation, PRINCIPAL_LESS), operation).toEqual(REFUSED_AT_ADMISSION); } - expect(await probes(booted, 'ledger', PRINCIPAL_LESS)).toEqual(ALL_TRUE); - expect(await booted.security.getReadFilter('ledger', { ...PRINCIPAL_LESS })).toBeUndefined(); + expect(await probes(booted, 'ledger', PRINCIPAL_LESS)).toEqual(ALL_FALSE); + expect(await booted.security.getReadFilter('ledger', { ...PRINCIPAL_LESS })).toEqual({ ...RLS_DENY_FILTER }); }); }); diff --git a/packages/plugins/plugin-security/src/zero-set-masking.test.ts b/packages/plugins/plugin-security/src/zero-set-masking.test.ts index 231e818579f..2fc9f7a3bf2 100644 --- a/packages/plugins/plugin-security/src/zero-set-masking.test.ts +++ b/packages/plugins/plugin-security/src/zero-set-masking.test.ts @@ -33,9 +33,11 @@ * through the public-form grant, which admits ahead of object admission; that * door is pinned in `public-form-grant-masking.test.ts`. * - * The last block pins the class's boundary: a principal-less context (no - * position, no named set, no user id) is handed straight through by the - * middleware, and the projections answer the same full set for it. + * The last block pins the class's former boundary: a principal-less context + * (no position, no named set, no user id) used to be handed straight through + * by the middleware, with the full field set from the projections. [#21908] + * ADR-0096 D5 strict mode refuses it at object admission, before any field + * guard, and its field answers are the zero-set ones above. * * Fixtures are synthetic. Harness mirrors `get-queryable-fields.test.ts`. */ @@ -187,29 +189,30 @@ describe('[#20995] a caller who resolves no permission set: every masking rule a } }); -describe('[#20995] the class boundary: a principal-less context is handed through untouched, and the projections agree', () => { +describe('[#21908] the former class boundary: a principal-less context is refused at object admission (ADR-0096 D5), and the projections give it the zero-set answers', () => { const PRINCIPAL_LESS = { positions: [], permissions: [] }; - it('the middleware serves every field as stored and admits a query on any field', async () => { + it('the middleware refuses the read and every query at object admission; nothing is served', async () => { const { middleware } = await boot(); const opCtx: Record = { - object: 'ledger', operation: 'find', context: { ...PRINCIPAL_LESS }, options: {}, ast: { where: {} }, result: [{ ...ROW }], + object: 'ledger', operation: 'find', context: { ...PRINCIPAL_LESS }, options: {}, ast: { where: {} }, }; - expect(await run(middleware, opCtx)).toEqual({ admitted: true }); - expect(opCtx.result[0]).toEqual(ROW); + expect(await run(middleware, opCtx)).toEqual(REFUSED_AT_ADMISSION); + expect(opCtx.result).toBeUndefined(); for (const field of FIELDS) { for (const [position, operation, ast] of PROBES) { const verdict = await run(middleware, { object: 'ledger', operation, context: { ...PRINCIPAL_LESS }, options: {}, ast: ast(field), }); - expect(verdict, `${field} as ${position}`).toEqual({ admitted: true }); + expect(verdict, `${field} as ${position}`).toEqual(REFUSED_AT_ADMISSION); } } }); - it('the read and query projections answer the full field set for it', async () => { + it('its row scope is the deny sentinel, and the projections are the zero-set ones: masked fields served, not queryable', async () => { const { plugin } = await boot(); + expect(await plugin.getReadFilter('ledger', { ...PRINCIPAL_LESS })).toEqual({ ...RLS_DENY_FILTER }); expect(await plugin.getReadableFields('ledger', { ...PRINCIPAL_LESS })).toEqual(FIELDS); - expect(await plugin.getQueryableFields('ledger', { ...PRINCIPAL_LESS })).toEqual(FIELDS); + expect(await plugin.getQueryableFields('ledger', { ...PRINCIPAL_LESS })).toEqual(FIELDS.filter((f) => !MASKED.includes(f))); }); }); diff --git a/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts b/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts index ca364c0695e..7f3c5021d61 100644 --- a/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts +++ b/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts @@ -147,7 +147,7 @@ describe('[#21918] business-unit and user deletes with the showcase federated fi await ql.insert( 'sys_business_unit', { id: 'bu_21918', name: 'Doomed Unit', kind: 'department', organization_id: orgId, active: true }, - SYSTEM_CTX, + { context: SYSTEM_CTX }, ); expect(await findRows(ql, 'sys_business_unit', { id: 'bu_21918' }, 1)).toHaveLength(1); const before = await findRows(ql, FEDERATED, {}, 500); diff --git a/packages/qa/dogfood/test/owner-of-private-object-under-strict-mode.dogfood.test.ts b/packages/qa/dogfood/test/owner-of-private-object-under-strict-mode.dogfood.test.ts new file mode 100644 index 00000000000..4a4fc9a1290 --- /dev/null +++ b/packages/qa/dogfood/test/owner-of-private-object-under-strict-mode.dogfood.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// An `owner_of:` audience on a PRIVATE object reaches the record's owner, end +// to end, with ADR-0096 D5 strict mode in force (#21908, maintainer ruling Q2). +// +// The recipient resolver reads the referenced record for its owner fields and +// nothing else. That read used to reach the engine with no principal: the +// security middleware handed it through, and the sharing middleware answered +// a principal-less read of a `private` object with deny-all, so an `owner_of:` +// audience on one silently resolved to nobody. The ruling gave the read the +// explicit system opt-in its sibling `resolveEmail` carries, and strict mode +// now refuses the principal-less spelling outright — so this pin is the proof +// that the owner is found by the declared route and not by a hand-off: +// +// ⭐ the composition refuses a principal-less read of the object (strict +// mode is in force here, not merely compiled in); +// ⭐ an `owner_of:` notification on a record of that private object lands +// in its owner's inbox, read back over `GET /api/v1/notifications`; +// ⭐ it lands in nobody else's: another member, who cannot read the record, +// is not a recipient. +// +// The fixture is owned by this pin and kept inline. Messaging runs its inline +// fan-out (`reliableDelivery: false`) so the inbox row exists when `emit` +// returns, the shape `approval-notification-body.dogfood.test.ts` uses. + +import { describe, it, expect } from 'vitest'; +import { bootStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { MessagingServicePlugin } from '@objectstack/service-messaging'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; + +const OBJECT = 'plq2_case'; +const OWNER = 'plq2-owner@example.com'; +const OTHER = 'plq2-other@example.com'; +const TOPIC = 'plq2.owner_ping'; + +const Plq2Case = ObjectSchema.create({ + name: OBJECT, + label: 'Owner Resolution Case', + pluralLabel: 'Owner Resolution Cases', + // The point of the pin: a record only its owner may read. + sharingModel: 'private', + fields: { + name: Field.text({ label: 'Name', required: true }), + }, +}); + +const plq2Stack = defineStack({ + manifest: { + id: 'com.dogfood.owner-of-private-strict-mode', + namespace: 'plq2', + version: '0.0.0', + type: 'app', + name: 'Owner Resolution Under Strict Mode Fixture', + description: 'One private object whose record owner an owner_of audience must reach.', + }, + objects: [Plq2Case], +}); + +/** Every fresh member may create and read the object; the private model keeps each to their own rows. */ +const plq2MemberSet = PermissionSetSchema.parse({ + name: 'plq2_member', + label: 'Owner Resolution Member — create + read on plq2_case', + objects: { + [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false }, + }, +}); + +interface InboxRow { + id: string; + type: string; + title: string; +} + +describe('[#21908] an owner_of audience on a private object reaches the owner under ADR-0096 D5 strict mode', () => { + it( + 'the owner receives the notification and nobody else does; a principal-less read of the object is refused', + async () => { + const stack = await bootStack(plq2Stack as unknown as Parameters[0], { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, plq2MemberSet], + fallbackPermissionSet: plq2MemberSet.name, + }), + extraPlugins: [new MessagingServicePlugin({ reliableDelivery: false })], + }); + try { + const ownerToken = await stack.signUp(OWNER); + const otherToken = await stack.signUp(OTHER); + + const created = await stack.apiAs(ownerToken, 'POST', `/data/${OBJECT}`, { name: 'owned record' }); + expect(created.status, await created.clone().text()).toBe(201); + const recordId = ((await created.json()) as { id: string }).id; + + // The record is private to its owner: the other member cannot read it. + const otherRead = await stack.apiAs(otherToken, 'GET', `/data/${OBJECT}/${recordId}`); + expect(otherRead.status).not.toBe(200); + + // Strict mode is in force in this composition: a principal-less read is refused. + const ql = stack.kernel.getService('objectql'); + const refused = await ql.find(OBJECT, {}).then( + () => null, + (e: { code?: string; status?: number }) => ({ code: e.code, status: e.status }), + ); + expect(refused).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + + const messaging = stack.kernel.getService<{ + emit(input: Record): Promise<{ deliveries: Array<{ recipient: string; ok: boolean }> }>; + }>('notification'); + const emitted = await messaging.emit({ + topic: TOPIC, + audience: `owner_of:${OBJECT}:${recordId}`, + payload: { title: 'A note for the owner', body: 'Owner resolution under strict mode.' }, + }); + expect(emitted.deliveries.length, 'the owner_of audience resolved a recipient').toBeGreaterThan(0); + + const inboxOf = async (token: string): Promise => { + const res = await stack.apiAs(token, 'GET', `/notifications?${new URLSearchParams({ type: TOPIC })}`); + expect(res.status, 'GET /notifications').toBe(200); + return ((await res.json()) as { data: { notifications: InboxRow[] } }).data.notifications; + }; + const ownerInbox = await inboxOf(ownerToken); + expect(ownerInbox.length, "the owner's inbox holds the one notification").toBe(1); + expect(ownerInbox[0].title).toBe('A note for the owner'); + expect(await inboxOf(otherToken), 'nobody but the owner is a recipient').toEqual([]); + } finally { + await stack.stop(); + } + }, + 120_000, + ); +}); diff --git a/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts b/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts index 08b7851a166..e557789ed98 100644 --- a/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts +++ b/packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts @@ -179,7 +179,7 @@ describe('dogfood: adopt orphaned metadata into a base (ADR-0070 D5 migration)', state: 'draft', package_id: null, metadata: JSON.stringify({ name: ORPHAN, label: 'Legacy', fields: { name: { type: 'text' } } }), - }); + }, { context: { isSystem: true } }); // Precondition: it is NOT owned by the base yet. expect(await ownedNames(ql, BASE)).not.toContain(ORPHAN); diff --git a/packages/runtime/src/dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts b/packages/runtime/src/dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts new file mode 100644 index 00000000000..e9c402c0bf0 --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.endpoint-principal-less-deny.integration.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21908] ADR-0096 D5 strict mode closes `HttpDispatcher.resolveRequestScope`'s + * infrastructure-fault class BY CONSTRUCTION. + * + * `resolveRequestScope` re-raises only the authorization-store-unavailable + * class out of identity resolution; any other fault leaves the request's + * `executionContext` undefined, and a data call the request then delegates + * reaches the engine with no principal. The engine refuses that class now + * (strict mode), whichever door delegated the call. + * + * Composition: the real dispatcher plugin over a real hono socket, the real + * fallback seam, policy chain, endpoint step and `callData`, and the REAL + * plugin-security middleware, run by the kernel-boundary engine stub before + * it serves anything. The fault is induced at the one seam that raises it: + * the dispatcher's timed identity resolution rejects once with a plain error. + * + * What is pinned is what the engine was HANDED (no principal), whether the + * store was REACHED (never), and what the caller was TOLD (403 + * PERMISSION_DENIED, nothing of the rows). The controls: with no fault the + * same anonymous request executes as the guest principal (#22147's posture, + * unchanged) and is refused by the deny baseline, and a signed-in caller who + * holds a grant is served through the same composition. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { LiteKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpointMatch, IHttpServer } from '@objectstack/spec/contracts'; +import type { PermissionSet } from '@objectstack/spec/security'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { HttpDispatcher } from './http-dispatcher.js'; + +const OBJECT = 'showcase_task'; + +const OPEN_FIND: ApiEndpoint = ApiEndpointSchema.parse({ + name: 'showcase_open_tasks', + path: '/api/v1/apps/showcase/open-tasks', + method: 'GET', + type: 'object_operation', + target: OBJECT, + objectParams: { object: OBJECT, operation: 'find' }, + authRequired: false, +}); + +/** Rows the stub store serves, so a body can be checked for them byte by byte. */ +const TASK_ROWS = [ + { id: 'tsk_1', name: 'pl-deny-row-marker-one', status: 'open' }, + { id: 'tsk_2', name: 'pl-deny-row-marker-two', status: 'done' }, +]; + +/** The deployment's baseline: read on the object, for every caller that carries a user id. */ +const READER_SET = { + name: 'pl_deny_reader', + label: 'Synthetic reader', + objects: { [OBJECT]: { allowRead: true } }, +} as unknown as PermissionSet; + +/** What the engine was handed for the fixture object, and whether the store was reached. */ +const engineFinds: Array<{ context: Record | undefined }> = []; +const storeReached: string[] = []; + +/** The real plugin-security middleware, booted on its own so the stub engine can run it. */ +async function securityMiddleware(): Promise<(opCtx: any, next: () => Promise) => Promise> { + let middleware: ((opCtx: any, next: () => Promise) => Promise) | undefined; + const services: Record = { + manifest: { register: () => undefined }, + objectql: { + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, + getSchema: (name: string) => (name === OBJECT + ? { name: OBJECT, fields: { name: { type: 'text' }, status: { type: 'text' } } } + : undefined), + findOne: async () => null, + }, + metadata: { + get: async (_type: string, name: string) => (name === OBJECT + ? { name: OBJECT, fields: { name: { type: 'text' }, status: { type: 'text' } } } + : undefined), + list: async () => [READER_SET], + }, + }; + const ctx: any = { + logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined }, + registerService: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + // The baseline applies to a caller with a user id only, so it reaches the + // signed-in control and neither the guest nor a principal-less call. + const plugin = new SecurityPlugin({ defaultPermissionSets: [], fallbackPermissionSet: READER_SET.name }); + await plugin.init(ctx); + await plugin.start(ctx); + if (!middleware) throw new Error('SecurityPlugin registered no middleware'); + return middleware; +} + +function fakeMetadataPlugin(): Plugin { + return { + name: 'com.objectstack.test.pl-deny-metadata', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('metadata', { + list: async () => [], + matchEndpoint: async (q: { path: string; method: string }): Promise => + q.path.replace(/\/$/, '') === OPEN_FIND.path && q.method.toUpperCase() === OPEN_FIND.method + ? { endpoint: OPEN_FIND, params: {} } + : undefined, + }); + }, + }; +} + +/** The kernel-boundary services: a session by header, and an engine that runs the security middleware first. */ +function servicesPlugin(middleware: (opCtx: any, next: () => Promise) => Promise): Plugin { + return { + name: 'com.objectstack.test.pl-deny-services', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + async getSession({ headers }: { headers: Headers }) { + const uid = headers.get('x-test-user'); + return uid ? { user: { id: uid } } : null; + }, + }, + }); + ctx.registerService('objectql', { + async find(object: string, options: any) { + // The authorization store the identity resolver reads holds + // no row: every grant here is the deployment baseline. + if (object !== OBJECT) return []; + engineFinds.push({ context: options?.context }); + const opCtx: any = { + object, operation: 'find', context: options?.context, options, ast: { where: options?.where }, + }; + await middleware(opCtx, async () => { + storeReached.push('find'); + opCtx.result = TASK_ROWS; + }); + return opCtx.result; + }, + }); + }, + }; +} + +describe('[#21908] an identity-resolution fault at an authRequired:false endpoint is refused by the engine', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + const middleware = await securityMiddleware(); + kernel = new LiteKernel(); + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(fakeMetadataPlugin()); + kernel.use(servicesPlugin(middleware)); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); + await kernel.bootstrap(); + baseUrl = `http://127.0.0.1:${kernel.getService('http.server').getPort!()}`; + }, 30_000); + + afterAll(async () => { + if (!kernel) return; + await Promise.race([kernel.shutdown(), new Promise((resolve) => setTimeout(resolve, 10_000))]); + }, 30_000); + + afterEach(() => { + vi.restoreAllMocks(); + engineFinds.length = 0; + storeReached.length = 0; + }); + + it('the faulted request reaches the engine with no principal, the store is never reached, and the caller gets 403 with nothing of the rows', async () => { + vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext') + .mockRejectedValueOnce(new Error('synthetic identity-resolution fault')); + + const res = await fetch(`${baseUrl}${OPEN_FIND.path}`); + const text = await res.text(); + + expect(engineFinds, 'the endpoint delegated its data call').toHaveLength(1); + const handed = engineFinds[0]!.context; + expect(handed?.userId, 'no user').toBeUndefined(); + expect((handed?.positions as unknown[] | undefined) ?? [], 'no position').toEqual([]); + expect((handed?.permissions as unknown[] | undefined) ?? [], 'no permission set').toEqual([]); + expect(handed?.isSystem, 'never the system principal').not.toBe(true); + + expect(storeReached).toEqual([]); + expect(res.status, text).toBe(403); + const body = JSON.parse(text) as { success?: boolean; data?: unknown; error?: { code?: string } }; + expect(body.success).toBe(false); + expect(body.error?.code).toBe('PERMISSION_DENIED'); + expect(body).not.toHaveProperty('data'); + for (const row of TASK_ROWS) expect(text).not.toContain(row.name); + }); + + it('CONTROL: with no fault the same anonymous request executes as the guest principal, refused by the deny baseline', async () => { + const res = await fetch(`${baseUrl}${OPEN_FIND.path}`); + expect(res.status).toBe(403); + expect(engineFinds).toHaveLength(1); + expect(engineFinds[0]!.context).toMatchObject({ principalKind: 'guest', positions: ['guest'], isSystem: false }); + expect(storeReached).toEqual([]); + }); + + it('CONTROL: a signed-in caller the baseline grants is served through the same composition', async () => { + const res = await fetch(`${baseUrl}${OPEN_FIND.path}`, { headers: { 'x-test-user': 'u_reader' } }); + const text = await res.text(); + expect(res.status, text).toBe(200); + expect(engineFinds).toHaveLength(1); + expect(engineFinds[0]!.context).toMatchObject({ userId: 'u_reader', isSystem: false }); + expect(storeReached).toEqual(['find']); + expect(text).toContain(TASK_ROWS[0]!.name); + }); +}); diff --git a/packages/runtime/src/domains/share-links-enforcement-context.test.ts b/packages/runtime/src/domains/share-links-enforcement-context.test.ts index c43353d9be9..8c64f33a54c 100644 --- a/packages/runtime/src/domains/share-links-enforcement-context.test.ts +++ b/packages/runtime/src/domains/share-links-enforcement-context.test.ts @@ -182,11 +182,16 @@ function makeEngine(tables: Record, schemas?: Record // is byte-for-byte the previous behaviour. getSchema: (name: string) => schemas ? schemas[name] : name === OBJECT ? ACCOUNT_SCHEMA : { name }, - async find(object: string, options: any = {}) { + // The engine's trailing read options carry a context too (`find(object, + // query, { context })`, merged as `mergeReadContext` merges them): + // plugin-security's permission-set loader passes its explicit system + // opt-in there. A double that dropped it handed that read to the + // middleware with no principal, which ADR-0096 D5 now refuses. + async find(object: string, options: any = {}, readOptions?: any) { const opCtx: any = { object, operation: 'find', - context: options?.context ?? {}, + context: { ...(options?.context ?? {}), ...(readOptions?.context ?? {}) }, options, ast: { where: options?.where }, }; 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 5c454efb66c..7a8079ecd25 100644 --- a/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts +++ b/packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts @@ -250,7 +250,9 @@ beforeAll(async () => { rows = {}; for (const t of ['sys_permission_set', 'sys_capability', 'sys_sharing_rule']) { - rows[t] = await ql.find(t, {}); + // The seeded rows as the platform reads them: the explicit system opt-in. + // A context-less read is refused by the security middleware (ADR-0096 D5). + rows[t] = await ql.find(t, { context: { isSystem: true } }); } }, BOOT_TIMEOUT);