Skip to content

Commit d2b23e2

Browse files
committed
refactor(plugin-security): move SecurityPlugin's controlled-by-parent read leaf into security-plugin-cbp-read-filter.ts
The bodies of computeControlledByParentFilter (the ADR-0055 §2 read derivation, answering the engine's ReadableIds leaf over the master) and controlledByParentChainRefusal (its chain refusal) move unedited to a new module beside the plugin, with CBP_LEAF_READ_VERBS, the one module-level declaration only they read. Each method keeps its name, signature, docblock and position and hands its body one host literal of exactly the members it reads; ql and logger go as getters. CBP_MAX_CHAIN_DEPTH stays (ADR-anchored) and is 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 3e51491 commit d2b23e2

3 files changed

Lines changed: 186 additions & 61 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25223 cut 4] The controlled-by-parent read leaf of `SecurityPlugin`, moved
5+
* out of `security-plugin.ts` whole: the ADR-0055 §2 read derivation, which
6+
* answers a `controlled_by_parent` object's read with the engine's
7+
* `ReadableIds` leaf over its master (ADR-0139 D6), and the chain refusal it
8+
* runs over the registry before it writes that leaf.
9+
*
10+
* A MOVE, not a redesign. Each function below is the body of the
11+
* `SecurityPlugin` method of the same name as it stood, re-indented one level
12+
* and otherwise unedited: the same leaf, the same deny sentinel, the same
13+
* chain guards, failing closed exactly where they did. The one module-level
14+
* declaration only these bodies read moved with them, also unedited:
15+
* `CBP_LEAF_READ_VERBS`, with the `ReadableIds` import that
16+
* `security-plugin.ts` no longer needs. The relation the leaf is derived from
17+
* stays the class's `resolveCbpRelation` (`security-plugin-cbp-relation.ts`),
18+
* read through the plugin as before.
19+
*
20+
* ## How the moved bodies reach the class
21+
*
22+
* The shape cuts 1 to 3 used (`security-plugin-object-inputs.ts`, whose header
23+
* carries the reasoning). The class keeps every method — the published
24+
* `dist/*.d.ts` lists each one's name, docblock and position — and each body is
25+
* one literal of exactly the {@link CbpReadFilterHost} members that method
26+
* reads, plus a `.call`. A method is handed over as a closure that calls it on
27+
* the plugin at call time, so a test double or spy set on the plugin is still
28+
* the one the body reaches, and the leaf reaches its chain refusal through the
29+
* plugin, as before. Both functions are synchronous.
30+
*
31+
* The two fields the bodies read go as GETTERS, by the rule cut 1 set: neither
32+
* is declared `readonly`. `start()` assigns `ql` and `logger` after
33+
* construction. Each read happens when the body makes it, as before.
34+
*
35+
* ## What stays in `security-plugin.ts`, and how it is read
36+
*
37+
* `CBP_MAX_CHAIN_DEPTH` stays there, exported, and is imported here by value:
38+
* ADR-0055 anchors it by symbol in that file, and the master-write gate's
39+
* chain walk (`security-plugin-cbp-write-gate.ts`) reads the same bound. It is
40+
* read only inside a function body, at call time, never while either module
41+
* evaluates, so the two-way import is safe under ESM, and the published bundle
42+
* holds one module. `RLS_DENY_FILTER` is imported from `rls-compiler.js`, as
43+
* `security-plugin.ts` imports it. `CbpRelation` keeps its one declaration in
44+
* `security-plugin.ts`; this module names it through the class's own signature
45+
* (below).
46+
*
47+
* Nothing re-exports this module, so the published surface does not move.
48+
*/
49+
50+
import type { SecurityPlugin } from './security-plugin.js';
51+
import { CBP_MAX_CHAIN_DEPTH } from './security-plugin.js';
52+
import { ReadableIds } from '@objectstack/spec/data';
53+
import { RLS_DENY_FILTER } from './rls-compiler.js';
54+
55+
/**
56+
* [ADR-0139 D1] The engine verbs whose `where` may carry a `ReadableIds`
57+
* leaf — the engine's own `READABLE_IDS_READ_VERBS`. The read derivation
58+
* answers its leaf for these and nothing for a write verb's predicate: a
59+
* predicate write is narrowed to the rows the read door returns for the
60+
* caller's own predicate ({@link SecurityPlugin.readableRowsScopeForPredicateWrite},
61+
* where the leaf composes) and judged per master by the 2.8 gate
62+
* ({@link SecurityPlugin.assertControlledByParentWrite}, a verdict, never a
63+
* scope), so no master set is composed into a write's predicate.
64+
*/
65+
const CBP_LEAF_READ_VERBS: ReadonlySet<string> = new Set(['find', 'findOne', 'count', 'aggregate']);
66+
67+
/**
68+
* A `controlled_by_parent` object's resolved master relation, as the class
69+
* declares it: the `CbpRelation` interface stays in `security-plugin.ts`
70+
* beside `cbpRelCache`, and the moved bodies name it exactly as they did.
71+
*/
72+
type CbpRelation = NonNullable<ReturnType<SecurityPlugin['resolveCbpRelation']>>;
73+
74+
/**
75+
* The `SecurityPlugin` members the functions below read — every one of them,
76+
* and no other; each function's `this` is the `Pick` of the members IT reads.
77+
* Each is typed AS the class member (an indexed access on the class), so a
78+
* change to a member's signature on the class is a change here too, and naming
79+
* a member the class does not have is a compile error. Every field here is a
80+
* getter on the host; see the module header.
81+
*/
82+
export interface CbpReadFilterHost {
83+
readonly logger: SecurityPlugin['logger'];
84+
readonly ql: SecurityPlugin['ql'];
85+
controlledByParentChainRefusal: SecurityPlugin['controlledByParentChainRefusal'];
86+
resolveCbpRelation: SecurityPlugin['resolveCbpRelation'];
87+
}
88+
89+
/** The body of `SecurityPlugin#computeControlledByParentFilter`; the method's docblock there is its contract. */
90+
export function computeControlledByParentFilter(
91+
this: Pick<CbpReadFilterHost, 'controlledByParentChainRefusal' | 'logger' | 'ql' | 'resolveCbpRelation'>,
92+
object: string,
93+
/** The engine verb the answer is composed onto; a write verb answers `null` (ADR-0139 D1). */
94+
operation: string,
95+
context: any,
96+
): Record<string, unknown> | null {
97+
if (!this.ql || !context?.userId) return null;
98+
const schema = typeof this.ql.getSchema === 'function' ? this.ql.getSchema(object) : null;
99+
const sharingModel = schema?.sharingModel ?? schema?.security?.sharingModel;
100+
if (sharingModel !== 'controlled_by_parent') return null;
101+
if (!CBP_LEAF_READ_VERBS.has(operation)) return null;
102+
103+
const rel = this.resolveCbpRelation(object);
104+
// No resolvable master: the deny sentinel, by reference (ADR-0139 D2 step 2).
105+
if (!rel) return RLS_DENY_FILTER;
106+
107+
// [ADR-0055 amendment] Chain guards, over the registry, BEFORE the leaf is
108+
// written. Both answer with the empty master set — the same shape the
109+
// #5386 sharing-resolution failure answered with, and the same posture: a
110+
// chain this derivation cannot resolve denies, because the alternative
111+
// ("no restriction") is the defect being fixed, and the other alternative
112+
// (the engine's refusal of the same chain) is a 500 for a declared shape.
113+
const refusal = this.controlledByParentChainRefusal(object, rel);
114+
if (refusal?.kind === 'cycle') {
115+
this.logger.error?.(
116+
`[security] controlled_by_parent derivation found a CYCLE resolving '${object}' ` +
117+
`(chain: ${refusal.chain.join(' -> ')}) — denying (fail-closed: a chain the derivation ` +
118+
`cannot resolve admits no child rather than leaving it unrestricted)`,
119+
);
120+
return { [rel.fk]: { $in: [] } };
121+
}
122+
if (refusal?.kind === 'depth') {
123+
this.logger.error?.(
124+
`[security] controlled_by_parent derivation exceeded the chain depth bound ` +
125+
`(${CBP_MAX_CHAIN_DEPTH}) resolving '${object}' ` +
126+
`(chain: ${refusal.chain.join(' -> ')}) — denying (fail-closed: a chain the derivation ` +
127+
`cannot resolve admits no child rather than leaving it unrestricted)`,
128+
);
129+
return { [rel.fk]: { $in: [] } };
130+
}
131+
return { [rel.fk]: { $in: ReadableIds.of(rel.master) } };
132+
}
133+
134+
/** The body of `SecurityPlugin#controlledByParentChainRefusal`; the method's docblock there is its contract. */
135+
export function controlledByParentChainRefusal(
136+
this: Pick<CbpReadFilterHost, 'ql' | 'resolveCbpRelation'>,
137+
object: string,
138+
rel: CbpRelation,
139+
): { kind: 'cycle' | 'depth'; chain: string[] } | null {
140+
const chain: string[] = [object];
141+
let master = rel.master;
142+
for (;;) {
143+
if (chain.includes(master)) return { kind: 'cycle', chain: [...chain, master] };
144+
const schema = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(master) : null;
145+
const model = schema?.sharingModel ?? schema?.security?.sharingModel;
146+
if (model !== 'controlled_by_parent') return null;
147+
if (chain.length >= CBP_MAX_CHAIN_DEPTH) return { kind: 'depth', chain: [...chain, master] };
148+
chain.push(master);
149+
const next = this.resolveCbpRelation(master);
150+
if (!next) return null;
151+
master = next.master;
152+
}
153+
}

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 26 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai';
1212
// [#21829] `MAX_BULK_PER_ROW_HOOK_ROWS`: the platform's row ceiling for one
1313
// predicate write, which also bounds the id list a predicate write's read
1414
// question composes (see PREDICATE_WRITE_READABLE_ID_CAP).
15-
import { markFilterSubtreeProvenance, FieldMaskingRuleSchema, MAX_BULK_PER_ROW_HOOK_ROWS, ReadableIds, type FieldMaskingRule } from '@objectstack/spec/data';
15+
import { markFilterSubtreeProvenance, FieldMaskingRuleSchema, MAX_BULK_PER_ROW_HOOK_ROWS, type FieldMaskingRule } from '@objectstack/spec/data';
1616
import { StandardErrorCode } from '@objectstack/spec/api';
1717
import type { NumberComparandDoorFieldMeta } from '@objectstack/spec/data';
1818
// [#7414] The SHARED operation-message catalog #7307 built for the data path's
@@ -135,6 +135,14 @@ import {
135135
controlledByParentPredicateWriteMasters,
136136
type CbpWriteGateHost,
137137
} from './security-plugin-cbp-write-gate.js';
138+
// [#25223 cut 4] The controlled-by-parent read leaf (ADR-0055 §2, ADR-0139 D6)
139+
// and its chain refusal; the methods of the same names below hand each body
140+
// the members it reads (`CbpReadFilterHost`).
141+
import {
142+
computeControlledByParentFilter,
143+
controlledByParentChainRefusal,
144+
type CbpReadFilterHost,
145+
} from './security-plugin-cbp-read-filter.js';
138146
import {
139147
isPlatformOwnershipFloorPolicy,
140148
masterGateCoversOperation,
@@ -648,18 +656,6 @@ interface RlsFilterOptions {
648656
*/
649657
export const CBP_MAX_CHAIN_DEPTH = 8;
650658

651-
/**
652-
* [ADR-0139 D1] The engine verbs whose `where` may carry a `ReadableIds`
653-
* leaf — the engine's own `READABLE_IDS_READ_VERBS`. The read derivation
654-
* answers its leaf for these and nothing for a write verb's predicate: a
655-
* predicate write is narrowed to the rows the read door returns for the
656-
* caller's own predicate ({@link SecurityPlugin.readableRowsScopeForPredicateWrite},
657-
* where the leaf composes) and judged per master by the 2.8 gate
658-
* ({@link SecurityPlugin.assertControlledByParentWrite}, a verdict, never a
659-
* scope), so no master set is composed into a write's predicate.
660-
*/
661-
const CBP_LEAF_READ_VERBS: ReadonlySet<string> = new Set(['find', 'findOne', 'count', 'aggregate']);
662-
663659
interface CbpRelation {
664660
/** The detail's master reference field key. */
665661
fk: string;
@@ -9061,41 +9057,16 @@ export class SecurityPlugin implements Plugin {
90619057
operation: string,
90629058
context: any,
90639059
): Record<string, unknown> | null {
9064-
if (!this.ql || !context?.userId) return null;
9065-
const schema = typeof this.ql.getSchema === 'function' ? this.ql.getSchema(object) : null;
9066-
const sharingModel = schema?.sharingModel ?? schema?.security?.sharingModel;
9067-
if (sharingModel !== 'controlled_by_parent') return null;
9068-
if (!CBP_LEAF_READ_VERBS.has(operation)) return null;
9069-
9070-
const rel = this.resolveCbpRelation(object);
9071-
// No resolvable master: the deny sentinel, by reference (ADR-0139 D2 step 2).
9072-
if (!rel) return RLS_DENY_FILTER;
9073-
9074-
// [ADR-0055 amendment] Chain guards, over the registry, BEFORE the leaf is
9075-
// written. Both answer with the empty master set — the same shape the
9076-
// #5386 sharing-resolution failure answered with, and the same posture: a
9077-
// chain this derivation cannot resolve denies, because the alternative
9078-
// ("no restriction") is the defect being fixed, and the other alternative
9079-
// (the engine's refusal of the same chain) is a 500 for a declared shape.
9080-
const refusal = this.controlledByParentChainRefusal(object, rel);
9081-
if (refusal?.kind === 'cycle') {
9082-
this.logger.error?.(
9083-
`[security] controlled_by_parent derivation found a CYCLE resolving '${object}' ` +
9084-
`(chain: ${refusal.chain.join(' -> ')}) — denying (fail-closed: a chain the derivation ` +
9085-
`cannot resolve admits no child rather than leaving it unrestricted)`,
9086-
);
9087-
return { [rel.fk]: { $in: [] } };
9088-
}
9089-
if (refusal?.kind === 'depth') {
9090-
this.logger.error?.(
9091-
`[security] controlled_by_parent derivation exceeded the chain depth bound ` +
9092-
`(${CBP_MAX_CHAIN_DEPTH}) resolving '${object}' ` +
9093-
`(chain: ${refusal.chain.join(' -> ')}) — denying (fail-closed: a chain the derivation ` +
9094-
`cannot resolve admits no child rather than leaving it unrestricted)`,
9095-
);
9096-
return { [rel.fk]: { $in: [] } };
9097-
}
9098-
return { [rel.fk]: { $in: ReadableIds.of(rel.master) } };
9060+
// [#25223 cut 4] The body moved to `security-plugin-cbp-read-filter.ts` (see `CbpReadFilterHost`).
9061+
const readLogger = () => this.logger;
9062+
const readQl = () => this.ql;
9063+
const host: Pick<CbpReadFilterHost, 'controlledByParentChainRefusal' | 'logger' | 'ql' | 'resolveCbpRelation'> = {
9064+
controlledByParentChainRefusal: (...a) => this.controlledByParentChainRefusal(...a),
9065+
get logger() { return readLogger(); },
9066+
get ql() { return readQl(); },
9067+
resolveCbpRelation: (...a) => this.resolveCbpRelation(...a),
9068+
};
9069+
return computeControlledByParentFilter.call(host, object, operation, context);
90999070
}
91009071

91019072
/**
@@ -9124,19 +9095,13 @@ export class SecurityPlugin implements Plugin {
91249095
object: string,
91259096
rel: CbpRelation,
91269097
): { kind: 'cycle' | 'depth'; chain: string[] } | null {
9127-
const chain: string[] = [object];
9128-
let master = rel.master;
9129-
for (;;) {
9130-
if (chain.includes(master)) return { kind: 'cycle', chain: [...chain, master] };
9131-
const schema = typeof this.ql?.getSchema === 'function' ? this.ql.getSchema(master) : null;
9132-
const model = schema?.sharingModel ?? schema?.security?.sharingModel;
9133-
if (model !== 'controlled_by_parent') return null;
9134-
if (chain.length >= CBP_MAX_CHAIN_DEPTH) return { kind: 'depth', chain: [...chain, master] };
9135-
chain.push(master);
9136-
const next = this.resolveCbpRelation(master);
9137-
if (!next) return null;
9138-
master = next.master;
9139-
}
9098+
// [#25223 cut 4] The body moved to `security-plugin-cbp-read-filter.ts` (see `CbpReadFilterHost`).
9099+
const readQl = () => this.ql;
9100+
const host: Pick<CbpReadFilterHost, 'ql' | 'resolveCbpRelation'> = {
9101+
get ql() { return readQl(); },
9102+
resolveCbpRelation: (...a) => this.resolveCbpRelation(...a),
9103+
};
9104+
return controlledByParentChainRefusal.call(host, object, rel);
91409105
}
91419106

91429107
/**
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"file": "packages/plugins/plugin-security/src/security-plugin-cbp-read-filter.ts",
3+
"adrs": [
4+
"ADR-0055"
5+
],
6+
"invariant": "ADR-0055 §1 — `resolveCbpRelation` derives a `controlled_by_parent` object's master from exactly one `master_detail` field declared `required: true` that names a reference, and from nothing else: zero such fields or two or more resolve `null`, which both halves treat as fail-closed (reads answer RLS_DENY_FILTER, writes throw MasterDetailRelationMissingError, 422 INVALID_METADATA), reported once per object per cache generation. Do NOT restore the retired fallback tiers (any `master_detail`, then a required `lookup`) or a first-by-field-order pick among two: they were never part of ADR-0055 §1, `ObjectSchema` refuses those shapes at parse, and a gate that derives access from a relation the parse refuses is the disagreement the step-18 semantic migration `cbp-master-relation-tiers-retired` closed. The reference-SPELLING tolerance in the same reader is a different, deliberate decision and stays."
7+
}

0 commit comments

Comments
 (0)