Skip to content

feat(audit): stamp actor on execution + RI-exchange state transitions (#995 followup) #1009

Description

@cristim

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_at timestamp; there's no way to attribute the action to the user who clicked.

This matters because:

  1. Compliance / forensics: financial actions on cloud commitments need an actor-of-record.
  2. Multi-admin orgs: with the update-any:purchases bypass 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.
  3. 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

  1. 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).
  2. 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).
  3. 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.
    • Scheduler tick passes nil (system-initiated). Retry paths (recover stranded approvals) pass nil. CR rate-limit timeouts pass nil.
  4. Tests:

    • For each handler with the new actor param: test the row's transitioned_by matches the session user post-call.
    • For system-initiated callers: test transitioned_by IS NULL.
    • Mock surface: MockConfigStore.TransitionExecutionStatusFn already exists; extend the signature consistent with the interface change.

Out of scope (file separately if needed)

  • Surfacing the actor in the History UI (UX work).
  • Audit log retention policy / queryable audit endpoint.
  • Stamping retroactively on existing rows (impossible without data; just NULL).
  • Generalizing to other state machines (plans table, etc.) — only the three identified above are financial state machines.

Acceptance criteria

  • Migration adds the columns idempotently.
  • All three Transition*Status methods accept and stamp the actor.
  • All handler call sites pass the session user ID.
  • Scheduler / retry / system paths pass nil and stamp NULL.
  • Tests assert the actor stamp for one happy path per method + one system path.
  • MockConfigStore updated 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions