Skip to content

sec(auth): enforce per-permission Constraints on execute-any/own, approve-any/own, runPlanned - #378

Merged
cristim merged 4 commits into
mainfrom
fix/60-enforce-approve-runplanned-constraints
Sep 28, 2026
Merged

cristim merged 4 commits into
mainfrom
fix/60-enforce-approve-runplanned-constraints

Conversation

@cristim

@cristim cristim commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Summary

PR cli-monorepo#1210 fixed SEC-01 (issue #1141) by enforcing per-permission
Constraints (MaxPurchaseAmount, Providers, Services, Regions, AccountIDs)
on the base execute:purchases permission before a web-submitted purchase
executes. The same fail-open class remained on three sibling
money-mutating call sites named in this issue: execute-any/execute-own
(direct-execute), approve-any/approve-own (session approve, which fires
the purchase synchronously via ApproveAndExecute), and runPlannedPurchase
(the scheduler run-now transition).

Root cause

authorizeSessionExecuteDirect, authorizeSessionApprove and
runPlannedPurchase each ran only a bare HasPermissionAPI verb check for
their respective verb, never consulting that permission's own Constraints.
A session whose execute-any/execute-own/approve-any/approve-own
permission was configured with a tighter cap than its base
execute:purchases permission could mutate past that tighter cap, because
the verb check alone (with nil request-side constraints) always passes for a
constrained permission.

requirePermissionConstraints also had its action parameter hardcoded to
"execute", since every existing caller checked exactly that action. That
made it structurally impossible to reuse for execute-any/execute-own/
approve-any/approve-own without threading a real parameter back in
(exactly what the removed constant's own doc comment called for).

An independent review of this PR (see thread comments) found a second gap
in the initial fix: approvePurchase's session-vs-token dispatch treated
any 403 from authorizeSessionApprove the same, whether it was a bare
missing-verb denial or a Constraints-exceeded denial, and fell through to
approveViaToken (the email deep-link flow) in both cases.
approveViaToken never evaluates Constraints, so a session with a capped
approve-any/approve-own who was also the account's registered
contact_email could approve past its own cap via the email link (which
always carries a session, since the frontend forces a login first). The
review also found that authorizeSessionApprove and runPlannedPurchase
skipped requireNonZeroCommitment, so an execution whose recommendations
summed to $0 would read as an uncapped MaxPurchaseAmount.

Fix

  • Threaded action through requirePermissionConstraints as an explicit
    parameter (internal/api/handler.go), updating the three existing
    execute:purchases/execute:ri-exchange call sites to pass "execute"
    explicitly.
  • authorizeSessionExecuteDirect (internal/api/handler_purchases.go) now
    takes the execution's recommendation set and, once the execute-any/
    execute-own verb branch is chosen, builds the same purchaseConstraintSets
    the web execute path already builds and calls
    requirePermissionConstraints(session, "execute-any"|"execute-own", "purchases", sets).
  • authorizeSessionApprove does the same for approve-any/approve-own
    against the already-loaded execution's Recommendations (no extra fetch
    needed: both callers already hold the full row).
  • runPlannedPurchase re-hydrates the execution via GetExecutionByID and
    calls the same constraint check before the pending|paused -> running
    transition.
  • Review follow-up: added a constraintDeniedError wrapper type so a
    Constraints-exceeded 403 is no longer recognized by isPermissionDenied's
    deliberately strict, unwrapped *clientError assertion, forcing
    approvePurchase's dispatch into its terminal default: (propagate)
    branch instead of falling through to the token flow.
    IsClientError still resolves it to the right HTTP code/message via
    errors.As + Unwrap, so response mapping is unaffected.
    approveViaToken also gets its own independent Constraints check
    (approveConstraintsForSession) as defense in depth, in case it's ever
    reached some other way with such a session present. All five constrained
    call sites now route through a shared enforcePurchaseConstraints(action, recs) helper that always runs requireNonZeroCommitment first.

Regression tests

Mirrors of TestHandler_executePurchase_PermissionConstraintsDenied for
every new call site, each proving a session that passes the bare-verb gate
but whose per-permission Constraints reject the request gets a 403
before any state mutation or SDK call:

  • TestHandler_authorizeSessionExecuteDirect_ExecuteAny_ConstraintsDenied
  • TestHandler_authorizeSessionExecuteDirect_ExecuteOwn_ConstraintsDenied
  • TestHandler_approvePurchase_SessionApproveAny_PermissionConstraintsDenied
    (end-to-end via approvePurchase, asserts ApproveAndExecute/ApproveExecution
    are never called)
  • TestHandler_authorizeSessionApprove_ApproveOwn_ConstraintsDenied
  • TestHandler_approvePurchase_SessionApproveAny_TokenPresent_ConstraintsDenied
    (review follow-up: drives approvePurchase with a non-empty token,
    the exact shape the review flagged, and asserts no ValidateCSRFToken /
    approveViaToken-only mock is ever consulted)
  • TestHandler_approveViaToken_SessionApproveAny_ConstraintsDenied (review
    follow-up: drives approveViaToken directly, with the contact_email
    gate wired to succeed, to isolate approveConstraintsForSession's own
    check)

Proof the fix closes a real gap (not committed, used to validate locally):
a standalone repro built a session holding execute:purchases with no cap
and execute-any:purchases capped at MaxPurchaseAmount: 100, then called
executePurchase with execute_mode: "direct" for a $50,000 recommendation.

  • Against origin/main (pre-fix): the call succeeds (err == nil), i.e. the
    $50,000 purchase executes despite the $100 execute-any cap.
  • Against this branch (post-fix): the call returns
    permission denied: this request exceeds the constraints configured on your execute-any permission for purchases.

The two review-follow-up tests above were verified against the pre-review-fix
commit (git stash isolating the two production files) and confirmed to
fail there (reaching approveViaToken/its contact_email gate, or hitting a
missing-mock panic proving the same code path was reached) before passing
against the current commit.

Verification

  • go build ./... — passes
  • go vet ./... — clean
  • go test -race ./internal/api/... — 2222 passed
  • go test ./... (full module) — 6140 passed in 33 packages
  • go mod tidy -diff — empty
  • golangci-lint run at the exact CI-pinned version (v2.10.1, under
    GOTOOLCHAIN=go1.26.6 to match ci.yml's GO_VERSION: 1.26.6) — 0 issues
  • gocyclo -over 10 on the changed files — clean

Docker is unavailable on this host (daemon hung), so testcontainers-based
DB integration tests were not run locally; CI covers those.

Follow-up

Issue #60's own review comments widened the fail-open census beyond the
three verbs above to five more call sites (upsertLadderConfig,
updateConfig, updateRIExchangeConfig, marketplaceList,
revokePurchase). Those are tracked separately in #404 rather than folded
into this PR, since each needs its own money-relevant Constraints
dimensions worked out (not a mechanical copy of purchaseConstraintSets).

Closes #60

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 8 billable files and costs up to $2.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 30 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 58 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml

Review profile: CHILL

Plan: Essentials

Run ID: 4b5ff3bf-035a-47e1-b5b7-a12912c5c94a

📥 Commits

Reviewing files that changed from the base of the PR and between 858b9c6 and 225a829.

📒 Files selected for processing (8)
  • internal/api/grantadmin_carveout_test.go
  • internal/api/handler.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_test.go
  • internal/api/middleware_test.go
  • internal/api/purchase_pricing.go

Comment @coderabbitai help to get the list of available commands.

@cristim cristim added urgency/this-sprint Within the current sprint priority/p1 Next up; this sprint severity/high Significant harm impact/all-users Affects every user effort/m Days type/security Security finding triaged Item has been triaged labels Sep 28, 2026
@cristim

cristim commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your current included review allowance is based on your included PR review attempts over the past 7 days. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 35 seconds.

cristim added a commit that referenced this pull request Sep 28, 2026
…ypass

Independent review of #378 found that approvePurchase's session-vs-token
dispatch treated authorizeSessionApprove's Constraints-exceeded 403 the
same as a bare missing-verb 403 (isPermissionDenied matches any 403
ClientError), so it fell through to approveViaToken -- which never
evaluates Constraints. A session with a capped approve-any/approve-own
permission who was also the account's registered contact_email could
approve past its own cap via the email deep-link (which always carries a
session, since the frontend forces a login first).

Fix: wrap a Constraints-exceeded 403 in a new constraintDeniedError type
so isPermissionDenied's deliberately strict, unwrapped *clientError
assertion no longer recognizes it, forcing the dispatch's default
(propagate, terminal) branch instead of the fall-through one.
IsClientError still resolves it via errors.As + Unwrap, so HTTP response
mapping is unaffected. approveViaToken also gets its own independent
Constraints check (approveConstraintsForSession) as defense in depth, in
case it is ever reached some other way with such a session present.

Also: authorizeSessionApprove and runPlannedPurchase previously skipped
requireNonZeroCommitment, so an execution whose recommendations summed
to /bin/zsh would read as an uncapped MaxPurchaseAmount. All five constrained
call sites (execute-any, execute-own, approve-any, approve-own,
runPlannedPurchase) now route through enforcePurchaseConstraints, which
threads an explicit action parameter and always runs
requireNonZeroCommitment before the Constraints check.

Filed #404 to track the five
remaining call sites named in issue #60's review comments (ladder
config, global config, RI-exchange config, marketplace list, revoke)
that carry the same fail-open shape but are out of scope for this PR.

Regression tests: TestHandler_approvePurchase_SessionApproveAny_TokenPresent_ConstraintsDenied
proves the dispatch-level fix with a non-empty token (the exact shape
the review flagged); TestHandler_approveViaToken_SessionApproveAny_ConstraintsDenied
drives approveViaToken directly to prove its own independent check.
Both fail against the pre-fix code (verified via git stash) and pass
against the fix.
cristim and others added 2 commits September 28, 2026 14:44
…rove-any/own, runPlanned

SEC-01 (issue #1141, PR cli-monorepo#1210) enforced per-permission
Constraints (MaxPurchaseAmount, Providers, Services, Regions, AccountIDs)
on the base execute:purchases permission. The same fail-open class
remained on three sibling money-mutating call sites: the direct-execute
gate (authorizeSessionExecuteDirect, execute-any/execute-own), the
session-approve gate (authorizeSessionApprove, approve-any/approve-own,
which hands off to ApproveAndExecute), and runPlannedPurchase's
pending/paused -> running transition. Each ran only a bare
HasPermissionAPI verb check, so a session capped tighter on one of
these verbs than on its base execute:purchases permission could still
mutate past that cap.

requirePermissionConstraints previously hardcoded its action parameter
to "execute" since every existing caller checked exactly that action.
Threaded action through explicitly now that execute-any, execute-own,
approve-any and approve-own are real distinct values, and wired all
three new call sites to build the same purchaseConstraintSets already
used by the web execute path and re-check them against the verb that
actually authorized the mutation.

Regression tests mirror TestHandler_executePurchase_PermissionConstraintsDenied
for each new call site (execute-any, execute-own, approve-any,
approve-own), asserting a 403 before any state mutation or SDK call.
Verified with a standalone repro: a session with execute:purchases
(uncapped) and execute-any:purchases capped at $100 could direct-execute
a $50,000 purchase pre-fix; post-fix the same request is denied with a
403 naming the exceeded constraints.

Co-Authored-By: claude-flow <ruv@ruv.net>
…ypass

Independent review of #378 found that approvePurchase's session-vs-token
dispatch treated authorizeSessionApprove's Constraints-exceeded 403 the
same as a bare missing-verb 403 (isPermissionDenied matches any 403
ClientError), so it fell through to approveViaToken -- which never
evaluates Constraints. A session with a capped approve-any/approve-own
permission who was also the account's registered contact_email could
approve past its own cap via the email deep-link (which always carries a
session, since the frontend forces a login first).

Fix: wrap a Constraints-exceeded 403 in a new constraintDeniedError type
so isPermissionDenied's deliberately strict, unwrapped *clientError
assertion no longer recognizes it, forcing the dispatch's default
(propagate, terminal) branch instead of the fall-through one.
IsClientError still resolves it via errors.As + Unwrap, so HTTP response
mapping is unaffected. approveViaToken also gets its own independent
Constraints check (approveConstraintsForSession) as defense in depth, in
case it is ever reached some other way with such a session present.

Also: authorizeSessionApprove and runPlannedPurchase previously skipped
requireNonZeroCommitment, so an execution whose recommendations summed
to /bin/zsh would read as an uncapped MaxPurchaseAmount. All five constrained
call sites (execute-any, execute-own, approve-any, approve-own,
runPlannedPurchase) now route through enforcePurchaseConstraints, which
threads an explicit action parameter and always runs
requireNonZeroCommitment before the Constraints check.

Filed #404 to track the five
remaining call sites named in issue #60's review comments (ladder
config, global config, RI-exchange config, marketplace list, revoke)
that carry the same fail-open shape but are out of scope for this PR.

Regression tests: TestHandler_approvePurchase_SessionApproveAny_TokenPresent_ConstraintsDenied
proves the dispatch-level fix with a non-empty token (the exact shape
the review flagged); TestHandler_approveViaToken_SessionApproveAny_ConstraintsDenied
drives approveViaToken directly to prove its own independent check.
Both fail against the pre-fix code (verified via git stash) and pass
against the fix.
@cristim
cristim force-pushed the fix/60-enforce-approve-runplanned-constraints branch from da30659 to 8efd142 Compare September 28, 2026 13:48
@cristim

cristim commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current origin/main (was 70 commits behind) to pick up RunPlannedPurchaseNow (issue #218's refactor of runPlannedPurchase to share ApproveAndExecute's 4-eyes-gated CAS funnel) and other merged sibling PRs. The Constraints check from this PR now runs before RunPlannedPurchaseNow fires the purchase, same as before against the old raw TransitionExecutionStatus call. New head: 8efd142d.

@cristim

cristim commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review, final head 225a829: MERGE. Earlier passes approved the code: the approve-any token-flow bypass is closed (terminal constraintDeniedError plus approveConstraintsForSession), requireNonZeroCommitment runs on all 5 call sites, and the rebase resolution runs the constraints check before RunPlannedPurchaseNow's 4-eyes/CAS funnel with both sides' guards intact. Final pass (Opus 5.5, tests delta 8efd142..225a829): only handler_purchases_test.go changed. With the runPlannedPurchase constraint block disabled, TestHandler_runPlannedPurchase_ConstraintDenied and _ZeroCommitment both fail; they pass restored. TestNoUnfailableMockAssertions passes; CI 25/25. TOCTOU follow-up: #415.

@cristim
cristim merged commit 3128d0f into main Sep 28, 2026
25 checks passed
cristim added a commit that referenced this pull request Sep 28, 2026
…first

requireExecutionAccess only checked that the execution's plan accounts
intersect the session's allowed_accounts. With an operator scoped to A, a
plan spanning {A, B} and an execution for B, the check passed, so approve,
cancel, retry, revoke and run-now all let the operator act on B's money.
It now also requires the session to be allowed the execution's own
CloudAccountID when one is set (via requireAccountAccess, which resolves
the account name for name-based allow-lists). A nil CloudAccountID keeps
the plan-only check, and an empty PlanID still fails closed.

The scope gate now runs before RBAC and Constraints on every session path:
the approve, cancel and revoke dispatchers, approvePurchaseViaSession,
cancelPurchaseViaSession, tryRevokeViaSession and revokeScheduledExecution.
Previously an in-RBAC but out-of-scope caller could get a Constraints 403
before the scope 404, confirming the execution exists; after #378 the
approve dispatcher also evaluated Constraints before the gate. A scope miss
in a dispatcher falls through to the email-token branch like an RBAC 403,
so the contact_email token path stays unscoped as issue #92 intends;
without a token the session branch re-runs the gate and returns 404 (401
from the revoke dispatcher, as for an RBAC denial).

Tests: TestExecutionEndpoints_OutOfScopeExecutionRefused drives approve,
cancel, retry, both revoke paths and run-now with a Production-scoped
caller holding every money verb, against a plan-out-of-scope execution
and a cross-account execution, asserting refusal and no mutation.
TestRequireExecutionAccess_InScopeCloudAccountAllowed is the positive
control. Tests reaching the dispatchers with an unscoped session now mock
GetAllowedAccountsAPI, since the gate runs first.
cristim added a commit that referenced this pull request Sep 28, 2026
…first

requireExecutionAccess only checked that the execution's plan accounts
intersect the session's allowed_accounts. With an operator scoped to A, a
plan spanning {A, B} and an execution for B, the check passed, so approve,
cancel, retry, revoke and run-now all let the operator act on B's money.
It now also requires the session to be allowed the execution's own
CloudAccountID when one is set (via requireAccountAccess, which resolves
the account name for name-based allow-lists). A nil CloudAccountID keeps
the plan-only check, and an empty PlanID still fails closed.

The scope gate now runs before RBAC and Constraints on every session path:
the approve, cancel and revoke dispatchers, approvePurchaseViaSession,
cancelPurchaseViaSession, tryRevokeViaSession and revokeScheduledExecution.
Previously an in-RBAC but out-of-scope caller could get a Constraints 403
before the scope 404, confirming the execution exists; after #378 the
approve dispatcher also evaluated Constraints before the gate. A scope miss
in a dispatcher falls through to the email-token branch like an RBAC 403,
so the contact_email token path stays unscoped as issue #92 intends;
without a token the session branch re-runs the gate and returns 404 (401
from the revoke dispatcher, as for an RBAC denial).

Tests: TestExecutionEndpoints_OutOfScopeExecutionRefused drives approve,
cancel, retry, both revoke paths and run-now with a Production-scoped
caller holding every money verb, against a plan-out-of-scope execution
and a cross-account execution, asserting refusal and no mutation.
TestRequireExecutionAccess_InScopeCloudAccountAllowed is the positive
control. Tests reaching the dispatchers with an unscoped session now mock
GetAllowedAccountsAPI, since the gate runs first.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/all-users Affects every user priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(auth): enforce per-permission Constraints on execute-any/own, approve-any/own, runPlanned (SEC-01 follow-up to #1210)

1 participant