operator admin sync: cap the grant at one send, split out response verdicts
The admin-room command stays in #admins' history, so a grant tuwunel ignores must not be re-sent every five minutes. The controller now sends `make-user-admin` at most once per operator per process; an operator still missing from the room on a later pass is logged as "make-user-admin for an operator failed to take" and left alone until the controller restarts. The cap lives in the pure `plan()`. swarm-matrix-client's `user_exists` and `joined_members` read their responses through pure `profile_verdict` / `joined_verdict`, the shape `whoami` / `whoami_verdict` already has, with every branch tested: only M_NOT_FOUND is "no account", and a 403 or a list without `joined` is an error rather than an answer. The bridge's handling after authentication moves into `respond()`, which takes the config and write lock instead of the whole AppState, so a test can drive ListOperators through the real request path and see the 403 without an HTTP client or an authelia. docs/integrations/matrix.md no longer says @swarm is the only homeserver admin.
This commit is contained in:
parent
9e7fdb55ae
commit
cf88fbba9d
4 changed files with 261 additions and 42 deletions
|
|
@ -19,9 +19,11 @@
|
|||
//! 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.
|
||||
//! 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.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -61,11 +63,20 @@ pub struct Plan {
|
|||
pub grant: Vec<(String, String)>,
|
||||
/// Operators whose username maps to no legal localpart.
|
||||
pub unmappable: Vec<String>,
|
||||
/// `(authelia username, user id)` for each operator this process already
|
||||
/// sent a grant for who is still not an admin. Not sent again.
|
||||
pub unconfirmed: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Decide which of `operators` need a grant on `server`, given the user ids
|
||||
/// already joined to the admin room.
|
||||
pub fn plan(operators: &[String], server: &str, admins: &BTreeSet<String>) -> Plan {
|
||||
/// already joined to the admin room and those this process already sent a
|
||||
/// grant for.
|
||||
pub fn plan(
|
||||
operators: &[String],
|
||||
server: &str,
|
||||
admins: &BTreeSet<String>,
|
||||
sent: &BTreeSet<String>,
|
||||
) -> Plan {
|
||||
let mut plan = Plan::default();
|
||||
for name in operators {
|
||||
let Some(localpart) = localpart(name) else {
|
||||
|
|
@ -73,7 +84,12 @@ pub fn plan(operators: &[String], server: &str, admins: &BTreeSet<String>) -> Pl
|
|||
continue;
|
||||
};
|
||||
let user_id = format!("@{localpart}:{server}");
|
||||
if !admins.contains(&user_id) {
|
||||
if admins.contains(&user_id) {
|
||||
continue;
|
||||
}
|
||||
if sent.contains(&user_id) {
|
||||
plan.unconfirmed.push((name.clone(), user_id));
|
||||
} else {
|
||||
plan.grant.push((name.clone(), user_id));
|
||||
}
|
||||
}
|
||||
|
|
@ -98,12 +114,13 @@ struct Outcome {
|
|||
}
|
||||
|
||||
/// One pass: read the operator group, and send a grant for each operator
|
||||
/// who has a matrix account and is not an admin.
|
||||
/// 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.
|
||||
///
|
||||
/// 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> {
|
||||
async fn pass(bridge: &AuthBridge, base: &str, sent: &mut BTreeSet<String>) -> Result<Outcome> {
|
||||
let operators = bridge
|
||||
.list_operators()
|
||||
.await
|
||||
|
|
@ -130,7 +147,7 @@ async fn pass(bridge: &AuthBridge, base: &str) -> Result<Outcome> {
|
|||
.await?;
|
||||
let admins = homeserver::joined_members(&http, base, &token, &room).await?;
|
||||
|
||||
let plan = plan(&operators, server, &admins);
|
||||
let plan = plan(&operators, server, &admins, sent);
|
||||
for name in &plan.unmappable {
|
||||
tracing::warn!(
|
||||
operator = %name,
|
||||
|
|
@ -138,10 +155,19 @@ async fn pass(bridge: &AuthBridge, base: &str) -> Result<Outcome> {
|
|||
have an account to make admin; skipped"
|
||||
);
|
||||
}
|
||||
for (name, user_id) in &plan.unconfirmed {
|
||||
tracing::warn!(
|
||||
operator = %name,
|
||||
%user_id,
|
||||
"make-user-admin for an operator failed to take: sent earlier by this process, \
|
||||
still not in the admin room; not sent again until the controller restarts"
|
||||
);
|
||||
}
|
||||
let mut outcome = Outcome::default();
|
||||
for (name, user_id) in &plan.grant {
|
||||
match grant(&http, base, &token, &room, user_id).await {
|
||||
Ok(true) => {
|
||||
sent.insert(user_id.clone());
|
||||
tracing::info!(
|
||||
operator = %name,
|
||||
%user_id,
|
||||
|
|
@ -198,10 +224,11 @@ async fn grant(
|
|||
/// 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 sent = BTreeSet::new();
|
||||
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match pass(&bridge, &base).await {
|
||||
match pass(&bridge, &base, &mut sent).await {
|
||||
Ok(outcome) if outcome.failed > 0 => tracing::warn!(
|
||||
granted = outcome.granted,
|
||||
failed = outcome.failed,
|
||||
|
|
@ -245,6 +272,7 @@ mod tests {
|
|||
&names(&["mara", "vinzenz"]),
|
||||
"t.local",
|
||||
&ids(&["@mara:t.local", "@swarm:t.local"]),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(
|
||||
plan.grant,
|
||||
|
|
@ -255,7 +283,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn a_pass_with_every_operator_admin_sends_nothing() {
|
||||
let plan = plan(&names(&["mara"]), "t.local", &ids(&["@mara:t.local"]));
|
||||
let plan = plan(
|
||||
&names(&["mara"]),
|
||||
"t.local",
|
||||
&ids(&["@mara:t.local"]),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(plan, Plan::default());
|
||||
}
|
||||
|
||||
|
|
@ -263,20 +296,61 @@ mod tests {
|
|||
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"]));
|
||||
let plan = plan(
|
||||
&names(&["Mara"]),
|
||||
"t.local",
|
||||
&ids(&["@mara:t.local"]),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(plan, Plan::default());
|
||||
assert_eq!(localpart("Mara.B_c").as_deref(), Some("mara.b_c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_tuwunel_would_refuse_is_reported_not_granted() {
|
||||
let plan = plan(&names(&["bad name", "ok"]), "t.local", &BTreeSet::new());
|
||||
let plan = plan(
|
||||
&names(&["bad name", "ok"]),
|
||||
"t.local",
|
||||
&BTreeSet::new(),
|
||||
&BTreeSet::new(),
|
||||
);
|
||||
assert_eq!(plan.grant, [("ok".to_owned(), "@ok:t.local".to_owned())]);
|
||||
assert_eq!(plan.unmappable, ["bad name"]);
|
||||
assert_eq!(localpart(""), None);
|
||||
assert_eq!(localpart("é"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_already_sent_by_this_process_is_not_sent_again() {
|
||||
// The cap on the admin room: a command tuwunel ignored leaves the
|
||||
// operator outside the room, and that must not produce a second send.
|
||||
let plan = plan(
|
||||
&names(&["mara", "vinzenz"]),
|
||||
"t.local",
|
||||
&BTreeSet::new(),
|
||||
&ids(&["@mara:t.local"]),
|
||||
);
|
||||
assert_eq!(
|
||||
plan.grant,
|
||||
[("vinzenz".to_owned(), "@vinzenz:t.local".to_owned())]
|
||||
);
|
||||
assert_eq!(
|
||||
plan.unconfirmed,
|
||||
[("mara".to_owned(), "@mara:t.local".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sent_grant_that_took_is_neither_sent_nor_reported() {
|
||||
let plan = plan(
|
||||
&names(&["mara"]),
|
||||
"t.local",
|
||||
&ids(&["@mara:t.local"]),
|
||||
&ids(&["@mara:t.local"]),
|
||||
);
|
||||
assert_eq!(plan, Plan::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_name_comes_from_the_full_user_id() {
|
||||
assert_eq!(server_name("@swarm:t.local"), Some("t.local"));
|
||||
|
|
|
|||
Loading…
Reference in a new issue