Skip to content

Commit 2f35c19

Browse files
committed
feat(audit): stamp actor on execution + RI-exchange state transitions (closes #1009)
Add transitioned_by/transitioned_at columns to purchase_executions, ri_exchange_history, and account_registrations (migration 000066). Thread an optional actor *string through all three TransitionStatus methods so human-initiated transitions record the session user UUID while system-initiated paths (reaper, scheduler, token-based approval) pass nil. Guard non-UUID reviewer IDs (e.g. "admin-api-key") via validUUIDPtrOrNil before using as a FK value. Mirrors the CancelExecutionAtomic(actor) pattern from #804.
1 parent e31ed68 commit 2f35c19

33 files changed

Lines changed: 446 additions & 159 deletions

‎internal/analytics/collector_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ func (m *mockConfigStore) ListPendingExecutionIDsForAccount(ctx context.Context,
241241
return nil, nil
242242
}
243243

244-
func (m *mockConfigStore) TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string) (*config.PurchaseExecution, error) {
244+
func (m *mockConfigStore) TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string) (*config.PurchaseExecution, error) {
245245
return nil, nil
246246
}
247247

@@ -265,7 +265,7 @@ func (m *mockConfigStore) GetRIExchangeRecordByToken(ctx context.Context, token
265265
func (m *mockConfigStore) GetRIExchangeHistory(ctx context.Context, since time.Time, limit int) ([]config.RIExchangeRecord, error) {
266266
return nil, nil
267267
}
268-
func (m *mockConfigStore) TransitionRIExchangeStatus(ctx context.Context, id string, fromStatus string, toStatus string) (*config.RIExchangeRecord, error) {
268+
func (m *mockConfigStore) TransitionRIExchangeStatus(ctx context.Context, id string, fromStatus string, toStatus string, actor *string) (*config.RIExchangeRecord, error) {
269269
return nil, nil
270270
}
271271
func (m *mockConfigStore) CompleteRIExchange(ctx context.Context, id string, exchangeID string) error {
@@ -350,7 +350,7 @@ func (m *mockConfigStore) ListAccountRegistrations(_ context.Context, _ config.A
350350
func (m *mockConfigStore) UpdateAccountRegistration(_ context.Context, _ *config.AccountRegistration) error {
351351
return nil
352352
}
353-
func (m *mockConfigStore) TransitionRegistrationStatus(_ context.Context, _ *config.AccountRegistration, _ string) error {
353+
func (m *mockConfigStore) TransitionRegistrationStatus(_ context.Context, _ *config.AccountRegistration, _ string, _ *string) error {
354354
return nil
355355
}
356356
func (m *mockConfigStore) DeleteAccountRegistration(_ context.Context, _ string) error {

‎internal/api/coverage_extras_test.go‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ func TestHandler_rejectRIExchange_AlreadyProcessed(t *testing.T) {
330330
mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return(
331331
&config.RIExchangeRecord{ID: "11111111-1111-1111-1111-111111111111", ApprovalToken: "tok"}, nil)
332332
// Transition returns nil indicating already processed
333-
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "cancelled").
333+
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "cancelled", mock.Anything).
334334
Return(nil, nil)
335335

336336
h := &Handler{config: mockStore}
@@ -364,11 +364,81 @@ func TestHandler_approveRIExchange_AlreadyProcessed(t *testing.T) {
364364
mockStore := new(MockConfigStore)
365365
mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return(
366366
&config.RIExchangeRecord{ID: "11111111-1111-1111-1111-111111111111", ApprovalToken: "tok"}, nil)
367-
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "processing").
367+
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "processing", mock.Anything).
368368
Return(nil, nil)
369369

370370
h := &Handler{config: mockStore}
371371
_, err := h.approveRIExchange(ctx, &events.LambdaFunctionURLRequest{}, "11111111-1111-1111-1111-111111111111", "tok")
372372
assert.Error(t, err)
373373
assert.Contains(t, err.Error(), "already processed")
374374
}
375+
376+
// ---------------------------------------------------------------------------
377+
// validUUIDPtrOrNil — actor-stamp helper (issue #1009)
378+
// ---------------------------------------------------------------------------
379+
380+
// TestValidUUIDPtrOrNil_ReturnsPointerForValidUUID asserts the happy-path:
381+
// a string that parses as a UUID is passed through unchanged.
382+
func TestValidUUIDPtrOrNil_ReturnsPointerForValidUUID(t *testing.T) {
383+
uid := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
384+
result := validUUIDPtrOrNil(&uid)
385+
require.NotNil(t, result, "valid UUID must return non-nil pointer")
386+
assert.Equal(t, uid, *result)
387+
}
388+
389+
// TestValidUUIDPtrOrNil_ReturnsNilForNonUUID asserts that a non-UUID string
390+
// (e.g. "admin-api-key") returns nil so it is never used as a FK actor.
391+
func TestValidUUIDPtrOrNil_ReturnsNilForNonUUID(t *testing.T) {
392+
s := "admin-api-key"
393+
assert.Nil(t, validUUIDPtrOrNil(&s), "non-UUID reviewer_by must map to nil actor")
394+
}
395+
396+
// TestValidUUIDPtrOrNil_ReturnsNilForNilInput asserts that a nil *string
397+
// returns nil (no panic on nil dereference).
398+
func TestValidUUIDPtrOrNil_ReturnsNilForNilInput(t *testing.T) {
399+
assert.Nil(t, validUUIDPtrOrNil(nil))
400+
}
401+
402+
// TestHandler_TransitionRegistrationStatus_ActorStamped asserts that
403+
// TransitionRegistrationStatus is called with a non-nil actor when ReviewedBy
404+
// holds a valid UUID (the common human-reviewed path).
405+
func TestHandler_TransitionRegistrationStatus_ActorStamped(t *testing.T) {
406+
ctx := context.Background()
407+
mockStore := new(MockConfigStore)
408+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
409+
410+
const actorID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
411+
reg := &config.AccountRegistration{
412+
ID: "reg-id-1",
413+
Status: "pending",
414+
ReviewedBy: &[]string{actorID}[0],
415+
}
416+
// Actor must equal the reviewer UUID.
417+
mockStore.On("TransitionRegistrationStatus", ctx, reg, "pending",
418+
mock.MatchedBy(func(a *string) bool { return a != nil && *a == actorID }),
419+
).Return(nil)
420+
421+
err := mockStore.TransitionRegistrationStatus(ctx, reg, "pending", validUUIDPtrOrNil(reg.ReviewedBy))
422+
require.NoError(t, err)
423+
}
424+
425+
// TestHandler_TransitionRegistrationStatus_NonUUIDActorIsNil asserts that when
426+
// ReviewedBy is "admin-api-key" (API key session), the actor passed to
427+
// TransitionRegistrationStatus is nil so transitioned_by = NULL.
428+
func TestHandler_TransitionRegistrationStatus_NonUUIDActorIsNil(t *testing.T) {
429+
ctx := context.Background()
430+
mockStore := new(MockConfigStore)
431+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
432+
433+
nonUUID := "admin-api-key"
434+
reg := &config.AccountRegistration{
435+
ID: "reg-id-2",
436+
Status: "pending",
437+
ReviewedBy: &nonUUID,
438+
}
439+
// Non-UUID reviewer: actor must be nil.
440+
mockStore.On("TransitionRegistrationStatus", ctx, reg, "pending", (*string)(nil)).Return(nil)
441+
442+
err := mockStore.TransitionRegistrationStatus(ctx, reg, "pending", validUUIDPtrOrNil(reg.ReviewedBy))
443+
require.NoError(t, err)
444+
}

‎internal/api/handler_history.go‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ func (h *Handler) expireStaleExecutionsAsync(staleExecs []config.PurchaseExecuti
206206
go func() {
207207
ctx := context.Background()
208208
for _, exec := range staleExecs {
209-
_, err := h.config.TransitionExecutionStatus(ctx, exec.ExecutionID, []string{"pending", "notified"}, "expired")
209+
_, err := h.config.TransitionExecutionStatus(ctx, exec.ExecutionID, []string{"pending", "notified"}, "expired", nil)
210210
if err != nil {
211211
logging.Warnf("history: async expire of execution %s failed: %v", exec.ExecutionID, err)
212212
}
@@ -239,6 +239,25 @@ func (h *Handler) resolveUserEmails(ctx context.Context, executions []config.Pur
239239
return out
240240
}
241241

242+
// expireIfStale transitions a pending/notified execution to "expired" when
243+
// its ScheduledDate is older than approvalExpiryWindow. Returns the possibly-
244+
// updated execution. Transition failures are non-fatal — the row still
245+
// renders, just with its original status.
246+
func (h *Handler) expireIfStale(ctx context.Context, exec config.PurchaseExecution) config.PurchaseExecution {
247+
if exec.Status != "pending" && exec.Status != "notified" {
248+
return exec
249+
}
250+
if time.Since(exec.ScheduledDate) < approvalExpiryWindow {
251+
return exec
252+
}
253+
updated, err := h.config.TransitionExecutionStatus(ctx, exec.ExecutionID, []string{"pending", "notified"}, "expired", nil)
254+
if err != nil {
255+
logging.Warnf("history: failed to expire execution %s: %v", exec.ExecutionID, err)
256+
return exec
257+
}
258+
return *updated
259+
}
260+
242261
// resolvePendingApproverEmail returns the notification email the approval
243262
// link was sent to (or would have been, if SES failed). Single-tenant
244263
// deployments share one value across every pending row, so this is looked up

‎internal/api/handler_history_test.go‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,9 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) {
242242
Return([]config.PurchaseExecution{freshExec(), staleExec("pending")}, nil)
243243
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil)
244244
// The goroutine uses context.Background(); context.Background() == ctx in
245-
// this test, so the matcher fires correctly.
246-
mockStore.On("TransitionExecutionStatus", mock.Anything, staleID, []string{"pending", "notified"}, "expired").
245+
// this test, so the matcher fires correctly. The trailing mock.Anything
246+
// matches the actor *string (nil for the system-initiated async expire).
247+
mockStore.On("TransitionExecutionStatus", mock.Anything, staleID, []string{"pending", "notified"}, "expired", mock.Anything).
247248
Run(waitForCall(done)).
248249
Return(&expired, nil).Once()
249250

@@ -263,7 +264,7 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) {
263264

264265
// Exactly one Transition call, only for the stale row.
265266
mockStore.AssertNumberOfCalls(t, "TransitionExecutionStatus", 1)
266-
mockStore.AssertCalled(t, "TransitionExecutionStatus", mock.Anything, staleID, []string{"pending", "notified"}, "expired")
267+
mockStore.AssertCalled(t, "TransitionExecutionStatus", mock.Anything, staleID, []string{"pending", "notified"}, "expired", mock.Anything)
267268

268269
historyResp := result.(HistoryResponse)
269270
require.Len(t, historyResp.Purchases, 2, "both executions must render as history rows")
@@ -300,7 +301,7 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) {
300301
mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).
301302
Return([]config.PurchaseExecution{staleExec("notified")}, nil)
302303
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil)
303-
mockStore.On("TransitionExecutionStatus", mock.Anything, staleID, []string{"pending", "notified"}, "expired").
304+
mockStore.On("TransitionExecutionStatus", mock.Anything, staleID, []string{"pending", "notified"}, "expired", mock.Anything).
304305
Run(waitForCall(done)).
305306
Return(&expired, nil).Once()
306307

@@ -337,7 +338,7 @@ func TestHandler_getHistory_ExpireIfStale(t *testing.T) {
337338
mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).
338339
Return([]config.PurchaseExecution{staleExec("pending")}, nil)
339340
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil)
340-
mockStore.On("TransitionExecutionStatus", mock.Anything, staleID, []string{"pending", "notified"}, "expired").
341+
mockStore.On("TransitionExecutionStatus", mock.Anything, staleID, []string{"pending", "notified"}, "expired", mock.Anything).
341342
Run(waitForCall(done)).
342343
Return(nil, errors.New("simulated store failure")).Once()
343344

@@ -540,6 +541,38 @@ func TestHandler_getHistory_ScopedUserSeesEmptyAccountRows(t *testing.T) {
540541
assert.Equal(t, 1, resp.Summary.TotalPending)
541542
}
542543

544+
// TestHandler_expireIfStale_SystemActorIsNil asserts that the expireIfStale
545+
// helper passes nil as the actor param to TransitionExecutionStatus.
546+
// expireIfStale is a system-initiated path (no human session), so transitioned_by
547+
// must be NULL on the affected row (issue #1009).
548+
func TestHandler_expireIfStale_SystemActorIsNil(t *testing.T) {
549+
ctx := context.Background()
550+
mockStore := new(MockConfigStore)
551+
approverEmail := "ops@example.com"
552+
t.Cleanup(func() { mockStore.AssertExpectations(t) })
553+
554+
staleID := "actor-nil-stale-exec"
555+
expired := config.PurchaseExecution{ExecutionID: staleID, Status: "expired"}
556+
557+
mockStore.On("GetAllPurchaseHistory", ctx, 100).Return([]config.PurchaseHistoryRecord{}, nil)
558+
mockStore.On("GetExecutionsByStatuses", ctx, mock.Anything, mock.Anything).
559+
Return([]config.PurchaseExecution{{
560+
ExecutionID: staleID,
561+
Status: "pending",
562+
ScheduledDate: time.Now().Add(-8 * 24 * time.Hour),
563+
}}, nil)
564+
mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{NotificationEmail: &approverEmail}, nil)
565+
// System path: expireIfStale must pass nil actor so transitioned_by = NULL.
566+
mockStore.On("TransitionExecutionStatus", ctx, staleID, []string{"pending", "notified"}, "expired",
567+
(*string)(nil),
568+
).Return(&expired, nil).Once()
569+
570+
mockAuth, req := adminHistoryReq(ctx)
571+
handler := &Handler{auth: mockAuth, config: mockStore}
572+
_, err := handler.getHistory(ctx, req, map[string]string{})
573+
require.NoError(t, err)
574+
}
575+
543576
// TestHandler_getHistory_PermissionDenied asserts that a non-admin user without
544577
// view:purchases gets 403 and never reaches the store.
545578
func TestHandler_getHistory_PermissionDenied(t *testing.T) {

‎internal/api/handler_per_account_perms_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,7 @@ func TestPerAccountPerms_PlannedPurchase_AllowedAccountPlanSucceeds(t *testing.T
965965
mockStore.On("GetExecutionByID", ctx, executionID).Return(&config.PurchaseExecution{
966966
ExecutionID: executionID, PlanID: planID, Status: "pending",
967967
}, nil)
968-
mockStore.On("TransitionExecutionStatus", ctx, executionID, mock.Anything, "paused").
968+
mockStore.On("TransitionExecutionStatus", ctx, executionID, mock.Anything, "paused", mock.Anything).
969969
Return(transitoned, nil)
970970

971971
// Plan is associated with account A — within the scoped user's allowed set.
@@ -985,7 +985,7 @@ func TestPerAccountPerms_PlannedPurchase_AllowedAccountPlanSucceeds(t *testing.T
985985
require.NoError(t, err, "scoped user must be able to pause an account-A execution")
986986
require.NotNil(t, result)
987987
assert.Equal(t, "paused", result.Status, "result must reflect the paused status")
988-
mockStore.AssertCalled(t, "TransitionExecutionStatus", ctx, executionID, mock.Anything, "paused")
988+
mockStore.AssertCalled(t, "TransitionExecutionStatus", ctx, executionID, mock.Anything, "paused", mock.Anything)
989989
}
990990

991991
// ─── 10. GET /ri-exchange/instances ──────────────────────────────────────────

‎internal/api/handler_purchases.go‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ func (h *Handler) pausePlannedPurchase(ctx context.Context, req *events.LambdaFu
272272
}
273273

274274
// Atomically transition to paused
275-
if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "running"}, "paused"); err != nil {
275+
if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "running"}, "paused", resolveCreatorUserID(session)); err != nil {
276276
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be paused: %v", executionID, err))
277277
}
278278

@@ -296,7 +296,7 @@ func (h *Handler) resumePlannedPurchase(ctx context.Context, req *events.LambdaF
296296
}
297297

298298
// Atomically transition from paused back to pending
299-
if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"paused"}, "pending"); err != nil {
299+
if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"paused"}, "pending", resolveCreatorUserID(session)); err != nil {
300300
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be resumed: %v", executionID, err))
301301
}
302302

@@ -321,7 +321,7 @@ func (h *Handler) runPlannedPurchase(ctx context.Context, req *events.LambdaFunc
321321

322322
// Atomically transition to running — only one concurrent caller can succeed.
323323
// TransitionExecutionStatus handles not-found and wrong-status cases.
324-
if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "running"); err != nil {
324+
if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "running", resolveCreatorUserID(session)); err != nil {
325325
return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be started: %v", executionID, err))
326326
}
327327

@@ -348,7 +348,7 @@ func (h *Handler) deletePlannedPurchase(ctx context.Context, req *events.LambdaF
348348
return nil, err
349349
}
350350

351-
cancelled, err := h.cancelOrRecoverExecution(ctx, executionID)
351+
cancelled, err := h.cancelOrRecoverExecution(ctx, executionID, resolveCreatorUserID(session))
352352
if err != nil {
353353
return nil, err
354354
}
@@ -371,8 +371,9 @@ func (h *Handler) deletePlannedPurchase(ctx context.Context, req *events.LambdaF
371371
// (ErrExecutionNotInExpectedStatus), it fetches the row instead so the caller
372372
// can still drive the plan-disable side-effect, keeping the operation
373373
// idempotent across retries.
374-
func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID string) (*config.PurchaseExecution, error) {
375-
cancelled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "cancelled")
374+
// actor is the UUID of the user initiating the cancel (nil for system-initiated paths).
375+
func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID string, actor *string) (*config.PurchaseExecution, error) {
376+
cancelled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "cancelled", actor)
376377
if err == nil {
377378
return cancelled, nil
378379
}

0 commit comments

Comments
 (0)