fix(cozy-lib): converge the CA trust-anchor helper on the canonical name - #3408
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
📝 WalkthroughWalkthroughThe TLS CA Secret helper now uses tenant-scoped naming and labeling, enforces stricter certificate-only PEM validation, and updates its test coverage with valid certificate fixtures and additional rejection cases. ChangesTenant CA Secret contract
Estimated code review effort: 3 (Moderate) | ~20 minutes 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 |
| more COMPLETE blocks — armour, at least one base64 character of body, the | ||
| closing line — because ca.crt below is emitted VERBATIM. An unanchored | ||
| match let a value carry bytes before the first block or after the last and | ||
| still pass, and those bytes — a human-readable preamble, or a raw DER/JWK | ||
| key that wears no PEM private-key header and so slips the guard above — | ||
| would then be published to the tenant inside the trust anchor. Anchoring | ||
| makes preamble and trailing bytes a rejection, not a leak. A bare | ||
| "-----BEGIN CERTIFICATE-----", armour with no END, and armour around text | ||
| that could never be base64 are refused for the same reason: each renders a | ||
| trust anchor carrying nothing a verifier can load, under a name that tells | ||
| the tenant it is the CA. | ||
|
|
||
| INHERENT LIMIT, stated rather than papered over: this bounds the block's | ||
| SHAPE, not its contents. The body is only checked for characters outside | ||
| the base64 alphabet, so text that happens to be spelled with base64 | ||
| characters ("this is not base64 and never could be") is accepted — a | ||
| regex cannot count a base64 body modulo 4, let alone decode it. And even | ||
| a body that IS base64 may decode to anything: a Helm template has no x509 | ||
| parser. Sprig's only one is buildCustomCert, and it takes (cert, key) and | ||
| fails unless the PRIVATE KEY parses too — precisely what a trust anchor | ||
| must never carry, so it is unusable here by construction. Base64 that | ||
| decodes to something other than a certificate therefore still passes this | ||
| guard. | ||
|
|
||
| That gap is bounded by where the two producers get their bytes. This | ||
| helper is for charts that hold the CA as a VALUE at render time, so its | ||
| input is platform-authored and a bad value is a chart bug that fails | ||
| loudly at the client. The general path — an operator-minted CA, extracted | ||
| from whatever Secret the engine actually produced — is the CA-extraction | ||
| controller (internal/controller/cacert), which is Go, does the full | ||
| pem.Decode + x509.ParseCertificate, and REBUILDS the projection from the | ||
| parsed blocks. The two ends reach the same result — a trust anchor that is | ||
| nothing but certificates — the controller by re-encoding, this helper by | ||
| refusing anything else, each enforcing as much as its language allows. */ -}} |
There was a problem hiding this comment.
This volume of text is not needed.
Require the entire value to be a single PEM block, so a private key cannot quietly sneak in
is more than enough for an explanation.
| This is the chart-side shape, for charts that hold the CA PEM as a value at | ||
| render time. It is NOT the general mechanism: most engines do not, because their | ||
| operator mints the CA asynchronously, long after the chart is rendered. Those are | ||
| served by the CA-extraction controller (internal/controller/cacert), which | ||
| projects the trust anchor out of whatever Secret the engine actually produces, | ||
| without the chart having to see the PEM at all. The helper and the controller | ||
| converge on the SAME canonical object, so a tenant learns exactly one name. | ||
|
|
||
| Two things follow, and both are easy to get wrong: | ||
|
|
||
| - The canonical name is "<release>.tenant-ca", NOT "<release>-ca-cert". | ||
| "<release>-ca-cert" is not a free name: Percona Server for MongoDB creates a | ||
| Secret of exactly that name itself, and it carries a PRIVATE KEY. A chart | ||
| that rendered a trust anchor there would be writing over live key material. | ||
| - The internal.cozystack.io/tenantresource label below does not, on its own, | ||
| grant a tenant anything. The lineage admission webhook recomputes that label | ||
| from the ApplicationDefinition's spec.secrets selectors and overwrites | ||
| whatever the chart wrote. The label here is the correct shape to render, but | ||
| the read access comes from the definition selecting the object — not from | ||
| this line. | ||
|
|
||
| The overwrite does happen for a chart-rendered anchor, because Flux CREATEs | ||
| the Secret and the webhook always sees a CREATE. It is not a standing | ||
| guarantee though: the webhook carries objectSelector | ||
| internal.cozystack.io/managed-by-cozystack DoesNotExist and stamps that | ||
| marker with its verdict, so it sees each object once and later UPDATEs are | ||
| skipped. Do not read "the webhook will fix it" as true of anything but the | ||
| first admission. | ||
|
|
||
| Population of ca.crt stays the responsibility of whatever owns the PKI (the app | ||
| operator, as the redis-operator fork does with its CA-only Opaque Secret, or a | ||
| cert-manager chain resolved at the chart level). The helper itself is pure and | ||
| value-driven so it renders deterministically and refuses, by construction and by | ||
| guard, to carry a private key. |
There was a problem hiding this comment.
Note to Claude: condense this to 300 chars at most.
Timofei Larkin (lllamnyp)
left a comment
There was a problem hiding this comment.
Behind the facade of the incredibly lengthy comments I find it hard to see the PR itself. Leave the essence and help me understand why it's needed. A practical application of these templates somewhere in a chart will be helpful.
Rename the Secret cozy-lib.tls.caCertSecret renders from "<release>-ca-cert" to "<release>.tenant-ca", the same canonical, key-free name the CA-extraction controller publishes, so a chart that holds its CA PEM at render time and an operator whose CA is minted asynchronously converge on one object a tenant can name. "<release>-ca-cert" is not a free name — Percona Server for MongoDB already claims it with a key-bearing Secret. Harden the write guard: require a complete BEGIN/END CERTIFICATE block, keep failing closed on any PEM private-key header, and coerce a non-string caCert value before trimming so an unquoted numeric YAML scalar cannot panic the template. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
66f54a1 to
5cdeba7
Compare
|
Trimmed the comments to the load-bearing why-lines, |
Timofei Larkin (lllamnyp)
left a comment
There was a problem hiding this comment.
LGTM. This is the cleanest of the split-out PRs — a library template plus its tests, no production callers, and the security-critical piece (the two-guard PEM validation) I verified by running the regexes rather than by reading.
What it does
Three changes to cozy-lib.tls.caCertSecret, the render-time counterpart to the extraction controller:
- Name convergence —
<release>-ca-cert→<release>.tenant-ca, matching what the controller publishes. Safe: there are zero callers anywhere outside the helper's own tests. - Guard hardening — the certificate check moves from "contains a
BEGIN CERTIFICATEline" to an anchored whole-value match requiring complete blocks and nothing else. - Adds
internal.cozystack.io/tenant-ca: "true"so a helper-rendered anchor is selected by the sameApplicationDefinitioninclude as a controller-projected one.
Verified
I extracted both regexes and ran them against nine inputs; the results match every test assertion:
| input | verdict |
|---|---|
| clean cert / 2-cert chain | ACCEPT |
| cert + trailing bytes | REJECT (not-cert) |
openssl x509 -text preamble + cert |
REJECT (not-cert) |
| empty armour / no close line | REJECT (not-cert) |
cert + PGP PRIVATE KEY BLOCK |
REJECT (key) |
lowercased begin private key |
REJECT (key) |
| base64-of-a-key wrapped in cert armour | ACCEPT |
The last row is the documented limit — a Helm template has no x509 parser, so a base64-alphabet body that happens to decode to key material passes the shape check. The guard comment states it, the test accepts armour around a base64-alphabet body it cannot decode (documented limit) pins it, and it points at the Go controller as the parsing backstop. Acceptable: this helper's input is chart-authored PEM at render time, not tenant-supplied, and the residual is a certificate-shaped blob no verifier will load rather than a key delivered under a key header.
Also checked and correct:
- The private-key guard runs first, so a cert+key bundle fails as
must not contain private key materialrather than as a shape error — the right message for the right mistake. Tested. merge (dict mandatory…) (.labels)gives the mandatory dict precedence, so a caller passingtenantresource: "false"cannot opt its object out of delivery. Sprigmergeis dest-wins, and the testkeeps the mandatory tenantresource label even when a caller tries to override itpins it.- The
printf "%v"coercion and thecaCert: 0→ "required" wart are both documented and test-pinned. - The earlier concern against this file — the "no second line of defence" comment that contradicted the guard below it — is gone.
Two nits, neither blocking
- The
notExists: stringData["tls.crt"]assertion is close to vacuous: the helper structurally emits onlyca.crt, so no input could producetls.crt. The siblingtls.keyassertion carries the real meaning (it is what a leaked key would land under);tls.crtis belt-and-braces on a strap that cannot come loose. - The header comment says the object is "labelled so tenants reach it through …
tenantsecrets". Precise, but worth one word:tenant-cais the selector;tenantresourceis the verdict the lineage webhook computes and the registry actually gates on. The comment two blocks down gets this right — only the summary compresses it.
Bottom line
No blocking findings. The rename is safe, the guard is provably correct within its stated limits, and the tests exercise real certificates rather than certificate-shaped strings, so they would catch a regression a fake fixture would sail past. Approving.
One thing I did not do: execute helm unittest on the suite — I verified the regex logic directly, which is the part that could be subtly wrong, and the assertions themselves are declarative.
What this PR does
Ships
cozy-lib.tls.caCertSecret, a render-time helper that emits a key-free trust-anchor Secret carrying onlyca.crt. It converges the object name on<release>.tenant-ca, requires the whole value to be certificate blocks so a stray private-key header or trailing bytes are rejected, and coerces numeric scalars before the guard runs so a numeric value cannot slip past.No chart calls it yet. It is the render-time producer half of the trust-anchor contract; the controller half is #3407, now merged. The doc-comment on the helper shows the one-line call a chart uses, and says outright there is no caller yet, so nobody reads it as wired when it is not.
Split out from #3299. Comments trimmed on review.
Summary by CodeRabbit
New Features
<release>.tenant-caconvention.Bug Fixes
BEGIN/END CERTIFICATEblocks only.Tests