@skyhook-io/k8s-ui 1.14.1 → 1.14.3

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/components/issues/IssuesView.tsx +1 -1
  3. package/src/components/issues/types.ts +2 -0
  4. package/src/components/resources/ResourcesView.tsx +261 -188
  5. package/src/components/resources/column-settings-merge.test.ts +104 -0
  6. package/src/components/resources/istio-gateway-filter.test.ts +5 -3
  7. package/src/components/resources/istio-policy-accessors.test.ts +193 -0
  8. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +11 -24
  9. package/src/components/resources/renderers/capi-cells.tsx +3 -0
  10. package/src/components/resources/renderers/certmanager-cells.tsx +2 -2
  11. package/src/components/resources/renderers/eso-cells.tsx +0 -5
  12. package/src/components/resources/renderers/istio-cells.tsx +36 -26
  13. package/src/components/resources/renderers/knative-cells.tsx +13 -0
  14. package/src/components/resources/renderers/kueue-cells.tsx +0 -9
  15. package/src/components/resources/renderers/kyverno-cells.tsx +8 -1
  16. package/src/components/resources/renderers/kyverno-modern-cells.tsx +6 -0
  17. package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
  18. package/src/components/resources/renderers/ray-cells.tsx +12 -4
  19. package/src/components/resources/renderers/trivy-cells.tsx +7 -0
  20. package/src/components/resources/renderers/velero-cells.tsx +3 -0
  21. package/src/components/resources/renderers/volcano-cells.tsx +0 -9
  22. package/src/components/resources/resource-utils-calico.test.ts +6 -0
  23. package/src/components/resources/resource-utils-calico.ts +6 -2
  24. package/src/components/resources/resource-utils-cnpg.ts +5 -0
  25. package/src/components/resources/resource-utils-eso.test.ts +56 -0
  26. package/src/components/resources/resource-utils-eso.ts +37 -5
  27. package/src/components/resources/resource-utils-istio.ts +152 -44
  28. package/src/components/resources/resource-utils-kyverno.ts +3 -6
  29. package/src/components/resources/resource-utils-prometheus.ts +51 -23
  30. package/src/components/resources/resource-utils-ray.test.ts +36 -0
  31. package/src/components/resources/resource-utils-ray.ts +6 -0
  32. package/src/components/resources/resource-utils-trivy.ts +3 -0
  33. package/src/components/resources/resource-utils.ts +183 -19
  34. package/src/components/resources/resources-column-filter.test.ts +20 -0
  35. package/src/components/resources/status-accessors.test.ts +363 -0
  36. package/src/components/resources/status-overclaim.test.ts +158 -0
  37. package/src/components/timeline/DiffViewer.test.tsx +26 -0
  38. package/src/components/timeline/DiffViewer.tsx +16 -12
  39. package/src/components/topology/TopologyGraph.tsx +7 -2
  40. package/src/components/ui/Collapse.tsx +9 -2
  41. package/src/components/ui/ConfirmDialog.test.tsx +37 -0
  42. package/src/components/ui/ConfirmDialog.tsx +3 -1
  43. package/src/components/ui/drawer-components.tsx +1 -1
  44. package/src/components/workload/WorkloadView.tsx +2 -1
@@ -19,7 +19,7 @@ export function ServiceMonitorCell({ resource, column }: { resource: any; column
19
19
  case 'status': {
20
20
  const status = getServiceMonitorStatus(resource)
21
21
  return (
22
- <span className={clsx('badge', status.color)}>
22
+ <span className={clsx('badge truncate max-w-full', status.color)} title={status.text}>
23
23
  {status.text}
24
24
  </span>
25
25
  )
@@ -40,7 +40,7 @@ export function PrometheusRuleCell({ resource, column }: { resource: any; column
40
40
  case 'status': {
41
41
  const status = getPrometheusRuleStatus(resource)
42
42
  return (
43
- <span className={clsx('badge', status.color)}>
43
+ <span className={clsx('badge truncate max-w-full', status.color)} title={status.text}>
44
44
  {status.text}
45
45
  </span>
46
46
  )
@@ -59,7 +59,7 @@ export function PodMonitorCell({ resource, column }: { resource: any; column: st
59
59
  case 'status': {
60
60
  const status = getPodMonitorStatus(resource)
61
61
  return (
62
- <span className={clsx('badge', status.color)}>
62
+ <span className={clsx('badge truncate max-w-full', status.color)} title={status.text}>
63
63
  {status.text}
64
64
  </span>
65
65
  )
@@ -15,7 +15,7 @@ import {
15
15
  getRayServiceClusters,
16
16
  getRayCronJobStatus,
17
17
  getRayCronJobSchedule,
18
- getRayCronJobSuspend,
18
+ getRayCronJobTimeZone,
19
19
  getRayCronJobLastSchedule,
20
20
  } from '../resource-utils-ray'
21
21
 
@@ -110,9 +110,17 @@ export function RayCronJobCell({ resource, column }: { resource: any; column: st
110
110
  const schedule = getRayCronJobSchedule(resource)
111
111
  return <span className="text-sm font-mono text-theme-text-secondary">{schedule}</span>
112
112
  }
113
- case 'suspend': {
114
- const suspended = getRayCronJobSuspend(resource)
115
- return <span className="text-sm text-theme-text-secondary">{suspended ? 'Yes' : 'No'}</span>
113
+ case 'timeZone': {
114
+ const zone = getRayCronJobTimeZone(resource)
115
+ const declared = Boolean(resource.spec?.timeZone)
116
+ return (
117
+ <span
118
+ className={clsx('text-sm truncate block', declared ? 'text-theme-text-secondary' : 'text-theme-text-tertiary')}
119
+ title={zone}
120
+ >
121
+ {zone}
122
+ </span>
123
+ )
116
124
  }
117
125
  case 'lastSchedule': {
118
126
  const lastSchedule = getRayCronJobLastSchedule(resource)
@@ -6,6 +6,7 @@ import {
6
6
  getVulnerabilityReportSummary,
7
7
  getVulnerabilityReportContainer,
8
8
  getVulnerabilityReportImage,
9
+ getSbomReportImage,
9
10
  getConfigAuditReportSummary,
10
11
  getConfigAuditReportStatus,
11
12
  getExposedSecretReportSummary,
@@ -133,6 +134,12 @@ export function ClusterComplianceReportCell({ resource, column }: { resource: an
133
134
 
134
135
  export function SbomReportCell({ resource, column }: { resource: any; column: string }) {
135
136
  switch (column) {
137
+ case 'image': {
138
+ const image = getSbomReportImage(resource)
139
+ return (
140
+ <span className="text-sm text-theme-text-secondary truncate block" title={image}>{image}</span>
141
+ )
142
+ }
136
143
  case 'container':
137
144
  return <span className="text-sm text-theme-text-secondary">{getSbomReportContainer(resource)}</span>
138
145
  case 'components': {
@@ -30,6 +30,7 @@ import {
30
30
  getVSLConfig,
31
31
  getBackupRepositoryStatus,
32
32
  getBackupRepositoryType,
33
+ getBackupRepositoryVolumeNamespace,
33
34
  } from '../resource-utils-velero'
34
35
 
35
36
  // The detail-page Phase value. The badge carries the same label the table
@@ -235,6 +236,8 @@ export function BackupRepositoryCell({ resource, column }: { resource: any; colu
235
236
  </span>
236
237
  )
237
238
  }
239
+ case 'volumeNamespace':
240
+ return <span className="text-sm text-theme-text-secondary">{getBackupRepositoryVolumeNamespace(resource)}</span>
238
241
  case 'repositoryType': {
239
242
  const type = getBackupRepositoryType(resource)
240
243
  // restic is on its way out (no new backups since v1.17, restore dropped in
@@ -16,7 +16,6 @@ import {
16
16
  getVolcanoPodGroupMinMember,
17
17
  getJobFlowStatus,
18
18
  getJobFlowFlowCount,
19
- getJobTemplateStatus,
20
19
  getJobTemplateTaskCount,
21
20
  } from '../resource-utils-volcano'
22
21
 
@@ -122,14 +121,6 @@ export function JobFlowCell({ resource, column }: { resource: any; column: strin
122
121
 
123
122
  export function JobTemplateCell({ resource, column }: { resource: any; column: string }) {
124
123
  switch (column) {
125
- case 'status': {
126
- const status = getJobTemplateStatus(resource)
127
- return (
128
- <span className={clsx('badge', status.color)}>
129
- {status.text}
130
- </span>
131
- )
132
- }
133
124
  case 'tasks': {
134
125
  const count = getJobTemplateTaskCount(resource)
135
126
  return <span className="text-sm text-theme-text-secondary">{count > 0 ? count : '-'}</span>
@@ -174,6 +174,12 @@ describe('IPPool derivations', () => {
174
174
  getCalicoIPPoolAllowedUses({ spec: { allowedUses: ['Tunnel'] } }),
175
175
  ).toBe('Tunnel')
176
176
  })
177
+
178
+ it('treats an empty allowedUses list as the default, not as no uses', () => {
179
+ // Calico: "If not specified or empty, defaults to ["Tunnel", "Workload"]".
180
+ // A dash here would read as a pool that allocates for nothing.
181
+ expect(getCalicoIPPoolAllowedUses({ spec: { allowedUses: [] } })).toBe('Workload, Tunnel')
182
+ })
177
183
  })
178
184
 
179
185
  describe('derived policy types', () => {
@@ -256,6 +256,10 @@ export function getCalicoIPPoolEncapsulation(pool: any): string {
256
256
 
257
257
  export function getCalicoIPPoolAllowedUses(pool: any): string {
258
258
  const uses = pool?.spec?.allowedUses
259
- if (!Array.isArray(uses)) return 'Workload, Tunnel'
260
- return uses.length > 0 ? uses.map(String).join(', ') : '-'
259
+ // Calico documents an empty list as equivalent to an absent one: "If not
260
+ // specified or empty, defaults to ["Tunnel", "Workload"] for
261
+ // back-compatibility." Rendering a dash there would read as a pool that
262
+ // allocates for nothing.
263
+ if (!Array.isArray(uses) || uses.length === 0) return 'Workload, Tunnel'
264
+ return uses.map(String).join(', ')
261
265
  }
@@ -646,6 +646,11 @@ export function getCNPGObjectStoreStatus(resource: any): StatusBadge {
646
646
  if (windows.some((w) => w.failingSinceLastSuccess)) {
647
647
  return { text: 'Backups Failing', color: healthColors.unhealthy, level: 'unhealthy' }
648
648
  }
649
+ // Every timestamp on a recovery window is optional, so a server can be listed
650
+ // with no recovery point at all. The entry existing is not a backup existing.
651
+ if (!windows.some((w) => w.firstRecoverabilityPoint || w.lastSuccessfulBackupTime)) {
652
+ return { text: 'No recovery point', color: healthColors.unknown, level: 'unknown' }
653
+ }
649
654
  return { text: 'Recoverable', color: healthColors.healthy, level: 'healthy' }
650
655
  }
651
656
 
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getSecretStoreProviderType, getSecretStoreProviderKey } from './resource-utils-eso'
3
+
4
+ // The provider label answers "which backend holds these secrets?", so it has to
5
+ // track the upstream `spec.provider` field names rather than plausible ones.
6
+
7
+ describe('getSecretStoreProviderType', () => {
8
+ it('names the AWS product rather than assuming Secrets Manager', () => {
9
+ // One `aws` key addresses three services; `service` is required and is the
10
+ // only thing that distinguishes them.
11
+ const forService = (service: string) =>
12
+ getSecretStoreProviderType({ spec: { provider: { aws: { service, region: 'us-east-1' } } } })
13
+
14
+ expect(forService('SecretsManager')).toBe('AWS Secrets Manager')
15
+ expect(forService('ParameterStore')).toBe('AWS Parameter Store')
16
+ expect(forService('CertificateManager')).toBe('AWS Certificate Manager')
17
+ })
18
+
19
+ it('falls back to the vendor when an AWS store declares no service', () => {
20
+ // Invalid per the CRD, but naming a product we cannot confirm is worse than
21
+ // naming only the vendor.
22
+ expect(getSecretStoreProviderType({ spec: { provider: { aws: {} } } })).toBe('AWS')
23
+ })
24
+
25
+ it('recognises Bitwarden under its real field name', () => {
26
+ expect(getSecretStoreProviderType({ spec: { provider: { bitwardensecretsmanager: {} } } }))
27
+ .toBe('Bitwarden Secrets Manager')
28
+ })
29
+
30
+ it('labels providers added after the map was first written', () => {
31
+ expect(getSecretStoreProviderType({ spec: { provider: { openBao: {} } } })).toBe('OpenBao')
32
+ expect(getSecretStoreProviderType({ spec: { provider: { conjur: {} } } })).toBe('CyberArk Conjur')
33
+ })
34
+
35
+ it('keeps v1beta1-only providers, which v1 clusters never send', () => {
36
+ expect(getSecretStoreProviderType({ spec: { provider: { device42: {} } } })).toBe('Device42')
37
+ })
38
+
39
+ it('shows the raw key rather than Unknown for an unrecognised provider', () => {
40
+ expect(getSecretStoreProviderType({ spec: { provider: { somefutureprovider: {} } } }))
41
+ .toBe('somefutureprovider')
42
+ })
43
+
44
+ it('reports nothing when there is no provider at all', () => {
45
+ expect(getSecretStoreProviderType({ spec: {} })).toBe('Unknown')
46
+ })
47
+ })
48
+
49
+ describe('getSecretStoreProviderKey', () => {
50
+ it('stays at the vendor level, which is what the badge colours by', () => {
51
+ const key = (service: string) =>
52
+ getSecretStoreProviderKey({ spec: { provider: { aws: { service } } } })
53
+ expect(key('SecretsManager')).toBe('aws')
54
+ expect(key('ParameterStore')).toBe('aws')
55
+ })
56
+ })
@@ -7,33 +7,63 @@ import { healthColors, formatAge } from './resource-utils'
7
7
  // PROVIDER DETECTION
8
8
  // ============================================================================
9
9
 
10
+ // Keys are the SecretStore `spec.provider` field names. v1 and v1beta1 do not
11
+ // carry the same set, so entries dropped upstream (device42, alibaba) stay for
12
+ // clusters still serving v1beta1.
10
13
  const PROVIDER_MAP: Record<string, string> = {
11
- aws: 'AWS Secrets Manager',
14
+ aws: 'AWS',
12
15
  azurekv: 'Azure Key Vault',
13
16
  gcpsm: 'GCP Secret Manager',
14
17
  vault: 'HashiCorp Vault',
18
+ openBao: 'OpenBao',
15
19
  kubernetes: 'Kubernetes',
20
+ crd: 'Kubernetes Resource',
16
21
  oracle: 'Oracle Vault',
17
22
  ibm: 'IBM Secrets Manager',
23
+ alibaba: 'Alibaba Cloud KMS',
24
+ ovh: 'OVHcloud Secret Manager',
25
+ volcengine: 'Volcengine',
26
+ cloudrusm: 'Cloud.ru Secret Manager',
27
+ nebiusmysterybox: 'Nebius Mysterybox',
28
+ barbican: 'OpenStack Barbican',
29
+ yandexlockbox: 'Yandex Lockbox',
30
+ yandexcertificatemanager: 'Yandex Certificate Manager',
18
31
  doppler: 'Doppler',
19
32
  onepassword: '1Password',
33
+ onepasswordSDK: '1Password',
34
+ bitwardensecretsmanager: 'Bitwarden Secrets Manager',
20
35
  senhasegura: 'senhasegura',
36
+ github: 'GitHub',
21
37
  gitlab: 'GitLab',
22
38
  webhook: 'Webhook',
23
39
  fake: 'Fake',
24
40
  keepersecurity: 'Keeper Security',
25
41
  scaleway: 'Scaleway',
26
- delinea: 'Delinea',
42
+ conjur: 'CyberArk Conjur',
43
+ delinea: 'Delinea DevOps Secrets Vault',
44
+ secretserver: 'Delinea Secret Server',
45
+ beyondtrust: 'BeyondTrust',
46
+ beyondtrustworkloadcredentials: 'BeyondTrust Workload Credentials',
27
47
  chef: 'Chef',
28
48
  pulumi: 'Pulumi ESC',
29
49
  fortanix: 'Fortanix',
30
50
  passworddepot: 'Password Depot',
51
+ passbolt: 'Passbolt',
52
+ previder: 'Previder',
53
+ onboardbase: 'Onboardbase',
54
+ dvls: 'Devolutions Server',
31
55
  device42: 'Device42',
32
56
  akeyless: 'Akeyless',
33
- beyondtrust: 'BeyondTrust',
34
57
  infisical: 'Infisical',
35
- passbolt: 'Passbolt',
36
- bitwarden: 'Bitwarden',
58
+ ngrok: 'ngrok',
59
+ }
60
+
61
+ // A single `aws` key addresses three unrelated AWS products; `service` is a
62
+ // required enum and is the only thing that says which one holds the secrets.
63
+ const AWS_SERVICE_LABELS: Record<string, string> = {
64
+ SecretsManager: 'AWS Secrets Manager',
65
+ ParameterStore: 'AWS Parameter Store',
66
+ CertificateManager: 'AWS Certificate Manager',
37
67
  }
38
68
 
39
69
  /**
@@ -44,6 +74,8 @@ export function getSecretStoreProviderType(resource: any): string {
44
74
  const provider = resource.spec?.provider
45
75
  if (!provider) return 'Unknown'
46
76
 
77
+ if (provider.aws) return AWS_SERVICE_LABELS[provider.aws.service] ?? 'AWS'
78
+
47
79
  for (const [key, label] of Object.entries(PROVIDER_MAP)) {
48
80
  if (provider[key]) return label
49
81
  }
@@ -28,15 +28,19 @@ export function getVirtualServiceStatus(resource: any): StatusBadge {
28
28
  const tlsRoutes = spec.tls || []
29
29
  const hosts = spec.hosts || []
30
30
 
31
- if (hosts.length === 0) {
32
- return { text: 'No Hosts', color: healthColors.unhealthy, level: 'unhealthy' }
33
- }
34
-
31
+ // Routes are what a VirtualService exists to provide, delegate or not.
35
32
  const totalRoutes = httpRoutes.length + tcpRoutes.length + tlsRoutes.length
36
33
  if (totalRoutes === 0) {
37
34
  return { text: 'No Routes', color: healthColors.unhealthy, level: 'unhealthy' }
38
35
  }
39
36
 
37
+ // A delegate VirtualService is required to declare no hosts, and this object
38
+ // cannot tell us whether a parent delegates to it, so an empty list is not
39
+ // evidence of a fault.
40
+ if (hosts.length === 0) {
41
+ return { text: 'No Hosts', color: healthColors.unknown, level: 'unknown' }
42
+ }
43
+
40
44
  // Check for fault injection on any route
41
45
  const hasFaultInjection = httpRoutes.some((r: any) => r.fault)
42
46
  if (hasFaultInjection) {
@@ -49,7 +53,7 @@ export function getVirtualServiceStatus(resource: any): StatusBadge {
49
53
  return { text: 'Mirroring', color: healthColors.degraded, level: 'degraded' }
50
54
  }
51
55
 
52
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
56
+ return { text: 'Defined', color: healthColors.neutral, level: 'neutral' }
53
57
  }
54
58
 
55
59
  export function getVirtualServiceHosts(resource: any): string {
@@ -123,26 +127,18 @@ export function getVirtualServiceDestinations(resource: any): Array<{ host: stri
123
127
  // DESTINATIONRULE UTILITIES
124
128
  // ============================================================================
125
129
 
130
+ /**
131
+ * The only failure this object can show on its own.
132
+ *
133
+ * A rule without a host applies to nothing. Everything else it declares —
134
+ * subsets, a traffic policy — is configuration, and says nothing about whether
135
+ * the host is reachable or the policy is in effect.
136
+ */
126
137
  export function getDestinationRuleStatus(resource: any): StatusBadge {
127
- const spec = resource.spec || {}
128
- const host = spec.host
129
-
130
- if (!host) {
138
+ if (!resource.spec?.host) {
131
139
  return { text: 'No Host', color: healthColors.unhealthy, level: 'unhealthy' }
132
140
  }
133
-
134
- const subsets = spec.subsets || []
135
- const trafficPolicy = spec.trafficPolicy
136
-
137
- if (subsets.length > 0) {
138
- return { text: pluralize(subsets.length, 'Subset'), color: healthColors.healthy, level: 'healthy' }
139
- }
140
-
141
- if (trafficPolicy) {
142
- return { text: 'Configured', color: healthColors.healthy, level: 'healthy' }
143
- }
144
-
145
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
141
+ return { text: 'Not assessed', color: healthColors.unknown, level: 'unknown' }
146
142
  }
147
143
 
148
144
  export function getDestinationRuleHost(resource: any): string {
@@ -161,6 +157,22 @@ export function getDestinationRuleSubsets(resource: any): Array<{ name: string;
161
157
  }))
162
158
  }
163
159
 
160
+ /**
161
+ * The client-side TLS mode this rule declares for its host.
162
+ *
163
+ * What it means depends on a different object. DISABLE is defined as "do not
164
+ * setup a TLS connection to the upstream endpoint", so the client sends
165
+ * plaintext; a PeerAuthentication STRICT is "an mTLS tunnel (TLS with client
166
+ * cert must be presented)" and PERMISSIVE is "either plaintext or mTLS tunnel".
167
+ * The pairing therefore lands as unencrypted traffic or as rejected requests —
168
+ * deduced from those two definitions, since neither API documents the mismatch
169
+ * directly. Nothing in this row shows which, and subset and port policies can
170
+ * override the mode besides, so it reports the declaration and not the posture.
171
+ */
172
+ export function getDestinationRuleTlsMode(resource: any): string {
173
+ return getDestinationRuleTrafficPolicy(resource)?.tls?.mode || '-'
174
+ }
175
+
164
176
  export function getDestinationRuleTrafficPolicy(resource: any): {
165
177
  connectionPool?: { tcp?: any; http?: any }
166
178
  loadBalancer?: { simple?: string; consistentHash?: any }
@@ -182,6 +194,15 @@ export function getDestinationRuleLoadBalancer(resource: any): string {
182
194
  // ISTIO GATEWAY UTILITIES
183
195
  // ============================================================================
184
196
 
197
+ /**
198
+ * What spec.servers alone can honestly claim.
199
+ *
200
+ * Not liveness: an Istio Gateway whose spec.selector matches no running
201
+ * ingressgateway pod is how these actually fail, and it has servers like any
202
+ * other, so a green badge there would say the opposite of the truth. TLS is a
203
+ * property of a server rather than a verdict, and this slot is the one every
204
+ * other kind here uses for a verdict.
205
+ */
185
206
  export function getIstioGatewayStatus(resource: any): StatusBadge {
186
207
  const servers = resource.spec?.servers || []
187
208
 
@@ -189,12 +210,7 @@ export function getIstioGatewayStatus(resource: any): StatusBadge {
189
210
  return { text: 'No Servers', color: healthColors.unhealthy, level: 'unhealthy' }
190
211
  }
191
212
 
192
- const hasTLS = servers.some((s: any) => s.tls)
193
- if (hasTLS) {
194
- return { text: 'TLS', color: healthColors.healthy, level: 'healthy' }
195
- }
196
-
197
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
213
+ return { text: 'Defined', color: healthColors.neutral, level: 'neutral' }
198
214
  }
199
215
 
200
216
  export function getIstioGatewayServers(resource: any): Array<{
@@ -236,12 +252,14 @@ export function getServiceEntryStatus(resource: any): StatusBadge {
236
252
  return { text: 'No Hosts', color: healthColors.unhealthy, level: 'unhealthy' }
237
253
  }
238
254
 
255
+ // Location is a mesh-membership classification, not reachability: nothing here
256
+ // establishes the hosts resolve or answer.
239
257
  const location = spec.location || 'MESH_EXTERNAL'
240
258
  if (location === 'MESH_EXTERNAL') {
241
- return { text: 'External', color: healthColors.healthy, level: 'healthy' }
259
+ return { text: 'External', color: healthColors.neutral, level: 'neutral' }
242
260
  }
243
261
 
244
- return { text: 'Internal', color: healthColors.healthy, level: 'healthy' }
262
+ return { text: 'Internal', color: healthColors.neutral, level: 'neutral' }
245
263
  }
246
264
 
247
265
  export function getServiceEntryHosts(resource: any): string {
@@ -327,21 +345,33 @@ export function getPeerAuthenticationPortLevelMtls(resource: any): Record<string
327
345
  // AUTHORIZATIONPOLICY UTILITIES
328
346
  // ============================================================================
329
347
 
348
+ /**
349
+ * What one AuthorizationPolicy declares — never a health verdict.
350
+ *
351
+ * An action is not a state of wellness: a DENY doing its job is not unhealthy,
352
+ * and an ALLOW is not proof anything is permitted. The request decision is made
353
+ * across every policy selecting the workload, so no single object can establish
354
+ * it, and a green or red tone would assert an answer this object does not have.
355
+ *
356
+ * The one shape worth marking is an ALLOW — the default action — carrying no
357
+ * rules. Rules are alternatives, so zero of them match nothing, and Istio
358
+ * documents `spec: {}` as the deny-all idiom. Amber says look closer; it stops
359
+ * short of claiming the outage, because other policies may permit the traffic.
360
+ */
330
361
  export function getAuthorizationPolicyStatus(resource: any): StatusBadge {
331
362
  const action = resource.spec?.action || 'ALLOW'
332
363
  const rules = resource.spec?.rules || []
333
364
 
334
- switch (action) {
335
- case 'ALLOW':
336
- return { text: `Allow (${pluralize(rules.length, 'rule')})`, color: healthColors.healthy, level: 'healthy' }
337
- case 'DENY':
338
- return { text: `Deny (${pluralize(rules.length, 'rule')})`, color: healthColors.unhealthy, level: 'unhealthy' }
339
- case 'CUSTOM':
340
- return { text: 'Custom', color: healthColors.degraded, level: 'degraded' }
341
- case 'AUDIT':
342
- return { text: 'Audit', color: healthColors.degraded, level: 'degraded' }
343
- default:
344
- return { text: action, color: healthColors.unknown, level: 'unknown' }
365
+ if (action === 'ALLOW' && rules.length === 0) {
366
+ return { text: 'No allow rules', color: healthColors.degraded, level: 'degraded' }
367
+ }
368
+ const known: Record<string, string> = { ALLOW: 'Allow', DENY: 'Deny', CUSTOM: 'Custom', AUDIT: 'Audit' }
369
+ const label = known[action]
370
+ if (!label) return { text: action, color: healthColors.unknown, level: 'unknown' }
371
+ return {
372
+ text: `${label} (${pluralize(rules.length, 'rule')})`,
373
+ color: healthColors.neutral,
374
+ level: 'neutral',
345
375
  }
346
376
  }
347
377
 
@@ -365,9 +395,87 @@ export function getAuthorizationPolicySelector(resource: any): Record<string, st
365
395
  return resource.spec?.selector?.matchLabels || {}
366
396
  }
367
397
 
398
+ /**
399
+ * What the policy declares it applies to.
400
+ *
401
+ * A policy can attach by targetRef instead of by labels — waypoints and
402
+ * Gateways in ambient mode do — so the selector alone does not describe scope.
403
+ * With neither, scope is inherited: the namespace, or the whole mesh when the policy
404
+ * sits in the mesh root namespace. Which of those applies depends on
405
+ * MeshConfig.rootNamespace, which is not readable from this object, so the cell
406
+ * names both rather than guessing from the conventional namespace name.
407
+ */
368
408
  export function getAuthorizationPolicySelectorString(resource: any): string {
369
- const labels = resource.spec?.selector?.matchLabels || {}
370
- const entries = Object.entries(labels)
371
- if (entries.length === 0) return 'Namespace-wide'
409
+ const spec = resource.spec || {}
410
+ const targets = [
411
+ ...(Array.isArray(spec.targetRefs) ? spec.targetRefs : []),
412
+ ...(spec.targetRef ? [spec.targetRef] : []),
413
+ ].filter(Boolean)
414
+ if (targets.length > 0) {
415
+ return targets
416
+ .map((t: any) => {
417
+ const name = [t?.kind, t?.name].filter(Boolean).join('/') || 'target'
418
+ return t?.namespace && t.namespace !== resource.metadata?.namespace
419
+ ? `${t.namespace}/${name}`
420
+ : name
421
+ })
422
+ .join(', ')
423
+ }
424
+ const entries = Object.entries(spec.selector?.matchLabels || {})
425
+ if (entries.length === 0) return 'Namespace / mesh scope'
372
426
  return entries.map(([k, v]) => `${k}=${v}`).join(', ')
373
427
  }
428
+
429
+ /**
430
+ * What an AuthorizationPolicy's rule list means for the traffic it governs.
431
+ *
432
+ * Rules are alternatives and an unset list never matches, so BOTH actions with
433
+ * no rules are inert in the same way — a DENY with none denies nothing, an
434
+ * ALLOW with none permits nothing. Only the ALLOW case has a consequence worth
435
+ * raising, because a workload with any ALLOW policy admits only what one of
436
+ * them matches. Neither can be stated as an outcome for the workload: every
437
+ * policy selecting it takes part in the decision.
438
+ */
439
+ export function getAuthorizationPolicyRuleNotice(
440
+ resource: any,
441
+ ): { level: 'warning' | 'info'; title: string; message: string } | null {
442
+ const action = resource?.spec?.action || 'ALLOW'
443
+ const rules = resource?.spec?.rules
444
+ if (Array.isArray(rules) && rules.length > 0) {
445
+ // A rule matches every request when it carries no conditions — which
446
+ // includes serialized empty lists, not just an empty object. On a DENY that
447
+ // is not a baseline other policies carve exceptions out of: DENY is
448
+ // evaluated first, so no ALLOW can re-permit what it matches. Unknown keys
449
+ // are treated as conditions, so a future field cannot be read as blanket.
450
+ const CONDITION_KEYS = ['from', 'to', 'when']
451
+ const matchesEverything = rules.some((r: any) => {
452
+ if (!r || typeof r !== 'object') return false
453
+ const keys = Object.keys(r)
454
+ if (keys.some(k => !CONDITION_KEYS.includes(k))) return false
455
+ return CONDITION_KEYS.every(k => !Array.isArray(r[k]) || r[k].length === 0)
456
+ })
457
+ if (action === 'DENY' && matchesEverything) {
458
+ return {
459
+ level: 'info',
460
+ title: 'Matches all requests',
461
+ message: 'A rule with no conditions matches every request, so this policy denies all traffic to the workloads it selects. DENY is evaluated before ALLOW, so no ALLOW policy can permit an exception.',
462
+ }
463
+ }
464
+ return null
465
+ }
466
+ if (action === 'ALLOW') {
467
+ return {
468
+ level: 'warning',
469
+ title: 'No allow rules',
470
+ message: 'Rules are alternatives, so an ALLOW policy with none of them matches nothing and contributes no permitted traffic. Other ALLOW policies selecting the same workload may still permit requests — a default-deny-plus-exceptions setup looks exactly like this.',
471
+ }
472
+ }
473
+ if (action === 'DENY') {
474
+ return {
475
+ level: 'info',
476
+ title: 'No deny rules',
477
+ message: 'This DENY policy has no rules, so it matches no requests and denies no traffic. Other policies may still deny requests.',
478
+ }
479
+ }
480
+ return null
481
+ }
@@ -107,12 +107,9 @@ export function getKyvernoPolicyStatus(resource: any): StatusBadge {
107
107
  return { text: readyCond.reason || 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
108
108
  }
109
109
 
110
- // Fallback: if spec exists, likely active
111
- if (resource.spec?.rules?.length > 0) {
112
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
113
- }
114
-
115
- return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
110
+ // Reached when the controller has not written Ready, or wrote it as Unknown.
111
+ // Declaring rules is not the controller accepting them.
112
+ return { text: 'Not assessed', color: healthColors.unknown, level: 'unknown' }
116
113
  }
117
114
 
118
115
  /**