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.
<!-- 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/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
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 @<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
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.

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
# an OIDC relying party — matrix SSO (`m.login.sso`), offered
# 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
# 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

View file

@ -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

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.
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.

View file

@ -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.

View file

@ -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 <user>` in the admin room
//! `#admins:<server>`, 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:<server>` 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 <user>`, goes to each operator outside the
//! room who has an account; the revoke is
//! `!admin users force-leave-room <user> <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:<server>`.
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<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>`.
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<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
/// 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<String>) -> Result<Outcome> {
/// 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<String>,
granted_path: &Path,
memory: &mut Memory,
) -> Result<Outcome> {
let operators = bridge
.list_operators()
.await
@ -146,8 +268,65 @@ async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet<String>) -> 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<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 {
tracing::warn!(
operator = %name,
@ -165,9 +344,11 @@ async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet<String>) -> 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<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
@ -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<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 {
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<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"
);
}
}