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
|
|
@ -187,7 +187,8 @@ newer channel breaks in ways hyperhive's CI doesn't catch.
|
||||||
|
|
||||||
You are an SSO user too: `swarmctl user add <you> --group admins`, and a
|
You are an SSO user too: `swarmctl user add <you> --group admins`, and a
|
||||||
first login through authelia creates your forge and matrix accounts;
|
first login through authelia creates your forge and matrix accounts;
|
||||||
`swarmctl forge make-admin <you>` promotes the forge one.
|
`swarmctl forge make-admin <you>` promotes the forge one, and the matrix
|
||||||
|
one becomes a homeserver admin within five minutes of that first login.
|
||||||
|
|
||||||
## Where to read next
|
## Where to read next
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,25 @@ when it's missing, unknown to the homeserver, or someone else's. An
|
||||||
agent's daemon picks a re-minted token up on its next start, and a timer
|
agent's daemon picks a re-minted token up on its next start, and a timer
|
||||||
restarts it while it's down.
|
restarts it while it's down.
|
||||||
|
|
||||||
|
### Operators are homeserver admins
|
||||||
|
|
||||||
|
Every member of authelia's `admins` group who has a matrix account is a
|
||||||
|
homeserver admin. `swarm-controller` reads the group through
|
||||||
|
`swarm-authelia-bridge` at start and every five minutes after, and for
|
||||||
|
each member not yet in `#admins:<server_name>` it sends
|
||||||
|
`!admin users make-user-admin @<name>:<server_name>` there as `@swarm`.
|
||||||
|
`<name>` is the authelia username lowercased, the account an SSO login
|
||||||
|
lands on.
|
||||||
|
|
||||||
|
- **Grant-only.** Removing someone from `admins` leaves their matrix
|
||||||
|
admin in place: tuwunel has no command that takes it away.
|
||||||
|
- **No account, no grant.** A member who has never logged in to matrix
|
||||||
|
has no account to promote. The first pass after their first SSO login
|
||||||
|
promotes them.
|
||||||
|
- Runs only on a controller with both
|
||||||
|
`services.hyperhive.deploy.swarm-controller.authBridgeUrl` and
|
||||||
|
`services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl` set.
|
||||||
|
|
||||||
### The appservice's sender account, and why it isn't an admin
|
### The appservice's sender account, and why it isn't an admin
|
||||||
|
|
||||||
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
|
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ result isn't.
|
||||||
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts |
|
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts |
|
||||||
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
|
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
|
||||||
|
|
||||||
Three consequences worth stating plainly:
|
Four consequences worth stating plainly:
|
||||||
|
|
||||||
- **A person's first forge login creates their forge account**, named
|
- **A person's first forge login creates their forge account**, named
|
||||||
after authelia's `preferred_username` (`[oauth2_client]` in the forge
|
after authelia's `preferred_username` (`[oauth2_client]` in the forge
|
||||||
|
|
@ -192,6 +192,11 @@ Three consequences worth stating plainly:
|
||||||
`swarm-controller` all have local accounts, and `swarmctl user add`
|
`swarm-controller` all have local accounts, and `swarmctl user add`
|
||||||
refuses none of those names.
|
refuses none of those names.
|
||||||
|
|
||||||
|
- **A member of `admins` becomes a matrix homeserver admin** once their
|
||||||
|
first matrix login has created the account: `swarm-controller` promotes
|
||||||
|
them within five minutes, and never demotes. See
|
||||||
|
[`../integrations/matrix.md`](../integrations/matrix.md#operators-are-homeserver-admins).
|
||||||
|
|
||||||
- **tuwunel re-reads its secret file on every OAuth exchange**, not only
|
- **tuwunel re-reads its secret file on every OAuth exchange**, not only
|
||||||
at startup, and its own sandboxing hides most paths from it. It gets the
|
at startup, and its own sandboxing hides most paths from it. It gets the
|
||||||
file through `LoadCredential` for the same reason the appservice
|
file through `LoadCredential` for the same reason the appservice
|
||||||
|
|
|
||||||
|
|
@ -924,6 +924,11 @@ in
|
||||||
# includes `bridgeClient`) — same file this container's own
|
# includes `bridgeClient`) — same file this container's own
|
||||||
# `renderClient` reads the digest half of.
|
# `renderClient` reads the digest half of.
|
||||||
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
|
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
|
||||||
|
# The operator list names who holds operator rights, so the
|
||||||
|
# bridge answers it to the controller's client and refuses every
|
||||||
|
# other active token (each hive and its agents hold one).
|
||||||
|
SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId;
|
||||||
|
SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,20 @@ let
|
||||||
&& lib.hasInfix "-authelia-users-placeholder.yml \"$users\"" s
|
&& lib.hasInfix "-authelia-users-placeholder.yml \"$users\"" s
|
||||||
&& !(lib.hasInfix ''echo "users: {}"'' s);
|
&& !(lib.hasInfix ''echo "users: {}"'' s);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# The bridge refuses its operator list to every client but the one
|
||||||
|
# named here, so this has to be the client the controller authenticates
|
||||||
|
# as. A drift is a controller whose admin sync is refused every pass.
|
||||||
|
name = "the authelia bridge answers its operator list to the controller's client only";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
env =
|
||||||
|
autheliaColocated.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment;
|
||||||
|
in
|
||||||
|
env.SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID
|
||||||
|
== autheliaColocated.services.hyperhive.swarm.controller.queueClientId
|
||||||
|
&& env.SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP == "admins";
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# Config NAMES the IdP; it never computes where the IdP is. Both arms
|
# Config NAMES the IdP; it never computes where the IdP is. Both arms
|
||||||
# matter together: the address is the swarm's name on a hive that runs
|
# matter together: the address is the swarm's name on a hive that runs
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@ pub enum BridgeRequest {
|
||||||
/// are separate sets on purpose, and an agent in the roster that has
|
/// are separate sets on purpose, and an agent in the roster that has
|
||||||
/// never reported is not the same as one that does not exist.
|
/// never reported is not the same as one that does not exist.
|
||||||
ListAgentIdentities,
|
ListAgentIdentities,
|
||||||
|
/// The usernames in authelia's operator group — the subjects that reach
|
||||||
|
/// operator-only surfaces.
|
||||||
|
///
|
||||||
|
/// ⚠️ **Only `swarm-controller`'s client may ask.** Every other verb
|
||||||
|
/// admits any active token; this one names who holds operator rights, so
|
||||||
|
/// the bridge refuses it to every other caller with `403`.
|
||||||
|
ListOperators,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One roster entry.
|
/// One roster entry.
|
||||||
|
|
@ -128,6 +135,14 @@ pub enum BridgeResponse {
|
||||||
/// means a consumer diffing two answers sees real changes only.
|
/// means a consumer diffing two answers sees real changes only.
|
||||||
agents: Vec<AgentIdentity>,
|
agents: Vec<AgentIdentity>,
|
||||||
},
|
},
|
||||||
|
/// The operator group's members, in answer to
|
||||||
|
/// [`BridgeRequest::ListOperators`].
|
||||||
|
Operators {
|
||||||
|
/// Authelia usernames, sorted. Plain strings rather than [`Ident`]:
|
||||||
|
/// these are humans, and the store admits `.`, `_` and uppercase in a
|
||||||
|
/// human's name.
|
||||||
|
operators: Vec<String>,
|
||||||
|
},
|
||||||
/// The request was rejected or the write failed. Carries a message
|
/// The request was rejected or the write failed. Carries a message
|
||||||
/// for the caller to log/propagate, not a typed error enum: the
|
/// for the caller to log/propagate, not a typed error enum: the
|
||||||
/// failure modes here (bad username, authelia binary failed, disk
|
/// failure modes here (bad username, authelia binary failed, disk
|
||||||
|
|
@ -175,6 +190,15 @@ mod tests {
|
||||||
serde_json::json!({"status": "agents", "agents": [{"name": "atlas"}]})
|
serde_json::json!({"status": "agents", "agents": [{"name": "atlas"}]})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let operators = serde_json::to_value(BridgeResponse::Operators {
|
||||||
|
operators: vec!["mara".to_owned()],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
operators,
|
||||||
|
serde_json::json!({"status": "operators", "operators": ["mara"]})
|
||||||
|
);
|
||||||
|
|
||||||
let err = serde_json::to_value(BridgeResponse::Error {
|
let err = serde_json::to_value(BridgeResponse::Error {
|
||||||
message: "boom".to_owned(),
|
message: "boom".to_owned(),
|
||||||
})
|
})
|
||||||
|
|
@ -205,6 +229,10 @@ mod tests {
|
||||||
let json = serde_json::to_string(&BridgeRequest::ListAgentIdentities).unwrap();
|
let json = serde_json::to_string(&BridgeRequest::ListAgentIdentities).unwrap();
|
||||||
let back: BridgeRequest = serde_json::from_str(&json).unwrap();
|
let back: BridgeRequest = serde_json::from_str(&json).unwrap();
|
||||||
assert!(matches!(back, BridgeRequest::ListAgentIdentities));
|
assert!(matches!(back, BridgeRequest::ListAgentIdentities));
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&BridgeRequest::ListOperators).unwrap();
|
||||||
|
let back: BridgeRequest = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(matches!(back, BridgeRequest::ListOperators));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An empty roster must be a roster, not an absence: `{"agents":[]}`
|
/// An empty roster must be a roster, not an absence: `{"agents":[]}`
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,16 @@ simply owns the file it writes. No elevated privilege anywhere.
|
||||||
|
|
||||||
- One endpoint, `POST /requests`, body = `swarm-authelia-bridge-sock`'s
|
- One endpoint, `POST /requests`, body = `swarm-authelia-bridge-sock`'s
|
||||||
`BridgeRequest` verbatim — `EnsureAgentIdentity` (idempotently ensure an
|
`BridgeRequest` verbatim — `EnsureAgentIdentity` (idempotently ensure an
|
||||||
agent exists as an authelia subject) and `ListAgentIdentities` (the
|
agent exists as an authelia subject), `ListAgentIdentities` (the
|
||||||
roster: the subjects carrying the agent marker group, names only).
|
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
|
- Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
||||||
7662), against `swarm-controller`'s **existing** machine-client identity
|
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
|
- No restart of authelia after a write — relies on
|
||||||
`authentication_backend.file.watch`, confirmed working against the
|
`authentication_backend.file.watch`, confirmed working against the
|
||||||
pinned 4.39.20 build during this design work.
|
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
|
//! Own copy of `swarm-nats-auth::introspect`'s shape (RFC 7662 token
|
||||||
//! introspection), not a shared dependency — `swarm-nats-auth` has no lib
|
//! 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
|
//! 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
|
//! consumer shows up. Same verdict rule: `active` is the whole admission
|
||||||
//! everything that isn't an explicit `active: true` is a denial (network
|
//! answer, and everything that isn't an explicit `active: true` is a denial
|
||||||
//! error, timeout, non-2xx, unparseable body) — the failure modes of an
|
//! (network error, timeout, non-2xx, unparseable body) — the failure modes of
|
||||||
//! HTTP call are exactly the conditions an attacker would like this to
|
//! an HTTP call are exactly the conditions an attacker would like this to
|
||||||
//! fall open under.
|
//! fall open under.
|
||||||
//!
|
//!
|
||||||
//! [`Verdict`] has three variants where admission has two: the extra one
|
//! [`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.
|
/// hung introspection call must not wedge a `CreateIdentity` job forever.
|
||||||
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_secs(5);
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct IntrospectionResponse {
|
struct IntrospectionResponse {
|
||||||
active: bool,
|
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.
|
/// 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
|
/// 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
|
/// does not compile, and there is no error path that could be `?`-ed past
|
||||||
/// into an accidental admission.
|
/// into an accidental admission.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Verdict {
|
pub enum Verdict {
|
||||||
/// 2xx with `active: true`. The only admitting outcome.
|
/// 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.
|
/// 2xx with `active: false` — authelia evaluated the token and said no.
|
||||||
/// The **caller's** credential is the problem.
|
/// The **caller's** credential is the problem.
|
||||||
Inactive,
|
Inactive,
|
||||||
|
|
@ -96,8 +117,7 @@ pub async fn introspect(
|
||||||
return Verdict::Unavailable;
|
return Verdict::Unavailable;
|
||||||
}
|
}
|
||||||
match resp.json::<IntrospectionResponse>().await {
|
match resp.json::<IntrospectionResponse>().await {
|
||||||
Ok(body) if body.active => Verdict::Active,
|
Ok(body) => body.verdict(),
|
||||||
Ok(_) => Verdict::Inactive,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = ?e, "introspection body did not parse; cannot validate");
|
tracing::warn!(error = ?e, "introspection body did not parse; cannot validate");
|
||||||
Verdict::Unavailable
|
Verdict::Unavailable
|
||||||
|
|
@ -129,4 +149,35 @@ mod tests {
|
||||||
"authelia answering `active: false` is authelia working, not authelia failing"
|
"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 =
|
//! One endpoint, `POST /requests`, body =
|
||||||
//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim: ensure an agent
|
//! [`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
|
//! wholesale-replace-the-file API — this process owns rendering
|
||||||
//! `users.yml` internally; see `store` module.
|
//! `users.yml` internally; see `store` module.
|
||||||
|
|
||||||
|
|
@ -52,6 +53,10 @@ struct Config {
|
||||||
introspection_url: String,
|
introspection_url: String,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
client_secret: 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 {
|
impl Config {
|
||||||
|
|
@ -63,6 +68,8 @@ impl Config {
|
||||||
introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?,
|
introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?,
|
||||||
client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?,
|
client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?,
|
||||||
client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?,
|
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,
|
headers: HeaderMap,
|
||||||
Json(req): Json<BridgeRequest>,
|
Json(req): Json<BridgeRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if let Err(resp) = authorize(&state, &headers).await {
|
let caller = match authorize(&state, &headers).await {
|
||||||
return resp;
|
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 {
|
let (op, result) = match req {
|
||||||
BridgeRequest::EnsureAgentIdentity { name } => {
|
BridgeRequest::EnsureAgentIdentity { name } => {
|
||||||
("ensure_identity", handle(&state, name).await)
|
("ensure_identity", handle(&state, name).await)
|
||||||
}
|
}
|
||||||
BridgeRequest::ListAgentIdentities => ("list_identities", list(&state)),
|
BridgeRequest::ListAgentIdentities => ("list_identities", list(&state)),
|
||||||
|
BridgeRequest::ListOperators => ("list_operators", list_operators(&state)),
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
|
Ok(body) => (StatusCode::OK, Json(body)).into_response(),
|
||||||
|
|
@ -168,8 +187,9 @@ async fn handle_request(
|
||||||
/// in."
|
/// in."
|
||||||
///
|
///
|
||||||
/// Anything short of an active token is refused; **which status** depends
|
/// Anything short of an active token is refused; **which status** depends
|
||||||
/// on whose credential failed, see [`Refusal`].
|
/// on whose credential failed, see [`Refusal`]. `Ok` carries the client the
|
||||||
async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
|
/// 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 {
|
let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||||||
return Err(refuse(Refusal::Caller));
|
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
|
/// 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 *real* rule, not a restatement of it, because this is the function
|
||||||
/// the request path calls.
|
/// the request path calls.
|
||||||
fn classify(verdict: introspect::Verdict) -> Result<(), Refusal> {
|
fn classify(verdict: introspect::Verdict) -> Result<Option<String>, Refusal> {
|
||||||
match verdict {
|
match verdict {
|
||||||
introspect::Verdict::Active => Ok(()),
|
introspect::Verdict::Active { client_id } => Ok(client_id),
|
||||||
introspect::Verdict::Inactive => Err(Refusal::Caller),
|
introspect::Verdict::Inactive => Err(Refusal::Caller),
|
||||||
introspect::Verdict::Unavailable => Err(Refusal::Bridge),
|
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.
|
/// Whose credential was at fault when a request was refused.
|
||||||
///
|
///
|
||||||
/// The distinction is the whole point: these used to be one 401, so a
|
/// 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
|
/// This bridge could not get an answer out of authelia at all, so the
|
||||||
/// request was never evaluated.
|
/// request was never evaluated.
|
||||||
Bridge,
|
Bridge,
|
||||||
|
/// The token is valid, but its client may not make this request.
|
||||||
|
Forbidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Refusal {
|
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
|
/// 401 would be wrong for [`Refusal::Bridge`] under RFC 7235 — nothing
|
||||||
/// about the caller's credential was determined — and it is also the
|
/// about the caller's credential was determined — and it is also the
|
||||||
|
|
@ -235,6 +276,7 @@ impl Refusal {
|
||||||
match self {
|
match self {
|
||||||
Refusal::Caller => StatusCode::UNAUTHORIZED,
|
Refusal::Caller => StatusCode::UNAUTHORIZED,
|
||||||
Refusal::Bridge => StatusCode::SERVICE_UNAVAILABLE,
|
Refusal::Bridge => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Refusal::Forbidden => StatusCode::FORBIDDEN,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,6 +289,7 @@ impl Refusal {
|
||||||
match self {
|
match self {
|
||||||
Refusal::Caller => "missing or invalid bearer token",
|
Refusal::Caller => "missing or invalid bearer token",
|
||||||
Refusal::Bridge => "cannot validate credentials",
|
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
|
/// Mint a password digest via the **configured** authelia — the argon2
|
||||||
/// parameters baked into a hash have to match the verifier's, same
|
/// parameters baked into a hash have to match the verifier's, same
|
||||||
/// reasoning `swarmctl::generate_password` documents (this is
|
/// reasoning `swarmctl::generate_password` documents (this is
|
||||||
|
|
@ -393,7 +444,7 @@ async fn generate_password(bin: &std::path::Path) -> Result<String> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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:
|
/// Fail-closed, asserted per variant rather than as one negation:
|
||||||
/// splitting blame out of a single denial is exactly the change that
|
/// splitting blame out of a single denial is exactly the change that
|
||||||
|
|
@ -401,7 +452,12 @@ mod tests {
|
||||||
/// its own line.
|
/// its own line.
|
||||||
#[test]
|
#[test]
|
||||||
fn only_an_active_token_is_admitted() {
|
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!(
|
assert_eq!(
|
||||||
classify(Verdict::Inactive),
|
classify(Verdict::Inactive),
|
||||||
Err(Refusal::Caller),
|
Err(Refusal::Caller),
|
||||||
|
|
@ -441,7 +497,7 @@ mod tests {
|
||||||
/// applied, nor anything about this bridge's own credential.
|
/// applied, nor anything about this bridge's own credential.
|
||||||
#[test]
|
#[test]
|
||||||
fn refusal_messages_say_nothing_useful_to_an_attacker() {
|
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();
|
let message = refusal.message();
|
||||||
for leak in ["secret", "client_id", "authelia", "http", "status"] {
|
for leak in ["secret", "client_id", "authelia", "http", "status"] {
|
||||||
assert!(
|
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
|
/// Sorted because [`UserStore::users`] is a `BTreeMap`, so this is free and
|
||||||
/// a consumer diffing two answers sees real changes rather than reordering.
|
/// a consumer diffing two answers sees real changes rather than reordering.
|
||||||
pub fn agent_names(store: &UserStore) -> Vec<String> {
|
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
|
store
|
||||||
.users
|
.users
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, user)| user.groups.iter().any(|g| g == AGENT_GROUP))
|
.filter(|(_, user)| user.groups.iter().any(|g| g == group))
|
||||||
.map(|(name, _)| name.clone())
|
.map(|(name, _)| name.clone())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
@ -343,9 +349,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The group an operator carries — a literal here rather than a shared
|
/// The group an operator carries — a literal here, because this binary
|
||||||
/// constant, because this binary does not read the operator group and a
|
/// takes the operator group from its environment and has no constant to
|
||||||
/// test that imported one would imply it did.
|
/// import.
|
||||||
fn operator(password: &str) -> User {
|
fn operator(password: &str) -> User {
|
||||||
User {
|
User {
|
||||||
groups: vec!["admins".to_owned()],
|
groups: vec!["admins".to_owned()],
|
||||||
|
|
@ -477,6 +483,22 @@ some_future_top_level_key: 7
|
||||||
assert_eq!(agent_names(&store), ["atlas"]);
|
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
|
/// An identity that predates the marker is invisible to the roster until
|
||||||
/// agent creation runs again — which is the agreed migration, so it has
|
/// agent creation runs again — which is the agreed migration, so it has
|
||||||
/// to actually change something.
|
/// to actually change something.
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,21 @@ impl AuthBridge {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The usernames in authelia's operator group. The bridge answers this
|
||||||
|
/// to this daemon's client only.
|
||||||
|
///
|
||||||
|
/// Names only, for [`Self::list_agent_identities`]'s reason.
|
||||||
|
pub async fn list_operators(&self) -> Result<Vec<String>> {
|
||||||
|
match self.request(&BridgeRequest::ListOperators).await? {
|
||||||
|
BridgeResponse::Operators { operators } => Ok(operators),
|
||||||
|
other => {
|
||||||
|
anyhow::bail!(
|
||||||
|
"swarm-authelia-bridge answered an operator-list request with {other:?}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One authenticated round-trip to the bridge.
|
/// One authenticated round-trip to the bridge.
|
||||||
///
|
///
|
||||||
/// A fresh token per call, deliberately — see the module doc. Shared by
|
/// A fresh token per call, deliberately — see the module doc. Shared by
|
||||||
|
|
|
||||||
|
|
@ -2283,16 +2283,20 @@ fn configured_matrix_homeserver() -> Option<Arc<str>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the matrix-account backfill and the hive sender-token pass when a
|
/// Start the matrix-account backfill and the hive sender-token pass when a
|
||||||
/// homeserver is configured. Lifted out of `main` for
|
/// homeserver is configured, and the operator admin pass when the bridge is
|
||||||
/// `clippy::too_many_lines`.
|
/// too. Lifted out of `main` for `clippy::too_many_lines`.
|
||||||
fn spawn_matrix_account_backfill(
|
fn spawn_matrix_account_backfill(
|
||||||
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||||
homeserver: Option<Arc<str>>,
|
homeserver: Option<Arc<str>>,
|
||||||
hives: &[HiveEntry],
|
hives: &[HiveEntry],
|
||||||
|
auth: Option<Arc<auth::AuthBridge>>,
|
||||||
) {
|
) {
|
||||||
let Some(base) = homeserver else {
|
let Some(base) = homeserver else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if let Some(bridge) = auth {
|
||||||
|
matrix_account::operator_admin::spawn(bridge, Arc::clone(&base));
|
||||||
|
}
|
||||||
let sched = Arc::clone(jobq);
|
let sched = Arc::clone(jobq);
|
||||||
matrix_account::agent_token::spawn(Arc::clone(&base), move |agents| {
|
matrix_account::agent_token::spawn(Arc::clone(&base), move |agents| {
|
||||||
if let Err(e) = queue_matrix_account_mints(&sched, agents) {
|
if let Err(e) = queue_matrix_account_mints(&sched, agents) {
|
||||||
|
|
@ -2875,7 +2879,7 @@ async fn main() -> Result<()> {
|
||||||
hive_jobq::resources::ResourceTable::new(),
|
hive_jobq::resources::ResourceTable::new(),
|
||||||
)));
|
)));
|
||||||
let hives = load_hives();
|
let hives = load_hives();
|
||||||
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), &hives);
|
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), &hives, auth.clone());
|
||||||
spawn_jobq_worker(Arc::clone(&jobq), deps);
|
spawn_jobq_worker(Arc::clone(&jobq), deps);
|
||||||
// Bound to a named variable, not `_` — dropping the provider stops its
|
// Bound to a named variable, not `_` — dropping the provider stops its
|
||||||
// `PeriodicReader`, so it must live as long as `main` does (which it
|
// `PeriodicReader`, so it must live as long as `main` does (which it
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
//! where the agent's daemon reads it. See docs/swarm/credentials.md for why
|
//! where the agent's daemon reads it. See docs/swarm/credentials.md for why
|
||||||
//! `main` is the one name [`put_matrix_account`] refuses to write.
|
//! `main` is the one name [`put_matrix_account`] refuses to write.
|
||||||
//! [`hive_sender`] mints each **hive's** sender account the same way, sharing this module's helpers with [`agent_token`].
|
//! [`hive_sender`] mints each **hive's** sender account the same way, sharing this module's helpers with [`agent_token`].
|
||||||
|
//! [`operator_admin`] makes authelia's operators homeserver admins with the same
|
||||||
|
//! appservice token.
|
||||||
//!
|
//!
|
||||||
//! **The external one** — [`put_matrix_account`] and everything under it — is
|
//! **The external one** — [`put_matrix_account`] and everything under it — is
|
||||||
//! an account somewhere else that an operator hands us a credential for: put it
|
//! an account somewhere else that an operator hands us a credential for: put it
|
||||||
|
|
@ -34,6 +36,7 @@ use super::{AppState, error_problem, swarm_hive};
|
||||||
|
|
||||||
pub mod agent_token;
|
pub mod agent_token;
|
||||||
pub mod hive_sender;
|
pub mod hive_sender;
|
||||||
|
pub mod operator_admin;
|
||||||
|
|
||||||
fn default_mode() -> String {
|
fn default_mode() -> String {
|
||||||
"token".to_owned()
|
"token".to_owned()
|
||||||
|
|
|
||||||
287
swarm-controller/src/matrix_account/operator_admin.rs
Normal file
287
swarm-controller/src/matrix_account/operator_admin.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
//! Homeserver admin for authelia's operators: every member of the operator
|
||||||
|
//! group who has an account on the swarm's homeserver is made a homeserver
|
||||||
|
//! admin, at start and every [`RECONCILE_INTERVAL`] after.
|
||||||
|
//!
|
||||||
|
//! ⚠️ **Grant-only.** tuwunel has no command that revokes homeserver admin,
|
||||||
|
//! so leaving the operator group leaves a matrix admin in place.
|
||||||
|
//!
|
||||||
|
//! The account an operator's SSO login lands on is named after authelia's
|
||||||
|
//! `preferred_username`, lowercased: `hive-matrix.nix` sets `userid_claims`,
|
||||||
|
//! and tuwunel lowercases the claim and refuses one that is not a legal
|
||||||
|
//! localpart. [`localpart`] applies the same rule.
|
||||||
|
//!
|
||||||
|
//! The command is `!admin users make-user-admin <user>` in the admin room
|
||||||
|
//! `#admins:<server>`, sent as `@swarm` with the swarm appservice token —
|
||||||
|
//! `@swarm` is the one account the homeserver promotes at boot, and tuwunel
|
||||||
|
//! runs a command only when its sender is already an admin. Being an admin
|
||||||
|
//! *is* being joined to that room, so the room's joined members are the
|
||||||
|
//! operators who need nothing. An operator with no matrix account yet (no SSO
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use swarm_matrix_client::{self as homeserver, Whoami};
|
||||||
|
|
||||||
|
use super::agent_token::appservice_token;
|
||||||
|
use crate::auth::AuthBridge;
|
||||||
|
|
||||||
|
/// How often [`spawn`] re-checks the operator group.
|
||||||
|
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
||||||
|
|
||||||
|
/// The localpart of tuwunel's admin room alias, `#admins:<server>`.
|
||||||
|
const ADMIN_ROOM_ALIAS_LOCALPART: &str = "admins";
|
||||||
|
|
||||||
|
/// The localpart an SSO login by authelia user `username` lands on, or `None`
|
||||||
|
/// when tuwunel would refuse the name and no account can exist for it.
|
||||||
|
///
|
||||||
|
/// Lowercased and then checked against the user-id grammar's localpart
|
||||||
|
/// characters, which is what tuwunel does with the claim.
|
||||||
|
pub fn localpart(username: &str) -> Option<String> {
|
||||||
|
let lower = username.to_lowercase();
|
||||||
|
let legal = !lower.is_empty()
|
||||||
|
&& lower.bytes().all(|b| {
|
||||||
|
b.is_ascii_lowercase()
|
||||||
|
|| b.is_ascii_digit()
|
||||||
|
|| matches!(b, b'.' | b'_' | b'=' | b'-' | b'/' | b'+')
|
||||||
|
});
|
||||||
|
legal.then_some(lower)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one pass decided, before any account is looked up.
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Plan {
|
||||||
|
/// `(authelia username, user id)` for each operator not yet an admin.
|
||||||
|
pub grant: Vec<(String, String)>,
|
||||||
|
/// Operators whose username maps to no legal localpart.
|
||||||
|
pub unmappable: Vec<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 {
|
||||||
|
let mut plan = Plan::default();
|
||||||
|
for name in operators {
|
||||||
|
let Some(localpart) = localpart(name) else {
|
||||||
|
plan.unmappable.push(name.clone());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let user_id = format!("@{localpart}:{server}");
|
||||||
|
if !admins.contains(&user_id) {
|
||||||
|
plan.grant.push((name.clone(), user_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server name of a full user id, `@<localpart>:<server>`.
|
||||||
|
fn server_name(user_id: &str) -> Option<&str> {
|
||||||
|
user_id
|
||||||
|
.strip_prefix('@')?
|
||||||
|
.split_once(':')
|
||||||
|
.map(|(_, server)| server)
|
||||||
|
.filter(|server| !server.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one pass did.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Outcome {
|
||||||
|
granted: usize,
|
||||||
|
no_account: usize,
|
||||||
|
failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass: read the operator group, and send a grant for each operator
|
||||||
|
/// who has a matrix account and is not an admin.
|
||||||
|
///
|
||||||
|
/// 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> {
|
||||||
|
let operators = bridge
|
||||||
|
.list_operators()
|
||||||
|
.await
|
||||||
|
.context("reading authelia's operator group")?;
|
||||||
|
let store = crate::store::connect()
|
||||||
|
.await
|
||||||
|
.context("logging in to the swarm secret store")?;
|
||||||
|
let token = appservice_token(&store).await?;
|
||||||
|
let http = homeserver::client()?;
|
||||||
|
let Whoami::User(sender) = homeserver::whoami(&http, base, &token)
|
||||||
|
.await
|
||||||
|
.context("asking the homeserver who the swarm appservice token is")?
|
||||||
|
else {
|
||||||
|
bail!("the homeserver does not know the swarm appservice token");
|
||||||
|
};
|
||||||
|
let server = server_name(&sender)
|
||||||
|
.with_context(|| format!("the homeserver named the swarm sender {sender:?}"))?;
|
||||||
|
let room = homeserver::resolve_alias(
|
||||||
|
&http,
|
||||||
|
base,
|
||||||
|
&token,
|
||||||
|
&format!("#{ADMIN_ROOM_ALIAS_LOCALPART}:{server}"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let admins = homeserver::joined_members(&http, base, &token, &room).await?;
|
||||||
|
|
||||||
|
let plan = plan(&operators, server, &admins);
|
||||||
|
for name in &plan.unmappable {
|
||||||
|
tracing::warn!(
|
||||||
|
operator = %name,
|
||||||
|
"operator's username is no legal matrix localpart, so no SSO login can \
|
||||||
|
have an account to make admin; skipped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut outcome = Outcome::default();
|
||||||
|
for (name, user_id) in &plan.grant {
|
||||||
|
match grant(&http, base, &token, &room, user_id).await {
|
||||||
|
Ok(true) => {
|
||||||
|
tracing::info!(
|
||||||
|
operator = %name,
|
||||||
|
%user_id,
|
||||||
|
"sent make-user-admin for an operator who is not a homeserver admin"
|
||||||
|
);
|
||||||
|
outcome.granted += 1;
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
tracing::debug!(
|
||||||
|
operator = %name,
|
||||||
|
%user_id,
|
||||||
|
"operator has no matrix account yet; nothing to make admin"
|
||||||
|
);
|
||||||
|
outcome.no_account += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
operator = %name,
|
||||||
|
%user_id,
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"make-user-admin for an operator failed"
|
||||||
|
);
|
||||||
|
outcome.failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send the grant for `user_id` when its account exists; `false` when it
|
||||||
|
/// does not, and nothing was sent.
|
||||||
|
async fn grant(
|
||||||
|
http: &reqwest::Client,
|
||||||
|
base: &str,
|
||||||
|
token: &str,
|
||||||
|
room: &str,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
if !homeserver::user_exists(http, base, token, user_id).await? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
homeserver::send_text(
|
||||||
|
http,
|
||||||
|
base,
|
||||||
|
token,
|
||||||
|
room,
|
||||||
|
&format!("!admin users make-user-admin {user_id}"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run [`pass`] now and every [`RECONCILE_INTERVAL`] after. A pass that fails
|
||||||
|
/// 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 ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
match pass(&bridge, &base).await {
|
||||||
|
Ok(outcome) if outcome.failed > 0 => tracing::warn!(
|
||||||
|
granted = outcome.granted,
|
||||||
|
failed = outcome.failed,
|
||||||
|
retry_in_s = RECONCILE_INTERVAL.as_secs(),
|
||||||
|
"operator matrix admin: pass incomplete; retrying next tick"
|
||||||
|
),
|
||||||
|
Ok(outcome) if outcome.granted > 0 => tracing::info!(
|
||||||
|
granted = outcome.granted,
|
||||||
|
no_account = outcome.no_account,
|
||||||
|
"operator matrix admin: grants sent"
|
||||||
|
),
|
||||||
|
Ok(outcome) => tracing::debug!(
|
||||||
|
no_account = outcome.no_account,
|
||||||
|
"operator matrix admin: every operator with an account is an admin"
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
retry_in_s = RECONCILE_INTERVAL.as_secs(),
|
||||||
|
"operator matrix admin: pass failed; retrying next tick"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn names(names: &[&str]) -> Vec<String> {
|
||||||
|
names.iter().map(|&n| n.to_owned()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ids(ids: &[&str]) -> BTreeSet<String> {
|
||||||
|
ids.iter().map(|&n| n.to_owned()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_operator_already_in_the_admin_room_gets_no_command() {
|
||||||
|
let plan = plan(
|
||||||
|
&names(&["mara", "vinzenz"]),
|
||||||
|
"t.local",
|
||||||
|
&ids(&["@mara:t.local", "@swarm:t.local"]),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.grant,
|
||||||
|
[("vinzenz".to_owned(), "@vinzenz:t.local".to_owned())]
|
||||||
|
);
|
||||||
|
assert!(plan.unmappable.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pass_with_every_operator_admin_sends_nothing() {
|
||||||
|
let plan = plan(&names(&["mara"]), "t.local", &ids(&["@mara:t.local"]));
|
||||||
|
assert_eq!(plan, Plan::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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"]));
|
||||||
|
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());
|
||||||
|
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 the_server_name_comes_from_the_full_user_id() {
|
||||||
|
assert_eq!(server_name("@swarm:t.local"), Some("t.local"));
|
||||||
|
assert_eq!(server_name("@swarm:t.local:8448"), Some("t.local:8448"));
|
||||||
|
assert_eq!(server_name("@swarm:"), None);
|
||||||
|
assert_eq!(server_name("swarm"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
//! The appservice calls against the swarm's homeserver: register, log in, and
|
//! The appservice calls against the swarm's homeserver: register, log in, ask
|
||||||
//! ask a token who it is. Shared by `swarm-matrix-ctl` and `swarm-controller`;
|
//! 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.
|
//! 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
|
//! 🩸 **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))
|
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.
|
/// The device every token this binary mints is pinned to.
|
||||||
fn device_id(localpart: &str) -> String {
|
fn device_id(localpart: &str) -> String {
|
||||||
format!("hyperhive-{localpart}")
|
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]
|
#[test]
|
||||||
fn a_minted_password_is_hex_of_the_declared_length() {
|
fn a_minted_password_is_hex_of_the_declared_length() {
|
||||||
// The control on the hex fold: a short or non-hex password would be
|
// The control on the hex fold: a short or non-hex password would be
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue