diff --git a/charts/kagenti-operator/crds/agent.kagenti.dev_agentruntimes.yaml b/charts/kagenti-operator/crds/agent.kagenti.dev_agentruntimes.yaml index 60f96d49..182f6407 100644 --- a/charts/kagenti-operator/crds/agent.kagenti.dev_agentruntimes.yaml +++ b/charts/kagenti-operator/crds/agent.kagenti.dev_agentruntimes.yaml @@ -94,6 +94,32 @@ spec: - lite - waypoint type: string + egressEnforcement: + description: |- + EgressEnforcement controls whether the proxy-init init container is + injected for fail-closed egress capture in proxy-sidecar / lite modes. + + Values: + enforce-redirect (default) — proxy-init is injected with iptables + rules that transparently REDIRECT egress bypassing + HTTP_PROXY to AuthBridge's transparent listener. + Requires NET_ADMIN capability and a kernel that + supports iptables (legacy or nft). + none — proxy-init is NOT injected. Egress enforcement + relies on HTTP_PROXY (cooperative) + inbound + AuthBridge on destinations + NetworkPolicy. + Use on platforms where iptables is unavailable + (e.g. ROSA HCP, managed OpenShift). + + Resolution: AgentRuntime CR > namespace authbridge-runtime-config + egressEnforcement field > "enforce-redirect" (default). + + Does not affect envoy-sidecar mode, which always uses proxy-init + for its structural iptables redirect. + enum: + - enforce-redirect + - none + type: string identity: description: Identity specifies optional per-workload identity overrides properties: diff --git a/charts/kagenti-operator/crds/agent.kagenti.dev_authorizationpolicies.yaml b/charts/kagenti-operator/crds/agent.kagenti.dev_authorizationpolicies.yaml index ff0d240a..ec1ab44a 100644 --- a/charts/kagenti-operator/crds/agent.kagenti.dev_authorizationpolicies.yaml +++ b/charts/kagenti-operator/crds/agent.kagenti.dev_authorizationpolicies.yaml @@ -1,6 +1,9 @@ +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.1 name: authorizationpolicies.agent.kagenti.dev spec: group: agent.kagenti.dev @@ -8,102 +11,147 @@ spec: kind: AuthorizationPolicy listKind: AuthorizationPolicyList plural: authorizationpolicies - singular: authorizationpolicy shortNames: - - ap + - ap + singular: authorizationpolicy scope: Namespaced versions: - - name: v1alpha1 - served: true - storage: true - subresources: - status: {} - additionalPrinterColumns: - - name: Scope - type: string - jsonPath: .spec.scope - - name: ClientID - type: string - jsonPath: .spec.clientID - - name: Hash - type: string - jsonPath: .status.bundleHash - priority: 1 - - name: Age - type: date - jsonPath: .metadata.creationTimestamp - schema: - openAPIV3Schema: - type: object - required: - - spec - properties: - spec: - type: object - required: - - scope - - policies - properties: - scope: - type: string - enum: - - global - - namespace - - client - default: client - clientID: - type: string - maxLength: 253 - pattern: "^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$" - policies: - type: array - minItems: 1 - items: - type: object - required: - - path - - content - properties: - path: - type: string - minLength: 1 - pattern: "^[a-z0-9][a-z0-9/_.-]*\\.rego$" - content: - type: string - minLength: 1 - x-kubernetes-validations: - - rule: "self.scope == 'client' ? self.clientID != '' : true" - message: "clientID is required when scope is 'client'" - - rule: "self.scope != 'client' ? !has(self.clientID) || self.clientID == '' : true" - message: "clientID must not be set when scope is 'global' or 'namespace'" - status: - type: object - properties: - bundleHash: - type: string - lastBuilt: - type: string - format: date-time - conditions: - type: array - items: - type: object - required: - - type - - status - properties: - type: - type: string - status: - type: string - enum: - - "True" - - "False" - - "Unknown" - lastTransitionTime: - type: string - format: date-time - reason: - type: string - message: - type: string + - additionalPrinterColumns: + - jsonPath: .spec.scope + name: Scope + type: string + - jsonPath: .spec.clientID + name: ClientID + type: string + - jsonPath: .status.bundleHash + name: Hash + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + clientID: + maxLength: 253 + pattern: ^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$ + type: string + policies: + items: + properties: + content: + minLength: 1 + type: string + path: + minLength: 1 + pattern: ^[a-z0-9][a-z0-9/_.-]*\.rego$ + type: string + required: + - content + - path + type: object + minItems: 1 + type: array + scope: + default: client + enum: + - global + - namespace + - client + type: string + required: + - policies + - scope + type: object + status: + properties: + bundleHash: + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastBuilt: + format: date-time + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/kagenti-operator/values.yaml b/charts/kagenti-operator/values.yaml index 466148bc..ff137db7 100644 --- a/charts/kagenti-operator/values.yaml +++ b/charts/kagenti-operator/values.yaml @@ -234,6 +234,16 @@ defaults: # (nft) or "iptables-legacy" to force a backend where detection is undesired. iptablesCmd: "" + # Platform-level governance: which egressEnforcement modes workloads may select. + # The webhook overrides the resolved value to the first entry when the + # workload's choice is not in this list. + # ["enforce-redirect"] — no opt-out allowed (iptables required) + # ["none"] — iptables disabled cluster-wide (ROSA HCP) + # ["enforce-redirect", "none"] — workloads choose (default) + allowedEgressEnforcement: + - enforce-redirect + - none + # Resource defaults (conservative for dev) # Note: requests must be <= limits resources: diff --git a/kagenti-operator/api/v1alpha1/agentruntime_types.go b/kagenti-operator/api/v1alpha1/agentruntime_types.go index 50ca0bba..87345acc 100644 --- a/kagenti-operator/api/v1alpha1/agentruntime_types.go +++ b/kagenti-operator/api/v1alpha1/agentruntime_types.go @@ -114,6 +114,31 @@ type AgentRuntimeSpec struct { // +kubebuilder:default=permissive // +kubebuilder:validation:Enum=disabled;permissive;strict MTLSMode string `json:"mtlsMode,omitempty"` + + // EgressEnforcement controls whether the proxy-init init container is + // injected for fail-closed egress capture in proxy-sidecar / lite modes. + // + // Values: + // enforce-redirect (default) — proxy-init is injected with iptables + // rules that transparently REDIRECT egress bypassing + // HTTP_PROXY to AuthBridge's transparent listener. + // Requires NET_ADMIN capability and a kernel that + // supports iptables (legacy or nft). + // none — proxy-init is NOT injected. Egress enforcement + // relies on HTTP_PROXY (cooperative) + inbound + // AuthBridge on destinations + NetworkPolicy. + // Use on platforms where iptables is unavailable + // (e.g. ROSA HCP, managed OpenShift). + // + // Resolution: AgentRuntime CR > namespace authbridge-runtime-config + // egressEnforcement field > "enforce-redirect" (default). + // + // Does not affect envoy-sidecar mode, which always uses proxy-init + // for its structural iptables redirect. + // + // +optional + // +kubebuilder:validation:Enum=enforce-redirect;none + EgressEnforcement string `json:"egressEnforcement,omitempty"` } // IdentitySpec configures workload identity for an AgentRuntime. diff --git a/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml b/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml index 60f96d49..182f6407 100644 --- a/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml +++ b/kagenti-operator/config/crd/bases/agent.kagenti.dev_agentruntimes.yaml @@ -94,6 +94,32 @@ spec: - lite - waypoint type: string + egressEnforcement: + description: |- + EgressEnforcement controls whether the proxy-init init container is + injected for fail-closed egress capture in proxy-sidecar / lite modes. + + Values: + enforce-redirect (default) — proxy-init is injected with iptables + rules that transparently REDIRECT egress bypassing + HTTP_PROXY to AuthBridge's transparent listener. + Requires NET_ADMIN capability and a kernel that + supports iptables (legacy or nft). + none — proxy-init is NOT injected. Egress enforcement + relies on HTTP_PROXY (cooperative) + inbound + AuthBridge on destinations + NetworkPolicy. + Use on platforms where iptables is unavailable + (e.g. ROSA HCP, managed OpenShift). + + Resolution: AgentRuntime CR > namespace authbridge-runtime-config + egressEnforcement field > "enforce-redirect" (default). + + Does not affect envoy-sidecar mode, which always uses proxy-init + for its structural iptables redirect. + enum: + - enforce-redirect + - none + type: string identity: description: Identity specifies optional per-workload identity overrides properties: diff --git a/kagenti-operator/config/crd/bases/agent.kagenti.dev_authorizationpolicies.yaml b/kagenti-operator/config/crd/bases/agent.kagenti.dev_authorizationpolicies.yaml index ff0d240a..ec1ab44a 100644 --- a/kagenti-operator/config/crd/bases/agent.kagenti.dev_authorizationpolicies.yaml +++ b/kagenti-operator/config/crd/bases/agent.kagenti.dev_authorizationpolicies.yaml @@ -1,6 +1,9 @@ +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.1 name: authorizationpolicies.agent.kagenti.dev spec: group: agent.kagenti.dev @@ -8,102 +11,147 @@ spec: kind: AuthorizationPolicy listKind: AuthorizationPolicyList plural: authorizationpolicies - singular: authorizationpolicy shortNames: - - ap + - ap + singular: authorizationpolicy scope: Namespaced versions: - - name: v1alpha1 - served: true - storage: true - subresources: - status: {} - additionalPrinterColumns: - - name: Scope - type: string - jsonPath: .spec.scope - - name: ClientID - type: string - jsonPath: .spec.clientID - - name: Hash - type: string - jsonPath: .status.bundleHash - priority: 1 - - name: Age - type: date - jsonPath: .metadata.creationTimestamp - schema: - openAPIV3Schema: - type: object - required: - - spec - properties: - spec: - type: object - required: - - scope - - policies - properties: - scope: - type: string - enum: - - global - - namespace - - client - default: client - clientID: - type: string - maxLength: 253 - pattern: "^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$" - policies: - type: array - minItems: 1 - items: - type: object - required: - - path - - content - properties: - path: - type: string - minLength: 1 - pattern: "^[a-z0-9][a-z0-9/_.-]*\\.rego$" - content: - type: string - minLength: 1 - x-kubernetes-validations: - - rule: "self.scope == 'client' ? self.clientID != '' : true" - message: "clientID is required when scope is 'client'" - - rule: "self.scope != 'client' ? !has(self.clientID) || self.clientID == '' : true" - message: "clientID must not be set when scope is 'global' or 'namespace'" - status: - type: object - properties: - bundleHash: - type: string - lastBuilt: - type: string - format: date-time - conditions: - type: array - items: - type: object - required: - - type - - status - properties: - type: - type: string - status: - type: string - enum: - - "True" - - "False" - - "Unknown" - lastTransitionTime: - type: string - format: date-time - reason: - type: string - message: - type: string + - additionalPrinterColumns: + - jsonPath: .spec.scope + name: Scope + type: string + - jsonPath: .spec.clientID + name: ClientID + type: string + - jsonPath: .status.bundleHash + name: Hash + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + clientID: + maxLength: 253 + pattern: ^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$ + type: string + policies: + items: + properties: + content: + minLength: 1 + type: string + path: + minLength: 1 + pattern: ^[a-z0-9][a-z0-9/_.-]*\.rego$ + type: string + required: + - content + - path + type: object + minItems: 1 + type: array + scope: + default: client + enum: + - global + - namespace + - client + type: string + required: + - policies + - scope + type: object + status: + properties: + bundleHash: + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastBuilt: + format: date-time + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/kagenti-operator/internal/webhook/config/defaults.go b/kagenti-operator/internal/webhook/config/defaults.go index 86f6c1e7..58fb5abf 100644 --- a/kagenti-operator/internal/webhook/config/defaults.go +++ b/kagenti-operator/internal/webhook/config/defaults.go @@ -38,6 +38,10 @@ func CompiledDefaults() *PlatformConfig { // Empty by default: proxy-init auto-detects the iptables backend from // /proc/modules. Set (e.g. "iptables") to force a backend per-platform. IptablesCmd: "", + // Both modes allowed by default. Set to ["none"] on platforms + // where iptables is unavailable (ROSA HCP, managed OpenShift), + // or ["enforce-redirect"] to prevent opt-out. + AllowedEgressEnforcement: []string{"enforce-redirect", "none"}, }, Resources: ResourcesConfig{ EnvoyProxy: corev1.ResourceRequirements{ diff --git a/kagenti-operator/internal/webhook/config/types.go b/kagenti-operator/internal/webhook/config/types.go index 7f2df7fe..b74fe5a8 100644 --- a/kagenti-operator/internal/webhook/config/types.go +++ b/kagenti-operator/internal/webhook/config/types.go @@ -59,6 +59,14 @@ type ProxyConfig struct { // ROSA). Set to "iptables" (nft) or "iptables-legacy" to force a backend // where auto-detection is wrong or undesired. IptablesCmd string `json:"iptablesCmd" yaml:"iptablesCmd"` + + // AllowedEgressEnforcement restricts which egressEnforcement values + // workloads (AgentRuntime CR / namespace ConfigMap) may select. + // The webhook rejects resolved values not in this list, falling back + // to the first entry. Default: ["enforce-redirect", "none"] (both + // allowed). Set to ["enforce-redirect"] to prevent opt-out, or + // ["none"] on platforms where iptables is unavailable. + AllowedEgressEnforcement []string `json:"allowedEgressEnforcement,omitempty" yaml:"allowedEgressEnforcement,omitempty"` } type ResourcesConfig struct { @@ -95,6 +103,11 @@ func (c *PlatformConfig) DeepCopy() *PlatformConfig { copy(result.TokenExchange.DefaultScopes, c.TokenExchange.DefaultScopes) } + if c.Proxy.AllowedEgressEnforcement != nil { + result.Proxy.AllowedEgressEnforcement = make([]string, len(c.Proxy.AllowedEgressEnforcement)) + copy(result.Proxy.AllowedEgressEnforcement, c.Proxy.AllowedEgressEnforcement) + } + // Deep copy ResourceRequirements — ResourceList is a map that would be shared result.Resources.EnvoyProxy = deepCopyResourceRequirements(c.Resources.EnvoyProxy) result.Resources.ProxyInit = deepCopyResourceRequirements(c.Resources.ProxyInit) @@ -148,6 +161,14 @@ func (c *PlatformConfig) Validate() error { default: return fmt.Errorf("proxy.iptablesCmd %q is not a recognized backend (want one of: \"\" (auto-detect), iptables, iptables-nft, iptables-legacy)", c.Proxy.IptablesCmd) } + if len(c.Proxy.AllowedEgressEnforcement) == 0 { + return fmt.Errorf("proxy.allowedEgressEnforcement must not be empty (set [\"enforce-redirect\"] to require enforcement, [\"none\"] to disable it, or both to allow workload choice)") + } + for _, mode := range c.Proxy.AllowedEgressEnforcement { + if mode != "enforce-redirect" && mode != "none" { + return fmt.Errorf("proxy.allowedEgressEnforcement contains invalid value %q (allowed: enforce-redirect, none)", mode) + } + } if c.Images.EnvoyProxy == "" { return fmt.Errorf("images.envoyProxy is required") } diff --git a/kagenti-operator/internal/webhook/injector/agentruntime_config.go b/kagenti-operator/internal/webhook/injector/agentruntime_config.go index ee2e7d73..89a440e9 100644 --- a/kagenti-operator/internal/webhook/injector/agentruntime_config.go +++ b/kagenti-operator/internal/webhook/injector/agentruntime_config.go @@ -59,6 +59,12 @@ type AgentRuntimeOverrides struct { // authbridge-runtime-config mtls.mode (if set) or "permissive" // applies. MTLSMode *string + + // Egress enforcement — from .spec.egressEnforcement + // Nil = no per-workload override; the namespace's + // authbridge-runtime-config egressEnforcement (if set) or + // "enforce-redirect" (default) applies. + EgressEnforcement *string } // ReadAgentRuntimeOverrides reads the AgentRuntime CR for a given workload @@ -125,11 +131,18 @@ func extractOverrides(rt *agentv1alpha1.AgentRuntime) *AgentRuntimeOverrides { overrides.MTLSMode = &mode } + // .spec.egressEnforcement + if rt.Spec.EgressEnforcement != "" { + ee := rt.Spec.EgressEnforcement + overrides.EgressEnforcement = &ee + } + arConfigLog.Info("AgentRuntime overrides extracted", "hasSpiffeTrustDomain", overrides.SpiffeTrustDomain != nil, "hasClientRegistration", overrides.ClientRegistrationProvider != nil, "hasAuthBridgeMode", overrides.AuthBridgeMode != nil, - "hasMTLSMode", overrides.MTLSMode != nil) + "hasMTLSMode", overrides.MTLSMode != nil, + "hasEgressEnforcement", overrides.EgressEnforcement != nil) return overrides } diff --git a/kagenti-operator/internal/webhook/injector/constants.go b/kagenti-operator/internal/webhook/injector/constants.go index b740a091..dde5bc6d 100644 --- a/kagenti-operator/internal/webhook/injector/constants.go +++ b/kagenti-operator/internal/webhook/injector/constants.go @@ -57,6 +57,19 @@ const ( ProxyInitModeEnforceRedirect ProxyInitMode = "enforce-redirect" ) +// Egress enforcement modes for proxy-sidecar / lite paths. +// Controls whether proxy-init is injected for fail-closed egress capture. +const ( + // EgressEnforcementEnforceRedirect injects proxy-init with iptables + // rules (default). Requires NET_ADMIN and a kernel with iptables support. + EgressEnforcementEnforceRedirect = "enforce-redirect" + + // EgressEnforcementNone skips proxy-init injection. Egress relies on + // HTTP_PROXY (cooperative) + inbound AuthBridge + NetworkPolicy. + // Use on platforms where iptables is unavailable (ROSA HCP, managed OpenShift). + EgressEnforcementNone = "none" +) + // mTLS modes for the proxy-sidecar / lite paths. Selected per workload // via AgentRuntime CR `Spec.MTLSMode`, falling back to the namespace // `authbridge-runtime-config` ConfigMap's `mtls.mode` field, then diff --git a/kagenti-operator/internal/webhook/injector/namespace_config.go b/kagenti-operator/internal/webhook/injector/namespace_config.go index 1009955a..e3ebf539 100644 --- a/kagenti-operator/internal/webhook/injector/namespace_config.go +++ b/kagenti-operator/internal/webhook/injector/namespace_config.go @@ -160,6 +160,26 @@ func ExtractMode(authbridgeYAML string) string { return top.Mode } +// ExtractEgressEnforcement parses an authbridge-runtime-config config.yaml +// string and returns the value of its top-level `egressEnforcement:` key. +// Returns "" if the YAML is empty, malformed, or has no `egressEnforcement` +// field — in any of those cases the caller should fall back to the next +// resolution layer (or the "enforce-redirect" default). +func ExtractEgressEnforcement(authbridgeYAML string) string { + if authbridgeYAML == "" { + return "" + } + var top struct { + EgressEnforcement string `json:"egressEnforcement"` + } + if err := yaml.Unmarshal([]byte(authbridgeYAML), &top); err != nil { + nsConfigLog.Info("WARN: failed to parse authbridge-runtime-config config.yaml for egressEnforcement; falling back to next resolution layer", + "error", err.Error()) + return "" + } + return top.EgressEnforcement +} + // ExtractMTLSMode parses an authbridge-runtime-config config.yaml string // and returns the value of its `mtls.mode` field. Returns "" if the YAML // is empty, malformed, has no `mtls` block, or its `mode` field is unset diff --git a/kagenti-operator/internal/webhook/injector/pod_mutator.go b/kagenti-operator/internal/webhook/injector/pod_mutator.go index 3fb1754c..ce00e399 100644 --- a/kagenti-operator/internal/webhook/injector/pod_mutator.go +++ b/kagenti-operator/internal/webhook/injector/pod_mutator.go @@ -301,6 +301,57 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp } } + // ======================================== + // Resolve egressEnforcement (CR > namespace > enforce-redirect) + // ======================================== + // + // Controls proxy-init injection in proxy-sidecar / lite modes. + // "enforce-redirect" (default): proxy-init injected with iptables. + // "none": proxy-init skipped — cooperative HTTP_PROXY only. + // envoy-sidecar mode ignores this (proxy-init is structural there). + egressEnforcement := "" + egressEnforcementSource := "" + if arOverrides != nil && arOverrides.EgressEnforcement != nil { + egressEnforcement = *arOverrides.EgressEnforcement + egressEnforcementSource = "agentruntime-cr" + } + if egressEnforcement == "" { + if ee := ExtractEgressEnforcement(nsConfig.AuthBridgeRuntimeYAML); ee != "" { + egressEnforcement = ee + egressEnforcementSource = "namespace-configmap" + } + } + if egressEnforcement == "" { + egressEnforcement = EgressEnforcementEnforceRedirect + egressEnforcementSource = "cluster-default" + } + switch egressEnforcement { + case EgressEnforcementEnforceRedirect, EgressEnforcementNone: + // recognized, keep as-is + default: + mutatorLog.Info("WARN: unrecognized egressEnforcement; defaulting to enforce-redirect (fail closed)", + "namespace", namespace, "crName", crName, + "unrecognized", egressEnforcement, "source", egressEnforcementSource) + egressEnforcement = EgressEnforcementEnforceRedirect + egressEnforcementSource = "default-invalid-fallback" + } + // Validate against the platform's allowed list. If the resolved value + // is not permitted, fall back to the first allowed value (fail closed + // when only enforce-redirect is allowed, fail open when only none is + // allowed — the admin controls the list). + allowed := currentConfig.Proxy.AllowedEgressEnforcement + if !slices.Contains(allowed, egressEnforcement) { + mutatorLog.Info("WARN: egressEnforcement value not in platform allowedEgressEnforcement; overriding", + "namespace", namespace, "crName", crName, + "requested", egressEnforcement, "allowed", allowed, + "overrideTo", allowed[0]) + egressEnforcement = allowed[0] + egressEnforcementSource = "platform-policy-override" + } + mutatorLog.Info("resolved egress enforcement", + "namespace", namespace, "crName", crName, + "mode", egressEnforcement, "source", egressEnforcementSource) + // ======================================== // Resolve AllowedAudiences (from AgentRuntime CR) // ======================================== @@ -534,19 +585,27 @@ func (m *PodMutator) InjectAuthBridge(ctx context.Context, podSpec *corev1.PodSp injectHTTPProxyEnv(c, forwardProxyPort) } - // Fail-closed egress enforcement (always-on for proxy-sidecar / lite). + // Egress enforcement for proxy-sidecar / lite. // HTTP_PROXY above is cooperative — an app that ignores it egresses - // directly. The enforce-redirect proxy-init guard transparently REDIRECTs - // any bypass egress to the forward proxy's transparent listener, so it is - // captured rather than dropped and nothing breaks. envoy-sidecar enforces - // structurally via its own transparent redirect, so this is the - // proxy-sidecar / lite path only. The exempted PROXY_UID equals the proxy - // container's RunAsUser (both b.cfg.Proxy.UID). - if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { - podSpec.InitContainers = append(podSpec.InitContainers, - builder.BuildProxyInitContainer(ProxyInitModeEnforceRedirect, "", "")) - mutatorLog.Info("proxy-sidecar egress enforcement enabled (enforce-redirect)", - "namespace", namespace, "crName", crName) + // directly. When egressEnforcement is "enforce-redirect" (default), + // proxy-init is injected with iptables rules that transparently + // REDIRECT bypass egress to AuthBridge's transparent listener. + // When "none", proxy-init is skipped — use on platforms where + // iptables is unavailable (ROSA HCP, managed OpenShift). + // envoy-sidecar mode has its own structural proxy-init and ignores + // this setting. + if egressEnforcement == EgressEnforcementEnforceRedirect { + if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { + podSpec.InitContainers = append(podSpec.InitContainers, + builder.BuildProxyInitContainer(ProxyInitModeEnforceRedirect, "", "")) + mutatorLog.Info("proxy-sidecar egress enforcement enabled (enforce-redirect)", + "namespace", namespace, "crName", crName) + } + } else { + mutatorLog.Info("proxy-sidecar egress enforcement disabled (cooperative mode)", + "namespace", namespace, "crName", crName, + "egressEnforcement", egressEnforcement, + "source", egressEnforcementSource) } // spiffe-helper is bundled in the authbridge combined image and diff --git a/kagenti-operator/internal/webhook/injector/pod_mutator_test.go b/kagenti-operator/internal/webhook/injector/pod_mutator_test.go index b113ce58..7664d9c7 100644 --- a/kagenti-operator/internal/webhook/injector/pod_mutator_test.go +++ b/kagenti-operator/internal/webhook/injector/pod_mutator_test.go @@ -176,7 +176,7 @@ func TestInjectAuthBridge_NoAgentRuntime_InjectsWithDefaults(t *testing.T) { t.Errorf("unexpected %s container in proxy-sidecar mode", EnvoyProxyContainerName) } if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { - t.Errorf("expected %s init container in proxy-sidecar mode (always-on enforce-redirect)", ProxyInitContainerName) + t.Errorf("expected %s init container in proxy-sidecar mode (default enforce-redirect)", ProxyInitContainerName) } } @@ -1915,3 +1915,288 @@ pipeline: t.Errorf("allowed_audiences = %v, want [agent-aud] (AgentRuntime CR must override base YAML)", audList) } } + +// ======================================== +// EgressEnforcement tests +// ======================================== + +// newAgentRuntimeWithEgressEnforcement creates an AgentRuntime CR with +// proxy-sidecar mode and the given egressEnforcement value. +func newAgentRuntimeWithEgressEnforcement(namespace, targetName, ee string) *agentv1alpha1.AgentRuntime { + rt := newAgentRuntimeWithMode(namespace, targetName, ModeProxySidecar) + rt.Spec.EgressEnforcement = ee + return rt +} + +func TestInjectAuthBridge_EgressEnforcement_DefaultInjectsProxyInit(t *testing.T) { + // Default (no egressEnforcement set) → proxy-init should be injected. + m := newTestMutator(newAgentRuntimeWithMode("test-ns", "my-agent", ModeProxySidecar)) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("expected proxy-init when egressEnforcement is unset (default enforce-redirect)") + } +} + +func TestInjectAuthBridge_EgressEnforcement_NoneSkipsProxyInit(t *testing.T) { + // egressEnforcement: none → proxy-init should NOT be injected. + rt := newAgentRuntimeWithEgressEnforcement("test-ns", "my-agent", EgressEnforcementNone) + m := newTestMutator(rt) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("proxy-init should NOT be injected when egressEnforcement=none") + } + // authbridge-proxy sidecar should still be injected + if !containerExists(podSpec.Containers, AuthBridgeProxyContainerName) { + t.Error("authbridge-proxy should still be injected when egressEnforcement=none") + } +} + +func TestInjectAuthBridge_EgressEnforcement_EnforceRedirectInjectsProxyInit(t *testing.T) { + // egressEnforcement: enforce-redirect → proxy-init should be injected. + rt := newAgentRuntimeWithEgressEnforcement("test-ns", "my-agent", EgressEnforcementEnforceRedirect) + m := newTestMutator(rt) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("expected proxy-init when egressEnforcement=enforce-redirect") + } +} + +func TestInjectAuthBridge_EgressEnforcement_NamespaceConfigMapNone(t *testing.T) { + // Namespace ConfigMap sets egressEnforcement: none, no CR override. + runtimeCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: AuthBridgeRuntimeConfigMapName, + Namespace: "test-ns", + }, + Data: map[string]string{ + "config.yaml": "mode: proxy-sidecar\negressEnforcement: none\n", + }, + } + rt := newAgentRuntimeWithMode("test-ns", "my-agent", ModeProxySidecar) + rt.Spec.EgressEnforcement = "" // no CR override + m := newTestMutator(rt, runtimeCM) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("proxy-init should NOT be injected when namespace ConfigMap sets egressEnforcement=none") + } +} + +func TestInjectAuthBridge_EgressEnforcement_CROverridesNamespace(t *testing.T) { + // Namespace ConfigMap says enforce-redirect, but CR says none → CR wins. + runtimeCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: AuthBridgeRuntimeConfigMapName, + Namespace: "test-ns", + }, + Data: map[string]string{ + "config.yaml": "mode: proxy-sidecar\negressEnforcement: enforce-redirect\n", + }, + } + rt := newAgentRuntimeWithEgressEnforcement("test-ns", "my-agent", EgressEnforcementNone) + m := newTestMutator(rt, runtimeCM) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("CR egressEnforcement=none should override namespace ConfigMap enforce-redirect") + } +} + +func TestInjectAuthBridge_EgressEnforcement_UnknownValueFailsClosed(t *testing.T) { + // Unknown value → fail closed to enforce-redirect (proxy-init injected). + runtimeCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: AuthBridgeRuntimeConfigMapName, + Namespace: "test-ns", + }, + Data: map[string]string{ + "config.yaml": "mode: proxy-sidecar\negressEnforcement: typo-value\n", + }, + } + rt := newAgentRuntimeWithMode("test-ns", "my-agent", ModeProxySidecar) + m := newTestMutator(rt, runtimeCM) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("unknown egressEnforcement value should fail closed (inject proxy-init)") + } +} + +func TestInjectAuthBridge_EgressEnforcement_EnvoySidecarIgnoresNone(t *testing.T) { + // In envoy-sidecar mode, egressEnforcement=none should be ignored — + // proxy-init is structural for envoy-sidecar (redirect mode, not enforce-redirect). + rt := newAgentRuntimeWithMode("test-ns", "my-agent", ModeEnvoySidecar) + rt.Spec.EgressEnforcement = EgressEnforcementNone + m := newTestMutator(rt) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("envoy-sidecar mode should inject proxy-init regardless of egressEnforcement=none") + } +} + +// newTestMutatorWithAllowedEgress creates a test mutator with a custom +// allowedEgressEnforcement platform policy. +func newTestMutatorWithAllowedEgress(allowed []string, objs ...client.Object) *PodMutator { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = agentv1alpha1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() + return &PodMutator{ + Client: fakeClient, + APIReader: fakeClient, + GetPlatformConfig: func() *config.PlatformConfig { + cfg := config.CompiledDefaults() + cfg.Proxy.AllowedEgressEnforcement = allowed + return cfg + }, + GetFeatureGates: config.DefaultFeatureGates, + } +} + +func TestInjectAuthBridge_EgressEnforcement_PlatformPolicyBlocksNone(t *testing.T) { + // Platform only allows enforce-redirect. CR requests none → overridden. + rt := newAgentRuntimeWithEgressEnforcement("test-ns", "my-agent", EgressEnforcementNone) + m := newTestMutatorWithAllowedEgress([]string{EgressEnforcementEnforceRedirect}, rt) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("platform policy allows only enforce-redirect; proxy-init should be injected despite CR requesting none") + } +} + +func TestInjectAuthBridge_EgressEnforcement_PlatformPolicyAllowsNone(t *testing.T) { + // Platform allows both modes. CR requests none → honored. + rt := newAgentRuntimeWithEgressEnforcement("test-ns", "my-agent", EgressEnforcementNone) + m := newTestMutatorWithAllowedEgress([]string{EgressEnforcementEnforceRedirect, EgressEnforcementNone}, rt) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("platform allows none; CR requests none; proxy-init should NOT be injected") + } +} + +func TestInjectAuthBridge_EgressEnforcement_PlatformPolicyOnlyNone(t *testing.T) { + // Platform only allows none (ROSA HCP). Default enforce-redirect → overridden to none. + rt := newAgentRuntimeWithMode("test-ns", "my-agent", ModeProxySidecar) + m := newTestMutatorWithAllowedEgress([]string{EgressEnforcementNone}, rt) + ctx := context.Background() + + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "agent", Image: "my-agent:latest"}, + }, + } + labels := map[string]string{KagentiTypeLabel: KagentiTypeAgent} + + _, err := m.InjectAuthBridge(ctx, podSpec, "test-ns", "my-agent", labels, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if containerExists(podSpec.InitContainers, ProxyInitContainerName) { + t.Error("platform only allows none; proxy-init should NOT be injected even with default enforce-redirect") + } +}