|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [ADR-0090 D10] An on-behalf-of by-id write never admits a row its |
| 5 | + * delegator's own by-id write refuses. |
| 6 | + * |
| 7 | + * An OAuth agent acting for a user carries a ceiling of its own and is bounded |
| 8 | + * by that user: every row gate a by-id write meets asks the delegator as well. |
| 9 | + * The bound pinned here is the one the delegator's OWN direct write sets, at |
| 10 | + * each gate that judges a delegated by-id write: |
| 11 | + * |
| 12 | + * - the by-id `update` and `delete` of a row; |
| 13 | + * - the row an `update` would store; |
| 14 | + * - a `controlled_by_parent` detail's write, judged through its master. |
| 15 | + * |
| 16 | + * Every refusal stands beside the delegator's own request as its control, and |
| 17 | + * every row-level verdict beside explain's record verdict for the same caller, |
| 18 | + * under a walled (`isolated`) and a wall-less (`single`) posture. |
| 19 | + * |
| 20 | + * The bound only narrows. Two shapes the delegator's own write admits stay |
| 21 | + * refused on its behalf, exactly as they were: making the delegated write |
| 22 | + * match the delegator's own there would WIDEN it, which is not what this rule |
| 23 | + * says, so those rows pin the refusal as unchanged. |
| 24 | + * |
| 25 | + * Real stack: `ObjectQL` over better-sqlite3, the real `SecurityPlugin`, the |
| 26 | + * real sharing service and middleware, and contexts assembled by |
| 27 | + * `resolveAuthzContext` and `assembleExecutionContext` — the agent's with the |
| 28 | + * `mcp_agent_data_write` OAuth ceiling. `@objectstack/core` and |
| 29 | + * `@objectstack/plugin-sharing` resolve through their built `dist/`, as this |
| 30 | + * package's other suites read them. |
| 31 | + */ |
| 32 | + |
| 33 | +import { describe, it, expect, vi, afterAll } from 'vitest'; |
| 34 | +import { AsyncLocalStorage } from 'node:async_hooks'; |
| 35 | +import { ObjectQL } from '@objectstack/objectql'; |
| 36 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 37 | +import { resolveAuthzContext, assembleExecutionContext } from '@objectstack/core'; |
| 38 | +import { SharingService, buildSharingMiddleware, SysRecordShare } from '@objectstack/plugin-sharing'; |
| 39 | +import { PermissionSetSchema } from '@objectstack/spec/security'; |
| 40 | +import type { ExplainDecision, TenancyPosture } from '@objectstack/spec/security'; |
| 41 | +import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; |
| 42 | + |
| 43 | +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; |
| 44 | +import { SysUserPosition } from './objects/sys-user-position.object.js'; |
| 45 | +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; |
| 46 | +import { SecurityPlugin } from './security-plugin.js'; |
| 47 | +import { defaultPermissionSets } from './objects/default-permission-sets.js'; |
| 48 | + |
| 49 | +const SYS = { context: { isSystem: true } } as never; |
| 50 | +const ALPHA = 'org_alpha'; |
| 51 | + |
| 52 | +/** A delegator whose own writes app-authored row-level policies bound. */ |
| 53 | +const BOUNDED = 'u_bounded_writer'; |
| 54 | +/** A delegator with an `org` write grant and no authored policy. */ |
| 55 | +const PLAIN = 'u_plain_writer'; |
| 56 | +/** Owns and creates the rows nobody else here does. */ |
| 57 | +const OTHER = 'u_someone_else'; |
| 58 | + |
| 59 | +/** An outcome with its ADR-0112 envelope: what a request answered. */ |
| 60 | +type Outcome = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number }; |
| 61 | +const ADMITTED: Outcome = { kind: 'admitted' }; |
| 62 | +const DENIED: Outcome = { kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }; |
| 63 | + |
| 64 | +const outcomeOf = (p: Promise<unknown>): Promise<Outcome> => |
| 65 | + p.then( |
| 66 | + () => ADMITTED, |
| 67 | + (e: unknown) => { |
| 68 | + const x = e as { code?: unknown; status?: unknown; statusCode?: unknown } | null; |
| 69 | + const status = x?.statusCode ?? x?.status; |
| 70 | + return { |
| 71 | + kind: 'refused' as const, |
| 72 | + code: x?.code == null ? undefined : String(x.code), |
| 73 | + status: status == null ? undefined : Number(status), |
| 74 | + }; |
| 75 | + }, |
| 76 | + ); |
| 77 | + |
| 78 | +/** See the explain-enforce-parity suite: a torn-down rig's async storages are released once its last row is judged. */ |
| 79 | +const releaseAsyncContexts = (...owners: object[]): void => { |
| 80 | + for (const owner of owners) { |
| 81 | + for (const value of Object.values(owner)) if (value instanceof AsyncLocalStorage) value.disable(); |
| 82 | + } |
| 83 | +}; |
| 84 | + |
| 85 | +async function boot(posture: TenancyPosture) { |
| 86 | + const OBJ = 'qa_bounded_record'; |
| 87 | + const MASTER = 'qa_bounded_master'; |
| 88 | + const DETAIL = 'qa_bounded_detail'; |
| 89 | + const engine = new ObjectQL(); |
| 90 | + engine.registerDriver( |
| 91 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, |
| 92 | + true, |
| 93 | + ); |
| 94 | + await engine.init(); |
| 95 | + const fields = { |
| 96 | + id: { name: 'id', type: 'text', primaryKey: true }, |
| 97 | + name: { name: 'name', type: 'text' }, |
| 98 | + note: { name: 'note', type: 'text' }, |
| 99 | + owner_id: { name: 'owner_id', type: 'text' }, |
| 100 | + created_by: { name: 'created_by', type: 'text' }, |
| 101 | + }; |
| 102 | + engine.registerApp({ |
| 103 | + id: `com.objectstack.qa.delegated-write-bound-${posture}`, |
| 104 | + name: 'An on-behalf-of write is bounded by its delegator', |
| 105 | + version: '1.0.0', |
| 106 | + type: 'plugin', |
| 107 | + scope: 'system', |
| 108 | + objects: [ |
| 109 | + SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet, |
| 110 | + SysOrganization, SysUser, SysMember, SysRecordShare, |
| 111 | + { name: OBJ, label: 'Record', sharingModel: 'private', fields }, |
| 112 | + { name: MASTER, label: 'Master', sharingModel: 'private', fields }, |
| 113 | + { |
| 114 | + name: DETAIL, |
| 115 | + label: 'Detail', |
| 116 | + sharingModel: 'controlled_by_parent', |
| 117 | + fields: { |
| 118 | + id: { name: 'id', type: 'text', primaryKey: true }, |
| 119 | + note: { name: 'note', type: 'text' }, |
| 120 | + created_by: { name: 'created_by', type: 'text' }, |
| 121 | + master: { name: 'master', type: 'master_detail', required: true, reference: MASTER }, |
| 122 | + }, |
| 123 | + }, |
| 124 | + ], |
| 125 | + } as never); |
| 126 | + await engine.syncSchemas(); |
| 127 | + |
| 128 | + const e = engine as unknown as { |
| 129 | + insert: (o: string, row: Record<string, unknown>, opts: never) => Promise<unknown>; |
| 130 | + getDriver: (o: string) => { create: (o: string, row: Record<string, unknown>) => Promise<unknown> }; |
| 131 | + logger: { warn: () => void }; |
| 132 | + }; |
| 133 | + await e.insert('sys_organization', { id: ALPHA, name: ALPHA, slug: ALPHA }, SYS); |
| 134 | + for (const id of [BOUNDED, PLAIN, OTHER]) { |
| 135 | + await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); |
| 136 | + await e.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: ALPHA, role: 'member' }, SYS); |
| 137 | + } |
| 138 | + const org = { allowRead: true, allowEdit: true, allowDelete: true, readScope: 'org', writeScope: 'org' } as const; |
| 139 | + const sets = [ |
| 140 | + ...defaultPermissionSets, |
| 141 | + PermissionSetSchema.parse({ |
| 142 | + name: 'qa_bounded_writer', |
| 143 | + objects: { [OBJ]: org, [MASTER]: org, [DETAIL]: { allowRead: true, allowEdit: true, allowDelete: true } }, |
| 144 | + rowLevelSecurity: [ |
| 145 | + { name: 'qa_bounded_updates', object: OBJ, operation: 'update', using: "name != 'held'" }, |
| 146 | + { name: 'qa_bounded_deletes', object: OBJ, operation: 'delete', using: "name != 'held'" }, |
| 147 | + { name: 'qa_bounded_master_updates', object: MASTER, operation: 'update', using: "name != 'held'" }, |
| 148 | + ], |
| 149 | + }), |
| 150 | + PermissionSetSchema.parse({ name: 'qa_plain_writer', objects: { [OBJ]: org } }), |
| 151 | + ]; |
| 152 | + for (const name of ['qa_bounded_writer', 'qa_plain_writer']) { |
| 153 | + await e.getDriver('sys_permission_set').create('sys_permission_set', { |
| 154 | + id: `ps_${name}`, name, label: name, organization_id: null, managed_by: 'admin', active: true, |
| 155 | + object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), |
| 156 | + }); |
| 157 | + } |
| 158 | + for (const [user, set] of [[BOUNDED, 'qa_bounded_writer'], [PLAIN, 'qa_plain_writer']] as const) { |
| 159 | + await e.insert('sys_user_permission_set', { |
| 160 | + id: `ups_${user}`, user_id: user, permission_set: set, organization_id: ALPHA, |
| 161 | + }, SYS); |
| 162 | + } |
| 163 | + const row = (id: string, name: string, owner: string, creator: string) => |
| 164 | + ({ id, name, note: '', owner_id: owner, created_by: creator, organization_id: ALPHA }); |
| 165 | + for (const r of [ |
| 166 | + row('held_1', 'held', BOUNDED, BOUNDED), |
| 167 | + row('held_2', 'held', OTHER, BOUNDED), |
| 168 | + row('free_1', 'free', BOUNDED, BOUNDED), |
| 169 | + row('held_del_direct', 'held', BOUNDED, BOUNDED), |
| 170 | + row('held_del_agent', 'held', BOUNDED, BOUNDED), |
| 171 | + row('free_del_direct', 'free', BOUNDED, BOUNDED), |
| 172 | + row('free_del_agent', 'free', BOUNDED, BOUNDED), |
| 173 | + row('store_direct', 'free', BOUNDED, BOUNDED), |
| 174 | + row('store_agent', 'free', BOUNDED, BOUNDED), |
| 175 | + row('plain_1', 'x', OTHER, OTHER), |
| 176 | + row('plain_2', 'x', PLAIN, OTHER), |
| 177 | + ]) await e.insert(OBJ, r, SYS); |
| 178 | + await e.insert(MASTER, row('m_held', 'held', BOUNDED, BOUNDED), SYS); |
| 179 | + await e.insert(MASTER, row('m_free', 'free', BOUNDED, BOUNDED), SYS); |
| 180 | + for (const [id, master] of [ |
| 181 | + ['d_held', 'm_held'], ['d_free', 'm_free'], ['d_held_del_direct', 'm_held'], ['d_held_del_agent', 'm_held'], |
| 182 | + ] as const) { |
| 183 | + await e.insert(DETAIL, { id, note: '', created_by: BOUNDED, master, organization_id: ALPHA }, SYS); |
| 184 | + } |
| 185 | + |
| 186 | + let security: { explain: (input: unknown, ctx: unknown) => Promise<ExplainDecision> } | undefined; |
| 187 | + let sharing: SharingService | undefined; |
| 188 | + const services: Record<string, unknown> = { |
| 189 | + manifest: { register: vi.fn() }, |
| 190 | + objectql: engine, |
| 191 | + metadata: { |
| 192 | + get: async (type: string, name: string) => |
| 193 | + type === 'permission' ? sets.find((s: { name?: string }) => s.name === name) ?? null : engine.getSchema(name) ?? null, |
| 194 | + list: async () => sets, |
| 195 | + }, |
| 196 | + tenancy: { posture }, |
| 197 | + get sharing() { return sharing; }, |
| 198 | + }; |
| 199 | + const ctx = { |
| 200 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, |
| 201 | + registerService: (name: string, svc: unknown) => { |
| 202 | + if (name === 'security') security = svc as typeof security; |
| 203 | + else services[name] = svc; |
| 204 | + }, |
| 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 | + const 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 | + // The row gates log the writes they refuse. |
| 217 | + vi.spyOn(e.logger, 'warn').mockImplementation(() => undefined); |
| 218 | + |
| 219 | + /** The caller's context as a request assembles it: the user's session, and for an agent its OAuth ceiling. */ |
| 220 | + const contextOf = async (userId: string, onBehalf: boolean) => { |
| 221 | + const authz = await resolveAuthzContext({ |
| 222 | + userAuthGates: undefined, |
| 223 | + apiKeyLifecycle: undefined, |
| 224 | + ql: engine, |
| 225 | + headers: {}, |
| 226 | + getSession: async () => ({ |
| 227 | + user: { id: userId }, |
| 228 | + session: { id: `sess_${userId}_${posture}`, userId, activeOrganizationId: ALPHA }, |
| 229 | + }), |
| 230 | + tenancyPosture: posture, |
| 231 | + }); |
| 232 | + return assembleExecutionContext({ |
| 233 | + authz, |
| 234 | + oauth: onBehalf |
| 235 | + ? { userId, scopes: ['data:write'], clientId: 'qa_agent_client', scopePermissions: ['mcp_agent_data_write'], delegatesActions: false } |
| 236 | + : undefined, |
| 237 | + localization: undefined, |
| 238 | + requestLocale: undefined, |
| 239 | + accessToken: undefined, |
| 240 | + } as never)!; |
| 241 | + }; |
| 242 | + const objects = { OBJ, DETAIL } as const; |
| 243 | + type Obj = keyof typeof objects; |
| 244 | + let n = 0; |
| 245 | + |
| 246 | + return { |
| 247 | + /** Explain's record verdict for the caller's own by-id write of `object/id`. */ |
| 248 | + explainVisible: async (userId: string, onBehalf: boolean, operation: 'update' | 'delete', object: Obj, id: string) => |
| 249 | + (await security!.explain({ object: objects[object], operation, recordId: id }, { ...(await contextOf(userId, onBehalf)) })) |
| 250 | + .record?.visible, |
| 251 | + update: async (userId: string, onBehalf: boolean, object: Obj, id: string) => |
| 252 | + outcomeOf(engine.update(objects[object], { note: `n_${++n}` }, { |
| 253 | + where: { id }, context: { ...(await contextOf(userId, onBehalf)) }, |
| 254 | + } as never)), |
| 255 | + /** A by-id update whose stored row carries a value the delegator's own update may not store. */ |
| 256 | + storeHeld: async (userId: string, onBehalf: boolean, id: string) => |
| 257 | + outcomeOf(engine.update(OBJ, { name: 'held' }, { |
| 258 | + where: { id }, context: { ...(await contextOf(userId, onBehalf)) }, |
| 259 | + } as never)), |
| 260 | + remove: async (userId: string, onBehalf: boolean, object: Obj, id: string) => |
| 261 | + outcomeOf(engine.delete(objects[object], { where: { id }, context: { ...(await contextOf(userId, onBehalf)) } } as never)), |
| 262 | + teardown: async () => { |
| 263 | + try { await engine.destroy(); } catch { /* noop */ } |
| 264 | + releaseAsyncContexts(engine, plugin); |
| 265 | + }, |
| 266 | + }; |
| 267 | +} |
| 268 | + |
| 269 | +type Rig = Awaited<ReturnType<typeof boot>>; |
| 270 | +const rigs = new Map<TenancyPosture, Promise<Rig>>(); |
| 271 | +const rigFor = (posture: TenancyPosture): Promise<Rig> => { |
| 272 | + let rig = rigs.get(posture); |
| 273 | + if (!rig) { |
| 274 | + rig = boot(posture); |
| 275 | + rigs.set(posture, rig); |
| 276 | + } |
| 277 | + return rig; |
| 278 | +}; |
| 279 | +afterAll(async () => { |
| 280 | + for (const rig of rigs.values()) await (await rig.catch(() => undefined))?.teardown(); |
| 281 | +}); |
| 282 | + |
| 283 | +const word = (o: Outcome): string => (o.kind === 'admitted' ? 'admitted' : `refused ${o.code} / ${o.status}`); |
| 284 | + |
| 285 | +/** |
| 286 | + * One by-id write, asked by the delegator itself and by an agent on its |
| 287 | + * behalf. `rows` names the row each asks about (a delete that lands removes |
| 288 | + * its row, so each delete asker has its own). |
| 289 | + */ |
| 290 | +interface Cell { |
| 291 | + delegator: string; |
| 292 | + verb: 'update' | 'delete'; |
| 293 | + object: 'OBJ' | 'DETAIL'; |
| 294 | + rows: { direct: string; agent: string }; |
| 295 | + shape: string; |
| 296 | + direct: Outcome; |
| 297 | + agent: Outcome; |
| 298 | +} |
| 299 | + |
| 300 | +const CELLS: Cell[] = [ |
| 301 | + { |
| 302 | + delegator: BOUNDED, verb: 'update', object: 'OBJ', rows: { direct: 'held_1', agent: 'held_1' }, |
| 303 | + shape: 'a row the delegator\'s own update refuses', direct: DENIED, agent: DENIED, |
| 304 | + }, |
| 305 | + { |
| 306 | + delegator: BOUNDED, verb: 'update', object: 'OBJ', rows: { direct: 'held_2', agent: 'held_2' }, |
| 307 | + shape: 'a second row the delegator\'s own update refuses', direct: DENIED, agent: DENIED, |
| 308 | + }, |
| 309 | + { |
| 310 | + delegator: BOUNDED, verb: 'update', object: 'OBJ', rows: { direct: 'free_1', agent: 'free_1' }, |
| 311 | + shape: 'the control: a row the delegator\'s own update admits', direct: ADMITTED, agent: ADMITTED, |
| 312 | + }, |
| 313 | + { |
| 314 | + delegator: BOUNDED, verb: 'delete', object: 'OBJ', rows: { direct: 'held_del_direct', agent: 'held_del_agent' }, |
| 315 | + shape: 'a row the delegator\'s own delete refuses', direct: DENIED, agent: DENIED, |
| 316 | + }, |
| 317 | + { |
| 318 | + delegator: BOUNDED, verb: 'delete', object: 'OBJ', rows: { direct: 'free_del_direct', agent: 'free_del_agent' }, |
| 319 | + shape: 'the control: a row the delegator\'s own delete admits', direct: ADMITTED, agent: ADMITTED, |
| 320 | + }, |
| 321 | + { |
| 322 | + delegator: BOUNDED, verb: 'update', object: 'DETAIL', rows: { direct: 'd_held', agent: 'd_held' }, |
| 323 | + shape: 'a detail whose master refuses the delegator\'s own detail update', direct: DENIED, agent: DENIED, |
| 324 | + }, |
| 325 | + { |
| 326 | + delegator: BOUNDED, verb: 'delete', object: 'DETAIL', rows: { direct: 'd_held_del_direct', agent: 'd_held_del_agent' }, |
| 327 | + shape: 'a detail whose master refuses the delegator\'s own detail delete', direct: DENIED, agent: DENIED, |
| 328 | + }, |
| 329 | + { |
| 330 | + delegator: BOUNDED, verb: 'update', object: 'DETAIL', rows: { direct: 'd_free', agent: 'd_free' }, |
| 331 | + shape: 'the control: a detail whose master admits the delegator\'s own detail update', direct: ADMITTED, agent: ADMITTED, |
| 332 | + }, |
| 333 | + // The bound only narrows: rows the delegator's own write admits and the |
| 334 | + // delegated write has always refused stay refused. |
| 335 | + { |
| 336 | + delegator: PLAIN, verb: 'update', object: 'OBJ', rows: { direct: 'plain_1', agent: 'plain_1' }, |
| 337 | + shape: 'unchanged: a row the delegator\'s own update admits and the delegated update refuses', direct: ADMITTED, agent: DENIED, |
| 338 | + }, |
| 339 | + { |
| 340 | + delegator: PLAIN, verb: 'update', object: 'OBJ', rows: { direct: 'plain_2', agent: 'plain_2' }, |
| 341 | + shape: 'unchanged: a second row the delegator\'s own update admits and the delegated update refuses', direct: ADMITTED, agent: DENIED, |
| 342 | + }, |
| 343 | +]; |
| 344 | + |
| 345 | +describe('an on-behalf-of by-id write never admits a row its delegator\'s own by-id write refuses (ADR-0090 D10)', () => { |
| 346 | + for (const posture of ['isolated', 'single'] as const) { |
| 347 | + for (const c of CELLS) { |
| 348 | + it(`\`${posture}\` · ${c.verb} · ${c.shape} → the delegator's own: ${word(c.direct)}; on its behalf: ${word(c.agent)}`, async () => { |
| 349 | + const rig = await rigFor(posture); |
| 350 | + const ask = (onBehalf: boolean, id: string) => |
| 351 | + c.verb === 'update' ? rig.update(c.delegator, onBehalf, c.object, id) : rig.remove(c.delegator, onBehalf, c.object, id); |
| 352 | + // Explain first: a delete that lands removes the row it explains. |
| 353 | + const explainedDirect = await rig.explainVisible(c.delegator, false, c.verb, c.object, c.rows.direct); |
| 354 | + const explainedAgent = await rig.explainVisible(c.delegator, true, c.verb, c.object, c.rows.agent); |
| 355 | + const direct = await ask(false, c.rows.direct); |
| 356 | + const agent = await ask(true, c.rows.agent); |
| 357 | + expect(direct, 'the delegator\'s own write').toStrictEqual(c.direct); |
| 358 | + expect(agent, 'the write on its behalf').toStrictEqual(c.agent); |
| 359 | + // Explain's record verdict is the door's, for both callers. |
| 360 | + expect(explainedDirect, 'explain, the delegator\'s own write').toBe(c.direct.kind === 'admitted'); |
| 361 | + expect(explainedAgent, 'explain, the write on its behalf').toBe(c.agent.kind === 'admitted'); |
| 362 | + }, 60_000); |
| 363 | + } |
| 364 | + |
| 365 | + it(`\`${posture}\` · update · a row the delegator's own update may not store → refused on its behalf as for the delegator`, async () => { |
| 366 | + const rig = await rigFor(posture); |
| 367 | + expect(await rig.storeHeld(BOUNDED, false, 'store_direct'), 'the delegator\'s own write').toStrictEqual(DENIED); |
| 368 | + expect(await rig.storeHeld(BOUNDED, true, 'store_agent'), 'the write on its behalf').toStrictEqual(DENIED); |
| 369 | + }, 60_000); |
| 370 | + } |
| 371 | +}); |
0 commit comments