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
|
|
@ -1,5 +1,7 @@
|
|||
//! The appservice calls against the swarm's homeserver: register, log in, and
|
||||
//! ask a token who it is. Shared by `swarm-matrix-ctl` and `swarm-controller`;
|
||||
//! The appservice calls against the swarm's homeserver: register, log in, ask
|
||||
//! a token who it is, and the room calls `swarm-controller`'s admin-room
|
||||
//! commands need (resolve an alias, list members, check an account exists,
|
||||
//! send a message). Shared by `swarm-matrix-ctl` and `swarm-controller`;
|
||||
//! see the README for why both mint through the same device id.
|
||||
//!
|
||||
//! 🩸 **Every error in this module is built from the response's `status` and
|
||||
|
|
@ -188,6 +190,183 @@ fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Resu
|
|||
bail!("the homeserver refused whoami: {}", why(status, json))
|
||||
}
|
||||
|
||||
/// The room id `alias` names.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses.
|
||||
pub async fn resolve_alias(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
alias: &str,
|
||||
) -> Result<String> {
|
||||
let (status, json) = get(
|
||||
client,
|
||||
&format!(
|
||||
"{base}/_matrix/client/v3/directory/room/{}",
|
||||
path_segment(alias)
|
||||
),
|
||||
token,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("GET /directory/room for {alias}"))?;
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused to resolve {alias}: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
json["room_id"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.with_context(|| format!("the homeserver's answer for {alias} carried no `room_id`"))
|
||||
}
|
||||
|
||||
/// The user ids joined to `room_id`. The token's own user must be one of them.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses.
|
||||
pub async fn joined_members(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
room_id: &str,
|
||||
) -> Result<std::collections::BTreeSet<String>> {
|
||||
let (status, json) = get(
|
||||
client,
|
||||
&format!(
|
||||
"{base}/_matrix/client/v3/rooms/{}/joined_members",
|
||||
path_segment(room_id)
|
||||
),
|
||||
token,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("GET /rooms/{room_id}/joined_members"))?;
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused to list {room_id}'s members: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
json["joined"]
|
||||
.as_object()
|
||||
.map(|joined| joined.keys().cloned().collect())
|
||||
.with_context(|| format!("the homeserver's member list for {room_id} carried no `joined`"))
|
||||
}
|
||||
|
||||
/// Whether `user_id` has an account on this homeserver.
|
||||
///
|
||||
/// Asked through the profile endpoint, which answers `M_NOT_FOUND` for a
|
||||
/// local user id with no account.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses with anything other than `M_NOT_FOUND`.
|
||||
pub async fn user_exists(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
) -> Result<bool> {
|
||||
let (status, json) = get(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/profile/{}", path_segment(user_id)),
|
||||
token,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("GET /profile/{user_id}"))?;
|
||||
if status.is_success() {
|
||||
return Ok(true);
|
||||
}
|
||||
if errcode(&json) == Some("M_NOT_FOUND") {
|
||||
return Ok(false);
|
||||
}
|
||||
bail!(
|
||||
"the homeserver refused {user_id}'s profile: {}",
|
||||
why(status, &json)
|
||||
)
|
||||
}
|
||||
|
||||
/// Send `body` to `room_id` as an `m.text` message, and return its event id.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses.
|
||||
pub async fn send_text(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
room_id: &str,
|
||||
body: &str,
|
||||
) -> Result<String> {
|
||||
let resp = client
|
||||
.put(format!(
|
||||
"{base}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
|
||||
path_segment(room_id),
|
||||
random_hex(8)?
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({ "msgtype": "m.text", "body": body }))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("PUT a message into {room_id}: sending the request"))?;
|
||||
let status = resp.status();
|
||||
let json = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.with_context(|| format!("PUT a message into {room_id}: decoding the response"))?;
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused a message into {room_id}: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
json["event_id"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.with_context(|| {
|
||||
format!("the homeserver's answer to a message into {room_id} carried no `event_id`")
|
||||
})
|
||||
}
|
||||
|
||||
/// `value` percent-encoded for one URL path segment: room ids, aliases and
|
||||
/// user ids carry `!`, `#`, `@` and `:`, and `#` would otherwise end the path.
|
||||
fn path_segment(value: &str) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
value.bytes().fold(String::new(), |mut acc, b| {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
|
||||
acc.push(char::from(b));
|
||||
} else {
|
||||
// Infallible, for `random_hex`'s reason.
|
||||
let _ = write!(acc, "%{b:02X}");
|
||||
}
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
/// One authenticated GET, returning the status beside the decoded body.
|
||||
async fn get(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
token: &str,
|
||||
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("sending the request")?;
|
||||
let status = resp.status();
|
||||
let json = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("decoding the response as JSON")?;
|
||||
Ok((status, json))
|
||||
}
|
||||
|
||||
/// The device every token this binary mints is pinned to.
|
||||
fn device_id(localpart: &str) -> String {
|
||||
format!("hyperhive-{localpart}")
|
||||
|
|
@ -360,6 +539,14 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_segment_escapes_what_would_end_or_split_the_path() {
|
||||
assert_eq!(path_segment("#admins:t.local"), "%23admins%3At.local");
|
||||
assert_eq!(path_segment("!abc:t.local"), "%21abc%3At.local");
|
||||
assert_eq!(path_segment("@a.b_c-d:t.local"), "%40a.b_c-d%3At.local");
|
||||
assert_eq!(path_segment("a/b"), "a%2Fb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minted_password_is_hex_of_the_declared_length() {
|
||||
// The control on the hex fold: a short or non-hex password would be
|
||||
|
|
|
|||
Loading…
Reference in a new issue