mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-13 08:05:19 +09:00
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:
1 parent
1af3fde88d
commit
7e26358817
3 files changed
+258
-18
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user