|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#5386] `controlled_by_parent` (ADR-0055) must resolve the MASTER's |
| 4 | +// accessibility through the SAME path a direct read / write of that master |
| 5 | +// takes — owner scope and `sys_record_share` grants folded in, not the master's |
| 6 | +// RLS policies alone. |
| 7 | +// |
| 8 | +// ## What was measured before this suite existed |
| 9 | +// |
| 10 | +// The derivation ran `computeRlsFilter(master, 'find')` and nothing else, under |
| 11 | +// a system context. Owner scope and record shares are contributed by a SIBLING |
| 12 | +// plugin (`plugin-sharing`), whose `buildReadFilter` returns `null` for any |
| 13 | +// object whose effective sharing model is not `private` — and |
| 14 | +// `controlled_by_parent` maps to `public` there. So the two halves of |
| 15 | +// record-level access never met on a derived object: |
| 16 | +// |
| 17 | +// • an app that authors NO `rowLevelSecurity` on the master got an |
| 18 | +// UNRESTRICTED master id set, i.e. the declared narrowing narrowed nothing |
| 19 | +// and every detail row was readable by any holder of object-level read; |
| 20 | +// • authoring RLS on the master was not a workaround, because RLS is ANDed |
| 21 | +// with the sharing filter rather than OR-ed into it, so it also cut off the |
| 22 | +// rows a grant had shared in; |
| 23 | +// • the write half had the same hole from the other side — its master row |
| 24 | +// check was SKIPPED WHOLE when the master's write RLS compiled to `null`. |
| 25 | +// |
| 26 | +// ## Why one fixture drives both faces |
| 27 | +// |
| 28 | +// The read filter and the write assertion are two implementation faces of ONE |
| 29 | +// contract ("a child follows its parent's access"). A suite that exercised them |
| 30 | +// on separate fixtures could not see them disagree, which is the failure this |
| 31 | +// family produces: a third, quieter answer where one face allows what the other |
| 32 | +// refuses. Every case below runs on the SAME three-account fixture — a master |
| 33 | +// owned by someone else and shared to the caller, a master owned by someone |
| 34 | +// else and NOT shared (the excluded row, without which "consistent" would prove |
| 35 | +// nothing), and a master the caller owns. |
| 36 | + |
| 37 | +import { describe, it, expect, vi } from 'vitest'; |
| 38 | +import { SecurityPlugin } from './security-plugin.js'; |
| 39 | +import { SharingService, type SharingEngine } from '@objectstack/plugin-sharing'; |
| 40 | +import { matchesFilterCondition } from '@objectstack/formula'; |
| 41 | +import type { PermissionSet } from '@objectstack/spec/security'; |
| 42 | + |
| 43 | +const REP = 'usr_rep'; |
| 44 | +const OTHER = 'usr_other'; |
| 45 | + |
| 46 | +/** The MASTER — owner-scoped by OWD (`private`), with no authored RLS at all. */ |
| 47 | +const ACCOUNT_SCHEMA = { |
| 48 | + name: 'crm_account', |
| 49 | + sharingModel: 'private', |
| 50 | + fields: { |
| 51 | + id: { name: 'id', type: 'text' }, |
| 52 | + name: { name: 'name', type: 'text' }, |
| 53 | + owner_id: { name: 'owner_id', type: 'lookup', reference: 'sys_user' }, |
| 54 | + }, |
| 55 | +}; |
| 56 | + |
| 57 | +/** The DETAIL — access derived from `crm_account` through the master_detail FK. */ |
| 58 | +const CONTACT_SCHEMA = { |
| 59 | + name: 'crm_contact', |
| 60 | + sharingModel: 'controlled_by_parent', |
| 61 | + fields: { |
| 62 | + id: { name: 'id', type: 'text' }, |
| 63 | + name: { name: 'name', type: 'text' }, |
| 64 | + account: { name: 'account', type: 'master_detail', required: true, reference: 'crm_account' }, |
| 65 | + }, |
| 66 | +}; |
| 67 | + |
| 68 | +const SHARE_SCHEMA = { |
| 69 | + name: 'sys_record_share', |
| 70 | + isSystem: true, |
| 71 | + fields: { |
| 72 | + id: { name: 'id', type: 'text' }, |
| 73 | + object_name: { name: 'object_name', type: 'text' }, |
| 74 | + record_id: { name: 'record_id', type: 'text' }, |
| 75 | + recipient_type: { name: 'recipient_type', type: 'text' }, |
| 76 | + recipient_id: { name: 'recipient_id', type: 'text' }, |
| 77 | + access_level: { name: 'access_level', type: 'text' }, |
| 78 | + owner_id: { name: 'owner_id', type: 'lookup', reference: 'sys_user' }, |
| 79 | + }, |
| 80 | +}; |
| 81 | + |
| 82 | +/** |
| 83 | + * The app's permission set: full CRUD on both objects (so requests reach the |
| 84 | + * record layer instead of being refused by RBAC) and — deliberately — NO |
| 85 | + * `rowLevelSecurity` anywhere. That is the shape the issue measured: the app |
| 86 | + * expresses its record boundary entirely through OWD + sharing, and expects |
| 87 | + * `controlled_by_parent` to follow it. |
| 88 | + */ |
| 89 | +const REP_SET: PermissionSet = { |
| 90 | + name: 'crm_rep', |
| 91 | + label: 'CRM Rep', |
| 92 | + objects: { |
| 93 | + crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, |
| 94 | + crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, |
| 95 | + }, |
| 96 | +} as unknown as PermissionSet; |
| 97 | + |
| 98 | +type Row = Record<string, unknown>; |
| 99 | + |
| 100 | +/** |
| 101 | + * In-memory store standing in for the engine's READ surface only — `find`, |
| 102 | + * `findOne`, `getSchema`. It declares NO write verb: nothing under test writes |
| 103 | + * through it, and a double that does not have a verb cannot be looser than the |
| 104 | + * engine on that verb (the `check:engine-double-contract` family, #4434/#5480). |
| 105 | + * Filtering runs through `matchesFilterCondition`, the same evaluator the |
| 106 | + * security plugin itself uses, so a filter this suite asserts on is a filter |
| 107 | + * that was really applied rather than one merely inspected. |
| 108 | + */ |
| 109 | +function makeStore(rows: Record<string, Row[]>) { |
| 110 | + const schemas: Record<string, unknown> = { |
| 111 | + crm_account: ACCOUNT_SCHEMA, |
| 112 | + crm_contact: CONTACT_SCHEMA, |
| 113 | + sys_record_share: SHARE_SCHEMA, |
| 114 | + }; |
| 115 | + return { |
| 116 | + rows, |
| 117 | + getSchema: (object: string) => schemas[object], |
| 118 | + find: vi.fn(async (object: string, options: any = {}) => { |
| 119 | + const all = rows[object] ?? []; |
| 120 | + const hits = all.filter((r) => matchesFilterCondition(r, options?.where ?? null)); |
| 121 | + return typeof options?.limit === 'number' ? hits.slice(0, options.limit) : hits; |
| 122 | + }), |
| 123 | + findOne: vi.fn(async (object: string, options: any = {}) => { |
| 124 | + const all = rows[object] ?? []; |
| 125 | + return all.find((r) => matchesFilterCondition(r, options?.where ?? null)) ?? null; |
| 126 | + }), |
| 127 | + }; |
| 128 | +} |
| 129 | + |
| 130 | +/** The fixture rows — identical for every case; only the grant level varies. */ |
| 131 | +function fixtureRows(shareLevel: 'read' | 'edit' | null): Record<string, Row[]> { |
| 132 | + return { |
| 133 | + crm_account: [ |
| 134 | + { id: 'acct_us', name: 'US Corp', owner_id: OTHER }, // shared to the rep |
| 135 | + { id: 'acct_eu', name: 'EU Corp', owner_id: OTHER }, // NOT shared — the excluded row |
| 136 | + { id: 'acct_own', name: 'Own Corp', owner_id: REP }, // the rep's own |
| 137 | + ], |
| 138 | + crm_contact: [ |
| 139 | + { id: 'ct_us', name: 'US contact', account: 'acct_us' }, |
| 140 | + { id: 'ct_eu', name: 'EU contact', account: 'acct_eu' }, |
| 141 | + { id: 'ct_own', name: 'Own contact', account: 'acct_own' }, |
| 142 | + ], |
| 143 | + sys_record_share: shareLevel |
| 144 | + ? [ |
| 145 | + { |
| 146 | + id: 'shr_1', |
| 147 | + object_name: 'crm_account', |
| 148 | + record_id: 'acct_us', |
| 149 | + recipient_type: 'user', |
| 150 | + recipient_id: REP, |
| 151 | + access_level: shareLevel, |
| 152 | + }, |
| 153 | + ] |
| 154 | + : [], |
| 155 | + }; |
| 156 | +} |
| 157 | + |
| 158 | +interface BootOptions { |
| 159 | + /** The single grant's level, or `null` for a fixture with no grant at all. */ |
| 160 | + shareLevel?: 'read' | 'edit' | null; |
| 161 | + /** `'none'` boots a deployment WITHOUT plugin-sharing; `'throws'` a broken one. */ |
| 162 | + sharing?: 'real' | 'none' | 'throws'; |
| 163 | +} |
| 164 | + |
| 165 | +async function boot(options: BootOptions = {}) { |
| 166 | + const shareLevel = options.shareLevel === undefined ? 'edit' : options.shareLevel; |
| 167 | + const store = makeStore(fixtureRows(shareLevel)); |
| 168 | + |
| 169 | + let middleware: any; |
| 170 | + const ql = { |
| 171 | + registerMiddleware: (mw: any) => { |
| 172 | + if (!middleware) middleware = mw; |
| 173 | + }, |
| 174 | + getSchema: store.getSchema, |
| 175 | + find: store.find, |
| 176 | + findOne: store.findOne, |
| 177 | + }; |
| 178 | + |
| 179 | + const services: Record<string, unknown> = { |
| 180 | + manifest: { register: vi.fn() }, |
| 181 | + objectql: ql, |
| 182 | + metadata: { get: async (n: string) => store.getSchema(n), list: async () => [REP_SET] }, |
| 183 | + }; |
| 184 | + if ((options.sharing ?? 'real') === 'real') { |
| 185 | + // The REAL sharing service over the same store — the point of the fix is |
| 186 | + // that the derivation reuses this exact producer instead of re-deriving |
| 187 | + // owner/share semantics inside plugin-security. |
| 188 | + services.sharing = new SharingService({ engine: store as unknown as SharingEngine }); |
| 189 | + } else if (options.sharing === 'throws') { |
| 190 | + services.sharing = { |
| 191 | + buildReadFilter: async () => { |
| 192 | + throw new Error('sharing store unreachable'); |
| 193 | + }, |
| 194 | + canEdit: async () => { |
| 195 | + throw new Error('sharing store unreachable'); |
| 196 | + }, |
| 197 | + }; |
| 198 | + } |
| 199 | + |
| 200 | + const ctx: any = { |
| 201 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, |
| 202 | + registerService: vi.fn(), |
| 203 | + getService: (name: string) => { |
| 204 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 205 | + return services[name]; |
| 206 | + }, |
| 207 | + }; |
| 208 | + const plugin = new SecurityPlugin({ |
| 209 | + defaultPermissionSets: [REP_SET], |
| 210 | + fallbackPermissionSet: 'crm_rep', |
| 211 | + }); |
| 212 | + await plugin.init(ctx); |
| 213 | + await plugin.start(ctx); |
| 214 | + |
| 215 | + const repContext = () => ({ userId: REP, tenantId: 'org-1', positions: [], permissions: [] }); |
| 216 | + |
| 217 | + /** The READ face: run the middleware, then apply the filter it injected. */ |
| 218 | + const visibleContacts = async (): Promise<string[]> => { |
| 219 | + const opCtx: any = { |
| 220 | + object: 'crm_contact', |
| 221 | + operation: 'find', |
| 222 | + ast: {}, |
| 223 | + options: {}, |
| 224 | + context: repContext(), |
| 225 | + }; |
| 226 | + await middleware(opCtx, async () => {}); |
| 227 | + return (store.rows.crm_contact ?? []) |
| 228 | + .filter((r) => matchesFilterCondition(r, opCtx.ast.where ?? null)) |
| 229 | + .map((r) => String(r.id)); |
| 230 | + }; |
| 231 | + |
| 232 | + /** The WRITE face: a by-id update of one detail row. Resolves or throws. */ |
| 233 | + const updateContact = async (id: string): Promise<void> => { |
| 234 | + const opCtx: any = { |
| 235 | + object: 'crm_contact', |
| 236 | + operation: 'update', |
| 237 | + data: { id, name: 'renamed' }, |
| 238 | + options: { where: { id } }, |
| 239 | + context: repContext(), |
| 240 | + }; |
| 241 | + await middleware(opCtx, async () => {}); |
| 242 | + }; |
| 243 | + |
| 244 | + const writableContacts = async (): Promise<string[]> => { |
| 245 | + const out: string[] = []; |
| 246 | + for (const row of store.rows.crm_contact ?? []) { |
| 247 | + try { |
| 248 | + await updateContact(String(row.id)); |
| 249 | + out.push(String(row.id)); |
| 250 | + } catch { |
| 251 | + /* denied */ |
| 252 | + } |
| 253 | + } |
| 254 | + return out; |
| 255 | + }; |
| 256 | + |
| 257 | + return { store, ctx, visibleContacts, updateContact, writableContacts }; |
| 258 | +} |
| 259 | + |
| 260 | +describe('[#5386] controlled_by_parent folds the master\'s ownership and share grants in', () => { |
| 261 | + // ── READ face ──────────────────────────────────────────────────────────── |
| 262 | + it('READ: only children of masters the caller can actually read are visible', async () => { |
| 263 | + const h = await boot({ shareLevel: 'edit' }); |
| 264 | + // acct_own is reachable by OWNERSHIP, acct_us by the single GRANT, |
| 265 | + // acct_eu by neither — so its contact must not appear. |
| 266 | + expect(await h.visibleContacts()).toEqual(['ct_us', 'ct_own']); |
| 267 | + }); |
| 268 | + |
| 269 | + it('READ: with no grant at all, only the caller-owned master\'s children survive', async () => { |
| 270 | + const h = await boot({ shareLevel: null }); |
| 271 | + expect(await h.visibleContacts()).toEqual(['ct_own']); |
| 272 | + }); |
| 273 | + |
| 274 | + it('READ: the derived master id set equals what a direct find of the master returns', async () => { |
| 275 | + const h = await boot({ shareLevel: 'edit' }); |
| 276 | + // The master half, resolved exactly as a direct read would: owner-match |
| 277 | + // OR-ed with the caller's grants. This is the set the child filter must |
| 278 | + // quantify over — asserted here so a future change that widens the derived |
| 279 | + // set without widening the master's own read is caught at the seam. |
| 280 | + const sharing = new SharingService({ engine: h.store as unknown as SharingEngine }); |
| 281 | + const masterFilter = await sharing.buildReadFilter('crm_account', { |
| 282 | + userId: REP, |
| 283 | + tenantId: 'org-1', |
| 284 | + } as any); |
| 285 | + const directlyReadable = (h.store.rows.crm_account ?? []) |
| 286 | + .filter((r) => matchesFilterCondition(r, masterFilter as any)) |
| 287 | + .map((r) => String(r.id)); |
| 288 | + expect(directlyReadable).toEqual(['acct_us', 'acct_own']); |
| 289 | + }); |
| 290 | + |
| 291 | + // ── WRITE face ─────────────────────────────────────────────────────────── |
| 292 | + it('WRITE: a by-id update of a child under an unreachable master is denied', async () => { |
| 293 | + const h = await boot({ shareLevel: 'edit' }); |
| 294 | + await expect(h.updateContact('ct_eu')).rejects.toThrow(/requires edit access to its master/); |
| 295 | + }); |
| 296 | + |
| 297 | + it('WRITE: children of an owned master and of an edit-shared master stay writable', async () => { |
| 298 | + const h = await boot({ shareLevel: 'edit' }); |
| 299 | + await expect(h.updateContact('ct_own')).resolves.toBeUndefined(); |
| 300 | + await expect(h.updateContact('ct_us')).resolves.toBeUndefined(); |
| 301 | + }); |
| 302 | + |
| 303 | + // ── the invariant the two faces owe each other ─────────────────────────── |
| 304 | + it('INVARIANT: read-visible and by-id-writable agree on the same fixture', async () => { |
| 305 | + const h = await boot({ shareLevel: 'edit' }); |
| 306 | + const readable = await h.visibleContacts(); |
| 307 | + const writable = await h.writableContacts(); |
| 308 | + expect(readable.sort()).toEqual(writable.sort()); |
| 309 | + // …and the agreement is not the empty agreement: exactly one row is |
| 310 | + // excluded, so "consistent" carries information. |
| 311 | + expect(readable).not.toContain('ct_eu'); |
| 312 | + expect(readable).toHaveLength(2); |
| 313 | + }); |
| 314 | + |
| 315 | + it('a READ-level grant opens the child for reading but NOT for writing — the master\'s own verb boundary', async () => { |
| 316 | + const h = await boot({ shareLevel: 'read' }); |
| 317 | + // Same asymmetry a DIRECT access of the master has: a `read` share widens |
| 318 | + // rows for reads only; `canEdit` refuses it. The derived faces must track |
| 319 | + // that per-verb answer rather than collapsing to one of them. |
| 320 | + expect(await h.visibleContacts()).toEqual(['ct_us', 'ct_own']); |
| 321 | + expect(await h.writableContacts()).toEqual(['ct_own']); |
| 322 | + await expect(h.updateContact('ct_us')).rejects.toThrow(/record sharing/); |
| 323 | + }); |
| 324 | + |
| 325 | + // ── boundary conditions ────────────────────────────────────────────────── |
| 326 | + it('the fold reads the grant table, resolves the master ONCE, and never re-enters the detail', async () => { |
| 327 | + const h = await boot({ shareLevel: 'edit' }); |
| 328 | + h.store.find.mockClear(); |
| 329 | + await h.visibleContacts(); |
| 330 | + const reads = h.store.find.mock.calls.map((c: any[]) => String(c[0])); |
| 331 | + // The grant table was consulted — direct evidence the sharing half ran and |
| 332 | + // not merely that some filter came back. |
| 333 | + expect(reads).toContain('sys_record_share'); |
| 334 | + // The master id set is resolved by ONE system-context read. More than one |
| 335 | + // would mean the derivation re-entered the middleware (v1 is single-level, |
| 336 | + // ADR-0055 — the master's own controlled_by_parent is not walked). |
| 337 | + expect(reads.filter((o) => o === 'crm_account')).toHaveLength(1); |
| 338 | + expect(reads).not.toContain('crm_contact'); |
| 339 | + }); |
| 340 | + |
| 341 | + it('a deployment WITHOUT plugin-sharing is unchanged — RLS-only derivation, nothing to fold', async () => { |
| 342 | + // No sharing service means no owner scope and no grants anywhere in the |
| 343 | + // deployment, so a direct find of the master returns every row too: the |
| 344 | + // derived set stays point-for-point equal to it. |
| 345 | + const h = await boot({ shareLevel: 'edit', sharing: 'none' }); |
| 346 | + expect(await h.visibleContacts()).toEqual(['ct_us', 'ct_eu', 'ct_own']); |
| 347 | + await expect(h.updateContact('ct_eu')).resolves.toBeUndefined(); |
| 348 | + }); |
| 349 | + |
| 350 | + it('fail-closed: a sharing service that throws denies BOTH faces', async () => { |
| 351 | + const h = await boot({ shareLevel: 'edit', sharing: 'throws' }); |
| 352 | + expect(await h.visibleContacts()).toEqual([]); |
| 353 | + await expect(h.updateContact('ct_us')).rejects.toThrow(/record sharing/); |
| 354 | + }); |
| 355 | +}); |
0 commit comments