diff --git a/.changeset/20002-explain-sharing-fault.md b/.changeset/20002-explain-sharing-fault.md new file mode 100644 index 00000000000..1378378be82 --- /dev/null +++ b/.changeset/20002-explain-sharing-fault.md @@ -0,0 +1,22 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): `security/explain` fails closed when a dependency it shares with enforcement throws, so a request that fails is no longer reported as allowed (#20002) + +Clause-②: no + +`POST /api/v1/security/explain` calls the same functions as the enforcement middleware. Enforcement does not catch a failure in them, so the request fails. The explain engine caught the same failure and turned it into a value that it then read as an answer. So when the sharing service's share store was unavailable, `{ object, operation: 'read', recordId }` answered `decision.record.visible: true` (`decidedBy: 'sharing'`, sharing layer `admitted`), and the caller's `find` for the same row threw. Four call sites had this problem: + +- **The sharing read filter** (the reported case). A failure became `null`, which the record matcher reads as "no filter". So an unshared row, a shared row, and the caller's own row were all reported visible. +- **The sharing service's per-record `update` / `delete` gate.** A failure became "no gate wired", so ownership, a `read` share or the OWD answered a write that the by-id `PATCH` / `DELETE` then failed on. +- **The layered row-level security composition.** A failure became "no tenant wall and no business RLS". The row was reported visible, while `allowed` was `false` because of the same failure. +- **An on-behalf-of delegator whose grants could not be read.** A failure became "no delegation", so the agent's own grants decided alone and `allowed` was `true`. The same `find` answered `503 SERVICE_UNAVAILABLE`. + +Each failure is now reported as it happened. The affected layer's `record.outcome` is `not_evaluated`, with no `rowFilter` and no `matchesRecord`, and its `detail` says the layer could not be evaluated. `record.visible` is `false`, and `decidedBy` names the layer that failed: `sharing`, or `rls` for the composition. For the delegator case, the `principal` and `object_crud` layers deny and `allowed` is `false`. The response has no new keys, and `not_evaluated` is an existing outcome value. + +Unchanged: + +- Enforcement admits and refuses exactly what it did before. +- A dependency that answers is reported exactly as before. That includes a read filter that answers "no restriction", such as an `org`-depth reader's `null`. +- Failures that already failed closed are unchanged: record fetch, share listing, and permission-set resolution for the principal or the delegator. diff --git a/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts b/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts new file mode 100644 index 00000000000..4ed05cfdcf2 --- /dev/null +++ b/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts @@ -0,0 +1,431 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `security/explain` must fail CLOSED when a dependency it shares with +// enforcement REJECTS. +// +// The report promises it runs the same code paths the enforcement middleware +// runs. Enforcement runs those calls un-caught, so when one of them rejects the +// request it explains FAILS. The explain engine used to catch the same +// rejection into a value its record matcher reads as an ANSWER: +// +// - plugin-sharing's read filter → `null`, which the matcher reads as "no +// filter": `record.visible: true`, `decidedBy: 'sharing'`, the sharing +// layer `admitted` — while the caller's find threw; +// - plugin-sharing's per-record write gate → "no gate wired", so ownership or +// a READ share answered a write the by-id PATCH then failed on; +// - the layered RLS composition → "no tenant wall, no business RLS", so the +// record read as visible beside an object-level `allowed: false` from the +// same fault; +// - the on-behalf-of delegator's resolution → "no delegation", so the agent's +// own grants decided alone and `allowed` came back `true` where the find +// answered 503. +// +// Each fault is now reported as what it is — the layer `not_evaluated`, the +// record NOT visible — and never as an admission. Every fault cell asserts both +// halves: explain's verdict, and that the SAME fault fails the real request +// through the real SecurityPlugin + SharingService middlewares. The healthy +// controls pin that a dependency that ANSWERS — including one that answers +// "no restriction" — is reported exactly as before. +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { SharingService, buildSharingMiddleware } from '@objectstack/plugin-sharing'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +// ── metadata ─────────────────────────────────────────────────────────────── + +const OBJECT = 'kpi_entry_line'; + +const fieldsOf = (...names: string[]) => + Object.fromEntries(names.map((n) => [n, { name: n }])); + +/** `sharingModel` unset → `private` (ADR-0090 D1), so plugin-sharing's owner-match is in play. */ +const PRIVATE_SCHEMA = { + name: OBJECT, + fields: fieldsOf('id', 'name', 'value', 'owner_id', 'created_by', 'organization_id'), +}; + +/** The object's own model opens reads — a sharing fault must still close the report. */ +const PUBLIC_READ_SCHEMA = { ...PRIVATE_SCHEMA, sharingModel: 'public_read' }; + +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; + +const grantAt = (name: string, scope: 'own' | 'org'): PermissionSet => + PermissionSetSchema.parse({ + name, + objects: { [OBJECT]: { allowRead: true, allowEdit: true, readScope: scope, writeScope: scope } }, + }); + +/** Depth `org` — plugin-sharing imposes no owner-match, so its read filter answers `null` with no I/O. */ +const HR_REVIEWER = grantAt('hr_reviewer', 'org'); +/** Depth `own` — widened only by ownership or a share, so the read filter reads the share store. */ +const DEPT_REPORTER = grantAt('dept_reporter', 'own'); + +const PERMISSION_SETS: PermissionSet[] = [MEMBER_DEFAULT, HR_REVIEWER, DEPT_REPORTER]; + +// ── principals and rows ──────────────────────────────────────────────────── + +const U_ADMIN = 'u_admin'; +const U_HR = 'u_hr'; +const U_REPORTER = 'u_reporter'; + +const ctxFor = (userId: string, permission: string) => ({ + userId, tenantId: 'org1', positions: ['org_member'], permissions: [permission], +}); + +const HR_CTX = ctxFor(U_HR, 'hr_reviewer'); +const REPORTER_CTX = ctxFor(U_REPORTER, 'dept_reporter'); +/** An agent-shaped principal acting for the reporter — the ADR-0090 D10 intersection applies. */ +const ON_BEHALF_CTX = { ...HR_CTX, onBehalfOf: { userId: U_REPORTER } }; + +type RowKey = 'shared' | 'owned' | 'unshared'; + +const ROWS: Record> = { + /** Owned by the admin; the reporter holds a `read` share on it. */ + shared: { id: 'kle_shared', name: 'Shared', value: 1, owner_id: U_ADMIN, created_by: U_ADMIN, organization_id: 'org1' }, + /** Owned AND created by the reporter, so every write floor admits the reporter. */ + owned: { id: 'kle_owned', name: 'Owned', value: 1, owner_id: U_REPORTER, created_by: U_REPORTER, organization_id: 'org1' }, + /** Neither shared with nor owned by the reporter. */ + unshared: { id: 'kle_unshared', name: 'Unshared', value: 1, owner_id: U_ADMIN, created_by: U_ADMIN, organization_id: 'org1' }, +}; + +const READ_SHARE = { + id: 'shr_read', object_name: OBJECT, record_id: ROWS.shared.id, + recipient_type: 'user', recipient_id: U_REPORTER, access_level: 'read', source: 'manual', +}; + +// ── in-memory engine ─────────────────────────────────────────────────────── + +/** + * A table whose store is down: reading it throws `fault`. Every read of the + * double below goes through `filter`, so the outage surfaces where a real + * driver's would — on the read — and `find` itself stays an ordinary double. + */ +function unavailableTable(fault: Error): any[] { + const rows: any[] = []; + (rows as any).filter = () => { throw fault; }; + return rows; +} + +/** + * `storeFaults` names the tables whose reads THROW. The thrown error is one + * instance per table, so a cell can assert that the error the real request + * failed with IS the injected fault, not merely some error. + */ +function makeEngine(schema: Record, storeFaults: ReadonlyMap) { + const tables: Record = { + [OBJECT]: (Object.keys(ROWS) as RowKey[]).map((k) => ({ ...ROWS[k] })), + sys_record_share: [{ ...READ_SHARE }], + // The delegator exists, so `resolveDelegatorContext` reaches the grant reads. + sys_user: [{ id: U_REPORTER, email: 'reporter@example.com' }], + }; + for (const [table, fault] of storeFaults) tables[table] = unavailableTable(fault); + // `$or` / `$and` conjoin WITH their sibling keys, the way a real driver ANDs + // them. An operator this double does not know THROWS rather than comparing an + // object to a scalar: a matcher that silently answers `false` would green a + // refused cell for the wrong reason. + const matches = (row: any, filter: any): boolean => { + if (!filter || typeof filter !== 'object') return true; + if (Array.isArray(filter.$or) && !filter.$or.some((f: any) => matches(row, f))) return false; + if (Array.isArray(filter.$and) && !filter.$and.every((f: any) => matches(row, f))) return false; + for (const [k, v] of Object.entries(filter)) { + if (k === '$or' || k === '$and') continue; + if (k.startsWith('$')) throw new Error(`engine double: unsupported operator '${k}'`); + if (v != null && typeof v === 'object') { + const ops = Object.keys(v as object); + if (ops.length !== 1 || ops[0] !== '$in') { + throw new Error(`engine double: unsupported predicate on '${k}': ${JSON.stringify(v)}`); + } + if (!(v as any).$in.includes(row[k])) return false; + continue; + } + if (row[k] !== v) return false; + } + return true; + }; + const middlewares: any[] = []; + return { + _middlewares: middlewares, + registerMiddleware: (mw: any) => middlewares.push(mw), + getSchema: (name: string) => (name === OBJECT ? schema : undefined), + async find(object: string, options: any = {}) { + const rows = (tables[object] ??= []); + const hits = rows.filter((r) => matches(r, options.filter ?? options.where)); + return typeof options.limit === 'number' ? hits.slice(0, options.limit) : hits; + }, + async findOne(object: string, options: any = {}) { + assertEngineFindOnePredicate(object, options); + const rows = await this.find(object, { ...options, limit: 1 }); + return rows[0] ?? null; + }, + }; +} + +// ── the stack ────────────────────────────────────────────────────────────── + +/** The real request's outcome. A failure keeps the thrown error itself. */ +interface RequestOutcome { + /** Read: the row came back. Write: the request passed every gate and reached the engine. */ + ok: boolean; + error?: any; +} + +interface Stack { + /** The SecurityPlugin instance — private members reached for dependency-level faults. */ + plugin: any; + sharing: SharingService; + /** One error instance per faulted table (see {@link makeEngine}). */ + storeFaults: Map; + explain: (operation: 'read' | 'update', rowKey: RowKey, context: any) => Promise; + /** The caller's own request for this one row, through BOTH real middlewares. */ + request: (operation: 'read' | 'update', rowKey: RowKey, context: any) => Promise; +} + +async function makeStack( + opts: { schema?: Record; storeFaults?: string[] } = {}, +): Promise { + const schema = opts.schema ?? PRIVATE_SCHEMA; + const storeFaults = new Map( + (opts.storeFaults ?? []).map((t) => [t, new Error(`store unavailable: ${t}`)]), + ); + const engine = makeEngine(schema, storeFaults); + const metadata = { + get: async (_type: string, name: string) => (name === OBJECT ? schema : null), + list: async () => PERMISSION_SETS, + }; + let security: any; + let sharing: SharingService; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata, + // Org scoping active, so Layer 0 contributes a real tenant predicate. + 'org-scoping': { name: 'org-scoping' }, + get sharing() { return sharing; }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, impl: any) => { if (name === 'security') security = impl; }, + 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); + await plugin.start(ctx); + if (!security) throw new Error('SecurityPlugin did not register the security service'); + + sharing = new SharingService({ engine: engine as any, securityService: () => security }); + const sharingMw = buildSharingMiddleware(sharing, ctx.logger); + const securityMw = engine._middlewares[0]; + const idOf = (rowKey: RowKey) => String(ROWS[rowKey].id); + + return { + plugin, + sharing, + storeFaults, + explain: (operation, rowKey, context) => + security.explain({ object: OBJECT, operation, recordId: idOf(rowKey) }, { ...context }), + async request(operation, rowKey, context) { + const recordId = idOf(rowKey); + const where = { id: recordId }; + const opCtx: any = operation === 'read' + ? { object: OBJECT, operation: 'find', context: { ...context }, options: { where }, ast: { where } } + : { object: OBJECT, operation: 'update', context: { ...context }, data: { id: recordId, value: 2 } }; + let rows: any[] | undefined; + let reached = false; + try { + await securityMw(opCtx, async () => { + await sharingMw(opCtx, async () => { + reached = true; + // A write stops at the engine's door: reaching it is the admission + // under test, and no write verb is exercised on this double. + if (operation === 'read') rows = await engine.find(OBJECT, { where: opCtx.ast.where }); + }); + }); + } catch (error) { + return { ok: false, error }; + } + if (!reached) throw new Error('a middleware swallowed the request'); + return { ok: operation === 'read' ? (rows ?? []).some((r) => r.id === recordId) : true }; + }, + }; +} + +const layerOf = (decision: any, layer: string) => decision.layers.find((l: any) => l.layer === layer); + +/** A rejecting stand-in whose error a cell can compare by identity. */ +function rejecting(message: string): { fn: (...args: any[]) => Promise; error: Error } { + const error = new Error(message); + return { fn: async () => { throw error; }, error }; +} + +/** + * A fault cell: explain reports the record NOT visible, decided by `decidedBy`, + * and the SAME fault fails the real request — the thrown error is `fault`. + */ +async function expectFaultCell( + stack: Stack, + operation: 'read' | 'update', + rowKey: RowKey, + context: any, + decidedBy: string, + fault: Error, +): Promise { + const decision = await stack.explain(operation, rowKey, context); + const outcome = await stack.request(operation, rowKey, context); + const cell = `${context.userId} × ${rowKey} × ${operation}`; + expect(outcome.ok, `${cell}: the real request fails`).toBe(false); + expect(outcome.error, `${cell}: the request failed with the injected fault itself`).toBe(fault); + expect( + decision.record?.visible, + `${cell}: explain record.visible (decidedBy ${decision.record?.decidedBy}) — a fault is not an admission`, + ).toBe(false); + expect(decision.record?.decidedBy, `${cell}: the layer whose dependency faulted decides`).toBe(decidedBy); + return decision; +} + +/** The layer attribution a fault leaves: not evaluated, and no filter it never received. */ +function expectNotEvaluated(decision: any, layer: string): void { + const record = layerOf(decision, layer).record; + expect(record.outcome, `${layer}: a layer whose dependency threw was not evaluated`).toBe('not_evaluated'); + expect(record.rowFilter, `${layer}: no filter is published for a call that returned none`).toBeUndefined(); + expect(record.matchesRecord, `${layer}: no match is claimed against a filter that does not exist`).toBeUndefined(); +} + +// ─────────────────────────────────────────────────────────────────────────── + +describe("plugin-sharing's read filter rejects — explain(read) fails closed", () => { + const rows: RowKey[] = ['unshared', 'shared', 'owned']; + it.each(rows)('the share store is down × %s row: not visible, and the find fails on the same fault', async (rowKey) => { + const stack = await makeStack({ storeFaults: ['sys_record_share'] }); + const decision = await expectFaultCell( + stack, 'read', rowKey, REPORTER_CTX, 'sharing', stack.storeFaults.get('sys_record_share')!, + ); + expectNotEvaluated(decision, 'sharing'); + }); + + it('a public_read object: the OWD does not reopen a record whose sharing call threw', async () => { + // The sharing middleware asks the read filter on EVERY find, whatever the + // OWD, and a rejection fails the find — so the baseline cannot admit it. + const stack = await makeStack({ schema: PUBLIC_READ_SCHEMA }); + const fault = rejecting('sharing read filter rejected'); + (stack.sharing as any).buildReadFilter = fault.fn; + const decision = await expectFaultCell(stack, 'read', 'unshared', REPORTER_CTX, 'sharing', fault.error); + expectNotEvaluated(decision, 'sharing'); + }); +}); + +describe('healthy sharing layer — reported exactly as before', () => { + it('a shared row: admitted by sharing, and the find returns it', async () => { + const stack = await makeStack(); + const decision = await stack.explain('read', 'shared', REPORTER_CTX); + expect((await stack.request('read', 'shared', REPORTER_CTX)).ok).toBe(true); + expect(decision.record).toEqual({ recordId: ROWS.shared.id, visible: true, decidedBy: 'sharing' }); + const sharingRecord = layerOf(decision, 'sharing').record; + expect(sharingRecord.outcome).toBe('admitted'); + expect(sharingRecord.matchesRecord).toBe(true); + }); + + it('an unshared row: excluded by sharing, and the find does not return it', async () => { + const stack = await makeStack(); + const decision = await stack.explain('read', 'unshared', REPORTER_CTX); + expect((await stack.request('read', 'unshared', REPORTER_CTX)).ok).toBe(false); + expect(decision.record).toEqual({ recordId: ROWS.unshared.id, visible: false, decidedBy: 'sharing' }); + const sharingRecord = layerOf(decision, 'sharing').record; + expect(sharingRecord.outcome).toBe('excluded'); + expect(sharingRecord.matchesRecord).toBe(false); + }); + + it('a read filter that ANSWERS "no restriction" is still an answer — org depth with the share store down', async () => { + // At `org` depth plugin-sharing returns `null` before it reads a share, so + // the find succeeds with the store down. Only a REJECTION is a fault. + const stack = await makeStack({ storeFaults: ['sys_record_share'] }); + const decision = await stack.explain('read', 'unshared', HR_CTX); + expect((await stack.request('read', 'unshared', HR_CTX)).ok).toBe(true); + expect(decision.record.visible).toBe(true); + const sharingRecord = layerOf(decision, 'sharing').record; + expect(sharingRecord.outcome).toBe('admitted'); + expect(sharingRecord.rowFilter).toBeNull(); + }); +}); + +describe("plugin-sharing's per-record write gate rejects — explain(update) fails closed", () => { + it('the owner: ownership does not answer for a gate that threw', async () => { + const stack = await makeStack(); + const fault = rejecting('sharing write gate rejected'); + (stack.sharing as any).canEdit = fault.fn; + const decision = await expectFaultCell(stack, 'update', 'owned', REPORTER_CTX, 'sharing', fault.error); + expectNotEvaluated(decision, 'sharing'); + }); + + it('a public_read object: the OWD does not answer for a gate that threw', async () => { + const stack = await makeStack({ schema: PUBLIC_READ_SCHEMA }); + const fault = rejecting('sharing write gate rejected'); + (stack.sharing as any).canEdit = fault.fn; + const decision = await expectFaultCell(stack, 'update', 'owned', REPORTER_CTX, 'sharing', fault.error); + expectNotEvaluated(decision, 'sharing'); + }); + + it('control: the healthy gate admits the owner, and the PATCH reaches the engine', async () => { + const stack = await makeStack(); + const decision = await stack.explain('update', 'owned', REPORTER_CTX); + expect((await stack.request('update', 'owned', REPORTER_CTX)).ok).toBe(true); + expect(decision.record.visible).toBe(true); + expect(layerOf(decision, 'sharing').record.outcome).toBe('admitted'); + }); +}); + +describe('the layered RLS composition rejects — explain(read) fails closed', () => { + const cells: Array<[string, RowKey, any]> = [ + ['dept_reporter via read share', 'shared', REPORTER_CTX], + ['dept_reporter as owner', 'owned', REPORTER_CTX], + ['hr_reviewer (org depth)', 'unshared', HR_CTX], + ]; + it.each(cells)('%s × %s row: not visible, and the find fails on the same fault', async (_label, rowKey, context) => { + const stack = await makeStack(); + const fault = rejecting('layered RLS composition rejected'); + stack.plugin.computeLayeredRlsFilter = fault.fn; + const decision = await expectFaultCell(stack, 'read', rowKey, context, 'rls', fault.error); + // The object-level answer to the same fault was already a denial; the row + // verdict may no longer contradict it. + expect(decision.allowed).toBe(false); + expectNotEvaluated(decision, 'tenant_isolation'); + expectNotEvaluated(decision, 'rls'); + }); + + it('control: the healthy composition admits the tenant wall and publishes its predicate', async () => { + const stack = await makeStack(); + const decision = await stack.explain('read', 'owned', REPORTER_CTX); + expect((await stack.request('read', 'owned', REPORTER_CTX)).ok).toBe(true); + expect(decision.record.visible).toBe(true); + const tenantRecord = layerOf(decision, 'tenant_isolation').record; + expect(tenantRecord.outcome).toBe('admitted'); + expect(tenantRecord.rowFilter).not.toBeNull(); + }); +}); + +describe("the on-behalf-of delegator's grants cannot be read — explain fails closed", () => { + it('the D10 intersection is not dropped: allowed is false, and the find answers the 503', async () => { + const stack = await makeStack({ storeFaults: ['sys_user_position'] }); + const decision = await stack.explain('read', 'unshared', ON_BEHALF_CTX); + const outcome = await stack.request('read', 'unshared', ON_BEHALF_CTX); + // ADR-0112 envelope of the enforcement refusal — never a bare throw. + expect(outcome.ok).toBe(false); + expect(outcome.error?.code).toBe('SERVICE_UNAVAILABLE'); + expect(outcome.error?.statusCode ?? outcome.error?.status).toBe(503); + expect(decision.allowed, 'the agent may not act alone when its delegator cannot be resolved').toBe(false); + expect(layerOf(decision, 'principal').verdict).toBe('denies'); + expect(layerOf(decision, 'object_crud').verdict).toBe('denies'); + expect(decision.record).toEqual({ recordId: ROWS.unshared.id, visible: false, decidedBy: 'object_crud' }); + }); + + it('control: a delegator that resolves leaves the principal layer neutral', async () => { + const stack = await makeStack(); + const decision = await stack.explain('read', 'unshared', ON_BEHALF_CTX); + expect(layerOf(decision, 'principal').verdict).toBe('neutral'); + }); +}); diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index f8a461ad2d4..fca6431074a 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -190,6 +190,24 @@ function isDenyAll(filter: unknown): boolean { return isDenyAllSentinel(filter) || isRlsDenySentinel(filter); } +/** + * A dependency call that REJECTED, kept apart from every value it could have + * resolved to. + * + * The engine calls the functions enforcement calls, and enforcement runs them + * un-caught: when one rejects, the request being explained FAILS. Folding that + * rejection into an answer — `null` read as "no filter", `undefined` read as + * "no gate wired", `{ kind: 'none' }` read as "no delegation" — explains a + * request that fails as one that succeeds. {@link settle} hands back this + * sentinel instead; each site reports its layer `not_evaluated` and its + * verdict fail-closed, and never as an admission. + */ +const DEPENDENCY_FAULT: unique symbol = Symbol('explain.dependency-fault'); + +function settle(call: Promise): Promise { + return call.then((value) => value, () => DEPENDENCY_FAULT); +} + /** Explain-operation → engine-operation (the middleware's vocabulary). */ const EXPLAIN_TO_ENGINE_OP: Record = { read: 'find', @@ -854,16 +872,25 @@ async function applyRecordAttribution( return matchesFilterCondition(record as Record, filter as any); }; - const layered = deps.computeLayeredRlsFilter - ? await deps.computeLayeredRlsFilter(sets, object, engineOp, context).catch(() => ({ layer0: null, layer1: null })) + // The composition enforcement runs before the query: when it throws, neither + // layer answered, so neither may read as "contributes nothing". + const layeredOrFault = deps.computeLayeredRlsFilter + ? await settle(deps.computeLayeredRlsFilter(sets, object, engineOp, context)) : undefined; + const layeredFault = layeredOrFault === DEPENDENCY_FAULT; + const layered = layeredOrFault === DEPENDENCY_FAULT ? undefined : layeredOrFault; const layer0 = layered?.layer0; const layer1 = layered?.layer1; + const layeredFaultDetail = (layerName: string): string => + `${layerName} could not be evaluated: the row-level security composition threw, and enforcement ` + + 'fails the request on the same call — so the record is reported NOT visible (fail closed), never admitted.'; // ── Layer 0: tenant_isolation (prepended as the always-first layer) ────── let tenantRecord: ExplainRecordAttribution; if (!recordExists) { tenantRecord = { outcome: 'not_evaluated', rules: [], detail: 'Record not found under a system read — filtered, deleted, or never existed.' }; + } else if (layeredFault) { + tenantRecord = { outcome: 'not_evaluated', rules: [], detail: layeredFaultDetail('Layer 0 (the tenant wall)') }; } else if (layered === undefined) { tenantRecord = { outcome: 'not_evaluated', rules: [], detail: 'Tenant layer split is unavailable on this engine build.' }; } else if (layer0 == null) { @@ -954,21 +981,46 @@ async function applyRecordAttribution( effect: recipMatchesUser ? 'admits' : 'neutral', }; }); - const sharingFilter = deps.sharingReadFilter && recordExists - ? await deps.sharingReadFilter(object, context).catch(() => null) + // A rejected read filter is not a `null` one: the matcher reads `null` as + // "no filter" and admits the row, while the find it explains fails. + const sharingFilterOrFault = deps.sharingReadFilter && recordExists + ? await settle(deps.sharingReadFilter(object, context)) : undefined; + const readFilterFault = sharingFilterOrFault === DEPENDENCY_FAULT; + const sharingFilter = sharingFilterOrFault === DEPENDENCY_FAULT ? undefined : sharingFilterOrFault; const sharingMatches = sharingFilter === undefined ? undefined : matches(sharingFilter); // Write ops: the by-construction verdict is the sharing service's own gate. // [ADR-0111 D3] delete has its own narrower gate (an edit share does not // confer delete), so a `delete` explanation consults canDelete, not canEdit. const writeGate = engineOp === 'delete' ? deps.canDeleteRecord : deps.canEditRecord; - const canEdit = !isRead && writeGate && recordExists - ? await writeGate(object, recordId, context).catch(() => undefined) + // A rejected gate is not an absent one: "no gate wired" hands the verdict to + // ownership and read shares, while the by-id write it explains fails. + const canEditOrFault = !isRead && writeGate && recordExists + ? await settle(writeGate(object, recordId, context)) : undefined; + const writeGateFault = canEditOrFault === DEPENDENCY_FAULT; + const canEdit = canEditOrFault === DEPENDENCY_FAULT ? undefined : canEditOrFault; + // The sharing call THIS operation's row verdict rests on: the read filter for + // a read (the sharing middleware ANDs it into every find), the per-record gate + // for a write (the middleware gates every by-id write on it). Either one + // rejecting fails the request whatever the OWD, so it is judged before the + // baseline is. + const sharingFaultDetail = isRead + ? (readFilterFault + ? "The sharing service's read filter could not be evaluated: it threw, and the find fails on the same " + + 'call — so the record is reported NOT visible (fail closed), never admitted.' + : undefined) + : (writeGateFault + ? `The sharing service's per-record ${engineOp === 'delete' ? 'delete' : 'update'} gate could not be ` + + 'evaluated: it threw, and the by-id write fails on the same call — so the record is reported NOT ' + + 'writable (fail closed), never admitted.' + : undefined); const anyShareAdmits = shareRules.some((r) => r.effect === 'admits'); let sharingOutcome: ExplainRecordAttribution['outcome']; if (!recordExists) { sharingOutcome = 'not_evaluated'; + } else if (sharingFaultDetail) { + sharingOutcome = 'not_evaluated'; } else if (owd.effect !== 'private') { sharingOutcome = 'not_evaluated'; // baseline already grants the rows sharing would add } else if (canEdit !== undefined) { @@ -984,10 +1036,13 @@ async function applyRecordAttribution( if (sharingLayer) { sharingLayer.record = { outcome: sharingOutcome, - rowFilter: sharingFilter === undefined ? undefined : sharingFilter, - matchesRecord: sharingMatches, + // A faulted layer publishes no predicate and no match: neither is the + // judgement behind a `not_evaluated` outcome. + rowFilter: sharingFaultDetail || sharingFilter === undefined ? undefined : sharingFilter, + matchesRecord: sharingFaultDetail ? undefined : sharingMatches, rules: shareRules, - detail: !recordExists + // A fault is only ever recorded against an existing record. + detail: sharingFaultDetail ?? (!recordExists ? 'Record not found; sharing not evaluated.' : owd.effect !== 'private' ? 'Baseline is not private — sharing adds nothing beyond it for this record.' @@ -1008,7 +1063,7 @@ async function applyRecordAttribution( : `${shareRules.length} share(s) attached; none grants the caller access to this record.` + (vamaEffective ? ` Superseded by the View/Modify All Data bypass [${vamaSets.join(', ')}] — see the vama_bypass layer.` - : ''), + : '')), }; } @@ -1036,6 +1091,8 @@ async function applyRecordAttribution( if (rlsLayer) { if (!recordExists) { rlsLayer.record = { outcome: 'not_evaluated', rules: [], detail: 'Record not found; business RLS not evaluated.' }; + } else if (layeredFault) { + rlsLayer.record = { outcome: 'not_evaluated', rules: [], detail: layeredFaultDetail('Business RLS') }; } else if (layered === undefined) { rlsLayer.record = { outcome: 'not_evaluated', rules: [], detail: 'Business RLS split is unavailable on this engine build.' }; } else if (layer1 == null) { @@ -1094,6 +1151,10 @@ async function applyRecordAttribution( else if (!recordExists) { visible = false; decidedBy = undefined; } else if (tenantExcluded) { visible = false; decidedBy = 'tenant_isolation'; } else if (rlsExcluded) { visible = false; decidedBy = 'rls'; } + // A dependency that threw decides by failing the request, in the order the + // pipeline calls it: the RLS composition runs before the sharing middleware. + else if (layeredFault) { visible = false; decidedBy = 'rls'; } + else if (sharingFaultDetail) { visible = false; decidedBy = 'sharing'; } else if (!businessRowAdmits) { visible = false; decidedBy = owd.effect === 'private' ? 'sharing' : 'owd_baseline'; } else { visible = true; @@ -1135,12 +1196,17 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput let delegatorSets: PermissionSet[] | null = null; let delegatorContextForRls: any = null; let delegatorMissing = false; + // The delegator's grants could not be READ — the resolution threw. The + // middleware resolves the delegator un-caught, so the request fails: a + // fail-closed denial like a missing delegator, never "no delegation", which + // would let the agent's own grants decide alone. + let delegatorUnresolved = false; let delegatorNames: string[] = []; if (context?.onBehalfOf?.userId) { - const del = await resolveDelegatorContext(deps.ql, context).catch( - () => ({ kind: 'none' }) as DelegatorResolution, - ); - if (del.kind === 'missing') { + const del = await settle(resolveDelegatorContext(deps.ql, context)); + if (del === DEPENDENCY_FAULT) { + delegatorUnresolved = true; + } else if (del.kind === 'missing') { delegatorMissing = true; } else if (del.kind === 'resolved') { delegatorContextForRls = del.context; @@ -1172,14 +1238,16 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput delegatedPositions.find((d) => d.name === name); layers.push({ layer: 'principal', - verdict: delegatorMissing ? 'denies' : 'neutral', + verdict: delegatorMissing || delegatorUnresolved ? 'denies' : 'neutral', detail: `Principal ${context?.userId ?? '(anonymous)'} holds position(s) [${positions.join(', ') || 'none'}] ` + `resolving to permission set(s) [${setNames.join(', ') || 'none'}] (union-merged, most-permissive).` + (context?.onBehalfOf?.userId ? delegatorMissing ? ` Acting on behalf of ${context.onBehalfOf.userId}, who no longer exists — D10 fails CLOSED (access denied).` - : ` Acting on behalf of ${context.onBehalfOf.userId} — effective access is the D10 INTERSECTION with the delegator's set(s) [${delegatorNames.join(', ') || 'none'}].` + : delegatorUnresolved + ? ` Acting on behalf of ${context.onBehalfOf.userId}, whose grants could not be resolved (the lookup threw) — D10 fails CLOSED (access denied).` + : ` Acting on behalf of ${context.onBehalfOf.userId} — effective access is the D10 INTERSECTION with the delegator's set(s) [${delegatorNames.join(', ') || 'none'}].` : '') + (delegatedPositions.length > 0 ? ` ${delegatedPositions.length} position(s) held via delegation (ADR-0091 D3): [${delegatedPositions @@ -1295,7 +1363,7 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput // that layer's grant is computed FROM, and reporting the real cause beats a // misleading "no set grants it" when the sets were never the problem. const postureUnresolved = secMeta.unresolved === true; - const crudAllowed = agentCrud && delegatorCrud && !delegatorMissing && !postureUnresolved; + const crudAllowed = agentCrud && delegatorCrud && !delegatorMissing && !delegatorUnresolved && !postureUnresolved; const granting = postureUnresolved ? [] : sets @@ -1314,10 +1382,12 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput ? unresolvedPostureExplainDetail(object, secMeta.unresolvedCause ?? 'unknown') : delegatorMissing ? `Delegator no longer exists — D10 fails closed (access denied).` - : agentCrud && !delegatorCrud - ? `The agent grants ${operation} on '${object}' but the DELEGATOR does not — D10 intersection denies (an agent may not exceed the user it acts for).` - : `No resolved permission set grants ${operation} on '${object}'` + - (secMeta.isPrivate ? " (object is 'private' posture — non-superuser '*' wildcards are excluded, ADR-0066 D2)." : '.'), + : delegatorUnresolved + ? `The delegator's grants could not be resolved — D10 fails closed (access denied).` + : agentCrud && !delegatorCrud + ? `The agent grants ${operation} on '${object}' but the DELEGATOR does not — D10 intersection denies (an agent may not exceed the user it acts for).` + : `No resolved permission set grants ${operation} on '${object}'` + + (secMeta.isPrivate ? " (object is 'private' posture — non-superuser '*' wildcards are excluded, ADR-0066 D2)." : '.'), contributors: granting.map((n) => ({ kind: 'permission_set' as const, name: n, via: viaOf(n) })), }); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 1c46daf3eb3..89a880d7cbd 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2851,6 +2851,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-security/src/explain-dependency-fault.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-security/src/explain-engine.test.ts", "verb": "findOne",