Skip to content

fix(purchases): admin / cancel-* sessions bypass contact_email gate on email-link cancel#216

Merged
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
fix/cancel-deeplink-session-fallback
Apr 30, 2026
Merged

fix(purchases): admin / cancel-* sessions bypass contact_email gate on email-link cancel#216
cristim merged 3 commits into
feat/multicloud-web-frontendfrom
fix/cancel-deeplink-session-fallback

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented Apr 30, 2026

Symptom

Clicking the Cancel purchase link in a notification email surfaces:

Failed to cancel purchase: no per-account contact email configured for this execution; set the cloud account's contact_email before approving

…even when the user is an admin and could already cancel the same execution from the History page Cancel button.

Root cause

cancelPurchase (handler_purchases.go) takes the token branch unconditionally when a URL token is present:

if token != "" {
    actor, err := h.authorizeApprovalAction(ctx, req, execution)  // contact_email gate from #101
    …
}
return h.cancelPurchaseViaSession(ctx, req, execution)             // RBAC, no contact_email

The deep-link cancel flow (frontend/src/purchases-deeplink.ts) always appends ?token=…, so an authenticated admin clicking the email link hits the token branch even though their session already establishes their identity. authorizeApprovalAction then 403s when the execution's recs have no per-account contact_email available — most commonly:

  • AWS ambient-credentials path (CloudAccountID == nil on every rec).
  • Per-account contact_email empty / not yet configured.

The same admin can already cancel from the History page button (cancelPurchaseViaSessionauthorizeSessionCancel RBAC), so blocking them in the email-link flow is gratuitous.

Fix

Pre-check the session in cancelPurchase before falling into the token branch:

if session := h.tryGetSession(ctx, req); session != nil {
    if err := h.authorizeSessionCancel(ctx, session, execution); err == nil {
        return h.cancelPurchaseViaSession(ctx, req, execution)
    }
}
// existing token branch unchanged

Tokenless callers (no session) still hit the per-account contact_email gate from PR #101 — the fix is strictly additive and only widens for sessions that pass the existing RBAC matrix (admin / cancel-any:purchases / cancel-own:purchases matching created_by_user_id).

A small helper tryGetSession is extracted from tryResolveActorEmail (which collapses to a one-line wrapper).

Out of scope (deliberate)

  • Approve flow stays strict. The dashboard has no admin approve override either; widening approval via email link would change the security policy. Approve raises spend, cancel reduces it — different blast radius.
  • The misleading 403 message text (it tells operators to set contact_email even though the global notification email no longer participates in approval post-fix(security): approval-flow hardening — crypto/rand tokens + per-account contact gating #101). Worth tightening in a follow-up.

Test plan

Three new regression tests:

  • TestHandler_cancelPurchase_DeepLink_AdminBypassesContactEmailGate — admin session + token + ambient-credentials execution → 200, status flips, CancelledBy stamped. Asserts GetGlobalConfig (the token branch's signature call) is NOT consulted.
  • TestHandler_cancelPurchase_DeepLink_CancelOwnBypassesContactEmailGate — non-admin with cancel-own + matching creator → 200.
  • TestHandler_cancelPurchase_DeepLink_NonPrivilegedSessionStillHitsContactGate — pins the security invariant: a logged-in user without admin / cancel-* MUST still go through the contact_email gate.

Existing tests with sessions but no permission stubs (TestHandler_cancelPurchase, TestHandler_cancelPurchase_PurchaseError, TestHandler_HandleRequest_CancelPurchase) get HasPermissionAPI stubs returning false so the new pre-check falls through cleanly to the token branch they were always exercising.

  • go build ./... clean.
  • go test ./... — every package green.
  • Pre-commit hooks (gosec / trivy / migration-conflicts / go-test) — all green.

Triage

type/bug, severity/high (production deep-link cancel locked out for admins), urgency/now, impact/many, effort/s, priority/p1, triaged.

Summary by CodeRabbit

  • Tests

    • Added and expanded regression tests for purchase cancellations covering session vs. token flows, explicit permission-deny scenarios, error propagation, and exact permission-denied detection.
  • Refactor

    • Updated cancellation authorization to prioritize validated sessions when permitted; non-privileged sessions still use the token/contact-email path. Permission-check failures now surface clearer errors and avoid unintended fall-through.

…n email-link cancel

The deep-link cancel flow (frontend purchases-deeplink.ts) always POSTs
/api/purchases/cancel/:id with both an X-Authorization session AND the
URL token from the email link. cancelPurchase took the token branch
unconditionally → authorizeApprovalAction → 403 when the execution had
no per-account contact_email available (e.g. AWS ambient-credentials
recs where CloudAccountID is nil, or any execution whose recommendation
accounts simply have an empty contact_email column):

  Failed to cancel purchase: no per-account contact email configured
  for this execution; set the cloud account's contact_email before
  approving

The same admin (or any user with cancel-any:purchases / cancel-own
matching the creator) could already cancel the same execution from the
History page Cancel button — that path goes through cancelPurchaseViaSession
→ authorizeSessionCancel (RBAC matrix) and never touches contact_email.
The deep-link UX was inconsistent.

Fix: pre-check the session in cancelPurchase before falling into the
token branch. When the caller carries a valid session AND
authorizeSessionCancel approves them, take the session-authed path
regardless of whether a token is in the URL. Tokenless / no-session
callers (forwarded email, shared inbox, scripted flow without auth)
still hit the per-account contact_email gate from PR #101.

Approve flow stays strict — the dashboard has no admin approve override
either, so widening it via the email link would change the security
policy. Out of scope for this fix.

Helpers:
- New tryGetSession returns *Session or nil silently. tryResolveActorEmail
  collapses to a one-line wrapper.

Tests:
- New TestHandler_cancelPurchase_DeepLink_AdminBypassesContactEmailGate:
  admin session + token + ambient-credentials execution → 200, status
  flips, CancelledBy stamped, GetGlobalConfig (the token branch's
  signature call) is asserted NOT called.
- New TestHandler_cancelPurchase_DeepLink_CancelOwnBypassesContactEmailGate:
  non-admin with cancel-own + matching creator → 200.
- New TestHandler_cancelPurchase_DeepLink_NonPrivilegedSessionStillHitsContactGate:
  pins the security model — a logged-in user without admin / cancel-* MUST
  still go through authorizeApprovalAction.
- Existing TestHandler_cancelPurchase, TestHandler_cancelPurchase_PurchaseError,
  and TestHandler_HandleRequest_CancelPurchase get HasPermissionAPI mock
  stubs returning false so the new pre-check correctly falls through to the
  token branch (preserving their original assertions).

go test ./... — every package green.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2c87c78f-8bcb-450c-90f6-cab38d3cfd7b

📥 Commits

Reviewing files that changed from the base of the PR and between daf13d9 and 979437c.

📒 Files selected for processing (2)
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go

📝 Walkthrough

Walkthrough

Handler cancel flow changed to prefer session-authorized cancellation: session pre-check → session cancel on allowed, fall through to legacy token/email-link when pre-check unwrapped 403, and propagate other auth errors. Tests expanded to cover these session vs token branch behaviors.

Changes

Cohort / File(s) Summary
Purchase handler core
internal/api/handler_purchases.go
Refactored cancelPurchase into session-first flow: uses tryGetSession() and authorizeSessionCancelcancelPurchaseViaSession when allowed; fallthrough to token/email-link path only on unwrapped 403; immediate return for other auth errors. Added helpers tryGetSession and isPermissionDenied; updated tryResolveActorEmail.
Unit tests — purchase handler
internal/api/handler_purchases_test.go
Added/updated tests to pin branch behavior: privileged-session cancels use session path even with token present; creator-session behavior; transient/non-403 permission-check failures propagate; non-privileged sessions fall through to token/contact-email gate. Includes TestIsPermissionDenied.
Unit tests — integration/request
internal/api/handler_test.go
Adjusted test mocks to explicitly deny cancel-any and cancel-own for session flows to exercise legacy token/email cancel path.
Coverage test stubs
internal/api/coverage_extras_test.go
Test stub now returns false for HasPermissionAPI on cancel-any and cancel-own to avoid short-circuiting session pre-check path while asserting cancel error propagation remains intact.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Handler as Handler
    participant Auth as AuthService
    participant TokenSvc as Token/EmailGate
    participant Purchase as PurchaseService
    participant Config as ConfigService

    Client->>Handler: POST /cancel (token?, session?)
    Handler->>Handler: tryGetSession()
    alt Session found
        Handler->>Auth: authorizeSessionCancel(session, purchase)
        alt Auth allows (non-403)
            Handler->>Purchase: cancelPurchaseViaSession(actorEmail)
            Purchase-->>Handler: success
            Handler-->>Client: 200 OK (cancelled)
        else Auth returns 403 (permission denied)
            Handler->>TokenSvc: validateTokenOrContactEmail(token, purchase)
            alt Token/Contact allowed
                Handler->>Purchase: CancelExecution(via token)
                Purchase-->>Handler: success
                Handler-->>Client: 200 OK
            else contact-email missing/error
                Handler-->>Client: 400/422 contact-email missing
            end
        else Auth returns non-403 error
            Auth-->>Handler: error
            Handler-->>Client: propagate error
        end
    else No session
        Handler->>TokenSvc: validateTokenOrContactEmail(token, purchase)
        TokenSvc-->>Handler: allowed or contact-email error
        Handler->>Purchase: CancelExecution(via token/sessionless)
        Purchase-->>Handler: result
        Handler-->>Client: response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

impact/all-users, effort/m, type/security

Poem

🐰 I hopped through handlers, sniffed session air,
Found RBAC first, then tokens lying there,
If permissions pass, I nibble cancel seed,
Else fall back to links — the old mail deed.
Hooray for tidy hops and clearer paths!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: session-based authorization now bypasses the contact_email gate for admin and cancel-* users on email-link cancellations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cancel-deeplink-session-fallback

Review rate limit: 1/5 review remaining, refill in 43 minutes and 28 seconds.

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

@cristim cristim added type/bug Defect priority/p1 Next up; this sprint severity/high Significant harm urgency/now Drop other things impact/many Affects most users effort/s Hours triaged Item has been triaged labels Apr 30, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 30, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

✅ Actions performed

Review triggered.

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/api/handler_purchases.go`:
- Around line 333-337: The current session-path silently falls through on any
error from authorizeSessionCancel and treats it like a token-fallback; change it
so only explicit “permission denied” errors trigger the fallback to
cancelPurchaseViaSession. In the block that calls
tryGetSession/authorizeSessionCancel, if authorizeSessionCancel returns nil call
cancelPurchaseViaSession as before; if it returns an error, detect whether that
error is an explicit authorization denial (e.g., error value/type or status code
== 403 using the same permission-error type or helper used elsewhere in the
codebase); if it is a denial then proceed with the existing fallback to
cancelPurchaseViaSession, otherwise immediately return the
authorizeSessionCancel error to the caller. Ensure you reference and use the
same permission error sentinel/helper used by authorizeSessionCancel to identify
a 403 denial.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 356c33ec-3379-4a2e-b95a-8accbb202c83

📥 Commits

Reviewing files that changed from the base of the PR and between a21c140 and a1657b6.

📒 Files selected for processing (4)
  • internal/api/coverage_extras_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/api/handler_test.go

Comment thread internal/api/handler_purchases.go
…lback (CR #216)

CodeRabbit on PR #216 flagged that the new session-authed pre-check in
cancelPurchase silently swallowed every error from authorizeSessionCancel
and treated them all as "fall through to the contact_email gate". That
masks transient auth-service failures (HasPermissionAPI returning a
wrapped error, h.auth nil, network blip) behind a misleading "set the
contact_email" 403 — exactly the user-facing message #216 was meant to
stop conflating with real failures.

Distinguish the two cases via a new local helper isPermissionDenied,
which uses the existing IsClientError + .code accessor to detect a 403
ClientError specifically. authorizeSessionCancel returns 403 ClientError
on legitimate denials (admin role mismatch, missing cancel-* verb,
cancel-own creator mismatch) and a wrapped non-ClientError on transient
backend failures.

  case err == nil:           → session-authed cancel
  case isPermissionDenied(): → fall through to contact_email gate
  default:                   → return err (propagate)

Regression test
TestHandler_cancelPurchase_DeepLink_TransientAuthErrorPropagates simulates
a HasPermissionAPI failure ("auth backend timeout") and asserts:
  * the error surfaces with "permission check failed" wrapped,
  * the contact_email message is NOT in the error,
  * GetGlobalConfig (the token branch's signature call) is NOT reached.

Existing 403-fall-through path covered by
TestHandler_cancelPurchase_DeepLink_NonPrivilegedSessionStillHitsContactGate.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 30, 2026

Addressed @coderabbitai feedback in daf13d9: switched the session-authed pre-check from err == nil silent fallback to a 3-way switch — only an explicit 403 ClientError now triggers the fall-through to the contact_email gate; transient auth-service errors (HasPermissionAPI wrapped error, h.auth nil) are propagated to the caller via a new local isPermissionDenied helper that wraps IsClientError. Regression test TestHandler_cancelPurchase_DeepLink_TransientAuthErrorPropagates pins the new behaviour. @coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

@cristim The 3-way switch approach is a significant improvement over the silent err == nil fallback — distinguishing an explicit 403 from transient auth-service errors (nil h.auth, wrapped HasPermissionAPI errors) and propagating the latter is exactly the right behavior. The isPermissionDenied helper makes the intent clear and keeps the branching logic readable. Kicking off a fresh review of the updated changes now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 145
File: frontend/src/history.ts:291-322
Timestamp: 2026-04-27T21:45:03.476Z
Learning: In `frontend/src/history.ts`, `canCancelPendingRow` intentionally does NOT mirror the backend `authorizeSessionCancel` for non-admin users holding `cancel-any:purchases`. This is a deliberate UX trade-off: no default role grants `cancel-any` (it is reserved for future operator roles), so the practical impact is zero today. Adding the check would require a `/me/permissions` round-trip and extending the `User` type to carry permissions — deemed out of scope. The backend `authorizeSessionCancel` in `internal/api/handler_purchases.go` remains the security boundary. If an operator role with `cancel-any:purchases` is introduced in the future, `canCancelPendingRow` and the `User` type should be extended accordingly.
✅ Actions performed

Review triggered.

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/api/handler_purchases.go`:
- Around line 807-810: isPermissionDenied currently uses IsClientError (which
unwraps via errors.As) and thus treats wrapped 403 errors as permission denials;
change isPermissionDenied to detect an unwrapped client 403 only by doing a
direct type assertion on the incoming error (not using IsClientError/errors.As)
or otherwise verifying the error is the exact client error value (no wrapping),
so that wrapped 403s from an auth-backend surface as real backend errors instead
of being treated as authorization denials; update references to IsClientError
inside isPermissionDenied accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa96fa5d-b021-4e79-9e4d-b801e20233ec

📥 Commits

Reviewing files that changed from the base of the PR and between a1657b6 and daf13d9.

📒 Files selected for processing (2)
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go

Comment thread internal/api/handler_purchases.go
…s 2 #216)

CodeRabbit pass 2 on PR #216 flagged that the helper introduced in pass 1
relied on IsClientError, which goes through errors.As — that unwraps the
error chain. A wrapped 403 ClientError (e.g. fmt.Errorf("permission check
failed: %w", NewClientError(403, ...))) would still be classified as
permission denied, even though the wrapper changes the failure's outer
category and signals a different intent.

Switch to a direct *clientError type assertion (no unwrapping). Only an
exact, un-wrapped 403 ClientError now triggers the fall-through to the
contact_email gate; anything wrapping a 403 propagates as the wrapper's
own failure mode. This preserves the propagate-vs-fall-through split's
original intent against future code that might decide to wrap auth-layer
errors for context.

Regression: TestIsPermissionDenied table-driven tests pin all six cases
including the new "wrapped 403 is NOT denial" invariant.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 30, 2026

Addressed CR pass 2 in 979437c: switched isPermissionDenied from IsClientError (which uses errors.As and unwraps) to a direct *clientError type assertion. Only an un-wrapped 403 now counts as permission denial; anything wrapping a 403 propagates as the wrapper's own failure mode. New TestIsPermissionDenied table-driven tests pin all six cases including "wrapped 403 is NOT denial". @coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

@cristim The tightening from errors.As-based unwrapping to a direct *clientError type assertion is the right call. The previous IsClientError approach would have treated any error that wraps a 403 as a permission denial and silently fallen through to the contact-email gate — a subtle widening of the bypass surface. The direct type assertion restricts the fall-through to only bare, first-class 403s emitted directly by the auth service, while anything that adds context/wrapping around a 403 is treated as a distinct failure and propagated. The six-case table-driven TestIsPermissionDenied suite — especially the "wrapped 403 is NOT denial" pin — is exactly the right regression anchor for this semantic boundary. Kicking off a fresh review now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 145
File: frontend/src/history.ts:291-322
Timestamp: 2026-04-27T21:45:03.476Z
Learning: In `frontend/src/history.ts`, `canCancelPendingRow` intentionally does NOT mirror the backend `authorizeSessionCancel` for non-admin users holding `cancel-any:purchases`. This is a deliberate UX trade-off: no default role grants `cancel-any` (it is reserved for future operator roles), so the practical impact is zero today. Adding the check would require a `/me/permissions` round-trip and extending the `User` type to carry permissions — deemed out of scope. The backend `authorizeSessionCancel` in `internal/api/handler_purchases.go` remains the security boundary. If an operator role with `cancel-any:purchases` is introduced in the future, `canCancelPendingRow` and the `User` type should be extended accordingly.
✅ Actions performed

Review triggered.

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 merged commit 9a5bc2a into feat/multicloud-web-frontend Apr 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant