Skip to content

Commit d2a8695

Browse files
os-zhuangclaude
andauthored
fix(approvals)!: scope approval requests to their participants, not the tenant (#3590) (#3599)
* fix(approvals)!: scope approval requests to their participants, not the tenant (#3590) getRequest / listRequests / countRequests deliberately query with SYSTEM_CTX to bypass RLS — as their own comments say, the approver-visibility rule spans identity forms RLS cannot model cleanly, so it has to be expressed in the service. Only the TENANT half of that rule was ever written. The comment names the participant half; the code never applied it. So any authenticated user could read any approval request in their tenant: its payload snapshot, its full decision history, and — once decision attachments derived their access from the request (#3580) — its files. `approverId` on listRequests is a FILTER, not authorization. Omitting it returned the whole tenant. A caller now sees a request when they participate in it: the submitter, a current approver (via the normalized approver index, so every identity form the write path recorded is covered), or someone who already acted on it — a past approver whose slot has moved on, a commenter. Override admins keep the unrestricted view the "all requests" console surface depends on; a tokenless context sees nothing. Keying on the concrete user id is sufficient rather than an approximation, and that is worth stating because under-granting here would silently hide approvals someone must act on: position/team/manager/field approvers are resolved to concrete user ids at open time, and the `type:value` literal is only the fallback for a spec that resolved to NOBODY — a slot no one can act on either way, since can_act is a plain membership test over the resolved ids. A write path's own result is NOT re-gated. Every operation echoes back the request it just changed via getRequest; the operation authorized itself before writing, and re-asking answers a settled question — wrongly, for a context that carries no userId (a flow-driven resume, a service-to-service call), turning a successful write into a null result. Four existing tests caught exactly that, which is why the echo now goes through readBackRequest instead. Verified in the browser on app-showcase: a non-admin approver's unfiltered list drops from 3 requests to the 1 she is on, an admin still sees all 3, her decision attachment still downloads (200, real PDF bytes — #3580 composes), and a request she has nothing to do with reads back empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd * docs(approvals): state who can see a request, now that it is participant-scoped The drift check flagged approvals.mdx, and rightly: it documented `approverId` as one filter among several and never said who may see what — so a reader would reasonably assume omitting it returns the whole tenant, which is exactly what this PR stops being true. Also records that a decision's attachments inherit the same rule, since that is the non-obvious half: the file is as readable as the decision, never more. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d5f726 commit d2a8695

4 files changed

Lines changed: 310 additions & 14 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
---
4+
5+
fix(approvals)!: an approval request is visible to its participants, not to the whole tenant (#3590)
6+
7+
`getRequest` / `listRequests` / `countRequests` deliberately query with
8+
`SYSTEM_CTX` to bypass RLS — as the code comments say, the approver-visibility
9+
rule spans identity forms RLS cannot model cleanly, so it has to be expressed in
10+
the service. Only the **tenant** half of that rule was ever applied. The
11+
participant half was named in the comment and never written, so **any
12+
authenticated user could read any approval request in their tenant** — its
13+
payload snapshot, its full decision history, and (once decision attachments
14+
derived their access from the request, #3580) its files.
15+
16+
`approverId` on `listRequests` is a *filter*, not authorization: omitting it
17+
returned the whole tenant.
18+
19+
A caller now sees a request when they are a participant — the submitter, a
20+
current approver (via the normalized approver index, so every identity form the
21+
write path recorded is covered), or someone who has already acted on it (a past
22+
approver whose slot has moved on, a commenter). Admins with override authority
23+
keep the unrestricted view the "all requests" console surface depends on, and a
24+
tokenless context sees nothing.
25+
26+
Keying on the concrete user id is sufficient rather than an approximation:
27+
position/team/manager/field approvers are resolved to concrete user ids at open
28+
time, and the `type:value` literal is only the fallback for a spec that resolved
29+
to *nobody* — a slot no one can act on either way. So this cannot hide a request
30+
from someone who could actually act on it.
31+
32+
**A write path's own result is not re-gated.** Every operation echoes back the
33+
request it just changed; the operation already authorized itself, and re-asking
34+
would answer wrong for a context carrying no `userId` (a flow-driven resume, a
35+
service-to-service call), turning a successful write into `null`.
36+
37+
Marked breaking because a client that listed requests without an `approverId`
38+
filter and expected the whole tenant will now receive only its own — which is
39+
the point.

content/docs/automation/approvals.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,20 @@ a person's identities in one call.
244244
Other filters: `status`, `object`, `recordId`, `submitterId`, `q`, `limit`,
245245
`offset`.
246246

247+
<Callout type="warn">
248+
**`approverId` is a filter, not authorization.** What you may see is decided
249+
separately: a request is visible to its **participants** — the submitter, a
250+
current approver, and anyone who has already acted on it (a past approver whose
251+
slot has moved on, a commenter). So omitting `approverId` returns *your*
252+
requests, not every request in the tenant. Admins with override authority
253+
(`admin_full_access`, or `organization_admin` within their org) see all of them
254+
— that is what the "all requests" view is for.
255+
256+
The same rule governs a decision's files: an attachment is exactly as readable
257+
as the decision it hangs off, never more (`sys_approval_action` delegates that
258+
question back to this service via `fileAccessDelegate`).
259+
</Callout>
260+
247261
<Callout type="warn">
248262
**Opening a request notifies nobody.** There is no built-in "you have an
249263
approval waiting" message today — an approver only discovers it by looking at

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

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -740,8 +740,10 @@ describe('ApprovalService (node era)', () => {
740740
expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false, can_override: false });
741741
const asSubmitter = await svc.getRequest(req.id, { userId: 'u1', tenantId: 't1' } as any);
742742
expect(asSubmitter!.viewer).toEqual({ can_act: false, is_submitter: true, can_override: false });
743-
const asOther = await svc.getRequest(req.id, { userId: 'u_stranger', tenantId: 't1' } as any);
744-
expect(asOther!.viewer).toEqual({ can_act: false, is_submitter: false, can_override: false });
743+
// #3590: a same-tenant stranger participates in nothing, so the request is
744+
// not readable at all — previously it came back with an all-false viewer
745+
// block, which meant every authenticated user could read every request.
746+
expect(await svc.getRequest(req.id, { userId: 'u_stranger', tenantId: 't1' } as any)).toBeNull();
745747
});
746748

747749
it('getRequest: viewer.can_act drops to false once the request is finalized', async () => {
@@ -1477,12 +1479,22 @@ describe('ApprovalService — admin override (#3424)', () => {
14771479
const req = await svc.openNodeRequest(stuckInput(), CTX);
14781480
const asAdmin = await svc.getRequest(req.id, TENANT_ADMIN);
14791481
expect(asAdmin!.viewer).toMatchObject({ can_act: false, can_override: true });
1480-
const asMember = await svc.getRequest(req.id, MEMBER);
1481-
expect(asMember!.viewer!.can_override).toBe(false);
1482+
// Someone who CAN see the request but holds no override privilege — the
1483+
// submitter. `can_override` is about the privilege, not about access, so
1484+
// the check needs a participant rather than a stranger.
1485+
const asSubmitter = await svc.getRequest(req.id, CTX);
1486+
expect(asSubmitter!.viewer!.can_override).toBe(false);
14821487
await svc.decide(req.id, { decision: 'approve', actorId: 'owner' }, TENANT_ADMIN);
14831488
const after = await svc.getRequest(req.id, TENANT_ADMIN);
14841489
expect(after!.viewer!.can_override).toBe(false);
14851490
});
1491+
1492+
// #3590: a plain member who participates in nothing now sees nothing — a
1493+
// request is no longer readable merely because you are in the same tenant.
1494+
it('a non-participant member cannot read the request at all', async () => {
1495+
const req = await svc.openNodeRequest(stuckInput(), CTX);
1496+
expect(await svc.getRequest(req.id, MEMBER)).toBeNull();
1497+
});
14861498
});
14871499

14881500
describe('record-lock hook (node era)', () => {
@@ -2114,3 +2126,101 @@ describe('ApprovalService — authorizeFileRead delegate (ADR-0104 D3 wave 2)',
21142126
expect((SysApprovalAction as any).fileAccessDelegate).toBe('approvals');
21152127
});
21162128
});
2129+
2130+
// ── Participant visibility (#3590) ───────────────────────────────────
2131+
//
2132+
// getRequest/listRequests deliberately query with SYSTEM_CTX (the
2133+
// approver-visibility rule spans identity forms RLS cannot model), but only
2134+
// the TENANT half of that rule was ever applied — so any authenticated user
2135+
// could read any request in their tenant, and after #3580 its decision
2136+
// attachments too. These lock in the participant half.
2137+
describe('ApprovalService — participant visibility (#3590)', () => {
2138+
const svcFor = (engine: any) => {
2139+
let n = 0;
2140+
return new ApprovalService({ engine, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
2141+
};
2142+
const asUser = (userId: string) => ({ userId, tenantId: 't1', positions: [], permissions: [] } as any);
2143+
const ADMIN = { userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'] } as any;
2144+
2145+
it('the submitter and a pending approver can read it; a same-tenant stranger cannot', async () => {
2146+
const engine = makeFakeEngine();
2147+
const svc = svcFor(engine);
2148+
const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
2149+
2150+
expect(await svc.getRequest(req.id, asUser('u1'))).not.toBeNull();
2151+
expect(await svc.getRequest(req.id, asUser('u9'))).not.toBeNull();
2152+
expect(await svc.getRequest(req.id, asUser('u_stranger'))).toBeNull();
2153+
});
2154+
2155+
it('someone who already acted keeps access after their slot moves on', async () => {
2156+
const engine = makeFakeEngine();
2157+
const svc = svcFor(engine);
2158+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2159+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
2160+
2161+
// u9 is no longer a pending approver, but the decision is theirs — the
2162+
// audit trail (and its attachments) must not vanish from under them.
2163+
expect(await svc.getRequest(req.id, asUser('u9'))).not.toBeNull();
2164+
});
2165+
2166+
it('an override admin keeps the unrestricted view the console depends on', async () => {
2167+
const engine = makeFakeEngine();
2168+
const svc = svcFor(engine);
2169+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2170+
2171+
expect(await svc.getRequest(req.id, ADMIN)).not.toBeNull();
2172+
});
2173+
2174+
it('a tokenless context sees nothing — the gate fails closed', async () => {
2175+
const engine = makeFakeEngine();
2176+
const svc = svcFor(engine);
2177+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2178+
2179+
expect(await svc.getRequest(req.id, { tenantId: 't1', positions: [], permissions: [] } as any)).toBeNull();
2180+
});
2181+
2182+
it('listRequests no longer returns the whole tenant when no approverId filter is passed', async () => {
2183+
const engine = makeFakeEngine();
2184+
const svc = svcFor(engine);
2185+
const mine = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1
2186+
await svc.openNodeRequest(openInput(['u7'], { recordId: 'opp2', record: { id: 'opp2' } }), asUser('u_other')); // unrelated to u1
2187+
2188+
// The old behaviour: omit approverId and receive every request in the
2189+
// tenant. `approverId` is a filter, never authorization.
2190+
const seen = await svc.listRequests(undefined, asUser('u1'));
2191+
expect(seen.map(r => r.id)).toEqual([mine.id]);
2192+
2193+
const strangerSees = await svc.listRequests(undefined, asUser('u_stranger'));
2194+
expect(strangerSees).toEqual([]);
2195+
2196+
// The admin console still sees everything.
2197+
expect((await svc.listRequests(undefined, ADMIN)).length).toBeGreaterThanOrEqual(2);
2198+
});
2199+
2200+
it('countRequests agrees with the list it paginates', async () => {
2201+
const engine = makeFakeEngine();
2202+
const svc = svcFor(engine);
2203+
await svc.openNodeRequest(openInput(['u9']), CTX);
2204+
await svc.openNodeRequest(openInput(['u7'], { recordId: 'opp2', record: { id: 'opp2' } }), asUser('u_other'));
2205+
2206+
expect(await svc.countRequests(undefined, asUser('u_stranger'))).toBe(0);
2207+
expect(await svc.countRequests(undefined, asUser('u1'))).toBe(1);
2208+
});
2209+
2210+
it('a write path still echoes back its own result when the context has no userId', async () => {
2211+
const engine = makeFakeEngine();
2212+
const svc = svcFor(engine);
2213+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2214+
2215+
// Flow-driven resumes and service-to-service calls carry no userId. The
2216+
// operation authorized itself; re-gating the echo would turn a successful
2217+
// write into a null result.
2218+
const res = await svc.decideNode(
2219+
req.id,
2220+
{ decision: 'approve', actorId: 'u9' },
2221+
{ isSystem: false, positions: [], permissions: [] } as any,
2222+
);
2223+
expect(res.request).not.toBeNull();
2224+
expect(res.request.status).toBe('approved');
2225+
});
2226+
});

0 commit comments

Comments
 (0)