swarm: OIDC clients registered wherever authelia runs; nats + matrix secrets from bao
The three client registrations move into glue-{controller,nats,matrix}-oidc-client.nix,
gated only on deploy.authelia.enable and reading their ids from swarm.* options,
so the host running authelia registers every one of them whichever host runs
the service. The matrix callback is a new read-only swarm.matrix.sso.redirectUri,
read by both the homeserver's config and its client entry.
swarm-secret-publish now also publishes the queue's and the homeserver's minted
secrets to swarm/services/<id>/oidc/client. The queue host reads its responder's
secret there with swarm-bao-nats-auth-oidc under the responder's own leaf
(swarm-nats-auth role gains that one read path), and the homeserver's host with
swarm-bao-matrix-oidc under matrix-ctl's leaf (swarm-matrix-ctl role gains its
one read path). The host-file copies are gone: swarm-nats-auth-secrets now
delivers only the callout seeds and the store identity, and
hive-matrix-oidc-secret is removed. A fetch unit whose leaf is unset still
renders and fails naming the option pair.
Closes #4943
This commit is contained in:
parent
72d0d8ccba
commit
962da3b784
20 changed files with 702 additions and 293 deletions
|
|
@ -98,6 +98,14 @@ let
|
|||
deploy.swarm-otel.enable = true;
|
||||
};
|
||||
|
||||
# The store and the homeserver on one host: the fetch of the homeserver's
|
||||
# OIDC secret logs in under matrix-ctl's leaf, whose role this host writes.
|
||||
baoGrantWithHomeserver = hive {
|
||||
deploy.bao.enable = true;
|
||||
deploy.bao.bootstrapTokenFile = "/run/secrets/bao-bootstrap.token";
|
||||
deploy.matrix.enable = true;
|
||||
};
|
||||
|
||||
# The store with none of the readers beside it. Grafana and the collector are
|
||||
# simply off; the queue reader renders on any host holding
|
||||
# its leaf, which ./glue-bao-tls.nix mints here, so that leaf is taken away.
|
||||
|
|
@ -426,23 +434,26 @@ let
|
|||
);
|
||||
}
|
||||
{
|
||||
# The third principal's grant, and the narrowest of the three: ONE path,
|
||||
# spelled to the leaf. The negative arms are the property — a homeserver
|
||||
# is not entitled to overwrite Grafana's OIDC client, so widening this to
|
||||
# the `services/` prefix the publisher holds would be a real loss even
|
||||
# though it would read as tidier.
|
||||
# The third principal's grant: two paths, each spelled to the leaf. The
|
||||
# negative arms are the property — a homeserver is not entitled to
|
||||
# overwrite Grafana's OIDC client, so widening this to the `services/`
|
||||
# prefix the publisher holds would be a real loss even though it would
|
||||
# read as tidier.
|
||||
#
|
||||
# The leaf is the swarm appservice token, which matrix-ctl mints and
|
||||
# publishes for the controller. Counted, so a second stanza fails rather
|
||||
# than riding along beside a correct one.
|
||||
name = "matrix-ctl's grant is the swarm appservice token, nothing else";
|
||||
# The first leaf is the swarm appservice token, which matrix-ctl mints
|
||||
# and publishes for the controller; the second is the homeserver's own
|
||||
# OIDC client secret, read-only. Counted, so a third stanza fails rather
|
||||
# than riding along beside correct ones.
|
||||
name = "matrix-ctl's grant is the swarm appservice token and the homeserver's OIDC secret, nothing else";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
|
||||
in
|
||||
lib.hasInfix "path \"secret/data/swarm/controller/swarm-controller/matrix/appservice-token\" {\n capabilities = [\"create\", \"update\", \"read\"]\n}" s
|
||||
&& lib.length (lib.splitString "path \"" s) == 2
|
||||
&& !(lib.hasInfix "secret/data/swarm/services" s)
|
||||
&& lib.hasInfix "path \"secret/data/swarm/services/tuwunel/oidc/client\" {\n capabilities = [\"read\"]\n}" s
|
||||
&& lib.length (lib.splitString "path \"" s) == 3
|
||||
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
|
||||
&& !(lib.hasInfix "swarm-grafana" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/agents" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/hives" s)
|
||||
&& !(lib.hasInfix "sys/policies/acl" s);
|
||||
|
|
@ -559,18 +570,20 @@ let
|
|||
{
|
||||
# `+` is one path segment, so this reaches `swarm/agents/<agent>/queue`
|
||||
# and no other credential an agent holds; `swarm/agents/*` would reach
|
||||
# all of them.
|
||||
name = "the queue responder's grant is every agent's queue credential and nothing else";
|
||||
# all of them. The responder's own OIDC client secret is the one
|
||||
# `services/` leaf beside it.
|
||||
name = "the queue responder's grant is every agent's queue credential and its own OIDC secret, nothing else";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-nats-auth-policy.script;
|
||||
in
|
||||
lib.hasInfix "path \"secret/data/swarm/agents/+/queue\" {" s
|
||||
&& lib.hasInfix "capabilities = [\"read\"]" s
|
||||
&& lib.length (lib.filter lib.isList (builtins.split "path \"" s)) == 1
|
||||
&& lib.hasInfix "path \"secret/data/swarm/services/swarm-nats/oidc/client\" {" s
|
||||
&& lib.length (lib.filter lib.isList (builtins.split "capabilities = \\[\"read\"]" s)) == 2
|
||||
&& lib.length (lib.filter lib.isList (builtins.split "path \"" s)) == 2
|
||||
&& !(lib.hasInfix "secret/data/swarm/agents/*" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/hives" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/services" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
|
||||
&& lib.hasInfix "auth/cert/certs/swarm-nats-auth" s
|
||||
&& lib.hasInfix "allowed_common_names=swarm-nats-auth" s
|
||||
&& lib.hasInfix "token_policies=swarm-nats-auth" s;
|
||||
|
|
@ -737,6 +750,23 @@ let
|
|||
in
|
||||
lib.all waitsFor (policyReaders ++ [ "swarm-bao-forwarder-oidc" ]);
|
||||
}
|
||||
{
|
||||
# The homeserver's OIDC secret is read under matrix-ctl's leaf, so its
|
||||
# reader waits for the unit writing matrix-ctl's role, not one named
|
||||
# after itself. The second arm is the absence control: on the store's
|
||||
# host without a homeserver or queue, the ordering defines no reader.
|
||||
name = "the homeserver's OIDC secret reader is ordered after matrix-ctl's policy unit";
|
||||
ok =
|
||||
let
|
||||
u = baoGrantWithHomeserver.systemd.services.swarm-bao-matrix-oidc;
|
||||
noReaders = baoGrantNoReaders.systemd.services;
|
||||
in
|
||||
lib.elem "swarm-bao-matrix-ctl-policy.service" u.after
|
||||
&& lib.elem "swarm-bao-matrix-ctl-policy.service" u.wants
|
||||
&& !(lib.elem "swarm-bao-matrix-ctl-policy.service" u.requires)
|
||||
&& !(noReaders ? swarm-bao-matrix-oidc)
|
||||
&& !(noReaders ? swarm-bao-nats-auth-oidc);
|
||||
}
|
||||
{
|
||||
# The ordering is set apart from each reader's own definition, so it can
|
||||
# define a reader by itself: `after` on a unit nothing else declares is a
|
||||
|
|
|
|||
|
|
@ -71,6 +71,42 @@ let
|
|||
&& !(s.hive-c0re.environment ? BAO_ADDR)
|
||||
&& !(lib.any (c: lib.hasPrefix "bao-" c) s.hive-c0re.serviceConfig.LoadCredential);
|
||||
}
|
||||
{
|
||||
# The homeserver's OIDC secret has one route, the store, under
|
||||
# matrix-ctl's leaf, landing where `clientSecretFile` points inside the
|
||||
# container. No unit copies it out of authelia's tree, even on a host
|
||||
# that runs authelia.
|
||||
name = "the homeserver reads its OIDC secret from the store under matrix-ctl's leaf";
|
||||
ok =
|
||||
let
|
||||
s = matrixBodyCap.systemd.services;
|
||||
u = s.swarm-bao-matrix-oidc;
|
||||
m = matrixBodyCap.services.hyperhive.deploy.matrix;
|
||||
in
|
||||
matrixBodyCap.services.hyperhive.deploy.authelia.enable
|
||||
&& u.environment.BAO_CLIENT_CERT == m.ctlBaoClientCertFile
|
||||
&& u.environment.BAO_CLIENT_KEY == m.ctlBaoClientKeyFile
|
||||
&& m.sso.clientSecretFile == "/var/lib/tuwunel-oidc/tuwunel.secret"
|
||||
&& lib.hasInfix "bao kv get -field=value secret/swarm/services/tuwunel/oidc/client" u.script
|
||||
&& lib.hasInfix "/var/lib/nixos-containers/hive-matrix/var/lib/tuwunel-oidc/tuwunel.secret" u.script
|
||||
&& lib.hasInfix "refresh_consumer hive-matrix tuwunel.service" u.script
|
||||
&& lib.elem "container@hive-matrix.service" u.before
|
||||
&& !(s ? hive-matrix-oidc-secret);
|
||||
}
|
||||
{
|
||||
# No matrix-ctl leaf is a homeserver with no OIDC secret, which tuwunel
|
||||
# refuses to start on. The unit still renders, and fails naming the
|
||||
# options.
|
||||
name = "a homeserver host with no matrix-ctl leaf renders a secret fetch that fails naming the options";
|
||||
ok =
|
||||
let
|
||||
u = matrixNoBaoIdentity.systemd.services.swarm-bao-matrix-oidc;
|
||||
in
|
||||
!(u.environment ? BAO_CLIENT_CERT)
|
||||
&& lib.hasInfix "deploy.matrix.ctlBaoClientCertFile" u.script
|
||||
&& lib.hasInfix "exit 1" u.script
|
||||
&& !(lib.hasInfix "bao login" u.script);
|
||||
}
|
||||
{
|
||||
# hive-c0re logs in with the hive's matrix token over this URL, and on a
|
||||
# host without the homeserver that login crosses the network, so the
|
||||
|
|
|
|||
|
|
@ -178,9 +178,8 @@ let
|
|||
&& lib.hasPrefix "https://" u.environment.BAO_ADDR;
|
||||
}
|
||||
{
|
||||
# An absent leaf must not stop the responder, which would deny every
|
||||
# client: it is delivered empty, and the three credentials the responder
|
||||
# cannot run without are delivered too.
|
||||
# An absent leaf must not stop the responder's agent-token path: it is
|
||||
# delivered empty, beside the two seeds.
|
||||
name = "the copy unit delivers the store identity, and an absent one as an empty file";
|
||||
ok =
|
||||
let
|
||||
|
|
@ -189,7 +188,43 @@ let
|
|||
lib.hasInfix "install -m 0400 /etc/pki/nats-auth.pem " s
|
||||
&& lib.hasInfix "install -m 0400 /etc/pki/nats-auth-key.pem " s
|
||||
&& lib.hasInfix "install -m 0400 /dev/null " s
|
||||
&& lib.hasInfix "oidc-client.secret" s;
|
||||
&& lib.hasInfix "callout-user.seed" s
|
||||
&& lib.hasInfix "issuer.seed" s;
|
||||
}
|
||||
{
|
||||
# The responder's OIDC secret has one route, the store, under the
|
||||
# responder's own leaf. The copy unit's arms are the control: on a queue
|
||||
# host without authelia nothing reads authelia's tree, and nothing
|
||||
# copies the secret from any host file.
|
||||
name = "a queue host without authelia reads the responder's OIDC secret from the store, and copies none from a host file";
|
||||
ok =
|
||||
let
|
||||
services = natsWithStoreIdentity.systemd.services;
|
||||
fetch = services.swarm-bao-nats-auth-oidc;
|
||||
copy = services.swarm-nats-auth-secrets.script;
|
||||
in
|
||||
!natsWithStoreIdentity.services.hyperhive.deploy.authelia.enable
|
||||
&& fetch.environment.BAO_CLIENT_CERT == "/etc/pki/nats-auth.pem"
|
||||
&& fetch.environment.BAO_CLIENT_KEY == "/etc/pki/nats-auth-key.pem"
|
||||
&& lib.hasInfix "bao kv get -field=value secret/swarm/services/swarm-nats/oidc/client" fetch.script
|
||||
&& lib.hasInfix "/var/lib/nixos-containers/swarm-nats/var/lib/swarm-nats-auth/oidc-client.secret" fetch.script
|
||||
&& lib.hasInfix "refresh_consumer swarm-nats swarm-nats-auth.service" fetch.script
|
||||
&& lib.elem "container@swarm-nats.service" fetch.before
|
||||
&& !(lib.hasInfix "oidc-client" copy)
|
||||
&& !(lib.hasInfix "authelia" copy);
|
||||
}
|
||||
{
|
||||
# No responder leaf is a responder with no OIDC secret, which refuses
|
||||
# every client. The unit still renders, and fails naming the options.
|
||||
name = "a queue host with no responder leaf renders a secret fetch that fails naming the options";
|
||||
ok =
|
||||
let
|
||||
fetch = natsOldPath.systemd.services.swarm-bao-nats-auth-oidc;
|
||||
in
|
||||
!(fetch.environment ? BAO_CLIENT_CERT)
|
||||
&& lib.hasInfix "deploy.nats.authBaoClientCertFile" fetch.script
|
||||
&& lib.hasInfix "exit 1" fetch.script
|
||||
&& !(lib.hasInfix "bao login" fetch.script);
|
||||
}
|
||||
{
|
||||
# The control: no identity, no store wiring, and the responder's
|
||||
|
|
@ -203,6 +238,18 @@ let
|
|||
&& !(u.environment ? BAO_CLIENT_CERT)
|
||||
&& lib.length u.serviceConfig.LoadCredential == 3;
|
||||
}
|
||||
{
|
||||
# On the store's host the fetch logs in under the responder's leaf, so it
|
||||
# waits for the unit writing that role.
|
||||
name = "on the store's host the responder's OIDC secret fetch uses its leaf and waits for its role";
|
||||
ok =
|
||||
let
|
||||
u = natsOnStoreHost.systemd.services.swarm-bao-nats-auth-oidc;
|
||||
in
|
||||
u.environment.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/nats-auth.pem"
|
||||
&& lib.elem "swarm-bao-nats-auth-policy.service" u.after
|
||||
&& lib.elem "swarm-bao-nats-auth-policy.service" u.wants;
|
||||
}
|
||||
{
|
||||
# On the store's host the glue pairs the responder with a leaf of its
|
||||
# own, never the queue's TLS-issuing one or the hive's.
|
||||
|
|
@ -220,10 +267,10 @@ let
|
|||
{
|
||||
# Not a rename test. `hostClientSecretDir` is `readOnly`, so the fixture
|
||||
# cannot define it; what can break is a reader left pointing at the
|
||||
# namespace it moved out of. Five modules read this through an
|
||||
# namespace it moved out of. Several modules read this through an
|
||||
# `autheliaCfg` alias, where a path-shaped grep does not see it.
|
||||
name = "a consumer of authelia's host client-secret dir renders it from the deploy namespace";
|
||||
ok = lib.hasInfix "/var/lib/nixos-containers/swarm-authelia/var/lib/authelia-swarm/oidc-clients/" autheliaOldPath.systemd.services.swarm-nats-auth-secrets.script;
|
||||
ok = lib.hasPrefix "/var/lib/nixos-containers/swarm-authelia/var/lib/authelia-swarm/oidc-clients/" autheliaOldPath.services.hyperhive.otel.clientSecretFile;
|
||||
}
|
||||
{
|
||||
# authelia exits at startup on `users: {}`, so an empty database, and a
|
||||
|
|
|
|||
|
|
@ -135,6 +135,56 @@ let
|
|||
lib.elem "swarm-collector" (clients secretPublisherHere)
|
||||
&& !(lib.elem "swarm-collector" (clients otelBaoRemoteAuthelia));
|
||||
}
|
||||
{
|
||||
# Registration follows authelia, not the service: the IdP host below runs
|
||||
# no controller, queue or homeserver (the first arms), and still lists all
|
||||
# three clients. The last arm is the control — a host with no IdP lists
|
||||
# none of them.
|
||||
name = "an authelia host running no controller, queue or homeserver registers all three clients";
|
||||
ok =
|
||||
let
|
||||
d = secretPublisherRemote.services.hyperhive.deploy;
|
||||
clients = m: map (c: c.id) m.services.hyperhive.swarm.authelia.oidc.clients;
|
||||
byId =
|
||||
id:
|
||||
lib.findFirst (
|
||||
c: c.id == id
|
||||
) null secretPublisherRemote.services.hyperhive.swarm.authelia.oidc.clients;
|
||||
controller = byId "swarm-controller";
|
||||
nats = byId "swarm-nats";
|
||||
matrix = byId "tuwunel";
|
||||
in
|
||||
!d.swarm-controller.enable
|
||||
&& !d.nats.enable
|
||||
&& !d.matrix.enable
|
||||
&& controller.kind == "machine"
|
||||
&& controller.audience == [ "swarm-controller" ]
|
||||
&& nats.kind == "machine"
|
||||
&&
|
||||
matrix.redirectUris == [
|
||||
"https://chat.t.local/_matrix/client/unstable/login/sso/callback/tuwunel"
|
||||
]
|
||||
&& matrix.tokenEndpointAuthMethod == "client_secret_post"
|
||||
&& !(lib.any (id: lib.elem id (clients grafanaRemoteAuthelia)) [
|
||||
"swarm-controller"
|
||||
"swarm-nats"
|
||||
"tuwunel"
|
||||
]);
|
||||
}
|
||||
{
|
||||
# The other half of the queue's and the homeserver's one route: the
|
||||
# publisher on an IdP host that runs neither carries both secrets to the
|
||||
# paths their readers fetch.
|
||||
name = "the publisher on an authelia host carries the queue's and homeserver's secrets";
|
||||
ok =
|
||||
let
|
||||
s = secretPublisherRemote.systemd.services.swarm-secret-publish.script;
|
||||
in
|
||||
lib.hasInfix "secret/swarm/services/swarm-nats/oidc/client" s
|
||||
&& lib.hasInfix "/swarm-nats.secret" s
|
||||
&& lib.hasInfix "secret/swarm/services/tuwunel/oidc/client" s
|
||||
&& lib.hasInfix "/tuwunel.secret" s;
|
||||
}
|
||||
{
|
||||
# Both ends of a wire nothing at eval time carries end to end: the
|
||||
# publisher on authelia's host writes the path the reader on the
|
||||
|
|
|
|||
Loading…
Reference in a new issue