Found while reviewing #3783's blast radius. Hardening task.
Detail is deliberately withheld here. This is a public repo with published
packages; the full analysis, the verification and the impact assessment live in a
private draft advisory under the repo's Security tab, per this repo's
split-disclosure convention. This issue tracks the requirement and the design.
Requirement
On every ApprovalService entrypoint the acting identity must be resolved
server-side from the authenticated execution context. Today it is taken from
the request payload (actorId), which REST forwards verbatim, and the service's
own checks then read that same value. An identity the caller supplies must never
be the thing that satisfies the check that gates the action.
#3783 touched the adjacent seam and pinned the data-write identity to the
context (actingUserId), but scoped itself there and characterised the remaining
audit-row use as tolerable. That characterisation was too generous: the same
field is read by the authorization checks, so it needs the same treatment.
Why the rule cannot be "actorId must equal context.userId"
A slot is not always keyed by a user id, so strict equality would reject
legitimate approvers:
resolveApproverSpec stores the type:value literal when a graph lookup
yields nothing, so a slot can read position:finance.- The showcase documents deciding with exactly that form.
- The Console picks from the caller's own identity list — id, email, or
role:<r> — and gates its buttons on that set client-side.
Design
The rule is "the actor must be an identity the server can prove belongs to the
caller", implemented as ApprovalService.resolveActor:
- system context → keeps its explicit actor (the SLA sentinel, the ADR-0043
action-link token, both of which derive their actor server-side already).
Unchanged. - no identity at all →
FORBIDDEN. - no
actorId, or actorId === context.userId → the caller. The common path. - anything else → allowed only when it matches
position:<p> / role:<p>
for a position on the server-resolved context, or the caller's own email (one
lazy sys_user read, only when nothing cheaper matched). Otherwise
FORBIDDEN.
REST keeps forwarding the body value — it becomes a hint the service validates,
which is what preserves the email and type:value slot cases.
This also makes two existing claims true that were previously aspirational:
sys-approval-request.object.ts ("actorId defaults to the caller server-side…
The service remains the authority on who may act"), and attachViewers' claim
that can_act "mirrors the exact authorization the decision methods enforce."
Note for reviewers of the fix
A number of existing tests fail against it, every one because it acted with a
context that named nobody, or named someone other than the actor it claimed —
approval-revise.test.ts shared one context with no userId across all its
calls, and approval-service.test.ts called as one user while passing another
as actorId. Each was updated to present the acting user's own context. Same
shape as #3760, where tests had likewise pinned the old behaviour.
Refs #3783, #3760. Fix in #3805.
Found while reviewing #3783's blast radius. Hardening task.
Detail is deliberately withheld here. This is a public repo with published
packages; the full analysis, the verification and the impact assessment live in a
private draft advisory under the repo's Security tab, per this repo's
split-disclosure convention. This issue tracks the requirement and the design.
Requirement
On every
ApprovalServiceentrypoint the acting identity must be resolvedserver-side from the authenticated execution context. Today it is taken from
the request payload (
actorId), which REST forwards verbatim, and the service'sown checks then read that same value. An identity the caller supplies must never
be the thing that satisfies the check that gates the action.
#3783 touched the adjacent seam and pinned the data-write identity to the
context (
actingUserId), but scoped itself there and characterised the remainingaudit-row use as tolerable. That characterisation was too generous: the same
field is read by the authorization checks, so it needs the same treatment.
Why the rule cannot be "
actorIdmust equalcontext.userId"A slot is not always keyed by a user id, so strict equality would reject
legitimate approvers:
resolveApproverSpecstores thetype:valueliteral when a graph lookupyields nothing, so a slot can read
position:finance.role:<r>— and gates its buttons on that set client-side.Design
The rule is "the actor must be an identity the server can prove belongs to the
caller", implemented as
ApprovalService.resolveActor:action-link token, both of which derive their actor server-side already).
Unchanged.
FORBIDDEN.actorId, oractorId === context.userId→ the caller. The common path.position:<p>/role:<p>for a position on the server-resolved context, or the caller's own email (one
lazy
sys_userread, only when nothing cheaper matched). OtherwiseFORBIDDEN.REST keeps forwarding the body value — it becomes a hint the service validates,
which is what preserves the email and
type:valueslot cases.This also makes two existing claims true that were previously aspirational:
sys-approval-request.object.ts("actorIddefaults to the caller server-side…The service remains the authority on who may act"), and
attachViewers' claimthat
can_act"mirrors the exact authorization the decision methods enforce."Note for reviewers of the fix
A number of existing tests fail against it, every one because it acted with a
context that named nobody, or named someone other than the actor it claimed —
approval-revise.test.tsshared one context with nouserIdacross all itscalls, and
approval-service.test.tscalled as one user while passing anotheras
actorId. Each was updated to present the acting user's own context. Sameshape as #3760, where tests had likewise pinned the old behaviour.
Refs #3783, #3760. Fix in #3805.