Skip to content

fix(api): enforce cancel permission constraints on session cancels - #975

Merged
cristim merged 1 commit into
mainfrom
fix/cancel-permission-constraints
Oct 16, 2026
Merged

cristim merged 1 commit into
mainfrom
fix/cancel-permission-constraints

Conversation

@cristim

@cristim cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member

Summary

Part of #678. Covers session cancels and the session branch of the email-token revoke route. Planned-purchase DELETE follows in a stacked PR, which closes #678.

authorizeSessionCancel (internal/api/handler_purchases.go) gated cancel-any and cancel-own with bare HasPermissionAPI and never evaluated the granting permission's Constraints (Providers/Services/Regions/AccountIDs). So a session whose cancel permission is scoped to one provider could cancel executions for any provider. This is the same SEC-01 fail-open class #404 fixed for revoke (e5baaab, #681).

Changes:

  • authorizeSessionCancel and a new authorizeCancelOwn call requirePermissionConstraints with revokeExecutionConstraintSets(exec), the strict per-recommendation sets fix(api): enforce permission Constraints on purchase revoke #681 already builds from the stored execution, never from the request. Existing helpers are reused, not copied.
  • cancel-any → cancel-own fallback happens only on a 403, as in fix(api): enforce permission Constraints on purchase revoke #681 revoke. Store errors propagate.
  • Terminal denial: a Constraints denial of a cancel verb the caller holds is wrapped with wrapConstraintDenied. A constrained user who is also a contact approver therefore cannot fall through to the email-token branch and bypass the scope, the same rule approve uses.
  • Covered entry points:
    • cancelPurchase (session mode);
    • cancelPurchaseViaSession;
    • the revoke route's session branch (tryRevokeViaSession).
  • Excluded by design (no session permission to constrain), and documented in comments:
    • the contact-email/token cancel path;
    • SQS cancel messages;
    • the revoke token branch.
  • API-key sessions are now evaluated on the key's effective permissions for these constraint checks, as revoke already does.

No migration. No change to concealment (404 vs 403) for callers without the verb.

Tests

New internal/api/handler_purchases_cancel_constraints_test.go:

  • deny and allow controls per dimension (provider, service, region, account) for each verb;
  • strict unknowns and a multi-recommendation execution;
  • fallback, not-creator and store-error cases;
  • constrained {admin,*} and a user-API-key session;
  • route-level checks that a denied cancel or revoke makes no write, and that a session constrained out of scope plus a token never reaches CancelExecution.

On unfixed code, 32 subtests failed (every deny case); the allow controls passed.

Mutation probes, all killed:

  • drop the cancel-any or cancel-own constraint check;
  • drop the terminal wrap;
  • never fall back, or fall back on any error;
  • blank sets;
  • the non-creator branch returns the ownership 403.

Verification (local, macOS)

  • go build ./... and go vet ./... are clean.
  • go test -race -count=1 ./internal/api/... ./internal/auth/... ./internal/server/... passes.
  • golangci-lint v2.10.1: 0 issues. The pre-commit gocyclo hook (threshold 10) passes.

Review

  • Plan (plans/678-cancel-constraints.md): independent adversarial review, two passes plus an F7 revision.
  • This diff: independent non-author review, two passes, approved at 75b906f.

Related follow-ups: #961 (planned DELETE update-any branch), #956 (admin key constraints on non-strict checks).

🤖 Generated with Claude Code

The session cancel path (POST /api/purchases/cancel/{id} and the session
branch of POST /api/purchases/revoke/{id}) checked only the cancel-any /
cancel-own verbs. The Constraints configured on the granting permission
(providers, services, regions, account ids) were never evaluated, so a
cancel-any grant limited to one provider could still cancel any execution.

authorizeSessionCancel now reuses requirePermissionConstraints and the strict
per-recommendation sets from the #404/#681 revoke fix
(revokeExecutionConstraintSets). A constrained cancel-any falls back to
cancel-own only on a 403, and cancel-own is bounded by its own Constraints.
A Constraints denial is wrapped with wrapConstraintDenied so it stays final
and does not fall through to the contact_email token branch. The token branch
is a separate capability and is not Constraints-checked.

Tests answer from the real matcher and cover every constraint dimension for
both verbs, strict unknown dimensions, multi-recommendation executions, the
fallback, the cancel and revoke-token routes, admin wildcards carrying
Constraints and user API key sessions. The planned-purchase DELETE route has
the same gap and follows in a stacked change.

Refs #678, #404, #681

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim cristim added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/few Limited audience effort/s Hours type/security Security finding triaged Item has been triaged labels Oct 16, 2026
@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 4 billable files and costs up to $1.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 7 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 64 included PR review attempts over the past 7 days set your current allowance at 1 review 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: 5bb813e1-b7d2-4309-ac37-fa672ec5263b
📥 Commits

Reviewing files that changed from the base of the PR and between 3b088a4 and 75b906f.

📒 Files selected for processing: 4
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_cancel_constraints_test.go
  • internal/api/handler_purchases_revoke.go
  • internal/api/handler_purchases_test.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@coderabbitai

coderabbitai Bot commented Oct 16, 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 Oct 16, 2026

Copy link
Copy Markdown
Member Author

Independent review verdict: CLEAN at 75b906f (for the paths this PR covers)

Reviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief, acting as a security reviewer. The PR body was treated as a claim. Coordinator: claude-cc-reviewer.

Blocking findings in this diff: none. No bypass or fail-open was found in the changed paths.

Merge-order condition: GitHub links this PR as closing #678, but one session path is still unconstrained here. authorizePlannedPurchaseCancel (handler_purchases.go:431-454) is reached from deletePlannedPurchase (:463-501, DELETE /api/purchases/planned/{id}). It cancels a pending, notified or paused execution after a bare HasPermissionAPI, with no constraints. That is the declared scope of stacked #978. #975 will merge only immediately before #978. If #978 is delayed, I'll reopen #678.

Entry points:

Path Status
cancelPurchase session branch (:1414) covered
cancelPurchaseViaSession (:1499) covered
revoke token route session branch tryRevokeViaSession (:1691) covered
POST /{id}/revoke and completed-purchase revoke covered by #681
contact-email/token cancel, SQS cancel, revoke token branch excluded by design (no session permission)
planned-purchase DELETE not covered here; #978

Non-blocking notes:

  • Missing test: dropping wrapConstraintDenied on the final cancel-own return (:1917) survives. No test covers a cancel-own-only creator who is constrained out, is also a contact approver, and sends a token. The code is correct as written.
  • Concealment: a constraint denial stays 403 "exceeds constraints", because concealExecutionDenial only converts strict *clientError. A caller without the verb still gets 404, as on main. This matches approve, and it only tells a UUID holder who already passed the account-scope gate that the execution exists.

What was attacked and how (local macOS, unit tests with mocks):

  • Empty or unknown scope: zero recommendations give one strict empty set, and knownScopeValue empties are denied by constrained grants. Fails closed.
  • Fallback chain:
    • cancel-any falls back to cancel-own only on isForbidden, and store and auth errors propagate;
    • cancel-own still requires the creator match;
    • constraint sets come only from the stored execution;
    • a denial stays terminal (wrapped).
  • API keys: checked with HasAPIKeyPermissionForConstraintsAPI against the key's effective permissions. The admin-key sentinel is the only bypass, as before.
  • go vet ./internal/api/ is clean. go test -race -count=1 ./internal/api/... ./internal/auth/... gave 4289 passed, 0 failed, 0 skipped.
  • Fail-before: with both handlers reverted to main, -run 'Cancel|Revoke' has 28 failures. With the fix it has 0.
  • Mutation probes:
    • Killed: falling back on any error (1 failing test), dropping the cancel-any wrap (6), dropping the cancel-own constraint call (7), dropping the cancel-any constraint call (24).
    • Survived: dropping the cancel-own wrap (0 failures), which is the non-blocking test gap above.

Gate: labels are mirrored from #678. CI is still finishing at this SHA. #975 merges together with #978 as described above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate 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 cancel-any/cancel-own (SEC-01 follow-up to #404)

1 participant