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
|
|
@ -20,11 +20,16 @@ simply owns the file it writes. No elevated privilege anywhere.
|
|||
|
||||
- One endpoint, `POST /requests`, body = `swarm-authelia-bridge-sock`'s
|
||||
`BridgeRequest` verbatim — `EnsureAgentIdentity` (idempotently ensure an
|
||||
agent exists as an authelia subject) and `ListAgentIdentities` (the
|
||||
roster: the subjects carrying the agent marker group, names only).
|
||||
agent exists as an authelia subject), `ListAgentIdentities` (the
|
||||
roster: the subjects carrying the agent marker group, names only) and
|
||||
`ListOperators` (the operator group's usernames).
|
||||
- Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
||||
7662), against `swarm-controller`'s **existing** machine-client identity
|
||||
(already minted for the queue connection) — no new credential.
|
||||
(already minted for the queue connection) — no new credential. The two
|
||||
agent verbs admit any active token. ⚠️ `ListOperators` admits only the
|
||||
client named by `SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID`
|
||||
(`swarm-controller`'s) and answers every other active token `403`:
|
||||
authelia issues machine tokens to every hive and its agents too.
|
||||
- No restart of authelia after a write — relies on
|
||||
`authentication_backend.file.watch`, confirmed working against the
|
||||
pinned 4.39.20 build during this design work.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,10 +132,16 @@ pub fn mark_as_agent(store: &mut UserStore, name: &str) -> bool {
|
|||
/// Sorted because [`UserStore::users`] is a `BTreeMap`, so this is free and
|
||||
/// a consumer diffing two answers sees real changes rather than reordering.
|
||||
pub fn agent_names(store: &UserStore) -> Vec<String> {
|
||||
group_members(store, AGENT_GROUP)
|
||||
}
|
||||
|
||||
/// The names in the store that carry `group`, sorted for
|
||||
/// [`agent_names`]'s reason.
|
||||
pub fn group_members(store: &UserStore, group: &str) -> Vec<String> {
|
||||
store
|
||||
.users
|
||||
.iter()
|
||||
.filter(|(_, user)| user.groups.iter().any(|g| g == AGENT_GROUP))
|
||||
.filter(|(_, user)| user.groups.iter().any(|g| g == group))
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -343,9 +349,9 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The group an operator carries — a literal here rather than a shared
|
||||
/// constant, because this binary does not read the operator group and a
|
||||
/// test that imported one would imply it did.
|
||||
/// The group an operator carries — a literal here, because this binary
|
||||
/// takes the operator group from its environment and has no constant to
|
||||
/// import.
|
||||
fn operator(password: &str) -> User {
|
||||
User {
|
||||
groups: vec!["admins".to_owned()],
|
||||
|
|
@ -477,6 +483,22 @@ some_future_top_level_key: 7
|
|||
assert_eq!(agent_names(&store), ["atlas"]);
|
||||
}
|
||||
|
||||
/// The operator list is the group's members only — not agents, and not
|
||||
/// an account that carries no group.
|
||||
#[test]
|
||||
fn the_operator_list_is_the_groups_members_and_not_the_file() {
|
||||
let mut store = UserStore::default();
|
||||
store.users.insert("atlas".to_owned(), agent("$argon2id$a"));
|
||||
store
|
||||
.users
|
||||
.insert("mara".to_owned(), operator("$argon2id$b"));
|
||||
store
|
||||
.users
|
||||
.insert("ungrouped".to_owned(), user("$argon2id$c"));
|
||||
|
||||
assert_eq!(group_members(&store, "admins"), ["mara"]);
|
||||
}
|
||||
|
||||
/// An identity that predates the marker is invisible to the roster until
|
||||
/// agent creation runs again — which is the agreed migration, so it has
|
||||
/// to actually change something.
|
||||
|
|
|
|||
Loading…
Reference in a new issue