From 23b0616bae8c661ca7dfcf8b6f85630ea04b509a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 10:55:47 +0000 Subject: [PATCH] refactor(plugin-security): move the permission-set resolution and addressed-write readers out of security-plugin.ts The bodies of resolvePermissionSetsForContextUnmemoized (the resolution itself: the position fold, the human baseline or the agent ceiling, the fallback), dropDeactivatedFoldedSets, reportNameFoldCollisions and permissionSetMemoKey move unedited to security-plugin-permission-sets.ts. The bodies of addressedByIdWriteId, addressedPredicateWrite, readableRowsScopeForPredicateWrite, extractSingleId and readRowById move unedited to security-plugin-addressed-writes.ts. Each takes the import names 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; a body that reads no member is called directly. The memo, the position-grant description and five thin members stay whole. readUnlessRefused and POSITION_NAME_FOLD_EVENT stay and are exported and imported by value. Move-only: no verdict changes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa --- .../src/security-plugin-addressed-writes.ts | 216 ++++++++++++ .../src/security-plugin-permission-sets.ts | 281 ++++++++++++++++ .../plugin-security/src/security-plugin.ts | 308 +++++------------- 3 files changed, 578 insertions(+), 227 deletions(-) create mode 100644 packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts create mode 100644 packages/plugins/plugin-security/src/security-plugin-permission-sets.ts diff --git a/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts b/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts new file mode 100644 index 00000000000..d5df0e5ad7b --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 8] The addressed-write readers of `SecurityPlugin`, moved out of + * `security-plugin.ts`: + * + * - `addressedByIdWriteId` and `addressedPredicateWrite`, which say whether an + * UPDATE or DELETE is the by-id or the predicate write the caller addressed, + * read from the engine's own dispatch predicates (#21771, #21829). + * - `readableRowsScopeForPredicateWrite`, the read door's answer for the + * caller's own predicate, as the id scope a predicate write's matched set is + * narrowed to (#21829). + * - `extractSingleId`, the single scalar id an update or delete names. + * - `readRowById`, the by-id row read every provenance and pre-image gate + * shares, whose `null` means absent and only absent (#7505). + * + * 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 exclusions, the same engine dispatch + * verdicts, the same read under the caller's context with the same cap and + * the same refusal above it, the same fail-closed errors when no engine is + * wired, and a store fault still propagates as raised. + * + * ## How the moved bodies reach the class + * + * The shape cuts 1 to 7 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps all five private methods — the + * published `dist/*.d.ts` lists each one's name, docblock and position. Three + * of the bodies read no member (`addressedByIdWriteId`, + * `addressedPredicateWrite`, `extractSingleId`), so their methods call their + * functions directly, as cut 1's `extractTargetField` does. The other two read + * `ql` alone, and each method's body is a literal of that one + * {@link AddressedWritesHost} member plus a `.call`. Every caller still reaches + * these methods through the plugin, so a test double or spy set on the plugin + * is still the one the gates reach. Two of the methods were async and their + * functions still are; each of those methods now returns its function's + * promise. + * + * `ql` is not declared `readonly` (`start()` assigns it after construction), + * so it goes as a GETTER, by the rule cut 1 set, and each read happens when the + * body makes it. + * + * ## What stays in `security-plugin.ts`, and how it is read + * + * Three members of the same family stay in the class whole, because each body + * is no longer than its stub would be: `declaresControlledByParent`, + * `callerCannotReadAddressedRow` and `getCallerPreImage`. `extractSingleId` + * has no docblock of its own: its docblock is the first of the two that sit + * above `declaresControlledByParent`'s, and it stays there. + * + * Four module-level values the predicate write's read question reads stay + * there too, and are imported here by value. `readUnlessRefused`, the + * three-way classification of a caller-context read, is exported for this + * module: `absentUnderCallerRead` and `unreadableUnderCallerRead` there ask it + * too. `PREDICATE_WRITE_READABLE_ID_CAP`, `predicateWriteReadableCapError` and + * `copyOfCallerPredicate` were exported by cut 3, for the predicate-write + * master set in `security-plugin-cbp-write-gate.ts`, which imports them from + * there. Each is read only inside a function body, at call time, never while + * either module evaluates, so the two-way import is safe under ESM, and the + * published bundle holds one module. + * + * The imports only these bodies read moved here: the engine's delete dispatch + * predicate and its input type from `@objectstack/metadata-core`, and the + * operation-private key strip from `@objectstack/core`. The update dispatch + * predicate and its data type are imported from `@objectstack/metadata-core`, + * as `security-plugin.ts` imports them. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import type { SecurityPlugin } from './security-plugin.js'; +import { + copyOfCallerPredicate, + PREDICATE_WRITE_READABLE_ID_CAP, + predicateWriteReadableCapError, + readUnlessRefused, +} from './security-plugin.js'; +import { withoutOperationPrivateKeys } from '@objectstack/core'; +import { + resolveEngineDeleteDispatch, + resolveEngineUpdateDispatch, + type EngineDeleteDispatchInput, + type EngineUpdateDispatchData, +} from '@objectstack/metadata-core'; + +/** + * The `SecurityPlugin` member the functions below read — the one, and no + * other; each function's `this` is the `Pick` of the members IT reads. It is + * typed AS the class member (an indexed access on the class), so a change to + * its signature on the class is a change here too. See the module header for + * why it is a getter. + */ +export interface AddressedWritesHost { + readonly ql: SecurityPlugin['ql']; +} + +/** The body of `SecurityPlugin#addressedByIdWriteId`; the method's docblock there is its contract. */ +export function addressedByIdWriteId( + opCtx: any, + nested: boolean, + referentialFieldClear: boolean, +): unknown { + if (nested || referentialFieldClear) return undefined; + if (opCtx.operation === 'update') { + const data = opCtx.data; + if (data == null || typeof data !== 'object') return undefined; + const route = resolveEngineUpdateDispatch(data as EngineUpdateDispatchData, opCtx.options); + return route.kind === 'by-id' ? route.id : undefined; + } + if (opCtx.operation === 'delete') { + const route = resolveEngineDeleteDispatch(opCtx.options as EngineDeleteDispatchInput | undefined); + return route.kind === 'by-id' ? route.id : undefined; + } + return undefined; +} + +/** The body of `SecurityPlugin#addressedPredicateWrite`; the method's docblock there is its contract. */ +export function addressedPredicateWrite( + opCtx: any, + nested: boolean, + referentialFieldClear: boolean, +): boolean { + if (nested || referentialFieldClear) return false; + if (opCtx.operation === 'update') { + const data = opCtx.data; + if (data == null || typeof data !== 'object') return false; + return resolveEngineUpdateDispatch(data as EngineUpdateDispatchData, opCtx.options).kind === 'multi'; + } + if (opCtx.operation === 'delete') { + return resolveEngineDeleteDispatch(opCtx.options as EngineDeleteDispatchInput | undefined).kind === 'multi'; + } + return false; +} + +/** The body of `SecurityPlugin#readableRowsScopeForPredicateWrite`; the method's docblock there is its contract. */ +export async function readableRowsScopeForPredicateWrite( + this: Pick, + opCtx: any, +): Promise | null> { + if (typeof this.ql?.find !== 'function') { + // The read question cannot be asked, and a write that cannot be narrowed + // must not run un-narrowed. Unreachable in a real deployment — `start()` + // registers no security middleware at all without a query engine — but a + // non-conforming engine double fails closed rather than writing wide. + throw new Error( + `[Security] Cannot narrow the predicate ${opCtx.operation} on '${opCtx.object}' to the rows the ` + + `caller can read: no query engine is available to ask.`, + ); + } + const callerWhere = (opCtx.options as { where?: unknown } | undefined)?.where; + const answer = await readUnlessRefused(() => + this.ql.find(opCtx.object, { + ...(callerWhere != null ? { where: copyOfCallerPredicate(callerWhere) } : {}), + fields: ['id'], + limit: PREDICATE_WRITE_READABLE_ID_CAP + 1, + // [#7145] The caller's envelope minus the operation-private keys: the + // write's own stamps (`__writeScope`, …) are not inputs to a read, and + // a copy keeps the read's stamps off the write's context. + context: withoutOperationPrivateKeys((opCtx.context ?? {}) as Record), + }), + ); + if (answer.refused) return null; + const rows: unknown[] = Array.isArray(answer.value) ? answer.value : []; + if (rows.length > PREDICATE_WRITE_READABLE_ID_CAP) { + throw predicateWriteReadableCapError(opCtx.object, opCtx.operation); + } + const ids = rows + .map((r) => (r && typeof r === 'object' ? (r as { id?: unknown }).id : undefined)) + .filter((id) => id !== undefined && id !== null); + return { id: { $in: ids } }; +} + +/** + * The body of `SecurityPlugin#extractSingleId`. Its contract is the docblock + * there that sits above `declaresControlledByParent`'s. + */ +export function extractSingleId( + opCtx: any, +): string | number | bigint | null { + const isScalar = (v: unknown): v is string | number | bigint => + v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'bigint'); + const data = opCtx?.data; + if (data && typeof data === 'object' && !Array.isArray(data) && isScalar(data.id)) { + return data.id; + } + const where = opCtx?.options?.where; + if (where && typeof where === 'object' && 'id' in where && isScalar((where as any).id)) { + return (where as any).id; + } + return null; +} + +/** The body of `SecurityPlugin#readRowById`; the method's docblock there is its contract. */ +export async function readRowById( + this: Pick, + object: string, + id: unknown, + context: any, +): Promise | null> { + // Not a read at all: there is no id to look one up by. Every call site + // already screens this; the guard is defence, and `null` is honest here + // because nothing was ever asked of the store. + if (id == null) return null; + if (typeof this.ql?.findOne !== 'function') { + // The third collapsed fact, and it is not absence either: the probe + // could not run. Unreachable in a real deployment — `start()` registers + // no security middleware at all without a query engine, so no gate can + // reach this line — but a non-conforming engine double must fail closed + // rather than manufacture a 404 for a row nobody looked for. + throw new Error( + `[Security] Cannot verify record '${String(id)}' on '${object}': no query engine is available to read it.`, + ); + } + const row = await this.ql.findOne(object, { where: { id }, context }); + return row && typeof row === 'object' ? (row as Record) : null; +} diff --git a/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts b/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts new file mode 100644 index 00000000000..247f3a7d74c --- /dev/null +++ b/packages/plugins/plugin-security/src/security-plugin-permission-sets.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25223 cut 8] The permission-set resolution of `SecurityPlugin` for an + * execution context, moved out of `security-plugin.ts`: + * + * - `resolvePermissionSetsForContextUnmemoized`, the resolution itself: the + * positions folded into the request (never the guest's), the additive human + * baseline or the agent's restricted ceiling, and the post-resolution + * fallback for an authenticated caller whose positions resolved nothing. + * - `dropDeactivatedFoldedSets`, the activation ledger's say over a set that + * reached the resolution only through a position of the same name. + * - `reportNameFoldCollisions`, the once-per-name warning for a grant that + * exists only because a position and a permission set share a name + * (#13419, #24757). + * - `permissionSetMemoKey`, the fields of the context the per-context memo is + * keyed on (#10757). + * + * 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 request, in the same order, the same + * baseline and fallback rules, the same ledger read, the same warning text and + * structured payload, once per name, and the same memo key. + * + * ## How the moved bodies reach the class + * + * The shape cuts 1 to 7 used (`security-plugin-object-inputs.ts`, whose header + * carries the reasoning). The class keeps all four 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 PermissionSetsHost} members + * that method reads, plus a `.call`. `permissionSetMemoKey` reads no member, + * so its method calls its function directly, as cut 1's `extractTargetField` + * does. 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 resolution still asks the plugin's catalog loader, its + * folded-set drop and its fold report, and the memo, the position catalog's + * description and every other caller still reach these methods through the + * plugin. Two of the methods were async and their functions still are; each + * of those methods now returns its function's promise. + * + * Each field was classified on its own declaration, by the rule cut 1 set. + * `baselinePermissionSets`, `bootstrapPermissionSets` and `nameFoldWarned` are + * declared `readonly` and go by value, so the bodies read the plugin's own + * lists and its own warned set. `logger`, `metadata`, `permissionEvaluator` + * and `ql` are not declared `readonly` (`start()` assigns three of them after + * construction, and tests replace `permissionEvaluator` on a constructed + * plugin), so they go as GETTERS, and each read happens when the body makes + * it. + * + * ## What stays in `security-plugin.ts`, and how it is read + * + * Four members of the same family stay in the class whole. + * + * - `getEffectiveObjectPermissions` and `resolveFallbackPermissionSets`: each + * body is no longer than its stub would be. + * - `resolvePermissionSetsForContext`, the memo: its body retires a rejected + * entry with `permissionSetMemo.delete(…)`. The tenant-audit census places + * that call by its receiver's declared type, a `WeakMap` the class + * constructs. Read through a host, the receiver has no type the census can + * read, and the census asks for a row in its untyped-receiver ledger, which + * is shrink-only. + * - `describePositionGrants`: its body asks the read admission + * `canReadObject`, and `row-serving-door-exposure.pin.test.ts` in + * `@objectstack/core` classifies every non-test source file that calls it, + * so moving that body would make this module a caller the pin has no + * classification for. + * + * Two module-level values these bodies read stay there too, and are exported + * and imported here by value: `isGuestEnvelope`, which the middleware's + * owner-write step reads as well (cut 6 exported it), and + * `POSITION_NAME_FOLD_EVENT`, which the fold report's docblock there names in + * place, with a `{@link}`. Each is read only inside a function body, at call + * time, never while either module evaluates, so the two-way import is safe + * under ESM, and the published bundle holds one module. The event name is + * still not exported from the package: `index.ts` exports only + * `SecurityPlugin`. + * + * The imports only these bodies read moved here: the agent's restricted set + * from `@objectstack/spec/ai`, and the activation ledger's reader from + * `@objectstack/core`. `PermissionSet` is imported from + * `@objectstack/spec/security`, as `security-plugin.ts` imports it. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import type { SecurityPlugin } from './security-plugin.js'; +import { isGuestEnvelope, POSITION_NAME_FOLD_EVENT } from './security-plugin.js'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; +import { readDisabledCatalogNames } from '@objectstack/core'; + +/** + * 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 PermissionSetsHost { + readonly baselinePermissionSets: SecurityPlugin['baselinePermissionSets']; + readonly bootstrapPermissionSets: SecurityPlugin['bootstrapPermissionSets']; + readonly logger: SecurityPlugin['logger']; + readonly metadata: SecurityPlugin['metadata']; + readonly nameFoldWarned: SecurityPlugin['nameFoldWarned']; + readonly permissionEvaluator: SecurityPlugin['permissionEvaluator']; + readonly ql: SecurityPlugin['ql']; + dbLoaderForContext: SecurityPlugin['dbLoaderForContext']; + dropDeactivatedFoldedSets: SecurityPlugin['dropDeactivatedFoldedSets']; + reportNameFoldCollisions: SecurityPlugin['reportNameFoldCollisions']; +} + +/** The body of `SecurityPlugin#permissionSetMemoKey`; the method's docblock there is its contract. */ +export function permissionSetMemoKey( + context: any, +): string { + const positions = Array.isArray(context?.positions) ? context.positions : []; + const permissions = Array.isArray(context?.permissions) ? context.permissions : []; + return JSON.stringify([ + positions, + permissions, + context?.principalKind ?? null, + context?.userId ? 1 : 0, + ]); +} + +/** The body of `SecurityPlugin#resolvePermissionSetsForContextUnmemoized`; the method's docblock there is its contract. */ +export async function resolvePermissionSetsForContextUnmemoized( + this: Pick, + context: any, +): Promise { + // [ADR-0138 D3] The GUEST requests exactly the sets its envelope carries — + // the `guest` anchor's bindings, which `resolveAuthzContext` resolved in + // the organization D4 derived — and they are treated as any requested set + // below. Its position is NOT folded into the request: the position-name + // fold (#13419's ruling retires it) would hand an anonymous caller any set + // that merely shares the anchor's name, the one channel D3 item 3 says + // never reaches the guest. The baseline does not reach it either (applied + // only to a caller with a user id, below), and `everyone` is never among + // its positions. + const isGuest = isGuestEnvelope(context); + const positions = isGuest ? [] : (context?.positions ?? []); + const explicitPermissionSets = context?.permissions ?? []; + const requested = [...positions, ...explicitPermissionSets]; + // [ADR-0090 D10] An AGENT principal's grants are EXACTLY its scope-derived + // ceiling set(s) — the additive human baseline (member_default) must NOT + // apply, or its write bit would silently widen a read-only agent past the + // scope the user consented to. The agent's floor is instead the restricted + // (no-object-access) set, so an agent whose sets fail to resolve fails + // CLOSED (every object op denied) rather than falling open. The delegating + // user's own baseline still applies on the OTHER side of the intersection + // (resolved from `onBehalfOf` via a context without this flag). + const isAgent = context?.principalKind === 'agent'; + // [#7555] The human side is a LIST, and the agent side deliberately is not: + // the agent's ceiling is EXACTLY the restricted set, and composing the + // platform baseline into it is precisely the widening D10 forbids. The two + // branches produce arrays only so the code below reads once. + const baseline: string[] = isAgent + ? [MCP_AGENT_PERMISSION_SET_RESTRICTED] + : this.baselinePermissionSets; + // [ADR-0090 D5] Baseline is ADDITIVE, always (for humans): the baseline + // set(s) apply to every authenticated request IN ADDITION to whatever else + // resolved. The former "only when the user has nothing else" conditional + // was the fallback CLIFF — receiving your first explicit grant silently + // cost you the entire baseline. Agents skip this additive step (their + // ceiling is closed, not floored) — see above. + // [#7555] The same cliff had a second spelling the list closes: an app + // declaring an `isDefault` set REPLACED `member_default` here, so every + // member of that app lost the platform floor (and with it every built-in + // Account destination) the moment the app declared a posture of its own. + // [#13419] Recorded, not merely pushed: a name granted by the BASELINE is in + // force whether or not the position-name fold exists, so it is not evidence + // of the fold and must not be warned about below. + const baselineApplied: string[] = []; + if (!isAgent && context?.userId) { + for (const name of baseline) { + if (!requested.includes(name)) requested.push(name); + baselineApplied.push(name); + } + } + let permissionSets = await this.permissionEvaluator.resolvePermissionSets( + requested, + this.metadata, + this.bootstrapPermissionSets, + this.dbLoaderForContext(context), + { logger: this.logger }, + ); + permissionSets = await this.dropDeactivatedFoldedSets(positions, explicitPermissionSets, baselineApplied, permissionSets); + // [#13419 执行要点 3] Report — never alter — a grant that exists ONLY because + // this method folded a POSITION name into the permission-set request above. + this.reportNameFoldCollisions(positions, explicitPermissionSets, baselineApplied, permissionSets); + // Post-resolution fallback — closes the fail-open hole where a populated + // `positions` array maps to no permission set yet (no sys_position binding), + // which would otherwise skip RLS entirely and expose every tenant's data. + // For an agent, the fallback is the restricted set (deny all objects), NOT + // the human baseline — a mis-resolved agent must never inherit member access. + if ( + permissionSets.length === 0 && + context?.userId && + baseline.length > 0 + ) { + permissionSets = await this.permissionEvaluator.resolvePermissionSets( + baseline, + this.metadata, + this.bootstrapPermissionSets, + this.dbLoaderForContext(context), + { logger: this.logger }, + ); + } + return permissionSets; +} + +/** The body of `SecurityPlugin#dropDeactivatedFoldedSets`; the method's docblock there is its contract. */ +export async function dropDeactivatedFoldedSets( + this: Pick, + positions: unknown, + explicitPermissionSets: unknown, + baselineApplied: string[], + resolved: PermissionSet[], +): Promise { + if (!Array.isArray(positions) || positions.length === 0 || resolved.length === 0) return resolved; + const judged = new Set(baselineApplied); + for (const n of Array.isArray(explicitPermissionSets) ? explicitPermissionSets : []) { + if (typeof n === 'string') judged.add(n); + } + const resolvedNames = new Set(resolved.map((ps) => (ps as any)?.name).filter((n: unknown): n is string => typeof n === 'string')); + const folded = positions.filter((n): n is string => typeof n === 'string' && resolvedNames.has(n) && !judged.has(n)); + if (folded.length === 0) return resolved; + const disabled = await readDisabledCatalogNames(this.ql, [], [...new Set(folded)]); + if (disabled.permissions.size === 0) return resolved; + return resolved.filter((ps) => !disabled.permissions.has((ps as any)?.name)); +} + +/** The body of `SecurityPlugin#reportNameFoldCollisions`; the method's docblock there is its contract. */ +export function reportNameFoldCollisions( + this: Pick, + positions: unknown, + explicitPermissionSets: unknown, + baselineApplied: string[], + resolved: PermissionSet[], +): void { + if (!Array.isArray(positions) || positions.length === 0) return; + const governed = new Set( + (Array.isArray(explicitPermissionSets) ? explicitPermissionSets : []).filter( + (n): n is string => typeof n === 'string', + ), + ); + for (const name of baselineApplied) governed.add(name); + const resolvedNames = new Set( + resolved.map((ps) => (ps as any)?.name).filter((n: unknown): n is string => typeof n === 'string'), + ); + for (const position of positions) { + if (typeof position !== 'string' || position.length === 0) continue; + if (!resolvedNames.has(position)) continue; + if (governed.has(position)) continue; + if (this.nameFoldWarned.has(position)) continue; + this.nameFoldWarned.add(position); + // ⛔ No tracker id in the STRING — an operator reading this log has no tracker, no + // git log and no ADR to resolve `#NNNN` against (`check:doc-authoring`; maintainer + // ruling 2026-08-12). The card anchor lives in the docblock above, where the reader + // who can resolve it is already looking. + this.logger.warn?.( + `[security] ${POSITION_NAME_FOLD_EVENT}: permission set '${position}' is in force for ` + + `holders of position '${position}' only because the two share a name — the definition ` + + `of position '${position}' does not name it in its permissionSets. This name-based ` + + `grant is deprecated and is removed in the next major version: from then on, holders ` + + `of position '${position}' do not receive permission set '${position}' unless the ` + + `position's definition names it. To keep the grant, declare it (position ` + + `'${position}': permissionSets: ['${position}']); if the shared name is a ` + + `coincidence, rename one of the two.`, + { + event: POSITION_NAME_FOLD_EVENT, + position, + permissionSet: position, + // The consequence as data, for a consumer that keys on fields rather + // than prose. The three fields above are unchanged on purpose. + removedIn: 'next_major', + }, + ); + } +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 34bfe1c3890..a9f8e9414be 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -2,11 +2,10 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { declaredHttpStatus } from '@objectstack/types'; -import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, recordNotFoundError, withoutOperationPrivateKeys } from '@objectstack/core'; +import { Plugin, PluginContext, POSTURE_LADDER, buildEffectiveObjectPermissions, recordNotFoundError } from '@objectstack/core'; import type { EffectiveObjectPermission, PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; import { describeHighPrivilegeBits, PUBLIC_FORM_SERVER_MANAGED_FIELDS } from '@objectstack/spec/security'; import type { AnchorBindingContext } from '@objectstack/spec/security'; -import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; // [#8220] The read-scope provenance mark: this middleware is one of the two // merge boundaries that stamp it (see the RLS injection below). // [#21829] `MAX_BULK_PER_ROW_HOOK_ROWS`: the platform's row ceiling for one @@ -21,7 +20,7 @@ import type { NumberComparandDoorFieldMeta } from '@objectstack/spec/data'; import { renderOperationMessage } from '@objectstack/spec/system'; // [#19989] The engine's own update-dispatch predicate, asked rather than // re-derived: step 3.6 needs to know which row the ENGINE will write. -import { resolveEngineDeleteDispatch, resolveEngineUpdateDispatch, type EngineDeleteDispatchInput, type EngineUpdateDispatchData } from '@objectstack/metadata-core'; +import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from '@objectstack/metadata-core'; import { localWriteEpochSource, resolveWriteEpochSource, @@ -88,7 +87,7 @@ import { import { isVerifiedPlatformOwnerRow, } from './platform-owner-wall-bypass.js'; -import { isConfiguredPlatformAdminEmail, vetOrganizationClaim, createSecurityCatalogReader, bindSecurityCatalogReader, securityCatalogReaderOf, readDisabledCatalogNames, readPositionGrantsInForce, countPositionHolders } from '@objectstack/core'; +import { isConfiguredPlatformAdminEmail, vetOrganizationClaim, 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`). @@ -174,6 +173,30 @@ import { stageRlsMembership, type RlsFilterHost, } from './security-plugin-rls-filter.js'; +// [#25223 cut 8] The permission-set resolution for an execution context: the +// resolution itself, the deactivated folded-set drop, the position-name fold +// report and the memo's key; the methods of the same names below hand each +// body the members it reads (`PermissionSetsHost`). +import { + dropDeactivatedFoldedSets, + permissionSetMemoKey, + reportNameFoldCollisions, + resolvePermissionSetsForContextUnmemoized, + type PermissionSetsHost, +} from './security-plugin-permission-sets.js'; +// [#25223 cut 8] The addressed-write readers: the by-id and predicate write +// the caller addressed, the read question a predicate write is narrowed by, +// the single id of a write and the by-id row read the provenance and +// pre-image gates share; the methods of the same names below hand each body +// the member it reads (`AddressedWritesHost`). +import { + addressedByIdWriteId, + addressedPredicateWrite, + extractSingleId, + readableRowsScopeForPredicateWrite, + readRowById, + type AddressedWritesHost, +} from './security-plugin-addressed-writes.js'; import { isPlatformOwnershipFloorPolicy, masterGateCoversOperation, @@ -795,7 +818,7 @@ async function unreadableUnderCallerRead(read: () => Promise): Promise< * is neither an answer nor a refusal, and reporting it as either would * relabel it. */ -async function readUnlessRefused( +export async function readUnlessRefused( read: () => Promise, ): Promise<{ refused: true } | { refused: false; value: T }> { try { @@ -992,7 +1015,7 @@ interface SecurityReportSink { * snake_case per the precedent beside it (`platform_owner_wall_bypass`): this * is a data value, not an error code (ADR-0112 governs those). */ -const POSITION_NAME_FOLD_EVENT = 'position_name_fold_grant'; +export const POSITION_NAME_FOLD_EVENT = 'position_name_fold_grant'; const CONSOLE_SECURITY_SINK: SecurityReportSink = Object.freeze({ warn: (...a: any[]) => { console.warn(...a); }, @@ -6904,14 +6927,8 @@ export class SecurityPlugin implements Plugin { * and nothing else off the context. */ private permissionSetMemoKey(context: any): string { - const positions = Array.isArray(context?.positions) ? context.positions : []; - const permissions = Array.isArray(context?.permissions) ? context.permissions : []; - return JSON.stringify([ - positions, - permissions, - context?.principalKind ?? null, - context?.userId ? 1 : 0, - ]); + // [#25223 cut 8] The body moved to `security-plugin-permission-sets.ts` (see `PermissionSetsHost`). + return permissionSetMemoKey(context); } /** @@ -7000,88 +7017,24 @@ export class SecurityPlugin implements Plugin { } /** The actual resolution — see {@link resolvePermissionSetsForContext}. */ - private async resolvePermissionSetsForContextUnmemoized( + private resolvePermissionSetsForContextUnmemoized( context: any, ): Promise { - // [ADR-0138 D3] The GUEST requests exactly the sets its envelope carries — - // the `guest` anchor's bindings, which `resolveAuthzContext` resolved in - // the organization D4 derived — and they are treated as any requested set - // below. Its position is NOT folded into the request: the position-name - // fold (#13419's ruling retires it) would hand an anonymous caller any set - // that merely shares the anchor's name, the one channel D3 item 3 says - // never reaches the guest. The baseline does not reach it either (applied - // only to a caller with a user id, below), and `everyone` is never among - // its positions. - const isGuest = isGuestEnvelope(context); - const positions = isGuest ? [] : (context?.positions ?? []); - const explicitPermissionSets = context?.permissions ?? []; - const requested = [...positions, ...explicitPermissionSets]; - // [ADR-0090 D10] An AGENT principal's grants are EXACTLY its scope-derived - // ceiling set(s) — the additive human baseline (member_default) must NOT - // apply, or its write bit would silently widen a read-only agent past the - // scope the user consented to. The agent's floor is instead the restricted - // (no-object-access) set, so an agent whose sets fail to resolve fails - // CLOSED (every object op denied) rather than falling open. The delegating - // user's own baseline still applies on the OTHER side of the intersection - // (resolved from `onBehalfOf` via a context without this flag). - const isAgent = context?.principalKind === 'agent'; - // [#7555] The human side is a LIST, and the agent side deliberately is not: - // the agent's ceiling is EXACTLY the restricted set, and composing the - // platform baseline into it is precisely the widening D10 forbids. The two - // branches produce arrays only so the code below reads once. - const baseline: string[] = isAgent - ? [MCP_AGENT_PERMISSION_SET_RESTRICTED] - : this.baselinePermissionSets; - // [ADR-0090 D5] Baseline is ADDITIVE, always (for humans): the baseline - // set(s) apply to every authenticated request IN ADDITION to whatever else - // resolved. The former "only when the user has nothing else" conditional - // was the fallback CLIFF — receiving your first explicit grant silently - // cost you the entire baseline. Agents skip this additive step (their - // ceiling is closed, not floored) — see above. - // [#7555] The same cliff had a second spelling the list closes: an app - // declaring an `isDefault` set REPLACED `member_default` here, so every - // member of that app lost the platform floor (and with it every built-in - // Account destination) the moment the app declared a posture of its own. - // [#13419] Recorded, not merely pushed: a name granted by the BASELINE is in - // force whether or not the position-name fold exists, so it is not evidence - // of the fold and must not be warned about below. - const baselineApplied: string[] = []; - if (!isAgent && context?.userId) { - for (const name of baseline) { - if (!requested.includes(name)) requested.push(name); - baselineApplied.push(name); - } - } - let permissionSets = await this.permissionEvaluator.resolvePermissionSets( - requested, - this.metadata, - this.bootstrapPermissionSets, - this.dbLoaderForContext(context), - { logger: this.logger }, - ); - permissionSets = await this.dropDeactivatedFoldedSets(positions, explicitPermissionSets, baselineApplied, permissionSets); - // [#13419 执行要点 3] Report — never alter — a grant that exists ONLY because - // this method folded a POSITION name into the permission-set request above. - this.reportNameFoldCollisions(positions, explicitPermissionSets, baselineApplied, permissionSets); - // Post-resolution fallback — closes the fail-open hole where a populated - // `positions` array maps to no permission set yet (no sys_position binding), - // which would otherwise skip RLS entirely and expose every tenant's data. - // For an agent, the fallback is the restricted set (deny all objects), NOT - // the human baseline — a mis-resolved agent must never inherit member access. - if ( - permissionSets.length === 0 && - context?.userId && - baseline.length > 0 - ) { - permissionSets = await this.permissionEvaluator.resolvePermissionSets( - baseline, - this.metadata, - this.bootstrapPermissionSets, - this.dbLoaderForContext(context), - { logger: this.logger }, - ); - } - return permissionSets; + // [#25223 cut 8] The body moved to `security-plugin-permission-sets.ts` (see `PermissionSetsHost`). + const readLogger = () => this.logger; + const readMetadata = () => this.metadata; + const readPermissionEvaluator = () => this.permissionEvaluator; + const host: Pick = { + baselinePermissionSets: this.baselinePermissionSets, + bootstrapPermissionSets: this.bootstrapPermissionSets, + dbLoaderForContext: (...a) => this.dbLoaderForContext(...a), + dropDeactivatedFoldedSets: (...a) => this.dropDeactivatedFoldedSets(...a), + get logger() { return readLogger(); }, + get metadata() { return readMetadata(); }, + get permissionEvaluator() { return readPermissionEvaluator(); }, + reportNameFoldCollisions: (...a) => this.reportNameFoldCollisions(...a), + }; + return resolvePermissionSetsForContextUnmemoized.call(host, context); } /** @@ -7102,23 +7055,18 @@ export class SecurityPlugin implements Plugin { * with no such collision issues no read. A read that fails throws * (`AuthzStoreUnavailableError`), and the request fails closed. */ - private async dropDeactivatedFoldedSets( + private dropDeactivatedFoldedSets( positions: unknown, explicitPermissionSets: unknown, baselineApplied: string[], resolved: PermissionSet[], ): Promise { - if (!Array.isArray(positions) || positions.length === 0 || resolved.length === 0) return resolved; - const judged = new Set(baselineApplied); - for (const n of Array.isArray(explicitPermissionSets) ? explicitPermissionSets : []) { - if (typeof n === 'string') judged.add(n); - } - const resolvedNames = new Set(resolved.map((ps) => (ps as any)?.name).filter((n: unknown): n is string => typeof n === 'string')); - const folded = positions.filter((n): n is string => typeof n === 'string' && resolvedNames.has(n) && !judged.has(n)); - if (folded.length === 0) return resolved; - const disabled = await readDisabledCatalogNames(this.ql, [], [...new Set(folded)]); - if (disabled.permissions.size === 0) return resolved; - return resolved.filter((ps) => !disabled.permissions.has((ps as any)?.name)); + // [#25223 cut 8] The body moved to `security-plugin-permission-sets.ts` (see `PermissionSetsHost`). + const readQl = () => this.ql; + const host: Pick = { + get ql() { return readQl(); }, + }; + return dropDeactivatedFoldedSets.call(host, positions, explicitPermissionSets, baselineApplied, resolved); } /** @@ -7282,45 +7230,13 @@ export class SecurityPlugin implements Plugin { baselineApplied: string[], resolved: PermissionSet[], ): void { - if (!Array.isArray(positions) || positions.length === 0) return; - const governed = new Set( - (Array.isArray(explicitPermissionSets) ? explicitPermissionSets : []).filter( - (n): n is string => typeof n === 'string', - ), - ); - for (const name of baselineApplied) governed.add(name); - const resolvedNames = new Set( - resolved.map((ps) => (ps as any)?.name).filter((n: unknown): n is string => typeof n === 'string'), - ); - for (const position of positions) { - if (typeof position !== 'string' || position.length === 0) continue; - if (!resolvedNames.has(position)) continue; - if (governed.has(position)) continue; - if (this.nameFoldWarned.has(position)) continue; - this.nameFoldWarned.add(position); - // ⛔ No tracker id in the STRING — an operator reading this log has no tracker, no - // git log and no ADR to resolve `#NNNN` against (`check:doc-authoring`; maintainer - // ruling 2026-08-12). The card anchor lives in the docblock above, where the reader - // who can resolve it is already looking. - this.logger.warn?.( - `[security] ${POSITION_NAME_FOLD_EVENT}: permission set '${position}' is in force for ` + - `holders of position '${position}' only because the two share a name — the definition ` + - `of position '${position}' does not name it in its permissionSets. This name-based ` + - `grant is deprecated and is removed in the next major version: from then on, holders ` + - `of position '${position}' do not receive permission set '${position}' unless the ` + - `position's definition names it. To keep the grant, declare it (position ` + - `'${position}': permissionSets: ['${position}']); if the shared name is a ` + - `coincidence, rename one of the two.`, - { - event: POSITION_NAME_FOLD_EVENT, - position, - permissionSet: position, - // The consequence as data, for a consumer that keys on fields rather - // than prose. The three fields above are unchanged on purpose. - removedIn: 'next_major', - }, - ); - } + // [#25223 cut 8] The body moved to `security-plugin-permission-sets.ts` (see `PermissionSetsHost`). + const readLogger = () => this.logger; + const host: Pick = { + get logger() { return readLogger(); }, + nameFoldWarned: this.nameFoldWarned, + }; + reportNameFoldCollisions.call(host, positions, explicitPermissionSets, baselineApplied, resolved); } /** @@ -7435,18 +7351,8 @@ export class SecurityPlugin implements Plugin { * verbs keep the pre-image check exactly as it was. */ private addressedByIdWriteId(opCtx: any, nested: boolean, referentialFieldClear: boolean): unknown { - if (nested || referentialFieldClear) return undefined; - if (opCtx.operation === 'update') { - const data = opCtx.data; - if (data == null || typeof data !== 'object') return undefined; - const route = resolveEngineUpdateDispatch(data as EngineUpdateDispatchData, opCtx.options); - return route.kind === 'by-id' ? route.id : undefined; - } - if (opCtx.operation === 'delete') { - const route = resolveEngineDeleteDispatch(opCtx.options as EngineDeleteDispatchInput | undefined); - return route.kind === 'by-id' ? route.id : undefined; - } - return undefined; + // [#25223 cut 8] The body moved to `security-plugin-addressed-writes.ts` (see `AddressedWritesHost`). + return addressedByIdWriteId(opCtx, nested, referentialFieldClear); } /** @@ -7484,16 +7390,8 @@ export class SecurityPlugin implements Plugin { * the predicate path reads its matched rows from. */ private addressedPredicateWrite(opCtx: any, nested: boolean, referentialFieldClear: boolean): boolean { - if (nested || referentialFieldClear) return false; - if (opCtx.operation === 'update') { - const data = opCtx.data; - if (data == null || typeof data !== 'object') return false; - return resolveEngineUpdateDispatch(data as EngineUpdateDispatchData, opCtx.options).kind === 'multi'; - } - if (opCtx.operation === 'delete') { - return resolveEngineDeleteDispatch(opCtx.options as EngineDeleteDispatchInput | undefined).kind === 'multi'; - } - return false; + // [#25223 cut 8] The body moved to `security-plugin-addressed-writes.ts` (see `AddressedWritesHost`). + return addressedPredicateWrite(opCtx, nested, referentialFieldClear); } /** @@ -7521,52 +7419,18 @@ export class SecurityPlugin implements Plugin { * The id list is bounded by {@link PREDICATE_WRITE_READABLE_ID_CAP}; see * there for what happens above it. */ - private async readableRowsScopeForPredicateWrite(opCtx: any): Promise | null> { - if (typeof this.ql?.find !== 'function') { - // The read question cannot be asked, and a write that cannot be narrowed - // must not run un-narrowed. Unreachable in a real deployment — `start()` - // registers no security middleware at all without a query engine — but a - // non-conforming engine double fails closed rather than writing wide. - throw new Error( - `[Security] Cannot narrow the predicate ${opCtx.operation} on '${opCtx.object}' to the rows the ` + - `caller can read: no query engine is available to ask.`, - ); - } - const callerWhere = (opCtx.options as { where?: unknown } | undefined)?.where; - const answer = await readUnlessRefused(() => - this.ql.find(opCtx.object, { - ...(callerWhere != null ? { where: copyOfCallerPredicate(callerWhere) } : {}), - fields: ['id'], - limit: PREDICATE_WRITE_READABLE_ID_CAP + 1, - // [#7145] The caller's envelope minus the operation-private keys: the - // write's own stamps (`__writeScope`, …) are not inputs to a read, and - // a copy keeps the read's stamps off the write's context. - context: withoutOperationPrivateKeys((opCtx.context ?? {}) as Record), - }), - ); - if (answer.refused) return null; - const rows: unknown[] = Array.isArray(answer.value) ? answer.value : []; - if (rows.length > PREDICATE_WRITE_READABLE_ID_CAP) { - throw predicateWriteReadableCapError(opCtx.object, opCtx.operation); - } - const ids = rows - .map((r) => (r && typeof r === 'object' ? (r as { id?: unknown }).id : undefined)) - .filter((id) => id !== undefined && id !== null); - return { id: { $in: ids } }; + private readableRowsScopeForPredicateWrite(opCtx: any): Promise | null> { + // [#25223 cut 8] The body moved to `security-plugin-addressed-writes.ts` (see `AddressedWritesHost`). + const readQl = () => this.ql; + const host: Pick = { + get ql() { return readQl(); }, + }; + return readableRowsScopeForPredicateWrite.call(host, opCtx); } private extractSingleId(opCtx: any): string | number | bigint | null { - const isScalar = (v: unknown): v is string | number | bigint => - v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'bigint'); - const data = opCtx?.data; - if (data && typeof data === 'object' && !Array.isArray(data) && isScalar(data.id)) { - return data.id; - } - const where = opCtx?.options?.where; - if (where && typeof where === 'object' && 'id' in where && isScalar((where as any).id)) { - return (where as any).id; - } - return null; + // [#25223 cut 8] The body moved to `security-plugin-addressed-writes.ts` (see `AddressedWritesHost`). + return extractSingleId(opCtx); } /** @@ -7610,23 +7474,13 @@ export class SecurityPlugin implements Plugin { * the provenance / existence gates, the CALLER's context for the pre-image — * and that choice is the caller's, not this method's. */ - private async readRowById(object: string, id: unknown, context: any): Promise | null> { - // Not a read at all: there is no id to look one up by. Every call site - // already screens this; the guard is defence, and `null` is honest here - // because nothing was ever asked of the store. - if (id == null) return null; - if (typeof this.ql?.findOne !== 'function') { - // The third collapsed fact, and it is not absence either: the probe - // could not run. Unreachable in a real deployment — `start()` registers - // no security middleware at all without a query engine, so no gate can - // reach this line — but a non-conforming engine double must fail closed - // rather than manufacture a 404 for a row nobody looked for. - throw new Error( - `[Security] Cannot verify record '${String(id)}' on '${object}': no query engine is available to read it.`, - ); - } - const row = await this.ql.findOne(object, { where: { id }, context }); - return row && typeof row === 'object' ? (row as Record) : null; + private readRowById(object: string, id: unknown, context: any): Promise | null> { + // [#25223 cut 8] The body moved to `security-plugin-addressed-writes.ts` (see `AddressedWritesHost`). + const readQl = () => this.ql; + const host: Pick = { + get ql() { return readQl(); }, + }; + return readRowById.call(host, object, id, context); } /**