@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,11 +1,64 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { UserCog, Key, Cloud, Shield, Users, AlertTriangle, Box } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { Section, PropertyList, Property, ResourceLink, AlertBanner } from '../../ui/drawer-components'
|
|
5
|
+
import type { ResourceRef } from '../../../types'
|
|
6
|
+
import type {
|
|
7
|
+
RBACSubjectResponse,
|
|
8
|
+
RBACBindingRules,
|
|
9
|
+
RBACPolicyRule,
|
|
10
|
+
RBACInheritedGroup,
|
|
11
|
+
} from '../../../types'
|
|
12
|
+
import {
|
|
13
|
+
rbacVerbBadgeClass,
|
|
14
|
+
rbacResourceBadgeClass,
|
|
15
|
+
rbacApiGroupBadgeClass,
|
|
16
|
+
rbacKindBadgeClass,
|
|
17
|
+
} from '../../../utils/rbac-badges'
|
|
18
|
+
import { RBAC_BLAST_ESCALATION_VERBS } from '../../../utils/rbac-blast-radius'
|
|
19
|
+
import { BADGE_SEVERITY_COLORS } from '../../ui/Badge'
|
|
3
20
|
|
|
4
21
|
interface ServiceAccountRendererProps {
|
|
5
22
|
data: any
|
|
23
|
+
/** RBAC reverse-lookup data fetched from /api/rbac/subject/ServiceAccount/{ns}/{name}.
|
|
24
|
+
* Undefined while loading; null when the fetch failed (renderer shows a tactful
|
|
25
|
+
* fallback). When omitted entirely (consumer didn't wire the fetch) the
|
|
26
|
+
* Bindings/Permissions sections are simply not rendered. */
|
|
27
|
+
rbacData?: RBACSubjectResponse | null
|
|
28
|
+
rbacLoading?: boolean
|
|
29
|
+
rbacError?: Error | null
|
|
30
|
+
/** Navigation callback for ResourceLinks pointing at bindings / roles. */
|
|
31
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
6
32
|
}
|
|
7
33
|
|
|
8
|
-
|
|
34
|
+
// "Dangerous" specifically — not the same as "permissive". Resource-wildcards
|
|
35
|
+
// with read-only verbs (e.g. `view` granting list/watch on `*` within a CRD
|
|
36
|
+
// group) are not alarming and were creating banner-fatigue on every SA. We
|
|
37
|
+
// only flag what a compromised SA could *act on*: verb wildcards, escalation
|
|
38
|
+
// verbs (escalate/bind/impersonate), and the cluster-admin role.
|
|
39
|
+
//
|
|
40
|
+
// Distinct from detectBlastRadius (utils/rbac-blast-radius.ts) — the SA page
|
|
41
|
+
// scopes to *direct* bindings only and returns boolean (does any direct
|
|
42
|
+
// binding qualify?), whereas the Pod/Workload pages walk direct + inherited
|
|
43
|
+
// and produce per-binding reasons. Sharing the implementation would force
|
|
44
|
+
// one of those to wear the wrong shape.
|
|
45
|
+
function bindingHasDangerousGrant(br: RBACBindingRules): boolean {
|
|
46
|
+
if (br.role.name === 'cluster-admin' && br.role.kind === 'ClusterRole') return true
|
|
47
|
+
return (br.rules ?? []).some((r) => {
|
|
48
|
+
const verbs = r.verbs ?? []
|
|
49
|
+
if (verbs.includes('*')) return true
|
|
50
|
+
if (verbs.some((v) => RBAC_BLAST_ESCALATION_VERBS.has(v))) return true
|
|
51
|
+
return false
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ServiceAccountRenderer({
|
|
56
|
+
data,
|
|
57
|
+
rbacData,
|
|
58
|
+
rbacLoading,
|
|
59
|
+
rbacError,
|
|
60
|
+
onNavigate,
|
|
61
|
+
}: ServiceAccountRendererProps) {
|
|
9
62
|
const metadata = data.metadata || {}
|
|
10
63
|
const annotations = metadata.annotations || {}
|
|
11
64
|
const secrets = data.secrets || []
|
|
@@ -20,8 +73,28 @@ export function ServiceAccountRenderer({ data }: ServiceAccountRendererProps) {
|
|
|
20
73
|
const azureClientId = annotations['azure.workload.identity/client-id']
|
|
21
74
|
const hasWorkloadIdentity = gcpServiceAccount || awsRoleArn || azureClientId
|
|
22
75
|
|
|
76
|
+
// Risk signal: scoped to *direct* bindings only. Inherited grants come from
|
|
77
|
+
// system groups every SA is in (system:authenticated, system:serviceaccounts),
|
|
78
|
+
// so flagging them here fires the banner on every SA — operators banner-blind
|
|
79
|
+
// and stop trusting the signal. Inherited risk surfaces in the per-binding
|
|
80
|
+
// rows below (the "permissive" chip) instead.
|
|
81
|
+
const dangerousBinding = (rbacData?.direct ?? []).find(bindingHasDangerousGrant)
|
|
82
|
+
|
|
23
83
|
return (
|
|
24
84
|
<>
|
|
85
|
+
{dangerousBinding && (
|
|
86
|
+
<AlertBanner
|
|
87
|
+
variant="warning"
|
|
88
|
+
title="Permissive grant detected"
|
|
89
|
+
message={
|
|
90
|
+
`This ServiceAccount is bound to ${dangerousBinding.role.kind} ` +
|
|
91
|
+
`"${dangerousBinding.role.name}", which grants verb wildcards or ` +
|
|
92
|
+
`privilege-escalation verbs. A compromise of this SA — leaked token, ` +
|
|
93
|
+
`compromised Pod — would give an attacker broad cluster access.`
|
|
94
|
+
}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
|
|
25
98
|
{/* Configuration */}
|
|
26
99
|
<Section title="Configuration" icon={UserCog}>
|
|
27
100
|
<PropertyList>
|
|
@@ -39,6 +112,27 @@ export function ServiceAccountRenderer({ data }: ServiceAccountRendererProps) {
|
|
|
39
112
|
</PropertyList>
|
|
40
113
|
</Section>
|
|
41
114
|
|
|
115
|
+
{/* RBAC: Direct Bindings + Effective Permissions. Only rendered when
|
|
116
|
+
* the host has wired the fetch (rbacData is not undefined). */}
|
|
117
|
+
{rbacData !== undefined && (
|
|
118
|
+
<RBACSections
|
|
119
|
+
rbacData={rbacData}
|
|
120
|
+
loading={!!rbacLoading}
|
|
121
|
+
error={rbacError ?? null}
|
|
122
|
+
onNavigate={onNavigate}
|
|
123
|
+
/>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{/* Pods using this SA — closes the loop. The bindings sections answer
|
|
127
|
+
* "what permissions"; this answers "what's actually running as this".
|
|
128
|
+
* Always rendered for SA subjects, even when the list is empty (the
|
|
129
|
+
* backend's omitempty drops zero-length slices, so we don't gate on
|
|
130
|
+
* the field's presence — `lonely-sa` should still show "No pods
|
|
131
|
+
* are running as this SA" rather than vanish from the page). */}
|
|
132
|
+
{rbacData && rbacData.subject.kind === 'ServiceAccount' && (
|
|
133
|
+
<UsedByPodsSection pods={rbacData.usedByPods ?? []} onNavigate={onNavigate} />
|
|
134
|
+
)}
|
|
135
|
+
|
|
42
136
|
{/* Workload Identity */}
|
|
43
137
|
{hasWorkloadIdentity && (
|
|
44
138
|
<Section title="Workload Identity" icon={Cloud}>
|
|
@@ -79,3 +173,362 @@ export function ServiceAccountRenderer({ data }: ServiceAccountRendererProps) {
|
|
|
79
173
|
</>
|
|
80
174
|
)
|
|
81
175
|
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// RBAC sections — pulled out so the main renderer body stays readable.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
interface RBACSectionsProps {
|
|
182
|
+
rbacData: RBACSubjectResponse | null
|
|
183
|
+
loading: boolean
|
|
184
|
+
error: Error | null
|
|
185
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function RBACSections({ rbacData, loading, error, onNavigate }: RBACSectionsProps) {
|
|
189
|
+
if (loading) {
|
|
190
|
+
return (
|
|
191
|
+
<Section title="Bindings" icon={Shield}>
|
|
192
|
+
<div className="text-sm text-theme-text-tertiary">Loading RBAC graph…</div>
|
|
193
|
+
</Section>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
if (error) {
|
|
197
|
+
return (
|
|
198
|
+
<Section title="Bindings" icon={Shield}>
|
|
199
|
+
<div className="text-sm text-red-400">
|
|
200
|
+
Could not load RBAC data: {error.message}
|
|
201
|
+
</div>
|
|
202
|
+
</Section>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
if (!rbacData) return null
|
|
206
|
+
|
|
207
|
+
const direct = rbacData.direct ?? []
|
|
208
|
+
const inherited = rbacData.inheritedFromGroups ?? []
|
|
209
|
+
const hasAnyBinding =
|
|
210
|
+
direct.length > 0 || inherited.some((g) => g.bindings.length > 0)
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<>
|
|
214
|
+
<Section
|
|
215
|
+
title={`Direct Bindings (${direct.length})`}
|
|
216
|
+
icon={Shield}
|
|
217
|
+
defaultExpanded
|
|
218
|
+
>
|
|
219
|
+
{direct.length === 0 ? (
|
|
220
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
221
|
+
No bindings reference this ServiceAccount directly.
|
|
222
|
+
{!hasAnyBinding &&
|
|
223
|
+
' It has no permissions beyond what implicit group memberships grant (see below).'}
|
|
224
|
+
</div>
|
|
225
|
+
) : (
|
|
226
|
+
<div className="space-y-2">
|
|
227
|
+
{direct.map((br) => (
|
|
228
|
+
<BindingRow key={br.binding.kind + '/' + br.binding.namespace + '/' + br.binding.name} br={br} onNavigate={onNavigate} />
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
</Section>
|
|
233
|
+
|
|
234
|
+
{/* Inherited from groups — collapsed by default (the load-bearing
|
|
235
|
+
* choice) so direct bindings stay the focal point. system:authenticated
|
|
236
|
+
* alone can drag in dozens of bindings on a real cluster; expanding
|
|
237
|
+
* them here would crowd out the actually-SA-specific direct bindings
|
|
238
|
+
* above and inflate the perceived blast radius. */}
|
|
239
|
+
{inherited.map((group) => (
|
|
240
|
+
<InheritedGroupSection
|
|
241
|
+
key={group.groupName}
|
|
242
|
+
group={group}
|
|
243
|
+
onNavigate={onNavigate}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
|
|
247
|
+
<EffectivePermissionsSection rbacData={rbacData} />
|
|
248
|
+
</>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function InheritedGroupSection({
|
|
253
|
+
group,
|
|
254
|
+
onNavigate,
|
|
255
|
+
}: {
|
|
256
|
+
group: RBACInheritedGroup
|
|
257
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
258
|
+
}) {
|
|
259
|
+
if (group.bindings.length === 0) return null
|
|
260
|
+
return (
|
|
261
|
+
<Section
|
|
262
|
+
title={`Inherited via ${group.groupName} (${group.bindings.length})`}
|
|
263
|
+
icon={Users}
|
|
264
|
+
defaultExpanded={false}
|
|
265
|
+
>
|
|
266
|
+
<div className="space-y-2">
|
|
267
|
+
{group.bindings.map((br) => (
|
|
268
|
+
<BindingRow
|
|
269
|
+
key={br.binding.kind + '/' + br.binding.namespace + '/' + br.binding.name}
|
|
270
|
+
br={br}
|
|
271
|
+
onNavigate={onNavigate}
|
|
272
|
+
inherited
|
|
273
|
+
/>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
</Section>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function BindingRow({
|
|
281
|
+
br,
|
|
282
|
+
onNavigate,
|
|
283
|
+
inherited,
|
|
284
|
+
}: {
|
|
285
|
+
br: RBACBindingRules
|
|
286
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
287
|
+
inherited?: boolean
|
|
288
|
+
}) {
|
|
289
|
+
// Map binding kind to the lowercase plural the resource-routing layer
|
|
290
|
+
// expects. The detail page route normalises these.
|
|
291
|
+
const bindingKindPlural =
|
|
292
|
+
br.binding.kind === 'RoleBinding' ? 'rolebindings' : 'clusterrolebindings'
|
|
293
|
+
const roleKindPlural =
|
|
294
|
+
br.role.kind === 'Role' ? 'roles' : 'clusterroles'
|
|
295
|
+
|
|
296
|
+
const risky = bindingHasDangerousGrant(br)
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<div className={clsx('card-inner', risky && 'border-orange-500/40')}>
|
|
300
|
+
{/* items-center + uniform text-xs so badges, arrow, and ResourceLink
|
|
301
|
+
* text share a baseline. items-start + a parent without a text size
|
|
302
|
+
* let ResourceLink inherit default sm/base while siblings were xs,
|
|
303
|
+
* producing visible baseline drift. */}
|
|
304
|
+
<div className="flex items-center gap-2 flex-wrap text-xs">
|
|
305
|
+
<span className={clsx('badge', rbacKindBadgeClass(br.binding.kind))}>
|
|
306
|
+
{br.binding.kind}
|
|
307
|
+
</span>
|
|
308
|
+
<ResourceLink
|
|
309
|
+
kind={bindingKindPlural}
|
|
310
|
+
namespace={br.binding.namespace}
|
|
311
|
+
name={br.binding.name}
|
|
312
|
+
onNavigate={onNavigate}
|
|
313
|
+
/>
|
|
314
|
+
<span className="text-theme-text-secondary">→</span>
|
|
315
|
+
<span className={clsx('badge', rbacKindBadgeClass(br.role.kind))}>
|
|
316
|
+
{br.role.kind}
|
|
317
|
+
</span>
|
|
318
|
+
<ResourceLink
|
|
319
|
+
kind={roleKindPlural}
|
|
320
|
+
namespace={br.role.namespace}
|
|
321
|
+
name={br.role.name}
|
|
322
|
+
onNavigate={onNavigate}
|
|
323
|
+
/>
|
|
324
|
+
{br.scopeNamespace && (
|
|
325
|
+
<span
|
|
326
|
+
className={clsx('badge', rbacApiGroupBadgeClass)}
|
|
327
|
+
title="RoleBinding grants a ClusterRole's rules only within this namespace"
|
|
328
|
+
>
|
|
329
|
+
scoped to {br.scopeNamespace}
|
|
330
|
+
</span>
|
|
331
|
+
)}
|
|
332
|
+
{risky && (
|
|
333
|
+
<span
|
|
334
|
+
className={clsx('badge inline-flex items-center gap-1', BADGE_SEVERITY_COLORS.alert)}
|
|
335
|
+
title="This binding grants wildcard or escalation permissions"
|
|
336
|
+
>
|
|
337
|
+
<AlertTriangle className="w-3 h-3" />
|
|
338
|
+
permissive
|
|
339
|
+
</span>
|
|
340
|
+
)}
|
|
341
|
+
{inherited && (
|
|
342
|
+
<span className="text-theme-text-secondary italic">
|
|
343
|
+
inherited
|
|
344
|
+
</span>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
{/* Rule preview — capped to keep the row compact. */}
|
|
348
|
+
{br.rules && br.rules.length > 0 && (
|
|
349
|
+
<RulesPreview rules={br.rules} max={3} />
|
|
350
|
+
)}
|
|
351
|
+
{br.rules && br.rules.length === 0 && (
|
|
352
|
+
<div className="mt-2 text-xs text-orange-400">
|
|
353
|
+
Referenced role has no rules (or could not be resolved — may be an orphan binding).
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function RulesPreview({ rules, max }: { rules: RBACPolicyRule[]; max: number }) {
|
|
361
|
+
const shown = rules.slice(0, max)
|
|
362
|
+
const hidden = rules.length - shown.length
|
|
363
|
+
return (
|
|
364
|
+
<div className="mt-2 space-y-1">
|
|
365
|
+
{shown.map((r, i) => (
|
|
366
|
+
<RuleLine key={i} rule={r} />
|
|
367
|
+
))}
|
|
368
|
+
{hidden > 0 && (
|
|
369
|
+
<div className="text-xs text-theme-text-tertiary">+{hidden} more rule{hidden === 1 ? '' : 's'}</div>
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function RuleLine({ rule }: { rule: RBACPolicyRule }) {
|
|
376
|
+
const verbs = rule.verbs ?? []
|
|
377
|
+
const resources = rule.resources ?? []
|
|
378
|
+
const nonResourceURLs = rule.nonResourceURLs ?? []
|
|
379
|
+
const groups = rule.apiGroups ?? []
|
|
380
|
+
// Non-resource rules (e.g. system:discovery grants `get` on /api, /healthz)
|
|
381
|
+
// have no resources/apiGroups — only URLs. Render those in place; suppresses
|
|
382
|
+
// the misleading bare "get on" with nothing after.
|
|
383
|
+
const isNonResource = resources.length === 0 && nonResourceURLs.length > 0
|
|
384
|
+
return (
|
|
385
|
+
<div className="flex items-center gap-1 flex-wrap text-xs">
|
|
386
|
+
{verbs.map((v) => (
|
|
387
|
+
<span key={v} className={clsx('badge text-xs', rbacVerbBadgeClass(v))}>{v}</span>
|
|
388
|
+
))}
|
|
389
|
+
<span className="text-theme-text-secondary">on</span>
|
|
390
|
+
{isNonResource ? (
|
|
391
|
+
nonResourceURLs.map((u) => (
|
|
392
|
+
<span key={u} className="badge text-xs font-mono bg-theme-elevated text-theme-text-secondary">{u}</span>
|
|
393
|
+
))
|
|
394
|
+
) : (
|
|
395
|
+
resources.map((r) => (
|
|
396
|
+
<span key={r} className={clsx('badge text-xs', rbacResourceBadgeClass)}>
|
|
397
|
+
{r}
|
|
398
|
+
</span>
|
|
399
|
+
))
|
|
400
|
+
)}
|
|
401
|
+
{!isNonResource && groups.length > 0 && groups.some((g) => g !== '') && (
|
|
402
|
+
<>
|
|
403
|
+
<span className="text-theme-text-secondary">in</span>
|
|
404
|
+
{groups.map((g) => (
|
|
405
|
+
<span key={g} className={clsx('badge text-xs', rbacApiGroupBadgeClass)}>
|
|
406
|
+
{g === '' ? 'core' : g}
|
|
407
|
+
</span>
|
|
408
|
+
))}
|
|
409
|
+
</>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Effective permissions: tab between per-binding (provenance preserved) and
|
|
416
|
+
// the flat deduplicated rule set ("does this SA have X anywhere?"). Collapsed
|
|
417
|
+
// by default — the Direct Bindings section above is the primary view.
|
|
418
|
+
function EffectivePermissionsSection({ rbacData }: { rbacData: RBACSubjectResponse }) {
|
|
419
|
+
const [tab, setTab] = useState<'by-binding' | 'flat'>('by-binding')
|
|
420
|
+
const allBindings: RBACBindingRules[] = [
|
|
421
|
+
...rbacData.direct,
|
|
422
|
+
...rbacData.inheritedFromGroups.flatMap((g) => g.bindings),
|
|
423
|
+
]
|
|
424
|
+
const ruleCount = rbacData.flat.length
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<Section
|
|
428
|
+
title={`Effective Permissions (${ruleCount}${rbacData.truncated ? '+' : ''} rule${ruleCount === 1 ? '' : 's'})`}
|
|
429
|
+
icon={Shield}
|
|
430
|
+
>
|
|
431
|
+
<div className="flex gap-1 mb-3">
|
|
432
|
+
<button
|
|
433
|
+
className={clsx(
|
|
434
|
+
'btn-brand-toggle text-xs px-2 py-1',
|
|
435
|
+
tab === 'by-binding' && 'bg-theme-elevated',
|
|
436
|
+
)}
|
|
437
|
+
onClick={() => setTab('by-binding')}
|
|
438
|
+
>
|
|
439
|
+
By binding
|
|
440
|
+
</button>
|
|
441
|
+
<button
|
|
442
|
+
className={clsx(
|
|
443
|
+
'btn-brand-toggle text-xs px-2 py-1',
|
|
444
|
+
tab === 'flat' && 'bg-theme-elevated',
|
|
445
|
+
)}
|
|
446
|
+
onClick={() => setTab('flat')}
|
|
447
|
+
>
|
|
448
|
+
All rules (flat)
|
|
449
|
+
</button>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
{rbacData.truncated && (
|
|
453
|
+
<div className="mb-2 text-xs text-orange-400">
|
|
454
|
+
Rule list capped at {ruleCount} entries — this SA has more
|
|
455
|
+
inherited bindings than fit in the summary. View the underlying
|
|
456
|
+
Role/ClusterRole YAML for the full picture.
|
|
457
|
+
</div>
|
|
458
|
+
)}
|
|
459
|
+
|
|
460
|
+
{tab === 'by-binding' && (
|
|
461
|
+
<div className="space-y-3">
|
|
462
|
+
{allBindings.length === 0 ? (
|
|
463
|
+
<div className="text-sm text-theme-text-tertiary">No effective permissions.</div>
|
|
464
|
+
) : (
|
|
465
|
+
allBindings.map((br, i) => (
|
|
466
|
+
<div key={i} className="card-inner-lg">
|
|
467
|
+
<div className="text-xs text-theme-text-tertiary mb-1">
|
|
468
|
+
Via {br.binding.kind} <span className="text-theme-text-secondary">{br.binding.name}</span>
|
|
469
|
+
{' → '}
|
|
470
|
+
{br.role.kind} <span className="text-theme-text-secondary">{br.role.name}</span>
|
|
471
|
+
</div>
|
|
472
|
+
<div className="space-y-1">
|
|
473
|
+
{(br.rules ?? []).map((r, j) => (
|
|
474
|
+
<RuleLine key={j} rule={r} />
|
|
475
|
+
))}
|
|
476
|
+
{(!br.rules || br.rules.length === 0) && (
|
|
477
|
+
<div className="text-xs text-theme-text-tertiary">No rules (orphan binding or empty role)</div>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
))
|
|
482
|
+
)}
|
|
483
|
+
</div>
|
|
484
|
+
)}
|
|
485
|
+
|
|
486
|
+
{tab === 'flat' && (
|
|
487
|
+
<div className="space-y-1">
|
|
488
|
+
{rbacData.flat.length === 0 ? (
|
|
489
|
+
<div className="text-sm text-theme-text-tertiary">No effective permissions.</div>
|
|
490
|
+
) : (
|
|
491
|
+
rbacData.flat.map((r, i) => <RuleLine key={i} rule={r} />)
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
</Section>
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ============================================================================
|
|
500
|
+
// USED BY PODS SECTION
|
|
501
|
+
// ============================================================================
|
|
502
|
+
// Closes the loop on the SA detail page: the bindings sections answer "what
|
|
503
|
+
// permissions" — this answers "what workloads inherit them". Operators
|
|
504
|
+
// reading top-down should be able to ask both questions without leaving.
|
|
505
|
+
|
|
506
|
+
function UsedByPodsSection({
|
|
507
|
+
pods,
|
|
508
|
+
onNavigate,
|
|
509
|
+
}: {
|
|
510
|
+
pods: { namespace: string; name: string }[]
|
|
511
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
512
|
+
}) {
|
|
513
|
+
return (
|
|
514
|
+
<Section title={`Used by Pods (${pods.length})`} icon={Box}>
|
|
515
|
+
{pods.length === 0 ? (
|
|
516
|
+
<div className="text-sm text-theme-text-secondary">
|
|
517
|
+
No Pods are currently running as this ServiceAccount.
|
|
518
|
+
</div>
|
|
519
|
+
) : (
|
|
520
|
+
<div className="flex flex-wrap gap-1.5 text-xs">
|
|
521
|
+
{pods.map((p) => (
|
|
522
|
+
<ResourceLink
|
|
523
|
+
key={p.namespace + '/' + p.name}
|
|
524
|
+
kind="pods"
|
|
525
|
+
namespace={p.namespace}
|
|
526
|
+
name={p.name}
|
|
527
|
+
onNavigate={onNavigate}
|
|
528
|
+
/>
|
|
529
|
+
))}
|
|
530
|
+
</div>
|
|
531
|
+
)}
|
|
532
|
+
</Section>
|
|
533
|
+
)
|
|
534
|
+
}
|