Repository navigation
Engine-agnostic CA extraction controller for the ca.crt-only trust anchor #3286
Description
Activity
- addedkind/featureCategorizes issue or PR as related to a new 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 releaseMust be staffed and worked on either currently, or very soon, ideally in time for the next releasetriage/acceptedIndicates an issue is ready to be actively worked onIndicates an issue is ready to be actively worked onarea/platformIssues or PRs related to platform infrastructure (bundle, flux, talos, installer)Issues or PRs related to platform infrastructure (bundle, flux, talos, installer)
on Jul 14, 2026 - addedsecuritySecurity-related issues and featuresSecurity-related issues and featuressecurity/triage-neededNeeds security triageNeeds security triage
on Jul 14, 2026 lexfrei commented
on Jul 14, 2026 ContributorAuthorMore actionsThe 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.inheritedMetadatais 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 throughSetInheritedData()/SetInheritedDataAndOwnership(), andinternal/controller/cluster_pki.gocalls neither —<cluster>-ca,<cluster>-serverand<cluster>-replicationreceive a bareutils.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 anadditionalLabelsparameter, and both of its callers passnil— the hook exists but is unreachable from outside.Percona PSMDB 1.22.0. The entire
spec.tlsin the shipped CRD is four fields, andsecretTemplatedoes not appear anywhere in the CRD. The operator constructs the cert-managerCertificatein code (pkg/psmdb/tls/certificate.go) and never setsSecretTemplate; there is no field naming the CA Secret; and a hand-applied patch to the generatedCertificateis reconciled away, because the operator hashes the desired object intopercona.com/last-config-hashand repairs drift. There is no user-side workaround of any kind.Consequence
Source selection is two-legged, and both legs are first-class:
- Label-driven — watch Secrets carrying
internal.cozystack.io/publish-ca-cert: "true". This is how the cert-manager-minting engines opt in. - 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>-cagenuinely carriesca.key, and PSMDB's<release>-ca-certgenuinely 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.serverCASecretandserverTLSSecret— 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.- Label-driven — watch Secrets carrying
lexfrei commented
on Jul 14, 2026 ContributorAuthorMore actionsThree updates.
Upstream feature requests filed, so the fallback has a retirement path if either lands: cloudnative-pg/cloudnative-pg#11188 (honour
inheritedMetadataon 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-certto<release>-tenant-ca.<release>-ca-certturned 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-cais claimed by no operator we ship and is symmetric with theinternal.cozystack.io/tenant-calabel 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.iokinds are virtual, served by the aggregated apiserver, and a tenant Application is stored as a HelmRelease whoseSpec.Valuesis 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.BlockOwnerDeletionstays 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.
lexfrei commented
on Jul 27, 2026 ContributorAuthorMore actionsDelivered by #3407, closing. The controller landed with a different discovery mechanism than this issue describes: the chart renders a namespaced
TenantProjectionsentinel naming the source Secret, and the controller watches that, nopublish-ca-certlabel on the Secret. The output contract is unchanged, a tenant reads<release>.tenant-cacarrying onlyca.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.
Context
The merged design proposal
unified-tls-pkisettles 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-cacontaining onlyca.crt(the DOT is load-bearing: the dash form is ambiguous — for a releasefooit collides with an operator-generated CA Secret of a neighbouring release namedfoo-tenant, and a dot cannot appear in operator-generated Secret names), stamped by the controller withinternal.cozystack.io/tenant-ca: "true"and made tenant-visible through the lineage webhook'stenantresourceverdict. Kafka's key-free<release>-clients-ca-certis the reference shape — not the name (see below). The two labels are not interchangeable: the controller stampstenant-ca, which the genericspec.secretsselector matches;tenantresourceis 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.crtto 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 inmainimplements it:publish-ca-certhas zero occurrences in the tree.Why a controller and not something cheaper
The proposal rejects every cheaper mechanism, and the reasons are load-bearing:
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 offlookupfor the same reason.valuesFromon the application HelmRelease —expectedValuesFrom()pins every application HelmRelease to the singlecozystack-valuesSecret and overwrites drift (internal/controller/applicationdefinition_helmreconciler.go).Bundlesources read from its own trust namespace, not from a per-release tenant namespace.Scope
Watch Secrets labelled
internal.cozystack.io/publish-ca-cert: "true"; an optionalinternal.cozystack.io/publish-ca-cert-keyannotation names the key to lift, defaulting toca.crt. A second required label,internal.cozystack.io/publish-ca-cert-release, carries the release name — the platform runs cert-manager withenableCertificateOwnerRef: 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 rideCertificate.spec.secretTemplate.labels. The label, not the name, is the contract — CA Secret names are non-uniform across engines, and<release>-ca-certis 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: OpaqueSecret named<release>.tenant-caholding onlyca.crt, re-copying on every source change so a CA rotation propagates without a chart re-render.Four behaviours are load-bearing:
-----BEGIN … PRIVATE KEY-----header, and fail closed if it does.Datawholesale.apps.cozystack.iokinds 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.BlockOwnerDeletionstays 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.secretsentry withmatchLabels: {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 thetenantresourcelabel; a statically stamped label does not survive on its own. The private-key guard already exists incozy-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:
internal.cozystack.io/publish-ca-cert: "true". This is how the cert-manager-minting engines opt in, stamping the label throughCertificate.spec.secretTemplate.labels.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>-cagenuinely carriesca.key, and PSMDB's<release>-ca-certgenuinely carries a private key, so on that leg the source is key-bearing by construction. A test must prove that a source holding bothca.crtandca.keyyields a projected Secret holdingca.crtalone.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
ca.crtfor any converged engine, and cannot read any object carryingtls.keyorca.key.Part of #2811. Blocks #2814.