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
|
|
@ -3,10 +3,10 @@
|
|||
//! Own copy of `swarm-nats-auth::introspect`'s shape (RFC 7662 token
|
||||
//! introspection), not a shared dependency — `swarm-nats-auth` has no lib
|
||||
//! target to import, and this is a handful of lines; factor out if a third
|
||||
//! consumer shows up. Same verdict rule: `active` is the whole answer, and
|
||||
//! everything that isn't an explicit `active: true` is a denial (network
|
||||
//! error, timeout, non-2xx, unparseable body) — the failure modes of an
|
||||
//! HTTP call are exactly the conditions an attacker would like this to
|
||||
//! consumer shows up. Same verdict rule: `active` is the whole admission
|
||||
//! answer, and everything that isn't an explicit `active: true` is a denial
|
||||
//! (network error, timeout, non-2xx, unparseable body) — the failure modes of
|
||||
//! an HTTP call are exactly the conditions an attacker would like this to
|
||||
//! fall open under.
|
||||
//!
|
||||
//! [`Verdict`] has three variants where admission has two: the extra one
|
||||
|
|
@ -33,9 +33,25 @@ use serde::Deserialize;
|
|||
/// hung introspection call must not wedge a `CreateIdentity` job forever.
|
||||
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// `active` decides whether to admit; `client_id` says as whom, which is
|
||||
/// what lets one verb be scoped to a single client. Same two fields, for the
|
||||
/// same reasons, as `swarm-nats-auth::introspect`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IntrospectionResponse {
|
||||
active: bool,
|
||||
#[serde(default)]
|
||||
client_id: Option<String>,
|
||||
}
|
||||
|
||||
impl IntrospectionResponse {
|
||||
fn verdict(self) -> Verdict {
|
||||
if !self.active {
|
||||
return Verdict::Inactive;
|
||||
}
|
||||
Verdict::Active {
|
||||
client_id: self.client_id.filter(|id| !id.is_empty()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of asking authelia about a token.
|
||||
|
|
@ -44,10 +60,15 @@ struct IntrospectionResponse {
|
|||
/// the call can go wrong is already a variant. A caller that forgets a case
|
||||
/// does not compile, and there is no error path that could be `?`-ed past
|
||||
/// into an accidental admission.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
/// 2xx with `active: true`. The only admitting outcome.
|
||||
Active,
|
||||
Active {
|
||||
/// The client the token was issued to. `None` when authelia named
|
||||
/// none, or named an empty one: a verb scoped to one client refuses
|
||||
/// such a caller, it does not match it to a blank.
|
||||
client_id: Option<String>,
|
||||
},
|
||||
/// 2xx with `active: false` — authelia evaluated the token and said no.
|
||||
/// The **caller's** credential is the problem.
|
||||
Inactive,
|
||||
|
|
@ -96,8 +117,7 @@ pub async fn introspect(
|
|||
return Verdict::Unavailable;
|
||||
}
|
||||
match resp.json::<IntrospectionResponse>().await {
|
||||
Ok(body) if body.active => Verdict::Active,
|
||||
Ok(_) => Verdict::Inactive,
|
||||
Ok(body) => body.verdict(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "introspection body did not parse; cannot validate");
|
||||
Verdict::Unavailable
|
||||
|
|
@ -129,4 +149,35 @@ mod tests {
|
|||
"authelia answering `active: false` is authelia working, not authelia failing"
|
||||
);
|
||||
}
|
||||
|
||||
fn parse(body: &str) -> Verdict {
|
||||
serde_json::from_str::<IntrospectionResponse>(body)
|
||||
.expect("introspection body")
|
||||
.verdict()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_active_token_names_the_client_that_holds_it() {
|
||||
assert_eq!(
|
||||
parse(r#"{"active":true,"client_id":"swarm-controller"}"#),
|
||||
Verdict::Active {
|
||||
client_id: Some("swarm-controller".to_owned())
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_or_empty_client_id_names_no_client() {
|
||||
for body in [r#"{"active":true}"#, r#"{"active":true,"client_id":""}"#] {
|
||||
assert_eq!(parse(body), Verdict::Active { client_id: None }, "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inactive_token_names_no_client_even_when_the_body_does() {
|
||||
assert_eq!(
|
||||
parse(r#"{"active":false,"client_id":"swarm-controller"}"#),
|
||||
Verdict::Inactive
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue