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"