Skip to content

feat(controller): engine-agnostic CA extraction for the tenant trust anchor#3299

Open
Aleksei Sviridkin (lexfrei) wants to merge 6 commits into
mainfrom
feat/ca-extraction-controller
Open

feat(controller): engine-agnostic CA extraction for the tenant trust anchor#3299
Aleksei Sviridkin (lexfrei) wants to merge 6 commits into
mainfrom
feat/ca-extraction-controller

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What this PR does

A tenant connecting to a managed TLS endpoint needs the CA certificate (ca.crt) to verify the server, and nothing more. Today the platform delivers it for no engine except kafka, and the in-flight per-app TLS work would each hand the tenant a Secret that also carries a private key — the cert-manager CA Secret holds tls.key, CloudNativePG's <release>-ca holds ca.key. Granting read on one of those to deliver the trust anchor hands over key material too.

This adds a controller that publishes, for every engine, one canonical key-free object per release: an Opaque Secret named <release>-tenant-ca holding only ca.crt. The name is deliberately not <release>-ca-cert, which Percona Server for MongoDB already creates carrying a private key.

Source discovery has two legs behind one write path, and the declared leg wins. When an ApplicationDefinition declares spec.caCert, that named source is authoritative and a labelled Secret is not consulted for the engine; the label leg serves only engines with no declaration. This ordering is a security boundary: the declaration lives on a platform object no tenant can write, while a publish label sits on a Secret a namespace writer could create — so a declared engine's trust anchor cannot be swapped for a forged one.

The name-driven (declared) leg exists because CloudNativePG and Percona Server for MongoDB create their CA Secret themselves and cannot be made to label it. The label-driven leg serves the cert-manager-minting charts, which opt in through Certificate.spec.secretTemplate.labels; on it a new required label internal.cozystack.io/publish-ca-cert-release names the release, because a cert-manager-issued Secret carries no OwnerReference (the platform ships enableCertificateOwnerRef: false) and no Helm metadata, so nothing else can attribute it to a release.

The write path is the single place bytes reach a projection, on both legs. It copies exactly one whitelisted key under the canonical ca.crt and fails closed on any value that carries a PEM private-key header or is not a PEM certificate — so the guard matters most on the name-driven leg, where the source is key-bearing by construction. The projection is owner-referenced to the application's HelmRelease with BlockOwnerDeletion: false (the apps.cozystack.io kinds are virtual, so there is no application CR in etcd to reference), so deleting the application collects it natively. A Secret of the canonical name the controller did not create is never overwritten; the collision surfaces as a Warning Event.

postgres is wired as the first consumer, validating the mechanism against the hardest input — an operator-created, asynchronous, key-bearing CA Secret on the name-driven leg. Its ApplicationDefinition declares CNPG's <release>-ca and selects the projection for tenant visibility with the generic internal.cozystack.io/tenant-ca label. The remaining engines are tracked in #2814.

The label leg is guarded at admission. A ValidatingAdmissionPolicy restricts writes to any Secret carrying internal.cozystack.io/publish-ca-cert to cert-manager's controller ServiceAccount, on both CREATE and UPDATE (checking the old object as well, so relabel-away is also caught). A forged or tampered label-leg source is therefore rejected before it can become a tenant's trust anchor, closing the one path where a namespace writer could otherwise spoof an anchor for a label-only engine.

The complementary visibility label internal.cozystack.io/tenant-ca — which the tenant-facing selector matches — is deliberately not admission-guarded here: this controller is its only writer today, and promoting a foreign key-bearing Secret through it (for example CNPG's <release>-ca, which holds ca.key) would require raw core-secrets write in the tenant namespace, which base tenant roles do not grant. That is one instance of the broader tenant-RBAC key-exposure surface tracked under the unified-TLS epic (#2811); this change does not widen its reachability beyond adding the label, and closing it belongs to that epic's RBAC work rather than a second per-label policy here.

Known limitation: a tenant who strips the internal.cozystack.io/ca-cert-copy marker from their own projection makes the controller treat it as foreign and stop updating that one anchor — self-inflicted, same-namespace, no key leak, a direct consequence of never adopting a Secret it cannot prove it owns.

Release note

feat(controller): add an engine-agnostic controller that publishes each application's CA certificate as a key-free `<release>-tenant-ca` Secret, so a tenant can verify a managed TLS endpoint without gaining access to private key material; postgres is wired as the first consumer

Closes #3286

Summary by CodeRabbit

  • New Features

    • Added configurable CA certificate sources for applications.
    • Applications now receive a canonical, key-free tenant CA Secret containing only ca.crt.
    • Added support for CA sources from declared operator Secrets and approved published certificate Secrets.
    • Added fail-closed protection against publishing private keys or invalid certificate data.
    • Updated PostgreSQL trust-anchor configuration to use the tenant CA Secret.
  • Documentation

    • Clarified tenant CA Secret naming, publishing behavior, and access selection.
  • Tests

    • Added comprehensive coverage for CA projection, rotation, security, ownership, cleanup, and recovery scenarios.

@github-actions github-actions Bot added area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/feature Categorizes issue or PR as related to a new feature labels Jul 15, 2026
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a CA source contract to ApplicationDefinition, introduces a controller that projects key-free <release>-tenant-ca Secrets, wires dedicated Secret caching and watches, adds admission and RBAC validation, and updates Postgres and TLS helper integrations.

Changes

CA certificate extraction

Layer / File(s) Summary
ApplicationDefinition CA contract
api/v1alpha1/..., packages/system/application-definition-crd/...
Adds spec.application.caCert with a required source Secret name, default ca.crt key, deepcopy support, and CRD schema.
Source resolution and tenant projection
internal/controller/cacert/reconciler.go
Resolves declared or labelled sources, validates certificate data, projects only ca.crt into <release>-tenant-ca, preserves metadata, handles collisions, retries, rotations, and pruning.
Controller watches and runtime registration
internal/controller/cacert/reconciler.go, cmd/cozystack-controller/main.go
Adds scoped Secret caching, source and definition event mapping, and dedicated cache-cluster registration.
Admission and Secret permissions
packages/system/cozystack-basics/..., packages/system/cozystack-controller/...
Adds fail-closed admission for labelled CA-source writes and validates the controller’s Secret permissions.
Tenant CA naming and application wiring
packages/library/cozy-lib/..., packages/system/postgres-rd/..., packages/tests/cozy-lib-tests/...
Uses the tenant CA name and label in the TLS helper and configures Postgres to consume the projected Secret.
Controller behavior coverage
internal/controller/cacert/reconciler_test.go
Tests source selection, sanitization, security checks, lifecycle behavior, collisions, retries, pruning, watches, and cache scoping.
Postgres wiring validation
internal/controller/cacert/postgres_wiring_test.go
Verifies the shipped Postgres definition projects a key-free CA Secret and preserves the operator source key.ahas

Estimated code review effort: 5 (Critical) | ~90+ minutes

Sequence Diagram(s)

sequenceDiagram
  participant HelmRelease
  participant CACertReconciler
  participant ApplicationDefinition
  participant OperatorSecret
  participant TenantCASecret
  HelmRelease->>CACertReconciler: reconcile application release
  CACertReconciler->>ApplicationDefinition: read caCert source declaration
  CACertReconciler->>OperatorSecret: read declared or labelled CA data
  CACertReconciler->>CACertReconciler: validate PEM and retain ca.crt
  CACertReconciler->>TenantCASecret: create or update key-free projection
Loading

Possibly related issues

Possibly related PRs

Suggested labels: area/database

Suggested reviewers: kvaps, androndo, ivanhunters, lllamnyp, sircthulhu

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #3286 with declared and label-driven CA sources, key-free projection, rotation handling, owner refs, collision safety, and tenant-label plumbing.
Out of Scope Changes check ✅ Passed The CRD, controller, chart, admission policy, RBAC, and tests all support the CA extraction contract, with no clearly unrelated changes.
Docstring Coverage ✅ Passed Docstring coverage is 86.11% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the new engine-agnostic CA extraction controller and tenant trust-anchor projection.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ca-extraction-controller

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 the size/XXL This PR changes 1000+ lines, ignoring generated files label Jul 15, 2026
@lexfrei Aleksei Sviridkin (lexfrei) marked this pull request as ready for review July 15, 2026 01:51
@dosubot dosubot Bot added the area/platform Issues or PRs related to platform infrastructure (bundle, flux, talos, installer) label Jul 15, 2026
@lexfrei Aleksei Sviridkin (lexfrei) removed the area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review label Jul 15, 2026
@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 implements a secure, engine-agnostic framework for distributing CA certificates to tenants. By decoupling the trust anchor from the underlying operator-managed secrets, the platform prevents the accidental exposure of private key material. The solution provides a robust, fail-closed extraction path that supports both label-based and explicitly declared sources, ensuring consistent and secure trust anchor delivery across various managed services.

Highlights

  • New CA-Extraction Controller: Introduced a new controller that extracts CA certificates into key-free Opaque Secrets named -tenant-ca, ensuring tenants can verify TLS endpoints without accessing private keys.
  • Dual-Leg Source Discovery: Implemented two discovery methods: a label-driven approach for cert-manager-minted secrets and a name-driven (declared) approach for operator-created secrets that cannot be labeled.
  • Security Hardening: Added write-path sanitization to block private key material and implemented a ValidatingAdmissionPolicy to restrict source secret modifications to authorized service accounts.
  • Postgres Integration: Wired the Postgres engine as the first consumer of this mechanism, validating the extraction logic against complex, key-bearing operator secrets.
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.

Ignored Files
  • Ignored by pattern: **/zz_generated.*.go (1)
    • api/v1alpha1/zz_generated.deepcopy.go
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 CA-extraction controller (cacert) that projects the TLS trust anchor of managed applications into a canonical, key-free Secret (<release>-tenant-ca) in the release namespace, allowing tenants to securely read CA certificates without exposing private keys. It updates the ApplicationDefinition CRD and CRD schema to support authoritative caCert declarations, configures the Postgres application definition to use this new mechanism, and updates the cozy-lib library chart helper to align with the new canonical naming and labelling. Additionally, it introduces a ValidatingAdmissionPolicy to restrict writes to CA-source Secrets to cert-manager only, updates controller RBAC permissions, and adds comprehensive unit and integration tests. I have no feedback to provide as there are no review comments to assess.

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🤖 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 `@internal/controller/cacert/reconciler.go`:
- Around line 565-576: Treat any source Secret already occupying the canonical
projection name as a collision, regardless of whether it contains private key
material: update reconcileSourceAtCanonicalName to emit the required collision
warning and requeue instead of silently succeeding. Update
internal/controller/cacert/reconciler_test.go lines 524-550 to assert the
collision warning and resulting behavior rather than a successful no-op.
- Around line 885-895: Validate the namespace parsed from SourceRefAnnotation
against hr.Namespace before calling r.Reader.Get in the existing source-lookup
block. Reject or ignore references where ns differs from hr.Namespace, ensuring
no cross-namespace Secret read occurs while preserving same-namespace lookup
behavior.
- Around line 495-502: Update the reconciliation path around the src == nil
branch and pruneProjection so the persisted source mode remains available when
the declared Secret disappears, and pass the current ApplicationDefinition into
pruning to distinguish a removed declaration from a temporarily absent source.
Ensure removing spec.caCert withdraws the previously projected trust anchor, and
add a regression test covering source disappearance before declaration removal.
- Around line 960-968: Update projectionData to fully decode and parse every PEM
block as a certificate, rejecting malformed or truncated payloads, trailing
bytes, and non-certificate blocks while preserving private-key rejection. In
internal/controller/cacert/reconciler.go lines 960-968, replace header matching
with complete validation; in internal/controller/cacert/reconciler_test.go lines
66-67, use valid certificate fixtures; and in
internal/controller/cacert/reconciler_test.go lines 1383-1398, add malformed,
trailing-data, and non-certificate-block cases.
- Around line 1085-1089: Update the rendered Secret name validation after
strings.TrimSpace in the surrounding name-rendering function to reject values
that are not valid DNS1123 subdomains, using the existing
CACertDeclarationInvalid error/result convention instead of the generic error.
Preserve the empty-name check and return the validated name only when it
satisfies the Kubernetes naming rules.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8f836b39-3c2b-4df5-a46a-93f85ee43477

📥 Commits

Reviewing files that changed from the base of the PR and between 6f58898 and c5d95dc.

📒 Files selected for processing (16)
  • api/v1alpha1/applicationdefinitions_types.go
  • api/v1alpha1/zz_generated.deepcopy.go
  • cmd/cozystack-controller/main.go
  • internal/controller/cacert/postgres_wiring_test.go
  • internal/controller/cacert/reconciler.go
  • internal/controller/cacert/reconciler_test.go
  • packages/library/cozy-lib/templates/_tls.tpl
  • packages/system/application-definition-crd/definition/cozystack.io_applicationdefinitions.yaml
  • packages/system/cozystack-basics/templates/publish-ca-cert-writer-policy.yaml
  • packages/system/cozystack-basics/tests/publish-ca-cert-writer-policy_test.yaml
  • packages/system/cozystack-controller/Makefile
  • packages/system/cozystack-controller/templates/rbac.yaml
  • packages/system/cozystack-controller/tests/rbac_test.yaml
  • packages/system/postgres-rd/cozyrds/postgres.yaml
  • packages/tests/cozy-lib-tests/templates/tests/tls-cacert.yaml
  • packages/tests/cozy-lib-tests/tests/tls_cacert_test.yaml

Comment on lines +495 to +502
if src == nil {
// Nothing resolved: no Secret opted in through the label, and the
// ApplicationDefinition declares no source.
//
// That is NOT yet enough to withdraw the trust anchor, because it conflates
// two opposite situations — the release genuinely stopped publishing, or its
// source is merely absent this instant. pruneProjection tells them apart.
return r.pruneProjection(ctx, hr)

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not let a vanished source hide removal of a declaration.

If the declared Secret disappears before spec.caCert is removed, pruneProjection sees the recorded source as absent and retains the projection forever. Persist the source mode and pass the current definition into pruning so removing a declaration definitively withdraws its old trust anchor. Add a regression test for this ordering.

🤖 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 `@internal/controller/cacert/reconciler.go` around lines 495 - 502, Update the
reconciliation path around the src == nil branch and pruneProjection so the
persisted source mode remains available when the declared Secret disappears, and
pass the current ApplicationDefinition into pruning to distinguish a removed
declaration from a temporarily absent source. Ensure removing spec.caCert
withdraws the previously projected trust anchor, and add a regression test
covering source disappearance before declaration removal.

Comment on lines +565 to +576
func (r *Reconciler) reconcileSourceAtCanonicalName(src *source, target string) (ctrl.Result, error) {
for key, value := range src.secret.Data {
if containsPrivateKey(string(value)) {
r.warn(src.secret, reasonCanonicalNameOccupied,
"the Secret %q occupies the canonical trust-anchor name and carries private key material under %q; no trust anchor is published for this release until that Secret is renamed",
target, key)
return ctrl.Result{RequeueAfter: resyncInterval}, nil
}
}
// Key-free and already canonical: the engine publishes its own trust
// anchor. Leave it alone.
return ctrl.Result{}, nil

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Treat every foreign Secret at the canonical name as a collision.

The key-free branch silently accepts an object lacking the tenant-CA label, controller ownership, sanitization, and garbage collection, so it does not satisfy the canonical projection contract or emit the required collision warning.

  • internal/controller/cacert/reconciler.go#L565-L576: refuse and warn when the source itself occupies the canonical projection name.
  • internal/controller/cacert/reconciler_test.go#L524-L550: expect a collision warning rather than a silent successful no-op.
📍 Affects 2 files
  • internal/controller/cacert/reconciler.go#L565-L576 (this comment)
  • internal/controller/cacert/reconciler_test.go#L524-L550
🤖 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 `@internal/controller/cacert/reconciler.go` around lines 565 - 576, Treat any
source Secret already occupying the canonical projection name as a collision,
regardless of whether it contains private key material: update
reconcileSourceAtCanonicalName to emit the required collision warning and
requeue instead of silently succeeding. Update
internal/controller/cacert/reconciler_test.go lines 524-550 to assert the
collision warning and resulting behavior rather than a successful no-op.

Comment on lines +885 to +895
if ns, name, ok := splitSourceRef(existing.Annotations[SourceRefAnnotation]); ok {
recorded := &corev1.Secret{}
err := r.Reader.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, recorded)
switch {
case apierrors.IsNotFound(err):
// MERELY ABSENT. Hold the trust anchor and wait for the source to come
// back — fast while the release is young, then on the ordinary resync.
wait := missingSourceWait(hr)
logger.Info("the CA source is gone; keeping the published trust anchor and waiting for it to return",
"secret", hr.Namespace+"/"+target, "source", ns+"/"+name, "retryIn", wait)
return ctrl.Result{RequeueAfter: wait}, nil

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Constrain recorded source lookups to the release namespace.

SourceRefAnnotation is mutable, but its namespace is used directly with the cluster-wide API reader. A forged cross-namespace reference creates a Secret-existence oracle and can suppress withdrawal when the target is absent. Reject references where ns != hr.Namespace before performing the read.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 892-893: A log/format call (log.Print/Printf/Println, the Fatal/Panic variants, fmt.Sprintf, or a structured logger's Info/Warn/Error/Debug method) is given a message built by concatenating a string literal with a non-literal value such as request data. Unsanitized, attacker-controlled input written to logs enables log forging / CRLF injection: an attacker can inject newlines to spoof log entries or break log parsers. Do not concatenate raw input into the log message; pass it as a separate structured field/argument (e.g. 'log.Printf("user: %s", user)' or 'logger.Info("login", "user", user)') and strip or escape newline characters first.
Context: logger.Info("the CA source is gone; keeping the published trust anchor and waiting for it to return",
"secret", hr.Namespace+"/"+target, "source", ns+"/"+name, "retryIn", wait)
Note: [CWE-117] Improper Output Neutralization for Logs.

(log-injection-request-data-concat-go)

🤖 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 `@internal/controller/cacert/reconciler.go` around lines 885 - 895, Validate
the namespace parsed from SourceRefAnnotation against hr.Namespace before
calling r.Reader.Get in the existing source-lookup block. Reject or ignore
references where ns differs from hr.Namespace, ensuring no cross-namespace
Secret read occurs while preserving same-namespace lookup behavior.

Comment thread internal/controller/cacert/reconciler.go
Comment on lines +1085 to +1089
name := strings.TrimSpace(buf.String())
if name == "" {
return "", errors.New("rendered to an empty name")
}
return name, nil

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map relevant structure first
ast-grep outline internal/controller/cacert/reconciler.go --view expanded || true

# Show the target area with line numbers
sed -n '1020,1115p' internal/controller/cacert/reconciler.go

# Search for name validation / declaration invalid errors in this controller package
rg -n "CACertDeclarationInvalid|rendered to an empty name|dns subdomain|IsDNS1123Subdomain|IsValidSecret|secret name|NotFound" internal/controller/cacert -S

Repository: cozystack/cozystack

Length of output: 9711


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '560,740p' internal/controller/cacert/reconciler.go
printf '\n---\n'
sed -n '840,920p' internal/controller/cacert/reconciler.go
printf '\n--- tests ---\n'
sed -n '150,240p' internal/controller/cacert/reconciler_test.go

Repository: cozystack/cozystack

Length of output: 15206


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find any explicit validation of Secret names in this controller package or repo.
rg -n "IsDNS1123Subdomain|IsValidSecretName|Validate.*Secret|validation.*secret|CACertDeclarationInvalid|unusable caCert declaration" . -S

Repository: cozystack/cozystack

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the renderSourceName call path and the handling of its errors.
nl -ba internal/controller/cacert/reconciler.go | sed -n '600,740p'
printf '\n---\n'
nl -ba internal/controller/cacert/reconciler.go | sed -n '860,920p'
printf '\n---\n'
nl -ba internal/controller/cacert/reconciler_test.go | sed -n '150,240p'
printf '\n---\n'
rg -n "renderSourceName\(" internal/controller/cacert/reconciler.go internal/controller/cacert/reconciler_test.go -n -S

Repository: cozystack/cozystack

Length of output: 197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the surrounding implementation of declaredSource and upsertProjection.
nl -ba internal/controller/cacert/reconciler.go | sed -n '640,730p'
printf '\n---\n'
nl -ba internal/controller/cacert/reconciler.go | sed -n '700,760p'
printf '\n---\n'
nl -ba internal/controller/cacert/reconciler.go | sed -n '860,920p'

Repository: cozystack/cozystack

Length of output: 197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

nl -ba internal/controller/cacert/reconciler.go | sed -n '640,740p'
printf '\n---\n'
nl -ba internal/controller/cacert/reconciler.go | sed -n '860,920p'

Repository: cozystack/cozystack

Length of output: 197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "IsDNS1123Subdomain|IsValidSecretName|Validate.*DNS|validation\.Is" internal/controller/cacert . -S

Repository: cozystack/cozystack

Length of output: 869


Validate the rendered Secret name before using it. strings.TrimSpace only catches the empty case; a template can still render a value that is not a valid DNS1123 subdomain, which will fail later in reconciliation. Return CACertDeclarationInvalid here instead.

🤖 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 `@internal/controller/cacert/reconciler.go` around lines 1085 - 1089, Update
the rendered Secret name validation after strings.TrimSpace in the surrounding
name-rendering function to reject values that are not valid DNS1123 subdomains,
using the existing CACertDeclarationInvalid error/result convention instead of
the generic error. Preserve the empty-name check and return the validated name
only when it satisfies the Kubernetes naming rules.

@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.

Reviewed the whole controller, the VAP, the RBAC delta, the cozy-lib guard, and the tests as the tree rather than the diff, and traced both source legs end to end against the tenant read path. The security story holds: tenants read trust anchors only through the label-gated, read-only tenantsecrets API and cannot write raw Secrets, the label leg is admission-gated to cert-manager, and the declared leg stays authoritative over the forgeable label leg — so the change fails closed on every tenant-influenceable path, is namespace-confined with no cross-tenant vector, and is unusually well tested (forged-label override, forged-projection healing, non-Opaque collision, recreate-rehome, poisoned rotation, prune discrimination).

Approving — everything below is non-blocking. The two follow-ups most worth doing are the x509-parse hardening of the write-path guard (it currently validates by header substring only) and tightening the prune/canonical-name corner, where a retired anchor can linger if a declaration is removed while its source is momentarily absent. Two nits I did not inline: selectorsDigest keeps only the low 64 bits of the SHA-256 (reconciler.go:952), and applicationDefinition returns the first kind match without flagging duplicate kinds (reconciler.go:696) — both harmless with platform-authored input.

if containsPrivateKey(pem) {
return nil, errPrivateKey
}
if !certificateHeader.MatchString(pem) {

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.

Non-blocking, defense-in-depth. This guard validates the trust anchor by header substring — BEGIN CERTIFICATE present and no BEGIN … PRIVATE KEY — but never decodes the PEM or runs x509.ParseCertificate, so certificate armor wrapped around non-certificate bytes (e.g. headerless DER) passes and is copied verbatim. It is not reachable from a shipped path: both legs' sources come from trusted writers (the operator-created <release>-ca, or cert-manager, which the writer VAP gates) and a tenant can write neither. But since this is the stated fail-closed boundary, worth making the check match the claim: decode with encoding/pem and assert every block is a CERTIFICATE that x509.ParseCertificate accepts, with no trailing remainder. The mirrored chart guard in _tls.tpl shares the limitation.

}
// Key-free and already canonical: the engine publishes its own trust
// anchor. Leave it alone.
return ctrl.Result{}, nil

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.

Two small asymmetries on the key-free canonical branch, both misconfig-only (no shipped engine declares <release>-tenant-ca as its source). It returns ctrl.Result{} with no RequeueAfter, so on the unwatched name-driven leg a self-source that later gains key material is never re-examined, whereas every other path here requeues on resyncInterval; and unlike the shared write path it does not assert the single-ca.crt/Opaque contract. It stamps no tenant-ca/tenantresource label, so it grants no tenant visibility on its own — but return ctrl.Result{RequeueAfter: resyncInterval} plus a warning on extra keys would make this corner symmetric with the rest.

err := r.Reader.Get(ctx, types.NamespacedName{Namespace: ns, Name: name}, recorded)
switch {
case apierrors.IsNotFound(err):
// MERELY ABSENT. Hold the trust anchor and wait for the source to come

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.

Correctness edge worth a look. If a declared source disappears and spec.caCert is then removed, resolveSource falls through to the empty label leg and returns nil, so pruneProjection runs — but because the recorded source is still absent it lands in this MERELY ABSENT branch and holds+requeues the projection indefinitely, even though removing the declaration is a definitive opt-out. The retired anchor stays tenant-readable until the source name reappears or the HelmRelease is deleted. The absent-vs-opt-out discriminator keys off the source Secret's existence, which cannot tell 'source rotating' apart from 'declaration withdrawn while the source happens to be gone'. Same-namespace and low-severity, but the withdrawal never fires.

# platform controller's own ServiceAccount; no tenant RBAC is widened.
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

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.

This widens the controller ServiceAccount from cluster-wide Secret read (already covered by the '*'/'*': get,list,watch catch-all) to cluster-wide create/update/patch/delete. It is genuinely required — projections land in tenant namespaces and RBAC cannot scope a verb to a name suffix — but it enlarges the blast radius if this SA is compromised, now co-located with cozystack.io/*: *. Non-blocking; worth a threat-model line, or later splitting the projection writes onto a dedicated SA that holds only the Secret verbs.

failurePolicy is Fail: a Secret whose write cannot be evaluated is denied,
not admitted — fail-closed, as a trust-anchor control must be.
*/}}
{{- $certManagerWriter := "system:serviceaccount:cozy-cert-manager:cert-manager" }}

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.

Two non-blocking operational notes on this policy. The allowed writer is pinned as a literal, so a cert-manager repackage/rename must move it in lockstep; it fails closed (denies the new identity, killing the label leg) rather than open, and since postgres uses the declared leg the policy gates nothing live yet, so the drift would only surface when the first label-leg engine converges — deriving the identity from the cert-manager package values at render time would remove the seam. Separately, failurePolicy: Fail + [Deny] with no namespace/object selector means a Velero or kubectl restore of a labelled cert-manager Secret during DR is denied (the restorer is not cert-manager); cert-manager re-mints so it is recoverable, but it is worth a runbook note.

The trust-anchor contract is a label on the CA Secret, but two engines
cannot honour it. CloudNativePG builds its PKI Secrets without applying
spec.inheritedMetadata, so the chart's labels never reach <release>-ca,
and Percona Server for MongoDB exposes no secretTemplate at all and
reconciles an out-of-band patch away.

Add an optional ApplicationDefinition.spec.caCert declaring, per engine,
the name of the Secret its operator creates and the key inside it that
holds the certificate. Registering an engine is then data, not code. The
declaration only names an object the platform already reads; nothing a
tenant can read is widened by it, and a labelled Secret still takes
precedence where an engine can label its own output.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Every engine in the catalog issues a per-release CA, but the object that
holds ca.crt almost always holds a private key next to it, so any RBAC
path that hands a tenant the trust anchor hands over key material too.

Add a controller that publishes, for each application release, a
canonical Opaque Secret <release>-tenant-ca holding only ca.crt. That
name is claimed by none of the operators the platform ships, which
matters: <release>-ca-cert, the obvious candidate, is already taken and
means opposite things across engines — a key-free CA on one, a
key-BEARING Secret on another — so projecting onto it could only be done
by overwriting live key material.

The certificate is found two ways. A Secret labelled
internal.cozystack.io/publish-ca-cert is how a chart opts in, and it is
watched, so a rotation propagates at once. For the engines whose operator
owns the CA Secret and will not label it, the application's caCert
declaration names that Secret, which is read by name and re-read on a
resync. The two legs differ only in how the source is found — extraction,
sanitization, ownership and collision handling are one shared write path,
because forking it is where a key-leak bug would hide.

Exactly one whitelisted key is ever copied, and every byte written is
re-checked for a PEM private-key header first: on the name-driven leg the
source is key-bearing by construction. The projection is owner-referenced
to the release, so deleting the application collects it with no prune
logic, and a Secret the controller did not create is never overwritten —
a Secret carrying key material on the canonical name is refused with a
Warning Event rather than adopted.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Wire the reconciler into the platform controller manager and grant its
ServiceAccount the Secret verbs the projection needs: create/update/patch
to publish the trust anchor, and delete to withdraw it when an application
stops publishing one. Read was already covered by the catch-all rule, and
no tenant RBAC is widened.

Give the reconciler its own dedicated, scoped Secret cache rather than
narrowing the manager's shared cache. Only labelled CA sources are held in
that informer, so a cluster-wide Secret watch never caches every Secret,
and so every private key, in the cluster; the operator-owned name-driven
sources and the projections are read through the uncached API reader
instead. A private cache also keeps this scoping independent of any other
controller on the same manager that needs to scope Secrets differently.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The CA-extraction controller publishes every engine's trust anchor as an
Opaque "<release>-tenant-ca" Secret labelled
internal.cozystack.io/tenant-ca. That label is the single, engine-agnostic
selector an ApplicationDefinition puts in spec.secrets, and it is what
ultimately reaches the tenant.

This helper is the other producer of that same object, for charts that
already hold the CA PEM at render time. It agreed with neither half of the
contract: it documented "<release>-ca-cert" as the name, and it stamped no
tenant-ca label at all.

Both are now fixed, and both mattered.

The name "<release>-ca-cert" is not merely a second choice, it is
unusable: Percona Server for MongoDB creates a Secret of exactly that name
itself and puts a private key in it, so a chart rendering a trust anchor
there would be writing over live key material.

Without the tenant-ca label, the two producers would converge on the name
but not on the label. A helper-rendered anchor would be missed by the
generic selector, the lineage webhook would mark it tenantresource=false,
and the tenant would be locked out of the very Secret the helper exists to
publish — while a controller-projected one, of the same name, worked.

Also record what the tenantresource label does and does not do. The
lineage webhook recomputes it from the definition's spec.secrets selectors
on every admission and overwrites whatever the chart wrote, so rendering it
is necessary but not sufficient for tenant read access.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Declare CNPG's operator-created CA Secret as the postgres trust-anchor
source (spec.caCert names {{ .release }}-ca), and add the generic
internal.cozystack.io/tenant-ca selector to spec.secrets so the key-free
<release>-tenant-ca projection the extraction controller publishes becomes
readable by the tenant.

postgres is the first converged engine, and the hardest: CNPG creates the
CA Secret itself, key-bearing, asynchronously, and offers no way to label
it, so it exercises the name-driven leg end to end. The test drives the
shipped ApplicationDefinition rather than a fixture, so the wiring and the
controller cannot drift apart unseen: a CNPG-shaped key-bearing
<release>-ca appears, and the projection must be produced key-free AND
selected for tenant visibility by the shipped selector.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
The CA-extraction controller's label-driven leg treats a core Secret
labelled internal.cozystack.io/publish-ca-cert as a trust-anchor source
and lifts its ca.crt into the tenant-readable projection. That label is
the whole attribution on that leg and sits on an ordinary Secret, so
without a platform-side control any principal that can write a Secret in
the namespace could forge a source or rewrite an existing one's ca.crt and
swap the trust anchor a tenant is handed as vouched.

Add a ValidatingAdmissionPolicy that allows a Secret carrying that label to
be created or updated only by cert-manager's controller ServiceAccount, the
sole legitimate writer of these Secrets. It covers both the forge-new
(CREATE) and tamper-existing (UPDATE, including a relabel-away evasion via
the old object) shapes, and by keying on the publish-ca-cert label it never
touches the declared-leg source, the projection, or any unlabelled Secret.

The allowed identity is pinned as a literal to the shipped cert-manager
package's controller ServiceAccount; a comment marks it as the fragile seam
that must move if cert-manager is repackaged.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei Aleksei Sviridkin (lexfrei) force-pushed the feat/ca-extraction-controller branch from c5d95dc to 98df611 Compare July 15, 2026 10:32
@github-actions github-actions Bot added the area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review label Jul 15, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 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 `@internal/controller/cacert/reconciler.go`:
- Around line 791-821: Update the projection reconciliation logic around
hasOwner to require exactly one OwnerReference matching owner, rather than
merely checking whether the desired owner exists. Treat any list with extra,
stale, or differing references as non-compliant and replace
existing.OwnerReferences with a single owner entry, while preserving the
early-return path only when labels, annotations, data, and ownership all match
exactly.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4dc4b7c7-ff0d-4dbc-be35-e80204e60fe1

📥 Commits

Reviewing files that changed from the base of the PR and between c5d95dc and 98df611.

📒 Files selected for processing (15)
  • api/v1alpha1/applicationdefinitions_types.go
  • api/v1alpha1/zz_generated.deepcopy.go
  • cmd/cozystack-controller/main.go
  • internal/controller/cacert/postgres_wiring_test.go
  • internal/controller/cacert/reconciler.go
  • internal/controller/cacert/reconciler_test.go
  • packages/library/cozy-lib/templates/_tls.tpl
  • packages/system/application-definition-crd/definition/cozystack.io_applicationdefinitions.yaml
  • packages/system/cozystack-basics/templates/publish-ca-cert-writer-policy.yaml
  • packages/system/cozystack-basics/tests/publish-ca-cert-writer-policy_test.yaml
  • packages/system/cozystack-controller/templates/rbac.yaml
  • packages/system/cozystack-controller/tests/rbac_test.yaml
  • packages/system/postgres-rd/cozyrds/postgres.yaml
  • packages/tests/cozy-lib-tests/templates/tests/tls-cacert.yaml
  • packages/tests/cozy-lib-tests/tests/tls_cacert_test.yaml
🚧 Files skipped from review as they are similar to previous changes (9)
  • packages/system/application-definition-crd/definition/cozystack.io_applicationdefinitions.yaml
  • packages/tests/cozy-lib-tests/templates/tests/tls-cacert.yaml
  • internal/controller/cacert/postgres_wiring_test.go
  • packages/system/cozystack-basics/tests/publish-ca-cert-writer-policy_test.yaml
  • packages/library/cozy-lib/templates/_tls.tpl
  • api/v1alpha1/zz_generated.deepcopy.go
  • packages/system/cozystack-controller/tests/rbac_test.yaml
  • api/v1alpha1/applicationdefinitions_types.go
  • cmd/cozystack-controller/main.go

Comment on lines +791 to +821
if maps.EqualFunc(existing.Data, desired, bytes.Equal) &&
existing.Labels[TenantCALabel] == trueValue &&
existing.Annotations[SourceRefAnnotation] == ref &&
existing.Annotations[SelectorsDigestAnnotation] == digest &&
hasOwner(existing.OwnerReferences, owner) {
return nil
}

// Merge, never replace: the lineage admission webhook stamps its own labels
// (managed-by-cozystack, tenantresource, application.*) on the projection at
// every admission, and replacing the map wholesale would strip the
// tenantresource label the tenant's read path depends on.
if existing.Labels == nil {
existing.Labels = map[string]string{}
}
existing.Labels[TenantCALabel] = trueValue
existing.Labels[ManagedLabel] = trueValue
if existing.Annotations == nil {
existing.Annotations = map[string]string{}
}
existing.Annotations[SourceRefAnnotation] = ref
existing.Annotations[SelectorsDigestAnnotation] = digest
if !hasOwner(existing.OwnerReferences, owner) {
// REPLACE, never append. A projection has exactly one controller owner,
// and the API server rejects a second reference with Controller=true
// outright ("Only one reference can have Controller set to true").
// A stale reference is exactly what deleting and recreating an
// application under the same name leaves behind — same release name, new
// UID — so appending would wedge the reconciler on an Invalid error
// instead of re-homing the projection onto the live release.
existing.OwnerReferences = []metav1.OwnerReference{owner}

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Enforce exactly one owner reference on every projection.

Lines 795 and 813 only verify that the desired HelmRelease owner exists, so an added second owner survives reconciliation. After the HelmRelease disappears, Line 429 stops reconciliation and garbage collection retains the Secret while that additional owner remains, leaving the trust anchor published indefinitely.

Require the owner-reference list to contain exactly the desired owner and replace it otherwise.

Proposed fix
-		hasOwner(existing.OwnerReferences, owner) {
+		len(existing.OwnerReferences) == 1 &&
+		hasOwner(existing.OwnerReferences, owner) {
 		return nil
 	}
...
-	if !hasOwner(existing.OwnerReferences, owner) {
+	if len(existing.OwnerReferences) != 1 || !hasOwner(existing.OwnerReferences, owner) {
 		existing.OwnerReferences = []metav1.OwnerReference{owner}
 	}

Based on PR objectives, projections must be garbage-collected after application deletion.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if maps.EqualFunc(existing.Data, desired, bytes.Equal) &&
existing.Labels[TenantCALabel] == trueValue &&
existing.Annotations[SourceRefAnnotation] == ref &&
existing.Annotations[SelectorsDigestAnnotation] == digest &&
hasOwner(existing.OwnerReferences, owner) {
return nil
}
// Merge, never replace: the lineage admission webhook stamps its own labels
// (managed-by-cozystack, tenantresource, application.*) on the projection at
// every admission, and replacing the map wholesale would strip the
// tenantresource label the tenant's read path depends on.
if existing.Labels == nil {
existing.Labels = map[string]string{}
}
existing.Labels[TenantCALabel] = trueValue
existing.Labels[ManagedLabel] = trueValue
if existing.Annotations == nil {
existing.Annotations = map[string]string{}
}
existing.Annotations[SourceRefAnnotation] = ref
existing.Annotations[SelectorsDigestAnnotation] = digest
if !hasOwner(existing.OwnerReferences, owner) {
// REPLACE, never append. A projection has exactly one controller owner,
// and the API server rejects a second reference with Controller=true
// outright ("Only one reference can have Controller set to true").
// A stale reference is exactly what deleting and recreating an
// application under the same name leaves behind — same release name, new
// UID — so appending would wedge the reconciler on an Invalid error
// instead of re-homing the projection onto the live release.
existing.OwnerReferences = []metav1.OwnerReference{owner}
if maps.EqualFunc(existing.Data, desired, bytes.Equal) &&
existing.Labels[TenantCALabel] == trueValue &&
existing.Annotations[SourceRefAnnotation] == ref &&
existing.Annotations[SelectorsDigestAnnotation] == digest &&
len(existing.OwnerReferences) == 1 &&
hasOwner(existing.OwnerReferences, owner) {
return nil
}
// Merge, never replace: the lineage admission webhook stamps its own labels
// (managed-by-cozystack, tenantresource, application.*) on the projection at
// every admission, and replacing the map wholesale would strip the
// tenantresource label the tenant's read path depends on.
if existing.Labels == nil {
existing.Labels = map[string]string{}
}
existing.Labels[TenantCALabel] = trueValue
existing.Labels[ManagedLabel] = trueValue
if existing.Annotations == nil {
existing.Annotations = map[string]string{}
}
existing.Annotations[SourceRefAnnotation] = ref
existing.Annotations[SelectorsDigestAnnotation] = digest
if len(existing.OwnerReferences) != 1 || !hasOwner(existing.OwnerReferences, owner) {
// REPLACE, never append. A projection has exactly one controller owner,
// and the API server rejects a second reference with Controller=true
// outright ("Only one reference can have Controller set to true").
// A stale reference is exactly what deleting and recreating an
// application under the same name leaves behind — same release name, new
// UID — so appending would wedge the reconciler on an Invalid error
// instead of re-homing the projection onto the live release.
existing.OwnerReferences = []metav1.OwnerReference{owner}
}
🤖 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 `@internal/controller/cacert/reconciler.go` around lines 791 - 821, Update the
projection reconciliation logic around hasOwner to require exactly one
OwnerReference matching owner, rather than merely checking whether the desired
owner exists. Treat any list with extra, stale, or differing references as
non-compliant and replace existing.OwnerReferences with a single owner entry,
while preserving the early-return path only when labels, annotations, data, and
ownership all match exactly.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. The engine-agnostic cacert controller is fail-closed on every security-critical path: the declared source takes priority over the forgeable label leg, the write path copies exactly one whitelisted ca.crt and rejects any private-key/non-cert PEM, owner-ref GC is native, and Secret.type immutability is handled. The new VAP is a GA in-apiserver policy with no webhook pod (no cold-start trap) and rejects no existing Flux write (no chart writes the publish-ca-cert label). Additive CRD field with committed deepcopy/schema, no migration; cozyrds selects the key-free tenant-ca projection, so there is no TLS key leak.

Non-blocking follow-ups (already tracked): x509-parse the guard instead of header-match only; distinguish declaration-removed from source-temporarily-absent in pruneProjection; harden the cross-ns source lookup; the hardcoded cert-manager SA in the VAP is a documented fragile seam.

@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 — re-reviewed after the rebase onto current main. The core controller, _tls.tpl, the API types, the CRD, the postgres wiring, and the ValidatingAdmissionPolicy's rendered output are byte-identical to the previously-approved revision. The force-push added two targeted tests and two documentation clarifications, on top of the environment change from the wildcard-secret controller landing on main.

What I re-verified

  • The only behavioral change to this controller's environment is that the manager's shared Secret informer is now scoped (by the wildcard-secret controller) where it was previously unscoped. That makes the invariant "every Secret read goes through the uncached Reader or this controller's private, scoped caSourceCluster cache — never the shared cached Client" load-bearing rather than a review-only guarantee. Audited every Secret access: the five Secret reads (reconciler.go:613 via Cache; :677, :719, :869, :887 via Reader) all bypass the shared cache; the remaining cached Get/List calls (:424, :692, :1243, :1272) are HelmRelease / ApplicationDefinition, not Secrets; writes (:736, :830, :907) bypass the cache. The scoping does not regress this controller.
  • The two new tests (TestReconcile_NameDrivenSource_ReadThroughUncachedReader, TestReconcile_UpsertExistingCheck_ReadThroughUncachedReader) lock exactly that invariant; both pass. go test ./internal/controller/cacert/... and go vet are green, and helm unittest passes for the cozystack-basics VAP and the cozystack-controller RBAC.
  • The doc-only changes are accurate: the VAP comment explaining why DELETE is intentionally ungated (integrity-only policy; a DELETE-matching VAP would also gate the garbage-collector and namespace-controller and wedge namespace teardown), and the RBAC comment now attributing the shared Secret write grant to both reconcilers (verbs unchanged).
  • The two red CI checks are unrelated to this PR: Build packages/apps/kubernetes fails on an unresolvable upstream centos:stream9 image digest (that package is untouched here), and Analyze (go) was canceled mid-run after its Go build step passed.

Non-blocking follow-ups

  1. Single-owner enforcement on the projection (reconciler.go:795, :813) — see inline comment.
  2. Carried over from the earlier review, still applicable (these files are unchanged):
    • decode and x509.ParseCertificate the write-path guard instead of validating by PEM-header substring (reconciler.go:965, mirrored in _tls.tpl);
    • the declaration-withdrawn-while-source-absent prune corner leaves a retired anchor tenant-readable until the source reappears or the release is deleted (:890);
    • the key-free canonical branch returns no RequeueAfter and skips the single-ca.crt/Opaque assertion (:576);
    • selectorsDigest retains only the low 64 bits of the SHA-256 (:952);
    • applicationDefinition returns the first kind match without flagging duplicate kinds (:696);
    • the cluster-wide Secret write grant enlarges the controller ServiceAccount's blast radius (rbac.yaml);
    • the VAP's cert-manager writer is pinned as a literal, and a DR restore of a labelled source is denied (publish-ca-cert-writer-policy.yaml).

None of these block merge.

}
existing.Annotations[SourceRefAnnotation] = ref
existing.Annotations[SelectorsDigestAnnotation] = digest
if !hasOwner(existing.OwnerReferences, owner) {

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.

Non-blocking hardening. The up-to-date short-circuit (:795) and this normalization both check only that the desired HelmRelease owner is present (hasOwner), not that it is the sole owner. A namespace actor able to update the projection could append a second, non-controller owner reference pointing at a long-lived object; it survives reconciliation, and once the HelmRelease is deleted the garbage collector keeps the Secret because that other owner still exists — the retired trust anchor lingers indefinitely.

Impact is bounded: the projection is key-free, same-namespace, and self-inflicted — the same class as the known limitation already noted in the PR description — so this is not a blocker. Cheap to close by gating both sites on len(existing.OwnerReferences) == 1 && hasOwner(...); worth a line in the known-limitations note either way.

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) area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants