# `checks.module-eval-nats-authelia` — 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 queue's callout identity, fourth split slice. `autoGenerateCallout` is # left FALSE on purpose: that is what makes the seed paths the thing deciding # `responderConfigured`, so the assertion below is about the seeds rather # than about the auto-mint branch. Every one of the seven old paths is # defined — `enable` included, which is why it is spelled the old way here # while the fixture below uses the new one — so dropping any single nats # shim fails the eval, not just the arms read. natsOldPath = hive { swarm.nats.enable = true; swarm.nats.autoGenerateCallout = false; swarm.nats.calloutUserPublicKey = "UTESTUSERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; swarm.nats.calloutIssuerPublicKey = "ATESTISSUERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed"; swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed"; swarm.nats.authPackage = pkgs.emptyDirectory; }; # Seventh split slice, plus slice 10's two authelia packages. Of slice 7's # movers only `usersFile` has a rename entry — the other two are `readOnly`, # and a rename module contributes a definition, which a read-only option # refuses; see ./host-modules/deploy.nix. `package` and `bridgePackage` are # ordinary options, so they do carry one. The two arms # below have different jobs. `usersFile` tests the rename; the nats one tests # that a reader pointed at the `deploy` namespace still renders the derived # path — seven of those reads go through an alias a path-shaped grep # cannot see. autheliaOldPath = hive { deploy.authelia.enable = true; deploy.nats.enable = true; swarm.authelia.usersFile = "/var/lib/test-authelia/users.yml"; swarm.authelia.package = pkgs.emptyDirectory; swarm.authelia.bridgePackage = pkgs.emptyDirectory; swarm.nats.autoGenerateCallout = false; swarm.nats.calloutUserPublicKey = "UTESTUSERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; swarm.nats.calloutIssuerPublicKey = "ATESTISSUERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed"; swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed"; }; # The queue with a store identity for its responder, placed by hand: the # queue host that is not the store's. natsWithStoreIdentity = hive { deploy.nats.enable = true; deploy.nats.autoGenerateCallout = false; deploy.nats.calloutUserPublicKey = "UTESTUSERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; deploy.nats.calloutIssuerPublicKey = "ATESTISSUERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; deploy.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed"; deploy.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed"; deploy.nats.authPackage = pkgs.emptyDirectory; deploy.nats.authBaoClientCertFile = "/etc/pki/nats-auth.pem"; deploy.nats.authBaoClientKeyFile = "/etc/pki/nats-auth-key.pem"; }; # The queue on the store's own host, where the PKI glue mints every leaf. natsOnStoreHost = hive { deploy.bao.enable = true; deploy.nats.enable = true; }; responderOf = h: h.containers.swarm-nats.config.systemd.services.swarm-nats-auth; # A hive running NOTHING of the swarm's own services — no IdP here, no # `swarm.authelia.url` set by hand. The whole point of the fixture is what it # does *not* say: no IdP address is given and none is derived from # co-location. autheliaNotColocated = hive { }; # The same swarm's IdP host, for the arm that the two agree. autheliaColocated = hive { deploy.authelia.enable = true; }; cases = [ { # Reads the RENDERED settings, not the option: `calloutBlocks {…} // { # … }` is a shallow merge, and a future edit that dropped or shadowed # this key would still evaluate cleanly — the only reader that would # notice is a publisher whose row exceeds upstream's much smaller # default, and by then it is a dropped row, not an eval failure. # Piggybacks on the pre-rename nats fixture above, which already # renders this container's full config. name = "the queue's payload ceiling is set, not inherited from the server's default"; ok = natsOldPath.containers.swarm-nats.config.services.nats.settings.max_payload == 8388608; } { # What keeps a callout request inside one principal: the responder is # the callout account's only member, and no system account exists to # observe it from. The runtime fragment is rendered by the same # `calloutBlocks`, so the count is the same there. The account name is # the control that the assertions read the callout block at all. name = "the callout account has exactly one member and no system account is configured"; ok = lib.all ( h: let settings = h.containers.swarm-nats.config.services.nats.settings; in settings.authorization.auth_callout.account == "AUTH" && lib.length settings.accounts.AUTH.users == 1 && !(settings ? system_account) ) [ natsOldPath natsOnStoreHost ]; } { # The xkey exists only at runtime, so the server's config is rendered as # it starts: always the wrapper, and never the store-rendered settings # alone, which carry no xkey. `+` so the render may write `runtimeDir`. name = "the queue's server starts only through the config rendered with its xkey"; ok = lib.all ( h: let c = h.containers.swarm-nats.config; nats = c.systemd.services.nats.serviceConfig; pre = nats.ExecStartPre or ""; in lib.hasSuffix "/bin/nats-server -c /var/lib/nats-callout/nats.conf" nats.ExecStart && lib.hasPrefix "+/nix/store/" pre && lib.hasSuffix "-swarm-nats-render-config" pre && !(c.services.nats.settings.authorization.auth_callout ? xkey) ) [ natsOldPath natsOnStoreHost ]; } { # A server that starts late, once its xkey arrives, must bring the # responder with it: the responder requires the server, so a server that # failed to start left the responder inactive rather than failed. name = "starting the queue's server starts its responder"; ok = lib.elem "nats.service" (responderOf natsOldPath).wantedBy; } { # The key is created before anything needs it, by the responder's own # verb under its own leaf, then read back from the public path and left # where the server's render reads it. Before the container and wanted by # it, and a fetch that finds nothing fails so `storeRetry` retries. name = "the callout xkey is created, then fetched from the store before the queue's container starts"; ok = let u = natsWithStoreIdentity.systemd.services.swarm-bao-nats-callout-xkey; s = builtins.unsafeDiscardStringContext u.script; at = needle: lib.stringLength (lib.head (lib.splitString needle s)); ensure = "swarm-nats-auth ensure-xkey --store-cert-role swarm-nats-auth"; fetch = "bao kv get -field=value secret/swarm/public/swarm-nats/callout-xkey"; file = "/var/lib/nixos-containers/swarm-nats/var/lib/nats-callout/callout-xkey.pub"; in lib.elem "container@swarm-nats.service" u.before && lib.elem "container@swarm-nats.service" u.wantedBy && u.environment.BAO_CLIENT_CERT == "/etc/pki/nats-auth.pem" && lib.hasInfix ensure s && lib.hasInfix fetch s && at ensure < at fetch && lib.hasInfix "atomic_write_secret 0444 \"\" ${file}" s && lib.hasInfix "refresh_consumer swarm-nats nats.service ${file}" s && !(lib.hasInfix "exit 0" s) && u.serviceConfig.Restart == "on-failure"; } { name = "a queue host with no responder leaf renders an xkey fetch that fails naming the options"; ok = let u = natsOldPath.systemd.services.swarm-bao-nats-callout-xkey; in !(u.environment ? BAO_CLIENT_CERT) && lib.hasInfix "deploy.nats.authBaoClientCertFile" u.script && lib.hasInfix "exit 1" u.script && !(lib.hasInfix "ensure-xkey" u.script); } { # Its creates are what the responder's role was granted; on the store's # host the role's unit has to have run first. name = "on the store's host the callout xkey fetch waits for the responder's role"; ok = let u = natsOnStoreHost.systemd.services.swarm-bao-nats-callout-xkey; 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; } { # The responder refuses the hive's shared agent client and takes no flag # granting it subjects: an `--agent-publish-subject` fails the unit at # start. name = "the responder grants the hive's shared agent client nothing"; ok = let exec = natsOldPath.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart; in !(lib.hasInfix "--agent-publish-subject " exec); } { # The per-agent grant: the terminal and agent-state streams keyed on the # agent alone, with the doubled dollar, and the role the store writes for # it. name = "the responder grants a verified agent its own hive-free subjects"; ok = let exec = (responderOf natsOldPath).serviceConfig.ExecStart; in lib.hasInfix "--agent-token-publish-subject '$$SWARM.term.{agent}'" exec && lib.hasInfix "--agent-token-publish-subject '$$SWARM.agent-state.{agent}'" exec && lib.hasInfix "--store-cert-role swarm-nats-auth" exec; } { # Its own arm, like the two above: the icon write is a separate feature # and dropping it must not hide behind the streams still being granted. name = "the responder grants a verified agent its own icon key"; ok = let exec = (responderOf natsOldPath).serviceConfig.ExecStart; in lib.hasInfix "--agent-token-publish-subject '$$KV.agent-icons.{agent}'" exec; } { # The whole per-agent grant, compared as a list rather than by infix: a # wider subject added beside these (`$$JS.API.>`, another stream's name) # would pass every presence check above. name = "a verified agent's grant is exactly its own subjects, and its one JetStream API subject reads its own schedule key"; ok = let args = lib.splitString " " (responderOf natsOldPath).serviceConfig.ExecStart; granted = lib.concatLists ( lib.imap0 ( i: a: lib.optional (a == "--agent-token-publish-subject" && i + 1 < lib.length args) ( lib.elemAt args (i + 1) ) ) args ); in granted == [ "'$$SWARM.term.{agent}'" "'$$SWARM.agent-state.{agent}'" "'$$KV.agent-icons.{agent}'" "'$$SWARM.term.{agent}.sub.>'" "'$$JS.API.DIRECT.GET.KV_agent-schedules.$$KV.agent-schedules.{agent}'" ]; } { # Each credential by `LoadCredential`, and the environment naming where # the unit sees it: a DynamicUser cannot read the copies directly. name = "a responder with a store identity loads it and is pointed at the store"; ok = let u = responderOf natsWithStoreIdentity; creds = u.serviceConfig.LoadCredential; in lib.elem "bao-client.pem:/var/lib/swarm-nats-auth/bao-client.pem" creds && lib.elem "bao-client-key.pem:/var/lib/swarm-nats-auth/bao-client-key.pem" creds && u.environment.BAO_CLIENT_CERT == "/run/credentials/swarm-nats-auth.service/bao-client.pem" && u.environment.BAO_CLIENT_KEY == "/run/credentials/swarm-nats-auth.service/bao-client-key.pem" && lib.hasPrefix "https://" u.environment.BAO_ADDR; } { # 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 s = natsWithStoreIdentity.systemd.services.swarm-nats-auth-secrets.script; in 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 "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 # An unpublished or empty secret fails the unit, so `storeRetry` retries it. && !(lib.hasInfix "exit 0" 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 # credentials are exactly the three it cannot run without. name = "a responder with no store identity is not pointed at a store"; ok = let u = responderOf natsOldPath; in !(u.environment ? BAO_ADDR) && !(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. name = "on the store's host the responder presents its own leaf"; ok = let n = natsOnStoreHost.services.hyperhive.deploy.nats; b = natsOnStoreHost.services.hyperhive.deploy.bao; in n.authBaoClientCertFile == "/var/lib/swarm-bao-pki/nats-auth.pem" && n.authBaoClientKeyFile == "/var/lib/swarm-bao-pki/nats-auth-key.pem" && n.authBaoClientCertFile != n.baoClientCertFile && n.authBaoClientCertFile != b.clientCertFile; } { # Not a rename test. `hostClientSecretDir` is `readOnly`, so the fixture # cannot define it; what can break is a reader left pointing at # `swarm.authelia` instead of `deploy.authelia`. 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.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 # file holding exactly that document, must be replaced by the disabled # placeholder rather than left for authelia to read. name = "an empty authelia users database is seeded with the disabled placeholder"; ok = let s = autheliaColocated.containers.swarm-authelia.config.systemd.services.authelia-swarm-secrets.script; in lib.hasInfix ''if [ ! -s "$users" ] || [ "$(cat "$users")" = "users: {}" ]; then'' s && lib.hasInfix "-authelia-users-placeholder.yml \"$users\"" s && !(lib.hasInfix ''echo "users: {}"'' s); } { # The bridge refuses every client but the one named here, so this has # to be the client the controller authenticates as. A drift is a # controller whose every bridge call is refused. name = "the authelia bridge answers the controller's client only"; ok = let env = autheliaColocated.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment; in env.SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID == autheliaColocated.services.hyperhive.swarm.controller.queueClientId && env.SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP == "admins"; } { # One address from every host, and a location the IdP host serves: a # controller off the authelia host has no other way to the bridge. name = "the auth bridge address is the authelia vhost's /bridge/ on every host"; ok = let url = h: h.services.hyperhive.deploy.swarm-controller.authBridgeUrl; authelia = autheliaColocated.services.hyperhive.swarm.authelia; location = autheliaColocated.services.nginx.virtualHosts.${authelia.domain}.locations."/bridge/"; in url autheliaNotColocated == "${autheliaNotColocated.services.hyperhive.swarm.authelia.url}/bridge" && url autheliaColocated == url autheliaNotColocated && location.proxyPass == "http://127.0.0.1:${toString authelia.bridgePort}/"; } { # Config NAMES the IdP; it never computes where the IdP is. Both arms # matter together: the address is the swarm's name on a hive that runs # nothing, and it is the SAME string on the hive that serves the vhost — # so a re-introduced co-location branch shows up as the two disagreeing # rather than as a value that merely looks plausible on one of them. name = "the swarm IdP address is its domain on every hive, co-located or not"; ok = autheliaNotColocated.services.hyperhive.swarm.authelia.url == "https://${autheliaNotColocated.services.hyperhive.swarm.authelia.domain}" && autheliaColocated.services.hyperhive.swarm.authelia.url == autheliaNotColocated.services.hyperhive.swarm.authelia.url; } { # …and the name it resolves through is a DOMAIN, not a host this config # picked. `127.0.0.1` is what a co-location-derived default renders on # the IdP host, the bridge address is what the queue's defaults render, # and neither is an address a client may be handed: the domain is # allowed to resolve differently in different places, which is the # whole property. name = "the swarm IdP address names no host address"; ok = let url = autheliaNotColocated.services.hyperhive.swarm.authelia.url; bridgeIp = autheliaNotColocated.services.hyperhive.network.bridgeIp; in !(lib.hasInfix "127.0.0.1" url) && !(lib.hasInfix "localhost" url) && !(lib.hasInfix bridgeIp url); } ]; in runGroup "nats-authelia" cases