matrix: one sender account and one sender token per hive

A swarm runs one homeserver and every hive on it logged in as the same
`@hive:` localpart, holding the same access token out of one swarm-wide
store path. That is one matrix identity for N hives: the homeserver
cannot attribute an action to the hive that took it, and revoking one
hive's standing revokes every hive's.

Three changes, and the third is the one that makes the other two real:

- **The localpart carries the hive's name** (`hive-<hive>`), derived in
  one place, `swarm_secret_client::matrix::hive_localpart`.
  `hive-matrix.nix` renders the same string as the appservice
  registration's `sender_localpart`, so the shared account stops being
  created rather than merely stops being used.
- **The store path is templated by hive**, not a constant. The
  "a swarm runs one homeserver, so this is a constant rather than a
  parameter" rationale went with it; it stopped holding the moment two
  hives shared the homeserver it describes.
- **The path moved out from under the grant every hive has.** It sat at
  `swarm/services/matrix/sender-token`, inside the
  `secret/data/swarm/services/*` read stanza `policy::render` gives every
  hive. It now sits under that hive's own stanza,
  `secret/data/swarm/hives/<hive>/*`, which interpolates the reader's
  name — so a hive reads its own token and is refused another's. The
  policy renderer itself is unchanged: narrowing the `services/*` grant
  would break the OIDC-secret read it exists for, and moving the
  credential is what this needed instead. A policy test walks the
  rendered stanzas and asserts none of hive alpha's covers hive beta's
  sender token, so a later stanza that widened it fails here.

`swarm-matrix-ctl` takes a new required `MATRIX_MINT_HIVE` and writes
that hive's path; its store grant in `swarm-bao.nix` follows, scoped to
one hive's leaf via the new `deploy.bao.matrixCtlHiveName` (defaulting to
this host's `hiveName`) rather than a `hives/*` wildcard, which would
hand the matrix container every hive's token back.

Migration: no outage at deploy. `ensure_hive_user` short-circuits on the
local token file, so a hive keeps running on what it has; with no such
file it reads the new per-hive path, finds nothing, and falls through to
the existing register-or-appservice-login ladder against its own
localpart — which needs only the per-hive `as_token` on local disk. The
old shared object is read by nothing afterwards. Rooms do not follow the
identity, and that is the one operator step; both ways out are written
into `docs/integrations/matrix.md`.

No admin standing is granted to the per-hive accounts: `admin_execute`
stays empty and the assertion pinning it is untouched.
This commit is contained in:
atlas 2026-09-20 20:06:31 +02:00 committed by mara
commit 1261b525d6
14 changed files with 461 additions and 133 deletions

View file

@ -149,19 +149,27 @@ a token.
### The appservice's sender account, and why it isn't an admin
`@hive:<server_name>` is the appservice's own `sender_localpart`, which
the homeserver creates itself when it loads the registration — on a
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
which the homeserver creates itself when it loads the registration — on a
zero-user database, inside startup, before the HTTP listener accepts
anything. It's an **ordinary account**: nothing promotes it, and the
homeserver runs no `admin_execute` for it.
**One account per hive.** The localpart carries the hive's name, so a swarm
whose hives share a homeserver gives each of them its own identity: the
homeserver attributes an action to the hive that took it, and revoking one
hive's standing leaves the others alone.
Its access token is the **sender token**, and it's the credential
hive-c0re presents for every homeserver call it makes on the hive's
behalf. `swarm-matrix-ctl` mints it inside the `hive-matrix`
container and publishes it to `swarm/services/matrix/sender-token`; the
hive reads it from there. The name says what it authenticates as — an
account the appservice registration brings into being — rather than any
privilege level, because it carries none.
behalf. It's per hive for the same reason the account is:
`swarm-matrix-ctl` mints it inside the `hive-matrix` container and
publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
reads it from there under its own certificate. That path sits inside the
hive's own read grant (`swarm/hives/<hive>/*`), so a hive fetches its own
token and gets a refusal on any other hive's. The name says what it
authenticates as — an account the appservice registration brings into being
— rather than any privilege level, because it carries none.
It needs no promotion for what the hive does with it. Creating the hive
Space and the chat room, writing their hierarchy and join rules, and
@ -175,13 +183,51 @@ and `hivectl matrix reset-password`. Both are `!admin …` messages into
`#admins:<server_name>`, and tuwunel only treats a message as a command
when its sender is already an admin. They're swarm-level operations,
rehomed to the swarm tier rather than granted here; from the hive,
`@hive:` has no admin sender to make that call with, so both get the
`@hive-<hive>:` has no admin sender to make that call with, so both get the
admin room's refusal rather than an over-privileged credential that
every other call site would also carry. The one hive-side path that
depends on them is the automatic password recovery for an agent that
has lost its stored password — the admin-sender limitation doesn't
touch the ordinary appservice re-login above.
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
Nothing to do, and no window where the hive is without an account.
<!-- vale write-good.Passive = NO -->
- **The old shared value at `swarm/services/matrix/sender-token` is read by
nothing.** `hive-c0re` and `swarm-matrix-ctl` both build the path from the
same function, and it now carries the hive's name — so the old object
stays in the store, unread, until an operator deletes it. Delete it or
leave it; the accounts it authenticates as keep their own standing either
way, since an access token lives on the device that minted it.
- **The hive re-mints, per hive, on the next boot.** `ensure_hive_user` runs
from the startup sweep. It short-circuits on the token file it already has
— so the first boot after the upgrade keeps running on the shared token
until that file goes — and on a hive with no such file it reads the new
per-hive store path, finds nothing on a store that has never held one, and
falls through to the register-or-appservice-login ladder against
`@hive-<hive>:`. That ladder needs only the `as_token`, which is per hive
and on local disk, so it works with or without a reachable store.
- **To move a hive onto its own account now**, delete its sender-token file
(`hivectl matrix sync-admin` re-provisions, or the next sweep does) — the
ladder then registers `@hive-<hive>:` and persists that account's token.
Only then does the hive stop presenting the shared one.
- **The rooms the shared account created don't follow the new account, and
this is the one step that needs a decision.** Membership is per account.
`ensure_hive_space` takes the stored room id first, so the sweep hands the
new account the old Space's id, the invite it then sends comes back
refused (a non-member can't invite), and the sweep logs it and carries
on — degraded, not crashed. Two ways out, both operator-chosen. Either invite
`@hive-<hive>:` into the existing Space and chat room from a client, which
keeps the history; or delete the hive's stored room-id files, after which
the next sweep creates a Space and chat room owned by the new account and
invites every agent into them. Do one of the two; leaving it gives a hive
that provisions no rooms.
<!-- vale write-good.Passive = YES -->
</details>
<details><summary>Upgrading a hive that used the registration token</summary>
Nothing to do, and nothing to time. The activation script mints the
@ -248,8 +294,8 @@ Initial rollout settings:
## Hive Matrix Space
On first boot, after hive-c0re provisions all agent accounts, it
creates a private **Matrix Space** named `"hive"` using the `@hive:`
account (`@hive:<server_name>`) and invites every provisioned agent
creates a private **Matrix Space** named `"hive"` using its own hive
account (`@hive-<hive>:<server_name>`) and invites every provisioned agent
into it. This gives the operator a single Space in FluffyChat or any
Matrix client that groups all agent-to-agent + operator rooms in one
place.

View file

@ -59,9 +59,9 @@ strategy for every credential, including the mTLS leaf.
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | 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 |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | 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 |
| `swarm/services/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | 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 |
<!-- vale write-good.Passive = YES -->

View file

@ -74,12 +74,13 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec
- `create-user`: for agents, persists the `access_token` to
`<state>/matrix-token`. Skips registration when the file already
exists — delete it first to force re-registration.
- `sync-admin`: ensures the appservice's sender account (`@hive:<server_name>`) exists
- `sync-admin`: ensures this hive's appservice sender account
(`@hive-<hive>:<server_name>`, one per hive) exists
(the account `hive-c0re` provisions rooms with). Token persisted to the
access token path. Safe to run again — idempotent.
- `promote-user`: promotes an already-registered user to homeserver
admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**,
which `@hive:` isn't — promotion is a swarm-level operation, rehomed to
which `@hive-<hive>:` isn't — promotion is a swarm-level operation, rehomed to
the swarm tier rather than granted here, so it has no admin sender to
call it with from the hive.
- `reset-password`: asks the admin room to set a new random

View file

@ -94,6 +94,20 @@ it.
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
agent and service stanzas above) because a hive's own path names it, so
scoping to the reader's own name costs nothing and drifts nowhere. That only
holds if a credential that must be one-per-hive is actually stored under the
hive kind — the matrix sender token was originally published to
`swarm/services/matrix/sender-token`, under the **service** kind, and so was
readable by every hive though it belonged to only one. Making it per-hive meant
moving its path under the hive kind, not narrowing the service stanza's grant.
**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
this note exists to stop.
### Threat model: prompt injection → confused deputy
The realistic adversary **never needs to breach the container**. They supply

View file

@ -65,21 +65,43 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
/// store it nowhere.
const PASSWORD_BYTES: usize = 32;
/// Matrix localpart for the hive system admin account. Not an agent; has
/// no state dir.
/// Matrix localpart this hive acts as. Not an agent; has no state dir.
///
/// **Per hive, derived from the hive's name** — `hive-<name>`, built by
/// `swarm_secret_client::matrix::hive_localpart`, which is the one place the
/// derivation lives. It used to be the bare constant `hive`, and a swarm runs
/// one homeserver: every hive on it logged in as the same `@hive:`, so the
/// homeserver could not attribute an action to a hive and revoking one hive's
/// standing revoked all of them.
///
/// Also the `sender_localpart` of the hive's appservice registration,
/// which is what creates this account on a homeserver that has never had
/// one: the homeserver creates a registration's sender user itself, at
/// startup, before it accepts a request. See
/// `nix/host-modules/hive-matrix.nix`, where this same literal appears as
/// `nix/host-modules/hive-matrix.nix`, where the same string is rendered as
/// `hiveLocalpart`. **The two must match**; nothing wires an override
/// across.
///
/// An ordinary account, with no homeserver-admin standing: what it
/// provisions — the Space, the chat room, the invites — it provisions as
/// the creator of those rooms.
pub const HIVE_LOCALPART: &str = "hive";
///
/// # Errors
/// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable
/// [`stored_sender_token`] logs in with and `hive-c0re`'s meta flake asserts
/// for every hive, so its absence is a broken deployment rather than a
/// supported one — and there is no safe default to fall back to: the only one
/// available is the shared account this replaced.
pub fn hive_localpart() -> Result<String> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|h| !h.is_empty())
.context(
"matrix: HYPERHIVE_HIVE_NAME is unset, so this hive has no matrix account of its own \
the localpart is derived from the hive name and has no swarm-wide fallback",
)?;
Ok(swarm_secret_client::matrix::hive_localpart(&hive))
}
/// Display name of the hive Space. Plain text, no special characters, so
/// the Space stays rediscoverable by name (no room alias needed) even when
@ -389,7 +411,7 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
/// room when the locally stored password is missing. Returns the new
/// password (already persisted to [`password_path`]) on success.
///
/// ⚠️ Needs an **admin sender**, which `@hive:` is not — the reset is a
/// ⚠️ Needs an **admin sender**, which `@hive-<hive>:` is not — the reset is a
/// `!admin` command and tuwunel only treats a message as a command when
/// its sender is an admin in that room. So this recovery path fails until
/// the two admin operations are rehomed at swarm level; the ordinary
@ -595,7 +617,7 @@ async fn admin_room_send_and_poll<T>(
// Poll for bot response: fetch the 20 most recent events (newest-first)
// on each tick. Walk the list until we hit our own command event_id;
// everything *before* that marker arrived after our command.
let own_user_id = format!("@{HIVE_LOCALPART}:{server_name}");
let own_user_id = format!("@{}:{server_name}", hive_localpart()?);
let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
for _ in 0..15_u8 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
@ -649,8 +671,8 @@ async fn admin_room_send_and_poll<T>(
}
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
/// Sends `!admin users reset-password @<localpart>:<server>` as @hive, polls for the bot's
/// response containing the new password.
/// Sends `!admin users reset-password @<localpart>:<server>` as `@hive-<hive>:`, polls for
/// the bot's response containing the new password.
///
/// Returns the new password; caller is responsible for persisting it.
async fn admin_room_reset_password(
@ -772,9 +794,9 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st
// Password file missing — attempt auto-recovery through the
// admin room.
// This covers the case where agent state dirs were wiped but the
// homeserver still has the accounts. Requires the `@hive:`
// homeserver still has the accounts. Requires the hive's sender
// token at /var/lib/hyperhive/matrix/access-token, and an admin
// sender, which `@hive:` no longer is.
// sender, which `@hive-<hive>:` is not.
tracing::info!(
%name,
"matrix: stored password missing, attempting admin-room auto-recovery"
@ -893,7 +915,7 @@ pub async fn sync_agent_standalone(name: &str) {
sync_agent(&client, name, &as_token).await;
}
/// Ensure the `@hive:` matrix user exists and that its access token is
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
/// persisted at [`sender_token_path()`].
///
/// **Nothing here depends on registration order, and nothing here is
@ -927,15 +949,18 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul
if let Some(token) = stored_sender_token().await {
return persist_sender_token(&path, &token);
}
// Per hive, and fatal when it cannot be derived: the fallback ladder below
// must not mint under some other hive's name.
let localpart = hive_localpart()?;
let password = random_password()?;
let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await {
let access_token = match register_user(client, &localpart, as_token, &password).await {
Ok(token) => {
let pw_path = password_path(HIVE_LOCALPART);
let pw_path = password_path(&localpart);
if let Some(parent) = pw_path.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
tracing::warn!(error = ?e, "matrix: failed to persist the @hive: account password");
tracing::warn!(error = ?e, %localpart, "matrix: failed to persist the sender account password");
} else {
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
}
@ -948,26 +973,26 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul
// hive-c0re gets a chance to ask. An appservice login needs
// no password, which is just as well since an account the
// homeserver created has none.
tracing::info!("matrix: the @hive: user already exists, logging in as the appservice");
match appservice_login(client, as_token, HIVE_LOCALPART).await {
tracing::info!(%localpart, "matrix: the sender account already exists, logging in as the appservice");
match appservice_login(client, as_token, &localpart).await {
Ok(token) => token,
Err(e) => {
tracing::warn!(error = ?e, "matrix: appservice login for @hive: failed; falling back to the stored password");
let pw_path = password_path(HIVE_LOCALPART);
tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password");
let pw_path = password_path(&localpart);
let stored = std::fs::read_to_string(&pw_path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"matrix: the @hive: user exists, appservice login failed, and no \
"matrix: @{localpart}: exists, appservice login failed, and no \
password is stored at {} check that the registration file's \
namespace covers @{HIVE_LOCALPART} and that the homeserver \
namespace covers @{localpart} and that the homeserver \
loaded it",
pw_path.display()
)
})?;
login_user(client, HIVE_LOCALPART, &stored).await?
login_user(client, &localpart, &stored).await?
}
}
}
@ -1002,8 +1027,10 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
/// the same role string `workers::credential` logs in with, and already in
/// this process's environment, so the store read costs no plumbing through
/// [`ensure_all`]. No new grant either: a hive's policy already covers the
/// whole `swarm/services/*` tree `swarm-matrix-ctl` writes into.
/// [`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:
/// the grant behind it is the hive's own `swarm/hives/<name>/*` stanza, not
/// 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
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
@ -1017,7 +1044,13 @@ async fn stored_sender_token() -> Option<String> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|h| !h.is_empty())?;
let path = swarm_secret_client::matrix::sender_token_path();
let path = match swarm_secret_client::matrix::sender_token_path(&hive) {
Ok(path) => path,
Err(e) => {
tracing::warn!(error = %e, "matrix: this hive's name does not form a store path");
return None;
}
};
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
Ok(store) => store,
Err(e) => {
@ -1095,11 +1128,11 @@ mod is_make_admin_success_tests {
/// Promote a user to homeserver admin via the Matrix admin room
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
/// @hive, polls for the bot's success reply.
/// `@hive-<hive>:`, polls for the bot's success reply.
///
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only
/// treats a message as a command when its sender is in the admin room.
/// `@hive:` is an ordinary account (`hive-matrix.nix` grants it no
/// `@hive-<hive>:` is an ordinary account (`hive-matrix.nix` grants it no
/// `admin_execute` promotion), so this call has no working sender from
/// the hive and fails with the admin room's refusal. Promotion is a
/// swarm-level operation and is being rehomed as such; this stays here,
@ -1139,13 +1172,13 @@ pub async fn promote_user_to_admin(
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
///
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as @hive,
/// polls for the bot's response containing the new password, and persists
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
/// `@hive-<hive>:`, polls for the bot's response containing the new password, and persists
/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login
/// on the next provisioning sweep.
///
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
/// the same consequence: `@hive:` is an ordinary account with no admin
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
/// sender, and reset, like promotion, is a swarm-level operation rehomed
/// to the swarm tier rather than granted here — so it has no working
/// sender from the hive either.
@ -1219,7 +1252,7 @@ pub fn read_sender_token() -> Result<String> {
format!(
"the matrix sender token was not found at {} — \
ensure hive-c0re has started at least once with matrix enabled \
(it provisions the @hive: account on boot)",
(it provisions the hive's @hive-<hive>: account on boot)",
path.display()
)
})
@ -1301,7 +1334,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
/// Create (or recover) the hive Matrix Space and persist its room ID to
/// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by
/// `@hive`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
/// `@hive-<hive>:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
///
/// Dedup strategy (single canonical space):
/// 1. If the room-id file exists, reuse it.
@ -1877,7 +1910,7 @@ pub async fn ensure_all() -> bool {
return false;
}
};
// The `@hive:` account first, because everything below provisions
// The hive's `@hive-<hive>:` account first, because everything below provisions
// THROUGH it (the Space, the chat room and every invite are sent with
// its token) — as an ordinary user that created those rooms, not as a
// homeserver admin.
@ -1934,17 +1967,17 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
return false;
}
};
// Invite @hive first, then all agents.
if let Err(e) = invite_to_room(
client,
&sender_token,
&room_id,
HIVE_LOCALPART,
&server_name,
)
.await
// This hive's own account first, then all agents.
let localpart = match hive_localpart() {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning");
return false;
}
};
if let Err(e) = invite_to_room(client, &sender_token, &room_id, &localpart, &server_name).await
{
tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the space failed");
ok = false;
}
for name in agent_names {
@ -1955,7 +1988,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
}
// Provision the default hive chat room as an m.space.child of the Space
// and invite @hive + every agent. Joining a Space alone surfaces no
// and invite `@hive-<hive>:` + every agent. Joining a Space alone surfaces no
// rooms to chat in (Matrix semantics — children aren't auto-joined), so
// without this the Space is empty. The restricted join rule additionally
// lets the operator (a Space member) join from the Space hierarchy.
@ -1965,12 +1998,12 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
client,
&sender_token,
&chat_room_id,
HIVE_LOCALPART,
&localpart,
&server_name,
)
.await
{
tracing::warn!(error = ?e, "matrix: invite @hive to chat room failed");
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the chat room failed");
ok = false;
}
for name in agent_names {

View file

@ -784,7 +784,7 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
Ok(HostResponse::messages(vec![
format!(
"matrix: the @{}: user is provisioned",
crate::matrix::HIVE_LOCALPART
crate::matrix::hive_localpart()?
),
format!("token persisted at: {}", path.display()),
]))

View file

@ -73,10 +73,21 @@ let
# anything, so the account exists on the very first boot of a fresh
# homeserver without hive-c0re racing to register it.
#
# ⚠️ Must equal `matrix::HIVE_LOCALPART` in hive-c0re, which derives
# it independently with nothing wiring an override across — same
# agreement, and same reason for saying so, as the token path below.
hiveLocalpart = "hive";
# 🩸 **Derived from the hive name, so it is one account per hive.** It was
# the bare `hive` and a swarm runs one homeserver: every hive on it acted
# as the same `@hive:`, which is one identity with no attribution and no
# way to revoke a single hive. The hives that do not run this container
# register their own `hive-<name>` through the appservice namespace below,
# which already admits it.
#
# ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which
# hive-c0re and swarm-matrix-ctl both derive from independently with
# nothing wiring an override across — same agreement, and same reason for
# saying so, as the token path below.
#
# `hiveName` has no fallback here for the reason ./glue-matrix-bao-token.nix
# gives at its own use of it: it is asserted set for every hyperhive host.
hiveLocalpart = "hive-${toString config.services.hyperhive.hiveName}";
# The `as_token`, and the `hs_token` the spec requires alongside it. Both
# minted by the render script below, mode 0600; the `as_token` is the one
@ -168,7 +179,7 @@ let
# knows about itself.
ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
# Every local user this hive may provision — agents, `@hive:` itself, and
# Every local user this hive may provision — agents, `@hive-<hive>:` itself, and
# the operator accounts `hivectl matrix create-user` makes, which is the
# whole matrix localpart charset.
#
@ -1439,6 +1450,12 @@ in
# never a value.
MATRIX_MINT_REGISTRATION = appserviceRegistrationPath;
MATRIX_MINT_LOCALPART = hiveLocalpart;
# The hive segment of the store path the token is published
# under, and so the thing that keeps this hive's token out of
# every other hive's reach: the grant that reaches it is the
# hive's own `swarm/hives/<name>/*` stanza. ./swarm-bao.nix
# spells the same name into matrix-ctl's write grant.
MATRIX_MINT_HIVE = toString config.services.hyperhive.hiveName;
MATRIX_MINT_HOMESERVER = ctlHomeserverUrl;
}
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {

View file

@ -249,9 +249,17 @@ let
# written by the caller — the same trap as the two grants above.
#
# Not `swarm/services/*` like the publisher's: this principal produces
# exactly one secret, the appservice sender account's access token, and a
# homeserver is not entitled to overwrite Grafana's OIDC client. The path is
# spelled to the leaf for that reason, not for tidiness.
# exactly one secret, its own hive's matrix sender account access token, and
# a homeserver is not entitled to overwrite Grafana's OIDC client. The path
# is spelled to the leaf for that reason, not for tidiness.
#
# 🩸 And the leaf now carries a HIVE segment, which is the narrowing that
# matters: the credential used to live at `swarm/services/matrix/sender-token`
# — one value for the whole swarm, under the `services/*` tree every hive's
# own policy grants read on. Under `swarm/hives/<name>/` the only read grant
# that reaches it is that hive's own stanza, so one hive cannot fetch
# another's. `matrixCtlHive` below is the name this principal may write, and
# it is one hive rather than a `hives/*` wildcard for the same reason.
#
# `read` as well as write, unlike either sibling, and it is what makes "and
# only once" mechanical: matrix-ctl's first act is to read this path back and
@ -260,11 +268,22 @@ let
# here recovers one secret this principal itself wrote, which is a much
# narrower grant than the publisher's would have been.
matrixCtlPolicyText = ''
path "${credentialMountPath}/data/swarm/services/matrix/sender-token" {
path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
capabilities = ["create", "update", "read"]
}
'';
# Which hive matrix-ctl mints for. This host's own by default, which is right
# whenever the store and the homeserver are co-located and is the shape the
# `mkDefault` deployments produce; an operator running them apart names the
# homeserver's hive here, because the policy is written where the store is
# and the container runs where the homeserver is.
#
# A wrong value is loud rather than silent: matrix-ctl's write comes back 403
# with the store's own message and the hive falls back to minting its account
# locally, which is the same degrade a store that was never deployed gives.
matrixCtlHive = baoDeploy.matrixCtlHiveName;
# 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
@ -711,6 +730,31 @@ in
'';
};
matrixCtlHiveName = lib.mkOption {
type = lib.types.str;
default = toString hyperhiveCfg.hiveName;
defaultText = lib.literalExpression "services.hyperhive.hiveName";
example = "pr1ma";
description = ''
Hive whose matrix sender token the store's matrix-ctl role may write.
The sender account's access token is **per hive**: it lives at
`swarm/hives/<name>/matrix/sender-token`, and the only read grant that
reaches it is that hive's own. So matrix-ctl's write grant names one
hive too the hive whose homeserver container it runs in.
Defaults to this host's own {option}`services.hyperhive.hiveName`,
which is correct whenever the store and the homeserver are co-located.
Set it when they are not: the policy is written where the store runs,
and the oneshot runs where the homeserver does.
A wrong value degrades rather than breaks matrix-ctl's write is
refused with the store's own message and the hive mints its account
locally instead, the same fallback a swarm that never deployed the
store already uses.
'';
};
serverCaFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;

View file

@ -147,20 +147,26 @@ let
# the `services/` prefix the publisher holds would be a real loss even
# though it would read as tidier.
#
# ⚠️ `services` is PLURAL, because the path segment comes from
# `Kind::Service`'s `#[strum(serialize = "services")]` and not from
# `Kind::label`, which renders the singular for error text. The singular
# spelling evaluates, deploys, and 403s every read with "permission
# denied" and nothing else.
name = "matrix-ctl's grant is the sender token's path and nothing else";
# ⚠️ `hives` is PLURAL, because the path segment comes from
# `Kind::Hive`'s strum serialisation and not from `Kind::label`, which
# renders the singular for error text. The singular spelling evaluates,
# deploys, and 403s every read with "permission denied" and nothing else.
#
# 🩸 The hive NAME in the middle is the per-hive half of this credential:
# the token used to be one swarm-wide value under `services/matrix/`,
# which every hive's own policy granted read on. The negative arms below
# are what keep it from drifting back — neither the `services/*` tree nor
# a `hives/*` wildcard may appear, since either one hands matrix-ctl (or
# a hive) reach beyond the single leaf it owns.
name = "matrix-ctl's grant is one hive's sender token path and nothing else";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
in
lib.hasInfix "path \"secret/data/swarm/services/matrix/sender-token\" {" s
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
lib.hasInfix "path \"secret/data/swarm/hives/h1/matrix/sender-token\" {" s
&& !(lib.hasInfix "secret/data/swarm/services" s)
&& !(lib.hasInfix "secret/data/swarm/agents" s)
&& !(lib.hasInfix "secret/data/swarm/hives" s)
&& !(lib.hasInfix "secret/data/swarm/hives/*" s)
&& !(lib.hasInfix "sys/policies/acl" s);
}
{

View file

@ -289,6 +289,26 @@ let
&& u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
&& u.serviceConfig.Type == "oneshot";
}
{
# 🩸 The per-hive minting identity, read off the rendered unit rather than
# off the option: the binary builds its store path out of
# `MATRIX_MINT_HIVE` and logs in as `MATRIX_MINT_LOCALPART`, so a unit
# that passed the old bare `hive` would publish one identity for the
# whole swarm again and nothing in the Rust tests could see it. Both
# spellings are pinned, and the localpart is pinned as *derived from* the
# hive name rather than as a literal, which is the agreement
# `swarm_secret_client::matrix::hive_localpart` owns.
name = "matrix-ctl is told which hive it mints for, and acts as that hive's account";
ok =
let
m = baoWithMatrix;
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
hive = m.services.hyperhive.hiveName;
in
u.environment.MATRIX_MINT_HIVE == hive
&& u.environment.MATRIX_MINT_LOCALPART == "hive-${hive}"
&& u.environment.MATRIX_MINT_LOCALPART != "hive";
}
{
# 🩸 The crate is a `*ctl` with subcommands, so the unit has to name a
# VERB. This is the one end of that contract nix owns: the binary's own

View file

@ -22,8 +22,8 @@ const PASSWORD_BYTES: usize = 32;
/// What the homeserver said about a registration attempt.
///
/// An enum rather than a string match on the error text: `M_USER_IN_USE` is the
/// *expected* answer here — the `@hive:` account is the appservice registration's
/// own `sender_localpart`, so the homeserver creates it at startup, before
/// *expected* answer here — the hive's `@hive-<hive>:` account is the appservice
/// registration's own `sender_localpart`, so the homeserver creates it at startup, before
/// anything gets to ask — and an expected answer should not have to be
/// recovered from a formatted message.
pub enum Registered {
@ -262,8 +262,8 @@ mod tests {
#[test]
fn the_expected_already_exists_answer_is_recognised_by_its_errcode() {
// Matched on the spec's code rather than on message text, because this
// is the arm a healthy homeserver takes every time: the `@hive:` account
// is the appservice registration's own sender, created at startup.
// is the arm a healthy homeserver takes every time: the hive's `@hive-<hive>:`
// account is the appservice registration's own sender, created at startup.
let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" });
assert_eq!(errcode(&json), Some("M_USER_IN_USE"));
}

View file

@ -2,10 +2,12 @@
//! homeserver access token to the swarm's secret store, once.
//!
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
//! swarm controller. It is this account rather than an agent's because a
//! homeserver has **one** appservice registration and so one sender account,
//! and a swarm runs one homeserver: "only once" is a property of what is being
//! minted, so there is nothing to lock and no trigger to serve.
//! swarm controller. It mints for **one hive** — the hive this container runs
//! on, named by [`ENV_HIVE`] — and publishes to that hive's own path, so a
//! swarm whose hives share a homeserver gets one account and one token per
//! hive rather than one shared between all of them. "Only once" is therefore
//! once per hive, and it is still a property of what is being minted rather
//! than of a lock: nothing else writes that path.
//!
//! The store, not the homeserver, is the idempotency key — see
//! [`already_published`]. On the hive side `hive-c0re`'s
@ -31,9 +33,15 @@ const ENV_API_URL: &str = "MATRIX_MINT_API_URL";
/// The bind-mounted appservice registration, which is where the `as_token`
/// comes from. A path, never a value.
const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION";
/// Localpart of the appservice's sender account. The registration's own
/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`.
/// Localpart of this hive's sender account. The registration's own
/// `sender_localpart`, rendered by `hive-matrix.nix` from the hive name — the
/// same string `swarm_secret_client::matrix::hive_localpart` builds, which is
/// what `hive-c0re` derives its own copy with.
const ENV_LOCALPART: &str = "MATRIX_MINT_LOCALPART";
/// Name of the hive this container belongs to, and so the segment of the store
/// path the token is published under. It is what keeps one hive's token out of
/// another hive's reach — see `swarm_secret_client::matrix::sender_token_path`.
const ENV_HIVE: &str = "MATRIX_MINT_HIVE";
/// Public base URL of the homeserver, stored beside the token so a reader can
/// reconstruct where it is good for. Optional: a swarm with no gateway vhost
/// has no such URL, and `matrix::Credential` types the field to say so.
@ -50,6 +58,7 @@ struct Config {
api_url: String,
registration: String,
localpart: String,
hive: String,
homeserver: Option<String>,
}
@ -77,6 +86,7 @@ impl Config {
api_url: required(ENV_API_URL)?,
registration: required(ENV_REGISTRATION)?,
localpart: required(ENV_LOCALPART)?,
hive: required(ENV_HIVE)?,
// Empty is absent: systemd renders an unset nix option as
// `Environment=VAR=`, so that is the shape this arrives in.
homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()),
@ -128,7 +138,8 @@ pub async fn run() -> Result<()> {
)
})?;
let path = matrix::sender_token_path();
let path = matrix::sender_token_path(&config.hive)
.with_context(|| format!("building the store path for hive {}", config.hive))?;
if already_published(&store, &path).await {
tracing::info!(%path, "the sender token is already published; not minting");
return Ok(());
@ -175,7 +186,8 @@ mod tests {
ENV_REGISTRATION => {
Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned())
}
ENV_LOCALPART => Some("hive".to_owned()),
ENV_LOCALPART => Some("hive-pr1ma".to_owned()),
ENV_HIVE => Some("pr1ma".to_owned()),
_ => None,
}
}
@ -185,13 +197,20 @@ mod tests {
// The control: without it every assertion below could be passing
// because `from_lookup` rejects everything.
let c = Config::from_lookup(full).expect("every required variable is set");
assert_eq!(c.localpart, "hive");
assert_eq!(c.localpart, "hive-pr1ma");
assert_eq!(c.hive, "pr1ma");
assert_eq!(c.homeserver, None, "an absent public URL is not an error");
}
#[test]
fn each_required_variable_is_named_when_it_is_the_missing_one() {
for var in [ENV_CERT_ROLE, ENV_API_URL, ENV_REGISTRATION, ENV_LOCALPART] {
for var in [
ENV_CERT_ROLE,
ENV_API_URL,
ENV_REGISTRATION,
ENV_LOCALPART,
ENV_HIVE,
] {
let e = Config::from_lookup(|k| if k == var { None } else { full(k) })
.expect_err("one required variable is absent");
assert!(
@ -237,6 +256,7 @@ mod tests {
ENV_API_URL,
ENV_REGISTRATION,
ENV_LOCALPART,
ENV_HIVE,
ENV_HOMESERVER,
] {
assert!(
@ -253,8 +273,29 @@ mod tests {
// and names the literal so a move of the path is a deliberate edit on
// both sides rather than a silent 404 on the reading one.
assert_eq!(
matrix::sender_token_path(),
"swarm/services/matrix/sender-token"
matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
"swarm/hives/pr1ma/matrix/sender-token"
);
}
#[test]
fn two_hives_are_published_to_two_paths() {
// What the hive segment is FOR: this binary runs beside a homeserver
// several hives share, so a path without the hive name in it would
// have each run overwrite the last and leave every hive holding one
// identity — which is the shape this change exists to end.
let a = matrix::sender_token_path("alpha").expect("legal");
let b = matrix::sender_token_path("beta").expect("legal");
assert_ne!(a, b);
}
#[test]
fn the_localpart_the_unit_hands_over_is_the_one_derived_from_the_hive() {
// The nix unit renders both variables independently; this pins that
// the pair it is expected to render agrees with the shared derivation,
// so a unit still passing the old bare `hive` fails here rather than
// silently logging in as another hive's account.
let c = Config::from_lookup(full).expect("every required variable is set");
assert_eq!(c.localpart, matrix::hive_localpart(&c.hive));
}
}

View file

@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use crate::{
Error,
path::{Kind, ROOT, checked_segment, principal_prefix},
path::{Kind, checked_segment, principal_prefix},
};
/// The path holding `agent`'s token for the external matrix account `account`.
@ -26,32 +26,50 @@ pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
Ok(format!("{prefix}/matrix/{account}"))
}
/// The swarm service the homeserver is, as the name segment under
/// [`Kind::Service`].
/// The localpart `hive` acts as on the homeserver, and the `sender_localpart`
/// of that hive's appservice registration.
///
/// A swarm runs one homeserver, so this is a constant rather than a parameter —
/// and that is the whole of what makes the credential below mintable
/// "only once" without a lock.
pub const HOMESERVER_SERVICE: &str = "matrix";
/// The path holding the matrix appservice sender account's homeserver
/// access token.
/// **Derived from the hive's name, which is what makes it one account per
/// hive.** It used to be the bare constant `hive`: one swarm runs one
/// homeserver, so every hive on it logged in as the same `@hive:` and the
/// homeserver could not tell them apart — no attribution, and no way to revoke
/// one hive without revoking all of them.
///
/// Keyed per **homeserver**, not per hive and not per agent: a homeserver has one
/// appservice registration and so one sender account (`@hive:<server_name>`,
/// hive-c0re's `matrix::HIVE_LOCALPART`), so a
/// per-hive copy would be several names for one secret. That is also why this
/// takes no argument and cannot fail — there is no caller-supplied segment in
/// it to reject.
/// The prefix is kept so the account still reads as a hive's rather than an
/// agent's; `nix/host-modules/hive-matrix.nix` renders the same string into
/// the registration's `sender_localpart`, and **the two must match** — nothing
/// wires an override across.
///
/// Reachable by every hive without a new grant: [`crate::policy::render`]
/// already grants a hive read on the whole [`Kind::Service`] tree.
/// Infallible on purpose: a hive name is `[a-z0-9-]` (`hive_types::Ident`) and
/// the appservice namespace regex admits that charset, so there is no
/// rejection arm to write. [`sender_token_path`] below does the checking, and
/// it is the one that interpolates into a store path.
#[must_use]
pub fn sender_token_path() -> String {
format!(
"{ROOT}/{}/{HOMESERVER_SERVICE}/sender-token",
<&str>::from(Kind::Service)
)
pub fn hive_localpart(hive: &str) -> String {
format!("hive-{hive}")
}
/// The path holding `hive`'s matrix sender account's homeserver access token.
///
/// Keyed per **hive**, like [`appservice_token_path`] below and unlike the
/// per-homeserver constant this replaced. The old path
/// (`swarm/services/matrix/sender-token`) held **one value for the whole
/// swarm** and sat under the [`Kind::Service`] tree that
/// [`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
/// [`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 sender_token_path(hive: &str) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Hive, hive)?;
Ok(format!("{prefix}/matrix/sender-token"))
}
/// The path holding `hive`'s matrix appservice token (`as_token`).
@ -115,25 +133,59 @@ mod tests {
}
#[test]
fn the_hive_credential_lands_under_the_service_prefix_the_grant_covers() {
fn the_sender_token_lands_under_the_hive_prefix_the_grant_covers() {
// Spelled out rather than rebuilt from the same pieces the code uses,
// for the reason above — and with a second job here: the hive's own
// policy grants read on `secret/data/swarm/services/*`, so this exact
// string is what makes the path reachable at all. The `services`
// segment is PLURAL; `Kind::label` renders the singular and is for
// error text only, so reading it as the path segment produces a
// 403 the store explains as "permission denied" and nothing else.
assert_eq!(sender_token_path(), "swarm/services/matrix/sender-token");
// policy grants read on `secret/data/swarm/hives/<this hive>/*`, so
// this exact string is what makes the path reachable at all.
assert_eq!(
sender_token_path("pr1ma").expect("a plain name is legal"),
"swarm/hives/pr1ma/matrix/sender-token"
);
}
#[test]
fn the_hive_credential_sits_where_a_service_principal_would() {
// The constant path above must be the same one the general builder
// produces, or the grant covering `Kind::Service` would cover a
// neighbouring tree instead of this one.
let prefix =
principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal");
assert_eq!(sender_token_path(), format!("{prefix}/sender-token"));
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]
fn one_hives_sender_token_is_not_another_hives() {
// The property the per-hive path exists for: two hives never name the
// same object, so the `hives/<name>/*` grant that reaches one cannot
// reach the other.
let a = sender_token_path("alpha").expect("legal");
let b = sender_token_path("beta").expect("legal");
assert_ne!(a, b);
}
#[test]
fn a_traversal_in_the_hive_name_is_refused_by_the_sender_path_too() {
let e = sender_token_path("../beta").expect_err("a traversal is not legal");
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
}
#[test]
fn the_localpart_is_derived_from_the_hive_name() {
// The literal is the point: `nix/host-modules/hive-matrix.nix` renders
// the same string into the registration's `sender_localpart` and into
// `MATRIX_MINT_LOCALPART`, and nothing wires an override across — so a
// change here is a change there.
assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma");
assert_ne!(
hive_localpart("pr1ma"),
hive_localpart("secunda"),
"two hives must not land on one account"
);
// And it is never the bare `hive` that used to be shared swarm-wide.
assert_ne!(hive_localpart("pr1ma"), "hive");
}
#[test]

View file

@ -24,10 +24,10 @@
//! 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 neighbours — the asymmetry
//! is the point.
//! is the point, and only holds if a one-per-hive credential is stored under
//! [`Kind::Hive`], not [`Kind::Service`] — see docs/trust-boundary/security.md.
//!
//! 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.
use crate::{
Error,
@ -251,6 +251,60 @@ mod tests {
);
}
/// 🩸 The per-hive matrix sender-token scoping, asserted on the rendered
/// 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
/// 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]
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
let alpha = render("alpha").expect("legal");
let own = crate::matrix::sender_token_path("alpha").expect("legal");
let other = crate::matrix::sender_token_path("beta").expect("legal");
// Reachable: the hive's own stanza is the prefix of its own path.
assert!(
alpha.contains("path \"secret/data/swarm/hives/alpha/*\""),
"{alpha}"
);
assert!(own.starts_with("swarm/hives/alpha/"), "{own}");
// Unreachable: no stanza in alpha's document is a prefix of beta's
// path. Checked by walking the stanzas rather than by asserting the
// absence of the string "beta", so a future stanza that happened to
// cover it — `swarm/hives/*`, say — would fail this too.
assert!(
!stanza_paths(&alpha)
.iter()
.any(|granted| covers(granted, &other)),
"alpha's document reaches {other}:\n{alpha}"
);
}
/// Every `path "…"` a rendered document grants, with the `secret/data/`
/// ACL prefix stripped so it compares against a store path.
fn stanza_paths(document: &str) -> Vec<String> {
document
.lines()
.filter_map(|line| line.strip_prefix("path \""))
.filter_map(|rest| rest.split('"').next())
.filter_map(|p| p.strip_prefix("secret/data/"))
.map(str::to_owned)
.collect()
}
/// Does a granted policy path cover `path`? Only the trailing-`*` form
/// this module renders, which is the only form it has to understand.
fn covers(granted: &str, path: &str) -> bool {
match granted.strip_suffix('*') {
Some(prefix) => path.starts_with(prefix),
None => granted == path,
}
}
#[test]
fn the_grant_is_read_only() {
// A hive reads credentials; a hive that could write one could hand