swarm-controller: make authelia's operators matrix homeserver admins
swarm-authelia-bridge gains ListOperators, the usernames in authelia's operator group (`operatorGroup = "admins"`, passed in from swarm-authelia.nix). Unlike the two agent verbs, which admit any active token, it answers only the client named by SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID (swarm-controller's queueClientId) and returns 403 to every other active token: authelia issues machine tokens to every hive and its agents as well. The bridge's introspection now models `client_id` to make that check, the same way swarm-nats-auth's does. swarm-controller runs a grant-only pass at start and every five minutes: read the group, map each username to the account an SSO login lands on (tuwunel lowercases `preferred_username`), skip anyone already joined to `#admins:<server>` (tuwunel's definition of admin) or without a matrix account, and send `!admin users make-user-admin <user>` there as @swarm with the swarm appservice token. Nothing is ever revoked: tuwunel has no command for it (#4799). The admin-room calls (alias, joined members, profile, send) go into swarm-matrix-client next to whoami, keeping its no-body-in-errors rule. Closes #4585
This commit is contained in:
parent
2c1a9fa488
commit
9e7fdb55ae
15 changed files with 787 additions and 33 deletions
|
|
@ -97,6 +97,21 @@ impl AuthBridge {
|
|||
}
|
||||
}
|
||||
|
||||
/// The usernames in authelia's operator group. The bridge answers this
|
||||
/// to this daemon's client only.
|
||||
///
|
||||
/// Names only, for [`Self::list_agent_identities`]'s reason.
|
||||
pub async fn list_operators(&self) -> Result<Vec<String>> {
|
||||
match self.request(&BridgeRequest::ListOperators).await? {
|
||||
BridgeResponse::Operators { operators } => Ok(operators),
|
||||
other => {
|
||||
anyhow::bail!(
|
||||
"swarm-authelia-bridge answered an operator-list request with {other:?}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One authenticated round-trip to the bridge.
|
||||
///
|
||||
/// A fresh token per call, deliberately — see the module doc. Shared by
|
||||
|
|
|
|||
|
|
@ -2283,16 +2283,20 @@ fn configured_matrix_homeserver() -> Option<Arc<str>> {
|
|||
}
|
||||
|
||||
/// Start the matrix-account backfill and the hive sender-token pass when a
|
||||
/// homeserver is configured. Lifted out of `main` for
|
||||
/// `clippy::too_many_lines`.
|
||||
/// homeserver is configured, and the operator admin pass when the bridge is
|
||||
/// too. Lifted out of `main` for `clippy::too_many_lines`.
|
||||
fn spawn_matrix_account_backfill(
|
||||
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||
homeserver: Option<Arc<str>>,
|
||||
hives: &[HiveEntry],
|
||||
auth: Option<Arc<auth::AuthBridge>>,
|
||||
) {
|
||||
let Some(base) = homeserver else {
|
||||
return;
|
||||
};
|
||||
if let Some(bridge) = auth {
|
||||
matrix_account::operator_admin::spawn(bridge, Arc::clone(&base));
|
||||
}
|
||||
let sched = Arc::clone(jobq);
|
||||
matrix_account::agent_token::spawn(Arc::clone(&base), move |agents| {
|
||||
if let Err(e) = queue_matrix_account_mints(&sched, agents) {
|
||||
|
|
@ -2875,7 +2879,7 @@ async fn main() -> Result<()> {
|
|||
hive_jobq::resources::ResourceTable::new(),
|
||||
)));
|
||||
let hives = load_hives();
|
||||
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), &hives);
|
||||
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), &hives, auth.clone());
|
||||
spawn_jobq_worker(Arc::clone(&jobq), deps);
|
||||
// Bound to a named variable, not `_` — dropping the provider stops its
|
||||
// `PeriodicReader`, so it must live as long as `main` does (which it
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
//! where the agent's daemon reads it. See docs/swarm/credentials.md for why
|
||||
//! `main` is the one name [`put_matrix_account`] refuses to write.
|
||||
//! [`hive_sender`] mints each **hive's** sender account the same way, sharing this module's helpers with [`agent_token`].
|
||||
//! [`operator_admin`] makes authelia's operators homeserver admins with the same
|
||||
//! appservice token.
|
||||
//!
|
||||
//! **The external one** — [`put_matrix_account`] and everything under it — is
|
||||
//! an account somewhere else that an operator hands us a credential for: put it
|
||||
|
|
@ -34,6 +36,7 @@ use super::{AppState, error_problem, swarm_hive};
|
|||
|
||||
pub mod agent_token;
|
||||
pub mod hive_sender;
|
||||
pub mod operator_admin;
|
||||
|
||||
fn default_mode() -> String {
|
||||
"token".to_owned()
|
||||
|
|
|
|||
287
swarm-controller/src/matrix_account/operator_admin.rs
Normal file
287
swarm-controller/src/matrix_account/operator_admin.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! The bot's reply is not read. A command that did not take shows up as the
|
||||
//! same operator still missing from the room on the next pass, which sends it
|
||||
//! again.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use swarm_matrix_client::{self as homeserver, Whoami};
|
||||
|
||||
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 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>,
|
||||
}
|
||||
|
||||
/// Decide which of `operators` need a grant on `server`, given the user ids
|
||||
/// already joined to the admin room.
|
||||
pub fn plan(operators: &[String], server: &str, admins: &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) {
|
||||
plan.grant.push((name.clone(), user_id));
|
||||
}
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
/// 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,
|
||||
no_account: usize,
|
||||
failed: usize,
|
||||
}
|
||||
|
||||
/// One pass: read the operator group, and send a grant for each operator
|
||||
/// who has a matrix account and is not an 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
|
||||
/// decision to make.
|
||||
async fn pass(bridge: &AuthBridge, base: &str) -> 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 plan = plan(&operators, server, &admins);
|
||||
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"
|
||||
);
|
||||
}
|
||||
let mut outcome = Outcome::default();
|
||||
for (name, user_id) in &plan.grant {
|
||||
match grant(&http, base, &token, &room, user_id).await {
|
||||
Ok(true) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match pass(&bridge, &base).await {
|
||||
Ok(outcome) if outcome.failed > 0 => tracing::warn!(
|
||||
granted = outcome.granted,
|
||||
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!(
|
||||
granted = outcome.granted,
|
||||
no_account = outcome.no_account,
|
||||
"operator matrix admin: grants 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"]),
|
||||
);
|
||||
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"]));
|
||||
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"]));
|
||||
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());
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue