From 5d2e727426055a221e120d5560401b3eb36e6f96 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Thu, 13 Aug 2026 22:46:43 +0530 Subject: [PATCH 1/4] feat(plane-enterprise): dedicated hooks for AI provider keys and silo connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both of these could previously only be externalized through a whole-Secret hook, which made the one thing people actually need impossible. AI/LLM provider keys lived in the Plane AI Secret alongside PLANE_PI_DATABASE_URL, FOLLOWER_POSTGRES_URI, AMQP_URL, CELERY_BROKER_URL and the signing keys. Adopting pi_api_env_existingSecret therefore meant owning all of those too — and they are per-environment, while a provider account is not. So "one provider key shared by every environment" had no expression. ai_providers_existingSecret carries nothing environment-specific, so one Secret can be mirrored into as many namespaces as share the account, while a customer can point the same values key at a per-environment Secret. The chart only takes a name; the granularity is the secret store's business. The suppression had to cover the empty-string branches, not just the populated ones. The chart rendered COHERE_API_KEY: "" and CUSTOM_LLM_API_KEY: "" whenever those providers were disabled, and envFrom resolves later-source-wins — so a shared Secret mounted earlier would have been silently overwritten with empty strings. There is now an assertion for exactly this, and it fails on the pre-change render. Silo connector credentials had the same shape of problem: the silo Secret also carries DATABASE_URL, AMQP_URL and REDIS_URL. silo_connectors_existingSecret takes the whole connector surface, client ids and app names included, so it has a single owner rather than a half-external state. It is mounted on all nine workloads that mount silo-secrets, not just silo: the Django auth adapter reads GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET from that Secret on the api family, so scoping it to silo alone would have dropped those variables there. There is an assertion for that too. Also adds hack/resolve-env.py and hack/assert-secrets.py. The first resolves what each container's environment actually becomes — envFrom in list order, then explicit env on top — because a chart diff cannot show whether a key that moved between Secrets is still reachable. The second carries the assertions this work turns on, including a no-DSN check: a connection string with an embedded password is a rotation dead-end, since nothing can track a rotated password baked into a string. Verified byte-identical resolved environments across all workloads when no new hook is set. Co-Authored-By: Claude Opus 5 (1M context) --- .../plane-enterprise/templates/_helpers.tpl | 41 +++++ .../templates/config-secrets/pi-api-env.yaml | 6 + .../templates/config-secrets/silo.yaml | 4 + .../workloads/agent-consumer.deployment.yaml | 1 + .../templates/workloads/api.deployment.yaml | 1 + .../workloads/beat-worker.deployment.yaml | 1 + .../workloads/external-api.deployment.yaml | 1 + .../templates/workloads/live.deployment.yaml | 1 + .../templates/workloads/migrator.job.yaml | 1 + .../workloads/pi-api.deployment.yaml | 1 + .../workloads/pi-beat.deployment.yaml | 1 + .../templates/workloads/pi-migrator.job.yaml | 1 + .../workloads/pi-worker.deployment.yaml | 1 + .../templates/workloads/silo.deployment.yaml | 1 + .../webhook-consumer.deployment.yaml | 1 + .../worker-importers.deployment.yaml | 1 + .../workloads/worker.deployment.yaml | 1 + charts/plane-enterprise/values.yaml | 42 +++++ hack/assert-secrets.py | 165 ++++++++++++++++++ hack/resolve-env.py | 146 ++++++++++++++++ 20 files changed, 418 insertions(+) create mode 100755 hack/assert-secrets.py create mode 100755 hack/resolve-env.py diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index e1801eb8..655889f2 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -404,6 +404,47 @@ Indentation is baked in for the container envFrom position, so call it bare: {{- end }} {{- end -}} +{{/* +envFrom entry for the Secret carrying the AI/LLM provider keys. Renders nothing +unless external_secrets.ai_providers_existingSecret is set. + +Separate from the Plane AI Secret because provider accounts are shared across +environments while everything else in that Secret is per-environment. Mounted on the +Plane AI workloads and on live (whose AI_OPENAI_API_KEY has no values key at all). + +Placed before the chart's own Secret so an operator who has already externalized +pi_api_env keeps that precedence. While this is set the chart emits none of these +keys itself — including the empty-string branches, which would otherwise overwrite +this Secret's values, since envFrom resolves later-source-wins. + +Indentation is baked in for the container envFrom position, so call it bare. +*/}} +{{- define "plane.aiProvidersSecretRef" -}} +{{- with .Values.external_secrets.ai_providers_existingSecret }} + - secretRef: + name: {{ . }} + optional: false +{{- end }} +{{- end -}} + +{{/* +envFrom entry for the Secret carrying the silo connector credentials. Renders nothing +unless external_secrets.silo_connectors_existingSecret is set. + +Mounted on every workload that mounts silo-secrets today, not just silo: the Django +auth adapter reads GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET from that Secret on the api +family, so mounting this only on silo would drop those variables there. + +Indentation is baked in for the container envFrom position, so call it bare. +*/}} +{{- define "plane.siloConnectorsSecretRef" -}} +{{- with .Values.external_secrets.silo_connectors_existingSecret }} + - secretRef: + name: {{ . }} + optional: false +{{- end }} +{{- end -}} + {{/* Returns "true" when an externally managed Secret supplies the Postgres credentials, in which case the chart must not render a composed DATABASE_URL that would take diff --git a/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml b/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml index 513ebff1..4b4a33e3 100644 --- a/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/pi-api-env.yaml @@ -57,6 +57,11 @@ stringData: CELERY_BROKER_URL: {{ .Values.env.remote_redis_url | default "" | quote }} {{- end }} + {{- if empty .Values.external_secrets.ai_providers_existingSecret }} + {{/* Suppressed wholesale when the AI-providers Secret supplies these. The + empty-string branches below matter as much as the populated ones: envFrom + resolves later-source-wins, so a rendered COHERE_API_KEY: "" would overwrite + the value coming from that Secret. */}} {{- if .Values.services.pi.ai_providers.openai.enabled }} OPENAI_API_KEY: {{ .Values.services.pi.ai_providers.openai.api_key | default "" | quote }} {{- end }} @@ -91,6 +96,7 @@ stringData: BR_AWS_SESSION_TOKEN: {{ . | quote }} {{- end }} {{- end }} + {{- end }} {{- if empty .Values.external_secrets.app_keys_existingSecret }} AES_SECRET_KEY: {{ include "plane.secretValue" (dict "context" $ "name" "env.silo_envs.aes_secret_key" "value" .Values.env.silo_envs.aes_secret_key "fallback" "dsOdt7YrvxsTIFJ37pOaEVvLxN8KGBCr") | quote }} diff --git a/charts/plane-enterprise/templates/config-secrets/silo.yaml b/charts/plane-enterprise/templates/config-secrets/silo.yaml index 289aee67..fdfa0a19 100644 --- a/charts/plane-enterprise/templates/config-secrets/silo.yaml +++ b/charts/plane-enterprise/templates/config-secrets/silo.yaml @@ -51,6 +51,9 @@ stringData: AMQP_URL: "" {{- end }} + {{- if empty .Values.external_secrets.silo_connectors_existingSecret }} + {{/* Suppressed wholesale when the connectors Secret supplies these — client ids and + app names included, so the connector surface has a single owner. */}} {{- if .Values.services.silo.connectors.slack.enabled }} SLACK_CLIENT_SECRET: {{ .Values.services.silo.connectors.slack.client_secret | default "" | quote }} SLACK_CLIENT_ID: {{ .Values.services.silo.connectors.slack.client_id | default "" | quote }} @@ -85,6 +88,7 @@ stringData: HUBSPOT_CLIENT_ID: {{ .Values.services.silo.connectors.hubspot.client_id | default "" | quote }} HUBSPOT_CLIENT_SECRET: {{ .Values.services.silo.connectors.hubspot.client_secret | default "" | quote }} {{- end }} + {{- end }} {{- end }} --- {{- if .Values.services.silo.enabled }} diff --git a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml index ceb5afef..9e41976e 100644 --- a/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/agent-consumer.deployment.yaml @@ -52,6 +52,7 @@ spec: - ./bin/docker-entrypoint-agent-consumer.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-agent-consumer-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/api.deployment.yaml b/charts/plane-enterprise/templates/workloads/api.deployment.yaml index ff27b60a..696d78fe 100644 --- a/charts/plane-enterprise/templates/workloads/api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/api.deployment.yaml @@ -79,6 +79,7 @@ spec: exec ./bin/docker-entrypoint-api-ee.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml index 2d422d24..4fc520f6 100644 --- a/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/beat-worker.deployment.yaml @@ -39,6 +39,7 @@ spec: - ./bin/docker-entrypoint-beat.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml index a9623155..04bf8e1d 100644 --- a/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/external-api.deployment.yaml @@ -80,6 +80,7 @@ spec: exec ./bin/docker-entrypoint-api-ee.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/live.deployment.yaml b/charts/plane-enterprise/templates/workloads/live.deployment.yaml index 768c12d1..aae6e76d 100644 --- a/charts/plane-enterprise/templates/workloads/live.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/live.deployment.yaml @@ -93,6 +93,7 @@ spec: {{- end }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-live-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/migrator.job.yaml b/charts/plane-enterprise/templates/workloads/migrator.job.yaml index 4e5d3638..ef386418 100644 --- a/charts/plane-enterprise/templates/workloads/migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/migrator.job.yaml @@ -27,6 +27,7 @@ spec: imagePullPolicy: {{ .Values.services.api.pullPolicy | default "Always" }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml index 68d56e03..62328478 100644 --- a/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-api.deployment.yaml @@ -79,6 +79,7 @@ spec: exec ./bin/entrypoint-api.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml index e6ee62d8..b0380aad 100644 --- a/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-beat.deployment.yaml @@ -54,6 +54,7 @@ spec: exec ./bin/entrypoint-celery-beat.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml index d20bf46c..9c280af2 100644 --- a/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-migrator.job.yaml @@ -43,6 +43,7 @@ spec: exec ./bin/entrypoint-migrator.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml index c59f9b17..5dbea10a 100644 --- a/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/pi-worker.deployment.yaml @@ -55,6 +55,7 @@ spec: exec ./bin/entrypoint-celery-worker.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.aiProvidersSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-pi-api-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml index cdea5153..eae6d9b8 100644 --- a/charts/plane-enterprise/templates/workloads/silo.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/silo.deployment.yaml @@ -116,6 +116,7 @@ spec: {{- end }} envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-silo-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml index 1f2e6d04..c39fd5d3 100644 --- a/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/webhook-consumer.deployment.yaml @@ -52,6 +52,7 @@ spec: - ./bin/docker-entrypoint-webhook-consumer.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-webhook-consumer-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml index a6f831fe..8ff85938 100644 --- a/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker-importers.deployment.yaml @@ -56,6 +56,7 @@ spec: exec ./bin/docker-entrypoint-worker.sh celery.importer envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml index b8a5eb35..e4bef8e8 100644 --- a/charts/plane-enterprise/templates/workloads/worker.deployment.yaml +++ b/charts/plane-enterprise/templates/workloads/worker.deployment.yaml @@ -55,6 +55,7 @@ spec: exec ./bin/docker-entrypoint-worker.sh envFrom: {{- include "plane.appKeysSecretRef" . }} + {{- include "plane.siloConnectorsSecretRef" . }} - configMapRef: name: {{ .Release.Name }}-app-vars optional: false diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index c683f89c..da1003e7 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -695,6 +695,48 @@ external_secrets: # others, leaving two services with different values of a key that must match. app_keys_existingSecret: '' + # AI/LLM provider keys ONLY. + # + # pi_api_env_existingSecret below replaces the WHOLE Plane AI Secret, which forces + # you to own PLANE_PI_DATABASE_URL, FOLLOWER_POSTGRES_URI, AMQP_URL, + # CELERY_BROKER_URL and the signing keys as well. Those are per-environment, so it + # cannot express "one provider account shared by every environment". This hook + # carries nothing environment-specific, so a single Secret can be mirrored into as + # many namespaces as share the account — while a customer deployment can just as + # well point it at a per-environment Secret. The chart only takes a name. + # + # Keys read (all optional; an omitted key is simply an absent env var): + # OPENAI_API_KEY CLAUDE_API_KEY GROQ_API_KEY COHERE_API_KEY + # CUSTOM_LLM_API_KEY + # BR_AWS_ACCESS_KEY_ID BR_AWS_SECRET_ACCESS_KEY BR_AWS_SESSION_TOKEN + # Bedrock embeddings. On AWS omit all three and let the pod's identity sign — + # Plane AI falls back to AWS_ACCESS_KEY_ID and then to boto3's own chain. + # AI_OPENAI_API_KEY + # The live server's OpenAI-compatible proxy. The chart has no values key for + # this at all, so this hook is the only way to supply it. + # + # Do NOT put base URLs in this Secret. They are not secret, they belong in + # services.pi.ai_providers.*.base_url, and the live server validates + # AI_OPENAI_BASE_URL as a URL and exits on an empty string — which is exactly what + # a `dataFrom.extract` of a JSON secret containing an empty base URL would deliver. + ai_providers_existingSecret: '' + + # Silo connector app credentials (OAuth client secrets, the GitHub App private key, + # webhook secrets). The silo Secret also carries DATABASE_URL / AMQP_URL / + # REDIS_URL, so silo_env_existingSecret cannot externalize just the connectors. + # + # When set the chart emits none of the connector keys and this Secret owns the whole + # connector surface — client ids and app names included. They are not secret, but a + # half-external, half-rendered connector block is a configuration people get wrong. + # + # Naturally per-environment: a connector's OAuth callback URL is per-host, so the + # app registration differs per environment. + # SLACK_CLIENT_ID/SECRET, SLACK_BASE_URL + # GITHUB_CLIENT_ID/SECRET, GITHUB_APP_NAME, GITHUB_APP_ID, GITHUB_PRIVATE_KEY + # GITLAB_CLIENT_ID/SECRET + # SENTRY_CLIENT_ID/SECRET, SENTRY_BASE_URL, SENTRY_INTEGRATION_SLUG + silo_connectors_existingSecret: '' + # DNS-01 API token for the cert-manager Issuer (ssl.issuer cloudflare/digitalocean). # Must contain the key `api-token`. ssl_token_existingSecret: '' diff --git a/hack/assert-secrets.py b/hack/assert-secrets.py new file mode 100755 index 00000000..367a911e --- /dev/null +++ b/hack/assert-secrets.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Assertions over a rendered Helm release, for the secret-externalization work. + +Two of these encode the properties the whole exercise exists to guarantee: + + --no-plaintext-secrets nothing that looks like a live credential is in the render + --no-dsn no connection string carries an embedded password + +The second is the one that proves rotation is actually possible. A DSN in a Secret is +a rotation dead-end: when a managed database rotates the password, nothing can track +it, because the password is baked into a string somebody has to recompose. Discrete +credential parts are what make a verbatim mirror of a rotation secret work. + +Usage: + assert-secrets.py rendered.yaml --no-plaintext-secrets --no-dsn + assert-secrets.py rendered.yaml --absent AWS_ACCESS_KEY_ID,AWS_REGION + assert-secrets.py rendered.yaml --no-key-in-secrets OPENAI_API_KEY,CLAUDE_API_KEY + assert-secrets.py rendered.yaml --order 'Deployment/t-pi-api-wl/t-pi-api:plane-ai=plane-pi' + +Exits non-zero and prints every violation. +""" + +import argparse +import re +import sys + +import yaml + +# Credential shapes worth failing a build over. Deliberately provider-prefix based: +# matching on entropy produces false positives on image digests and checksums. +SECRET_PATTERNS = { + "OpenAI key": re.compile(r"sk-(proj-|svcacct-)?[A-Za-z0-9_-]{20,}"), + "Anthropic key": re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), + "Groq key": re.compile(r"gsk_[A-Za-z0-9]{20,}"), + "Docker Hub token": re.compile(r"dckr_(oat|pat)_[A-Za-z0-9_-]{10,}"), + "AWS access key id": re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), + "GitLab OAuth token": re.compile(r"gloas-[A-Za-z0-9_-]{20,}"), + "Google OAuth secret": re.compile(r"GOCSPX-[A-Za-z0-9_-]{10,}"), + "private key block": re.compile(r"BEGIN [A-Z ]*PRIVATE KEY"), +} + +# A connection string whose authority section carries a password. +DSN_WITH_PASSWORD = re.compile(r"\b(postgresql|postgres|amqps?|rediss?|mongodb)://[^\s:/@]+:[^\s@]+@") + + +def load(path): + with open(path) as fh: + return [d for d in yaml.safe_load_all(fh) if isinstance(d, dict)] + + +def iter_values(docs, kinds=("Secret", "ConfigMap")): + """Yield (kind, name, key, value) for every data entry in the render.""" + for doc in docs: + kind = doc.get("kind") + if kind not in kinds: + continue + name = (doc.get("metadata") or {}).get("name", "") + for field in ("stringData", "data"): + for k, v in (doc.get(field) or {}).items(): + if isinstance(v, str): + yield kind, name, k, v + + +def iter_container_env(docs): + """Yield (workload, container, env_entry) for explicit env lists.""" + for doc in docs: + if doc.get("kind") not in ("Deployment", "StatefulSet", "DaemonSet", "Job"): + continue + name = (doc.get("metadata") or {}).get("name", "") + pod = (((doc.get("spec") or {}).get("template") or {}).get("spec") or {}) + for c in (pod.get("initContainers") or []) + (pod.get("containers") or []): + for e in c.get("env") or []: + yield name, c.get("name", "?"), e + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("rendered") + ap.add_argument("--no-plaintext-secrets", action="store_true") + ap.add_argument("--no-dsn", action="store_true") + ap.add_argument("--absent", default="", help="env names that must not appear anywhere") + ap.add_argument("--no-key-in-secrets", default="", help="keys no rendered Secret may contain, even empty") + ap.add_argument( + "--order", + action="append", + default=[], + metavar="WORKLOAD/CONTAINER:EARLIER=LATER", + help="assert one envFrom secretRef precedes another", + ) + args = ap.parse_args() + + docs = load(args.rendered) + problems = [] + + if args.no_plaintext_secrets: + for kind, name, key, value in iter_values(docs): + for label, pat in SECRET_PATTERNS.items(): + if pat.search(value): + problems.append(f"plaintext {label} in {kind}/{name} key {key}") + for wl, cname, entry in iter_container_env(docs): + v = entry.get("value") + if isinstance(v, str): + for label, pat in SECRET_PATTERNS.items(): + if pat.search(v): + problems.append(f"plaintext {label} in {wl}/{cname} env {entry.get('name')}") + + if args.no_dsn: + for kind, name, key, value in iter_values(docs): + if DSN_WITH_PASSWORD.search(value): + problems.append( + f"connection string with an embedded password in {kind}/{name} key {key} " + "— a rotated credential can never reach this; use the discrete credential parts" + ) + + if args.absent: + wanted = {k for k in args.absent.split(",") if k} + for kind, name, key, _ in iter_values(docs): + if key in wanted: + problems.append(f"{key} must be absent but is set in {kind}/{name}") + for wl, cname, entry in iter_container_env(docs): + if entry.get("name") in wanted: + problems.append(f"{entry['name']} must be absent but is set on {wl}/{cname}") + + if args.no_key_in_secrets: + wanted = {k for k in args.no_key_in_secrets.split(",") if k} + for kind, name, key, value in iter_values(docs, kinds=("Secret",)): + if key in wanted: + shown = "empty string" if value == "" else "a value" + problems.append( + f"Secret/{name} still renders {key} ({shown}) — envFrom resolves " + "later-source-wins, so this overwrites the external Secret" + ) + + for spec in args.order: + target, _, pair = spec.partition(":") + earlier, _, later = pair.partition("=") + found = False + for doc in docs: + if doc.get("kind") not in ("Deployment", "StatefulSet", "Job"): + continue + name = (doc.get("metadata") or {}).get("name", "") + pod = (((doc.get("spec") or {}).get("template") or {}).get("spec") or {}) + for c in pod.get("containers") or []: + if f"{doc['kind']}/{name}/{c.get('name')}" != target: + continue + found = True + names = [s["secretRef"]["name"] for s in (c.get("envFrom") or []) if "secretRef" in s] + if earlier not in names or later not in names: + problems.append(f"{target}: expected both {earlier} and {later} in envFrom, got {names}") + elif names.index(earlier) > names.index(later): + problems.append(f"{target}: {earlier} must precede {later}, got {names}") + if not found: + problems.append(f"{target}: container not found in render") + + if problems: + print(f"FAIL — {len(problems)} violation(s):", file=sys.stderr) + for p in problems: + print(f" - {p}", file=sys.stderr) + return 1 + print("PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hack/resolve-env.py b/hack/resolve-env.py new file mode 100755 index 00000000..4d440d94 --- /dev/null +++ b/hack/resolve-env.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Resolve the effective environment of every container in a rendered Helm release. + +Reading a chart diff tells you what the templates changed. It does not tell you what +the *pods* end up with, which is the only thing that matters when secrets move between +Secrets: `envFrom` resolves in list order with later sources winning, and explicit +`env` entries beat all of them. A key that moves from one Secret to another, or that a +suppression forgot to remove, shows up here and nowhere else. + +Emits sorted JSON: {"kind/name/container": {"ENV_NAME": ""}} where the +resolution is the literal value, or a marker: + + valueFrom.secretKeyRef + valueFrom.configMapKeyRef + valueFrom.fieldRef + envFrom referenced a Secret this release never renders + (expected for operator-supplied external Secrets) + +Usage: + resolve-env.py rendered.yaml [--external NAME=KEY1,KEY2 ...] > effective.json + +--external declares a Secret that lives outside the chart along with the keys it +carries, so its contribution can be modelled instead of reported as missing. +""" + +import argparse +import json +import re +import sys + +import yaml + +WORKLOAD_KINDS = {"Deployment", "StatefulSet", "DaemonSet", "Job", "CronJob"} +# Job names carry a render timestamp; strip it so runs are comparable. +TIMESTAMP_SUFFIX = re.compile(r"-\d{8}-\d{6}$") + + +def load_docs(path): + with open(path) as fh: + return [d for d in yaml.safe_load_all(fh) if isinstance(d, dict)] + + +def collect_sources(docs, external): + """Map Secret/ConfigMap name -> {key: value}. External Secrets get sentinel values.""" + secrets, configmaps = dict(external), {} + for doc in docs: + kind = doc.get("kind") + name = (doc.get("metadata") or {}).get("name", "") + if kind == "Secret": + merged = dict(doc.get("stringData") or {}) + # data is base64; the value is irrelevant for comparison, the key is not. + for k in (doc.get("data") or {}): + merged.setdefault(k, "") + secrets[name] = merged + elif kind == "ConfigMap": + configmaps[name] = dict(doc.get("data") or {}) + return secrets, configmaps + + +def pod_templates(doc): + """Yield (container_dict, is_init) for every container in a workload.""" + kind = doc.get("kind") + spec = doc.get("spec") or {} + if kind == "CronJob": + spec = ((spec.get("jobTemplate") or {}).get("spec") or {}) + pod = ((spec.get("template") or {}).get("spec") or {}) + for c in pod.get("initContainers") or []: + yield c, True + for c in pod.get("containers") or []: + yield c, False + + +def resolve_container(container, secrets, configmaps): + env = {} + # envFrom first, in order: later sources overwrite earlier ones. + for source in container.get("envFrom") or []: + if "secretRef" in source: + name = source["secretRef"].get("name", "") + if name in secrets: + env.update(secrets[name]) + else: + env[f""] = "" + elif "configMapRef" in source: + name = source["configMapRef"].get("name", "") + if name in configmaps: + env.update(configmaps[name]) + else: + env[f""] = "" + # Explicit env always wins over envFrom. + for entry in container.get("env") or []: + name = entry.get("name") + if not name: + continue + if "value" in entry: + env[name] = entry["value"] + continue + vf = entry.get("valueFrom") or {} + if "secretKeyRef" in vf: + ref = vf["secretKeyRef"] + env[name] = f"" + elif "configMapKeyRef" in vf: + ref = vf["configMapKeyRef"] + env[name] = f"" + elif "fieldRef" in vf: + env[name] = f"" + else: + env[name] = "" + return env + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("rendered") + ap.add_argument( + "--external", + action="append", + default=[], + metavar="NAME=KEY1,KEY2", + help="model an operator-supplied Secret and the keys it carries", + ) + args = ap.parse_args() + + external = {} + for spec in args.external: + name, _, keys = spec.partition("=") + external[name] = {k: f"" for k in keys.split(",") if k} + + docs = load_docs(args.rendered) + secrets, configmaps = collect_sources(docs, external) + + out = {} + for doc in docs: + if doc.get("kind") not in WORKLOAD_KINDS: + continue + name = TIMESTAMP_SUFFIX.sub("", (doc.get("metadata") or {}).get("name", "")) + for container, is_init in pod_templates(doc): + cname = container.get("name", "?") + key = f"{doc['kind']}/{name}/{'init:' if is_init else ''}{cname}" + out[key] = dict(sorted(resolve_container(container, secrets, configmaps).items())) + + json.dump(dict(sorted(out.items())), sys.stdout, indent=2) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() From 1c80a74f5cfa4977745ae573cd1621e8adeec899 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Thu, 13 Aug 2026 22:51:48 +0530 Subject: [PATCH 2/4] feat(plane-enterprise): read-replica credential mirror, and omit an unset AWS region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit services.postgres.read_replica.remote_url was the last password-bearing connection string in this chart with no discrete-parts alternative, so a managed rotation could never reach the replica. external_secrets.database.readReplica now supplies POSTGRES_READ_REPLICA_{USER,PASSWORD} from a mirrored Secret with the endpoint in values, and DATABASE_READ_REPLICA_URL is blanked in that mode so a stale composed URL cannot shadow the rotated credential. No application change was needed: Django takes the replica's parts straight into its DATABASES dict, so nothing composes a URL at all. secretName defaults to the primary's, since a replica normally accepts the same credential. The fail guard that demanded remote_url now accepts the mirror as a source too, and says what the alternatives are rather than naming only one of them — otherwise enabling a replica keylessly was impossible by construction. AWS_REGION and AWS_S3_ENDPOINT_URL are now omitted when unset rather than rendered empty. The keys next to them were already treated this way because an empty AWS_ACCESS_KEY_ID derails boto3's credential chain; the region is subtler. When a pod identity is attached and OpenSearch has no basic auth, the API signs OpenSearch requests with SigV4 and reads the region as `os.environ.get("AWS_REGION") or default` — an empty string is present, so the default never applies, and every signed request goes out with an empty region and is rejected. Absent is the only safe encoding of "unset". The companion app change makes the same value safe on older charts. Verified with hack/: the resolved environment of every container is byte-identical when no new hook is set; a fully externalized render (database, rabbitmq, redis, replica, external object storage) now contains zero connection strings with embedded passwords; and in the keyless case AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION are absent from every container rather than empty. Co-Authored-By: Claude Opus 5 (1M context) --- .../plane-enterprise/templates/_helpers.tpl | 51 +++++++++++++++++++ .../templates/config-secrets/app-env.yaml | 18 +++++-- .../templates/config-secrets/doc-store.yaml | 14 ++++- charts/plane-enterprise/values.yaml | 24 +++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index 655889f2..532ec287 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -572,6 +572,57 @@ the live server needs Redis and nothing else. {{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_DB" "secret" $db.secretName "key" .) }} {{- end }} {{- end }} +{{- include "plane.postgresReadReplicaCredsEnv" . }} +{{- end -}} + +{{/* +Returns "true" when the read replica's credentials come from an externally managed +Secret. Falls back to the primary's Secret, since a replica normally accepts the same +credential — set readReplica.secretName only when it has its own user. +*/}} +{{- define "plane.externalReadReplica" -}} +{{- if .Values.services.postgres.read_replica.enabled -}} +{{- if or .Values.external_secrets.database.readReplica.secretName .Values.external_secrets.database.secretName -}} +true +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Read-replica credentials as discrete parts. + +services.postgres.read_replica.remote_url is a DSN carrying the password, so a managed +rotation can never update it. The API reads POSTGRES_READ_REPLICA_* natively — Django +takes the parts straight into a config dict, so nothing composes a URL — which makes +this a chart-only change. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.postgresReadReplicaCredsEnv" -}} +{{- $db := .Values.external_secrets.database -}} +{{- $rr := $db.readReplica -}} +{{- $secret := $rr.secretName | default $db.secretName -}} +{{/* The newline after this `if` is deliberate: call sites use a left-trim marker, so + the output has to open with one to keep this entry off the previous line. */}} +{{- if include "plane.externalReadReplica" . }} +- name: POSTGRES_READ_REPLICA_HOST + value: {{ .Values.env.pgdb_read_replica_host | quote }} +- name: POSTGRES_READ_REPLICA_PORT + value: {{ .Values.env.pgdb_read_replica_port | default "5432" | quote }} +- name: POSTGRES_READ_REPLICA_DB + value: {{ .Values.env.pgdb_read_replica_name | default .Values.env.pgdb_name | default "plane" | quote }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_USER" "secret" $secret "key" ($rr.usernameKey | default $db.usernameKey | default "username")) }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_PASSWORD" "secret" $secret "key" ($rr.passwordKey | default $db.passwordKey | default "password")) }} +{{- with $rr.hostKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_HOST" "secret" $secret "key" .) }} +{{- end }} +{{- with $rr.portKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_PORT" "secret" $secret "key" .) }} +{{- end }} +{{- with $rr.dbNameKey }} +{{- include "plane.secretKeyEnv" (dict "name" "POSTGRES_READ_REPLICA_DB" "secret" $secret "key" .) }} +{{- end }} +{{- end }} {{- end -}} {{/* diff --git a/charts/plane-enterprise/templates/config-secrets/app-env.yaml b/charts/plane-enterprise/templates/config-secrets/app-env.yaml index 6e598c6b..822eec00 100644 --- a/charts/plane-enterprise/templates/config-secrets/app-env.yaml +++ b/charts/plane-enterprise/templates/config-secrets/app-env.yaml @@ -1,5 +1,11 @@ -{{- if and .Values.services.postgres.read_replica.enabled (not .Values.services.postgres.read_replica.remote_url) (empty .Values.external_secrets.app_env_existingSecret) }} -{{- fail "read_replica.enabled is true but read_replica.remote_url is empty" }} +{{/* The replica needs a source of connection details: a DSN in values, a Secret the + operator owns, or the discrete-parts mirror. Without one of those, enabling it + would silently produce a replica entry with no host. */}} +{{- if and .Values.services.postgres.read_replica.enabled + (not .Values.services.postgres.read_replica.remote_url) + (empty .Values.external_secrets.app_env_existingSecret) + (not (include "plane.externalReadReplica" .)) }} +{{- fail "read_replica.enabled is true but no source of replica credentials is configured — set services.postgres.read_replica.remote_url, or external_secrets.database.readReplica.secretName (or external_secrets.database.secretName, which it falls back to) together with env.pgdb_read_replica_host" }} {{- end }} {{- if empty .Values.external_secrets.app_env_existingSecret}} apiVersion: v1 @@ -45,7 +51,13 @@ stringData: DATABASE_URL: "" {{- end }} - {{- if and .Values.services.postgres.read_replica.enabled .Values.services.postgres.read_replica.remote_url }} + {{- if include "plane.externalReadReplica" . }} + {{/* Credentials come from the readReplica mirror as discrete + POSTGRES_READ_REPLICA_* parts. Left empty on purpose: the API prefers + DATABASE_READ_REPLICA_URL when set, so a composed URL here would shadow the + rotated password. */}} + DATABASE_READ_REPLICA_URL: "" + {{- else if and .Values.services.postgres.read_replica.enabled .Values.services.postgres.read_replica.remote_url }} DATABASE_READ_REPLICA_URL: {{ .Values.services.postgres.read_replica.remote_url | quote }} {{- end }} diff --git a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml index c6e06602..4ebd6119 100644 --- a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml +++ b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml @@ -46,8 +46,18 @@ stringData: {{- with .Values.env.aws_secret_access_key }} AWS_SECRET_ACCESS_KEY: {{ . | quote }} {{- end }} - AWS_REGION: {{ .Values.env.aws_region | default "" | quote }} - AWS_S3_ENDPOINT_URL: {{ .Values.env.aws_s3_endpoint_url | quote }} + {{/* Also omitted rather than blank, for a subtler reason than the keys above. When a + pod identity is attached and OpenSearch has no basic auth, the API signs its + OpenSearch requests with SigV4 — and it reads the region as + os.environ.get("AWS_REGION") or a default. An empty string is *present*, so the + default never applies and every signed request goes out with an empty region and + is rejected. Absent is the only safe representation of "unset". */}} + {{- with .Values.env.aws_region }} + AWS_REGION: {{ . | quote }} + {{- end }} + {{- with .Values.env.aws_s3_endpoint_url }} + AWS_S3_ENDPOINT_URL: {{ . | quote }} + {{- end }} {{- end }} {{- end }} --- diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index da1003e7..2104d7c8 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -638,6 +638,23 @@ external_secrets: portKey: '' dbNameKey: '' + # Read replica. services.postgres.read_replica.remote_url is a DSN with the + # password inside it, which a managed rotation can never update — so this is the + # replica's equivalent of the block above. The API reads + # POSTGRES_READ_REPLICA_{DB,USER,PASSWORD,HOST,PORT} natively (Django takes the + # parts as a config dict; nothing composes a URL), so no application change is + # needed to use it. + # + # secretName defaults to the primary's, since a replica normally accepts the same + # credential. Set it only when the replica has its own user. + readReplica: + secretName: '' + usernameKey: '' # defaults to database.usernameKey + passwordKey: '' # defaults to database.passwordKey + hostKey: '' + portKey: '' + dbNameKey: '' + rabbitmq: # Amazon MQ shape. Set env.rabbitmq_ssl: true and env.rabbitmq_port: '5671' — # Amazon MQ refuses plaintext AMQP. @@ -815,6 +832,13 @@ env: pgdb_host: '' pgdb_port: '5432' + # Read-replica endpoint, used with external_secrets.database.readReplica (the + # credentials come from that Secret). The database name defaults to pgdb_name, since + # a replica serves the same database under a different endpoint. + pgdb_read_replica_host: '' + pgdb_read_replica_port: '5432' + pgdb_read_replica_name: '' + # RabbitMQ endpoint used with external_secrets.rabbitmq (credentials come from # that Secret). Ignored when services.rabbitmq.local_setup is true. rabbitmq_host: '' From 7f063fa2dc19591b3847ff4a740c9c80cd1e4871 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Thu, 13 Aug 2026 23:14:34 +0530 Subject: [PATCH 3/4] feat(plane-enterprise): storage credential mirror and a cloud-identity marker (3.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit external_secrets.storage covers the object-storage credentials that a pod identity cannot replace: an S3-compatible backend with no workload identity, or GCS with a service-account key. That key is the highest-value blob in a GCS deployment — the whole service-account JSON, private key included — and it had no hook at all, so the only way to keep it out of values.yaml was to take over the entire doc-store Secret and own USE_MINIO, the bucket name and the size limit along with it. The mirror is wired through the three composite credential helpers rather than per-workload, since those already reach every workload that mounts the doc-store Secret. It is inert while the bundled MinIO is deployed, which supplies its own credentials. serviceAccount.cloudIdentity declares that an identity is attached out of band. It changes no rendered output, and it cannot be inferred: an EKS Pod Identity association is an API object keyed on cluster, namespace and service account, and nothing about it shows up in the pod spec. What it buys is three warnings for the interactions an attached identity creates — a static env.aws_access_key silently beating the identity in the SDK chain, the same for Plane AI's Bedrock keys, and the one that is genuinely surprising: a remote OpenSearch with no credentials will be authenticated with SigV4, because both the API and Plane AI read "AWS credentials are present" as "sign requests" — and an identity puts credentials in every container on the ServiceAccount. Correct for Amazon OpenSearch Service, wrong for any other. The warning names both fixes. Verified with hack/: the resolved environment of every container is byte-identical when no new hook is set; ten configurations render valid YAML including all hooks at once and the legacy groups; and the acceptance case — every hook in use, requireExplicitSecrets on, all signing keys blanked — renders with no credential pattern, no connection string carrying a password, and AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION absent from every container. What remains in the chart's own Secrets is empty URL placeholders and non-secret configuration. Co-Authored-By: Claude Opus 5 (1M context) --- charts/plane-enterprise/Chart.yaml | 2 +- charts/plane-enterprise/templates/NOTES.txt | 29 +++++++++++++- .../plane-enterprise/templates/_helpers.tpl | 39 +++++++++++++++++++ .../templates/config-secrets/doc-store.yaml | 2 + charts/plane-enterprise/values.yaml | 30 ++++++++++++++ 5 files changed, 100 insertions(+), 2 deletions(-) diff --git a/charts/plane-enterprise/Chart.yaml b/charts/plane-enterprise/Chart.yaml index ee450fb7..76880543 100644 --- a/charts/plane-enterprise/Chart.yaml +++ b/charts/plane-enterprise/Chart.yaml @@ -5,7 +5,7 @@ description: Meet Plane. An Enterprise software development tool to manage issue type: application -version: 3.4.0 +version: 3.5.0 appVersion: "3.1.0" home: https://plane.so/ diff --git a/charts/plane-enterprise/templates/NOTES.txt b/charts/plane-enterprise/templates/NOTES.txt index 83dd2d9b..6e667333 100644 --- a/charts/plane-enterprise/templates/NOTES.txt +++ b/charts/plane-enterprise/templates/NOTES.txt @@ -89,10 +89,37 @@ different values of a key that has to match. {{- end }} {{- /* Static object-storage keys where a cloud identity would do. */}} -{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) (not .Values.serviceAccount.annotations) }} +{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) (not .Values.serviceAccount.annotations) (not .Values.serviceAccount.cloudIdentity) }} {{- $warnings = append $warnings "Object storage is configured with a static AWS access key. Prefer a cloud identity:\n annotate the ServiceAccount (serviceAccount.annotations) for IRSA / EKS Pod Identity\n / GKE or Azure Workload Identity and clear env.aws_access_key and\n env.aws_secret_access_key — the chart then omits those env vars so the SDK's default\n credential chain picks up the pod's role." }} {{- end }} +{{- /* +An attached cloud identity reaches every container on the ServiceAccount, so it changes +behaviour beyond object storage. The chart cannot detect the association — it is an EKS +API object keyed on cluster + namespace + service account — hence the declared flag. +*/}} +{{- $hasIdentity := or .Values.serviceAccount.cloudIdentity .Values.serviceAccount.annotations }} +{{- if $hasIdentity }} + +{{- /* A static key beats the identity in the SDK credential chain. */}} +{{- if and .Values.env.aws_access_key (not (include "plane.minioEnabled" .)) }} +{{- $warnings = append $warnings "A cloud identity is configured, but env.aws_access_key is also set. Environment\n credentials take precedence over the identity in every AWS SDK's chain, so the\n static key is what will be used. Clear env.aws_access_key and\n env.aws_secret_access_key to let the identity apply." }} +{{- end }} + +{{- if .Values.services.pi.ai_providers.embedding_model.aws_access_key }} +{{- $warnings = append $warnings "A cloud identity is configured, but\n services.pi.ai_providers.embedding_model.aws_access_key is also set. Plane AI falls\n back to the identity when the BR_AWS_* keys are absent, so clear them to use it." }} +{{- end }} + +{{- /* +The one that is genuinely surprising: an identity attached for S3 silently changes how +OpenSearch is authenticated, because both services read "AWS credentials are present" +as "sign with SigV4". +*/}} +{{- if and .Values.env.opensearch_remote_url (not (include "plane.externalOpensearch" .)) (not .Values.env.opensearch_remote_username) }} +{{- $warnings = append $warnings "A cloud identity is configured and the remote OpenSearch has no credentials, so the\n API and Plane AI will authenticate to it with SigV4 — an identity injects AWS\n credentials into every container, and both read that as a request to sign.\n That is correct for Amazon OpenSearch Service and wrong for any other OpenSearch.\n Either set external_secrets.opensearch.secretName, or set OPENSEARCH_AUTH_MODE=basic\n through extraEnv (needs planeVersion v3.2.0 or newer)." }} +{{- end }} +{{- end }} + {{- if $warnings }} ──────────────────────────────────────────────────────────────────────────────── diff --git a/charts/plane-enterprise/templates/_helpers.tpl b/charts/plane-enterprise/templates/_helpers.tpl index 532ec287..9330447a 100644 --- a/charts/plane-enterprise/templates/_helpers.tpl +++ b/charts/plane-enterprise/templates/_helpers.tpl @@ -544,6 +544,7 @@ Caller must indent to the correct depth (env list items). {{- include "plane.rabbitmqCredsEnv" . }} {{- include "plane.redisCredsEnv" . }} {{- include "plane.opensearchCredsEnv" . }} +{{- include "plane.storageCredsEnv" . }} {{- end -}} {{/* @@ -575,6 +576,42 @@ the live server needs Redis and nothing else. {{- include "plane.postgresReadReplicaCredsEnv" . }} {{- end -}} +{{/* +Returns "true" when object-storage credentials come from an externally managed Secret. +Never true while the bundled MinIO is deployed — that supplies its own credentials, and +overriding them would break the in-cluster client. +*/}} +{{- define "plane.externalStorage" -}} +{{- if and .Values.external_secrets.storage.secretName (not (include "plane.minioEnabled" .)) -}} +true +{{- end -}} +{{- end -}} + +{{/* +Object-storage credentials as explicit env entries, so they win over the doc-store +Secret mounted via envFrom. + +Only the keys the operator names are emitted: an S3 deployment sets the two access-key +keys, a GCS deployment sets gcsCredentialsJsonKey, and a deployment using a pod identity +sets none of them and relies on the SDK credential chain. + +Caller must indent to the correct depth (env list items). +*/}} +{{- define "plane.storageCredsEnv" -}} +{{- $st := .Values.external_secrets.storage -}} +{{- if include "plane.externalStorage" . }} +{{- with $st.accessKeyIdKey }} +{{- include "plane.secretKeyEnv" (dict "name" "AWS_ACCESS_KEY_ID" "secret" $st.secretName "key" .) }} +{{- end }} +{{- with $st.secretAccessKeyKey }} +{{- include "plane.secretKeyEnv" (dict "name" "AWS_SECRET_ACCESS_KEY" "secret" $st.secretName "key" .) }} +{{- end }} +{{- with $st.gcsCredentialsJsonKey }} +{{- include "plane.secretKeyEnv" (dict "name" "GCS_CREDENTIALS_JSON" "secret" $st.secretName "key" .) }} +{{- end }} +{{- end }} +{{- end -}} + {{/* Returns "true" when the read replica's credentials come from an externally managed Secret. Falls back to the primary's Secret, since a replica normally accepts the same @@ -707,6 +744,7 @@ Caller must indent to the correct depth (env list items). {{- include "plane.postgresCredsEnv" . }} {{- include "plane.rabbitmqCredsEnv" . }} {{- include "plane.redisCredsEnv" . }} +{{- include "plane.storageCredsEnv" . }} {{- end -}} {{/* @@ -753,4 +791,5 @@ Caller must indent to the correct depth (env list items). {{- end }} {{- include "plane.redisCredsEnv" . }} {{- include "plane.opensearchCredsEnv" . }} +{{- include "plane.storageCredsEnv" . }} {{- end -}} diff --git a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml index 4ebd6119..32d3bfd0 100644 --- a/charts/plane-enterprise/templates/config-secrets/doc-store.yaml +++ b/charts/plane-enterprise/templates/config-secrets/doc-store.yaml @@ -22,9 +22,11 @@ stringData: {{- with .Values.env.gcs_credentials_path }} GCS_CREDENTIALS_PATH: {{ . | quote }} {{- end }} + {{- if not (include "plane.externalStorage" .) }} {{- with .Values.env.gcs_credentials_json }} GCS_CREDENTIALS_JSON: {{ . | quote }} {{- end }} + {{- end }} {{- else if .Values.services.minio.local_setup }} USE_MINIO: "1" MINIO_ROOT_USER: {{ .Values.services.minio.root_user | default "admin" | quote }} diff --git a/charts/plane-enterprise/values.yaml b/charts/plane-enterprise/values.yaml index 2104d7c8..a9a20470 100644 --- a/charts/plane-enterprise/values.yaml +++ b/charts/plane-enterprise/values.yaml @@ -599,6 +599,15 @@ serviceAccount: # Extra pod-template labels (Azure Workload Identity requires one). podLabels: {} + # Declares that this ServiceAccount is bound to a cloud identity configured OUT OF + # BAND — an EKS Pod Identity association, a GKE Workload Identity binding. The chart + # cannot detect that on its own: a Pod Identity association is an EKS API object keyed + # on cluster + namespace + service account, and nothing about it appears in the pod + # spec. Setting this changes no rendered output; it only lets `helm upgrade` warn + # about the interactions an attached identity creates, chiefly that credentials now + # reach every container on this ServiceAccount. + cloudIdentity: false + # ============================================================ # Externalized secrets # ============================================================ @@ -673,6 +682,27 @@ external_secrets: hostKey: '' portKey: '' + storage: + # Object-storage credentials. + # + # On a cloud, leave this unset and give the pod an identity instead — annotate the + # ServiceAccount (see serviceAccount.annotations) or attach an EKS Pod Identity + # association. With env.aws_access_key / aws_secret_access_key empty the chart omits + # those variables entirely, which is what lets the SDK's credential chain reach the + # pod's role. An empty value would beat the chain and fail every request. + # + # Use this for an S3-compatible backend with no workload identity (Ceph, DO Spaces, + # an external MinIO), or for GCS with a service-account key. Ignored while the + # bundled MinIO is deployed, which supplies its own credentials. + # + # gcsCredentialsJsonKey carries the whole service-account JSON, private key + # included — the single highest-value blob in a GCS deployment, and the reason it + # should never sit in values.yaml. + secretName: '' + accessKeyIdKey: '' # -> AWS_ACCESS_KEY_ID + secretAccessKeyKey: '' # -> AWS_SECRET_ACCESS_KEY + gcsCredentialsJsonKey: '' # -> GCS_CREDENTIALS_JSON + opensearch: # Credentials for a REMOTE OpenSearch. Ignored when # services.opensearch.local_setup is true — the chart owns both sides of the From 7b8083166daeab022a461279327d71c5144dc4b4 Mon Sep 17 00:00:00 2001 From: Pratapa Lakshmi Date: Thu, 13 Aug 2026 23:53:50 +0530 Subject: [PATCH 4/4] =?UTF-8?q?feat(plane-enterprise):=20expose=20the=203.?= =?UTF-8?q?3.0=E2=80=933.5.0=20secret=20hooks=20in=20questions.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of the credential mirrors, shared key groups or ServiceAccount fields were reachable from the Rancher UI, which is how most operators configure this chart — a hook nobody can find is a hook nobody uses. Three new groups. Credential Mirrors covers external_secrets.{database,rabbitmq,redis, storage,opensearch}, kept apart from the existing whole-Secret replacements because they answer a different question: a mirror is read as parts and never composed into a DSN, so a managed rotation lands with nothing to recompute. Shared Secrets covers the four key groups. Service Account covers create/name/annotations/cloudIdentity plus env.requireExplicitSecrets. The descriptions carry the operational traps rather than restating the field names: Amazon MQ needs rabbitmq_ssl and port 5671 because the parts path has no URL scheme to carry TLS; ElastiCache puts its auth token in the password key; the signing keys must never be shared or rotated; EKS Pod Identity needs no annotation because it binds by ServiceAccount name. --- charts/plane-enterprise/questions.yml | 135 ++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/charts/plane-enterprise/questions.yml b/charts/plane-enterprise/questions.yml index e7a6bafd..59a46ae2 100644 --- a/charts/plane-enterprise/questions.yml +++ b/charts/plane-enterprise/questions.yml @@ -1793,3 +1793,138 @@ questions: type: string default: "" group: "External Secrets" + +# --- Credential mirrors ------------------------------------------------------- +# A Secret that MIRRORS the managed one your cloud already rotates — the RDS secret +# created with the instance, an ElastiCache auth-token secret, an Amazon MQ secret. +# The chart reads the parts out of it and never composes a connection string, so a +# rotation lands with nothing to recompute. That is why these are separate from the +# whole-Secret replacements above, which pin a DSN the rotation can never reach. +- variable: external_secrets.database.secretName + label: "Postgres Credential Mirror Secret" + description: "A Secret carrying the Postgres username and password as separate keys — point it at a verbatim mirror of your RDS/Cloud SQL secret so a rotation needs no second copy." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.database.usernameKey + label: "Postgres Username Key" + type: string + default: "username" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.database.passwordKey + label: "Postgres Password Key" + type: string + default: "password" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.database.hostKey + label: "Postgres Host Key (optional)" + description: "Only when the mirrored Secret carries the endpoint too. Otherwise the host comes from env.pgdb_remote_host." + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.database.secretName!=" +- variable: external_secrets.rabbitmq.secretName + label: "RabbitMQ Credential Mirror Secret" + description: "For Amazon MQ also set env.rabbitmq_ssl=true and env.rabbitmq_port=5671 — the parts path has no URL scheme to carry TLS, and Amazon MQ refuses plaintext." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.redis.secretName + label: "Redis Credential Mirror Secret" + description: "For ElastiCache the auth token goes in the password key; also set env.redis_ssl=true." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.storage.secretName + label: "Object Storage Credential Secret" + description: "Leave empty and annotate the ServiceAccount instead — a pod identity beats a stored access key. Only the key names you fill in below are emitted." + type: string + default: "" + group: "Credential Mirrors" +- variable: external_secrets.storage.accessKeyIdKey + label: "Storage Access Key ID Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.storage.secretAccessKeyKey + label: "Storage Secret Access Key Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.storage.gcsCredentialsJsonKey + label: "GCS Credentials JSON Key" + type: string + default: "" + group: "Credential Mirrors" + show_if: "external_secrets.storage.secretName!=" +- variable: external_secrets.opensearch.secretName + label: "OpenSearch Credential Mirror Secret" + description: "For an AWS-managed domain, leave this empty and let the pods authenticate with SigV4 through their cloud identity." + type: string + default: "" + group: "Credential Mirrors" + +# --- Shared key groups -------------------------------------------------------- +# One Secret carrying one group of keys, mirrored into as many namespaces as you like. +# The chart stops rendering those keys, which is what makes the sharing work: an empty +# string under envFrom does not defer to another Secret, it overwrites it. +- variable: external_secrets.app_keys_existingSecret + label: "Signing Keys Secret" + description: "SECRET_KEY, AES_SECRET_KEY, AES_SALT, LIVE_SERVER_SECRET_KEY, SILO_HMAC_SECRET_KEY, RUNNER_HMAC_SECRET_KEY. These encrypt stored data: never share them between environments and never rotate them." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.ai_providers_existingSecret + label: "AI Provider Keys Secret" + description: "OPENAI / CLAUDE / GEMINI / COHERE / CUSTOM_LLM keys. One AI account normally serves every deployment, so this is the group worth sharing." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.silo_connectors_existingSecret + label: "Silo Connector Secrets Secret" + description: "Connector OAuth client secrets and the GitHub App private key. An OAuth app is registered once, so its secret is the same wherever the connector is enabled." + type: string + default: "" + group: "Shared Secrets" +- variable: external_secrets.ssl_token_existingSecret + label: "DNS-01 Token Secret" + description: "cert-manager DNS provider API token, under the key api-token." + type: string + default: "" + group: "Shared Secrets" + +# --- Cloud identity ----------------------------------------------------------- +- variable: serviceAccount.create + label: "Create the ServiceAccount" + description: "Turn off to run as a ServiceAccount managed outside this chart — one created by Terraform or Crossplane and already bound to a cloud role." + type: boolean + default: true + group: "Service Account" +- variable: serviceAccount.name + label: "ServiceAccount Name" + description: "Defaults to -srv-account." + type: string + default: "" + group: "Service Account" +- variable: serviceAccount.annotations + label: "ServiceAccount Annotations" + description: "Where a workload identity binding goes: eks.amazonaws.com/role-arn for IRSA, iam.gke.io/gcp-service-account for GKE, azure.workload.identity/client-id for Azure. EKS Pod Identity needs no annotation — it binds by name." + type: string + default: "" + group: "Service Account" +- variable: serviceAccount.cloudIdentity + label: "ServiceAccount Has a Cloud Identity" + description: "Advisory. A Pod Identity association is an EKS API object invisible to the pod spec, so the chart cannot detect it; setting this turns on the install notes that catch static credentials shadowing the identity." + type: boolean + default: false + group: "Service Account" +- variable: env.requireExplicitSecrets + label: "Refuse to Render Default Signing Keys" + description: "Fails the install instead of falling back to the published defaults for SECRET_KEY and friends. Worth turning on once the keys come from a Secret." + type: boolean + default: false + group: "Service Account"