Watch
0
0
Fork
You've already forked hyperhive
0

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:<server>` is
sent `!admin users force-leave-room <user> <admin room id>` 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:<server>`), 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
This commit is contained in:
atlas 2026-10-05 10:27:57 +02:00 • committed by mara
commit 962c1548eb
8 changed files with 515 additions and 41 deletions

View file

@ -472,6 +472,14 @@ rather than accepting deliveries it can't verify. Everything else the
controller serves is unaffected. controller serves is unaffected.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
- `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-time dirs
`/run/hyperhive/` is tmpfs-backed (systemd `RuntimeDirectory=`) but `/run/hyperhive/` is tmpfs-backed (systemd `RuntimeDirectory=`) but

View file

@ -181,15 +181,29 @@ each member not yet in `#admins:<server_name>` it sends
`<name>` is the authelia username lowercased, the account an SSO login `<name>` is the authelia username lowercased, the account an SSO login
lands on. lands on.
- **Grant-only.** Removing someone from `admins` leaves their matrix - **Leaving `admins` revokes what the sync granted.** The controller
admin in place: tuwunel has no command that takes it away. 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 @<name>:<server_name> <admin room id>`
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:<server_name>`, 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 - **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 has no account to promote. The first pass after their first SSO login
promotes them. promotes them.
- **One command per member per controller run.** A member still missing - **One command per account per controller run.** A member still missing
from `#admins` after it's sent gets a `make-user-admin … failed to take` from `#admins` after the controller sends its grant gets a
warning in the controller log on each later pass, and no second command `make-user-admin … failed to take` warning in the controller log on
until the controller restarts. 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 - Runs only on a controller with both
`services.hyperhive.deploy.swarm-controller.authBridgeUrl` and `services.hyperhive.deploy.swarm-controller.authBridgeUrl` and
`services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl` set. `services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl` set.

View file

@ -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 # This homeserver always delegates login to the swarm's authelia, as
# an OIDC relying party — matrix SSO (`m.login.sso`), offered # an OIDC relying party — matrix SSO (`m.login.sso`), offered
# alongside password login. No toggle: a homeserver in a swarm is a # alongside password login. No toggle: a homeserver in a swarm is a

View file

@ -877,6 +877,10 @@ in
# nothing about it is unsafe to define on a host whose queue is # nothing about it is unsafe to define on a host whose queue is
# off (the daemon just has nothing to apply it to). # off (the daemon just has nothing to apply it to).
SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds; 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 // queueEnv
// forgeEnv // forgeEnv

View file

@ -63,6 +63,16 @@ let
swarm.matrix.gatewayHost = null; 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 # The two authorities told apart. A deployment that self-signs both ends
# points `clientCaFile` and `serverCaFile` at one file, so on the fixture # 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 # 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 ? 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 # 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 # added to it and every case still passed — measured, not assumed: the

View file

@ -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. 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 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 The job graph lives in memory; hive status and wanted state live in the swarm
queue, so both survive a restart. queue, so both survive a restart.

View file

@ -8,9 +8,10 @@
//! `load_hives`. A config change means a redeploy, same as every other //! `load_hives`. A config change means a redeploy, same as every other
//! option this process reads. //! option this process reads.
//! //!
//! The one thing it does persist is `webhook-secret` under its //! It persists `webhook-secret` under its `StateDirectory` (see `webhook`),
//! `StateDirectory` (see `webhook`), because that key is handed to Forgejo //! because that key is handed to Forgejo at registration and so cannot be
//! at registration and so cannot be regenerated per boot. //! 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 //! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on
//! one host, this owns what is true across hives. //! one host, this owns what is true across hives.

View file

@ -1,35 +1,38 @@
//! Homeserver admin for authelia's operators: every member of the operator //! 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 //! group who has an account on the swarm's homeserver is made a homeserver
//! admin, at start and every [`RECONCILE_INTERVAL`] after. //! admin, at start and every [`RECONCILE_INTERVAL`] after, and an account this
//! //! sync made admin loses it once its owner leaves the group.
//! ⚠️ **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 //! The account an operator's SSO login lands on is named after authelia's
//! `preferred_username`, lowercased: `hive-matrix.nix` sets `userid_claims`, //! `preferred_username`, lowercased: `hive-matrix.nix` sets `userid_claims`,
//! and tuwunel lowercases the claim and refuses one that is not a legal //! and tuwunel lowercases the claim and refuses one that is not a legal
//! localpart. [`localpart`] applies the same rule. //! localpart. [`localpart`] applies the same rule.
//! //!
//! The command is `!admin users make-user-admin <user>` in the admin room //! Both commands go to the admin room `#admins:<server>` as `@swarm`, with the
//! `#admins:<server>`, sent as `@swarm` with the swarm appservice token — //! swarm appservice token: `@swarm` is the one account the homeserver promotes
//! `@swarm` is the one account the homeserver promotes at boot, and tuwunel //! at boot, and tuwunel runs a command only when its sender is already an
//! runs a command only when its sender is already an admin. Being an admin //! admin. Being an admin *is* being joined to that room. The grant,
//! *is* being joined to that room, so the room's joined members are the //! `!admin users make-user-admin <user>`, goes to each operator outside the
//! operators who need nothing. An operator with no matrix account yet (no SSO //! room who has an account; the revoke is
//! login so far) gets no command either: tuwunel would only refuse it, every //! `!admin users force-leave-room <user> <room>`.
//! pass.
//! //!
//! The bot's reply is not read, and a grant is sent **at most once per //! The revoke reaches only the user ids in [`GRANTED_FILE`], those this sync
//! operator per controller process**. An operator still missing from the room //! sent a grant for, and never `@swarm`, the server user, an agent's account
//! on a later pass is logged as `make-user-admin … failed to take` and not sent //! or a user id in [`IGNORE_USERS_ENV`]. One that would leave no admin but
//! again until the controller restarts: the command stays in the room's //! `@swarm` and the server user is held back and logged.
//! history, so one tuwunel ignores must not be repeated every pass. //!
//! 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::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use swarm_matrix_client::{self as homeserver, Whoami}; use swarm_matrix_client::{self as homeserver, Whoami};
use swarm_secret_client::{client::DEFAULT_CERT_MOUNT, policy};
use super::agent_token::appservice_token; use super::agent_token::appservice_token;
use crate::auth::AuthBridge; 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:<server>`. /// The localpart of tuwunel's admin room alias, `#admins:<server>`.
const ADMIN_ROOM_ALIAS_LOCALPART: &str = "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` /// 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. /// when tuwunel would refuse the name and no account can exist for it.
/// ///
@ -96,6 +113,86 @@ pub fn plan(
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<String>,
/// Those this process already sent a revoke for. Not sent again.
pub unconfirmed: Vec<String>,
/// Held back: revoking them would leave no admin outside `keepers`.
pub last_admin: Vec<String>,
/// Neither operators nor admins: dropped from the set.
pub forget: Vec<String>,
}
/// 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<String>,
operators: &BTreeSet<String>,
admins: &BTreeSet<String>,
exempt: &BTreeSet<String>,
keepers: &BTreeSet<String>,
revoked: &BTreeSet<String>,
) -> 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<String> {
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<BTreeSet<String>> {
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<String>) -> 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, `@<localpart>:<server>`. /// The server name of a full user id, `@<localpart>:<server>`.
fn server_name(user_id: &str) -> Option<&str> { fn server_name(user_id: &str) -> Option<&str> {
user_id user_id
@ -109,18 +206,43 @@ fn server_name(user_id: &str) -> Option<&str> {
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct Outcome { struct Outcome {
granted: usize, granted: usize,
revoked: usize,
no_account: usize, no_account: usize,
failed: usize, failed: usize,
} }
/// One pass: read the operator group, and send a grant for each operator /// What [`spawn`]'s loop carries from one pass to the next.
/// who has a matrix account, is not an admin, and is not in `sent` — the user #[derive(Debug, Default)]
/// ids this process already sent one for, which a sent grant joins. struct Memory {
/// User ids this process sent a grant for, which a sent grant joins.
sent: BTreeSet<String>,
/// User ids this process sent a revoke for.
revoked: BTreeSet<String>,
}
/// 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 /// 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 /// room, its members, the agent roster or the stored set cannot be read: with
/// decision to make. /// any of them missing there is no decision to make.
async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet<String>) -> Result<Outcome> { async fn pass(
bridge: &AuthBridge,
base: &str,
ignore: &BTreeSet<String>,
granted_path: &Path,
memory: &mut Memory,
) -> Result<Outcome> {
let operators = bridge let operators = bridge
.list_operators() .list_operators()
.await .await
@ -146,8 +268,65 @@ async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet<String>) -> R
) )
.await?; .await?;
let admins = homeserver::joined_members(&http, base, &token, &room).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<String> = 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<String> =
[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<String>,
) -> Outcome {
for name in &plan.unmappable { for name in &plan.unmappable {
tracing::warn!( tracing::warn!(
operator = %name, operator = %name,
@ -165,9 +344,11 @@ async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet<String>) -> R
} }
let mut outcome = Outcome::default(); let mut outcome = Outcome::default();
for (name, user_id) in &plan.grant { 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) => { 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!( tracing::info!(
operator = %name, operator = %name,
%user_id, %user_id,
@ -194,7 +375,70 @@ async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet<String>) -> 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<String>,
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 /// 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 /// 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. /// is logged and retried on the next tick; it never stops the daemon.
pub fn spawn(bridge: Arc<AuthBridge>, base: Arc<str>) { pub fn spawn(bridge: Arc<AuthBridge>, base: Arc<str>) {
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 { tokio::spawn(async move {
let mut sent = BTreeSet::new(); let mut memory = Memory::default();
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop { loop {
ticker.tick().await; 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!( Ok(outcome) if outcome.failed > 0 => tracing::warn!(
granted = outcome.granted, granted = outcome.granted,
revoked = outcome.revoked,
failed = outcome.failed, failed = outcome.failed,
retry_in_s = RECONCILE_INTERVAL.as_secs(), retry_in_s = RECONCILE_INTERVAL.as_secs(),
"operator matrix admin: pass incomplete; retrying next tick" "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, granted = outcome.granted,
revoked = outcome.revoked,
no_account = outcome.no_account, no_account = outcome.no_account,
"operator matrix admin: grants sent" "operator matrix admin: commands sent"
), ),
Ok(outcome) => tracing::debug!( Ok(outcome) => tracing::debug!(
no_account = outcome.no_account, no_account = outcome.no_account,
@ -358,4 +606,172 @@ mod tests {
assert_eq!(server_name("@swarm:"), None); assert_eq!(server_name("@swarm:"), None);
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<String> {
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"
);
}
} }