Repository navigation
Cap DCR clients and expire unused ones after 24h - #6863
Conversation
/oauth/register is unauthenticated and every call persists a client. Redis had no cap, so live rows were bounded only by rate x TTL (about 2.6M per replica), and the memory store's eviction let a caller registering at full rate push out every idle real client. Implements #6861: - Cap live DCR-issued clients on both backends (default 10,000, 0 is unlimited), reject-only with a Retry-After from the oldest expiry. Configured and CIMD clients are not counted. - On Redis, count via a hash-tagged sorted set updated atomically by a Lua register script and on every write/renew path, with a startup backfill for rows written before the index existed. - Remove memory-store eviction; give DCR entries real expiry instead. - New DCR clients get a 24h TTL; the first token exchange raises it to the existing 30-day TTL through RenewClientTTL. - Make the /oauth/register rate and burst configurable. - Add a live DCR client gauge and a capacity rejection counter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixed issues from code review: - HIGH: Scan the node owning the key prefix's slot so the DCR index backfill works on Redis Cluster - MEDIUM: Skip unparseable client rows in the backfill instead of failing startup, and batch its reads per SCAN page - MEDIUM: Derive the 429 Retry-After from the configured register rate - MEDIUM: Unregister the live-client gauge callback on server Close - MEDIUM: Document that CIMD rows are not bounded by the DCR cap - LOW: Score the DCR index from the Redis server clock (TIME) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LOW: Clamp the 429 Retry-After so a tiny configured rate cannot overflow int - LOW: Document that rows written by old replicas during a rolling upgrade stay uncounted until the next backfill or their expiry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6863 +/- ##
==========================================
+ Coverage 79.70% 79.73% +0.03%
==========================================
Files 822 825 +3
Lines 83843 84154 +311
==========================================
+ Hits 66824 67102 +278
- Misses 17014 17047 +33
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: concurrency-redis, security, memory-store, config-docs, test-coverage, review-friendliness, general
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | New TIME calls are outside the documented Redis ACL |
8/10 | HIGH | Fix |
| 2 | Operator cannot set max_dcr_clients / registration_rate_limit |
9/10 | MEDIUM | Fix or document |
| 3 | In-memory backend loses its only bound on CIMD rows | 8/10 | MEDIUM | Discuss |
| 4 | Sizing relationship and default-config lockout undocumented | 9/10 | MEDIUM | Fix |
| 5 | Upgrade behaviour only in the PR body, not in docs | 8/10 | MEDIUM | Fix |
| 6 | No test that startup runs the backfill | 7/10 | MEDIUM | Fix |
| 7 | No test that registration_rate_limit reaches the handler |
7/10 | MEDIUM | Fix |
| 8 | UpsertDCRIssuedClient counted branch unreachable, bypasses cap, extra TIME |
9/10 | LOW | Fix |
| 9 | Unchecked type assertions on the register script reply | 7/10 | LOW | Fix |
| 10 | Duplicate-ID vs at-capacity precedence differs between backends | 8/10 | LOW | Fix |
| 11 | Register limiter accepts NaN/Inf; 429 Retry-After doc says exact |
8/10 | LOW | Fix |
| 12 | No test pins index/row hash-slot co-location | 8/10 | LOW | Fix |
| 13 | Exact Retry-After test is clock-dependent; gauge untested via decorators |
8/10 | LOW | Fix |
| 14 | Lock-held O(n) scan, stale comment, partial clock injection (memory) | 7/10 | LOW | Fix |
| 15 | Cap options treat negative values as unlimited | 7/10 | LOW | Fix |
| 16 | Redis deployment requirements for the index undocumented | 7/10 | LOW | Fix |
| 17 | New metrics missing from docs/observability.md |
7/10 | LOW | Fix |
| 18 | PR body overstates the Redis paths (see below; no inline comment) | 8/10 | LOW | Fix |
Rows 1-17 have an inline comment. Row 18 is described under PR description below.
Overall
This PR bounds open DCR registration without evicting anyone: a hash-tagged sorted-set index, a single Lua script that trims, checks the cap and writes the row plus its index entry, scores taken from Redis TIME so replica clock skew cannot desync the index from key expiry, and matching expiry rules on the memory store. The Redis atomicity, Cluster key co-location, unit handling (ms scores against PX), renew/replace/reconcile index maintenance and backfill idempotency all held up under review, and every acceptance criterion in the linked issue has an implementation and at least one test.
One finding should block merge: the new server-side TIME calls are not covered by the ACL that docs/redis-storage.md and the operator examples tell users to create, so on an ACL-restricted deployment registration, renewal and the startup backfill would fail (row 1). The remaining findings are mostly gaps around the change rather than in it: the operator cannot set the new knobs, so the documented upgrade remedy is unavailable there (row 2); the sizing formula and the 24h default-config lockout the issue thread asked to document are missing (rows 4 and 5); two wiring links, the startup backfill call and the registration_rate_limit plumbing, have no test (rows 6 and 7); and the memory store's CIMD rows lose their only bound with no linked follow-up (row 3).
The integration-tagged Redis tests have not been run, per the PR description, and they use unrestricted users, so they would not have caught row 1 either. They should pass in CI before merge.
PR description
The Summary and Changes table say there are "Lua scripts for capped register, renew, replace and delete" and that "RenewClientTTL, the replace path, and delete all keep the index in step". In the diff only register, renew and count are Lua scripts; the replace path (UpsertDCRIssuedClient) and ReconcileConfiguredClient are WATCH/MULTI transactions, and RedisStorage has no client-delete path, so that requirement is vacuous rather than implemented. Correcting the wording avoids reviewers hunting for scripts and a delete path that are not there. The PR also links the CIMD-bounding and operator-CRD follow-ups only in prose; tracking issues would make those accepted trade-offs checkable.
Documentation
docs/arch/11-auth-server-storage.md:308and:322: sizing formula, default-config lockout, 30-day promotion and drain caveats, and upgrade notes (rows 4 and 5).docs/redis-storage.md:255,263andexamples/operator/redis-storage/*.yaml: ACL needs+@fastor+time(row 1);:334: hash-tag and eviction requirements (row 16).docs/observability.md: add the two new auth-server metrics (row 17).
Generated with Claude Code
Redis TIME is in the @fast ACL category, which the documented auth-server ACL does not grant, so ACL-restricted deployments would fail startup and every registration after upgrading. Index scores now come from the replica clock, passed to the scripts as an argument. Addresses #6863 review comments: - HIGH pkg/authserver/storage/redis.go (4317000776): no TIME calls outside the documented ACL - LOW pkg/authserver/storage/redis.go (4317000835): UpsertDCRIssuedClient refuses counted clients - LOW pkg/authserver/storage/redis.go (4317000843): check register script reply types - LOW pkg/authserver/storage/redis.go (4317000882): report an existing ID before the cap - LOW pkg/authserver/storage/redis_dcr_cap_test.go (4317000904): pin index/row hash-slot layout - LOW pkg/authserver/storage/memory.go (4317000933): reject negative cap options - LOW docs/redis-storage.md (4317000942): document hash tag and eviction requirements Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The in-memory store lost its only bound on CIMD rows when they left the DCR cap, so they get a separate cap. The limiter now rejects NaN and rate.Inf, and new tests pin that startup runs the index backfill and that registration_rate_limit reaches /oauth/register. Addresses #6863 review comments: - MEDIUM pkg/authserver/storage/memory.go (4317000790): cap in-memory CIMD rows - MEDIUM pkg/authserver/server_impl.go (4317000814): test that startup runs the backfill - MEDIUM pkg/authserver/runner/embeddedauthserver.go (4317000827): test registration_rate_limit wiring - LOW pkg/authserver/server/handlers/handler.go (4317000894): reject NaN/Inf rates, fix Retry-After doc - LOW pkg/authserver/server/handlers/dcr_cap_test.go (4317000916): clock-tolerant Retry-After, gauge via decorator - LOW pkg/authserver/storage/memory.go (4317000926): bounded scans, one clock read, explicit put helpers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses #6863 review comments: - MEDIUM pkg/authserver/storage/config.go (4317000784): state the settings are RunConfig-only, track CRD fields in #6864 - MEDIUM docs/arch/11-auth-server-storage.md (4317000799): document sizing formula and default-config lockout - MEDIUM docs/arch/11-auth-server-storage.md (4317000803): add upgrade notes, mirrored in docs/redis-storage.md - LOW docs/arch/11-auth-server-storage.md (4317000956): add the DCR metrics to docs/observability.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review row 18 (PR description): fixed. The body now describes the Redis paths as they are. Only register and renew are Lua scripts. ReconcileConfiguredClient maintains the index inside its WATCH/MULTI transaction. UpsertDCRIssuedClient refuses counted clients. There's no client-delete path. The operator CRD follow-up is tracked in #6864, and the in-memory CIMD bound is now implemented in d98fe26 rather than left as a follow-up. |
|
I checked the follow-up commits (5fb2bf9, d98fe26, b25181b) against each finding in the earlier review and resolved all 17 threads. Highlights:
Still open before merge:
The lint, unit-test, swagger and codegen checks were still running when I looked, so I haven't seen their results. Generated with Claude Code |
Addresses #6863 review comments: - MEDIUM sdk/go/client/client_test.go:225 (issuecomment-6274488433): count the new RegistrationRateLimitRunConfig schema (203) - MEDIUM pkg/authserver/storage/redis_migrate.go:418,489 (issuecomment-6274488433): spell "unparsable" for codespell Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to #6863 (comment):
Generated with Claude Code |
jhrozek
left a comment
There was a problem hiding this comment.
A few operational notes on the cap, the backfill and the upgrade path. None of them block this from my side.
A failed backfill only leaves the cap undercounting older rows, which is how the server behaved before the cap, so it no longer stops startup. The backfill now logs what it did and warns when existing clients already fill the cap, so operators can see why registrations fail from the first request. Addresses #6863 review comments: - MEDIUM pkg/authserver/server_impl.go (4318839163): log a backfill failure at WARN and continue; log rows indexed and duration - MEDIUM pkg/authserver/storage/redis_migrate.go (4318839143): WARN once at startup when the count is at or above max_dcr_clients Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cap rejects without evicting, and without telemetry the first sign was users getting a 503. Rejections now log one WARN at most every 15 minutes with the cap, the live count, Retry-After and how many rejections were held back, so the volume is bounded by time rather than by the unauthenticated caller. ClientCapacityError carries the cap that was reached for that line. Addresses #6863 review comments: - MEDIUM pkg/authserver/server/handlers/dcr.go (4318839156): rate-limited WARN on cap rejection; alerting guidance in docs/observability.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jhrozek
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround on the operational notes. The cap, the backfill and the upgrade path all look good to me.
Resolve conflicts with the consent-exempt clients change (#6882) by keeping both sides: Config.Validate runs both the client admission and the issuer/consent checks, and buildHandlerOptions wires both the register rate limit and the consent-exempt client IDs. NewHandler moves the consent-exemption setup into a helper to stay under the gocyclo limit, and runs it before registering the DCR metrics so a validation failure cannot leave a gauge callback behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Operator-managed deployments, the main multi-replica Redis audience, could not set the DCR client cap or the /oauth/register rate limit added in #6863, so a Redis deployment already over the default cap had no way to raise it before upgrading. Implements changes for issue #6864: - Add storage.maxDCRClients (minimum 0) and registrationRateLimit to the embedded auth server config. The rate limit reuses the CRD's RateLimitBucket (maxTokens, refillPeriod) rather than a float field. - Convert both in BuildAuthServerRunConfig. A memory storage config is now emitted when it sets a cap; a non-positive refillPeriod is rejected as an invalid config. - Regenerate CRDs, API docs and the swagger/SDK descriptions, and drop the "not yet settable through the operator" notes from the docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Expose DCR client cap and register rate in CRD Operator-managed deployments, the main multi-replica Redis audience, could not set the DCR client cap or the /oauth/register rate limit added in #6863, so a Redis deployment already over the default cap had no way to raise it before upgrading. Implements changes for issue #6864: - Add storage.maxDCRClients (minimum 0) and registrationRateLimit to the embedded auth server config. The rate limit reuses the CRD's RateLimitBucket (maxTokens, refillPeriod) rather than a float field. - Convert both in BuildAuthServerRunConfig. A memory storage config is now emitted when it sets a cap; a non-positive refillPeriod is rejected as an invalid config. - Regenerate CRDs, API docs and the swagger/SDK descriptions, and drop the "not yet settable through the operator" notes from the docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Validate registration refill period at admission A zero or negative refillPeriod was only caught at reconcile time. A CEL rule on registrationRateLimit now rejects it when the resource is created, and the Go check stays as a backstop for objects admitted before the rule existed. The rule sits on the field rather than the shared RateLimitBucket type, so MCPServer rate limits are unchanged. Envtest cases cover the new admission rules for both fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name CRD fields in registration rate errors Addresses #6867 review comments: - LOW cmd/thv-operator/pkg/controllerutil/authserver.go (4319701380): describe the refillPeriod check as defense in depth - LOW cmd/thv-operator/pkg/controllerutil/authserver.go (4319701407): check maxTokens and wrap RunConfig validation errors with the CRD field name - LOW cmd/thv-operator/test-integration/mcp-external-auth/dcr_cap_validation_test.go (4319701411): cover VirtualMCPServer admission and vMCP conversion - LOW cmd/thv-operator/pkg/controllerutil/authserver_dcr_cap_test.go (4319701415): use the package's k8sptr alias Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document DCR cap upgrade order and rate examples Addresses #6867 review comments: - MEDIUM docs/arch/11-auth-server-storage.md (4319701371): spell out the operator-crds, field, operator upgrade order and drop upgrade advice from the CRD field text - LOW cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go (4319701419): state that maxDCRClients 0 removes all protection and is meant to be temporary - LOW cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go (4319701422): add a worked rate/burst example and a sample manifest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
/oauth/registeris unauthenticated, and every successful call persists a client row. On Redis nothing capped those rows, and a client that was never used lived 30 days, so the row count was limited only by rate × TTL: about 2.6M rows per replica. The memory store did have a cap, but enforced it by evicting idle clients, so anyone registering at the full rate could push out real clients and break their refresh tokens without the operator seeing it. This PR limits how much open registration can cost on both backends without evicting anyone.RegisterClientreturnsErrClientCapacityand writes nothing. The 503Retry-Afteris the number of seconds until the oldest counted client expires. On Redis, a sorted set of DCR client IDs scored by expiry time is the count, and it lives under the same hash-tagged prefix as the client rows. One Lua script trims expired entries, checks the cap, and writes the row together with its index entry, so the cap holds across replicas. A second script renews a row's TTL and its index score together, andReconcileConfiguredClientdrops any stale index entry for its ID in the same WATCH/MULTI transaction as the row.UpsertDCRIssuedClient(the CIMD path) refuses counted clients, so it can't bypass the cap. Redis storage has no client-delete path, so there is nothing to keep in step there.storage.max_dcr_clients.DefaultUnusedDCRClientTTL(24h). The existingRenewClientTTLcall on the first successful token exchange raises it to 30 days. This lowers the worst case to about 86,400 rows per replica. The memory store now uses the same expiry rules, so a full map drains on its own.minClientAgeandDefaultMinClientAgeare removed.registration_rate_limit(requests_per_second,burst) replaces the hardcoded 1/s burst 5, which stays the default.Retry-Afterand the number of rejections held back. The volume is bounded by time rather than by the unauthenticated caller.Closes #6861
Type of change
Test plan
Unit tests (
task test)E2E tests (
task test-e2e)Linting (
task lint-fix)Manual testing (describe below)
Unit tests: full
task testpassed exceptpkg/container/dockerTestCreateNetwork_ConcurrentSameName. That test is unrelated and fails locally because Docker reports "all predefined address pools have been fully subnetted". CI'sTests / Test Go Codejob passes on 040b16a, along with every other check, including the E2E and operator suites.Linting:
task lintpasses.Integration: ran the
integration-tagged Redis tests inpkg/authserver/storage/redis_dcr_cap_integration_test.golocally againstredis:7-alpine(Sentinel, via testcontainers), on the current scripts.TestIntegration_DCRCappasses all four subtests: rejection at the cap with no row written, two storage instances racing never exceeding the cap, TTL and index score following registration and first use, and backfill. Slot release on expiry is covered by the miniredis unit tests (TestRedisStorage_DCRCap_FreesOnExpiry), not the integration tests. No CI workflow runstask test-integrationfor this package, so this local run is the only one against real Redis.Unrelated failure in the same package:
TestIntegration_ClientAssertionJWT/known_JTI_is_invalidfails for a reason that has nothing to do with this PR: the test, which this PR does not touch, sets a 1h assertion lifetime, and Add private_key_jwt DCR client authentication #6427 capped lifetimes at 5m.Changes
pkg/authserver/storage/redis.go,redis_keys.goReconcileConfiguredClient;UpsertDCRIssuedClientrefuses counted clients;CountDCRClientspkg/authserver/storage/redis_migrate.gopkg/authserver/storage/memory.go,cimd_decorator.goCountDCRClientspkg/authserver/storage/dcr_cap.go,types.go,config.goClientCapacityErrorcarrying Retry-After,DCRClientCounter, new TTL/cap defaults,max_dcr_clientspkg/authserver/server/handlers/dcr.go,handler.go,token.gopkg/authserver/server/handlers/dcr_metrics.go,dcr_capacity_warner.gopkg/authserver/config.go,server_impl.go,runner/embeddedauthserver.goregistration_rate_limitconfig, validation, and plumbingdocs/arch/11-auth-server-storage.md,docs/redis-storage.md,docs/observability.mddocs/server/*,sdk/go/*task docs*_test.goDoes this introduce a user-facing change?
Yes.
storage.max_dcr_clients(0= unlimited). At the cap,/oauth/registerreturns503withserver_errorand aRetry-Afterequal to the seconds until the oldest counted client expires. A deployment that already holds more than 10,000 live DCR clients will reject new registrations right after upgrading until the count drops below the cap. To avoid this, raisemax_dcr_clientsor set it to0before upgrading. The operator CRD fields come in a stacked PR for Expose DCR client cap and register rate limit in the operator CRD #6864, planned for the same release. At startup, the server logs a WARN when existing clients already fill the cap. Backfilled rows keep their current TTL of up to 30 days. No Redis ACL change is needed: the scripts use only commands the documented ACL already grants.Retry-After: 3600only when no client was old enough. After: always rejects without evicting, andRetry-Afteris the time until the oldest counted client expires. CIMD rows are no longer counted against that cap; they get their own cap of 10,000 instead, and past it the best-effort CIMD write-through skips the row.registration_rate_limit(requests_per_second,burst); unset keeps 1/s burst 5. The limiter is per process, so the effective rate is the configured rate × the replica count. The429Retry-Afteris nowceil(1/rate)instead of a fixed1, which gives the same value at the default rate.toolhive_authserver_dcr_clientsand countertoolhive_authserver_dcr_capacity_rejections.DCR client cap reached; rejecting new client registrations, at most once every 15 minutes while registrations are being rejected. On Redis, also one line at startup when the backfilled count is already at or above the cap, and one if the backfill fails.Special notes for reviewers
docs/server/*andsdk/go/*, fromtask docs).TIME.TIMEis in the@fastACL category, which the documented auth-server ACL doesn't grant, so using it would break ACL-restricted deployments on upgrade. Clock skew between replicas only shifts when an index entry is trimmed relative to its row's expiry.UpsertDCRIssuedClientrefuses counted clients on both backends. Its only caller is the CIMD decorator, and counted clients have to go throughRegisterClient, which enforces the cap.max_dcr_clientsandregistration_rate_limit(Expose DCR client cap and register rate limit in the operator CRD #6864), as a stacked PR for the same release; the same Redis Cluster SCAN flaw in the existing legacy upstream migrations inredis_migrate.go(not filed).🤖 Generated with Claude Code