diff --git a/helm/render_test.go b/helm/render_test.go index 30203c03..fe2e0b31 100644 --- a/helm/render_test.go +++ b/helm/render_test.go @@ -399,6 +399,81 @@ func TestDeploymentOIDCProvidersSecretConflictsWithLiteral(t *testing.T) { } } +func TestDeploymentZitadelLinkSecretConflictsWithLiteral(t *testing.T) { + helmPath := requireHelm(t) + + for _, key := range []string{"ZITADEL_LINK_CLIENT_ID", "ZITADEL_LINK_CLIENT_SECRET"} { + // #nosec G204 -- helmPath is resolved via exec.LookPath("helm"), and the + // arguments are fixed literals. + cmd := exec.Command(helmPath, "template", "mctl-api", ".", + "--set", "zitadelLinkSecret=mctl-api-oidc-zitadel", + "--set-string", "env."+key+"=literal", + ) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err == nil { + t.Fatalf("expected helm template to fail when both env.%s and zitadelLinkSecret are set", key) + } + if !strings.Contains(out.String(), "not both") { + t.Errorf("%s: unexpected failure output:\n%s", key, out.String()) + } + } +} + +// An empty literal does not trip the guard, but must not render a second +// entry next to the secretKeyRef either. +func TestDeploymentZitadelLinkSecretSkipsAnEmptyLiteral(t *testing.T) { + d, _ := renderDeployment(t, "--set", "zitadelLinkSecret=mctl-api-oidc-zitadel", + "--set-string", "env.ZITADEL_LINK_CLIENT_ID=") + n := 0 + for _, e := range d.Spec.Template.Spec.Containers[0].Env { + if e.Name == "ZITADEL_LINK_CLIENT_ID" { + n++ + if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { + t.Errorf("ZITADEL_LINK_CLIENT_ID rendered as a literal: %+v", e) + } + } + } + if n != 1 { + t.Errorf("ZITADEL_LINK_CLIENT_ID rendered %d times, want 1", n) + } +} + +func TestDeploymentEmptyZitadelLinkSecretRendersUnchanged(t *testing.T) { + _, defaultOut := renderDeployment(t) + _, emptyOut := renderDeployment(t, "--set", "zitadelLinkSecret=") + + if !bytes.Equal(defaultOut, emptyOut) { + t.Error("empty zitadelLinkSecret should render byte-identical output to the default") + } +} + +func TestDeploymentRendersZitadelLinkSecretEnv(t *testing.T) { + d, _ := renderDeployment(t, "--set", "zitadelLinkSecret=mctl-api-oidc-zitadel") + c := mainContainer(t, d) + + for _, name := range []string{"ZITADEL_LINK_CLIENT_ID", "ZITADEL_LINK_CLIENT_SECRET"} { + if n := countEnv(c, name); n != 1 { + t.Fatalf("expected exactly one %s env entry, got %d", name, n) + } + env, _ := findEnv(c, name) + if env.Value != "" { + t.Errorf("%s.value = %q, want empty (no literal in the manifest)", name, env.Value) + } + if env.ValueFrom == nil || env.ValueFrom.SecretKeyRef == nil { + t.Fatalf("expected %s to be sourced from a secretKeyRef", name) + } + ref := env.ValueFrom.SecretKeyRef + if ref.Name != "mctl-api-oidc-zitadel" || ref.Key != name { + t.Errorf("%s secretKeyRef = %s/%s, want mctl-api-oidc-zitadel/%s", name, ref.Name, ref.Key, name) + } + if !ref.Optional { + t.Errorf("expected %s secretKeyRef.optional to be true", name) + } + } +} + func TestDeploymentUsagePricingKeyOverride(t *testing.T) { d, _ := renderDeployment(t, "--set", "usagePricingConfigMap=cm", @@ -470,6 +545,7 @@ func TestHelmLintCleanAcrossOptionalValues(t *testing.T) { {"--set", "usagePricingConfigMap=mctl-api-usage-pricing"}, {"--set", "surfaceTelegramTokenSecret=mctl-api-surface-telegram"}, {"--set", "oidcProvidersSecret=mctl-api-oidc-zitadel"}, + {"--set", "zitadelLinkSecret=mctl-api-oidc-zitadel"}, } for _, extra := range cases { args := append([]string{"lint", "."}, extra...) diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 3e52ce95..90d88013 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -40,11 +40,16 @@ spec: {{- if and .Values.oidcProvidersSecret .Values.env.MCTL_OIDC_PROVIDERS }} {{- fail "set MCTL_OIDC_PROVIDERS either in env or through oidcProvidersSecret, not both" }} {{- end }} + {{- if and .Values.zitadelLinkSecret (or .Values.env.ZITADEL_LINK_CLIENT_ID .Values.env.ZITADEL_LINK_CLIENT_SECRET) }} + {{- fail "set ZITADEL_LINK_CLIENT_ID and ZITADEL_LINK_CLIENT_SECRET either in env or through zitadelLinkSecret, not both" }} + {{- end }} {{- range $key, $value := .Values.env }} - {{- /* oidcProvidersSecret supplies it below; the empty default in - values.yaml would otherwise render a second entry with the - same name, which server-side apply refuses. */}} - {{- if not (and $.Values.oidcProvidersSecret (eq $key "MCTL_OIDC_PROVIDERS")) }} + {{- /* oidcProvidersSecret and zitadelLinkSecret supply these + below; a literal entry (even an empty one) would otherwise + render a second entry with the same name, which + server-side apply refuses. */}} + {{- if not (or (and $.Values.oidcProvidersSecret (eq $key "MCTL_OIDC_PROVIDERS")) + (and $.Values.zitadelLinkSecret (or (eq $key "ZITADEL_LINK_CLIENT_ID") (eq $key "ZITADEL_LINK_CLIENT_SECRET")))) }} - name: {{ $key }} value: {{ $value | quote }} {{- end }} @@ -120,6 +125,24 @@ spec: key: MCTL_OIDC_PROVIDERS optional: true {{- end }} + {{- if .Values.zitadelLinkSecret }} + # The confidential ZITADEL client of identity linking + # (docs/principals.md, mctlhq/mctl-api#435), written by the + # zitadel-iac Job. Optional: without both keys the link flow + # answers 503 and nothing else changes. + - name: ZITADEL_LINK_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ .Values.zitadelLinkSecret }} + key: ZITADEL_LINK_CLIENT_ID + optional: true + - name: ZITADEL_LINK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.zitadelLinkSecret }} + key: ZITADEL_LINK_CLIENT_SECRET + optional: true + {{- end }} {{- if .Values.usagePricingConfigMap }} # The path, not the JSON. internal/usage/catalog_file.go reads this file # with os.ReadFile; a rate card is reviewed as a file in GitOps, and an env diff --git a/helm/values.yaml b/helm/values.yaml index 2ff10658..9baad528 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -144,6 +144,14 @@ surfacePortalTokenSecret: "" # the Dex-only configuration, unchanged. oidcProvidersSecret: "" +# Optional: Secret holding ZITADEL_LINK_CLIENT_ID and +# ZITADEL_LINK_CLIENT_SECRET, the confidential ZITADEL client of explicit +# identity linking (docs/principals.md, mctlhq/mctl-api#435). In production +# the zitadel-iac Job writes both into the same Secret as +# MCTL_OIDC_PROVIDERS. Optional, so absent keys leave the link flow off +# (503) rather than failing the pod. +zitadelLinkSecret: "" + # Optional: mount a versioned usage rate card from a ConfigMap. # # When set, the ConfigMap is mounted read-only at