diff --git a/.changeset/24962-cbp-master-object-read.md b/.changeset/24962-cbp-master-object-read.md new file mode 100644 index 00000000000..477e887e711 --- /dev/null +++ b/.changeset/24962-cbp-master-object-read.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): a `controlled_by_parent` detail's read scope requires object-level read on its master (ADR-0055 §3) + +Clause-②: no + +ADR-0055 §3 says reading a detail requires read access to its master, and §4 says a member who cannot read the master can neither read nor by-id-write a detail under it. The master-derived read scope of a `controlled_by_parent` object scoped the master's rows, but it did not ask whether the principal may read the master object at all. It now asks first, through the predicate `canReadObject` answers. A principal the master's own read door refuses at the object gate gets an empty master set, so it reads no detail under that master. + +- **Every door that composes the scope.** The engine read door (`find`, `findOne`, `count`), the delegator half of an on-behalf-of read (ADR-0090 D10), `getReadFilter`, and every hop of a chain all answer this way. +- **The write doors follow.** A by-id update or delete of such a detail answers the read door's not-found (`RECORD_NOT_FOUND`, 404), as it does for any row the principal cannot read. A predicate update or delete matches no such detail. +- **The engine already gave this answer.** Its ADR-0139 `ReadableIds` leaf asks `canReadObject` before it scopes a parent's rows. The id-list derivation and the leaf now agree. +- **Unchanged.** A principal that may read the master object keeps its previous answer, whether or not its master rows are scoped. So do a system context, a super-user grant, and an on-behalf-of read whose delegator may read the master. diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-master-object-read.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-master-object-read.test.ts new file mode 100644 index 00000000000..d8d77f076b1 --- /dev/null +++ b/packages/plugins/plugin-security/src/controlled-by-parent-master-object-read.test.ts @@ -0,0 +1,437 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0055 §3 / §4] Reading a `controlled_by_parent` detail requires READ + * access to its master, and the master's OBJECT-level read admission is part + * of that access. + * + * §3: "Reading a detail requires read access to its master". §4: a member who + * cannot read master M "can neither read nor by-id-write a detail D under M". + * + * The master-derived read scope (`computeControlledByParentFilter`) scopes the + * master's ROWS under a system context. What is pinned here is that it asks the + * master's object-level read admission first — the predicate the master's own + * read door and `canReadObject` answer — at every door that composes it: + * + * - the engine read door (`find`, `findOne`, `count`): the caller half; + * - the same door on an on-behalf-of read: the ADR-0090 D10 delegator half; + * - `getReadFilter`, the read scope a door that bypasses the engine ANDs in; + * - every hop of a chain, since the derivation recurses through itself; + * - the write doors of §4, which ask the read door's question first (a by-id + * write) or are scoped by it (a predicate write). + * + * The controls hold the change to exactly that rule. A principal who may read + * the master object keeps its answer, whether its master rows are scoped or + * not, and so do a system context, a super-user grant and an on-behalf-of read + * whose delegator may read the master. + * + * Real stack: `ObjectQL` over better-sqlite3, the real `SecurityPlugin`, and + * the on-behalf-of context assembled by `resolveAuthzContext` and + * `assembleExecutionContext` with the `mcp_agent_data_read` OAuth ceiling. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveAuthzContext, assembleExecutionContext } from '@objectstack/core'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; + +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { context: { isSystem: true } } as never; +const ORG = 'org_cbp_mor'; + +/** The master: it governs its own rows. */ +const MASTER = 'qa_cbp_mor_master'; +/** A detail of the master. */ +const DETAIL = 'qa_cbp_mor_detail'; +/** A detail of the detail: the chain's second hop. */ +const LINE = 'qa_cbp_mor_line'; + +const ME = 'u_cbp_mor_me'; +const NOBODY = 'u_cbp_mor_nobody'; +const OTHER = 'u_cbp_mor_other'; +/** A delegator whose grants do not admit the master object. */ +const DELEGATOR_UNADMITTED = 'u_cbp_mor_delegator_unadmitted'; +/** A delegator whose grants admit it. */ +const DELEGATOR_ADMITTED = 'u_cbp_mor_delegator_admitted'; + +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } as const; + +/** + * The permission sets. Every one admits the detail objects it is used on; they + * differ in whether the master object's read door admits the holder. + */ +const SET = { + /** The master object's read door does not admit the holder (first set). */ + unadmittedA: 'qa_cbp_mor_unadmitted_a', + /** The master object's read door does not admit the holder (second set). */ + unadmittedB: 'qa_cbp_mor_unadmitted_b', + /** Admitted to the master object, with its rows scoped to the holder's own. */ + masterReadRowScoped: 'qa_cbp_mor_master_read_row_scoped', + /** Admitted to the master object, rows unscoped. */ + masterRead: 'qa_cbp_mor_master_read', + /** Admitted to the line and to the root master, not to the detail between them. */ + noMiddleGrant: 'qa_cbp_mor_no_middle_grant', +} as const; + +type Outcome = { kind: 'landed' } | { kind: 'refused'; code?: string; status?: number }; + +const outcomeOf = (p: Promise): Promise => + p.then( + () => ({ kind: 'landed' as const }), + (e: unknown) => { + const x = e as { code?: unknown; status?: unknown; statusCode?: unknown } | null; + const status = x?.statusCode ?? x?.status; + return { + kind: 'refused' as const, + code: x?.code == null ? undefined : String(x.code), + status: status == null ? undefined : Number(status), + }; + }, + ); + +/** Release a torn-down rig's async storages (see the explain-enforce-parity suite). */ +const releaseAsyncContexts = (...owners: object[]): void => { + for (const owner of owners) { + for (const value of Object.values(owner)) if (value instanceof AsyncLocalStorage) value.disable(); + } +}; + +async function boot() { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.cbp-master-object-read', + name: 'A controlled_by_parent detail needs object-level read on its master', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet, + SysOrganization, SysUser, SysMember, + { + name: MASTER, + label: 'Master', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + owner: { name: 'owner', type: 'text' }, + }, + }, + { + name: DETAIL, + label: 'Detail', + sharingModel: 'controlled_by_parent', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + note: { name: 'note', type: 'text' }, + master: { name: 'master', type: 'master_detail', required: true, reference: MASTER }, + }, + }, + { + name: LINE, + label: 'Line', + sharingModel: 'controlled_by_parent', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + note: { name: 'note', type: 'text' }, + detail: { name: 'detail', type: 'master_detail', required: true, reference: DETAIL }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + + const e = engine as unknown as { + insert: (o: string, row: Record, opts: never) => Promise; + getDriver: (o: string) => { create: (o: string, row: Record) => Promise }; + logger: { warn: () => void }; + }; + await e.insert('sys_organization', { id: ORG, name: ORG, slug: ORG }, SYS); + for (const id of [ME, NOBODY, OTHER, DELEGATOR_UNADMITTED, DELEGATOR_ADMITTED]) { + await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); + await e.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: ORG, role: 'member' }, SYS); + } + const sets = [ + ...defaultPermissionSets, + PermissionSetSchema.parse({ name: SET.unadmittedA, objects: { [DETAIL]: CRUD, [LINE]: CRUD } }), + PermissionSetSchema.parse({ + name: SET.unadmittedB, + objects: { [MASTER]: { allowEdit: true }, [DETAIL]: CRUD, [LINE]: CRUD }, + }), + PermissionSetSchema.parse({ + name: SET.masterReadRowScoped, + objects: { [MASTER]: { allowRead: true, allowEdit: true }, [DETAIL]: CRUD, [LINE]: CRUD }, + rowLevelSecurity: [ + { name: 'qa_cbp_mor_master_owner', object: MASTER, operation: 'select', using: 'record.owner == current_user.id' }, + ], + }), + PermissionSetSchema.parse({ + name: SET.masterRead, + objects: { [MASTER]: { allowRead: true, allowEdit: true }, [DETAIL]: CRUD, [LINE]: CRUD }, + }), + PermissionSetSchema.parse({ + name: SET.noMiddleGrant, + objects: { [MASTER]: { allowRead: true, allowEdit: true }, [LINE]: CRUD }, + }), + ]; + // The delegators hold their sets through stored grants, as a user does. + for (const name of [SET.unadmittedA, SET.masterRead]) { + await e.getDriver('sys_permission_set').create('sys_permission_set', { + id: `ps_${name}`, name, label: name, organization_id: null, managed_by: 'admin', active: true, + object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), + }); + } + for (const [user, set] of [[DELEGATOR_UNADMITTED, SET.unadmittedA], [DELEGATOR_ADMITTED, SET.masterRead]] as const) { + await e.insert('sys_user_permission_set', { id: `ups_${user}`, user_id: user, permission_set: set, organization_id: ORG }, SYS); + } + + await e.insert(MASTER, { id: 'm_mine', name: 'mine', owner: ME }, SYS); + await e.insert(MASTER, { id: 'm_other', name: 'other', owner: OTHER }, SYS); + await e.insert(DETAIL, { id: 'd_mine', note: '', master: 'm_mine' }, SYS); + await e.insert(DETAIL, { id: 'd_other', note: '', master: 'm_other' }, SYS); + await e.insert(LINE, { id: 'l_mine', note: '', detail: 'd_mine' }, SYS); + await e.insert(LINE, { id: 'l_other', note: '', detail: 'd_other' }, SYS); + + let security: { getReadFilter: (o: string, c: unknown) => Promise | undefined> } | undefined; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' ? sets.find((s: { name?: string }) => s.name === name) ?? null : engine.getSchema(name) ?? null, + list: async () => sets, + }, + tenancy: { posture: 'single' }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { + if (name === 'security') security = svc as typeof security; + else services[name] = svc; + }, + hook: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + // The row gates log the writes they refuse. + vi.spyOn(e.logger, 'warn').mockImplementation(() => undefined); + + /** An agent's context for `userId`, as a request assembles it: the user's session and the OAuth read ceiling. */ + const onBehalfOf = async (userId: string) => { + const authz = await resolveAuthzContext({ + userAuthGates: undefined, + apiKeyLifecycle: undefined, + ql: engine, + headers: {}, + getSession: async () => ({ + user: { id: userId }, + session: { id: `sess_${userId}`, userId, activeOrganizationId: ORG }, + }), + tenancyPosture: 'single', + } as never); + return assembleExecutionContext({ + authz, + oauth: { userId, scopes: ['data:read'], clientId: 'qa_agent_client', scopePermissions: ['mcp_agent_data_read'], delegatesActions: false }, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + } as never)!; + }; + + const ids = (rows: unknown): string[] => + (Array.isArray(rows) ? rows : [rows]) + .filter(Boolean) + .map((r) => String((r as { id: unknown }).id)) + .sort(); + + return { + engine, + onBehalfOf, + /** The ids a context reads from `object` through the engine read door. */ + read: async (object: string, context: unknown) => ids(await engine.find(object, { context: { ...(context as object) } } as never)), + readOne: async (object: string, id: string, context: unknown) => + ids(await engine.findOne(object, { where: { id }, context: { ...(context as object) } } as never)), + count: (object: string, context: unknown) => engine.count(object, { context: { ...(context as object) } } as never), + readMaster: (context: unknown) => outcomeOf(engine.find(MASTER, { context: { ...(context as object) } } as never)), + /** The ids `getReadFilter`'s scope admits, read with it as the only predicate. */ + readThroughReadFilter: async (object: string, context: unknown) => { + const scope = await security!.getReadFilter(object, { ...(context as object) }); + return ids(await engine.find(object, { where: scope ?? {}, context: { isSystem: true } } as never)); + }, + stored: async (object: string, id: string) => + ((await engine.findOne(object, { where: { id }, context: { isSystem: true } } as never)) ?? null) as Record | null, + seedDetail: (id: string) => e.insert(DETAIL, { id, note: 'seeded', master: 'm_mine' }, SYS), + teardown: async () => { + try { await engine.destroy(); } catch { /* noop */ } + releaseAsyncContexts(engine, plugin); + }, + }; +} + +type Rig = Awaited>; + +const member = (userId: string, set: string) => ({ userId, positions: [], permissions: [set], posture: 'MEMBER' }); + +/** Two principals the master's own read door refuses at the object gate. */ +const UNADMITTED = [ + { label: 'unadmitted principal A', context: member(ME, SET.unadmittedA) }, + { label: 'unadmitted principal B', context: member(ME, SET.unadmittedB) }, +] as const; + +describe('a controlled_by_parent detail is not read without object-level read on its master', () => { + let rig: Rig; + beforeAll(async () => { rig = await boot(); }); + afterAll(async () => { await rig.teardown(); }); + + for (const { label, context } of UNADMITTED) { + it(`${label}: the master's own read door refuses (the precondition)`, async () => { + expect(await rig.readMaster(context)).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + }); + + it(`${label}: find, findOne and count read no detail`, async () => { + expect(await rig.read(DETAIL, context)).toEqual([]); + expect(await rig.readOne(DETAIL, 'd_mine', context)).toEqual([]); + expect(await rig.count(DETAIL, context)).toBe(0); + }); + + it(`${label}: the getReadFilter scope admits no detail`, async () => { + expect(await rig.readThroughReadFilter(DETAIL, context)).toEqual([]); + }); + + it(`${label}: a detail two hops down is not read either`, async () => { + expect(await rig.read(LINE, context)).toEqual([]); + }); + } + + it('a detail whose master is itself controlled_by_parent is not read without object-level read on that master', async () => { + // Read on the line and on the root master, nothing on the detail between them. + const context = member(ME, SET.noMiddleGrant); + expect(await rig.read(MASTER, context)).toEqual(['m_mine', 'm_other']); + expect(await rig.read(LINE, context)).toEqual([]); + }); + + it("on an on-behalf-of read, the delegator's object-level read on the master is required too", async () => { + const context = await rig.onBehalfOf(DELEGATOR_UNADMITTED); + expect(await rig.readMaster(context)).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect(await rig.read(DETAIL, context)).toEqual([]); + expect(await rig.read(LINE, context)).toEqual([]); + }); +}); + +describe('a principal the master object admits keeps its answer', () => { + let rig: Rig; + beforeAll(async () => { rig = await boot(); }); + afterAll(async () => { await rig.teardown(); }); + + it('master read, rows scoped: exactly the details under the master rows it reads', async () => { + const context = member(ME, SET.masterReadRowScoped); + expect(await rig.read(MASTER, context)).toEqual(['m_mine']); + expect(await rig.read(DETAIL, context)).toEqual(['d_mine']); + expect(await rig.read(LINE, context)).toEqual(['l_mine']); + expect(await rig.readThroughReadFilter(DETAIL, context)).toEqual(['d_mine']); + }); + + it('master read, no master row in scope: no detail, as before', async () => { + const context = member(NOBODY, SET.masterReadRowScoped); + expect(await rig.read(MASTER, context)).toEqual([]); + expect(await rig.read(DETAIL, context)).toEqual([]); + expect(await rig.read(LINE, context)).toEqual([]); + }); + + it('master read, rows unscoped: every detail', async () => { + const context = member(ME, SET.masterRead); + expect(await rig.read(DETAIL, context)).toEqual(['d_mine', 'd_other']); + expect(await rig.read(LINE, context)).toEqual(['l_mine', 'l_other']); + expect(await rig.count(DETAIL, context)).toBe(2); + expect(await rig.readThroughReadFilter(DETAIL, context)).toEqual(['d_mine', 'd_other']); + }); + + it('a super-user grant: every detail', async () => { + const context = { userId: 'u_cbp_mor_admin', positions: [], permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN' }; + expect(await rig.read(DETAIL, context)).toEqual(['d_mine', 'd_other']); + expect(await rig.read(LINE, context)).toEqual(['l_mine', 'l_other']); + }); + + it('a system context: every detail', async () => { + expect(await rig.read(DETAIL, { isSystem: true })).toEqual(['d_mine', 'd_other']); + expect(await rig.read(LINE, { isSystem: true })).toEqual(['l_mine', 'l_other']); + }); + + it('an on-behalf-of read whose delegator reads the master: every detail', async () => { + const context = await rig.onBehalfOf(DELEGATOR_ADMITTED); + expect(await rig.read(DETAIL, context)).toEqual(['d_mine', 'd_other']); + expect(await rig.read(LINE, context)).toEqual(['l_mine', 'l_other']); + }); +}); + +describe('the write doors (§4): no detail is written without object-level read on its master', () => { + let rig: Rig; + beforeAll(async () => { rig = await boot(); }); + afterAll(async () => { await rig.teardown(); }); + + UNADMITTED.forEach(({ label, context }, i) => { + it(`${label}: a by-id update or delete of a detail answers the read door's not-found, and writes nothing`, async () => { + const updated = `d_update_${i}`; + const deleted = `d_delete_${i}`; + await rig.seedDetail(updated); + await rig.seedDetail(deleted); + + expect(await outcomeOf(rig.engine.update(DETAIL, { note: 'written' }, { where: { id: updated }, context: { ...context } } as never))) + .toMatchObject({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 }); + expect(await outcomeOf(rig.engine.delete(DETAIL, { where: { id: deleted }, context: { ...context } } as never))) + .toMatchObject({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 }); + + expect((await rig.stored(DETAIL, updated))?.note).toBe('seeded'); + expect(await rig.stored(DETAIL, deleted)).toBeTruthy(); + }); + + it(`${label}: an insert of a detail under the master is refused`, async () => { + // §4's third write. Refused by gates ahead of the read scope (the master + // gate, the reference check), so it stands with or without it; pinned so + // the suite covers every by-id write §4 names. + const got = await outcomeOf(rig.engine.insert(DETAIL, { id: `d_insert_${i}`, note: '', master: 'm_mine' }, { context: { ...context } } as never)); + expect(got.kind).toBe('refused'); + expect(await rig.stored(DETAIL, `d_insert_${i}`)).toBeNull(); + }); + + it(`${label}: a predicate update matches no detail`, async () => { + const mark = `bulk_${i}`; + expect(await outcomeOf(rig.engine.update(DETAIL, { note: mark }, { where: { note: { $ne: mark } }, multi: true, context: { ...context } } as never))) + .toEqual({ kind: 'landed' }); + expect(await rig.engine.count(DETAIL, { where: { note: mark }, context: { isSystem: true } } as never)).toBe(0); + }); + }); + + it('the control: a principal that reads the master object writes a detail by id, inserts one and updates by predicate', async () => { + const context = member(ME, SET.masterRead); + await rig.seedDetail('d_update_control'); + expect(await outcomeOf(rig.engine.update(DETAIL, { note: 'written' }, { where: { id: 'd_update_control' }, context: { ...context } } as never))) + .toEqual({ kind: 'landed' }); + expect((await rig.stored(DETAIL, 'd_update_control'))?.note).toBe('written'); + expect(await outcomeOf(rig.engine.insert(DETAIL, { id: 'd_insert_control', note: '', master: 'm_mine' }, { context: { ...context } } as never))) + .toEqual({ kind: 'landed' }); + expect(await outcomeOf(rig.engine.update(DETAIL, { note: 'bulk_control' }, { where: { note: { $ne: 'bulk_control' } }, multi: true, context: { ...context } } as never))) + .toEqual({ kind: 'landed' }); + expect(await rig.engine.count(DETAIL, { where: { note: 'bulk_control' }, context: { isSystem: true } } as never)) + .toBe(await rig.engine.count(DETAIL, { context: { isSystem: true } } as never)); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 25d6a049dc7..a774fe4e1dd 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -6330,11 +6330,6 @@ export class SecurityPlugin implements Plugin { // empty list grants nothing, which is the middleware's step 2 answer. if (permissionSets.length === 0) return false; - const { isPrivate, unresolved, requiredPermissions } = - await this.getObjectSecurityMeta(objectName); - // 3. [#3545] Posture unresolvable → deny. - if (unresolved) return false; - // [ADR-0090 D10] Resolve the delegator ONCE — arms 4 and 6 both need it, // and a dangling link denies before either runs. let delegatorSets: PermissionSet[] | null = null; @@ -6346,36 +6341,9 @@ export class SecurityPlugin implements Plugin { } } - // 4. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both - // principals — a caller missing any required capability is denied - // however permissive their grants are. - const required = requiredCapsForOperation(requiredPermissions, 'find'); - if (required.length > 0) { - const held = this.permissionEvaluator.getSystemPermissions(permissionSets); - if (required.some((cap) => !held.has(cap))) return false; - if (delegatorSets && delegatorSets.length > 0) { - const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); - if (required.some((cap) => !delHeld.has(cap))) return false; - } - } - - // 5. The object-level CRUD grant. - if (!this.permissionEvaluator.checkObjectPermission('find', objectName, permissionSets, { isPrivate })) { - return false; - } - - // 6. [ADR-0090 D10] The delegator must independently grant the same read. - // [#21079] Asked over the delegator's list whatever its length, as the - // middleware asks it: a delegator who resolves NO set grants nothing, - // the deny baseline for the second principal too. - if ( - delegatorSets && - !this.permissionEvaluator.checkObjectPermission('find', objectName, delegatorSets, { isPrivate }) - ) { - return false; - } - - return true; + // 3–6. The posture and grant arms, over the resolved sets — the one + // predicate the ADR-0055 master-derived read scope asks as well. + return await this.objectReadAdmission(objectName, permissionSets, delegatorSets); } catch (e) { this.logger.error?.( `[security] canReadObject could not resolve the object-level read admission for ` + @@ -6386,6 +6354,81 @@ export class SecurityPlugin implements Plugin { } } + /** + * Arms 3–6 of {@link canReadObject} — the OBJECT-level read admission over + * permission sets the caller has ALREADY resolved: the posture arm, the + * ADR-0066 D3/⑤ capability AND-gate, the `allowRead` CRUD grant, and the + * ADR-0090 D10 delegator's same two questions when `delegatorSets` is given. + * Each is the primitive the middleware's own read gate calls (steps 1, 1.5 + * and 2), in that order. + * + * Two askers, one rule: + * + * - {@link canReadObject}, which resolves the context first (system bypass, + * principal-less refusal, sets, delegator) and then asks this; + * - [ADR-0055 §3] {@link computeControlledByParentFilter}, which asks it of + * the MASTER for the principal whose sets it was handed — the caller's on + * the caller half, the delegator's on the D10 half — before it scopes the + * master's rows. "Reading a detail requires read access to its master", + * and the object-level admission is the first half of that access. + * + * ⛔ There is no second copy of this rule: a reader that needs the object-level + * read answer for sets it already holds asks this, and one that holds only a + * context asks {@link canReadObject}. + * + * - [#21079] An empty list grants nothing (ADR-0056 D2). + * - 3. [#3545] An unresolvable posture denies. + * - 4. The capabilities the object requires for a read, held by the caller + * and (D10) by a delegator who resolved any set. + * - 5. The caller's `find` grant, with the posture's `isPrivate` (ADR-0066 D2). + * - 6. [ADR-0090 D10] The delegator's `find` grant, asked over its list + * whatever its length, as the middleware asks it: a delegator who resolves + * NO set grants nothing, the deny baseline for the second principal too. + * + * ⛔ Object-level ONLY, like {@link canReadObject}: `true` never means + * "unrestricted". A throw propagates; each asker fails closed on it. + */ + private async objectReadAdmission( + objectName: string, + permissionSets: PermissionSet[], + delegatorSets: PermissionSet[] | null, + ): Promise { + if (permissionSets.length === 0) return false; + + const { isPrivate, unresolved, requiredPermissions } = + await this.getObjectSecurityMeta(objectName); + // 3. [#3545] Posture unresolvable → deny. + if (unresolved) return false; + + // 4. [ADR-0066 D3/⑤] The capability AND-gate, ahead of the grant, for both + // principals — a caller missing any required capability is denied + // however permissive their grants are. + const required = requiredCapsForOperation(requiredPermissions, 'find'); + if (required.length > 0) { + const held = this.permissionEvaluator.getSystemPermissions(permissionSets); + if (required.some((cap) => !held.has(cap))) return false; + if (delegatorSets && delegatorSets.length > 0) { + const delHeld = this.permissionEvaluator.getSystemPermissions(delegatorSets); + if (required.some((cap) => !delHeld.has(cap))) return false; + } + } + + // 5. The object-level CRUD grant. + if (!this.permissionEvaluator.checkObjectPermission('find', objectName, permissionSets, { isPrivate })) { + return false; + } + + // 6. [ADR-0090 D10] The delegator must independently grant the same read. + if ( + delegatorSets && + !this.permissionEvaluator.checkObjectPermission('find', objectName, delegatorSets, { isPrivate }) + ) { + return false; + } + + return true; + } + /** * [#18682] Whether `context` may CREATE or UPDATE `object` under the arms * enumerated below — the WRITE admission the preview asks for, and the @@ -8493,6 +8536,14 @@ export class SecurityPlugin implements Plugin { * `buildReadFilter` returns null for a non-`private` master — so the derived * set stays point-for-point equal to what a direct find of the master returns. * + * [ADR-0055 §3] Both halves scope ROWS. Ahead of them the master's OBJECT-level + * read admission is asked ({@link objectReadAdmission}, the predicate + * `canReadObject` asks), for the principal whose sets this call was handed: a + * principal the master's own read door refuses at the object gate gets the + * empty master set, so the equality with a direct find of the master holds for + * that principal too, and with the engine's ReadableIds leaf, which asks + * `canReadObject` before it scopes a parent's rows. + * * An empty set yields `{ masterFK: { $in: [] } }`, which matches no rows (fail * closed), as does a failure to resolve the sharing half. A misconfigured * object (not exactly one required master_detail to derive from, ADR-0055 §1) @@ -8575,6 +8626,32 @@ export class SecurityPlugin implements Plugin { return { [rel.fk]: { $in: [] } }; } + // [ADR-0055 §3] "Reading a detail requires READ access to its master", and + // the OBJECT-level read admission is the first half of that access — the + // half the master's own read door asks before any row filter, and the half + // the engine's ADR-0139 ReadableIds leaf asks (`canReadObject`). Asked here + // through the one predicate both read ({@link objectReadAdmission}), for + // the principal whose sets this call was handed, before the master's rows + // are scoped below under a system context. A principal the master's own + // door does not admit reads no master, so the master set is empty and no + // detail is read; at every hop of a chain alike, since the recursion below + // asks the same question of the next master. A throw denies too + // (fail-closed), as the sharing half's resolution failure does. + let masterObjectReadable: boolean; + try { + masterObjectReadable = await this.objectReadAdmission(rel.master, permissionSets, null); + } catch (e) { + this.logger.error?.( + `[security] controlled_by_parent derivation could not resolve the object-level read admission ` + + `of master '${rel.master}' for '${object}' (user ${context?.userId ?? 'unknown'}) ` + + `— denying (fail-closed: a child is readable only where its master is, so a master whose ` + + `admission cannot be resolved admits no child)`, + e instanceof Error ? e : new Error(String(e)), + ); + return { [rel.fk]: { $in: [] } }; + } + if (!masterObjectReadable) return { [rel.fk]: { $in: [] } }; + const masterRlsFilter = await this.computeRlsFilter(permissionSets, rel.master, 'find', context); // [#5386] The OWD / record-share half, resolved through the SAME helper // `getReadFilter` uses, so the derived path and the direct path cannot