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

@ -24,7 +24,8 @@
//!
//! One endpoint, `POST /requests`, body =
//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim: ensure an agent
//! exists as an authelia subject, and list the ones that do. Not a
//! exists as an authelia subject, list the ones that do, and list the
//! operator group (to `swarm-controller` only, see `permit`). Not a
//! wholesale-replace-the-file API — this process owns rendering
//! `users.yml` internally; see `store` module.
@ -52,6 +53,10 @@ struct Config {
introspection_url: String,
client_id: String,
client_secret: String,
/// The one client [`BridgeRequest::ListOperators`] answers.
operator_reader_client_id: String,
/// The authelia group whose members are operators.
operator_group: String,
}
impl Config {
@ -63,6 +68,8 @@ impl Config {
introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?,
client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?,
client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?,
operator_reader_client_id: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID")?,
operator_group: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP")?,
})
}
}
@ -139,14 +146,26 @@ async fn handle_request(
headers: HeaderMap,
Json(req): Json<BridgeRequest>,
) -> Response {
if let Err(resp) = authorize(&state, &headers).await {
return resp;
let caller = match authorize(&state, &headers).await {
Ok(caller) => caller,
Err(resp) => return resp,
};
if let Err(refusal) = permit(
&req,
caller.as_deref(),
&state.config.operator_reader_client_id,
) {
// The client id is not a credential; naming it is what lets an
// operator see which client asked for something it may not have.
tracing::warn!(client_id = ?caller, request = ?req, "refused a request scoped to another client");
return refuse(refusal);
}
let (op, result) = match req {
BridgeRequest::EnsureAgentIdentity { name } => {
("ensure_identity", handle(&state, name).await)
}
BridgeRequest::ListAgentIdentities => ("list_identities", list(&state)),
BridgeRequest::ListOperators => ("list_operators", list_operators(&state)),
};
match result {
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
@ -168,8 +187,9 @@ async fn handle_request(
/// in."
///
/// Anything short of an active token is refused; **which status** depends
/// on whose credential failed, see [`Refusal`].
async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
/// on whose credential failed, see [`Refusal`]. `Ok` carries the client the
/// token was issued to, for [`permit`].
async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<Option<String>, Response> {
let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else {
return Err(refuse(Refusal::Caller));
};
@ -196,14 +216,32 @@ async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response
/// of [`authorize`] so the rule is testable without an authelia — and it is
/// the *real* rule, not a restatement of it, because this is the function
/// the request path calls.
fn classify(verdict: introspect::Verdict) -> Result<(), Refusal> {
fn classify(verdict: introspect::Verdict) -> Result<Option<String>, Refusal> {
match verdict {
introspect::Verdict::Active => Ok(()),
introspect::Verdict::Active { client_id } => Ok(client_id),
introspect::Verdict::Inactive => Err(Refusal::Caller),
introspect::Verdict::Unavailable => Err(Refusal::Bridge),
}
}
/// Whether an admitted caller may make this particular request.
///
/// [`BridgeRequest::ListOperators`] answers only `operator_reader`, the
/// controller's client: authelia also issues machine tokens to every hive and
/// every hive's agents, and those are as active as the controller's. A caller
/// with no client id is refused it too, rather than compared as a blank.
fn permit(
req: &BridgeRequest,
client_id: Option<&str>,
operator_reader: &str,
) -> Result<(), Refusal> {
match req {
BridgeRequest::EnsureAgentIdentity { .. } | BridgeRequest::ListAgentIdentities => Ok(()),
BridgeRequest::ListOperators if client_id == Some(operator_reader) => Ok(()),
BridgeRequest::ListOperators => Err(Refusal::Forbidden),
}
}
/// Whose credential was at fault when a request was refused.
///
/// The distinction is the whole point: these used to be one 401, so a
@ -216,10 +254,13 @@ enum Refusal {
/// This bridge could not get an answer out of authelia at all, so the
/// request was never evaluated.
Bridge,
/// The token is valid, but its client may not make this request.
Forbidden,
}
impl Refusal {
/// `401` for the caller; `503` for us.
/// `401` for the caller; `503` for us; `403` for a valid caller asking
/// for something scoped to another client.
///
/// 401 would be wrong for [`Refusal::Bridge`] under RFC 7235 — nothing
/// about the caller's credential was determined — and it is also the
@ -235,6 +276,7 @@ impl Refusal {
match self {
Refusal::Caller => StatusCode::UNAUTHORIZED,
Refusal::Bridge => StatusCode::SERVICE_UNAVAILABLE,
Refusal::Forbidden => StatusCode::FORBIDDEN,
}
}
@ -247,6 +289,7 @@ impl Refusal {
match self {
Refusal::Caller => "missing or invalid bearer token",
Refusal::Bridge => "cannot validate credentials",
Refusal::Forbidden => "not permitted",
}
}
}
@ -351,6 +394,14 @@ fn list(state: &AppState) -> Result<BridgeResponse> {
})
}
/// The operator group's members. No write lock, for [`list`]'s reason.
fn list_operators(state: &AppState) -> Result<BridgeResponse> {
let user_store = store::load_store(&state.config.users_file)?;
Ok(BridgeResponse::Operators {
operators: store::group_members(&user_store, &state.config.operator_group),
})
}
/// Mint a password digest via the **configured** authelia — the argon2
/// parameters baked into a hash have to match the verifier's, same
/// reasoning `swarmctl::generate_password` documents (this is
@ -393,7 +444,7 @@ async fn generate_password(bin: &std::path::Path) -> Result<String> {
#[cfg(test)]
mod tests {
use super::{Refusal, StatusCode, classify, introspect::Verdict};
use super::{BridgeRequest, Refusal, StatusCode, classify, introspect::Verdict, permit};
/// Fail-closed, asserted per variant rather than as one negation:
/// splitting blame out of a single denial is exactly the change that
@ -401,7 +452,12 @@ mod tests {
/// its own line.
#[test]
fn only_an_active_token_is_admitted() {
assert_eq!(classify(Verdict::Active), Ok(()));
assert_eq!(
classify(Verdict::Active {
client_id: Some("swarm-controller".to_owned())
}),
Ok(Some("swarm-controller".to_owned()))
);
assert_eq!(
classify(Verdict::Inactive),
Err(Refusal::Caller),
@ -441,7 +497,7 @@ mod tests {
/// applied, nor anything about this bridge's own credential.
#[test]
fn refusal_messages_say_nothing_useful_to_an_attacker() {
for refusal in [Refusal::Caller, Refusal::Bridge] {
for refusal in [Refusal::Caller, Refusal::Bridge, Refusal::Forbidden] {
let message = refusal.message();
for leak in ["secret", "client_id", "authelia", "http", "status"] {
assert!(
@ -451,4 +507,56 @@ mod tests {
}
}
}
const CONTROLLER: &str = "swarm-controller";
/// The trust boundary on the operator list. Every caller below holds a
/// token authelia calls active — a hive and a hive's agents are issued
/// machine tokens too — and only the controller's client is answered.
#[test]
fn only_the_controller_may_list_operators() {
let req = BridgeRequest::ListOperators;
assert_eq!(permit(&req, Some(CONTROLLER), CONTROLLER), Ok(()));
for other in ["hive-alpha", "hive-alpha-agent", "swarm-authelia-bridge"] {
assert_eq!(
permit(&req, Some(other), CONTROLLER),
Err(Refusal::Forbidden),
"{other} holds an active token but is not the controller"
);
}
assert_eq!(
permit(&req, None, CONTROLLER),
Err(Refusal::Forbidden),
"a token authelia attributed to no client is not the controller's"
);
}
/// The control for the test above: the agent verbs stay open to any
/// admitted caller, so the refusal there comes from the scoping and not
/// from `permit` refusing everyone who is not the controller.
#[test]
fn the_agent_verbs_stay_open_to_any_active_caller() {
for req in [
BridgeRequest::EnsureAgentIdentity {
name: "atlas".to_owned(),
},
BridgeRequest::ListAgentIdentities,
] {
assert_eq!(
permit(&req, Some("hive-alpha"), CONTROLLER),
Ok(()),
"{req:?}"
);
assert_eq!(permit(&req, None, CONTROLLER), Ok(()), "{req:?}");
}
}
#[test]
fn a_scoped_refusal_is_403_not_401() {
assert_eq!(
Refusal::Forbidden.status(),
StatusCode::FORBIDDEN,
"the token is valid; a 401 would send the caller off to mint a new one"
);
}
}