test(nats): pin the tenant-CA anchor and key-bearing Secret exclusion

The ResourceDefinition and the dashboard Role are the tenant-facing half of
the trust-anchor delivery path, and neither is a chart template, so the
helm-unittest suite covers neither. Deleting the tenant-CA selector or
granting a key-bearing Secret by name both left the suite fully green.

nats-<name>-ca holds the CA private key and nats-<name>-tls the server key.
Granting either to a tenant would let the holder issue certificates rather
than merely verify them. What a client needs is ca.crt alone, delivered as
the key-free <release>.tenant-ca projection and selected by label, since a
label is stamped only on an object the platform produced while a name grant
conveys whatever occupies the name.

The include set is asserted as a whitelist so a key-bearing Secret cannot
slip in under a spelling the test did not anticipate.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
Assisted-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aleksei Sviridkin authored and Aleksei Sviridkin committed 2026-08-12 18:42:13 +03:00
1 parent 1af3fde88d
commit 7e26358817
3 files changed
+258 -18

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bats
# Unit test: the nats trust anchor reaches tenants as a key-free projection, and
# the two key-BEARING Secrets the chart creates never do.
#
# The chart issues nats-<name>-ca (CA certificate AND CA private key, the latter
# under tls.key) and nats-<name>-tls (server certificate AND server private key).
# Neither may be handed to a tenant: read access to the CA key would let the
# holder issue certificates for anything rather than merely verify the server.
#
# What a client actually needs is ca.crt alone. That is delivered by the
# CA-extraction controller as the key-free <release>.tenant-ca projection,
# selected here by LABEL rather than by name. The label is stamped only on an
# object the controller itself produced, whereas a name grant conveys whatever
# happens to occupy the name.
#
# These assertions exist because both halves are one edit away from breaking
# with a fully green chart suite: helm-unittest covers the chart templates, and
# neither the ResourceDefinition nor the dashboard Role is a chart template.
#
# The grants are asserted as WHITELISTS against rendered output rather than as
# a blacklist of forbidden names over source text. A blacklist grepping for
# "{{ .Release.Name }}-ca" is defeated by quoting the scalar, by $.Release.Name,
# or by a fullname helper -- all of which grant the CA private key while the
# guard stays green.
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
COZYRDS="$REPO_ROOT/packages/system/nats-rd/cozyrds/nats.yaml"
CHART="$REPO_ROOT/packages/apps/nats"
# Secret names the tenant is allowed to hold by NAME. Credentials only: the
# trust anchor is reached by label, through the tenantsecrets API.
EXPECTED_RD_NAMES='nats-{{ .name }}-credentials'
EXPECTED_ROLE_NAMES='nats-test-credentials'
@test "nats-rd cozyrds selects the key-free tenant-CA projection by label" {
if [ ! -f "$COZYRDS" ]; then
echo "ResourceDefinition not found at $COZYRDS -- did it move?" >&2
exit 1
fi
count=$(yq eval \
'[.spec.secrets.include[] | select(.matchLabels."internal.cozystack.io/tenant-ca" == "true")] | length' \
"$COZYRDS") || exit 1
if [ "$count" -lt 1 ]; then
echo 'nats-rd does not select internal.cozystack.io/tenant-ca: "true"' >&2
echo "Without it the tenant cannot read ca.crt and TLS verification is impossible." >&2
exit 1
fi
}
@test "nats-rd cozyrds grants no Secret by name except the credentials Secret" {
if [ ! -f "$COZYRDS" ]; then
echo "ResourceDefinition not found at $COZYRDS -- did it move?" >&2
exit 1
fi
names=$(yq eval \
'[.spec.secrets.include[].resourceNames // [] | .[]] | sort | join(",")' \
"$COZYRDS") || exit 1
if [ "$names" != "$EXPECTED_RD_NAMES" ]; then
echo "Unexpected resourceNames in nats-rd spec.secrets.include: $names" >&2
echo "Expected exactly: $EXPECTED_RD_NAMES" >&2
echo "nats-<name>-ca holds the CA private key and nats-<name>-tls the server key;" >&2
echo "neither may be granted to a tenant." >&2
exit 1
fi
}
# Asserted against RENDERED output, so the spelling used in the template --
# quoted, helper-derived, or otherwise -- cannot smuggle a name past the check.
@test "nats dashboard Role grants no Secret by name except the credentials Secret" {
if [ ! -d "$CHART" ]; then
echo "Chart not found at $CHART -- did it move?" >&2
exit 1
fi
# stderr is deliberately not discarded: when the render breaks, helm's own
# message is the only thing that says why, and a CI red showing just
# "helm template failed" sends the reader back here to rerun it by hand.
rendered=$(helm template nats-test "$CHART" \
--namespace tenant-test \
--set '_cluster.cluster-domain=cozy.local' \
--show-only templates/dashboard-resourcemap.yaml) || {
echo "helm template failed for $CHART" >&2
exit 1
}
names=$(printf '%s\n' "$rendered" | yq eval \
'[select(.kind == "Role") | .rules[] | select(.resources[] == "secrets") | .resourceNames[]] | sort | join(",")' \
-) || exit 1
# Fail closed: an empty result means the Role stopped granting Secrets at all,
# or the render silently produced nothing. Either way this guard is no longer
# observing what it claims to observe.
if [ -z "$names" ]; then
echo "No secrets grant found in the rendered dashboard Role -- guard is blind." >&2
exit 1
fi
if [ "$names" != "$EXPECTED_ROLE_NAMES" ]; then
echo "Unexpected Secret grants in the nats dashboard Role: $names" >&2
echo "Expected exactly: $EXPECTED_ROLE_NAMES" >&2
echo "nats-<release>-ca holds the CA PRIVATE KEY and nats-<release>-tls the" >&2
echo "server private key. The trust anchor reaches the tenant as the key-free" >&2
echo "<release>.tenant-ca projection via tenantsecrets, not by a name grant." >&2
exit 1
fi
}
+33 -18
View File
@@ -139,32 +139,47 @@ tests:
value: my-nats-ca
###################################################
# CA Secret carries trust-anchor publish labels #
# Key-bearing Secrets stay invisible to tenants #
###################################################
- it: CA certificate publishes ca.crt to tenants via secretTemplate labels
# Both Certificates below mint a private key: the CA Secret stores the CA key
# under tls.key, the leaf Secret the server key. The nats ResourceDefinition
# grants tenants read access to Secrets matching
# internal.cozystack.io/tenant-ca, so stamping either visibility label here
# would hand that key to every tenant subject. Only the controller-authored
# projection may carry them.
#
# Selected by the Secret each Certificate writes rather than by position: a
# documentIndex would follow the slot, so reordering this template would let
# a labelled key-bearing Certificate slide out from under the assertion and
# pass green.
- it: the key-bearing CA Secret is never marked tenant-readable
set:
tls:
enabled: true
external: false
documentIndex: 1
asserts:
- equal:
path: spec.secretTemplate.labels["internal.cozystack.io/publish-ca-cert"]
value: "true"
- equal:
path: spec.secretTemplate.labels["internal.cozystack.io/publish-ca-cert-release"]
value: my-nats
- it: leaf certificate does NOT carry publish-ca-cert labels
set:
tls:
enabled: true
external: false
documentIndex: 3
documentSelector:
path: spec.secretName
value: my-nats-ca
asserts:
- notExists:
path: spec.secretTemplate.labels["internal.cozystack.io/publish-ca-cert"]
path: spec.secretTemplate.labels["internal.cozystack.io/tenant-ca"]
- notExists:
path: spec.secretTemplate.labels["internal.cozystack.io/tenantresource"]
- it: the key-bearing leaf Secret is never marked tenant-readable
set:
tls:
enabled: true
external: false
documentSelector:
path: spec.secretName
value: my-nats-tls
asserts:
- notExists:
path: spec.secretTemplate.labels["internal.cozystack.io/tenant-ca"]
- notExists:
path: spec.secretTemplate.labels["internal.cozystack.io/tenantresource"]
###################################################
# SAN coverage (internal, tls.enabled=true) #
@@ -0,0 +1,115 @@
suite: nats TenantProjection sentinel
# The chart renders one TenantProjection per TLS-enabled release: the
# declaration that tells the CA-extraction controller to lift ca.crt out of the
# key-bearing <release>-ca into the tenant-readable <release>.tenant-ca
# projection.
#
# The load-bearing invariant is the ABSENCE of ownerReferences. The sentinel must
# carry none — it owns the projection, and it is itself owned only through the
# helm.toolkit.fluxcd.io/name label Flux stamps, which is how the lineage webhook
# resolves the application. An ownerReference here would short-circuit that walk
# and silently break the tenant's read path (pkg/lineage/lineage.go).
release:
name: my-nats
namespace: tenant-test
templates:
- templates/tenant-projection.yaml
tests:
- it: renders exactly one TenantProjection named after the release
set:
tls:
enabled: true
asserts:
- hasDocuments:
count: 1
- isKind:
of: TenantProjection
- equal:
path: apiVersion
value: internal.cozystack.io/v1alpha1
- equal:
path: metadata.name
value: my-nats-ca
- it: declares a CACert projection of the chart's CA
set:
tls:
enabled: true
asserts:
- equal:
path: spec.projections[0].type
value: CACert
- equal:
path: spec.projections[0].sourceSecretName
value: my-nats-ca
- equal:
path: spec.projections[0].sourceKey
value: ca.crt
# The one load-bearing invariant: an ownerReference here would short-circuit
# the lineage walk before it reaches the helm-label fallback, so the tenant
# would silently lose the anchor.
- it: carries no ownerReferences, so the lineage walk reaches the helm label
set:
tls:
enabled: true
asserts:
- notExists:
path: metadata.ownerReferences
# Not an invariant — conformance to the shape the merged engines share. An
# explicit namespace would pin the sentinel outside the release, and chart
# labels would compete with the Flux label that is the whole attribution
# path. Pinned separately so a failure here is not read as a lineage break.
- it: omits namespace and chart-added labels, matching the shared shape
set:
tls:
enabled: true
asserts:
- notExists:
path: metadata.namespace
- notExists:
path: metadata.labels
# The nats CA exists only while TLS is on. An ungated sentinel would sit
# Ready=False/SourceNotFound forever on every plaintext release.
#
# The gate must track the chart's effective TLS state, not the raw
# tls.enabled value: leaving it unset means "follow external". A naive
# `if .Values.tls.enabled` still passes the two explicit cases below while
# silently publishing no anchor to exactly the releases most likely to need
# one, so both implied legs are pinned too.
- it: renders the sentinel when TLS is implied by external access
set:
external: true
asserts:
- hasDocuments:
count: 1
- it: renders nothing when TLS is implied off by external being false
set:
external: false
asserts:
- hasDocuments:
count: 0
- it: renders nothing when TLS is explicitly disabled despite external access
set:
external: true
tls:
enabled: false
asserts:
- hasDocuments:
count: 0
- it: renders nothing when TLS is disabled
set:
tls:
enabled: false
asserts:
- hasDocuments:
count: 0