Skip to content

Commit fb90784

Browse files
os-zhuangclaude
andauthored
fix(approvals): the status mirror names the human who caused the transition (#3783) (#3790)
The write that puts the approval status on the business record is what fires that object's record-change flows — so it is the seam "when the invoice is approved, do X" runs through. It presented a bare `{ isSystem: true }` context with no `userId`, at six call sites that each know exactly who acted. Combined with #3760 (a `runAs:'user'` run with no trigger user may not touch data) that gap made the most natural approvals automation unwritable in its obvious form: the cascade inherited no user, its data nodes were refused, and the only way forward was blanket `runAs:'system'` elevation for a case where a scoped identity existed at the call site all along. The mirror now carries the acting user and stays `isSystem` — the record is normally locked while its approval is live, so only a platform write can land the status. Elevation and anonymity are separate choices; this write only ever needed the first. - Identity comes from the authenticated context, never the request body's `actorId`: that field is caller-supplied and only checked against the pending approver slate, so promoting it to the identity of an RLS-scoped write would turn a mislabelled audit trail into identity spoofing. - ADR-0043 action links now decide as the approver their single-use token binds, so an emailed approval cascades identically to one made in the UI. - The SLA auto-decision and the dead-run sweep stay user-less on purpose — `system:sla` / `system:dead-run` are audit actors, not users. A flow reacting to those declares `runAs:'system'`, which is now a decision, not an artefact. - Attribution only: the request's org is deliberately not carried, because `tenantId` on an ExecutionContext is a driver-scoping knob and would have silently no-op'd the mirror on a record whose org differs. Visible change: the mirrored record's `updated_by` now names the acting user — ObjectQL's audit stamping is gated on `userId` alone and `isSystem` buys no exemption. That is the attribution this fix is for. Verified end-to-end: a new integration test drives a real kernel (ObjectQL + record-change trigger + automation + ApprovalService) and asserts an approval cascade now runs, with the dead-run sweep's user-less mirror still refused as the control. Both fail without this change. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8607a55 commit fb90784

8 files changed

Lines changed: 543 additions & 18 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/plugin-approvals": minor
3+
"@objectstack/service-automation": patch
4+
---
5+
6+
fix(approvals): the status mirror names the human who caused the transition (#3783)
7+
8+
When an approval moves, the service writes the new status onto the business
9+
record (`approvalStatusField`). That write is what fires the record-change flows
10+
bound to that object — so it is the seam "when the invoice is approved, do X"
11+
runs through. It presented a bare `{ isSystem: true }` context with **no
12+
`userId`**, at six call sites that each know exactly who acted: a submitter
13+
submitting, an approver approving, rejecting, sending back, recalling.
14+
15+
Combined with #3760 — which stopped letting a `runAs:'user'` run with no trigger
16+
user touch data — that identity gap made the most natural approvals automation
17+
there is unwritable in its obvious form. The cascade inherited no user, so its
18+
data nodes were refused, and the author's only way forward was to declare
19+
`runAs: 'system'` and take blanket elevation for a case where a perfectly good
20+
scoped identity existed at the call site all along.
21+
22+
The mirror now carries the acting user. It stays `isSystem` — the record is
23+
normally locked while its approval is live, so only a platform write can land the
24+
status — because elevation and anonymity are separate choices, and this write
25+
only ever needed the first. Cascades now run as the deciding user with RLS
26+
enforced.
27+
28+
- **The identity is the authenticated principal, never the request body's
29+
`actorId`.** `actorId` arrives from the caller (`body.actorId ?? context.userId`)
30+
and is only checked against the pending approver slate, never against the
31+
caller. That is tolerable on an audit row; promoting it to the identity of an
32+
RLS-scoped write would have turned a mislabelled audit trail into identity
33+
spoofing.
34+
- **Approval-by-email-link is attributed too.** ADR-0043 action links carry no
35+
session, so they used to decide as pure system. The single-use hashed token
36+
binds exactly one approver and is re-checked against the live slate at
37+
redemption — that is an authentication — so the redeemed decision now presents
38+
that approver, and an emailed approval cascades identically to one made in the
39+
UI.
40+
- **The two machine-driven transitions stay user-less on purpose**: the SLA
41+
escalation's auto-decision and the dead-run sweep. `system:sla` and
42+
`system:dead-run` are reserved audit actors, not users, and presenting one as a
43+
user would put a non-user in `updated_by` and in every downstream flow's
44+
identity. A flow that wants to react to those declares `runAs:'system'` — the
45+
honest answer, and now a deliberate one rather than an artefact.
46+
- **Attribution only — the write is not newly org-scoped.** On an
47+
ExecutionContext `tenantId` is a driver-scoping knob, not attribution
48+
(ObjectQL turns it into a tenant predicate), so passing the request's org would
49+
have silently no-op'd the mirror on a record whose org differs. The automation
50+
engine already back-fills a run's `tenantId` from the resolved user's grants.
51+
52+
**Visible change:** the mirrored record's `updated_by` now names the acting user
53+
instead of retaining its previous value — ObjectQL's audit stamping is gated on
54+
the write context's `userId` alone, and `isSystem` buys no exemption. That is the
55+
attribution this fix is for: the approver who set the record to `approved` is now
56+
its last modifier.

packages/plugins/plugin-approvals/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"devDependencies": {
2727
"@objectstack/objectql": "workspace:*",
2828
"@objectstack/service-automation": "workspace:*",
29+
"@objectstack/trigger-record-change": "workspace:*",
2930
"@types/node": "^26.1.1",
3031
"typescript": "^6.0.3",
3132
"vitest": "^4.1.10"

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

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,13 @@ function makeFakeEngine() {
4545
return true;
4646
}
4747

48+
/** Every `update` the service made, with the context it presented (#3783). */
49+
const writes: Array<{ object: string; data: any; context: any }> = [];
50+
4851
return {
4952
_tables: tables,
5053
_hooks: hooks,
54+
_writes: writes,
5155
async find(object: string, options?: any) {
5256
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
5357
if (options?.orderBy?.[0]) {
@@ -73,6 +77,7 @@ function makeFakeEngine() {
7377
async update(object: string, idOrData: any, _opts?: any) {
7478
const data = typeof idOrData === 'object' ? idOrData : _opts;
7579
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
80+
writes.push({ object, data, context: _opts?.context });
7681
const table = ensure(object);
7782
const i = table.findIndex(r => r.id === id);
7883
if (i >= 0) table[i] = { ...table[i], ...data };
@@ -2538,3 +2543,135 @@ describe('in-band transitions finalise before they resume (#3456 invariant)', ()
25382543
expectCleanHandoffs();
25392544
});
25402545
});
2546+
2547+
/**
2548+
* #3783 — the status mirror names the human who caused the transition.
2549+
*
2550+
* The mirror write lands on the CUSTOMER's object, so it is what fires that
2551+
* object's record-change flows. It has to stay `isSystem` (the record is locked
2552+
* while its approval is live), but dropping the actor left every one of those
2553+
* cascades with no trigger user — which #3760 now refuses outright, forcing
2554+
* "when the invoice is approved, do X" to declare `runAs:'system'`.
2555+
*
2556+
* Each case therefore asserts BOTH halves: the elevation survives (or the lock
2557+
* hook stops mirroring at all) and the identity is present.
2558+
*/
2559+
describe('status mirror identity (#3783)', () => {
2560+
let engine: ReturnType<typeof makeFakeEngine>;
2561+
let svc: ApprovalService;
2562+
let n = 0;
2563+
const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
2564+
2565+
const REVISE_FLOW = {
2566+
name: 'deal_approval',
2567+
edges: [{ id: 'e_rev', source: 'approve_step', target: 'wait_revision', label: 'revise' }],
2568+
};
2569+
2570+
/** The context the service presented on the mirror write, or undefined. */
2571+
const mirrorContext = () =>
2572+
engine._writes.filter(w => w.object === 'opportunity').at(-1)?.context as any;
2573+
2574+
const open = (configExtra: Record<string, any> = {}, ctx: any = CTX) =>
2575+
svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status', ...configExtra }), ctx);
2576+
2577+
beforeEach(() => {
2578+
engine = makeFakeEngine();
2579+
n = 0;
2580+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
2581+
svc.attachAutomation({
2582+
async resume() {},
2583+
async cancelRun() {},
2584+
async getFlow() { return REVISE_FLOW; },
2585+
} as any);
2586+
engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
2587+
});
2588+
2589+
it('submit: mirrors as the submitter, still elevated', async () => {
2590+
await open();
2591+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u1' });
2592+
});
2593+
2594+
it('decide: mirrors as the deciding user', async () => {
2595+
const req = await open();
2596+
const approver = { ...CTX, userId: 'u9' };
2597+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, approver as any);
2598+
expect(engine._tables['opportunity'][0].approval_status).toBe('approved');
2599+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
2600+
});
2601+
2602+
it('recall: mirrors as the recalling user', async () => {
2603+
const req = await open();
2604+
await svc.recall(req.id, { actorId: 'u1' }, CTX);
2605+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u1' });
2606+
});
2607+
2608+
it('sendBack: mirrors as the approver who returned it', async () => {
2609+
const req = await open();
2610+
const approver = { ...CTX, userId: 'u9' };
2611+
await svc.sendBack(req.id, { actorId: 'u9', comment: 'redo the totals' }, approver as any);
2612+
expect(engine._tables['opportunity'][0].approval_status).toBe('returned');
2613+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
2614+
});
2615+
2616+
it('sendBack past the revision budget: the auto-reject mirror names the approver too', async () => {
2617+
const req = await open({ maxRevisions: 0 });
2618+
const approver = { ...CTX, userId: 'u9' };
2619+
const out = await svc.sendBack(req.id, { actorId: 'u9' }, approver as any);
2620+
expect(out.autoRejected, 'expected the auto-reject branch').toBe(true);
2621+
expect(engine._tables['opportunity'][0].approval_status).toBe('rejected');
2622+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
2623+
});
2624+
2625+
it('action link: mirrors as the approver the token is bound to', async () => {
2626+
// ADR-0043 email approval — no session at all, but the single-use hashed
2627+
// token names exactly one approver, and `resolveActionToken` has just
2628+
// re-checked they still hold a pending slot. That IS an authenticated act.
2629+
const req = await open();
2630+
const { approve } = await svc.issueActionTokens(req.id, 'u9');
2631+
expect(await svc.redeemActionToken(approve)).toMatchObject({ ok: true });
2632+
expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
2633+
});
2634+
2635+
it('never takes the identity from the caller-supplied actorId', async () => {
2636+
// `actorId` arrives in the REST body (`body.actorId ?? context.userId`) and
2637+
// is only checked against the pending slate, never against the caller. It is
2638+
// fine on an audit row; making it the identity of an RLS-scoped write would
2639+
// let any authenticated caller borrow a slot holder's identity.
2640+
const req = await open();
2641+
const someoneElse = { ...CTX, userId: 'intruder' };
2642+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, someoneElse as any);
2643+
expect(mirrorContext()?.userId).toBe('intruder');
2644+
});
2645+
2646+
it('SLA auto-decision: stays user-less — no human did it', async () => {
2647+
const req = await open({ escalation: { timeoutHours: 1, action: 'auto_approve', notifySubmitter: false } });
2648+
const raw = engine._tables['sys_approval_request'].find((r: any) => r.id === req.id)!;
2649+
raw.created_at = new Date(baseTime - 3 * 60 * 60 * 1000).toISOString();
2650+
await svc.runEscalations();
2651+
expect(engine._tables['opportunity'][0].approval_status).toBe('approved');
2652+
// `system:sla` is a reserved audit actor, not a user — it must never be
2653+
// presented as one. The cascade stays user-less on purpose; a flow that
2654+
// wants to react to an SLA auto-decision declares runAs:'system'.
2655+
expect(mirrorContext()?.userId).toBeUndefined();
2656+
expect(mirrorContext()).toMatchObject({ isSystem: true });
2657+
});
2658+
2659+
it('dead-run sweep: stays user-less — no human did it', async () => {
2660+
await open();
2661+
svc.attachAutomation({ getRun: async () => ({ status: 'failed' }) } as any);
2662+
expect(await svc.releaseDeadRunRequests()).toMatchObject({ released: 1 });
2663+
expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
2664+
expect(mirrorContext()?.userId).toBeUndefined();
2665+
expect(mirrorContext()).toMatchObject({ isSystem: true });
2666+
});
2667+
2668+
it('carries the actor WITHOUT org-scoping the write', async () => {
2669+
// `tenantId` on an ExecutionContext is a driver-scoping knob, not
2670+
// attribution: ObjectQL turns it into a tenant predicate on the update. The
2671+
// submitter's org (`t1` on CTX) must therefore not ride along, or the mirror
2672+
// would silently no-op on a record whose org differs from the request's.
2673+
await open();
2674+
expect(mirrorContext()).not.toHaveProperty('tenantId');
2675+
expect(mirrorContext()).not.toHaveProperty('organizationId');
2676+
});
2677+
});

0 commit comments

Comments
 (0)