Skip to content

feat(email): post-execution notification with one-click revoke (closes #291)#889

Open
cristim wants to merge 2 commits into
feat/multicloud-web-frontendfrom
fix/291-wave2
Open

feat(email): post-execution notification with one-click revoke (closes #291)#889
cristim wants to merge 2 commits into
feat/multicloud-web-frontendfrom
fix/291-wave2

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented May 30, 2026

Summary

  • After a purchase execution completes (both session-RBAC and token approval paths), send a post-execution notification email to: global notification_email, per-account contact_email(s), and the requester's email. Recipients are deduplicated; first contact is To, rest are Cc.
  • The email includes purchase details and a one-click "Revoke this purchase" button backed by the existing approval_token (a dedicated revocation token column is deferred to the sibling AWS RI/SP revocation-via-support-case issue).
  • New AuthPublic route GET/POST /api/purchases/revoke/{id}?token=... validates the token with crypto/subtle.ConstantTimeCompare (timing-safe) and records revocation_requested status. Session-authed users with cancel-any or cancel-own permission can also revoke without a token (same three-mode dispatch as approve/cancel).

Test plan

  • TestHandler_revokePurchase_ValidToken - valid token path resolves to revocation_requested
  • TestHandler_revokePurchase_InvalidToken - wrong token returns 403
  • TestHandler_revokePurchase_PendingExecution - pending purchase returns 409 directing to Cancel
  • TestHandler_revokePurchase_NotFound - missing execution returns 404
  • TestResolveExecutedNotificationRecipients_* - four dedup/fallback scenarios covered
  • Full test suite: 4974 passed, 0 failed across 38 packages
  • PII-safe logging: recipient count only, no raw email addresses in logs

Summary by CodeRabbit

  • New Features
    • Added purchase revocation capability for completed transactions, supporting both authenticated sessions and token-based access
    • Introduced post-execution notifications that alert users when purchases complete, including execution details and one-click revocation links valid during the AWS cancellation window
    • Notification recipients include global notification contacts, per-account contacts, and the purchase requester

…#291)

After a purchase execution completes (via both session-RBAC and token
approval paths), send a post-execution notification email to the global
notification address, per-account contact email(s), and the requester's
email (deduplicated, first contact is To, rest are Cc).

The email includes a one-click "Revoke this purchase" link backed by the
existing approval_token. A new AuthPublic route GET/POST
/api/purchases/revoke/{id}?token=... validates the token via
crypto/subtle.ConstantTimeCompare (timing-safe), then records
revocation_requested status. Session-authed users with cancel-any/own
permission may also revoke without a token.

New helpers: sendPurchaseExecutedEmail, resolveExecutedNotificationRecipients,
revokePurchase, revokeViaSession. PII-safe: log recipient count, never raw
addresses. Mock stubs added for scheduler, server, and purchase test packages.
@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy urgency/this-quarter Within the quarter impact/many Affects most users effort/s Hours type/feat New capability labels May 30, 2026
@cristim
Copy link
Copy Markdown
Member Author

cristim commented May 30, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements purchase revocation and post-execution email notifications. It extends the email sender interface with a new method, adds email templates for purchase-executed notifications, implements a revocation handler with token and session authentication, integrates post-execution email sending into approval handlers, exposes new revoke endpoints, and updates all test mocks.

Changes

Purchase Revocation and Post-Execution Notifications

Layer / File(s) Summary
Email Interface and Notification Data Contract
internal/email/interfaces.go, internal/email/sender.go
SenderInterface gains SendPurchaseExecutedNotification method; NotificationData gains RevocationToken, RevocationWindowClosesAt, ExecutedAt, and ExecutedBy fields for post-execution email rendering.
Email Sender Implementations
internal/email/nop_sender.go, internal/email/smtp_sender.go
NopSender and SMTPSender implement SendPurchaseExecutedNotification; SMTP version validates recipient availability and delegates to template send helper.
Email Templates and Send Logic
internal/email/templates.go
Plain-text and HTML email templates for purchase-executed notifications include commitment summaries, requester/executor attribution, and optional revocation sections; multipart send helper with HTML fallback and template rendering functions added.
Revocation Handler and Token Validation
internal/api/handler_purchases.go (lines 7, 622-763)
New revokePurchase handler supports session-authenticated, token-authenticated, and unauthorized dispatch modes; validates revocation tokens using constant-time comparison; checks eligibility for completed/partially-completed executions; records revocation_requested status.
Post-Execution Email Integration into Approvals
internal/api/handler_purchases.go (lines 394-395, 446-450, 1808-1981)
Both token-authenticated and session-authenticated approval paths now trigger sendPurchaseExecutedEmail after successful completion; helpers compose notification data from global notification email, per-account contact emails, and optional requester lookup; email failures logged but non-propagating.
Router and Middleware Configuration
internal/api/router.go, internal/api/middleware.go
New /api/purchases/revoke/ GET/POST endpoints with approve_cancel_public rate-limiting; revokePurchaseHandler extracts tokens and dispatches to revocation logic; revoke path marked as public in authentication middleware.
Test Mock and Stub Updates
internal/api/coverage_gaps_test.go, internal/mocks/email.go, internal/purchase/mocks_test.go, internal/scheduler/scheduler_test.go, internal/server/app_test.go, internal/server/handler_ri_exchange_test.go
All test doubles updated to implement SendPurchaseExecutedNotification as no-op; mock email sender includes interface declaration and call tracking.
Revocation Handler and Notification Tests
internal/api/handler_purchases_test.go
Tests for revokePurchase: valid token updates status to revocation_requested; invalid token returns 403; pending execution returns 409 with guidance; missing execution returns 404. Tests for resolveExecutedNotificationRecipients: contact emails to "To", global notify fallback, requester fallback, case-insensitive deduplication.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • LeanerCloud/CUDly#289: Implements post-execution cancel-link email functionality with SendPurchaseExecutedNotification method and revocation token support directly addressing the feature requirement.
  • LeanerCloud/CUDly#290: Implements purchase revocation flow with token-based endpoints, revocation metadata, and post-execution notification email matching the code-level changes described.
  • LeanerCloud/CUDly#291: Implements post-execution notification email, revocation tokens, routes, handlers, and email interface updates directly implementing the functionality described.

Possibly related PRs

  • LeanerCloud/CUDly#613: Both modify internal/api/handler_purchases.go approval paths; PR #613 adds orphan-execution guard while this PR hooks post-approval email sending into the same approval handlers.
  • LeanerCloud/CUDly#299: Both modify approval flow in handler_purchases.go; this PR adds post-approval email dispatch and revoke functionality while PR #299 changes approval RBAC/session-vs-token dispatch.
  • LeanerCloud/CUDly#333: Both modify email layer surface area (internal/email/nop_sender.go) to keep no-op sender implementing updated sender methods and interface requirements.

Suggested labels

severity/medium

Poem

A rabbit hops through revocation's door,
With tokens shining bright once more,
Post-execute, an email flies,
One-click cancel—revoke before time dies! 🐰✉️
No approval stands when revoked with grace.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the main change: adding post-execution notification emails with one-click revoke functionality, matching the substantial implementation across multiple files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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/291-wave2

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

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 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.

…rchase helpers to fit gocyclo budget

sendPurchaseExecutedEmail (16 → 9): extract globalNotifyEmail and
lookupRequesterInfo to pull the nullable-field branches and auth
lookup out of the main flow.

revokePurchase (13 → 9): extract checkRevokableStatus,
tryRevokeViaSession, and validateRevokeToken, mirroring the pattern
used by cancelPurchase and its siblings.

No behaviour changes; validation order, error messages, and email
sender call shape are identical.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Jun 1, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 1, 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: 4

🧹 Nitpick comments (2)
internal/purchase/mocks_test.go (1)

654-656: ⚡ Quick win

Use testify call recording for the new mock method.

This method bypasses m.Called, so tests can’t assert invocation or force error paths for SendPurchaseExecutedNotification.

Proposed fix
 func (m *MockEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error {
-	return nil
+	args := m.Called()
+	return args.Error(0)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/purchase/mocks_test.go` around lines 654 - 656, The mock method
MockEmailSender.SendPurchaseExecutedNotification currently returns nil directly
and skips testify's call recorder, preventing tests from asserting or simulating
errors; modify this method to call and return m.Called(...) results (use
m.Called(ctx, notificationData) and convert the first return to error) so tests
can assert invocation and set return values via m.On/Assert; update the method
in the MockEmailSender type to delegate to m.Called instead of returning nil.
internal/scheduler/scheduler_test.go (1)

534-536: ⚡ Quick win

Align this mock method with the rest of the testify-backed sender.

Returning nil unconditionally blocks expectation/assertion coverage for SendPurchaseExecutedNotification.

Proposed fix
-func (m *MockEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error {
-	return nil
+func (m *MockEmailSender) SendPurchaseExecutedNotification(ctx context.Context, data email.NotificationData) error {
+	args := m.Called(ctx, data)
+	return args.Error(0)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/scheduler/scheduler_test.go` around lines 534 - 536, The mock method
MockEmailSender.SendPurchaseExecutedNotification currently always returns nil
which prevents testify mock expectations from being asserted; modify this method
to use the testify mock call pattern (invoke m.Called with the context and
notification args or placeholders) and return the error extracted from the call
result (e.g., args.Error(0)) so tests can set expectations and control the
returned error via mock.On for SendPurchaseExecutedNotification.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/handler_purchases_test.go`:
- Around line 2850-2987: Add tests that exercise the session-auth revoke branch
by invoking Handler.revokePurchase without a token in the request so the path
goes through tryRevokeViaSession -> revokeViaSession; create cases for an admin
with HasPermissionAPI returning true for "cancel-any" and a non-admin owner with
"cancel-own" true (and corresponding ValidateSession returning a Session with
matching Email), plus a failure case where neither permission is present to
confirm denial. Ensure the tests mock MockAuth.ValidateSession,
MockAuth.HasPermissionAPI, and
MockConfigStore.GetExecutionByID/GetCloudAccount/SavePurchaseExecution to assert
the execution status transitions (e.g., to "revocation_requested") or the
expected client errors, and reference the functions tryRevokeViaSession,
revokeViaSession and revokePurchase in test names/comments so reviewers can
locate them.

In `@internal/api/handler_purchases.go`:
- Around line 681-685: The current revoke path calls
validateRevokeToken(execution, token) but never verifies the token/window
expiry, allowing old executions to be marked revocation_requested; update the
revoke flow to enforce expiry by adding an expiry/window check before calling
revokeViaSession: either extend validateRevokeToken to also validate token
expiration and execution window timestamps (e.g., compare
execution.RevokeTokenExpiry or execution.WindowEnd against time.Now()) or call a
new helper (e.g., validateRevokeTokenExpiry or isRevokeWindowValid) after
validateRevokeToken and before revokeViaSession(ctx, execution, actor), and
return an error if the token/window is expired; apply the same change to the
other revoke path handling lines ~733-741 so both code paths enforce expiry.
- Line 757: The log call currently prints the raw actor email via revokedBy in
logging.Infof("Revocation requested for execution %s by %s",
execution.ExecutionID, revokedBy); change this to emit a PII-safe identifier
instead (e.g., mask or hash the email or log the actor's internal ID). Update
the call to use a helper like redactEmail(revokedBy) or hashActor(revokedBy)
(create such a helper if missing) so the message becomes
logging.Infof("Revocation requested for execution %s by %s",
execution.ExecutionID, redactEmail(revokedBy)); ensure redactEmail/hashActor is
deterministic and irreversible to avoid leaking the actual email.
- Around line 748-756: revokeViaSession currently mutates execution.Status and
calls h.config.SavePurchaseExecution, which can cause lost-updates; change it to
perform an atomic/conditional status transition that only sets Status =
"revocation_requested" when the current status is one of the allowed
predecessors ("completed" or "partially_completed"). Locate revokeViaSession and
replace the blind mutation+SavePurchaseExecution with a single conditional
update call (or add a new config method) that issues an UPDATE ... WHERE
execution_id = ? AND status IN ('completed','partially_completed') (or
equivalent ORM/Repo call) and returns a clear error when no rows were updated;
ensure CancelledBy is included in the same atomic update and preserve existing
SavePurchaseExecution behavior for other flows.

---

Nitpick comments:
In `@internal/purchase/mocks_test.go`:
- Around line 654-656: The mock method
MockEmailSender.SendPurchaseExecutedNotification currently returns nil directly
and skips testify's call recorder, preventing tests from asserting or simulating
errors; modify this method to call and return m.Called(...) results (use
m.Called(ctx, notificationData) and convert the first return to error) so tests
can assert invocation and set return values via m.On/Assert; update the method
in the MockEmailSender type to delegate to m.Called instead of returning nil.

In `@internal/scheduler/scheduler_test.go`:
- Around line 534-536: The mock method
MockEmailSender.SendPurchaseExecutedNotification currently always returns nil
which prevents testify mock expectations from being asserted; modify this method
to use the testify mock call pattern (invoke m.Called with the context and
notification args or placeholders) and return the error extracted from the call
result (e.g., args.Error(0)) so tests can set expectations and control the
returned error via mock.On for SendPurchaseExecutedNotification.
🪄 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: d8b413f2-fa20-492c-8162-27871ec7dbd4

📥 Commits

Reviewing files that changed from the base of the PR and between 4956d66 and 1be3f5c.

📒 Files selected for processing (15)
  • internal/api/coverage_gaps_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/api/middleware.go
  • internal/api/router.go
  • internal/email/interfaces.go
  • internal/email/nop_sender.go
  • internal/email/sender.go
  • internal/email/smtp_sender.go
  • internal/email/templates.go
  • internal/mocks/email.go
  • internal/purchase/mocks_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/app_test.go
  • internal/server/handler_ri_exchange_test.go

Comment on lines +2850 to +2987
// TestHandler_revokePurchase_ValidToken verifies that a valid token on a
// completed execution records a revocation_requested status.
func TestHandler_revokePurchase_ValidToken(t *testing.T) {
ctx := context.Background()
execID := "11111111-1111-1111-1111-111111111111"
token := "abc123validtoken"
revokerEmail := "contact@acct.example.com"
accountID := "acct-1"

exec := &config.PurchaseExecution{
ExecutionID: execID,
ApprovalToken: token,
Status: "completed",
Recommendations: []config.RecommendationRecord{
{ID: "r1", CloudAccountID: &accountID},
},
}

mockStore := new(MockConfigStore)
mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil)
// authorizeApprovalAction: GetGlobalConfig for global notify
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil)
// GetCloudAccount returns the contact email so authorizeApprovalAction can
// resolve the approver and verify the session's email matches.
mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
return &config.CloudAccount{ID: id, ContactEmail: revokerEmail}, nil
}
// SavePurchaseExecution for the revocation_requested update.
mockStore.On("SavePurchaseExecution", ctx, mock.MatchedBy(func(e *config.PurchaseExecution) bool {
return e.ExecutionID == execID && e.Status == "revocation_requested"
})).Return(nil)

mockAuth := new(MockAuthService)
// Provide a session for the revoker so authorizeApprovalAction resolves actor.
mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: revokerEmail}, nil)
// RBAC: revoker has no cancel-any or cancel-own, so falls through to token path.
mockAuth.On("HasPermissionAPI", ctx, "", "cancel-any", "purchases").Return(false, nil).Maybe()
mockAuth.On("HasPermissionAPI", ctx, "", "cancel-own", "purchases").Return(false, nil).Maybe()

handler := &Handler{config: mockStore, auth: mockAuth}

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"authorization": "Bearer sess-tok"},
QueryStringParameters: map[string]string{"token": token},
}
result, err := handler.revokePurchase(ctx, req, execID, token)
require.NoError(t, err, "valid token on completed execution must not error")
resultMap := result.(map[string]string)
assert.Equal(t, "revocation_requested", resultMap["status"])
mockStore.AssertExpectations(t)
}

// TestHandler_revokePurchase_InvalidToken verifies that a wrong token returns 403.
// The session provides an email that matches the contact email (so
// authorizeApprovalAction passes), but the token itself is wrong.
func TestHandler_revokePurchase_InvalidToken(t *testing.T) {
ctx := context.Background()
execID := "22222222-2222-2222-2222-222222222222"
contactEmail := "contact@acct.example.com"
accountID := "acct-1"

exec := &config.PurchaseExecution{
ExecutionID: execID,
ApprovalToken: "the-real-token",
Status: "completed",
Recommendations: []config.RecommendationRecord{
{ID: "r1", CloudAccountID: &accountID},
},
}

mockStore := new(MockConfigStore)
mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil)
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil)
mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
return &config.CloudAccount{ID: id, ContactEmail: contactEmail}, nil
}

mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: contactEmail}, nil)
mockAuth.On("HasPermissionAPI", ctx, "", "cancel-any", "purchases").Return(false, nil).Maybe()
mockAuth.On("HasPermissionAPI", ctx, "", "cancel-own", "purchases").Return(false, nil).Maybe()

handler := &Handler{config: mockStore, auth: mockAuth}

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"authorization": "Bearer sess-tok"},
QueryStringParameters: map[string]string{"token": "wrong-token"},
}
_, err := handler.revokePurchase(ctx, req, execID, "wrong-token")
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "expected a client error")
assert.Equal(t, 403, ce.code)
}

// TestHandler_revokePurchase_PendingExecution verifies that a pending execution
// returns 409 with a friendly message directing the user to Cancel instead.
func TestHandler_revokePurchase_PendingExecution(t *testing.T) {
ctx := context.Background()
execID := "33333333-3333-3333-3333-333333333333"

exec := &config.PurchaseExecution{
ExecutionID: execID,
ApprovalToken: "tok",
Status: "pending",
}

mockStore := new(MockConfigStore)
mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil)

handler := &Handler{config: mockStore}
req := &events.LambdaFunctionURLRequest{
QueryStringParameters: map[string]string{"token": "tok"},
}
_, err := handler.revokePurchase(ctx, req, execID, "tok")
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "expected a client error")
assert.Equal(t, 409, ce.code)
assert.Contains(t, ce.message, "Cancel")
}

// TestHandler_revokePurchase_NotFound verifies 404 when the execution does not exist.
func TestHandler_revokePurchase_NotFound(t *testing.T) {
ctx := context.Background()
execID := "44444444-4444-4444-4444-444444444444"

mockStore := new(MockConfigStore)
mockStore.On("GetExecutionByID", ctx, execID).Return(nil, nil)

handler := &Handler{config: mockStore}
req := &events.LambdaFunctionURLRequest{}
_, err := handler.revokePurchase(ctx, req, execID, "some-token")
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "expected a client error")
assert.Equal(t, 404, ce.code)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add tests for the session-auth revoke branch

The new suite validates token-mode revoke, but it doesn’t pin the tokenless session-authorized revoke path (tryRevokeViaSessionrevokeViaSession). That’s a primary feature path and should be covered explicitly (admin/cancel-any/cancel-own and no-token request).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_purchases_test.go` around lines 2850 - 2987, Add tests
that exercise the session-auth revoke branch by invoking Handler.revokePurchase
without a token in the request so the path goes through tryRevokeViaSession ->
revokeViaSession; create cases for an admin with HasPermissionAPI returning true
for "cancel-any" and a non-admin owner with "cancel-own" true (and corresponding
ValidateSession returning a Session with matching Email), plus a failure case
where neither permission is present to confirm denial. Ensure the tests mock
MockAuth.ValidateSession, MockAuth.HasPermissionAPI, and
MockConfigStore.GetExecutionByID/GetCloudAccount/SavePurchaseExecution to assert
the execution status transitions (e.g., to "revocation_requested") or the
expected client errors, and reference the functions tryRevokeViaSession,
revokeViaSession and revokePurchase in test names/comments so reviewers can
locate them.

Comment on lines +681 to +685
if err := validateRevokeToken(execution, token); err != nil {
return nil, err
}
return h.revokeViaSession(ctx, execution, actor)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce token expiry/window on revoke token path

Line 681 validates token equality, but never checks token/window expiry before accepting revocation. With current logic, an old completed execution can still be marked revocation_requested as long as the token matches.

Suggested fix
 func validateRevokeToken(execution *config.PurchaseExecution, token string) error {
 	if execution.ApprovalToken == "" {
 		return NewClientError(403, "invalid revocation token")
 	}
+	if execution.ApprovalTokenExpiresAt != nil && time.Now().After(*execution.ApprovalTokenExpiresAt) {
+		return NewClientError(409, fmt.Sprintf(
+			"execution %s cannot be revoked: revocation link has expired", execution.ExecutionID))
+	}
 	if subtle.ConstantTimeCompare([]byte(execution.ApprovalToken), []byte(token)) != 1 {
 		return NewClientError(403, "invalid revocation token")
 	}
 	return nil
 }

Also applies to: 733-741

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_purchases.go` around lines 681 - 685, The current revoke
path calls validateRevokeToken(execution, token) but never verifies the
token/window expiry, allowing old executions to be marked revocation_requested;
update the revoke flow to enforce expiry by adding an expiry/window check before
calling revokeViaSession: either extend validateRevokeToken to also validate
token expiration and execution window timestamps (e.g., compare
execution.RevokeTokenExpiry or execution.WindowEnd against time.Now()) or call a
new helper (e.g., validateRevokeTokenExpiry or isRevokeWindowValid) after
validateRevokeToken and before revokeViaSession(ctx, execution, actor), and
return an error if the token/window is expired; apply the same change to the
other revoke path handling lines ~733-741 so both code paths enforce expiry.

Comment on lines +748 to +756
func (h *Handler) revokeViaSession(ctx context.Context, execution *config.PurchaseExecution, revokedBy string) (any, error) {
execution.Status = "revocation_requested"
if revokedBy != "" {
rb := revokedBy
execution.CancelledBy = &rb
}
if err := h.config.SavePurchaseExecution(ctx, execution); err != nil {
return nil, fmt.Errorf("failed to record revocation request for execution %s: %w", execution.ExecutionID, err)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use an atomic status transition for revocation_requested

revokeViaSession mutates the loaded struct then calls SavePurchaseExecution, which is vulnerable to race/lost-update behavior if the row changes between read and write. This should use a conditional transition (completed|partially_completed -> revocation_requested) like other state transitions.

Suggested fix
 func (h *Handler) revokeViaSession(ctx context.Context, execution *config.PurchaseExecution, revokedBy string) (any, error) {
-	execution.Status = "revocation_requested"
-	if revokedBy != "" {
-		rb := revokedBy
-		execution.CancelledBy = &rb
-	}
-	if err := h.config.SavePurchaseExecution(ctx, execution); err != nil {
-		return nil, fmt.Errorf("failed to record revocation request for execution %s: %w", execution.ExecutionID, err)
-	}
+	updated, err := h.config.TransitionExecutionStatus(ctx, execution.ExecutionID, []string{"completed", "partially_completed"}, "revocation_requested")
+	if err != nil {
+		return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be revoked: %v", execution.ExecutionID, err))
+	}
+	if revokedBy != "" {
+		rb := revokedBy
+		updated.CancelledBy = &rb
+		if err := h.config.SavePurchaseExecution(ctx, updated); err != nil {
+			return nil, fmt.Errorf("failed to record revocation requester for execution %s: %w", execution.ExecutionID, err)
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_purchases.go` around lines 748 - 756, revokeViaSession
currently mutates execution.Status and calls h.config.SavePurchaseExecution,
which can cause lost-updates; change it to perform an atomic/conditional status
transition that only sets Status = "revocation_requested" when the current
status is one of the allowed predecessors ("completed" or
"partially_completed"). Locate revokeViaSession and replace the blind
mutation+SavePurchaseExecution with a single conditional update call (or add a
new config method) that issues an UPDATE ... WHERE execution_id = ? AND status
IN ('completed','partially_completed') (or equivalent ORM/Repo call) and returns
a clear error when no rows were updated; ensure CancelledBy is included in the
same atomic update and preserve existing SavePurchaseExecution behavior for
other flows.

if err := h.config.SavePurchaseExecution(ctx, execution); err != nil {
return nil, fmt.Errorf("failed to record revocation request for execution %s: %w", execution.ExecutionID, err)
}
logging.Infof("Revocation requested for execution %s by %s", execution.ExecutionID, revokedBy)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging raw actor email in revoke path

Line 757 logs revokedBy directly. That leaks PII into logs and conflicts with the PR’s stated PII-safe logging behavior.

Suggested fix
-	logging.Infof("Revocation requested for execution %s by %s", execution.ExecutionID, revokedBy)
+	logging.Infof("Revocation requested for execution %s (actor_present=%t)", execution.ExecutionID, revokedBy != "")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logging.Infof("Revocation requested for execution %s by %s", execution.ExecutionID, revokedBy)
logging.Infof("Revocation requested for execution %s (actor_present=%t)", execution.ExecutionID, revokedBy != "")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_purchases.go` at line 757, The log call currently prints
the raw actor email via revokedBy in logging.Infof("Revocation requested for
execution %s by %s", execution.ExecutionID, revokedBy); change this to emit a
PII-safe identifier instead (e.g., mask or hash the email or log the actor's
internal ID). Update the call to use a helper like redactEmail(revokedBy) or
hashActor(revokedBy) (create such a helper if missing) so the message becomes
logging.Infof("Revocation requested for execution %s by %s",
execution.ExecutionID, redactEmail(revokedBy)); ensure redactEmail/hashActor is
deterministic and irreversible to avoid leaking the actual email.

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/p2 Backlog-worthy triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant