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.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue