Four units read one path each out of the store, and all four logged in holding `deploy.bao.clientCertFile` — the hive's own leaf. Bao identifies a principal by the subject of the certificate it presents, so four readers behind one certificate were ONE principal, and the only grant expressible was the union of what the four need: read on `swarm/agents/*`, `swarm/hives/<hive>/*` and `swarm/services/*`. The unit fetching Grafana's OIDC client secret could fetch every agent credential in the swarm; the one fetching this hive's matrix token could fetch Grafana's. Least privilege was not misconfigured here, it was unrepresentable. Each now holds a leaf, a cert-auth role and a policy of its own, and each policy is the single `secret/data/…` path that unit's own script names — spelled to the leaf, not to a prefix, the way matrix-ctl's already is. Following the four exemplars in-tree rather than building a mechanism: `signLeaf` mints the leaves, `swarm-bao.nix` writes the roles from the bootstrap token, the consumers name their own pair. Two of the four are written PER HIVE and two are not, which is the shape of the paths rather than a preference. A matrix appservice token and a queue credential live under `swarm/hives/<name>/` and every hive runs a reader for its own, so one role for all of them would have to be granted `hives/*` — letting one hive read another's, a reach no hive has today. An OIDC client secret lives under `swarm/services/<client-id>/` and a swarm registers each exactly once, so one role each is enough. The per-hive subjects are `<prefix>-<hive>` and swarm.nix reserves every composed spelling as a hive name, so a hive cannot be named into another hive's role. The shared leaf stays: hive-c0re still passes it into its container, the `bao` CLI wrapper still defaults to it, and the three `glue-*-bao-identity.nix` files derive the PKI directory from it. module-eval-bao-grants gains a negative arm per principal — each pins the three stanzas the hive's leaf carried and the two wildcards a later widening would reach for, so a policy that grows fails here rather than in a store. Plus the consuming side: repointing a unit back at the hive's leaf would evaluate, deploy and log in, and silently restore the union. A hive that reads a store on another machine now places one leaf per principal instead of one shared by four. That cost is the point, and docs/swarm/secrets.md lists the pairs.
378 lines
18 KiB
Nix
378 lines
18 KiB
Nix
# `checks.module-eval-bao-matrix-reader` — see ./lib.nix for the shared
|
|
# rationale (why this suite exists, naming convention, "evaluates
|
|
# not executes").
|
|
{
|
|
pkgs,
|
|
lib,
|
|
self,
|
|
nixosSystem,
|
|
}:
|
|
let
|
|
inherit
|
|
(import ./lib.nix {
|
|
inherit
|
|
pkgs
|
|
lib
|
|
self
|
|
nixosSystem
|
|
;
|
|
})
|
|
hive
|
|
runGroup
|
|
;
|
|
|
|
# The store and a service that reads from it, versus the store alone. The
|
|
# pair is what makes the reader's absence arm mean anything.
|
|
baoWithMatrix = hive {
|
|
deploy.bao.enable = true;
|
|
deploy.matrix.enable = true;
|
|
};
|
|
|
|
# A hive that reads from a store it does not run: no `deploy.bao.enable`, so
|
|
# nothing here mints a leaf and the operator names one placed by hand. The
|
|
# deployment this pairing exists to serve, and the one that was previously
|
|
# inexpressible — the gate asked whether the store was a neighbour.
|
|
#
|
|
# 🩸 One leaf PER READER, and this fixture is where that cost is visible: the
|
|
# two units below identify themselves to the store separately, so an operator
|
|
# placing leaves by hand places one for each rather than one for both. Naming
|
|
# only `clientCertFile` here would leave neither unit rendered — which is what
|
|
# the arms below would then be asserting about.
|
|
baoRemoteReader = hive {
|
|
deploy.matrix.enable = true;
|
|
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
|
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
|
deploy.bao.matrixTokenClientCertFile = "/etc/pki/bao-matrix-token.pem";
|
|
deploy.bao.matrixTokenClientKeyFile = "/etc/pki/bao-matrix-token-key.pem";
|
|
deploy.bao.queueAgentClientCertFile = "/etc/pki/bao-queue-agent.pem";
|
|
deploy.bao.queueAgentClientKeyFile = "/etc/pki/bao-queue-agent-key.pem";
|
|
};
|
|
|
|
# A homeserver on a hive with NO store identity at all — neither a local
|
|
# store nor a hand-placed leaf. The absence arm for the matrix-ctl cases below
|
|
# needs it, and defining it here rather than importing keeps each group's
|
|
# fixture set its own, as ./lib.nix asks.
|
|
matrixNoBaoIdentity = hive { deploy.matrix.enable = true; };
|
|
cases = [
|
|
{
|
|
# A login failure is the store being unreachable, sealed, or not yet
|
|
# holding this host's role — all of which a retry fixes. A read that
|
|
# answers "nothing there" is not, so only the first is allowed to fail
|
|
# the unit.
|
|
name = "the matrix token reader retries a failed login and still degrades on an empty read";
|
|
ok =
|
|
let
|
|
u = baoWithMatrix.systemd.services.swarm-bao-matrix-token;
|
|
# Everything between the login's failure branch and the read's, which
|
|
# is where the exit that decides "retry or give up" lives.
|
|
afterLogin = lib.last (lib.splitString "bao login" u.script);
|
|
loginBranch = lib.head (lib.splitString "bao kv get" afterLogin);
|
|
in
|
|
u.serviceConfig.Restart or null == "on-failure"
|
|
&& u.startLimitBurst or 0 > 0
|
|
# The window has to outlast every attempt, or the burst is unreachable.
|
|
&& u.startLimitIntervalSec or 0 > (u.serviceConfig.RestartSec or 0) * (u.startLimitBurst or 0)
|
|
&& lib.hasInfix "exit 1" loginBranch
|
|
&& lib.hasInfix "exit 0" (lib.last (lib.splitString "bao kv get" u.script));
|
|
}
|
|
{
|
|
# The reader's own grant covers `swarm/hives/<this hive>/*` and
|
|
# `swarm/agents/*`; a path outside those answers 403, not "no such key".
|
|
# So the hive segment is what makes the read reachable, and a rename that
|
|
# drops it looks correct and fails identically on every boot.
|
|
name = "the matrix token path sits inside the prefix the reader is granted";
|
|
ok =
|
|
let
|
|
s = baoWithMatrix.systemd.services.swarm-bao-matrix-token.script;
|
|
in
|
|
lib.hasInfix "secret/swarm/hives/" s
|
|
&& lib.hasInfix "/matrix/appservice-token" s
|
|
# The shape it used to have: `matrix` where a principal kind belongs,
|
|
# which no grant covers.
|
|
&& !(lib.hasInfix "secret/swarm/matrix/" s);
|
|
}
|
|
{
|
|
# The store's second reader, and the gate that decides it exists is the
|
|
# certificate rather than anything about agents: containers are created
|
|
# at runtime, so there is no static "this hive runs agents" fact to ask.
|
|
name = "a hive that names a client identity reads its agent queue credential";
|
|
ok = baoRemoteReader.systemd.services ? swarm-bao-queue-agent;
|
|
}
|
|
{
|
|
# Same 403-not-a-miss reason as the matrix arm above, against the path
|
|
# `swarm_secret_client::queue::agent_client_path` builds from the same
|
|
# pieces. The negative arm is the rename this one is exposed to: a
|
|
# credential named for the queue rather than for the hive that presents
|
|
# it reads as correct and is refused on every boot.
|
|
name = "the agent queue credential path sits inside the prefix the reader is granted";
|
|
ok =
|
|
let
|
|
s = baoRemoteReader.systemd.services.swarm-bao-queue-agent.script;
|
|
in
|
|
lib.hasInfix "secret/swarm/hives/h1/queue/agent" s && !(lib.hasInfix "secret/swarm/queue/" s);
|
|
}
|
|
{
|
|
# The unit's output is the option's value, not a literal that agrees with
|
|
# it today: an operator moving the directory has to move both files. The
|
|
# prefix is asserted too because `hasInfix ""` is true — an option
|
|
# renamed out from under this arm would otherwise read empty and pass.
|
|
name = "the queue credential reader writes both files under the directory its option names";
|
|
ok =
|
|
let
|
|
m = baoRemoteReader;
|
|
dir = toString m.services.hyperhive.deploy.hive-controller.queue.agentCredentialDir;
|
|
s = m.systemd.services.swarm-bao-queue-agent.script;
|
|
in
|
|
lib.hasPrefix "/var/lib/" dir
|
|
&& lib.hasInfix "${dir}/secret" s
|
|
&& lib.hasInfix "${dir}/client_id" s;
|
|
}
|
|
{
|
|
# A reader off the store's host is a reader whose journal is the only
|
|
# record of why a hive's agents never connected, so the collector has to
|
|
# be told the unit exists. Nothing else can say it: the store's module
|
|
# does not know who holds a certificate.
|
|
name = "the queue credential reader's journal reaches the collector";
|
|
ok = builtins.elem "swarm-bao-queue-agent" baoRemoteReader.services.hyperhive.swarm.otel.journaldUnits;
|
|
}
|
|
{
|
|
# No agent container may render before this unit has had its attempts,
|
|
# and the edge that guarantees it must delay hive-c0re rather than sink
|
|
# it: an unreachable store is this unit's `Restart=on-failure` window,
|
|
# not a reason for the daemon that renders every agent to fail its own
|
|
# start.
|
|
name = "the queue credential reader orders before hive-c0re and is wanted, not required, by it";
|
|
ok =
|
|
let
|
|
u = baoRemoteReader.systemd.services.swarm-bao-queue-agent;
|
|
in
|
|
builtins.elem "hive-c0re.service" (u.before or [ ])
|
|
&& builtins.elem "hive-c0re.service" (u.wantedBy or [ ])
|
|
&& !(builtins.elem "hive-c0re.service" (u.requiredBy or [ ]))
|
|
&& !(builtins.elem "hive-c0re.service" (u.requires or [ ]));
|
|
}
|
|
{
|
|
# The store's first reader. Its unit belongs to the pairing, not to
|
|
# either service: matrix must not learn the store exists, and the store
|
|
# must not know who reads it.
|
|
name = "a store deployed beside the homeserver fetches its appservice token";
|
|
ok = baoWithMatrix.systemd.services ? swarm-bao-matrix-token;
|
|
}
|
|
{
|
|
name = "a hive that names a client identity reads from a store it does not run";
|
|
ok = baoRemoteReader.systemd.services ? swarm-bao-matrix-token;
|
|
}
|
|
{
|
|
# `Requires=` on a unit that does not exist fails the job, and nothing
|
|
# local mints certificates off-host — so this orders against nothing.
|
|
# Eval cannot see that failure; only the empty list here stands in for it.
|
|
name = "an off-host reader requires no unit the store's host would have provided";
|
|
# Membership first, then the value: indexing a missing unit throws, and a
|
|
# table that reports which property broke must not be the thing that dies.
|
|
ok =
|
|
let
|
|
s = baoRemoteReader.systemd.services;
|
|
in
|
|
s ? swarm-bao-matrix-token && s.swarm-bao-matrix-token.requires == [ ];
|
|
}
|
|
{
|
|
# Presence control for the case above: the list is conditional, not gone.
|
|
name = "a co-located reader still orders after the local pki unit";
|
|
ok =
|
|
let
|
|
s = baoWithMatrix.systemd.services;
|
|
in
|
|
s ? swarm-bao-matrix-token && s.swarm-bao-matrix-token.requires == [ "swarm-bao-pki.service" ];
|
|
}
|
|
{
|
|
# Nothing asserted this script before, which is how it kept a branch that
|
|
# named three states and threw away the only thing telling them apart. A
|
|
# missing value, a refused identity and an unreachable host all end in the
|
|
# same degraded mode here, correctly — what must survive is which one.
|
|
name = "the matrix token reader carries the store's own diagnostic into the journal";
|
|
ok =
|
|
let
|
|
s = baoWithMatrix.systemd.services.swarm-bao-matrix-token.script;
|
|
in
|
|
!(lib.hasInfix "2>/dev/null" s) && lib.hasInfix ''cat "''$err"'' s;
|
|
}
|
|
{
|
|
# hive-c0re runs as hive-core and the client key is `0600` root-owned
|
|
# inside a `0700` directory, so the identity reaches the daemon as a
|
|
# systemd credential and the environment names `%d` rather than the
|
|
# file. Both halves are asserted together because either alone is a
|
|
# daemon that fails at the TLS handshake, naming neither.
|
|
name = "a reader hands hive-c0re a store identity the daemon cannot open itself";
|
|
ok =
|
|
let
|
|
s = baoRemoteReader.systemd.services;
|
|
in
|
|
s ? hive-c0re
|
|
&& (s.hive-c0re.environment.BAO_CLIENT_CERT or null) == "%d/bao-client.pem"
|
|
&& (s.hive-c0re.environment.BAO_CLIENT_KEY or null) == "%d/bao-client-key.pem"
|
|
&& builtins.elem "bao-client.pem:/etc/pki/bao-client.pem" s.hive-c0re.serviceConfig.LoadCredential
|
|
&& builtins.elem "bao-client-key.pem:/etc/pki/bao-client-key.pem" s.hive-c0re.serviceConfig.LoadCredential;
|
|
}
|
|
{
|
|
# The CA is its own arm: absent means the system trust store, which is
|
|
# right for a deployment with a real CA and wrong for a self-signed one.
|
|
name = "a reader that names no store CA falls through to the system trust store";
|
|
ok =
|
|
let
|
|
s = baoRemoteReader.systemd.services;
|
|
in
|
|
s ? hive-c0re && !(s.hive-c0re.environment ? BAO_CACERT);
|
|
}
|
|
{
|
|
# Presence control for the arm above: the CA is conditional, not gone.
|
|
# Co-located, ./host-modules/glue-bao-tls.nix mints one and names it.
|
|
name = "a reader beside a self-signed store is given that store's CA";
|
|
ok =
|
|
let
|
|
s = baoWithMatrix.systemd.services;
|
|
in
|
|
s ? hive-c0re
|
|
&& (s.hive-c0re.environment.BAO_CACERT or null) == "%d/bao-ca.pem"
|
|
&& lib.any (c: lib.hasPrefix "bao-ca.pem:" c) s.hive-c0re.serviceConfig.LoadCredential;
|
|
}
|
|
{
|
|
# The same hole a third time, and the leaf whose absence is hardest to
|
|
# see from outside: it is consumed by a unit INSIDE a container, so a
|
|
# missing pairing renders as a container that comes up fine and publishes
|
|
# nothing.
|
|
name = "the store mints a leaf for matrix-ctl, and the container is pointed at it";
|
|
ok =
|
|
let
|
|
m = baoWithMatrix;
|
|
p = m.services.hyperhive.deploy.matrix;
|
|
in
|
|
lib.hasInfix "matrix-ctl.pem" m.systemd.services.swarm-bao-pki.script
|
|
&& p.ctlBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-ctl.pem"
|
|
&& p.ctlBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-ctl-key.pem";
|
|
}
|
|
{
|
|
# 🩸 The identity separation this whole arrangement buys, stated as the
|
|
# one thing that would silently undo it. The container gets MATRIX-CTL's
|
|
# leaf — whose grant is a single path — and not the hive's, which reads
|
|
# every secret in the store. Both files exist in the same directory and
|
|
# both would evaluate, deploy and work.
|
|
name = "matrix-ctl presents its own leaf, never the hive's store-wide one";
|
|
ok =
|
|
let
|
|
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment;
|
|
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
|
|
in
|
|
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf;
|
|
}
|
|
{
|
|
# The bind mount is what makes the environment above resolvable: without
|
|
# it the unit names two paths the container does not have, and fails at
|
|
# the TLS handshake naming no cause. Read off the mount table rather than
|
|
# the option, so a pairing that stops reaching `bindMounts` still fails.
|
|
#
|
|
# The second arm is the shape guard: `bindMounts` is one literal plus two
|
|
# merges, and a rewrite that dropped the appservice registration would
|
|
# take the homeserver's own credential with it.
|
|
name = "the matrix container binds matrix-ctl's PKI read-only, without losing the appservice registration";
|
|
ok =
|
|
let
|
|
mounts = baoWithMatrix.containers.hive-matrix.bindMounts;
|
|
in
|
|
mounts ? "/var/lib/swarm-bao-pki"
|
|
&& mounts."/var/lib/swarm-bao-pki".isReadOnly
|
|
&& mounts ? "/var/lib/hyperhive/matrix-appservice";
|
|
}
|
|
{
|
|
# What the unit is for, read as the two agreements it cannot get wrong:
|
|
# the cert role ./host-modules/swarm-bao.nix writes, and a homeserver
|
|
# address that is loopback because the container shares the host netns. A
|
|
# vhost here would be a request out through the gateway and back.
|
|
name = "matrix-ctl is handed the store role and the loopback homeserver";
|
|
ok =
|
|
let
|
|
m = baoWithMatrix;
|
|
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
|
|
port = m.services.hyperhive.swarm.matrix.httpPort;
|
|
in
|
|
u.environment.MATRIX_MINT_CERT_ROLE == "swarm-matrix-ctl"
|
|
&& u.environment.MATRIX_MINT_API_URL == "http://127.0.0.1:${toString port}"
|
|
&& u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
|
|
&& u.serviceConfig.Type == "oneshot";
|
|
}
|
|
{
|
|
# 🩸 The per-hive minting identity, read off the rendered unit rather than
|
|
# off the option: the binary builds its store path out of
|
|
# `MATRIX_MINT_HIVE` and logs in as `MATRIX_MINT_LOCALPART`, so a unit
|
|
# that passed the old bare `hive` would publish one identity for the
|
|
# whole swarm again and nothing in the Rust tests could see it. Both
|
|
# spellings are pinned, and the localpart is pinned as *derived from* the
|
|
# hive name rather than as a literal, which is the agreement
|
|
# `swarm_secret_client::matrix::hive_localpart` owns.
|
|
name = "matrix-ctl is told which hive it mints for, and acts as that hive's account";
|
|
ok =
|
|
let
|
|
m = baoWithMatrix;
|
|
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
|
|
hive = m.services.hyperhive.hiveName;
|
|
in
|
|
u.environment.MATRIX_MINT_HIVE == hive
|
|
&& u.environment.MATRIX_MINT_LOCALPART == "hive-${hive}"
|
|
&& u.environment.MATRIX_MINT_LOCALPART != "hive";
|
|
}
|
|
{
|
|
# 🩸 The crate is a `*ctl` with subcommands, so the unit has to name a
|
|
# VERB. This is the one end of that contract nix owns: the binary's own
|
|
# test pins how `mint` is spelled, but only a rendered `ExecStart` can
|
|
# say the unit actually passes it. A bare invocation exits non-zero with
|
|
# clap's usage — which is a deploy-time failure with no local signal, and
|
|
# exactly what the next verb added here is most likely to disturb.
|
|
name = "the unit invokes a verb rather than the bare binary";
|
|
ok =
|
|
let
|
|
exec =
|
|
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.serviceConfig.ExecStart;
|
|
in
|
|
lib.hasSuffix "/bin/swarm-matrix-ctl mint" exec;
|
|
}
|
|
{
|
|
# 🩸 A secret is a path, never a value — checked on the one unit in this
|
|
# tree whose whole job is an `as_token`. Every variable it is given names
|
|
# a file or an address; the token itself is read out of the bind-mounted
|
|
# registration at runtime, so nothing here can be a token and an
|
|
# environment block is world-readable through `systemctl show`.
|
|
name = "matrix-ctl's environment carries paths and addresses, never a token";
|
|
ok =
|
|
let
|
|
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment;
|
|
in
|
|
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
|
|
}
|
|
{
|
|
# The absence arm, and the deployment it protects: a homeserver on a hive
|
|
# with no store identity at all. Without it the unit would exist naming
|
|
# `null` as its certificate, which nixos renders as the literal string.
|
|
name = "a matrix container with no store identity runs no matrix-ctl and binds no PKI";
|
|
ok =
|
|
let
|
|
units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services;
|
|
in
|
|
!(units ? swarm-matrix-ctl)
|
|
&& !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
|
|
}
|
|
{
|
|
# 🩸 The privilege arm of the credential this slice publishes: the
|
|
# account it belongs to must not be a homeserver admin. Read on the
|
|
# rendered homeserver settings rather than on an option, because the
|
|
# grant was never an option — it was a boot command in `admin_execute`,
|
|
# and a command list is exactly the shape a later edit re-adds without
|
|
# anything noticing.
|
|
name = "the homeserver promotes no account to admin at boot";
|
|
ok =
|
|
let
|
|
g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global;
|
|
in
|
|
!(g ? admin_execute) || g.admin_execute == [ ];
|
|
}
|
|
];
|
|
in
|
|
runGroup "bao-matrix-reader" cases
|