//! 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, and an account this //! sync made admin loses it once its owner leaves the group. //! //! 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. //! //! Both commands go to the admin room `#admins:` 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. The grant, //! `!admin users make-user-admin `, goes to each operator outside the //! room who has an account; the revoke is //! `!admin users force-leave-room `. //! //! The revoke reaches only the user ids in [`GRANTED_FILE`], those this sync //! sent a grant for, and never `@swarm`, the server user, an agent's account //! or a user id in [`IGNORE_USERS_ENV`]. One that would leave no admin but //! `@swarm` and the server user is held back and logged. //! //! The bot's reply is not read, and each command is sent **at most once per //! account per controller process**: one still unconfirmed on a later pass is //! logged as `… failed to take` and not sent again until the controller //! restarts, since every command stays in the room's history. use std::collections::BTreeSet; use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result, bail}; use swarm_matrix_client::{self as homeserver, Whoami}; use swarm_secret_client::{client::DEFAULT_CERT_MOUNT, policy}; 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 of tuwunel's server user, which creates the admin room and /// stays joined to it. tuwunel's default `server_user_localpart`; /// `hive-matrix.nix` does not set it. const SERVER_USER_LOCALPART: &str = "conduit"; /// The set of user ids this sync sent a grant for, a JSON array, in the /// controller's state directory. A missing file is an empty set. A revoked /// user id stays in it until a pass sees it out of the admin room. const GRANTED_FILE: &str = "operator-matrix-admins.json"; /// Whitespace-separated user ids the revoke never touches, set from /// `services.hyperhive.swarm.matrix.adminSync.ignoreUsers`. const IGNORE_USERS_ENV: &str = "SWARM_CONTROLLER_MATRIX_ADMIN_SYNC_IGNORE_USERS"; /// 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, /// `(authelia username, user id)` for each operator this process already /// sent a grant for who is still not an admin. Not sent again. pub unconfirmed: Vec<(String, String)>, } /// Decide which of `operators` need a grant on `server`, given the user ids /// already joined to the admin room and those this process already sent a /// grant for. pub fn plan( operators: &[String], server: &str, admins: &BTreeSet, sent: &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) { continue; } if sent.contains(&user_id) { plan.unconfirmed.push((name.clone(), user_id)); } else { plan.grant.push((name.clone(), user_id)); } } plan } /// What one pass decided about the user ids in [`GRANTED_FILE`]. #[derive(Debug, Default, PartialEq, Eq)] pub struct Revokes { /// No longer operators and still admins: sent a revoke. pub revoke: Vec, /// Those this process already sent a revoke for. Not sent again. pub unconfirmed: Vec, /// Held back: revoking them would leave no admin outside `keepers`. pub last_admin: Vec, /// Neither operators nor admins: dropped from the set. pub forget: Vec, } /// Decide which of `granted` lose admin, given the operator group's user ids, /// the admin room's joined members, the accounts never revoked (`exempt`), /// the accounts that do not count as an admin left behind (`keepers`, a /// subset of `exempt`), and the user ids this process already sent a revoke /// for. pub fn plan_revokes( granted: &BTreeSet, operators: &BTreeSet, admins: &BTreeSet, exempt: &BTreeSet, keepers: &BTreeSet, revoked: &BTreeSet, ) -> Revokes { let mut out = Revokes::default(); let mut left: BTreeSet<&String> = admins.iter().filter(|a| !keepers.contains(*a)).collect(); for user_id in granted { if operators.contains(user_id) { continue; } if !admins.contains(user_id) { out.forget.push(user_id.clone()); continue; } if exempt.contains(user_id) { continue; } if revoked.contains(user_id) { out.unconfirmed.push(user_id.clone()); } else if left.len() <= 1 { out.last_admin.push(user_id.clone()); } else { left.remove(user_id); out.revoke.push(user_id.clone()); } } out } /// The user ids in [`IGNORE_USERS_ENV`]'s value. fn parse_ignore_users(raw: &str) -> BTreeSet { raw.split_whitespace().map(str::to_owned).collect() } /// The set stored at `path`; empty when there is no file. fn load_granted(path: &Path) -> Result> { match std::fs::read(path) { Ok(bytes) => serde_json::from_slice(&bytes) .with_context(|| format!("parsing the granted operator admins in {}", path.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::new()), Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), } } /// Replace the set stored at `path`: written beside it and renamed over it, /// so a reader sees the old set or the new one, never part of either. fn save_granted(path: &Path, granted: &BTreeSet) -> Result<()> { let tmp = path.with_extension("json.tmp"); let bytes = serde_json::to_vec(granted).context("encoding the granted operator admins")?; let mut file = std::fs::File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?; std::io::Write::write_all(&mut file, &bytes) .and_then(|()| file.sync_all()) .with_context(|| format!("writing {}", tmp.display()))?; std::fs::rename(&tmp, path) .with_context(|| format!("renaming {} to {}", tmp.display(), path.display())) } /// 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, revoked: usize, no_account: usize, failed: usize, } /// What [`spawn`]'s loop carries from one pass to the next. #[derive(Debug, Default)] struct Memory { /// User ids this process sent a grant for, which a sent grant joins. sent: BTreeSet, /// User ids this process sent a revoke for. revoked: BTreeSet, } /// The admin room, and what sending to it takes. struct AdminRoom<'a> { http: &'a reqwest::Client, base: &'a str, token: &'a str, id: &'a str, } /// One pass: read the operator group, send a grant for each operator who has /// a matrix account, is not an admin, and is not in `memory.sent`, then a /// revoke for each account [`plan_revokes`] picks out of the set stored at /// `granted_path`. /// /// Fails as a whole when the group, the token, the server name, the admin /// room, its members, the agent roster or the stored set cannot be read: with /// any of them missing there is no decision to make. async fn pass( bridge: &AuthBridge, base: &str, ignore: &BTreeSet, granted_path: &Path, memory: &mut Memory, ) -> 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 roles = store .list_cert_roles(DEFAULT_CERT_MOUNT) .await .context("listing the store's cert-auth roles")?; let stored = load_granted(granted_path)?; let mut granted: BTreeSet = stored.union(&memory.sent).cloned().collect(); let admin_room = AdminRoom { http: &http, base, token: &token, id: &room, }; let mut outcome = send_grants( &admin_room, &plan(&operators, server, &admins, &memory.sent), memory, &mut granted, ) .await; let operator_ids = operators .iter() .filter_map(|name| localpart(name)) .map(|l| format!("@{l}:{server}")) .collect(); let keepers: BTreeSet = [sender.clone(), format!("@{SERVER_USER_LOCALPART}:{server}")].into(); let mut exempt = keepers.clone(); exempt.extend( policy::agents_from_role_names(&roles) .into_iter() .map(|agent| format!("@{agent}:{server}")), ); exempt.extend(ignore.iter().cloned()); let revokes = plan_revokes( &granted, &operator_ids, &admins, &exempt, &keepers, &memory.revoked, ); send_revokes(&admin_room, &revokes, memory, &mut granted, &mut outcome).await; if granted != stored { save_granted(granted_path, &granted)?; } Ok(outcome) } /// Log `plan`'s findings and send its grants, adding each one sent to /// `memory.sent` and `granted`. async fn send_grants( room: &AdminRoom<'_>, plan: &Plan, memory: &mut Memory, granted: &mut BTreeSet, ) -> Outcome { 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" ); } for (name, user_id) in &plan.unconfirmed { tracing::warn!( operator = %name, %user_id, "make-user-admin for an operator failed to take: sent earlier by this process, \ still not in the admin room; not sent again until the controller restarts" ); } let mut outcome = Outcome::default(); for (name, user_id) in &plan.grant { match grant(room.http, room.base, room.token, room.id, user_id).await { Ok(true) => { memory.sent.insert(user_id.clone()); memory.revoked.remove(user_id); granted.insert(user_id.clone()); 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; } } } outcome } /// Log `revokes`' findings, drop the forgotten user ids from `granted` and /// `memory`, and send its revokes, adding each one sent to `memory.revoked`. async fn send_revokes( room: &AdminRoom<'_>, revokes: &Revokes, memory: &mut Memory, granted: &mut BTreeSet, outcome: &mut Outcome, ) { for user_id in &revokes.forget { granted.remove(user_id); memory.sent.remove(user_id); memory.revoked.remove(user_id); tracing::debug!( %user_id, "granted account is neither an operator nor an admin; no longer tracked" ); } for user_id in &revokes.unconfirmed { tracing::warn!( %user_id, "force-leave-room for a former operator failed to take: sent earlier by this \ process, still in the admin room; not sent again until the controller restarts" ); } for user_id in &revokes.last_admin { tracing::warn!( %user_id, "former operator is the last homeserver admin besides @swarm and the server \ user; not revoked" ); } for user_id in &revokes.revoke { match homeserver::send_text( room.http, room.base, room.token, room.id, &format!("!admin users force-leave-room {user_id} {}", room.id), ) .await { Ok(_) => { memory.revoked.insert(user_id.clone()); memory.sent.remove(user_id); tracing::info!( %user_id, "sent force-leave-room for a former operator this sync made admin" ); outcome.revoked += 1; } Err(e) => { tracing::warn!( %user_id, error = %format!("{e:#}"), "force-leave-room for a former operator failed" ); outcome.failed += 1; } } } } /// 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) { let ignore = parse_ignore_users(&std::env::var(IGNORE_USERS_ENV).unwrap_or_default()); let granted_path = crate::webhook::state_dir().join(GRANTED_FILE); tokio::spawn(async move { let mut memory = Memory::default(); let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); loop { ticker.tick().await; match pass(&bridge, &base, &ignore, &granted_path, &mut memory).await { Ok(outcome) if outcome.failed > 0 => tracing::warn!( granted = outcome.granted, revoked = outcome.revoked, failed = outcome.failed, retry_in_s = RECONCILE_INTERVAL.as_secs(), "operator matrix admin: pass incomplete; retrying next tick" ), Ok(outcome) if outcome.granted > 0 || outcome.revoked > 0 => tracing::info!( granted = outcome.granted, revoked = outcome.revoked, no_account = outcome.no_account, "operator matrix admin: commands 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"]), &BTreeSet::new(), ); 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"]), &BTreeSet::new(), ); 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"]), &BTreeSet::new(), ); 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(), &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 a_grant_already_sent_by_this_process_is_not_sent_again() { // The cap on the admin room: a command tuwunel ignored leaves the // operator outside the room, and that must not produce a second send. let plan = plan( &names(&["mara", "vinzenz"]), "t.local", &BTreeSet::new(), &ids(&["@mara:t.local"]), ); assert_eq!( plan.grant, [("vinzenz".to_owned(), "@vinzenz:t.local".to_owned())] ); assert_eq!( plan.unconfirmed, [("mara".to_owned(), "@mara:t.local".to_owned())] ); } #[test] fn a_sent_grant_that_took_is_neither_sent_nor_reported() { let plan = plan( &names(&["mara"]), "t.local", &ids(&["@mara:t.local"]), &ids(&["@mara:t.local"]), ); assert_eq!(plan, Plan::default()); } #[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); } /// `@swarm` and the server user, the accounts `pass` always exempts. fn keepers() -> BTreeSet { ids(&["@swarm:t.local", "@conduit:t.local"]) } #[test] fn a_former_operator_the_sync_granted_is_revoked() { let revokes = plan_revokes( &ids(&["@mara:t.local", "@ex:t.local"]), &ids(&["@mara:t.local"]), &ids(&[ "@swarm:t.local", "@conduit:t.local", "@mara:t.local", "@ex:t.local", ]), &keepers(), &keepers(), &BTreeSet::new(), ); assert_eq!( revokes, Revokes { revoke: vec!["@ex:t.local".to_owned()], ..Revokes::default() } ); } #[test] fn a_hand_made_admin_is_never_revoked() { // `@hand` is no operator and an admin, but the sync never granted it. let revokes = plan_revokes( &ids(&["@mara:t.local"]), &ids(&["@mara:t.local"]), &ids(&["@swarm:t.local", "@mara:t.local", "@hand:t.local"]), &keepers(), &keepers(), &BTreeSet::new(), ); assert_eq!(revokes, Revokes::default()); } #[test] fn an_ignored_user_is_never_revoked() { let ignore = parse_ignore_users(" @ex:t.local\n@other:t.local "); assert_eq!(ignore, ids(&["@ex:t.local", "@other:t.local"])); let mut exempt = keepers(); exempt.extend(ignore); let revokes = plan_revokes( &ids(&["@ex:t.local"]), &BTreeSet::new(), &ids(&["@swarm:t.local", "@mara:t.local", "@ex:t.local"]), &exempt, &keepers(), &BTreeSet::new(), ); assert_eq!(revokes, Revokes::default()); } #[test] fn swarm_the_server_user_and_agents_are_never_revoked() { // An operator whose username matches an agent's name shares its // account; leaving the group must not take the agent's admin. let mut exempt = keepers(); exempt.insert("@argus:t.local".to_owned()); let revokes = plan_revokes( &ids(&["@swarm:t.local", "@conduit:t.local", "@argus:t.local"]), &BTreeSet::new(), &ids(&[ "@swarm:t.local", "@conduit:t.local", "@argus:t.local", "@mara:t.local", ]), &exempt, &keepers(), &BTreeSet::new(), ); assert_eq!(revokes, Revokes::default()); } #[test] fn the_last_admin_besides_swarm_and_the_server_user_is_not_revoked() { let revokes = plan_revokes( &ids(&["@exa:t.local", "@exb:t.local"]), &BTreeSet::new(), &ids(&[ "@swarm:t.local", "@conduit:t.local", "@exa:t.local", "@exb:t.local", ]), &keepers(), &keepers(), &BTreeSet::new(), ); assert_eq!(revokes.revoke, ["@exa:t.local"]); assert_eq!(revokes.last_admin, ["@exb:t.local"]); } #[test] fn a_revoke_already_sent_by_this_process_is_not_sent_again() { let revokes = plan_revokes( &ids(&["@ex:t.local"]), &BTreeSet::new(), &ids(&["@swarm:t.local", "@mara:t.local", "@ex:t.local"]), &keepers(), &keepers(), &ids(&["@ex:t.local"]), ); assert_eq!( revokes, Revokes { unconfirmed: vec!["@ex:t.local".to_owned()], ..Revokes::default() } ); } #[test] fn a_granted_account_out_of_the_room_and_the_group_is_forgotten() { // Forgotten, so an admin someone makes of it by hand later is theirs. let revokes = plan_revokes( &ids(&["@mara:t.local", "@ex:t.local"]), &ids(&["@mara:t.local"]), &ids(&["@swarm:t.local", "@mara:t.local"]), &keepers(), &keepers(), &ids(&["@ex:t.local"]), ); assert_eq!( revokes, Revokes { forget: vec!["@ex:t.local".to_owned()], ..Revokes::default() } ); } #[test] fn the_granted_set_survives_a_save_and_a_load() { let dir = std::env::temp_dir().join(format!("operator-admin-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("mkdir"); let path = dir.join(GRANTED_FILE); let missing = load_granted(&path); let set = ids(&["@mara:t.local", "@ex:t.local"]); save_granted(&path, &set).expect("first save"); let first = load_granted(&path); save_granted(&path, &ids(&["@mara:t.local"])).expect("second save"); let second = load_granted(&path); let leftovers: Vec<_> = std::fs::read_dir(&dir) .expect("list") .map(|e| e.expect("entry").file_name()) .collect(); std::fs::remove_dir_all(&dir).ok(); assert_eq!(missing.expect("a missing file loads"), BTreeSet::new()); assert_eq!(first.expect("loads"), set); assert_eq!(second.expect("loads"), ids(&["@mara:t.local"])); assert_eq!( leftovers, [GRANTED_FILE], "no temporary file is left beside it" ); } }