Skip to content

Engine-agnostic CA extraction controller for the ca.crt-only trust anchor #3286

Description

Context

The merged design proposal unified-tls-pki settles the consume contract for TLS trust anchors: every engine, regardless of who owns its CA, exposes its trust anchor through one canonical object — a Secret named <release>.tenant-ca containing only ca.crt (the DOT is load-bearing: the dash form is ambiguous — for a release foo it collides with an operator-generated CA Secret of a neighbouring release named foo-tenant, and a dot cannot appear in operator-generated Secret names), stamped by the controller with internal.cozystack.io/tenant-ca: "true" and made tenant-visible through the lineage webhook's tenantresource verdict. Kafka's key-free <release>-clients-ca-cert is the reference shape — not the name (see below). The two labels are not interchangeable: the controller stamps tenant-ca, which the generic spec.secrets selector matches; tenantresource is the webhook's authoritative output and cannot be forged by stamping it statically.

Two engines already meet that contract: kafka, where Strimzi publishes a key-free CA natively, and redis, where the forked operator emits one (PR #2729). Every other engine has a key-bearing CA Secret and delivers no ca.crt to the tenant at all. That is one uniform delivery gap, and §5 of the proposal answers it with a single engine-agnostic extraction controller. That controller has no home today — this issue is it. Nothing in main implements it: publish-ca-cert has zero occurrences in the tree.

Why a controller and not something cheaper

The proposal rejects every cheaper mechanism, and the reasons are load-bearing:

  • Helm lookup — runs at render time and is invisible to the Flux digest, so a chart would not re-render when an operator-created CA appears asynchronously. PR [platform] Replace Helm lookup with valuesFrom mechanism #1787 already moved the global-values channel off lookup for the same reason.
  • A custom valuesFrom on the application HelmRelease — expectedValuesFrom() pins every application HelmRelease to the single cozystack-values Secret and overwrites drift (internal/controller/applicationdefinition_helmreconciler.go).
  • Forking each upstream operator — the redis path. Owning forks of CloudNativePG, PSMDB, mariadb-operator, opster and the RabbitMQ cluster operator is far more surface than one controller.
  • trust-manager — its Bundle sources read from its own trust namespace, not from a per-release tenant namespace.
  • External Secrets Operator — mechanically sound, but ESO is opt-in and not a default platform component, so it cannot be assumed installed. Recorded as the retirement path if that ever changes.

Scope

Watch Secrets labelled internal.cozystack.io/publish-ca-cert: "true"; an optional internal.cozystack.io/publish-ca-cert-key annotation names the key to lift, defaulting to ca.crt. A second required label, internal.cozystack.io/publish-ca-cert-release, carries the release name — the platform runs cert-manager with enableCertificateOwnerRef: false, so a cert-manager Secret has no ownerReference and its release cannot be inferred from the lineage graph; it must be stated explicitly. For the cert-manager-minting charts all three markers ride Certificate.spec.secretTemplate.labels. The label, not the name, is the contract — CA Secret names are non-uniform across engines, and <release>-ca-cert is overloaded to the point of meaning opposite things on redis and mongodb — which is also why it is not the projection's name.

For each labelled source, upsert an intra-namespace type: Opaque Secret named <release>.tenant-ca holding only ca.crt, re-copying on every source change so a CA rotation propagates without a chart re-render.

Four behaviours are load-bearing:

  1. Key off the label and the content, never the name. Verify the lifted value carries no -----BEGIN … PRIVATE KEY----- header, and fail closed if it does.
  2. Tolerate operator-created, asynchronous sources. CNPG and PSMDB create their CA Secret at runtime, well after the chart renders. The controller waits on the watch event; it must not error or busy-loop before the source exists.
  3. Sanitize at write time, not only at render time. Copy a single whitelisted key and re-assert the no-private-key check on every write. Never copy Data wholesale.
  4. Owner-ref the projected Secret to the release's HelmRelease. Garbage collection is then native, with no prune logic. Do not owner-ref the source Secret. Note the apps.cozystack.io kinds are virtual — a tenant Application is stored as a HelmRelease — so there is no app CR in etcd to reference; the HelmRelease is the only real object in the chain, and deleting the app CR deletes it, which cascades to the projection. BlockOwnerDeletion stays false so a trust anchor never delays an application's teardown.

Never overwrite a Secret the controller did not create; surface a name collision as a Warning Event rather than failing silently.

Reuse

The skeleton is the wildcard-secret reconciler from PR #2990 (internal/controller/wildcardsecret/reconciler.go), with one structural difference: that controller replicates cross-namespace, while this one is strictly intra-namespace and per-release.

Tenant marking needs no per-release templating — one generic spec.secrets entry with matchLabels: {internal.cozystack.io/tenant-ca: "true"}, stamped by the controller on every projected Secret, covers every engine. The lineage admission webhook (internal/lineagecontrollerwebhook/webhook.go) remains the authority on the tenantresource label; a statically stamped label does not survive on its own. The private-key guard already exists in cozy-lib.tls.caCertSecret (packages/library/cozy-lib/templates/_tls.tpl, PR #2989).

The irreducibly new work is the extraction step itself: read one key, write a key-free copy, re-copy on rotation.

Source selection: two legs

The open question originally recorded here — whether operators that create their own CA Secret can be made to label it — has been answered. Neither CloudNativePG nor Percona PSMDB can, so source selection is two-legged and both legs are first-class:

  1. Label-driven (the default). Watch Secrets carrying internal.cozystack.io/publish-ca-cert: "true". This is how the cert-manager-minting engines opt in, stamping the label through Certificate.spec.secretTemplate.labels.
  2. Name-driven fallback. Watch a named Secret in the release namespace, with no label on the source at all. Required for postgres (CNPG) and mongodb (PSMDB), whose operators will not label their output. Keep this declarative — a small per-engine configuration of source name and key to lift, so that adding an engine is data rather than code.

Projection, sanitization, the private-key guard, owner-ref and collision handling are identical on both legs; only source discovery differs. The write path must not fork — that is exactly where a key-leak bug would hide.

The private-key guard matters more on the fallback leg rather than less. CNPG's <cluster>-ca genuinely carries ca.key, and PSMDB's <release>-ca-cert genuinely carries a private key, so on that leg the source is key-bearing by construction. A test must prove that a source holding both ca.crt and ca.key yields a projected Secret holding ca.crt alone.

The evidence for both verdicts is in the comments. A possible escape from the fallback for postgres only (CNPG bring-your-own-CA) is tracked as #3295; it does not remove the need for the mechanism, because mongodb forces it regardless.

Acceptance

  • A tenant can read ca.crt for any converged engine, and cannot read any object carrying tls.key or ca.key.
  • A CA rotation on the source propagates to the projected Secret without a chart re-render.
  • Deleting the application instance garbage-collects the projected Secret, with no prune logic.
  • The projected Secret is never written over a pre-existing foreign Secret of the same name.

Part of #2811. Blocks #2814.

Activity

  1. added
    kind/featureCategorizes issue or PR as related to a new feature
    priority/important-soonMust be staffed and worked on either currently, or very soon, ideally in time for the next release
    triage/acceptedIndicates an issue is ready to be actively worked on
    area/platformIssues or PRs related to platform infrastructure (bundle, flux, talos, installer)
    on Jul 14, 2026
  2. lexfrei commented on Jul 14, 2026

    @lexfrei
    ContributorAuthor

    The open question in this issue — whether the operators that create their own CA Secret can be made to label it — is now answered. The answer is no, for both. Verified against the versions we ship and independently re-checked against upstream source.

    CloudNativePG 1.27.1. spec.inheritedMetadata is documented in the CRD as "Metadata that will be inherited by all objects related to the Cluster", but the implementation does not honour that for the PKI secrets. Inherited labels are applied only through SetInheritedData() / SetInheritedDataAndOwnership(), and internal/controller/cluster_pki.go calls neither — <cluster>-ca, <cluster>-server and <cluster>-replication receive a bare utils.SetAsOwnedBy() and nothing more. The credential secrets do inherit (cluster_create.go:190, :232), so the split runs within secrets rather than between resource types. ensureLeafCertificate() even accepts an additionalLabels parameter, and both of its callers pass nil — the hook exists but is unreachable from outside.

    Percona PSMDB 1.22.0. The entire spec.tls in the shipped CRD is four fields, and secretTemplate does not appear anywhere in the CRD. The operator constructs the cert-manager Certificate in code (pkg/psmdb/tls/certificate.go) and never sets SecretTemplate; there is no field naming the CA Secret; and a hand-applied patch to the generated Certificate is reconciled away, because the operator hashes the desired object into percona.com/last-config-hash and repairs drift. There is no user-side workaround of any kind.

    Consequence

    Source selection is two-legged, and both legs are first-class:

    1. Label-driven — watch Secrets carrying internal.cozystack.io/publish-ca-cert: "true". This is how the cert-manager-minting engines opt in.
    2. Name-driven fallback — watch a named Secret in the release namespace, with no label on the source at all. Required for postgres and mongodb, whose operators will not label their output.

    Projection, sanitization, the private-key guard, owner-ref and collision handling are identical on both legs; only source discovery differs. The private-key guard matters more on the fallback leg rather than less: CNPG's <cluster>-ca genuinely carries ca.key, and PSMDB's <release>-ca-cert genuinely carries a private key, so on that leg the source is key-bearing by construction.

    Feature requests are being filed against both operators. For postgres there is a possible escape from the fallback — CNPG supports bringing your own CA through spec.certificates.serverCASecret and serverTLSSecret — but it would not remove the need for the fallback mechanism, since mongodb forces it regardless, and it carries a PKI migration. It is therefore tracked separately as #3295 rather than blocking this work.

  3. lexfrei commented on Jul 14, 2026

    @lexfrei
    ContributorAuthor

    Three updates.

    Upstream feature requests filed, so the fallback has a retirement path if either lands: cloudnative-pg/cloudnative-pg#11188 (honour inheritedMetadata on the operator-generated PKI secrets) and percona/percona-server-mongodb-operator#2459 (allow custom labels on Secrets generated from operator-managed cert-manager Certificates). Neither is a dependency — the fallback ships regardless, since the currently shipped operator versions have to be supported either way.

    The canonical projection is renamed from <release>-ca-cert to <release>-tenant-ca. <release>-ca-cert turned out not to be a free name. Percona PSMDB creates it itself, key-bearing, so source and target would be the same object and mongodb — the engine that forces the fallback to exist — could never receive a trust anchor at all. Strimzi separately publishes under <release>-clients-ca-cert, so the contract was not uniform for kafka either. <release>-tenant-ca is claimed by no operator we ship and is symmetric with the internal.cozystack.io/tenant-ca label already stamped on every projection. This simplifies the model rather than complicating it: redis and kafka stop being special cases that "already sit at the target" and become ordinary declared sources whose projection is a straight copy, so every engine without exception exposes one identically-named object.

    The owner-ref target is corrected: the projection is owned by the HelmRelease, not by the application CR. The original text of this issue asked for an owner-ref to the application instance CR. That is not implementable: the apps.cozystack.io kinds are virtual, served by the aggregated apiserver, and a tenant Application is stored as a HelmRelease whose Spec.Values is the Application spec verbatim. There is no app CR object in etcd and therefore no UID to reference. The HelmRelease is the only real object in the chain, and garbage collection still behaves correctly: deleting the app CR deletes the HelmRelease through the aggregated API, which cascades to the projection. BlockOwnerDeletion stays false so a trust anchor can never delay the teardown of the application it belongs to.

    The merged design proposal carries both of these errors and will be amended.

  4. lexfrei commented on Jul 27, 2026

    @lexfrei
    ContributorAuthor

    Delivered by #3407, closing. The controller landed with a different discovery mechanism than this issue describes: the chart renders a namespaced TenantProjection sentinel naming the source Secret, and the controller watches that, no publish-ca-cert label on the Secret. The output contract is unchanged, a tenant reads <release>.tenant-ca carrying only ca.crt. That came out of API-owner review, reasoning is in #3299, proposal update in cozystack/community#41. Postgres is converged, the rest of the engines are #2814.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/platformIssues or PRs related to platform infrastructure (bundle, flux, talos, installer)kind/featureCategorizes issue or PR as related to a new featurepriority/important-soonMust be staffed and worked on either currently, or very soon, ideally in time for the next releasesecuritySecurity-related issues and featuressecurity/triage-neededNeeds security triagetriage/acceptedIndicates an issue is ready to be actively worked on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions