Skip to content

feat(cozy-lib): add CA-only TLS trust-anchor helper for tenants - #2989

Merged
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
feat/tls-ca-trust-anchor
Jun 23, 2026
Merged

feat(cozy-lib): add CA-only TLS trust-anchor helper for tenants#2989
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
feat/tls-ca-trust-anchor

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Part of #2814. Part of #2811.

This is the foundational ("keystone") piece of WS3: a canonical, reusable trust-anchor object that lets a tenant obtain ca.crt to verify a per-app TLS endpoint without read access to any object that also carries a private key. It unblocks the per-app TLS series (WS4/WS5) by giving every chart one agreed, key-free delivery shape instead of each chart inventing its own.

The problem (verified against current main)

Per-app TLS issues a per-release, self-signed CA. The only objects that hold ca.crt today also hold private keys:

  • the cert-manager CA Secret <release>-ca carries the CA private key (tls.key) — full trust-chain compromise if leaked (packages/apps/nats/templates/certmanager.yaml, the isCA Certificate);
  • the cert-manager leaf Secret <release>-tls carries the server private key (tls.key) plus ca.crt (same file, the leaf Certificate);
  • CNPG surfaces ca.crt only inside its user-credentials Secret (packages/apps/postgres/templates/db.yaml, the operator-managed TLS block).

So any RBAC path that hands a tenant ca.crt by granting read on one of those Secrets also hands over a private key. On main today the cert-manager apps grant tenants no access to those Secrets at all, so a tenant currently cannot obtain ca.crt to verify the server — the gap this object closes safely.

Design decision: key-free Opaque object delivered through tenantsecrets, not trust-manager

I evaluated the two candidate delivery mechanisms.

Rejected — trust-manager. A trust-manager Bundle reads its sources only from trust-manager's single configured "trust namespace"; its namespaceSelector governs only where the bundle is distributed to, not where it is sourced from. A per-release self-signed CA lives in the tenant namespace, so trust-manager cannot use it as a source, and projecting one shared CA to all tenants would defeat per-release isolation. I re-verified this against current upstream docs (cert-manager.io trust-manager) and the open per-namespace-trust-bundle request (cert-manager/trust-manager#131, which is target-side only). The prior analysis still holds.

Chosen — a ca.crt-only Opaque Secret surfaced through the existing tenant-secret API. This generalizes the reference pattern from the redis work (#2729), where the operator publishes a CA-only Opaque Secret and RBAC is scoped to it instead of to the CA-private-key Secret. The new cozy-lib.tls.caCertSecret helper renders that object once, identically, for every chart:

  • type: Opaque, a single ca.crt data key, no tls.key/tls.crt;
  • label internal.cozystack.io/tenantresource: "true";
  • fails closed on empty input and refuses any PEM that contains private key material.

How the RBAC wiring works (no new roles needed)

The label is the entire mechanism, and it routes through RBAC that already exists:

  • pkg/registry/core/tenantsecret/rest.go surfaces, under the virtual resource core.cozystack.io/tenantsecrets, exactly the namespace Secrets bearing internal.cozystack.io/tenantresource=true (buildTenantSelector, lines 199-213; label constants pkg/apis/core/v1alpha1/tenantresource_types.go:3-4; full Data copied through by secretToTenant, lines 84-103).
  • packages/system/cozystack-basics/templates/clusterroles.yaml grants get/list/watch on core.cozystack.io/tenantsecrets to tenant ServiceAccounts via cozy:tenant:base (lines 44-47) and to use/admin/super-admin subjects via cozy:tenant:use:base (lines 179-180). None of this touches raw core secrets, so attaching the label to a key-free object exposes the trust anchor and nothing else.
  • internal/backupcontroller/credentials_projector.go:128-133 relies on the same rule in reverse — it deliberately omits the label so its key-bearing projection is not promoted to a TenantSecret. This helper is the positive counterpart: a key-free object that is safe to promote.

view deliberately does not receive the trust anchor through this path: cozy:tenant:view:base grants only core.cozystack.io/options (lines 120-126), and tenantsecrets also includes credential Secrets, so granting it to a read-only role would leak passwords. The trust anchor reaches use and above plus tenant ServiceAccounts — the same access level at which connection credentials are already surfaced (the per-release dashboard Role binds at use, e.g. packages/apps/postgres/templates/dashboard-resourcemap.yaml via cozy-lib.rbac.subjectsForTenantAndAccessLevel). A chart that must show ca.crt at view level in the dashboard can additionally name the key-free Secret in its own per-release Role; that is a per-app detail, not a base-role change.

Why a library helper and not a controller here

Populating ca.crt stays the responsibility of whatever owns the PKI — the app operator (as the redis fork does) or a cert-manager chain resolved at the chart level. The helper is intentionally pure and value-driven so it renders deterministically and is the single shape every per-app PR converges on. A shared key-stripping projection controller is a reasonable future generalization but is out of scope for this foundational object.

Convergence plan for the per-app TLS series (follow-up, not this PR)

I did not modify any of the open per-app branches. Each should adopt the canonical object as follows:

Tests

helm-unittest in the cozy-lib test chart (make -C packages/tests/cozy-lib-tests test) covers: the rendered object is Opaque with ca.crt and no tls.key/tls.crt; the tenantresource label is present; caller labels/annotations merge while the security label always wins; the helper fails closed on empty caCert; and it refuses a PEM that carries private key material.

Release note

NONE

Summary by CodeRabbit

Release Notes

  • New Features

    • Added a Helm helper to generate CA-only Kubernetes TLS trust-anchor secrets, including input validation (rejects private key material, requires a properly formatted certificate, and ensures required metadata).
  • Tests

    • Introduced a dedicated test suite covering successful rendering, metadata and content assertions, tenant label enforcement, label/annotation merging, and detailed failure cases for invalid inputs and incorrect arguments.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d93ea65e-4b5f-4b16-93b6-521122c0de60

📥 Commits

Reviewing files that changed from the base of the PR and between 31dce87 and 73f6382.

📒 Files selected for processing (6)
  • packages/library/cozy-lib/templates/_tls.tpl
  • packages/tests/cozy-lib-tests/templates/tests/tls-cacert-nondict.yaml
  • packages/tests/cozy-lib-tests/templates/tests/tls-cacert-params.yaml
  • packages/tests/cozy-lib-tests/templates/tests/tls-cacert.yaml
  • packages/tests/cozy-lib-tests/tests/tls_cacert_test.yaml
  • packages/tests/cozy-lib-tests/tests/tls_cacert_values.yaml
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/tests/cozy-lib-tests/tests/tls_cacert_values.yaml
  • packages/tests/cozy-lib-tests/templates/tests/tls-cacert.yaml
  • packages/library/cozy-lib/templates/_tls.tpl

📝 Walkthrough

Walkthrough

Adds a new Helm named template cozy-lib.tls.caCertSecret in packages/library/cozy-lib/templates/_tls.tpl that renders a CA-only Kubernetes Opaque Secret with PEM validation, private-key rejection, and a mandatory internal.cozystack.io/tenantresource=true label. A corresponding test package with fixture templates and a helm-unittest suite is added.

Changes

CA-only Secret Helm helper

Layer / File(s) Summary
Template helper: docs, validation, and manifest rendering
packages/library/cozy-lib/templates/_tls.tpl
Full cozy-lib.tls.caCertSecret implementation: design-rationale docs, parameter-contract docs, input validation (dict type, non-empty name, BEGIN CERTIFICATE PEM requirement, BEGIN ... PRIVATE KEY rejection), mandatory label merge overriding caller input, and the rendered apiVersion: v1 Opaque Secret with stringData.ca.crt.
Test fixture templates
packages/tests/cozy-lib-tests/templates/tests/tls-cacert.yaml, packages/tests/cozy-lib-tests/templates/tests/tls-cacert-params.yaml, packages/tests/cozy-lib-tests/templates/tests/tls-cacert-nondict.yaml, packages/tests/cozy-lib-tests/tests/tls_cacert_values.yaml
Three conditional fixture templates wiring default release context, params-from-Values invocation, and a string-literal invocation to trigger the type guard, plus the shared PEM CA cert values file.
Unit test suite
packages/tests/cozy-lib-tests/tests/tls_cacert_test.yaml
Helm-unittest suite asserting correct Secret structure (only ca.crt in stringData, no tls.key/tls.crt), mandatory label presence and non-overridability, caller label/annotation merging, namespace omission, and failure cases for empty caCert, private-key PEM headers (both casings), non-certificate PEM, empty name, and non-dict argument.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Suggested labels

area/uncategorized

Suggested reviewers

  • kvaps
  • lllamnyp
  • androndo
  • IvanHunters
  • sircthulhu
  • myasnikovdaniil

🐇 A cert without a key, how neat!
Just ca.crt, no secret to beat.
Private keys rejected with care,
The tenant label? Always there.
Hop hop, the trust anchor's complete! 🔐

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(cozy-lib): add CA-only TLS trust-anchor helper for tenants' clearly and concisely describes the main change: a new helper for CA-only TLS trust anchors.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tls-ca-trust-anchor

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) kind/feature Categorizes issue or PR as related to a new feature size/L This PR changes 100-499 lines, ignoring generated files labels Jun 22, 2026
@lexfrei
Aleksei Sviridkin (lexfrei) marked this pull request as ready for review June 22, 2026 22:29
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request provides a foundational mechanism for secure TLS trust-anchor distribution across Cozystack applications. By decoupling the CA certificate from secrets containing private keys, it enables tenants to verify per-app TLS endpoints safely. This approach establishes a standardized delivery pattern that simplifies future per-app TLS implementations while maintaining strict security boundaries.

Highlights

  • New TLS Trust-Anchor Helper: Introduced a new Helm template helper cozy-lib.tls.caCertSecret to render a canonical, key-free Opaque Secret containing only the CA certificate.
  • Secure RBAC Delivery: The generated Secret is automatically labeled with internal.cozystack.io/tenantresource: "true", allowing it to be surfaced via the core.cozystack.io/tenantsecrets API without exposing sensitive private keys.
  • Security Guards: The helper includes strict validation to ensure the provided PEM data contains a valid certificate and explicitly rejects any input containing private key material.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new Helm library helper cozy-lib.tls.caCertSecret in cozy-lib to safely render CA-only trust-anchor Secrets without exposing private keys. It includes comprehensive unit tests to verify proper validation behavior, such as rejecting private keys and ensuring the mandatory tenantresource label is applied. The reviewer suggested trimming the $caCert variable at assignment time to clean up whitespace and simplify the empty check.

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.

Comment on lines +81 to +84
{{- $caCert := default "" .caCert -}}
{{- if eq (trim $caCert) "" -}}
{{- fail "cozy-lib.tls.caCertSecret: caCert is required and must be a non-empty PEM" -}}
{{- end -}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low

Trimming the $caCert variable at assignment time is cleaner and ensures that any accidental leading or trailing whitespace/newlines in the input .caCert are stripped before being stored in the Secret. This also simplifies the subsequent empty check.

{{-   $caCert := trim (default "" .caCert) -}}
{{-   if eq $caCert "" -}}
{{-     fail "cozy-lib.tls.caCertSecret: caCert is required and must be a non-empty PEM" -}}
{{-   end -}}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — leaving this as-is intentionally. The empty check already trims (eq (trim $caCert) ""), so whitespace-only input is still rejected. I keep the stored value verbatim so the helper faithfully preserves the input PEM, including the trailing newline that certificate blocks conventionally carry; trimming it would strip that with no functional benefit for verification. So I'd rather not mutate the cert content here.

Per-app TLS issues a per-release self-signed CA, but the only objects
that carry ca.crt — the cert-manager CA and leaf Secrets — also carry
private keys. Granting a tenant read on those to obtain the trust
anchor also hands over a server or CA private key.

Add cozy-lib.tls.caCertSecret, which renders a canonical Opaque Secret
holding only ca.crt and labelled internal.cozystack.io/tenantresource=
true. The tenantsecret registry surfaces label-bearing Secrets under
core.cozystack.io/tenantsecrets, which the base tenant roles already
grant, so tenants reach the trust anchor without any grant on a Secret
that contains tls.key. The helper fails closed on empty input and
refuses any PEM carrying private key material.

Covered by helm-unittest in the cozy-lib test chart.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>

@myasnikovdaniil myasnikovdaniil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Reviewed statically (library-helper change; stayed off dev10 due to an in-progress CNI roll).

The security core is sound: cozy-lib.tls.caCertSecret provably emits only ca.crt in an Opaque Secret — there is no path by which tls.key/tls.crt or private material reaches the rendered output. I independently verified the private-key guard with regexMatch: it matches every standard key header (PKCS#8, RSA, EC, DSA, ENCRYPTED, OPENSSH) including lowercase, and correctly does NOT match the CERTIFICATE header or a free-text friendlyName=… PRIVATE KEY … line. The "cert + trailing key block" negative test covers the realistic accident.

Verification:

  • make -C packages/tests/cozy-lib-tests test → 14/14 pass.
  • Independent helm template of the consumer fixture: only ca.crt in stringData, mandatory internal.cozystack.io/tenantresource=true label present, type: Opaque.
  • RBAC/registry claims cross-checked against pkg/registry/core/tenantsecret/rest.go, pkg/apis/core/v1alpha1/tenantresource_types.go, and packages/system/cozystack-basics/templates/clusterroles.yaml — accurate. Note secretToTenant copies sec.Data straight through, which is exactly why emitting only ca.crt is load-bearing — the helper does the right thing.
  • Diff is purely additive (+349/-0, 6 files), no change to existing chart behavior.

API naming (cozy-lib.tls.caCertSecret) and the single-dict-arg convention are consistent with the existing cozy-lib.* helpers. The fail-closed behavior on empty/keyed input is the correct posture for a foundational object.

One non-blocking suggestion below regarding the docstring usage example and the not-yet-issued (lookup returns empty) case the #2988/#2990 series will hit. Approving as-is.

"namespace" .Release.Namespace
"caCert" $caCertPem
"labels" (dict "app.kubernetes.io/instance" .Release.Name)
) }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (non-blocking): the helper fails closed when caCert is empty (correct), but this usage example passes caCert unconditionally. The #2988 ACME flow will lookup the per-release CA, which returns empty on the first reconcile before cert-manager issues it — calling the helper at that point hard-fails the render. The safe idiom is a consumer-side guard:

{{- $caCert := (lookup "v1" "Secret" .Release.Namespace "wildcard-tls").data["ca.crt"] | default "" }}
{{- if $caCert }}
{{ include "cozy-lib.tls.caCertSecret" (dict "name" (printf "%s-ca-cert" .Release.Name) "namespace" .Release.Namespace "caCert" ($caCert | b64dec)) }}
{{- end }}

which renders nothing (no error) when the CA isn't issued yet and renders the trust anchor once it is. Consider showing the if $caCert guard in the docstring example so every per-app PR copies the not-yet-issued-safe pattern. Keep the helper's own fail-closed behavior as is.

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

Labels

area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) kind/feature Categorizes issue or PR as related to a new feature size/L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants