Watch
0
0
Fork
You've already forked hyperhive
0

swarm-secret-client: hives no longer read swarm/services/*

The hive policy rendered by policy::render granted every hive cert read
on secret/data/swarm/services/*, which holds each swarm service's OIDC
client secret. No reader of that tree authenticates with a hive cert:
grafana, the otel collector and the store forwarder each read with
their own leaf, and the bao UI's secret is read by the store's granter.

render() now emits two stanzas: swarm/agents/* and the hive's own
swarm/hives/<hive>/*. Kind::Service had no other caller and is removed.
Docs and comments that described the grant as a decision are rewritten
to the current state.

Closes #4960
This commit is contained in:
atlas 2026-10-04 22:32:28 +02:00 • committed by mara
commit 2505008b83
13 changed files with 82 additions and 136 deletions

View file

@ -69,7 +69,7 @@ of the cell says how.
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated | | `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs | | `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated | | `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | must be stated | | `swarm/services/<clientId>/oidc/client` | authelia | that service's own reader, under its own certificate; the bao UI's by the store's granter | must be stated | must be stated |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated | | _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->

View file

@ -72,37 +72,24 @@ advertises a boundary it doesn't actually hold. A wide grant that says what it
is beats a narrow one that only looks narrow. is beats a narrow one that only looks narrow.
What still holds: the grant is **read-only** (a hive can't write an agent's What still holds: the grant is **read-only** (a hive can't write an agent's
credential, so it can't hand itself an agent's identity), and it reaches three credential, so it can't hand itself an agent's identity), and it reaches two
prefixes and nothing else in the store — every agent's credentials, the reader's prefixes and nothing else in the store — every agent's credentials, and the
_own_ entry under the hive namespace, which names the hive asking and so widens reader's _own_ entry under the hive namespace, which names the hive asking and
nothing between them, and every **swarm service's** OIDC client secret. so widens nothing between them.
That third prefix has the same shape of reason as the first, and the same honest A hive reads no **swarm service's** OIDC client secret. Every reader of
cost. A swarm service (Grafana and the swarm collector are the two there today) `swarm/services/` presents a certificate that isn't a hive's: Grafana, the swarm
registers **one** client for the whole swarm, so its credential's path names collector and the store forwarder each fetch theirs with a leaf of their own,
the service and never the host — and which hive runs a given service is a and the store's granter fetches the bao UI's.
`deploy.*` fact, per-host by definition, so nothing swarm-wide exists to scope
the grant to. The host running such a service has no store identity of its own
either; it reads with the certificate of the hive it is. **Any hive can
therefore read any swarm service's client secret**, which is worth what it
buys: every swarm service gets its credential the same way from anywhere,
instead of only where the identity provider happens to sit. Giving such a
service its own store identity is what would remove this rather than re-scope
it.
**A tracked follow-up** narrows this, with the two candidate directions: scope ### Per-hive credentials live under the hive kind
the grant per hive (and pay for the re-emission), or give each agent container
its own store identity so credentials never pass through a hive at all.
### Per-hive credentials live under the hive kind, not beside a service
`swarm_secret_client::policy` scopes the **hive** stanza narrowly (unlike the `swarm_secret_client::policy` scopes the **hive** stanza narrowly (unlike the
agent and service stanzas above) because a hive's own path names it, so agent stanza above) because a hive's own path names it, so scoping to the
scoping to the reader's own name costs nothing and drifts nowhere. That only reader's own name costs nothing and drifts nowhere. That only holds if a
holds if a credential that must be one-per-hive is actually stored under the credential that must be one-per-hive is actually stored under the hive kind.
hive kind.
**A credential that must be one-per-hive goes under `Kind::Hive`**; putting it **A credential that must be one-per-hive goes under `Kind::Hive`**; putting it
under `Kind::Service` and expecting the grant to scope it — that's the mistake under the agent prefix and expecting the grant to scope it — that's the mistake
this note exists to stop. this note exists to stop.
### Threat model: prompt injection → confused deputy ### Threat model: prompt injection → confused deputy

View file

@ -218,8 +218,7 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
/// this process's environment, so the store read costs no plumbing through /// this process's environment, so the store read costs no plumbing through
/// [`ensure_all`]. That name is also the path's own hive segment, which is /// [`ensure_all`]. That name is also the path's own hive segment, which is
/// what makes this read reach **this** hive's token and 403 on any other's: /// what makes this read reach **this** hive's token and 403 on any other's:
/// the grant behind it is the hive's own `swarm/hives/<name>/*` stanza, not /// the grant behind it is the hive's own `swarm/hives/<name>/*` stanza.
/// the swarm-wide `swarm/services/*` one the shared token used to sit under.
/// ///
/// `None`, never an error, for every way this can come up empty — no hive /// `None`, never an error, for every way this can come up empty — no hive
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All /// name, no `BAO_*` identity, an unreachable store, nothing at the path. All

View file

@ -759,7 +759,7 @@ let
# #
# 🩸 Each of the four reads exactly ONE path in the store, and until this # 🩸 Each of the four reads exactly ONE path in the store, and until this
# split each did it holding `deploy.bao.clientCertFile` — the hive's own leaf, # split each did it holding `deploy.bao.clientCertFile` — the hive's own leaf,
# whose policy (`swarm-secret-client`'s `policy::render`) grants read on # whose policy (`swarm-secret-client`'s `policy::render`) granted read on
# `swarm/agents/*`, `swarm/hives/<hive>/*` AND `swarm/services/*`. Four # `swarm/agents/*`, `swarm/hives/<hive>/*` AND `swarm/services/*`. Four
# principals presenting one certificate are ONE principal to bao, so the union # principals presenting one certificate are ONE principal to bao, so the union
# of what the four need was the only grant expressible: the unit that fetches # of what the four need was the only grant expressible: the unit that fetches
@ -1142,8 +1142,7 @@ let
# own and lands it in a collector's container — because that route is # own and lands it in a collector's container — because that route is
# proven and there is no second one worth inventing. # proven and there is no second one worth inventing.
# #
# Where the publisher on authelia's host leaves it. The `services` segment # Where the publisher on authelia's host leaves it. A swarm service's client
# is `swarm-secret-client`'s `path::Kind::Service`: a swarm service's client
# is registered once for the whole swarm, so its secret is one value. The # is registered once for the whole swarm, so its secret is one value. The
# `swarm-forwarder-oidc` role's grant is this one path, so any other answers # `swarm-forwarder-oidc` role's grant is this one path, so any other answers
# 403 however correct it looks. # 403 however correct it looks.
@ -2288,8 +2287,8 @@ in
environment = { environment = {
BAO_ADDR = "https://${cfg.domain}:${toString cfg.port}"; BAO_ADDR = "https://${cfg.domain}:${toString cfg.port}";
# 🩸 Its OWN leaf, not `clientCertFile`: the hive's grant reads every # 🩸 Its OWN leaf, not `clientCertFile`: the hive's grant reads every
# agent's credential and every service's OIDC secret, and this unit # agent's credential, and this unit needs one path. The pair is
# needs one path. The pair is asserted set above. # asserted set above.
BAO_CLIENT_CERT = baoDeploy.forwarderOidcClientCertFile; BAO_CLIENT_CERT = baoDeploy.forwarderOidcClientCertFile;
BAO_CLIENT_KEY = baoDeploy.forwarderOidcClientKeyFile; BAO_CLIENT_KEY = baoDeploy.forwarderOidcClientKeyFile;
} }

View file

@ -238,8 +238,7 @@ let
hostSecretPath = "/var/lib/nixos-containers/${cfg.machine}${secretPath}"; hostSecretPath = "/var/lib/nixos-containers/${cfg.machine}${secretPath}";
hostSecretDir = builtins.dirOf hostSecretPath; hostSecretDir = builtins.dirOf hostSecretPath;
# Where the publisher on authelia's host leaves this client's secret. The # Where the publisher on authelia's host leaves this client's secret. A
# `services` segment is `swarm-secret-client`'s `path::Kind::Service` — a
# swarm service's client is registered once for the whole swarm, so its # swarm service's client is registered once for the whole swarm, so its
# secret is one value and not one per hive. Both ends compose this from the # secret is one value and not one per hive. Both ends compose this from the
# same swarm-wide client id, so a rename cannot leave one of them behind. # same swarm-wide client id, so a rename cannot leave one of them behind.

View file

@ -272,10 +272,8 @@ let
# Where the publisher on authelia's host leaves this client's secret — # Where the publisher on authelia's host leaves this client's secret —
# composed from the same swarm-wide `clientId` the registration in # composed from the same swarm-wide `clientId` the registration in
# ./glue-swarm-otel-oidc-client.nix uses, so a rename cannot leave one of # ./glue-swarm-otel-oidc-client.nix uses, so a rename cannot leave one of
# them behind. The `services` segment is `swarm-secret-client`'s # them behind. The same prefix ./swarm-grafana.nix reads its own client
# `path::Kind::Service`, the same prefix ./swarm-grafana.nix reads its own # secret under; the `swarm-otel-oidc` role in ./swarm-bao.nix grants the read.
# client secret under — one write grant in ./swarm-bao.nix and one hive read
# grant in `policy::render` already cover it.
storeSecretPath = "secret/swarm/services/${cfg.clientId}/oidc/client"; storeSecretPath = "secret/swarm/services/${cfg.clientId}/oidc/client";
# The operator-configured upstream, named once: the same exporter carries # The operator-configured upstream, named once: the same exporter carries

View file

@ -60,8 +60,7 @@ let
# The swarm's own services, as opposed to its hives. One client for the whole # The swarm's own services, as opposed to its hives. One client for the whole
# swarm rather than one per hive, so one value in the store rather than a copy # swarm rather than one per hive, so one value in the store rather than a copy
# each: `swarm/services/<id>/oidc/client`, under the `services` kind # each: `swarm/services/<id>/oidc/client`.
# `swarm-secret-client`'s `path::Kind` declares.
# #
# The ids come from `swarm.*`, which is identical on every host — that is what # The ids come from `swarm.*`, which is identical on every host — that is what
# lets this host name a service's client while running none of them, and it is # lets this host name a service's client while running none of them, and it is
@ -267,9 +266,8 @@ in
fi fi
'') serviceClientIds} '') serviceClientIds}
# The controller's own client, under `controller/` rather than # The controller's own client, under `controller/`, which no hive
# `services/`: every hive reads `services/*`, and this client may read # reads: this client may read every hive's status. The nix half of
# every hive's status. The nix half of
# `swarm_secret_client::queue::controller_client_path`. # `swarm_secret_client::queue::controller_client_path`.
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${controllerClientId}.secret"} src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${controllerClientId}.secret"}
if [ -s "$src" ]; then if [ -s "$src" ]; then

View file

@ -482,7 +482,7 @@ let
# ── the four readers that used to share the hive's own leaf ────────────── # ── the four readers that used to share the hive's own leaf ──────────────
# #
# 🩸 Until this split all four presented `deploy.bao.clientCertFile`, whose # 🩸 Until this split all four presented `deploy.bao.clientCertFile`, whose
# policy grants read on `swarm/agents/*`, `swarm/hives/<hive>/*` AND # policy granted read on `swarm/agents/*`, `swarm/hives/<hive>/*` AND
# `swarm/services/*`. Four principals behind one certificate are one # `swarm/services/*`. Four principals behind one certificate are one
# principal to bao, so the only expressible grant was the union: the unit # principal to bao, so the only expressible grant was the union: the unit
# fetching Grafana's OIDC secret could fetch every agent credential in the # fetching Grafana's OIDC secret could fetch every agent credential in the

View file

@ -88,7 +88,7 @@ let
{ {
# The controller reads this exact path # The controller reads this exact path
# (`swarm_secret_client::queue::controller_client_path`, pinned by that # (`swarm_secret_client::queue::controller_client_path`, pinned by that
# crate's own test). Not under `services/`, which every hive reads: this # crate's own test). Under `controller/`, which no hive reads: this
# client may read every hive's status. # client may read every hive's status.
name = "the publisher writes the controller's client under controller/, never services/"; name = "the publisher writes the controller's client under controller/, never services/";
ok = ok =

View file

@ -60,18 +60,10 @@ pub fn hive_localpart(hive: &str) -> String {
/// The path holding `hive`'s matrix sender account's homeserver access token. /// The path holding `hive`'s matrix sender account's homeserver access token.
/// ///
/// Keyed per **hive**, like [`appservice_token_path`] below and unlike the /// Keyed per **hive**, like [`appservice_token_path`] below. Under
/// per-homeserver constant this replaced. The old path /// [`Kind::Hive`] the grant that reaches it is the hive's **own** stanza,
/// (`swarm/services/matrix/sender-token`) held **one value for the whole /// `secret/data/swarm/hives/<this hive>/*`, which interpolates the name: hive
/// swarm** and sat under the [`Kind::Service`] tree that /// `alpha` reads its own token and gets a 403 on `beta`'s.
/// [`crate::policy::render`] grants *every* hive read on — so every hive could
/// read, and act as, the one shared account.
///
/// Under [`Kind::Hive`] the grant that reaches it is the hive's **own**
/// stanza, `secret/data/swarm/hives/<this hive>/*`, which interpolates the
/// name: hive `alpha` reads its own token and gets a 403 on `beta`'s. Nothing
/// was added to the policy to do that — the path moved out from under the
/// broad grant, which is the half that makes it real.
/// ///
/// # Errors /// # Errors
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`, /// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
@ -112,9 +104,8 @@ pub(crate) const CONTROLLER: &str = "swarm-controller";
/// ///
/// The matrix container mints it and publishes it here; `swarm-controller` /// The matrix container mints it and publishes it here; `swarm-controller`
/// reads it to create agents' accounts. Under [`Kind::Controller`] because /// reads it to create agents' accounts. Under [`Kind::Controller`] because
/// that is the one kind [`crate::policy::render`] grants no hive: every hive /// [`crate::policy::render`] grants no hive that kind: every hive reads
/// reads `agents/*`, its own `hives/<hive>/*` and `services/*`, so under any /// `agents/*` and its own `hives/<hive>/*`, and nothing else.
/// of those this admin credential would be readable by every hive.
/// ///
/// # Errors /// # Errors
/// Never in practice: the name segment is a constant. The `Result` is /// Never in practice: the name segment is a constant. The `Result` is
@ -174,18 +165,6 @@ mod tests {
); );
} }
#[test]
fn the_sender_token_left_the_tree_every_hive_can_read() {
// 🩸 The whole point of the move. `policy::render` grants every hive
// read on `secret/data/swarm/services/*` — a deliberate grant that
// stays, because a service's OIDC secret is read with the certificate
// of whatever hive hosts it. What must not stay is this credential
// sitting inside it: under `services/` one shared token was readable
// by every hive, which is one matrix identity for the whole swarm.
let p = sender_token_path("pr1ma").expect("legal");
assert!(!p.starts_with("swarm/services/"), "{p}");
}
#[test] #[test]
fn one_hives_sender_token_is_not_another_hives() { fn one_hives_sender_token_is_not_another_hives() {
// The property the per-hive path exists for: two hives never name the // The property the per-hive path exists for: two hives never name the

View file

@ -40,9 +40,6 @@ pub enum Kind {
/// is what uses it. /// is what uses it.
#[strum(serialize = "hives")] #[strum(serialize = "hives")]
Hive, Hive,
/// One swarm service — the things a swarm runs beside the controller.
#[strum(serialize = "services")]
Service,
/// The controller itself. There is one per swarm, so the name segment /// The controller itself. There is one per swarm, so the name segment
/// below it does not vary; the shape stays uniform anyway, because one /// below it does not vary; the shape stays uniform anyway, because one
/// grant pattern over `<kind>/<name>` is cheaper than a special case. /// grant pattern over `<kind>/<name>` is cheaper than a special case.
@ -53,7 +50,7 @@ pub enum Kind {
impl Kind { impl Kind {
/// Every kind, so callers that must cover the whole set can iterate rather /// Every kind, so callers that must cover the whole set can iterate rather
/// than restate it — a second list is a list that drifts. /// than restate it — a second list is a list that drifts.
pub const ALL: [Kind; 4] = [Kind::Agent, Kind::Hive, Kind::Service, Kind::Controller]; pub const ALL: [Kind; 3] = [Kind::Agent, Kind::Hive, Kind::Controller];
/// What to call the name in an error — singular, because the message reads /// What to call the name in an error — singular, because the message reads
/// "hive name ... is not a single path segment". /// "hive name ... is not a single path segment".
@ -62,7 +59,6 @@ impl Kind {
match self { match self {
Kind::Agent => "agent", Kind::Agent => "agent",
Kind::Hive => "hive", Kind::Hive => "hive",
Kind::Service => "service",
Kind::Controller => "controller", Kind::Controller => "controller",
} }
} }
@ -175,7 +171,7 @@ mod tests {
// decision. Spelling each one out is what makes adding a kind a // decision. Spelling each one out is what makes adding a kind a
// deliberate edit. // deliberate edit.
let segments: Vec<&str> = Kind::ALL.iter().map(|k| (*k).into()).collect(); let segments: Vec<&str> = Kind::ALL.iter().map(|k| (*k).into()).collect();
assert_eq!(segments, ["agents", "hives", "services", "controller"]); assert_eq!(segments, ["agents", "hives", "controller"]);
} }
#[test] #[test]

View file

@ -16,16 +16,15 @@
//! narrow one that only looks narrow. The narrower shapes, and what they would //! narrow one that only looks narrow. The narrower shapes, and what they would
//! cost, are in `docs/trust-boundary/security.md`. //! cost, are in `docs/trust-boundary/security.md`.
//! //!
//! ⚠️ The **service** stanza is wide for the same shape of reason: a swarm
//! service's client is registered once per swarm, so its path names the service
//! and never the host, and which hive runs a service is a `deploy.*` fact with
//! no swarm-wide spelling to scope against. Cost: the same doc.
//!
//! The **hive** stanza has no such problem and is therefore narrow: that path //! 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 //! names its principal, so scoping it to the reader's own name costs nothing
//! and drifts nowhere. Do not widen it to match its neighbours — the asymmetry //! and drifts nowhere. Do not widen it to match the agent stanza — the
//! is the point, and only holds if a one-per-hive credential is stored under //! asymmetry is the point, and only holds if a one-per-hive credential is
//! [`Kind::Hive`], not [`Kind::Service`] — see docs/trust-boundary/security.md. //! stored under [`Kind::Hive`] — see docs/trust-boundary/security.md.
//!
//! A hive reads nothing under `swarm/services/` or `swarm/controller/`. A swarm
//! service's OIDC client secret is read under a certificate that is not a
//! hive's: `swarm-bao.nix`'s per-service readers, and its granter for the UI's.
//! //!
//! Rendering stays separate from writing so the text can be asserted with no store to talk to. //! Rendering stays separate from writing so the text can be asserted with no store to talk to.
@ -127,8 +126,8 @@ fn list_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n") format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
} }
/// Render `hive`'s policy document: read on every agent's credentials, on this /// Render `hive`'s policy document: read on every agent's credentials and on
/// hive's own, and on the swarm services'. /// this hive's own.
/// ///
/// The name is the only input, and it is deploy-time — so the document is /// 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 /// still a deploy-time object rather than derived state with a re-emission to
@ -136,15 +135,11 @@ fn list_stanza(path: &str) -> String {
/// ///
/// Read-only: the controller mints these and never reads one back. /// Read-only: the controller mints these and never reads one back.
/// ///
/// ⚠️ The controller kind is deliberately absent: a hive has no business /// ⚠️ The controller kind and the `swarm/services/` tree are deliberately
/// reading the credentials of the thing that provisions it. Adding it is a /// absent: a hive has no business reading the credentials of the thing that
/// boundary decision, not a consequence of the namespace growing. /// provisions it, and a swarm service's OIDC client secret is read under a
/// /// certificate that is not a hive's. Adding either is a boundary decision,
/// The **service** kind is granted, and that was such a decision rather than a /// not a consequence of the namespace growing.
/// consequence: a service whose identity provider is on another host reads its
/// own OIDC client secret with the certificate of the hive it runs on, that
/// being the only identity such a host has — so every hive can read every
/// service's. Bought and paid for in `docs/trust-boundary/security.md`.
/// ///
/// The hive's *own* kind is granted, and that is the decision the agent-only /// 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 /// version of this grant said had to be made rather than assumed: a hive holds
@ -166,11 +161,7 @@ pub fn render(hive: &str) -> Result<String, Error> {
"{MOUNT}/data/{ROOT}/{}/{hive}/*", "{MOUNT}/data/{ROOT}/{}/{hive}/*",
<&str>::from(Kind::Hive) <&str>::from(Kind::Hive)
)); ));
let services = read_stanza(&format!( Ok(format!("{agents}{own}"))
"{MOUNT}/data/{ROOT}/{}/*",
<&str>::from(Kind::Service)
));
Ok(format!("{agents}{own}{services}"))
} }
/// Render `agent`'s policy document: read on that one agent's credentials, list /// Render `agent`'s policy document: read on that one agent's credentials, list
@ -186,13 +177,13 @@ pub fn render(hive: &str) -> Result<String, Error> {
/// ///
/// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants /// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants
/// read on `swarm/agents/*` — *every* agent's credentials, not the ones that /// read on `swarm/agents/*` — *every* agent's credentials, not the ones that
/// hive hosts — and on `swarm/services/*` the same way; the module header says /// hive hosts; the module header says why that is a decision rather than an
/// why each is a decision rather than an oversight. Neither reason transfers: /// oversight. The reason does not transfer: an agent's path names the agent,
/// an agent's path names the agent, so scoping costs nothing and drifts nowhere /// so scoping costs nothing and drifts nowhere (the argument that keeps
/// (the argument that keeps [`render`]'s hive stanza narrow), and an agent has /// [`render`]'s hive stanza narrow), and an agent has no business with another
/// no business with a service's OIDC secret, another agent's credentials, a /// agent's credentials, a hive's, a service's, or the controller's. Narrow
/// hive's, or the controller's. Narrow here does **not** narrow the hive's: a /// here does **not** narrow the hive's: a hive still reads this agent's
/// hive still reads this agent's secrets, and closing that is its own decision. /// secrets, and closing that is its own decision.
/// Nothing here writes, for the reason the hive's does not: an agent that could /// Nothing here writes, for the reason the hive's does not: an agent that could
/// write its own credentials could hand itself an identity it was never issued. /// write its own credentials could hand itself an identity it was never issued.
/// ///
@ -216,26 +207,31 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn the_document_grants_two_whole_prefixes_and_this_hive_alone() { fn the_document_grants_every_agent_and_this_hive_alone() {
assert_eq!( assert_eq!(
render("pr1ma").expect("a plain name is legal"), render("pr1ma").expect("a plain name is legal"),
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\ "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n\ path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n"
path \"secret/data/swarm/services/*\" {\n capabilities = [\"read\"]\n}\n"
); );
} }
#[test] #[test]
fn the_service_stanza_covers_a_service_this_hive_was_never_named_beside() { fn a_hive_reaches_no_swarm_services_secret() {
// The property the swarm-grafana delivery depends on: the host running a // A swarm service's OIDC client secret is read under a certificate that
// swarm service reads that service's client secret with its own hive // is not a hive's. Checked by walking the stanzas, so a wider grant
// certificate, and the path names the service rather than the host. A // that happened to cover the tree (`swarm/*`, say) fails too.
// stanza narrowed to the reader's name would 403 every such read.
let p = render("pr1ma").expect("legal"); let p = render("pr1ma").expect("legal");
assert!(p.contains("path \"secret/data/swarm/services/*\"")); assert!(!p.contains("swarm/services"), "{p}");
let service = "swarm/services/swarm-grafana/oidc/client";
assert!( assert!(
!p.contains("services/pr1ma"), !stanza_paths(&p).iter().any(|g| covers(g, service)),
"the service stanza is not scoped to the reader" "a hive's document reaches {service}:\n{p}"
);
// The control: the walk does find a path the document grants.
assert!(
stanza_paths(&p)
.iter()
.any(|g| covers(g, "swarm/hives/pr1ma/queue/agent"))
); );
} }
@ -243,10 +239,6 @@ mod tests {
/// document rather than on the path function alone: it is the policy text /// document rather than on the path function alone: it is the policy text
/// that decides what a hive may fetch, so "the path is per-hive" only /// that decides what a hive may fetch, so "the path is per-hive" only
/// means something if the stanza that reaches it is too. /// means something if the stanza that reaches it is too.
///
/// The `services/*` stanza above is deliberate and stays — it is how a
/// host reads the OIDC secret of a service it runs. The property here is
/// that the matrix sender token is no longer *inside* it.
#[test] #[test]
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() { fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
let alpha = render("alpha").expect("legal"); let alpha = render("alpha").expect("legal");
@ -516,16 +508,16 @@ mod tests {
fn an_agents_document_is_not_the_hives() { fn an_agents_document_is_not_the_hives() {
// The pin the two renderers need against each other: a later edit that // The pin the two renderers need against each other: a later edit that
// made `render_agent` delegate to `render`, or vice versa, would hand // made `render_agent` delegate to `render`, or vice versa, would hand
// an agent the hive's two wide stanzas. // an agent the hive's `agents/*` stanza.
let agent = render_agent("atlas").expect("legal"); let agent = render_agent("atlas").expect("legal");
let hive = render("atlas").expect("legal"); let hive = render("atlas").expect("legal");
assert_ne!(agent, hive); assert_ne!(agent, hive);
assert!(!agent.contains("swarm/services/")); assert!(!agent.contains("swarm/services/"));
assert!(!agent.contains("swarm/hives/")); assert!(!agent.contains("swarm/hives/"));
// And the hive's is untouched by this module's growth: still the three // And the hive's is untouched by this module's growth: still the two
// stanzas `the_document_grants_two_whole_prefixes_and_this_hive_alone` // stanzas `the_document_grants_every_agent_and_this_hive_alone` pins
// pins byte for byte, none of them narrowed to make the agent's look // byte for byte, neither narrowed to make the agent's look consistent
// consistent with it. // with it.
assert!(hive.contains("path \"secret/data/swarm/agents/*\"")); assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
} }

View file

@ -63,9 +63,8 @@ pub fn agent_queue_path(agent: &str) -> Result<String, Error> {
/// The path holding `swarm-controller`'s own OIDC client secret: the identity /// The path holding `swarm-controller`'s own OIDC client secret: the identity
/// it connects to the queue with, and mints its other bearer tokens from. /// it connects to the queue with, and mints its other bearer tokens from.
/// ///
/// Under [`Kind::Controller`] because no hive's policy reads that kind. The /// Under [`Kind::Controller`] because no hive's policy reads that kind, and
/// client may read every hive's status, so a copy under `services/`, which /// this client may read every hive's status.
/// every hive reads, would hand that reach to every hive.
/// ///
/// The nix half is `swarm-secret-publisher.nix`, which writes it, and /// The nix half is `swarm-secret-publisher.nix`, which writes it, and
/// `swarm-bao.nix`'s `controllerPolicyText`, which grants the read. /// `swarm-bao.nix`'s `controllerPolicyText`, which grants the read.