@opensoha/contracts 0.1.26 → 0.1.29

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 (213) hide show
  1. package/COMPATIBILITY.md +14 -3
  2. package/README.md +8 -9
  3. package/auth/catalog_generated.go +2 -2
  4. package/auth/permission-catalog.json +481 -1
  5. package/compat/consumer-revisions.json +1 -1
  6. package/compat/json-schema-ecosystem-0.1.0.json +1 -1
  7. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  8. package/endpointcontrol/decode.go +64 -0
  9. package/endpointcontrol/models.go +138 -0
  10. package/endpointcontrol/models_test.go +51 -0
  11. package/endpointcontrol/protocol.schema.json +397 -0
  12. package/fixtures/json-schema/endpoint-controls/invalid/controls.json +16 -0
  13. package/fixtures/json-schema/endpoint-controls/invalid/missing-channel-decision.json +15 -0
  14. package/fixtures/json-schema/endpoint-controls/invalid/receipt-content.json +36 -0
  15. package/fixtures/json-schema/endpoint-controls/valid/controls.json +16 -0
  16. package/fixtures/json-schema/endpoint-controls/valid/receipt.json +35 -0
  17. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-activate-bad-fingerprint.json +14 -0
  18. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-gateway-request.json +14 -0
  19. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-private-key-request.json +15 -0
  20. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-profile-without-eap-credential.json +22 -0
  21. package/fixtures/json-schema/network-runtime-protocol/invalid/endpoint-dns-observation.json +25 -0
  22. package/fixtures/json-schema/network-runtime-protocol/invalid/gateway-presence.json +22 -0
  23. package/fixtures/json-schema/network-runtime-protocol/invalid/mihomo-protection-zero-version.json +45 -0
  24. package/fixtures/json-schema/network-runtime-protocol/invalid/presence-raw-key.json +23 -0
  25. package/fixtures/json-schema/network-runtime-protocol/invalid/presence-zero-sequence.json +22 -0
  26. package/fixtures/json-schema/network-runtime-protocol/invalid/workload-wide-vpn.json +17 -0
  27. package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-activate.json +14 -0
  28. package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-request.json +14 -0
  29. package/fixtures/json-schema/network-runtime-protocol/valid/eap-profile-enrollment.json +24 -0
  30. package/fixtures/json-schema/network-runtime-protocol/valid/endpoint-presence.json +22 -0
  31. package/fixtures/json-schema/network-runtime-protocol/valid/ipv6-configuration.json +102 -0
  32. package/fixtures/json-schema/network-runtime-protocol/valid/mihomo-protected-policy.json +45 -0
  33. package/fixtures/json-schema/network-runtime-protocol/valid/site-dns-ipv6.json +25 -0
  34. package/fixtures/json-schema/network-runtime-protocol/valid/workload-direct-ztna.json +20 -0
  35. package/fixtures/openapi/DLPShadowDecision/invalid/step-up-type.json +14 -0
  36. package/fixtures/openapi/DLPShadowDecision/valid/archive-v2.json +14 -0
  37. package/fixtures/openapi/DLPShadowDecision/valid/step-up.json +14 -0
  38. package/fixtures/openapi/DLPShadowInput/invalid/self-reported-result.json +7 -0
  39. package/fixtures/openapi/DLPShadowInput/invalid/too-many-samples.json +14 -0
  40. package/fixtures/openapi/DLPShadowInput/valid/bounded.json +7 -0
  41. package/fixtures/openapi/DockerVolumeDLPAppealInput/invalid/p4-response.json +3 -0
  42. package/fixtures/openapi/DockerVolumeDLPAppealInput/valid/p4-response.json +3 -0
  43. package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/invalid/p4-response.json +5 -0
  44. package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/valid/p4-response.json +5 -0
  45. package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/invalid/p4-response.json +3 -0
  46. package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/valid/p4-response.json +3 -0
  47. package/fixtures/openapi/DockerVolumeDLPContent/invalid/archive-unsupported-channel.json +9 -0
  48. package/fixtures/openapi/DockerVolumeDLPContent/invalid/p4.json +6 -0
  49. package/fixtures/openapi/DockerVolumeDLPContent/invalid/step-up-audit.json +9 -0
  50. package/fixtures/openapi/DockerVolumeDLPContent/invalid/unavailable-engine.json +9 -0
  51. package/fixtures/openapi/DockerVolumeDLPContent/invalid/unknown-data-level.json +12 -0
  52. package/fixtures/openapi/DockerVolumeDLPContent/valid/classified-rule.json +16 -0
  53. package/fixtures/openapi/DockerVolumeDLPContent/valid/p4.json +8 -0
  54. package/fixtures/openapi/DockerVolumeDLPContent/valid/step-up.json +9 -0
  55. package/fixtures/openapi/DockerVolumeDLPDecision/invalid/p4.json +9 -0
  56. package/fixtures/openapi/DockerVolumeDLPDecision/invalid/step-up-type.json +12 -0
  57. package/fixtures/openapi/DockerVolumeDLPDecision/valid/archive-v2.json +12 -0
  58. package/fixtures/openapi/DockerVolumeDLPDecision/valid/p4.json +11 -0
  59. package/fixtures/openapi/DockerVolumeDLPDecision/valid/step-up.json +12 -0
  60. package/fixtures/openapi/DockerVolumeDLPSaveInput/invalid/p4.json +11 -0
  61. package/fixtures/openapi/DockerVolumeDLPSaveInput/valid/p4.json +11 -0
  62. package/fixtures/openapi/DockerVolumeFileReadInput/invalid/p4-response.json +5 -0
  63. package/fixtures/openapi/DockerVolumeFileReadInput/valid/p4-response.json +4 -0
  64. package/fixtures/openapi/EndpointDeviceInput/invalid/reported-management.json +5 -0
  65. package/fixtures/openapi/EndpointDeviceInput/valid/management-state.json +5 -0
  66. package/fixtures/openapi/EndpointDeviceRegistrationInput/invalid/self-promote-management.json +5 -0
  67. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-length.json +3 -0
  68. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-type.json +3 -0
  69. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-alias.json +5 -0
  70. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-reset.json +3 -0
  71. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/invalid/revision-overflow.json +4 -0
  72. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/legacy.json +3 -0
  73. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/observed-revision.json +4 -0
  74. package/fixtures/openapi/KubernetesJobDetail/invalid/execution-observation.json +23 -0
  75. package/fixtures/openapi/KubernetesJobDetail/valid/execution-observation.json +23 -0
  76. package/fixtures/openapi/KubernetesNode/invalid/labels.json +10 -0
  77. package/fixtures/openapi/KubernetesNode/valid/labels.json +11 -0
  78. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/empty-uid.json +13 -0
  79. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/invalid-container-state.json +20 -0
  80. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/missing-container-ready.json +19 -0
  81. package/fixtures/openapi/KubernetesPodListEnvelope/valid/container-statuses.json +67 -0
  82. package/fixtures/openapi/KubernetesPodListEnvelope/valid/running-pod.json +1 -0
  83. package/fixtures/openapi/KubernetesPrometheusDetail/invalid/metric-cluster-value-type.json +4 -0
  84. package/fixtures/openapi/KubernetesPrometheusDetail/valid/metric-cluster-alias.json +5 -0
  85. package/fixtures/openapi/KubernetesResourceGraphEnvelope/invalid/network-edge-source-payload.json +73 -0
  86. package/fixtures/openapi/KubernetesResourceGraphEnvelope/valid/network-edge-facts.json +67 -0
  87. package/fixtures/openapi/KubernetesSecretCertificate/invalid/secret-data.json +1 -0
  88. package/fixtures/openapi/KubernetesSecretCertificate/invalid/status.json +1 -0
  89. package/fixtures/openapi/KubernetesSecretCertificate/valid/available.json +1 -0
  90. package/fixtures/openapi/KubernetesSecretCertificate/valid/missing.json +1 -0
  91. package/fixtures/openapi/ManagedContentPasteInput/invalid/boundary.json +5 -0
  92. package/fixtures/openapi/ManagedContentPasteInput/valid/boundary.json +6 -0
  93. package/fixtures/openapi/ManagedContentPolicy/invalid/self-reported-allow.json +8 -0
  94. package/fixtures/openapi/ManagedContentPolicy/invalid/step-up-audit.json +8 -0
  95. package/fixtures/openapi/ManagedContentPolicy/invalid/unknown-extractor.json +8 -0
  96. package/fixtures/openapi/ManagedContentPolicy/valid/archive-v2.json +8 -0
  97. package/fixtures/openapi/ManagedContentPolicy/valid/network-and-level.json +10 -0
  98. package/fixtures/openapi/ManagedContentPolicy/valid/step-up.json +8 -0
  99. package/fixtures/openapi/ManagedContentReviewInput/invalid/boundary.json +5 -0
  100. package/fixtures/openapi/ManagedContentReviewInput/valid/boundary.json +5 -0
  101. package/fixtures/openapi/ManagedContentSaveInput/invalid/boundary.json +14 -0
  102. package/fixtures/openapi/ManagedContentSaveInput/valid/boundary.json +16 -0
  103. package/fixtures/openapi/ManagedContentUploadInput/invalid/boundary.json +7 -0
  104. package/fixtures/openapi/ManagedContentUploadInput/valid/boundary.json +6 -0
  105. package/fixtures/openapi/NetworkAccessPolicyInput/invalid/unknown-management-state.json +35 -0
  106. package/fixtures/openapi/NetworkAccessPolicyInput/valid/managed-device.json +35 -0
  107. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/deviceId-characters.json +4 -0
  108. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/nasBindingId-characters.json +4 -0
  109. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/subject-override.json +1 -0
  110. package/fixtures/openapi/NetworkAdmissionPreviewRequest/valid/device-nas.json +1 -0
  111. package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-attributeVersion.json +13 -0
  112. package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-evaluatedAt.json +13 -0
  113. package/fixtures/openapi/NetworkAdmissionPreviewResult/valid/versioned.json +14 -0
  114. package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/missing-runtime-ranges.json +1 -0
  115. package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/unknown-source-type.json +9 -0
  116. package/fixtures/openapi/NetworkConflictAnalysisRequest/valid/empty-runtime-ranges.json +3 -0
  117. package/fixtures/openapi/NetworkConflictAnalysisRequest/{invalid → valid}/ipv6-range.json +1 -1
  118. package/fixtures/openapi/NetworkEAPServerTrust/invalid/no-server-validation.json +5 -0
  119. package/fixtures/openapi/NetworkEAPServerTrust/valid/named-server.json +9 -0
  120. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/all-sites-implicit.json +45 -0
  121. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/unbounded-stages.json +54 -0
  122. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/zero-heartbeat.json +47 -0
  123. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/valid/staged-protection.json +47 -0
  124. package/fixtures/openapi/NetworkEndpointResponseRestoreInput/invalid/missing-reason.json +3 -0
  125. package/fixtures/openapi/NetworkEndpointResponseRestoreInput/valid/expected-current.json +4 -0
  126. package/fixtures/openapi/NetworkPolicyPreviewResult/invalid/attribute-version.json +1 -0
  127. package/fixtures/openapi/NetworkPolicyPreviewResult/valid/admission-version.json +1 -0
  128. package/fixtures/openapi/NetworkPolicySubjects/invalid/duplicate-role.json +1 -0
  129. package/fixtures/openapi/NetworkPolicySubjects/invalid/role-characters.json +5 -0
  130. package/fixtures/openapi/NetworkPolicySubjects/valid/role-bound.json +1 -0
  131. package/fixtures/openapi/NetworkProxyConfigurationInput/invalid/missing-content.json +1 -0
  132. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/bad-binding-id.json +9 -0
  133. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/empty-content.json +1 -0
  134. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/binding-only.json +11 -0
  135. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/clear-binding-disabled.json +5 -0
  136. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/enabled.json +1 -0
  137. package/fixtures/openapi/NetworkSiteInput/invalid/unknown-governance.json +6 -0
  138. package/fixtures/openapi/NetworkSiteInput/valid/restricted-unmanaged.json +6 -0
  139. package/gen/go/sohaapi/client.go +1 -1
  140. package/gen/go/sohaapi/client_test.go +13 -0
  141. package/gen/go/sohaapi/kubernetes_workload_optional_test.go +40 -0
  142. package/gen/go/sohaapi/pod_status_compat_test.go +36 -0
  143. package/gen/go/sohaapi/proxy_configuration_test.go +38 -0
  144. package/gen/go/sohaapi/types.go +3222 -260
  145. package/gen/ts/sohaapi/index.ts +15506 -10104
  146. package/helmrelease/runtime/values_upgrade.go +78 -0
  147. package/helmrelease/runtime/values_upgrade_test.go +123 -0
  148. package/helmrelease/values_update.go +31 -0
  149. package/network/README.md +34 -0
  150. package/network/network-radius-accounting.schema.json +4 -1
  151. package/network/network-runtime-protocol.schema.json +2166 -317
  152. package/network/proxy-runtime.schema.json +482 -67
  153. package/openapi/network_control_test.go +64 -0
  154. package/openapi/soha-api.json +50412 -41310
  155. package/openapi/soha-api.yaml +16859 -10699
  156. package/openapi/spec_generated.go +3 -3
  157. package/package.json +6 -3
  158. package/proxytransport/handler.go +271 -0
  159. package/proxytransport/probe.go +30 -0
  160. package/proxytransport/scope.go +89 -0
  161. package/proxytransport/transport_test.go +60 -0
  162. package/resource/agent_profiles.go +9 -15
  163. package/resource/agent_profiles_test.go +9 -3
  164. package/resource/custom_graph_page.go +18 -0
  165. package/resource/models.go +102 -57
  166. package/resource/network_topology.go +22 -0
  167. package/resource/network_topology_projection.go +36 -22
  168. package/resource/network_topology_projection_test.go +4 -0
  169. package/resource/network_topology_test.go +20 -0
  170. package/resource/network_topology_validation.go +5 -0
  171. package/resource/observed_workload.go +18 -0
  172. package/resource/pod_status_test.go +22 -0
  173. package/resource/rbac_graph.go +4 -0
  174. package/resource/rollout_optional_test.go +33 -0
  175. package/resource/runtime/network_relationship_primitives.go +66 -0
  176. package/resource/runtime/network_relationship_primitives_test.go +147 -0
  177. package/resource/runtime/network_topology.go +17 -5
  178. package/resource/runtime/network_topology_builder.go +405 -0
  179. package/resource/runtime/network_topology_builder_backends.go +195 -0
  180. package/resource/runtime/network_topology_builder_gateway_l4_test.go +86 -0
  181. package/resource/runtime/network_topology_builder_ingress.go +302 -0
  182. package/resource/runtime/network_topology_builder_legacy.go +66 -0
  183. package/resource/runtime/network_topology_builder_providers.go +242 -0
  184. package/resource/runtime/network_topology_builder_test.go +186 -0
  185. package/resource/runtime/network_topology_declarations.go +329 -0
  186. package/resource/runtime/resource_edit.go +22 -0
  187. package/resource/runtime/resource_edit_test.go +32 -0
  188. package/resource/runtime/resource_graph_collect.go +325 -0
  189. package/resource/runtime/resource_graph_configuration.go +59 -0
  190. package/resource/runtime/resource_graph_configuration_collect.go +198 -0
  191. package/resource/runtime/resource_graph_configuration_test.go +470 -0
  192. package/resource/runtime/resource_graph_custom_page.go +159 -0
  193. package/resource/runtime/resource_graph_custom_root.go +132 -0
  194. package/resource/runtime/resource_graph_custom_root_test.go +197 -0
  195. package/resource/runtime/resource_graph_enrichment.go +417 -0
  196. package/resource/runtime/resource_graph_enrichment_hpa_test.go +117 -0
  197. package/resource/runtime/resource_graph_enrichment_test.go +201 -0
  198. package/resource/runtime/resource_graph_metadata.go +125 -0
  199. package/resource/runtime/resource_graph_metadata_test.go +204 -0
  200. package/resource/runtime/resource_graph_network.go +224 -0
  201. package/resource/runtime/resource_graph_network_collect.go +174 -0
  202. package/resource/runtime/resource_graph_network_collect_test.go +147 -0
  203. package/resource/runtime/resource_graph_network_test.go +342 -0
  204. package/resource/runtime/resource_graph_rbac.go +278 -0
  205. package/resource/runtime/resource_graph_rbac_test.go +372 -0
  206. package/resource/runtime/resource_graph_rules.go +834 -0
  207. package/resource/runtime/resource_graph_storage.go +144 -0
  208. package/resource/runtime/resource_graph_storage_collect.go +163 -0
  209. package/resource/runtime/resource_graph_storage_test.go +397 -0
  210. package/resource/runtime/resource_graph_test.go +627 -0
  211. package/resource/runtime/secret_certificate.go +44 -0
  212. package/resource/runtime/secret_certificate_test.go +87 -0
  213. package/resource/storage_graph.go +4 -0
@@ -0,0 +1,201 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ cr "github.com/opensoha/soha-contracts/resource"
9
+ "reflect"
10
+ "strings"
11
+ "testing"
12
+ )
13
+
14
+ func graphEnrichmentFixture() (cr.ResourceGraph, ResourceGraphEnrichment) {
15
+ object := graphTestObject("custom.example/v1", "Widget", "owner", "owner-uid")
16
+ ref := enrichmentRef("cluster-a", object)
17
+ graph := cr.ResourceGraph{ClusterID: "cluster-a", Namespace: "ns", RootID: "opaque-pod", Nodes: []cr.ResourceGraphNode{{ID: "opaque-pod", Resource: cr.ResourceRef{ClusterID: "cluster-a", APIVersion: "v1", Kind: "Pod", Namespace: "ns", Name: "pod", UID: "pod-uid", ScopeMode: cr.ResourceScopeModeNamespace}, Status: "Running"}, {ID: "opaque-owner", Resource: ref, Status: "unresolved"}}, Edges: []cr.ResourceGraphEdge{{ID: "opaque-edge", SourceID: "opaque-owner", TargetID: "opaque-pod", Relation: "controls"}}, Warnings: []string{"existing warning"}, Evidence: []cr.ResourceEvidence{{ID: "evidence", Summary: "preserve evidence"}}}
18
+ input := ResourceGraphEnrichment{TargetID: "opaque-owner", Object: object, Definition: cr.ResourceRef{ClusterID: "cluster-a", APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "widgets.custom.example", UID: "crd-uid", ScopeMode: cr.ResourceScopeModeCluster}}
19
+ return graph, input
20
+ }
21
+ func TestResourceGraphEnrichmentIdentityAndScope(t *testing.T) {
22
+ for _, test := range []struct {
23
+ name string
24
+ edit func(*ResourceGraphEnrichment)
25
+ }{
26
+ {"wrong group", func(e *ResourceGraphEnrichment) { e.Object.SetAPIVersion("apps/v1") }},
27
+ {"replacement", func(e *ResourceGraphEnrichment) { e.Object.SetUID("new-uid") }},
28
+ {"wrong kind", func(e *ResourceGraphEnrichment) { e.Object.SetKind("Deployment") }},
29
+ {"wrong name", func(e *ResourceGraphEnrichment) { e.Object.SetName("other") }},
30
+ {"cross namespace", func(e *ResourceGraphEnrichment) { e.Object.SetNamespace("other") }},
31
+ {"missing target", func(e *ResourceGraphEnrichment) { e.TargetID = "missing" }},
32
+ {"unobserved definition", func(e *ResourceGraphEnrichment) { e.Definition.UID = "" }},
33
+ } {
34
+ t.Run(test.name, func(t *testing.T) {
35
+ graph, input := graphEnrichmentFixture()
36
+ test.edit(&input)
37
+ _, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
38
+ if !errors.Is(err, ErrInvalidResourceGraphEnrichment) {
39
+ t.Fatalf("identity accepted: %v", err)
40
+ }
41
+ if graph.Nodes[1].Status != "unresolved" {
42
+ t.Fatal("input graph mutated")
43
+ }
44
+ })
45
+ }
46
+ graph, input := graphEnrichmentFixture()
47
+ input.Object.SetNamespace("")
48
+ result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
49
+ if err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ found := false
53
+ for _, node := range result.Nodes {
54
+ if node.ID == "opaque-owner" {
55
+ found = true
56
+ if node.Status == "unresolved" || node.Resource.Namespace != "" || node.Resource.ScopeMode != cr.ResourceScopeModeCluster {
57
+ t.Fatalf("scope not corrected: %+v", node)
58
+ }
59
+ }
60
+ }
61
+ if !found {
62
+ t.Fatal("opaque target ID changed")
63
+ }
64
+ if !graphTestEdge(result, "defines", "widgets.custom.example", "owner") {
65
+ t.Fatal("CRD defines direction incorrect")
66
+ }
67
+ if !reflect.DeepEqual(result.Evidence, graph.Evidence) || result.Warnings[0] != "existing warning" {
68
+ t.Fatal("evidence/warnings changed")
69
+ }
70
+ for _, edge := range result.Edges {
71
+ if edge.Relation == "controls" && edge.ID != "opaque-edge" {
72
+ t.Fatal("existing edge ID rewritten")
73
+ }
74
+ }
75
+ }
76
+ func TestResourceGraphEnrichmentOwnerMetadataAndIdempotency(t *testing.T) {
77
+ for _, controller := range []bool{false, true} {
78
+ t.Run(fmt.Sprint(controller), func(t *testing.T) {
79
+ graph, input := graphEnrichmentFixture()
80
+ graphTestOwner(input.Object, "custom.example/v1", "Parent", "parent", "parent-uid", controller)
81
+ input.Object.Object["spec"] = map[string]interface{}{"secret": "MUST-NOT-LEAK"}
82
+ input.Object.Object["data"] = map[string]interface{}{"token": "MUST-NOT-LEAK"}
83
+ result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input, input})
84
+ if err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ relation := "owns"
88
+ if controller {
89
+ relation = "controls"
90
+ }
91
+ if !graphTestEdge(result, relation, "parent", "owner") {
92
+ t.Fatal("owner/controller relation missing")
93
+ }
94
+ for _, node := range result.Nodes {
95
+ if node.Resource.Name == "parent" && node.Status != "unresolved" {
96
+ t.Fatal("unobserved parent confirmed")
97
+ }
98
+ }
99
+ encoded, _ := json.Marshal(result)
100
+ if strings.Contains(string(encoded), "MUST-NOT-LEAK") {
101
+ t.Fatal("unknown payload exposed")
102
+ }
103
+ again, err := EnrichResourceGraph(context.Background(), result, []ResourceGraphEnrichment{input})
104
+ if err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ if !reflect.DeepEqual(result, again) {
108
+ t.Fatalf("repeated enrichment not stable\n%+v\n%+v", result, again)
109
+ }
110
+ })
111
+ }
112
+ }
113
+ func TestResourceGraphEnrichmentCancellationAndBudget(t *testing.T) {
114
+ graph, input := graphEnrichmentFixture()
115
+ ctx, cancel := context.WithCancel(context.Background())
116
+ cancel()
117
+ _, err := EnrichResourceGraph(ctx, graph, []ResourceGraphEnrichment{input})
118
+ if !errors.Is(err, context.Canceled) {
119
+ t.Fatalf("cancellation ignored: %v", err)
120
+ }
121
+ for len(graph.Nodes) < ResourceGraphNodeLimit {
122
+ index := len(graph.Nodes)
123
+ graph.Nodes = append(graph.Nodes, cr.ResourceGraphNode{ID: fmt.Sprintf("extra-%d", index), Resource: cr.ResourceRef{ClusterID: graph.ClusterID, APIVersion: "v1", Kind: "Pod", Name: fmt.Sprint(index), Namespace: "ns", ScopeMode: cr.ResourceScopeModeNamespace}})
124
+ }
125
+ result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
126
+ if err != nil {
127
+ t.Fatal(err)
128
+ }
129
+ if len(result.Nodes) > 500 || len(result.Edges) > 1000 || !strings.Contains(strings.Join(result.Warnings, " "), "enrichment is partial") {
130
+ t.Fatal("budget overflow silent")
131
+ }
132
+ graph.Nodes = append(graph.Nodes, graph.Nodes[0])
133
+ _, err = EnrichResourceGraph(context.Background(), graph, nil)
134
+ if !errors.Is(err, ErrResourceGraphBudget) {
135
+ t.Fatalf("invalid initial budget accepted: %v", err)
136
+ }
137
+ }
138
+ func TestResourceGraphMetadataJSONBoundaries(t *testing.T) {
139
+ graph, input := graphEnrichmentFixture()
140
+ content := []byte(`{"apiVersion":"custom.example/v1","kind":"Widget","metadata":{"name":"owner","namespace":"ns","uid":"owner-uid","annotations":{"secret":"MUST-NOT-LEAK"}},"spec":{"credential":"MUST-NOT-LEAK"},"data":{"password":"MUST-NOT-LEAK"}}`)
141
+ metadata := ResourceGraphMetadataEnrichment{TargetID: input.TargetID, Content: content, Definition: input.Definition}
142
+ result, err := EnrichResourceGraphFromMetadataJSON(context.Background(), graph, []ResourceGraphMetadataEnrichment{metadata})
143
+ if err != nil {
144
+ t.Fatal(err)
145
+ }
146
+ encoded, _ := json.Marshal(result)
147
+ if strings.Contains(string(encoded), "MUST-NOT-LEAK") {
148
+ t.Fatal("JSON payload exposed")
149
+ }
150
+ metadata.Content = make([]byte, ResourceGraphMetadataJSONLimit+1)
151
+ _, err = EnrichResourceGraphFromMetadataJSON(context.Background(), graph, []ResourceGraphMetadataEnrichment{metadata})
152
+ if !errors.Is(err, ErrResourceGraphBudget) {
153
+ t.Fatal("JSON size unbounded")
154
+ }
155
+ _, err = EnrichResourceGraphFromMetadataJSON(context.Background(), graph, make([]ResourceGraphMetadataEnrichment, 17))
156
+ if !errors.Is(err, ErrResourceGraphBudget) {
157
+ t.Fatal("JSON item count unbounded")
158
+ }
159
+ metadata.Content = []byte(`{"metadata":`)
160
+ _, err = EnrichResourceGraphFromMetadataJSON(context.Background(), graph, []ResourceGraphMetadataEnrichment{metadata})
161
+ if !errors.Is(err, ErrInvalidResourceGraphEnrichment) {
162
+ t.Fatal("malformed JSON accepted")
163
+ }
164
+ }
165
+
166
+ func TestResourceGraphEnrichmentDepthAndStableEdgeIDs(t *testing.T) {
167
+ graph, input := graphEnrichmentFixture()
168
+ middle := graph.Nodes[1]
169
+ middle.ID = "middle"
170
+ middle.Resource.Name = "middle"
171
+ middle.Resource.UID = "middle-uid"
172
+ middle.Status = ""
173
+ far := middle
174
+ far.ID = "far"
175
+ far.Resource.Name = "far"
176
+ far.Resource.UID = "far-uid"
177
+ graph.Nodes = append(graph.Nodes, middle, far)
178
+ graph.Edges = []cr.ResourceGraphEdge{{ID: "edge-one", SourceID: "middle", TargetID: "opaque-pod", Relation: "controls"}, {ID: "edge-two", SourceID: "far", TargetID: "middle", Relation: "controls"}, {ID: "edge-three", SourceID: "opaque-owner", TargetID: "far", Relation: "controls"}}
179
+ graphTestOwner(input.Object, "custom.example/v1", "Parent", "beyond-depth", "parent-uid", true)
180
+ result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
181
+ if err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ if graphTestHas(result, "beyond-depth") || graphTestHas(result, "widgets.custom.example") {
185
+ t.Fatal("enrichment crossed three-hop boundary")
186
+ }
187
+ if !strings.Contains(strings.Join(result.Warnings, " "), "enrichment is partial") {
188
+ t.Fatal("depth omission silent")
189
+ }
190
+ for _, original := range graph.Edges {
191
+ found := false
192
+ for _, edge := range result.Edges {
193
+ if edge == original {
194
+ found = true
195
+ }
196
+ }
197
+ if !found {
198
+ t.Fatalf("existing opaque edge modified: %+v", original)
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,125 @@
1
+ package runtime
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "mime"
9
+ "net/http"
10
+
11
+ "k8s.io/client-go/metadata"
12
+ "k8s.io/client-go/rest"
13
+ )
14
+
15
+ const resourceGraphMetadataAccept = "application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1"
16
+ const resourceGraphMetadataResponseLimit = 1 << 20
17
+
18
+ // NewResourceGraphMetadataClient creates a metadata-only Graph client. It rejects
19
+ // full-object fallback and validates bounded metadata envelopes before SDK decoding.
20
+ // The caller's REST configuration and transport wrappers remain intact.
21
+ func NewResourceGraphMetadataClient(config *rest.Config) (metadata.Interface, error) {
22
+ if config == nil {
23
+ return nil, fmt.Errorf("resource graph metadata REST configuration is unavailable")
24
+ }
25
+ strict := rest.CopyConfig(config)
26
+ strict.Wrap(func(base http.RoundTripper) http.RoundTripper {
27
+ return resourceGraphMetadataTransport{RoundTripper: base}
28
+ })
29
+ return metadata.NewForConfig(strict)
30
+ }
31
+
32
+ type resourceGraphMetadataTransport struct{ http.RoundTripper }
33
+
34
+ func (t resourceGraphMetadataTransport) RoundTrip(request *http.Request) (*http.Response, error) {
35
+ request = request.Clone(request.Context())
36
+ request.Header.Set("Accept", resourceGraphMetadataAccept)
37
+ response, err := t.RoundTripper.RoundTrip(request)
38
+ if err != nil {
39
+ return response, err
40
+ }
41
+ body := response.Body
42
+ defer func() { _ = body.Close() }()
43
+ // A redirected request would pass through the Kubernetes authentication
44
+ // transport again, which can attach credentials to the new endpoint.
45
+ if response.StatusCode >= 300 && response.StatusCode < 400 {
46
+ return nil, fmt.Errorf("resource graph metadata endpoint redirected")
47
+ }
48
+ var payload []byte
49
+ if response.StatusCode >= 200 && response.StatusCode < 300 {
50
+ mediaType, _, contentTypeErr := mime.ParseMediaType(response.Header.Get("Content-Type"))
51
+ if contentTypeErr != nil || mediaType != "application/json" {
52
+ return nil, fmt.Errorf("resource graph metadata response is not JSON metadata")
53
+ }
54
+ payload, err = readResourceGraphMetadataResponse(body)
55
+ } else {
56
+ payload, err = io.ReadAll(io.LimitReader(body, resourceGraphMetadataResponseLimit+1))
57
+ if len(payload) > resourceGraphMetadataResponseLimit {
58
+ return nil, fmt.Errorf("resource graph metadata response exceeds its size limit")
59
+ }
60
+ }
61
+ if err != nil {
62
+ if canceled := request.Context().Err(); canceled != nil {
63
+ return nil, canceled
64
+ }
65
+ return nil, err
66
+ }
67
+ response.Body = io.NopCloser(bytes.NewReader(payload))
68
+ response.ContentLength = int64(len(payload))
69
+ return response, nil
70
+ }
71
+
72
+ // Reject ordinary objects before the SDK's permissive metadata conversion runs.
73
+ func readResourceGraphMetadataResponse(reader io.Reader) ([]byte, error) {
74
+ var payload bytes.Buffer
75
+ decoder := json.NewDecoder(io.TeeReader(io.LimitReader(reader, resourceGraphMetadataResponseLimit+1), &payload))
76
+ token, err := decoder.Token()
77
+ if err != nil || token != json.Delim('{') {
78
+ return nil, fmt.Errorf("invalid resource graph metadata envelope")
79
+ }
80
+ seen := map[string]bool{}
81
+ for decoder.More() {
82
+ token, err = decoder.Token()
83
+ key, ok := token.(string)
84
+ if err != nil || !ok || seen[key] {
85
+ return nil, fmt.Errorf("invalid resource graph metadata field")
86
+ }
87
+ seen[key] = true
88
+ if err = readResourceGraphMetadataField(decoder, key); err != nil {
89
+ return nil, err
90
+ }
91
+ }
92
+ if _, err = decoder.Token(); err != nil {
93
+ return nil, fmt.Errorf("invalid resource graph metadata envelope")
94
+ }
95
+ if !seen["kind"] || !seen["apiVersion"] || !seen["metadata"] {
96
+ return nil, fmt.Errorf("resource graph response is not PartialObjectMetadata")
97
+ }
98
+ var extra any
99
+ if decoder.Decode(&extra) != io.EOF || payload.Len() > resourceGraphMetadataResponseLimit {
100
+ return nil, fmt.Errorf("resource graph metadata response exceeds its envelope or size limit")
101
+ }
102
+ return payload.Bytes(), nil
103
+ }
104
+
105
+ func readResourceGraphMetadataField(decoder *json.Decoder, key string) error {
106
+ switch key {
107
+ case "kind", "apiVersion":
108
+ want := "PartialObjectMetadata"
109
+ if key == "apiVersion" {
110
+ want = "meta.k8s.io/v1"
111
+ }
112
+ var value string
113
+ if err := decoder.Decode(&value); err != nil || value != want {
114
+ return fmt.Errorf("resource graph response is not PartialObjectMetadata")
115
+ }
116
+ case "metadata":
117
+ var value json.RawMessage
118
+ if err := decoder.Decode(&value); err != nil || len(value) == 0 || value[0] != '{' {
119
+ return fmt.Errorf("invalid resource graph object metadata")
120
+ }
121
+ default:
122
+ return fmt.Errorf("resource graph response contains a non-metadata field")
123
+ }
124
+ return nil
125
+ }
@@ -0,0 +1,204 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "strings"
11
+ "sync/atomic"
12
+ "testing"
13
+
14
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
15
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16
+ "k8s.io/apimachinery/pkg/runtime/schema"
17
+ "k8s.io/client-go/rest"
18
+ )
19
+
20
+ func TestResourceGraphMetadataTransportNeverFallsBackToSecretPayload(t *testing.T) {
21
+ for _, status := range []int32{http.StatusOK, http.StatusForbidden, http.StatusNotAcceptable} {
22
+ t.Run(http.StatusText(int(status)), func(t *testing.T) {
23
+ var requests atomic.Int32
24
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25
+ requests.Add(1)
26
+ if r.Method != http.MethodGet || r.URL.Path != "/api/v1/namespaces/team-a/secrets/credentials" {
27
+ t.Errorf("unexpected metadata request: %s %s", r.Method, r.URL.Path)
28
+ }
29
+ if r.Header.Get("Accept") != resourceGraphMetadataAccept {
30
+ t.Errorf("unsafe Secret negotiation: %q", r.Header.Get("Accept"))
31
+ }
32
+ w.Header().Set("Content-Type", "application/json")
33
+ w.WriteHeader(int(status))
34
+ if status == http.StatusOK {
35
+ _ = json.NewEncoder(w).Encode(metav1.PartialObjectMetadata{TypeMeta: metav1.TypeMeta{APIVersion: "meta.k8s.io/v1", Kind: "PartialObjectMetadata"}, ObjectMeta: metav1.ObjectMeta{Name: "credentials", Namespace: "team-a", UID: "secret-uid"}})
36
+ return
37
+ }
38
+ reason := metav1.StatusReasonForbidden
39
+ if status == http.StatusNotAcceptable {
40
+ reason = metav1.StatusReasonNotAcceptable
41
+ }
42
+ _ = json.NewEncoder(w).Encode(metav1.Status{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, Status: "Failure", Reason: reason, Code: status})
43
+ }))
44
+ defer server.Close()
45
+ config := &rest.Config{Host: server.URL}
46
+ client, err := NewResourceGraphMetadataClient(config)
47
+ if err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ result, err := client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}).Namespace("team-a").Get(context.Background(), "credentials", metav1.GetOptions{})
51
+ switch status {
52
+ case http.StatusOK:
53
+ if err != nil || result.UID != "secret-uid" {
54
+ t.Fatalf("metadata=%+v err=%v", result, err)
55
+ }
56
+ case http.StatusForbidden:
57
+ if !apierrors.IsForbidden(err) {
58
+ t.Fatalf("forbidden mapping lost: %v", err)
59
+ }
60
+ case http.StatusNotAcceptable:
61
+ if !apierrors.IsNotAcceptable(err) {
62
+ t.Fatalf("metadata rejection lost: %v", err)
63
+ }
64
+ }
65
+ if requests.Load() != 1 {
66
+ t.Fatalf("metadata request fell back or retried: %d", requests.Load())
67
+ }
68
+ if config.WrapTransport != nil {
69
+ t.Fatal("shared REST configuration was mutated")
70
+ }
71
+ })
72
+ }
73
+ }
74
+
75
+ func TestResourceGraphMetadataTransportRejectsOrdinaryObjects(t *testing.T) {
76
+ for _, tc := range []struct{ name, contentType, payload string }{
77
+ {"ordinary Secret", "application/json", `{"kind":"Secret","apiVersion":"v1","metadata":{"name":"credentials","namespace":"team-a"},"data":{"password":"must-not-read"}}`},
78
+ {"disguised Secret", resourceGraphMetadataAccept, `{"kind":"Secret","apiVersion":"v1","metadata":{"name":"credentials"},"data":{"password":"must-not-read"}}`},
79
+ {"payload on metadata", "application/json", `{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{"name":"credentials"},"data":{"password":"must-not-read"}}`},
80
+ {"wrong metadata version", "application/json", `{"kind":"PartialObjectMetadata","apiVersion":"v1","metadata":{"name":"credentials"}}`},
81
+ {"missing identity", "application/json", `{"metadata":{"name":"credentials"}}`},
82
+ {"duplicate identity", "application/json", `{"kind":"PartialObjectMetadata","kind":"Secret","apiVersion":"meta.k8s.io/v1","metadata":{"name":"credentials"}}`},
83
+ {"trailing object", "application/json", `{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{}} {"kind":"Secret"}`},
84
+ {"non-json metadata", "application/yaml", "kind: PartialObjectMetadata"},
85
+ {"oversized metadata", "application/json", `{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{"name":"` + strings.Repeat("a", resourceGraphMetadataResponseLimit) + `"}}`},
86
+ } {
87
+ t.Run(tc.name, func(t *testing.T) {
88
+ var requests atomic.Int32
89
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90
+ requests.Add(1)
91
+ w.Header().Set("Content-Type", tc.contentType)
92
+ _, _ = io.WriteString(w, tc.payload)
93
+ }))
94
+ defer server.Close()
95
+ client, err := NewResourceGraphMetadataClient(&rest.Config{Host: server.URL})
96
+ if err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ result, err := client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}).Namespace("team-a").Get(context.Background(), "credentials", metav1.GetOptions{})
100
+ if err == nil || result != nil || strings.Contains(err.Error(), "must-not-read") {
101
+ t.Fatalf("ordinary payload accepted or exposed: result=%+v err=%v", result, err)
102
+ }
103
+ if requests.Load() != 1 {
104
+ t.Fatalf("unsafe payload retried or fell back: %d", requests.Load())
105
+ }
106
+ })
107
+ }
108
+ }
109
+
110
+ func TestResourceGraphMetadataTransportRejectsCredentialRedirect(t *testing.T) {
111
+ var redirected atomic.Bool
112
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
113
+ redirected.Store(true)
114
+ w.WriteHeader(http.StatusForbidden)
115
+ }))
116
+ defer target.Close()
117
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118
+ if r.Header.Get("Authorization") != "Bearer synthetic-graph-token" {
119
+ t.Error("authenticated metadata request did not carry its credential")
120
+ }
121
+ http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
122
+ }))
123
+ defer server.Close()
124
+ client, err := NewResourceGraphMetadataClient(&rest.Config{Host: server.URL, BearerToken: "synthetic-graph-token"})
125
+ if err != nil {
126
+ t.Fatal(err)
127
+ }
128
+ _, err = client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}).Namespace("team-a").Get(context.Background(), "credentials", metav1.GetOptions{})
129
+ if err == nil || !strings.Contains(err.Error(), "endpoint redirected") || redirected.Load() {
130
+ t.Fatalf("redirect was not rejected: requested=%v err=%v", redirected.Load(), err)
131
+ }
132
+ }
133
+
134
+ func TestResourceGraphMetadataTransportBoundsErrorPayload(t *testing.T) {
135
+ for _, status := range []int{http.StatusForbidden, http.StatusNotAcceptable, http.StatusInternalServerError} {
136
+ t.Run(http.StatusText(status), func(t *testing.T) {
137
+ transport := resourceGraphMetadataTransport{RoundTripper: resourceGraphCancelRoundTripper(func(*http.Request) (*http.Response, error) {
138
+ return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(strings.Repeat("a", resourceGraphMetadataResponseLimit+1)))}, nil
139
+ })}
140
+ request := httptest.NewRequest(http.MethodGet, "https://cluster.invalid/api/v1/namespaces/team-a/secrets/credentials", nil)
141
+ response, err := transport.RoundTrip(request)
142
+ if response != nil {
143
+ if closeErr := response.Body.Close(); closeErr != nil {
144
+ t.Errorf("close unexpected response body: %v", closeErr)
145
+ }
146
+ }
147
+ if err == nil || !strings.Contains(err.Error(), "size limit") || response != nil {
148
+ t.Fatalf("oversized error reached the metadata client: response=%v err=%v", response, err)
149
+ }
150
+ })
151
+ }
152
+ }
153
+
154
+ type resourceGraphCancelRoundTripper func(*http.Request) (*http.Response, error)
155
+
156
+ func (f resourceGraphCancelRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
157
+ return f(request)
158
+ }
159
+
160
+ type resourceGraphCancelBody struct {
161
+ io.ReadCloser
162
+ cancel context.CancelFunc
163
+ read *atomic.Bool
164
+ }
165
+
166
+ func (b resourceGraphCancelBody) Read(payload []byte) (int, error) {
167
+ b.read.Store(true)
168
+ b.cancel()
169
+ return b.ReadCloser.Read(payload)
170
+ }
171
+
172
+ func TestResourceGraphMetadataTransportRetainsBodyReadCancellation(t *testing.T) {
173
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174
+ w.Header().Set("Content-Type", "application/json")
175
+ w.WriteHeader(http.StatusOK)
176
+ _, _ = io.WriteString(w, `{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":`)
177
+ if err := http.NewResponseController(w).Flush(); err != nil {
178
+ t.Errorf("flush metadata response: %v", err)
179
+ return
180
+ }
181
+ <-r.Context().Done()
182
+ }))
183
+ defer server.Close()
184
+ ctx, cancel := context.WithCancel(context.Background())
185
+ defer cancel()
186
+ var bodyRead atomic.Bool
187
+ config := &rest.Config{Host: server.URL, WrapTransport: func(base http.RoundTripper) http.RoundTripper {
188
+ return resourceGraphCancelRoundTripper(func(request *http.Request) (*http.Response, error) {
189
+ response, err := base.RoundTrip(request)
190
+ if err == nil {
191
+ response.Body = resourceGraphCancelBody{ReadCloser: response.Body, cancel: cancel, read: &bodyRead}
192
+ }
193
+ return response, err
194
+ })
195
+ }}
196
+ client, err := NewResourceGraphMetadataClient(config)
197
+ if err != nil {
198
+ t.Fatal(err)
199
+ }
200
+ _, err = client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}).Namespace("team-a").Get(ctx, "credentials", metav1.GetOptions{})
201
+ if !bodyRead.Load() || !errors.Is(err, context.Canceled) {
202
+ t.Fatalf("HTTP 200 body cancellation lost: bodyRead=%v err=%v", bodyRead.Load(), err)
203
+ }
204
+ }