Skip to content

Commit 23b0616

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa
1 parent 2dfeb1a commit 23b0616

3 files changed

Lines changed: 578 additions & 227 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25223 cut 8] The addressed-write readers of `SecurityPlugin`, moved out of
5+
* `security-plugin.ts`:
6+
*
7+
* - `addressedByIdWriteId` and `addressedPredicateWrite`, which say whether an
8+
* UPDATE or DELETE is the by-id or the predicate write the caller addressed,
9+
* read from the engine's own dispatch predicates (#21771, #21829).
10+
* - `readableRowsScopeForPredicateWrite`, the read door's answer for the
11+
* caller's own predicate, as the id scope a predicate write's matched set is
12+
* narrowed to (#21829).
13+
* - `extractSingleId`, the single scalar id an update or delete names.
14+
* - `readRowById`, the by-id row read every provenance and pre-image gate
15+
* shares, whose `null` means absent and only absent (#7505).
16+
*
17+
* A MOVE, not a redesign. Each function below is the body of the
18+
* `SecurityPlugin` method of the same name as it stood, re-indented one level
19+
* and otherwise unedited: the same exclusions, the same engine dispatch
20+
* verdicts, the same read under the caller's context with the same cap and
21+
* the same refusal above it, the same fail-closed errors when no engine is
22+
* wired, and a store fault still propagates as raised.
23+
*
24+
* ## How the moved bodies reach the class
25+
*
26+
* The shape cuts 1 to 7 used (`security-plugin-object-inputs.ts`, whose header
27+
* carries the reasoning). The class keeps all five private methods — the
28+
* published `dist/*.d.ts` lists each one's name, docblock and position. Three
29+
* of the bodies read no member (`addressedByIdWriteId`,
30+
* `addressedPredicateWrite`, `extractSingleId`), so their methods call their
31+
* functions directly, as cut 1's `extractTargetField` does. The other two read
32+
* `ql` alone, and each method's body is a literal of that one
33+
* {@link AddressedWritesHost} member plus a `.call`. Every caller still reaches
34+
* these methods through the plugin, so a test double or spy set on the plugin
35+
* is still the one the gates reach. Two of the methods were async and their
36+
* functions still are; each of those methods now returns its function's
37+
* promise.
38+
*
39+
* `ql` is not declared `readonly` (`start()` assigns it after construction),
40+
* so it goes as a GETTER, by the rule cut 1 set, and each read happens when the
41+
* body makes it.
42+
*
43+
* ## What stays in `security-plugin.ts`, and how it is read
44+
*
45+
* Three members of the same family stay in the class whole, because each body
46+
* is no longer than its stub would be: `declaresControlledByParent`,
47+
* `callerCannotReadAddressedRow` and `getCallerPreImage`. `extractSingleId`
48+
* has no docblock of its own: its docblock is the first of the two that sit
49+
* above `declaresControlledByParent`'s, and it stays there.
50+
*
51+
* Four module-level values the predicate write's read question reads stay
52+
* there too, and are imported here by value. `readUnlessRefused`, the
53+
* three-way classification of a caller-context read, is exported for this
54+
* module: `absentUnderCallerRead` and `unreadableUnderCallerRead` there ask it
55+
* too. `PREDICATE_WRITE_READABLE_ID_CAP`, `predicateWriteReadableCapError` and
56+
* `copyOfCallerPredicate` were exported by cut 3, for the predicate-write
57+
* master set in `security-plugin-cbp-write-gate.ts`, which imports them from
58+
* there. Each is read only inside a function body, at call time, never while
59+
* either module evaluates, so the two-way import is safe under ESM, and the
60+
* published bundle holds one module.
61+
*
62+
* The imports only these bodies read moved here: the engine's delete dispatch
63+
* predicate and its input type from `@objectstack/metadata-core`, and the
64+
* operation-private key strip from `@objectstack/core`. The update dispatch
65+
* predicate and its data type are imported from `@objectstack/metadata-core`,
66+
* as `security-plugin.ts` imports them.
67+
*
68+
* Nothing re-exports this module, so the published surface does not move.
69+
*/
70+
71+
import type { SecurityPlugin } from './security-plugin.js';
72+
import {
73+
copyOfCallerPredicate,
74+
PREDICATE_WRITE_READABLE_ID_CAP,
75+
predicateWriteReadableCapError,
76+
readUnlessRefused,
77+
} from './security-plugin.js';
78+
import { withoutOperationPrivateKeys } from '@objectstack/core';
79+
import {
80+
resolveEngineDeleteDispatch,
81+
resolveEngineUpdateDispatch,
82+
type EngineDeleteDispatchInput,
83+
type EngineUpdateDispatchData,
84+
} from '@objectstack/metadata-core';
85+
86+
/**
87+
* The `SecurityPlugin` member the functions below read — the one, and no
88+
* other; each function's `this` is the `Pick` of the members IT reads. It is
89+
* typed AS the class member (an indexed access on the class), so a change to
90+
* its signature on the class is a change here too. See the module header for
91+
* why it is a getter.
92+
*/
93+
export interface AddressedWritesHost {
94+
readonly ql: SecurityPlugin['ql'];
95+
}
96+
97+
/** The body of `SecurityPlugin#addressedByIdWriteId`; the method's docblock there is its contract. */
98+
export function addressedByIdWriteId(
99+
opCtx: any,
100+
nested: boolean,
101+
referentialFieldClear: boolean,
102+
): unknown {
103+
if (nested || referentialFieldClear) return undefined;
104+
if (opCtx.operation === 'update') {
105+
const data = opCtx.data;
106+
if (data == null || typeof data !== 'object') return undefined;
107+
const route = resolveEngineUpdateDispatch(data as EngineUpdateDispatchData, opCtx.options);
108+
return route.kind === 'by-id' ? route.id : undefined;
109+
}
110+
if (opCtx.operation === 'delete') {
111+
const route = resolveEngineDeleteDispatch(opCtx.options as EngineDeleteDispatchInput | undefined);
112+
return route.kind === 'by-id' ? route.id : undefined;
113+
}
114+
return undefined;
115+
}
116+
117+
/** The body of `SecurityPlugin#addressedPredicateWrite`; the method's docblock there is its contract. */
118+
export function addressedPredicateWrite(
119+
opCtx: any,
120+
nested: boolean,
121+
referentialFieldClear: boolean,
122+
): boolean {
123+
if (nested || referentialFieldClear) return false;
124+
if (opCtx.operation === 'update') {
125+
const data = opCtx.data;
126+
if (data == null || typeof data !== 'object') return false;
127+
return resolveEngineUpdateDispatch(data as EngineUpdateDispatchData, opCtx.options).kind === 'multi';
128+
}
129+
if (opCtx.operation === 'delete') {
130+
return resolveEngineDeleteDispatch(opCtx.options as EngineDeleteDispatchInput | undefined).kind === 'multi';
131+
}
132+
return false;
133+
}
134+
135+
/** The body of `SecurityPlugin#readableRowsScopeForPredicateWrite`; the method's docblock there is its contract. */
136+
export async function readableRowsScopeForPredicateWrite(
137+
this: Pick<AddressedWritesHost, 'ql'>,
138+
opCtx: any,
139+
): Promise<Record<string, unknown> | null> {
140+
if (typeof this.ql?.find !== 'function') {
141+
// The read question cannot be asked, and a write that cannot be narrowed
142+
// must not run un-narrowed. Unreachable in a real deployment — `start()`
143+
// registers no security middleware at all without a query engine — but a
144+
// non-conforming engine double fails closed rather than writing wide.
145+
throw new Error(
146+
`[Security] Cannot narrow the predicate ${opCtx.operation} on '${opCtx.object}' to the rows the ` +
147+
`caller can read: no query engine is available to ask.`,
148+
);
149+
}
150+
const callerWhere = (opCtx.options as { where?: unknown } | undefined)?.where;
151+
const answer = await readUnlessRefused(() =>
152+
this.ql.find(opCtx.object, {
153+
...(callerWhere != null ? { where: copyOfCallerPredicate(callerWhere) } : {}),
154+
fields: ['id'],
155+
limit: PREDICATE_WRITE_READABLE_ID_CAP + 1,
156+
// [#7145] The caller's envelope minus the operation-private keys: the
157+
// write's own stamps (`__writeScope`, …) are not inputs to a read, and
158+
// a copy keeps the read's stamps off the write's context.
159+
context: withoutOperationPrivateKeys((opCtx.context ?? {}) as Record<string, unknown>),
160+
}),
161+
);
162+
if (answer.refused) return null;
163+
const rows: unknown[] = Array.isArray(answer.value) ? answer.value : [];
164+
if (rows.length > PREDICATE_WRITE_READABLE_ID_CAP) {
165+
throw predicateWriteReadableCapError(opCtx.object, opCtx.operation);
166+
}
167+
const ids = rows
168+
.map((r) => (r && typeof r === 'object' ? (r as { id?: unknown }).id : undefined))
169+
.filter((id) => id !== undefined && id !== null);
170+
return { id: { $in: ids } };
171+
}
172+
173+
/**
174+
* The body of `SecurityPlugin#extractSingleId`. Its contract is the docblock
175+
* there that sits above `declaresControlledByParent`'s.
176+
*/
177+
export function extractSingleId(
178+
opCtx: any,
179+
): string | number | bigint | null {
180+
const isScalar = (v: unknown): v is string | number | bigint =>
181+
v !== null && (typeof v === 'string' || typeof v === 'number' || typeof v === 'bigint');
182+
const data = opCtx?.data;
183+
if (data && typeof data === 'object' && !Array.isArray(data) && isScalar(data.id)) {
184+
return data.id;
185+
}
186+
const where = opCtx?.options?.where;
187+
if (where && typeof where === 'object' && 'id' in where && isScalar((where as any).id)) {
188+
return (where as any).id;
189+
}
190+
return null;
191+
}
192+
193+
/** The body of `SecurityPlugin#readRowById`; the method's docblock there is its contract. */
194+
export async function readRowById(
195+
this: Pick<AddressedWritesHost, 'ql'>,
196+
object: string,
197+
id: unknown,
198+
context: any,
199+
): Promise<Record<string, unknown> | null> {
200+
// Not a read at all: there is no id to look one up by. Every call site
201+
// already screens this; the guard is defence, and `null` is honest here
202+
// because nothing was ever asked of the store.
203+
if (id == null) return null;
204+
if (typeof this.ql?.findOne !== 'function') {
205+
// The third collapsed fact, and it is not absence either: the probe
206+
// could not run. Unreachable in a real deployment — `start()` registers
207+
// no security middleware at all without a query engine, so no gate can
208+
// reach this line — but a non-conforming engine double must fail closed
209+
// rather than manufacture a 404 for a row nobody looked for.
210+
throw new Error(
211+
`[Security] Cannot verify record '${String(id)}' on '${object}': no query engine is available to read it.`,
212+
);
213+
}
214+
const row = await this.ql.findOne(object, { where: { id }, context });
215+
return row && typeof row === 'object' ? (row as Record<string, unknown>) : null;
216+
}

0 commit comments

Comments
 (0)