|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [ADR-0055] `security/explain`'s record-grained verdict for an update and a |
| 4 | +// delete of a `controlled_by_parent` record whose MASTER the caller may edit |
| 5 | +// through `modifyAllRecords` on the master alone, beside the door's answer for |
| 6 | +// the same caller and row. |
| 7 | +// |
| 8 | +// ## Why this route has its own file |
| 9 | +// |
| 10 | +// The console's record header asks exactly this question: it posts |
| 11 | +// `security/explain` with a `recordId`, once for `update` and once for |
| 12 | +// `delete`, and shows Edit and Delete on `decision.record.visible`. A sales |
| 13 | +// manager who holds `viewAllRecords` + `modifyAllRecords` on the master (the |
| 14 | +// account) and plain edit on the detail (a contract, a quote) reaches the |
| 15 | +// detail's rows only through the master: the detail grants no bypass of its |
| 16 | +// own, and the master is owned and created by somebody else. |
| 17 | +// |
| 18 | +// The routes to the master that the explain/door pair already pins beside each |
| 19 | +// other (`controlled-by-parent-write-member.test.ts`, and the REST twin |
| 20 | +// `packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts`) are an |
| 21 | +// owned master, a master shared at `edit`, a master an RLS policy admits and a |
| 22 | +// two-hop chain. None of them is the Modify All Data bypass, which the master |
| 23 | +// check reaches by two seams of its own: the master's ownership floor comes off |
| 24 | +// on the sharing write verdict `allow` that the bypass answers, and the master's |
| 25 | +// record-sharing leg is the bypass branch of `canEdit`. This file pins that |
| 26 | +// route, with the floor armed on both objects. |
| 27 | +// |
| 28 | +// ## The control |
| 29 | +// |
| 30 | +// The viewer holds `viewAllRecords` on the master WITHOUT `modifyAllRecords`: |
| 31 | +// the near neighbour of the manager's grant. They read every master and every |
| 32 | +// detail, and may edit neither the master nor, therefore, its details. Both |
| 33 | +// faces must refuse them, and explain must name the master check's leg. A read |
| 34 | +// bypass that leaked into a write verdict, or an explanation that stopped |
| 35 | +// asking the master check, fails this half. |
| 36 | +// |
| 37 | +// ## The rig |
| 38 | +// |
| 39 | +// The real `SecurityPlugin` over a real `ObjectQL` and SQL driver |
| 40 | +// (better-sqlite3, in memory), the real `SharingService` as the kernel's |
| 41 | +// `sharing` service with its middleware on the engine after this plugin's, and |
| 42 | +// the platform's own `member_default` set, whose wildcard `owner_only_writes` / |
| 43 | +// `owner_only_deletes` floor (`created_by == current_user.id`, positions |
| 44 | +// `['org_member']`) is armed for both callers. The armed case below shows it |
| 45 | +// binding each of them on this stack, through explain's own `rls` record. |
| 46 | +// |
| 47 | +// `@objectstack/core` and `@objectstack/plugin-sharing` resolve through their |
| 48 | +// built `dist/` here, as this package's other suites read them. |
| 49 | + |
| 50 | +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; |
| 51 | +import { ObjectQL } from '@objectstack/objectql'; |
| 52 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 53 | +import { SharingService, buildSharingMiddleware, SysRecordShare } from '@objectstack/plugin-sharing'; |
| 54 | +import { PermissionSetSchema } from '@objectstack/spec/security'; |
| 55 | +import type { ExplainDecision } from '@objectstack/spec/security'; |
| 56 | +import { SecurityPlugin } from './security-plugin.js'; |
| 57 | +import { defaultPermissionSets } from './objects/default-permission-sets.js'; |
| 58 | + |
| 59 | +const SYS = { context: { isSystem: true } } as never; |
| 60 | +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; |
| 61 | + |
| 62 | +const ACCOUNT = 'cbm_account'; |
| 63 | +const CONTRACT = 'cbm_contract'; |
| 64 | +/** |
| 65 | + * The floor witness: an object with no master and no owner column, whose rows |
| 66 | + * neither caller created. Explaining a write of one shows the floor's own |
| 67 | + * predicate in the `rls` record, for each caller. |
| 68 | + */ |
| 69 | +const MEMO = 'cbm_memo'; |
| 70 | + |
| 71 | +/** The master's owner and creator, and the creator of every detail row: nobody the suite explains. */ |
| 72 | +const REP = 'usr_rep'; |
| 73 | +const MANAGER = 'usr_manager'; |
| 74 | +const VIEWER = 'usr_viewer'; |
| 75 | + |
| 76 | +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; |
| 77 | + |
| 78 | +/** Modify All Data on the master only; the detail grants plain CRUD, reads included, and no write bypass. */ |
| 79 | +const MANAGER_SET = PermissionSetSchema.parse({ |
| 80 | + name: 'cbm_manager', |
| 81 | + objects: { |
| 82 | + [ACCOUNT]: { ...CRUD, viewAllRecords: true, modifyAllRecords: true }, |
| 83 | + [CONTRACT]: { ...CRUD, viewAllRecords: true, modifyAllRecords: false }, |
| 84 | + [MEMO]: { allowRead: true, allowEdit: true, viewAllRecords: true }, |
| 85 | + }, |
| 86 | +}); |
| 87 | + |
| 88 | +/** The control: the same grants with the master's write bypass withheld. */ |
| 89 | +const VIEWER_SET = PermissionSetSchema.parse({ |
| 90 | + name: 'cbm_viewer', |
| 91 | + objects: { |
| 92 | + [ACCOUNT]: { allowRead: true, allowCreate: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: false }, |
| 93 | + [CONTRACT]: { ...CRUD, viewAllRecords: true, modifyAllRecords: false }, |
| 94 | + [MEMO]: { allowRead: true, allowEdit: true, viewAllRecords: true }, |
| 95 | + }, |
| 96 | +}); |
| 97 | + |
| 98 | +/** The execution context `resolveAuthzContext` hands the middleware for an organization member holding the set. */ |
| 99 | +const callerOf = (userId: string, set: string) => ({ |
| 100 | + userId, |
| 101 | + positions: ['org_member'], |
| 102 | + permissions: [set, 'member_default'], |
| 103 | + posture: 'MEMBER', |
| 104 | +}); |
| 105 | +const MANAGER_CALLER = callerOf(MANAGER, MANAGER_SET.name); |
| 106 | +const VIEWER_CALLER = callerOf(VIEWER, VIEWER_SET.name); |
| 107 | + |
| 108 | +type Answer = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number; message: string }; |
| 109 | + |
| 110 | +const landed = (p: Promise<unknown>): Promise<Answer> => |
| 111 | + p.then( |
| 112 | + () => ({ kind: 'admitted' as const }), |
| 113 | + (e: any) => ({ |
| 114 | + kind: 'refused' as const, |
| 115 | + code: e?.code == null ? undefined : String(e.code), |
| 116 | + status: e?.statusCode ?? e?.status, |
| 117 | + message: `${String(e?.message ?? '')} ${String(e?.developerMessage ?? '')}`, |
| 118 | + }), |
| 119 | + ); |
| 120 | + |
| 121 | +let engine: ObjectQL; |
| 122 | +let plugin: SecurityPlugin; |
| 123 | + |
| 124 | +beforeAll(async () => { |
| 125 | + engine = new ObjectQL(); |
| 126 | + engine.registerDriver( |
| 127 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, |
| 128 | + true, |
| 129 | + ); |
| 130 | + await engine.init(); |
| 131 | + engine.registerApp({ |
| 132 | + id: 'com.objectstack.qa.explain-controlled-by-parent-master-modify-all', |
| 133 | + name: 'Explain: a controlled_by_parent record under a master edited through Modify All Data', |
| 134 | + version: '1.0.0', |
| 135 | + type: 'plugin', |
| 136 | + scope: 'system', |
| 137 | + objects: [ |
| 138 | + SysRecordShare, |
| 139 | + { |
| 140 | + name: ACCOUNT, |
| 141 | + label: 'Account', |
| 142 | + sharingModel: 'private', |
| 143 | + tenancy: { enabled: false }, |
| 144 | + fields: { |
| 145 | + id: { name: 'id', type: 'text', primaryKey: true }, |
| 146 | + name: { name: 'name', type: 'text' }, |
| 147 | + owner_id: { name: 'owner_id', type: 'text' }, |
| 148 | + created_by: { name: 'created_by', type: 'text' }, |
| 149 | + }, |
| 150 | + }, |
| 151 | + { |
| 152 | + name: CONTRACT, |
| 153 | + label: 'Contract', |
| 154 | + sharingModel: 'controlled_by_parent', |
| 155 | + tenancy: { enabled: false }, |
| 156 | + fields: { |
| 157 | + id: { name: 'id', type: 'text', primaryKey: true }, |
| 158 | + name: { name: 'name', type: 'text' }, |
| 159 | + account: { name: 'account', type: 'master_detail', reference: ACCOUNT, required: true }, |
| 160 | + created_by: { name: 'created_by', type: 'text' }, |
| 161 | + }, |
| 162 | + }, |
| 163 | + { |
| 164 | + name: MEMO, |
| 165 | + label: 'Memo', |
| 166 | + sharingModel: 'private', |
| 167 | + tenancy: { enabled: false }, |
| 168 | + fields: { |
| 169 | + id: { name: 'id', type: 'text', primaryKey: true }, |
| 170 | + name: { name: 'name', type: 'text' }, |
| 171 | + created_by: { name: 'created_by', type: 'text' }, |
| 172 | + }, |
| 173 | + }, |
| 174 | + ], |
| 175 | + } as never); |
| 176 | + await engine.syncSchemas(); |
| 177 | + await engine.insert(ACCOUNT, [{ id: 'acct_rep', name: "the rep's account", owner_id: REP, created_by: REP }], SYS); |
| 178 | + // One detail row per (caller, verb), so a delete that lands never changes another case's row. |
| 179 | + await engine.insert(CONTRACT, [ |
| 180 | + { id: 'c_manager_update', name: 'contract', account: 'acct_rep', created_by: REP }, |
| 181 | + { id: 'c_manager_delete', name: 'contract', account: 'acct_rep', created_by: REP }, |
| 182 | + { id: 'c_viewer_update', name: 'contract', account: 'acct_rep', created_by: REP }, |
| 183 | + { id: 'c_viewer_delete', name: 'contract', account: 'acct_rep', created_by: REP }, |
| 184 | + ], SYS); |
| 185 | + await engine.insert(MEMO, [{ id: 'm_rep', name: "the rep's memo", created_by: REP }], SYS); |
| 186 | + |
| 187 | + const sets = [MEMBER_DEFAULT, MANAGER_SET, VIEWER_SET]; |
| 188 | + let security: unknown; |
| 189 | + let sharing: SharingService | undefined; |
| 190 | + const services: Record<string, unknown> = { |
| 191 | + manifest: { register: vi.fn() }, |
| 192 | + objectql: engine, |
| 193 | + metadata: { |
| 194 | + get: async (type: string, name: string) => |
| 195 | + type === 'permission' ? sets.find((s) => s.name === name) ?? null : engine.getSchema(name) ?? null, |
| 196 | + list: async () => sets, |
| 197 | + }, |
| 198 | + get sharing() { return sharing; }, |
| 199 | + }; |
| 200 | + const ctx = { |
| 201 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 202 | + registerService: (name: string, svc: unknown) => { if (name === 'security') security = svc; }, |
| 203 | + // The lifecycle hooks are collected and never fired, so no bootstrap read |
| 204 | + // is left running into the engine teardown. |
| 205 | + hook: () => undefined, |
| 206 | + getService: (name: string) => { |
| 207 | + if (!(name in services)) throw new Error(`service not registered: ${name}`); |
| 208 | + return services[name]; |
| 209 | + }, |
| 210 | + }; |
| 211 | + plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); |
| 212 | + await plugin.init(ctx as never); |
| 213 | + await plugin.start(ctx as never); |
| 214 | + sharing = new SharingService({ engine: engine as never, securityService: () => security as never }); |
| 215 | + engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' }); |
| 216 | +}); |
| 217 | + |
| 218 | +afterAll(async () => { |
| 219 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 220 | +}); |
| 221 | + |
| 222 | +const explain = (operation: 'update' | 'delete', recordId: string, caller: object): Promise<ExplainDecision> => |
| 223 | + plugin.explainAccessForCaller({ object: CONTRACT, operation, recordId }, { ...caller }); |
| 224 | + |
| 225 | +const door = (operation: 'update' | 'delete', recordId: string, caller: object): Promise<Answer> => |
| 226 | + operation === 'update' |
| 227 | + ? landed(engine.update(CONTRACT, { name: 'edited' }, { where: { id: recordId }, context: { ...caller } } as never)) |
| 228 | + : landed(engine.delete(CONTRACT, { where: { id: recordId }, context: { ...caller } } as never)); |
| 229 | + |
| 230 | +const sharingDetailOf = (d: ExplainDecision): string => |
| 231 | + String(d.layers.find((l) => l.layer === 'sharing')?.record?.detail ?? ''); |
| 232 | + |
| 233 | +describe('[ADR-0055] explain answers a controlled_by_parent write under a Modify All Data master as the door does', () => { |
| 234 | + it('armed: every row was created by another user, and the ownership floor binds both callers on this stack', async () => { |
| 235 | + const stored = (await engine.find(CONTRACT, { where: {}, context: { isSystem: true } } as never)) as Array<Record<string, unknown>>; |
| 236 | + expect(stored.map((r) => r.created_by)).toEqual([REP, REP, REP, REP]); |
| 237 | + // The floor is the only update policy these sets carry: explained on a row |
| 238 | + // the caller did not create, the `rls` record names its predicate, |
| 239 | + // `created_by == current_user.id`, and it excludes the row. |
| 240 | + for (const caller of [MANAGER_CALLER, VIEWER_CALLER]) { |
| 241 | + const d = await plugin.explainAccessForCaller({ object: MEMO, operation: 'update', recordId: 'm_rep' }, { ...caller }); |
| 242 | + const rls = d.layers.find((l) => l.layer === 'rls')?.record; |
| 243 | + expect(rls?.rowFilter, `the floor's predicate for ${caller.userId}`).toEqual({ created_by: caller.userId }); |
| 244 | + expect(rls?.outcome).toBe('excluded'); |
| 245 | + } |
| 246 | + }); |
| 247 | + |
| 248 | + for (const verb of ['update', 'delete'] as const) { |
| 249 | + it(`${verb}: the master's Modify All Data holder, who created neither row, is explained writable and the door admits`, async () => { |
| 250 | + const id = `c_manager_${verb}`; |
| 251 | + const explained = await explain(verb, id, MANAGER_CALLER); |
| 252 | + const answered = await door(verb, id, MANAGER_CALLER); |
| 253 | + expect(answered, `${verb} door, the manager`).toEqual({ kind: 'admitted' }); |
| 254 | + expect(explained.record, `explain ${verb}, the manager`).toMatchObject({ recordId: id, visible: true }); |
| 255 | + expect(sharingDetailOf(explained)).not.toContain('master-detail write check'); |
| 256 | + }); |
| 257 | + |
| 258 | + it(`${verb}: control, the master's reader without Modify All Data is refused by both, on the master leg`, async () => { |
| 259 | + const id = `c_viewer_${verb}`; |
| 260 | + const explained = await explain(verb, id, VIEWER_CALLER); |
| 261 | + const answered = await door(verb, id, VIEWER_CALLER); |
| 262 | + expect(answered, `${verb} door, the viewer`).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); |
| 263 | + expect((answered as { message: string }).message).toMatch(/requires edit access to its master record/); |
| 264 | + expect(explained.record, `explain ${verb}, the viewer`).toEqual({ recordId: id, visible: false, decidedBy: 'sharing' }); |
| 265 | + expect(sharingDetailOf(explained)).toMatch(new RegExp(`refuses this ${verb} on its '[a-z_]+' leg`)); |
| 266 | + }); |
| 267 | + } |
| 268 | + |
| 269 | + it("reference: the master's own door gives the two callers the answers the master check gives them", async () => { |
| 270 | + const asManager = await landed(engine.update(ACCOUNT, { name: 'renamed' }, { where: { id: 'acct_rep' }, context: { ...MANAGER_CALLER } } as never)); |
| 271 | + const asViewer = await landed(engine.update(ACCOUNT, { name: 'renamed' }, { where: { id: 'acct_rep' }, context: { ...VIEWER_CALLER } } as never)); |
| 272 | + expect(asManager, 'the manager PATCHes the master').toEqual({ kind: 'admitted' }); |
| 273 | + expect(asViewer, 'the viewer PATCHes the master').toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); |
| 274 | + }); |
| 275 | +}); |
0 commit comments