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
777 lines
26 KiB
Rust
777 lines
26 KiB
Rust
//! 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:<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 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:<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.
|
|
///
|
|
/// 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<String> {
|
|
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<String>,
|
|
/// `(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<String>,
|
|
sent: &BTreeSet<String>,
|
|
) -> 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<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
|
|
.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<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, 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
|
|
.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<String> = 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<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,
|
|
"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<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
|
|
/// does not, and nothing was sent.
|
|
async fn grant(
|
|
http: &reqwest::Client,
|
|
base: &str,
|
|
token: &str,
|
|
room: &str,
|
|
user_id: &str,
|
|
) -> Result<bool> {
|
|
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<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 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<String> {
|
|
names.iter().map(|&n| n.to_owned()).collect()
|
|
}
|
|
|
|
fn ids(ids: &[&str]) -> BTreeSet<String> {
|
|
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<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"
|
|
);
|
|
}
|
|
}
|