From 962c1548eb09b78d705199c162c18183081bc054 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 5 Oct 2026 10:27:57 +0200 Subject: [PATCH] swarm-controller: revoke matrix admin from former operators The operator admin sync now also takes homeserver admin away. It records each user id it sends `make-user-admin` for in `operator-matrix-admins.json` under the controller's state directory (written to a temporary file and renamed over it; a missing file is an empty set). On each pass, a recorded account that is no longer in authelia's `admins` group but is still joined to `#admins:` is sent `!admin users force-leave-room ` as `@swarm`, over the same `/_matrix` path the grant uses. Leaving that room is what ends tuwunel admin. It never revokes an admin it didn't grant, `@swarm`, tuwunel's server user (`@conduit:`), an agent's account (the store's `hive-agent-*` cert roles, the same roster the agent account mint uses), or anyone in the new `services.hyperhive.swarm.matrix.adminSync.ignoreUsers` list. That list reaches the daemon as `SWARM_CONTROLLER_MATRIX_ADMIN_SYNC_IGNORE_USERS`. It also never sends a revoke that would leave `#admins` with no member besides `@swarm` and the server user. A revoke is sent at most once per account per controller process, like a grant. A recorded account that is out of both the group and the room is dropped from the file. Closes #4799 --- docs/agent-lifecycle/persistence.md | 8 + docs/integrations/matrix.md | 26 +- nix/host-modules/hive-matrix-service.nix | 12 + nix/host-modules/swarm-controller.nix | 4 + nix/module-eval/bao-controller.nix | 18 + swarm-controller/README.md | 3 +- swarm-controller/src/main.rs | 7 +- .../src/matrix_account/operator_admin.rs | 478 ++++++++++++++++-- 8 files changed, 515 insertions(+), 41 deletions(-) diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 9d02c92b..f6e104b1 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -472,6 +472,14 @@ rather than accepting deliveries it can't verify. Everything else the controller serves is unaffected. +- `operator-matrix-admins.json` — the matrix user ids the operator admin + sync has made homeserver admin, a JSON array. The sync revokes admin + only from these, so deleting the file leaves every current admin in + place; a missing file is an empty set. A file the controller can't + read or parse stops the sync, grants included, and the controller logs + `operator matrix admin: pass failed` on every pass. See + [`docs/integrations/matrix.md`](../integrations/matrix.md#operators-are-homeserver-admins). + ## Run-time dirs `/run/hyperhive/` is tmpfs-backed (systemd `RuntimeDirectory=`) but diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 8a0e0739..289d3f56 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -181,15 +181,29 @@ each member not yet in `#admins:` it sends `` 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. +- **Leaving `admins` revokes what the sync granted.** The controller + records each account it sends a grant for in + `/var/lib/swarm-controller/operator-matrix-admins.json`. When one of + them is no longer in `admins` but still in `#admins`, it sends + `!admin users force-leave-room @: ` + there as `@swarm`; leaving that room is what ends homeserver admin. +- **Admins the sync didn't make keep admin.** An admin missing from that + file, `@swarm`, the server user `@conduit:`, every agent's + account, and every user id in + `services.hyperhive.swarm.matrix.adminSync.ignoreUsers` are never + revoked. +- **The last admin stays.** A revoke that would leave no admin in + `#admins` besides `@swarm` and the server user isn't sent; the + controller logs a warning on each pass instead. - **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. -- **One command per member per controller run.** A member still missing - from `#admins` after it's sent gets a `make-user-admin … failed to take` - warning in the controller log on each later pass, and no second command - until the controller restarts. +- **One command per account per controller run.** A member still missing + from `#admins` after the controller sends its grant gets a + `make-user-admin … failed to take` warning in the controller log on + each later pass, and no second command until the controller restarts; + a revoke that leaves its account in `#admins` gets + `force-leave-room … failed to take` the same way. - Runs only on a controller with both `services.hyperhive.deploy.swarm-controller.authBridgeUrl` and `services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl` set. diff --git a/nix/host-modules/hive-matrix-service.nix b/nix/host-modules/hive-matrix-service.nix index f2a780c3..ad009222 100644 --- a/nix/host-modules/hive-matrix-service.nix +++ b/nix/host-modules/hive-matrix-service.nix @@ -147,6 +147,18 @@ in ''; }; + adminSync.ignoreUsers = lib.mkOption { + type = lib.types.listOf (lib.types.strMatching "@[^:[:space:]]+:[^[:space:]]+"); + default = [ ]; + example = [ "@mara:example.org" ]; + description = '' + Full matrix user ids `swarm-controller`'s operator admin sync never + revokes homeserver admin from. The sync revokes admin only from + accounts it made admin itself, once their owner leaves authelia's + `admins` group; an account listed here keeps admin regardless. + ''; + }; + # This homeserver always delegates login to the swarm's authelia, as # an OIDC relying party — matrix SSO (`m.login.sso`), offered # alongside password login. No toggle: a homeserver in a swarm is a diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index b76f85a3..4c2b716e 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -877,6 +877,10 @@ in # nothing about it is unsafe to define on a host whose queue is # off (the daemon just has nothing to apply it to). SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds; + # The accounts the operator admin sync never revokes. Whitespace- + # separated: a matrix user id cannot contain a space. Consumed by + # swarm-controller/src/matrix_account/operator_admin.rs. + SWARM_CONTROLLER_MATRIX_ADMIN_SYNC_IGNORE_USERS = lib.concatStringsSep " " matrixCfg.adminSync.ignoreUsers; } // queueEnv // forgeEnv diff --git a/nix/module-eval/bao-controller.nix b/nix/module-eval/bao-controller.nix index 8f7d6bc9..6c7ef437 100644 --- a/nix/module-eval/bao-controller.nix +++ b/nix/module-eval/bao-controller.nix @@ -63,6 +63,16 @@ let swarm.matrix.gatewayHost = null; }; + # An ignore list no default could supply, with two entries so the case below + # also pins the separator the daemon splits on. + controllerIgnoresAdmins = hive { + deploy.swarm-controller.enable = true; + swarm.matrix.adminSync.ignoreUsers = [ + "@keep-a:t.local" + "@keep-b:t.local" + ]; + }; + # The two authorities told apart. A deployment that self-signs both ends # points `clientCaFile` and `serverCaFile` at one file, so on the fixture # above the CA a hive is issued from and the CA the store is verified by are @@ -127,6 +137,14 @@ let ? SWARM_CONTROLLER_MATRIX_HOMESERVER_URL ); } + { + # operator_admin.rs splits this on whitespace; an entry missing here is + # an account the sync would revoke. + name = "the controller is told the operator admin sync's ignore list"; + ok = + controllerIgnoresAdmins.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_MATRIX_ADMIN_SYNC_IGNORE_USERS + or null == "@keep-a:t.local @keep-b:t.local"; + } { # Nothing asserted the PKI script before this, so a third leaf could be # added to it and every case still passed — measured, not assumed: the diff --git a/swarm-controller/README.md b/swarm-controller/README.md index 876483b3..c1889227 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -50,7 +50,8 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients. icons, the cross-repo issue report, and the UI's quick links. It reads its configuration once at startup, from the environment the nix module -sets. The one file it persists is `webhook-secret` in its state directory. +sets. It persists `webhook-secret` and `operator-matrix-admins.json` in its +state directory. The job graph lives in memory; hive status and wanted state live in the swarm queue, so both survive a restart. diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index ee0717a8..ac9c5591 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -8,9 +8,10 @@ //! `load_hives`. A config change means a redeploy, same as every other //! option this process reads. //! -//! The one thing it does persist is `webhook-secret` under its -//! `StateDirectory` (see `webhook`), because that key is handed to Forgejo -//! at registration and so cannot be regenerated per boot. +//! It persists `webhook-secret` under its `StateDirectory` (see `webhook`), +//! because that key is handed to Forgejo at registration and so cannot be +//! regenerated per boot, and the operator admin sync's granted set beside it +//! (see `matrix_account::operator_admin`). //! //! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on //! one host, this owns what is true across hives. diff --git a/swarm-controller/src/matrix_account/operator_admin.rs b/swarm-controller/src/matrix_account/operator_admin.rs index 3bc7d06e..a4ffaddd 100644 --- a/swarm-controller/src/matrix_account/operator_admin.rs +++ b/swarm-controller/src/matrix_account/operator_admin.rs @@ -1,35 +1,38 @@ //! 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. +//! 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. //! -//! 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. +//! 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 bot's reply is not read, and a grant is sent **at most once per -//! operator per controller process**. An operator still missing from the room -//! on a later pass is logged as `make-user-admin … failed to take` and not sent -//! again until the controller restarts: the command stays in the room's -//! history, so one tuwunel ignores must not be repeated every pass. +//! 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; @@ -40,6 +43,20 @@ 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. /// @@ -96,6 +113,86 @@ pub fn plan( 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 @@ -109,18 +206,43 @@ fn server_name(user_id: &str) -> Option<&str> { #[derive(Debug, Default)] struct Outcome { granted: usize, + revoked: usize, no_account: usize, failed: usize, } -/// One pass: read the operator group, and send a grant for each operator -/// who has a matrix account, is not an admin, and is not in `sent` — the user -/// ids this process already sent one for, which a sent grant joins. +/// 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 or its members cannot be read: with any of them missing there is no -/// decision to make. -async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet) -> Result { +/// 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 @@ -146,8 +268,65 @@ async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet) -> R ) .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 plan = plan(&operators, server, &admins, sent); + 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, @@ -165,9 +344,11 @@ async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet) -> R } let mut outcome = Outcome::default(); for (name, user_id) in &plan.grant { - match grant(&http, base, &token, &room, user_id).await { + match grant(room.http, room.base, room.token, room.id, user_id).await { Ok(true) => { - sent.insert(user_id.clone()); + memory.sent.insert(user_id.clone()); + memory.revoked.remove(user_id); + granted.insert(user_id.clone()); tracing::info!( operator = %name, %user_id, @@ -194,7 +375,70 @@ async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet) -> R } } } - Ok(outcome) + 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 @@ -223,22 +467,26 @@ async fn grant( /// 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 sent = BTreeSet::new(); + let mut memory = Memory::default(); let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); loop { ticker.tick().await; - match pass(&bridge, &base, &mut sent).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 => tracing::info!( + 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: grants sent" + "operator matrix admin: commands sent" ), Ok(outcome) => tracing::debug!( no_account = outcome.no_account, @@ -358,4 +606,172 @@ mod tests { 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" + ); + } }