docs(unified-tls-pki): fix the canonical trust-anchor name and its owner#36
docs(unified-tls-pki): fix the canonical trust-anchor name and its owner#36Aleksei Sviridkin (lexfrei) wants to merge 3 commits into
Conversation
Implementation surfaced two errors in the accepted design. The canonical projection cannot be named <release>-ca-cert. Percona PSMDB creates a Secret of exactly that name and puts a private key in it, so source and target collide on the very engine that most needs the projection; Strimzi publishes its key-free CA under a different name again. The document itself warned three times that this name means opposite things on different engines, used that to reject name-based source selection, and then picked it for the target anyway. The canonical object is now <release>-tenant-ca, which no shipped operator claims. The projection also cannot be owner-referenced to the application CR. The apps.cozystack.io kinds are virtual: an application is stored as a HelmRelease, so there is no object in etcd to reference. The HelmRelease is the only real owner, and deleting the application deletes it, which garbage-collects the projection. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
📝 WalkthroughWalkthroughThe proposals rename the canonical tenant trust-anchor Secret to ChangesTenant trust-anchor contract
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request updates the design proposals for external database exposure and unified TLS PKI. It renames the canonical trust-anchor Secret from <release>-ca-cert to <release>-tenant-ca to prevent naming collisions with existing operator-created Secrets (such as Percona PSMDB). Additionally, it updates the extraction controller's design to owner-reference the HelmRelease instead of the virtual application instance CR to ensure proper garbage collection. The reviewer feedback points out that the external database exposure proposal still contains outdated text claiming redis self-publishes its CA directly, whereas under the updated design, all engines (including redis and kafka) utilize the extraction controller to project their CAs to the canonical <release>-tenant-ca Secret.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| ## Scope and related proposals | ||
|
|
||
| - **Depends on:** `design-proposals/unified-tls-pki` — provides the `<release>-ca-cert` key-free trust anchor that external clients use to verify the endpoint. This proposal does not re-specify it. It is a companion submission under the same epic, on its own branch; the path resolves once both proposals merge. The dependency is **per-engine, not blanket**: an engine is exposable here only once its `ca.crt` is actually delivered under that contract, and the path differs by engine — redis self-publishes a key-free `<release>-ca-cert` through its forked operator, while postgres and mongodb obtain theirs through the extraction controller in `unified-tls-pki`. So SNI exposure for a given engine is gated on that engine's `unified-tls-pki` convergence, not merely on the contract existing. | ||
| - **Depends on:** `design-proposals/unified-tls-pki` — provides the `<release>-tenant-ca` key-free trust anchor that external clients use to verify the endpoint. This proposal does not re-specify it. It is a companion submission under the same epic, on its own branch; the path resolves once both proposals merge. The dependency is **per-engine, not blanket**: an engine is exposable here only once its `ca.crt` is actually delivered under that contract, and the path differs by engine — redis self-publishes a key-free `<release>-ca-cert` through its forked operator, while postgres and mongodb obtain theirs through the extraction controller in `unified-tls-pki`. So SNI exposure for a given engine is gated on that engine's `unified-tls-pki` convergence, not merely on the contract existing. |
There was a problem hiding this comment.
The text still states that redis self-publishes a key-free <release>-ca-cert through its forked operator, while postgres and mongodb obtain theirs through the extraction controller.
However, under the updated design, redis and kafka also require the extraction controller to project their engine-specific CA Secrets to the canonical <release>-tenant-ca Secret. This sentence should be updated to reflect that all engines now utilize the extraction controller/projection mechanism to deliver the canonical trust anchor.
For example:
- **Depends on:** `design-proposals/unified-tls-pki` — provides the `<release>-tenant-ca` key-free trust anchor that external clients use to verify the endpoint. This proposal does not re-specify it. It is a companion submission under the same epic, on its own branch; the path resolves once both proposals merge. The dependency is **per-engine, not blanket**: an engine is exposable here only once its `ca.crt` is actually delivered under that contract, and the path differs by engine — while redis self-publishes a key-free `<release>-ca-cert` and postgres/mongodb have key-bearing CAs, all of them now obtain their canonical `<release>-tenant-ca` through the extraction controller in `unified-tls-pki`. So SNI exposure for a given engine is gated on that engine's `unified-tls-pki` convergence, not merely on the contract existing.Implementation showed the source-selection label is not enough on its own. The platform runs cert-manager with enableCertificateOwnerRef set to false, so a cert-manager-issued Secret carries no ownerReference back to its application and the lineage graph cannot tell which release it belongs to. The consume contract therefore requires a second label, internal.cozystack.io/publish-ca-cert-release, carrying the release name, stamped alongside the opt-in label in the same secretTemplate block. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
…truction The trust anchor was named <release>-tenant-ca on the grounds that no operator the platform ships claims it. That is false. It collides across releases: for an application foo the projection is postgres-foo-tenant-ca, and for a sibling application foo-tenant CloudNativePG's own CA secret is postgres-foo-tenant plus -ca, which is the same string. Both names are legal and several Postgres instances in one namespace is ordinary. The collision has a direction no guard can refuse. When the projection is written first, CloudNativePG rejects the key-free secret with 'missing ca.key secret data' and the sibling's PKI never completes, so that application never starts, blaming a secret its owner never created. The controller that wrote first was within its rights, so there is nothing to refuse; and before this contract existed both applications worked. The name is now <release>.tenant-ca, which is disjoint by character class rather than by survey: application names are DNS-1035 labels and cannot contain a dot, release prefixes are dot-free, and secret names are DNS-1123 subdomains where a dot is legal. No prefix, application name and operator suffix can therefore produce a dotted name, for any operator, now or later. This is the second time the survey argument failed — the previous name was rejected because Percona claims it. Recording the reasoning so the third name is not chosen the same way. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@design-proposals/external-database-exposure/README.md`:
- Line 139: Update the diagram’s trust-anchor Secret reference near the external
database exposure flow to use <release>.tenant-ca instead of <release>-ca-cert,
matching the trust-anchor name defined in the surrounding proposal and keeping
the ca.crt-only contract unchanged.
In `@design-proposals/unified-tls-pki/README.md`:
- Around line 156-167: Define one canonical delivery path: require every
engine-specific CA Secret, including self-publishing Redis and similar sources,
to be projected into <release>.tenant-ca by the extraction controller rather
than writing that canonical object directly. Update
design-proposals/unified-tls-pki/README.md at lines 156-167 to make this
mandatory and consistent, and update
design-proposals/external-database-exposure/README.md at line 19 to describe
Redis’s <release>-ca-cert strictly as an internal source.
- Around line 156-167: Clarify the design so every engine has one mandatory path
for producing <release>.tenant-ca. Either require the extraction controller to
project the canonical Secret for all engines, or explicitly document each
self-publishing engine's equivalent canonical-object creation and remove any
conflicting opt-out language. Update the descriptions in the
extraction-controller and reuse sections consistently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ff0f6c24-ea7c-4bf2-a5a6-98138745a2e1
📒 Files selected for processing (2)
design-proposals/external-database-exposure/README.mddesign-proposals/unified-tls-pki/README.md
| WS5 adds nothing beyond two hooks that already exist: | ||
|
|
||
| 1. **Trust-anchor delivery** — the `<release>-ca-cert` `ca.crt`-only object from `unified-tls-pki`, projected to the tenant. The external client verifies against that `ca.crt`. | ||
| 1. **Trust-anchor delivery** — the `<release>.tenant-ca` `ca.crt`-only object from `unified-tls-pki`, projected to the tenant. The external client verifies against that `ca.crt`. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Update the remaining trust-anchor reference in the diagram.
The diagram at Line 110 still names <release>-ca-cert, while this section now defines <release>.tenant-ca. Leaving both names in the same proposal can cause clients or controllers to consume the wrong Secret.
Proposed documentation fix
- CA["<release>-ca-cert (ca.crt only)<br/>projected via tenantsecrets"]
+ CA["<release>.tenant-ca (ca.crt only)<br/>projected via tenantsecrets"]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@design-proposals/external-database-exposure/README.md` at line 139, Update
the diagram’s trust-anchor Secret reference near the external database exposure
flow to use <release>.tenant-ca instead of <release>-ca-cert, matching the
trust-anchor name defined in the surrounding proposal and keeping the
ca.crt-only contract unchanged.
| **(a) An explicit source-selection label, engine-agnostic, not a name convention.** The owner of the CA-bearing Secret stamps `internal.cozystack.io/publish-ca-cert: "true"`, with an optional `internal.cozystack.io/publish-ca-cert-key` annotation naming the key to lift (default `ca.crt`). It also stamps `internal.cozystack.io/publish-ca-cert-release` with the release name: implementation showed the release cannot be inferred on the label leg, because the platform runs cert-manager with `enableCertificateOwnerRef: false`, so a cert-manager-issued Secret carries no ownerReference back to its owning application and the lineage graph cannot resolve which release it belongs to. The release label supplies that link explicitly. For the cert-manager-minting charts all three markers ride `Certificate.spec.secretTemplate.labels`, so even the asynchronously-created CA Secret carries them from the moment cert-manager writes it. The label, not the name, is the contract — which matters because the CA-bearing Secret names are non-uniform (`<release>-ca`, `<release>-ca-tls`, `<release>-http-ca`, `<release>-ca-cert`) and the name `<release>-ca-cert` is overloaded: key-free for the redis fork, key-bearing for PSMDB. A name convention would mis-handle one of them; the label plus a content check does not. | ||
|
|
||
| **(b) A small extraction controller.** Its watch/upsert skeleton can follow the wildcard-secret reconciler from PR `cozystack/cozystack#2990` (`internal/controller/wildcardsecret/reconciler.go`), but it carries none of that reconciler's copy-marking or prune logic — lineage provides those (part (c)) — and, being **intra-namespace**, it can do something that reconciler cannot. For each label-selected source Secret it upserts a `type: Opaque` Secret named `<release>-ca-cert` containing **only** `ca.crt`, re-copying on every source change so a CA rotation propagates without a chart re-render. It does four security-load-bearing things and nothing more: | ||
| **(b) A small extraction controller.** Its watch/upsert skeleton can follow the wildcard-secret reconciler from PR `cozystack/cozystack#2990` (`internal/controller/wildcardsecret/reconciler.go`), but it carries none of that reconciler's copy-marking or prune logic — lineage provides those (part (c)) — and, being **intra-namespace**, it can do something that reconciler cannot. For each selected source Secret it upserts a `type: Opaque` Secret named `<release>.tenant-ca` containing **only** `ca.crt`, re-copying on every source change so a CA rotation propagates without a chart re-render. It does four security-load-bearing things and nothing more: | ||
|
|
||
| - **Key off the label and the content, never the name.** Select sources by the `publish-ca-cert` label; before writing, verify the lifted value is a CA certificate and carries no `-----BEGIN … PRIVATE KEY-----` header. This is what lets one controller serve a key-free redis source it should ignore and a key-bearing PSMDB source it must strip, both named `<release>-ca-cert`-adjacent, without confusion. | ||
| - **Tolerate operator-created, asynchronous sources.** The source may be created by the CNPG or PSMDB operator after the chart renders (not just by cert-manager). The controller waits on the watch event for the labeled source; it does not assume the chart owns the Secret and does not error or busy-loop before the source exists. | ||
| - **Sanitize at write time, not just render time.** The `cozy-lib.tls.caCertSecret` helper's fail-closed guard runs at *chart-render* time; this controller writes at *runtime*, so it must itself copy only the single `ca.crt` key (an explicit whitelist) and re-assert the no-private-key check on every write. It never copies the whole `Data`. | ||
| - **Owner-ref the projected Secret to the application instance CR**, resolved from the `app.kubernetes.io/instance` label already on the source Secret. This is where intra-namespace beats the `cozystack/cozystack#2990` reconciler, which tracks its replicas by a management label plus a back-reference annotation *because* a cross-namespace replica cannot carry a valid `ownerReference`. Our projection is same-namespace, so a real owner-reference is valid: Kubernetes garbage-collects the `<release>-ca-cert` on app deletion for free, and the controller needs no prune logic of its own. (It does not owner-ref the *source* `<release>-ca`: cert-manager and the DB operators do not own their output Secrets, so a walk from the source would dead-end before the app.) | ||
| - **Owner-ref the projected Secret to the release's `HelmRelease`.** An earlier revision of this document said to owner-ref 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 — so there is no application object in etcd and therefore no UID to reference. The `HelmRelease` is the only real object in the chain, and it is enough: deleting the application through the aggregated API deletes the `HelmRelease`, which garbage-collects the projection with it. This is where intra-namespace beats the `cozystack/cozystack#2990` reconciler, which tracks its replicas by a management label plus a back-reference annotation *because* a cross-namespace replica cannot carry a valid `ownerReference`. Our projection is same-namespace, so a real owner-reference is valid and the controller needs no prune logic of its own. `blockOwnerDeletion` stays false: a trust anchor must never delay the teardown of the application it belongs to. (It does not owner-ref the *source* `<release>-ca`: cert-manager and the DB operators do not own their output Secrets, so a walk from the source would dead-end before the app.) | ||
|
|
||
| **(c) Marking stays in `spec.secrets`, by label not by name.** Because `ApplicationDefinition.spec.secrets.include` accepts a label selector (`internal/lineagecontrollerwebhook/matcher.go`), one generic entry — `matchLabels: {internal.cozystack.io/tenant-ca: "true"}`, stamped by the controller on every projected Secret — covers every engine with no per-release `resourceName` templating. The lineage admission webhook then does the rest: on admission it walks the projected Secret's `ownerReferences` to the owning application and authoritatively stamps `internal.cozystack.io/tenantresource` to `true` (and to `false` should the Secret ever stop matching). | ||
|
|
||
| What this *reuses* rather than rebuilds: the label selector in `ApplicationDefinition.spec.secrets` (`internal/lineagecontrollerwebhook/matcher.go`), the lineage webhook's owner-reference walk and authoritative `tenantresource` stamping (`internal/lineagecontrollerwebhook/webhook.go`, `pkg/lineage/lineage.go`), the private-key guard in `cozy-lib.tls.caCertSecret` (`packages/library/cozy-lib/templates/_tls.tpl`), and native Kubernetes garbage collection via the owner reference. The irreducibly new work is the extraction step itself — read one key, write a key-free copy, re-copy on rotation — the same job an operator does natively on the engines that already ship a key-free CA object (kafka's `<release>-clients-ca-cert`, the redis fork's `<release>-ca-cert`). | ||
| What this *reuses* rather than rebuilds: the label selector in `ApplicationDefinition.spec.secrets` (`internal/lineagecontrollerwebhook/matcher.go`), the lineage webhook's owner-reference walk and authoritative `tenantresource` stamping (`internal/lineagecontrollerwebhook/webhook.go`, `pkg/lineage/lineage.go`), the private-key guard in `cozy-lib.tls.caCertSecret` (`packages/library/cozy-lib/templates/_tls.tpl`), and native Kubernetes garbage collection via the owner reference on the `HelmRelease`. The irreducibly new work is the extraction step itself — read one key, write a key-free copy, re-copy on rotation — the same job an operator does natively on the engines that already ship a key-free CA object (kafka's `<release>-clients-ca-cert`, the redis fork's `<release>-ca-cert`) — those two still need the projection, because their CA lands under an engine-specific name rather than the canonical one; for them the extraction step is a straight copy, with nothing to strip. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Define one canonical delivery path for every engine.
The documents mix engine-specific source names with the canonical tenant-facing object, while also inconsistently describing self-publishing engines as both opting out and requiring projection.
design-proposals/unified-tls-pki/README.md#L156-L167: state whether every engine-specific source is projected to<release>.tenant-ca, or whether self-publishing engines write that canonical object directly.design-proposals/external-database-exposure/README.md#L19-L19: describe Redis's<release>-ca-certstrictly as an internal source if projection remains mandatory.
📍 Affects 2 files
design-proposals/unified-tls-pki/README.md#L156-L167(this comment)design-proposals/external-database-exposure/README.md#L19-L19
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@design-proposals/unified-tls-pki/README.md` around lines 156 - 167, Define
one canonical delivery path: require every engine-specific CA Secret, including
self-publishing Redis and similar sources, to be projected into
<release>.tenant-ca by the extraction controller rather than writing that
canonical object directly. Update design-proposals/unified-tls-pki/README.md at
lines 156-167 to make this mandatory and consistent, and update
design-proposals/external-database-exposure/README.md at line 19 to describe
Redis’s <release>-ca-cert strictly as an internal source.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the proposal file and inspect the relevant section with line numbers.
wc -l design-proposals/unified-tls-pki/README.md
sed -n '130,230p' design-proposals/unified-tls-pki/README.md
# Find the specific terms used in the review comment and nearby proposal text.
rg -n --no-heading "tenant-ca|publish-ca-cert|self-publishing|projection|clients-ca-cert|ca-cert|tenantresource|spec\.secrets|HelmRelease|ApplicationDefinition" design-proposals/unified-tls-pki/README.md
# Look for related implementation/docs references across the repo.
rg -n --no-heading "tenant-ca|publish-ca-cert|tenantresource|spec.secrets|lineagecontrollerwebhook|caCertSecret|clients-ca-cert|ca-cert" .Repository: cozystack/community
Length of output: 50375
Make the canonical CA path mandatory
<release>.tenant-ca is described as universal, but the design also says self-publishing engines opt out of the controller while Kafka/Redis still need projection. Pick one path and state explicitly how every engine produces the canonical object, or remove the opt-out language.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@design-proposals/unified-tls-pki/README.md` around lines 156 - 167, Clarify
the design so every engine has one mandatory path for producing
<release>.tenant-ca. Either require the extraction controller to project the
canonical Secret for all engines, or explicitly document each self-publishing
engine's equivalent canonical-object creation and remove any conflicting opt-out
language. Update the descriptions in the extraction-controller and reuse
sections consistently.
Implementing the accepted design surfaced four errors in it. All are corrected here, across both proposals. Every one was found by building the thing, not by reading it.
The canonical trust anchor is now
<release>.tenant-ca, and the reason it took three names is the point.<release>-ca-certwas rejected because Percona PSMDB creates a Secret of exactly that name and puts a private key in it — a collision across engines. Its replacement<release>-tenant-cawas justified as "claimed by no operator this platform ships". That was also false, one level up: it collides across releases. For an applicationfoothe projection ispostgres-foo-tenant-ca; for a sibling applicationfoo-tenant, CloudNativePG's own CA ispostgres-foo-tenant+-ca— the same string. Both names are legal and several Postgres instances per namespace is ordinary. One direction of that collision cannot be guarded at all: if the projection is written first, CloudNativePG rejects the key-free Secret (missing ca.key secret data), the sibling's PKI never completes, and that application never starts — silently, blaming a Secret its owner never created. It is a regression, and there is nothing to refuse, because the controller that wrote first was legitimate.<release>.tenant-cais disjoint by character class instead: application names are DNS-1035 labels and structurally cannot contain a dot, release prefixes are dot-free, and Secret names are DNS-1123 subdomains where a dot is legal — so no prefix + application + operator suffix can ever produce a dotted name, for any operator, now or later. That is a proof rather than a survey, which is what the two previous names lacked.The projection cannot be owner-referenced to the application CR. The
apps.cozystack.iokinds are virtual: they are served by the aggregated apiserver, and an application is stored as a HelmRelease whosespec.valuesis the application spec verbatim. There is no application object in etcd and therefore no UID to reference. The HelmRelease is the only real object in the chain — and it is sufficient, because deleting the application through the aggregated API deletes the HelmRelease, which garbage-collects the projection with it.The source-selection label is not enough on its own. The platform runs cert-manager with
enableCertificateOwnerRef: false, so a cert-manager-issued Secret carries no ownerReference back to its application and the lineage graph cannot resolve which release it belongs to. The consume contract therefore requires a second label,internal.cozystack.io/publish-ca-cert-release, carrying the release name, stamped alongside the opt-in label in the samesecretTemplateblock.The trust-anchor guard must parse, not pattern-match. The proposal's fail-closed boundary was specified as a header check — reject anything carrying a private-key header, accept anything carrying a certificate header. That is not sufficient: PEM armour around arbitrary bytes satisfies it, and
pem.Decodedoes not close the gap either, since it validates the armour and the base64 rather than the contents. Onlyx509.ParseCertificateon every decoded block, with no trailing remainder, actually asserts "this is a certificate".All four were found by the implementation in cozystack/cozystack#3299.
Summary by CodeRabbit
<release>.tenant-caobject name.ca.crtand uses updated labeling.