|
| 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 | +} |
0 commit comments