@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
@@ -105,6 +105,7 @@ import {
105
105
  ClusterNetworkPolicyRenderer,
106
106
  PodDisruptionBudgetRenderer,
107
107
  ServiceAccountRenderer,
108
+ NamespaceRenderer,
108
109
  RoleRenderer,
109
110
  RoleBindingRenderer,
110
111
  WebhookConfigRenderer,
@@ -199,7 +200,23 @@ import {
199
200
  AzureManagedControlPlaneRenderer,
200
201
  AzureManagedMachinePoolRenderer,
201
202
  AzureMachineRenderer,
203
+ ManagedResourceRenderer,
204
+ CompositeRenderer,
205
+ CrossplanePackageRenderer,
206
+ CrossplaneProviderConfigRenderer,
207
+ CompositionRenderer,
208
+ CompositionRevisionRenderer,
209
+ XRDRenderer,
202
210
  } from '../resources/renderers'
211
+ import type { ComposedRefStatus } from '../resources/renderers/CompositeRenderer'
212
+ import {
213
+ getCrossplaneStatus,
214
+ getProviderStatus,
215
+ getProviderConfigStatus,
216
+ isManagedResource,
217
+ isComposite,
218
+ isClaim,
219
+ } from '../resources/resource-utils-crossplane'
203
220
  import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom, TimelineEvent } from '../../types'
204
221
  import type { CopyHandler } from '../ui/drawer-components'
205
222
  import { AlertBanner } from '../ui/drawer-components'
@@ -229,6 +246,51 @@ export interface RendererOverrides {
229
246
  kind: string; data: any
230
247
  onNavigate?: (ref: ResourceRef) => void
231
248
  }>
249
+ // Optional override for Crossplane Composite / Claim — host wraps the
250
+ // package renderer to fan out per-composed-ref status fetches via React Query.
251
+ CompositeRenderer?: React.ComponentType<{
252
+ data: any
253
+ onNavigate?: (ref: ResourceRef) => void
254
+ composedRefStatuses?: Map<string, ComposedRefStatus>
255
+ }>
256
+ // ServiceAccount reverse-lookup: the host fetches /api/rbac/subject/... and
257
+ // feeds the result into the base renderer via this wrapper.
258
+ ServiceAccountRenderer?: React.ComponentType<{
259
+ data: any
260
+ onNavigate?: (ref: ResourceRef) => void
261
+ }>
262
+ // Role / ClusterRole reverse-lookup: host fetches /api/rbac/role/... so the
263
+ // detail page can show "who is bound to this role".
264
+ RoleRenderer?: React.ComponentType<{
265
+ data: any
266
+ onNavigate?: (ref: ResourceRef) => void
267
+ }>
268
+ // RoleBinding inline rules preview: host fetches the referenced Role/
269
+ // ClusterRole's rules so the binding view can show what's granted without
270
+ // a navigation step.
271
+ RoleBindingRenderer?: React.ComponentType<{
272
+ data: any
273
+ onNavigate?: (ref: ResourceRef) => void
274
+ }>
275
+ // Namespace RBAC summary: host fetches /api/rbac/namespace/{ns} so the
276
+ // namespace page can show bindings configured here without falling
277
+ // through to GenericRenderer.
278
+ NamespaceRenderer?: React.ComponentType<{
279
+ data: any
280
+ onNavigate?: (ref: ResourceRef) => void
281
+ }>
282
+ // HPA: host wraps the base renderer to add Prometheus-backed replicas /
283
+ // metric charts below the static spec data.
284
+ HPARenderer?: React.ComponentType<{
285
+ data: any
286
+ onNavigate?: (ref: ResourceRef) => void
287
+ }>
288
+ // PVC: host wraps the base renderer to add a kubelet-derived usage gauge
289
+ // when Prometheus is scraping kubelet endpoints.
290
+ PVCRenderer?: React.ComponentType<{
291
+ data: any
292
+ onNavigate?: (ref: ResourceRef) => void
293
+ }>
232
294
  }
233
295
 
234
296
  // Known resource types with specific renderers (module-level to avoid re-allocation)
@@ -243,7 +305,7 @@ const KNOWN_KINDS = new Set([
243
305
  'networkpolicies', 'networkpolicy',
244
306
  'ciliumnetworkpolicies', 'ciliumnetworkpolicy', 'ciliumclusterwidenetworkpolicies', 'ciliumclusterwidenetworkpolicy',
245
307
  'clusternetworkpolicies', 'clusternetworkpolicy',
246
- 'poddisruptionbudgets', 'serviceaccounts',
308
+ 'poddisruptionbudgets', 'serviceaccounts', 'namespaces',
247
309
  'roles', 'clusterroles', 'rolebindings', 'clusterrolebindings',
248
310
  'events', 'gitrepositories', 'ocirepositories', 'helmrepositories',
249
311
  'kustomizations', 'helmreleases', 'alerts', 'applications',
@@ -282,6 +344,12 @@ const KNOWN_KINDS = new Set([
282
344
  // Azure CAPI Infrastructure Provider
283
345
  'azuremanagedcontrolplanes', 'azuremanagedmachinepools', 'azuremachines',
284
346
  'azuremachinetemplates', 'azuremanagedclusters',
347
+ // Crossplane core (Managed Resources, Composites, and Claims are detected
348
+ // dynamically by spec shape — their plurals are unbounded so they're handled
349
+ // via fall-through, not enumerated in KNOWN_KINDS).
350
+ 'providers', 'providerconfigs',
351
+ 'compositeresourcedefinitions', 'compositions', 'compositionrevisions',
352
+ 'functions', 'configurations',
285
353
  ])
286
354
 
287
355
  // ============================================================================
@@ -355,12 +423,47 @@ export function ResourceRendererDispatch({
355
423
  }: ResourceRendererDispatchProps) {
356
424
  const kind = resource.kind.toLowerCase()
357
425
 
358
- const isKnownKind = KNOWN_KINDS.has(kind)
426
+ // Crossplane Managed Resources / Composites / Claims are detected by spec
427
+ // shape because their plurals are unbounded (one CRD kind per provider
428
+ // service). These flags suppress the GenericRenderer fall-through and route
429
+ // to the right Crossplane renderer below.
430
+ const isCrossplaneMR = isManagedResource(data)
431
+ const isCrossplaneClaim = !isCrossplaneMR && isClaim(data)
432
+ const isCrossplaneXR = !isCrossplaneMR && !isCrossplaneClaim && isComposite(data)
433
+
434
+ // Crossplane plurals that collide with foreign CRDs — `configurations`
435
+ // overlaps Knative serving.knative.dev/Configuration, `functions` could
436
+ // collide with OpenFaaS, `compositions` is in this list because its
437
+ // render line is apiVersion-gated. We add these to KNOWN_KINDS so the
438
+ // Crossplane renderer wins on apiVersion match, but a foreign CR with
439
+ // the same plural needs to fall through to GenericRenderer — otherwise
440
+ // it renders blank (no Crossplane match + isKnownKind suppresses
441
+ // generic). Only kinds whose render lines are apiVersion-gated belong
442
+ // here; `providerconfigs`/`compositionrevisions`/`compositeresource-
443
+ // definitions` are Crossplane-specific plurals with no realistic
444
+ // collision and unguarded render lines, so including them would risk
445
+ // double-render for foreign CRDs we'll never see.
446
+ const isCollisionGatedKind =
447
+ kind === 'providers' || kind === 'functions' || kind === 'configurations' || kind === 'compositions'
448
+ const crossplaneApiVersionMatched = isCollisionGatedKind && (
449
+ data?.apiVersion?.startsWith('pkg.crossplane.io/')
450
+ || data?.apiVersion?.startsWith('apiextensions.crossplane.io/')
451
+ )
452
+ const crossplaneCollisionFallthrough = isCollisionGatedKind && !crossplaneApiVersionMatched
453
+
454
+ const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
359
455
 
360
456
  const PodComp = rendererOverrides?.PodRenderer ?? PodRenderer
361
457
  const WorkloadComp = rendererOverrides?.WorkloadRenderer ?? WorkloadRenderer
362
458
  const NodeComp = rendererOverrides?.NodeRenderer ?? NodeRenderer
363
459
  const ServiceComp = rendererOverrides?.ServiceRenderer ?? ServiceRenderer
460
+ const CompositeComp = rendererOverrides?.CompositeRenderer ?? CompositeRenderer
461
+ const ServiceAccountComp = rendererOverrides?.ServiceAccountRenderer ?? ServiceAccountRenderer
462
+ const RoleComp = rendererOverrides?.RoleRenderer ?? RoleRenderer
463
+ const RoleBindingComp = rendererOverrides?.RoleBindingRenderer ?? RoleBindingRenderer
464
+ const NamespaceComp = rendererOverrides?.NamespaceRenderer ?? NamespaceRenderer
465
+ const HPAComp = rendererOverrides?.HPARenderer ?? HPARenderer
466
+ const PVCComp = rendererOverrides?.PVCRenderer ?? PVCRenderer
364
467
 
365
468
  const sidebarContent = showCommonSections && (
366
469
  <>
@@ -385,9 +488,9 @@ export function ResourceRendererDispatch({
385
488
  {kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
386
489
  {kind === 'jobs' && <JobRenderer data={data} />}
387
490
  {kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
388
- {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPARenderer data={data} onNavigate={onNavigate} />}
491
+ {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPAComp data={data} onNavigate={onNavigate} />}
389
492
  {kind === 'nodes' && <NodeComp data={data} relationships={relationships} />}
390
- {kind === 'persistentvolumeclaims' && <PVCRenderer data={data} onNavigate={onNavigate} />}
493
+ {kind === 'persistentvolumeclaims' && <PVCComp data={data} onNavigate={onNavigate} />}
391
494
  {kind === 'rollouts' && <RolloutRenderer data={data} />}
392
495
  {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <CertificateRenderer data={data} />}
393
496
  {kind === 'workflows' && <WorkflowRenderer data={data} />}
@@ -410,9 +513,10 @@ export function ResourceRendererDispatch({
410
513
  {(kind === 'ciliumnetworkpolicies' || kind === 'ciliumnetworkpolicy' || kind === 'ciliumclusterwidenetworkpolicies' || kind === 'ciliumclusterwidenetworkpolicy') && <CiliumNetworkPolicyRenderer data={data} />}
411
514
  {(kind === 'clusternetworkpolicies' || kind === 'clusternetworkpolicy') && <ClusterNetworkPolicyRenderer data={data} />}
412
515
  {kind === 'poddisruptionbudgets' && <PodDisruptionBudgetRenderer data={data} />}
413
- {kind === 'serviceaccounts' && <ServiceAccountRenderer data={data} />}
414
- {(kind === 'roles' || kind === 'clusterroles') && <RoleRenderer data={data} />}
415
- {(kind === 'rolebindings' || kind === 'clusterrolebindings') && <RoleBindingRenderer data={data} onNavigate={onNavigate} />}
516
+ {kind === 'serviceaccounts' && <ServiceAccountComp data={data} onNavigate={onNavigate} />}
517
+ {kind === 'namespaces' && <NamespaceComp data={data} onNavigate={onNavigate} />}
518
+ {(kind === 'roles' || kind === 'clusterroles') && <RoleComp data={data} onNavigate={onNavigate} />}
519
+ {(kind === 'rolebindings' || kind === 'clusterrolebindings') && <RoleBindingComp data={data} onNavigate={onNavigate} />}
416
520
  {kind === 'events' && <EventRenderer data={data} onNavigate={onNavigate} />}
417
521
  {kind === 'gitrepositories' && <GitRepositoryRenderer data={data} />}
418
522
  {kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
@@ -525,8 +629,23 @@ export function ResourceRendererDispatch({
525
629
  {/* Contour */}
526
630
  {kind === 'httpproxies' && <ContourHTTPProxyRenderer data={data} onNavigate={onNavigate} />}
527
631
 
528
- {/* Generic renderer for CRDs and unknown resource types */}
529
- {!isKnownKind && <GenericRenderer data={data} />}
632
+ {/* Crossplane — kind-dispatched for the static package/config kinds, spec-shape
633
+ detected for MR/XR/Claim (their plurals are unbounded). */}
634
+ {kind === 'providers' && data?.apiVersion?.startsWith('pkg.crossplane.io/') && <CrossplanePackageRenderer data={data} kindLabel="Provider" onNavigate={onNavigate} />}
635
+ {kind === 'functions' && data?.apiVersion?.startsWith('pkg.crossplane.io/') && <CrossplanePackageRenderer data={data} kindLabel="Function" onNavigate={onNavigate} />}
636
+ {kind === 'configurations' && data?.apiVersion?.startsWith('pkg.crossplane.io/') && <CrossplanePackageRenderer data={data} kindLabel="Configuration" onNavigate={onNavigate} />}
637
+ {kind === 'providerconfigs' && <CrossplaneProviderConfigRenderer data={data} onNavigate={onNavigate} />}
638
+ {kind === 'compositeresourcedefinitions' && <XRDRenderer data={data} onNavigate={onNavigate} />}
639
+ {kind === 'compositions' && data?.apiVersion?.startsWith('apiextensions.crossplane.io/') && <CompositionRenderer data={data} onNavigate={onNavigate} />}
640
+ {kind === 'compositionrevisions' && <CompositionRevisionRenderer data={data} onNavigate={onNavigate} />}
641
+ {isCrossplaneMR && <ManagedResourceRenderer data={data} onNavigate={onNavigate} />}
642
+ {(isCrossplaneXR || isCrossplaneClaim) && <CompositeComp data={data} onNavigate={onNavigate} />}
643
+
644
+ {/* Generic renderer for CRDs and unknown resource types — also fires
645
+ for known-plural collisions where no apiVersion-gated renderer
646
+ matched (e.g. a Knative Configuration sharing the `configurations`
647
+ plural with Crossplane Configuration). */}
648
+ {(!isKnownKind || crossplaneCollisionFallthrough) && <GenericRenderer data={data} />}
530
649
 
531
650
  {/* Common sections - can be disabled when parent handles them separately */}
532
651
  {showCommonSections && (
@@ -662,6 +781,16 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
662
781
  if (k === 'peerauthentications') return getPeerAuthenticationStatus(data)
663
782
  if (k === 'authorizationpolicies') return getAuthorizationPolicyStatus(data)
664
783
 
784
+ // Crossplane — Provider/Function/Configuration share package conditions;
785
+ // ProviderConfig has its own; MR/XR/Claim detected by spec shape.
786
+ if ((k === 'providers' || k === 'functions' || k === 'configurations') && data?.apiVersion?.startsWith('pkg.crossplane.io/')) {
787
+ return getProviderStatus(data)
788
+ }
789
+ if (k === 'providerconfigs') return getProviderConfigStatus(data)
790
+ if (isManagedResource(data) || isComposite(data) || isClaim(data)) {
791
+ return getCrossplaneStatus(data)
792
+ }
793
+
665
794
  // Contour HTTPProxy
666
795
  if (k === 'httpproxies') {
667
796
  const s = getHTTPProxyStatus(data)
@@ -1,5 +1,6 @@
1
1
  import { useRef, useCallback, useEffect } from 'react'
2
- import Editor, { OnMount, OnChange, type Monaco } from '@monaco-editor/react'
2
+ import Editor, { DiffEditor, OnMount, OnChange, type Monaco } from '@monaco-editor/react'
3
+ import { PaneLoader } from './PaneLoader'
3
4
  import type { editor } from 'monaco-editor'
4
5
 
5
6
  interface YamlEditorProps {
@@ -240,40 +241,52 @@ export function YamlEditor({
240
241
  )
241
242
  }
242
243
 
243
- // Diff editor for showing changes before apply
244
244
  interface YamlDiffEditorProps {
245
245
  original: string
246
246
  modified: string
247
247
  height?: string | number
248
+ /** When true, render unified (single column) instead of side-by-side. */
249
+ unified?: boolean
250
+ /** When true, only diff regions stay rendered — unchanged sections collapse. */
251
+ hideUnchanged?: boolean
252
+ /** Caller-controlled theme. Defaults to dark to match YamlEditor. */
253
+ theme?: 'vs-dark' | 'vs'
254
+ /** When true, drops the rounded border so the editor reaches its container's edges. */
255
+ bleed?: boolean
248
256
  }
249
257
 
250
258
  export function YamlDiffEditor({
251
- original: _original,
259
+ original,
252
260
  modified,
253
261
  height = '100%',
262
+ unified = false,
263
+ hideUnchanged = false,
264
+ theme = 'vs-dark',
265
+ bleed = false,
254
266
  }: YamlDiffEditorProps) {
255
- // TODO: Use Monaco DiffEditor to show actual diff
256
- void _original
257
267
  return (
258
- <div className="rounded-lg overflow-hidden border border-theme-border" style={{ height }}>
259
- <Editor
260
- defaultLanguage="yaml"
261
- value={modified}
262
- theme="vs-dark"
268
+ <div className={bleed ? 'overflow-hidden' : 'rounded-lg overflow-hidden border border-theme-border'} style={{ height }}>
269
+ <DiffEditor
270
+ original={original}
271
+ modified={modified}
272
+ language="yaml"
273
+ theme={theme}
263
274
  options={{
264
275
  readOnly: true,
276
+ renderSideBySide: !unified,
277
+ hideUnchangedRegions: { enabled: hideUnchanged },
265
278
  minimap: { enabled: false },
266
279
  lineNumbers: 'on',
267
280
  scrollBeyondLastLine: false,
281
+ wordWrap: 'on',
268
282
  fontSize: 13,
269
283
  fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
270
284
  padding: { top: 12, bottom: 12 },
285
+ renderOverviewRuler: true,
286
+ ignoreTrimWhitespace: false,
287
+ automaticLayout: true,
271
288
  }}
272
- loading={
273
- <div className="flex items-center justify-center h-full bg-theme-surface text-theme-text-secondary">
274
- Loading diff...
275
- </div>
276
- }
289
+ loading={<PaneLoader label="Loading resources…" className="h-full" />}
277
290
  />
278
291
  </div>
279
292
  )
@@ -37,7 +37,7 @@ interface ResourceDetailDrawerProps {
37
37
  }) => ReactNode
38
38
  }
39
39
 
40
- const MIN_WIDTH = 400
40
+ const MIN_WIDTH = 520
41
41
  const MAX_WIDTH_PERCENT = 0.7
42
42
  const DEFAULT_WIDTH = 550
43
43
  const WIDE_WIDTH = 750
package/src/index.ts CHANGED
@@ -42,3 +42,6 @@ export * from './components/audit'
42
42
 
43
43
  // Cluster switcher (shared trigger+dropdown for OSS Radar and Radar Hub)
44
44
  export * from './components/cluster-switcher'
45
+
46
+ // Compare (ResourceCompareView, CompareResourcePicker, normalize utilities)
47
+ export * from './components/compare'
@@ -2,3 +2,4 @@ export * from './core'
2
2
  export * from './gitops'
3
3
  export * from './gitops-tree'
4
4
  export * from './gitops-insights'
5
+ export * from './rbac'
@@ -0,0 +1,99 @@
1
+ // Wire types for the /api/rbac/* endpoints, kept in the shared package so
2
+ // both the host (which fetches) and the renderers (which display) agree on
3
+ // the shape. Mirrors the Go types in internal/server/rbac_handlers.go and
4
+ // pkg/rbac/index.go.
5
+
6
+ export interface RBACSubject {
7
+ kind: string // "ServiceAccount" | "User" | "Group"
8
+ namespace: string // empty for User/Group
9
+ name: string
10
+ }
11
+
12
+ export interface RBACRoleRef {
13
+ kind: string // "Role" | "ClusterRole"
14
+ namespace: string // empty for ClusterRole
15
+ name: string
16
+ }
17
+
18
+ export interface RBACBindingRef {
19
+ kind: string // "RoleBinding" | "ClusterRoleBinding"
20
+ namespace: string // empty for ClusterRoleBinding
21
+ name: string
22
+ roleRef: RBACRoleRef
23
+ }
24
+
25
+ export interface RBACPolicyRule {
26
+ verbs?: string[]
27
+ apiGroups?: string[]
28
+ resources?: string[]
29
+ resourceNames?: string[]
30
+ nonResourceURLs?: string[]
31
+ }
32
+
33
+ export interface RBACBindingRules {
34
+ binding: RBACBindingRef
35
+ role: RBACRoleRef
36
+ rules: RBACPolicyRule[]
37
+ /** Populated when a RoleBinding references a ClusterRole — rules apply
38
+ * only in this namespace. */
39
+ scopeNamespace?: string
40
+ }
41
+
42
+ export interface RBACInheritedGroup {
43
+ groupName: string // e.g. "system:authenticated"
44
+ bindings: RBACBindingRules[]
45
+ }
46
+
47
+ export interface RBACSubjectResponse {
48
+ subject: RBACSubject
49
+ direct: RBACBindingRules[]
50
+ inheritedFromGroups: RBACInheritedGroup[]
51
+ flat: RBACPolicyRule[]
52
+ truncated: boolean
53
+ /** Pods whose spec.serviceAccountName references this SA. Populated only
54
+ * for ServiceAccount subjects; undefined or empty for User/Group. */
55
+ usedByPods?: RBACPodRef[]
56
+ }
57
+
58
+ export interface RBACPodRef {
59
+ namespace: string
60
+ name: string
61
+ }
62
+
63
+ export interface RBACNamespaceResponse {
64
+ namespace: string
65
+ roleBindings: RBACBindingWithSubjects[]
66
+ /** ClusterRoleBindings with at least one subject in this namespace. */
67
+ clusterRoleBindingsWithLocalSubject: RBACBindingWithSubjects[]
68
+ serviceAccountCount: number
69
+ }
70
+
71
+ export interface RBACBindingWithSubjects {
72
+ binding: RBACBindingRef
73
+ subjects: RBACSubject[]
74
+ }
75
+
76
+ export interface RBACRoleResponse {
77
+ role: RBACRoleRef
78
+ bindings: RBACBindingWithSubjects[]
79
+ }
80
+
81
+ export interface RBACResourceRule {
82
+ verbs: string[]
83
+ apiGroups?: string[]
84
+ resources?: string[]
85
+ resourceNames?: string[]
86
+ }
87
+
88
+ export interface RBACNonResourceRule {
89
+ verbs: string[]
90
+ nonResourceURLs?: string[]
91
+ }
92
+
93
+ export interface RBACWhoamiResponse {
94
+ namespace: string
95
+ resourceRules: RBACResourceRule[]
96
+ nonResourceRules: RBACNonResourceRule[]
97
+ incomplete: boolean
98
+ evaluationError?: string
99
+ }
@@ -124,8 +124,11 @@ export function formatGroupName(group: string): string {
124
124
  'gateway.networking.k8s.io': 'Gateway API',
125
125
  'traefik.io': 'Traefik',
126
126
  'traefik.containo.us': 'Traefik',
127
+ 'crossplane.io': 'Crossplane',
127
128
  'pkg.crossplane.io': 'Crossplane',
128
129
  'apiextensions.crossplane.io': 'Crossplane',
130
+ 'helm.crossplane.io': 'Crossplane',
131
+ 'kubernetes.crossplane.io': 'Crossplane',
129
132
  'source.toolkit.fluxcd.io': 'Flux',
130
133
  'helm.toolkit.fluxcd.io': 'Flux',
131
134
  'kustomize.toolkit.fluxcd.io': 'Flux',
@@ -167,7 +170,12 @@ export function formatGroupName(group: string): string {
167
170
  'kubeflow.org': 'Kubeflow',
168
171
  'snapshot.storage.k8s.io': 'Snapshots',
169
172
  }
170
- return knownGroups[group] || group
173
+ if (knownGroups[group]) return knownGroups[group]
174
+ // Suffix rules — for unbounded provider group sets (Crossplane providers ship
175
+ // their own per-service groups like s3.aws.upbound.io, compute.gcp.upbound.io).
176
+ if (group.endsWith('.upbound.io')) return 'Crossplane'
177
+ if (group.endsWith('.crossplane.io')) return 'Crossplane'
178
+ return group
171
179
  }
172
180
 
173
181
  export function shortenGroupName(group: string): string {
@@ -16,3 +16,4 @@ export * from './view-transition'
16
16
  export * from './validators'
17
17
  export * from './gitops-owner'
18
18
  export * from './gitops-route'
19
+ export * from './rbac-badges'
@@ -0,0 +1,61 @@
1
+ // Theme-aware badge classes for RBAC display (verbs, resources, API groups).
2
+ // Reuses the same light/dark pairs as Badge.tsx's SEVERITY/KIND maps so the
3
+ // RBAC views read with the same contrast as the rest of the app — the
4
+ // inherited `text-*-400` foreground without a light counterpart was washing
5
+ // out on light backgrounds.
6
+ //
7
+ // Centralized here so SA / Pod / RoleBinding renderers and the
8
+ // MyPermissions dialog all stay in sync; previous duplicate copies drifted.
9
+
10
+ import { BADGE_SEVERITY_COLORS, BADGE_KIND_COLORS } from '../components/ui/Badge'
11
+ import {
12
+ RBAC_BLAST_READ_VERBS,
13
+ RBAC_BLAST_WRITE_VERBS,
14
+ RBAC_BLAST_DELETE_VERBS,
15
+ RBAC_BLAST_ESCALATION_VERBS,
16
+ } from './rbac-blast-radius'
17
+
18
+ /** Theme-aware classes for an RBAC verb chip. */
19
+ export function rbacVerbBadgeClass(verb: string): string {
20
+ if (verb === '*') return BADGE_SEVERITY_COLORS.error
21
+ if (RBAC_BLAST_ESCALATION_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.error
22
+ if (RBAC_BLAST_DELETE_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.error
23
+ if (RBAC_BLAST_WRITE_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.warning
24
+ if (RBAC_BLAST_READ_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.success
25
+ return BADGE_SEVERITY_COLORS.info
26
+ }
27
+
28
+ /** Theme-aware classes for an RBAC resource chip (pods, configmaps, …). */
29
+ export const rbacResourceBadgeClass: string =
30
+ BADGE_KIND_COLORS.HTTPRoute // purple-100/-700 in light, purple-950/-400 in dark
31
+
32
+ /**
33
+ * Theme-aware kind badge for the four RBAC kinds. We can't put these in
34
+ * Badge.tsx's KIND map without churn there, so reuse the closest existing
35
+ * palette entry: blue/info for namespaced (Role/RoleBinding), purple for
36
+ * cluster-scoped (ClusterRole/ClusterRoleBinding).
37
+ */
38
+ export function rbacKindBadgeClass(kind: string): string {
39
+ switch (kind) {
40
+ case 'Role':
41
+ case 'RoleBinding':
42
+ return BADGE_KIND_COLORS.Service // blue-100/-700 light, blue-950/-400 dark
43
+ case 'ClusterRole':
44
+ case 'ClusterRoleBinding':
45
+ return BADGE_KIND_COLORS.HelmRelease // purple-100/-700 light, purple-950/-400 dark
46
+ default:
47
+ return BADGE_KIND_COLORS.HelmRelease
48
+ }
49
+ }
50
+
51
+ /** Theme-aware classes for an API group chip (notification.toolkit.fluxcd.io, …). */
52
+ export const rbacApiGroupBadgeClass: string =
53
+ 'bg-theme-hover/60 text-theme-text-secondary border-theme-border'
54
+
55
+ /** Theme-aware classes for a resourceName chip (rare — `resourceNames: [...]`). */
56
+ export const rbacResourceNameBadgeClass: string =
57
+ BADGE_KIND_COLORS.PersistentVolumeClaim // cyan, distinct from resources
58
+
59
+ /** Theme-aware classes for a non-resource URL chip (/healthz etc). */
60
+ export const rbacNonResourceUrlBadgeClass: string =
61
+ BADGE_KIND_COLORS.Application // orange, distinct from everything above
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { detectBlastRadius, rulePermissivenessScore } from './rbac-blast-radius'
3
+ import type { RBACBindingRules, RBACSubjectResponse } from '../types/rbac'
4
+
5
+ // Skeleton helpers — keep fixtures terse so each test reads as a contract.
6
+ function binding(over: Partial<RBACBindingRules> = {}): RBACBindingRules {
7
+ return {
8
+ binding: { kind: 'RoleBinding', namespace: 'default', name: 'b', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } },
9
+ role: { kind: 'Role', namespace: 'default', name: 'r' },
10
+ rules: [],
11
+ ...over,
12
+ }
13
+ }
14
+
15
+ function subject(direct: RBACBindingRules[] = [], inherited: { groupName: string; bindings: RBACBindingRules[] }[] = []): RBACSubjectResponse {
16
+ return {
17
+ subject: { kind: 'ServiceAccount', namespace: 'default', name: 'app-sa' },
18
+ direct,
19
+ inheritedFromGroups: inherited,
20
+ flat: [],
21
+ truncated: false,
22
+ }
23
+ }
24
+
25
+ describe('detectBlastRadius', () => {
26
+ // The five documented triggers; each is a security-UX invariant the PR
27
+ // description and inline comments call load-bearing. If any of these
28
+ // silently regresses, the Pod blast-radius banner stops alarming on the
29
+ // exact scenarios it was added for.
30
+
31
+ it('flags cluster-admin role-name match', () => {
32
+ const b = binding({ role: { kind: 'ClusterRole', name: 'cluster-admin', namespace: '' } })
33
+ const reasons = detectBlastRadius(subject([b]))
34
+ expect(reasons).toHaveLength(1)
35
+ expect(reasons[0].reason).toMatch(/cluster-admin/)
36
+ })
37
+
38
+ it('flags verb wildcards', () => {
39
+ const b = binding({
40
+ rules: [{ verbs: ['*'], resources: ['pods'], apiGroups: [''] }],
41
+ })
42
+ const reasons = detectBlastRadius(subject([b]))
43
+ expect(reasons).toHaveLength(1)
44
+ expect(reasons[0].reason).toMatch(/verb wildcard/)
45
+ })
46
+
47
+ it('flags escalation verbs', () => {
48
+ const b = binding({
49
+ rules: [{ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] }],
50
+ })
51
+ const reasons = detectBlastRadius(subject([b]))
52
+ expect(reasons).toHaveLength(1)
53
+ expect(reasons[0].reason).toMatch(/escalate/)
54
+ })
55
+
56
+ it('flags impersonate and bind too', () => {
57
+ const b1 = binding({ binding: { kind: 'RoleBinding', namespace: 'default', name: 'b1', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } }, rules: [{ verbs: ['impersonate'], resources: ['users'], apiGroups: [''] }] })
58
+ const b2 = binding({ binding: { kind: 'RoleBinding', namespace: 'default', name: 'b2', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } }, rules: [{ verbs: ['bind'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] }] })
59
+ const reasons = detectBlastRadius(subject([b1, b2]))
60
+ expect(reasons).toHaveLength(2)
61
+ expect(reasons[0].reason).toMatch(/impersonate/)
62
+ expect(reasons[1].reason).toMatch(/bind/)
63
+ })
64
+
65
+ it('flags cluster-wide "create pods" but NOT namespace-scoped "create pods"', () => {
66
+ // The cluster-wide form is the lateral-movement vector — a Pod with
67
+ // namespace-scoped pod-create can only move within its namespace.
68
+ const cluster = binding({
69
+ binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'cluster-b', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
70
+ rules: [{ verbs: ['create'], resources: ['pods'], apiGroups: [''] }],
71
+ })
72
+ const namespaced = binding({
73
+ binding: { kind: 'RoleBinding', namespace: 'default', name: 'ns-b', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } },
74
+ rules: [{ verbs: ['create'], resources: ['pods'], apiGroups: [''] }],
75
+ })
76
+ expect(detectBlastRadius(subject([cluster]))).toHaveLength(1)
77
+ expect(detectBlastRadius(subject([namespaced]))).toHaveLength(0)
78
+ })
79
+
80
+ it('does NOT flag resource-only wildcards', () => {
81
+ // The `view` ClusterRole grants get/list/watch on `*` resources in
82
+ // every fluxcd CRD. Flagging this would alarm on every authenticated
83
+ // SA — calibration call documented in the source.
84
+ const b = binding({
85
+ rules: [{ verbs: ['get', 'list', 'watch'], resources: ['*'], apiGroups: ['source.toolkit.fluxcd.io'] }],
86
+ })
87
+ expect(detectBlastRadius(subject([b]))).toHaveLength(0)
88
+ })
89
+
90
+ it('dedupes by binding identity when multiple risky rules match', () => {
91
+ const b = binding({
92
+ rules: [
93
+ { verbs: ['*'], resources: ['pods'], apiGroups: [''] },
94
+ { verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] },
95
+ ],
96
+ })
97
+ const reasons = detectBlastRadius(subject([b]))
98
+ expect(reasons).toHaveLength(1)
99
+ })
100
+
101
+ it('includes inherited-from-group bindings, not just direct', () => {
102
+ const b = binding({
103
+ binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'inherited', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
104
+ rules: [{ verbs: ['*'], resources: ['secrets'], apiGroups: [''] }],
105
+ })
106
+ const reasons = detectBlastRadius(subject([], [{ groupName: 'system:authenticated', bindings: [b] }]))
107
+ expect(reasons).toHaveLength(1)
108
+ expect(reasons[0].reason).toMatch(/verb wildcard/)
109
+ })
110
+
111
+ it('returns empty for benign bindings', () => {
112
+ const b = binding({
113
+ rules: [{ verbs: ['get', 'list'], resources: ['configmaps'], apiGroups: [''] }],
114
+ })
115
+ expect(detectBlastRadius(subject([b]))).toHaveLength(0)
116
+ })
117
+ })
118
+
119
+ describe('rulePermissivenessScore', () => {
120
+ it('scores wildcards highest', () => {
121
+ const wildcard = rulePermissivenessScore({ verbs: ['*'], resources: ['*'], apiGroups: ['*'] })
122
+ const benign = rulePermissivenessScore({ verbs: ['get'], resources: ['configmaps'], apiGroups: [''] })
123
+ expect(wildcard).toBeGreaterThan(benign)
124
+ })
125
+
126
+ it('boosts cluster-wide "create pods"', () => {
127
+ const createPods = rulePermissivenessScore({ verbs: ['create'], resources: ['pods'], apiGroups: [''] })
128
+ const createOthers = rulePermissivenessScore({ verbs: ['create'], resources: ['configmaps'], apiGroups: [''] })
129
+ expect(createPods).toBeGreaterThan(createOthers)
130
+ })
131
+
132
+ it('scores escalation verbs higher than plain writes', () => {
133
+ const escalate = rulePermissivenessScore({ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] })
134
+ const patch = rulePermissivenessScore({ verbs: ['patch'], resources: ['configmaps'], apiGroups: [''] })
135
+ expect(escalate).toBeGreaterThan(patch)
136
+ })
137
+ })