Skip to content

Commit ea10adc

Browse files
authored
feat: probe-based informer gating + per-user RBAC for namespace picker, REST, and MCP (#609)
Per-user RBAC across the cache, the namespace picker, REST, and MCP. Three changes that grew together once live testing showed they had to ship as one to be coherent. Each typed kind now goes cluster-wide, namespace-scoped, or off based on a real `list?limit=1` probe against the SA / kubeconfig identity — not an SSAR. List probes are authoritative because they ARE the operation the informer will perform; SSAR is one indirection too many and disagrees with reality on clusters using webhook authorizers (GKE IAM in particular). Mixed-scope users keep the access they have, with listers routed to the matching factory; dynamic/CRD watches probe cluster-wide first and only fall back per-GVR after a real 403. Reflector 403/Unauthorized errors now surface in diagnostics so RBAC drift is visible instead of looping silently. Adds Role, ClusterRole, RoleBinding, ClusterRoleBinding to the probe/cache path with informers, listers, counts, and handlers — closes the access-control blind spot in the typed cache. The header switcher used to call `PerformNamespaceSwitch`, which tore down and rebuilt the process-wide informer cache scoped to the picked namespace. In any shared deploy, one logged-in user could narrow every other user's view in real time. The cache is never reshaped by user action. The pick is stored as a per-user preference (`Server.nsPreferences`, keyed by username + context) and intersected into reads on the way out: - Switch is ~1s instead of 5–30s — no informer rebuild - One user's pick doesn't affect anyone else's view - Cluster-only kinds (Nodes, PVs, StorageClasses, etc.) stay visible for cluster-admins regardless of the pick — they have no namespace dimension to filter on - Local single-user mode still persists picks across restarts via `settings.ActiveNamespaces` - On context switch, all users' picks are dropped — they reference the previous cluster's namespaces Every read path now agrees on the same boundary the apiserver would enforce. - **MCP read tools** (`list_resources`, `get_resource`, `get_topology`, `get_events`, `get_pod_logs`, `list_namespaces`, `get_changes`, `get_workload_logs`, `get_audit`, `get_dashboard`) intersect requested namespaces with the user's RBAC-allowed set. New helper in `internal/mcp/permissions.go`. MCP write tools / exec / logs continue to impersonate via `DynamicClientFromContext`, so the apiserver enforces full RBAC there directly. - **Picker source** (`/api/cluster/namespace-scope.accessibleNamespaces`) returns only what the calling user can access; `set-active-namespace` 403s when the pick isn't in that set (alice picking bob's namespace → 403, not silent empty results). - **REST cluster-scoped reads** (`/api/resources/{nodes,namespaces,…}`, `/api/metrics/top/nodes`, `/api/nodes/.../{metrics,history}`, dashboard CRDs) gate per-kind via `canRead` SAR. Cluster-wide pod visibility does NOT imply cluster-scoped reads — that's the load-bearing distinction (a user with `list pods` everywhere still can't read Nodes / Namespace metadata / cluster-scoped CRDs / ClusterRoles). - **Namespace metadata** specifically requires `list/get namespaces` SAR — `AllowedNamespaces` (which is populated from `list pods` cluster-wide) is not a sufficient fallback. Restricted users keep the picker via the synthesized `{name, status}` view at `/api/namespaces` and the `list_namespaces` MCP tool. - **Topology** strips cluster-scoped node kinds (Karpenter NodePool/NodeClaim/NodeClass, GatewayClass, PV, StorageClass, ClusterNetworkPolicy, CiliumClusterwideNetworkPolicy) per-user via SAR, in both REST `handleTopology` and MCP `handleGetTopology`. The strip table is a denylist — it covers every cluster-scoped kind the topology builder creates that we identified in review, and the maintenance signal lives on `topology.NodeKind` so new kinds get caught. Architectural follow-up (per-node GVR/scope metadata so the strip is scope-driven) is non-blocking. - **Cross-cluster authorization race closed.** `permCache` entries are stamped with the K8s context name at write time; `Get` returns nil when the stamp doesn't match the current context, so an old-cluster `canI` cache result can't authorize a new-cluster read in the window between context switch and cache invalidation. `s.finalizePostContextSwitch` runs as an `OnContextSwitch` callback so the invalidation order is consistent across REST and MCP. Per-kind RBAC inside a namespace ("can list pods but not secrets in `team-a`") is deliberately deferred to a follow-up. Same boundary REST already enforced; fits 90% of K8s RBAC setups in practice. User-controlled strings (`X-Forwarded-User`, URL-derived kind/group/resource, kubeconfig fields) flow into `log.Printf` along several auth paths. Sanitized via `k8s.SanitizeForLog` (CRLF strip — CodeQL doesn't model `%q` escaping). Closes the four open CodeQL `go/log-injection` alerts on this PR plus the sister sites in impersonation, namespace discovery, and SAR error paths. Three layers, in order of rigor: 1. **Unit** (`go test ./internal/...`, `cd pkg && go test ./...`) — probe scenarios incl. forced-namespace cluster-only kinds, filter primitives, cluster-only kind detection, topology strip semantics with nil/empty edge cases, namespace SAR gates, cross-user `canI` cache isolation, cross-cluster permCache invalidation. 2. **Handler integration** (`internal/mcp/tools_filter_test.go`) — each MCP read handler exercised with fake K8s + `InitTestResourceCache` against no-auth / cluster-admin / namespace-restricted user contexts; asserts result content matches expected per-user filtering. Catches regressions where new read tools forget to filter. 3. **Live multi-user RBAC** (`scripts/test-rbac.sh`) — boots Radar with `--auth-mode=proxy`, applies test RoleBindings for five users (alice, bob, carol, dave, erin) covering namespace-scoped, cluster-wide-pods-only, multi-namespace, and nodes-only RBAC shapes, verifies REST + MCP both filter per-user, picker is per-user, cluster-only kinds hidden, denied namespaces rejected, cluster-scoped CRDs gated via discovery. 24/24 pass on a kind cluster. RBAC cleaned up on exit. Run with `--yes` to skip the safety prompt. Visual layer covered separately against a real cluster (#655 tracks three UX polish items found during that pass — none security-relevant). `cd web && npm run tsc` clean. - `CLAUDE.md`: K8s Caching section rewritten for the new model (per-user filter, MCP RBAC parity, where state lives). - `docs/configuration.md`: namespace picker described as a per-user view filter; doesn't affect other users. - `docs/in-cluster.md`: voluntary scoping note plus an explicit note that MCP enforces the same RBAC boundary as REST; pod SA is the ceiling for both. - `docs/mcp.md`: "RBAC-aware" rewritten to be specific about each deploy shape (local kubeconfig / in-cluster auth / in-cluster no-auth). Removed misleading "Local-only" line. - Mirrored in `radar-docs` (PR skyhook-dev/radar-docs#8).
1 parent 22ee2f5 commit ea10adc

52 files changed

Lines changed: 5792 additions & 762 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,10 @@ If a UI change feels worth checking, mention it when you wrap up — even just f
334334
- Memory-efficient with field stripping (removes managed fields, last-applied annotations)
335335
- Change notifications via channel for real-time SSE updates
336336
- Application-specific behavior injected via `CacheConfig` callbacks: `OnChange`, `OnEventChange`, `OnReceived`, `OnDrop`, `ComputeDiff`, `IsNoisyResource`
337+
- **Per-kind scope decisions** via `CacheConfig.ResourceScopes` — each kind can independently be cluster-wide, namespaced, or disabled based on what the SA can list. `pkg/k8score/cache.go`'s `pickFactory` routes each informer to the matching factory; cluster-only kinds (Nodes, Namespaces, PVs, StorageClasses, IngressClasses) always use the cluster-wide factory regardless of caller intent
338+
- **Probe-based RBAC gating** (`internal/k8s/capabilities.go`): at startup, Radar runs a real list call against each typed kind (using the SA / kubeconfig identity) to decide if it goes cluster-wide, namespace-scoped, or off. List probes are authoritative because they ARE the operation the informer will perform — SSAR is one indirection too many and can disagree with reality on clusters using webhook authorizers (e.g. GKE IAM)
339+
- **In-app namespace switcher = per-user view filter**: the header's `NamespaceSwitcher` POSTs to `/api/cluster/namespace`, which the server stores as a per-user preference in `Server.nsPreferences` (key: `username\x00contextName`). It does NOT mutate the shared cache. The pick is intersected with the user's RBAC-allowed namespaces on every read in `parseNamespacesForUser` (REST) and `filterNamespacesForUser` (MCP). For the no-auth/local case, the pick persists across restarts via `settings.ActiveNamespaces` and is loaded lazily on first request. On context switch, all users' picks are dropped — they reference the previous cluster's namespaces
340+
- **Per-user RBAC filtering** (auth enabled): namespaced reads filter via `parseNamespacesForUser``getUserNamespaces``auth.DiscoverNamespaces` (SubjectAccessReview-based, "list pods" / "list deployments" sentinel). Cluster-scoped reads gated per-kind via `Server.canRead` / MCP `canReadClusterScopedKind` — both run a SAR for the exact (group, resource, verb) and cache on `UserPermissions.canI`. Cluster-wide pod visibility does NOT imply cluster-scoped reads; this is the load-bearing security distinction. Static cluster-only kinds map via `k8s.ClusterOnlyKindGVR`; dynamic CRDs use discovery's `GetResourceWithGroup`. MCP write tools / exec / logs impersonate via `DynamicClientFromContext`, so the apiserver enforces full RBAC there directly
337341
- Supports: Pods, Services, Deployments, DaemonSets, StatefulSets, ReplicaSets, Ingresses, IngressClasses, ConfigMaps, Secrets, Events, Jobs, CronJobs, HorizontalPodAutoscalers, PersistentVolumeClaims, PersistentVolumes, StorageClasses, PodDisruptionBudgets, ServiceAccounts, Nodes, Namespaces
338342

339343
### Server-Sent Events (SSE)

docs/configuration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ Radar supports switching between Kubernetes contexts at runtime through the UI.
109109

110110
When running in-cluster (using the pod's service account), context switching is disabled.
111111

112+
## Namespace Picker
113+
114+
The header has a namespace picker on the right. Pick a single namespace to focus the view, or **All namespaces** to see everything you have access to. Cluster-scoped resources (Nodes, Namespaces, PVs, StorageClasses) appear regardless of the pick if your RBAC permits them — they have no namespace to filter on. Namespace-restricted users without their own cluster-scoped RBAC won't see cluster-scoped sections at all.
115+
116+
The pick is a per-user view filter — it doesn't change anything for other users sharing the same Radar instance. Locally, your pick is remembered per kubeconfig context across restarts. In shared (auth-enabled) deployments the pick lives for the session.
117+
112118
## Related Documentation
113119

114120
- [README](../README.md#usage) — CLI flags and basic usage

docs/in-cluster.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,14 +179,16 @@ rbac:
179179
180180
### Graceful RBAC Degradation
181181
182-
Radar works with whatever permissions are availableit does not require full cluster-admin access. At startup, Radar checks which resource types are accessible using `SelfSubjectAccessReview` and only starts informers for permitted resources.
182+
You see what you have access toRadar doesn't require cluster-admin. Whatever your ServiceAccount (or the impersonated user, when auth is enabled) can list, Radar shows. Resource types you can't list show an "Access Restricted" message; namespaces you can't access don't appear.
183183
184-
**What this means in practice:**
184+
A namespace-scoped ServiceAccount (RoleBinding without a ClusterRole) is fully supported — Radar detects this at startup and works within the permitted namespace.
185185
186-
- If your ServiceAccount can only list Pods and Services, Radar shows those — other resource types display an "Access Restricted" message
187-
- Cluster-scoped resources (Nodes, Namespaces) require a ClusterRole; if unavailable, those sections are gracefully hidden
188-
- For namespace-scoped ServiceAccounts (RoleBinding instead of ClusterRoleBinding), Radar automatically detects this and scopes its informers to the permitted namespace
189-
- The UI clearly indicates which resources are restricted vs simply empty
186+
**RBAC granularity (auth enabled):**
187+
188+
- Namespaced resources (Pods, Deployments, Services, …) are filtered by namespace: read access is granted in any namespace where the user has list-pods or list-deployments. Per-resource gating *within* a namespace is currently coarse — if a user has any namespace-level read access, they can see all namespaced resources Radar's pod ServiceAccount caches in that namespace. Where you need finer control (e.g. denying Secrets in a shared namespace), enforce it via the pod ServiceAccount's RBAC instead.
189+
- Cluster-scoped resources (Nodes, PVs, StorageClasses, ClusterRoles, cluster-scoped CRDs, …) are gated per-kind via SubjectAccessReview. Cluster-wide pod visibility does NOT imply Node visibility — every cluster-scoped read goes through its own RBAC check, with results cached per user.
190+
191+
The same RBAC boundary applies to MCP — read tools intersect with each user's allowed namespaces, write tools impersonate the user against the apiserver, and cluster-scoped reads run the same per-kind SAR. The pod ServiceAccount's permissions are the upper bound for both REST and MCP; per-user RBAC narrows that to what each user can see.
190192
191193
**Example: Namespace-scoped deployment**
192194

docs/mcp.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,10 @@ Add to `~/.gemini/settings.json`:
198198
## Security
199199

200200
- **Safe by design** — read tools are strictly read-only; write tools perform non-destructive operations (restart, scale, sync) and are annotated with MCP tool hints so AI clients can distinguish them
201-
- **Local-only** — MCP server runs on localhost alongside Radar
202-
- **RBAC-aware** — respects your kubeconfig's RBAC permissions; returns 403 for unauthorized resources
201+
- **RBAC-aware** — every call enforces RBAC at the same boundary as the REST API:
202+
- **Local binary**: the cache uses your kubeconfig identity, so MCP can only see what `kubectl` can see for that user
203+
- **In-cluster (auth enabled)**: read tools intersect namespaced reads with the calling user's RBAC-allowed namespaces; cluster-scoped reads (Nodes, PVs, ClusterRoles, cluster-scoped CRDs) are gated per-kind via SubjectAccessReview, so cluster-wide pod visibility doesn't implicitly grant Node read; write tools, exec, and logs are fully impersonated so the apiserver enforces the user's RBAC end-to-end
204+
- **In-cluster (no auth)**: every MCP caller shares the pod ServiceAccount's view — only deploy this way when MCP isn't exposed beyond a trusted boundary
203205
- **Secret redaction** — Secret `.data` and `.stringData` are never exposed; only key names are shown
204206
- **Value redaction** — environment variable values are scrubbed for known secret patterns (API keys, tokens, passwords, base64 blocks)
205207
- **Log redaction** — pod log output is scrubbed for secret patterns before being returned

internal/k8s/cache.go

Lines changed: 54 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,7 @@ var initialSyncComplete bool
4848
// deferredResources lists informer keys that are NOT required for the initial
4949
// dashboard render. These sync in the background after the critical informers
5050
// complete, so the UI can render immediately with core resources.
51-
// Critical: pods, deployments, services, statefulsets, daemonsets, nodes,
52-
// namespaces, ingresses, jobs, cronjobs
51+
// Critical: pods, deployments, services, statefulsets, daemonsets, nodes, namespaces, ingresses, jobs, cronjobs.
5352
var deferredResources = map[string]bool{
5453
"secrets": true,
5554
"events": true,
@@ -122,51 +121,33 @@ func InitResourceCache(ctx context.Context) error {
122121
return
123122
}
124123

125-
// Check RBAC permissions for all resource types before creating informers.
124+
// Probe per-resource list access before creating informers. The
125+
// returned scope map is authoritative for both enablement and
126+
// per-kind namespace scoping (some kinds may be cluster-wide while
127+
// others are namespace-scoped to the same fallback namespace).
126128
rbacStart := time.Now()
127129
rbacCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
128130
permResult := CheckResourcePermissions(rbacCtx)
129131
cancel()
130-
logTiming(" RBAC permission checks: %v", time.Since(rbacStart))
132+
logTiming(" Resource access probes: %v", time.Since(rbacStart))
131133

132134
if ctx.Err() != nil {
133135
initErr = ctx.Err()
134136
return
135137
}
136138

137-
perms := permResult.Perms
138-
139-
enabled := map[string]bool{
140-
"pods": perms.Pods,
141-
"services": perms.Services,
142-
"deployments": perms.Deployments,
143-
"daemonsets": perms.DaemonSets,
144-
"statefulsets": perms.StatefulSets,
145-
"replicasets": perms.ReplicaSets,
146-
"ingresses": perms.Ingresses,
147-
"configmaps": perms.ConfigMaps,
148-
"secrets": perms.Secrets,
149-
"events": perms.Events,
150-
"persistentvolumeclaims": perms.PersistentVolumeClaims,
151-
"nodes": perms.Nodes,
152-
"namespaces": perms.Namespaces,
153-
"jobs": perms.Jobs,
154-
"cronjobs": perms.CronJobs,
155-
"horizontalpodautoscalers": perms.HorizontalPodAutoscalers,
156-
"persistentvolumes": perms.PersistentVolumes,
157-
"storageclasses": perms.StorageClasses,
158-
"poddisruptionbudgets": perms.PodDisruptionBudgets,
159-
"networkpolicies": perms.NetworkPolicies,
160-
"serviceaccounts": perms.ServiceAccounts,
161-
"limitranges": perms.LimitRanges,
139+
// scopes drives which informers are created and at what scope.
140+
// k8score routes each kind through the matching factory (cluster-wide
141+
// or namespace-scoped) based on these per-kind decisions.
142+
scopes := permResult.Scopes
143+
if scopes == nil {
144+
scopes = map[string]k8score.ResourceScope{}
162145
}
163146

164147
cfg := k8score.CacheConfig{
165148
Client: k8sClient,
166-
ResourceTypes: enabled,
149+
ResourceScopes: scopes,
167150
DeferredTypes: deferredResources,
168-
NamespaceScoped: permResult.NamespaceScoped,
169-
Namespace: permResult.Namespace,
170151
DebugEvents: DebugEvents,
171152
TimingLogger: logTiming,
172153
PatienceWindow: firstPaintPatience,
@@ -222,7 +203,7 @@ func InitResourceCache(ctx context.Context) error {
222203

223204
resourceCache = &ResourceCache{
224205
ResourceCache: core,
225-
secretsEnabled: enabled["secrets"],
206+
secretsEnabled: scopes["secrets"].Enabled,
226207
}
227208
})
228209
return initErr
@@ -645,6 +626,10 @@ var knownKinds = map[string]bool{
645626
"storageclass": true, "storageclasses": true, "sc": true,
646627
"poddisruptionbudget": true, "poddisruptionbudgets": true, "pdb": true, "pdbs": true,
647628
"networkpolicy": true, "networkpolicies": true, "netpol": true,
629+
"role": true, "roles": true,
630+
"clusterrole": true, "clusterroles": true,
631+
"rolebinding": true, "rolebindings": true,
632+
"clusterrolebinding": true, "clusterrolebindings": true,
648633
}
649634

650635
// IsKnownKind returns true if the kind is handled by the typed cache
@@ -931,6 +916,42 @@ func (c *ResourceCache) GetResourceStatus(kind, namespace, name string) *Resourc
931916
Status: "Active",
932917
}
933918

919+
case "role", "roles":
920+
if c.Roles() == nil {
921+
return nil
922+
}
923+
if _, err := c.Roles().Roles(namespace).Get(name); err != nil {
924+
return nil
925+
}
926+
return &ResourceStatus{Status: "Active"}
927+
928+
case "clusterrole", "clusterroles":
929+
if c.ClusterRoles() == nil {
930+
return nil
931+
}
932+
if _, err := c.ClusterRoles().Get(name); err != nil {
933+
return nil
934+
}
935+
return &ResourceStatus{Status: "Active"}
936+
937+
case "rolebinding", "rolebindings":
938+
if c.RoleBindings() == nil {
939+
return nil
940+
}
941+
if _, err := c.RoleBindings().RoleBindings(namespace).Get(name); err != nil {
942+
return nil
943+
}
944+
return &ResourceStatus{Status: "Active"}
945+
946+
case "clusterrolebinding", "clusterrolebindings":
947+
if c.ClusterRoleBindings() == nil {
948+
return nil
949+
}
950+
if _, err := c.ClusterRoleBindings().Get(name); err != nil {
951+
return nil
952+
}
953+
return &ResourceStatus{Status: "Active"}
954+
934955
case "configmap", "configmaps":
935956
if c.ConfigMaps() == nil {
936957
return nil

0 commit comments

Comments
 (0)