Repository navigation
feat(audit): record config_audit_events for account service override writes - #1510
Conversation
…writes Writes to account_service_overrides left no audit trail, and the config_audit_events table the override import was meant to match did not exist. Add it (migration 000147) and record one event per override change in the same transaction as the change: - account_service_override.upsert for a single PUT or an imported row, with the stored row before (NULL for a create) and after - account_service_override.delete for a single DELETE, with the deleted row as before - account_service_override.import for an applied import (mode, row counts); its row events link to it via parent_event_id and carry their file row number A save that changes no setting, a delete of a missing override, a dry run and a rejected strict import record nothing; a failed import rolls back its rows and every event. The audited store write takes a per-override advisory lock and reads the row FOR UPDATE before upserting with RETURNING, so before is exactly the row the write replaced even when two writers create or change the same override at once, and after is what the columns hold. The import writes its rows in key order so two imports cannot deadlock on those locks. SaveAccountServiceOverrideTx, whose only caller was the import, is replaced by the audited variant. Closes LeanerCloud#1493 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rF3. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rF4. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Problem
Writes to
account_service_overridesfrom the singlePUT, the singleDELETEand the bulk import (#1486) left no audit record. Theconfig_audit_eventstable that issue #9 asks the import to "match" did not exist anywhere in the schema or the code.Fix
Schema: migration
000147_config_audit_events. 000146 is reserved for #1484. On this branch alonepreviousMigrationVersionresolves 147 to 145.New table
config_audit_events:idseqcreated_at.event_typeaccount_service_override.upsert,.delete,.importparent_event_idactoractor_user_id,actor_api_key_idaccount_id,provider,servicebefore,afterdetailscreated_atCHECK constraints:
event_typemust be one of the three values above.actormust not be empty.after.beforeand noafter.There are no foreign keys to users or accounts, so the trail outlives the account (whose overrides cascade away). This follows the same pattern as
ri_exchange_direct_executions(000131). The down migration drops the table.Store (
internal/config/store_postgres_config_audit.go):SaveAccountServiceOverrideAuditedTx(ctx, tx, override, AuditContext)runs in this order:hashtextextended('cudly:account-service-override:<account>/<provider>/<service>').FOR UPDATE.RETURNING.before(the locked row) andafter(the row read back, so coverage shows theDECIMAL(5,2)value actually stored).Both locks are needed. The advisory lock serializes two first creates, which a row lock can't do because the row isn't visible yet. The row lock covers writers that don't take the advisory lock, such as a concurrent
DELETE. Without both, an event could report the wrongbefore.DeleteAccountServiceOverrideAuditedTxrunsDELETE ... RETURNINGand records the deleted row asbefore. Deleting a missing override records nothing.SaveConfigAuditEventTxrecords the import's own event.SaveAccountServiceOverrideTxis replaced by the audited variant. The import was its only caller.Handlers:
PUT /api/accounts/:id/service-overrides/:provider/:serviceand the matchingDELETEnow write through the audited methods insideWithTx.configAuditActor, which uses the same identity rules as the RI-exchange direct-execute audit. A failed audit insert fails the request, so a write never commits without its event.POST /api/accounts/service-overrides/import(applied, not dry-run) does the following in its existing single transaction:account_service_override.importevent with{mode, summary, rows}.parent_event_idpointing at that event anddetails.import_rowset to the row's 1-based position in the file.(account, provider, service)order instead of file order. Each write holds its key's locks until commit, so two overlapping imports could otherwise deadlock. The report and its row numbers are unchanged.Closes #1493
Verification
All commands were run on
fix/issue-1493(723c88fc) withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true.Static checks:
go build ./...,go vet ./...,go vet -tags integration ./...: cleangolangci-lint run --allow-parallel-runners [--build-tags integration]on./internal/api/ ./internal/config/ ./internal/mocks/ ./internal/database/postgres/migrations/: 0 issuesgocyclo -over 10 -ignore "_test\.go"on the changed non-test files: no outputUnit tests, one package at a time:
go test -count=1on./internal/mocks/,./internal/config/,./internal/api/,./internal/database/postgres/migrations/: all okReal-Postgres integration tests (testcontainers), fail-before / pass-after. Handler-level tests in
internal/api/handler_override_audit_integration_test.go:TestOverrideAudit_SinglePutAndDelete: PUT insert, PUT update, no-op PUT, DELETE, and DELETE of a missing override. Checks exactly 3 events, the actor, the target, and thebefore/afterchain.TestOverrideAudit_Import: a rejected strict import and a dry run write no events. A best-effort import writes one import event with mode and counts plus linked row events withbefore/afterandimport_row. A DB trigger that fails part way rolls back both rows and events.TestOverrideAudit_ConcurrentFirstSavesChain: 20 rounds of two concurrent PUTs creating the same override. The second event'sbeforemust equal the first event'safter.On
origin/mainall three fail withrelation "config_audit_events" does not exist. Main has no audit trail, so this fail-before only shows the feature was absent. On this branch all three pass. The existingTestImportOverrides_StrictIsAllOrNothingPostgresalso passes.Store-level deterministic lock tests in
internal/config/store_postgres_config_audit_integration_test.go. Each parks a competing writer, waits onWaitForLockWait, then commits it:SaveQueuesBehindDelete: covers theFOR UPDATEread. PASS.SaveQueuesBehindFirstCreate: covers the advisory lock. PASS.AfterIsTheStoredRow: coverage 33.333 is recorded as the stored 33.33. PASS.Migration test:
PASS. It covers up from the previous version, valid inserts, seven rejected shapes, down dropping the table, and re-up.
Mutation probes (each applied locally, then reverted):
FOR UPDATESaveQueuesBehindDeletefailsSaveQueuesBehindFirstCreatefails, andConcurrentFirstSavesChainfails in 20 of 20 roundsSinglePutAndDeletefailsUnit tests (mock / pgxmock / in-memory store evidence):
TestPGXMock_SaveOverrideAudited_*andTestPGXMock_DeleteOverrideAudited: statement order is lock,FOR UPDATE, upsertRETURNING, event. Also covers no event on a no-op and no event on a missing delete.TestPGXMock_SaveConfigAuditEvent_RequiresActorTestOverrideSnapshotJSONhandler_override_audit_test.go:configAuditActorhandles the admin API key sentinel and the API-key ID.Combined-tree verification at local queue commit
08ea6aeb= origin/main3bfc2fe5+fix/issue-1484,fix/issue-1498,fix/issue-1489,fix/issue-1494,fix/issue-1488andfix/issue-1493merged in that order (so migration 000147 runs on top of #1484's 000146). Passed:go build ./...;go vetwith and without-tags integration;go test -race -short -count=1 ./...; golangci-lint v2.10.1 with and without the integration tag; gocyclo -over 10;go test -race -tags integrationoninternal/api,internal/configandinternal/database/postgres/migrations; the git-secrets scan andscripts/test-git-secrets-allowlist.sh.Merge order: this adds migration 000147, so it must merge after #1505 (#1484, migration 000146).
Left out of scope
GETendpoint and an audit view would be a follow-up.saveAccountServiceOverridebuilds the row from the request plusexisting.ID/created_at, read outside the transaction. The PUT is a full replace of the settings, so a stale read only affects the returned ID andcreated_at. The upsert keys on(account, provider, service), and the response now returns the stored row's ID. The auditbeforeis always the row actually replaced, because it is read under the locks.SaveAccountServiceOverrideandDeleteAccountServiceOverridestore methods are kept. No production code calls them any more. The mocks' fallbacks and the existing tests use them, so removing them would mean churning those tests.Maintainer: please mirror the issue's triage labels on this PR:
priority/p3,type/feat. The issue has noseverity/*,urgency/*,impact/*,effort/*ortriagedlabel.🤖 Generated with Claude Code