|
84 | 84 | * The comparands are left as written, because the read pairs none with the |
85 | 85 | * wrap: `$contains` / `$notContains` take one MEMBER, and every scalar |
86 | 86 | * comparison on such a column is refused by the read |
87 | | - * (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), never compared with a list. |
| 87 | + * (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`), never compared with a list — and, |
| 88 | + * since [#21254], by this step too (next section). |
| 89 | + * |
| 90 | + * ## [#21254] An operator the read refuses on a JSON-stored column is refused here too |
| 91 | + * |
| 92 | + * `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS` is the set of |
| 93 | + * operators no face answers on a column the object declares JSON-stored (a |
| 94 | + * structured-JSON type, or a multi-valued field): the scalar comparisons, the |
| 95 | + * orderings and the text operators other than the membership pair. The read a |
| 96 | + * policy scopes is compiled by the driver, which refuses them with |
| 97 | + * `INVALID_FILTER` / 400; the engine's per-aggregation `filter` and |
| 98 | + * `driver-memory`'s filter gate refuse the same set on the same declared |
| 99 | + * fields. The write check judged them instead, in JS, against the stored |
| 100 | + * list. Measured through `ObjectQL.insert` + `SecurityPlugin` + two SQL driver |
| 101 | + * families as a member resolving a permission set, `using` and `check` the |
| 102 | + * same predicate, `tags` declared `tags`: |
| 103 | + * |
| 104 | + * | `check` | written | write, before | stored | read under the same predicate | |
| 105 | + * |---|---|---|---|---| |
| 106 | + * | `record.tags != 'x'` | `['x']` or `'x'` | admitted | `["x"]` | 400 | |
| 107 | + * | `!(record.tags in ['x'])` | `['x']` | admitted | `["x"]` | 400 | |
| 108 | + * | `record.tags == 'x'` | `['x']` | 403 | — | 400 | |
| 109 | + * | `record.tags in ['x']` | `['x']` | 403 | — | 400 | |
| 110 | + * | `record.tags > 'a'` | `['x']` | 400, the evaluator's list-under-ordering refusal | — | 400 | |
| 111 | + * |
| 112 | + * The first two are the exclusion family's fail-OPEN: a list never equals a |
| 113 | + * scalar, so "not equal" held for the very row the policy names, and a policy |
| 114 | + * whose read is refused admitted that write. So this step refuses what the |
| 115 | + * read refuses, by one rule ({@link findJsonColumnCheckRefusal}): an operator |
| 116 | + * in that set, or implicit equality, aimed at a column the object declares |
| 117 | + * JSON-stored, whatever the comparand, at any depth under `$and` / `$or` / |
| 118 | + * `$not` — the traversal objectql's per-aggregation gate takes. The set and |
| 119 | + * the words are core's (`jsonColumnOperatorRefusalText`), imported, never |
| 120 | + * copied; the error constructor is this face's, as each face keeps its own, |
| 121 | + * with the read's envelope, `INVALID_FILTER` / 400. All five rows above now |
| 122 | + * get it, so the write and the read give one answer for one policy. The three |
| 123 | + * that refused before still admit nothing; their answer is now the read's. |
| 124 | + * |
| 125 | + * It reads the declaration, never the record: the verdict is reached once, |
| 126 | + * from the parts and the declared columns, and every image the judge is handed |
| 127 | + * gets it before any is evaluated, so a policy is refused for every row or for |
| 128 | + * none. What still answers on such a column is unchanged: the membership pair |
| 129 | + * `$contains` / `$notContains`, and the presence predicates `$null`, |
| 130 | + * `$exists`, `$empty`. A column declared neither way, and an object whose |
| 131 | + * schema cannot be loaded, are judged as before. |
| 132 | + * |
| 133 | + * The message names neither the field nor the operator (the policy is an |
| 134 | + * administrator's, and the caller is usually not its author) and says the full |
| 135 | + * diagnostic is in the server log. The write gate makes that true: the |
| 136 | + * diagnostic travels on the error, off the wire |
| 137 | + * ({@link jsonColumnCheckRefusalCarriedBy}), and the gate logs it beside the |
| 138 | + * policy's name. |
88 | 139 | * |
89 | 140 | * ## What it does not carry |
90 | 141 | * |
|
97 | 148 | * rule declares. |
98 | 149 | */ |
99 | 150 |
|
100 | | -import { multiValueStorageForm, temporalStorageForm, type TemporalComparandKind } from '@objectstack/core'; |
| 151 | +import { |
| 152 | + JSON_COLUMN_INCOMPATIBLE_OPERATORS, |
| 153 | + jsonColumnOperatorRefusalText, |
| 154 | + multiValueStorageForm, |
| 155 | + temporalStorageForm, |
| 156 | + type TemporalComparandKind, |
| 157 | +} from '@objectstack/core'; |
101 | 158 | import { matchesFilterCondition, type MatchesFilterOptions } from '@objectstack/formula'; |
| 159 | +import { StandardErrorCode } from '@objectstack/spec/api'; |
102 | 160 | import { |
103 | 161 | CALENDAR_DATE_TYPES, |
104 | 162 | CLOCK_TIME_TYPES, |
105 | 163 | INSTANT_TYPES, |
| 164 | + STRUCTURED_JSON_TYPES, |
106 | 165 | filterSubtreeProvenanceOf, |
107 | 166 | isMultiValueField, |
108 | 167 | markFilterSubtreeProvenance, |
109 | 168 | } from '@objectstack/spec/data'; |
| 169 | +import { compiledPolicyNameOf } from './rls-compiler.js'; |
110 | 170 |
|
111 | 171 | /** The declared temporal columns of one object, by name, each with its storage rule's kind. */ |
112 | 172 | export type DeclaredTemporalColumns = ReadonlyMap<string, TemporalComparandKind>; |
@@ -163,6 +223,156 @@ export function declaredMultiValueColumns(columns: MatchesFilterOptions | undefi |
163 | 223 | return out; |
164 | 224 | } |
165 | 225 |
|
| 226 | +/** [#21254] The declared JSON-stored columns of one object, by name. */ |
| 227 | +export type DeclaredJsonStoredColumns = ReadonlySet<string>; |
| 228 | + |
| 229 | +/** |
| 230 | + * [#21254] The columns `columns` declares JSON-stored: the declared |
| 231 | + * multi-valued columns ({@link declaredMultiValueColumns}) and the columns of a |
| 232 | + * structured-JSON type (the spec's `STRUCTURED_JSON_TYPES`). These are the two |
| 233 | + * halves every face of core's JSON-column refusal reads, and the population |
| 234 | + * `matchesFilterCondition` already asks `$contains` membership of, over the |
| 235 | + * same declaration. Empty when the object hands over no declaration. |
| 236 | + */ |
| 237 | +export function declaredJsonStoredColumns(columns: MatchesFilterOptions | undefined): DeclaredJsonStoredColumns { |
| 238 | + const out = new Set<string>(); |
| 239 | + const multiValue = declaredMultiValueColumns(columns); |
| 240 | + for (const [name, decl] of Object.entries(columns?.fields ?? {})) { |
| 241 | + if (multiValue.has(name) || STRUCTURED_JSON_TYPES.has(decl.type)) out.add(name); |
| 242 | + } |
| 243 | + return out; |
| 244 | +} |
| 245 | + |
| 246 | +/** [#21254] One operator the read refuses, as {@link findJsonColumnCheckRefusal} found it. */ |
| 247 | +export interface JsonColumnCheckRefusal { |
| 248 | + /** The declared JSON-stored column the operator is aimed at. */ |
| 249 | + readonly field: string; |
| 250 | + /** The operator as written in the compiled check; `=` for implicit equality. */ |
| 251 | + readonly operator: string; |
| 252 | + /** Where it sits: `check[<part>]`, then the path through the compiled filter. */ |
| 253 | + readonly path: string; |
| 254 | + /** The policy the offending node was compiled from, when the compiler marked one. */ |
| 255 | + readonly policy: string | undefined; |
| 256 | + /** What the caller is told: core's message, which names neither the field nor the operator. */ |
| 257 | + readonly message: string; |
| 258 | + /** |
| 259 | + * Core's full diagnostic, the field and the operator named. SERVER-SIDE ONLY: |
| 260 | + * the policy is an administrator's, so it goes to a log, never into an error |
| 261 | + * message (see {@link jsonColumnCheckRefusalCarriedBy}). |
| 262 | + */ |
| 263 | + readonly diagnostic: string; |
| 264 | +} |
| 265 | + |
| 266 | +/** |
| 267 | + * [#21254] A column condition that is IMPLICIT equality: a comparand rather |
| 268 | + * than an operator map (a primitive, `null`, a `Date` or an array). The split |
| 269 | + * objectql's per-aggregation gate makes on the same shapes. |
| 270 | + */ |
| 271 | +function isImplicitEquality(condition: unknown): boolean { |
| 272 | + return typeof condition !== 'object' |
| 273 | + || condition === null |
| 274 | + || condition instanceof Date |
| 275 | + || Array.isArray(condition); |
| 276 | +} |
| 277 | + |
| 278 | +/** |
| 279 | + * [#21254] The first operator in `parts` that the read refuses on a declared |
| 280 | + * JSON-stored column, or `null` when there is none: an operator in |
| 281 | + * `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, or implicit |
| 282 | + * equality, whatever the comparand (`null` and a `{ $field }` operand |
| 283 | + * included), at any depth under `$and` / `$or` / `$not`. Pure: it reads the |
| 284 | + * parts and the declaration, never a record. |
| 285 | + * |
| 286 | + * The traversal is objectql's per-aggregation gate's |
| 287 | + * (`assertAggregationFilterSparesJsonStoredFields`): any other `$` key is not |
| 288 | + * a column and is left to the evaluator, and so is a column the declaration |
| 289 | + * does not name JSON-stored. The words are core's |
| 290 | + * (`jsonColumnOperatorRefusalText`); the bare spelling's operator is `=`. |
| 291 | + */ |
| 292 | +export function findJsonColumnCheckRefusal( |
| 293 | + parts: readonly Record<string, unknown>[], |
| 294 | + jsonStored: DeclaredJsonStoredColumns, |
| 295 | +): JsonColumnCheckRefusal | null { |
| 296 | + if (jsonStored.size === 0) return null; |
| 297 | + const refusal = ( |
| 298 | + field: string, |
| 299 | + operator: string, |
| 300 | + bare: boolean, |
| 301 | + path: string, |
| 302 | + policy: string | undefined, |
| 303 | + ): JsonColumnCheckRefusal => { |
| 304 | + const { message, diagnostic } = jsonColumnOperatorRefusalText(field, operator, bare); |
| 305 | + return { field, operator, path, policy, message, diagnostic }; |
| 306 | + }; |
| 307 | + const walk = (cond: unknown, path: string, policy: string | undefined): JsonColumnCheckRefusal | null => { |
| 308 | + if (!cond || typeof cond !== 'object') return null; |
| 309 | + const owner = compiledPolicyNameOf(cond) ?? policy; |
| 310 | + for (const [key, value] of Object.entries(cond)) { |
| 311 | + const here = `${path}.${key}`; |
| 312 | + if (key === '$and' || key === '$or') { |
| 313 | + const branches = Array.isArray(value) ? value : [value]; |
| 314 | + for (let i = 0; i < branches.length; i++) { |
| 315 | + const found = walk(branches[i], `${here}[${i}]`, owner); |
| 316 | + if (found) return found; |
| 317 | + } |
| 318 | + continue; |
| 319 | + } |
| 320 | + if (key === '$not') { |
| 321 | + const found = walk(value, here, owner); |
| 322 | + if (found) return found; |
| 323 | + continue; |
| 324 | + } |
| 325 | + if (key.startsWith('$') || !jsonStored.has(key)) continue; |
| 326 | + if (isImplicitEquality(value)) return refusal(key, '=', true, here, owner); |
| 327 | + for (const op of Object.keys(value as Record<string, unknown>)) { |
| 328 | + if (JSON_COLUMN_INCOMPATIBLE_OPERATORS.has(op)) return refusal(key, op, false, `${here}.${op}`, owner); |
| 329 | + } |
| 330 | + } |
| 331 | + return null; |
| 332 | + }; |
| 333 | + for (let i = 0; i < parts.length; i++) { |
| 334 | + const found = walk(parts[i], `check[${i}]`, undefined); |
| 335 | + if (found) return found; |
| 336 | + } |
| 337 | + return null; |
| 338 | +} |
| 339 | + |
| 340 | +/** |
| 341 | + * [#21254] The refusal carried on the error, under a SYMBOL key, for the |
| 342 | + * reason `@objectstack/formula`'s comparison-class refusal carries its own |
| 343 | + * that way: `JSON.stringify`, a spread, `Object.keys` and the structured-clone |
| 344 | + * boundary all skip it, so no error mapper can put the field and the operator |
| 345 | + * back on the wire. `Symbol.for` so a duplicated copy of this package resolves |
| 346 | + * the same key. |
| 347 | + */ |
| 348 | +const JSON_COLUMN_CHECK_REFUSAL = Symbol.for('objectstack.plugin-security.jsonColumnCheckRefusal'); |
| 349 | + |
| 350 | +/** |
| 351 | + * [#21254] The write check's refusal: core's message, with the envelope the |
| 352 | + * read gives the same policy, `INVALID_FILTER` / 400 (and `httpStatus`, the |
| 353 | + * same number under ADR-0112 D5's spelling, as the engine's own filter |
| 354 | + * refusals carry it). |
| 355 | + */ |
| 356 | +function jsonColumnCheckRefusalError(refusal: JsonColumnCheckRefusal): Error { |
| 357 | + const err = new Error(refusal.message) as Error & { code?: string; status?: number; httpStatus?: number }; |
| 358 | + err.code = StandardErrorCode.enum.INVALID_FILTER; |
| 359 | + err.status = 400; |
| 360 | + err.httpStatus = 400; |
| 361 | + Object.defineProperty(err, JSON_COLUMN_CHECK_REFUSAL, { value: refusal, enumerable: false }); |
| 362 | + return err; |
| 363 | +} |
| 364 | + |
| 365 | +/** |
| 366 | + * [#21254] The JSON-column refusal an error carries, or `null` for any other |
| 367 | + * error: the read half of the judge's refusal, for the write gate, which logs |
| 368 | + * the diagnostic server-side beside the policy's name. |
| 369 | + */ |
| 370 | +export function jsonColumnCheckRefusalCarriedBy(err: unknown): JsonColumnCheckRefusal | null { |
| 371 | + if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return null; |
| 372 | + const refusal = (err as Record<symbol, unknown>)[JSON_COLUMN_CHECK_REFUSAL]; |
| 373 | + return refusal && typeof refusal === 'object' ? (refusal as JsonColumnCheckRefusal) : null; |
| 374 | +} |
| 375 | + |
166 | 376 | /** A plain object: a filter node, an operator map or a `{ $field }` reference — never a comparand value. */ |
167 | 377 | function isPlainObject(value: unknown): value is Record<string, unknown> { |
168 | 378 | if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; |
@@ -301,17 +511,25 @@ export function storedFormImage( |
301 | 511 | * A refusal the evaluator raises propagates unchanged. The parts the caller |
302 | 512 | * attributes it to are its own: the rewritten parts are used for evaluation |
303 | 513 | * only. |
| 514 | + * |
| 515 | + * [#21254] One refusal is this step's own: an operator the read refuses on a |
| 516 | + * declared JSON-stored column ({@link findJsonColumnCheckRefusal}). It is |
| 517 | + * found once, here, on the parts as compiled (they carry the policy marks), |
| 518 | + * and thrown for every image before any is evaluated, so the verdict is the |
| 519 | + * declaration's and never a record's. |
304 | 520 | */ |
305 | 521 | export function storedFormCheckJudge( |
306 | 522 | parts: readonly Record<string, unknown>[], |
307 | 523 | columns: MatchesFilterOptions | undefined, |
308 | 524 | ): (image: Record<string, unknown>) => boolean { |
309 | 525 | const temporal = declaredTemporalColumns(columns); |
310 | 526 | const multiValue = declaredMultiValueColumns(columns); |
| 527 | + const refusal = findJsonColumnCheckRefusal(parts, declaredJsonStoredColumns(columns)); |
311 | 528 | // The comparands are put into the temporal form only: on a multi-valued |
312 | 529 | // column the read pairs no comparand with the wrap (see the module note). |
313 | 530 | const storedParts = parts.map((part) => storedFormCheckFilter(part, temporal)); |
314 | 531 | return (image) => { |
| 532 | + if (refusal) throw jsonColumnCheckRefusalError(refusal); |
315 | 533 | const stored = storedFormImage(image, temporal, multiValue); |
316 | 534 | return storedParts.every((part) => matchesFilterCondition(stored, part as never, columns)); |
317 | 535 | }; |
|
0 commit comments