@skyhook-io/k8s-ui 1.7.14 → 1.8.0
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 +7 -7
- package/src/components/applications/ApplicationsList.tsx +131 -115
- package/src/components/applications/index.ts +3 -1
- package/src/components/audit/AuditCard.tsx +1 -1
- package/src/components/charts/AreaChart.tsx +52 -18
- package/src/components/charts/MetricsSummary.tsx +12 -3
- package/src/components/charts/saturation.test.ts +34 -0
- package/src/components/charts/saturation.ts +1 -0
- package/src/components/charts/types.ts +5 -4
- package/src/components/checks/ChecksView.tsx +12 -23
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +298 -274
- package/src/components/issues/IssuesView.tsx +4 -6
- package/src/components/issues/severity.ts +19 -11
- package/src/components/issues/types.ts +22 -2
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +180 -21
- package/src/components/resources/get-pod-problems.test.ts +127 -0
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
- package/src/components/resources/renderers/HPARenderer.tsx +93 -86
- package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
- package/src/components/resources/renderers/PodRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
- package/src/components/resources/resource-utils-hpa.test.ts +30 -0
- package/src/components/resources/resource-utils-hpa.ts +106 -0
- package/src/components/resources/resource-utils.ts +41 -28
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- package/src/components/ui/DistributionBar.tsx +88 -0
- package/src/components/ui/Facet.tsx +130 -0
- package/src/components/ui/PageHeader.tsx +43 -0
- package/src/components/ui/SearchPillInput.tsx +237 -0
- package/src/components/ui/SortableTh.tsx +58 -0
- package/src/components/ui/SummaryTile.tsx +60 -0
- package/src/components/ui/drawer-components.tsx +56 -17
- package/src/components/ui/index.ts +12 -0
- package/src/components/workload/WorkloadView.tsx +18 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +7 -3
- package/src/types/core.ts +64 -2
- package/src/utils/bulk-workload-actions.test.ts +78 -0
- package/src/utils/bulk-workload-actions.ts +50 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-blast-radius.test.ts +48 -3
- package/src/utils/rbac-blast-radius.ts +14 -4
- package/tsconfig.json +1 -0
package/src/types/core.ts
CHANGED
|
@@ -47,6 +47,15 @@ export const OPTIONAL_RESOURCE_KINDS: ReadonlyArray<keyof ResourcePermissions> =
|
|
|
47
47
|
'verticalPodAutoscalers',
|
|
48
48
|
]
|
|
49
49
|
|
|
50
|
+
// Per-workload write permissions. Field names must match
|
|
51
|
+
// WorkloadWritePermissions in internal/k8s/capabilities.go.
|
|
52
|
+
export interface WorkloadWritePermissions {
|
|
53
|
+
deployments: boolean
|
|
54
|
+
daemonSets: boolean
|
|
55
|
+
statefulSets: boolean
|
|
56
|
+
rollouts: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
// Feature capabilities based on RBAC permissions
|
|
51
60
|
export interface Capabilities {
|
|
52
61
|
exec: boolean // Terminal feature (pods/exec)
|
|
@@ -57,6 +66,7 @@ export interface Capabilities {
|
|
|
57
66
|
secretsUpdate: boolean // Update secrets (inline editing)
|
|
58
67
|
helmWrite: boolean // Helm write operations (install, upgrade, rollback, uninstall, apply values)
|
|
59
68
|
nodeWrite: boolean // Node write operations (cordon, uncordon, drain)
|
|
69
|
+
workloadWrites?: WorkloadWritePermissions // Workload patch permissions (restart/scale controls)
|
|
60
70
|
mcpEnabled: boolean // MCP server is running
|
|
61
71
|
// How / where this Radar binary is running. Optional on the wire so a
|
|
62
72
|
// newer frontend (e.g. radar-hub-web bundling a fresher @skyhook-io/radar-app)
|
|
@@ -462,11 +472,63 @@ export interface SecretCertificateInfo {
|
|
|
462
472
|
certificates: CertificateInfo[]
|
|
463
473
|
}
|
|
464
474
|
|
|
475
|
+
export type HPADiagnosisState =
|
|
476
|
+
| 'ok'
|
|
477
|
+
| 'scaling_up'
|
|
478
|
+
| 'scaling_down'
|
|
479
|
+
| 'limited_max'
|
|
480
|
+
| 'limited_min'
|
|
481
|
+
| 'metrics_unavailable'
|
|
482
|
+
| 'metrics_incomplete'
|
|
483
|
+
| 'unable_to_scale'
|
|
484
|
+
| 'disabled'
|
|
485
|
+
| 'pinned'
|
|
486
|
+
| 'stale'
|
|
487
|
+
| 'stabilized'
|
|
488
|
+
| 'unknown'
|
|
489
|
+
|
|
490
|
+
export interface HPADiagnosis {
|
|
491
|
+
state: HPADiagnosisState
|
|
492
|
+
summary: string
|
|
493
|
+
target: {
|
|
494
|
+
apiVersion?: string
|
|
495
|
+
kind?: string
|
|
496
|
+
name?: string
|
|
497
|
+
}
|
|
498
|
+
bounds: {
|
|
499
|
+
min: number
|
|
500
|
+
max: number
|
|
501
|
+
current: number
|
|
502
|
+
desired: number
|
|
503
|
+
observedGeneration?: number
|
|
504
|
+
generation?: number
|
|
505
|
+
}
|
|
506
|
+
metrics?: HPAMetricSummary[]
|
|
507
|
+
reasons?: HPAReasonSummary[]
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export interface HPAReasonSummary {
|
|
511
|
+
id: string
|
|
512
|
+
message: string
|
|
513
|
+
detail?: string
|
|
514
|
+
conditionType?: string
|
|
515
|
+
conditionReason?: string
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export interface HPAMetricSummary {
|
|
519
|
+
type: string
|
|
520
|
+
name: string
|
|
521
|
+
current?: string
|
|
522
|
+
target?: string
|
|
523
|
+
status: string
|
|
524
|
+
}
|
|
525
|
+
|
|
465
526
|
// Resource with computed relationships and optional certificate info (API response wrapper)
|
|
466
527
|
export interface ResourceWithRelationships<T = unknown> {
|
|
467
528
|
resource: T
|
|
468
529
|
relationships?: Relationships
|
|
469
530
|
certificateInfo?: SecretCertificateInfo
|
|
531
|
+
hpaDiagnosis?: HPADiagnosis
|
|
470
532
|
}
|
|
471
533
|
|
|
472
534
|
// API Resource (from discovery endpoint)
|
|
@@ -937,11 +999,11 @@ export interface TrafficFilters {
|
|
|
937
999
|
timeRange: string
|
|
938
1000
|
}
|
|
939
1001
|
|
|
940
|
-
// Main view type now includes 'traffic', 'cost', '
|
|
1002
|
+
// Main view type now includes 'traffic', 'cost', 'checks', 'gitops'.
|
|
941
1003
|
// Library consumers (Radar Hub) get all GitOps surfaces — the package
|
|
942
1004
|
// IS the public surface, so adding new top-level views must extend
|
|
943
1005
|
// this type rather than rely on app-local extensions.
|
|
944
|
-
export type ExtendedMainView = MainView | 'traffic' | 'cost' | '
|
|
1006
|
+
export type ExtendedMainView = MainView | 'traffic' | 'cost' | 'checks' | 'gitops' | 'issues' | 'applications'
|
|
945
1007
|
|
|
946
1008
|
// ============================================================================
|
|
947
1009
|
// Image Filesystem Types
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
canBulkRestartKind,
|
|
4
|
+
canBulkScaleKind,
|
|
5
|
+
intersectWorkloadWrites,
|
|
6
|
+
} from './bulk-workload-actions'
|
|
7
|
+
import type { Capabilities, WorkloadWritePermissions } from '../types/core'
|
|
8
|
+
|
|
9
|
+
const writes = (overrides: Partial<WorkloadWritePermissions> = {}): WorkloadWritePermissions => ({
|
|
10
|
+
deployments: false,
|
|
11
|
+
daemonSets: false,
|
|
12
|
+
statefulSets: false,
|
|
13
|
+
rollouts: false,
|
|
14
|
+
...overrides,
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const caps = (workloadWrites: WorkloadWritePermissions): Pick<Capabilities, 'workloadWrites'> => ({
|
|
18
|
+
workloadWrites,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('bulk workload action gating', () => {
|
|
22
|
+
it('allows restart for patchable workload kinds', () => {
|
|
23
|
+
const all = writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })
|
|
24
|
+
|
|
25
|
+
expect(canBulkRestartKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
|
|
26
|
+
expect(canBulkRestartKind({ name: 'daemonsets', group: 'apps' }, all)).toBe(true)
|
|
27
|
+
expect(canBulkRestartKind({ name: 'statefulsets', group: 'apps' }, all)).toBe(true)
|
|
28
|
+
expect(canBulkRestartKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('requires the apps group for built-in workload restart', () => {
|
|
32
|
+
const all = writes({ deployments: true, daemonSets: true, statefulSets: true })
|
|
33
|
+
|
|
34
|
+
expect(canBulkRestartKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
|
|
35
|
+
expect(canBulkRestartKind({ name: 'deployments', group: 'example.com' }, all)).toBe(false)
|
|
36
|
+
expect(canBulkRestartKind({ name: 'deployments' }, all)).toBe(false)
|
|
37
|
+
expect(canBulkRestartKind({ name: 'daemonsets', group: 'example.com' }, all)).toBe(false)
|
|
38
|
+
expect(canBulkRestartKind({ name: 'statefulsets', group: 'example.com' }, all)).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('requires the Argo group for rollout restart', () => {
|
|
42
|
+
const all = writes({ rollouts: true })
|
|
43
|
+
|
|
44
|
+
expect(canBulkRestartKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(true)
|
|
45
|
+
expect(canBulkRestartKind({ name: 'rollouts', group: 'example.com' }, all)).toBe(false)
|
|
46
|
+
expect(canBulkRestartKind({ name: 'rollouts' }, all)).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('allows scale only for deployments and statefulsets', () => {
|
|
50
|
+
const all = writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })
|
|
51
|
+
|
|
52
|
+
expect(canBulkScaleKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
|
|
53
|
+
expect(canBulkScaleKind({ name: 'statefulsets', group: 'apps' }, all)).toBe(true)
|
|
54
|
+
expect(canBulkScaleKind({ name: 'daemonsets', group: 'apps' }, all)).toBe(false)
|
|
55
|
+
expect(canBulkScaleKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(false)
|
|
56
|
+
expect(canBulkScaleKind({ name: 'deployments', group: 'example.com' }, all)).toBe(false)
|
|
57
|
+
expect(canBulkScaleKind({ name: 'deployments' }, all)).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('withholds actions when permissions are absent', () => {
|
|
61
|
+
expect(canBulkRestartKind({ name: 'deployments', group: 'apps' }, undefined)).toBe(false)
|
|
62
|
+
expect(canBulkScaleKind({ name: 'deployments', group: 'apps' }, undefined)).toBe(false)
|
|
63
|
+
expect(intersectWorkloadWrites(undefined)).toBeUndefined()
|
|
64
|
+
expect(intersectWorkloadWrites([])).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('intersects multi-namespace permissions with AND semantics', () => {
|
|
68
|
+
expect(intersectWorkloadWrites([
|
|
69
|
+
caps(writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })),
|
|
70
|
+
caps(writes({ deployments: true, statefulSets: true })),
|
|
71
|
+
])).toEqual({
|
|
72
|
+
deployments: true,
|
|
73
|
+
daemonSets: false,
|
|
74
|
+
statefulSets: true,
|
|
75
|
+
rollouts: false,
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Capabilities, WorkloadWritePermissions } from '../types/core'
|
|
2
|
+
|
|
3
|
+
export type BulkWorkloadKindInfo = {
|
|
4
|
+
name: string
|
|
5
|
+
group?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function canBulkRestartKind(
|
|
9
|
+
kind: BulkWorkloadKindInfo | null | undefined,
|
|
10
|
+
writes: WorkloadWritePermissions | undefined,
|
|
11
|
+
): boolean {
|
|
12
|
+
switch (kind?.name.toLowerCase()) {
|
|
13
|
+
case 'deployments':
|
|
14
|
+
return kind.group === 'apps' && writes?.deployments === true
|
|
15
|
+
case 'daemonsets':
|
|
16
|
+
return kind.group === 'apps' && writes?.daemonSets === true
|
|
17
|
+
case 'statefulsets':
|
|
18
|
+
return kind.group === 'apps' && writes?.statefulSets === true
|
|
19
|
+
case 'rollouts':
|
|
20
|
+
return kind.group === 'argoproj.io' && writes?.rollouts === true
|
|
21
|
+
default:
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function canBulkScaleKind(
|
|
27
|
+
kind: BulkWorkloadKindInfo | null | undefined,
|
|
28
|
+
writes: WorkloadWritePermissions | undefined,
|
|
29
|
+
): boolean {
|
|
30
|
+
switch (kind?.name.toLowerCase()) {
|
|
31
|
+
case 'deployments':
|
|
32
|
+
return kind.group === 'apps' && writes?.deployments === true
|
|
33
|
+
case 'statefulsets':
|
|
34
|
+
return kind.group === 'apps' && writes?.statefulSets === true
|
|
35
|
+
default:
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function intersectWorkloadWrites(
|
|
41
|
+
capabilities: Array<Pick<Capabilities, 'workloadWrites'>> | undefined,
|
|
42
|
+
): WorkloadWritePermissions | undefined {
|
|
43
|
+
if (!capabilities || capabilities.length === 0) return undefined
|
|
44
|
+
return {
|
|
45
|
+
deployments: capabilities.every(c => c.workloadWrites?.deployments === true),
|
|
46
|
+
daemonSets: capabilities.every(c => c.workloadWrites?.daemonSets === true),
|
|
47
|
+
statefulSets: capabilities.every(c => c.workloadWrites?.statefulSets === true),
|
|
48
|
+
rollouts: capabilities.every(c => c.workloadWrites?.rollouts === true),
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -35,15 +35,59 @@ describe('detectBlastRadius', () => {
|
|
|
35
35
|
expect(reasons[0].reason).toMatch(/cluster-admin/)
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
-
it('flags
|
|
38
|
+
it('flags an admin-shaped grant via ClusterRoleBinding', () => {
|
|
39
|
+
// apiGroups/resources/verbs all `*` bound cluster-wide is god-mode.
|
|
39
40
|
const b = binding({
|
|
40
|
-
|
|
41
|
+
binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'admin-crb', roleRef: { kind: 'ClusterRole', namespace: '', name: 'admin' } },
|
|
42
|
+
rules: [{ verbs: ['*'], resources: ['*'], apiGroups: ['*'] }],
|
|
41
43
|
})
|
|
42
44
|
const reasons = detectBlastRadius(subject([b]))
|
|
43
45
|
expect(reasons).toHaveLength(1)
|
|
44
46
|
expect(reasons[0].reason).toMatch(/verb wildcard/)
|
|
45
47
|
})
|
|
46
48
|
|
|
49
|
+
it('does NOT flag the same admin-shaped grant via a namespace RoleBinding', () => {
|
|
50
|
+
// A RoleBinding is namespace-scoped by apiserver construction — even
|
|
51
|
+
// `*/*/*` is namespace-admin, not cluster takeover, so it stays collapsed.
|
|
52
|
+
const b = binding({
|
|
53
|
+
rules: [{ verbs: ['*'], resources: ['*'], apiGroups: ['*'] }],
|
|
54
|
+
})
|
|
55
|
+
expect(detectBlastRadius(subject([b]))).toHaveLength(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('flags verb wildcards on a cluster-wide binding (incl. cluster-wide secrets)', () => {
|
|
59
|
+
// Cluster-wide `verbs:['*']` on secrets is read-every-secret takeover and
|
|
60
|
+
// MUST stay loud even though the resource is named, not `*`.
|
|
61
|
+
const b = binding({
|
|
62
|
+
binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'cluster-b', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
|
|
63
|
+
rules: [{ verbs: ['*'], resources: ['secrets'], apiGroups: [''] }],
|
|
64
|
+
})
|
|
65
|
+
const reasons = detectBlastRadius(subject([b]))
|
|
66
|
+
expect(reasons).toHaveLength(1)
|
|
67
|
+
expect(reasons[0].reason).toMatch(/verb wildcard/)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("does NOT flag a verb wildcard scoped to one operator's own CRDs", () => {
|
|
71
|
+
// `verbs:['*']` confined to a specific apiGroup+resources in a single
|
|
72
|
+
// namespace (RoleBinding) is the routine operator-owns-its-CRDs grant —
|
|
73
|
+
// expanding the Permissions section on every such SA is the over-fire
|
|
74
|
+
// this exclusion fixes.
|
|
75
|
+
const b = binding({
|
|
76
|
+
rules: [{ verbs: ['*'], resources: ['widgets'], apiGroups: ['acme.example.com'] }],
|
|
77
|
+
})
|
|
78
|
+
expect(detectBlastRadius(subject([b]))).toHaveLength(0)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does NOT flag a namespace RoleBinding with resource-wildcard verb-wildcard over one group', () => {
|
|
82
|
+
// `verbs:['*']` over `resources:['*']` in one apiGroup, bound via a
|
|
83
|
+
// namespace RoleBinding, is namespace-scoped — broad within the namespace
|
|
84
|
+
// but not cluster takeover. Common operator shape; stays collapsed.
|
|
85
|
+
const b = binding({
|
|
86
|
+
rules: [{ verbs: ['*'], resources: ['*'], apiGroups: ['acme.example.com'] }],
|
|
87
|
+
})
|
|
88
|
+
expect(detectBlastRadius(subject([b]))).toHaveLength(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
47
91
|
it('flags escalation verbs', () => {
|
|
48
92
|
const b = binding({
|
|
49
93
|
rules: [{ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] }],
|
|
@@ -89,8 +133,9 @@ describe('detectBlastRadius', () => {
|
|
|
89
133
|
|
|
90
134
|
it('dedupes by binding identity when multiple risky rules match', () => {
|
|
91
135
|
const b = binding({
|
|
136
|
+
binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'cluster-b', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
|
|
92
137
|
rules: [
|
|
93
|
-
{ verbs: ['*'], resources: ['
|
|
138
|
+
{ verbs: ['*'], resources: ['secrets'], apiGroups: [''] },
|
|
94
139
|
{ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] },
|
|
95
140
|
],
|
|
96
141
|
})
|
|
@@ -47,14 +47,19 @@ export interface BlastRadiusReason {
|
|
|
47
47
|
*
|
|
48
48
|
* 1. cluster-admin role-name match (covers the "bound my SA to cluster-admin
|
|
49
49
|
* for testing" pattern even when rules aren't loaded)
|
|
50
|
-
* 2. verb wildcards (`*`)
|
|
50
|
+
* 2. verb wildcards (`*`) granted by a cluster-wide binding (ClusterRoleBinding)
|
|
51
51
|
* 3. escalation verbs (`escalate` / `bind` / `impersonate`)
|
|
52
52
|
* 4. cluster-wide `create pods` (lateral-movement vector)
|
|
53
53
|
*
|
|
54
54
|
* Resource-only wildcards (e.g. `view`'s fluxcd CRD coverage) deliberately
|
|
55
55
|
* do NOT trigger — they fire on every authenticated SA and would train
|
|
56
|
-
* operators to ignore the alarm.
|
|
57
|
-
*
|
|
56
|
+
* operators to ignore the alarm. A verb wildcard granted by a RoleBinding is
|
|
57
|
+
* likewise excluded — a RoleBinding is namespace-scoped by apiserver
|
|
58
|
+
* construction, so even `verbs:['*']` over `resources:['*']` across every
|
|
59
|
+
* apiGroup (namespace-admin) is not cluster takeover, and operators routinely
|
|
60
|
+
* hold broad wildcards over their own CRDs. Only a cluster-wide binding counts
|
|
61
|
+
* as genuine god-mode. The current set is the calibrated minimum for "the
|
|
62
|
+
* Pod's identity is genuinely god-mode".
|
|
58
63
|
*
|
|
59
64
|
* Dedupes by binding identity so a binding with multiple risky rules
|
|
60
65
|
* counts once; first matching reason wins.
|
|
@@ -73,7 +78,12 @@ export function detectBlastRadius(rbacData: RBACSubjectResponse): BlastRadiusRea
|
|
|
73
78
|
for (const r of br.rules ?? []) {
|
|
74
79
|
const verbs = r.verbs ?? []
|
|
75
80
|
const resources = r.resources ?? []
|
|
76
|
-
|
|
81
|
+
// A verb wildcard is only cluster takeover when the binding is itself
|
|
82
|
+
// cluster-wide. A RoleBinding is namespace-scoped by apiserver
|
|
83
|
+
// construction no matter how broad its rule (apiGroups/resources `*`
|
|
84
|
+
// included), so namespace-admin SAs stay collapsed; only
|
|
85
|
+
// ClusterRoleBindings count.
|
|
86
|
+
if (verbs.includes('*') && br.binding.kind === 'ClusterRoleBinding') {
|
|
77
87
|
reasons.push({ binding: br, reason: 'grants verb wildcard (*) — every action on the listed resources' })
|
|
78
88
|
break
|
|
79
89
|
}
|