swarm-bao: give the store forwarder's OIDC reader its own bao identity

`swarm-bao-forwarder-oidc` fetches the store container's collector secret,
one path, and was the last reader still logging in with
`deploy.bao.clientCertFile`: the hive's own leaf, whose policy reads every
agent's credentials, the hive's tree and every service's OIDC secret. The
four-way split gave grafana's and the swarm collector's readers leaves of
their own and left this one behind.

It now holds `forwarder-oidc.pem`, minted by `swarm-bao-pki`, and logs in
under the `swarm-forwarder-oidc` cert-auth role, whose policy reads
`secret/data/swarm/services/<store forwarder client id>/oidc/client` and
nothing else. The role is written by `swarm-bao-forwarder-oidc-policy`
from the bootstrap token, which gains the two grants that unit calls, and
the reader is ordered after it. The subject is reserved as a hive name. A
store host whose pair is null is refused at eval rather than falling back
to the hive's leaf.

The hive's own role and `client.pem` are untouched; nothing is revoked.
This commit is contained in:
atlas 2026-09-24 17:25:34 +02:00 • committed by mara
commit 92e1909caf
9 changed files with 229 additions and 47 deletions

View file

@ -67,6 +67,14 @@ let
deploy.bao.queueAgentClientKeyFile = lib.mkForce null;
};
# The store with the forwarder's own pair taken away. The forwarder renders
# wherever the store does, so this is the deployment the assertion refuses.
baoNoForwarderIdentity = hive {
deploy.bao.enable = true;
deploy.bao.forwarderOidcClientCertFile = lib.mkForce null;
deploy.bao.forwarderOidcClientKeyFile = lib.mkForce null;
};
# All four readers against a store they do not run, each with a leaf placed
# by hand. The deployment in which there is no local policy unit to wait for.
baoRemoteReaders = hive {
@ -454,6 +462,26 @@ let
&& !(lib.hasInfix "swarm-grafana" s)
&& !(lib.hasInfix "sys/policies/acl" s);
}
{
# The fifth, and the one that stayed on the hive's leaf longest: the
# store's own forwarder. Its client id is `swarm-bao-collector`, so the
# arm naming `swarm-collector/` is the swarm collector's secret, which
# this principal is not entitled to.
name = "the store forwarder's OIDC reader's grant is its own client secret and nothing else";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-forwarder-oidc-policy.script;
in
lib.hasInfix "path \"secret/data/swarm/services/swarm-bao-collector/oidc/client\" {" s
&& lib.hasInfix "capabilities = [\"read\"]" s
&& lib.length (lib.filter lib.isList (builtins.split "path \"" s)) == 1
&& !(lib.hasInfix "secret/data/swarm/agents" s)
&& !(lib.hasInfix "secret/data/swarm/hives" s)
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
&& !(lib.hasInfix "services/swarm-collector/" s)
&& !(lib.hasInfix "swarm-grafana" s)
&& !(lib.hasInfix "sys/policies/acl" s);
}
{
# 🩸 The half that makes the policies above bind: a policy grants only
# through a token that carries it, and a token is minted by a cert-auth
@ -463,7 +491,7 @@ let
# The per-hive subjects carry the hive name because their paths do; the
# two service subjects do not, because an OIDC client is registered once
# per swarm. Pinned so neither shape is tidied into the other.
name = "each of the four readers logs in under a subject of its own";
name = "each of the five readers logs in under a subject of its own";
ok =
let
subjectOf =
@ -481,7 +509,8 @@ let
subjectOf "swarm-bao-matrix-token-policy" "swarm-matrix-token-h1" "swarm-bao-matrix-token-h1"
&& subjectOf "swarm-bao-queue-agent-policy" "swarm-queue-agent-h1" "swarm-bao-queue-agent-h1"
&& subjectOf "swarm-bao-grafana-oidc-policy" "swarm-grafana-oidc" "swarm-bao-grafana-oidc"
&& subjectOf "swarm-bao-otel-oidc-policy" "swarm-otel-oidc" "swarm-bao-otel-oidc";
&& subjectOf "swarm-bao-otel-oidc-policy" "swarm-otel-oidc" "swarm-bao-otel-oidc"
&& subjectOf "swarm-bao-forwarder-oidc-policy" "swarm-forwarder-oidc" "swarm-bao-forwarder-oidc";
}
{
# 🩸 The consuming side, and the arm that would catch the regression that
@ -492,7 +521,7 @@ let
#
# Each pair is asserted whole: a certificate with no key authenticates
# nothing, so a half-set pair is a reader that does not render.
name = "each of the four readers presents its own leaf, never the hive's";
name = "each of the five readers presents its own leaf, never the hive's";
ok =
let
b = baoGrantWithConsumers.services.hyperhive.deploy.bao;
@ -509,6 +538,8 @@ let
b.grafanaOidcClientKeyFile
b.otelOidcClientCertFile
b.otelOidcClientKeyFile
b.forwarderOidcClientCertFile
b.forwarderOidcClientKeyFile
];
envOf = unit: baoGrantWithConsumers.systemd.services.${unit}.environment;
presents =
@ -521,7 +552,8 @@ let
&& presents "swarm-bao-matrix-token" b.matrixTokenClientCertFile b.matrixTokenClientKeyFile
&& presents "swarm-bao-queue-agent" b.queueAgentClientCertFile b.queueAgentClientKeyFile
&& presents "swarm-bao-grafana-oidc" b.grafanaOidcClientCertFile b.grafanaOidcClientKeyFile
&& presents "swarm-bao-otel-oidc" b.otelOidcClientCertFile b.otelOidcClientKeyFile;
&& presents "swarm-bao-otel-oidc" b.otelOidcClientCertFile b.otelOidcClientKeyFile
&& presents "swarm-bao-forwarder-oidc" b.forwarderOidcClientCertFile b.forwarderOidcClientKeyFile;
}
{
# The minting side of the same claim. A role matching a subject nothing
@ -547,6 +579,8 @@ let
"swarm-bao-grafana-oidc \"\" clientAuth"
"/otel-oidc.pem ]"
"swarm-bao-otel-oidc \"\" clientAuth"
"/forwarder-oidc.pem ]"
"swarm-bao-forwarder-oidc \"\" clientAuth"
];
}
{
@ -555,7 +589,7 @@ let
# still asserted, exactly as the three service principals above behave in
# this deployment. A unit that vanished here would take the policy with
# it and leave nothing to diagnose.
name = "with no client CA the four readers get policies but no login roles";
name = "with no client CA the five readers get policies but no login roles";
ok =
let
units = [
@ -563,6 +597,7 @@ let
"swarm-bao-queue-agent-policy"
"swarm-bao-grafana-oidc-policy"
"swarm-bao-otel-oidc-policy"
"swarm-bao-forwarder-oidc-policy"
];
scriptOf = unit: baoGrantNoClientCa.systemd.services.${unit}.script;
in
@ -578,7 +613,7 @@ let
# client certificate and the host is the side that has one, so a unit
# rendered inside the store's container would have neither an identity
# nor a route. Plus the ordering that makes the mounts exist first.
name = "the four readers' granting units are ordered after the mounts and rendered on the host";
name = "the five readers' granting units are ordered after the mounts and rendered on the host";
ok =
let
units = [
@ -586,6 +621,7 @@ let
"swarm-bao-queue-agent-policy"
"swarm-bao-grafana-oidc-policy"
"swarm-bao-otel-oidc-policy"
"swarm-bao-forwarder-oidc-policy"
];
in
lib.all (
@ -598,7 +634,11 @@ let
# The other end of those units: each reader logs in against the role its
# own policy unit writes, so it has to wait for that unit. Ordering and
# never a requirement, since the policy unit skips once the token is gone.
name = "each of the four readers is ordered after the unit writing its role";
#
# The forwarder is listed apart from `policyReaders`: it renders wherever
# the store does, so it is never absent on a store host and never present
# on a remote one, and the two cases below would fail on it for that.
name = "each of the five readers is ordered after the unit writing its role";
ok =
let
s = baoGrantWithConsumers.systemd.services;
@ -611,7 +651,7 @@ let
&& lib.elem policy s.${reader}.wants
&& !(lib.elem policy s.${reader}.requires);
in
lib.all waitsFor policyReaders;
lib.all waitsFor (policyReaders ++ [ "swarm-bao-forwarder-oidc" ]);
}
{
# The ordering is set apart from each reader's own definition, so it can
@ -643,7 +683,7 @@ let
# `baoGrantNoStore` makes for the controller's, one file over. Without
# this arm `lib.mkIf haveBootstrapToken` could be dropped from the shared
# builder and every other case here would still pass.
name = "without a bootstrap token none of the four readers' granting units render";
name = "without a bootstrap token none of the five readers' granting units render";
ok =
let
s = baoGrantNoStore.systemd.services;
@ -651,7 +691,27 @@ let
!(s ? swarm-bao-matrix-token-policy)
&& !(s ? swarm-bao-queue-agent-policy)
&& !(s ? swarm-bao-grafana-oidc-policy)
&& !(s ? swarm-bao-otel-oidc-policy);
&& !(s ? swarm-bao-otel-oidc-policy)
&& !(s ? swarm-bao-forwarder-oidc-policy);
}
{
# 🩸 The refusal half of the forwarder's own leaf. It renders wherever the
# store does and has no mode without a secret, so a null pair has one
# fallback left — the hive's leaf and its union grant. Refused at eval,
# with both options named.
name = "a store host without the forwarder's own pair is refused, naming both options";
ok =
let
refused = lib.filter (a: !a.assertion) baoNoForwarderIdentity.assertions;
names =
a:
lib.hasInfix "services.hyperhive.deploy.bao.forwarderOidcClientCertFile" a.message
&& lib.hasInfix "services.hyperhive.deploy.bao.forwarderOidcClientKeyFile" a.message;
in
lib.any names refused
# The control: the same store with the pair in place trips no such
# assertion, so the arm above is not firing on every store host.
&& !(lib.any (a: !a.assertion && names a) baoGrantHere.assertions);
}
{
# The policy authorising this route lives in another file, and nothing
@ -760,7 +820,7 @@ let
{
# What makes the case above mean something: discovery by token path
# reaches every unit that uses the token today, and each yields calls.
name = "the bootstrap-policy check sees all nine units that use the token, and parses calls from each";
name = "the bootstrap-policy check sees all ten units that use the token, and parses calls from each";
ok =
lib.all (n: bootstrapUnits ? ${n}) [
"swarm-bao-controller-policy"
@ -770,6 +830,7 @@ let
"swarm-bao-queue-agent-policy"
"swarm-bao-grafana-oidc-policy"
"swarm-bao-otel-oidc-policy"
"swarm-bao-forwarder-oidc-policy"
"swarm-bao-services-issuer-policy"
"swarm-bao-nats-tls-policy"
]

View file

@ -382,7 +382,7 @@ let
}
{
# Same 403-not-a-miss property the grafana and matrix readers are pinned
# for: the reader's grant covers the `services` prefix, so a secret
# for: the reader's grant is one path under `services`, so a secret
# filed under the hive that happens to run the store would be refused
# rather than missing, however correct the path reads.
name = "the forwarder's secret is read from the prefix the publisher writes";

View file

@ -76,6 +76,13 @@ let
swarm.hives.otctl.domain = "o.t.local";
};
# The store forwarder's OIDC reader, the fifth fixed subject.
hiveNamedAfterForwarderOidcSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.forwarderOidcCommonName = "fwctl";
swarm.hives.fwctl.domain = "f.t.local";
};
# 🩸 A different shape from every fixture above: the matrix-token and
# queue-credential roles are written PER HIVE, so the subject a hive must not
# be is `<prefix>-<some hive's name>` rather than the prefix itself. Reserving
@ -157,6 +164,15 @@ let
a: !a.assertion && lib.hasInfix "'otctl'" a.message
) hiveNamedAfterOtelOidcSubject.assertions;
}
{
# And the store forwarder's, for the same reason one element later.
name = "a hive named after the store forwarder's OIDC-reader subject is refused too";
ok =
equalityGuardFired hiveNamedAfterForwarderOidcSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'fwctl'" a.message
) hiveNamedAfterForwarderOidcSubject.assertions;
}
{
# 🩸 The per-hive half, and the one a prefix-only reservation would miss:
# the role is `<prefix>-<hive>`, so the reserved string has to be composed