From 5f901a560b4a2b9ae058e94e9d820c805119e934 Mon Sep 17 00:00:00 2001 From: ember33 Date: Fri, 20 Mar 2026 08:52:13 +0100 Subject: [PATCH] feat(spaces): add admin commands for space role management Add !admin space roles subcommands: list, add, remove, assign, revoke, require, unrequire, user, room, enable, disable, status. The remove command uses cascade_remove_role macro to deduplicate member and room cleanup loops. Role definitions, assignments, and room requirements are managed via state events. --- src/admin/admin.rs | 9 + src/admin/mod.rs | 1 + src/admin/space/mod.rs | 15 + src/admin/space/roles.rs | 632 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 657 insertions(+) create mode 100644 src/admin/space/mod.rs create mode 100644 src/admin/space/roles.rs diff --git a/src/admin/admin.rs b/src/admin/admin.rs index 4db3393a..caa44f3c 100644 --- a/src/admin/admin.rs +++ b/src/admin/admin.rs @@ -11,6 +11,7 @@ use crate::{ query::{self, QueryCommand}, room::{self, RoomCommand}, server::{self, ServerCommand}, + space::{self, SpaceCommand}, token::{self, TokenCommand}, user::{self, UserCommand}, }; @@ -34,6 +35,10 @@ pub enum AdminCommand { /// Commands for managing rooms Rooms(RoomCommand), + #[command(subcommand)] + /// Commands for managing space permissions + Spaces(SpaceCommand), + #[command(subcommand)] /// Commands for managing federation Federation(FederationCommand), @@ -81,6 +86,10 @@ pub(super) async fn process(command: AdminCommand, context: &Context<'_>) -> Res token::process(command, context).await }, | Rooms(command) => room::process(command, context).await, + | Spaces(command) => { + context.bail_restricted()?; + space::process(command, context).await + }, | Federation(command) => federation::process(command, context).await, | Server(command) => server::process(command, context).await, | Debug(command) => debug::process(command, context).await, diff --git a/src/admin/mod.rs b/src/admin/mod.rs index b343fd2e..bd088fe6 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod media; pub(crate) mod query; pub(crate) mod room; pub(crate) mod server; +pub(crate) mod space; pub(crate) mod token; pub(crate) mod user; diff --git a/src/admin/space/mod.rs b/src/admin/space/mod.rs new file mode 100644 index 00000000..0b183601 --- /dev/null +++ b/src/admin/space/mod.rs @@ -0,0 +1,15 @@ +pub(super) mod roles; + +use clap::Subcommand; +use conduwuit::Result; + +use self::roles::SpaceRolesCommand; +use crate::admin_command_dispatch; + +#[admin_command_dispatch] +#[derive(Debug, Subcommand)] +pub enum SpaceCommand { + #[command(subcommand)] + /// Manage space roles and permissions + Roles(SpaceRolesCommand), +} diff --git a/src/admin/space/roles.rs b/src/admin/space/roles.rs new file mode 100644 index 00000000..a1813a40 --- /dev/null +++ b/src/admin/space/roles.rs @@ -0,0 +1,632 @@ +use std::fmt::Write; + +use clap::Subcommand; +use conduwuit::{Err, Event, Result, matrix::pdu::PduBuilder}; +use conduwuit_core::matrix::space_roles::{ + RoleDefinition, SPACE_CASCADING_EVENT_TYPE, SPACE_ROLE_MEMBER_EVENT_TYPE, + SPACE_ROLE_ROOM_EVENT_TYPE, SPACE_ROLES_EVENT_TYPE, SpaceCascadingEventContent, + SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, SpaceRolesEventContent, +}; +use futures::StreamExt; +use ruma::{OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId, events::StateEventType}; +use serde_json::value::to_raw_value; + +use crate::{admin_command, admin_command_dispatch}; + +fn roles_event_type() -> StateEventType { + StateEventType::from(SPACE_ROLES_EVENT_TYPE.to_owned()) +} + +fn member_event_type() -> StateEventType { + StateEventType::from(SPACE_ROLE_MEMBER_EVENT_TYPE.to_owned()) +} + +fn room_event_type() -> StateEventType { + StateEventType::from(SPACE_ROLE_ROOM_EVENT_TYPE.to_owned()) +} + +fn cascading_event_type() -> StateEventType { + StateEventType::from(SPACE_CASCADING_EVENT_TYPE.to_owned()) +} + +macro_rules! resolve_room_as_space { + ($self:expr, $space:expr) => {{ + let space_id = $self.services.rooms.alias.resolve(&$space).await?; + if !matches!( + $self + .services + .rooms + .state_accessor + .get_room_type(&space_id) + .await, + Ok(ruma::room::RoomType::Space) + ) { + return Err!("The specified room is not a Space."); + } + space_id + }}; +} + +macro_rules! resolve_space { + ($self:expr, $space:expr) => {{ + let space_id = resolve_room_as_space!($self, $space); + if !$self + .services + .rooms + .roles + .is_enabled_for_space(&space_id) + .await + { + return $self + .write_str( + "Space permission cascading is disabled for this Space. Enable it \ + server-wide with `space_permission_cascading = true` in your config, or \ + per-Space with `!admin space roles enable `.", + ) + .await; + } + space_id + }}; +} + +macro_rules! custom_state_pdu { + ($event_type:expr, $state_key:expr, $content:expr) => { + PduBuilder { + event_type: $event_type.to_owned().into(), + content: to_raw_value($content) + .map_err(|e| conduwuit::err!("Failed to serialize state event content: {e}"))?, + state_key: Some($state_key.to_owned().into()), + ..PduBuilder::default() + } + }; +} + +/// Cascade-remove a role name from all state events of a given type. For each +/// event that contains the role, the `$field` is filtered and the updated +/// content is sent back as a new state event. +macro_rules! cascade_remove_role { + ( + $self:expr, + $shortstatehash:expr, + $event_type_fn:expr, + $event_type_const:expr, + $content_ty:ty, + $field:ident, + $role_name:expr, + $space_id:expr, + $state_lock:expr, + $server_user:expr + ) => {{ + let ev_type = $event_type_fn; + let entries: Vec<(_, ruma::OwnedEventId)> = $self + .services + .rooms + .state_accessor + .state_keys_with_ids($shortstatehash, &ev_type) + .collect() + .await; + + for (state_key, event_id) in entries { + if let Ok(pdu) = $self.services.rooms.timeline.get_pdu(&event_id).await { + if let Ok(mut content) = pdu.get_content::<$content_ty>() { + if content.$field.contains($role_name) { + content.$field.retain(|r| r != $role_name); + $self + .services + .rooms + .timeline + .build_and_append_pdu( + custom_state_pdu!($event_type_const, &state_key, &content), + $server_user, + Some(&$space_id), + &$state_lock, + ) + .await?; + } + } + } + } + }}; +} + +macro_rules! send_space_state { + ($self:expr, $space_id:expr, $event_type:expr, $state_key:expr, $content:expr) => {{ + let state_lock = $self.services.rooms.state.mutex.lock(&$space_id).await; + let server_user = &$self.services.globals.server_user; + $self + .services + .rooms + .timeline + .build_and_append_pdu( + custom_state_pdu!($event_type, $state_key, $content), + server_user, + Some(&$space_id), + &state_lock, + ) + .await? + }}; +} + +#[admin_command_dispatch] +#[derive(Debug, Subcommand)] +pub enum SpaceRolesCommand { + /// List all roles defined in a space + List { + space: OwnedRoomOrAliasId, + }, + /// Add a new role to a space + Add { + space: OwnedRoomOrAliasId, + role_name: String, + #[arg(long)] + description: Option, + #[arg(long)] + power_level: Option, + }, + /// Remove a role from a space + Remove { + space: OwnedRoomOrAliasId, + role_name: String, + }, + /// Assign a role to a user + Assign { + space: OwnedRoomOrAliasId, + user_id: OwnedUserId, + role_name: String, + }, + /// Revoke a role from a user + Revoke { + space: OwnedRoomOrAliasId, + user_id: OwnedUserId, + role_name: String, + }, + /// Require a role for a room + Require { + space: OwnedRoomOrAliasId, + room_id: OwnedRoomId, + role_name: String, + }, + /// Remove a role requirement from a room + Unrequire { + space: OwnedRoomOrAliasId, + room_id: OwnedRoomId, + role_name: String, + }, + /// Show a user's roles in a space + User { + space: OwnedRoomOrAliasId, + user_id: OwnedUserId, + }, + /// Show a room's role requirements in a space + Room { + space: OwnedRoomOrAliasId, + room_id: OwnedRoomId, + }, + /// Enable space permission cascading for a specific space (overrides + /// server config) + Enable { + space: OwnedRoomOrAliasId, + }, + /// Disable space permission cascading for a specific space (overrides + /// server config) + Disable { + space: OwnedRoomOrAliasId, + }, + /// Show whether cascading is enabled for a space and the source (server + /// default or per-space override) + Status { + space: OwnedRoomOrAliasId, + }, +} + +#[admin_command] +async fn list(&self, space: OwnedRoomOrAliasId) -> Result { + let space_id = resolve_space!(self, space); + let roles_event_type = roles_event_type(); + + let content: SpaceRolesEventContent = self + .services + .rooms + .state_accessor + .room_state_get_content(&space_id, &roles_event_type, "") + .await + .unwrap_or_default(); + + if content.roles.is_empty() { + return self.write_str("No roles defined in this space.").await; + } + + let mut msg = format!("Roles in {space_id}:\n```\n"); + for (name, def) in &content.roles { + let pl = def + .power_level + .map(|p| format!(" (power_level: {p})")) + .unwrap_or_default(); + let _ = writeln!(msg, "- {name}: {}{pl}", def.description); + } + msg.push_str("```"); + + self.write_str(&msg).await +} + +#[admin_command] +async fn add( + &self, + space: OwnedRoomOrAliasId, + role_name: String, + description: Option, + power_level: Option, +) -> Result { + let space_id = resolve_space!(self, space); + + if let Some(pl) = power_level { + if pl > i64::from(ruma::Int::MAX) || pl < i64::from(ruma::Int::MIN) { + return Err!( + "Power level must be between {} and {}.", + ruma::Int::MIN, + ruma::Int::MAX + ); + } + } + + let roles_event_type = roles_event_type(); + + let mut content: SpaceRolesEventContent = self + .services + .rooms + .state_accessor + .room_state_get_content(&space_id, &roles_event_type, "") + .await + .unwrap_or_default(); + + if content.roles.contains_key(&role_name) { + return Err!("Role '{role_name}' already exists in this space."); + } + + content.roles.insert(role_name.clone(), RoleDefinition { + description: description.unwrap_or_else(|| role_name.clone()), + power_level, + }); + + send_space_state!(self, space_id, SPACE_ROLES_EVENT_TYPE, "", &content); + + self.write_str(&format!("Added role '{role_name}' to space {space_id}.")) + .await +} + +#[admin_command] +async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result { + let space_id = resolve_space!(self, space); + let roles_event_type = roles_event_type(); + + let mut content: SpaceRolesEventContent = self + .services + .rooms + .state_accessor + .room_state_get_content(&space_id, &roles_event_type, "") + .await + .unwrap_or_default(); + + if content.roles.remove(&role_name).is_none() { + return Err!("Role '{role_name}' does not exist in this space."); + } + + send_space_state!(self, space_id, SPACE_ROLES_EVENT_TYPE, "", &content); + + // Cascade: remove the deleted role from all member and room events + let server_user = &self.services.globals.server_user; + if let Ok(shortstatehash) = self + .services + .rooms + .state + .get_room_shortstatehash(&space_id) + .await + { + let state_lock = self.services.rooms.state.mutex.lock(&space_id).await; + + cascade_remove_role!( + self, + shortstatehash, + member_event_type(), + SPACE_ROLE_MEMBER_EVENT_TYPE, + SpaceRoleMemberEventContent, + roles, + &role_name, + space_id, + state_lock, + server_user + ); + + cascade_remove_role!( + self, + shortstatehash, + room_event_type(), + SPACE_ROLE_ROOM_EVENT_TYPE, + SpaceRoleRoomEventContent, + required_roles, + &role_name, + space_id, + state_lock, + server_user + ); + } + + self.write_str(&format!("Removed role '{role_name}' from space {space_id}.")) + .await +} + +#[admin_command] +async fn assign( + &self, + space: OwnedRoomOrAliasId, + user_id: OwnedUserId, + role_name: String, +) -> Result { + let space_id = resolve_space!(self, space); + + let roles_event_type = roles_event_type(); + let role_defs: SpaceRolesEventContent = self + .services + .rooms + .state_accessor + .room_state_get_content(&space_id, &roles_event_type, "") + .await + .unwrap_or_default(); + + if !role_defs.roles.contains_key(&role_name) { + return Err!("Role '{role_name}' does not exist in this space."); + } + + let member_event_type = member_event_type(); + + let mut content: SpaceRoleMemberEventContent = self + .services + .rooms + .state_accessor + .room_state_get_content(&space_id, &member_event_type, user_id.as_str()) + .await + .unwrap_or_default(); + + if content.roles.contains(&role_name) { + return Err!("User {user_id} already has role '{role_name}' in this space."); + } + + content.roles.push(role_name.clone()); + + send_space_state!(self, space_id, SPACE_ROLE_MEMBER_EVENT_TYPE, user_id.as_str(), &content); + + self.write_str(&format!("Assigned role '{role_name}' to {user_id} in space {space_id}.")) + .await +} + +#[admin_command] +async fn revoke( + &self, + space: OwnedRoomOrAliasId, + user_id: OwnedUserId, + role_name: String, +) -> Result { + let space_id = resolve_space!(self, space); + let member_event_type = member_event_type(); + + let mut content: SpaceRoleMemberEventContent = self + .services + .rooms + .state_accessor + .room_state_get_content(&space_id, &member_event_type, user_id.as_str()) + .await + .unwrap_or_default(); + + let original_len = content.roles.len(); + content.roles.retain(|r| r != &role_name); + + if content.roles.len() == original_len { + return Err!("User {user_id} does not have role '{role_name}' in this space."); + } + + send_space_state!(self, space_id, SPACE_ROLE_MEMBER_EVENT_TYPE, user_id.as_str(), &content); + + self.write_str(&format!("Revoked role '{role_name}' from {user_id} in space {space_id}.")) + .await +} + +#[admin_command] +async fn require( + &self, + space: OwnedRoomOrAliasId, + room_id: OwnedRoomId, + role_name: String, +) -> Result { + let space_id = resolve_space!(self, space); + + let child_rooms = self.services.rooms.roles.get_child_rooms(&space_id).await; + if !child_rooms.contains(&room_id) { + return Err!("Room {room_id} is not a child of space {space_id}."); + } + + let roles_event_type = roles_event_type(); + let role_defs: SpaceRolesEventContent = self + .services + .rooms + .state_accessor + .room_state_get_content(&space_id, &roles_event_type, "") + .await + .unwrap_or_default(); + + if !role_defs.roles.contains_key(&role_name) { + return Err!("Role '{role_name}' does not exist in this space."); + } + + let room_event_type = room_event_type(); + + let mut content: SpaceRoleRoomEventContent = self + .services + .rooms + .state_accessor + .room_state_get_content(&space_id, &room_event_type, room_id.as_str()) + .await + .unwrap_or_default(); + + if content.required_roles.contains(&role_name) { + return Err!("Room {room_id} already requires role '{role_name}' in this space."); + } + + content.required_roles.push(role_name.clone()); + + send_space_state!(self, space_id, SPACE_ROLE_ROOM_EVENT_TYPE, room_id.as_str(), &content); + + self.write_str(&format!( + "Room {room_id} now requires role '{role_name}' in space {space_id}." + )) + .await +} + +#[admin_command] +async fn unrequire( + &self, + space: OwnedRoomOrAliasId, + room_id: OwnedRoomId, + role_name: String, +) -> Result { + let space_id = resolve_space!(self, space); + let room_event_type = room_event_type(); + + let mut content: SpaceRoleRoomEventContent = self + .services + .rooms + .state_accessor + .room_state_get_content(&space_id, &room_event_type, room_id.as_str()) + .await + .unwrap_or_default(); + + let original_len = content.required_roles.len(); + content.required_roles.retain(|r| r != &role_name); + + if content.required_roles.len() == original_len { + return Err!("Room {room_id} does not require role '{role_name}' in this space."); + } + + send_space_state!(self, space_id, SPACE_ROLE_ROOM_EVENT_TYPE, room_id.as_str(), &content); + + self.write_str(&format!( + "Removed role requirement '{role_name}' from room {room_id} in space {space_id}." + )) + .await +} + +#[admin_command] +async fn user(&self, space: OwnedRoomOrAliasId, user_id: OwnedUserId) -> Result { + let space_id = resolve_space!(self, space); + + let roles = self + .services + .rooms + .roles + .get_user_roles_in_space(&space_id, &user_id) + .await; + + match roles { + | Some(roles) if !roles.is_empty() => { + let list: String = roles + .iter() + .map(|r| format!("- {r}")) + .collect::>() + .join("\n"); + self.write_str(&format!("Roles for {user_id} in space {space_id}:\n```\n{list}\n```")) + .await + }, + | _ => + self.write_str(&format!("User {user_id} has no roles in space {space_id}.")) + .await, + } +} + +#[admin_command] +async fn room(&self, space: OwnedRoomOrAliasId, room_id: OwnedRoomId) -> Result { + let space_id = resolve_space!(self, space); + + let reqs = self + .services + .rooms + .roles + .get_room_requirements_in_space(&space_id, &room_id) + .await; + + match reqs { + | Some(reqs) if !reqs.is_empty() => { + let list: String = reqs + .iter() + .map(|r| format!("- {r}")) + .collect::>() + .join("\n"); + self.write_str(&format!( + "Required roles for room {room_id} in space {space_id}:\n```\n{list}\n```" + )) + .await + }, + | _ => + self.write_str(&format!( + "Room {room_id} has no role requirements in space {space_id}." + )) + .await, + } +} + +#[admin_command] +async fn enable(&self, space: OwnedRoomOrAliasId) -> Result { + let space_id = resolve_room_as_space!(self, space); + + self.services + .rooms + .roles + .ensure_default_roles(&space_id) + .await?; + + let content = SpaceCascadingEventContent { enabled: true }; + send_space_state!(self, space_id, SPACE_CASCADING_EVENT_TYPE, "", &content); + + self.write_str(&format!("Space permission cascading enabled for {space_id}.")) + .await +} + +#[admin_command] +async fn disable(&self, space: OwnedRoomOrAliasId) -> Result { + let space_id = resolve_room_as_space!(self, space); + + let content = SpaceCascadingEventContent { enabled: false }; + send_space_state!(self, space_id, SPACE_CASCADING_EVENT_TYPE, "", &content); + + self.write_str(&format!("Space permission cascading disabled for {space_id}.")) + .await +} + +#[admin_command] +async fn status(&self, space: OwnedRoomOrAliasId) -> Result { + let space_id = resolve_room_as_space!(self, space); + + let global_default = self.services.rooms.roles.is_enabled(); + let cascading_event_type = cascading_event_type(); + let per_space_override: Option = self + .services + .rooms + .state_accessor + .room_state_get_content::( + &space_id, + &cascading_event_type, + "", + ) + .await + .ok() + .map(|c| c.enabled); + + let effective = per_space_override.unwrap_or(global_default); + let source = match per_space_override { + | Some(v) => format!("per-Space override (enabled: {v})"), + | None => format!("server default (space_permission_cascading: {global_default})"), + }; + + self.write_str(&format!( + "Cascading status for {space_id}:\n- Effective: **{effective}**\n- Source: {source}" + )) + .await +}