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); } /** 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." +}