From 0feb79ca4fbcaa02aa6356962d00086914b547c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 13:28:22 +0000 Subject: [PATCH 1/2] refactor(plugin-security): move the read filters, field readers and object admissions out of security-plugin.ts The bodies of getReadFilter and computeDelegatedReadFilter (the row-level read scope and its on-behalf-of intersection) move unedited to security-plugin-read-filter.ts. The bodies of resolveProjectionFieldMask, computeReadableFields and resolveCallerPosture (the field projections' shared derivation, the readable projection and the caller's posture) move unedited to security-plugin-field-readers.ts, with the one declaration and the one import name only they read. The bodies of canReadObject, objectReadAdmission, canWriteObject and canExport move unedited to security-plugin-object-admission.ts. Each method keeps its name, signature, docblock and position and hands its body one host literal of exactly the members it reads. The four thin field projections stay whole. The two readers that read the moved lines by path are re-pointed: the system-context page's five elevation-read anchors name the new modules, and the delegator enumeration pin lists the three new files. Move-only: no verdict changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa --- content/docs/permissions/system-context.mdx | 10 +- .../delegated-organization-membership.test.ts | 11 +- .../src/security-plugin-field-readers.ts | 252 +++++++ .../src/security-plugin-object-admission.ts | 409 +++++++++++ .../src/security-plugin-read-filter.ts | 241 +++++++ .../plugin-security/src/security-plugin.ts | 680 ++++-------------- 6 files changed, 1058 insertions(+), 545 deletions(-) create mode 100644 packages/plugins/plugin-security/src/security-plugin-field-readers.ts create mode 100644 packages/plugins/plugin-security/src/security-plugin-object-admission.ts create mode 100644 packages/plugins/plugin-security/src/security-plugin-read-filter.ts diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e1872dcc6a5..c91561cc130 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -98,11 +98,11 @@ that silently does not happen. |:--|:---|:---|:---|:---| | 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to `org` (row 7) — this is the single largest behaviour on the page | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `packages/plugins/plugin-security/src/security-plugin.ts#start` | -| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter` | -| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin.ts#resolveProjectionFieldMask` | -| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin.ts#canExport` | -| 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin.ts#canReadObject` | -| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture, the D10 dangling delegator and, on a walled posture, the D10 delegator who is not a member of the organization the request runs in), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin.ts#canWriteObject` | +| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `packages/plugins/plugin-security/src/security-plugin-read-filter.ts#getReadFilter` | +| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin-field-readers.ts#resolveProjectionFieldMask` | +| 5 | Export permission granted unconditionally | plugin-security | Get: `canExport` is `true` | `packages/plugins/plugin-security/src/security-plugin-object-admission.ts#canExport` | +| 6 | Object-level read admission granted unconditionally | plugin-security | Get: `canReadObject` is `true`. This is the OBJECT-level half of a read — "may this caller read this object at all" — which the doors that bypass this middleware ask before they compile a statement of their own; `getReadFilter` is its row-level half, and the two are not interchangeable | `packages/plugins/plugin-security/src/security-plugin-object-admission.ts#canReadObject` | +| 6b | WRITE admission granted unconditionally | plugin-security | Get: `canWriteObject` is `true`. The WRITE twin of the object-level read admission above, asked by the write PREVIEW (`ObjectQL.validate()`), which runs no middleware for its target object and so has no gate of its own. It exists because a validation rule that reads one hop through a reference field is evaluated against a related row fetched under system authority, and that elevation is bounded to the arms the question RUNS — named, ⛔ never a category of the write decision, and ⛔ not a promise the write would succeed. The arms: the ADR-0103 engine-owned affordance gate and the ADR-0090 D12 delegated-admin gate (both ahead of every resolution), the fail-closed postures (#3545's unresolvable posture, the D10 dangling delegator and, on a walled posture, the D10 delegator who is not a member of the organization the request runs in), the ADR-0066 D3 capability arm, the CRUD grant, the ADR-0090 D10 delegator arm that a bare CRUD-grant check misses, the field-level-security write gate over the caller's payload when one is supplied, and the ADR-0123 D2 no-active-organization wall | `packages/plugins/plugin-security/src/security-plugin-object-admission.ts#canWriteObject` | | 6c | The tenant wall's per-row verdict for the write PREVIEW answers **"not asked"** | plugin-security | Get: `writeWallVerdict` returns `null`, so `ObjectQL.validate()` treats the rows exactly as it does with no verdict registered: no row is refused by the Layer 0 tenant wall, and the elevated preview's `readonly` strips stay skipped as they always were. That is what the elevated write does: the whole security middleware short-circuits before step 3.7, so the wall never judges an elevated row's organization. Lose: nothing the write enforces. The verdict exists so a preview answers a row's organization as its write answers it (#25062), and an elevated write meets no tenant wall to answer for | `packages/plugins/plugin-security/src/security-plugin-write-tenant-wall.ts#writeWallVerdict` | | 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` | | 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | diff --git a/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts b/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts index e72e922f7bc..22b284db1c8 100644 --- a/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts +++ b/packages/plugins/plugin-security/src/delegated-organization-membership.test.ts @@ -685,7 +685,7 @@ describe('[#25107] every reader in this package that resolves a delegator asks t const calls = (text: string, name: string) => (text.match(new RegExp(`\\b${name}\\(`, 'g')) ?? []).length - (text.match(new RegExp(`function ${name}\\(`, 'g')) ?? []).length; - it('per source file, the asks equal the resolutions — and the three files that resolve one are all counted', () => { + it('per source file, the asks equal the resolutions — and the six files that resolve one are all counted', () => { const counted: Record = {}; for (const file of readdirSync(SRC, { recursive: true }) as string[]) { if (!file.endsWith('.ts') || file.endsWith('.test.ts') || file.includes('.testkit.')) continue; @@ -694,7 +694,14 @@ describe('[#25107] every reader in this package that resolves a delegator asks t if (resolutions === 0) continue; counted[file] = [resolutions, calls(text, 'delegatedOrganizationRefusal')]; } - expect(Object.keys(counted).sort()).toEqual(['explain-engine.ts', 'security-plugin-operation-principals.ts', 'security-plugin.ts']); + expect(Object.keys(counted).sort()).toEqual([ + 'explain-engine.ts', + 'security-plugin-field-readers.ts', + 'security-plugin-object-admission.ts', + 'security-plugin-operation-principals.ts', + 'security-plugin-read-filter.ts', + 'security-plugin.ts', + ]); for (const [file, [resolutions, asks]] of Object.entries(counted)) expect(asks, file).toBe(resolutions); }); }); diff --git a/packages/plugins/plugin-security/src/security-plugin-field-readers.ts b/packages/plugins/plugin-security/src/security-plugin-field-readers.ts new file mode 100644 index 00000000000..e5bca44c9e9 --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-field-readers.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 9] The field readers of `SecurityPlugin`, moved out of + * `security-plugin.ts`: + * + * - `resolveProjectionFieldMask`, the derivation every published field + * projection shares: the schema's field universe, the caller's permission + * sets (the configured fallback set on the metadata plane), the posture its + * gates read, the evaluator's field map with the per-field capability fold, + * and the delegator's intersection, refused where the middleware's prologue + * refuses the delegator (#25107). + * - `computeReadableFields`, the readable projection the data plane's and the + * metadata plane's readers both answer from: every schema field not masked + * non-readable, a partially masked field kept as a served column (#8993). + * - `resolveCallerPosture`, the one object posture a caller's gates read, the + * middleware's and the projections' alike, with the stand-in for a caller + * who resolved no set (#20995, #21063). + * + * A MOVE, not a redesign. Each function below is the body of the + * `SecurityPlugin` method of the same name as it stood, re-indented one level + * and otherwise unedited: the same universe, the same steps in the same + * order, the same answers. What only these bodies read moved with them, also + * unedited: `EMPTY_REQUIRED_PERMISSIONS`, the stand-in's empty capability + * contract, and the `intersectFieldMasks` import name. + * + * ## How the moved bodies reach the class + * + * The shape cuts 1 to 8 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps all three private methods — the + * published `dist/*.d.ts` lists each one's name, docblock and position — and + * each body is one literal of exactly the {@link FieldReadersHost} members that + * method reads, plus a `.call`. A method is handed over as a closure that calls + * it on the plugin at call time, so a test double or spy set on the plugin is + * still the one the body reaches: the derivation still asks the plugin's field + * universe, set resolution, fallback sets, posture and field folds, and the + * four public projections and the middleware still reach these methods + * through the plugin. All three methods were async and all three functions + * still are; each method now returns its function's promise. + * + * Each field was classified on its own declaration, by the rule cut 1 set. + * `metadata`, `permissionEvaluator`, `ql` and `tenancyPosture` are not + * declared `readonly` (`start()` assigns three of them after construction, + * and tests replace `permissionEvaluator` on a constructed plugin), so they go + * as GETTERS, and each read happens when the body makes it. + * + * ## What stays in `security-plugin.ts`, and how it is read + * + * The four public projections stay in the class whole — + * `getReadableFields`, `getMetadataReadableFields`, `getWritableFields` and + * `getQueryableFields` — because each body is no longer than its stub would + * be. The posture type `ObjectSecurityMeta` keeps its one declaration there, + * where the posture reader and every gate read it; this module names it + * through the class's own signature, and the capability-contract type through + * the posture's own field (below). `PermissionSet` is imported from + * `@objectstack/spec/security`, `FieldMaskingRule` from + * `@objectstack/spec/data`, and the delegator's resolution and its + * organization check from `explain-engine.js`, as `security-plugin.ts` imports + * them. + * + * The system-context page anchors the projections' elevation read on this + * module's `resolveProjectionFieldMask`, which is where the read now sits. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import type { SecurityPlugin } from './security-plugin.js'; +import type { PermissionSet } from '@objectstack/spec/security'; +import type { FieldMaskingRule } from '@objectstack/spec/data'; +import { delegatedOrganizationRefusal, intersectFieldMasks, resolveDelegatorContext } from './explain-engine.js'; + +/** + * The `SecurityPlugin` members the functions below read — every one of them, + * and no other; each function's `this` is the `Pick` of the members IT reads. + * Each is typed AS the class member (an indexed access on the class), so a + * change to a member's signature on the class is a change here too, and naming + * a member the class does not have is a compile error. See the module header + * for which members are getters. + */ +export interface FieldReadersHost { + readonly metadata: SecurityPlugin['metadata']; + readonly permissionEvaluator: SecurityPlugin['permissionEvaluator']; + readonly ql: SecurityPlugin['ql']; + readonly tenancyPosture: SecurityPlugin['tenancyPosture']; + computeQueryGuardFieldPerms: SecurityPlugin['computeQueryGuardFieldPerms']; + computeReadPartialMaskRules: SecurityPlugin['computeReadPartialMaskRules']; + foldFieldRequiredPermissions: SecurityPlugin['foldFieldRequiredPermissions']; + getObjectFieldNames: SecurityPlugin['getObjectFieldNames']; + getObjectSecurityMeta: SecurityPlugin['getObjectSecurityMeta']; + resolveCallerPosture: SecurityPlugin['resolveCallerPosture']; + resolveFallbackPermissionSets: SecurityPlugin['resolveFallbackPermissionSets']; + resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; + resolveProjectionFieldMask: SecurityPlugin['resolveProjectionFieldMask']; +} + +/** + * The posture type, named through the class's own signature: the interface + * stays in `security-plugin.ts`, where the posture reader and every gate read + * it. + */ +type ObjectSecurityMeta = Awaited>; +/** The capability-contract type, named through the posture's own field. */ +type NormalizedRequiredPermissions = ObjectSecurityMeta['requiredPermissions']; + +const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze({ + all: [], read: [], create: [], update: [], delete: [], +}) as NormalizedRequiredPermissions; + +/** The body of `SecurityPlugin#computeReadableFields`; the method's docblock there is its contract. */ +export async function computeReadableFields( + this: Pick, + object: string, + context: any, + options: { fallbackOnEmptySets: boolean }, +): Promise { + const mask = await this.resolveProjectionFieldMask(object, context, options); + if (mask.kind === 'answer') return mask.fields; + + // [#8993] A field this caller sees PARTIALLY MASKED is still a served + // column — the read path REPLACES its value rather than deleting the key — + // so it stays in the projection (an export header must include the column + // whose masked values the same caller's rows carry). Mirrors the step-4 + // exclusion exactly: an explicit permission-set deny keeps the field + // deleted, hence out of the projection. + const partialRules = mask.readPartialMaskRules(); + + // Readable = every schema field NOT explicitly masked non-readable. A field + // with no permission entry passes through (the field allow-list only + // enumerates fields it names) — the exact complement of maskResults' delete + // set, so the export header matches list's readable columns by construction. + return mask.allFields.filter((f) => mask.fieldPerms[f]?.readable !== false || partialRules[f] !== undefined); +} + +/** The body of `SecurityPlugin#resolveProjectionFieldMask`; the method's docblock there is its contract. */ +export async function resolveProjectionFieldMask( + this: Pick, + object: string, + context: any, + options: { fallbackOnEmptySets: boolean }, +): Promise< + | { kind: 'answer'; fields: string[] | undefined } + | { + kind: 'mask'; + allFields: string[]; + fieldPerms: Record; + readPartialMaskRules: () => Record; + /** [#20935] The middleware's query-guard map for these sets (computeQueryGuardFieldPerms). */ + readQueryGuardPerms: () => Record; + } +> { + const objectName = String(object ?? ''); + if (!objectName) return { kind: 'answer', fields: undefined }; + // The field universe — the SAME source the RLS field pass uses (ObjectQL's + // live SchemaRegistry first, metadata artifact fallback). `null` → schema + // not resolvable → let the caller fall back rather than guess. + const fieldNameSet = await this.getObjectFieldNames(this.metadata, objectName, this.ql); + if (!fieldNameSet) return { kind: 'answer', fields: undefined }; + const allFields = [...fieldNameSet]; + // System operations bypass FLS (mirrors the middleware's isSystem skip). + if (context?.isSystem) return { kind: 'answer', fields: allFields }; + + let permissionSets = await this.resolvePermissionSetsForContext(context); + if (permissionSets.length === 0 && options.fallbackOnEmptySets) { + // [ADR-0106 D7] `resolvePermissionSetsForContext` applies the baseline + // only for a caller carrying `userId`, so a guest/anonymous caller lands + // here with nothing. Resolve the configured fallback set explicitly — + // the same two-step `/auth/me/permissions` performs. + permissionSets = await this.resolveFallbackPermissionSets(context); + } + // [#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 + // stance this method already takes on a dangling delegator below. The + // per-field capability contract (`fieldRequiredPermissions`) would otherwise + // default to empty and silently unmask every capability-gated column, and + // [#20995] the masking rules a caller with no set is held to are unknown + // ([#21063] as is that caller's field capability contract). + if (secMeta.unresolved) return { kind: 'answer', fields: [] }; + const basePerms = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets); + let fieldPerms = this.foldFieldRequiredPermissions(basePerms, secMeta.fieldRequiredPermissions, permissionSets); + + // [ADR-0090 D10] On an on-behalf-of request the projection must NOT widen + // past the DELEGATOR's — intersect the delegator's field mask too. A + // dangling delegator fails CLOSED (expose no columns), the same fail-closed + // stance the CRUD middleware takes on a 'missing' delegator; [#25107] and so + // does a delegator who is not a member of the organization the request runs + // in, on a walled posture, which the middleware's prologue refuses + // ({@link delegatedOrganizationRefusal}). [#20995] Asked + // only when the caller resolved a set, as the middleware asks it: a caller + // who resolved none reaches here only for its masking rules and [#21063] + // its field capability contract, and every one of them already applies to + // it. + let delBasePerms: Record | null = null; + let delegatorSets: PermissionSet[] | null = null; + if (permissionSets.length > 0 && context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind === 'missing') return { kind: 'answer', fields: [] }; + if (del.kind === 'resolved') { + if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) { + return { kind: 'answer', fields: [] }; + } + delegatorSets = await this.resolvePermissionSetsForContext(del.context); + delBasePerms = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); + let delFieldPerms = this.foldFieldRequiredPermissions(delBasePerms, secMeta.fieldRequiredPermissions, delegatorSets); + fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); + } + } + + return { + kind: 'mask', + allFields, + fieldPerms, + readPartialMaskRules: () => this.computeReadPartialMaskRules( + secMeta, permissionSets, delegatorSets, basePerms, delBasePerms, + ), + readQueryGuardPerms: () => this.computeQueryGuardFieldPerms( + objectName, secMeta, permissionSets, delegatorSets, + ), + }; +} + +/** The body of `SecurityPlugin#resolveCallerPosture`; the method's docblock there is its contract. */ +export async function resolveCallerPosture( + this: Pick, + object: string, + permissionSets: PermissionSet[], +): Promise { + const posture = await this.getObjectSecurityMeta(object); + if (permissionSets.length > 0) return posture; + return { + isPrivate: false, + tenancyDisabled: false, + isBetterAuthManaged: false, + tenantAnchorIsPhantom: false, + owdOpensRowWrites: false, + requiredPermissions: EMPTY_REQUIRED_PERMISSIONS, + fieldRequiredPermissions: posture.fieldRequiredPermissions, + fieldMaskingRules: posture.fieldMaskingRules, + unresolved: posture.unresolved, + // [#10401] Explanation only, and present only on the refusing path. + unresolvedCause: posture.unresolvedCause, + }; +} diff --git a/packages/plugins/plugin-security/src/security-plugin-object-admission.ts b/packages/plugins/plugin-security/src/security-plugin-object-admission.ts new file mode 100644 index 00000000000..64e445d6064 --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-object-admission.ts @@ -0,0 +1,409 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 9] The object-level admissions of `SecurityPlugin`, moved out of + * `security-plugin.ts`: + * + * - `canReadObject`, whether a context may read an object at all: the + * admission the read doors that bypass the engine middleware ask before + * they compile a statement of their own. + * - `objectReadAdmission`, that admission's arms over permission sets the + * caller has already resolved: the posture, the capability AND-gate, the + * read grant, and the delegator's same questions. + * - `canWriteObject`, the write admission the write preview asks (#18682), the + * sibling of `canReadObject`, with its arms in the middleware's own order. + * - `canExport`, the user-level export axis a bulk-egress door asks before it + * reads (#3544). + * + * A MOVE, not a redesign. Each function below is the body of the + * `SecurityPlugin` method of the same name as it stood, re-indented one level + * and otherwise unedited: the same arms in the same order, the same system + * and principal-less answers, the same fail-closed delegator legs, the same + * warnings. + * + * ## How the moved bodies reach the class + * + * The shape cuts 1 to 8 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps all four methods — the published + * `dist/*.d.ts` lists each one's name, docblock and position, and three of + * them are public — and each body is one literal of exactly the + * {@link ObjectAdmissionHost} members that method reads, plus a `.call`. A + * method is handed over as a closure that calls it on the plugin at call + * time, so a test double or spy set on the plugin is still the one the body + * reaches: the admissions still ask the plugin's set resolution, posture, + * arms, organization wall and forbidden-field check, and the service binding, + * the write preview and every other caller still reach these methods through + * the plugin. All four methods were async and all four functions still are; + * each method now returns its function's promise. + * + * Each field was classified on its own declaration, by the rule cut 1 set. + * `delegatedAdminGate`, `logger`, `permissionEvaluator`, `ql` and + * `tenancyPosture` are not declared `readonly` (`start()` assigns four of them + * after construction, and tests replace `permissionEvaluator` on a constructed + * plugin), so they go as GETTERS, and each read happens when the body makes + * it. The delegated-admin gate is an object whose own method the write + * admission calls, so no receiver changes. + * + * ## What stays in `security-plugin.ts`, and how it is read + * + * Two module-level functions these bodies read stay there, exported, and are + * imported here by value: `isPrincipalLessContext`, which ADR-0138 anchors in + * that file (cut 6 exported it), and `requiredCapsForOperation`, which the + * middleware and the explain path read too. Each is read only inside a + * function body, at call time, never while either module evaluates, so the + * two-way import is safe under ESM, and the published bundle holds one + * module. `PermissionSet` is imported from `@objectstack/spec/security`, + * `PermissionDeniedError` from `errors.js`, the engine-owned write guard from + * `system-write-guard.js`, and the delegator's resolution and its + * organization check from `explain-engine.js`, as `security-plugin.ts` + * imports them. + * + * The system-context page anchors the three admissions' elevation reads on + * this module's `canReadObject`, `canWriteObject` and `canExport`, which is + * where the reads now sit. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import type { SecurityPlugin } from './security-plugin.js'; +import { isPrincipalLessContext, requiredCapsForOperation } from './security-plugin.js'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { PermissionDeniedError } from './errors.js'; +import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; +import { delegatedOrganizationRefusal, resolveDelegatorContext } from './explain-engine.js'; + +/** + * The `SecurityPlugin` members the functions below read — every one of them, + * and no other; each function's `this` is the `Pick` of the members IT reads. + * Each is typed AS the class member (an indexed access on the class), so a + * change to a member's signature on the class is a change here too, and naming + * a member the class does not have is a compile error. See the module header + * for which members are getters. + */ +export interface ObjectAdmissionHost { + readonly delegatedAdminGate: SecurityPlugin['delegatedAdminGate']; + readonly logger: SecurityPlugin['logger']; + readonly permissionEvaluator: SecurityPlugin['permissionEvaluator']; + readonly ql: SecurityPlugin['ql']; + readonly tenancyPosture: SecurityPlugin['tenancyPosture']; + computeForbiddenFieldWrites: SecurityPlugin['computeForbiddenFieldWrites']; + getObjectSecurityMeta: SecurityPlugin['getObjectSecurityMeta']; + objectReadAdmission: SecurityPlugin['objectReadAdmission']; + organizationWallRefusal: SecurityPlugin['organizationWallRefusal']; + resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; +} + +/** The body of `SecurityPlugin#canReadObject`; the method's docblock there is its contract. */ +export async function canReadObject( + this: Pick, + object: string, + context?: any, +): Promise { + const objectName = String(object ?? ''); + 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); + // [#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; + + // [ADR-0090 D10] Resolve the delegator ONCE — arms 4 and 6 both need it, + // and a dangling link denies before either runs. [#25107] So does a + // delegator who is not a member of the organization the read runs in, on + // a walled posture: the middleware's prologue refuses the read there + // ({@link delegatedOrganizationRefusal}). + let delegatorSets: PermissionSet[] | null = null; + if (context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind === 'missing') return false; + if (del.kind === 'resolved') { + if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return false; + delegatorSets = await this.resolvePermissionSetsForContext(del.context); + } + } + + // 3–6. The posture and grant arms, over the resolved sets — the one + // predicate the ADR-0055 master-derived read scope asks as well. + return await this.objectReadAdmission(objectName, permissionSets, delegatorSets); + } catch (e) { + this.logger.error?.( + `[security] canReadObject could not resolve the object-level read admission for ` + + `'${objectName}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, + e instanceof Error ? e : new Error(String(e)), + ); + return false; + } +} + +/** The body of `SecurityPlugin#objectReadAdmission`; the method's docblock there is its contract. */ +export async function objectReadAdmission( + this: Pick, + objectName: string, + permissionSets: PermissionSet[], + delegatorSets: PermissionSet[] | null, +): Promise { + if (permissionSets.length === 0) return false; + + const { isPrivate, unresolved, requiredPermissions } = + await this.getObjectSecurityMeta(objectName); + // 3. [#3545] Posture unresolvable → deny. + if (unresolved) return false; + + // 4. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both + // principals — a caller missing any required capability is denied + // however permissive their grants are. + const required = requiredCapsForOperation(requiredPermissions, 'find'); + if (required.length > 0) { + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + if (required.some((cap) => !held.has(cap))) return false; + if (delegatorSets && delegatorSets.length > 0) { + const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); + if (required.some((cap) => !delHeld.has(cap))) return false; + } + } + + // 5. The object-level CRUD grant. + if (!this.permissionEvaluator.checkObjectPermission('find', objectName, permissionSets, { isPrivate })) { + return false; + } + + // 6. [ADR-0090 D10] The delegator must independently grant the same read. + if ( + delegatorSets && + !this.permissionEvaluator.checkObjectPermission('find', objectName, delegatorSets, { isPrivate }) + ) { + return false; + } + + return true; +} + +/** The body of `SecurityPlugin#canWriteObject`; the method's docblock there is its contract. */ +export async function canWriteObject( + this: Pick, + object: string, + operation: 'insert' | 'update', + context?: any, + data?: unknown, +): Promise { + const objectName = String(object ?? ''); + if (!objectName) return false; + // 1. System operations bypass. + if (context?.isSystem) return true; + + 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 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 + // is exactly the class this method exists to keep out of the + // preview. + try { + // 2. [ADR-0103] Engine-owned write affordance. ⛔ Not a re-derivation: + // this is the middleware's own primitive over the same registered + // schema, so the `userActions` members that reopen a verb pass here + // for the one reason they pass there. + assertEngineOwnedWriteAllowed( + typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(objectName) : undefined, + operation, + context, + ); + // 3. [ADR-0090 D12] Delegated administration on the RBAC link tables. + // The middleware hands its whole `opCtx`; a preview names no stored + // row, so this hands `object`, `operation`, `context` and the rows + // of `data` and NOTHING else — leaving the gate's delegate branch + // refusing an id-less mutation it cannot boundary-check, pinned as a + // direction in `can-write-object-admission.test.ts`. + // + // ⭐ And the rows it supplies are SHALLOW COPIES, never the caller's + // own objects. The gate stamps `granted_by` onto the rows it + // materialises (its dual audit), and on an insert it materialises + // the payload rows BY REFERENCE — while `validate()` hands this + // method the caller's RAW payload, so passing it straight through + // would let a PREVIEW write into the caller's own objects. The + // decision cannot notice the copy: nothing reads `granted_by` back, + // it is only ever written — so its pin reads the caller's rows + // instead (`can-write-object-admission.test.ts`, the copy block). + if (this.delegatedAdminGate) { + const shallow = (row: unknown) => + row && typeof row === 'object' && !Array.isArray(row) + ? { ...(row as Record) } + : row; + await this.delegatedAdminGate.assert({ + object: objectName, + operation, + context, + data: Array.isArray(data) ? data.map(shallow) : shallow(data), + }); + } + } catch (e) { + // A refusal from either gate IS the admission answer — the write path's + // own denial, not a failure to resolve one. Anything else is a + // subsystem failure and belongs to the outer catch, which logs it and + // still denies. + if (e instanceof PermissionDeniedError) return false; + 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 + // 3.7 guard: a payload is supplied and the context names a user. Its + // point is last, after the field gate. + const organizationWallAdmits = async ( + delegatorSets: PermissionSet[] | null, + delegatorContext: unknown, + ): Promise => + !(data && typeof data === 'object' && context?.userId + && (await this.organizationWallRefusal( + permissionSets, objectName, operation, context, delegatorSets, delegatorContext, + ))); + // [#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. + if (permissionSets.length === 0) return false; + + const { isPrivate, unresolved, requiredPermissions, fieldRequiredPermissions } = + await this.getObjectSecurityMeta(objectName); + // 5. [#3545] Posture unresolvable → deny. + if (unresolved) return false; + + // [ADR-0090 D10] Resolve the delegator ONCE — the arms below need it, + // and a dangling link denies before any of them runs. [#25107] So does a + // delegator who is not a member of the organization the write runs in, + // on a walled posture: the middleware's prologue refuses the write there + // ({@link delegatedOrganizationRefusal}). + let delegatorSets: PermissionSet[] | null = null; + let delegatorContext: unknown = null; + if (context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind === 'missing') return false; + if (del.kind === 'resolved') { + if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return false; + delegatorContext = del.context; + delegatorSets = await this.resolvePermissionSetsForContext(del.context); + } + } + + // 6. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both + // principals. + const required = requiredCapsForOperation(requiredPermissions, operation); + if (required.length > 0) { + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + if (required.some((cap) => !held.has(cap))) return false; + if (delegatorSets && delegatorSets.length > 0) { + const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); + if (required.some((cap) => !delHeld.has(cap))) return false; + } + } + + // 7. The object-level CRUD grant for the operation asked. + if (!this.permissionEvaluator.checkObjectPermission(operation, objectName, permissionSets, { isPrivate })) { + return false; + } + + // 8. [ADR-0090 D10] The delegator must independently grant the same write. + // [#21079] Asked over the delegator's list whatever its length, as the + // middleware asks it: a delegator who resolves NO set grants nothing, + // the deny baseline for the second principal too. + if ( + delegatorSets && + !this.permissionEvaluator.checkObjectPermission(operation, objectName, delegatorSets, { isPrivate }) + ) { + return false; + } + + // 9. The field-level-security WRITE gate — the middleware's step 2.5, + // over the payload the caller supplied: the middleware runs it for an + // `insert`/`update` carrying `opCtx.data`. ⛔ Not a re-derivation — + // [#21063] it IS the middleware's verdict (computeForbiddenFieldWrites); + // a second spelling of "which fields may this caller write" is the + // drift this whole method exists to avoid. + if (data) { + const forbidden = this.computeForbiddenFieldWrites( + objectName, data as Record | Record[], + { fieldRequiredPermissions }, permissionSets, delegatorSets, + ); + if (forbidden.length > 0) return false; + } + + // 10. [ADR-0123 D2] At the middleware's point — see above. + return await organizationWallAdmits(delegatorSets, delegatorContext); + } catch (e) { + this.logger.error?.( + `[security] canWriteObject could not resolve the write admission for ` + + `'${objectName}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, + e instanceof Error ? e : new Error(String(e)), + ); + return false; + } +} + +/** The body of `SecurityPlugin#canExport`; the method's docblock there is its contract. */ +export async function canExport( + this: Pick, + 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. + if (permissionSets.length === 0) return false; + + const { isPrivate, unresolved } = await this.getObjectSecurityMeta(objectName); + // [#3545] Posture unresolvable → deny. `isPrivate` would default to `false`, + // which is precisely what lets a plain wildcard reach the object; a bulk + // egress decision must not rest on a default we could not read. + if (unresolved) return false; + if (!this.permissionEvaluator.checkObjectPermission('export', objectName, permissionSets, { isPrivate })) { + return false; + } + + // [ADR-0090 D10] An on-behalf-of caller may never export past what the + // DELEGATOR could have exported themselves — intersect the delegator's own + // answer. A dangling delegator fails CLOSED, the same stance the CRUD + // middleware and {@link getReadableFields} take. [#21079] So does a + // delegator who resolves NO set: the evaluator answers that empty list's + // export as it answers any list, and the middleware refuses its read. + // [#25107] So does a delegator who is not a member of the organization the + // export runs in, on a walled posture: the middleware's prologue refuses + // the read there ({@link delegatedOrganizationRefusal}). + if (context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind === 'missing') return false; + if (del.kind === 'resolved') { + if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return false; + const delegatorSets = await this.resolvePermissionSetsForContext(del.context); + if ( + !this.permissionEvaluator.checkObjectPermission('export', objectName, delegatorSets, { isPrivate }) + ) { + return false; + } + } + } + + return true; +} diff --git a/packages/plugins/plugin-security/src/security-plugin-read-filter.ts b/packages/plugins/plugin-security/src/security-plugin-read-filter.ts new file mode 100644 index 00000000000..e6eaca6cda0 --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-read-filter.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 9] The row-level read scope of `SecurityPlugin`, moved out of + * `security-plugin.ts`: + * + * - `getReadFilter`, the scope the `security` service serves to the doors that + * compile their own statement: the same three layers the engine middleware + * ANDs into every find (tenant Layer 0 and RLS, the controlled-by-parent + * leaf, the sharing filter), the deny sentinel for a caller who resolves no + * set or carries no principal, and fail-closed on any resolution failure. + * - `computeDelegatedReadFilter`, the same scope for an on-behalf-of caller: + * the intersection of the principal's leg and the delegator's, layer for + * layer, refused where the middleware's prologue refuses the delegator + * (#25106). + * + * A MOVE, not a redesign. Each function below is the body of the + * `SecurityPlugin` method of the same name as it stood, re-indented one level + * and otherwise unedited: the same layers in the same order, the same deny + * sentinel by identity, the same warnings. + * + * ## How the moved bodies reach the class + * + * The shape cuts 1 to 8 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps both methods — the published + * `dist/*.d.ts` lists each one's name, docblock and position, and + * `getReadFilter` is public — and each body is one literal of exactly the + * {@link ReadFilterHost} members that method reads, plus a `.call`. A method + * is handed over as a closure that calls it on the plugin at call time, so a + * test double or spy set on the plugin is still the one the body reaches: the + * scope still asks the plugin's RLS filter, controlled-by-parent leaf, sharing + * filter, set resolution and posture, and the service binding, the middleware + * and every other caller still reach both methods through the plugin. Both + * methods were async and both functions still are; each method now returns + * its function's promise. + * + * Each field was classified on its own declaration, by the rule cut 1 set. + * `logger`, `permissionEvaluator`, `ql` and `tenancyPosture` are not declared + * `readonly` (`start()` assigns three of them after construction, and tests + * replace `permissionEvaluator` on a constructed plugin), so they go as + * GETTERS, and each read happens when the body makes it. + * + * ## What stays in `security-plugin.ts`, and how it is read + * + * `isPrincipalLessContext` stays there: ADR-0138 anchors it in that file, and + * the docblocks that stay there, `getReadFilter`'s among them, link to it in + * place. It is exported (cut 6) and imported here by value, as the prologue's + * and the object admission's modules import it. It is read only inside a + * function body, at call time, + * never while either module evaluates, so the two-way import is safe under + * ESM, and the published bundle holds one module. The deny sentinel is + * imported from `rls-compiler.js`, the layer composition from + * `tenant-layer.js`, and the delegator's resolution, its organization check + * and the sharing-depth intersection from `explain-engine.js`, as + * `security-plugin.ts` imports them. + * + * The system-context page anchors the scope's elevation read on this module's + * `getReadFilter`, which is where the read now sits. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import type { SecurityPlugin } from './security-plugin.js'; +import { isPrincipalLessContext } from './security-plugin.js'; +import { RLS_DENY_FILTER } from './rls-compiler.js'; +import { andComposeLayers } from './tenant-layer.js'; +import { delegatedOrganizationRefusal, intersectDelegatedScope, resolveDelegatorContext } from './explain-engine.js'; + +/** + * The `SecurityPlugin` members the functions below read — every one of them, + * and no other; each function's `this` is the `Pick` of the members IT reads. + * Each is typed AS the class member (an indexed access on the class), so a + * change to a member's signature on the class is a change here too, and naming + * a member the class does not have is a compile error. See the module header + * for which members are getters. + */ +export interface ReadFilterHost { + readonly logger: SecurityPlugin['logger']; + readonly permissionEvaluator: SecurityPlugin['permissionEvaluator']; + readonly ql: SecurityPlugin['ql']; + readonly tenancyPosture: SecurityPlugin['tenancyPosture']; + computeControlledByParentFilter: SecurityPlugin['computeControlledByParentFilter']; + computeDelegatedReadFilter: SecurityPlugin['computeDelegatedReadFilter']; + computeRlsFilter: SecurityPlugin['computeRlsFilter']; + getObjectSecurityMeta: SecurityPlugin['getObjectSecurityMeta']; + resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; + resolveSharingReadFilter: SecurityPlugin['resolveSharingReadFilter']; +} + +/** The body of `SecurityPlugin#getReadFilter`; the method's docblock there is its contract. */ +export async function getReadFilter( + this: Pick, + object: string, + context?: any, +): 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; + // [ADR-0090 D10 / ADR-0139 D5.1 as amended] A DELEGATED (on-behalf-of) + // context is scoped to the intersection of its own leg and its delegator's, + // composed as the engine middleware chain composes a delegated find + // ({@link computeDelegatedReadFilter}). This path used to answer the deny + // sentinel for every such context, because the intersection was threaded + // through the chain only; the engine's `ReadableIds` composition asks this + // method with the delegated context as it stands, so the intersection + // reaches here now. Fails CLOSED on every throw: a delegated read is never + // scoped wider than its delegator's own, and never served with a layer + // dropped. + if (context?.onBehalfOf?.userId) { + try { + return await this.computeDelegatedReadFilter(object, context); + } catch (e) { + this.logger.error?.( + `[security] getReadFilter failed for object '${object}' (principal ${context?.userId ?? 'unknown'} ` + + `on behalf of ${context.onBehalfOf.userId}) — denying (fail-closed)`, + e instanceof Error ? e : new Error(String(e)), + ); + 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 + // reason to drop it. A resolution failure denies outright — running the + // analytics raw-SQL path with a dropped owner predicate is the leak. + let sharingFilter: Record | null; + try { + sharingFilter = await this.resolveSharingReadFilter(object, context); + } catch (e) { + this.logger.error?.( + `[security] getReadFilter could not resolve the sharing (OWD) read scope for object ` + + `'${object}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed: a path that bypasses ` + + `the engine middleware never runs without the owner and share scope a direct read applies)`, + e instanceof Error ? e : new Error(String(e)), + ); + return RLS_DENY_FILTER; + } + try { + const permissionSets = await this.resolvePermissionSetsForContext(context); + // [#21079] ADR-0056 D2 — the deny baseline at the row scope. A caller who + // 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 + // row of an object whose sharing model is not private. Not a failure, + // so nothing is logged. + if (permissionSets.length === 0) return RLS_DENY_FILTER; + const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); + // [#5815] ADR-0055 — the SECOND thing the middleware ANDs into `ast.where` + // for a read: the leaf over the master, the same answer the middleware + // writes for a find. It stands down (null) for any object that is not + // controlled_by_parent, and it is internally fail-closed (the deny + // sentinel for an unresolvable relation, `$in: []` for a chain the + // registry walk refuses); a THROW propagates to the catch below, which + // denies — the same posture as the surrounding layers. + const cbpFilter = this.computeControlledByParentFilter(object, 'find', context); + // [#4467] RLS AND controlled-by-parent AND sharing — the same + // AND-composition the middlewares achieve by all writing into `ast.where`. + // Any layer may be absent; `andComposeLayers` returns the others, or null + // when none constrains. + return andComposeLayers(andComposeLayers(filter, cbpFilter), sharingFilter) ?? undefined; + } catch (e) { + // Fail CLOSED — a resolution failure must deny (zero rows), never expose + // every tenant's data through the raw-SQL analytics path. + this.logger.error?.( + `[security] getReadFilter failed for object '${object}' ` + + `(user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, + e instanceof Error ? e : new Error(String(e)), + ); + return RLS_DENY_FILTER; + } +} + +/** The body of `SecurityPlugin#computeDelegatedReadFilter`; the method's docblock there is its contract. */ +export async function computeDelegatedReadFilter( + this: Pick, + object: string, + context: any, +): Promise | undefined> { + const permissionSets = await this.resolvePermissionSetsForContext(context); + if (permissionSets.length === 0) return RLS_DENY_FILTER; + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind !== 'resolved') { + this.logger.error?.( + `[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator ` + + `'${String(context?.onBehalfOf?.userId)}' does not exist — denying (fail-closed: a delegated read is ` + + `never scoped wider than its delegator's own) — with no delegator to intersect against it is not ` + + `scoped at all (ADR-0090 D10)`, + ); + return RLS_DENY_FILTER; + } + const delegatorContext = del.context; + // [#25106 · ADR-0090 D10] The prologue's organization-membership refusal + // (`resolveOperationPrincipals`), mirrored at the same point: after the + // dangling-delegator refusal, before the delegator's sets resolve. On a + // walled posture the find path refuses every operation of a delegated + // context whose live active organization the delegator's own membership + // does not back, so the scope that agrees with it is zero rows, on every + // object. [#25107] The prologue's own ask ({@link delegatedOrganizationRefusal}), + // with the inputs it passes; under `single` the claim stands, and a context + // with no active organization is not this rule's (ADR-0123 D2). + if (delegatedOrganizationRefusal(context, delegatorContext, this.tenancyPosture) !== undefined) { + this.logger.warn?.( + `[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator is not a ` + + `member of the organization it runs in — denying, as the operation prologue refuses it (ADR-0090 D10, fail-closed)`, + ); + return RLS_DENY_FILTER; + } + const delegatorSets = await this.resolvePermissionSetsForContext(delegatorContext); + if (delegatorSets.length === 0) return RLS_DENY_FILTER; + + // Step 2.6's two depth stamps, from the same two evaluator calls. + const meta = await this.getObjectSecurityMeta(object); + const delegatorScope = this.permissionEvaluator.getEffectiveScope('read', object, delegatorSets, { isPrivate: meta.isPrivate }); + const declared = this.permissionEvaluator.getDeclaredScope('read', object, permissionSets, { isPrivate: meta.isPrivate }); + const agentLegScope = intersectDelegatedScope(declared, delegatorScope).agentLegScope; + + // Layers 5 and 6 — the sibling middleware's two calls. Resolved first, as + // the non-delegated path resolves its one sharing layer first: a sharing + // outage denies before any RLS layer is composed. + const sharingFilter = await this.resolveSharingReadFilter(object, context, permissionSets, agentLegScope); + const delegatorSharingFilter = await this.resolveSharingReadFilter( + object, + { ...context, userId: delegatorContext.userId, onBehalfOf: undefined }, + delegatorSets, + delegatorScope, + ); + + // Layers 1 to 4 — the security middleware's own four, in its order. + const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); + const cbpFilter = this.computeControlledByParentFilter(object, 'find', context); + const delFilter = await this.computeRlsFilter(delegatorSets, object, 'find', delegatorContext); + const delCbpFilter = this.computeControlledByParentFilter(object, 'find', delegatorContext); + + const securityLayers = andComposeLayers(andComposeLayers(filter, cbpFilter), andComposeLayers(delFilter, delCbpFilter)); + const sharingLayers = andComposeLayers(sharingFilter, delegatorSharingFilter); + return andComposeLayers(securityLayers, sharingLayers) ?? undefined; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index a9f8e9414be..4dbc128019a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -52,7 +52,6 @@ import { buildContextForUser, resolveDelegatorContext, delegatedOrganizationRefusal, - intersectFieldMasks, intersectDelegatedScope, d10NarrowingStatement, } from './explain-engine.js'; @@ -197,6 +196,35 @@ import { readRowById, type AddressedWritesHost, } from './security-plugin-addressed-writes.js'; +// [#25223 cut 9] The row-level read scope: the scope the service serves to +// the doors that compile their own statement, and the same scope for an +// on-behalf-of caller; the methods of the same names below hand each body the +// members it reads (`ReadFilterHost`). +import { + computeDelegatedReadFilter, + getReadFilter, + type ReadFilterHost, +} from './security-plugin-read-filter.js'; +// [#25223 cut 9] The field readers: the derivation every field projection +// shares, the readable projection and the posture a caller's gates read; the +// methods of the same names below hand each body the members it reads +// (`FieldReadersHost`). +import { + computeReadableFields, + resolveCallerPosture, + resolveProjectionFieldMask, + type FieldReadersHost, +} from './security-plugin-field-readers.js'; +// [#25223 cut 9] The object-level admissions: read, read over resolved sets, +// write and export; the methods of the same names below hand each body the +// members it reads (`ObjectAdmissionHost`). +import { + canExport, + canReadObject, + canWriteObject, + objectReadAdmission, + type ObjectAdmissionHost, +} from './security-plugin-object-admission.js'; import { isPlatformOwnershipFloorPolicy, masterGateCoversOperation, @@ -520,10 +548,6 @@ interface ObjectSecurityMeta { unresolvedCause?: UnresolvedPostureCause; } -const EMPTY_REQUIRED_PERMISSIONS: NormalizedRequiredPermissions = Object.freeze({ - all: [], read: [], create: [], update: [], delete: [], -}) as NormalizedRequiredPermissions; - /** * [#20995] A context that carries NO principal: no position, no named * permission set and no user id. @@ -5868,89 +5892,21 @@ export class SecurityPlugin implements Plugin { * answers `$in: []`. ⛔ Never return a spread copy; a copy still matches zero * rows, but it is no longer the sentinel. */ - async getReadFilter( + getReadFilter( object: string, context?: any, ): 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; - // [ADR-0090 D10 / ADR-0139 D5.1 as amended] A DELEGATED (on-behalf-of) - // context is scoped to the intersection of its own leg and its delegator's, - // composed as the engine middleware chain composes a delegated find - // ({@link computeDelegatedReadFilter}). This path used to answer the deny - // sentinel for every such context, because the intersection was threaded - // through the chain only; the engine's `ReadableIds` composition asks this - // method with the delegated context as it stands, so the intersection - // reaches here now. Fails CLOSED on every throw: a delegated read is never - // scoped wider than its delegator's own, and never served with a layer - // dropped. - if (context?.onBehalfOf?.userId) { - try { - return await this.computeDelegatedReadFilter(object, context); - } catch (e) { - this.logger.error?.( - `[security] getReadFilter failed for object '${object}' (principal ${context?.userId ?? 'unknown'} ` + - `on behalf of ${context.onBehalfOf.userId}) — denying (fail-closed)`, - e instanceof Error ? e : new Error(String(e)), - ); - 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 - // reason to drop it. A resolution failure denies outright — running the - // analytics raw-SQL path with a dropped owner predicate is the leak. - let sharingFilter: Record | null; - try { - sharingFilter = await this.resolveSharingReadFilter(object, context); - } catch (e) { - this.logger.error?.( - `[security] getReadFilter could not resolve the sharing (OWD) read scope for object ` + - `'${object}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed: a path that bypasses ` + - `the engine middleware never runs without the owner and share scope a direct read applies)`, - e instanceof Error ? e : new Error(String(e)), - ); - return RLS_DENY_FILTER; - } - try { - const permissionSets = await this.resolvePermissionSetsForContext(context); - // [#21079] ADR-0056 D2 — the deny baseline at the row scope. A caller who - // 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 - // row of an object whose sharing model is not private. Not a failure, - // so nothing is logged. - if (permissionSets.length === 0) return RLS_DENY_FILTER; - const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); - // [#5815] ADR-0055 — the SECOND thing the middleware ANDs into `ast.where` - // for a read: the leaf over the master, the same answer the middleware - // writes for a find. It stands down (null) for any object that is not - // controlled_by_parent, and it is internally fail-closed (the deny - // sentinel for an unresolvable relation, `$in: []` for a chain the - // registry walk refuses); a THROW propagates to the catch below, which - // denies — the same posture as the surrounding layers. - const cbpFilter = this.computeControlledByParentFilter(object, 'find', context); - // [#4467] RLS AND controlled-by-parent AND sharing — the same - // AND-composition the middlewares achieve by all writing into `ast.where`. - // Any layer may be absent; `andComposeLayers` returns the others, or null - // when none constrains. - return andComposeLayers(andComposeLayers(filter, cbpFilter), sharingFilter) ?? undefined; - } catch (e) { - // Fail CLOSED — a resolution failure must deny (zero rows), never expose - // every tenant's data through the raw-SQL analytics path. - this.logger.error?.( - `[security] getReadFilter failed for object '${object}' ` + - `(user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, - e instanceof Error ? e : new Error(String(e)), - ); - return RLS_DENY_FILTER; - } + // [#25223 cut 9] The body moved to `security-plugin-read-filter.ts` (see `ReadFilterHost`). + const readLogger = () => this.logger; + const host: Pick = { + computeControlledByParentFilter: (...a) => this.computeControlledByParentFilter(...a), + computeDelegatedReadFilter: (...a) => this.computeDelegatedReadFilter(...a), + computeRlsFilter: (...a) => this.computeRlsFilter(...a), + get logger() { return readLogger(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + resolveSharingReadFilter: (...a) => this.resolveSharingReadFilter(...a), + }; + return getReadFilter.call(host, object, context); } /** @@ -5990,68 +5946,27 @@ export class SecurityPlugin implements Plugin { * middleware calls, in the middleware's spelling, and the delegator's context * comes from the one resolver the middleware and `canReadObject` share. */ - private async computeDelegatedReadFilter( + private computeDelegatedReadFilter( object: string, context: any, ): Promise | undefined> { - const permissionSets = await this.resolvePermissionSetsForContext(context); - if (permissionSets.length === 0) return RLS_DENY_FILTER; - const del = await resolveDelegatorContext(this.ql, context); - if (del.kind !== 'resolved') { - this.logger.error?.( - `[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator ` + - `'${String(context?.onBehalfOf?.userId)}' does not exist — denying (fail-closed: a delegated read is ` + - `never scoped wider than its delegator's own) — with no delegator to intersect against it is not ` + - `scoped at all (ADR-0090 D10)`, - ); - return RLS_DENY_FILTER; - } - const delegatorContext = del.context; - // [#25106 · ADR-0090 D10] The prologue's organization-membership refusal - // (`resolveOperationPrincipals`), mirrored at the same point: after the - // dangling-delegator refusal, before the delegator's sets resolve. On a - // walled posture the find path refuses every operation of a delegated - // context whose live active organization the delegator's own membership - // does not back, so the scope that agrees with it is zero rows, on every - // object. [#25107] The prologue's own ask ({@link delegatedOrganizationRefusal}), - // with the inputs it passes; under `single` the claim stands, and a context - // with no active organization is not this rule's (ADR-0123 D2). - if (delegatedOrganizationRefusal(context, delegatorContext, this.tenancyPosture) !== undefined) { - this.logger.warn?.( - `[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator is not a ` + - `member of the organization it runs in — denying, as the operation prologue refuses it (ADR-0090 D10, fail-closed)`, - ); - return RLS_DENY_FILTER; - } - const delegatorSets = await this.resolvePermissionSetsForContext(delegatorContext); - if (delegatorSets.length === 0) return RLS_DENY_FILTER; - - // Step 2.6's two depth stamps, from the same two evaluator calls. - const meta = await this.getObjectSecurityMeta(object); - const delegatorScope = this.permissionEvaluator.getEffectiveScope('read', object, delegatorSets, { isPrivate: meta.isPrivate }); - const declared = this.permissionEvaluator.getDeclaredScope('read', object, permissionSets, { isPrivate: meta.isPrivate }); - const agentLegScope = intersectDelegatedScope(declared, delegatorScope).agentLegScope; - - // Layers 5 and 6 — the sibling middleware's two calls. Resolved first, as - // the non-delegated path resolves its one sharing layer first: a sharing - // outage denies before any RLS layer is composed. - const sharingFilter = await this.resolveSharingReadFilter(object, context, permissionSets, agentLegScope); - const delegatorSharingFilter = await this.resolveSharingReadFilter( - object, - { ...context, userId: delegatorContext.userId, onBehalfOf: undefined }, - delegatorSets, - delegatorScope, - ); - - // Layers 1 to 4 — the security middleware's own four, in its order. - const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); - const cbpFilter = this.computeControlledByParentFilter(object, 'find', context); - const delFilter = await this.computeRlsFilter(delegatorSets, object, 'find', delegatorContext); - const delCbpFilter = this.computeControlledByParentFilter(object, 'find', delegatorContext); - - const securityLayers = andComposeLayers(andComposeLayers(filter, cbpFilter), andComposeLayers(delFilter, delCbpFilter)); - const sharingLayers = andComposeLayers(sharingFilter, delegatorSharingFilter); - return andComposeLayers(securityLayers, sharingLayers) ?? undefined; + // [#25223 cut 9] The body moved to `security-plugin-read-filter.ts` (see `ReadFilterHost`). + const readLogger = () => this.logger; + const readPermissionEvaluator = () => this.permissionEvaluator; + const readQl = () => this.ql; + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + computeControlledByParentFilter: (...a) => this.computeControlledByParentFilter(...a), + computeRlsFilter: (...a) => this.computeRlsFilter(...a), + getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), + get logger() { return readLogger(); }, + get permissionEvaluator() { return readPermissionEvaluator(); }, + get ql() { return readQl(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + resolveSharingReadFilter: (...a) => this.resolveSharingReadFilter(...a), + get tenancyPosture() { return readTenancyPosture(); }, + }; + return computeDelegatedReadFilter.call(host, object, context); } /** @@ -6116,27 +6031,16 @@ export class SecurityPlugin implements Plugin { return this.computeReadableFields(object, context, { fallbackOnEmptySets: true }); } - private async computeReadableFields( + private computeReadableFields( object: string, context: any, options: { fallbackOnEmptySets: boolean }, ): Promise { - const mask = await this.resolveProjectionFieldMask(object, context, options); - if (mask.kind === 'answer') return mask.fields; - - // [#8993] A field this caller sees PARTIALLY MASKED is still a served - // column — the read path REPLACES its value rather than deleting the key — - // so it stays in the projection (an export header must include the column - // whose masked values the same caller's rows carry). Mirrors the step-4 - // exclusion exactly: an explicit permission-set deny keeps the field - // deleted, hence out of the projection. - const partialRules = mask.readPartialMaskRules(); - - // Readable = every schema field NOT explicitly masked non-readable. A field - // with no permission entry passes through (the field allow-list only - // enumerates fields it names) — the exact complement of maskResults' delete - // set, so the export header matches list's readable columns by construction. - return mask.allFields.filter((f) => mask.fieldPerms[f]?.readable !== false || partialRules[f] !== undefined); + // [#25223 cut 9] The body moved to `security-plugin-field-readers.ts` (see `FieldReadersHost`). + const host: Pick = { + resolveProjectionFieldMask: (...a) => this.resolveProjectionFieldMask(...a), + }; + return computeReadableFields.call(host, object, context, options); } /** @@ -6204,7 +6108,7 @@ export class SecurityPlugin implements Plugin { * write gate each take. A case that settles the answer before any mask * exists comes back as `answer`. */ - private async resolveProjectionFieldMask( + private resolveProjectionFieldMask( object: string, context: any, options: { fallbackOnEmptySets: boolean }, @@ -6219,84 +6123,25 @@ export class SecurityPlugin implements Plugin { readQueryGuardPerms: () => Record; } > { - const objectName = String(object ?? ''); - if (!objectName) return { kind: 'answer', fields: undefined }; - // The field universe — the SAME source the RLS field pass uses (ObjectQL's - // live SchemaRegistry first, metadata artifact fallback). `null` → schema - // not resolvable → let the caller fall back rather than guess. - const fieldNameSet = await this.getObjectFieldNames(this.metadata, objectName, this.ql); - if (!fieldNameSet) return { kind: 'answer', fields: undefined }; - const allFields = [...fieldNameSet]; - // System operations bypass FLS (mirrors the middleware's isSystem skip). - if (context?.isSystem) return { kind: 'answer', fields: allFields }; - - let permissionSets = await this.resolvePermissionSetsForContext(context); - if (permissionSets.length === 0 && options.fallbackOnEmptySets) { - // [ADR-0106 D7] `resolvePermissionSetsForContext` applies the baseline - // only for a caller carrying `userId`, so a guest/anonymous caller lands - // here with nothing. Resolve the configured fallback set explicitly — - // the same two-step `/auth/me/permissions` performs. - permissionSets = await this.resolveFallbackPermissionSets(context); - } - // [#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 - // stance this method already takes on a dangling delegator below. The - // per-field capability contract (`fieldRequiredPermissions`) would otherwise - // default to empty and silently unmask every capability-gated column, and - // [#20995] the masking rules a caller with no set is held to are unknown - // ([#21063] as is that caller's field capability contract). - if (secMeta.unresolved) return { kind: 'answer', fields: [] }; - const basePerms = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets); - let fieldPerms = this.foldFieldRequiredPermissions(basePerms, secMeta.fieldRequiredPermissions, permissionSets); - - // [ADR-0090 D10] On an on-behalf-of request the projection must NOT widen - // past the DELEGATOR's — intersect the delegator's field mask too. A - // dangling delegator fails CLOSED (expose no columns), the same fail-closed - // stance the CRUD middleware takes on a 'missing' delegator; [#25107] and so - // does a delegator who is not a member of the organization the request runs - // in, on a walled posture, which the middleware's prologue refuses - // ({@link delegatedOrganizationRefusal}). [#20995] Asked - // only when the caller resolved a set, as the middleware asks it: a caller - // who resolved none reaches here only for its masking rules and [#21063] - // its field capability contract, and every one of them already applies to - // it. - let delBasePerms: Record | null = null; - let delegatorSets: PermissionSet[] | null = null; - if (permissionSets.length > 0 && context?.onBehalfOf?.userId) { - const del = await resolveDelegatorContext(this.ql, context); - if (del.kind === 'missing') return { kind: 'answer', fields: [] }; - if (del.kind === 'resolved') { - if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) { - return { kind: 'answer', fields: [] }; - } - delegatorSets = await this.resolvePermissionSetsForContext(del.context); - delBasePerms = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); - let delFieldPerms = this.foldFieldRequiredPermissions(delBasePerms, secMeta.fieldRequiredPermissions, delegatorSets); - fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); - } - } - - return { - kind: 'mask', - allFields, - fieldPerms, - readPartialMaskRules: () => this.computeReadPartialMaskRules( - secMeta, permissionSets, delegatorSets, basePerms, delBasePerms, - ), - readQueryGuardPerms: () => this.computeQueryGuardFieldPerms( - objectName, secMeta, permissionSets, delegatorSets, - ), + // [#25223 cut 9] The body moved to `security-plugin-field-readers.ts` (see `FieldReadersHost`). + const readMetadata = () => this.metadata; + const readPermissionEvaluator = () => this.permissionEvaluator; + const readQl = () => this.ql; + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + computeQueryGuardFieldPerms: (...a) => this.computeQueryGuardFieldPerms(...a), + computeReadPartialMaskRules: (...a) => this.computeReadPartialMaskRules(...a), + foldFieldRequiredPermissions: (...a) => this.foldFieldRequiredPermissions(...a), + getObjectFieldNames: (...a) => this.getObjectFieldNames(...a), + get metadata() { return readMetadata(); }, + get permissionEvaluator() { return readPermissionEvaluator(); }, + get ql() { return readQl(); }, + resolveCallerPosture: (...a) => this.resolveCallerPosture(...a), + resolveFallbackPermissionSets: (...a) => this.resolveFallbackPermissionSets(...a), + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + get tenancyPosture() { return readTenancyPosture(); }, }; + return resolveProjectionFieldMask.call(host, object, context, options); } /** @@ -6343,25 +6188,15 @@ export class SecurityPlugin implements Plugin { * whose answer for this caller is the full field set minus every field it is * not served, as the service contract states. */ - private async resolveCallerPosture( + private resolveCallerPosture( object: string, permissionSets: PermissionSet[], ): Promise { - const posture = await this.getObjectSecurityMeta(object); - if (permissionSets.length > 0) return posture; - return { - isPrivate: false, - tenancyDisabled: false, - isBetterAuthManaged: false, - tenantAnchorIsPhantom: false, - owdOpensRowWrites: false, - requiredPermissions: EMPTY_REQUIRED_PERMISSIONS, - fieldRequiredPermissions: posture.fieldRequiredPermissions, - fieldMaskingRules: posture.fieldMaskingRules, - unresolved: posture.unresolved, - // [#10401] Explanation only, and present only on the refusing path. - unresolvedCause: posture.unresolvedCause, + // [#25223 cut 9] The body moved to `security-plugin-field-readers.ts` (see `FieldReadersHost`). + const host: Pick = { + getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), }; + return resolveCallerPosture.call(host, object, permissionSets); } /** @@ -6413,47 +6248,19 @@ export class SecurityPlugin implements Plugin { * still {@link getReadFilter}'s and it is still mandatory. Nothing here may be * used to widen. */ - async canReadObject(object: string, context?: any): Promise { - const objectName = String(object ?? ''); - 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); - // [#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; - - // [ADR-0090 D10] Resolve the delegator ONCE — arms 4 and 6 both need it, - // and a dangling link denies before either runs. [#25107] So does a - // delegator who is not a member of the organization the read runs in, on - // a walled posture: the middleware's prologue refuses the read there - // ({@link delegatedOrganizationRefusal}). - let delegatorSets: PermissionSet[] | null = null; - if (context?.onBehalfOf?.userId) { - const del = await resolveDelegatorContext(this.ql, context); - if (del.kind === 'missing') return false; - if (del.kind === 'resolved') { - if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return false; - delegatorSets = await this.resolvePermissionSetsForContext(del.context); - } - } - - // 3–6. The posture and grant arms, over the resolved sets — the one - // predicate the ADR-0055 master-derived read scope asks as well. - return await this.objectReadAdmission(objectName, permissionSets, delegatorSets); - } catch (e) { - this.logger.error?.( - `[security] canReadObject could not resolve the object-level read admission for ` + - `'${objectName}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, - e instanceof Error ? e : new Error(String(e)), - ); - return false; - } + canReadObject(object: string, context?: any): Promise { + // [#25223 cut 9] The body moved to `security-plugin-object-admission.ts` (see `ObjectAdmissionHost`). + const readLogger = () => this.logger; + const readQl = () => this.ql; + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + get logger() { return readLogger(); }, + objectReadAdmission: (...a) => this.objectReadAdmission(...a), + get ql() { return readQl(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + get tenancyPosture() { return readTenancyPosture(); }, + }; + return canReadObject.call(host, object, context); } /** @@ -6490,45 +6297,18 @@ export class SecurityPlugin implements Plugin { * ⛔ Object-level ONLY, like {@link canReadObject}: `true` never means * "unrestricted". A throw propagates; each asker fails closed on it. */ - private async objectReadAdmission( + private objectReadAdmission( objectName: string, permissionSets: PermissionSet[], delegatorSets: PermissionSet[] | null, ): Promise { - if (permissionSets.length === 0) return false; - - const { isPrivate, unresolved, requiredPermissions } = - await this.getObjectSecurityMeta(objectName); - // 3. [#3545] Posture unresolvable → deny. - if (unresolved) return false; - - // 4. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both - // principals — a caller missing any required capability is denied - // however permissive their grants are. - const required = requiredCapsForOperation(requiredPermissions, 'find'); - if (required.length > 0) { - const held = this.permissionEvaluator.getSystemPermissions(permissionSets); - if (required.some((cap) => !held.has(cap))) return false; - if (delegatorSets && delegatorSets.length > 0) { - const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); - if (required.some((cap) => !delHeld.has(cap))) return false; - } - } - - // 5. The object-level CRUD grant. - if (!this.permissionEvaluator.checkObjectPermission('find', objectName, permissionSets, { isPrivate })) { - return false; - } - - // 6. [ADR-0090 D10] The delegator must independently grant the same read. - if ( - delegatorSets && - !this.permissionEvaluator.checkObjectPermission('find', objectName, delegatorSets, { isPrivate }) - ) { - return false; - } - - return true; + // [#25223 cut 9] The body moved to `security-plugin-object-admission.ts` (see `ObjectAdmissionHost`). + const readPermissionEvaluator = () => this.permissionEvaluator; + const host: Pick = { + getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), + get permissionEvaluator() { return readPermissionEvaluator(); }, + }; + return objectReadAdmission.call(host, objectName, permissionSets, delegatorSets); } /** @@ -6677,172 +6457,30 @@ export class SecurityPlugin implements Plugin { * * Nothing here may be used to widen. */ - async canWriteObject( + canWriteObject( object: string, operation: 'insert' | 'update', context?: any, data?: unknown, ): Promise { - const objectName = String(object ?? ''); - if (!objectName) return false; - // 1. System operations bypass. - if (context?.isSystem) return true; - - 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 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 - // is exactly the class this method exists to keep out of the - // preview. - try { - // 2. [ADR-0103] Engine-owned write affordance. ⛔ Not a re-derivation: - // this is the middleware's own primitive over the same registered - // schema, so the `userActions` members that reopen a verb pass here - // for the one reason they pass there. - assertEngineOwnedWriteAllowed( - typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(objectName) : undefined, - operation, - context, - ); - // 3. [ADR-0090 D12] Delegated administration on the RBAC link tables. - // The middleware hands its whole `opCtx`; a preview names no stored - // row, so this hands `object`, `operation`, `context` and the rows - // of `data` and NOTHING else — leaving the gate's delegate branch - // refusing an id-less mutation it cannot boundary-check, pinned as a - // direction in `can-write-object-admission.test.ts`. - // - // ⭐ And the rows it supplies are SHALLOW COPIES, never the caller's - // own objects. The gate stamps `granted_by` onto the rows it - // materialises (its dual audit), and on an insert it materialises - // the payload rows BY REFERENCE — while `validate()` hands this - // method the caller's RAW payload, so passing it straight through - // would let a PREVIEW write into the caller's own objects. The - // decision cannot notice the copy: nothing reads `granted_by` back, - // it is only ever written — so its pin reads the caller's rows - // instead (`can-write-object-admission.test.ts`, the copy block). - if (this.delegatedAdminGate) { - const shallow = (row: unknown) => - row && typeof row === 'object' && !Array.isArray(row) - ? { ...(row as Record) } - : row; - await this.delegatedAdminGate.assert({ - object: objectName, - operation, - context, - data: Array.isArray(data) ? data.map(shallow) : shallow(data), - }); - } - } catch (e) { - // A refusal from either gate IS the admission answer — the write path's - // own denial, not a failure to resolve one. Anything else is a - // subsystem failure and belongs to the outer catch, which logs it and - // still denies. - if (e instanceof PermissionDeniedError) return false; - 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 - // 3.7 guard: a payload is supplied and the context names a user. Its - // point is last, after the field gate. - const organizationWallAdmits = async ( - delegatorSets: PermissionSet[] | null, - delegatorContext: unknown, - ): Promise => - !(data && typeof data === 'object' && context?.userId - && (await this.organizationWallRefusal( - permissionSets, objectName, operation, context, delegatorSets, delegatorContext, - ))); - // [#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. - if (permissionSets.length === 0) return false; - - const { isPrivate, unresolved, requiredPermissions, fieldRequiredPermissions } = - await this.getObjectSecurityMeta(objectName); - // 5. [#3545] Posture unresolvable → deny. - if (unresolved) return false; - - // [ADR-0090 D10] Resolve the delegator ONCE — the arms below need it, - // and a dangling link denies before any of them runs. [#25107] So does a - // delegator who is not a member of the organization the write runs in, - // on a walled posture: the middleware's prologue refuses the write there - // ({@link delegatedOrganizationRefusal}). - let delegatorSets: PermissionSet[] | null = null; - let delegatorContext: unknown = null; - if (context?.onBehalfOf?.userId) { - const del = await resolveDelegatorContext(this.ql, context); - if (del.kind === 'missing') return false; - if (del.kind === 'resolved') { - if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return false; - delegatorContext = del.context; - delegatorSets = await this.resolvePermissionSetsForContext(del.context); - } - } - - // 6. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both - // principals. - const required = requiredCapsForOperation(requiredPermissions, operation); - if (required.length > 0) { - const held = this.permissionEvaluator.getSystemPermissions(permissionSets); - if (required.some((cap) => !held.has(cap))) return false; - if (delegatorSets && delegatorSets.length > 0) { - const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); - if (required.some((cap) => !delHeld.has(cap))) return false; - } - } - - // 7. The object-level CRUD grant for the operation asked. - if (!this.permissionEvaluator.checkObjectPermission(operation, objectName, permissionSets, { isPrivate })) { - return false; - } - - // 8. [ADR-0090 D10] The delegator must independently grant the same write. - // [#21079] Asked over the delegator's list whatever its length, as the - // middleware asks it: a delegator who resolves NO set grants nothing, - // the deny baseline for the second principal too. - if ( - delegatorSets && - !this.permissionEvaluator.checkObjectPermission(operation, objectName, delegatorSets, { isPrivate }) - ) { - return false; - } - - // 9. The field-level-security WRITE gate — the middleware's step 2.5, - // over the payload the caller supplied: the middleware runs it for an - // `insert`/`update` carrying `opCtx.data`. ⛔ Not a re-derivation — - // [#21063] it IS the middleware's verdict (computeForbiddenFieldWrites); - // a second spelling of "which fields may this caller write" is the - // drift this whole method exists to avoid. - if (data) { - const forbidden = this.computeForbiddenFieldWrites( - objectName, data as Record | Record[], - { fieldRequiredPermissions }, permissionSets, delegatorSets, - ); - if (forbidden.length > 0) return false; - } - - // 10. [ADR-0123 D2] At the middleware's point — see above. - return await organizationWallAdmits(delegatorSets, delegatorContext); - } catch (e) { - this.logger.error?.( - `[security] canWriteObject could not resolve the write admission for ` + - `'${objectName}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, - e instanceof Error ? e : new Error(String(e)), - ); - return false; - } + // [#25223 cut 9] The body moved to `security-plugin-object-admission.ts` (see `ObjectAdmissionHost`). + const readDelegatedAdminGate = () => this.delegatedAdminGate; + const readLogger = () => this.logger; + const readPermissionEvaluator = () => this.permissionEvaluator; + const readQl = () => this.ql; + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + computeForbiddenFieldWrites: (...a) => this.computeForbiddenFieldWrites(...a), + get delegatedAdminGate() { return readDelegatedAdminGate(); }, + getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), + get logger() { return readLogger(); }, + organizationWallRefusal: (...a) => this.organizationWallRefusal(...a), + get permissionEvaluator() { return readPermissionEvaluator(); }, + get ql() { return readQl(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + get tenancyPosture() { return readTenancyPosture(); }, + }; + return canWriteObject.call(host, object, operation, context, data); } /** @@ -6863,53 +6501,19 @@ export class SecurityPlugin implements Plugin { * 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. - if (permissionSets.length === 0) return false; - - const { isPrivate, unresolved } = await this.getObjectSecurityMeta(objectName); - // [#3545] Posture unresolvable → deny. `isPrivate` would default to `false`, - // which is precisely what lets a plain wildcard reach the object; a bulk - // egress decision must not rest on a default we could not read. - if (unresolved) return false; - if (!this.permissionEvaluator.checkObjectPermission('export', objectName, permissionSets, { isPrivate })) { - return false; - } - - // [ADR-0090 D10] An on-behalf-of caller may never export past what the - // DELEGATOR could have exported themselves — intersect the delegator's own - // answer. A dangling delegator fails CLOSED, the same stance the CRUD - // middleware and {@link getReadableFields} take. [#21079] So does a - // delegator who resolves NO set: the evaluator answers that empty list's - // export as it answers any list, and the middleware refuses its read. - // [#25107] So does a delegator who is not a member of the organization the - // export runs in, on a walled posture: the middleware's prologue refuses - // the read there ({@link delegatedOrganizationRefusal}). - if (context?.onBehalfOf?.userId) { - const del = await resolveDelegatorContext(this.ql, context); - if (del.kind === 'missing') return false; - if (del.kind === 'resolved') { - if (delegatedOrganizationRefusal(context, del.context, this.tenancyPosture) !== undefined) return false; - const delegatorSets = await this.resolvePermissionSetsForContext(del.context); - if ( - !this.permissionEvaluator.checkObjectPermission('export', objectName, delegatorSets, { isPrivate }) - ) { - return false; - } - } - } - - return true; + canExport(object: string, context?: any): Promise { + // [#25223 cut 9] The body moved to `security-plugin-object-admission.ts` (see `ObjectAdmissionHost`). + const readPermissionEvaluator = () => this.permissionEvaluator; + const readQl = () => this.ql; + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), + get permissionEvaluator() { return readPermissionEvaluator(); }, + get ql() { return readQl(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + get tenancyPosture() { return readTenancyPosture(); }, + }; + return canExport.call(host, object, context); } /** From b4ced7f87e2954d1041841b25405346d813c67a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 13:41:01 +0000 Subject: [PATCH 2/2] chore(adr-anchors): anchor the field-readers module to ADR-0106 D7 The metadata-plane half of the ADR-0106 D7 paragraph, the configured fallback set a caller with no set resolves, is the fallbackOnEmptySets branch of resolveProjectionFieldMask, whose body moved to security-plugin-field-readers.ts. The new entry carries ADR-0106 and that paragraph of the security-plugin.ts entry verbatim, as the cbp and wall module entries carry theirs. The security-plugin.ts entry is unchanged: getReadableFields and getMetadataReadableFields stay there. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa --- ...in-security__src__security-plugin-field-readers.ts.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin-field-readers.ts.json diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin-field-readers.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin-field-readers.ts.json new file mode 100644 index 00000000000..9cbcc2871e5 --- /dev/null +++ b/scripts/adr-anchors/packages__plugins__plugin-security__src__security-plugin-field-readers.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/plugins/plugin-security/src/security-plugin-field-readers.ts", + "adrs": [ + "ADR-0106" + ], + "invariant": "ADR-0106 D7 — getMetadataReadableFields differs from getReadableFields in exactly one place, and the asymmetry is the decision. On the DATA plane a caller resolving to zero permission sets falls OPEN, mirroring the engine middleware, because reporting a narrowing the data path would not enforce is its own drift. On the METADATA plane the same caller resolves the configured fallback permission set (the two-step /auth/me/permissions performs), so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default. Converging the two methods in either direction reverses this." +}