@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.
- package/package.json +6 -1
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +338 -57
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- 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
|
|
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
|
+
}
|