Fix stale-owner API key: charge pre-auth IP budget before per-key quota (#1404) - #1412
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
Coordinator adversarial review — two independent read-only lenses (overnight run 2026-07-17)Lens A — security / auth-ordering / abuse-economics: MERGE-SAFE, 0 findings. Lens B — EF-translation / blast radius / test rigor: MERGE-SAFE, 1 LOW.
Lens B otherwise confirmed: single-statement SQLite translation of the correlated projection (real-SQLite tests assert Required fixes (zero-skip; one batched round)
Fix evidence comment to follow after the push. |
… owner unreachable) and pin the cascade 401 path with a test (#1404 review)
Fix evidence — review round (comment 5006673128)Both adversarial lenses returned MERGE-SAFE with one LOW finding; fixed zero-skip in commit
Test evidence (post-fix)Scope note: targeted filters only, per the wave protocol — the full suite is the coordinator's gate. |
Summary
Closes #1404.
Fixes the stale-owner API-key defect surfaced by Codex P2 on PR #1401. Account deletion/deactivation only sets
User.IsActive = false; the API-key row stays active. After #1401 the per-key pre-auth budget (60/min) was charged BEFORE the owner-active check, so a stale-owner key (active key row, inactive/deleted owner) fell into a loop: the first ~60 requests/window 401'd and charged the per-IP failure budget, the rest 429'd via the per-key check (which correctly does NOT charge the IP budget). Net effect — the 120-permit IP failure budget never exhausted, the pre-auth pre-check never tripped, and the SHA-256 +ApiKeyslookup ran on every request indefinitely.The fix
In
ApiKeyMiddleware, the owner'sIsActiveis now resolved in the INITIALApiKeyskey lookup via a correlated projection on theUserIdFK (there is noApiKey→Usernavigation). A stale-owner key is now rejected with 401 — charging the pre-auth IP failure budget viaWriteErrorResponse— BEFORE the per-key quota charge. Consequences:UsersSELECT is removed from the auth path entirely — one folded query instead of two (happy-path perf win).Key/owner active-state is still computed in memory from projected scalar columns (
RevokedAt/ExpiresAt/owner flag), so expiry is evaluated against the same wall clock as before — nothing is pushed into SQL.OwnerIsActiveis null when the owner row is absent (hard-deleted user), so that case fails the== truegate too.Out of scope (tracked alternative, deliberately NOT implemented): key-revocation-on-deactivation.
Files changed
backend/src/Taskdeck.Api/Middleware/ApiKeyMiddleware.cs— fold owner-active resolution into the initialApiKeyslookup; reject stale-owner keys before the per-key charge; drop the separateUsersquery; add theApiKeyAuthProjectionresult type.backend/tests/Taskdeck.Api.Tests/ApiKeyMiddlewarePerKeyRateLimitTests.cs— newStaleOwnerKey_Returns401_ChargesIpFailureBudget_BeforePerKeyCharge(unit, real SQLite + command interceptor); updated the over-quota and (renamed) under-quota DB-work-counting tests to assert the single folded lookup and the absence of a standaloneUsersSELECT.backend/tests/Taskdeck.Api.Tests/McpHttpTransportApiKeyTests.cs— new integration testMcpEndpoint_StaleOwnerKey_IsRateLimitedBeforeDatabaseLookupmirroring the nonexistent-key pre-auth-budget convention: first attempt 401s, second attempt 429s viaMcpAuthenticationPerIp.Test evidence
Acceptance mapping:
AuthenticationFailedItemKeyset andApiKeyIdItemKeyNOT set (per-key never charged); integration test asserts second attempt is 429 viaMcpAuthenticationPerIp.UnderQuotaValidKey_PassesThrough_WithFoldedOwnerLookup,OverQuotaValidKey_Returns429_..., and the existing per-key-count / IP-budget tests still pass.Userslookup gone: interceptor assertions (ContainSingleSELECT, no standaloneUsersSELECT) confirm one folded query.Verification scope note: targeted filters only — the full backend suite is the coordinator's to run.