You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(audit): stamp actor on execution + RI-exchange state transitions (#995 followup) #1009
The adversarial review of PR #995 (gate scheduled-purchase management on creator-scope ownership, closes #950) surfaced a pre-existing audit gap that's orthogonal to PR #995's scope but real: state-transition store methods don't record WHO performed the transition. After a Pause/Resume/Cancel/Edit action lands, the only audit trail is the row's updated_at timestamp; there's no way to attribute the action to the user who clicked.
This matters because:
Compliance / forensics: financial actions on cloud commitments need an actor-of-record.
Incident response: if a purchase is cancelled in error, today there's no quick way to find out who did it.
Scope
Affected store methods (Go, internal/config/store_postgres.go):
TransitionExecutionStatus(ctx, executionID, fromStatuses, toStatus) — generic, used by Pause/Resume/Disable/Edit flows. Takes no actor.
TransitionRIExchangeStatus(ctx, id, fromStatus, toStatus) — RI-exchange state machine. Takes no actor.
TransitionRegistrationStatus(ctx, reg, fromStatus) — account-registration state. Takes no actor. (Less financially sensitive but same shape.)
Already correct (use as the reference shape):
CancelExecutionAtomic(ctx, tx, executionID, cancelledBy *string) — accepts cancelledBy. Added recently. This is the pattern to mirror.
Proposed design
Schema (one migration, idempotent):
Add transitioned_by UUID NULL REFERENCES users(id) ON DELETE SET NULL to purchase_executions, ri_exchanges, account_registrations.
Add transitioned_at TIMESTAMPTZ NULL to the same tables (in case updated_at is reused for other reasons later).
Index transitioned_by if queries-by-actor become common (defer; add later if needed).
Store interface (Go):
Extend TransitionExecutionStatus signature with a trailing actor *string (or actor uuid.UUID) parameter. Stamps both transitioned_by and transitioned_at in the UPDATE.
Same for TransitionRIExchangeStatus and TransitionRegistrationStatus.
Mirror the cancelledBy *string nullable pattern from CancelExecutionAtomic so system-initiated transitions (scheduler tick, retry worker) can pass nil and the row is stamped with NULL (system actor).
Handler-side wiring:
Every handler call site of these methods threads the session's user ID through. Today: ~6 sites in internal/api/handler_purchases.go, ~3 in handler_ri_exchange.go, plus the scheduler tick + retry paths.
Background
The adversarial review of PR #995 (gate scheduled-purchase management on creator-scope ownership, closes #950) surfaced a pre-existing audit gap that's orthogonal to PR #995's scope but real: state-transition store methods don't record WHO performed the transition. After a Pause/Resume/Cancel/Edit action lands, the only audit trail is the row's
updated_attimestamp; there's no way to attribute the action to the user who clicked.This matters because:
update-any:purchasesbypass introduced in sec(purchases): standard user can pause/resume other users' scheduled purchases (no ownership gate) #950, any admin can act on any purchase, but the row gives no signal of which admin did it.Scope
Affected store methods (Go,
internal/config/store_postgres.go):TransitionExecutionStatus(ctx, executionID, fromStatuses, toStatus)— generic, used by Pause/Resume/Disable/Edit flows. Takes no actor.TransitionRIExchangeStatus(ctx, id, fromStatus, toStatus)— RI-exchange state machine. Takes no actor.TransitionRegistrationStatus(ctx, reg, fromStatus)— account-registration state. Takes no actor. (Less financially sensitive but same shape.)Already correct (use as the reference shape):
CancelExecutionAtomic(ctx, tx, executionID, cancelledBy *string)— acceptscancelledBy. Added recently. This is the pattern to mirror.Proposed design
Schema (one migration, idempotent):
transitioned_by UUID NULL REFERENCES users(id) ON DELETE SET NULLtopurchase_executions,ri_exchanges,account_registrations.transitioned_at TIMESTAMPTZ NULLto the same tables (in caseupdated_atis reused for other reasons later).transitioned_byif queries-by-actor become common (defer; add later if needed).Store interface (Go):
TransitionExecutionStatussignature with a trailingactor *string(oractor uuid.UUID) parameter. Stamps bothtransitioned_byandtransitioned_atin the UPDATE.TransitionRIExchangeStatusandTransitionRegistrationStatus.cancelledBy *stringnullable pattern fromCancelExecutionAtomicso system-initiated transitions (scheduler tick, retry worker) can passniland the row is stamped with NULL (system actor).Handler-side wiring:
internal/api/handler_purchases.go, ~3 in handler_ri_exchange.go, plus the scheduler tick + retry paths.Tests:
transitioned_bymatches the session user post-call.transitioned_by IS NULL.MockConfigStore.TransitionExecutionStatusFnalready exists; extend the signature consistent with the interface change.Out of scope (file separately if needed)
Acceptance criteria
Transition*Statusmethods accept and stamp the actor.MockConfigStoreupdated with the new signature; per-package mocks rely on the canonical mock (consolidated in PR feat(purchases): in-app revocation within free-cancel window (closes #290) #804).Related
CancelExecutionAtomic(... cancelledBy *string)pattern.created_by_user_idalready exists; this issue adds the per-action actor).