Uh oh!
There was an error while loading. Please reload this page.
fix(approvals): an approval action is recorded against the authenticated caller (#3800) - #3805
Merged
Merged
Conversation
…ted caller (#3800) 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: Claude <noreply@anthropic.com>
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes#3800.
Public text kept neutral on purpose. This is a public repo with published
packages; the analysis and impact assessment live in a private draft advisory
under the repo's Security tab, per this repo's split-disclosure convention.
This description covers the requirement and the design only.
Requirement
On every
ApprovalServiceentrypoint the acting identity must be resolvedserver-side from the authenticated execution context. It was taken from the
request payload (
actorId, forwarded verbatim by REST at all nine call sites)and the service's own checks then read that same value — so the thing that
satisfied the check was supplied by the caller.
#3783 pinned the data-write identity to the context (
actingUserId), scopeditself there, and characterised the remaining payload use as tolerable. Too
generous: the same field is read by the authorization checks, so it needed the
same treatment.
The fix
ApprovalService.resolveActorpins the actor server-side on all nineentrypoints.
The rule is deliberately not "
actorIdmust equalcontext.userId." A slotcan legitimately be keyed by something else —
resolveApproverSpecstores thetype:valueliteral when a graph lookup finds no holders, the showcasedocuments
{ actorId: 'position:finance' }, and the Console picks from thecaller's own identity list (id, email, or
role:<r>), gating its buttons onthat set client-side rather than on the server's
viewer.can_act. A strict-uidrule would 403 real approvers. So:
FORBIDDEN.actorId, or one naming the callerposition:/role:against the resolved authz context, or their own email (one lazysys_userread, only when nothing cheaper matched). ElseFORBIDDEN.REST keeps forwarding the body value; it is now a hint the service validates,
which is what preserves the email and
type:valueslot cases. A comment at theroute says so, so nobody reads it as unchecked again.
Verification
A new suite covers the rule across all nine entrypoints — 16 cases, of which 12
were red before this change and all 16 pass now.
It deliberately keeps 4 of them as load-bearing negatives — the real approver
deciding their own slot, the real submitter recalling, the SLA sentinel under a
system context, and an admin override — so "reject every
actorIdthat isn'tthe caller" cannot pass by breaking the feature instead of securing it.
Note the pre-existing
approval-service.test.ts:653("blocks a non-approver ina non-system context") only ever exercised a non-approver, so it passed
while the case this PR closes went uncovered.
pnpm test: 132/132 turbo tasks green, dogfood included.The 24 existing tests that failed against the fix
Worth reading as evidence rather than noise — every one acted with a context
that named nobody, or named someone other than the actor it claimed:
approval-revise.test.tsused a singleUSER_CTX = { isSystem: false, positions: [], permissions: [] }— nouserId— for all 32 calls,pinning the old permissive behaviour in place.
approval-service.test.tscalled asCTX(u1) while passingactorId: 'u9'in 11 places.Each now presents the acting user's own context via an
asUser(id)helper. Twoneeded real thought rather than a mechanical fix:
now unreachable. Split into the authenticated case (the approver's own write
echoes back even though approving clears them from
pending_approvers— astronger guard for 审批请求的可见性是「按租户」而非「按参与者」——getRequest 用 SYSTEM_CTX 读、只按 organization_id 收窄 #3590's actual intent) and the service-to-service case
under a system context.
runAs:'system'#3783 "never takes the identity from the caller-suppliedactorId" testdescribed a scenario that can no longer happen. It now asserts the call is
refused and that the mirror identity did not follow the body, so neither
line can regress silently behind the other.
Same shape as #3760, where tests had likewise pinned the prior behaviour.
Filed separately, not fixed here
#3801 — the generic run-resume route validates machine state only and needs an
authorization gate of its own, keyed on what the run is parked on. It is a
different shape (the route is load-bearing for screen flows) and belongs in its
own change.
🤖 Generated with Claude Code