From 38392cb1b72803f2402f921e78a040e3ebbf1883 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:27:32 +0000 Subject: [PATCH] refactor(plugin-security): move the everyone anchor, the explanation, the sharing verdicts and the authored-row write verdict out of security-plugin.ts The bodies of declareEveryone and declareBuiltinPositions (the everyone anchor's definition and the built-in positions) move unedited to security-plugin-everyone-anchor.ts, with the four import names only they read. The body of explainAccessForCaller moves unedited to security-plugin-explain-access.ts, with the import names only it reads; the two caller-read classifications it shares with the write path stay in security-plugin.ts and are exported. The bodies of the six sharing verdicts (resolveSharingReadFilter, resolveSharingCanEdit, resolveWriteScopeForSharing, resolveSharingWriteVerdict, resolvePreImageFloorDrop, delegatorOwnWriteFloorOptions) move unedited to security-plugin-sharing-verdicts.ts. The body of checkAuthoredRowWrite moves unedited to security-plugin-authored-row-write.ts, with the probe scope constant only it reads. Each method keeps its name, signature, docblock and position and hands its body one host literal of exactly the members it reads. everyoneBaseline and destroy stay whole. The two readers that read the moved lines by path are re-pointed: the system-context page's explain anchor names the new module, and the delegator enumeration pin lists it. Move-only: no verdict changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa --- content/docs/permissions/system-context.mdx | 2 +- .../delegated-organization-membership.test.ts | 3 +- .../src/security-plugin-authored-row-write.ts | 184 +++++ .../src/security-plugin-everyone-anchor.ts | 140 ++++ .../src/security-plugin-explain-access.ts | 433 ++++++++++ .../src/security-plugin-sharing-verdicts.ts | 277 +++++++ .../plugin-security/src/security-plugin.ts | 746 ++++-------------- 7 files changed, 1187 insertions(+), 598 deletions(-) create mode 100644 packages/plugins/plugin-security/src/security-plugin-authored-row-write.ts create mode 100644 packages/plugins/plugin-security/src/security-plugin-everyone-anchor.ts create mode 100644 packages/plugins/plugin-security/src/security-plugin-explain-access.ts create mode 100644 packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c91561cc130..a4bf4d31ff3 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -107,7 +107,7 @@ that silently does not happen. | 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` | | 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail`, `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` | -| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` | +| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin-explain-access.ts#explainAccessForCaller` | | 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#checkControlledByParentWrite` | | 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | | 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | 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 22b284db1c8..14ab50b5532 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 six files that resolve one are all counted', () => { + it('per source file, the asks equal the resolutions — and the seven 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; @@ -696,6 +696,7 @@ describe('[#25107] every reader in this package that resolves a delegator asks t } expect(Object.keys(counted).sort()).toEqual([ 'explain-engine.ts', + 'security-plugin-explain-access.ts', 'security-plugin-field-readers.ts', 'security-plugin-object-admission.ts', 'security-plugin-operation-principals.ts', diff --git a/packages/plugins/plugin-security/src/security-plugin-authored-row-write.ts b/packages/plugins/plugin-security/src/security-plugin-authored-row-write.ts new file mode 100644 index 00000000000..3d588c9429e --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-authored-row-write.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 10] `SecurityPlugin#checkAuthoredRowWrite`, moved out of + * `security-plugin.ts`: the `ISecurityService.checkAuthoredRowWrite` verdict, + * whether an app-authored row policy admits one row for an `update` or a + * `delete` — `admit` or `abstain`, never a refusal, and `abstain` on every + * failure — together with the scope its probe read runs under, + * `AUTHORED_ROW_WRITE_PROBE_CONTEXT`, which nothing else reads. + * + * A MOVE, not a redesign. The function below is the body of the method of the + * same name as it stood, re-indented one level and otherwise unedited: the + * same abstentions in the same order, the same provenance pre-check, the same + * layered predicate and the same id-only probe read. The scope constant and + * its docblock moved with it unedited, still unexported, and the probe still + * spreads it at its one call site. + * + * ## How the moved body reaches the class + * + * The shape cuts 1 to 9 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps the public method — the published + * `dist/*.d.ts` lists its name, docblock and position — and its body is one + * literal of exactly the {@link AuthoredRowWriteHost} members it 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 verdict still asks the plugin's set resolution, RLS + * policy collection and layered RLS filter, and the `security` service + * binding and every other caller still reach the method through the plugin. + * The method was async and the function still is; the method now returns its + * function's promise. + * + * Each field was classified on its own declaration, by the rule cut 1 set. + * `logger` and `ql` are not declared `readonly` (`start()` assigns both after + * construction), so they go as GETTERS, and each read happens when the body + * makes it. Both are objects whose own methods the body calls, so no receiver + * changes. + * + * ## What stays in `security-plugin.ts`, and how it is read + * + * The verdict and operation types are imported from + * `@objectstack/spec/contracts`, the platform-ownership floor predicate from + * `platform-ownership-policies.js`, and the clause predicate from + * `rls-compiler.js`, as `security-plugin.ts` imports them. + * + * Nothing re-exports this module, so the published surface does not move. + */ +import type { AuthoredRowWriteOperation, AuthoredRowWriteVerdict } from '@objectstack/spec/contracts'; +import type { SecurityPlugin } from './security-plugin.js'; +import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.js'; +import { policyDeclaresClause } from './rls-compiler.js'; + +/** + * The `SecurityPlugin` members the function below reads — every one of them, + * and no other; the 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 AuthoredRowWriteHost { + readonly logger: SecurityPlugin['logger']; + readonly ql: SecurityPlugin['ql']; + collectRLSPolicies: SecurityPlugin['collectRLSPolicies']; + computeLayeredRlsFilter: SecurityPlugin['computeLayeredRlsFilter']; + resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; +} + +/** + * [#7281] The scope `checkAuthoredRowWrite`'s probe read runs under. + * + * The method asks ONE question — "does the declared, app-authored widener admit + * this row for this operation?" — and that question is about the row and the + * declaration, never about what the CALLER may see. Resolving it under the + * caller's own context folded a READ decision into a WRITE question: on a + * `private`-OWD object `plugin-sharing`'s read filter scopes every re-read to + * owner-match OR shares, so a cross-owner row was invisible to the probe and + * the verdict was `abstain` for a row the declaration names by predicate. The + * by-id widener surface was therefore structurally dead on `private` — the + * posture #5493 built it for (maintainer ruling, 2026-08-10: reading 2). + * + * Elevating the READ does not widen the ANSWER, because the predicate carries + * the whole of the question and travels in the `where`, not in the scope: + * `{ id } AND layer0(tenant wall) AND layer1(app-authored policies)`. Both + * layers are computed from the CALLER's permission sets and the CALLER's + * tenant BEFORE the read (see {@link SecurityPlugin.checkAuthoredRowWrite}), so + * a row in another tenant, a row no authored policy matches, and a caller + * holding no authored policy at all are all still `abstain` — measured, not + * asserted (`authored-row-write-verdict.test.ts`, and the real-stack pin + * `packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts`). + * + * Principal-less on purpose: it carries no `userId`, so nothing downstream can + * mistake it for the caller acting with more authority than they hold. It is + * SPREAD at the single call site rather than passed by reference, so no + * middleware can stamp state onto a shared object. + */ +const AUTHORED_ROW_WRITE_PROBE_CONTEXT = { isSystem: true, positions: [], permissions: [] } as const; + +/** The body of `SecurityPlugin#checkAuthoredRowWrite`; the method's docblock there is its contract. */ +export async function checkAuthoredRowWrite( + this: Pick, + object: string, + recordId: string, + operation: AuthoredRowWriteOperation, + context?: any, +): Promise { + try { + if (!object || recordId == null || recordId === '') return 'abstain'; + if (operation !== 'update' && operation !== 'delete') return 'abstain'; + if (!this.ql) return 'abstain'; + // No principal, or a delegated identity this path cannot intersect — + // both are "cannot measure", which is `abstain` (never `admit`). + if (!context?.userId) return 'abstain'; + if (context?.onBehalfOf?.userId) return 'abstain'; + + const permissionSets = await this.resolvePermissionSetsForContext(context); + if (permissionSets.length === 0) return 'abstain'; + + // Cheap provenance pre-check: if the caller holds NO app-authored policy + // applicable to (object, operation), there is nothing that could admit by + // declaration — answer without spending a database round-trip. This is + // the same collection the compiler consumes, filtered by the same + // provenance predicate, so the two cannot disagree about what "authored" + // means. + // + // [#8059] The pre-check asks for an authored policy that ADMITS ROWS — + // one carrying a `using` scope predicate. A `check`-only policy declares + // no row scope at all (ADR-0058 D4: post-image validation), so it can + // admit nothing, and the verdict for a caller holding only check-only + // policies is `abstain` — which is the verdict this method already + // returned before #8059 and must keep returning after it. It got there + // by a route the fix removes: a check-only policy compiles to no `using` + // filter, so Layer 1 was null and the `layer1 == null → abstain` rule + // below fired. Site 1 now DERIVES a Layer 1 from the caller's select + // narrowing in exactly that case, so without this clause the derived + // readable-set scope would start answering `admit` — and `admit` is what + // the by-id widener uses to WIDEN. That is precisely the masquerade the + // doc comment above promises cannot happen (#5493 / #7281), so the + // promise is now kept by an explicit test rather than by the accident of + // a null Layer 1. Nothing narrows here: `abstain` changes nothing on its + // own, and the pre-image gate still refuses on its own terms. + const authored = this.collectRLSPolicies( + permissionSets, + object, + operation, + (context?.positions ?? []) as string[], + context, + ).filter((p) => !isPlatformOwnershipFloorPolicy(p) && policyDeclaresClause(p, 'using')); + if (authored.length === 0) return 'abstain'; + + const { layer0, layer1 } = await this.computeLayeredRlsFilter( + permissionSets, + object, + operation, + context, + { dropPlatformOwnershipFloor: true }, + ); + // See the doc above: a null Layer 1 means no authored predicate is + // gating this write, which is an abstention and not an admission. + if (layer1 == null) return 'abstain'; + + const parts = [{ id: recordId }, ...(layer0 ? [layer0] : []), layer1]; + // [#7281] The predicate is the question; the scope is not. Read ELEVATED + // (see AUTHORED_ROW_WRITE_PROBE_CONTEXT) so the answer is decided by + // `{id} AND layer0 AND layer1` alone, and projected to `id` so the probe + // can only ever learn EXISTENCE — no column of a row the caller may not + // read is materialised, let alone returned. The verdict remains evidence, + // never authorization: the by-id write pre-image gate still resolves the + // write under the caller's own context. + const row = await this.ql.findOne(object, { + where: { $and: parts }, + fields: ['id'], + context: { ...AUTHORED_ROW_WRITE_PROBE_CONTEXT }, + }); + return row ? 'admit' : 'abstain'; + } catch (e) { + this.logger.warn?.( + `[security] checkAuthoredRowWrite could not resolve an authored-policy verdict for ` + + `'${object}' record '${recordId}' (${operation}, user ${context?.userId ?? 'unknown'}) — ` + + `abstaining (fail-closed: a verdict that cannot be resolved never lifts the sharing refusal)`, + e instanceof Error ? e : new Error(String(e)), + ); + return 'abstain'; + } +} diff --git a/packages/plugins/plugin-security/src/security-plugin-everyone-anchor.ts b/packages/plugins/plugin-security/src/security-plugin-everyone-anchor.ts new file mode 100644 index 00000000000..fc2b9ce89d1 --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-everyone-anchor.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 10] The `everyone` anchor's declarations of `SecurityPlugin`, + * moved out of `security-plugin.ts`: + * + * - `declareEveryone`, the anchor's definition as a started kernel derives + * it: the deployment's baseline plus every package default a + * `manage_metadata` holder accepted, each withheld acceptance reported with + * its reason, and the grants caches retired on the metadata epoch. + * - `declareBuiltinPositions`, the six built-in positions declared to the + * engine registry from `init()`, with `everyone` naming the fail-closed + * baseline. + * + * 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 reads in the same order, the same + * registry writes, the same warnings and errors. + * + * ## How the moved bodies reach the class + * + * The shape cuts 1 to 9 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps both 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 EveryoneAnchorHost} 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: both declarations still ask the + * plugin's baseline, and the derivation its deployment acceptance, and + * every caller still reaches both methods through the plugin. + * `declareEveryone` was async and its function still is, and the method now + * returns its function's promise; `declareBuiltinPositions` was synchronous + * and so are its method and function. + * + * Each field was classified on its own declaration, by the rule cut 1 set. + * `epoch`, `metadata` and `ql` are not declared `readonly` (`start()` assigns + * all three after construction), 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 + * + * `everyoneBaseline`, the one pure derivation both declarations share, stays + * in the class whole, because its body is no longer than its stub would be; + * the bodies reach it through the host. The baseline registration and the + * position registration are imported from `builtin-positions.js`, the + * declared-capability context from `declared-capability-context.js`, the + * accepted package defaults from `suggested-audience-bindings.js`, and the + * plugin's package id from `manifest.js`, as `security-plugin.ts` imported + * them; no body that stays there reads the first four, so that file no longer + * imports them. + * + * Nothing re-exports this module, so the published surface does not move. + */ +import type { PluginContext } from '@objectstack/core'; +import type { SecurityPlugin } from './security-plugin.js'; +import { declareEveryoneBaseline, registerBuiltinPositions } from './builtin-positions.js'; +import { readDeclaredCapabilityContext } from './declared-capability-context.js'; +import { SECURITY_PLUGIN_ID } from './manifest.js'; +import { readAcceptedEveryoneSets } from './suggested-audience-bindings.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 EveryoneAnchorHost { + readonly epoch: SecurityPlugin['epoch']; + readonly metadata: SecurityPlugin['metadata']; + readonly ql: SecurityPlugin['ql']; + deploymentAcceptance: SecurityPlugin['deploymentAcceptance']; + everyoneBaseline: SecurityPlugin['everyoneBaseline']; +} + +/** The body of `SecurityPlugin#declareEveryone`; the method's docblock there is its contract. */ +export async function declareEveryone( + this: Pick, + logger: PluginContext['logger'], +): Promise { + const ql = this.ql; + try { + const anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); + const sets = this.everyoneBaseline(anchorContext, (set, offending) => { + logger.warn('[security] the everyone anchor does not name a baseline set — high-privilege bits', { set, offending }); + }); + try { + const derived = await readAcceptedEveryoneSets(ql, this.metadata, this.deploymentAcceptance()); + for (const name of derived.accepted) if (!sets.includes(name)) sets.push(name); + for (const w of derived.withheld) { + const line = w.source === 'host-configuration' + ? `[security] the everyone anchor does not carry the set '${w.set}' the security plugin's ` + + `\`acceptedDefaultPermissionSets\` option declares: ${w.reason}. It grants nothing while that holds; ` + + 'remove the name from the option, or serve the set again as an isDefault set some package declares.' + : `[security] the everyone anchor does not carry the accepted set '${w.set}' (package '${w.packageId}'): ` + + `${w.reason}. Its acceptance stays recorded and grants nothing until the set is served again as ` + + 'accepted; a manage_metadata holder can revoke it in Setup.'; + if (w.forbidden) logger.error(line, undefined, { set: w.set, package: w.packageId }); + else logger.warn(line, { set: w.set, package: w.packageId }); + } + } catch (e) { + logger.error( + '[security] the accepted package defaults for the everyone anchor could NOT be read — every signed-in ' + + 'member holds the baseline alone until the next derivation (boot, accept, revoke, uninstall or package ' + + 'publish) reads them. Remedy: restore read access to sys_audience_binding_suggestion and restart.', + e as Error, + ); + } + if (sets.length > 0) declareEveryoneBaseline((ql as { registry?: unknown })?.registry, SECURITY_PLUGIN_ID, sets); + this.epoch.bump('metadata'); + } catch (e) { + logger.warn('[security] everyone-anchor declaration failed (non-fatal)', { error: (e as Error).message }); + } +} + +/** The body of `SecurityPlugin#declareBuiltinPositions`; the method's docblock there is its contract. */ +export function declareBuiltinPositions( + this: Pick, + ctx: PluginContext, +): void { + let ql: { registry?: unknown } | undefined; + try { + ql = ctx.getService<{ registry?: unknown }>('objectql'); + } catch { + return; // No engine: `start()` reports it, and nothing can be declared. + } + if (!ql) return; + if (registerBuiltinPositions(ql.registry, SECURITY_PLUGIN_ID) === 0) { + ctx.logger.warn( + '[security] the built-in positions (platform_admin, org_owner, org_admin, org_member, everyone, guest) ' + + 'were NOT declared as position metadata: the ObjectQL engine exposes no registry that can register ' + + 'them. The security catalog read and GET /api/v1/meta/position will not list them, and the ' + + '`everyone` baseline sets are granted through no position.', + ); + return; + } + const baseline = this.everyoneBaseline(undefined); + if (baseline.length > 0) declareEveryoneBaseline(ql.registry, SECURITY_PLUGIN_ID, baseline); +} diff --git a/packages/plugins/plugin-security/src/security-plugin-explain-access.ts b/packages/plugins/plugin-security/src/security-plugin-explain-access.ts new file mode 100644 index 00000000000..6753355f479 --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-explain-access.ts @@ -0,0 +1,433 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 10] `SecurityPlugin#explainAccessForCaller`, moved out of + * `security-plugin.ts`: the `security/explain` answer for a caller — who may + * explain whom, the explained user's context built and vetted against the + * caller's organization, and the evaluation through the explain engine with + * the same internals the middleware uses and with the CALLER as the asker of + * the record's read. + * + * A MOVE, not a redesign. The function below is the body of the method of the + * same name as it stood, re-indented one level and otherwise unedited: the + * same target check, the same context build, the same internals handed to + * the engine in the same order. + * + * ## How the moved body reaches the class + * + * The shape cuts 1 to 9 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps the public method — the published + * `dist/*.d.ts` lists its name, docblock and position — and its body is one + * literal of exactly the {@link ExplainAccessHost} members it 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 explanation still asks the plugin's set resolution, + * RLS filters, sharing read filter, write-depth stash, floor decision, + * controlled-by-parent check, field folds, posture reader and by-id read, and + * the `security/explain` route and every other caller still reach the method + * through the plugin. The method was async and the function still is; the + * method now returns its function's promise. + * + * Each field was classified on its own declaration, by the rule cut 1 set. + * `baselinePermissionSets` is declared `readonly` and goes by VALUE. + * `delegatedAdminGate`, `permissionEvaluator`, `ql`, `resolveKernelService` + * and `tenancyPosture` are not (`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 and the evaluator are objects whose own + * methods the body calls, so no receiver changes. `resolveKernelService` is a + * function-valued field the body calls through the host, so its receiver is + * the host and no longer the plugin; the one function ever assigned to it is + * the arrow function `start()` assigns, which reads no `this`, so the call + * answers as it did. + * + * ## What stays in `security-plugin.ts`, and how it is read + * + * Three module-level functions this body reads stay there, exported, and are + * imported here by value: `requiredCapsForOperation`, which the middleware + * and the object admissions read too, and the two classifications of a + * caller-context by-id read, `absentUnderCallerRead` — shared with the write + * path's step 2.7 — and `unreadableUnderCallerRead`, which is defined and + * documented beside it (this cut exports both, and changes nothing else in + * either). Each is read only inside the 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` and the explain + * decision types are imported from `@objectstack/spec/security`, the write + * operation type from `@objectstack/spec/contracts`, `PermissionDeniedError` + * from `errors.js`, the organization vetting from `@objectstack/core`, and + * the context build, the explain engine, the delegator's resolution, its + * organization check and the sharing-depth intersection from + * `explain-engine.js`, as `security-plugin.ts` imported them; no body that + * stays there reads the context build, the explain engine, the organization + * vetting or the explain operation type, so that file no longer imports them. + * + * The system-context page anchors the explanation's elevation read on this + * module's `explainAccessForCaller`, which is where the read now sits, and the + * delegated-organization pin counts the delegator resolution here. + * + * Nothing re-exports this module, so the published surface does not move. + */ +import { vetOrganizationClaim } from '@objectstack/core'; +import type { AuthoredRowWriteOperation } from '@objectstack/spec/contracts'; +import type { ExplainDecision, ExplainOperation, PermissionSet } from '@objectstack/spec/security'; +import type { SecurityPlugin } from './security-plugin.js'; +import { absentUnderCallerRead, requiredCapsForOperation, unreadableUnderCallerRead } from './security-plugin.js'; +import { PermissionDeniedError } from './errors.js'; +import { + buildContextForUser, + delegatedOrganizationRefusal, + explainAccessForAsker, + 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 ExplainAccessHost { + readonly baselinePermissionSets: SecurityPlugin['baselinePermissionSets']; + readonly delegatedAdminGate: SecurityPlugin['delegatedAdminGate']; + readonly permissionEvaluator: SecurityPlugin['permissionEvaluator']; + readonly ql: SecurityPlugin['ql']; + readonly resolveKernelService: SecurityPlugin['resolveKernelService']; + readonly tenancyPosture: SecurityPlugin['tenancyPosture']; + checkControlledByParentWrite: SecurityPlugin['checkControlledByParentWrite']; + computeLayeredRlsFilter: SecurityPlugin['computeLayeredRlsFilter']; + computeReadPartialMaskRules: SecurityPlugin['computeReadPartialMaskRules']; + computeRlsFilter: SecurityPlugin['computeRlsFilter']; + foldFieldRequiredPermissions: SecurityPlugin['foldFieldRequiredPermissions']; + getObjectSecurityMeta: SecurityPlugin['getObjectSecurityMeta']; + readRowById: SecurityPlugin['readRowById']; + resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; + resolvePreImageFloorDrop: SecurityPlugin['resolvePreImageFloorDrop']; + resolveSharingReadFilter: SecurityPlugin['resolveSharingReadFilter']; + resolveWriteScopeForSharing: SecurityPlugin['resolveWriteScopeForSharing']; +} + +/** + * The layered computation's options, as the class declares them: the + * `RlsFilterOptions` interface stays in `security-plugin.ts`, and the moved + * body names it exactly as it did. + */ +type RlsFilterOptions = NonNullable[4]>; + +/** The body of `SecurityPlugin#explainAccessForCaller`; the method's docblock there is its contract. */ +export async function explainAccessForCaller( + this: Pick, + request: { object: string; operation: string; userId?: string; recordId?: string }, + callerContext?: any, +): Promise { + const operation = String(request?.operation ?? 'read') as ExplainOperation; + const object = String(request?.object ?? ''); + if (!object) throw new Error('[Security] explain: request.object is required'); + const recordId = request?.recordId != null && request.recordId !== '' ? String(request.recordId) : undefined; + + let targetContext = callerContext ?? {}; + if (request.userId && request.userId !== callerContext?.userId) { + const callerIsSystem = callerContext?.isSystem === true; + if (!callerIsSystem) { + const callerSets = await this.resolvePermissionSetsForContext(callerContext).catch(() => []); + const held = this.permissionEvaluator.getSystemPermissions(callerSets); + if (!held.has('manage_users')) { + // [ADR-0090 D12, amended 2026-10-17] An organization administrator + // may explain a member of the organization it administers — the + // members whose assignments it creates and removes. Then [D12] + // delegated administrators may explain principals inside their + // delegation boundary. Both fail closed on any error, and both read + // the organization the caller's sets were resolved in, never another. + const organizationLevel = this.delegatedAdminGate + ? await this.delegatedAdminGate + .organizationAdminCoversUser(callerSets, request.userId, callerContext) + .catch(() => false) + : false; + const delegated = organizationLevel || (this.delegatedAdminGate + ? await this.delegatedAdminGate + .scopesCoverUser(callerSets, request.userId, callerContext) + .catch(() => false) + : false); + if (!delegated) { + throw new PermissionDeniedError( + `[Security] Access denied: explaining another user's access requires the 'manage_users' ` + + `capability, organization administration of an organization that user is a member of, ` + + `or a delegated adminScope covering that user (ADR-0090 D6/D12).`, + { object, operation, targetUserId: request.userId }, + ); + } + } + } + // [#20515] Resolved in the CALLER's organization — the one the explain + // right above was checked in. Grants scoped to any other organization do + // not apply there, and with no active organization only global grants do. + const callerTenantId = typeof callerContext?.tenantId === 'string' && callerContext.tenantId !== '' + ? callerContext.tenantId + : undefined; + // [#20580] ...but only while the explained user could be resolved in it. + // Explaining them in the caller's organization is a CLAIM on it made for + // them, and enforcement vets a claim before it resolves anything: + // under a walled posture a member removed from that organization, whose + // session still names it, has the claim dropped and resolves with NO + // active organization — so none of that organization's grants apply. + // `vetOrganizationClaim` is that check (the session arm of + // `resolveAuthzContext`), asked of the same membership set; the explainer + // spells no membership rule of its own. + const nowMs = Date.now(); + let explained = await buildContextForUser(this.ql, request.userId, nowMs, callerTenantId); + const explainedTenantId = vetOrganizationClaim(callerTenantId, explained.accessible_org_ids ?? [], this.tenancyPosture); + if (explainedTenantId !== callerTenantId) { + explained = await buildContextForUser(this.ql, request.userId, nowMs, explainedTenantId); + } + // [#20604] ...and the organization the user is resolved in is the one + // their requests run in: enforcement's context for them carries it as + // `tenantId`, which the tenant wall, the organization-scoped + // permission-set catalogue and the engine all read. `buildContextForUser` + // returns none of its own, so it is set here from the SAME vetted value, + // as `resolveDelegatorContext` sets a delegator's. Without it a current + // member was explained with NO organization: denied a tenant object + // their own find reads under `isolated`, and without a permission set + // their organization authored. + if (explainedTenantId !== undefined) explained.tenantId = explainedTenantId; + targetContext = explained; + } + + // [C2 / ADR-0095] The optional `sharing` service backs the record-grained + // sharing attribution. Resolved lazily; absent (no plugin-sharing) → the + // record path still works, the sharing layer simply reports not_evaluated. + const sharing = recordId ? (this.resolveKernelService?.('sharing') as any) : undefined; + + // [#19963] A record-grained WRITE verdict is computed from the by-id write + // path's own inputs, so `record.visible` for `update` / `delete` is the + // answer the by-id PATCH / DELETE gives: + // - Layer 1 carries the pre-image gate's floor decision (step 2.7, + // {@link resolvePreImageFloorDrop}). + // - [ADR-0055] It also carries step 2.7's coverage vouch, + // `masterGateCoversThisWrite`, on step 2.7's own condition (not on behalf + // of anyone), for an `update` and a `delete` alike: explain asks the + // master gate for every by-id write of a record that exists + // ({@link checkControlledByParentWrite}, wired below), so a + // `controlled_by_parent` record's ownership floor (`owner_only_writes`, + // `owner_only_deletes`) is handed over to that check here exactly as the + // write path hands it over. + // - plugin-sharing's per-record gate is asked with `__writeScope` stamped + // as the middleware stamps it (step 2.6), always overwritten, so an + // `org` / unit writer is not judged owner-only. + // - [#5493 / ADR-0105 D3] A refusal of that gate is deferred as the + // middleware defers it: to an app-authored row-level policy that admits + // the row for the gate's verb (`probeAuthoredRowWrite`, wired below). + // [#19986] The READ verdict's twin: plugin-sharing's read filter is asked + // with `__readScope` stamped as step 2.6 stamps it for a find, so + // `record.visible` for `read` is what the caller's find returns. + // [#24646] On the on-behalf-of path the per-record WRITE gate is asked as + // the by-id write asks it ({@link delegatedWriteGate}): once for the + // delegated principal at its agent-leg depth, then, if that admits, once + // for the delegator at the delegator's own depth. The delegator's own + // write filter, which step 2.7 ANDs into the pre-image re-read, is composed + // by the engine, as the gate composes it: once with the floor standing + // (`computeRlsFilter`), once as the delegator's own record composition + // (`computeLayeredRlsFilter` below, asked with the delegator's context, so + // its floor decision is the delegator's own). The floor decision still + // excludes the delegated context itself, as the gate's does. The delegated + // READ depths are not modelled on explain's record + // path: the read filter is asked with the delegated context as it stands. + const actsOnBehalfOf = (c: any): boolean => !!c?.onBehalfOf?.userId; + const recordWriteFloorOptions = async ( + sets: PermissionSet[], + o: string, + engineOp: string, + c: any, + ): Promise => { + // [#22550] `engineOp` here is the operation explain composes the verb's + // row-level security for (`rlsOperationForVerb`, the 2.7 gate's own + // mapping), so a transfer arrives as `update` and meets the floor + // decision and the vouch step 2.7 makes for it, on the mapped verb. + if (!recordId || (engineOp !== 'update' && engineOp !== 'delete')) return undefined; + // Fail toward the floor: an unanswerable decision keeps it standing, + // which is what the gate's own verdict does on a failed probe. + const dropPlatformOwnershipFloor = await this.resolvePreImageFloorDrop( + engineOp, o, recordId, c, sets, actsOnBehalfOf(c), + ).catch(() => false); + // Step 2.7's vouch is `!delegatorSets` for every verb it runs on; the + // floor itself comes off only where `masterGateCoversOperation` says the + // master gate covers the verb (`update`, `delete`). + const masterGateCoversThisWrite = !actsOnBehalfOf(c); + return { dropPlatformOwnershipFloor, masterGateCoversThisWrite }; + }; + const withWriteScope = async (o: string, c: any): Promise => + actsOnBehalfOf(c) ? c : { ...c, __writeScope: await this.resolveWriteScopeForSharing(o, c) }; + /** + * [#24646 / ADR-0090 D10] The two contexts the by-id write path hands + * plugin-sharing's per-record gate for an on-behalf-of principal, built as + * the write path builds them: + * + * - `agent`: the delegated context with step 2.6's two stamps, the + * agent-leg write depth ({@link intersectDelegatedScope} of the ceiling's + * DECLARED depth and the delegator's effective one) as `__writeScope`, and + * the delegator's effective depth as `__delegatorWriteScope`; + * - `delegator`: what the sharing middleware's second gate call hands the + * service, that same context as the delegator (`userId` the delegator's, + * no delegation link) at the delegator's depth. + * + * A delegation link naming no user (`missing`) answers `null`: the write + * path refuses such a principal before any gate runs, and so does every + * gate built on this. [#25107] So does a delegator who is not a member of + * the organization the write runs in, on a walled posture: the prologue + * refuses it there ({@link delegatedOrganizationRefusal}). + */ + const delegatedWriteContexts = async (o: string, c: any): Promise<{ agent: any; delegator: any } | null> => { + const del = await resolveDelegatorContext(this.ql, c); + if (del.kind !== 'resolved') return null; + if (delegatedOrganizationRefusal(c, del.context, this.tenancyPosture) !== undefined) return null; + const [sets, delegatorSets, meta] = await Promise.all([ + this.resolvePermissionSetsForContext(c), + this.resolvePermissionSetsForContext(del.context), + this.getObjectSecurityMeta(o), + ]); + const delegatorScope = this.permissionEvaluator.getEffectiveScope('write', o, delegatorSets, { isPrivate: meta.isPrivate }); + const declared = this.permissionEvaluator.getDeclaredScope('write', o, sets, { isPrivate: meta.isPrivate }); + const agent = { + ...c, + __writeScope: intersectDelegatedScope(declared, delegatorScope).agentLegScope, + __delegatorWriteScope: delegatorScope, + }; + return { agent, delegator: { ...agent, userId: c.onBehalfOf.userId, onBehalfOf: undefined, __writeScope: delegatorScope } }; + }; + /** + * [#24646] A per-record write gate of the sharing service, asked as the + * by-id write asks it: for an ordinary principal once, at its own write + * depth; for an on-behalf-of principal as the agent and then, only if that + * admits, as the delegator — the sharing middleware's two calls, in its + * order ({@link delegatedWriteContexts}). + */ + const delegatedWriteGate = (gate: (o: string, rid: string, c: any) => Promise) => + async (o: string, rid: string, c: any): Promise => { + if (!actsOnBehalfOf(c)) return gate(o, rid, await withWriteScope(o, c)); + const legs = await delegatedWriteContexts(o, c); + if (!legs) return false; + if (!(await gate(o, rid, legs.agent))) return false; + return gate(o, rid, legs.delegator); + }; + // {@link resolveSharingReadFilter} IS the read-depth stamp step 2.6 makes + // (the same two inputs to the same evaluator call); the analytics path + // already asks it. The caller's own key is cleared first, so the depth + // is always the computed one — never one the context brought — even + // where resolution fails and the helper stamps nothing (`own`, the safe + // direction). + const readFilterWithReadScope = (o: string, c: any): Promise => + this.resolveSharingReadFilter(o, { ...c, __readScope: undefined }); + + return explainAccessForAsker( + { + ql: this.ql, + resolveSets: (c: any) => this.resolvePermissionSetsForContext(c), + evaluator: this.permissionEvaluator, + getObjectSecurityMeta: (o: string) => this.getObjectSecurityMeta(o), + requiredCaps: (meta: any, engineOp: string) => requiredCapsForOperation(meta, engineOp), + computeRlsFilter: (sets, o, engineOp, c) => this.computeRlsFilter(sets as any, o, engineOp, c), + getFieldMask: (sets, o, fieldRequired) => { + let fp = this.permissionEvaluator.getFieldPermissions(o, sets as any); + fp = this.foldFieldRequiredPermissions(fp, fieldRequired, sets as any); + return fp as any; + }, + // [#9127] The partial-mask dimension of the SAME field-mask decision, + // read straight off the enforcement composition (`maskResults`' own + // `partialRules` argument) rather than re-derived — explain reports the + // three states, it does not compute them. + getPartialMaskRules: async (sets, o, delSets) => { + const meta = await this.getObjectSecurityMeta(o); + const basePerms = this.permissionEvaluator.getFieldPermissions(o, sets as any); + const delBasePerms = delSets + ? this.permissionEvaluator.getFieldPermissions(o, delSets as any) + : null; + return this.computeReadPartialMaskRules(meta, sets as any, delSets as any, basePerms, delBasePerms); + }, + baselinePermissionSets: this.baselinePermissionSets, + // [#25107] The prologue's posture, for its organization-membership + // refusal of a delegated context ({@link delegatedOrganizationRefusal}). + tenancyPosture: this.tenancyPosture, + // ── record-grained deps (only consulted when recordId is present) ── + computeLayeredRlsFilter: async (sets, o, engineOp, c) => + this.computeLayeredRlsFilter( + sets as any, o, engineOp, c, await recordWriteFloorOptions(sets as any, o, engineOp, c), + ), + fetchRecord: async (o: string, rid: string) => { + try { + const finder = this.ql?.findOne + ? this.ql.findOne(o, { where: { id: rid }, context: { isSystem: true } }) + : this.ql?.find?.(o, { where: { id: rid }, limit: 1, context: { isSystem: true } }); + const res = await finder; + const row = Array.isArray(res) ? res[0] : res; + return row ?? null; + } catch { + return null; + } + }, + ...(sharing && typeof sharing.buildReadFilter === 'function' + ? { + sharingReadFilter: (o: string, c: any) => + actsOnBehalfOf(c) ? sharing.buildReadFilter(o, c) : readFilterWithReadScope(o, c), + } + : {}), + ...(sharing && typeof sharing.listShares === 'function' + // [ADR-0111 D5] listShares is now management-gated in the sharing + // service, but explain's own caller authorization already ran + // (explaining ANOTHER user requires `manage_users` — D12). Read the + // stored rows under system context so the record story keeps its + // share attribution when the EXPLAINED principal isn't a share + // manager — the exact behaviour this binding had before the gate. + ? { listRecordShares: (o: string, rid: string) => sharing.listShares(o, rid, { isSystem: true }) } + : {}), + ...(sharing && typeof sharing.canEdit === 'function' + ? { canEditRecord: delegatedWriteGate((o, rid, c) => sharing.canEdit(o, rid, c)) } + : {}), + // [ADR-0111 D3] The narrower delete gate — an edit share opens update + // but not delete, so a delete explanation must consult this. + ...(sharing && typeof sharing.canDelete === 'function' + ? { canDeleteRecord: delegatedWriteGate((o, rid, c) => sharing.canDelete(o, rid, c)) } + : {}), + // [#5493 / ADR-0105 D3] The by-id write gate's deferral, asked through + // the sharing service's own probe, the member its middleware asks + // after the gate above refuses, with the context that gate is asked + // with. The probe passes through to {@link checkAuthoredRowWrite} and + // abstains on every failure; a sharing service without it defers + // nothing, so explain reports the gate's refusal as before. [#24646] An + // on-behalf-of principal's probe carries the agent leg's stamps, as the + // middleware's own context does when it asks. + ...(sharing && typeof sharing.probeAuthoredRowWrite === 'function' + ? { + probeAuthoredRowWrite: async (o: string, rid: string, op: AuthoredRowWriteOperation, c: any) => { + if (!actsOnBehalfOf(c)) return sharing.probeAuthoredRowWrite(o, rid, op, await withWriteScope(o, c)); + const legs = await delegatedWriteContexts(o, c); + return legs ? sharing.probeAuthoredRowWrite(o, rid, op, legs.agent) : 'abstain'; + }, + } + : {}), + // [#21771] The by-id write path's read question, asked the same way: + // the caller-context by-id read, every data middleware included. + recordAbsentToCaller: (o: string, rid: string, c: any) => + absentUnderCallerRead(() => this.readRowById(o, rid, c)), + // [ADR-0055] The master-detail write check a by-id update, delete or + // transfer meets at step 2.8, served as + // `ISecurityService.checkControlledByParentWrite` and asked with the + // EXPLAINED context. The update's answer is each of those verbs' answer + // (the check judges EDIT of the master whatever the record's verb). Its + // presence is what licenses the `masterGateCoversThisWrite` vouch + // above: the two go together. + checkControlledByParentWrite: (o: string, rid: string, c: any) => + this.checkControlledByParentWrite(o, rid, c), + }, + { object, operation, context: targetContext, recordId }, + // The asker: the caller, whose read of the record decides whether this + // explanation discloses that the record exists. On a self-explanation it + // is the explained context itself, and the engine asks it the write + // path's question; explaining another user, the engine asks `cannotRead`, + // the same read with a refusal counted as "cannot read" (ruling C). + { + context: callerContext, + cannotRead: (o: string, rid: string) => unreadableUnderCallerRead(() => this.readRowById(o, rid, callerContext)), + }, + ); +} diff --git a/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts b/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts new file mode 100644 index 00000000000..972112532f2 --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-sharing-verdicts.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 10] The record-sharing verdicts of `SecurityPlugin`, moved out + * of `security-plugin.ts`: + * + * - `resolveSharingReadFilter`, the owner/share half of the read scope, + * resolved through the late-bound `sharing` service with the read depth the + * middleware would stash, and THROWING on a resolution failure so its + * callers fail closed. + * - `resolveSharingCanEdit`, the same service's gate for a single-record + * write, failing closed (`false`) when the probe throws. + * - `resolveWriteScopeForSharing`, the write-depth stash both write probes + * hand the service, resolved for the probed object. + * - `resolveSharingWriteVerdict`, the tri-state write verdict for a single + * row, `abstain` where nothing was consulted and `deny` where the probe + * throws. + * - `resolvePreImageFloorDrop`, the by-id write pre-image gate's floor + * decision, which `security/explain` asks too. + * - `delegatorOwnWriteFloorOptions`, the composition knobs the delegator's + * own direct by-id write would get. + * + * 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 service methods asked in the same order, + * the same fail-closed answers, the same errors logged. + * + * ## How the moved bodies reach the class + * + * The shape cuts 1 to 9 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps all six 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 SharingVerdictsHost} 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 verdicts still ask the + * plugin's set resolution, posture reader and RLS policy collection, and + * each other through the plugin, and every caller still reaches the six + * methods through the plugin. All six methods were async and all six + * 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` and `resolveKernelService` are not declared + * `readonly` (`start()` assigns two 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 logger and the + * evaluator are objects whose own methods the bodies call, so no receiver + * changes. `resolveKernelService` is a function-valued field the bodies call + * through the host, so its receiver is the host and no longer the plugin; the + * one function ever assigned to it is the arrow function `start()` assigns, + * which reads no `this`, so each call answers as it did. + * + * ## What stays in `security-plugin.ts`, and how it is read + * + * The options interface `RlsFilterOptions` keeps its one declaration there, + * where the RLS filter and every gate read it; this module names it through + * the class's own signature (below). `PermissionSet` is imported from + * `@objectstack/spec/security`, the write verdict type from + * `@objectstack/spec/contracts`, and the platform-ownership floor predicate + * from `platform-ownership-policies.js`, as `security-plugin.ts` imports them. + * + * Nothing re-exports this module, so the published surface does not move. + */ +import type { SharingWriteVerdict } from '@objectstack/spec/contracts'; +import type { PermissionSet } from '@objectstack/spec/security'; +import type { SecurityPlugin } from './security-plugin.js'; +import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.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 SharingVerdictsHost { + readonly logger: SecurityPlugin['logger']; + readonly permissionEvaluator: SecurityPlugin['permissionEvaluator']; + readonly resolveKernelService: SecurityPlugin['resolveKernelService']; + collectRLSPolicies: SecurityPlugin['collectRLSPolicies']; + getObjectSecurityMeta: SecurityPlugin['getObjectSecurityMeta']; + resolvePermissionSetsForContext: SecurityPlugin['resolvePermissionSetsForContext']; + resolvePreImageFloorDrop: SecurityPlugin['resolvePreImageFloorDrop']; + resolveSharingWriteVerdict: SecurityPlugin['resolveSharingWriteVerdict']; + resolveWriteScopeForSharing: SecurityPlugin['resolveWriteScopeForSharing']; +} + +/** + * The layered computation's options, as the class declares them: the + * `RlsFilterOptions` interface stays in `security-plugin.ts`, and the moved + * bodies name it exactly as they did. + */ +type RlsFilterOptions = NonNullable[4]>; + +/** The body of `SecurityPlugin#resolveSharingReadFilter`; the method's docblock there is its contract. */ +export async function resolveSharingReadFilter( + this: Pick, + object: string, + context: any, + resolvedSets?: PermissionSet[], + /** + * [ADR-0090 D10] The depth to stamp instead of the one `getEffectiveScope` + * answers for `resolvedSets` — what step 2.6 stamps on a DELEGATED read, + * where the agent leg carries the intersected depth and the delegator leg + * its own ({@link computeDelegatedReadFilter}). Absent on every + * non-delegated call, which resolves the depth here as before. + */ + depth?: string, +): Promise | null> { + const sharing = this.resolveKernelService?.('sharing') as + | { buildReadFilter?: (o: string, c: any) => Promise } + | undefined; + if (!sharing || typeof sharing.buildReadFilter !== 'function') return null; + // Mirror the middleware's ADR-0057 D1 depth stash. `getEffectiveScope` + // needs the resolved sets and the object's posture — the same two inputs + // the middleware feeds it — so the owner-match widens identically here. + // [#5386] `resolvedSets` lets an in-middleware caller (the + // controlled_by_parent derivation) pass the sets it already resolved for + // THIS identity, instead of re-resolving them from the context. + let readScope: string | undefined = depth; + if (readScope === undefined) { + try { + const permissionSets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context)); + if (permissionSets.length > 0) { + const meta = await this.getObjectSecurityMeta(object); + readScope = this.permissionEvaluator.getEffectiveScope( + 'read', + object, + permissionSets, + { isPrivate: meta.isPrivate }, + ); + } + } catch { + // Depth is a WIDENING input: failing to resolve it leaves the owner-match + // at its narrowest ('own'), which is the safe direction. The sharing call + // below still runs — and its own failure still denies. + readScope = undefined; + } + } + const filter = await sharing.buildReadFilter(object, { + ...context, + ...(readScope ? { __readScope: readScope } : {}), + }); + return (filter ?? null) as Record | null; +} + +/** The body of `SecurityPlugin#resolveSharingCanEdit`; the method's docblock there is its contract. */ +export async function resolveSharingCanEdit( + this: Pick, + object: string, + recordId: string, + context: any, + resolvedSets?: PermissionSet[], +): Promise { + const sharing = this.resolveKernelService?.('sharing') as + | { canEdit?: (o: string, id: string, c: any) => Promise } + | undefined; + if (!sharing || typeof sharing.canEdit !== 'function') return true; + const writeScope = await this.resolveWriteScopeForSharing(object, context, resolvedSets); + try { + return ( + (await sharing.canEdit(object, recordId, { ...context, __writeScope: writeScope })) === true + ); + } catch (e) { + this.logger.error?.( + `[security] controlled_by_parent write gate could not resolve the sharing (OWD) edit ` + + `check for '${object}' record '${recordId}' (user ${context?.userId ?? 'unknown'}) — ` + + `denying (fail-closed: a child is writable only where its master is, so a master check that ` + + `cannot be resolved refuses)`, + e instanceof Error ? e : new Error(String(e)), + ); + return false; + } +} + +/** The body of `SecurityPlugin#resolveWriteScopeForSharing`; the method's docblock there is its contract. */ +export async function resolveWriteScopeForSharing( + this: Pick, + object: string, + context: any, + resolvedSets?: PermissionSet[], +): Promise { + try { + const permissionSets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context)); + if (permissionSets.length === 0) return undefined; + const meta = await this.getObjectSecurityMeta(object); + return this.permissionEvaluator.getEffectiveScope( + 'write', + object, + permissionSets, + { isPrivate: meta.isPrivate }, + ); + } catch { + return undefined; + } +} + +/** The body of `SecurityPlugin#resolveSharingWriteVerdict`; the method's docblock there is its contract. */ +export async function resolveSharingWriteVerdict( + this: Pick, + rlsOperation: string, + object: string, + recordId: string, + context: any, + resolvedSets?: PermissionSet[], +): Promise { + const method = rlsOperation === 'delete' ? 'checkDelete' : 'checkEdit'; + const sharing = this.resolveKernelService?.('sharing') as + | Record Promise) | undefined> + | undefined; + const probe = sharing?.[method]; + if (typeof probe !== 'function') return 'abstain'; + const writeScope = await this.resolveWriteScopeForSharing(object, context, resolvedSets); + try { + const verdict = await probe.call(sharing, object, recordId, { + ...context, + __writeScope: writeScope, + }); + return verdict === 'allow' || verdict === 'deny' ? verdict : 'abstain'; + } catch (e) { + this.logger.error?.( + `[security] the row-level write gate could not resolve the sharing (${method}) verdict ` + + `for '${object}' record '${recordId}' (user ${context?.userId ?? 'unknown'}) — keeping ` + + `the platform ownership floor (fail-closed: only a resolved sharing allow, from Modify All Data ` + + `or an edit-level share, may replace that floor)`, + e instanceof Error ? e : new Error(String(e)), + ); + return 'deny'; + } +} + +/** The body of `SecurityPlugin#resolvePreImageFloorDrop`; the method's docblock there is its contract. */ +export async function resolvePreImageFloorDrop( + this: Pick, + rlsOperation: string, + object: string, + recordId: string, + context: any, + permissionSets: PermissionSet[], + delegated: boolean, +): Promise { + if (delegated) return false; + const floorApplies = this.collectRLSPolicies( + permissionSets, + object, + rlsOperation, + (context?.positions ?? []) as string[], + context, + ).some(isPlatformOwnershipFloorPolicy); + if (!floorApplies) return false; + return ( + (await this.resolveSharingWriteVerdict(rlsOperation, object, recordId, context, permissionSets)) === + 'allow' + ); +} + +/** The body of `SecurityPlugin#delegatorOwnWriteFloorOptions`; the method's docblock there is its contract. */ +export async function delegatorOwnWriteFloorOptions( + this: Pick, + rlsOperation: string, + object: string, + recordId: string, + delegatorContext: any, + delegatorSets: PermissionSet[], + masterGateCoversThisWrite: boolean, +): Promise { + const dropPlatformOwnershipFloor = await this.resolvePreImageFloorDrop( + rlsOperation, + object, + recordId, + delegatorContext, + delegatorSets, + false, + ); + return { dropPlatformOwnershipFloor, masterGateCoversThisWrite }; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 4dbc128019a..a138152180f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -48,15 +48,13 @@ import { } from './access-upgrade-report.js'; import { ACCESS_UPGRADE_CHECK_SERVICE, createAccessUpgradeCheck } from './access-upgrade-check.js'; import { - explainAccessForAsker, - buildContextForUser, resolveDelegatorContext, delegatedOrganizationRefusal, intersectDelegatedScope, d10NarrowingStatement, } from './explain-engine.js'; import { jsonColumnCheckRefusalCarriedBy, storedFormCheckJudge } from './rls-check-stored-form.js'; -import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; +import type { ExplainDecision } from '@objectstack/spec/security'; import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; import { applyManagedWriteDenies } from './managed-object-write-denies.js'; @@ -69,7 +67,6 @@ import { listAudienceBindingSuggestions, confirmAudienceBindingSuggestion, dismissAudienceBindingSuggestion, - readAcceptedEveryoneSets, readAcceptedDefaultPermissionSetsOption, refuseAcceptedDefaultsWithoutWall, type DeploymentAcceptance, @@ -77,7 +74,7 @@ import { type SuggestionListFilter, } from './suggested-audience-bindings.js'; import { cleanupPackagePermissions } from './cleanup-package-permissions.js'; -import { readDeclared, readDeclaredCapabilityContext } from './declared-capability-context.js'; +import { readDeclared } from './declared-capability-context.js'; import { RLSCompiler, RLS_DENY_FILTER, compiledPolicyNameOf, policyDeclaresClause } from './rls-compiler.js'; import { andComposeLayers, @@ -86,7 +83,7 @@ import { import { isVerifiedPlatformOwnerRow, } from './platform-owner-wall-bypass.js'; -import { isConfiguredPlatformAdminEmail, vetOrganizationClaim, createSecurityCatalogReader, bindSecurityCatalogReader, securityCatalogReaderOf, readPositionGrantsInForce, countPositionHolders } from '@objectstack/core'; +import { isConfiguredPlatformAdminEmail, createSecurityCatalogReader, bindSecurityCatalogReader, securityCatalogReaderOf, readPositionGrantsInForce, countPositionHolders } from '@objectstack/core'; // [#25223 cut 1] The leaf readers (policy collection, unresolved-posture // probes, field masks, field-name tables); the methods of the same names below // hand each body the members it reads (`ObjectInputsHost`). @@ -225,6 +222,42 @@ import { objectReadAdmission, type ObjectAdmissionHost, } from './security-plugin-object-admission.js'; +// [#25223 cut 10] The `everyone` anchor's declarations: the anchor's +// definition a started kernel derives, and the built-in positions `init()` +// declares; the methods of the same names below hand each body the members it +// reads (`EveryoneAnchorHost`). +import { + declareBuiltinPositions, + declareEveryone, + type EveryoneAnchorHost, +} from './security-plugin-everyone-anchor.js'; +// [#25223 cut 10] The `security/explain` answer for a caller; the method of +// the same name below hands the body the members it reads +// (`ExplainAccessHost`). +import { + explainAccessForCaller, + type ExplainAccessHost, +} from './security-plugin-explain-access.js'; +// [#25223 cut 10] The record-sharing verdicts: the read filter, the edit gate, +// the write-depth stash, the tri-state write verdict, the pre-image floor +// decision and the delegator's own floor knobs; the methods of the same names +// below hand each body the members it reads (`SharingVerdictsHost`). +import { + delegatorOwnWriteFloorOptions, + resolvePreImageFloorDrop, + resolveSharingCanEdit, + resolveSharingReadFilter, + resolveSharingWriteVerdict, + resolveWriteScopeForSharing, + type SharingVerdictsHost, +} from './security-plugin-sharing-verdicts.js'; +// [#25223 cut 10] The authored-row write verdict and its probe scope; the +// method of the same name below hands the body the members it reads +// (`AuthoredRowWriteHost`). +import { + checkAuthoredRowWrite, + type AuthoredRowWriteHost, +} from './security-plugin-authored-row-write.js'; import { isPlatformOwnershipFloorPolicy, masterGateCoversOperation, @@ -292,7 +325,6 @@ import { securityPluginManifestHeader, SECURITY_PLUGIN_ID, } from './manifest.js'; -import { declareEveryoneBaseline, registerBuiltinPositions } from './builtin-positions.js'; import { GUEST_POSITION } from '@objectstack/spec'; import { registerBuiltinCapabilities } from './builtin-capabilities.js'; @@ -757,36 +789,6 @@ interface CbpRelation { masterNotWritable: string | null; } -/** - * [#7281] The scope `checkAuthoredRowWrite`'s probe read runs under. - * - * The method asks ONE question — "does the declared, app-authored widener admit - * this row for this operation?" — and that question is about the row and the - * declaration, never about what the CALLER may see. Resolving it under the - * caller's own context folded a READ decision into a WRITE question: on a - * `private`-OWD object `plugin-sharing`'s read filter scopes every re-read to - * owner-match OR shares, so a cross-owner row was invisible to the probe and - * the verdict was `abstain` for a row the declaration names by predicate. The - * by-id widener surface was therefore structurally dead on `private` — the - * posture #5493 built it for (maintainer ruling, 2026-08-10: reading 2). - * - * Elevating the READ does not widen the ANSWER, because the predicate carries - * the whole of the question and travels in the `where`, not in the scope: - * `{ id } AND layer0(tenant wall) AND layer1(app-authored policies)`. Both - * layers are computed from the CALLER's permission sets and the CALLER's - * tenant BEFORE the read (see {@link SecurityPlugin.checkAuthoredRowWrite}), so - * a row in another tenant, a row no authored policy matches, and a caller - * holding no authored policy at all are all still `abstain` — measured, not - * asserted (`authored-row-write-verdict.test.ts`, and the real-stack pin - * `packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts`). - * - * Principal-less on purpose: it carries no `userId`, so nothing downstream can - * mistake it for the caller acting with more authority than they hold. It is - * SPREAD at the single call site rather than passed by reference, so no - * middleware can stamp state onto a shared object. - */ -const AUTHORED_ROW_WRITE_PROBE_CONTEXT = { isSystem: true, positions: [], permissions: [] } as const; - /** * [#21771] Ruling A's read question, judged on the answer of a caller-context * by-id read: is the row ABSENT to this caller? @@ -807,7 +809,7 @@ const AUTHORED_ROW_WRITE_PROBE_CONTEXT = { isSystem: true, positions: [], permis * one question one way. The three-way classification itself is * {@link readUnlessRefused}, which the predicate write's read question asks too. */ -async function absentUnderCallerRead(read: () => Promise): Promise { +export async function absentUnderCallerRead(read: () => Promise): Promise { const answer = await readUnlessRefused(read); return !answer.refused && answer.value == null; } @@ -824,7 +826,7 @@ async function absentUnderCallerRead(read: () => Promise): Promise Promise): Promise { +export async function unreadableUnderCallerRead(read: () => Promise): Promise { const answer = await readUnlessRefused(read); return answer.refused || answer.value == null; } @@ -4909,40 +4911,19 @@ export class SecurityPlugin implements Plugin { * could not read the record keeps the baseline alone and says so at `error`. * The grants caches retire on the metadata epoch, as for any declaration. */ - private async declareEveryone(logger: PluginContext['logger']): Promise { - const ql = this.ql; - try { - const anchorContext = await readDeclaredCapabilityContext(ql, this.metadata); - const sets = this.everyoneBaseline(anchorContext, (set, offending) => { - logger.warn('[security] the everyone anchor does not name a baseline set — high-privilege bits', { set, offending }); - }); - try { - const derived = await readAcceptedEveryoneSets(ql, this.metadata, this.deploymentAcceptance()); - for (const name of derived.accepted) if (!sets.includes(name)) sets.push(name); - for (const w of derived.withheld) { - const line = w.source === 'host-configuration' - ? `[security] the everyone anchor does not carry the set '${w.set}' the security plugin's ` + - `\`acceptedDefaultPermissionSets\` option declares: ${w.reason}. It grants nothing while that holds; ` + - 'remove the name from the option, or serve the set again as an isDefault set some package declares.' - : `[security] the everyone anchor does not carry the accepted set '${w.set}' (package '${w.packageId}'): ` + - `${w.reason}. Its acceptance stays recorded and grants nothing until the set is served again as ` + - 'accepted; a manage_metadata holder can revoke it in Setup.'; - if (w.forbidden) logger.error(line, undefined, { set: w.set, package: w.packageId }); - else logger.warn(line, { set: w.set, package: w.packageId }); - } - } catch (e) { - logger.error( - '[security] the accepted package defaults for the everyone anchor could NOT be read — every signed-in ' + - 'member holds the baseline alone until the next derivation (boot, accept, revoke, uninstall or package ' + - 'publish) reads them. Remedy: restore read access to sys_audience_binding_suggestion and restart.', - e as Error, - ); - } - if (sets.length > 0) declareEveryoneBaseline((ql as { registry?: unknown })?.registry, SECURITY_PLUGIN_ID, sets); - this.epoch.bump('metadata'); - } catch (e) { - logger.warn('[security] everyone-anchor declaration failed (non-fatal)', { error: (e as Error).message }); - } + private declareEveryone(logger: PluginContext['logger']): Promise { + // [#25223 cut 10] The body moved to `security-plugin-everyone-anchor.ts` (see `EveryoneAnchorHost`). + const readEpoch = () => this.epoch; + const readMetadata = () => this.metadata; + const readQl = () => this.ql; + const host: Pick = { + deploymentAcceptance: (...a) => this.deploymentAcceptance(...a), + get epoch() { return readEpoch(); }, + everyoneBaseline: (...a) => this.everyoneBaseline(...a), + get metadata() { return readMetadata(); }, + get ql() { return readQl(); }, + }; + return declareEveryone.call(host, logger); } /** @@ -4992,24 +4973,11 @@ export class SecurityPlugin implements Plugin { * {@link declareEveryone} reports what it withholds. */ private declareBuiltinPositions(ctx: PluginContext): void { - let ql: { registry?: unknown } | undefined; - try { - ql = ctx.getService<{ registry?: unknown }>('objectql'); - } catch { - return; // No engine: `start()` reports it, and nothing can be declared. - } - if (!ql) return; - if (registerBuiltinPositions(ql.registry, SECURITY_PLUGIN_ID) === 0) { - ctx.logger.warn( - '[security] the built-in positions (platform_admin, org_owner, org_admin, org_member, everyone, guest) ' - + 'were NOT declared as position metadata: the ObjectQL engine exposes no registry that can register ' - + 'them. The security catalog read and GET /api/v1/meta/position will not list them, and the ' - + '`everyone` baseline sets are granted through no position.', - ); - return; - } - const baseline = this.everyoneBaseline(undefined); - if (baseline.length > 0) declareEveryoneBaseline(ql.registry, SECURITY_PLUGIN_ID, baseline); + // [#25223 cut 10] The body moved to `security-plugin-everyone-anchor.ts` (see `EveryoneAnchorHost`). + const host: Pick = { + everyoneBaseline: (...a) => this.everyoneBaseline(...a), + }; + declareBuiltinPositions.call(host, ctx); } async destroy(): Promise { @@ -5058,318 +5026,36 @@ export class SecurityPlugin implements Plugin { * 4xx) is answered so too; a caller explaining itself keeps the write path's * notion (no row), so its explanation shows what its own doors do. */ - async explainAccessForCaller( + explainAccessForCaller( request: { object: string; operation: string; userId?: string; recordId?: string }, callerContext?: any, ): Promise { - const operation = String(request?.operation ?? 'read') as ExplainOperation; - const object = String(request?.object ?? ''); - if (!object) throw new Error('[Security] explain: request.object is required'); - const recordId = request?.recordId != null && request.recordId !== '' ? String(request.recordId) : undefined; - - let targetContext = callerContext ?? {}; - if (request.userId && request.userId !== callerContext?.userId) { - const callerIsSystem = callerContext?.isSystem === true; - if (!callerIsSystem) { - const callerSets = await this.resolvePermissionSetsForContext(callerContext).catch(() => []); - const held = this.permissionEvaluator.getSystemPermissions(callerSets); - if (!held.has('manage_users')) { - // [ADR-0090 D12, amended 2026-10-17] An organization administrator - // may explain a member of the organization it administers — the - // members whose assignments it creates and removes. Then [D12] - // delegated administrators may explain principals inside their - // delegation boundary. Both fail closed on any error, and both read - // the organization the caller's sets were resolved in, never another. - const organizationLevel = this.delegatedAdminGate - ? await this.delegatedAdminGate - .organizationAdminCoversUser(callerSets, request.userId, callerContext) - .catch(() => false) - : false; - const delegated = organizationLevel || (this.delegatedAdminGate - ? await this.delegatedAdminGate - .scopesCoverUser(callerSets, request.userId, callerContext) - .catch(() => false) - : false); - if (!delegated) { - throw new PermissionDeniedError( - `[Security] Access denied: explaining another user's access requires the 'manage_users' ` + - `capability, organization administration of an organization that user is a member of, ` + - `or a delegated adminScope covering that user (ADR-0090 D6/D12).`, - { object, operation, targetUserId: request.userId }, - ); - } - } - } - // [#20515] Resolved in the CALLER's organization — the one the explain - // right above was checked in. Grants scoped to any other organization do - // not apply there, and with no active organization only global grants do. - const callerTenantId = typeof callerContext?.tenantId === 'string' && callerContext.tenantId !== '' - ? callerContext.tenantId - : undefined; - // [#20580] ...but only while the explained user could be resolved in it. - // Explaining them in the caller's organization is a CLAIM on it made for - // them, and enforcement vets a claim before it resolves anything: - // under a walled posture a member removed from that organization, whose - // session still names it, has the claim dropped and resolves with NO - // active organization — so none of that organization's grants apply. - // `vetOrganizationClaim` is that check (the session arm of - // `resolveAuthzContext`), asked of the same membership set; the explainer - // spells no membership rule of its own. - const nowMs = Date.now(); - let explained = await buildContextForUser(this.ql, request.userId, nowMs, callerTenantId); - const explainedTenantId = vetOrganizationClaim(callerTenantId, explained.accessible_org_ids ?? [], this.tenancyPosture); - if (explainedTenantId !== callerTenantId) { - explained = await buildContextForUser(this.ql, request.userId, nowMs, explainedTenantId); - } - // [#20604] ...and the organization the user is resolved in is the one - // their requests run in: enforcement's context for them carries it as - // `tenantId`, which the tenant wall, the organization-scoped - // permission-set catalogue and the engine all read. `buildContextForUser` - // returns none of its own, so it is set here from the SAME vetted value, - // as `resolveDelegatorContext` sets a delegator's. Without it a current - // member was explained with NO organization: denied a tenant object - // their own find reads under `isolated`, and without a permission set - // their organization authored. - if (explainedTenantId !== undefined) explained.tenantId = explainedTenantId; - targetContext = explained; - } - - // [C2 / ADR-0095] The optional `sharing` service backs the record-grained - // sharing attribution. Resolved lazily; absent (no plugin-sharing) → the - // record path still works, the sharing layer simply reports not_evaluated. - const sharing = recordId ? (this.resolveKernelService?.('sharing') as any) : undefined; - - // [#19963] A record-grained WRITE verdict is computed from the by-id write - // path's own inputs, so `record.visible` for `update` / `delete` is the - // answer the by-id PATCH / DELETE gives: - // - Layer 1 carries the pre-image gate's floor decision (step 2.7, - // {@link resolvePreImageFloorDrop}). - // - [ADR-0055] It also carries step 2.7's coverage vouch, - // `masterGateCoversThisWrite`, on step 2.7's own condition (not on behalf - // of anyone), for an `update` and a `delete` alike: explain asks the - // master gate for every by-id write of a record that exists - // ({@link checkControlledByParentWrite}, wired below), so a - // `controlled_by_parent` record's ownership floor (`owner_only_writes`, - // `owner_only_deletes`) is handed over to that check here exactly as the - // write path hands it over. - // - plugin-sharing's per-record gate is asked with `__writeScope` stamped - // as the middleware stamps it (step 2.6), always overwritten, so an - // `org` / unit writer is not judged owner-only. - // - [#5493 / ADR-0105 D3] A refusal of that gate is deferred as the - // middleware defers it: to an app-authored row-level policy that admits - // the row for the gate's verb (`probeAuthoredRowWrite`, wired below). - // [#19986] The READ verdict's twin: plugin-sharing's read filter is asked - // with `__readScope` stamped as step 2.6 stamps it for a find, so - // `record.visible` for `read` is what the caller's find returns. - // [#24646] On the on-behalf-of path the per-record WRITE gate is asked as - // the by-id write asks it ({@link delegatedWriteGate}): once for the - // delegated principal at its agent-leg depth, then, if that admits, once - // for the delegator at the delegator's own depth. The delegator's own - // write filter, which step 2.7 ANDs into the pre-image re-read, is composed - // by the engine, as the gate composes it: once with the floor standing - // (`computeRlsFilter`), once as the delegator's own record composition - // (`computeLayeredRlsFilter` below, asked with the delegator's context, so - // its floor decision is the delegator's own). The floor decision still - // excludes the delegated context itself, as the gate's does. The delegated - // READ depths are not modelled on explain's record - // path: the read filter is asked with the delegated context as it stands. - const actsOnBehalfOf = (c: any): boolean => !!c?.onBehalfOf?.userId; - const recordWriteFloorOptions = async ( - sets: PermissionSet[], - o: string, - engineOp: string, - c: any, - ): Promise => { - // [#22550] `engineOp` here is the operation explain composes the verb's - // row-level security for (`rlsOperationForVerb`, the 2.7 gate's own - // mapping), so a transfer arrives as `update` and meets the floor - // decision and the vouch step 2.7 makes for it, on the mapped verb. - if (!recordId || (engineOp !== 'update' && engineOp !== 'delete')) return undefined; - // Fail toward the floor: an unanswerable decision keeps it standing, - // which is what the gate's own verdict does on a failed probe. - const dropPlatformOwnershipFloor = await this.resolvePreImageFloorDrop( - engineOp, o, recordId, c, sets, actsOnBehalfOf(c), - ).catch(() => false); - // Step 2.7's vouch is `!delegatorSets` for every verb it runs on; the - // floor itself comes off only where `masterGateCoversOperation` says the - // master gate covers the verb (`update`, `delete`). - const masterGateCoversThisWrite = !actsOnBehalfOf(c); - return { dropPlatformOwnershipFloor, masterGateCoversThisWrite }; - }; - const withWriteScope = async (o: string, c: any): Promise => - actsOnBehalfOf(c) ? c : { ...c, __writeScope: await this.resolveWriteScopeForSharing(o, c) }; - /** - * [#24646 / ADR-0090 D10] The two contexts the by-id write path hands - * plugin-sharing's per-record gate for an on-behalf-of principal, built as - * the write path builds them: - * - * - `agent`: the delegated context with step 2.6's two stamps, the - * agent-leg write depth ({@link intersectDelegatedScope} of the ceiling's - * DECLARED depth and the delegator's effective one) as `__writeScope`, and - * the delegator's effective depth as `__delegatorWriteScope`; - * - `delegator`: what the sharing middleware's second gate call hands the - * service, that same context as the delegator (`userId` the delegator's, - * no delegation link) at the delegator's depth. - * - * A delegation link naming no user (`missing`) answers `null`: the write - * path refuses such a principal before any gate runs, and so does every - * gate built on this. [#25107] So does a delegator who is not a member of - * the organization the write runs in, on a walled posture: the prologue - * refuses it there ({@link delegatedOrganizationRefusal}). - */ - const delegatedWriteContexts = async (o: string, c: any): Promise<{ agent: any; delegator: any } | null> => { - const del = await resolveDelegatorContext(this.ql, c); - if (del.kind !== 'resolved') return null; - if (delegatedOrganizationRefusal(c, del.context, this.tenancyPosture) !== undefined) return null; - const [sets, delegatorSets, meta] = await Promise.all([ - this.resolvePermissionSetsForContext(c), - this.resolvePermissionSetsForContext(del.context), - this.getObjectSecurityMeta(o), - ]); - const delegatorScope = this.permissionEvaluator.getEffectiveScope('write', o, delegatorSets, { isPrivate: meta.isPrivate }); - const declared = this.permissionEvaluator.getDeclaredScope('write', o, sets, { isPrivate: meta.isPrivate }); - const agent = { - ...c, - __writeScope: intersectDelegatedScope(declared, delegatorScope).agentLegScope, - __delegatorWriteScope: delegatorScope, - }; - return { agent, delegator: { ...agent, userId: c.onBehalfOf.userId, onBehalfOf: undefined, __writeScope: delegatorScope } }; + // [#25223 cut 10] The body moved to `security-plugin-explain-access.ts` (see `ExplainAccessHost`). + const readDelegatedAdminGate = () => this.delegatedAdminGate; + const readPermissionEvaluator = () => this.permissionEvaluator; + const readQl = () => this.ql; + const readResolveKernelService = () => this.resolveKernelService; + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + baselinePermissionSets: this.baselinePermissionSets, + checkControlledByParentWrite: (...a) => this.checkControlledByParentWrite(...a), + computeLayeredRlsFilter: (...a) => this.computeLayeredRlsFilter(...a), + computeReadPartialMaskRules: (...a) => this.computeReadPartialMaskRules(...a), + computeRlsFilter: (...a) => this.computeRlsFilter(...a), + get delegatedAdminGate() { return readDelegatedAdminGate(); }, + foldFieldRequiredPermissions: (...a) => this.foldFieldRequiredPermissions(...a), + getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), + get permissionEvaluator() { return readPermissionEvaluator(); }, + get ql() { return readQl(); }, + readRowById: (...a) => this.readRowById(...a), + get resolveKernelService() { return readResolveKernelService(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + resolvePreImageFloorDrop: (...a) => this.resolvePreImageFloorDrop(...a), + resolveSharingReadFilter: (...a) => this.resolveSharingReadFilter(...a), + resolveWriteScopeForSharing: (...a) => this.resolveWriteScopeForSharing(...a), + get tenancyPosture() { return readTenancyPosture(); }, }; - /** - * [#24646] A per-record write gate of the sharing service, asked as the - * by-id write asks it: for an ordinary principal once, at its own write - * depth; for an on-behalf-of principal as the agent and then, only if that - * admits, as the delegator — the sharing middleware's two calls, in its - * order ({@link delegatedWriteContexts}). - */ - const delegatedWriteGate = (gate: (o: string, rid: string, c: any) => Promise) => - async (o: string, rid: string, c: any): Promise => { - if (!actsOnBehalfOf(c)) return gate(o, rid, await withWriteScope(o, c)); - const legs = await delegatedWriteContexts(o, c); - if (!legs) return false; - if (!(await gate(o, rid, legs.agent))) return false; - return gate(o, rid, legs.delegator); - }; - // {@link resolveSharingReadFilter} IS the read-depth stamp step 2.6 makes - // (the same two inputs to the same evaluator call); the analytics path - // already asks it. The caller's own key is cleared first, so the depth - // is always the computed one — never one the context brought — even - // where resolution fails and the helper stamps nothing (`own`, the safe - // direction). - const readFilterWithReadScope = (o: string, c: any): Promise => - this.resolveSharingReadFilter(o, { ...c, __readScope: undefined }); - - return explainAccessForAsker( - { - ql: this.ql, - resolveSets: (c: any) => this.resolvePermissionSetsForContext(c), - evaluator: this.permissionEvaluator, - getObjectSecurityMeta: (o: string) => this.getObjectSecurityMeta(o), - requiredCaps: (meta: any, engineOp: string) => requiredCapsForOperation(meta, engineOp), - computeRlsFilter: (sets, o, engineOp, c) => this.computeRlsFilter(sets as any, o, engineOp, c), - getFieldMask: (sets, o, fieldRequired) => { - let fp = this.permissionEvaluator.getFieldPermissions(o, sets as any); - fp = this.foldFieldRequiredPermissions(fp, fieldRequired, sets as any); - return fp as any; - }, - // [#9127] The partial-mask dimension of the SAME field-mask decision, - // read straight off the enforcement composition (`maskResults`' own - // `partialRules` argument) rather than re-derived — explain reports the - // three states, it does not compute them. - getPartialMaskRules: async (sets, o, delSets) => { - const meta = await this.getObjectSecurityMeta(o); - const basePerms = this.permissionEvaluator.getFieldPermissions(o, sets as any); - const delBasePerms = delSets - ? this.permissionEvaluator.getFieldPermissions(o, delSets as any) - : null; - return this.computeReadPartialMaskRules(meta, sets as any, delSets as any, basePerms, delBasePerms); - }, - baselinePermissionSets: this.baselinePermissionSets, - // [#25107] The prologue's posture, for its organization-membership - // refusal of a delegated context ({@link delegatedOrganizationRefusal}). - tenancyPosture: this.tenancyPosture, - // ── record-grained deps (only consulted when recordId is present) ── - computeLayeredRlsFilter: async (sets, o, engineOp, c) => - this.computeLayeredRlsFilter( - sets as any, o, engineOp, c, await recordWriteFloorOptions(sets as any, o, engineOp, c), - ), - fetchRecord: async (o: string, rid: string) => { - try { - const finder = this.ql?.findOne - ? this.ql.findOne(o, { where: { id: rid }, context: { isSystem: true } }) - : this.ql?.find?.(o, { where: { id: rid }, limit: 1, context: { isSystem: true } }); - const res = await finder; - const row = Array.isArray(res) ? res[0] : res; - return row ?? null; - } catch { - return null; - } - }, - ...(sharing && typeof sharing.buildReadFilter === 'function' - ? { - sharingReadFilter: (o: string, c: any) => - actsOnBehalfOf(c) ? sharing.buildReadFilter(o, c) : readFilterWithReadScope(o, c), - } - : {}), - ...(sharing && typeof sharing.listShares === 'function' - // [ADR-0111 D5] listShares is now management-gated in the sharing - // service, but explain's own caller authorization already ran - // (explaining ANOTHER user requires `manage_users` — D12). Read the - // stored rows under system context so the record story keeps its - // share attribution when the EXPLAINED principal isn't a share - // manager — the exact behaviour this binding had before the gate. - ? { listRecordShares: (o: string, rid: string) => sharing.listShares(o, rid, { isSystem: true }) } - : {}), - ...(sharing && typeof sharing.canEdit === 'function' - ? { canEditRecord: delegatedWriteGate((o, rid, c) => sharing.canEdit(o, rid, c)) } - : {}), - // [ADR-0111 D3] The narrower delete gate — an edit share opens update - // but not delete, so a delete explanation must consult this. - ...(sharing && typeof sharing.canDelete === 'function' - ? { canDeleteRecord: delegatedWriteGate((o, rid, c) => sharing.canDelete(o, rid, c)) } - : {}), - // [#5493 / ADR-0105 D3] The by-id write gate's deferral, asked through - // the sharing service's own probe, the member its middleware asks - // after the gate above refuses, with the context that gate is asked - // with. The probe passes through to {@link checkAuthoredRowWrite} and - // abstains on every failure; a sharing service without it defers - // nothing, so explain reports the gate's refusal as before. [#24646] An - // on-behalf-of principal's probe carries the agent leg's stamps, as the - // middleware's own context does when it asks. - ...(sharing && typeof sharing.probeAuthoredRowWrite === 'function' - ? { - probeAuthoredRowWrite: async (o: string, rid: string, op: AuthoredRowWriteOperation, c: any) => { - if (!actsOnBehalfOf(c)) return sharing.probeAuthoredRowWrite(o, rid, op, await withWriteScope(o, c)); - const legs = await delegatedWriteContexts(o, c); - return legs ? sharing.probeAuthoredRowWrite(o, rid, op, legs.agent) : 'abstain'; - }, - } - : {}), - // [#21771] The by-id write path's read question, asked the same way: - // the caller-context by-id read, every data middleware included. - recordAbsentToCaller: (o: string, rid: string, c: any) => - absentUnderCallerRead(() => this.readRowById(o, rid, c)), - // [ADR-0055] The master-detail write check a by-id update, delete or - // transfer meets at step 2.8, served as - // `ISecurityService.checkControlledByParentWrite` and asked with the - // EXPLAINED context. The update's answer is each of those verbs' answer - // (the check judges EDIT of the master whatever the record's verb). Its - // presence is what licenses the `masterGateCoversThisWrite` vouch - // above: the two go together. - checkControlledByParentWrite: (o: string, rid: string, c: any) => - this.checkControlledByParentWrite(o, rid, c), - }, - { object, operation, context: targetContext, recordId }, - // The asker: the caller, whose read of the record decides whether this - // explanation discloses that the record exists. On a self-explanation it - // is the explained context itself, and the engine asks it the write - // path's question; explaining another user, the engine asks `cannotRead`, - // the same read with a refusal counted as "cannot read" (ruling C). - { - context: callerContext, - cannotRead: (o: string, rid: string) => unreadableUnderCallerRead(() => this.readRowById(o, rid, callerContext)), - }, - ); + return explainAccessForCaller.call(host, request, callerContext); } /** @@ -5398,7 +5084,7 @@ export class SecurityPlugin implements Plugin { * resolution failure so the caller can fail closed — a dropped sharing * predicate is exactly the leak this fixes. */ - private async resolveSharingReadFilter( + private resolveSharingReadFilter( object: string, context: any, resolvedSets?: PermissionSet[], @@ -5411,41 +5097,16 @@ export class SecurityPlugin implements Plugin { */ depth?: string, ): Promise | null> { - const sharing = this.resolveKernelService?.('sharing') as - | { buildReadFilter?: (o: string, c: any) => Promise } - | undefined; - if (!sharing || typeof sharing.buildReadFilter !== 'function') return null; - // Mirror the middleware's ADR-0057 D1 depth stash. `getEffectiveScope` - // needs the resolved sets and the object's posture — the same two inputs - // the middleware feeds it — so the owner-match widens identically here. - // [#5386] `resolvedSets` lets an in-middleware caller (the - // controlled_by_parent derivation) pass the sets it already resolved for - // THIS identity, instead of re-resolving them from the context. - let readScope: string | undefined = depth; - if (readScope === undefined) { - try { - const permissionSets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context)); - if (permissionSets.length > 0) { - const meta = await this.getObjectSecurityMeta(object); - readScope = this.permissionEvaluator.getEffectiveScope( - 'read', - object, - permissionSets, - { isPrivate: meta.isPrivate }, - ); - } - } catch { - // Depth is a WIDENING input: failing to resolve it leaves the owner-match - // at its narrowest ('own'), which is the safe direction. The sharing call - // below still runs — and its own failure still denies. - readScope = undefined; - } - } - const filter = await sharing.buildReadFilter(object, { - ...context, - ...(readScope ? { __readScope: readScope } : {}), - }); - return (filter ?? null) as Record | null; + // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). + const readPermissionEvaluator = () => this.permissionEvaluator; + const readResolveKernelService = () => this.resolveKernelService; + const host: Pick = { + getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), + get permissionEvaluator() { return readPermissionEvaluator(); }, + get resolveKernelService() { return readResolveKernelService(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + }; + return resolveSharingReadFilter.call(host, object, context, resolvedSets, depth); } /** @@ -5465,31 +5126,21 @@ export class SecurityPlugin implements Plugin { * `canEdit` answers all of those itself. FAILS CLOSED (`false`) when the probe * throws: a dropped record-share gate is the leak, not the denial. */ - private async resolveSharingCanEdit( + private resolveSharingCanEdit( object: string, recordId: string, context: any, resolvedSets?: PermissionSet[], ): Promise { - const sharing = this.resolveKernelService?.('sharing') as - | { canEdit?: (o: string, id: string, c: any) => Promise } - | undefined; - if (!sharing || typeof sharing.canEdit !== 'function') return true; - const writeScope = await this.resolveWriteScopeForSharing(object, context, resolvedSets); - try { - return ( - (await sharing.canEdit(object, recordId, { ...context, __writeScope: writeScope })) === true - ); - } catch (e) { - this.logger.error?.( - `[security] controlled_by_parent write gate could not resolve the sharing (OWD) edit ` + - `check for '${object}' record '${recordId}' (user ${context?.userId ?? 'unknown'}) — ` + - `denying (fail-closed: a child is writable only where its master is, so a master check that ` + - `cannot be resolved refuses)`, - e instanceof Error ? e : new Error(String(e)), - ); - return false; - } + // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). + const readLogger = () => this.logger; + const readResolveKernelService = () => this.resolveKernelService; + const host: Pick = { + get logger() { return readLogger(); }, + get resolveKernelService() { return readResolveKernelService(); }, + resolveWriteScopeForSharing: (...a) => this.resolveWriteScopeForSharing(...a), + }; + return resolveSharingCanEdit.call(host, object, recordId, context, resolvedSets); } /** @@ -5510,24 +5161,19 @@ export class SecurityPlugin implements Plugin { * {@link resolveSharingWriteVerdict} cannot drift on it: they are two forms of * one question and must feed the service the same depth. */ - private async resolveWriteScopeForSharing( + private resolveWriteScopeForSharing( object: string, context: any, resolvedSets?: PermissionSet[], ): Promise { - try { - const permissionSets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context)); - if (permissionSets.length === 0) return undefined; - const meta = await this.getObjectSecurityMeta(object); - return this.permissionEvaluator.getEffectiveScope( - 'write', - object, - permissionSets, - { isPrivate: meta.isPrivate }, - ); - } catch { - return undefined; - } + // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). + const readPermissionEvaluator = () => this.permissionEvaluator; + const host: Pick = { + getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), + get permissionEvaluator() { return readPermissionEvaluator(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + }; + return resolveWriteScopeForSharing.call(host, object, context, resolvedSets); } /** @@ -5560,36 +5206,22 @@ export class SecurityPlugin implements Plugin { * on an unresolvable lookup (#6428); this covers the call failing outright. * Both leave the floor standing, which is the non-widening direction. */ - private async resolveSharingWriteVerdict( + private resolveSharingWriteVerdict( rlsOperation: string, object: string, recordId: string, context: any, resolvedSets?: PermissionSet[], ): Promise { - const method = rlsOperation === 'delete' ? 'checkDelete' : 'checkEdit'; - const sharing = this.resolveKernelService?.('sharing') as - | Record Promise) | undefined> - | undefined; - const probe = sharing?.[method]; - if (typeof probe !== 'function') return 'abstain'; - const writeScope = await this.resolveWriteScopeForSharing(object, context, resolvedSets); - try { - const verdict = await probe.call(sharing, object, recordId, { - ...context, - __writeScope: writeScope, - }); - return verdict === 'allow' || verdict === 'deny' ? verdict : 'abstain'; - } catch (e) { - this.logger.error?.( - `[security] the row-level write gate could not resolve the sharing (${method}) verdict ` + - `for '${object}' record '${recordId}' (user ${context?.userId ?? 'unknown'}) — keeping ` + - `the platform ownership floor (fail-closed: only a resolved sharing allow, from Modify All Data ` + - `or an edit-level share, may replace that floor)`, - e instanceof Error ? e : new Error(String(e)), - ); - return 'deny'; - } + // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). + const readLogger = () => this.logger; + const readResolveKernelService = () => this.resolveKernelService; + const host: Pick = { + get logger() { return readLogger(); }, + get resolveKernelService() { return readResolveKernelService(); }, + resolveWriteScopeForSharing: (...a) => this.resolveWriteScopeForSharing(...a), + }; + return resolveSharingWriteVerdict.call(host, rlsOperation, object, recordId, context, resolvedSets); } /** @@ -5618,7 +5250,7 @@ export class SecurityPlugin implements Plugin { * floor-standing filter it also ANDs the delegator's own composition, * asked here AS the delegator ({@link delegatorOwnWriteFloorOptions}). */ - private async resolvePreImageFloorDrop( + private resolvePreImageFloorDrop( rlsOperation: string, object: string, recordId: string, @@ -5626,19 +5258,12 @@ export class SecurityPlugin implements Plugin { permissionSets: PermissionSet[], delegated: boolean, ): Promise { - if (delegated) return false; - const floorApplies = this.collectRLSPolicies( - permissionSets, - object, - rlsOperation, - (context?.positions ?? []) as string[], - context, - ).some(isPlatformOwnershipFloorPolicy); - if (!floorApplies) return false; - return ( - (await this.resolveSharingWriteVerdict(rlsOperation, object, recordId, context, permissionSets)) === - 'allow' - ); + // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). + const host: Pick = { + collectRLSPolicies: (...a) => this.collectRLSPolicies(...a), + resolveSharingWriteVerdict: (...a) => this.resolveSharingWriteVerdict(...a), + }; + return resolvePreImageFloorDrop.call(host, rlsOperation, object, recordId, context, permissionSets, delegated); } /** @@ -5664,7 +5289,7 @@ export class SecurityPlugin implements Plugin { * composition makes, passed rather than assumed, because coverage is a fact * about the call site ({@link RlsFilterOptions.masterGateCoversThisWrite}). */ - private async delegatorOwnWriteFloorOptions( + private delegatorOwnWriteFloorOptions( rlsOperation: string, object: string, recordId: string, @@ -5672,15 +5297,11 @@ export class SecurityPlugin implements Plugin { delegatorSets: PermissionSet[], masterGateCoversThisWrite: boolean, ): Promise { - const dropPlatformOwnershipFloor = await this.resolvePreImageFloorDrop( - rlsOperation, - object, - recordId, - delegatorContext, - delegatorSets, - false, - ); - return { dropPlatformOwnershipFloor, masterGateCoversThisWrite }; + // [#25223 cut 10] The body moved to `security-plugin-sharing-verdicts.ts` (see `SharingVerdictsHost`). + const host: Pick = { + resolvePreImageFloorDrop: (...a) => this.resolvePreImageFloorDrop(...a), + }; + return delegatorOwnWriteFloorOptions.call(host, rlsOperation, object, recordId, delegatorContext, delegatorSets, masterGateCoversThisWrite); } /** @@ -5751,90 +5372,23 @@ export class SecurityPlugin implements Plugin { * correct; reviving the by-id widener on `private` end-to-end is a separate * contract question about the pre-image gate's own read scope. */ - async checkAuthoredRowWrite( + checkAuthoredRowWrite( object: string, recordId: string, operation: AuthoredRowWriteOperation, context?: any, ): Promise { - try { - if (!object || recordId == null || recordId === '') return 'abstain'; - if (operation !== 'update' && operation !== 'delete') return 'abstain'; - if (!this.ql) return 'abstain'; - // No principal, or a delegated identity this path cannot intersect — - // both are "cannot measure", which is `abstain` (never `admit`). - if (!context?.userId) return 'abstain'; - if (context?.onBehalfOf?.userId) return 'abstain'; - - const permissionSets = await this.resolvePermissionSetsForContext(context); - if (permissionSets.length === 0) return 'abstain'; - - // Cheap provenance pre-check: if the caller holds NO app-authored policy - // applicable to (object, operation), there is nothing that could admit by - // declaration — answer without spending a database round-trip. This is - // the same collection the compiler consumes, filtered by the same - // provenance predicate, so the two cannot disagree about what "authored" - // means. - // - // [#8059] The pre-check asks for an authored policy that ADMITS ROWS — - // one carrying a `using` scope predicate. A `check`-only policy declares - // no row scope at all (ADR-0058 D4: post-image validation), so it can - // admit nothing, and the verdict for a caller holding only check-only - // policies is `abstain` — which is the verdict this method already - // returned before #8059 and must keep returning after it. It got there - // by a route the fix removes: a check-only policy compiles to no `using` - // filter, so Layer 1 was null and the `layer1 == null → abstain` rule - // below fired. Site 1 now DERIVES a Layer 1 from the caller's select - // narrowing in exactly that case, so without this clause the derived - // readable-set scope would start answering `admit` — and `admit` is what - // the by-id widener uses to WIDEN. That is precisely the masquerade the - // doc comment above promises cannot happen (#5493 / #7281), so the - // promise is now kept by an explicit test rather than by the accident of - // a null Layer 1. Nothing narrows here: `abstain` changes nothing on its - // own, and the pre-image gate still refuses on its own terms. - const authored = this.collectRLSPolicies( - permissionSets, - object, - operation, - (context?.positions ?? []) as string[], - context, - ).filter((p) => !isPlatformOwnershipFloorPolicy(p) && policyDeclaresClause(p, 'using')); - if (authored.length === 0) return 'abstain'; - - const { layer0, layer1 } = await this.computeLayeredRlsFilter( - permissionSets, - object, - operation, - context, - { dropPlatformOwnershipFloor: true }, - ); - // See the doc above: a null Layer 1 means no authored predicate is - // gating this write, which is an abstention and not an admission. - if (layer1 == null) return 'abstain'; - - const parts = [{ id: recordId }, ...(layer0 ? [layer0] : []), layer1]; - // [#7281] The predicate is the question; the scope is not. Read ELEVATED - // (see AUTHORED_ROW_WRITE_PROBE_CONTEXT) so the answer is decided by - // `{id} AND layer0 AND layer1` alone, and projected to `id` so the probe - // can only ever learn EXISTENCE — no column of a row the caller may not - // read is materialised, let alone returned. The verdict remains evidence, - // never authorization: the by-id write pre-image gate still resolves the - // write under the caller's own context. - const row = await this.ql.findOne(object, { - where: { $and: parts }, - fields: ['id'], - context: { ...AUTHORED_ROW_WRITE_PROBE_CONTEXT }, - }); - return row ? 'admit' : 'abstain'; - } catch (e) { - this.logger.warn?.( - `[security] checkAuthoredRowWrite could not resolve an authored-policy verdict for ` + - `'${object}' record '${recordId}' (${operation}, user ${context?.userId ?? 'unknown'}) — ` + - `abstaining (fail-closed: a verdict that cannot be resolved never lifts the sharing refusal)`, - e instanceof Error ? e : new Error(String(e)), - ); - return 'abstain'; - } + // [#25223 cut 10] The body moved to `security-plugin-authored-row-write.ts` (see `AuthoredRowWriteHost`). + const readLogger = () => this.logger; + const readQl = () => this.ql; + const host: Pick = { + collectRLSPolicies: (...a) => this.collectRLSPolicies(...a), + computeLayeredRlsFilter: (...a) => this.computeLayeredRlsFilter(...a), + get logger() { return readLogger(); }, + get ql() { return readQl(); }, + resolvePermissionSetsForContext: (...a) => this.resolvePermissionSetsForContext(...a), + }; + return checkAuthoredRowWrite.call(host, object, recordId, operation, context); } /**