Skip to content

Commit bf1c8e3

Browse files
committed
fix(api/purchases): enforce revoke-token expiry, atomic status, PII-safe log
Address CodeRabbit pass-1 findings on the one-click revoke path (#291): - validateRevokeToken now rejects an expired ApprovalToken (409) before the constant-time match, mirroring ApproveExecution's expiry guard in internal/purchase/approvals.go. Legacy rows without ApprovalTokenExpiresAt (pre-migration 000051) still pass through. - revokeViaSession replaces the read-modify-write SavePurchaseExecution with the existing atomic TransitionExecutionStatus conditional UPDATE (completed|partially_completed -> revocation_requested), closing a lost-update race; CAS rejection maps to 409 and a vanished row to 404. CancelledBy is stamped in a follow-up save on the freshly-returned row. - The revocation log line now redacts the actor email via redactEmail instead of printing it raw, removing PII from logs. Tests: - Add session-auth revoke branch coverage (tryRevokeViaSession -> revokeViaSession): admin cancel-any, owner cancel-own, and a no-permission/no-token denial. - Add regression tests for the expired-token rejection and the concurrent-transition 409. - Switch MockEmailSender.SendPurchaseExecutedNotification to testify's m.Called recorder in internal/purchase and internal/scheduler so the method participates in expectation/assertion coverage.
1 parent 0970eec commit bf1c8e3

4 files changed

Lines changed: 232 additions & 14 deletions

File tree

‎internal/api/handler_purchases.go‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -809,13 +809,20 @@ func checkRevokableStatus(execution *config.PurchaseExecution) error {
809809
}
810810

811811
// validateRevokeToken checks that the execution carries a non-empty
812-
// ApprovalToken and that it matches the supplied token using constant-time
813-
// comparison (same guard as ApproveExecution in internal/purchase/approvals.go).
812+
// ApprovalToken, that the token has not expired, and that it matches the
813+
// supplied token using constant-time comparison (same guard as
814+
// ApproveExecution in internal/purchase/approvals.go). Pre-migration rows
815+
// without an ApprovalTokenExpiresAt (legacy executions created before
816+
// migration 000051) pass the expiry check, mirroring ApproveExecution.
814817
// Extracted from revokePurchase to keep that function under the cyclomatic limit.
815818
func validateRevokeToken(execution *config.PurchaseExecution, token string) error {
816819
if execution.ApprovalToken == "" {
817820
return NewClientError(403, "invalid revocation token")
818821
}
822+
if execution.ApprovalTokenExpiresAt != nil && time.Now().After(*execution.ApprovalTokenExpiresAt) {
823+
return NewClientError(409, fmt.Sprintf(
824+
"execution %s cannot be revoked: the revocation link has expired", execution.ExecutionID))
825+
}
819826
if subtle.ConstantTimeCompare([]byte(execution.ApprovalToken), []byte(token)) != 1 {
820827
return NewClientError(403, "invalid revocation token")
821828
}
@@ -827,16 +834,35 @@ func validateRevokeToken(execution *config.PurchaseExecution, token string) erro
827834
// revocation (AWS support-case) is out of scope for issue #291 — it is
828835
// handled by the sibling "AWS RI/SP revocation via support case" issue.
829836
// This call records the intent so the History UI can surface it.
837+
//
838+
// The status flip uses TransitionExecutionStatus, which issues a conditional
839+
// UPDATE WHERE status IN ('completed','partially_completed'). This guards
840+
// against lost-update races: if the row changed between the GetExecutionByID
841+
// read and this write (e.g. a concurrent transition), zero rows are affected
842+
// and we return a 409 rather than blindly overwriting. CancelledBy is stamped
843+
// in a follow-up SavePurchaseExecution on the freshly-returned row.
830844
func (h *Handler) revokeViaSession(ctx context.Context, execution *config.PurchaseExecution, revokedBy string) (any, error) {
831-
execution.Status = "revocation_requested"
845+
updated, err := h.config.TransitionExecutionStatus(
846+
ctx, execution.ExecutionID,
847+
[]string{"completed", "partially_completed"}, "revocation_requested")
848+
if err != nil {
849+
if errors.Is(err, config.ErrExecutionNotInExpectedStatus) {
850+
return nil, NewClientError(409, fmt.Sprintf(
851+
"execution %s cannot be revoked: a concurrent operation changed its status", execution.ExecutionID))
852+
}
853+
if errors.Is(err, config.ErrNotFound) {
854+
return nil, NewClientError(404, "execution not found")
855+
}
856+
return nil, fmt.Errorf("failed to record revocation request for execution %s: %w", execution.ExecutionID, err)
857+
}
832858
if revokedBy != "" {
833859
rb := revokedBy
834-
execution.CancelledBy = &rb
835-
}
836-
if err := h.config.SavePurchaseExecution(ctx, execution); err != nil {
837-
return nil, fmt.Errorf("failed to record revocation request for execution %s: %w", execution.ExecutionID, err)
860+
updated.CancelledBy = &rb
861+
if err := h.config.SavePurchaseExecution(ctx, updated); err != nil {
862+
return nil, fmt.Errorf("failed to record revocation requester for execution %s: %w", execution.ExecutionID, err)
863+
}
838864
}
839-
logging.Infof("Revocation requested for execution %s by %s", execution.ExecutionID, revokedBy)
865+
logging.Infof("Revocation requested for execution %s by %s", execution.ExecutionID, redactEmail(revokedBy))
840866
return map[string]string{
841867
"status": "revocation_requested",
842868
"message": "Revocation request recorded. Contact AWS Support to complete the cancellation within the allowed window.",

‎internal/api/handler_purchases_test.go‎

Lines changed: 192 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3313,9 +3313,15 @@ func TestHandler_revokePurchase_ValidToken(t *testing.T) {
33133313
mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
33143314
return &config.CloudAccount{ID: id, ContactEmail: revokerEmail}, nil
33153315
}
3316-
// SavePurchaseExecution for the revocation_requested update.
3316+
// Atomic conditional transition completed/partially_completed ->
3317+
// revocation_requested, returning the updated row.
3318+
mockStore.On("TransitionExecutionStatus", ctx, execID,
3319+
[]string{"completed", "partially_completed"}, "revocation_requested").
3320+
Return(&config.PurchaseExecution{ExecutionID: execID, Status: "revocation_requested"}, nil)
3321+
// SavePurchaseExecution stamps CancelledBy on the returned row.
33173322
mockStore.On("SavePurchaseExecution", ctx, mock.MatchedBy(func(e *config.PurchaseExecution) bool {
3318-
return e.ExecutionID == execID && e.Status == "revocation_requested"
3323+
return e.ExecutionID == execID && e.Status == "revocation_requested" &&
3324+
e.CancelledBy != nil && *e.CancelledBy == revokerEmail
33193325
})).Return(nil)
33203326

33213327
mockAuth := new(MockAuthService)
@@ -3425,6 +3431,190 @@ func TestHandler_revokePurchase_NotFound(t *testing.T) {
34253431
assert.Equal(t, 404, ce.code)
34263432
}
34273433

3434+
// TestHandler_revokePurchase_ExpiredToken verifies that a matching token whose
3435+
// ApprovalTokenExpiresAt deadline has passed is rejected with 409, so a stale
3436+
// completed execution cannot be marked revocation_requested via an old link.
3437+
// Regression test for the revoke-token expiry CR finding (PR #889).
3438+
func TestHandler_revokePurchase_ExpiredToken(t *testing.T) {
3439+
ctx := context.Background()
3440+
execID := "55555555-5555-5555-5555-555555555555"
3441+
token := "expiredbutmatching"
3442+
contactEmail := "contact@acct.example.com"
3443+
accountID := "acct-1"
3444+
past := time.Now().Add(-1 * time.Hour)
3445+
3446+
exec := &config.PurchaseExecution{
3447+
ExecutionID: execID,
3448+
ApprovalToken: token,
3449+
ApprovalTokenExpiresAt: &past,
3450+
Status: "completed",
3451+
Recommendations: []config.RecommendationRecord{
3452+
{ID: "r1", CloudAccountID: &accountID},
3453+
},
3454+
}
3455+
3456+
mockStore := new(MockConfigStore)
3457+
mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil)
3458+
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil)
3459+
mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) {
3460+
return &config.CloudAccount{ID: id, ContactEmail: contactEmail}, nil
3461+
}
3462+
3463+
mockAuth := new(MockAuthService)
3464+
mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: contactEmail}, nil)
3465+
mockAuth.On("HasPermissionAPI", ctx, "", "cancel-any", "purchases").Return(false, nil).Maybe()
3466+
mockAuth.On("HasPermissionAPI", ctx, "", "cancel-own", "purchases").Return(false, nil).Maybe()
3467+
3468+
handler := &Handler{config: mockStore, auth: mockAuth}
3469+
req := &events.LambdaFunctionURLRequest{
3470+
Headers: map[string]string{"authorization": "Bearer sess-tok"},
3471+
QueryStringParameters: map[string]string{"token": token},
3472+
}
3473+
_, err := handler.revokePurchase(ctx, req, execID, token)
3474+
require.Error(t, err, "expired revocation token must be rejected")
3475+
ce, ok := IsClientError(err)
3476+
require.True(t, ok, "expected a client error")
3477+
assert.Equal(t, 409, ce.code)
3478+
assert.Contains(t, ce.message, "expired")
3479+
}
3480+
3481+
// TestHandler_revokePurchase_SessionAdminCancelAny exercises the tokenless
3482+
// session-authorized revoke branch (tryRevokeViaSession -> revokeViaSession)
3483+
// for an admin holding cancel-any on purchases. It asserts the atomic
3484+
// TransitionExecutionStatus call and the revocation_requested result.
3485+
// Covers the session-auth revoke branch CR finding (PR #889).
3486+
func TestHandler_revokePurchase_SessionAdminCancelAny(t *testing.T) {
3487+
ctx := context.Background()
3488+
execID := "66666666-6666-6666-6666-666666666666"
3489+
adminEmail := "admin@example.com"
3490+
adminUserID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
3491+
3492+
exec := buildCompletedExec(execID, "unused-token")
3493+
3494+
mockStore := new(MockConfigStore)
3495+
mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil)
3496+
mockStore.On("TransitionExecutionStatus", ctx, execID,
3497+
[]string{"completed", "partially_completed"}, "revocation_requested").
3498+
Return(&config.PurchaseExecution{ExecutionID: execID, Status: "revocation_requested"}, nil)
3499+
mockStore.On("SavePurchaseExecution", ctx, mock.MatchedBy(func(e *config.PurchaseExecution) bool {
3500+
return e.ExecutionID == execID && e.CancelledBy != nil && *e.CancelledBy == adminEmail
3501+
})).Return(nil)
3502+
3503+
mockAuth := new(MockAuthService)
3504+
mockAuth.On("ValidateSession", ctx, "admin-token").
3505+
Return(&Session{UserID: adminUserID, Email: adminEmail}, nil)
3506+
mockAuth.On("HasPermissionAPI", ctx, adminUserID, "cancel-any", "purchases").Return(true, nil)
3507+
3508+
handler := &Handler{config: mockStore, auth: mockAuth}
3509+
// No token query param: forces the tokenless session-auth path.
3510+
req := &events.LambdaFunctionURLRequest{
3511+
Headers: map[string]string{"authorization": "Bearer admin-token"},
3512+
}
3513+
result, err := handler.revokePurchase(ctx, req, execID, "")
3514+
require.NoError(t, err, "session admin with cancel-any must succeed")
3515+
resultMap := result.(map[string]string)
3516+
assert.Equal(t, "revocation_requested", resultMap["status"])
3517+
mockStore.AssertExpectations(t)
3518+
mockAuth.AssertExpectations(t)
3519+
}
3520+
3521+
// TestHandler_revokePurchase_SessionOwnerCancelOwn exercises the tokenless
3522+
// session-auth branch for a non-admin owner holding cancel-own on a purchase
3523+
// they created. Covers the session-auth revoke branch CR finding (PR #889).
3524+
func TestHandler_revokePurchase_SessionOwnerCancelOwn(t *testing.T) {
3525+
ctx := context.Background()
3526+
execID := "77777777-7777-7777-7777-777777777777"
3527+
ownerEmail := "owner@example.com"
3528+
ownerUserID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
3529+
3530+
exec := buildCompletedExec(execID, "unused-token")
3531+
exec.CreatedByUserID = &ownerUserID
3532+
3533+
mockStore := new(MockConfigStore)
3534+
mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil)
3535+
mockStore.On("TransitionExecutionStatus", ctx, execID,
3536+
[]string{"completed", "partially_completed"}, "revocation_requested").
3537+
Return(&config.PurchaseExecution{ExecutionID: execID, Status: "revocation_requested"}, nil)
3538+
mockStore.On("SavePurchaseExecution", ctx, mock.MatchedBy(func(e *config.PurchaseExecution) bool {
3539+
return e.ExecutionID == execID && e.CancelledBy != nil && *e.CancelledBy == ownerEmail
3540+
})).Return(nil)
3541+
3542+
mockAuth := new(MockAuthService)
3543+
mockAuth.On("ValidateSession", ctx, "owner-token").
3544+
Return(&Session{UserID: ownerUserID, Email: ownerEmail}, nil)
3545+
mockAuth.On("HasPermissionAPI", ctx, ownerUserID, "cancel-any", "purchases").Return(false, nil)
3546+
mockAuth.On("HasPermissionAPI", ctx, ownerUserID, "cancel-own", "purchases").Return(true, nil)
3547+
3548+
handler := &Handler{config: mockStore, auth: mockAuth}
3549+
req := &events.LambdaFunctionURLRequest{
3550+
Headers: map[string]string{"authorization": "Bearer owner-token"},
3551+
}
3552+
result, err := handler.revokePurchase(ctx, req, execID, "")
3553+
require.NoError(t, err, "session owner with cancel-own must succeed")
3554+
resultMap := result.(map[string]string)
3555+
assert.Equal(t, "revocation_requested", resultMap["status"])
3556+
mockStore.AssertExpectations(t)
3557+
mockAuth.AssertExpectations(t)
3558+
}
3559+
3560+
// TestHandler_revokePurchase_SessionNoPermissionNoToken verifies that a session
3561+
// lacking both cancel-any and cancel-own, with no token supplied, is denied:
3562+
// the session branch returns permission-denied and falls through to the
3563+
// tokenless 401. Covers the session-auth revoke branch CR finding (PR #889).
3564+
func TestHandler_revokePurchase_SessionNoPermissionNoToken(t *testing.T) {
3565+
ctx := context.Background()
3566+
execID := "88888888-8888-8888-8888-888888888888"
3567+
userEmail := "nobody@example.com"
3568+
userID := "cccccccc-cccc-cccc-cccc-cccccccccccc"
3569+
3570+
exec := buildCompletedExec(execID, "unused-token")
3571+
3572+
mockStore := new(MockConfigStore)
3573+
mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil)
3574+
3575+
mockAuth := new(MockAuthService)
3576+
mockAuth.On("ValidateSession", ctx, "user-token").
3577+
Return(&Session{UserID: userID, Email: userEmail}, nil)
3578+
mockAuth.On("HasPermissionAPI", ctx, userID, "cancel-any", "purchases").Return(false, nil)
3579+
mockAuth.On("HasPermissionAPI", ctx, userID, "cancel-own", "purchases").Return(false, nil)
3580+
3581+
handler := &Handler{config: mockStore, auth: mockAuth}
3582+
req := &events.LambdaFunctionURLRequest{
3583+
Headers: map[string]string{"authorization": "Bearer user-token"},
3584+
}
3585+
_, err := handler.revokePurchase(ctx, req, execID, "")
3586+
require.Error(t, err, "no permission and no token must be denied")
3587+
ce, ok := IsClientError(err)
3588+
require.True(t, ok, "expected a client error")
3589+
assert.Equal(t, 401, ce.code)
3590+
mockStore.AssertExpectations(t)
3591+
mockAuth.AssertExpectations(t)
3592+
}
3593+
3594+
// TestHandler_revokeViaSession_ConcurrentTransition verifies that a lost-update
3595+
// race (the row's status changed between read and the conditional UPDATE) is
3596+
// surfaced as a 409 rather than silently overwriting. Regression test for the
3597+
// atomic-status-transition CR finding (PR #889).
3598+
func TestHandler_revokeViaSession_ConcurrentTransition(t *testing.T) {
3599+
ctx := context.Background()
3600+
execID := "99999999-9999-9999-9999-999999999999"
3601+
3602+
exec := buildCompletedExec(execID, "tok")
3603+
3604+
mockStore := new(MockConfigStore)
3605+
mockStore.On("TransitionExecutionStatus", ctx, execID,
3606+
[]string{"completed", "partially_completed"}, "revocation_requested").
3607+
Return(nil, fmt.Errorf("%w: execution %s", config.ErrExecutionNotInExpectedStatus, execID))
3608+
3609+
handler := &Handler{config: mockStore}
3610+
_, err := handler.revokeViaSession(ctx, exec, "someone@example.com")
3611+
require.Error(t, err, "a concurrent transition must surface an error")
3612+
ce, ok := IsClientError(err)
3613+
require.True(t, ok, "expected a client error")
3614+
assert.Equal(t, 409, ce.code)
3615+
mockStore.AssertExpectations(t)
3616+
}
3617+
34283618
// ---------------------------------------------------------------------------
34293619
// resolveExecutedNotificationRecipients unit tests (issue #291)
34303620
// ---------------------------------------------------------------------------

‎internal/purchase/mocks_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,8 +659,9 @@ func (m *MockEmailSender) SendPurchaseApprovalRequest(ctx context.Context, data
659659
args := m.Called(ctx, data)
660660
return args.Error(0)
661661
}
662-
func (m *MockEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error {
663-
return nil
662+
func (m *MockEmailSender) SendPurchaseExecutedNotification(ctx context.Context, data email.NotificationData) error {
663+
args := m.Called(ctx, data)
664+
return args.Error(0)
664665
}
665666
func (m *MockEmailSender) SendRegistrationReceivedNotification(_ context.Context, _ email.RegistrationNotificationData) error {
666667
return nil

‎internal/scheduler/scheduler_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,8 +539,9 @@ func (m *MockEmailSender) SendPurchaseApprovalRequest(ctx context.Context, data
539539
args := m.Called(ctx, data)
540540
return args.Error(0)
541541
}
542-
func (m *MockEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error {
543-
return nil
542+
func (m *MockEmailSender) SendPurchaseExecutedNotification(ctx context.Context, data email.NotificationData) error {
543+
args := m.Called(ctx, data)
544+
return args.Error(0)
544545
}
545546
func (m *MockEmailSender) SendRegistrationReceivedNotification(_ context.Context, _ email.RegistrationNotificationData) error {
546547
return nil

0 commit comments

Comments
 (0)