Skip to content

Wire per-user identity into rate limit middleware#4718

Merged
jerm-dro merged 10 commits intomainfrom
jerm-dro/per-user-rate-limit-middleware
Apr 10, 2026
Merged

Wire per-user identity into rate limit middleware#4718
jerm-dro merged 10 commits intomainfrom
jerm-dro/per-user-rate-limit-middleware

Conversation

@jerm-dro
Copy link
Copy Markdown
Contributor

@jerm-dro jerm-dro commented Apr 9, 2026

Summary

The rate limit middleware was passing an empty string for userID, meaning per-user buckets (added in #4704) were never checked. This wires identity.Subject from the auth context into the limiter so per-user rate limits are enforced at runtime.

  • Extract auth.IdentityFromContext() in rateLimitHandler and pass identity.Subject as the userID to limiter.Allow()
  • Hoist the parameterized mock OIDC server from virtualmcp/helpers.go into the shared testutil package for reuse across test suites
  • Add E2E acceptance test: deploy MCPServer with perUser rate limit + inline OIDC auth, verify user-a is rejected after limit, user-b succeeds independently

Closes #4550

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • E2E tests (task test-e2e) — per-user rate limit with mock OIDC auth

Changes

File Change
pkg/ratelimit/middleware.go Extract identity from context, pass Subject to Allow()
pkg/ratelimit/middleware_test.go Add tests for identity passthrough and unauthenticated fallback
test/e2e/thv-operator/testutil/oidc.go New: hoisted parameterized mock OIDC server from virtualmcp
test/e2e/thv-operator/virtualmcp/helpers.go Delegate to testutil, remove duplicated code
test/e2e/thv-operator/acceptance_tests/helpers.go Add SendAuthenticatedToolCall, GetOIDCToken helpers
test/e2e/thv-operator/acceptance_tests/ratelimit_test.go Add per-user rate limit E2E test (AC11, AC12)

Does this introduce a user-facing change?

Per-user rate limits configured via rateLimiting.perUser are now enforced at runtime. Previously the CRD field was accepted but the middleware did not use the authenticated user's identity for rate limiting.

Special notes for reviewers

  • The production code change is 7 lines in middleware.go — the identity extraction and passthrough. Everything else is tests and test infrastructure.
  • When no identity is in context (unauthenticated request), userID remains empty and per-user buckets are silently skipped. This is the correct behavior: per-user limits require auth, and the CEL rule + reconciler condition from PR 1 enforce that.
  • The mock OIDC server hoist is a refactor with no behavior change — virtualmcp now delegates to testutil.DeployParameterizedOIDCServer.

Generated with Claude Code

@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Apr 9, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 9, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.88%. Comparing base (a7f347f) to head (026252d).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4718      +/-   ##
==========================================
+ Coverage   68.82%   68.88%   +0.05%     
==========================================
  Files         516      516              
  Lines       54153    54156       +3     
==========================================
+ Hits        37272    37304      +32     
+ Misses      14018    13989      -29     
  Partials     2863     2863              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Apr 9, 2026
@jerm-dro jerm-dro marked this pull request as ready for review April 10, 2026 02:43
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core production change is correct and well-tested, but the hand-written mock added in this PR explicitly violates the project rule in .claude/rules/testing.md — worth a human look before merging.

Extended reasoning...

Overview\nThis PR fixes per-user rate limiting by wiring identity.Subject into rateLimitHandler (7 lines in middleware.go). The bulk of the diff is test infrastructure: hoisting the parameterized OIDC server into testutil, adding unit tests for identity passthrough, and a new E2E acceptance test for AC11/AC12.\n\n### Security risks\nRate limiting is security-adjacent but not auth itself. The change only reads an already-authenticated identity from context — it does not bypass or weaken any auth check. The fail-open behavior for missing identity (empty userID, skips per-user bucket) matches the documented design intent. No new security risks introduced.\n\n### Level of scrutiny\nThe production change is minimal and mechanical. However, the gomock convention violation (recordingLimiter hand-written mock in middleware_test.go) is an explicit project rule breach added in this PR — not grandfathered legacy. This warrants a human decision on whether to accept the deviation or require a generated mock before merging.\n\n### Other factors\nThe dead SendAuthenticatedToolCall export is a minor cleanliness issue in test helpers with no functional impact. Codecov reports all modified lines covered. No prior human or bot reviews on this PR.

Comment thread test/e2e/thv-operator/acceptance_tests/helpers.go Outdated
Comment thread pkg/ratelimit/middleware_test.go
jhrozek
jhrozek previously approved these changes Apr 10, 2026
Copy link
Copy Markdown
Contributor

@jhrozek jhrozek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good -- the 4-line middleware change is clean and correct, identity extraction is idiomatic, unit tests cover both branches, and the E2E test infrastructure is solid. A few nits inline.

Comment thread test/e2e/thv-operator/acceptance_tests/helpers.go Outdated
Comment thread pkg/ratelimit/middleware.go
Comment thread test/e2e/thv-operator/acceptance_tests/helpers.go Outdated
@jerm-dro
Copy link
Copy Markdown
Contributor Author

Thanks @jhrozek! All addressed in 8cf6cd8:

  1. Removed SendAuthenticatedToolCall — dead code, SendAuthenticatedToolCallWithSession handles the no-session case with empty sessionID.

  2. Added empty-userID design comment — documented in middleware.go that unauthenticated requests skip per-user buckets and only hit shared limits, with CEL as the primary gate.

  3. Accept ctx in all HTTP helpersSendToolCall, SendInitialize, SendAuthenticatedToolCallWithSession, and GetOIDCToken now accept context.Context so the framework can cancel on suite timeout.

Re: the gomock convention — the recordingLimiter is 10 lines and captures two arguments. A generated mock + DoAndReturn for this would be heavier infrastructure for the same result. Happy to convert if you feel strongly, but leaving it for now alongside the pre-existing dummyLimiter.

@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Apr 10, 2026
@jhrozek
Copy link
Copy Markdown
Contributor

jhrozek commented Apr 10, 2026

looks like you need a rebase.

jerm-dro and others added 4 commits April 10, 2026 08:41
Move DeployParameterizedOIDCServer and its Python script from the
virtualmcp helpers into the shared testutil package so acceptance
tests can reuse it for per-user rate limiting E2E tests.

The virtualmcp package retains a thin wrapper that delegates to
testutil for backwards compatibility.

Part of #4550

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract identity.Subject from the auth context and pass it as
the userID argument to limiter.Allow(). When no identity is
present (unauthenticated requests), userID remains empty and
per-user buckets are silently skipped.

This connects PR 1's per-user limiter logic to the middleware
so that per-user rate limits are enforced at runtime.

Part of #4550

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Deploy MCPServer with perUser rate limit (maxTokens=2) and inline
OIDC pointing to the parameterized mock OIDC server. Verify:
- user-a is rejected after exceeding per-user limit (HTTP 429)
- JSON-RPC error -32029 with retryAfterSeconds in response body
- user-b succeeds independently (per-user bucket isolation)

Add SendAuthenticatedToolCall and GetOIDCToken helpers for
authenticated E2E request patterns.

Closes #4550

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Return Retry-After header from SendAuthenticatedToolCall and
  assert it is non-empty on 429 responses (AC12 was not actually
  verified)
- Deploy Redis independently in per-user test block to avoid
  ordering dependency on the shared rate limit test's AfterAll
  cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jerm-dro and others added 6 commits April 10, 2026 08:41
The per-user and shared rate limit test blocks run in parallel in
Ginkgo, so both calling DeployRedis causes an "already exists"
conflict. Add EnsureRedis that checks for an existing deployment
before creating, and use it in the per-user block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both Describe blocks run their BeforeAll concurrently in Ginkgo,
so both must use the idempotent EnsureRedis instead of DeployRedis
to avoid "already exists" conflicts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ginkgo runs top-level Ordered Describe blocks with concurrent
BeforeAll, so both rate limit test blocks race to create Redis.
Replace the check-then-create pattern (TOCTOU) with create that
tolerates AlreadyExists on both Deployment and Service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
With OIDC auth enabled, the proxy enforces MCP session lifecycle —
tools/call requires a prior initialize handshake. Add SendInitialize
helper that returns the Mcp-Session-Id, and
SendAuthenticatedToolCallWithSession that includes both Bearer
token and session ID headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restructure into a single Ordered Describe with nested Context
blocks for shared and per-user tests. Redis is deployed once in
the outer BeforeAll and cleaned up once in the outer AfterAll.
This eliminates the concurrent BeforeAll race and ensures Redis
is available before either MCPServer is created.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove dead SendAuthenticatedToolCall helper — superseded by
  SendAuthenticatedToolCallWithSession which handles the no-session
  case when sessionID is empty
- Accept context.Context in all HTTP test helpers (SendToolCall,
  SendInitialize, SendAuthenticatedToolCallWithSession, GetOIDCToken)
  so the framework can cancel in-flight requests on suite timeout
- Add comment documenting the empty-userID design assumption: when
  no identity is present, only shared rate limits apply and CEL
  validation is the primary gate ensuring perUser requires auth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jerm-dro jerm-dro force-pushed the jerm-dro/per-user-rate-limit-middleware branch from 8cf6cd8 to 026252d Compare April 10, 2026 15:44
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Apr 10, 2026
@jerm-dro jerm-dro requested a review from jhrozek April 10, 2026 15:45
@jerm-dro jerm-dro merged commit 3d7b916 into main Apr 10, 2026
67 of 69 checks passed
@jerm-dro jerm-dro deleted the jerm-dro/per-user-rate-limit-middleware branch April 10, 2026 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configure per-user rate limits on MCPServer

3 participants