@skyhook-io/k8s-ui 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/resources/ResourcesView.tsx +338 -57
  25. package/src/components/resources/index.ts +1 -0
  26. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  27. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  28. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  29. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  30. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  31. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  32. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  33. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  34. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  35. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  36. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  37. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  39. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  40. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  41. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  42. package/src/components/resources/renderers/index.ts +8 -0
  43. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  44. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  45. package/src/components/resources/resource-utils-flux.ts +14 -0
  46. package/src/components/resources/resource-utils.ts +1 -0
  47. package/src/components/resources/resources-column-filter.test.ts +74 -0
  48. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  50. package/src/components/ui/YamlEditor.tsx +28 -15
  51. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  52. package/src/index.ts +3 -0
  53. package/src/types/index.ts +1 -0
  54. package/src/types/rbac.ts +99 -0
  55. package/src/utils/api-resources.ts +9 -1
  56. package/src/utils/index.ts +1 -0
  57. package/src/utils/rbac-badges.ts +61 -0
  58. package/src/utils/rbac-blast-radius.test.ts +137 -0
  59. package/src/utils/rbac-blast-radius.ts +98 -0
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react'
1
2
  import { Cpu, AlertTriangle } from 'lucide-react'
2
3
  import { clsx } from 'clsx'
3
4
  import { Section, PropertyList, Property, ConditionsSection, ResourceLink } from '../../ui/drawer-components'
@@ -7,6 +8,8 @@ import { formatAge } from '../resource-utils'
7
8
  interface HPARendererProps {
8
9
  data: any
9
10
  onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
11
+ /** Optional host-provided section rendered after Conditions — used to inject Prometheus-backed charts. */
12
+ extraSections?: ReactNode
10
13
  }
11
14
 
12
15
  // Extract problems from HPA conditions
@@ -53,7 +56,7 @@ function getHPAProblems(data: any): string[] {
53
56
  return problems
54
57
  }
55
58
 
56
- export function HPARenderer({ data, onNavigate }: HPARendererProps) {
59
+ export function HPARenderer({ data, onNavigate, extraSections }: HPARendererProps) {
57
60
  const status = data.status || {}
58
61
  const spec = data.spec || {}
59
62
  const metrics = status.currentMetrics || []
@@ -160,6 +163,8 @@ export function HPARenderer({ data, onNavigate }: HPARendererProps) {
160
163
  )}
161
164
 
162
165
  <ConditionsSection conditions={status.conditions} />
166
+
167
+ {extraSections}
163
168
  </>
164
169
  )
165
170
  }
@@ -0,0 +1,131 @@
1
+ import { Box, Settings, Cloud, ScrollText, Pause, Layers } from 'lucide-react'
2
+ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
3
+ import { CodeViewer } from '../../ui/CodeViewer'
4
+ import {
5
+ getCrossplaneStatus,
6
+ getCrossplaneStatusReason,
7
+ getProviderConfigRef,
8
+ getExternalName,
9
+ getManagementPolicies,
10
+ getDeletionPolicy,
11
+ isCrossplanePaused,
12
+ getComposingXRRef,
13
+ } from '../resource-utils-crossplane'
14
+ import { kindToPlural } from '../../../utils/navigation'
15
+
16
+ interface ManagedResourceRendererProps {
17
+ data: any
18
+ onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
19
+ }
20
+
21
+ function extractApiGroup(apiVersion?: string): string {
22
+ if (!apiVersion) return ''
23
+ const slash = apiVersion.indexOf('/')
24
+ return slash < 0 ? '' : apiVersion.slice(0, slash)
25
+ }
26
+
27
+
28
+ export function ManagedResourceRenderer({ data, onNavigate }: ManagedResourceRendererProps) {
29
+ const status = getCrossplaneStatus(data)
30
+ const statusReason = getCrossplaneStatusReason(data)
31
+ const providerConfigRef = getProviderConfigRef(data)
32
+ const externalName = getExternalName(data)
33
+ const managementPolicies = getManagementPolicies(data)
34
+ const deletionPolicy = getDeletionPolicy(data)
35
+ const apiGroup = extractApiGroup(data?.apiVersion)
36
+ const namespace = data?.metadata?.namespace || ''
37
+ const paused = isCrossplanePaused(data)
38
+ const composingXR = getComposingXRRef(data)
39
+
40
+ const forProvider = data?.spec?.forProvider
41
+ const atProvider = data?.status?.atProvider
42
+
43
+ const alertVariant = status.level === 'unhealthy' || status.level === 'alert' ? status.level : null
44
+
45
+ return (
46
+ <>
47
+ {paused && (
48
+ <AlertBanner
49
+ variant="warning"
50
+ icon={Pause}
51
+ title="Reconciliation paused"
52
+ message="The crossplane.io/paused annotation is set. The provider will not reconcile this resource until the annotation is removed."
53
+ />
54
+ )}
55
+
56
+ {alertVariant && statusReason && (
57
+ <AlertBanner
58
+ variant={alertVariant === 'unhealthy' ? 'error' : 'warning'}
59
+ title={status.text}
60
+ message={statusReason}
61
+ />
62
+ )}
63
+
64
+ <Section title="Managed Resource" icon={Box} defaultExpanded>
65
+ <PropertyList>
66
+ <Property label="Kind" value={data?.kind || '-'} />
67
+ <Property label="API Group" value={apiGroup || '-'} />
68
+ {externalName && <Property label="External Name" value={externalName} />}
69
+ {managementPolicies && managementPolicies.length > 0 && (
70
+ <Property label="Management Policies" value={managementPolicies.join(', ')} />
71
+ )}
72
+ {deletionPolicy && <Property label="Deletion Policy" value={deletionPolicy} />}
73
+ </PropertyList>
74
+ </Section>
75
+
76
+ {composingXR && (
77
+ <Section title="Composed By" icon={Layers} defaultExpanded>
78
+ <PropertyList>
79
+ <Property
80
+ label={composingXR.kind}
81
+ value={
82
+ <ResourceLink
83
+ name={composingXR.name}
84
+ kind={kindToPlural(composingXR.kind)}
85
+ namespace={namespace}
86
+ group={extractApiGroup(composingXR.apiVersion) || undefined}
87
+ onNavigate={onNavigate}
88
+ />
89
+ }
90
+ />
91
+ {composingXR.apiVersion && <Property label="API Version" value={composingXR.apiVersion} />}
92
+ </PropertyList>
93
+ </Section>
94
+ )}
95
+
96
+ {providerConfigRef && (
97
+ <Section title="Provider" icon={Cloud} defaultExpanded>
98
+ <PropertyList>
99
+ <Property
100
+ label="ProviderConfig"
101
+ value={
102
+ <ResourceLink
103
+ name={providerConfigRef.name}
104
+ kind="providerconfigs"
105
+ namespace={namespace}
106
+ group={extractApiGroup(providerConfigRef.apiVersion) || undefined}
107
+ onNavigate={onNavigate}
108
+ />
109
+ }
110
+ />
111
+ {providerConfigRef.kind && <Property label="Kind" value={providerConfigRef.kind} />}
112
+ </PropertyList>
113
+ </Section>
114
+ )}
115
+
116
+ {forProvider && (
117
+ <Section title="Spec — forProvider" icon={Settings} defaultExpanded={false}>
118
+ <CodeViewer code={JSON.stringify(forProvider, null, 2)} language="json" />
119
+ </Section>
120
+ )}
121
+
122
+ {atProvider && (
123
+ <Section title="Status — atProvider" icon={ScrollText} defaultExpanded={false}>
124
+ <CodeViewer code={JSON.stringify(atProvider, null, 2)} language="json" />
125
+ </Section>
126
+ )}
127
+
128
+ <ConditionsSection conditions={data?.status?.conditions} />
129
+ </>
130
+ )
131
+ }
@@ -0,0 +1,223 @@
1
+ import { Shield, Box, Users } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { Section, PropertyList, Property, ResourceLink } from '../../ui/drawer-components'
4
+ import type { RBACNamespaceResponse, RBACBindingWithSubjects, RBACSubject, ResourceRef } from '../../../types'
5
+ import { rbacKindBadgeClass } from '../../../utils/rbac-badges'
6
+
7
+ interface NamespaceRendererProps {
8
+ data: any
9
+ /**
10
+ * RBAC summary for this namespace fetched from /api/rbac/namespace/{ns}.
11
+ * Undefined when the host hasn't wired the fetch (RBAC section omitted).
12
+ * Null when the fetch failed; section shows a tactful note.
13
+ */
14
+ rbacData?: RBACNamespaceResponse | null
15
+ rbacLoading?: boolean
16
+ rbacError?: Error | null
17
+ onNavigate?: (ref: ResourceRef) => void
18
+ }
19
+
20
+ export function NamespaceRenderer({ data, rbacData, rbacLoading, rbacError, onNavigate }: NamespaceRendererProps) {
21
+ const metadata = data.metadata || {}
22
+ const status = data.status || {}
23
+ const phase = status.phase
24
+ const labels = metadata.labels || {}
25
+
26
+ // Common signal labels: control-plane / cluster-wide / managed-by-* markers.
27
+ // We surface them as a quick read since the generic labels section is
28
+ // collapsed in the sidebar.
29
+ const istioInjection = labels['istio-injection']
30
+ const linkerdInjection = labels['linkerd.io/inject']
31
+ const managedBy = labels['app.kubernetes.io/managed-by']
32
+
33
+ return (
34
+ <>
35
+ <Section title="Status" icon={Box}>
36
+ <PropertyList>
37
+ <Property label="Phase" value={
38
+ phase ? (
39
+ <span className={clsx(
40
+ phase === 'Active' && 'text-emerald-700 dark:text-emerald-400',
41
+ phase === 'Terminating' && 'text-orange-700 dark:text-orange-400',
42
+ )}>{phase}</span>
43
+ ) : undefined
44
+ } />
45
+ {istioInjection && <Property label="Istio injection" value={istioInjection} />}
46
+ {linkerdInjection && <Property label="Linkerd injection" value={linkerdInjection} />}
47
+ {managedBy && <Property label="Managed by" value={managedBy} />}
48
+ </PropertyList>
49
+ </Section>
50
+
51
+ {/* RBAC summary — only when host wired the fetch. */}
52
+ {rbacData !== undefined && (
53
+ <NamespaceRBACSection
54
+ rbacData={rbacData}
55
+ loading={!!rbacLoading}
56
+ error={rbacError ?? null}
57
+ onNavigate={onNavigate}
58
+ />
59
+ )}
60
+ </>
61
+ )
62
+ }
63
+
64
+ // ============================================================================
65
+ // NAMESPACE RBAC SECTION
66
+ // ============================================================================
67
+ // Answers "what RBAC is configured here" without forcing operators to pivot
68
+ // through individual SAs. We show two slices:
69
+ // 1. RoleBindings whose own namespace is this namespace (the most direct
70
+ // "configured here" answer).
71
+ // 2. ClusterRoleBindings with at least one ServiceAccount subject in this
72
+ // namespace (cluster-wide grants whose blast radius touches the
73
+ // namespace — what an attacker compromising a workload here would
74
+ // inherit beyond the local bindings).
75
+ // Cluster-wide bindings to wide groups (system:authenticated etc.) are
76
+ // excluded from #2 because they touch *every* namespace and would be noise.
77
+
78
+ function NamespaceRBACSection({
79
+ rbacData,
80
+ loading,
81
+ error,
82
+ onNavigate,
83
+ }: {
84
+ rbacData: RBACNamespaceResponse | null
85
+ loading: boolean
86
+ error: Error | null
87
+ onNavigate?: (ref: ResourceRef) => void
88
+ }) {
89
+ if (loading) {
90
+ return (
91
+ <Section title="RBAC" icon={Shield}>
92
+ <div className="text-sm text-theme-text-secondary">Loading RBAC summary…</div>
93
+ </Section>
94
+ )
95
+ }
96
+ if (error) {
97
+ return (
98
+ <Section title="RBAC" icon={Shield}>
99
+ <div className="text-sm text-red-400">Could not load RBAC summary: {error.message}</div>
100
+ </Section>
101
+ )
102
+ }
103
+ if (!rbacData) return null
104
+
105
+ const localBindings = rbacData.roleBindings ?? []
106
+ const clusterBindings = rbacData.clusterRoleBindingsWithLocalSubject ?? []
107
+ const totalBindings = localBindings.length + clusterBindings.length
108
+
109
+ return (
110
+ <>
111
+ <Section title="RBAC" icon={Shield} defaultExpanded>
112
+ <PropertyList>
113
+ <Property label="ServiceAccounts" value={rbacData.serviceAccountCount} />
114
+ <Property label="Bindings touching this namespace" value={totalBindings} />
115
+ </PropertyList>
116
+ </Section>
117
+
118
+ <Section title={`RoleBindings (${localBindings.length})`} icon={Users} defaultExpanded={localBindings.length > 0}>
119
+ {localBindings.length === 0 ? (
120
+ <div className="text-sm text-theme-text-secondary">
121
+ No RoleBindings are defined in this namespace.
122
+ </div>
123
+ ) : (
124
+ <div className="space-y-2">
125
+ {localBindings.map((b) => (
126
+ <BindingSummaryRow key={b.binding.kind + '/' + b.binding.namespace + '/' + b.binding.name} entry={b} onNavigate={onNavigate} />
127
+ ))}
128
+ </div>
129
+ )}
130
+ </Section>
131
+
132
+ {clusterBindings.length > 0 && (
133
+ <Section
134
+ title={`ClusterRoleBindings touching this namespace (${clusterBindings.length})`}
135
+ icon={Users}
136
+ >
137
+ <div className="space-y-2">
138
+ {clusterBindings.map((b) => (
139
+ <BindingSummaryRow key={b.binding.kind + '/' + b.binding.namespace + '/' + b.binding.name} entry={b} onNavigate={onNavigate} />
140
+ ))}
141
+ </div>
142
+ </Section>
143
+ )}
144
+ </>
145
+ )
146
+ }
147
+
148
+ function BindingSummaryRow({
149
+ entry,
150
+ onNavigate,
151
+ }: {
152
+ entry: RBACBindingWithSubjects
153
+ onNavigate?: (ref: ResourceRef) => void
154
+ }) {
155
+ const bindingKindPlural =
156
+ entry.binding.kind === 'RoleBinding' ? 'rolebindings' : 'clusterrolebindings'
157
+ const roleKindPlural =
158
+ entry.binding.roleRef.kind === 'Role' ? 'roles' : 'clusterroles'
159
+
160
+ return (
161
+ <div className="card-inner">
162
+ <div className="flex items-center gap-2 flex-wrap text-xs mb-1.5">
163
+ <span className={clsx('badge', rbacKindBadgeClass(entry.binding.kind))}>
164
+ {entry.binding.kind}
165
+ </span>
166
+ <ResourceLink
167
+ kind={bindingKindPlural}
168
+ namespace={entry.binding.namespace}
169
+ name={entry.binding.name}
170
+ onNavigate={onNavigate}
171
+ />
172
+ <span className="text-theme-text-secondary">→</span>
173
+ <span className={clsx('badge', rbacKindBadgeClass(entry.binding.roleRef.kind))}>
174
+ {entry.binding.roleRef.kind}
175
+ </span>
176
+ <ResourceLink
177
+ kind={roleKindPlural}
178
+ namespace={entry.binding.roleRef.namespace}
179
+ name={entry.binding.roleRef.name}
180
+ onNavigate={onNavigate}
181
+ />
182
+ </div>
183
+ {entry.subjects.length > 0 && (
184
+ <div className="text-xs text-theme-text-secondary">
185
+ {entry.subjects.length} subject{entry.subjects.length === 1 ? '' : 's'}:{' '}
186
+ <SubjectsInline subjects={entry.subjects} onNavigate={onNavigate} />
187
+ </div>
188
+ )}
189
+ </div>
190
+ )
191
+ }
192
+
193
+ function SubjectsInline({
194
+ subjects,
195
+ onNavigate,
196
+ }: {
197
+ subjects: RBACSubject[]
198
+ onNavigate?: (ref: ResourceRef) => void
199
+ }) {
200
+ const shown = subjects.slice(0, 3)
201
+ const hidden = subjects.length - shown.length
202
+ return (
203
+ <span className="inline-flex items-center gap-1.5 flex-wrap">
204
+ {shown.map((s, i) => (
205
+ <span key={i} className="inline-flex items-center gap-1">
206
+ <span className="text-theme-text-tertiary">{s.kind.toLowerCase()}:</span>
207
+ {s.kind === 'ServiceAccount' ? (
208
+ <ResourceLink
209
+ kind="serviceaccounts"
210
+ namespace={s.namespace}
211
+ name={s.name}
212
+ onNavigate={onNavigate}
213
+ />
214
+ ) : (
215
+ <span className="text-theme-text-primary">{s.name}</span>
216
+ )}
217
+ {i < shown.length - 1 && <span className="text-theme-text-tertiary">,</span>}
218
+ </span>
219
+ ))}
220
+ {hidden > 0 && <span className="text-theme-text-tertiary">+{hidden} more</span>}
221
+ </span>
222
+ )
223
+ }
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react'
1
2
  import { HardDrive } from 'lucide-react'
2
3
  import { clsx } from 'clsx'
3
4
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
@@ -5,6 +6,8 @@ import { Section, PropertyList, Property, ConditionsSection, AlertBanner, Resour
5
6
  interface PVCRendererProps {
6
7
  data: any
7
8
  onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
9
+ /** Optional host-provided section, used for a Prometheus-derived usage gauge. */
10
+ extraSections?: ReactNode
8
11
  }
9
12
 
10
13
  const accessModeShorthand: Record<string, string> = {
@@ -19,7 +22,7 @@ function formatAccessModes(modes: string[] | undefined): string | undefined {
19
22
  return modes.map(m => accessModeShorthand[m] || m).join(', ')
20
23
  }
21
24
 
22
- export function PVCRenderer({ data, onNavigate }: PVCRendererProps) {
25
+ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProps) {
23
26
  const status = data.status || {}
24
27
  const spec = data.spec || {}
25
28
  const annotations = data.metadata?.annotations || {}
@@ -92,6 +95,8 @@ export function PVCRenderer({ data, onNavigate }: PVCRendererProps) {
92
95
  </Section>
93
96
  )}
94
97
 
98
+ {extraSections}
99
+
95
100
  <ConditionsSection conditions={status.conditions} />
96
101
  </>
97
102
  )
@@ -1,10 +1,16 @@
1
1
  import { useState, type ReactNode, type JSX } from 'react'
2
- import { Server, HardDrive, Terminal as TerminalIcon, FileText, Activity, CirclePlay, FolderOpen, List, Eye, EyeOff } from 'lucide-react'
2
+ import { Server, HardDrive, Terminal as TerminalIcon, FileText, Activity, CirclePlay, FolderOpen, List, Eye, EyeOff, Shield } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink } from '../../ui/drawer-components'
5
5
  import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR } from '../resource-utils'
6
6
  import { getResourceStatusColor, SEVERITY_BADGE_BORDERED } from '../../../utils/badge-colors'
7
- import type { ResolvedEnvFrom } from '../../../types'
7
+ import {
8
+ rbacVerbBadgeClass,
9
+ rbacResourceBadgeClass,
10
+ rbacApiGroupBadgeClass,
11
+ } from '../../../utils/rbac-badges'
12
+ import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
13
+ import type { ResolvedEnvFrom, RBACSubjectResponse, RBACPolicyRule } from '../../../types'
8
14
  import { Tooltip } from '../../ui/Tooltip'
9
15
  import { MetricsChart } from '../../ui/MetricsChart'
10
16
 
@@ -75,6 +81,14 @@ interface PodRendererProps {
75
81
  * When provided, expands ConfigMap/Secret keys inline instead of showing "(all keys)".
76
82
  */
77
83
  resolvedEnvFrom?: ResolvedEnvFrom
84
+ /**
85
+ * RBAC reverse-lookup for the Pod's ServiceAccount. Undefined means the host
86
+ * didn't wire the fetch (Permissions section is omitted). Null means the
87
+ * fetch failed; the section renders an inline error.
88
+ */
89
+ rbacData?: RBACSubjectResponse | null
90
+ rbacLoading?: boolean
91
+ rbacError?: Error | null
78
92
  }
79
93
 
80
94
  // ── Env vars section — extracted to use hooks (useState for reveal) ──────────
@@ -240,6 +254,9 @@ export function PodRenderer({
240
254
  renderImageBrowser,
241
255
  renderPodBrowser,
242
256
  resolvedEnvFrom,
257
+ rbacData,
258
+ rbacLoading,
259
+ rbacError,
243
260
  }: PodRendererProps) {
244
261
  const containerStatuses = data.status?.containerStatuses || []
245
262
  const containers = data.spec?.containers || []
@@ -771,6 +788,22 @@ export function PodRenderer({
771
788
  {/* Conditions */}
772
789
  <ConditionsSection conditions={data.status?.conditions} />
773
790
 
791
+ {/* Permissions (via ServiceAccount) — placed below the diagnostic-
792
+ * signal sections (status, containers, resource usage, conditions)
793
+ * because it answers an incident/audit question ("if this Pod is
794
+ * compromised, what does the attacker get?"), not a daily-browsing
795
+ * one. Only renders when the host wired the RBAC fetch. */}
796
+ {rbacData !== undefined && (
797
+ <PodPermissionsSection
798
+ saName={data.spec?.serviceAccountName || 'default'}
799
+ namespace={data.metadata?.namespace || ''}
800
+ rbacData={rbacData}
801
+ loading={!!rbacLoading}
802
+ error={rbacError ?? null}
803
+ onNavigate={onNavigate}
804
+ />
805
+ )}
806
+
774
807
  {/* Image Filesystem Modal (via render prop) */}
775
808
  {selectedImage && renderImageBrowser && renderImageBrowser({
776
809
  image: selectedImage,
@@ -801,3 +834,175 @@ export function PodRenderer({
801
834
  </>
802
835
  )
803
836
  }
837
+
838
+ // ============================================================================
839
+ // POD PERMISSIONS SECTION (via ServiceAccount)
840
+ // ============================================================================
841
+ // Frames the SA's permissions in attacker terms — "if this Pod is compromised,
842
+ // here's what the attacker gets". No OSS dashboard surfaces this view cleanly
843
+ // today; the goal is to make blast radius legible without leaving the Pod page.
844
+
845
+ interface PodPermissionsSectionProps {
846
+ saName: string
847
+ namespace: string
848
+ rbacData: RBACSubjectResponse | null
849
+ loading: boolean
850
+ error: Error | null
851
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
852
+ }
853
+
854
+ // Verb categorization for the permissiveness scorer + blast-radius detector.
855
+ // Badge colors come from the shared rbacVerbBadgeClass (theme-aware).
856
+ // Blast-radius detection and scoring shared with Workload / ServiceAccount
857
+ // renderers — see utils/rbac-blast-radius.ts.
858
+
859
+ function PodPermissionsSection({
860
+ saName,
861
+ namespace,
862
+ rbacData,
863
+ loading,
864
+ error,
865
+ onNavigate,
866
+ }: PodPermissionsSectionProps) {
867
+ const title = `Permissions via ServiceAccount: ${saName}`
868
+
869
+ if (loading) {
870
+ return (
871
+ <Section title={title} icon={Shield}>
872
+ <div className="text-sm text-theme-text-tertiary">Loading RBAC graph…</div>
873
+ </Section>
874
+ )
875
+ }
876
+ if (error) {
877
+ return (
878
+ <Section title={title} icon={Shield}>
879
+ <div className="text-sm text-red-400">
880
+ Could not load permissions: {error.message}
881
+ </div>
882
+ </Section>
883
+ )
884
+ }
885
+ if (!rbacData) return null
886
+
887
+ const direct = rbacData.direct ?? []
888
+ const inheritedAll = (rbacData.inheritedFromGroups ?? []).flatMap((g) => g.bindings)
889
+ const inheritedCount = inheritedAll.length
890
+ const directCount = direct.length
891
+ const ruleCount = rbacData.flat?.length ?? 0
892
+
893
+ const blastReasons = detectBlastRadius(rbacData)
894
+
895
+ // Top-5 most-permissive rules across the full flat set.
896
+ const sortedRules = [...(rbacData.flat ?? [])].sort(
897
+ (a, b) => rulePermissivenessScore(b) - rulePermissivenessScore(a),
898
+ )
899
+ const previewRules = sortedRules.slice(0, 5)
900
+ const moreCount = Math.max(0, sortedRules.length - previewRules.length)
901
+
902
+ // Default collapsed: most operators opening a Pod want Status / Containers
903
+ // / Resource Usage / Events, not "what could this Pod do if compromised".
904
+ // That's an incident-response question, not daily-browsing. Auto-expand
905
+ // when something *is* risky so the page still shouts when it should.
906
+ const hasBlastRadius = blastReasons.length > 0
907
+ return (
908
+ <Section title={title} icon={Shield} defaultExpanded={hasBlastRadius}>
909
+ {/* Blast-radius alert — only when something risky was detected. */}
910
+ {blastReasons.length > 0 && (
911
+ <AlertBanner variant="warning" title="Blast radius">
912
+ <div className="text-xs">
913
+ If this Pod is compromised, the attacker inherits the
914
+ ServiceAccount's permissions, which include:
915
+ </div>
916
+ <ul className="mt-1.5 text-xs space-y-1">
917
+ {blastReasons.map((r, i) => (
918
+ <li key={i}>
919
+ <span className="text-theme-text-secondary">
920
+ {r.binding.binding.kind} <span className="font-medium">{r.binding.binding.name}</span>
921
+ </span>{' '}
922
+ <span className="text-theme-text-tertiary">{r.reason}</span>
923
+ </li>
924
+ ))}
925
+ </ul>
926
+ </AlertBanner>
927
+ )}
928
+
929
+ {/* One-line summary */}
930
+ <div className="text-xs text-theme-text-tertiary mb-3">
931
+ {directCount} direct binding{directCount === 1 ? '' : 's'} ·{' '}
932
+ {inheritedCount} inherited via group
933
+ {inheritedCount === 1 ? '' : 's'} ·{' '}
934
+ {ruleCount} distinct rule{ruleCount === 1 ? '' : 's'}
935
+ {rbacData.truncated && <span className="text-orange-400"> (truncated)</span>}
936
+ </div>
937
+
938
+ {/* Top-N most-permissive rules. When the SA has zero permissions,
939
+ * call that out explicitly — silence would look like a fetch error. */}
940
+ {previewRules.length === 0 ? (
941
+ <div className="text-sm text-theme-text-tertiary">
942
+ This ServiceAccount has no effective permissions in the cluster.
943
+ </div>
944
+ ) : (
945
+ <div className="space-y-1">
946
+ {previewRules.map((r, i) => (
947
+ <PodRulePreviewLine key={i} rule={r} />
948
+ ))}
949
+ {moreCount > 0 && (
950
+ <div className="text-xs text-theme-text-tertiary">
951
+ +{moreCount} more rule{moreCount === 1 ? '' : 's'} — open the
952
+ ServiceAccount to see the full grant.
953
+ </div>
954
+ )}
955
+ </div>
956
+ )}
957
+
958
+ {/* Footer link to the SA detail page where Effective Permissions
959
+ * has the per-binding provenance + full rules. */}
960
+ <div className="mt-3 text-xs">
961
+ <ResourceLink
962
+ name={saName}
963
+ kind="serviceaccounts"
964
+ namespace={namespace}
965
+ label="View full permissions →"
966
+ onNavigate={onNavigate}
967
+ />
968
+ </div>
969
+ </Section>
970
+ )
971
+ }
972
+
973
+ function PodRulePreviewLine({ rule }: { rule: RBACPolicyRule }) {
974
+ const verbs = rule.verbs ?? []
975
+ const resources = rule.resources ?? []
976
+ const nonResourceURLs = rule.nonResourceURLs ?? []
977
+ const groups = rule.apiGroups ?? []
978
+ const isNonResource = resources.length === 0 && nonResourceURLs.length > 0
979
+ return (
980
+ <div className="flex items-center gap-1 flex-wrap text-xs">
981
+ {verbs.map((v) => (
982
+ <span key={v} className={clsx('badge', rbacVerbBadgeClass(v))}>{v}</span>
983
+ ))}
984
+ <span className="text-theme-text-secondary">on</span>
985
+ {isNonResource ? (
986
+ nonResourceURLs.map((u) => (
987
+ <span key={u} className="badge font-mono bg-theme-elevated text-theme-text-secondary">{u}</span>
988
+ ))
989
+ ) : (
990
+ resources.map((r) => (
991
+ <span key={r} className={clsx('badge', rbacResourceBadgeClass)}>
992
+ {r === '*' ? '*' : r}
993
+ </span>
994
+ ))
995
+ )}
996
+ {!isNonResource && groups.length > 0 && groups.some((g) => g !== '') && (
997
+ <>
998
+ <span className="text-theme-text-secondary">in</span>
999
+ {groups.map((g) => (
1000
+ <span key={g} className={clsx('badge', rbacApiGroupBadgeClass)}>
1001
+ {g === '' ? 'core' : g}
1002
+ </span>
1003
+ ))}
1004
+ </>
1005
+ )}
1006
+ </div>
1007
+ )
1008
+ }