Repository navigation
feat(audit): stamp actor on execution + RI-exchange state transitions (#995 followup) #1009
Copy link
Copy link
Closed
Labels
effort/mDaysDaysimpact/fewLimited audienceLimited audiencepr-createdA PR has been opened for this issue (dedup guard for the auto-PR loop)A PR has been opened for this issue (dedup guard for the auto-PR loop)priority/p2Backlog-worthyBacklog-worthyseverity/mediumModerate harmModerate harmtriagedItem has been triagedItem has been triagedtype/featNew capabilityNew capabilityurgency/this-quarterWithin the quarterWithin the quarter
Description
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:
- Compliance / forensics: financial actions on cloud commitments need an actor-of-record.
- Multi-admin orgs: with the
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. - 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)— acceptscancelledBy. 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 NULLtopurchase_executions,ri_exchanges,account_registrations. - Add
transitioned_at TIMESTAMPTZ NULLto the same tables (in caseupdated_atis reused for other reasons later). - Index
transitioned_byif queries-by-actor become common (defer; add later if needed).
- Add
-
Store interface (Go):
- Extend
TransitionExecutionStatussignature with a trailingactor *string(oractor uuid.UUID) parameter. Stamps bothtransitioned_byandtransitioned_atin the UPDATE. - Same for
TransitionRIExchangeStatusandTransitionRegistrationStatus. - Mirror the
cancelledBy *stringnullable pattern fromCancelExecutionAtomicso system-initiated transitions (scheduler tick, retry worker) can passniland the row is stamped with NULL (system actor).
- Extend
-
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.
- Every handler call site of these methods threads the session's user ID through. Today: ~6 sites in
-
Tests:
- For each handler with the new actor param: test the row's
transitioned_bymatches the session user post-call. - For system-initiated callers: test
transitioned_by IS NULL. - Mock surface:
MockConfigStore.TransitionExecutionStatusFnalready exists; extend the signature consistent with the interface change.
- For each handler with the new actor param: test the row's
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*Statusmethods 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.
-
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
- Closes the F1 surface from the PR fix(purchases): gate scheduled-purchase management on creator-scope ownership (closes #950) #995 adversarial review report.
- Builds on PR feat(purchases): in-app revocation within free-cancel window (closes #290) #804's
CancelExecutionAtomic(... cancelledBy *string)pattern. - Builds on PR fix(purchases): gate scheduled-purchase management on creator-scope ownership (closes #950) #995's ownership gate (
created_by_user_idalready exists; this issue adds the per-action actor).
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
effort/mDaysDaysimpact/fewLimited audienceLimited audiencepr-createdA PR has been opened for this issue (dedup guard for the auto-PR loop)A PR has been opened for this issue (dedup guard for the auto-PR loop)priority/p2Backlog-worthyBacklog-worthyseverity/mediumModerate harmModerate harmtriagedItem has been triagedItem has been triagedtype/featNew capabilityNew capabilityurgency/this-quarterWithin the quarterWithin the quarter
Activity
feat(audit): stamp actor on execution + RI-exchange state transitions (…
fix(audit): stamp transition actor on session approvals; renumber mig…
feat(audit): stamp actor on execution + RI-exchange state transitions (…
fix(audit): stamp transition actor on session approvals; renumber mig…
Merge pull request #1011 from LeanerCloud/fix/1009-audit-actor-stamps