Skip to content

Commit 066c215

Browse files
authored
feat: cluster-scoped RBAC visibility (denied-state UX) (#988)
1 parent bc0cfd2 commit 066c215

7 files changed

Lines changed: 308 additions & 9 deletions

File tree

internal/server/resource_counts.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,20 @@ type ResourceCountsResponse struct {
1515
Counts map[string]int `json:"counts"`
1616
Forbidden []string `json:"forbidden,omitempty"`
1717
Unavailable []string `json:"unavailable,omitempty"`
18+
// Reasons maps a forbidden kind key to why it's hidden:
19+
// "rbac_denied" — Radar's ServiceAccount can read the kind but the user's
20+
// own RBAC denies it. Granting the user list access surfaces it.
21+
// "unavailable" — Radar can't read the kind at all (no informer): its type
22+
// isn't installed, the SA lacks RBAC, or the feature is off (e.g.
23+
// rbac.viewRBAC). A user-level grant won't help.
24+
Reasons map[string]string `json:"reasons,omitempty"`
1825
}
1926

27+
const (
28+
reasonRBACDenied = "rbac_denied"
29+
reasonUnavailable = "unavailable"
30+
)
31+
2032
const (
2133
endpointSliceCountKey = "discovery.k8s.io/EndpointSlice"
2234
endpointSliceCountNamespaceCap = 50
@@ -43,6 +55,7 @@ func (s *Server) handleResourceCounts(w http.ResponseWriter, r *http.Request) {
4355
counts := make(map[string]int)
4456
var forbidden []string
4557
var unavailable []string
58+
reasons := map[string]string{}
4659

4760
countEndpointSlices := func() {
4861
dynamicCache := k8s.GetDynamicResourceCache()
@@ -69,15 +82,26 @@ func (s *Server) handleResourceCounts(w http.ResponseWriter, r *http.Request) {
6982
for _, kl := range k8score.AllKindListers() {
7083
l := kl.Lister()(cache.ResourceCache)
7184
if l == nil {
85+
// No informer: Radar's SA can't read this kind (not installed, SA
86+
// RBAC, or feature off) — a user-level grant won't surface it.
7287
forbidden = append(forbidden, kl.CountKey())
88+
reasons[kl.CountKey()] = reasonUnavailable
7389
continue
7490
}
7591
// Cluster-scoped kinds: ListCountNamespaced ignores the namespace
7692
// filter and returns the cluster-wide count, so authorize the kind
7793
// per-user via SAR before counting.
7894
if k8s.IsClusterOnlyKind(kl.Kind()) {
7995
group, resource, ok := k8s.ClusterOnlyKindGVR(kl.Kind())
80-
if !ok || !s.canRead(r, group, resource, "", "list") {
96+
if !ok {
97+
continue
98+
}
99+
// A core cluster-scoped kind always exists, so an RBAC denial is
100+
// surfaced as forbidden rather than silently omitted — otherwise the
101+
// UI shows "0 / No X found", indistinguishable from an empty cluster.
102+
if !s.canRead(r, group, resource, "", "list") {
103+
forbidden = append(forbidden, kl.CountKey())
104+
reasons[kl.CountKey()] = reasonRBACDenied
81105
continue
82106
}
83107
}
@@ -174,5 +198,6 @@ func (s *Server) handleResourceCounts(w http.ResponseWriter, r *http.Request) {
174198
Counts: counts,
175199
Forbidden: forbidden,
176200
Unavailable: unavailable,
201+
Reasons: reasons,
177202
})
178203
}

internal/server/resource_counts_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,37 @@ func TestResourceCountsOmitsClusterScopedCRDWhenCanReadDenies(t *testing.T) {
134134
}
135135
}
136136

137+
func TestResourceCountsSurfacesDeniedCoreClusterScopedKindAsForbidden(t *testing.T) {
138+
// A core cluster-scoped kind (Node) always exists, so an RBAC denial must be
139+
// surfaced as forbidden — not silently omitted, which would render as
140+
// "0 / No Node found", indistinguishable from an empty cluster.
141+
s := newAuthServer(auth.Config{Mode: "proxy"})
142+
user := &auth.User{Username: "alice"}
143+
perms := &auth.UserPermissions{AllowedNamespaces: nil}
144+
perms.SetCanI("list", "", "nodes", "", false)
145+
s.permCache.Set(user.Username, perms)
146+
req := requestWithUser(http.MethodGet, "/api/resource-counts", user)
147+
148+
rec := httptest.NewRecorder()
149+
s.handleResourceCounts(rec, req)
150+
if rec.Code != http.StatusOK {
151+
t.Fatalf("status = %d, body: %s", rec.Code, rec.Body.String())
152+
}
153+
var body ResourceCountsResponse
154+
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
155+
t.Fatalf("decode response: %v", err)
156+
}
157+
if _, ok := body.Counts["Node"]; ok {
158+
t.Fatalf("denied Node leaked a count: %v", body.Counts["Node"])
159+
}
160+
if !containsString(body.Forbidden, "Node") {
161+
t.Fatalf("denied core cluster-scoped Node should be in forbidden, got: %v", body.Forbidden)
162+
}
163+
if body.Reasons["Node"] != "rbac_denied" {
164+
t.Fatalf("Node reason = %q, want rbac_denied (SA can read it, user can't)", body.Reasons["Node"])
165+
}
166+
}
167+
137168
func TestResourceCountsCountsClusterScopedCRDDespiteNamespaceFilter(t *testing.T) {
138169
nodePoolGVR := schema.GroupVersionResource{Group: "karpenter.sh", Version: "v1", Resource: "nodepools"}
139170
endpointSliceGVR := schema.GroupVersionResource{Group: "discovery.k8s.io", Version: "v1", Resource: "endpointslices"}

packages/k8s-ui/src/components/resources/ResourcesView.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, u
22
import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
33
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
44
import { PaneLoader } from '../ui/PaneLoader'
5+
import { RestrictedState } from '../ui/RestrictedState'
56
import type { TopPodMetrics, TopNodeMetrics } from '../../types'
67
import {
78
Search,
@@ -1840,6 +1841,9 @@ interface ResourcesViewProps {
18401841
// Lightweight counts for sidebar badges (from /api/resource-counts)
18411842
resourceCounts?: Record<string, number>
18421843
resourceForbidden?: string[]
1844+
/** Per-kind reason a forbidden kind is hidden ("rbac_denied" | "unavailable"),
1845+
* keyed by the same count key as resourceForbidden. Drives RestrictedState copy. */
1846+
resourceReasons?: Record<string, string>
18431847
resourceUnavailable?: string[]
18441848
// Single query for the currently selected kind's full data
18451849
selectedKindQuery?: ResourceQueryResult
@@ -2058,6 +2062,7 @@ export function ResourcesView({
20582062
resourceQueries: resourceQueriesProp,
20592063
resourceCounts: resourceCountsProp,
20602064
resourceForbidden: resourceForbiddenProp,
2065+
resourceReasons,
20612066
resourceUnavailable: resourceUnavailableProp,
20622067
selectedKindQuery: selectedKindQueryProp,
20632068
largeListGuard,
@@ -3232,7 +3237,6 @@ export function ResourcesView({
32323237
}, [resources])
32333238
const isLoading = selectedQuery?.isLoading ?? true
32343239
const selectedQueryError = selectedQuery?.error
3235-
const isSelectedForbidden = isForbiddenError(selectedQueryError)
32363240
const refetchFn = selectedQuery?.refetch
32373241
const dataUpdatedAt = selectedQuery?.dataUpdatedAt
32383242

@@ -3294,6 +3298,23 @@ export function ResourcesView({
32943298
return result
32953299
}, [useNewCountsMode, resourceForbiddenProp, resourcesToCount, resourceQueries])
32963300

3301+
// Render the restricted state when EITHER the list query 403s (namespaced
3302+
// denials) OR the selected kind is in the counts `forbidden` set. Denied
3303+
// cluster-scoped kinds return 200 with `[]` from the list endpoint, so the
3304+
// 403 signal alone misses them — they'd fall through to "No <kind> found".
3305+
const selectedKindCountKey = selectedKind.group
3306+
? `${selectedKind.group}/${selectedKind.kind}`
3307+
: selectedKind.kind
3308+
// Actual rows win over a stale counts `forbidden` entry: a kind can be marked
3309+
// forbidden/unavailable in counts (e.g. an informer not yet synced at counts
3310+
// time) while the list query has since returned data — show the table, not
3311+
// RestrictedState. A 403 on the list itself never carries rows, so it still
3312+
// forces the restricted state.
3313+
const selectedHasRows = Array.isArray(resources) && resources.length > 0
3314+
const isSelectedForbidden =
3315+
isForbiddenError(selectedQueryError) ||
3316+
(!selectedHasRows && forbiddenKinds.has(selectedKindCountKey))
3317+
32973318
// Reset sort and filters when kind changes (but not when syncing from URL navigation)
32983319
// Track previous kind to skip on mount (where the effect fires but kind hasn't actually changed)
32993320
const prevKindRef = useRef(selectedKind.name)
@@ -4505,10 +4526,18 @@ export function ResourcesView({
45054526
{isLoading ? (
45064527
<PaneLoader className="absolute inset-0" />
45074528
) : isSelectedForbidden ? (
4508-
<div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary">
4509-
<Shield className="w-8 h-8 text-amber-400 mb-2" />
4510-
<p className="text-theme-text-secondary font-medium">Access Restricted</p>
4511-
<p className="text-sm mt-1">Insufficient permissions to list {selectedKind.kind} resources</p>
4529+
// overflow-y-auto + min-h-full: centered when the panel is short
4530+
// (collapsed), scrollable (no top clip) when the RBAC snippet is
4531+
// expanded on a shorter pane.
4532+
<div className="absolute inset-0 overflow-y-auto">
4533+
<div className="min-h-full flex items-center justify-center">
4534+
<RestrictedState
4535+
kindLabel={selectedKind.kind}
4536+
group={selectedKind.group}
4537+
resource={selectedKind.name}
4538+
reason={resourceReasons?.[selectedKindCountKey]}
4539+
/>
4540+
</div>
45124541
</div>
45134542
) : largeListGuard ? (
45144543
<div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary px-6 text-center">
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useState } from 'react'
2+
import { clsx } from 'clsx'
3+
import { Shield, ChevronDown, Copy, Check } from 'lucide-react'
4+
5+
// RestrictedState is the shared "you can't see this because of Kubernetes RBAC"
6+
// surface — distinct from EmptyState (which covers healthy / filtered / no-data).
7+
// It never claims WHY the SAR failed (Radar can't prove tier vs custom-role vs
8+
// disabled-value from a denied check); it states the fact and hands the operator
9+
// something to forward to whoever administers their cluster.
10+
//
11+
// Reused across the resource list, topology, search, and the per-cluster access
12+
// summary so the messaging can't drift.
13+
14+
interface Props {
15+
/** Display kind, e.g. "Node". */
16+
kindLabel: string
17+
/** API group for the kind ("" for core). Used to build the example RBAC. */
18+
group?: string
19+
/** Plural resource name, e.g. "nodes". When omitted the snippet uses a
20+
* placeholder the admin fills in. */
21+
resource?: string
22+
/** Why the kind is hidden. "rbac_denied" (default): Radar can read it but the
23+
* user's RBAC can't — show the grant request. "unavailable": Radar's
24+
* ServiceAccount can't read it at all (not installed / SA RBAC / feature off)
25+
* — a user grant won't help, so show a different message and no snippet. */
26+
reason?: 'rbac_denied' | 'unavailable' | string
27+
/** Tightens spacing for inline/embedded use (topology overlay, etc.). */
28+
compact?: boolean
29+
className?: string
30+
}
31+
32+
function buildRbacRequest(group: string, resource: string): string {
33+
// resource is the placeholder when we don't have a confident API plural
34+
// (e.g. a CRD deep-link before discovery resolves the kind) — use a generic
35+
// role name rather than radar-read-<Kind>.
36+
const roleName = resource === '<resource>' ? 'radar-read-access' : `radar-read-${resource}`
37+
return [
38+
`apiVersion: rbac.authorization.k8s.io/v1`,
39+
`kind: ClusterRole`,
40+
`metadata:`,
41+
` name: ${roleName}`,
42+
`rules:`,
43+
` - apiGroups: ["${group}"]`,
44+
` resources: ["${resource}"]`,
45+
` verbs: ["get", "list", "watch"]`,
46+
`---`,
47+
`apiVersion: rbac.authorization.k8s.io/v1`,
48+
`kind: ClusterRoleBinding`,
49+
`metadata:`,
50+
` name: ${roleName}`,
51+
`roleRef:`,
52+
` apiGroup: rbac.authorization.k8s.io`,
53+
` kind: ClusterRole`,
54+
` name: ${roleName}`,
55+
`subjects:`,
56+
` - kind: Group # or User / ServiceAccount`,
57+
` name: <your-identity>`,
58+
` apiGroup: rbac.authorization.k8s.io`,
59+
].join('\n')
60+
}
61+
62+
export function RestrictedState({ kindLabel, group = '', resource, reason, compact, className }: Props) {
63+
const [expanded, setExpanded] = useState(false)
64+
const [copied, setCopied] = useState(false)
65+
const isUnavailable = reason === 'unavailable'
66+
67+
// RBAC `resources` must be the lowercase API plural. selectedKind.name is
68+
// that in normal use, but a CRD deep-link can transiently carry the Kind
69+
// (e.g. "HTTPRoute") before discovery resolves it — emitting that would
70+
// produce a snippet that doesn't grant access. Only inline the resource when
71+
// it looks like a valid resource name; otherwise leave a clear placeholder.
72+
const validResource = resource && /^[a-z0-9.-]+$/.test(resource) ? resource : '<resource>'
73+
const snippet = buildRbacRequest(group, validResource)
74+
75+
const copy = () => {
76+
navigator.clipboard.writeText(snippet).then(
77+
() => {
78+
setCopied(true)
79+
setTimeout(() => setCopied(false), 2000)
80+
},
81+
() => {},
82+
)
83+
}
84+
85+
return (
86+
<div
87+
className={clsx(
88+
'flex flex-col items-center justify-center text-center text-theme-text-tertiary',
89+
compact ? 'p-4' : 'p-6',
90+
className,
91+
)}
92+
>
93+
<Shield className="w-8 h-8 text-amber-400 mb-2" />
94+
{isUnavailable ? (
95+
// Radar's ServiceAccount can't read this kind — a user grant won't help.
96+
<>
97+
<p className="text-theme-text-secondary font-medium">{kindLabel} isn't available here</p>
98+
<p className="text-sm mt-1 max-w-md">
99+
Radar can't read {kindLabel} resources in this cluster — the type may not be installed,
100+
or read access isn't granted to Radar's ServiceAccount (some kinds, like RBAC objects
101+
and Secrets, are off unless enabled in the Radar chart). Granting your own identity
102+
access won't surface it.
103+
</p>
104+
</>
105+
) : (
106+
<>
107+
<p className="text-theme-text-secondary font-medium">You don't have access to {kindLabel}</p>
108+
<p className="text-sm mt-1 max-w-md">
109+
Your Kubernetes RBAC doesn't allow listing {kindLabel} resources in this cluster. This
110+
isn't an empty cluster — Radar is hiding what your identity can't read.
111+
</p>
112+
113+
<div className="mt-3 w-full max-w-md">
114+
<button
115+
onClick={() => setExpanded((v) => !v)}
116+
className="flex items-center gap-1.5 mx-auto text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors"
117+
>
118+
<ChevronDown className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-180')} />
119+
How to get access
120+
</button>
121+
122+
{expanded && (
123+
<div className="mt-2 text-left">
124+
<p className="text-xs text-theme-text-tertiary mb-2">
125+
Apply this, or send it to whoever administers your cluster, to grant your identity
126+
read access.
127+
</p>
128+
<div className="rounded-md border border-theme-border bg-theme-base overflow-hidden">
129+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-theme-border bg-theme-elevated">
130+
<span className="text-xs text-theme-text-tertiary">
131+
ClusterRole + ClusterRoleBinding
132+
</span>
133+
<button
134+
onClick={copy}
135+
className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary transition-colors"
136+
>
137+
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
138+
{copied ? 'Copied' : 'Copy'}
139+
</button>
140+
</div>
141+
<pre className="text-xs p-3 max-h-72 overflow-auto text-theme-text-secondary">
142+
{snippet}
143+
</pre>
144+
</div>
145+
</div>
146+
)}
147+
</div>
148+
</>
149+
)}
150+
</div>
151+
)
152+
}

packages/k8s-ui/src/components/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { MiddleEllipsis } from './MiddleEllipsis'
55
export type { MiddleEllipsisProps } from './MiddleEllipsis'
66
export { EmptyState } from './EmptyState'
77
export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
8+
export { RestrictedState } from './RestrictedState'
89
export { FetchResult } from './FetchResult'
910
export { SearchBox } from './SearchBox'
1011
export { FilterPill } from './FilterPill'

web/src/components/resources/ResourcesView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { getSkeletonYaml } from '../../utils/skeleton-yaml'
2323
interface ResourceCountsResponse {
2424
counts: Record<string, number>
2525
forbidden?: string[]
26+
reasons?: Record<string, string>
2627
unavailable?: string[]
2728
}
2829

@@ -284,6 +285,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
284285
// Lightweight counts for sidebar (replaces 233 parallel queries)
285286
resourceCounts={countsData?.counts}
286287
resourceForbidden={countsData?.forbidden}
288+
resourceReasons={countsData?.reasons}
287289
resourceUnavailable={countsData?.unavailable}
288290
selectedKindQuery={selectedKindQueryResult}
289291
largeListGuard={largeListGuard}

0 commit comments

Comments
 (0)