hive-matrix-daemon now learns which external matrix accounts it has from the swarm secret store, under the agent's own certificate, and the hive push chain for matrix is gone. The daemon lists swarm/agents/<agent>/matrix/ (the `list` its policy grants on its own metadata subtree), reads each account's homeserver from its credential, and brings the accounts up with their tokens from the store. Every two minutes it lists again and exits with 75 when the set of linked accounts changed; the unit restarts on 75 without counting a failure. A listed name whose credential reads as absent is skipped and logged once. At start it removes the matrix-token-<a> / matrix-account-<a>.json pairs a hive delivered (a sidecar marks a pair as delivered; a declared tokenFile keeps its token). Removed: CredentialNotice and the $SWARM.credential.* subject and NATS grant, the controller's publish and its queue precondition on the PUT route, hive-c0re's credential subscription arm and workers/credential.rs, priv_client::write_agent_matrix_token, hive-priv's WriteAgentMatrixToken and its helpers, and the daemon's state-dir account discovery. Kept: WriteAgentGithubToken and the external-forge path (WriteAgentExtraForgeAccount, extra_forges.rs) are untouched, and a declared matrixAccounts tokenFile is still read when the store has no token for that account. Refs #4348
349 lines
16 KiB
Rust
349 lines
16 KiB
Rust
//! The matrix agreement: where an account's credential lives in the store, and
|
|
//! what the object at that path holds.
|
|
//!
|
|
//! Both halves are one agreement and neither end of it is senior, so they are
|
|
//! stated together here rather than split between the path module and the
|
|
//! client. Nothing in [`crate::client`] knows this shape — it moves whatever
|
|
//! type a caller names — so a second kind of swarm secret gets its own module
|
|
//! beside this one instead of another field on a shared struct.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
Error,
|
|
path::{Kind, checked_segment, principal_prefix},
|
|
};
|
|
|
|
/// The path holding `agent`'s token for the external matrix account `account`.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when either name contains anything but
|
|
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
|
|
/// another agent's secret.
|
|
pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
|
|
checked_segment("account", account)?;
|
|
Ok(format!("{}/{account}", accounts_dir(agent)?))
|
|
}
|
|
|
|
/// The directory every one of `agent`'s [`account_path`]s is under, in the
|
|
/// form [`crate::SecretStore::list`] takes.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`.
|
|
pub fn accounts_dir(agent: &str) -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Agent, agent)?;
|
|
Ok(format!("{prefix}/matrix"))
|
|
}
|
|
|
|
/// The localpart `hive` acts as on the homeserver, and the `sender_localpart`
|
|
/// of that hive's appservice registration.
|
|
///
|
|
/// **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.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// 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 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`).
|
|
///
|
|
/// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`]
|
|
/// and unlike [`account_path`] above: one homeserver admits one hive's
|
|
/// accounts, so the identity that creates them is the hive's.
|
|
///
|
|
/// ⚠️ Renamed from `registration-token` along with what it holds: the
|
|
/// homeserver no longer accepts a shared registration secret at all, so a
|
|
/// value still stored under the old path would be read by nothing. A hive
|
|
/// whose store has only the old path falls back to its locally minted
|
|
/// token — see `glue-matrix-bao-token.nix` — so the rename degrades rather
|
|
/// than breaks, but the store needs a fresh `put` to take effect again.
|
|
///
|
|
/// # 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 appservice_token_path(hive: &str) -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Hive, hive)?;
|
|
Ok(format!("{prefix}/matrix/appservice-token"))
|
|
}
|
|
|
|
/// The name segment of the controller's own subtree, and the cert-auth role it
|
|
/// logs in under (`swarm-controller`'s `store::CERT_ROLE`).
|
|
pub(crate) const CONTROLLER: &str = "swarm-controller";
|
|
|
|
/// The path holding the **swarm's** appservice token: the one registration
|
|
/// on the swarm's homeserver that is not a hive's, whose sender is promoted
|
|
/// to homeserver admin at boot.
|
|
///
|
|
/// The matrix container mints it and publishes it here; `swarm-controller`
|
|
/// reads it to create agents' accounts. Under [`Kind::Controller`] because
|
|
/// that is the one kind [`crate::policy::render`] grants no hive: every hive
|
|
/// reads `agents/*`, its own `hives/<hive>/*` and `services/*`, so under any
|
|
/// of those this admin credential would be readable by every hive.
|
|
///
|
|
/// # Errors
|
|
/// Never in practice: the name segment is a constant. The `Result` is
|
|
/// [`principal_prefix`]'s.
|
|
pub fn swarm_appservice_token_path() -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Controller, CONTROLLER)?;
|
|
Ok(format!("{prefix}/matrix/appservice-token"))
|
|
}
|
|
|
|
/// What an account's path holds: the token, plus the homeserver it belongs to.
|
|
///
|
|
/// The homeserver rides with the token because the agent's daemon learns both
|
|
/// from this one object: an account it finds by listing [`accounts_dir`] has
|
|
/// no other place its homeserver is written down.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Credential {
|
|
/// `glue-matrix-bao-token.nix` reads the store with
|
|
/// `bao kv get -field=value`, so this name is load-bearing for a reader
|
|
/// this crate does not control. Renaming it silently breaks that unit.
|
|
pub value: String,
|
|
|
|
/// Absent on every object written before this field existed, and KV2 keeps
|
|
/// those versions forever. **`Option` is what tolerates that** — serde
|
|
/// decodes a missing field to `None` for an optional type, so the type is
|
|
/// the compatibility guarantee and changing it to a bare `String` is what
|
|
/// would break every stored credential at once.
|
|
///
|
|
/// `skip_serializing_if` is doing separate work: without it a token-only
|
|
/// credential serialises `"homeserver":null`, and this object is read by
|
|
/// `glue-matrix-bao-token.nix` with `bao kv get -field=value`.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub homeserver: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_well_formed_pair_lands_under_the_agent_prefix() {
|
|
let p = account_path("atlas", "ops-relay").expect("both segments are legal");
|
|
// Spelled out rather than rebuilt from the same pieces the code uses:
|
|
// a test that composes `ROOT` and `Kind::Agent` would keep passing
|
|
// through a rename that moves every stored credential.
|
|
assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay");
|
|
}
|
|
|
|
#[test]
|
|
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/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_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
|
|
// 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]
|
|
fn the_appservice_token_lands_under_the_hive_prefix_the_grant_covers() {
|
|
// Spelled out for the same reason as above, and with a second job here:
|
|
// the read policy grants `secret/data/swarm/hives/<hive>/*`, so this
|
|
// string is what makes the path reachable at all.
|
|
assert_eq!(
|
|
appservice_token_path("pr1ma").expect("a plain name is legal"),
|
|
"swarm/hives/pr1ma/matrix/appservice-token"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_appservice_token_is_not_a_top_level_namespace() {
|
|
// The path its predecessor used to hold. `Kind` is a closed set and
|
|
// `matrix` is not one of its members, so a path with `matrix` as the
|
|
// second segment is outside every grant — which is how it came to 403
|
|
// on every read.
|
|
let p = appservice_token_path("pr1ma").expect("legal");
|
|
assert!(!p.starts_with("swarm/matrix/"), "{p}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_traversal_in_the_hive_name_is_refused() {
|
|
let e = appservice_token_path("../beta").expect_err("a traversal is not");
|
|
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_swarm_appservice_token_lands_under_the_controller() {
|
|
assert_eq!(
|
|
swarm_appservice_token_path().expect("a constant segment"),
|
|
"swarm/controller/swarm-controller/matrix/appservice-token"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_hive_policy_reaches_the_swarm_appservice_token() {
|
|
// 🩸 The swarm's sender is a homeserver admin, so its token must not
|
|
// be under any stanza a hive's policy renders. Checked against the
|
|
// rendered document rather than against the prefix, because the
|
|
// document is what the store enforces.
|
|
let doc = crate::policy::render("pr1ma").expect("a plain name is legal");
|
|
// Every stanza is `path "secret/data/<prefix>*" { … }`.
|
|
let granted: Vec<&str> = doc
|
|
.lines()
|
|
.filter_map(|l| l.strip_prefix("path \"secret/data/"))
|
|
.filter_map(|p| p.strip_suffix("*\" {"))
|
|
.collect();
|
|
let reads = |path: &str| granted.iter().any(|g| path.starts_with(g));
|
|
let path = swarm_appservice_token_path().expect("a constant segment");
|
|
assert!(!reads(&path), "{path} is readable under {granted:?}");
|
|
// The control: an agent's account path IS under a hive stanza, so the
|
|
// assertion above can fail at all.
|
|
let agent = account_path("atlas", "main").expect("legal");
|
|
assert!(
|
|
reads(&agent),
|
|
"{agent} should be readable under {granted:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_segment_cannot_escape_its_own_directory() {
|
|
// Each of these is a *different* way to address another agent's tree,
|
|
// and the last two are the ones a charset check catches but a
|
|
// `contains("..")` check does not.
|
|
for bad in [
|
|
"../argus",
|
|
"atlas/../argus",
|
|
"atlas/matrix",
|
|
"a b",
|
|
"a.b",
|
|
"",
|
|
] {
|
|
assert!(
|
|
account_path(bad, "ops-relay").is_err(),
|
|
"agent segment {bad:?} must be refused"
|
|
);
|
|
assert!(
|
|
account_path("atlas", bad).is_err(),
|
|
"account segment {bad:?} must be refused"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_legal_charset_is_actually_reachable() {
|
|
// The control for the test above: if `checked_segment` rejected
|
|
// everything, the escape cases would pass for the wrong reason.
|
|
assert!(account_path("a-b_C9", "d-e_F0").is_ok());
|
|
}
|
|
|
|
/// KV2 keeps every prior version, so objects written before `homeserver`
|
|
/// existed are still readable and still get decoded by this type. What
|
|
/// tolerates the absence is the field being `Option`, not any attribute:
|
|
/// making it a bare `String` would not surface as a migration, it would
|
|
/// surface as every previously-stored credential becoming unreadable at
|
|
/// once.
|
|
#[test]
|
|
fn a_credential_stored_before_the_homeserver_field_still_decodes() {
|
|
let old: Credential = serde_json::from_str(r#"{"value":"t"}"#)
|
|
.expect("an object written by the previous version must still decode");
|
|
assert_eq!(old.value, "t");
|
|
assert_eq!(old.homeserver, None);
|
|
|
|
// Control: the field is genuinely read when present, so the arm above
|
|
// is about absence being tolerated rather than the field being ignored.
|
|
let new: Credential = serde_json::from_str(r#"{"value":"t","homeserver":"https://hs"}"#)
|
|
.expect("an object with the field decodes too");
|
|
assert_eq!(new.homeserver.as_deref(), Some("https://hs"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_value_field_matches_what_the_nix_reader_asks_for() {
|
|
// The literal is the point: `bao kv get -field=value` is the other end
|
|
// of this agreement and lives in a file no Rust test can reach, so the
|
|
// name is pinned here rather than derived from the struct.
|
|
//
|
|
// It doubles as the compatibility control for `homeserver`: a
|
|
// token-only credential must still serialise to exactly these bytes,
|
|
// with no `homeserver` key at all, so adding the field cannot change
|
|
// what that unit reads.
|
|
let json = serde_json::to_string(&Credential {
|
|
value: "t".to_owned(),
|
|
homeserver: None,
|
|
})
|
|
.expect("a struct of one String serialises");
|
|
assert_eq!(json, r#"{"value":"t"}"#);
|
|
}
|
|
}
|