diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index da8d748d..fd80803f 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -26,11 +26,9 @@ ./glue-bao-tls.nix ./glue-controller-bao-identity.nix ./glue-matrix-bao-token.nix - ./glue-secret-publisher-bao-identity.nix ./swarm-authelia.nix ./swarm-bao.nix ./swarm-ca.nix - ./swarm-secret-publisher.nix ./swarm-nats.nix ./swarm-controller.nix ./swarm-grafana.nix diff --git a/nix/host-modules/glue-bao-tls.nix b/nix/host-modules/glue-bao-tls.nix index 419f9c63..18549577 100644 --- a/nix/host-modules/glue-bao-tls.nix +++ b/nix/host-modules/glue-bao-tls.nix @@ -140,13 +140,6 @@ in # this hive's name. [ -s ${pkiDir}/controller.pem ] || ${signLeaf} ${pkiDir} controller \ ${lib.escapeShellArg deployCfg.bao.controllerCommonName} "" clientAuth - - # The secret publisher's, minted here for the reason the controller's - # line above gives — and it serves that case more often, not less: the - # publisher runs beside AUTHELIA, which is the one host guaranteed not - # to be this one whenever the store has a host of its own. - [ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \ - ${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth ''; }; }; diff --git a/nix/host-modules/glue-secret-publisher-bao-identity.nix b/nix/host-modules/glue-secret-publisher-bao-identity.nix deleted file mode 100644 index 88fc92b9..00000000 --- a/nix/host-modules/glue-secret-publisher-bao-identity.nix +++ /dev/null @@ -1,42 +0,0 @@ -# Glue: point the secret publisher at the bao leaf minted for it. -# -# ONE PAIRING PER FILE — publisher ← bao, and nothing else. Deleting this -# leaves a publisher that takes operator-provided certificate paths, which is -# what any deployment not minting its own already does. -# -# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the -# leaf, because the thing that owns a private key owns issuing from it. What -# belongs here is the pairing: which paths this host's publisher reads. -# -# ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule -# ./glue-controller-bao-identity.nix states. A publisher on the store's own -# host is one deployment; a publisher beside a remote authelia holding a leaf -# issued out of band is another, and both want this wiring. -# -# Everything is `mkDefault`. An operator naming their own paths wins. -{ - lib, - config, - ... -}: -let - hyperhiveCfg = config.services.hyperhive; - deployCfg = hyperhiveCfg.deploy; - baoDeploy = deployCfg.bao; - - # Where ./glue-bao-tls.nix puts the leaves, derived from the reader's own - # path rather than repeating that file's directory literal: an operator who - # moves the PKI moves both, and the two cannot drift apart. - haveMintedPki = baoDeploy.clientCertFile != null; - pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null; -in -{ - config = - lib.mkIf (hyperhiveCfg.enable && deployCfg.swarm-secret-publisher.enable && haveMintedPki) - { - services.hyperhive.deploy.swarm-secret-publisher = { - baoClientCertFile = lib.mkDefault "${pkiDir}/secret-publisher.pem"; - baoClientKeyFile = lib.mkDefault "${pkiDir}/secret-publisher-key.pem"; - }; - }; -} diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 52590ec5..d18b825c 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -203,30 +203,6 @@ let } ''; - # The identity that copies authelia's minted OIDC client secrets into the - # store. Named outside `hive-*` for the same reason as the controller's: the - # controller may create policies under that prefix, and a policy it can - # rewrite is not a constraint on anything. - secretPublisherPolicyName = "swarm-secret-publisher"; - secretPublisherCn = baoDeploy.secretPublisherCommonName; - - # One grant, and every narrowing in it is load-bearing. - # - # `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than - # written by the caller — same trap as the controller's grant above. - # - # `hives/` and not `swarm/*`: this principal has no business with an agent's - # or a service's credentials, and the hive prefix is the only one whose paths - # it produces. - # - # Write-only. It copies secrets in and never reads one back; a read - # capability would let a file-copier recover every hive's credentials. - secretPublisherPolicyText = '' - path "${credentialMountPath}/data/swarm/hives/*" { - capabilities = ["create", "update"] - } - ''; - # The KV v2 engine the controller writes agent credentials through. Named # once because the grant above and the `secrets enable` in the bootstrap unit # have to agree: a policy pointing at a mount nobody created is precisely the @@ -578,29 +554,6 @@ in ''; }; - secretPublisherCommonName = lib.mkOption { - type = lib.types.str; - default = "swarm-secret-publisher"; - example = "swarm-secret-publisher.svc"; - description = '' - Subject the store's secret-publisher cert-auth role accepts — the - identity that copies authelia's minted OIDC client secrets into the - store so a hive that does not host authelia can read its own. - - A **second** identity rather than reuse of - {option}`services.hyperhive.deploy.bao.controllerCommonName`: the - controller's grant includes rewriting every hive's policy and login - role, which is not something a unit whose whole job is copying one file - should be able to do. This one's grant is `create`/`update` under the - hive prefix and nothing else. - - ⚠️ Same collision as its sibling above, and the same answer: ./swarm.nix - feeds this value into the guard on - {option}`services.hyperhive.swarm.hives`, so a hive named after it fails - evaluation rather than silently receiving the publisher's grants. - ''; - }; - serverCaFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; @@ -1007,58 +960,6 @@ in ''; }; - # A SIBLING rather than more script in the unit above, because that unit's - # name is an operator-facing string: ../../docs/getting-started/setup.md - # tells a reader to run `systemctl status swarm-bao-controller-policy`. - # Widening it to two principals would make the name wrong, and renaming it - # would make that instruction wrong. - # - # `after` and not `requires`: the unit above creates the KV and cert-auth - # mounts this one writes into, but a failed oneshot still counts as - # finished, so `requires` would neither wait for its success nor re-run - # this one when its own retry eventually lands. Ordering plus this unit's - # own retry is what actually converges. - systemd.services.swarm-bao-secret-publisher-policy = lib.mkIf haveBootstrapToken { - description = "write the swarm secret publisher's bao policy and cert-auth role"; - after = [ - "container@${cfg.machine}.service" - "swarm-bao-controller-policy.service" - ]; - wantedBy = [ "multi-user.target" ]; - path = [ - baoCli - pkgs.coreutils - ]; - unitConfig.ConditionPathExists = baoDeploy.bootstrapTokenFile; - # Same unseal wait as its sibling above, for the reason stated there: - # under `seal = "shamir"` a human unseals by hand, which can take a day. - startLimitBurst = 2880; - startLimitIntervalSec = 90000; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - Restart = "on-failure"; - RestartSec = 30; - }; - script = '' - set -euo pipefail - - BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})" - export BAO_TOKEN - - printf '%s' ${lib.escapeShellArg secretPublisherPolicyText} | - bao policy write ${lib.escapeShellArg secretPublisherPolicyName} - - '' - + lib.optionalString (baoDeploy.clientCaFile != null) '' - - bao write auth/cert/certs/${lib.escapeShellArg secretPublisherPolicyName} \ - certificate=@${tlsDir}/client-ca.pem \ - allowed_common_names=${lib.escapeShellArg secretPublisherCn} \ - token_policies=${lib.escapeShellArg secretPublisherPolicyName} \ - display_name=${lib.escapeShellArg secretPublisherCn} - ''; - }; - containers.${cfg.machine} = { autoStart = true; ephemeral = false; diff --git a/nix/host-modules/swarm-secret-publisher.nix b/nix/host-modules/swarm-secret-publisher.nix deleted file mode 100644 index 27b68adb..00000000 --- a/nix/host-modules/swarm-secret-publisher.nix +++ /dev/null @@ -1,172 +0,0 @@ -# The unit that copies authelia's minted OIDC client secrets into the swarm's -# secret store, so a hive that does not host authelia can read its own. -# -# ⚠️ IT RUNS WHERE AUTHELIA DOES, and that is the whole reason it exists as a -# separate thing. `deploy.authelia.hostClientSecretDir`'s own description says -# why: the plaintext's other reader lives in a **different container**, and -# containers that share this host's network namespace still have separate -# filesystem roots, so "the host is the only place both trees are addressable". -# A delivery step therefore runs on the host that mints — not on the store's -# host, and not on the reading hive's. -# -# ⚠️ ITS OWN STORE IDENTITY, not swarm-controller's. That principal may rewrite -# every hive's policy and login role; a unit whose entire job is copying one -# file has no business holding it. ./swarm-bao.nix grants this one -# `create`/`update` under the hive prefix and nothing else. -# -# ⚠️ THE SECRET NEVER REACHES `argv`. `bao` is an external binary, so every -# argument is world-readable in /proc for the life of the call — the value is -# passed as `@` and read by bao itself. (`/knowledge/secret-hygiene.md`: -# "a path keeps the secret out of the store, and reading it into a shell -# variable puts it straight into argv".) -{ - pkgs, - lib, - config, - ... -}: -let - hyperhiveCfg = config.services.hyperhive; - deployCfg = hyperhiveCfg.deploy; - baoDeploy = deployCfg.bao; - autheliaCfg = hyperhiveCfg.swarm.authelia; - cfg = deployCfg.swarm-secret-publisher; - - # A reader is defined by holding a certificate the store accepts, never by - # standing next to it — the rule ./glue-matrix-bao-token.nix states in full. - # `deploy.bao.enable` here would be the co-location assumption itself. - haveClientIdentity = cfg.baoClientCertFile != null && cfg.baoClientKeyFile != null; - - # Both halves have to be here: the mint (authelia, for the plaintext) and an - # identity (for the store). Neither implies the other. - active = hyperhiveCfg.enable && cfg.enable && deployCfg.authelia.enable && haveClientIdentity; - - hiveNames = lib.attrNames hyperhiveCfg.swarm.hives; - - # The client id agent containers present, per hive — composed exactly as - # ./swarm-authelia.nix composes it, from the same two read-only options, so a - # rename there cannot leave this spelling behind. - agentClientId = hive: "${autheliaCfg.hiveClientPrefix}${hive}${autheliaCfg.agentClientSuffix}"; -in -{ - options.services.hyperhive.deploy.swarm-secret-publisher = { - enable = lib.mkOption { - type = lib.types.bool; - default = deployCfg.authelia.enable; - defaultText = lib.literalExpression "deploy.authelia.enable"; - description = '' - Publish the OIDC client secrets this host mints into the swarm's - secret store, so hives that do not run authelia can read their own. - - Defaults to whether this host mints them, which is the only half of - the question that is a property of *this* host. - - ⚠️ Deliberately **not** `deploy.bao.enable`. That asks whether the - store stands here, and a publisher beside a remote store, holding a - leaf issued out of band, is a deployment this exists to serve — - defaulting on co-location would leave exactly that shape silently - publishing nothing. Whether the wiring is complete is the client - identity's job (see `baoClientCertFile`), not this option's. - - Turning it off leaves every non-co-located hive without a delivery - path, which is the state this exists to end — so the honest reason - to set it false is a deployment delivering those secrets by some - other mechanism it owns. - ''; - }; - - baoClientCertFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = '' - Client certificate this publisher presents to the store. Its subject - must be {option}`services.hyperhive.deploy.bao.secretPublisherCommonName` - — cert auth matches on the CN, and the role accepts nothing else. - - No default: a module that guessed would be holding the CA opinion - ./swarm-bao.nix deliberately does not hold. - ./glue-secret-publisher-bao-identity.nix points it at the leaf - ./glue-bao-tls.nix mints, where this host mints one. - ''; - }; - - baoClientKeyFile = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = '' - Private key for {option}`services.hyperhive.deploy.swarm-secret-publisher.baoClientCertFile`. - Both or neither — the unit does not exist unless each is set. - ''; - }; - }; - - config = lib.mkIf active { - services.hyperhive.swarm.otel.journaldUnits = [ "swarm-secret-publish" ]; - - # Re-publish when authelia rotates a secret. The mint writes the file, so - # the file is the event — there is no signal from authelia to subscribe to. - systemd.paths.swarm-secret-publish = { - description = "watch for minted OIDC client secrets to publish"; - wantedBy = [ "multi-user.target" ]; - pathConfig = { - PathChanged = deployCfg.authelia.hostClientSecretDir; - Unit = "swarm-secret-publish.service"; - }; - }; - - systemd.services.swarm-secret-publish = { - description = "publish minted OIDC client secrets to the swarm secret store"; - wantedBy = [ "multi-user.target" ]; - after = [ "network.target" ]; - path = [ - baoDeploy.package - pkgs.coreutils - ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - # Bounded here rather than left to systemd's default, so the number a - # boot waits on is in the file that waits. A sealed store answers on - # the port and never answers the write. - TimeoutStartSec = 60; - Restart = "on-failure"; - RestartSec = 30; - }; - environment = { - BAO_ADDR = "https://${hyperhiveCfg.swarm.bao.domain}:${toString hyperhiveCfg.swarm.bao.port}"; - BAO_CLIENT_CERT = cfg.baoClientCertFile; - BAO_CLIENT_KEY = cfg.baoClientKeyFile; - } - // lib.optionalAttrs (baoDeploy.serverCaFile != null) { - BAO_CACERT = baoDeploy.serverCaFile; - }; - script = '' - set -euo pipefail - - published=0 - skipped=0 - - ${lib.concatMapStringsSep "\n" (hive: '' - src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${agentClientId hive}.secret"} - if [ -s "$src" ]; then - # `value=@$src` hands bao the PATH: bao opens the file itself, so - # the plaintext is never an argument of this process. Writing it as - # `value="$(cat "$src")"` would publish it to /proc for anyone on - # the host to read. - bao kv put ${lib.escapeShellArg "secret/swarm/hives/${hive}/queue/agent"} \ - value=@"$src" \ - client_id=${lib.escapeShellArg (agentClientId hive)} - published=$((published + 1)) - else - # Not an error: authelia mints on its FIRST BOOT, so an absent file - # is "not yet", and the path unit above re-runs this when it lands. - echo "no minted secret at $src yet; the path unit will re-run this" >&2 - skipped=$((skipped + 1)) - fi - '') hiveNames} - - echo "published $published client secret(s), skipped $skipped" - ''; - }; - }; -} diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 0267d1c6..6052cc8f 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -43,10 +43,7 @@ let # every role added beside these widens what a hive name must not collide # with. A hive's own leaf carries its name as the CN, so a hive named after # one of these presents a certificate that role accepts. - certAuthCns = [ - deployCfg.bao.controllerCommonName - deployCfg.bao.secretPublisherCommonName - ]; + certAuthCns = [ deployCfg.bao.controllerCommonName ]; # Public hostnames of the swarm's own services, in declaration order. # `serviceDomains` below is this set sorted + deduplicated. diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 50313344..dea14c02 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -327,32 +327,6 @@ let deploy.bao.serverCaFile = lib.mkForce "/etc/pki/store-server-ca.pem"; }; - # The IdP and the store on one machine: the shape where minted plaintext and - # a store identity are both present without an operator placing anything. - # Two hives in the roster, because the publisher walks it — an arm written - # against a single-hive fixture passes on a hardcoded name. - secretPublisherHere = hive { - deploy.bao.enable = true; - deploy.authelia.enable = true; - swarm.hives.h2.domain = "h2.t.local"; - }; - # The IdP with no store on the box and a leaf placed by hand, which is the - # deployment this unit exists for: authelia is the one host the store is - # guaranteed not to share once either has a machine of its own. - # - # ⚠️ `enable` is deliberately NOT set here. It used to be, with a comment - # saying the default asked whether both ran on this host — which documented - # the co-location bug instead of catching it. Leaving it unset is what makes - # this fixture exercise the default rather than mask it. - secretPublisherRemote = hive { - deploy.authelia.enable = true; - deploy.swarm-secret-publisher.baoClientCertFile = "/etc/pki/publisher.pem"; - deploy.swarm-secret-publisher.baoClientKeyFile = "/etc/pki/publisher-key.pem"; - }; - # The same IdP with the identity taken away. Minting the secrets is not being - # able to publish them, and this is the arm that separates the two. - secretPublisherNoIdentity = hive { deploy.authelia.enable = true; }; - # The host's `bao` wrapper, pulled apart once so each case below names one # property instead of a conjunction — a failing conjunction says only that # something is wrong. @@ -565,15 +539,6 @@ let deploy.bao.controllerCommonName = "ctl"; }; - # The reserved subjects are a LIST, and a list with one consulted element and - # one dead one looks identical from the first element's case. This fixture - # collides with the SECOND, leaving the controller's at its default. - hiveNamedAfterPublisherSubject = hive { - deploy.swarm-otel.enable = false; - deploy.bao.secretPublisherCommonName = "pubctl"; - swarm.hives.pubctl.domain = "p.t.local"; - }; - hiveNameWithComposedWord = hive { deploy.swarm-otel.enable = false; swarm.hives."h1-agent".domain = "a.t.local"; @@ -600,17 +565,6 @@ let equalityGuardFired hiveNamedAfterCertSubject && lib.any (a: !a.assertion && lib.hasInfix "'ctl'" a.message) hiveNamedAfterCertSubject.assertions; } - { - # Every cert-auth subject is reserved, not just the first one in the - # list. Without this case the second element could be dead and the case - # above would still pass. - name = "a hive named after the secret publisher's subject is refused too"; - ok = - equalityGuardFired hiveNamedAfterPublisherSubject - && lib.any ( - a: !a.assertion && lib.hasInfix "'pubctl'" a.message - ) hiveNamedAfterPublisherSubject.assertions; - } { # Without this the case above proves nothing: an arm that fires for every # roster is not a guard, and `hives` is non-empty in both fixtures. @@ -867,51 +821,6 @@ let in lib.hasInfix "sys/policies/acl/hive-*" s && !(lib.hasInfix "sys/policies/acl/*" s); } - { - # Same host-side reasoning as the controller's granting unit above: the - # write needs a client certificate and the host is the side that has one. - name = "a store host with a placed bootstrap token renders the publisher's granting unit too"; - ok = - let - u = baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy; - in - u.unitConfig.ConditionPathExists == "/run/secrets/bao-bootstrap.token" - && lib.hasInfix "swarm-secret-publisher" u.script; - } - { - # The control for the case above, and the same one the controller's unit - # has: rendered on the host means NOT rendered in the container, where it - # would have neither an identity nor a route to the store. - name = "the publisher's granting unit is not rendered inside the store's container"; - ok = - !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-secret-publisher-policy); - } - { - # The whole point of a second principal. `hives/` and not `swarm/`, so it - # cannot touch an agent's or a service's credentials; and no `read`, so a - # unit whose job is copying a file cannot recover what is already there. - # Pinned as the full capability list, because an added capability is - # exactly what a presence check misses. - name = "the publisher's grant is write-only and reaches the hive prefix alone"; - ok = - let - s = baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.script; - in - lib.hasInfix "path \"secret/data/swarm/hives/*\" {\n capabilities = [\"create\", \"update\"]" s - && !(lib.hasInfix "secret/data/swarm/agents" s) - && !(lib.hasInfix "secret/data/swarm/*" s) - && !(lib.hasInfix "sys/policies/acl" s); - } - { - # The ordering is load-bearing and invisible at runtime: the controller's - # unit creates the KV and cert-auth mounts this one writes into, so - # without it a cold boot races and fails with "route entry not found", - # which names neither unit. - name = "the publisher's granting unit is ordered after the one that creates the mounts"; - ok = lib.elem "swarm-bao-controller-policy.service" ( - baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.after - ); - } { # The policy authorising this route lives in another file, and nothing # else relates the grants to the paths the code actually writes. @@ -1025,98 +934,6 @@ let && builtins.elem "bao-client.pem:/var/lib/swarm-bao-pki/controller.pem" s.swarm-controller.serviceConfig.LoadCredential && builtins.elem "bao-client-key.pem:/var/lib/swarm-bao-pki/controller-key.pem" s.swarm-controller.serviceConfig.LoadCredential; } - { - # The same hole the controller's case above names, open a second time: the - # PKI script grew a third leaf and no case read it. - name = "the store mints a leaf for the secret publisher, and the publisher is pointed at it"; - ok = - let - m = secretPublisherHere; - p = m.services.hyperhive.deploy.swarm-secret-publisher; - in - lib.hasInfix "secret-publisher.pem" m.systemd.services.swarm-bao-pki.script - && p.baoClientCertFile == "/var/lib/swarm-bao-pki/secret-publisher.pem" - && p.baoClientKeyFile == "/var/lib/swarm-bao-pki/secret-publisher-key.pem"; - } - { - # mara caught this by reading, which means no arm existed for it: the - # default asked `authelia.enable && bao.enable`, so the split deployment - # this unit is FOR defaulted off and published nothing, silently. - # - # The second clause is the control. Without it this passes on a default - # of plain `true`, which would be a different bug with the same symptom - # — an IdP-less host claiming it publishes secrets it never mints. - name = "the publisher defaults on where secrets are minted, whether or not the store is local"; - ok = - secretPublisherRemote.services.hyperhive.deploy.swarm-secret-publisher.enable - && !bare.services.hyperhive.deploy.swarm-secret-publisher.enable; - } - { - # The one security property of this unit, and why its push cannot be - # rewritten into the obvious shape: `bao` is an external binary, so an - # argument is world-readable in /proc for the life of the call. - # `value=@` hands it the path and bao opens the file itself. - # - # The second arm is what makes the first mean anything — `value=@` can - # sit one line above a command substitution that put the plaintext in - # argv anyway. - # - # ⚠️ Comments are stripped first, and that is not tidiness. A `script` - # renders its own comments into the text, and this unit's comments name - # the hazard verbatim so the next editor does not reintroduce it. Without - # the strip this case reads that warning and fails — a check the artifact - # defeats by DESCRIBING the thing it is checked for. - name = "the publisher hands bao the secret's path, never the secret"; - ok = - let - s = secretPublisherHere.systemd.services.swarm-secret-publish.script; - code = lib.concatStringsSep "\n" ( - lib.filter (l: builtins.match "[[:space:]]*#.*" l == null) (lib.splitString "\n" s) - ); - in - lib.hasInfix "value=@" code && !(lib.hasInfix "$(cat" code); - } - { - # Two ends of a wire nothing at eval time carries end to end: this is the - # path `swarm_secret_client::queue` resolves for the reader. Both hives - # are asserted, so a publisher that knew one name rather than the roster - # fails here rather than on the second hive ever added to a swarm. - name = "the publisher writes every hive in the roster to that hive's own queue path"; - ok = - let - s = secretPublisherHere.systemd.services.swarm-secret-publish.script; - in - lib.hasInfix "secret/swarm/hives/h1/queue/agent" s - && lib.hasInfix "secret/swarm/hives/h2/queue/agent" s; - } - { - # The doctrine three glue files state, as a property a rewrite has to - # keep: a client is defined by holding a certificate the store accepts, - # never by standing next to the store. Gating this on `deploy.bao.enable` - # would have left the unit rendering only on the one deployment that has - # no use for it. - name = "a publisher holding an identity runs on a host with no store"; - ok = - let - m = secretPublisherRemote; - in - !m.services.hyperhive.deploy.bao.enable - && (m.systemd.services ? swarm-secret-publish) - && (m.systemd.paths ? swarm-secret-publish); - } - { - # What makes the arm above able to fail. Minting the secrets is not being - # able to publish them: with no certificate the unit would fail a TLS - # handshake on every rotation, so it must not exist at all. - name = "an IdP host with no store identity renders no publisher"; - ok = - let - m = secretPublisherNoIdentity; - in - m.services.hyperhive.deploy.authelia.enable - && !(m.systemd.services ? swarm-secret-publish) - && !(m.systemd.paths ? swarm-secret-publish); - } { # A hive's cert-auth role carries the authority by value, so the daemon # has to be handed the file rather than a path into the store's own diff --git a/swarm-controller/src/read_policy.rs b/swarm-controller/src/read_policy.rs index b13103ee..af437575 100644 --- a/swarm-controller/src/read_policy.rs +++ b/swarm-controller/src/read_policy.rs @@ -165,11 +165,11 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> { Ok(()) } -/// One hive's policy: its own name, its own document. +/// One hive's policy: its own name, the shared document. async fn write_policy_for(store: &SecretStore, hive: &str) -> Result<()> { let name = policy::hive_object_name(hive)?; store - .write_policy(&name, &policy::render(hive)?) + .write_policy(&name, &policy::render()) .await .with_context(|| format!("writing the read policy {name}"))?; tracing::info!(hive, policy = %name, "hive read policy in place"); diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs index 9f39fa9a..93891cf4 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -305,7 +305,7 @@ mod tests { let request = WriteAclPolicy { name: "hive-pr1ma".to_owned(), - policy: crate::policy::render("pr1ma").expect("a plain name is legal"), + policy: crate::policy::render(), }; assert_eq!(request.path(), "sys/policies/acl/hive-pr1ma"); } @@ -316,7 +316,7 @@ mod tests { let request = WriteAclPolicy { name: "hive-pr1ma".to_owned(), - policy: crate::policy::render("pr1ma").expect("a plain name is legal"), + policy: crate::policy::render(), }; let body = request .body() @@ -324,10 +324,7 @@ mod tests { .expect("a policy write sends one"); let sent: serde_json::Value = serde_json::from_slice(&body).expect("the body is the JSON the store parses"); - assert_eq!( - sent["policy"], - crate::policy::render("pr1ma").expect("legal") - ); + assert_eq!(sent["policy"], crate::policy::render()); assert!( sent.get("name").is_none(), "`name` addresses the policy in the path; sending it too would make \ diff --git a/swarm-secret-client/src/lib.rs b/swarm-secret-client/src/lib.rs index 43815132..baf77e10 100644 --- a/swarm-secret-client/src/lib.rs +++ b/swarm-secret-client/src/lib.rs @@ -5,8 +5,8 @@ //! the rules every path obeys ([`path`]), the translation from this //! deployment's environment into a logged-in client ([`client`]), and, per kind //! of secret, the path it lives at together with the fields it holds -//! ([`matrix`], [`queue`]). Each of those is a thing the controller and a hive -//! must say identically, so it is said once here. +//! ([`matrix`]). Each of those is a thing the controller and a hive must say +//! identically, so it is said once here. //! //! [`policy`] is the same kind of agreement seen from the other side: which of //! those paths a hive's own token may read. It belongs here rather than in the @@ -21,7 +21,6 @@ pub mod client; pub mod matrix; pub mod path; pub mod policy; -pub mod queue; pub use client::SecretStore; diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index 62201f2d..897b45b5 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -1,25 +1,16 @@ //! The read agreement: which credentials a hive's own token may fetch. //! -//! The mirror of [`crate::matrix`] and [`crate::queue`]. Those modules say -//! where a credential lives; this one says who is allowed to read it, and the -//! two have to agree on the same path or a delivery fails with a 403 that names -//! nothing. +//! The mirror of [`crate::matrix`]. That module says where a credential lives; +//! this one says who is allowed to read it, and the two have to agree on the +//! same path or a delivery fails with a 403 that names nothing. //! -//! The document has one stanza per kind a hive reads, and the two are scoped -//! differently on purpose: -//! -//! ⚠️ The **agent** stanza grants read on *every* agent's credentials rather -//! than on the ones that hive hosts. That is a decision, not an oversight: an -//! agent's path does not name its hive, so a per-hive grant has to be -//! enumerated and re-emitted, and an enumeration that can silently drift -//! advertises a boundary it does not hold. A wide grant that says so beats a -//! narrow one that only looks narrow. The narrower shapes, and what they would -//! cost, are in `docs/trust-boundary/security.md`. -//! -//! The **hive** stanza has no such problem and is therefore narrow: that path -//! names its principal, so scoping it to the reader's own name costs nothing -//! and drifts nowhere. Do not widen it to match its neighbour — the asymmetry -//! is the point. +//! ⚠️ Every hive gets the same document, and it grants read on **every** +//! agent's credentials rather than on the ones that hive hosts. That is a +//! decision, not an oversight: an agent's path does not name its hive, so a +//! per-hive grant has to be enumerated and re-emitted, and an enumeration that +//! can silently drift advertises a boundary it does not hold. A wide grant that +//! says so beats a narrow one that only looks narrow. The narrower shapes, and +//! what they would cost, are in `docs/trust-boundary/security.md`. //! //! Rendering stays separate from writing so the text can be asserted with no //! store to talk to. @@ -46,46 +37,25 @@ pub fn hive_object_name(hive: &str) -> Result { Ok(format!("{HIVE_PREFIX}{hive}")) } -/// One read stanza. The only shape this module emits, so "read-only" is a -/// property of the renderer rather than of each call site. -fn read_stanza(path: &str) -> String { - format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n") -} - -/// Render `hive`'s policy document: read on every agent's credentials, and on -/// this hive's own. +/// Render the document every hive's policy holds: read on every agent's +/// credentials. /// -/// The name is the only input, and it is deploy-time — so the document is -/// still a deploy-time object rather than derived state with a re-emission to -/// get wrong. Nothing about which agents exist changes the text. +/// Takes no arguments because it depends on nothing — same text for every +/// hive, unchanged by which agents exist. That is what makes it a deploy-time +/// object rather than derived state with a re-emission to get wrong. /// /// Read-only: the controller mints these and never reads one back. -/// -/// ⚠️ The service and controller kinds are deliberately absent: a hive has no -/// business reading a service's or the controller's credentials. Adding either -/// is a boundary decision, not a consequence of the namespace growing. -/// -/// The hive's *own* kind is granted, and that is the decision the agent-only -/// version of this grant said had to be made rather than assumed: a hive holds -/// the queue credential its own agents authenticate with, so it has to read the -/// one principal named after itself — and only that one, which is why the path -/// interpolates the name instead of widening to the whole kind. -/// -/// # Errors -/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]` — the -/// name is interpolated into a policy path, so a name that could close the -/// stanza could grant itself anything. -pub fn render(hive: &str) -> Result { - checked_segment("hive", hive)?; - let agents = read_stanza(&format!( - "{MOUNT}/data/{ROOT}/{}/*", +/// ⚠️ Still the agent kind alone. The other kinds are deliberately absent: a +/// hive has no business reading a service's or the controller's credentials, +/// and what a hive may read of its *own* kind is a boundary question this +/// module's header answers only for agents. Widening it is a decision, not a +/// consequence of the namespace growing. +#[must_use] +pub fn render() -> String { + format!( + "path \"{MOUNT}/data/{ROOT}/{}/*\" {{\n capabilities = [\"read\"]\n}}\n", <&str>::from(Kind::Agent) - )); - let own = read_stanza(&format!( - "{MOUNT}/data/{ROOT}/{}/{hive}/*", - <&str>::from(Kind::Hive) - )); - Ok(format!("{agents}{own}")) + ) } #[cfg(test)] @@ -93,19 +63,19 @@ mod tests { use super::*; #[test] - fn the_document_grants_the_whole_agent_prefix_and_this_hive_alone() { + fn the_document_grants_read_over_the_whole_agent_prefix() { assert_eq!( - render("pr1ma").expect("a plain name is legal"), - "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\ - path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n" + render(), + "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n" ); } #[test] fn the_grant_is_read_only() { - // A hive reads credentials; a hive that could write one could hand - // itself an agent's identity. - let p = render("pr1ma").expect("legal"); + // The half of the old policy that survives the widening: a hive reads + // credentials, and a hive that could write one could hand itself an + // agent's identity. + let p = render(); assert!(!p.contains("create")); assert!(!p.contains("update")); assert!(!p.contains("delete")); @@ -113,36 +83,17 @@ mod tests { } #[test] - fn one_hives_document_does_not_reach_another_hives_path() { - // Replaces `every_hive_gets_a_byte_identical_document`: the hive stanza - // is per-reader now, so identical text is no longer the property. This - // is what that test was protecting — that a document says only what the - // deploy-time name puts in it. - let a = render("alpha").expect("legal"); - assert!(a.contains("swarm/hives/alpha/*")); - assert!(!a.contains("beta")); - assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow"); - } - - #[test] - fn the_same_name_still_renders_byte_identically() { - // The half of the old property that survives: the text is a function of - // the deploy-time name alone, so a re-emission cannot drift. - assert_eq!( - render("pr1ma").expect("legal"), - render("pr1ma").expect("legal") - ); - } - - #[test] - fn a_name_that_could_close_the_stanza_is_refused() { - // The name reaches the document now, which it did not before — so the - // injection case is live again in the policy TEXT, not just in the - // policy's identifier. - assert!(render("alpha/*\" { capabilities = [\"root\"] }").is_err()); + fn no_name_reaches_the_document_at_all() { + // Why the injection cases that used to live here are gone rather than + // relaxed: nothing interpolates into the text any more, so there is no + // stanza for a name to close. `hive_object_name` still validates, + // because a name does reach the policy's *identifier*. + let p = render(); + assert!(!p.contains("pr1ma")); + assert_eq!(p.matches("path \"").count(), 1, "one stanza, no per-agent"); assert!( hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(), - "the object NAME is a place a name can do damage too" + "the object NAME is still a place a name can do damage" ); } @@ -151,7 +102,13 @@ mod tests { // The control for the case above: if every name were refused, that // assertion would pass while proving nothing. assert!(hive_object_name("a-b_C9").is_ok()); - assert!(render("a-b_C9").is_ok()); + } + + #[test] + fn every_hive_gets_a_byte_identical_document() { + // The property the deploy-time write depends on: nothing about a hive + // or its agents changes the text, so there is nothing to re-emit. + assert_eq!(render(), render()); } #[test] diff --git a/swarm-secret-client/src/queue.rs b/swarm-secret-client/src/queue.rs deleted file mode 100644 index ee424e42..00000000 --- a/swarm-secret-client/src/queue.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! The queue agreement: where a hive's agent-container credential lives in the -//! store, and what the object at that path holds. -//! -//! The sibling of [`crate::matrix`], and it differs from it in one way worth -//! reading before using either: a matrix credential is keyed per **agent**, -//! this one per **hive**. Agents are created at runtime, so the queue -//! identity they present is minted once per hive at deploy time and says which -//! hive an agent belongs to, never which agent. - -use serde::{Deserialize, Serialize}; - -use crate::{ - Error, - path::{Kind, principal_prefix}, -}; - -/// The path holding the client secret that agent containers on `hive` present -/// to the swarm queue. -/// -/// # Errors -/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`, -/// which is what keeps one hive's name from addressing another hive's secret. -pub fn agent_client_path(hive: &str) -> Result { - let prefix = principal_prefix(Kind::Hive, hive)?; - Ok(format!("{prefix}/queue/agent")) -} - -/// What the path holds: the client secret, plus the client id it belongs to. -/// -/// The id rides with the secret for the same reason the homeserver rides with -/// a matrix token — a credential has to be reconstructable from the store -/// alone. Deriving it on the reading side instead would mean spelling -/// `hive--agent` in a second place, and the authelia module's own option -/// says what a split spelling costs: every agent is denied as a timeout. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Credential { - /// The secret itself. Named to match [`crate::matrix::Credential::value`] - /// so a nix-side reader spells `bao kv get -field=value` for either kind. - pub value: String, - - /// The OIDC client id the secret authenticates. - /// - /// Required, unlike [`crate::matrix::Credential::homeserver`]: that field - /// is optional because it was added to objects already in the store, and - /// this path is new. An object without an id is not a usable credential, - /// so failing to decode is the wanted behaviour — the alternative is a - /// reader holding `None` whose only recourse is to spell the id itself. - pub client_id: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_hive_name_lands_under_its_own_principal_prefix() { - assert_eq!( - agent_client_path("alpha").expect("a plain name is legal"), - "swarm/hives/alpha/queue/agent" - ); - } - - #[test] - fn a_traversal_in_the_hive_name_is_refused() { - let e = agent_client_path("../beta").expect_err("a traversal is not"); - assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}"); - } - - #[test] - fn two_hives_never_share_a_path() { - assert_ne!( - agent_client_path("alpha").expect("legal"), - agent_client_path("beta").expect("legal") - ); - } - - #[test] - fn the_object_round_trips_through_the_store_representation() { - let c = Credential { - value: "s3cr3t".to_owned(), - client_id: "hive-alpha-agent".to_owned(), - }; - let json = serde_json::to_string(&c).expect("serialises"); - assert_eq!( - serde_json::from_str::(&json).expect("deserialises"), - c - ); - } - - #[test] - fn the_field_names_the_nix_reader_asks_for_are_the_ones_written() { - let json = serde_json::to_value(Credential { - value: "s3cr3t".to_owned(), - client_id: "hive-alpha-agent".to_owned(), - }) - .expect("serialises"); - assert_eq!(json["value"], "s3cr3t"); - assert_eq!(json["client_id"], "hive-alpha-agent"); - } -}