From 1e4d817a5c65a29f54a6fce70f75caaa2cc170b0 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Mon, 18 May 2026 12:24:33 -0500 Subject: [PATCH] feat: typed spec.extensionProviders[] for ext_authz registrations Adds a typed surface on IstioStack for registering Istio MeshConfig ExtensionProviders (HTTP + gRPC). Per-namespace oauth2-proxy bridges declare themselves once; AuthorizationPolicy.CUSTOM rules in any consumer namespace reference the registered name. Render fails with actionable messages on missing name, duplicate name, both modes set, or neither mode set. Status surface exposes the configured provider list so consumers can gate AuthorizationPolicy MRs on readiness. End-to-end verified on pat-local: registered smoke-test-ext-authz points at a deny-403 nginx; waypoint Envoy access log shows "302 UAEX ext_authz_denied" via the registered provider. Implements [[tasks/istio-stack-extension-providers]] Pattern: [[specs/platform-public-exposure]] --- Makefile | 3 +- apis/istiostacks/definition.yaml | 92 ++++++++ .../istiostacks/with-extensionproviders.yaml | 34 +++ functions/render/000-state-init.yaml.gotmpl | 28 +++ functions/render/010-state-status.yaml.gotmpl | 10 + functions/render/210-istiod.yaml.gotmpl | 4 + functions/render/999-status.yaml.gotmpl | 3 + tests/test-render/main.k | 206 +++++++++++++++++- 8 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 examples/istiostacks/with-extensionproviders.yaml diff --git a/Makefile b/Makefile index 2c9e783..748db7a 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,8 @@ generate-configuration: EXAMPLES := \ examples/istiostacks/minimal.yaml:: \ examples/istiostacks/standard.yaml:: \ - examples/istiostacks/with-aws-lbc.yaml:: + examples/istiostacks/with-aws-lbc.yaml:: \ + examples/istiostacks/with-extensionproviders.yaml:: # Render all examples (parallel execution, output shown per-job when complete) render\:all: diff --git a/apis/istiostacks/definition.yaml b/apis/istiostacks/definition.yaml index f34392d..b55d632 100644 --- a/apis/istiostacks/definition.yaml +++ b/apis/istiostacks/definition.yaml @@ -128,6 +128,86 @@ spec: items: type: object x-kubernetes-preserve-unknown-fields: true + extensionProviders: + description: | + Istio MeshConfig extensionProviders registered on istiod. Each entry maps 1:1 + onto a meshConfig.extensionProviders[] element so AuthorizationPolicy.CUSTOM + rules in any namespace can reference the registered provider name. Operator- + managed list — consumer stacks (oauth2-proxy bridges, etc.) declare intent in + their own spec, and the operator adds the matching entry here. Currently + scoped to envoyExtAuthzHttp and envoyExtAuthzGrpc; broader MeshConfig knobs + are out of scope for this surface. Exactly one of envoyExtAuthzHttp / + envoyExtAuthzGrpc must be set per entry; render fails otherwise. + type: array + items: + type: object + required: + - name + properties: + name: + description: Unique provider name. AuthorizationPolicy.CUSTOM rules reference this string. Convention is -oauth2-proxy (e.g., observe-oauth2-proxy). + type: string + envoyExtAuthzHttp: + description: HTTP ext_authz provider. Mirrors Istio MeshConfig.ExtensionProvider.EnvoyExternalAuthorizationHttpProvider. + type: object + required: + - service + - port + properties: + service: + description: FQDN of the ext_authz service (e.g., oauth2-proxy.observe.svc.cluster.local). + type: string + port: + description: Port of the ext_authz service. + type: integer + pathPrefix: + description: Path prefix for the ext_authz HTTP check (e.g., /oauth2/auth). + type: string + timeout: + description: ext_authz call timeout as a duration string (e.g., 1s, 250ms). + type: string + includeRequestHeadersInCheck: + description: Request headers to forward to the ext_authz service. + type: array + items: + type: string + headersToUpstreamOnAllow: + description: Response headers from the ext_authz service to forward upstream when the request is allowed. + type: array + items: + type: string + headersToDownstreamOnAllow: + description: Response headers from the ext_authz service to forward downstream when the request is allowed. + type: array + items: + type: string + headersToDownstreamOnDeny: + description: Response headers from the ext_authz service to forward downstream when the request is denied. + type: array + items: + type: string + statusOnError: + description: HTTP status code returned to the downstream client when the ext_authz call fails (default 403). + type: string + envoyExtAuthzGrpc: + description: gRPC ext_authz provider. Mirrors Istio MeshConfig.ExtensionProvider.EnvoyExternalAuthorizationGrpcProvider. + type: object + required: + - service + - port + properties: + service: + description: FQDN of the ext_authz gRPC service. + type: string + port: + description: Port of the ext_authz gRPC service. + type: integer + timeout: + description: ext_authz call timeout. + type: string + failOpen: + description: When true, allow requests if the ext_authz service is unreachable. Defaults to false (deny on error), matching Istio. + type: boolean awsLoadBalancerController: description: AWS Load Balancer Controller integration. This stack does NOT install the controller — install separately via aws-lbc-stack. When enabled, NLB annotations are propagated to the Gateway's auto-created Service via spec.infrastructure.annotations on the Gateway resource. The LBC then provisions an NLB in TCP passthrough mode. type: object @@ -199,5 +279,17 @@ spec: properties: ready: type: boolean + extensionProviders: + description: Observed state of the registered MeshConfig extensionProviders. Consumers gate AuthorizationPolicy.CUSTOM MRs on this readiness to avoid race-on-first-apply. + type: object + properties: + configured: + description: Names of extensionProviders that made it into the rendered istiod helm values. + type: array + items: + type: string + ready: + description: True when istiod is Ready and the rendered providers match the spec list. + type: boolean required: - spec diff --git a/examples/istiostacks/with-extensionproviders.yaml b/examples/istiostacks/with-extensionproviders.yaml new file mode 100644 index 0000000..c3900ac --- /dev/null +++ b/examples/istiostacks/with-extensionproviders.yaml @@ -0,0 +1,34 @@ +apiVersion: hops.ops.com.ai/v1alpha1 +kind: IstioStack +metadata: + name: istio + namespace: default +spec: + clusterName: my-cluster + + # MeshConfig extensionProviders registered on istiod so consumer namespaces + # can attach AuthorizationPolicy.CUSTOM rules that delegate to ext_authz + # services running in their own namespace. Operator-managed list — one entry + # per oauth2-proxy bridge, named -oauth2-proxy. + # + # See specs/platform-public-exposure for the architectural pattern. + extensionProviders: + - name: observe-oauth2-proxy + envoyExtAuthzHttp: + service: oauth2-proxy.observe.svc.cluster.local + port: 4180 + pathPrefix: /oauth2/auth + timeout: 1s + includeRequestHeadersInCheck: + - authorization + - cookie + headersToUpstreamOnAllow: + - authorization + - path + - x-auth-request-user + - x-auth-request-email + - x-auth-request-access-token + headersToDownstreamOnDeny: + - content-type + - set-cookie + statusOnError: "403" diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl index 1c5a4ab..e107ca3 100644 --- a/functions/render/000-state-init.yaml.gotmpl +++ b/functions/render/000-state-init.yaml.gotmpl @@ -47,6 +47,33 @@ {{- $cni := $spec.cni | default dict }} {{- $ztunnel := $spec.ztunnel | default dict }} +# ============================================================================== +# MeshConfig extensionProviders +# +# Operator-managed list. Each entry mirrors a meshConfig.extensionProviders[] +# element. Exactly one of envoyExtAuthzHttp / envoyExtAuthzGrpc must be set. +# Names must be unique — duplicates would silently overwrite in MeshConfig. +# ============================================================================== +{{- $extensionProviders := $spec.extensionProviders | default list }} +{{- $seenNames := dict }} +{{- range $idx, $ep := $extensionProviders }} + {{- if not $ep.name }} + {{- fail (printf "spec.extensionProviders[%d].name is required" $idx) }} + {{- end }} + {{- if hasKey $seenNames $ep.name }} + {{- fail (printf "spec.extensionProviders: duplicate name %q (entries must be uniquely named)" $ep.name) }} + {{- end }} + {{- $seenNames = set $seenNames $ep.name true }} + {{- $hasHttp := and (hasKey $ep "envoyExtAuthzHttp") (gt (len ($ep.envoyExtAuthzHttp | default dict)) 0) }} + {{- $hasGrpc := and (hasKey $ep "envoyExtAuthzGrpc") (gt (len ($ep.envoyExtAuthzGrpc | default dict)) 0) }} + {{- if and $hasHttp $hasGrpc }} + {{- fail (printf "spec.extensionProviders[%s]: set exactly one of envoyExtAuthzHttp or envoyExtAuthzGrpc, not both" $ep.name) }} + {{- end }} + {{- if not (or $hasHttp $hasGrpc) }} + {{- fail (printf "spec.extensionProviders[%s]: set exactly one of envoyExtAuthzHttp or envoyExtAuthzGrpc" $ep.name) }} + {{- end }} +{{- end }} + # ============================================================================== # Ingress Gateway # ============================================================================== @@ -135,6 +162,7 @@ "enabled" $lbcEnabled "releaseRef" (dict "name" $lbcReleaseRefName) ) + "extensionProviders" $extensionProviders "observed" (dict) "status" (dict) }} diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/render/010-state-status.yaml.gotmpl index 23ab7ed..1d20340 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/render/010-state-status.yaml.gotmpl @@ -24,6 +24,12 @@ {{- $allReady = and $allReady (get $checkReady "gateway") }} {{- end }} +{{- $configuredProviders := list }} +{{- range $ep := $state.extensionProviders }} + {{- $configuredProviders = append $configuredProviders $ep.name }} +{{- end }} +{{- $providersReady := and (get $checkReady "istiod") (gt (len $configuredProviders) 0) }} + {{- $state = set $state "observed" (dict "controlPlaneNamespace" (dict "ready" (get $checkReady "control-plane-namespace")) "ingressNamespace" (dict "ready" (get $checkReady "ingress-namespace")) @@ -32,6 +38,10 @@ "cni" (dict "ready" (get $checkReady "istio-cni")) "ztunnel" (dict "ready" (get $checkReady "ztunnel")) "gateway" (dict "ready" (get $checkReady "gateway")) + "extensionProviders" (dict + "configured" $configuredProviders + "ready" $providersReady + ) ) }} {{- $state = set $state "status" (dict diff --git a/functions/render/210-istiod.yaml.gotmpl b/functions/render/210-istiod.yaml.gotmpl index 2d0e35a..bfdf031 100644 --- a/functions/render/210-istiod.yaml.gotmpl +++ b/functions/render/210-istiod.yaml.gotmpl @@ -26,6 +26,10 @@ spec: profile: ambient meshConfig: accessLogFile: /dev/stdout + {{- if gt (len $state.extensionProviders) 0 }} + extensionProviders: + {{- toYaml $state.extensionProviders | nindent 10 }} + {{- end }} pilot: env: PILOT_ENABLE_METADATA_EXCHANGE: "true" diff --git a/functions/render/999-status.yaml.gotmpl b/functions/render/999-status.yaml.gotmpl index ea02cac..90990ba 100644 --- a/functions/render/999-status.yaml.gotmpl +++ b/functions/render/999-status.yaml.gotmpl @@ -19,3 +19,6 @@ status: ready: {{ $state.observed.ztunnel.ready }} gateway: ready: {{ $state.observed.gateway.ready }} + extensionProviders: + configured: {{ $state.observed.extensionProviders.configured | toJson }} + ready: {{ $state.observed.extensionProviders.ready }} diff --git a/tests/test-render/main.k b/tests/test-render/main.k index 2ca2482..26b0cce 100644 --- a/tests/test-render/main.k +++ b/tests/test-render/main.k @@ -249,7 +249,211 @@ _items = [ } # ========================================================================== - # Test 6: providerConfigRefs default to clusterName. + # Test 6a: single HTTP extensionProvider renders into meshConfig with all + # typed fields (service/port/path/headers/timeout/statusOnError). + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "extension-providers-http-renders-meshconfig" + spec = { + compositionPath = "apis/istiostacks/composition.yaml" + xrdPath = "apis/istiostacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.IstioStack { + metadata.name = "test" + spec = { + clusterName = "my-cluster" + extensionProviders = [{ + name = "observe-oauth2-proxy" + envoyExtAuthzHttp = { + service = "oauth2-proxy.observe.svc.cluster.local" + port = 4180 + pathPrefix = "/oauth2/auth" + timeout = "1s" + includeRequestHeadersInCheck = ["authorization", "cookie"] + headersToUpstreamOnAllow = ["authorization", "x-auth-request-user"] + statusOnError = "403" + } + }] + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "test-istiod" + spec.forProvider.values.meshConfig.extensionProviders = [{ + name = "observe-oauth2-proxy" + envoyExtAuthzHttp = { + service = "oauth2-proxy.observe.svc.cluster.local" + port = 4180 + pathPrefix = "/oauth2/auth" + timeout = "1s" + includeRequestHeadersInCheck = ["authorization", "cookie"] + headersToUpstreamOnAllow = ["authorization", "x-auth-request-user"] + statusOnError = "403" + } + }] + } + { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "IstioStack" + metadata.name = "test" + status.extensionProviders.configured = ["observe-oauth2-proxy"] + } + ] + } + } + + # ========================================================================== + # Test 6b: single gRPC extensionProvider renders the envoyExtAuthzGrpc + # block (different shape: failOpen instead of HTTP header lists). + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "extension-providers-grpc-renders-meshconfig" + spec = { + compositionPath = "apis/istiostacks/composition.yaml" + xrdPath = "apis/istiostacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.IstioStack { + metadata.name = "test" + spec = { + clusterName = "my-cluster" + extensionProviders = [{ + name = "policy-engine" + envoyExtAuthzGrpc = { + service = "opa.policy.svc.cluster.local" + port = 9191 + timeout = "250ms" + failOpen = False + } + }] + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "test-istiod" + spec.forProvider.values.meshConfig.extensionProviders = [{ + name = "policy-engine" + envoyExtAuthzGrpc = { + service = "opa.policy.svc.cluster.local" + port = 9191 + timeout = "250ms" + failOpen = False + } + }] + } + ] + } + } + + # ========================================================================== + # Test 6c: multiple extensionProviders render in order; status.configured + # echoes the registered names. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "extension-providers-multiple-render-in-order" + spec = { + compositionPath = "apis/istiostacks/composition.yaml" + xrdPath = "apis/istiostacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.IstioStack { + metadata.name = "test" + spec = { + clusterName = "my-cluster" + extensionProviders = [ + { + name = "observe-oauth2-proxy" + envoyExtAuthzHttp = { + service = "oauth2-proxy.observe.svc.cluster.local" + port = 4180 + } + } + { + name = "knative-oauth2-proxy" + envoyExtAuthzHttp = { + service = "oauth2-proxy.knative.svc.cluster.local" + port = 4180 + } + } + ] + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "test-istiod" + spec.forProvider.values.meshConfig.extensionProviders = [ + { + name = "observe-oauth2-proxy" + envoyExtAuthzHttp = { + service = "oauth2-proxy.observe.svc.cluster.local" + port = 4180 + } + } + { + name = "knative-oauth2-proxy" + envoyExtAuthzHttp = { + service = "oauth2-proxy.knative.svc.cluster.local" + port = 4180 + } + } + ] + } + { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "IstioStack" + metadata.name = "test" + status.extensionProviders.configured = ["observe-oauth2-proxy", "knative-oauth2-proxy"] + } + ] + } + } + + # ========================================================================== + # Test 6d: empty extensionProviders list leaves meshConfig unchanged + # (no extensionProviders key emitted) and status.configured is empty. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "extension-providers-empty-leaves-meshconfig-clean" + spec = { + compositionPath = "apis/istiostacks/composition.yaml" + xrdPath = "apis/istiostacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.IstioStack { + metadata.name = "test" + spec.clusterName = "my-cluster" + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "test-istiod" + spec.forProvider.values.meshConfig = { + accessLogFile = "/dev/stdout" + } + } + { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "IstioStack" + metadata.name = "test" + status.extensionProviders = { + configured = [] + ready = False + } + } + ] + } + } + + # ========================================================================== + # Test 7: providerConfigRefs default to clusterName. # ========================================================================== metav1alpha1.CompositionTest { metadata.name = "provider-config-refs-default-to-cluster-name"