fix(rbac): bound SSE change-authorizer with a per-connection SAR memo - #1313
fix(rbac): bound SSE change-authorizer with a per-connection SAR memo#1313hisco wants to merge 3 commits into
Conversation
The SSE live-stream change authorizer built a plain closure that called canReadUser per frame. canReadUser only writes a SubjectAccessReview result back to the shared permission cache when that user's entry still exists; the SSE path primes the entry once at subscribe and it TTLs out after ~2min. From then on every qualifying change frame for a long-lived client ran a fresh, UNCACHED SAR, serially inside the single broadcast goroutine, with no timeout — one slow apiserver call stalled broadcasts for all clients, and sustained change traffic multiplied SAR load by client × (kind, namespace). Only affects auth-enabled multi-user deployments; auth-off (local kubeconfig) is unchanged. Fix: newSSEChangeAuthorizer wraps the decision in a connection-lived TTL memo (memoizedAuthorizer), so each (context, verb, group, resource, namespace) is resolved at most once per TTL instead of once per frame. Details: - The memo's base runs canReadUserSAR — a fresh, bounded SAR that BYPASSES the shared permission cache — so SSE staleness is bounded to the memo's own 2min TTL rather than stacking with the shared-cache TTL (~4min worst case). - The memo key includes the current context name. A kubeconfig context switch leaves SSE connections open (they receive a context_changed frame, not a disconnect), so without this a still-open stream could authorize new-cluster frames with the previous cluster's decisions; post-switch keys now miss and re-run against the new apiserver, mirroring the shared cache's own stamping. - If the context changes while a SAR is in flight, the result is returned but not cached, so a switch-back within the TTL can't serve a wrong-cluster decision. - The SAR runs under a bounded 5s context so a hung apiserver call can't wedge the broadcast loop. canReadUser is refactored to delegate its SAR tail to canReadUserSAR; behavior is unchanged (reads shared cache, writes back only when the entry exists). Tests: memoizedAuthorizer within-TTL memoization + past-TTL re-check, context-switch decision isolation, no-cache-on-mid-SAR-switch, and auth-off passthrough. Verified end-to-end against a kind cluster in --auth-mode proxy: a single long-lived viewer stream (no secrets RBAC) received zero Secret frames across the 2min cache-expiry boundary while still receiving new Deployment frames post-expiry. Claude-Session: https://claude.ai/code/session_01XNhMe6Zdqwj5EoBazNDnmW
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 49b8626. Configure here.
canReadUserSAR collapsed a missing client, SAR error, or the 5s timeout into the same false as a real deny, and both the SSE memo and canReadUser cached it for the full TTL — so a momentary apiserver blip would deny a (kind, namespace) on the live stream for up to 2min (and, via canReadUser's write-back, in the shared permission cache too). The pre-refactor canReadUser returned the error-false BEFORE SetCanI, so it never cached failures; the SSE refactor had inadvertently dropped that distinction. canReadUserSAR now returns (allowed, authoritative). A non-authoritative result is a fail-closed false that callers return but must not cache: canReadUser skips SetCanI, and memoizedAuthorizer skips the memo store. The next frame retries, so a transient failure drops at most the frames in flight during the blip, not a whole TTL window. Real allow/deny verdicts cache as before. Adds TestMemoizedAuthorizer_TransientFailureNotCached (failure not cached → retry re-runs base and succeeds; the subsequent authoritative allow is cached). Claude-Session: https://claude.ai/code/session_01XNhMe6Zdqwj5EoBazNDnmW
nadaverell
left a comment
There was a problem hiding this comment.
Adversarial review (self + codex cross-model) of the full branch delta. Approve — this correctly fixes the finding from the #1300 review, and strictly improves on main in every scenario.
Vetted green
- The flagged bug is closed: per-frame uncached SARs are gone; each
(context, verb, group, resource, namespace)resolves at most once per 2-min TTL per connection, and the 5s SAR bound means a hung apiserver call can no longer wedge the broadcast loop indefinitely. - Authoritative-verdict split is right: transient failures (no client, SAR error, timeout) fail closed for the in-flight frame but are never cached — in the memo or the shared cache (
canReadUserskipsSetCanIlikewise). Bugbot's finding, fixed properly in 8ec8bbc with a test. - Context-switch key scoping + mid-SAR non-caching: post-switch frames miss and re-run against the new apiserver; a switch-straddling SAR result is not cached. Both pinned by tests.
- REST behavior preserved: the
canReadUser→canReadUserSARrefactor keeps the exact same read-cache/write-back semantics on request paths. - Verified locally: new tests pass;
Authorizeis only reachable from the broadcast path; auth-off is a strict passthrough. - The PR's e2e (viewer across the TTL boundary: 0 Secret frames, Deployment frames keep flowing) demonstrates exactly the failure mode the original finding described, fixed without over-denial.
Non-blocking hardenings (fine as follow-ups; none is a regression vs main)
- Fail closed on mid-SAR context change. When
contextName() != ctxNameafterbase(), the code correctly skips caching but still returns the SAR result — a verdict possibly decided against the other cluster can release the frame. Returningfalsethere instead is 2 lines and strictly safer. (Context: SSE frames during a switch window aren't cluster-stamped anyway — the same async informer-shutdown window #1301 documents — so this narrows an existing residue rather than closing it fully.) - Short negative-result TTL. Non-authoritative results are never cached, so with a degraded apiserver every frame re-pays up to 5s per cold tuple per connection, serially in the single broadcast goroutine (N clients compound). Caching failures for ~10s would bound that to one stall per tuple per 10s while still recovering fast. This is the highest-value residual: today's worst case (unhealthy apiserver + many auth clients) can still meaningfully stall the watcher.
- Opportunistic memo eviction. Expired entries are overwritten but never removed, so the per-connection map grows with every observed context × kind × namespace tuple over the connection's lifetime. A sweep when the map exceeds a threshold (or on context switch) keeps long-lived all-namespace streams on CRD-heavy clusters bounded.
Also noting for the record: same-name context identity (reconnect/credential refresh under an unchanged context name serving memoized decisions for up to the TTL) matches the shared permission cache's existing username\x00contextName keying — consistent with the system's context-identity model, TTL-bounded, not introduced here.
Three follow-ups from the #1313 review of the SSE change-authorizer memo. All are in memoizedAuthorizer; none changes REST or auth-off behavior. 1. Fail closed on a mid-SAR context switch. When the kubeconfig context changed while base() was in flight, the memo already skipped caching the result (decided against a different apiserver than the key names) but still returned it, so a wrong-cluster verdict could release one frame. It now returns a fail-closed deny instead; the next frame re-evaluates against the new cluster. Dropping one frame during a switch is harmless — the client is about to receive a context_changed frame anyway. 2. Cache a transient SAR failure briefly instead of not at all. This REFINES 8ec8bbc, it does not revert it. That commit fixed caching failures for the full 2min TTL (which denied a readable tuple for the whole window on a blip) by never caching them. But never caching means a degraded-but-alive apiserver re-pays the 5s SAR timeout on every frame for the same tuple, serially in the single broadcast goroutine, stalling all clients. A non-authoritative result is now cached as a fail-closed deny for a short negativeTTL (10s): short enough that a momentary blip can't deny a readable tuple for the full TTL (Bugbot's concern), non-zero so a sustained outage doesn't re-SAR every frame (the stall concern). Authoritative allow/deny still caches for the full TTL. 3. Bound the per-connection memo. Entries expired but were never removed, so a long-lived all-namespace stream on a CRD-heavy cluster accumulated one entry per observed (context, verb, group, resource, namespace) tuple for the life of the connection. Past a soft cap (sseChangeAuthMemoCap), expired entries are swept before the next insert; the sweep is time-gated so a genuinely large live working set doesn't trigger an O(n) pass on every frame. Eviction logic extracted to sweepExpiredAuthMemo for direct unit testing. Tests: assert fail-closed on mid-SAR switch; rewrite the transient-failure test for brief caching (served from the negative cache within negativeTTL, re-checks and caches the recovered allow after it); add TestSweepExpiredAuthMemo. Full internal/server suite green under -race. Claude-Session: https://claude.ai/code/session_01Eyyu9N6tEELU1YgFd51J4D

Problem
Follow-up to #1300. The SSE live-stream change authorizer built a plain closure that called
canReadUserper change frame.canReadUseronly writes aSubjectAccessReviewresult back to the shared permission cache when that user's entry still exists — and the SSE path primes the entry only once at subscribe, so it TTLs out after ~2min. From then on, for every long-lived client:Effect: one slow/hung apiserver SAR stalls change broadcasts for all connected clients; sustained change traffic multiplies apiserver SAR load by client × distinct (kind, namespace). This hits exactly the deployment mode the RBAC work targets — auth-enabled, multi-user (Radar Hub), long-lived streams. Auth-off (local kubeconfig, single user) is unaffected — the authorizer short-circuits on
niluser.Flagged in the #1300 review; #1300 merged before it was addressed, so this is a standalone follow-up.
Fix
newSSEChangeAuthorizerwraps the per-frame decision in a connection-lived TTL memo (memoizedAuthorizer), so each(context, verb, group, resource, namespace)is resolved at most once per TTL instead of once per frame:canReadUserSAR— a bounded SAR that skips the shared permission cache — so SSE staleness is bounded to the memo's own 2min TTL, not stacked with the shared-cache TTL (~4min worst case).context_changedframe, not a disconnect). The memo key includes the current context name, so post-switch frames miss and re-run against the new apiserver — a still-open stream can't authorize new-cluster frames with the previous cluster's decisions. Mirrors the shared cache's own context stamping.canReadUseris refactored to delegate its SAR tail tocanReadUserSAR; its REST behavior is unchanged (reads the shared cache, writes back only when the entry exists). It deliberately still never caches a transient failure — unlike the SSE memo's brief negative cache — because the REST path runs one check per request goroutine, not serially on the shared broadcast loop, so caching a failure there would only add deny-latency with no throughput win.Tests
memoizedAuthorizer: within-TTL memoization, past-TTL re-check, context-switch decision isolation, fail-closed + no-cache on mid-SAR switch, and brief negative caching (served within the 10s window, then re-checks and caches the recovered allow for the full TTL).sweepExpiredAuthMemo: reclaims only expired entries, leaves live ones intact.newSSEChangeAuthorizerauth-off passthrough.End-to-end against a real
kindcluster in--auth-mode proxy, driving a single long-livedviewerstream (RoleBinding without secrets access) across the ~2min cache-expiry boundary:Viewer received zero Secret frames across the whole run while still receiving new Deployment frames after the shared cache expired — proving the memo carries correct per-kind filtering past the bug window without over-denying readable kinds.
Deferred (tracked, not in this PR)
Two residuals are intentionally left as follow-ups — neither is a regression vs
main, and both are gated on real-world evidence rather than built speculatively:Not pursued: gating on
SelfSubjectRulesReview(one call for all kinds) instead of per-kindSubjectAccessReview. It isn't authoritative on clusters with webhook/external authorizers (e.g. GKE IAM, which Radar Hub targets) — it returns anIncompleterule set — so it would over-deny or mismatch the real decision. Per-kind SAR is the authoritative primitive; the memo bounds its cost.https://claude.ai/code/session_01XNhMe6Zdqwj5EoBazNDnmW
Note
High Risk
Changes authorization on the live SSE broadcast path (RBAC/SAR gating); mistakes could leak change frames or over-deny, though behavior is fail-closed and covered by new tests.
Overview
Fixes long-lived SSE streams that were issuing an uncached SubjectAccessReview on every change frame after the shared permission cache expired (~2 min), serially in the single broadcast goroutine and without a timeout—stalling all clients and amplifying apiserver load.
SSE subscribe now wires
newSSEChangeAuthorizer, which memoizes per-frame(context, verb, group, resource, namespace)decisions for 2 minutes on a connection-local cache. Lookups callcanReadUserSAR(bypassing the shared cache) under a 5s context so hung SARs cannot wedge broadcasts. Memo keys include the kubeconfig context name so open streams after a context switch re-authorize against the new cluster; mid-SAR context changes fail closed and are not cached. Transient SAR failures are denied briefly (10s negative TTL) without poisoning the full TTL.canReadUseris refactored to delegate SAR tocanReadUserSAR(allowed, authoritative)and only writes authoritative verdicts back to the shared cache—same REST behavior, but transient errors are no longer memoized as long denials.Unit tests cover memo TTL, context isolation, switch-during-SAR, and negative caching.
Reviewed by Cursor Bugbot for commit 47485b3. Bugbot is set up for automated code reviews on this repo. Configure here.