Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-04 15:21:41 +02:00 • committed by mara
commit 9e7fdb55ae
15 changed files with 787 additions and 33 deletions

View file

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

View file

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

View file

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

View 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);
}
}