Skip to content
Merged
27 changes: 26 additions & 1 deletion internal/server/resource_counts.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,20 @@ type ResourceCountsResponse struct {
Counts map[string]int `json:"counts"`
Forbidden []string `json:"forbidden,omitempty"`
Unavailable []string `json:"unavailable,omitempty"`
// Reasons maps a forbidden kind key to why it's hidden:
// "rbac_denied" — Radar's ServiceAccount can read the kind but the user's
// own RBAC denies it. Granting the user list access surfaces it.
// "unavailable" — Radar can't read the kind at all (no informer): its type
// isn't installed, the SA lacks RBAC, or the feature is off (e.g.
// rbac.viewRBAC). A user-level grant won't help.
Reasons map[string]string `json:"reasons,omitempty"`
}

const (
reasonRBACDenied = "rbac_denied"
reasonUnavailable = "unavailable"
)

const (
endpointSliceCountKey = "discovery.k8s.io/EndpointSlice"
endpointSliceCountNamespaceCap = 50
Expand All @@ -43,6 +55,7 @@ func (s *Server) handleResourceCounts(w http.ResponseWriter, r *http.Request) {
counts := make(map[string]int)
var forbidden []string
var unavailable []string
reasons := map[string]string{}

countEndpointSlices := func() {
dynamicCache := k8s.GetDynamicResourceCache()
Expand All @@ -69,15 +82,26 @@ func (s *Server) handleResourceCounts(w http.ResponseWriter, r *http.Request) {
for _, kl := range k8score.AllKindListers() {
l := kl.Lister()(cache.ResourceCache)
if l == nil {
// No informer: Radar's SA can't read this kind (not installed, SA
// RBAC, or feature off) — a user-level grant won't surface it.
forbidden = append(forbidden, kl.CountKey())
reasons[kl.CountKey()] = reasonUnavailable
continue
}
// Cluster-scoped kinds: ListCountNamespaced ignores the namespace
// filter and returns the cluster-wide count, so authorize the kind
// per-user via SAR before counting.
if k8s.IsClusterOnlyKind(kl.Kind()) {
group, resource, ok := k8s.ClusterOnlyKindGVR(kl.Kind())
if !ok || !s.canRead(r, group, resource, "", "list") {
if !ok {
continue
}
// A core cluster-scoped kind always exists, so an RBAC denial is
// surfaced as forbidden rather than silently omitted — otherwise the
// UI shows "0 / No X found", indistinguishable from an empty cluster.
if !s.canRead(r, group, resource, "", "list") {
forbidden = append(forbidden, kl.CountKey())
reasons[kl.CountKey()] = reasonRBACDenied
continue
}
}
Expand Down Expand Up @@ -174,5 +198,6 @@ func (s *Server) handleResourceCounts(w http.ResponseWriter, r *http.Request) {
Counts: counts,
Forbidden: forbidden,
Unavailable: unavailable,
Reasons: reasons,
})
}
31 changes: 31 additions & 0 deletions internal/server/resource_counts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,37 @@ func TestResourceCountsOmitsClusterScopedCRDWhenCanReadDenies(t *testing.T) {
}
}

func TestResourceCountsSurfacesDeniedCoreClusterScopedKindAsForbidden(t *testing.T) {
// A core cluster-scoped kind (Node) always exists, so an RBAC denial must be
// surfaced as forbidden — not silently omitted, which would render as
// "0 / No Node found", indistinguishable from an empty cluster.
s := newAuthServer(auth.Config{Mode: "proxy"})
user := &auth.User{Username: "alice"}
perms := &auth.UserPermissions{AllowedNamespaces: nil}
perms.SetCanI("list", "", "nodes", "", false)
s.permCache.Set(user.Username, perms)
req := requestWithUser(http.MethodGet, "/api/resource-counts", user)

rec := httptest.NewRecorder()
s.handleResourceCounts(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", rec.Code, rec.Body.String())
}
var body ResourceCountsResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if _, ok := body.Counts["Node"]; ok {
t.Fatalf("denied Node leaked a count: %v", body.Counts["Node"])
}
if !containsString(body.Forbidden, "Node") {
t.Fatalf("denied core cluster-scoped Node should be in forbidden, got: %v", body.Forbidden)
}
if body.Reasons["Node"] != "rbac_denied" {
t.Fatalf("Node reason = %q, want rbac_denied (SA can read it, user can't)", body.Reasons["Node"])
}
}

func TestResourceCountsCountsClusterScopedCRDDespiteNamespaceFilter(t *testing.T) {
nodePoolGVR := schema.GroupVersionResource{Group: "karpenter.sh", Version: "v1", Resource: "nodepools"}
endpointSliceGVR := schema.GroupVersionResource{Group: "discovery.k8s.io", Version: "v1", Resource: "endpointslices"}
Expand Down
39 changes: 34 additions & 5 deletions packages/k8s-ui/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, u
import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
import { PaneLoader } from '../ui/PaneLoader'
import { RestrictedState } from '../ui/RestrictedState'
import type { TopPodMetrics, TopNodeMetrics } from '../../types'
import {
Search,
Expand Down Expand Up @@ -1840,6 +1841,9 @@ interface ResourcesViewProps {
// Lightweight counts for sidebar badges (from /api/resource-counts)
resourceCounts?: Record<string, number>
resourceForbidden?: string[]
/** Per-kind reason a forbidden kind is hidden ("rbac_denied" | "unavailable"),
* keyed by the same count key as resourceForbidden. Drives RestrictedState copy. */
resourceReasons?: Record<string, string>
resourceUnavailable?: string[]
// Single query for the currently selected kind's full data
selectedKindQuery?: ResourceQueryResult
Expand Down Expand Up @@ -2058,6 +2062,7 @@ export function ResourcesView({
resourceQueries: resourceQueriesProp,
resourceCounts: resourceCountsProp,
resourceForbidden: resourceForbiddenProp,
resourceReasons,
resourceUnavailable: resourceUnavailableProp,
selectedKindQuery: selectedKindQueryProp,
largeListGuard,
Expand Down Expand Up @@ -3232,7 +3237,6 @@ export function ResourcesView({
}, [resources])
const isLoading = selectedQuery?.isLoading ?? true
const selectedQueryError = selectedQuery?.error
const isSelectedForbidden = isForbiddenError(selectedQueryError)
const refetchFn = selectedQuery?.refetch
const dataUpdatedAt = selectedQuery?.dataUpdatedAt

Expand Down Expand Up @@ -3294,6 +3298,23 @@ export function ResourcesView({
return result
}, [useNewCountsMode, resourceForbiddenProp, resourcesToCount, resourceQueries])

// Render the restricted state when EITHER the list query 403s (namespaced
// denials) OR the selected kind is in the counts `forbidden` set. Denied
// cluster-scoped kinds return 200 with `[]` from the list endpoint, so the
// 403 signal alone misses them — they'd fall through to "No <kind> found".
const selectedKindCountKey = selectedKind.group
? `${selectedKind.group}/${selectedKind.kind}`
: selectedKind.kind
// Actual rows win over a stale counts `forbidden` entry: a kind can be marked
// forbidden/unavailable in counts (e.g. an informer not yet synced at counts
// time) while the list query has since returned data — show the table, not
// RestrictedState. A 403 on the list itself never carries rows, so it still
// forces the restricted state.
const selectedHasRows = Array.isArray(resources) && resources.length > 0
const isSelectedForbidden =
isForbiddenError(selectedQueryError) ||
(!selectedHasRows && forbiddenKinds.has(selectedKindCountKey))

// Reset sort and filters when kind changes (but not when syncing from URL navigation)
// Track previous kind to skip on mount (where the effect fires but kind hasn't actually changed)
const prevKindRef = useRef(selectedKind.name)
Expand Down Expand Up @@ -4505,10 +4526,18 @@ export function ResourcesView({
{isLoading ? (
<PaneLoader className="absolute inset-0" />
) : isSelectedForbidden ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary">
Comment thread
cursor[bot] marked this conversation as resolved.
<Shield className="w-8 h-8 text-amber-400 mb-2" />
<p className="text-theme-text-secondary font-medium">Access Restricted</p>
<p className="text-sm mt-1">Insufficient permissions to list {selectedKind.kind} resources</p>
// overflow-y-auto + min-h-full: centered when the panel is short
// (collapsed), scrollable (no top clip) when the RBAC snippet is
// expanded on a shorter pane.
<div className="absolute inset-0 overflow-y-auto">
<div className="min-h-full flex items-center justify-center">
<RestrictedState
kindLabel={selectedKind.kind}
group={selectedKind.group}
resource={selectedKind.name}
reason={resourceReasons?.[selectedKindCountKey]}
/>
</div>
</div>
) : largeListGuard ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary px-6 text-center">
Expand Down
152 changes: 152 additions & 0 deletions packages/k8s-ui/src/components/ui/RestrictedState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { useState } from 'react'
import { clsx } from 'clsx'
import { Shield, ChevronDown, Copy, Check } from 'lucide-react'

// RestrictedState is the shared "you can't see this because of Kubernetes RBAC"
// surface — distinct from EmptyState (which covers healthy / filtered / no-data).
// It never claims WHY the SAR failed (Radar can't prove tier vs custom-role vs
// disabled-value from a denied check); it states the fact and hands the operator
// something to forward to whoever administers their cluster.
//
// Reused across the resource list, topology, search, and the per-cluster access
// summary so the messaging can't drift.

interface Props {
/** Display kind, e.g. "Node". */
kindLabel: string
/** API group for the kind ("" for core). Used to build the example RBAC. */
group?: string
/** Plural resource name, e.g. "nodes". When omitted the snippet uses a
* placeholder the admin fills in. */
resource?: string
/** Why the kind is hidden. "rbac_denied" (default): Radar can read it but the
* user's RBAC can't — show the grant request. "unavailable": Radar's
* ServiceAccount can't read it at all (not installed / SA RBAC / feature off)
* — a user grant won't help, so show a different message and no snippet. */
reason?: 'rbac_denied' | 'unavailable' | string
/** Tightens spacing for inline/embedded use (topology overlay, etc.). */
compact?: boolean
className?: string
}

function buildRbacRequest(group: string, resource: string): string {
// resource is the placeholder when we don't have a confident API plural
// (e.g. a CRD deep-link before discovery resolves the kind) — use a generic
// role name rather than radar-read-<Kind>.
const roleName = resource === '<resource>' ? 'radar-read-access' : `radar-read-${resource}`
return [
`apiVersion: rbac.authorization.k8s.io/v1`,
`kind: ClusterRole`,
`metadata:`,
` name: ${roleName}`,
`rules:`,
` - apiGroups: ["${group}"]`,
` resources: ["${resource}"]`,
` verbs: ["get", "list", "watch"]`,
`---`,
`apiVersion: rbac.authorization.k8s.io/v1`,
`kind: ClusterRoleBinding`,
`metadata:`,
` name: ${roleName}`,
`roleRef:`,
` apiGroup: rbac.authorization.k8s.io`,
` kind: ClusterRole`,
` name: ${roleName}`,
`subjects:`,
` - kind: Group # or User / ServiceAccount`,
` name: <your-identity>`,
` apiGroup: rbac.authorization.k8s.io`,
].join('\n')
}

export function RestrictedState({ kindLabel, group = '', resource, reason, compact, className }: Props) {
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const isUnavailable = reason === 'unavailable'

// RBAC `resources` must be the lowercase API plural. selectedKind.name is
// that in normal use, but a CRD deep-link can transiently carry the Kind
// (e.g. "HTTPRoute") before discovery resolves it — emitting that would
// produce a snippet that doesn't grant access. Only inline the resource when
// it looks like a valid resource name; otherwise leave a clear placeholder.
const validResource = resource && /^[a-z0-9.-]+$/.test(resource) ? resource : '<resource>'
const snippet = buildRbacRequest(group, validResource)

const copy = () => {
navigator.clipboard.writeText(snippet).then(
() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
},
() => {},
)
}

return (
<div
className={clsx(
'flex flex-col items-center justify-center text-center text-theme-text-tertiary',
compact ? 'p-4' : 'p-6',
className,
)}
>
<Shield className="w-8 h-8 text-amber-400 mb-2" />
{isUnavailable ? (
// Radar's ServiceAccount can't read this kind — a user grant won't help.
<>
<p className="text-theme-text-secondary font-medium">{kindLabel} isn't available here</p>
<p className="text-sm mt-1 max-w-md">
Radar can't read {kindLabel} resources in this cluster — the type may not be installed,
or read access isn't granted to Radar's ServiceAccount (some kinds, like RBAC objects
and Secrets, are off unless enabled in the Radar chart). Granting your own identity
access won't surface it.
</p>
</>
) : (
<>
<p className="text-theme-text-secondary font-medium">You don't have access to {kindLabel}</p>
<p className="text-sm mt-1 max-w-md">
Your Kubernetes RBAC doesn't allow listing {kindLabel} resources in this cluster. This
isn't an empty cluster — Radar is hiding what your identity can't read.
</p>

<div className="mt-3 w-full max-w-md">
<button
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 mx-auto text-sm text-theme-text-secondary hover:text-theme-text-primary transition-colors"
>
<ChevronDown className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-180')} />
How to get access
</button>

{expanded && (
<div className="mt-2 text-left">
<p className="text-xs text-theme-text-tertiary mb-2">
Apply this, or send it to whoever administers your cluster, to grant your identity
read access.
</p>
<div className="rounded-md border border-theme-border bg-theme-base overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-theme-border bg-theme-elevated">
<span className="text-xs text-theme-text-tertiary">
ClusterRole + ClusterRoleBinding
</span>
<button
onClick={copy}
className="flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary transition-colors"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<pre className="text-xs p-3 max-h-72 overflow-auto text-theme-text-secondary">
{snippet}
</pre>
</div>
</div>
)}
</div>
</>
)}
</div>
)
}
1 change: 1 addition & 0 deletions packages/k8s-ui/src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { MiddleEllipsis } from './MiddleEllipsis'
export type { MiddleEllipsisProps } from './MiddleEllipsis'
export { EmptyState } from './EmptyState'
export type { EmptyStateTone, EmptyStateVariant } from './EmptyState'
export { RestrictedState } from './RestrictedState'
export { FetchResult } from './FetchResult'
export { SearchBox } from './SearchBox'
export { FilterPill } from './FilterPill'
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/resources/ResourcesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { getSkeletonYaml } from '../../utils/skeleton-yaml'
interface ResourceCountsResponse {
counts: Record<string, number>
forbidden?: string[]
reasons?: Record<string, string>
unavailable?: string[]
}

Expand Down Expand Up @@ -284,6 +285,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
// Lightweight counts for sidebar (replaces 233 parallel queries)
resourceCounts={countsData?.counts}
resourceForbidden={countsData?.forbidden}
resourceReasons={countsData?.reasons}
resourceUnavailable={countsData?.unavailable}
selectedKindQuery={selectedKindQueryResult}
largeListGuard={largeListGuard}
Expand Down
Loading
Loading