diff --git a/README.md b/README.md index 35aea61c..ede2a5e0 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,8 @@ newer channel breaks in ways hyperhive's CI doesn't catch. You are an SSO user too: `swarmctl user add --group admins`, and a first login through authelia creates your forge and matrix accounts; -`swarmctl forge make-admin ` promotes the forge one. +`swarmctl forge make-admin ` promotes the forge one, and the matrix +one becomes a homeserver admin within five minutes of that first login. ## Where to read next diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 6f683403..90cb0772 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -169,6 +169,25 @@ when it's missing, unknown to the homeserver, or someone else's. An agent's daemon picks a re-minted token up on its next start, and a timer restarts it while it's down. +### Operators are homeserver admins + +Every member of authelia's `admins` group who has a matrix account is a +homeserver admin. `swarm-controller` reads the group through +`swarm-authelia-bridge` at start and every five minutes after, and for +each member not yet in `#admins:` it sends +`!admin users make-user-admin @:` there as `@swarm`. +`` is the authelia username lowercased, the account an SSO login +lands on. + +- **Grant-only.** Removing someone from `admins` leaves their matrix + admin in place: tuwunel has no command that takes it away. +- **No account, no grant.** A member who has never logged in to matrix + has no account to promote. The first pass after their first SSO login + promotes them. +- Runs only on a controller with both + `services.hyperhive.deploy.swarm-controller.authBridgeUrl` and + `services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl` set. + ### The appservice's sender account, and why it isn't an admin `@hive-:` is the appservice's own `sender_localpart`, diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index 977fee90..90caeca8 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -181,7 +181,7 @@ result isn't. | callback URL | `/user/oauth2//callback` | `/_matrix/client/unstable/login/sso/callback/`, a shape tuwunel fixes rather than accepts | | cost of a malformed entry | the login source is missing | the homeserver can refuse to start | -Three consequences worth stating plainly: +Four consequences worth stating plainly: - **A person's first forge login creates their forge account**, named after authelia's `preferred_username` (`[oauth2_client]` in the forge @@ -192,6 +192,11 @@ Three consequences worth stating plainly: `swarm-controller` all have local accounts, and `swarmctl user add` refuses none of those names. +- **A member of `admins` becomes a matrix homeserver admin** once their + first matrix login has created the account: `swarm-controller` promotes + them within five minutes, and never demotes. See + [`../integrations/matrix.md`](../integrations/matrix.md#operators-are-homeserver-admins). + - **tuwunel re-reads its secret file on every OAuth exchange**, not only at startup, and its own sandboxing hides most paths from it. It gets the file through `LoadCredential` for the same reason the appservice diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index c5b2ee5c..12168038 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -924,6 +924,11 @@ in # includes `bridgeClient`) — same file this container's own # `renderClient` reads the digest half of. SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret"; + # The operator list names who holds operator rights, so the + # bridge answers it to the controller's client and refuses every + # other active token (each hive and its agents hold one). + SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId; + SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup; }; }; diff --git a/nix/module-eval/nats-authelia.nix b/nix/module-eval/nats-authelia.nix index 92f46889..78b30f79 100644 --- a/nix/module-eval/nats-authelia.nix +++ b/nix/module-eval/nats-authelia.nix @@ -262,6 +262,20 @@ let && lib.hasInfix "-authelia-users-placeholder.yml \"$users\"" s && !(lib.hasInfix ''echo "users: {}"'' s); } + { + # The bridge refuses its operator list to every client but the one + # named here, so this has to be the client the controller authenticates + # as. A drift is a controller whose admin sync is refused every pass. + name = "the authelia bridge answers its operator list to the controller's client only"; + ok = + let + env = + autheliaColocated.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment; + in + env.SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID + == autheliaColocated.services.hyperhive.swarm.controller.queueClientId + && env.SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP == "admins"; + } { # Config NAMES the IdP; it never computes where the IdP is. Both arms # matter together: the address is the swarm's name on a hive that runs diff --git a/swarm-authelia-bridge-sock/src/lib.rs b/swarm-authelia-bridge-sock/src/lib.rs index 3865aed4..6d75e5dd 100644 --- a/swarm-authelia-bridge-sock/src/lib.rs +++ b/swarm-authelia-bridge-sock/src/lib.rs @@ -58,6 +58,13 @@ pub enum BridgeRequest { /// are separate sets on purpose, and an agent in the roster that has /// never reported is not the same as one that does not exist. ListAgentIdentities, + /// The usernames in authelia's operator group — the subjects that reach + /// operator-only surfaces. + /// + /// ⚠️ **Only `swarm-controller`'s client may ask.** Every other verb + /// admits any active token; this one names who holds operator rights, so + /// the bridge refuses it to every other caller with `403`. + ListOperators, } /// One roster entry. @@ -128,6 +135,14 @@ pub enum BridgeResponse { /// means a consumer diffing two answers sees real changes only. agents: Vec, }, + /// The operator group's members, in answer to + /// [`BridgeRequest::ListOperators`]. + Operators { + /// Authelia usernames, sorted. Plain strings rather than [`Ident`]: + /// these are humans, and the store admits `.`, `_` and uppercase in a + /// human's name. + operators: Vec, + }, /// The request was rejected or the write failed. Carries a message /// for the caller to log/propagate, not a typed error enum: the /// failure modes here (bad username, authelia binary failed, disk @@ -175,6 +190,15 @@ mod tests { serde_json::json!({"status": "agents", "agents": [{"name": "atlas"}]}) ); + let operators = serde_json::to_value(BridgeResponse::Operators { + operators: vec!["mara".to_owned()], + }) + .unwrap(); + assert_eq!( + operators, + serde_json::json!({"status": "operators", "operators": ["mara"]}) + ); + let err = serde_json::to_value(BridgeResponse::Error { message: "boom".to_owned(), }) @@ -205,6 +229,10 @@ mod tests { let json = serde_json::to_string(&BridgeRequest::ListAgentIdentities).unwrap(); let back: BridgeRequest = serde_json::from_str(&json).unwrap(); assert!(matches!(back, BridgeRequest::ListAgentIdentities)); + + let json = serde_json::to_string(&BridgeRequest::ListOperators).unwrap(); + let back: BridgeRequest = serde_json::from_str(&json).unwrap(); + assert!(matches!(back, BridgeRequest::ListOperators)); } /// An empty roster must be a roster, not an absence: `{"agents":[]}` diff --git a/swarm-authelia-bridge/README.md b/swarm-authelia-bridge/README.md index 885e385f..03699d89 100644 --- a/swarm-authelia-bridge/README.md +++ b/swarm-authelia-bridge/README.md @@ -20,11 +20,16 @@ simply owns the file it writes. No elevated privilege anywhere. - One endpoint, `POST /requests`, body = `swarm-authelia-bridge-sock`'s `BridgeRequest` verbatim — `EnsureAgentIdentity` (idempotently ensure an - agent exists as an authelia subject) and `ListAgentIdentities` (the - roster: the subjects carrying the agent marker group, names only). + agent exists as an authelia subject), `ListAgentIdentities` (the + roster: the subjects carrying the agent marker group, names only) and + `ListOperators` (the operator group's usernames). - Bearer-authenticated via authelia's own OIDC token introspection (RFC 7662), against `swarm-controller`'s **existing** machine-client identity - (already minted for the queue connection) — no new credential. + (already minted for the queue connection) — no new credential. The two + agent verbs admit any active token. ⚠️ `ListOperators` admits only the + client named by `SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID` + (`swarm-controller`'s) and answers every other active token `403`: + authelia issues machine tokens to every hive and its agents too. - No restart of authelia after a write — relies on `authentication_backend.file.watch`, confirmed working against the pinned 4.39.20 build during this design work. diff --git a/swarm-authelia-bridge/src/introspect.rs b/swarm-authelia-bridge/src/introspect.rs index e12be0a8..70bb1e91 100644 --- a/swarm-authelia-bridge/src/introspect.rs +++ b/swarm-authelia-bridge/src/introspect.rs @@ -3,10 +3,10 @@ //! Own copy of `swarm-nats-auth::introspect`'s shape (RFC 7662 token //! introspection), not a shared dependency — `swarm-nats-auth` has no lib //! target to import, and this is a handful of lines; factor out if a third -//! consumer shows up. Same verdict rule: `active` is the whole answer, and -//! everything that isn't an explicit `active: true` is a denial (network -//! error, timeout, non-2xx, unparseable body) — the failure modes of an -//! HTTP call are exactly the conditions an attacker would like this to +//! consumer shows up. Same verdict rule: `active` is the whole admission +//! answer, and everything that isn't an explicit `active: true` is a denial +//! (network error, timeout, non-2xx, unparseable body) — the failure modes of +//! an HTTP call are exactly the conditions an attacker would like this to //! fall open under. //! //! [`Verdict`] has three variants where admission has two: the extra one @@ -33,9 +33,25 @@ use serde::Deserialize; /// hung introspection call must not wedge a `CreateIdentity` job forever. pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_secs(5); +/// `active` decides whether to admit; `client_id` says as whom, which is +/// what lets one verb be scoped to a single client. Same two fields, for the +/// same reasons, as `swarm-nats-auth::introspect`. #[derive(Debug, Deserialize)] struct IntrospectionResponse { active: bool, + #[serde(default)] + client_id: Option, +} + +impl IntrospectionResponse { + fn verdict(self) -> Verdict { + if !self.active { + return Verdict::Inactive; + } + Verdict::Active { + client_id: self.client_id.filter(|id| !id.is_empty()), + } + } } /// The outcome of asking authelia about a token. @@ -44,10 +60,15 @@ struct IntrospectionResponse { /// the call can go wrong is already a variant. A caller that forgets a case /// does not compile, and there is no error path that could be `?`-ed past /// into an accidental admission. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Verdict { /// 2xx with `active: true`. The only admitting outcome. - Active, + Active { + /// The client the token was issued to. `None` when authelia named + /// none, or named an empty one: a verb scoped to one client refuses + /// such a caller, it does not match it to a blank. + client_id: Option, + }, /// 2xx with `active: false` — authelia evaluated the token and said no. /// The **caller's** credential is the problem. Inactive, @@ -96,8 +117,7 @@ pub async fn introspect( return Verdict::Unavailable; } match resp.json::().await { - Ok(body) if body.active => Verdict::Active, - Ok(_) => Verdict::Inactive, + Ok(body) => body.verdict(), Err(e) => { tracing::warn!(error = ?e, "introspection body did not parse; cannot validate"); Verdict::Unavailable @@ -129,4 +149,35 @@ mod tests { "authelia answering `active: false` is authelia working, not authelia failing" ); } + + fn parse(body: &str) -> Verdict { + serde_json::from_str::(body) + .expect("introspection body") + .verdict() + } + + #[test] + fn an_active_token_names_the_client_that_holds_it() { + assert_eq!( + parse(r#"{"active":true,"client_id":"swarm-controller"}"#), + Verdict::Active { + client_id: Some("swarm-controller".to_owned()) + } + ); + } + + #[test] + fn a_missing_or_empty_client_id_names_no_client() { + for body in [r#"{"active":true}"#, r#"{"active":true,"client_id":""}"#] { + assert_eq!(parse(body), Verdict::Active { client_id: None }, "{body}"); + } + } + + #[test] + fn an_inactive_token_names_no_client_even_when_the_body_does() { + assert_eq!( + parse(r#"{"active":false,"client_id":"swarm-controller"}"#), + Verdict::Inactive + ); + } } diff --git a/swarm-authelia-bridge/src/main.rs b/swarm-authelia-bridge/src/main.rs index 7dcf3a9e..8c892c18 100644 --- a/swarm-authelia-bridge/src/main.rs +++ b/swarm-authelia-bridge/src/main.rs @@ -24,7 +24,8 @@ //! //! One endpoint, `POST /requests`, body = //! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim: ensure an agent -//! exists as an authelia subject, and list the ones that do. Not a +//! exists as an authelia subject, list the ones that do, and list the +//! operator group (to `swarm-controller` only, see `permit`). Not a //! wholesale-replace-the-file API — this process owns rendering //! `users.yml` internally; see `store` module. @@ -52,6 +53,10 @@ struct Config { introspection_url: String, client_id: String, client_secret: String, + /// The one client [`BridgeRequest::ListOperators`] answers. + operator_reader_client_id: String, + /// The authelia group whose members are operators. + operator_group: String, } impl Config { @@ -63,6 +68,8 @@ impl Config { introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?, client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?, client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?, + operator_reader_client_id: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID")?, + operator_group: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP")?, }) } } @@ -139,14 +146,26 @@ async fn handle_request( headers: HeaderMap, Json(req): Json, ) -> Response { - if let Err(resp) = authorize(&state, &headers).await { - return resp; + let caller = match authorize(&state, &headers).await { + Ok(caller) => caller, + Err(resp) => return resp, + }; + if let Err(refusal) = permit( + &req, + caller.as_deref(), + &state.config.operator_reader_client_id, + ) { + // The client id is not a credential; naming it is what lets an + // operator see which client asked for something it may not have. + tracing::warn!(client_id = ?caller, request = ?req, "refused a request scoped to another client"); + return refuse(refusal); } let (op, result) = match req { BridgeRequest::EnsureAgentIdentity { name } => { ("ensure_identity", handle(&state, name).await) } BridgeRequest::ListAgentIdentities => ("list_identities", list(&state)), + BridgeRequest::ListOperators => ("list_operators", list_operators(&state)), }; match result { Ok(body) => (StatusCode::OK, Json(body)).into_response(), @@ -168,8 +187,9 @@ async fn handle_request( /// in." /// /// Anything short of an active token is refused; **which status** depends -/// on whose credential failed, see [`Refusal`]. -async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> { +/// on whose credential failed, see [`Refusal`]. `Ok` carries the client the +/// token was issued to, for [`permit`]. +async fn authorize(state: &AppState, headers: &HeaderMap) -> Result, Response> { let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else { return Err(refuse(Refusal::Caller)); }; @@ -196,14 +216,32 @@ async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response /// of [`authorize`] so the rule is testable without an authelia — and it is /// the *real* rule, not a restatement of it, because this is the function /// the request path calls. -fn classify(verdict: introspect::Verdict) -> Result<(), Refusal> { +fn classify(verdict: introspect::Verdict) -> Result, Refusal> { match verdict { - introspect::Verdict::Active => Ok(()), + introspect::Verdict::Active { client_id } => Ok(client_id), introspect::Verdict::Inactive => Err(Refusal::Caller), introspect::Verdict::Unavailable => Err(Refusal::Bridge), } } +/// Whether an admitted caller may make this particular request. +/// +/// [`BridgeRequest::ListOperators`] answers only `operator_reader`, the +/// controller's client: authelia also issues machine tokens to every hive and +/// every hive's agents, and those are as active as the controller's. A caller +/// with no client id is refused it too, rather than compared as a blank. +fn permit( + req: &BridgeRequest, + client_id: Option<&str>, + operator_reader: &str, +) -> Result<(), Refusal> { + match req { + BridgeRequest::EnsureAgentIdentity { .. } | BridgeRequest::ListAgentIdentities => Ok(()), + BridgeRequest::ListOperators if client_id == Some(operator_reader) => Ok(()), + BridgeRequest::ListOperators => Err(Refusal::Forbidden), + } +} + /// Whose credential was at fault when a request was refused. /// /// The distinction is the whole point: these used to be one 401, so a @@ -216,10 +254,13 @@ enum Refusal { /// This bridge could not get an answer out of authelia at all, so the /// request was never evaluated. Bridge, + /// The token is valid, but its client may not make this request. + Forbidden, } impl Refusal { - /// `401` for the caller; `503` for us. + /// `401` for the caller; `503` for us; `403` for a valid caller asking + /// for something scoped to another client. /// /// 401 would be wrong for [`Refusal::Bridge`] under RFC 7235 — nothing /// about the caller's credential was determined — and it is also the @@ -235,6 +276,7 @@ impl Refusal { match self { Refusal::Caller => StatusCode::UNAUTHORIZED, Refusal::Bridge => StatusCode::SERVICE_UNAVAILABLE, + Refusal::Forbidden => StatusCode::FORBIDDEN, } } @@ -247,6 +289,7 @@ impl Refusal { match self { Refusal::Caller => "missing or invalid bearer token", Refusal::Bridge => "cannot validate credentials", + Refusal::Forbidden => "not permitted", } } } @@ -351,6 +394,14 @@ fn list(state: &AppState) -> Result { }) } +/// The operator group's members. No write lock, for [`list`]'s reason. +fn list_operators(state: &AppState) -> Result { + let user_store = store::load_store(&state.config.users_file)?; + Ok(BridgeResponse::Operators { + operators: store::group_members(&user_store, &state.config.operator_group), + }) +} + /// Mint a password digest via the **configured** authelia — the argon2 /// parameters baked into a hash have to match the verifier's, same /// reasoning `swarmctl::generate_password` documents (this is @@ -393,7 +444,7 @@ async fn generate_password(bin: &std::path::Path) -> Result { #[cfg(test)] mod tests { - use super::{Refusal, StatusCode, classify, introspect::Verdict}; + use super::{BridgeRequest, Refusal, StatusCode, classify, introspect::Verdict, permit}; /// Fail-closed, asserted per variant rather than as one negation: /// splitting blame out of a single denial is exactly the change that @@ -401,7 +452,12 @@ mod tests { /// its own line. #[test] fn only_an_active_token_is_admitted() { - assert_eq!(classify(Verdict::Active), Ok(())); + assert_eq!( + classify(Verdict::Active { + client_id: Some("swarm-controller".to_owned()) + }), + Ok(Some("swarm-controller".to_owned())) + ); assert_eq!( classify(Verdict::Inactive), Err(Refusal::Caller), @@ -441,7 +497,7 @@ mod tests { /// applied, nor anything about this bridge's own credential. #[test] fn refusal_messages_say_nothing_useful_to_an_attacker() { - for refusal in [Refusal::Caller, Refusal::Bridge] { + for refusal in [Refusal::Caller, Refusal::Bridge, Refusal::Forbidden] { let message = refusal.message(); for leak in ["secret", "client_id", "authelia", "http", "status"] { assert!( @@ -451,4 +507,56 @@ mod tests { } } } + + const CONTROLLER: &str = "swarm-controller"; + + /// The trust boundary on the operator list. Every caller below holds a + /// token authelia calls active — a hive and a hive's agents are issued + /// machine tokens too — and only the controller's client is answered. + #[test] + fn only_the_controller_may_list_operators() { + let req = BridgeRequest::ListOperators; + assert_eq!(permit(&req, Some(CONTROLLER), CONTROLLER), Ok(())); + for other in ["hive-alpha", "hive-alpha-agent", "swarm-authelia-bridge"] { + assert_eq!( + permit(&req, Some(other), CONTROLLER), + Err(Refusal::Forbidden), + "{other} holds an active token but is not the controller" + ); + } + assert_eq!( + permit(&req, None, CONTROLLER), + Err(Refusal::Forbidden), + "a token authelia attributed to no client is not the controller's" + ); + } + + /// The control for the test above: the agent verbs stay open to any + /// admitted caller, so the refusal there comes from the scoping and not + /// from `permit` refusing everyone who is not the controller. + #[test] + fn the_agent_verbs_stay_open_to_any_active_caller() { + for req in [ + BridgeRequest::EnsureAgentIdentity { + name: "atlas".to_owned(), + }, + BridgeRequest::ListAgentIdentities, + ] { + assert_eq!( + permit(&req, Some("hive-alpha"), CONTROLLER), + Ok(()), + "{req:?}" + ); + assert_eq!(permit(&req, None, CONTROLLER), Ok(()), "{req:?}"); + } + } + + #[test] + fn a_scoped_refusal_is_403_not_401() { + assert_eq!( + Refusal::Forbidden.status(), + StatusCode::FORBIDDEN, + "the token is valid; a 401 would send the caller off to mint a new one" + ); + } } diff --git a/swarm-authelia-bridge/src/store.rs b/swarm-authelia-bridge/src/store.rs index 21abfe48..88c71ee1 100644 --- a/swarm-authelia-bridge/src/store.rs +++ b/swarm-authelia-bridge/src/store.rs @@ -132,10 +132,16 @@ pub fn mark_as_agent(store: &mut UserStore, name: &str) -> bool { /// Sorted because [`UserStore::users`] is a `BTreeMap`, so this is free and /// a consumer diffing two answers sees real changes rather than reordering. pub fn agent_names(store: &UserStore) -> Vec { + group_members(store, AGENT_GROUP) +} + +/// The names in the store that carry `group`, sorted for +/// [`agent_names`]'s reason. +pub fn group_members(store: &UserStore, group: &str) -> Vec { store .users .iter() - .filter(|(_, user)| user.groups.iter().any(|g| g == AGENT_GROUP)) + .filter(|(_, user)| user.groups.iter().any(|g| g == group)) .map(|(name, _)| name.clone()) .collect() } @@ -343,9 +349,9 @@ mod tests { } } - /// The group an operator carries — a literal here rather than a shared - /// constant, because this binary does not read the operator group and a - /// test that imported one would imply it did. + /// The group an operator carries — a literal here, because this binary + /// takes the operator group from its environment and has no constant to + /// import. fn operator(password: &str) -> User { User { groups: vec!["admins".to_owned()], @@ -477,6 +483,22 @@ some_future_top_level_key: 7 assert_eq!(agent_names(&store), ["atlas"]); } + /// The operator list is the group's members only — not agents, and not + /// an account that carries no group. + #[test] + fn the_operator_list_is_the_groups_members_and_not_the_file() { + let mut store = UserStore::default(); + store.users.insert("atlas".to_owned(), agent("$argon2id$a")); + store + .users + .insert("mara".to_owned(), operator("$argon2id$b")); + store + .users + .insert("ungrouped".to_owned(), user("$argon2id$c")); + + assert_eq!(group_members(&store, "admins"), ["mara"]); + } + /// An identity that predates the marker is invisible to the roster until /// agent creation runs again — which is the agreed migration, so it has /// to actually change something. diff --git a/swarm-controller/src/auth.rs b/swarm-controller/src/auth.rs index 72a070f3..db551dc0 100644 --- a/swarm-controller/src/auth.rs +++ b/swarm-controller/src/auth.rs @@ -97,6 +97,21 @@ impl AuthBridge { } } + /// The usernames in authelia's operator group. The bridge answers this + /// to this daemon's client only. + /// + /// Names only, for [`Self::list_agent_identities`]'s reason. + pub async fn list_operators(&self) -> Result> { + match self.request(&BridgeRequest::ListOperators).await? { + BridgeResponse::Operators { operators } => Ok(operators), + other => { + anyhow::bail!( + "swarm-authelia-bridge answered an operator-list request with {other:?}" + ) + } + } + } + /// One authenticated round-trip to the bridge. /// /// A fresh token per call, deliberately — see the module doc. Shared by diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 80339b53..48115441 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -2283,16 +2283,20 @@ fn configured_matrix_homeserver() -> Option> { } /// Start the matrix-account backfill and the hive sender-token pass when a -/// homeserver is configured. Lifted out of `main` for -/// `clippy::too_many_lines`. +/// homeserver is configured, and the operator admin pass when the bridge is +/// too. Lifted out of `main` for `clippy::too_many_lines`. fn spawn_matrix_account_backfill( jobq: &Arc>>, homeserver: Option>, hives: &[HiveEntry], + auth: Option>, ) { let Some(base) = homeserver else { return; }; + if let Some(bridge) = auth { + matrix_account::operator_admin::spawn(bridge, Arc::clone(&base)); + } let sched = Arc::clone(jobq); matrix_account::agent_token::spawn(Arc::clone(&base), move |agents| { if let Err(e) = queue_matrix_account_mints(&sched, agents) { @@ -2875,7 +2879,7 @@ async fn main() -> Result<()> { hive_jobq::resources::ResourceTable::new(), ))); let hives = load_hives(); - spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), &hives); + spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), &hives, auth.clone()); spawn_jobq_worker(Arc::clone(&jobq), deps); // Bound to a named variable, not `_` — dropping the provider stops its // `PeriodicReader`, so it must live as long as `main` does (which it diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 17aa7c77..1817c8b1 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -5,6 +5,8 @@ //! where the agent's daemon reads it. See docs/swarm/credentials.md for why //! `main` is the one name [`put_matrix_account`] refuses to write. //! [`hive_sender`] mints each **hive's** sender account the same way, sharing this module's helpers with [`agent_token`]. +//! [`operator_admin`] makes authelia's operators homeserver admins with the same +//! appservice token. //! //! **The external one** — [`put_matrix_account`] and everything under it — is //! an account somewhere else that an operator hands us a credential for: put it @@ -34,6 +36,7 @@ use super::{AppState, error_problem, swarm_hive}; pub mod agent_token; pub mod hive_sender; +pub mod operator_admin; fn default_mode() -> String { "token".to_owned() diff --git a/swarm-controller/src/matrix_account/operator_admin.rs b/swarm-controller/src/matrix_account/operator_admin.rs new file mode 100644 index 00000000..09e1b9f7 --- /dev/null +++ b/swarm-controller/src/matrix_account/operator_admin.rs @@ -0,0 +1,287 @@ +//! Homeserver admin for authelia's operators: every member of the operator +//! group who has an account on the swarm's homeserver is made a homeserver +//! admin, at start and every [`RECONCILE_INTERVAL`] after. +//! +//! ⚠️ **Grant-only.** tuwunel has no command that revokes homeserver admin, +//! so leaving the operator group leaves a matrix admin in place. +//! +//! The account an operator's SSO login lands on is named after authelia's +//! `preferred_username`, lowercased: `hive-matrix.nix` sets `userid_claims`, +//! and tuwunel lowercases the claim and refuses one that is not a legal +//! localpart. [`localpart`] applies the same rule. +//! +//! The command is `!admin users make-user-admin ` in the admin room +//! `#admins:`, sent as `@swarm` with the swarm appservice token — +//! `@swarm` is the one account the homeserver promotes at boot, and tuwunel +//! runs a command only when its sender is already an admin. Being an admin +//! *is* being joined to that room, so the room's joined members are the +//! operators who need nothing. An operator with no matrix account yet (no SSO +//! login so far) gets no command either: tuwunel would only refuse it, every +//! pass. +//! +//! The bot's reply is not read. A command that did not take shows up as the +//! same operator still missing from the room on the next pass, which sends it +//! again. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use swarm_matrix_client::{self as homeserver, Whoami}; + +use super::agent_token::appservice_token; +use crate::auth::AuthBridge; + +/// How often [`spawn`] re-checks the operator group. +const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5); + +/// The localpart of tuwunel's admin room alias, `#admins:`. +const ADMIN_ROOM_ALIAS_LOCALPART: &str = "admins"; + +/// The localpart an SSO login by authelia user `username` lands on, or `None` +/// when tuwunel would refuse the name and no account can exist for it. +/// +/// Lowercased and then checked against the user-id grammar's localpart +/// characters, which is what tuwunel does with the claim. +pub fn localpart(username: &str) -> Option { + let lower = username.to_lowercase(); + let legal = !lower.is_empty() + && lower.bytes().all(|b| { + b.is_ascii_lowercase() + || b.is_ascii_digit() + || matches!(b, b'.' | b'_' | b'=' | b'-' | b'/' | b'+') + }); + legal.then_some(lower) +} + +/// What one pass decided, before any account is looked up. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Plan { + /// `(authelia username, user id)` for each operator not yet an admin. + pub grant: Vec<(String, String)>, + /// Operators whose username maps to no legal localpart. + pub unmappable: Vec, +} + +/// Decide which of `operators` need a grant on `server`, given the user ids +/// already joined to the admin room. +pub fn plan(operators: &[String], server: &str, admins: &BTreeSet) -> Plan { + let mut plan = Plan::default(); + for name in operators { + let Some(localpart) = localpart(name) else { + plan.unmappable.push(name.clone()); + continue; + }; + let user_id = format!("@{localpart}:{server}"); + if !admins.contains(&user_id) { + plan.grant.push((name.clone(), user_id)); + } + } + plan +} + +/// The server name of a full user id, `@:`. +fn server_name(user_id: &str) -> Option<&str> { + user_id + .strip_prefix('@')? + .split_once(':') + .map(|(_, server)| server) + .filter(|server| !server.is_empty()) +} + +/// What one pass did. +#[derive(Debug, Default)] +struct Outcome { + granted: usize, + no_account: usize, + failed: usize, +} + +/// One pass: read the operator group, and send a grant for each operator +/// who has a matrix account and is not an admin. +/// +/// Fails as a whole when the group, the token, the server name, the admin +/// room or its members cannot be read: with any of them missing there is no +/// decision to make. +async fn pass(bridge: &AuthBridge, base: &str) -> Result { + let operators = bridge + .list_operators() + .await + .context("reading authelia's operator group")?; + let store = crate::store::connect() + .await + .context("logging in to the swarm secret store")?; + let token = appservice_token(&store).await?; + let http = homeserver::client()?; + let Whoami::User(sender) = homeserver::whoami(&http, base, &token) + .await + .context("asking the homeserver who the swarm appservice token is")? + else { + bail!("the homeserver does not know the swarm appservice token"); + }; + let server = server_name(&sender) + .with_context(|| format!("the homeserver named the swarm sender {sender:?}"))?; + let room = homeserver::resolve_alias( + &http, + base, + &token, + &format!("#{ADMIN_ROOM_ALIAS_LOCALPART}:{server}"), + ) + .await?; + let admins = homeserver::joined_members(&http, base, &token, &room).await?; + + let plan = plan(&operators, server, &admins); + for name in &plan.unmappable { + tracing::warn!( + operator = %name, + "operator's username is no legal matrix localpart, so no SSO login can \ + have an account to make admin; skipped" + ); + } + let mut outcome = Outcome::default(); + for (name, user_id) in &plan.grant { + match grant(&http, base, &token, &room, user_id).await { + Ok(true) => { + tracing::info!( + operator = %name, + %user_id, + "sent make-user-admin for an operator who is not a homeserver admin" + ); + outcome.granted += 1; + } + Ok(false) => { + tracing::debug!( + operator = %name, + %user_id, + "operator has no matrix account yet; nothing to make admin" + ); + outcome.no_account += 1; + } + Err(e) => { + tracing::warn!( + operator = %name, + %user_id, + error = %format!("{e:#}"), + "make-user-admin for an operator failed" + ); + outcome.failed += 1; + } + } + } + Ok(outcome) +} + +/// Send the grant for `user_id` when its account exists; `false` when it +/// does not, and nothing was sent. +async fn grant( + http: &reqwest::Client, + base: &str, + token: &str, + room: &str, + user_id: &str, +) -> Result { + if !homeserver::user_exists(http, base, token, user_id).await? { + return Ok(false); + } + homeserver::send_text( + http, + base, + token, + room, + &format!("!admin users make-user-admin {user_id}"), + ) + .await?; + Ok(true) +} + +/// Run [`pass`] now and every [`RECONCILE_INTERVAL`] after. A pass that fails +/// is logged and retried on the next tick; it never stops the daemon. +pub fn spawn(bridge: Arc, base: Arc) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); + loop { + ticker.tick().await; + match pass(&bridge, &base).await { + Ok(outcome) if outcome.failed > 0 => tracing::warn!( + granted = outcome.granted, + failed = outcome.failed, + retry_in_s = RECONCILE_INTERVAL.as_secs(), + "operator matrix admin: pass incomplete; retrying next tick" + ), + Ok(outcome) if outcome.granted > 0 => tracing::info!( + granted = outcome.granted, + no_account = outcome.no_account, + "operator matrix admin: grants sent" + ), + Ok(outcome) => tracing::debug!( + no_account = outcome.no_account, + "operator matrix admin: every operator with an account is an admin" + ), + Err(e) => tracing::warn!( + error = %format!("{e:#}"), + retry_in_s = RECONCILE_INTERVAL.as_secs(), + "operator matrix admin: pass failed; retrying next tick" + ), + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(names: &[&str]) -> Vec { + names.iter().map(|&n| n.to_owned()).collect() + } + + fn ids(ids: &[&str]) -> BTreeSet { + ids.iter().map(|&n| n.to_owned()).collect() + } + + #[test] + fn an_operator_already_in_the_admin_room_gets_no_command() { + let plan = plan( + &names(&["mara", "vinzenz"]), + "t.local", + &ids(&["@mara:t.local", "@swarm:t.local"]), + ); + assert_eq!( + plan.grant, + [("vinzenz".to_owned(), "@vinzenz:t.local".to_owned())] + ); + assert!(plan.unmappable.is_empty()); + } + + #[test] + fn a_pass_with_every_operator_admin_sends_nothing() { + let plan = plan(&names(&["mara"]), "t.local", &ids(&["@mara:t.local"])); + assert_eq!(plan, Plan::default()); + } + + #[test] + fn an_uppercase_username_maps_to_the_lowercased_account() { + // tuwunel lowercases the claim, so `Mara`'s login is `@mara:`, and that + // account being admin already is a match, not a second grant. + let plan = plan(&names(&["Mara"]), "t.local", &ids(&["@mara:t.local"])); + assert_eq!(plan, Plan::default()); + assert_eq!(localpart("Mara.B_c").as_deref(), Some("mara.b_c")); + } + + #[test] + fn a_name_tuwunel_would_refuse_is_reported_not_granted() { + let plan = plan(&names(&["bad name", "ok"]), "t.local", &BTreeSet::new()); + assert_eq!(plan.grant, [("ok".to_owned(), "@ok:t.local".to_owned())]); + assert_eq!(plan.unmappable, ["bad name"]); + assert_eq!(localpart(""), None); + assert_eq!(localpart("é"), None); + } + + #[test] + fn the_server_name_comes_from_the_full_user_id() { + assert_eq!(server_name("@swarm:t.local"), Some("t.local")); + assert_eq!(server_name("@swarm:t.local:8448"), Some("t.local:8448")); + assert_eq!(server_name("@swarm:"), None); + assert_eq!(server_name("swarm"), None); + } +} diff --git a/swarm-matrix-client/src/lib.rs b/swarm-matrix-client/src/lib.rs index c7c4c4fa..ccaf9fad 100644 --- a/swarm-matrix-client/src/lib.rs +++ b/swarm-matrix-client/src/lib.rs @@ -1,5 +1,7 @@ -//! The appservice calls against the swarm's homeserver: register, log in, and -//! ask a token who it is. Shared by `swarm-matrix-ctl` and `swarm-controller`; +//! The appservice calls against the swarm's homeserver: register, log in, ask +//! a token who it is, and the room calls `swarm-controller`'s admin-room +//! commands need (resolve an alias, list members, check an account exists, +//! send a message). Shared by `swarm-matrix-ctl` and `swarm-controller`; //! see the README for why both mint through the same device id. //! //! 🩸 **Every error in this module is built from the response's `status` and @@ -188,6 +190,183 @@ fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Resu bail!("the homeserver refused whoami: {}", why(status, json)) } +/// The room id `alias` names. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses. +pub async fn resolve_alias( + client: &reqwest::Client, + base: &str, + token: &str, + alias: &str, +) -> Result { + let (status, json) = get( + client, + &format!( + "{base}/_matrix/client/v3/directory/room/{}", + path_segment(alias) + ), + token, + ) + .await + .with_context(|| format!("GET /directory/room for {alias}"))?; + if !status.is_success() { + bail!( + "the homeserver refused to resolve {alias}: {}", + why(status, &json) + ); + } + json["room_id"] + .as_str() + .map(str::to_owned) + .with_context(|| format!("the homeserver's answer for {alias} carried no `room_id`")) +} + +/// The user ids joined to `room_id`. The token's own user must be one of them. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses. +pub async fn joined_members( + client: &reqwest::Client, + base: &str, + token: &str, + room_id: &str, +) -> Result> { + let (status, json) = get( + client, + &format!( + "{base}/_matrix/client/v3/rooms/{}/joined_members", + path_segment(room_id) + ), + token, + ) + .await + .with_context(|| format!("GET /rooms/{room_id}/joined_members"))?; + if !status.is_success() { + bail!( + "the homeserver refused to list {room_id}'s members: {}", + why(status, &json) + ); + } + json["joined"] + .as_object() + .map(|joined| joined.keys().cloned().collect()) + .with_context(|| format!("the homeserver's member list for {room_id} carried no `joined`")) +} + +/// Whether `user_id` has an account on this homeserver. +/// +/// Asked through the profile endpoint, which answers `M_NOT_FOUND` for a +/// local user id with no account. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses with anything other than `M_NOT_FOUND`. +pub async fn user_exists( + client: &reqwest::Client, + base: &str, + token: &str, + user_id: &str, +) -> Result { + let (status, json) = get( + client, + &format!("{base}/_matrix/client/v3/profile/{}", path_segment(user_id)), + token, + ) + .await + .with_context(|| format!("GET /profile/{user_id}"))?; + if status.is_success() { + return Ok(true); + } + if errcode(&json) == Some("M_NOT_FOUND") { + return Ok(false); + } + bail!( + "the homeserver refused {user_id}'s profile: {}", + why(status, &json) + ) +} + +/// Send `body` to `room_id` as an `m.text` message, and return its event id. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses. +pub async fn send_text( + client: &reqwest::Client, + base: &str, + token: &str, + room_id: &str, + body: &str, +) -> Result { + let resp = client + .put(format!( + "{base}/_matrix/client/v3/rooms/{}/send/m.room.message/{}", + path_segment(room_id), + random_hex(8)? + )) + .bearer_auth(token) + .json(&serde_json::json!({ "msgtype": "m.text", "body": body })) + .send() + .await + .with_context(|| format!("PUT a message into {room_id}: sending the request"))?; + let status = resp.status(); + let json = resp + .json::() + .await + .with_context(|| format!("PUT a message into {room_id}: decoding the response"))?; + if !status.is_success() { + bail!( + "the homeserver refused a message into {room_id}: {}", + why(status, &json) + ); + } + json["event_id"] + .as_str() + .map(str::to_owned) + .with_context(|| { + format!("the homeserver's answer to a message into {room_id} carried no `event_id`") + }) +} + +/// `value` percent-encoded for one URL path segment: room ids, aliases and +/// user ids carry `!`, `#`, `@` and `:`, and `#` would otherwise end the path. +fn path_segment(value: &str) -> String { + use std::fmt::Write as _; + + value.bytes().fold(String::new(), |mut acc, b| { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { + acc.push(char::from(b)); + } else { + // Infallible, for `random_hex`'s reason. + let _ = write!(acc, "%{b:02X}"); + } + acc + }) +} + +/// One authenticated GET, returning the status beside the decoded body. +async fn get( + client: &reqwest::Client, + url: &str, + token: &str, +) -> Result<(reqwest::StatusCode, serde_json::Value)> { + let resp = client + .get(url) + .bearer_auth(token) + .send() + .await + .context("sending the request")?; + let status = resp.status(); + let json = resp + .json::() + .await + .context("decoding the response as JSON")?; + Ok((status, json)) +} + /// The device every token this binary mints is pinned to. fn device_id(localpart: &str) -> String { format!("hyperhive-{localpart}") @@ -360,6 +539,14 @@ mod tests { } } + #[test] + fn a_path_segment_escapes_what_would_end_or_split_the_path() { + assert_eq!(path_segment("#admins:t.local"), "%23admins%3At.local"); + assert_eq!(path_segment("!abc:t.local"), "%21abc%3At.local"); + assert_eq!(path_segment("@a.b_c-d:t.local"), "%40a.b_c-d%3At.local"); + assert_eq!(path_segment("a/b"), "a%2Fb"); + } + #[test] fn a_minted_password_is_hex_of_the_declared_length() { // The control on the hex fold: a short or non-hex password would be