Enforce MCP per-key budget before auth-stage DB work (one budget, checked once) - #1401
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the MCP per-API-key rate limiting by moving the enforcement of the McpPerApiKey budget from an endpoint-stage policy into ApiKeyMiddleware via a new McpPerApiKeyRateLimiter class. This optimization shields the database from unnecessary user lookups and last-used writes when a valid key is over quota. The changes also include comprehensive tests to verify this behavior in both co-hosted and standalone environments. The review feedback suggests using null-conditional operators when accessing context.RequestServices and context.Connection to prevent potential NullReferenceExceptions in testing or mock environments.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Adversarial Code ReviewReviewed against: double-charge/one-budget correctness, 429 contract parity, pre-auth IP budget non-regression, DI lifetime, partition-key equivalence, both-pipeline coverage. CRITICAL
HIGH
MEDIUM
LOW
Bot Comments Addressed
Summary0 CRITICAL, 0 HIGH, 3 MEDIUM (2 bot null-safety + 1 test gap), 2 LOW (1 style kept, 1 by-design note). Not merge-blocking on logic; fixing all MEDIUM findings before handoff per zero-skip policy. CI is green. |
… test disabled path
Adversarial Review — Fixes Applied
Both gemini-code-assist bot findings accepted and fixed. Verification after fixes:
Out-of-scope finding discovered during review filed as #1402 ( All findings addressed. CI status: previous run GREEN; re-running on the fix push. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8844bd1a0d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Adversarial Review — Round 2 (consolidated, two independent lenses)Two independent reviews were run against this PR: a security/resource-exhaustion lens and a pipeline-parity/regression lens. Verdict: zero substantive findings. Attacks attempted and refuted with code-path evidence:
Adjudicated dispositions (this round's fixes)
Fix evidence follows in the next comment after the batched push. |
Adversarial Review Round 2 — Fixes Applied
Verification after fixes (exact counts):
All round-2 findings addressed in one batched push ( |
Summary
Closes #1384.
The MCP per-key rate limit (
McpPerApiKey, 60/min per key) was an endpoint-stage policy that ran afterApiKeyMiddlewarehad already completed its authentication-stage database work. So a valid but over-quota key still paid, on every request before the 429: the SHA-256 hash, theApiKeyslookup, theUserslookup, and theUpdateLastUsedAsyncwrite. The advertised per-key quota bounded MCP endpoint work but not auth-stage database work — the only remaining bound on that path was the per-address pre-auth concurrency gate (parallelism capped, sequential throughput not).The one-budget design (checked once, at the earliest point)
The per-key budget is now enforced inside
ApiKeyMiddleware, immediately after theApiKeysrow is resolved and confirmed active and before theUserslookup and theUpdateLastUsedAsyncwrite:McpPerApiKeyRateLimiter— a fixed-windowPartitionedRateLimiter<HttpContext>on the samemcp-apikey:{keyId}partition and the sameMcpPerApiKeysettings as the removed endpoint policy, emitting the identical 429 contract (429,Retry-After,X-RateLimit-Policy: McpPerApiKey,application/jsonApiErrorResponse).ApiKeyMiddlewarestores the key-id item, then charges exactly once; on rejection it writes the 429 and returns before the user lookup / usage write.RequireRateLimiting(McpPerApiKey)(and the now-dead policy + partition helper) are removed, so a request that passes the early check is never charged again — one budget, checked once, no double-charge.AddTaskdeckRateLimiting(gated onEnabled), which both the co-hosted API and the standalone MCP host call — so both pipelines get identical enforcement (Fail closed on separator-only AllowedHosts in standalone MCP host security #1372 mirroring discipline).ApiKeyMiddlewareresolves it optionally, so "rate limiting disabled = no MCP throttling" is preserved.Pre-auth IP budget is not regressed (#1368/#1381)
A per-key rejection returns 429 and does not set
AuthenticationFailedItemKey, soMcpAuthenticationRateLimitingMiddlewaredoes not spend the pre-auth IP failure budget for it. Valid keys still never touch the IP budget; only 401s do. The per-address concurrency admission gate and the abort-proof failure consumption are untouched.Both pipelines covered
PipelineConfiguration+ theMcpHttpTransportApiKeyTestsintegration suite (fullTestWebApplicationFactorypipeline).Program.cs--mcp --transport httphost + a new real-host e2e that seeds a valid key into the host DB and drives the per-key 429.Tests
ApiKeyMiddlewarePerKeyRateLimitTests(unit, real SQLite + command interceptor):UsersSELECT and before theApiKeysUPDATE (asserted at the SQL boundary); noAuthenticationFailedItemKey; full 429 contract.PermitLimit(3) requests admitted, the next rejected — no double-charge.McpHttpTransportApiKeyTests.McpEndpoint_PerKeyBudget_AllowsExactlyPermitLimitRequests_ThenRejects— end-to-end boundary count through the real co-hosted pipeline (3 succeed, 4th 429 with theMcpPerApiKeycontract). The existing partition/isolation test still passes unchanged.StandaloneMcpHostFilteringTests.StandaloneMcpHttpHost_PerKeyBudget_RejectsOverQuotaValidKey— real standalone host, seeded key, per-key 429 withMcpPerApiKeyheader.Verification (exact counts)
dotnet build backend/src/Taskdeck.Api/...— succeeded, 0 warnings.--filter ApiKeyMiddlewarePerKeyRateLimitTests— 3/3 passed.--filter McpHttpTransportApiKeyTests— 49/49 passed.--filter StandaloneMcpHostFilteringTests|McpAuthenticationRateLimitingMiddlewareTests|FallbackPolicyTests— 19/19 passed.Taskdeck.Api.Testsproject — 2072 passed, 0 failed (4m49s).Out-of-scope finding (not fixed here; tracked separately)
While proving the over-quota path skips the usage write, the command interceptor showed
UpdateLastUsedAsyncnever issues itsUPDATE:SetProperty(k => k.LastUsedAt /* DateTimeOffset? */, DateTimeOffset.UtcNow /* non-nullable */)fails EF Core's SQLite translation ((DateTimeOffset?)DateTimeOffset.UtcNowis not a validSetPropertyvalue expression), and the exception is swallowed by the method's non-criticaltry/catch. SoApiKey.LastUsedAtis effectively never persisted today. This is pre-existing and unrelated to the #1384 rate-limit fix; filed as a separate issue.Intentional contract change (recorded per round-2 review, F3)
An ACTIVE key owned by an INACTIVE user now spends one per-key permit before its 401. On main, such a request died in
ApiKeyMiddlewarebefore the endpoint policy ever ran, so only the pre-auth IP failure budget was spent for it. The new behavior is stricter and consistent with this PR's goal (charge at the earliest point the key ID is known, bounding the Users lookup). The user-inactive 401 still setsAuthenticationFailedItemKey, so the IP failure budget is also still charged for it exactly as before.