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

@ -132,10 +132,16 @@ pub fn mark_as_agent(store: &mut UserStore, name: &str) -> bool {
/// Sorted because [`UserStore::users`] is a `BTreeMap`, so this is free and
/// a consumer diffing two answers sees real changes rather than reordering.
pub fn agent_names(store: &UserStore) -> Vec<String> {
group_members(store, AGENT_GROUP)
}
/// The names in the store that carry `group`, sorted for
/// [`agent_names`]'s reason.
pub fn group_members(store: &UserStore, group: &str) -> Vec<String> {
store
.users
.iter()
.filter(|(_, user)| user.groups.iter().any(|g| g == AGENT_GROUP))
.filter(|(_, user)| user.groups.iter().any(|g| g == group))
.map(|(name, _)| name.clone())
.collect()
}
@ -343,9 +349,9 @@ mod tests {
}
}
/// The group an operator carries — a literal here rather than a shared
/// constant, because this binary does not read the operator group and a
/// test that imported one would imply it did.
/// The group an operator carries — a literal here, because this binary
/// takes the operator group from its environment and has no constant to
/// import.
fn operator(password: &str) -> User {
User {
groups: vec!["admins".to_owned()],
@ -477,6 +483,22 @@ some_future_top_level_key: 7
assert_eq!(agent_names(&store), ["atlas"]);
}
/// The operator list is the group's members only — not agents, and not
/// an account that carries no group.
#[test]
fn the_operator_list_is_the_groups_members_and_not_the_file() {
let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), agent("$argon2id$a"));
store
.users
.insert("mara".to_owned(), operator("$argon2id$b"));
store
.users
.insert("ungrouped".to_owned(), user("$argon2id$c"));
assert_eq!(group_members(&store, "admins"), ["mara"]);
}
/// An identity that predates the marker is invisible to the roster until
/// agent creation runs again — which is the agreed migration, so it has
/// to actually change something.