Skip to content

Commit f92096b

Browse files
os-zhuangclaude
andauthored
fix(approvals): an approval action is recorded against the authenticated caller (#3800) (#3805)
The approvals REST routes filled the acting identity from `body.actorId ?? body.actor_id ?? context.userId`, and the service then authorized *that value* — `pending_approvers.includes(input.actorId)` for a decision, `submitter_id === actorId` for a recall — never checking that it named the caller. Any authenticated user could POST `{"actorId": "<someone else>"}` and have that person's approval recorded, the request finalized and the owning flow resumed; with `api.requireAuth` unset, so could an anonymous one. #3783 scoped this correctly for the data-write identity and called the audit-row half "tolerable". It was not — the same unchecked string was the authorization key, so naming someone else was how you got through the door, not merely how you mislabelled the row. `ApprovalService.resolveActor` now pins the actor on all nine entrypoints. The rule is not "actorId must equal context.userId": a slot can legitimately be keyed by a `type:value` literal or by the caller's email, and the Console sends those. It is "the actor must be an identity the server can prove belongs to the caller" — the caller's own id, a `position:`/`role:` token backed by the resolved authz context, or their own email. A system context keeps its explicit actor, so the SLA sweep's sentinel and the ADR-0043 action link are unchanged. A caller with no identity at all is refused. REST keeps forwarding the body value as a hint the service validates, which is what preserves the email and `type:value` slot cases. Tests: `approval-actor-impersonation.test.ts` — 16 cases, 12 of which passed the impersonation before this change. 24 existing tests also failed against the fix, every one because it acted with a context naming nobody (`approval-revise.ts` used one identity-less `USER_CTX` for all 32 calls) or someone other than the actor it claimed; each now presents the acting user's context. Filed the adjacent bypass separately as #3801: the generic `runs/:runId/resume` route advances a suspended approval node down the `approve` edge with no approver check and no audit row. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4927390 commit f92096b

6 files changed

Lines changed: 616 additions & 105 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
"@objectstack/rest": patch
4+
---
5+
6+
fix(approvals): an approval action is recorded against the authenticated caller, never a body field (#3800)
7+
8+
Every mutating approvals entrypoint takes an `actorId`, and the REST routes
9+
filled it from `body.actorId ?? body.actor_id ?? context.userId` — so the body
10+
won. The service then authorized *that value*: `pending_approvers.includes(
11+
input.actorId)` for a decision, `submitter_id === actorId` for a recall. It never
12+
checked that the value named the caller.
13+
14+
So any authenticated user could POST `{"actorId": "<someone else>"}` and have
15+
that person's approval recorded, the request finalized, and the owning flow run
16+
resumed down the `approve` edge — or name a request's submitter and recall it.
17+
With `api.requireAuth` unset the anonymous-deny never fires either, so an
18+
unauthenticated request could do the same.
19+
20+
#3783 drew this line for the *data-write* identity and called the audit-row half
21+
"tolerable". It was not: the same unchecked string was the authorization key, so
22+
naming someone else was not a mislabelled audit row, it was how you got through
23+
the door.
24+
25+
The actor is now resolved server-side (`ApprovalService.resolveActor`) on all
26+
nine entrypoints — `decide` / `decideNode`, `recall`, `sendBack`, `resubmit`,
27+
`reassign`, `remind`, `requestInfo`, `comment`.
28+
29+
**The rule is not "`actorId` must equal `context.userId`."** A slot can
30+
legitimately be keyed by something else: the approver resolver stores the
31+
`type:value` literal when a graph lookup finds no holders, and the Console picks
32+
from the caller's own identity list — user id, email, or `role:<r>`. The rule is
33+
**"the actor must be an identity the server can prove belongs to the caller"**:
34+
35+
- A **system** context keeps its explicit actor. The SLA sweep's reserved
36+
`system:sla` sentinel and the ADR-0043 action link — whose single-use hashed
37+
token binds exactly one approver — are unchanged. They are the only callers
38+
holding a trustworthy actor with no session behind them.
39+
- A caller with **no identity at all** is now refused. This is the anonymous case
40+
above.
41+
- **No `actorId`, or one naming the caller**, resolves to the caller. This is the
42+
common path and what the Console already sends.
43+
- **Any other value** is accepted only when the server can prove the caller holds
44+
it — `position:<p>` / `role:<p>` against the positions on the resolved authz
45+
context, or the caller's own email (one lazy `sys_user` read, taken only when
46+
nothing cheaper matched). Otherwise `FORBIDDEN`.
47+
48+
REST still forwards the body value; it is now a *hint* the service validates,
49+
which is what keeps the email and `type:value` slot cases working.
50+
51+
**Upgrade note.** A client that deliberately sent another user's `actorId` now
52+
gets `403 FORBIDDEN` instead of silently succeeding. Send the action as the
53+
acting user's own session — the field can be omitted entirely, and the caller is
54+
used. Server-to-server callers that legitimately act for someone else should
55+
present a system context, as the SLA sweep and the action link already do.
56+
57+
This also makes two existing claims true that were previously aspirational: the
58+
approval object's declared actions say "`actorId` defaults to the caller
59+
server-side… the service remains the authority on who may act", and
60+
`attachViewers` documents `can_act` as mirroring "the exact authorization the
61+
decision methods enforce".
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The acting identity on an approval is the AUTHENTICATED CALLER, never a
5+
* request-body field.
6+
*
7+
* Every mutating entrypoint on the service takes an `actorId` and — before this
8+
* suite — authorized *that value* rather than the caller behind it. The REST
9+
* routes fill it from `body.actorId ?? body.actor_id ?? context.userId`, so the
10+
* body won. An authenticated user could therefore name any pending approver and
11+
* have that approver's decision recorded, finalized, and the owning flow resumed
12+
* — or name a request's submitter and recall it.
13+
*
14+
* #3783 drew exactly this line for the *data-write* identity (see the
15+
* `actingUserId` docblock in the service) and left the authorization side
16+
* body-driven, calling a mislabelled audit row "tolerable". It is not merely a
17+
* label: `pending_approvers.includes(input.actorId)` is the authorization gate
18+
* itself, so naming someone else does not just misattribute the row — it is how
19+
* you get through the door.
20+
*
21+
* The tests below are all "mallory is logged in, names someone else". Each one
22+
* must be FORBIDDEN. The final block is the load-bearing negative: the two
23+
* legitimate callers that supply an actor with no session behind them — the SLA
24+
* sweep (a reserved sentinel) and the ADR-0043 action link (a single-use token
25+
* cryptographically bound to one approver) — must keep working, or the fix has
26+
* simply broken the feature instead of securing it.
27+
*/
28+
29+
import { describe, it, expect, beforeEach } from 'vitest';
30+
import { ApprovalService, SLA_ACTOR_ID } from './approval-service.js';
31+
32+
interface FakeRow { [k: string]: any }
33+
34+
/** Equality/`$in`/`$ne`/`$contains` WHERE matcher — mirrors approval-service.test.ts. */
35+
function makeFakeEngine() {
36+
const tables: Record<string, FakeRow[]> = {};
37+
const ensure = (n: string) => (tables[n] ??= []);
38+
39+
function matches(row: FakeRow, filter: any): boolean {
40+
if (!filter || typeof filter !== 'object') return true;
41+
for (const [k, v] of Object.entries(filter)) {
42+
if (k === '$or') {
43+
if (!(v as any[]).some(sub => matches(row, sub))) return false;
44+
continue;
45+
}
46+
const rv = row[k];
47+
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
48+
if (!(v as any).$in.includes(rv)) return false;
49+
continue;
50+
}
51+
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
52+
if (rv === (v as any).$ne) return false;
53+
continue;
54+
}
55+
if (v != null && typeof v === 'object' && '$contains' in (v as any)) {
56+
if (!String(rv ?? '').includes(String((v as any).$contains))) return false;
57+
continue;
58+
}
59+
if (rv !== v) return false;
60+
}
61+
return true;
62+
}
63+
64+
return {
65+
_tables: tables,
66+
async find(object: string, options?: any) {
67+
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
68+
if (options?.orderBy?.[0]) {
69+
const { field, order } = options.orderBy[0];
70+
rows.sort((a, b) => {
71+
const av = a[field]; const bv = b[field];
72+
if (av === bv) return 0;
73+
const cmp = av > bv ? 1 : -1;
74+
return order === 'desc' ? -cmp : cmp;
75+
});
76+
}
77+
const start = options?.offset ?? 0;
78+
return rows.slice(start, start + (options?.limit ?? 1000));
79+
},
80+
async insert(object: string, data: any) {
81+
ensure(object).push({ ...data });
82+
return { ...data };
83+
},
84+
async update(object: string, idOrData: any, _opts?: any) {
85+
const data = typeof idOrData === 'object' ? idOrData : _opts;
86+
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
87+
const table = ensure(object);
88+
const i = table.findIndex(r => r.id === id);
89+
if (i >= 0) table[i] = { ...table[i], ...data };
90+
return table[i];
91+
},
92+
async delete(object: string, options?: any) {
93+
const table = ensure(object);
94+
const id = options?.where?.id ?? options?.id;
95+
const i = table.findIndex(r => r.id === id);
96+
if (i >= 0) table.splice(i, 1);
97+
return { id };
98+
},
99+
registerHook() {},
100+
unregisterHooksByPackage() { return 0; },
101+
};
102+
}
103+
104+
/** The submitter, and the approver whose slot is up for grabs. */
105+
const SUBMITTER = 'alice';
106+
const APPROVER = 'bob';
107+
/** Authenticated, ordinary, and on neither side of the request. */
108+
const MALLORY = { userId: 'mallory', tenantId: 't1', positions: [], permissions: [] } as any;
109+
const ALICE = { userId: SUBMITTER, tenantId: 't1', positions: [], permissions: [] } as any;
110+
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
111+
112+
function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
113+
return {
114+
approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
115+
behavior: 'first_response' as const,
116+
...extra,
117+
};
118+
}
119+
120+
function openInput(approvers: string[], extra: Record<string, any> = {}, configExtra: Record<string, any> = {}) {
121+
return {
122+
object: 'opportunity',
123+
recordId: 'opp1',
124+
runId: 'run_1',
125+
nodeId: 'approve_step',
126+
flowName: 'deal_approval',
127+
config: nodeConfig(approvers, configExtra),
128+
record: { id: 'opp1', amount: 100 },
129+
...extra,
130+
};
131+
}
132+
133+
/**
134+
* Enough automation surface for the send-back path's ADR-0044 guards to pass,
135+
* so a `sendBack` / `resubmit` test fails on the ACTOR check or not at all —
136+
* never on a missing `revise` out-edge.
137+
*/
138+
function makeAutomationStub() {
139+
const resumed: any[] = [];
140+
const cancelled: string[] = [];
141+
return {
142+
resumed,
143+
cancelled,
144+
async getFlow() {
145+
return {
146+
name: 'deal_approval',
147+
nodes: [{ id: 'approve_step', type: 'approval' }, { id: 'wait_revision', type: 'wait' }],
148+
edges: [
149+
{ id: 'e1', source: 'approve_step', target: 'ok', label: 'approve' },
150+
{ id: 'e2', source: 'approve_step', target: 'no', label: 'reject' },
151+
{ id: 'e3', source: 'approve_step', target: 'wait_revision', label: 'revise' },
152+
{ id: 'e4', source: 'wait_revision', target: 'approve_step', label: 'resubmit', type: 'back' },
153+
],
154+
};
155+
},
156+
async resume(runId: string, signal: any) { resumed.push({ runId, signal }); },
157+
async cancelRun(runId: string) { cancelled.push(runId); },
158+
};
159+
}
160+
161+
describe('approvals: the actor is the authenticated caller, not a body field', () => {
162+
let engine: ReturnType<typeof makeFakeEngine>;
163+
let svc: ApprovalService;
164+
let n = 0;
165+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
166+
167+
beforeEach(() => {
168+
engine = makeFakeEngine();
169+
n = 0;
170+
svc = new ApprovalService({
171+
engine: engine as any,
172+
clock: { now: () => new Date(baseTime + (n++) * 1000) },
173+
});
174+
});
175+
176+
/** Open a request submitted by alice and pending on bob. */
177+
const open = (approvers = [APPROVER], configExtra: Record<string, any> = {}) =>
178+
svc.openNodeRequest(openInput(approvers, {}, configExtra), ALICE);
179+
180+
// ── the decision itself ─────────────────────────────────────────
181+
182+
it('decideNode: mallory cannot approve by naming the pending approver', async () => {
183+
const req = await open();
184+
await expect(
185+
svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
186+
).rejects.toThrow(/FORBIDDEN/);
187+
});
188+
189+
it('decideNode: a refused impersonation writes no audit row and leaves the request pending', async () => {
190+
const req = await open();
191+
await expect(
192+
svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
193+
).rejects.toThrow(/FORBIDDEN/);
194+
195+
const decisions = (engine._tables['sys_approval_action'] ?? [])
196+
.filter((a: any) => a.action === 'approve' || a.action === 'reject');
197+
expect(decisions).toHaveLength(0);
198+
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
199+
});
200+
201+
it('decideNode: mallory cannot reject by naming the pending approver', async () => {
202+
const req = await open();
203+
await expect(
204+
svc.decideNode(req.id, { decision: 'reject', actorId: APPROVER, comment: 'no' }, MALLORY),
205+
).rejects.toThrow(/FORBIDDEN/);
206+
});
207+
208+
it('decide: the flow is not resumed by an impersonated decision', async () => {
209+
const resumed: any[] = [];
210+
svc.attachAutomation({ async resume(runId: string, signal: any) { resumed.push({ runId, signal }); } } as any);
211+
const req = await open();
212+
await expect(
213+
svc.decide(req.id, { decision: 'approve', actorId: APPROVER }, MALLORY),
214+
).rejects.toThrow(/FORBIDDEN/);
215+
expect(resumed).toHaveLength(0);
216+
});
217+
218+
it('decideNode: a unanimous slate cannot be filled by one user naming the others', async () => {
219+
const req = await open(['bob', 'carol'], { behavior: 'unanimous' });
220+
const asBob = { userId: 'bob', tenantId: 't1', positions: [], permissions: [] } as any;
221+
const first = await svc.decideNode(req.id, { decision: 'approve', actorId: 'bob' }, asBob);
222+
expect(first.finalized).toBe(false);
223+
// Bob holds a slot, so he clears the "is a pending approver" gate — but the
224+
// slot he clears it with is his own, not carol's.
225+
await expect(
226+
svc.decideNode(req.id, { decision: 'approve', actorId: 'carol' }, asBob),
227+
).rejects.toThrow(/FORBIDDEN/);
228+
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
229+
});
230+
231+
// ── the submitter-only moves ────────────────────────────────────
232+
233+
it('recall: mallory cannot withdraw the request by naming its submitter', async () => {
234+
const req = await open();
235+
await expect(
236+
svc.recall(req.id, { actorId: SUBMITTER, comment: 'gone' }, MALLORY),
237+
).rejects.toThrow(/FORBIDDEN/);
238+
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
239+
});
240+
241+
it('resubmit: mallory cannot resubmit a returned request by naming its submitter', async () => {
242+
svc.attachAutomation(makeAutomationStub() as any);
243+
const req = await open();
244+
const asBob = { userId: APPROVER, tenantId: 't1', positions: [], permissions: [] } as any;
245+
await svc.sendBack(req.id, { actorId: APPROVER, comment: 'fix it' }, asBob);
246+
expect(engine._tables['sys_approval_request'][0].status).toBe('returned');
247+
await expect(
248+
svc.resubmit(req.id, { actorId: SUBMITTER, comment: 'done' }, MALLORY),
249+
).rejects.toThrow(/FORBIDDEN/);
250+
});
251+
252+
it('sendBack: mallory cannot return the request by naming the pending approver', async () => {
253+
const req = await open();
254+
await expect(
255+
svc.sendBack(req.id, { actorId: APPROVER, comment: 'revise' }, MALLORY),
256+
).rejects.toThrow(/FORBIDDEN/);
257+
expect(engine._tables['sys_approval_request'][0].status).toBe('pending');
258+
});
259+
260+
// ── the thread moves ────────────────────────────────────────────
261+
262+
it('reassign: mallory cannot move the slot by naming its holder', async () => {
263+
const req = await open();
264+
await expect(
265+
svc.reassign(req.id, { actorId: APPROVER, to: 'mallory' }, MALLORY),
266+
).rejects.toThrow(/FORBIDDEN/);
267+
expect(engine._tables['sys_approval_request'][0].pending_approvers).toBe(APPROVER);
268+
});
269+
270+
it('requestInfo: mallory cannot post as the pending approver', async () => {
271+
const req = await open();
272+
await expect(
273+
svc.requestInfo(req.id, { actorId: APPROVER, comment: 'send the contract' }, MALLORY),
274+
).rejects.toThrow(/FORBIDDEN/);
275+
});
276+
277+
it('comment: mallory cannot post to the thread as the submitter', async () => {
278+
const req = await open();
279+
await expect(
280+
svc.comment(req.id, { actorId: SUBMITTER, comment: 'looks fine to me' }, MALLORY),
281+
).rejects.toThrow(/FORBIDDEN/);
282+
});
283+
284+
it('remind: mallory cannot nudge as the submitter', async () => {
285+
const req = await open();
286+
await expect(
287+
svc.remind(req.id, { actorId: SUBMITTER }, MALLORY),
288+
).rejects.toThrow(/FORBIDDEN/);
289+
});
290+
291+
// ── the legitimate paths must survive ───────────────────────────
292+
//
293+
// Without these, "reject every actorId that isn't the caller" would pass the
294+
// suite above by breaking the SLA sweep and the emailed action link — the two
295+
// callers that hold a trustworthy actor with no session behind it.
296+
297+
it('the real approver still decides their own slot', async () => {
298+
const req = await open();
299+
const asBob = { userId: APPROVER, tenantId: 't1', positions: [], permissions: [] } as any;
300+
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: APPROVER }, asBob);
301+
expect(out.finalized).toBe(true);
302+
expect(out.request.status).toBe('approved');
303+
const row = (engine._tables['sys_approval_action'] ?? []).find((a: any) => a.action === 'approve');
304+
expect(row.actor_id).toBe(APPROVER);
305+
});
306+
307+
it('the real submitter still recalls their own request', async () => {
308+
const req = await open();
309+
const out = await svc.recall(req.id, { actorId: SUBMITTER }, ALICE);
310+
expect(out.request.status).toBe('recalled');
311+
});
312+
313+
it('a system context may still name an actor with no session behind it (SLA sweep)', async () => {
314+
const req = await open();
315+
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: SLA_ACTOR_ID }, SYS);
316+
expect(out.finalized).toBe(true);
317+
const row = (engine._tables['sys_approval_action'] ?? []).find((a: any) => a.action === 'approve');
318+
expect(row.actor_id).toBe(SLA_ACTOR_ID);
319+
});
320+
321+
it('a privileged admin may still override a stuck request', async () => {
322+
const req = await open(['position:cfo']);
323+
const admin = {
324+
userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'],
325+
} as any;
326+
const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'root' }, admin);
327+
expect(out.finalized).toBe(true);
328+
expect(out.request.status).toBe('approved');
329+
});
330+
});

0 commit comments

Comments
 (0)