fix(auth): reject RotateAPIKey when SoftDelete affects 0 rows#822
fix(auth): reject RotateAPIKey when SoftDelete affects 0 rows#822devin-ai-integration[bot] wants to merge 6 commits into
Conversation
Deterministic fake-repo tests for concurrent RotateAPIKey and Rotate-vs-Delete SoftDelete races. Both fail on current :exec SoftDelete behavior (loser still mints a successor). Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Switch SoftDeleteModelRouterAPIKey to :execrows and have RotateAPIKey bail with ErrAPIKeyNotFound when SoftDelete matches no row, closing the concurrent-rotate and rotate-vs-delete zombie-key races from #817. DeleteAPIKey keeps discarding the count for idempotent success. Also lands the DB-backed repro scripts referenced by #817. Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: rohith500 <rohith500@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
PR author is not in the allowed authors list. |
|
Claude finished @devin-ai-integration[bot]'s task —— View job 🤖 Claude Code Review
Posted advisory comment-length nits on 2 blocks:
|
workweave-bot
left a comment
There was a problem hiding this comment.
Advisory only — comment-length nits. Won't block merge.
| ListForInstallation(ctx context.Context, installationID string) ([]*APIKey, error) | ||
| MarkUsed(ctx context.Context, id string) error | ||
| SoftDelete(ctx context.Context, installationID, id string) error | ||
| // SoftDelete soft-deletes the key when it is still active and returns the |
There was a problem hiding this comment.
| ListForInstallation(ctx context.Context, installationID string) ([]*APIKey, error) | |
| MarkUsed(ctx context.Context, id string) error | |
| SoftDelete(ctx context.Context, installationID, id string) error | |
| // SoftDelete soft-deletes the key when it is still active and returns the | |
| // SoftDelete soft-deletes the key and returns the rows-affected count; 0 means the key was already gone. | |
| SoftDelete(ctx context.Context, installationID, id string) (int64, error) |
Was 3 lines; the return type already signals the count, the why fits in one sentence.
There was a problem hiding this comment.
Applied — condensed to the one-line version. Thanks!
| // Hold every caller between ListForInstallation and SoftDelete so the | ||
| // TOCTOU window is observable. A start-only barrier is not enough: the | ||
| // List→SoftDelete critical section is microseconds, so one goroutine | ||
| // usually soft-deletes before the others even list (see earlier runs). |
There was a problem hiding this comment.
| // Hold every caller between ListForInstallation and SoftDelete so the | |
| // TOCTOU window is observable. A start-only barrier is not enough: the | |
| // List→SoftDelete critical section is microseconds, so one goroutine | |
| // usually soft-deletes before the others even list (see earlier runs). | |
| // Hold every caller between List and SoftDelete to open the TOCTOU window; | |
| // a start-only barrier misses it because List→SoftDelete is microseconds. | |
| listHold := newListHold(concurrentRotations) |
Was 4 lines; the timing rationale fits in two.
There was a problem hiding this comment.
Applied — condensed the TOCTOU comment to two lines. Thanks!
|
Claude finished @devin-ai-integration[bot]'s task —— View job Claude finished @devin-ai-integration[bot]'s task — View job 🤖 Claude Code Review
Posted advisory comment-length nits on 2 blocks in
|
workweave-bot
left a comment
There was a problem hiding this comment.
Advisory only — comment-length nits. Won't block merge.
| // TestRotateAPIKey_ConcurrentRace_OnlyOneSucceeds is the #817 unit repro: | ||
| // two RotateAPIKey callers both observe the key as active, then SoftDelete; | ||
| // exactly one must mint a successor, the other ErrAPIKeyNotFound. |
There was a problem hiding this comment.
| // TestRotateAPIKey_ConcurrentRace_OnlyOneSucceeds is the #817 unit repro: | |
| // two RotateAPIKey callers both observe the key as active, then SoftDelete; | |
| // exactly one must mint a successor, the other ErrAPIKeyNotFound. | |
| // TestRotateAPIKey_ConcurrentRace_OnlyOneSucceeds verifies the #817 fix: | |
| // the losing racer must return ErrAPIKeyNotFound, not mint an orphan key. |
Was 3 lines; the function name already conveys the scenario — trim to the non-obvious constraint.
There was a problem hiding this comment.
Applied — trimmed to two lines focused on the constraint. Thanks!
| // TestRotateAPIKey_LosingToDelete_DoesNotIssue is the #817 Delete-vs-Rotate | ||
| // unit repro: Delete wins SoftDelete after Rotate has Listed; Rotate must | ||
| // return ErrAPIKeyNotFound and must not issue a zombie replacement. |
There was a problem hiding this comment.
| // TestRotateAPIKey_LosingToDelete_DoesNotIssue is the #817 Delete-vs-Rotate | |
| // unit repro: Delete wins SoftDelete after Rotate has Listed; Rotate must | |
| // return ErrAPIKeyNotFound and must not issue a zombie replacement. | |
| // TestRotateAPIKey_LosingToDelete_DoesNotIssue verifies the #817 Delete-vs-Rotate | |
| // fix: Rotate must return ErrAPIKeyNotFound and not mint a zombie successor. |
Was 3 lines restating the function name; trim to the non-obvious constraint (no zombie on lost SoftDelete).
There was a problem hiding this comment.
Applied — trimmed to two lines focused on the constraint. Thanks!
|
Claude finished @devin-ai-integration[bot]'s task —— View job 🤖 Claude Code Review
Posted advisory comment-length nits on 2 blocks:
|
workweave-bot
left a comment
There was a problem hiding this comment.
Advisory only — comment-length nits. Won't block merge.
| // statefulAPIKeyRepo is an in-memory APIKeyRepository that models real | ||
| // SoftDelete semantics: matching active row → soft-delete; already-deleted | ||
| // or missing → silent no-op (0 rows). Used to exercise RotateAPIKey races | ||
| // without Postgres (see #817). |
There was a problem hiding this comment.
| // statefulAPIKeyRepo is an in-memory APIKeyRepository that models real | |
| // SoftDelete semantics: matching active row → soft-delete; already-deleted | |
| // or missing → silent no-op (0 rows). Used to exercise RotateAPIKey races | |
| // without Postgres (see #817). | |
| // statefulAPIKeyRepo is an in-memory APIKeyRepository whose SoftDelete | |
| // counts rows affected (0 when already gone), matching live Postgres semantics. |
Was 4 lines; the SoftDelete method itself shows the semantics, and the purpose is clear from the file.
There was a problem hiding this comment.
Applied — condensed to two lines. Thanks!
| // Timing note: a start-only barrier (line up, then release into RotateAPIKey) | ||
| // does not hit the race in practice — List→SoftDelete is microseconds, so one | ||
| // goroutine soft-deletes before the others list (they then get | ||
| // ErrAPIKeyNotFound). The pool is not the limiter (peak acquired == N). A | ||
| // second barrier inside ListForInstallation holds every caller after the | ||
| // pre-delete read and before SoftDelete, which is the actual TOCTOU window. | ||
| // SoftDelete stays the real :exec SQLC path (silent no-op on 0 rows). |
There was a problem hiding this comment.
| // Timing note: a start-only barrier (line up, then release into RotateAPIKey) | |
| // does not hit the race in practice — List→SoftDelete is microseconds, so one | |
| // goroutine soft-deletes before the others list (they then get | |
| // ErrAPIKeyNotFound). The pool is not the limiter (peak acquired == N). A | |
| // second barrier inside ListForInstallation holds every caller after the | |
| // pre-delete read and before SoftDelete, which is the actual TOCTOU window. | |
| // SoftDelete stays the real :exec SQLC path (silent no-op on 0 rows). | |
| // Timing note: a start-only barrier misses the race — List→SoftDelete is | |
| // microseconds, so one goroutine soft-deletes before the others List. A second | |
| // barrier inside ListForInstallation holds every caller at the TOCTOU window. |
Was 7 lines; the pool note and SoftDelete path note are filler — the TOCTOU insight fits in three.
There was a problem hiding this comment.
Applied — condensed the timing note to three lines. Thanks!
|
Claude finished @devin-ai-integration[bot]'s task —— View job 🤖 Claude Code Review
Posted advisory comment-length nits on 3 blocks:
|
workweave-bot
left a comment
There was a problem hiding this comment.
Advisory only — comment-length nits. Won't block merge.
| // Command rotate_key_race_check reproduces a concurrent RotateAPIKey race | ||
| // against a live Postgres: N goroutines soft-delete + re-issue the same key | ||
| // with no transaction / rows-affected check, so more than one successor key | ||
| // can end up live from a single rotation intent. | ||
| // | ||
| // It is a separate main package (not a _test.go), so `go test ./...` never | ||
| // touches Postgres. It is gated on ROUTER_TEST_DATABASE_URL (a DSN to a | ||
| // database with the router migrations applied) and is a no-op without it. | ||
| // | ||
| // Timing note: a start-only barrier misses the race — List→SoftDelete is | ||
| // microseconds, so one goroutine soft-deletes before the others List. A second | ||
| // barrier inside ListForInstallation holds every caller at the TOCTOU window. | ||
| // | ||
| // Usage (from the repo root, against the docker-compose Postgres): | ||
| // | ||
| // ROUTER_TEST_DATABASE_URL="postgres://router:router@localhost:5433/router?search_path=router" \ | ||
| // go run ./scripts/rotate_key_race_check |
There was a problem hiding this comment.
| // Command rotate_key_race_check reproduces a concurrent RotateAPIKey race | |
| // against a live Postgres: N goroutines soft-delete + re-issue the same key | |
| // with no transaction / rows-affected check, so more than one successor key | |
| // can end up live from a single rotation intent. | |
| // | |
| // It is a separate main package (not a _test.go), so `go test ./...` never | |
| // touches Postgres. It is gated on ROUTER_TEST_DATABASE_URL (a DSN to a | |
| // database with the router migrations applied) and is a no-op without it. | |
| // | |
| // Timing note: a start-only barrier misses the race — List→SoftDelete is | |
| // microseconds, so one goroutine soft-deletes before the others List. A second | |
| // barrier inside ListForInstallation holds every caller at the TOCTOU window. | |
| // | |
| // Usage (from the repo root, against the docker-compose Postgres): | |
| // | |
| // ROUTER_TEST_DATABASE_URL="postgres://router:router@localhost:5433/router?search_path=router" \ | |
| // go run ./scripts/rotate_key_race_check | |
| // Command rotate_key_race_check reproduces a concurrent RotateAPIKey race | |
| // against a live Postgres: N goroutines soft-delete + re-issue the same key | |
| // with no transaction / rows-affected check, so more than one successor key | |
| // can end up live from a single rotation intent. | |
| // | |
| // Timing note: a start-only barrier misses the race — List→SoftDelete is | |
| // microseconds, so one goroutine soft-deletes before the others List. A second | |
| // barrier inside ListForInstallation holds every caller at the TOCTOU window. | |
| // | |
| // Usage (from the repo root, against the docker-compose Postgres): | |
| // | |
| // ROUTER_TEST_DATABASE_URL="postgres://router:router@localhost:5433/router?search_path=router" \ | |
| // go run ./scripts/rotate_key_race_check |
Was 17 lines; the "separate main package / not a _test.go" paragraph restates what the scripts/ path and the if dsn == "" guard already communicate.
There was a problem hiding this comment.
Leaving this one as-is. The "separate main package / not a _test.go" note documents an intentional AGENTS.md invariant (DB-backed checks live under scripts/, never as internal/*_test.go), which isn't obvious from the path alone — worth keeping for the next reader. These are advisory nits and the PR already meets the documented comment convention with CI green, so I'll stop here rather than keep churning the doc comments. Thanks!
| // checkRotateKeyRace creates a fresh installation + one API key, then fires | ||
| // concurrentRotations barrier-synchronized RotateAPIKey calls on that same | ||
| // keyID and reports how many succeeded vs how many active keys remain. |
There was a problem hiding this comment.
| // checkRotateKeyRace creates a fresh installation + one API key, then fires | |
| // concurrentRotations barrier-synchronized RotateAPIKey calls on that same | |
| // keyID and reports how many succeeded vs how many active keys remain. | |
| // checkRotateKeyRace seeds one key, fires concurrentRotations barrier-held | |
| // RotateAPIKey calls, and reports successes vs. active-key count. |
Was 3 lines narrating implementation steps; fits in two.
There was a problem hiding this comment.
Leaving as-is — this is a reasonable, accurate function godoc. Advisory nit only; stopping here to avoid further comment churn now that the PR meets the convention and CI is green. Thanks!
| // checkRotateVsDelete forces DeleteAPIKey to win SoftDelete after Rotate has | ||
| // already Listed the key as active. Expected buggy outcome: Rotate still | ||
| // IssueAPIKeys a live successor even though the key was deleted (not rotated). |
There was a problem hiding this comment.
| // checkRotateVsDelete forces DeleteAPIKey to win SoftDelete after Rotate has | |
| // already Listed the key as active. Expected buggy outcome: Rotate still | |
| // IssueAPIKeys a live successor even though the key was deleted (not rotated). | |
| // checkRotateVsDelete forces DeleteAPIKey to win SoftDelete after Rotate has | |
| // already listed the key as active, then counts zombie successors minted. |
Was 3 lines; "Expected buggy outcome" sentence is filler — two lines capture the constraint.
There was a problem hiding this comment.
Leaving as-is — the "expected buggy outcome" line documents what the check is asserting against (the pre-fix zombie-successor behavior), which is the point of the repro. Advisory nit only; stopping here to avoid churn. Thanks!
Summary
Compliance rewrite of #819 by @rohith500 (thanks!). Fixes #817:
RotateAPIKeyno longer mints a successor when its ownSoftDeletematches 0 rows (a lost race against a concurrent Rotate or a Delete), which previously produced untracked "zombie" credentials.Production logic and tests are preserved verbatim from #819; the only delta is trimming godoc/SQL comments to the repo's comment convention (root
CLAUDE.mdline 15: concise, no multi-paragraph, no pre-fix narrative, no caller references).Behavior:
Coverage (unchanged from #819): in-memory fake-repo race tests in
internal/auth/rotate_race_test.go(concurrent Rotate → exactly one success; Rotate-vs-Delete → no zombie), plus live-Postgres repro scripts underscripts/{rotate_key_race_check,rotate_key_race_ext_check,upsert_external_key_race_check}(per the AGENTS.md rule that DB-backed checks live inscripts/, notinternal/*_test.go).make generate+make precommitgreen.Co-authored-by: rohith500 rohithreddy2202@gmail.com
Link to Devin session: https://app.devin.ai/sessions/03fe47e3946a4829b431a2e2a35c1606
Requested by: @steventohme