Files
cozystack/packages
Aleksei Sviridkin 491b184948 feat(cozy-lib): add CA-only TLS trust-anchor helper for tenants (#2989)
## 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:

- redis #2729 — already publishes an operator-side CA-only Opaque Secret
and already drops the grant on the CA-private-key Secret; converging
means emitting it through the helper / carrying the `tenantresource`
label so delivery matches the rest of the catalog.
- nats, qdrant (merged, cert-manager) — emit the canonical object via
the helper and withhold `<release>-ca` / `<release>-tls` from tenants.
- mariadb #2680, opensearch #2682, rabbitmq #2683, mongodb #2692 — same:
emit the canonical object, never grant a Secret that contains `tls.key`.

### 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

```release-note
NONE
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-23 13:13:52 +03:00
..