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:
parent
40d6f30834
commit
962c1548eb
8 changed files with 515 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue