@skyhook-io/k8s-ui 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/resources/ResourcesView.tsx +338 -57
  25. package/src/components/resources/index.ts +1 -0
  26. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  27. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  28. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  29. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  30. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  31. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  32. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  33. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  34. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  35. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  36. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  37. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  39. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  40. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  41. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  42. package/src/components/resources/renderers/index.ts +8 -0
  43. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  44. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  45. package/src/components/resources/resource-utils-flux.ts +14 -0
  46. package/src/components/resources/resource-utils.ts +1 -0
  47. package/src/components/resources/resources-column-filter.test.ts +74 -0
  48. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  50. package/src/components/ui/YamlEditor.tsx +28 -15
  51. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  52. package/src/index.ts +3 -0
  53. package/src/types/index.ts +1 -0
  54. package/src/types/rbac.ts +99 -0
  55. package/src/utils/api-resources.ts +9 -1
  56. package/src/utils/index.ts +1 -0
  57. package/src/utils/rbac-badges.ts +61 -0
  58. package/src/utils/rbac-blast-radius.test.ts +137 -0
  59. package/src/utils/rbac-blast-radius.ts +98 -0
@@ -1,41 +1,67 @@
1
- import { Shield, Users } from 'lucide-react'
1
+ import { Shield, Users, Eye } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ResourceLink, AlertBanner } from '../../ui/drawer-components'
4
- import { BADGE_INACTIVE } from '../../../utils/badge-colors'
4
+ import type { ResourceRef, RBACPolicyRule } from '../../../types'
5
+ import {
6
+ rbacVerbBadgeClass,
7
+ rbacResourceBadgeClass,
8
+ rbacApiGroupBadgeClass,
9
+ rbacKindBadgeClass,
10
+ } from '../../../utils/rbac-badges'
11
+ import { BADGE_SEVERITY_COLORS } from '../../ui/Badge'
5
12
 
6
13
  interface RoleBindingRendererProps {
7
14
  data: any
8
- onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
15
+ onNavigate?: (ref: ResourceRef) => void
16
+ /** Rules from the referenced Role/ClusterRole. Undefined means the host
17
+ * hasn't wired the fetch (inline rules preview is omitted). Null means
18
+ * the fetch failed; the section shows a tactful note instead of silence. */
19
+ roleRules?: RBACPolicyRule[] | null
20
+ roleRulesLoading?: boolean
9
21
  }
10
22
 
23
+ // Wide groups whose membership effectively widens a binding beyond a named
24
+ // subject. Kept in sync with pkg/rbac/builtins.go (WideGroups). Mismatch
25
+ // here = misleading or missing warning, not a security flaw — but cross-
26
+ // check if you add to one side, add to the other.
27
+ const WIDE_GROUP_DESCRIPTIONS: Record<string, string> = {
28
+ 'system:authenticated':
29
+ 'every authenticated entity in the cluster, including external OIDC users',
30
+ 'system:unauthenticated':
31
+ 'every unauthenticated (anonymous) request',
32
+ 'system:masters':
33
+ 'the system:masters super-user group, which bypasses authorization',
34
+ }
35
+
36
+ function findWideGroupSubject(subjects: any[]): { name: string; description: string } | null {
37
+ for (const s of subjects) {
38
+ if (s.kind !== 'Group') continue
39
+ const d = WIDE_GROUP_DESCRIPTIONS[s.name]
40
+ if (d) return { name: s.name, description: d }
41
+ }
42
+ return null
43
+ }
44
+
45
+ // Subject-kind colors (ServiceAccount/User/Group). Reuse theme-aware palette
46
+ // classes for proper light/dark contrast.
11
47
  function getSubjectKindBadgeClass(kind: string): string {
12
48
  switch (kind) {
13
49
  case 'ServiceAccount':
14
- return 'bg-green-500/20 text-green-400'
50
+ return BADGE_SEVERITY_COLORS.success
15
51
  case 'User':
16
- return 'bg-blue-500/20 text-blue-400'
52
+ return BADGE_SEVERITY_COLORS.info
17
53
  case 'Group':
18
- return 'bg-purple-500/20 text-purple-400'
19
- default:
20
- return BADGE_INACTIVE
21
- }
22
- }
23
-
24
- function getRoleRefKindBadgeClass(kind: string): string {
25
- switch (kind) {
26
- case 'Role':
27
- return 'bg-blue-500/20 text-blue-400'
28
- case 'ClusterRole':
29
- return 'bg-purple-500/20 text-purple-400'
54
+ return rbacKindBadgeClass('ClusterRole') // purple — distinct from User
30
55
  default:
31
- return BADGE_INACTIVE
56
+ return BADGE_SEVERITY_COLORS.neutral
32
57
  }
33
58
  }
34
59
 
35
- export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererProps) {
60
+ export function RoleBindingRenderer({ data, onNavigate, roleRules, roleRulesLoading }: RoleBindingRendererProps) {
36
61
  const roleRef = data.roleRef || {}
37
62
  const subjects: any[] = data.subjects || []
38
63
  const isClusterRoleBinding = data.kind === 'ClusterRoleBinding'
64
+ const wideGroup = findWideGroupSubject(subjects)
39
65
 
40
66
  return (
41
67
  <>
@@ -47,6 +73,19 @@ export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererPro
47
73
  />
48
74
  )}
49
75
 
76
+ {/* Wide-group warning — describe the blast radius, don't just label it. */}
77
+ {wideGroup && (
78
+ <AlertBanner variant="warning" title={`Wide grant: ${wideGroup.name}`}>
79
+ <div className="text-xs">
80
+ This binding grants{' '}
81
+ <span className="text-theme-text-secondary font-medium">
82
+ {roleRef.kind || 'a role'}{roleRef.name ? ` "${roleRef.name}"` : ''}
83
+ </span>{' '}
84
+ to {wideGroup.description}.
85
+ </div>
86
+ </AlertBanner>
87
+ )}
88
+
50
89
  {isClusterRoleBinding && (
51
90
  <div className="p-2 bg-blue-500/10 border border-blue-500/30 rounded text-xs text-blue-300/80 flex items-start gap-2">
52
91
  <span>ClusterRoleBinding grants permissions across all namespaces.</span>
@@ -59,7 +98,7 @@ export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererPro
59
98
  label="Kind"
60
99
  value={
61
100
  roleRef.kind ? (
62
- <span className={clsx('badge', getRoleRefKindBadgeClass(roleRef.kind))}>
101
+ <span className={clsx('badge', rbacKindBadgeClass(roleRef.kind))}>
63
102
  {roleRef.kind}
64
103
  </span>
65
104
  ) : undefined
@@ -72,6 +111,16 @@ export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererPro
72
111
  </PropertyList>
73
112
  </Section>
74
113
 
114
+ {/* Inline rules preview — saves a click when operators are validating
115
+ * "what does this binding actually grant". Only when host wired fetch. */}
116
+ {roleRules !== undefined && (
117
+ <RulesPreviewSection
118
+ rules={roleRules}
119
+ loading={!!roleRulesLoading}
120
+ roleName={roleRef.name}
121
+ />
122
+ )}
123
+
75
124
  <Section title={`Subjects (${subjects.length})`} icon={Users} defaultExpanded>
76
125
  <div className="space-y-2">
77
126
  {subjects.map((subject: any, i: number) => (
@@ -85,6 +134,9 @@ export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererPro
85
134
  ) : (
86
135
  <span className="text-theme-text-primary font-medium">{subject.name}</span>
87
136
  )}
137
+ {subject.kind === 'Group' && WIDE_GROUP_DESCRIPTIONS[subject.name] && (
138
+ <span className={clsx('badge text-xs', BADGE_SEVERITY_COLORS.alert)}>wide</span>
139
+ )}
88
140
  </div>
89
141
  <div className="text-xs text-theme-text-tertiary mt-1">
90
142
  Namespace: {subject.kind === 'ServiceAccount' ? (subject.namespace || 'default') : '-'}
@@ -96,3 +148,63 @@ export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererPro
96
148
  </>
97
149
  )
98
150
  }
151
+
152
+ function RulesPreviewSection({
153
+ rules,
154
+ loading,
155
+ roleName,
156
+ }: {
157
+ rules: RBACPolicyRule[] | null
158
+ loading: boolean
159
+ roleName?: string
160
+ }) {
161
+ return (
162
+ <Section title="Rules Granted" icon={Eye} defaultExpanded={false}>
163
+ {loading ? (
164
+ <div className="text-sm text-theme-text-tertiary">Loading rules…</div>
165
+ ) : !rules ? (
166
+ <div className="text-sm text-theme-text-tertiary">
167
+ Could not resolve referenced role
168
+ {roleName ? ` "${roleName}"` : ''} — it may not exist (orphan binding)
169
+ or be unavailable.
170
+ </div>
171
+ ) : rules.length === 0 ? (
172
+ <div className="text-sm text-theme-text-tertiary">
173
+ The referenced role has no rules.
174
+ </div>
175
+ ) : (
176
+ <div className="space-y-1">
177
+ {rules.map((r, i) => {
178
+ const resources = r.resources ?? []
179
+ const nonResourceURLs = r.nonResourceURLs ?? []
180
+ // Non-resource rules (e.g. system:discovery grants `get` on
181
+ // /api, /healthz) have no resources/apiGroups - render the
182
+ // URLs in place so the line doesn't read as a bare "X on"
183
+ // with nothing after it.
184
+ const isNonResource = resources.length === 0 && nonResourceURLs.length > 0
185
+ return (
186
+ <div key={i} className="flex items-center gap-1 flex-wrap text-xs">
187
+ {(r.verbs ?? []).map((v) => (
188
+ <span key={v} className={clsx('badge', rbacVerbBadgeClass(v))}>{v}</span>
189
+ ))}
190
+ <span className="text-theme-text-secondary">on</span>
191
+ {isNonResource ? (
192
+ nonResourceURLs.map((u) => (
193
+ <span key={u} className="badge text-xs font-mono bg-theme-elevated text-theme-text-secondary">{u}</span>
194
+ ))
195
+ ) : (
196
+ resources.map((res) => (
197
+ <span key={res} className={clsx('badge', rbacResourceBadgeClass)}>{res}</span>
198
+ ))
199
+ )}
200
+ {!isNonResource && (r.apiGroups ?? []).filter((g) => g !== '').map((g) => (
201
+ <span key={g} className={clsx('badge', rbacApiGroupBadgeClass)}>{g}</span>
202
+ ))}
203
+ </div>
204
+ )
205
+ })}
206
+ </div>
207
+ )}
208
+ </Section>
209
+ )
210
+ }
@@ -1,24 +1,22 @@
1
- import { Shield, Info } from 'lucide-react'
1
+ import { Shield, Info, Users } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-components'
3
+ import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
+ import type { RBACRoleResponse, RBACSubject, ResourceRef } from '../../../types'
5
+ import { rbacKindBadgeClass, rbacVerbBadgeClass } from '../../../utils/rbac-badges'
4
6
 
5
7
  interface RoleRendererProps {
6
8
  data: any
9
+ /** Reverse-lookup data from /api/rbac/role/{kind}/{namespace}/{name}.
10
+ * Undefined when the host hasn't wired the fetch (Bindings section is
11
+ * omitted). Null when the fetch failed (renders an inline error). */
12
+ rbacRoleData?: RBACRoleResponse | null
13
+ rbacRoleLoading?: boolean
14
+ rbacRoleError?: Error | null
15
+ onNavigate?: (ref: ResourceRef) => void
7
16
  }
8
17
 
9
- const READ_VERBS = new Set(['get', 'list', 'watch'])
10
- const WRITE_VERBS = new Set(['create', 'update', 'patch'])
11
- const DELETE_VERBS = new Set(['delete', 'deletecollection'])
12
18
 
13
- function getVerbColor(verb: string): string {
14
- if (verb === '*') return 'bg-red-500/20 text-red-400'
15
- if (READ_VERBS.has(verb)) return 'bg-green-500/20 text-green-400'
16
- if (WRITE_VERBS.has(verb)) return 'bg-yellow-500/20 text-yellow-400'
17
- if (DELETE_VERBS.has(verb)) return 'bg-red-500/20 text-red-400'
18
- return 'bg-blue-500/20 text-blue-400'
19
- }
20
-
21
- export function RoleRenderer({ data }: RoleRendererProps) {
19
+ export function RoleRenderer({ data, rbacRoleData, rbacRoleLoading, rbacRoleError, onNavigate }: RoleRendererProps) {
22
20
  const rules = data.rules || []
23
21
  const aggregationRule = data.aggregationRule
24
22
 
@@ -36,6 +34,16 @@ export function RoleRenderer({ data }: RoleRendererProps) {
36
34
  />
37
35
  )}
38
36
 
37
+ {/* Bindings reverse-lookup — only when host wired the fetch. */}
38
+ {rbacRoleData !== undefined && (
39
+ <RoleBindingsSection
40
+ rbacRoleData={rbacRoleData}
41
+ loading={!!rbacRoleLoading}
42
+ error={rbacRoleError ?? null}
43
+ onNavigate={onNavigate}
44
+ />
45
+ )}
46
+
39
47
  {/* Overview */}
40
48
  <Section title="Overview" icon={Shield}>
41
49
  <PropertyList>
@@ -107,10 +115,7 @@ export function RoleRenderer({ data }: RoleRendererProps) {
107
115
  {rule.verbs.map((verb: string) => (
108
116
  <span
109
117
  key={verb}
110
- className={clsx(
111
- 'badge',
112
- getVerbColor(verb)
113
- )}
118
+ className={clsx('badge', rbacVerbBadgeClass(verb))}
114
119
  >
115
120
  {verb}
116
121
  </span>
@@ -190,3 +195,128 @@ export function RoleRenderer({ data }: RoleRendererProps) {
190
195
  </>
191
196
  )
192
197
  }
198
+
199
+ // ============================================================================
200
+ // ROLE BINDINGS REVERSE-LOOKUP SECTION
201
+ // ============================================================================
202
+
203
+ interface RoleBindingsSectionProps {
204
+ rbacRoleData: RBACRoleResponse | null
205
+ loading: boolean
206
+ error: Error | null
207
+ onNavigate?: (ref: ResourceRef) => void
208
+ }
209
+
210
+ function RoleBindingsSection({ rbacRoleData, loading, error, onNavigate }: RoleBindingsSectionProps) {
211
+ if (loading) {
212
+ return (
213
+ <Section title="Bindings" icon={Users}>
214
+ <div className="text-sm text-theme-text-tertiary">Loading bindings…</div>
215
+ </Section>
216
+ )
217
+ }
218
+ if (error) {
219
+ return (
220
+ <Section title="Bindings" icon={Users}>
221
+ <div className="text-sm text-red-400">
222
+ Could not load bindings: {error.message}
223
+ </div>
224
+ </Section>
225
+ )
226
+ }
227
+ if (!rbacRoleData) return null
228
+
229
+ const bindings = rbacRoleData.bindings ?? []
230
+ return (
231
+ <Section title={`Bindings (${bindings.length})`} icon={Users} defaultExpanded>
232
+ {bindings.length === 0 ? (
233
+ <div className="text-sm text-theme-text-tertiary">
234
+ No bindings reference this role. It grants no effective permissions.
235
+ </div>
236
+ ) : (
237
+ <div className="space-y-2">
238
+ {bindings.map((b) => {
239
+ const bindingKindPlural =
240
+ b.binding.kind === 'RoleBinding' ? 'rolebindings' : 'clusterrolebindings'
241
+ return (
242
+ <div key={b.binding.kind + '/' + b.binding.namespace + '/' + b.binding.name} className="card-inner">
243
+ {/* items-center + uniform text-xs so the badge, link, and
244
+ * "granted to N subjects" footnote share a baseline. */}
245
+ <div className="flex items-center gap-2 flex-wrap text-xs mb-2">
246
+ <span className={clsx('badge', rbacKindBadgeClass(b.binding.kind))}>
247
+ {b.binding.kind}
248
+ </span>
249
+ <ResourceLink
250
+ kind={bindingKindPlural}
251
+ namespace={b.binding.namespace}
252
+ name={b.binding.name}
253
+ onNavigate={onNavigate}
254
+ />
255
+ <span className="text-theme-text-secondary">
256
+ granted to {b.subjects.length} subject{b.subjects.length === 1 ? '' : 's'}
257
+ </span>
258
+ </div>
259
+ <RoleBindingSubjectsList subjects={b.subjects} onNavigate={onNavigate} />
260
+ </div>
261
+ )
262
+ })}
263
+ </div>
264
+ )}
265
+ </Section>
266
+ )
267
+ }
268
+
269
+ function RoleBindingSubjectsList({
270
+ subjects,
271
+ onNavigate,
272
+ }: {
273
+ subjects: RBACSubject[]
274
+ onNavigate?: (ref: ResourceRef) => void
275
+ }) {
276
+ const inline = subjects.slice(0, 3)
277
+ const hidden = subjects.length - inline.length
278
+ return (
279
+ <div className="flex flex-wrap gap-1 items-center text-xs">
280
+ {inline.map((s, i) => (
281
+ <RoleBindingSubjectChip key={i} subject={s} onNavigate={onNavigate} />
282
+ ))}
283
+ {hidden > 0 && (
284
+ <span className="text-theme-text-secondary">+{hidden} more</span>
285
+ )}
286
+ </div>
287
+ )
288
+ }
289
+
290
+ function RoleBindingSubjectChip({
291
+ subject,
292
+ onNavigate,
293
+ }: {
294
+ subject: RBACSubject
295
+ onNavigate?: (ref: ResourceRef) => void
296
+ }) {
297
+ // ServiceAccounts have a detail page; Users and Groups don't (those are
298
+ // external identities — there's no "User" resource in Kubernetes).
299
+ if (subject.kind === 'ServiceAccount') {
300
+ return (
301
+ <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-theme-elevated border border-theme-border">
302
+ <span className="text-theme-text-secondary">sa:</span>
303
+ <ResourceLink
304
+ kind="serviceaccounts"
305
+ namespace={subject.namespace}
306
+ name={subject.name}
307
+ onNavigate={onNavigate}
308
+ />
309
+ {subject.namespace && <span className="text-theme-text-secondary">({subject.namespace})</span>}
310
+ </span>
311
+ )
312
+ }
313
+ return (
314
+ <span
315
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-theme-elevated border border-theme-border text-theme-text-primary"
316
+ title={`${subject.kind} — no detail page (external identity)`}
317
+ >
318
+ <span className="text-theme-text-secondary">{subject.kind.toLowerCase()}:</span>
319
+ {subject.name}
320
+ </span>
321
+ )
322
+ }