Skip to content

Commit b7b04e5

Browse files
fix(plugin-approvals,plugin-sharing): the approver a pending step waits on reads the record its request is about (#26397)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQ8cRQ3qrqydKXXbAgwLfCm)_ Fixes #26180 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:审批请求处于待审时,当前步骤审批人名单上的人(按用户 id 或账号邮箱)和覆盖权持有人,可以只读打开该请求对应的那一条记录;步骤一结束就收回。 - 为什么改:审批人此前常常打不开要审的记录,只能看快照就批合同;这次按裁决 A 从名单推导读权,不新增任何元数据键或应用声明。 - 风险与回滚:新增的是一条只读的行级读权,写入口径不读它;查不到或审批服务缺席时一律不授(fail-closed),也不跨组织。回滚即 revert 本 PR,无数据迁移。 - 先看:`plugin-approvals/src/pending-approver-read-grant.ts`(谁有读权、组织围栏、按请求缓存)、`plugin-sharing/src/sharing-service.ts` 的 `buildReadFilter` 并入处与 fail-closed 分支、`plugin-security/src/explain-engine.ts` 的 approval request 归因。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/approval-pending-approver-record-read.dogfood.test.ts`,预期 1 passed:审批人 200、陌生人 404、PATCH 403、批准/驳回后 404。 - 需要你做的:无,席位落地。 ## What changes While an approval request is pending, each **person** on its current step's slate and the **override holder** may **read** the one record the request is about. The grant is derived from state plugin-approvals already keeps (the pending-approver index `sys_approval_approver`), so nothing is declared: no metadata key, no app setting (ruling 6429330267, letter A). - **`@objectstack/plugin-approvals`**: new `PendingApproverReadGrantSource` (`pending-approver-read-grant.ts`), exposed as `ApprovalService.pendingApproverReadGrants(object, context)`, which answers `record id → pending request ids`. - A person is a slot naming the caller's user id or the email their own account carries, taken from `actingAddresses` with no positions (the two addresses `slateNamesAPerson` calls a person). A `position:`/`team:` literal names no person and grants nothing. - The override holder is `isOverrideActor` asked with the caller's active organization: every pending request of that organization. - Both probes are fenced to the caller's active organization (an org-less caller reaches org-less requests only). The platform admin's override crosses the wall to decide, not to read. - The request row is re-read with `status: 'pending'`, and the index only holds rows while pending, so a step that is approved, rejected or advanced stops granting at the next read. - Cost: memoized per execution context and per engine write epoch (the `permissionSetMemo` precedent). A person's first read is one `sys_user` read and one indexed `sys_approval_approver` read; a caller on no slate stops there for every object. A caller on a slate, or the override holder, pays one `sys_approval_request` read per object. Never a read per row. Only settled answers are memoized; a failed read rejects and is asked again next time. - **`@objectstack/plugin-sharing`**: `SharingService.buildReadFilter` ORs those record ids into the `id $in` arm beside the caller's record shares, so every read door composing the sharing filter serves them. The probe is late-bound to the `approvals` service and fails closed: no approvals service, no member, a lookup or read that throws, or an on-behalf-of principal (ADR-0090 D10) all grant nothing, with one warn line when a read fails. Write gates read `sys_record_share` access levels only, so the member cannot write. New `listApprovalReadGrants(object, recordId, context)` for explain. - **`@objectstack/plugin-security`**: `explain` attributes the grant on the sharing layer as a `record_share` rule with `via: "approval request N"`: `admits` on read, `neutral` on writes. A failing lookup attributes nothing. - Attachments: no new child reach. ADR-0104's parent-read inheritance follows the record read as for any reader. #8652 is untouched. ## Tests - `packages/qa/dogfood/test/approval-pending-approver-record-read.dogfood.test.ts` (new, real `bootStack`, red first on main): a private object, a two-step flow with approvers by email. The first approver gets GET 200 and the record in the list; a stranger gets 404; the approver's PATCH is 403 with nothing landed; `POST /security/explain` names "approval request N"; the override holder (`organization_admin_no_bypass`) reads; approving advances the step (first approver 404, second approver 200); the final approval revokes (second approver and overrider 404); a rejection revokes. - `packages/plugins/plugin-approvals/src/pending-approver-read-grant.integration.test.ts` (new, ObjectQL + SqlDriver on better-sqlite3): person by id and by email, position literal grants nobody, stranger/submitter get nothing; override holder; decided requests (approve, reject) grant nothing, also on the same context; the org wall holds, for the platform admin and for an org-less caller too; the cost pin (five reads of one object plus one of another = `sys_user`, `sys_approval_approver`, two `sys_approval_request` reads; a stranger costs two reads; a write retires the memo); a rejected read is rethrown and not memoized. - `packages/plugins/plugin-sharing/src/sharing-service.test.ts`: seven cases on the OR-in, the join with record shares, no write gate, not asked for null/deny, delegated principal gets none, the fail-closed variants, and `listApprovalReadGrants`. - `packages/plugins/plugin-security/src/explain-engine.test.ts`: attribution on read (admits) and update (neutral), and a failing lookup attributes nothing. - `approver-address-readers.test.ts`: the new slot comparison site is classified as a reader. ## Acceptance notes - The approval grant is the row-level reach of a read share: the approver still needs read on the object itself, as for any record share. - A unanimous or quorum voter leaves the slate once they vote (the index drops their row), so they lose the read while the step stays pending for the others. That follows "each resolved person on the current step's slate". - Explain reuses kind `record_share` with `via: "approval request N"`, because a dedicated kind is a spec enum edit plus a ui label, outside this card. - The org-wall test lives at the service level: the dogfood boot is single-organization. - Overlap: #26337 (ownership queues) edits the same insertion points in `sharing-service.ts` (options, field, constructor, the `buildReadFilter` tail), `sharing-plugin.ts` and `explain-engine.ts`. `git merge-tree` reports content conflicts in those three files, all adjacent inserts. Whichever lands second keeps both: the queue disjunct and the approval ids in the `id $in` arm. No other open PR touches these files. - Dogfood Regression Gate does not run on PRs; the dogfood file above was run locally on the final head. ## Verification All on head e8fb0d3 unless noted. <details><summary>Red on main</summary> The five source files reverted to base 6390fe5 (trap restoring from HEAD), the three plugins rebuilt, `ablation-dist-preflight.mjs --absent` confirmed the dist: the dogfood test failed at line 183, `expected 404 to be 200` (the first approver's read). Restore proved by an empty `git diff HEAD`, then the dist was rebuilt and the preflight presence check passed. </details> <details><summary>Suites</summary> - plugin-approvals: Test Files 100 passed, Tests 1286 passed (lock VERDICT command-exit 0). - plugin-sharing: Tests 1240 passed; plugin-security: 4962 passed, 45 skipped (both packages unchanged since 1639ac9, where they were run). - dogfood file: 1 passed. Typecheck of the three plugins: exit 0. - repo-level census projects (core 83, objectql 5, rest 280 + 18 skipped, runtime 947, spec 1279, types 11): all exit 0. - eslint `--no-inline-config` on the three plugin packages and the dogfood file: exit 0, no findings. </details> <details><summary>Derived gates</summary> Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (74 commands), each run with `timeout 900`, exit captured before any pipe. - 73 exit 0. - `pnpm check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET (no `dist/` for packages this diff does not touch: studio, client-react, embedder-openai, knowledge-memory and others). NOT MEASURED locally; CI builds them. - `--ran` reconciliation: "74 derived famil(ies) accounted for, 73 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)", exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016NZtJpJqVWVJA1XDU9dbnz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 13e5c0e commit b7b04e5

12 files changed

Lines changed: 1049 additions & 2 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
'@objectstack/plugin-sharing': patch
4+
'@objectstack/plugin-security': patch
5+
---
6+
7+
fix(plugin-approvals,plugin-sharing): the approver a pending step waits on can read the record the request is about
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
An approver was asked to decide on a record they could not open. Unless the app happened to grant them read of that exact row, the request page showed only the snapshot and the record card read "unable to open this record", so a contract was approved without its document being read.
13+
14+
- **Who reads.** While an approval request is pending, each person on its current step's slate (a slot naming their user id, or the email their own account carries) and the override holder (the administrator who may decide any pending request of their organization) may READ the one record the request is about. A slot left as a `position:` or `team:` literal names no person and grants no read.
15+
- **How far.** Read only: the write gates never read this grant, so the approver still cannot edit or delete the record. Only the record itself, as a reader of it reads it: attachments follow the record read, as they do for any reader. Only while the step is pending: a decision that resolves the step, or advances the request to its next step, ends the grant at the next read. Never across organizations, the platform administrator's override included. An on-behalf-of (agent) principal gets no approval grant.
16+
- **Where it is decided.** `@objectstack/plugin-approvals` derives the records from the slate it already resolves (`ApprovalService.pendingApproverReadGrants`, one indexed read of the pending-approver index per request context, plus one request read per object, memoized per request and retired by any write). `@objectstack/plugin-sharing` ORs those record ids into its read filter beside the caller's record shares, so every read door that composes the sharing filter serves them; it grants nothing when the approvals service is absent or its read fails.
17+
- **Explain.** `@objectstack/plugin-security`'s access explanation names the grant on the sharing layer as a read share "via approval request N".
18+
19+
The grant is the row-level reach of a read share; the approver still needs read on the object itself. Nothing is declared: no metadata key, no app setting.
20+
21+
What to do: nothing. An app that wrote sharing rules mirroring its approval routing only so approvers could open the record may drop them.

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ import {
123123
} from './payload-redaction.js';
124124
import { APPROVAL_REQUEST_CHILD_OBJECTS, type RequestVisibilitySource } from './request-read-gate.js';
125125
import { nodeConfigLocksRecord, recordHeldByLockingApproval } from './lifecycle-hooks.js';
126+
import { PendingApproverReadGrantSource, type PendingApproverReadGrants } from './pending-approver-read-grant.js';
126127
// The ADR-0043 pages — the ONE copy of their text, rendered by the plugin's
127128
// self-hosted mount and by `handleActionPage` alike.
128129
import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
@@ -1619,10 +1620,21 @@ export class ApprovalService implements IApprovalService {
16191620
* registration, and the audit writer memoizes the same way.
16201621
*/
16211622
private readonly recordOrgResolver: RecordOrganizationResolver;
1623+
/**
1624+
* [#26180] The records a pending approver may read: memoized per execution
1625+
* context and engine write epoch ({@link ApprovalService.pendingApproverReadGrants}).
1626+
*/
1627+
private readonly pendingApproverReads: PendingApproverReadGrantSource;
16221628

16231629
constructor(opts: ApprovalServiceOptions) {
16241630
this.engine = opts.engine;
16251631
this.recordOrgResolver = createRecordOrganizationResolver(opts.engine);
1632+
this.pendingApproverReads = new PendingApproverReadGrantSource({
1633+
engine: opts.engine,
1634+
isOverrideActor: (context, organizationId) => this.isOverrideActor(context, organizationId),
1635+
accountEmail: (userId) => this.callerAccountEmail(userId),
1636+
logger: opts.logger,
1637+
});
16261638
this.clock = opts.clock ?? { now: () => new Date() };
16271639
this.logger = opts.logger;
16281640
this.automation = opts.automation;
@@ -1685,6 +1697,24 @@ export class ApprovalService implements IApprovalService {
16851697
});
16861698
}
16871699

1700+
/**
1701+
* [#26180 · ruling 6429330267, letter A] The records of `object` the caller
1702+
* may READ because a pending approval step waits on them: record id → the
1703+
* pending request ids that grant it.
1704+
*
1705+
* While a request is pending, each resolved person on its current step's
1706+
* slate (the caller's user id or account email in a slot) and the override
1707+
* holder ({@link ApprovalService.isOverrideActor}) may read the one record
1708+
* it is about. Derived from the slate this service resolved, never declared;
1709+
* fenced to the caller's active organization; ends when the step resolves.
1710+
* plugin-sharing ORs the ids into its read filter and names the requests in
1711+
* `explain`. Rejects when a read fails, so the consumer can fail closed.
1712+
* The rules and the cost live in `pending-approver-read-grant.ts`.
1713+
*/
1714+
async pendingApproverReadGrants(object: string, context: ExecutionContext): Promise<PendingApproverReadGrants> {
1715+
return this.pendingApproverReads.forObject(object, context);
1716+
}
1717+
16881718
/** Attach (or replace) the ADR-0105 D9 posture provider. */
16891719
attachTenancyPosture(provider: () => string | undefined): void {
16901720
this.tenancyPosture = provider;

‎packages/plugins/plugin-approvals/src/approver-address-readers.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ const SLOT_SITES: Readonly<Record<string, { role: SiteRole; why: string }>> = {
147147
why: 'a presence test, not a comparison: selects the action rows that hold a slot to label (#23889 boot '
148148
+ 'backfill); the slot is compared with nothing, and no caller identity is involved',
149149
},
150+
'pending-approver-read-grant.ts · slateRequestIds · approver: persons.length === 1 ? persons[0] : { $in: persons }': {
151+
role: 'reader',
152+
why: 'the pending approver\'s record read (#26180): `persons` is actingAddresses(caller) with no positions — the '
153+
+ 'user id and account email slateNamesAPerson calls a person; a position literal names no person, so grants no read',
154+
},
150155
"sys-approval-request.object.ts · (module) · field: 'pending_approvers'": {
151156
role: 'declarative',
152157
why: 'the `my_pending` list view\'s substring filter on {current_user_id}; served only by the generic data '
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26180 · ruling 6429330267, letter A] Which records a pending approval step
5+
* lets a caller READ — `ApprovalService.pendingApproverReadGrants`, the answer
6+
* plugin-sharing ORs into its read filter.
7+
*
8+
* The full door (a real kernel, the security and sharing plugins, a two-step
9+
* flow) is pinned in dogfood's `approval-pending-approver-record-read`. What
10+
* only this level can state is the derivation itself and its bounds:
11+
*
12+
* - who: a PERSON on the pending slate — the caller's user id or account
13+
* email in a slot — and the override holder; a `position:` literal (a
14+
* target that resolved to nobody) names no person and grants no read;
15+
* - while: a decided request grants nothing, at the next read of the same
16+
* request context too (a write retires the memo);
17+
* - where: never across organizations, the platform administrator's override
18+
* included (that boot is `single`, one organization, no wall to cross);
19+
* - cost: a request context pays one account read and one index read for its
20+
* slate, and one request read per object — never a read per record, and a
21+
* caller on no slate stops after the index read;
22+
* - fail: a failed read is rethrown and never memoized.
23+
*
24+
* The rig is the read-gate suites' (`record-reader-opt-in.integration.test.ts`):
25+
* a real ObjectQL engine over better-sqlite3 and the real
26+
* `ApprovalsServicePlugin.start()`.
27+
*/
28+
29+
import { describe, it, expect, afterEach } from 'vitest';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import type { EngineQueryOptions } from '@objectstack/spec/data';
33+
import type { ExecutionContext } from '@objectstack/spec/kernel';
34+
import { ApprovalService } from './approval-service.js';
35+
import { ApprovalsServicePlugin } from './approvals-plugin.js';
36+
import { SysApprovalRequest } from './sys-approval-request.object.js';
37+
import { SysApprovalAction } from './sys-approval-action.object.js';
38+
import { SysApprovalApprover } from './sys-approval-approver.object.js';
39+
import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
40+
import { DEFAULT_ORGANIZATION_ID, installDefaultOrganization } from './default-organization.testkit.js';
41+
42+
const OBJECT = 'contract_sheet';
43+
const OTHER = 'memo_sheet';
44+
const PKG = 'approvals-pending-approver-read-test';
45+
const OTHER_ORG = 'org_other';
46+
47+
const SYSTEM: ExecutionContext = { isSystem: true, positions: [], permissions: [] };
48+
/** A request row as the system reads it. */
49+
const requestRow = async (rig: Rig, where: Record<string, unknown>): Promise<any> =>
50+
((await rig.engine.find('sys_approval_request', { where, context: SYSTEM } satisfies EngineQueryOptions)) as any[])[0];
51+
/** A member of `org`, acting as themself. */
52+
const member = (userId: string, org: string = DEFAULT_ORGANIZATION_ID) =>
53+
({ userId, tenantId: org, organizationId: org, positions: [], permissions: [], posture: 'MEMBER' }) as any;
54+
55+
const SUBMITTER = 'u_submitter';
56+
/** On the slate by user id. */
57+
const BY_ID = 'u_by_id';
58+
/** On the slate by the email their account carries. */
59+
const BY_EMAIL = 'u_by_email';
60+
const BY_EMAIL_ADDRESS = 'by.email@verify.test';
61+
/** Holds position `buyer`; the slate holds only the unresolved `position:buyer` literal. */
62+
const BUYER = 'u_buyer';
63+
/** On no slate. */
64+
const STRANGER = 'u_stranger';
65+
66+
function sheet(name: string) {
67+
return {
68+
name,
69+
label: name,
70+
fields: {
71+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
72+
title: { name: 'title', label: 'Title', type: 'text' as const },
73+
},
74+
};
75+
}
76+
77+
/** The account table `callerAccountEmail` reads. */
78+
const SysUser = {
79+
name: 'sys_user',
80+
label: 'User',
81+
managedBy: 'better-auth',
82+
fields: {
83+
id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true },
84+
email: { name: 'email', label: 'Email', type: 'text' as const },
85+
},
86+
};
87+
88+
interface Rig {
89+
engine: ObjectQL;
90+
svc: ApprovalService;
91+
finds: string[];
92+
}
93+
94+
let current: ObjectQL | undefined;
95+
96+
afterEach(async () => {
97+
try { await current?.destroy(); } catch { /* noop */ }
98+
current = undefined;
99+
});
100+
101+
async function boot(): Promise<Rig> {
102+
const engine = new ObjectQL();
103+
current = engine;
104+
engine.registerDriver(new SqlDriver({
105+
client: 'better-sqlite3',
106+
connection: { filename: ':memory:' },
107+
useNullAsDefault: true,
108+
}), true);
109+
await engine.init();
110+
for (const def of [
111+
sheet(OBJECT), sheet(OTHER), SysUser,
112+
SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalDelegation,
113+
]) {
114+
engine.registry.registerObject(def as any, PKG, PKG);
115+
}
116+
await engine.syncSchemas();
117+
await installDefaultOrganization(engine);
118+
await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: OTHER_ORG, name: 'Other' });
119+
await engine.getDriverForObject('sys_user')!.create('sys_user', { id: BY_EMAIL, email: BY_EMAIL_ADDRESS });
120+
121+
const services: Record<string, unknown> = { objectql: engine };
122+
const ctx: any = {
123+
getService: (name: string) => {
124+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
125+
return services[name];
126+
},
127+
registerService: (name: string, svc: unknown) => { services[name] = svc; },
128+
hook: () => {},
129+
logger: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} },
130+
};
131+
await new ApprovalsServicePlugin().start(ctx);
132+
133+
// Every engine read of the approval tables and the account table, by table —
134+
// the cost the read path pays. Counted at the engine's own `find`.
135+
const finds: string[] = [];
136+
const find = engine.find.bind(engine);
137+
(engine as any).find = (object: string, options?: EngineQueryOptions) => {
138+
if (object.startsWith('sys_approval') || object === 'sys_user') finds.push(object);
139+
return find(object, options);
140+
};
141+
return { engine, svc: services.approvals as ApprovalService, finds };
142+
}
143+
144+
/** Open one pending request on `recordId` of `object`, its slate the given approver targets. */
145+
async function open(
146+
rig: Rig,
147+
object: string,
148+
recordId: string,
149+
approvers: Array<{ type: 'user' | 'position'; value: string }>,
150+
organizationId: string = DEFAULT_ORGANIZATION_ID,
151+
): Promise<string> {
152+
await rig.engine.insert(object, { id: recordId, title: recordId, organization_id: organizationId }, { context: SYSTEM });
153+
const opened = await rig.svc.openNodeRequest({
154+
object, recordId, runId: `run_${recordId}`, nodeId: 'review', flowName: 'contract_review',
155+
config: { approvers, behavior: 'first_response' } as any,
156+
submitterId: SUBMITTER, record: { id: recordId, title: recordId }, organizationId,
157+
}, member(SUBMITTER, organizationId)) as any;
158+
expect(opened?.status, `request on ${recordId} is pending`).toBe('pending');
159+
return String(opened.id);
160+
}
161+
162+
/** The grant as plain data: record id → request ids. */
163+
async function grantsOf(rig: Rig, object: string, context: any): Promise<Record<string, string[]>> {
164+
return Object.fromEntries([...(await rig.svc.pendingApproverReadGrants(object, context))].map(([k, v]) => [k, [...v]]));
165+
}
166+
167+
describe('pendingApproverReadGrants — the records a pending approval step lets a caller read (#26180)', () => {
168+
it('grants a person on the pending slate, by user id or by account email, the one record — and no one else', async () => {
169+
const rig = await boot();
170+
const byIdRequest = await open(rig, OBJECT, 'c_by_id', [{ type: 'user', value: BY_ID }]);
171+
const byEmailRequest = await open(rig, OBJECT, 'c_by_email', [{ type: 'user', value: BY_EMAIL_ADDRESS }]);
172+
await open(rig, OBJECT, 'c_position', [{ type: 'position', value: 'buyer' }]);
173+
174+
expect(await grantsOf(rig, OBJECT, member(BY_ID))).toEqual({ c_by_id: [byIdRequest] });
175+
expect(await grantsOf(rig, OBJECT, member(BY_EMAIL))).toEqual({ c_by_email: [byEmailRequest] });
176+
// The submitter, a stranger: on no slate.
177+
expect(await grantsOf(rig, OBJECT, member(SUBMITTER))).toEqual({});
178+
expect(await grantsOf(rig, OBJECT, member(STRANGER))).toEqual({});
179+
// A `position:` literal names no person: the holder may decide it, but
180+
// the read is the resolved person's alone.
181+
const positionRow = await requestRow(rig, { record_id: 'c_position' });
182+
expect(String(positionRow.pending_approvers)).toBe('position:buyer');
183+
expect(await grantsOf(rig, OBJECT, { ...member(BUYER), positions: ['buyer'] })).toEqual({});
184+
// Another object's records are never granted by this one's requests.
185+
expect(await grantsOf(rig, OTHER, member(BY_ID))).toEqual({});
186+
// No user, a system context: nothing to grant (system reads are unscoped anyway).
187+
expect(await grantsOf(rig, OBJECT, { positions: [], permissions: [] })).toEqual({});
188+
expect(await grantsOf(rig, OBJECT, SYSTEM)).toEqual({});
189+
});
190+
191+
it('grants the override holder every pending record of the object in their organization', async () => {
192+
const rig = await boot();
193+
const a = await open(rig, OBJECT, 'c_a', [{ type: 'user', value: BY_ID }]);
194+
const b = await open(rig, OBJECT, 'c_b', [{ type: 'user', value: BY_EMAIL_ADDRESS }]);
195+
const tenantAdmin = { ...member('u_admin'), posture: 'TENANT_ADMIN', permissions: ['organization_admin_no_bypass'] };
196+
expect(await grantsOf(rig, OBJECT, tenantAdmin)).toEqual({ c_a: [a], c_b: [b] });
197+
});
198+
199+
it('ends with the step: a decided request grants nothing, on the same request context too', async () => {
200+
const rig = await boot();
201+
const request = await open(rig, OBJECT, 'c_decided', [{ type: 'user', value: BY_ID }]);
202+
const ctx = member(BY_ID);
203+
expect(await grantsOf(rig, OBJECT, ctx)).toEqual({ c_decided: [request] });
204+
205+
await rig.svc.decideNode(request, { decision: 'approve', actorId: BY_ID }, member(BY_ID));
206+
const row = await requestRow(rig, { id: request });
207+
expect(row.status).toBe('approved');
208+
expect(await grantsOf(rig, OBJECT, ctx)).toEqual({});
209+
expect(await grantsOf(rig, OBJECT, member(BY_ID))).toEqual({});
210+
211+
const rejected = await open(rig, OBJECT, 'c_rejected', [{ type: 'user', value: BY_ID }]);
212+
expect(await grantsOf(rig, OBJECT, ctx)).toEqual({ c_rejected: [rejected] });
213+
await rig.svc.decideNode(rejected, { decision: 'reject', actorId: BY_ID }, member(BY_ID));
214+
expect(await grantsOf(rig, OBJECT, ctx)).toEqual({});
215+
});
216+
217+
it('never crosses an organization — the platform administrator\'s override included', async () => {
218+
const rig = await boot();
219+
const there = await open(rig, OBJECT, 'c_there', [{ type: 'user', value: BY_ID }], OTHER_ORG);
220+
const row = await requestRow(rig, { id: there });
221+
expect(row.organization_id, 'the request lives in the other organization').toBe(OTHER_ORG);
222+
223+
// The same person, acting in their default organization: nothing.
224+
expect(await grantsOf(rig, OBJECT, member(BY_ID))).toEqual({});
225+
// …and in the request's own organization: the record.
226+
expect(await grantsOf(rig, OBJECT, member(BY_ID, OTHER_ORG))).toEqual({ c_there: [there] });
227+
// The platform administrator may DECIDE across the wall; the read stays home.
228+
const platformAdmin = { ...member('u_platform'), posture: 'PLATFORM_ADMIN' };
229+
expect(await grantsOf(rig, OBJECT, platformAdmin)).toEqual({});
230+
// An org-less caller reaches only org-less requests.
231+
expect(await grantsOf(rig, OBJECT, { userId: BY_ID, positions: [], permissions: [] })).toEqual({});
232+
});
233+
234+
it('costs one account read and one index read per request context, plus one request read per object — never per record', async () => {
235+
const rig = await boot();
236+
const r1 = await open(rig, OBJECT, 'c_1', [{ type: 'user', value: BY_ID }]);
237+
const r2 = await open(rig, OBJECT, 'c_2', [{ type: 'user', value: BY_ID }]);
238+
239+
const ctx = member(BY_ID);
240+
rig.finds.length = 0;
241+
for (let i = 0; i < 5; i++) expect(await grantsOf(rig, OBJECT, ctx)).toEqual({ c_1: [r1], c_2: [r2] });
242+
expect(await grantsOf(rig, OTHER, ctx)).toEqual({});
243+
expect(rig.finds).toEqual(['sys_user', 'sys_approval_approver', 'sys_approval_request', 'sys_approval_request']);
244+
245+
// A caller on no slate stops after the index read, for every object.
246+
const stranger = member(STRANGER);
247+
rig.finds.length = 0;
248+
await grantsOf(rig, OBJECT, stranger);
249+
await grantsOf(rig, OTHER, stranger);
250+
expect(rig.finds).toEqual(['sys_user', 'sys_approval_approver']);
251+
252+
// Any write through the engine retires the memo: the next read asks again.
253+
await rig.engine.insert(OTHER, { id: 'm_1', title: 'unrelated', organization_id: DEFAULT_ORGANIZATION_ID }, { context: SYSTEM });
254+
rig.finds.length = 0;
255+
await grantsOf(rig, OBJECT, ctx);
256+
expect(rig.finds).toEqual(['sys_user', 'sys_approval_approver', 'sys_approval_request']);
257+
});
258+
259+
it('rethrows a failed read and never memoizes it', async () => {
260+
const rig = await boot();
261+
const request = await open(rig, OBJECT, 'c_fault', [{ type: 'user', value: BY_ID }]);
262+
const ctx = member(BY_ID);
263+
const find = (rig.engine as any).find;
264+
let failNext = true;
265+
(rig.engine as any).find = (object: string, options?: EngineQueryOptions) => {
266+
if (object === 'sys_approval_approver' && failNext) {
267+
failNext = false;
268+
return Promise.reject(new Error('store unavailable'));
269+
}
270+
return find(object, options);
271+
};
272+
await expect(rig.svc.pendingApproverReadGrants(OBJECT, ctx)).rejects.toThrow('store unavailable');
273+
expect(await grantsOf(rig, OBJECT, ctx)).toEqual({ c_fault: [request] });
274+
});
275+
});

0 commit comments

Comments
 (0)