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

@ -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

View file

@ -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`,

View file

@ -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

View file

@ -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;
}; };
}; };

View file

@ -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

View file

@ -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":[]}`

View file

@ -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.

View file

@ -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
);
}
} }

View file

@ -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"
);
}
} }

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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()

View 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);
}
}

View file

@ -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