diff --git a/hack/check-nats-rd-secrets.bats b/hack/check-nats-rd-secrets.bats new file mode 100644 index 000000000..f4853f823 --- /dev/null +++ b/hack/check-nats-rd-secrets.bats @@ -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--ca (CA certificate AND CA private key, the latter +# under tls.key) and nats--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 .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--ca holds the CA private key and nats--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--ca holds the CA PRIVATE KEY and nats--tls the" >&2 + echo "server private key. The trust anchor reaches the tenant as the key-free" >&2 + echo ".tenant-ca projection via tenantsecrets, not by a name grant." >&2 + exit 1 + fi +} diff --git a/packages/apps/nats/tests/certmanager_test.yaml b/packages/apps/nats/tests/certmanager_test.yaml index 4e83bc4a0..642f22084 100644 --- a/packages/apps/nats/tests/certmanager_test.yaml +++ b/packages/apps/nats/tests/certmanager_test.yaml @@ -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) # diff --git a/packages/apps/nats/tests/tenant_projection_test.yaml b/packages/apps/nats/tests/tenant_projection_test.yaml new file mode 100644 index 000000000..206d6daf3 --- /dev/null +++ b/packages/apps/nats/tests/tenant_projection_test.yaml @@ -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 -ca into the tenant-readable .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