Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion charts/plane-enterprise/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Meet Plane. An Enterprise software development tool to manage issue

type: application

version: 3.3.0
version: 3.5.0
appVersion: "3.1.0"

home: https://plane.so/
Expand Down
12 changes: 9 additions & 3 deletions charts/plane-enterprise/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -998,11 +998,13 @@ If your rotation secret happens to carry the endpoint too (RDS non-master rotati

Whenever one of these is set, the chart stops emitting the corresponding composed `DATABASE_URL` / `AMQP_URL` / `REDIS_URL` in its own Secrets — the application prefers a URL when one is present, so a stale URL would silently shadow the rotated credential. **If you also supply `app_env_existingSecret`, make sure it does not contain those URL keys.**

Applies to the Django services: api, external-api, worker, importer worker, beat-worker, webhook and automation consumers, outbox poller, migrator. OpenSearch credentials additionally reach the Plane AI workloads, since they query the same cluster.
**Every service reads discrete parts from planeVersion v3.2.0 onward** — the Django family (api, external-api, worker, importer worker, beat-worker, webhook and automation consumers, outbox poller, migrator) plus silo, live and Plane AI. Below v3.2.0 only the Django family does; `helm upgrade` warns when your `planeVersion` predates the support you have configured.

**Silo, live and Plane AI still need a connection URL** for the database, broker and cache — they do not read discrete parts. For those, keep using `silo_env_existingSecret` / `live_env_existingSecret` / `pi_api_env_existingSecret` and let ESO compose the DSN with a `template` block (see `examples/external-secrets/`). `helm upgrade` prints a warning when you have configured a combination that would leave one of them without connection details.
Each workload receives only the credentials it uses. Live gets Redis and nothing else; silo gets Postgres, RabbitMQ and Redis but not OpenSearch; Plane AI gets its own `PLANE_PI_POSTGRES_*` and `FOLLOWER_POSTGRES_*` names plus Redis and OpenSearch, and deliberately no `RabbitMQ` — Plane AI prefers an AMQP broker over a Redis one, so sending it RabbitMQ parts would quietly move its queue off Redis.

OpenSearch is the exception: `external_secrets.opensearch` covers the Plane AI workloads too, so search credentials rotate for the whole deployment at once.
Plane AI's two databases both come from the same `external_secrets.database` Secret, which is the shape this chart provisions (one managed instance, two databases). If yours have genuinely separate credentials, set `env.pi_envs.follower_postgres_uri` — it still takes precedence — or use `pi_api_env_existingSecret`.

For **planeVersion below v3.2.0**, supply silo/live/Plane AI DSNs through `silo_env_existingSecret` / `live_env_existingSecret` / `pi_api_env_existingSecret` and let ESO compose them with a `template` block (see `examples/external-secrets/`).

### 3. Shared signing keys in one Secret

Expand All @@ -1015,6 +1017,10 @@ external_secrets:

The Secret should carry `SECRET_KEY`, `AES_SECRET_KEY`, `AES_SALT`, `LIVE_SERVER_SECRET_KEY`, `PI_INTERNAL_SECRET`, `SILO_HMAC_SECRET_KEY`, `CURSOR_WEBHOOK_SECRET`. While it is set, the chart stops emitting those keys in its own Secrets. It is mounted first in `envFrom`, so a key you already externalized through one of the older `*_existingSecret` groups still wins — don't define the same key in both.

The Secret must carry every key your deployment uses — a missing key is not a render error, just an absent env var. `SECRET_KEY` matters most: the API falls back to a per-pod random value when it is absent, so JWTs stop verifying across replicas and encrypted instance-configuration rows become unreadable, with no error. `RUNNER_HMAC_SECRET_KEY` belongs here too when the runner is deployed.

Do not also define any of these keys in one of the `*_existingSecret` groups. Those Secrets are mounted after this one, so a duplicate wins on the workloads mounting that group and loses everywhere else — leaving two services disagreeing on a key that has to match. `helm upgrade` warns when it sees both set.

The trade-off: because it is one Secret, every service that mounts it sees all of its keys — the live server's pods get `SECRET_KEY` in their environment even though only the API uses it. These are all first-party Plane services in one namespace, so this is the same trust boundary the duplicated copies already shared. If you need the keys separated per service, keep using the per-group `*_existingSecret` mechanism and take on keeping the shared values in step yourself.

> **Never rotate `SECRET_KEY`, `AES_SECRET_KEY` or `AES_SALT` on a running instance.** `SECRET_KEY` derives the Fernet key that encrypts the instance-configuration rows (SMTP password, OAuth client secrets, LLM keys); the AES pair protects stored OAuth application secrets, MCP connections and desktop handoff tokens. Changing either makes existing ciphertext undecryptable, and the failure is silent — values come back empty. Keep them in a secret with no rotation schedule.
Expand Down
11 changes: 11 additions & 0 deletions charts/plane-enterprise/examples/external-secrets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ reloader:

If your provider can push on rotation (an AWS Lambda rotation hook that annotates the `ExternalSecret`, or ESO's `PushSecret`/webhook paths), prefer that over polling frequently.

## Composed DSNs are only for older app versions

From **planeVersion v3.2.0** every service — including silo, live and Plane AI — reads
discrete credential parts, so the `template:` blocks in the provider examples that build
a `DATABASE_URL`/`REDIS_URL`/`AMQP_URL` are no longer needed. Point
`external_secrets.database` / `rabbitmq` / `redis` at the mirrored secret and let each
app compose its own URL.

Keep using the templated DSN sections (`plane-silo-env` and friends) only when pinned
below v3.2.0, or when a service genuinely needs different credentials from the primary.

## What must never rotate

Do not put `SECRET_KEY`, `AES_SECRET_KEY` or `AES_SALT` in a secret with a rotation policy. `SECRET_KEY` derives the Fernet key encrypting the instance-configuration rows, and the AES pair protects stored OAuth/MCP tokens; changing either makes existing ciphertext undecryptable, silently. Keep them in a separate, static secret — that is what `app_keys_existingSecret` is for.
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,15 @@ Both fail **silently**: decryption errors are swallowed and the values come back

**Database-resident secrets do not rotate through the environment.** With the default `SKIP_ENV_VAR=1`, the SMTP password, OAuth client secrets, `LLM_API_KEY` and `LDAP_BIND_PASSWORD` live in the instance-configuration table, seeded from the environment only on first boot. Set `env.skip_env_var: '0'` to make the environment the source of truth on every restart — at the cost of god-mode admin UI edits to those settings being overwritten.

**Silo, live and Plane AI read connection URLs, not discrete parts.** Their DSN has to be composed by ESO with a `template` block (see the provider examples). That template embeds the password, so it must use `urlEncode`.
**Silo, live and Plane AI rotate like the API from planeVersion v3.2.0** — they read
discrete parts, so nothing recomposes a DSN and a Reloader restart is all that is needed.
Below v3.2.0 their DSN has to be composed by ESO with a `template` block (see the provider
examples), and that template embeds the password, so it must use `urlEncode`.

**Plane AI's follower connection re-reads its credentials at call time.** After an
authentication failure it refetches without waiting for a restart, so a remounted Secret
can take effect mid-process. Its own database and broker still resolve at import, so those
rely on the Reloader restart like everything else.

## Verifying a rotation

Expand Down
135 changes: 135 additions & 0 deletions charts/plane-enterprise/questions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <release>-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"
Loading