|
| 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