Skip to content

Commit 491b184

Browse files
authored
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 -->
2 parents 51c5273 + 73f6382 commit 491b184

6 files changed

Lines changed: 349 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
{{/*
2+
=============================================================================
3+
TLS trust-anchor helpers
4+
=============================================================================
5+
6+
Per-app TLS in the Cozystack catalog issues a per-release, self-signed CA (via
7+
cert-manager or the app operator). A tenant that connects to such an endpoint
8+
needs the CA certificate (ca.crt) to verify the server — but nothing more. The
9+
objects that hold ca.crt today also hold private keys:
10+
11+
- the cert-manager CA secret <release>-ca carries the CA private key
12+
(tls.key) — full trust-chain compromise if leaked;
13+
- the cert-manager leaf secret <release>-tls carries the server private key
14+
(tls.key) — server impersonation / MITM if leaked.
15+
16+
So any RBAC path that hands a tenant ca.crt by granting read on one of those
17+
Secrets also hands over a private key. cozy-lib.tls.caCertSecret breaks that
18+
coupling: it renders a canonical trust-anchor object that carries ONLY ca.crt,
19+
labelled so tenants reach it through the core.cozystack.io/tenantsecrets API
20+
that the base tenant roles already grant — never through a Secret that contains
21+
tls.key.
22+
23+
Delivery mechanism (grounded in the platform RBAC, not invented here):
24+
25+
- pkg/registry/core/tenantsecret/rest.go surfaces, under the virtual resource
26+
core.cozystack.io/tenantsecrets, exactly the namespace Secrets labelled
27+
internal.cozystack.io/tenantresource=true (TenantResourceLabelKey /
28+
TenantResourceLabelValue in pkg/apis/core/v1alpha1/tenantresource_types.go).
29+
- packages/system/cozystack-basics/templates/clusterroles.yaml grants
30+
get/list/watch on core.cozystack.io/tenantsecrets to tenant ServiceAccounts
31+
(cozy:tenant:base) and to use/admin/super-admin subjects (cozy:tenant:use:
32+
base). No grant on raw core/v1 secrets is involved, so attaching the label
33+
to a key-free object exposes the trust anchor without exposing any key.
34+
- internal/backupcontroller/credentials_projector.go relies on the same rule
35+
in reverse: it deliberately OMITS the label so its key-bearing projection
36+
is NOT promoted to a TenantSecret. This helper is the positive counterpart —
37+
a key-FREE object that is safe to promote.
38+
39+
This is the chart-side shape every per-app TLS PR converges on; population of
40+
ca.crt stays the responsibility of whatever owns the PKI (the app operator, as
41+
the redis-operator fork does with its CA-only Opaque Secret, or a cert-manager
42+
chain resolved at the chart level). The helper itself is pure and value-driven
43+
so it renders deterministically and refuses, by construction and by guard, to
44+
carry a private key.
45+
*/}}
46+
47+
{{/*
48+
cozy-lib.tls.caCertSecret renders the canonical CA-only trust-anchor Secret.
49+
50+
Invoked with a single dict argument (named parameters, not the (arg, $) list
51+
form, because it needs no global scope):
52+
53+
{{ include "cozy-lib.tls.caCertSecret" (dict
54+
"name" (printf "%s-ca-cert" .Release.Name)
55+
"namespace" .Release.Namespace
56+
"caCert" $caCertPem
57+
"labels" (dict "app.kubernetes.io/instance" .Release.Name)
58+
) }}
59+
60+
Parameters:
61+
- name (required) Secret name. Convention: "<release>-ca-cert".
62+
- caCert (required) the CA certificate chain in PEM. Must contain a
63+
BEGIN CERTIFICATE PEM header and must NOT contain a
64+
BEGIN...PRIVATE KEY PEM header; the helper fails closed on either
65+
violation. Both checks are anchored to the PEM header line and are
66+
case-insensitive, so they neither false-positive on certificate
67+
body/comment text nor miss a lowercased hand-pasted header.
68+
- namespace (optional) metadata.namespace.
69+
- labels (optional) extra labels merged onto the mandatory
70+
internal.cozystack.io/tenantresource label (which always wins).
71+
- annotations (optional) extra annotations.
72+
*/}}
73+
{{- define "cozy-lib.tls.caCertSecret" -}}
74+
{{- if not (kindIs "map" .) -}}
75+
{{- fail "cozy-lib.tls.caCertSecret: expected a single dict argument" -}}
76+
{{- end -}}
77+
{{- $name := default "" .name -}}
78+
{{- if eq $name "" -}}
79+
{{- fail "cozy-lib.tls.caCertSecret: name is required" -}}
80+
{{- end -}}
81+
{{- $caCert := default "" .caCert -}}
82+
{{- if eq (trim $caCert) "" -}}
83+
{{- fail "cozy-lib.tls.caCertSecret: caCert is required and must be a non-empty PEM" -}}
84+
{{- end -}}
85+
{{- /* Anchor both guards to the PEM header form, case-insensitively. A free
86+
substring match would false-positive on a legitimate certificate whose
87+
subject/comment text happens to contain "PRIVATE KEY", and would miss a
88+
lowercase hand-pasted header. The (?i) header regex catches every key
89+
variant (PKCS#8, RSA, EC, ENCRYPTED, OPENSSH, DSA) and only a real
90+
BEGIN...PRIVATE KEY line, not arbitrary blob text. */ -}}
91+
{{- if regexMatch "(?i)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----" $caCert -}}
92+
{{- fail "cozy-lib.tls.caCertSecret: caCert must not contain private key material" -}}
93+
{{- end -}}
94+
{{- if not (regexMatch "(?i)-----BEGIN CERTIFICATE-----" $caCert) -}}
95+
{{- fail "cozy-lib.tls.caCertSecret: caCert must contain a PEM certificate (BEGIN CERTIFICATE)" -}}
96+
{{- end -}}
97+
{{- $labels := merge (dict "internal.cozystack.io/tenantresource" "true") (default (dict) .labels) -}}
98+
apiVersion: v1
99+
kind: Secret
100+
metadata:
101+
name: {{ $name }}
102+
{{- with .namespace }}
103+
namespace: {{ . }}
104+
{{- end }}
105+
labels: {{- toYaml $labels | nindent 4 }}
106+
{{- with .annotations }}
107+
annotations: {{- toYaml . | nindent 4 }}
108+
{{- end }}
109+
type: Opaque
110+
stringData:
111+
ca.crt: {{ $caCert | quote }}
112+
{{- end -}}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{{- if .Values.nonDict -}}
2+
{{ include "cozy-lib.tls.caCertSecret" "not-a-dict" }}
3+
{{- end -}}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{{- if .Values.useParams -}}
2+
{{ include "cozy-lib.tls.caCertSecret" (dict
3+
"name" .Values.name
4+
"namespace" .Values.namespace
5+
"caCert" .Values.caCert
6+
) }}
7+
{{- end -}}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{{ include "cozy-lib.tls.caCertSecret" (dict
2+
"name" (printf "%s-ca-cert" .Release.Name)
3+
"namespace" .Release.Namespace
4+
"caCert" .Values.caCert
5+
"labels" (merge (dict "app.kubernetes.io/instance" .Release.Name) (default (dict) .Values.callerLabels))
6+
"annotations" (dict "cozystack.io/example" "yes")
7+
) }}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# ./tests/tls_cacert_test.yaml
2+
suite: tls caCertSecret helper
3+
4+
templates:
5+
- templates/tests/tls-cacert.yaml
6+
- templates/tests/tls-cacert-params.yaml
7+
- templates/tests/tls-cacert-nondict.yaml
8+
9+
release:
10+
name: myrelease
11+
namespace: tenant-acme
12+
revision: 1
13+
isUpgrade: false
14+
15+
tests:
16+
- it: renders an Opaque Secret carrying only ca.crt
17+
templates:
18+
- templates/tests/tls-cacert.yaml
19+
values:
20+
- tls_cacert_values.yaml
21+
asserts:
22+
- hasDocuments:
23+
count: 1
24+
- isKind:
25+
of: Secret
26+
- equal:
27+
path: apiVersion
28+
value: v1
29+
- equal:
30+
path: metadata.name
31+
value: myrelease-ca-cert
32+
- equal:
33+
path: metadata.namespace
34+
value: tenant-acme
35+
- equal:
36+
path: type
37+
value: Opaque
38+
- isNotNullOrEmpty:
39+
path: stringData["ca.crt"]
40+
# The trust anchor must never carry private key material. The helper
41+
# only ever emits stringData, so these are the meaningful assertions.
42+
- notExists:
43+
path: stringData["tls.key"]
44+
- notExists:
45+
path: stringData["tls.crt"]
46+
47+
- it: carries the tenantresource label so it is delivered via core.cozystack.io/tenantsecrets
48+
templates:
49+
- templates/tests/tls-cacert.yaml
50+
values:
51+
- tls_cacert_values.yaml
52+
asserts:
53+
# This label is the entire RBAC delivery mechanism: the tenantsecret
54+
# registry surfaces only Secrets bearing it, and the base tenant roles
55+
# grant read on core.cozystack.io/tenantsecrets (not raw core secrets).
56+
- equal:
57+
path: metadata.labels["internal.cozystack.io/tenantresource"]
58+
value: "true"
59+
60+
- it: merges caller-supplied labels and annotations
61+
templates:
62+
- templates/tests/tls-cacert.yaml
63+
values:
64+
- tls_cacert_values.yaml
65+
asserts:
66+
- equal:
67+
path: metadata.labels["app.kubernetes.io/instance"]
68+
value: myrelease
69+
- equal:
70+
path: metadata.annotations["cozystack.io/example"]
71+
value: "yes"
72+
73+
- it: keeps the mandatory tenantresource label even when a caller tries to override it
74+
templates:
75+
- templates/tests/tls-cacert.yaml
76+
values:
77+
- tls_cacert_values.yaml
78+
set:
79+
# A caller (or a compromised values override) attempting to opt the object
80+
# out of tenant delivery — or worse, flip the label onto a key-bearing
81+
# Secret — must not win. The helper's mandatory label always overrides.
82+
callerLabels:
83+
internal.cozystack.io/tenantresource: "false"
84+
asserts:
85+
- equal:
86+
path: metadata.labels["internal.cozystack.io/tenantresource"]
87+
value: "true"
88+
89+
- it: fails closed when caCert is empty
90+
templates:
91+
- templates/tests/tls-cacert.yaml
92+
set:
93+
caCert: ""
94+
asserts:
95+
- failedTemplate:
96+
errorMessage: "cozy-lib.tls.caCertSecret: caCert is required and must be a non-empty PEM"
97+
98+
- it: refuses to publish private key material to tenants
99+
templates:
100+
- templates/tests/tls-cacert.yaml
101+
set:
102+
caCert: |
103+
-----BEGIN EC PRIVATE KEY-----
104+
MHcCAQEEexampleprivatekeymaterialthatmustneverreachtenants00000000
105+
-----END EC PRIVATE KEY-----
106+
asserts:
107+
- failedTemplate:
108+
errorMessage: "cozy-lib.tls.caCertSecret: caCert must not contain private key material"
109+
110+
- it: refuses private key material regardless of PEM header casing
111+
templates:
112+
- templates/tests/tls-cacert.yaml
113+
set:
114+
# RFC 7468 mandates uppercase headers, but the guard must also catch
115+
# malformed / hand-pasted input where the casing differs.
116+
caCert: |
117+
-----begin private key-----
118+
MIIExampleLowercaseHeaderPrivateKeyMustStillBeRejected0000000000
119+
-----end private key-----
120+
asserts:
121+
- failedTemplate:
122+
errorMessage: "cozy-lib.tls.caCertSecret: caCert must not contain private key material"
123+
124+
- it: refuses a certificate bundle that also carries a private key block
125+
templates:
126+
- templates/tests/tls-cacert.yaml
127+
set:
128+
# The realistic accident: a full keypair pasted into the CA field. The
129+
# guard must scan the entire blob, not merely reject key-only input — a
130+
# valid leading certificate block must not let a trailing private key
131+
# slip through to a tenant-readable trust anchor.
132+
caCert: |
133+
-----BEGIN CERTIFICATE-----
134+
MIIBExampleCertificateBodyOnlyNoPrivateKeyHeaderPresent0000000000
135+
-----END CERTIFICATE-----
136+
-----BEGIN PRIVATE KEY-----
137+
MIIExamplePrivateKeyThatMustNeverReachTenantsViaCABundle00000000
138+
-----END PRIVATE KEY-----
139+
asserts:
140+
- failedTemplate:
141+
errorMessage: "cozy-lib.tls.caCertSecret: caCert must not contain private key material"
142+
143+
- it: rejects input that is not a PEM certificate
144+
templates:
145+
- templates/tests/tls-cacert.yaml
146+
set:
147+
caCert: |
148+
this is not a certificate at all
149+
asserts:
150+
- failedTemplate:
151+
errorMessage: "cozy-lib.tls.caCertSecret: caCert must contain a PEM certificate (BEGIN CERTIFICATE)"
152+
153+
- it: accepts a certificate even when the literal text PRIVATE KEY appears outside a PEM header
154+
templates:
155+
- templates/tests/tls-cacert-params.yaml
156+
set:
157+
useParams: true
158+
name: my-ca-cert
159+
namespace: tenant-acme
160+
# "PRIVATE KEY" here is in a friendlyName line, not a PEM header — a free
161+
# substring guard would wrongly reject this valid, key-free certificate.
162+
caCert: |
163+
Bag Attributes: friendlyName=acme PRIVATE KEY authority
164+
-----BEGIN CERTIFICATE-----
165+
MIIBExampleCertificateBodyOnlyNoPrivateKeyHeaderPresent0000000000
166+
-----END CERTIFICATE-----
167+
asserts:
168+
- isKind:
169+
of: Secret
170+
- isNotNullOrEmpty:
171+
path: stringData["ca.crt"]
172+
173+
- it: fails closed when name is empty
174+
templates:
175+
- templates/tests/tls-cacert-params.yaml
176+
set:
177+
useParams: true
178+
name: ""
179+
namespace: tenant-acme
180+
caCert: |
181+
-----BEGIN CERTIFICATE-----
182+
MIIBExampleCertificateBodyOnlyNoPrivateKeyHeaderPresent0000000000
183+
-----END CERTIFICATE-----
184+
asserts:
185+
- failedTemplate:
186+
errorMessage: "cozy-lib.tls.caCertSecret: name is required"
187+
188+
- it: omits metadata.namespace when namespace is not supplied
189+
templates:
190+
- templates/tests/tls-cacert-params.yaml
191+
set:
192+
useParams: true
193+
name: my-ca-cert
194+
caCert: |
195+
-----BEGIN CERTIFICATE-----
196+
MIIBExampleCertificateBodyOnlyNoPrivateKeyHeaderPresent0000000000
197+
-----END CERTIFICATE-----
198+
asserts:
199+
- equal:
200+
path: metadata.name
201+
value: my-ca-cert
202+
- notExists:
203+
path: metadata.namespace
204+
205+
- it: fails closed when invoked with a non-dict argument
206+
templates:
207+
- templates/tests/tls-cacert-nondict.yaml
208+
# A valid caCert keeps the other fixtures rendering cleanly so the only
209+
# failure is the non-dict guard on this fixture.
210+
values:
211+
- tls_cacert_values.yaml
212+
set:
213+
nonDict: true
214+
asserts:
215+
- failedTemplate:
216+
errorMessage: "cozy-lib.tls.caCertSecret: expected a single dict argument"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
caCert: |
2+
-----BEGIN CERTIFICATE-----
3+
MIIBExampleCACertificateOnlyNoPrivateKeyMaterialPresentHere00000000
4+
-----END CERTIFICATE-----

0 commit comments

Comments
 (0)