|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21319] `security.explain` answers what enforcement answers for a row-level |
| 5 | + * policy that aims an operator the read refuses at a column the object |
| 6 | + * declares JSON-stored: the request is REFUSED, with the find's own envelope |
| 7 | + * (`INVALID_FILTER` / 400), and no verdict is reported. |
| 8 | + * |
| 9 | + * A multi-valued field (`tags`, a `select` or `lookup` flagged `multiple`) and |
| 10 | + * a structured-JSON type (`json`) are stored as JSON. driver-sql refuses |
| 11 | + * `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, and implicit |
| 12 | + * equality, on such a column, so the caller's find answers `INVALID_FILTER` / |
| 13 | + * 400 whatever the rows; a by-id update or delete fails closed at its |
| 14 | + * row-level gate (403), whose pre-image re-read is that refused read; and the |
| 15 | + * RLS write check refuses an insert with the read's 400 (#21254). The explain |
| 16 | + * engine's record matcher evaluated the operators in JS instead. Measured on |
| 17 | + * this stack before the fix, on both SQLite families: |
| 18 | + * |
| 19 | + * | `using` | stored | find | by-id update / delete | explain, every operation | |
| 20 | + * |---|---|---|---|---| |
| 21 | + * | `record.tags != 'x'` | `['y']` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` | |
| 22 | + * | `record.meta == 'x'` | `'x'` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` | |
| 23 | + * | `!(record.tags in ['x'])` | `['y']` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` | |
| 24 | + * | `record.owners != 'x'` (`select`, `multiple`) | `['y']` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` | |
| 25 | + * | `record.refs != 'x'` (`lookup`, `multiple`) | `['y']` | 400 | 403 | `visible: true`, `decidedBy: 'rls'` | |
| 26 | + * |
| 27 | + * The object-level report (no `recordId`) said `allowed: true` with `rls` |
| 28 | + * `narrows`, and a record id no row carries was reported `visible: false`. |
| 29 | + * `POST /api/v1/security/explain` relayed the same 200. |
| 30 | + * |
| 31 | + * The explain engine now judges each row filter by the rule the write check |
| 32 | + * applies (`findJsonColumnCheckRefusal`, imported, so the operator set is |
| 33 | + * core's and is copied nowhere), before the matcher reads a record, and |
| 34 | + * answers with the refusal: the shape it gives a cross-class comparison |
| 35 | + * (`explain-cross-class-refusal.test.ts`), a thrown envelope with no decision. |
| 36 | + * Its `cause` is the read's refusal: core's message, which the find answers |
| 37 | + * byte for byte. Its message is core's diagnostic, naming the field and the |
| 38 | + * operator, then the policy that carries them. |
| 39 | + * |
| 40 | + * Every refused cell asserts both halves: explain's answer, and the real |
| 41 | + * request through the real `SecurityPlugin`, `ObjectQL` and SQL driver. The |
| 42 | + * controls are the operators that still answer on such a column (the |
| 43 | + * membership pair and presence) and a column declared neither way, which keep |
| 44 | + * their row verdict on both sides. |
| 45 | + * |
| 46 | + * PostgreSQL runs when `OS_TEST_POSTGRES_URL` names a server (CI does not run |
| 47 | + * this package against a live server, so there it is skipped). |
| 48 | + */ |
| 49 | + |
| 50 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
| 51 | +import { ObjectQL } from '@objectstack/objectql'; |
| 52 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 53 | +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; |
| 54 | +import { jsonColumnOperatorRefusalText } from '@objectstack/core'; |
| 55 | +import { truncateClientMessage } from '@objectstack/types'; |
| 56 | +import { PermissionSetSchema } from '@objectstack/spec/security'; |
| 57 | +import type { ExplainDecision } from '@objectstack/spec/security'; |
| 58 | +import { SecurityPlugin } from './security-plugin.js'; |
| 59 | +import { defaultPermissionSets } from './objects/default-permission-sets.js'; |
| 60 | + |
| 61 | +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; |
| 62 | +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; |
| 63 | +const PG_URL = process.env.OS_TEST_POSTGRES_URL; |
| 64 | +const POLICY = 'tag_guard'; |
| 65 | + |
| 66 | +type Driver = { disconnect?: () => Promise<void> }; |
| 67 | +const DRIVERS: Array<[name: string, make: () => Driver, available: boolean]> = [ |
| 68 | + ['driver-sql (better-sqlite3)', () => |
| 69 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true], |
| 70 | + ['driver-sqlite-wasm', () => new SqliteWasmDriver({ filename: ':memory:' }), true], |
| 71 | + ['driver-sql (PostgreSQL)', () => new SqlDriver({ client: 'pg', connection: PG_URL! }), !!PG_URL], |
| 72 | +]; |
| 73 | + |
| 74 | +const booted: Array<{ engine: ObjectQL; driver: Driver; table: string }> = []; |
| 75 | +afterEach(async () => { |
| 76 | + while (booted.length) { |
| 77 | + const { engine, driver, table } = booted.pop()!; |
| 78 | + // A live server outlives the run: drop what this file created. |
| 79 | + const knex = (driver as { knex?: { schema: { dropTableIfExists: (t: string) => Promise<unknown> } } }).knex; |
| 80 | + if (PG_URL && knex && driver instanceof SqlDriver) { |
| 81 | + try { await knex.schema.dropTableIfExists(table); } catch { /* noop */ } |
| 82 | + } |
| 83 | + try { await engine.destroy(); } catch { /* noop */ } |
| 84 | + } |
| 85 | +}); |
| 86 | + |
| 87 | +type ExplainOp = 'read' | 'create' | 'update' | 'delete'; |
| 88 | + |
| 89 | +let seq = 0; |
| 90 | +/** One engine and plugin, with the policies `predicates` names, each scoping every operation. */ |
| 91 | +async function boot(makeDriver: () => Driver, predicates: Record<string, string>) { |
| 92 | + const OBJ = `qa_deal_21319_${process.pid}_${++seq}`; |
| 93 | + const driver = makeDriver(); |
| 94 | + const engine = new ObjectQL(); |
| 95 | + engine.registerDriver(driver as never, true); |
| 96 | + await engine.init(); |
| 97 | + engine.registerApp({ |
| 98 | + id: `com.objectstack.qa.explain-json-column-21319-${seq}`, |
| 99 | + name: 'Explain: RLS operator on a JSON-stored column', |
| 100 | + version: '1.0.0', |
| 101 | + type: 'plugin', |
| 102 | + scope: 'system', |
| 103 | + objects: [ |
| 104 | + { |
| 105 | + name: OBJ, |
| 106 | + label: 'Deal', |
| 107 | + sharingModel: 'public_read_write', |
| 108 | + fields: { |
| 109 | + id: { name: 'id', type: 'text', primaryKey: true }, |
| 110 | + title: { name: 'title', type: 'text' }, |
| 111 | + tags: { name: 'tags', type: 'tags' }, |
| 112 | + owners: { |
| 113 | + name: 'owners', |
| 114 | + type: 'select', |
| 115 | + multiple: true, |
| 116 | + options: [{ label: 'X', value: 'x' }, { label: 'Y', value: 'y' }], |
| 117 | + }, |
| 118 | + refs: { name: 'refs', type: 'lookup', reference: OBJ, multiple: true }, |
| 119 | + meta: { name: 'meta', type: 'json' }, |
| 120 | + }, |
| 121 | + }, |
| 122 | + ], |
| 123 | + } as never); |
| 124 | + await engine.syncSchemas(); |
| 125 | + booted.push({ engine, driver, table: OBJ }); |
| 126 | + |
| 127 | + const set = PermissionSetSchema.parse({ |
| 128 | + name: 'qa_tag_guard', |
| 129 | + objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, |
| 130 | + rowLevelSecurity: Object.entries(predicates).map(([name, using]) => ({ name, object: OBJ, operation: 'all', using })), |
| 131 | + }); |
| 132 | + const services: Record<string, unknown> = { |
| 133 | + manifest: { register: vi.fn() }, |
| 134 | + objectql: engine, |
| 135 | + metadata: { |
| 136 | + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, |
| 137 | + list: async () => [MEMBER_DEFAULT, set], |
| 138 | + }, |
| 139 | + }; |
| 140 | + const registerService = vi.fn(); |
| 141 | + const ctx = { |
| 142 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 143 | + registerService, |
| 144 | + getService: (name: string) => { |
| 145 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 146 | + return services[name]; |
| 147 | + }, |
| 148 | + }; |
| 149 | + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); |
| 150 | + await plugin.init(ctx as never); |
| 151 | + await plugin.start(ctx as never); |
| 152 | + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); |
| 153 | + |
| 154 | + /** The registered `security` service: what `POST /api/v1/security/explain` dispatches to. */ |
| 155 | + const security = registerService.mock.calls.find((c) => c[0] === 'security')?.[1] as { |
| 156 | + explain: (request: Record<string, unknown>, callerContext: unknown) => Promise<ExplainDecision>; |
| 157 | + }; |
| 158 | + expect(security?.explain, 'the security service is registered with explain').toBeTypeOf('function'); |
| 159 | + |
| 160 | + const caller = { userId: 'usr_member', positions: ['qa_pos'], permissions: [set.name], posture: 'MEMBER' }; |
| 161 | + /** The caller's own access, for one record or (no id) for the object. */ |
| 162 | + const explain = (operation: ExplainOp, recordId?: string): Promise<ExplainDecision> => |
| 163 | + security.explain({ object: OBJ, operation, ...(recordId ? { recordId } : {}) }, caller); |
| 164 | + /** The caller's own request for the one record, through the real middleware and driver. */ |
| 165 | + const request = (operation: ExplainOp, recordId: string): Promise<unknown> => |
| 166 | + operation === 'read' |
| 167 | + ? engine.find(OBJ, { where: { id: recordId }, context: caller } as never) |
| 168 | + : operation === 'create' |
| 169 | + ? engine.insert(OBJ, { id: recordId, title: 'n' }, { context: caller } as never) |
| 170 | + : operation === 'update' |
| 171 | + ? engine.update(OBJ, { title: 'z' }, { where: { id: recordId }, context: caller } as never) |
| 172 | + : engine.delete(OBJ, { where: { id: recordId }, context: caller } as never); |
| 173 | + const visibleIds = async (): Promise<string[]> => |
| 174 | + ((await engine.find(OBJ, { context: caller } as never)) as Array<{ id: string }>).map((r) => r.id).sort(); |
| 175 | + const stored = async () => |
| 176 | + ((await engine.find(OBJ, { context: SYS_CTX } as never)) as Array<Record<string, unknown>>) |
| 177 | + .map((r) => ({ id: r.id, title: r.title })) |
| 178 | + .sort((a, b) => String(a.id).localeCompare(String(b.id))); |
| 179 | + return { OBJ, engine, explain, request, visibleIds, stored }; |
| 180 | +} |
| 181 | + |
| 182 | +type Envelope = { code: string; status: number }; |
| 183 | +const DENIED: Envelope = { code: 'PERMISSION_DENIED', status: 403 }; |
| 184 | +const INVALID: Envelope = { code: 'INVALID_FILTER', status: 400 }; |
| 185 | + |
| 186 | +const envelopeOf = (e: unknown): Envelope => { |
| 187 | + const x = e as { code?: string; status?: number; statusCode?: number }; |
| 188 | + return { code: String(x?.code), status: Number(x?.statusCode ?? x?.status) }; |
| 189 | +}; |
| 190 | +const outcome = (p: Promise<unknown>): Promise<'admitted' | Envelope> => |
| 191 | + p.then(() => 'admitted' as const, (e: unknown) => envelopeOf(e)); |
| 192 | +const thrown = (p: Promise<unknown>): Promise<unknown> => |
| 193 | + p.then(() => 'answered' as const, (e: unknown) => e); |
| 194 | + |
| 195 | +/** What enforcement answers the caller's own request with, per operation. */ |
| 196 | +const ENFORCED: Record<ExplainOp, Envelope> = { read: INVALID, create: INVALID, update: DENIED, delete: DENIED }; |
| 197 | + |
| 198 | +type Refused = [field: string, op: string, bare: boolean]; |
| 199 | + |
| 200 | +/** |
| 201 | + * Explain's answer is the read's refusal: the find's envelope, no decision and |
| 202 | + * so no verdict; core's diagnostic first, which names the field and the |
| 203 | + * operator and holds the remedy, then the policy; and, as its `cause`, the |
| 204 | + * read's own refusal, whose message is the find's byte for byte. |
| 205 | + */ |
| 206 | +async function expectExplainRefuses(p: Promise<unknown>, refused: Refused, findMessage: string, policies = [POLICY]) { |
| 207 | + const e = await thrown(p); |
| 208 | + expect(e, 'explain answered a decision').not.toBe('answered'); |
| 209 | + expect(envelopeOf(e)).toEqual(INVALID); |
| 210 | + const words = jsonColumnOperatorRefusalText(...refused); |
| 211 | + const message = String((e as Error).message); |
| 212 | + expect(message.startsWith(`${words.diagnostic} `), message).toBe(true); |
| 213 | + for (const policy of policies) expect(message).toContain(`'${policy}'`); |
| 214 | + // The REST door keeps a 4xx message's head: the diagnostic, remedy included, survives its bound. |
| 215 | + expect(truncateClientMessage(message).startsWith(words.diagnostic)).toBe(true); |
| 216 | + const cause = (e as { cause?: unknown }).cause; |
| 217 | + expect(envelopeOf(cause)).toEqual(INVALID); |
| 218 | + expect((cause as Error).message).toBe(words.message); |
| 219 | + expect((cause as Error).message).toBe(findMessage); |
| 220 | +} |
| 221 | + |
| 222 | +interface Case { |
| 223 | + id: string; |
| 224 | + predicate: string; |
| 225 | + /** The stored row: a list on a multi-valued column, a scalar on the `json` one. */ |
| 226 | + row: Record<string, unknown>; |
| 227 | + refused: Refused; |
| 228 | +} |
| 229 | + |
| 230 | +/** The card's three rows (J1–J3), then a `select` and a `lookup` flagged `multiple`. */ |
| 231 | +const REFUSED: Case[] = [ |
| 232 | + { id: 'J1 multi-valued !=', predicate: "record.tags != 'x'", row: { tags: ['y'] }, refused: ['tags', '$ne', false] }, |
| 233 | + { id: 'J2 json ==', predicate: "record.meta == 'x'", row: { meta: 'x' }, refused: ['meta', '=', true] }, |
| 234 | + { id: 'J3 negated in', predicate: "!(record.tags in ['x'])", row: { tags: ['y'] }, refused: ['tags', '$in', false] }, |
| 235 | + { id: 'J4 select multiple !=', predicate: "record.owners != 'x'", row: { owners: ['y'] }, refused: ['owners', '$ne', false] }, |
| 236 | + { id: 'J5 lookup multiple !=', predicate: "record.refs != 'x'", row: { refs: ['y'] }, refused: ['refs', '$ne', false] }, |
| 237 | +]; |
| 238 | + |
| 239 | +interface Control { |
| 240 | + id: string; |
| 241 | + predicate: string; |
| 242 | + rows: Array<Record<string, unknown>>; |
| 243 | + /** The rows the caller's find returns under the policy. */ |
| 244 | + visible: string[]; |
| 245 | +} |
| 246 | + |
| 247 | +const CONTROLS: Control[] = [ |
| 248 | + { |
| 249 | + id: 'membership `contains`', |
| 250 | + predicate: "record.tags.contains('x')", |
| 251 | + rows: [{ id: 'r1', tags: ['x'] }, { id: 'r2', tags: ['y'] }], |
| 252 | + visible: ['r1'], |
| 253 | + }, |
| 254 | + { |
| 255 | + id: 'membership `!contains`', |
| 256 | + predicate: "!record.tags.contains('x')", |
| 257 | + rows: [{ id: 'r1', tags: ['x'] }, { id: 'r2', tags: ['y'] }], |
| 258 | + visible: ['r2'], |
| 259 | + }, |
| 260 | + { |
| 261 | + id: 'presence `!= null`', |
| 262 | + predicate: 'record.tags != null', |
| 263 | + rows: [{ id: 'r1', tags: ['x'] }, { id: 'r2', tags: null }], |
| 264 | + visible: ['r1'], |
| 265 | + }, |
| 266 | + { |
| 267 | + id: 'a column declared neither way, `!=`', |
| 268 | + predicate: "record.title != 'x'", |
| 269 | + rows: [{ id: 'r1', title: 'y' }, { id: 'r2', title: 'x' }], |
| 270 | + visible: ['r1'], |
| 271 | + }, |
| 272 | +]; |
| 273 | + |
| 274 | +for (const [driverName, makeDriver, available] of DRIVERS) { |
| 275 | + describe.skipIf(!available)(`[#21319] ${driverName}: explain reports the refusal enforcement gives an operator the read refuses on a JSON-stored column`, () => { |
| 276 | + for (const c of REFUSED) { |
| 277 | + it(`${c.id} \`${c.predicate}\` — every operation: enforcement refuses, explain answers the read's INVALID_FILTER / 400 and no verdict`, async () => { |
| 278 | + const w = await boot(makeDriver, { [POLICY]: c.predicate }); |
| 279 | + await w.engine.insert(w.OBJ, [{ id: 'r1', title: 't', ...c.row }], { context: SYS_CTX } as never); |
| 280 | + const before = await w.stored(); |
| 281 | + const findError = await thrown(w.request('read', 'r1')); |
| 282 | + const findMessage = String((findError as Error)?.message); |
| 283 | + |
| 284 | + for (const op of ['read', 'create', 'update', 'delete'] as const) { |
| 285 | + const id = op === 'create' ? 'r_new' : 'r1'; |
| 286 | + expect(await outcome(w.request(op, id)), `${op}: enforcement`).toEqual(ENFORCED[op]); |
| 287 | + await expectExplainRefuses(w.explain(op, id), c.refused, findMessage); |
| 288 | + } |
| 289 | + // The object-level report, and a record id no row carries: the find refuses both, whatever the rows. |
| 290 | + await expectExplainRefuses(w.explain('read'), c.refused, findMessage); |
| 291 | + expect(await outcome(w.request('read', 'r_absent'))).toEqual(INVALID); |
| 292 | + await expectExplainRefuses(w.explain('read', 'r_absent'), c.refused, findMessage); |
| 293 | + expect(await w.stored()).toEqual(before); |
| 294 | + }); |
| 295 | + } |
| 296 | + |
| 297 | + it('two policies, one refusing: explain names only the one that carries the refused operator, and the find refuses both', async () => { |
| 298 | + const w = await boot(makeDriver, { [POLICY]: "record.tags != 'x'", title_guard: "record.title == 't'" }); |
| 299 | + await w.engine.insert(w.OBJ, [{ id: 'r1', title: 't', tags: ['y'] }], { context: SYS_CTX } as never); |
| 300 | + const findError = await thrown(w.request('read', 'r1')); |
| 301 | + expect(envelopeOf(findError)).toEqual(INVALID); |
| 302 | + for (const p of [w.explain('read', 'r1'), w.explain('read')]) { |
| 303 | + const e = await thrown(p); |
| 304 | + await expectExplainRefuses(Promise.reject(e), ['tags', '$ne', false], String((findError as Error).message)); |
| 305 | + expect(String((e as Error).message)).not.toContain("'title_guard'"); |
| 306 | + } |
| 307 | + }); |
| 308 | + |
| 309 | + for (const c of CONTROLS) { |
| 310 | + it(`control — ${c.id} \`${c.predicate}\` keeps its row verdict on both sides`, async () => { |
| 311 | + const w = await boot(makeDriver, { [POLICY]: c.predicate }); |
| 312 | + await w.engine.insert(w.OBJ, c.rows.map((r) => ({ title: 't', ...r })), { context: SYS_CTX } as never); |
| 313 | + expect(await w.visibleIds()).toEqual(c.visible); |
| 314 | + |
| 315 | + const object = await w.explain('read'); |
| 316 | + expect(object.allowed).toBe(true); |
| 317 | + expect(object.readFilter).toBeTruthy(); |
| 318 | + for (const { id } of c.rows as Array<{ id: string }>) { |
| 319 | + const visible = c.visible.includes(id); |
| 320 | + expect((await w.explain('read', id)).record, `read ${id}`).toEqual({ recordId: id, visible, decidedBy: 'rls' }); |
| 321 | + expect((await w.explain('update', id)).record, `update ${id}`).toEqual({ recordId: id, visible, decidedBy: 'rls' }); |
| 322 | + } |
| 323 | + }); |
| 324 | + } |
| 325 | + }); |
| 326 | +} |
0 commit comments