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..09e8f12b --- /dev/null +++ b/src/admin/space/roles.rs @@ -0,0 +1,430 @@ +use clap::Subcommand; +use conduwuit::{Err, Result}; +use conduwuit_core::matrix::space_roles::{ + RoleDefinition, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, + SpaceRolesEventContent, +}; +use ruma::{OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId, events::StateEventType}; +use serde_json::value::to_raw_value; + +use conduwuit::matrix::pdu::PduBuilder; + +use crate::{admin_command, admin_command_dispatch}; + +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) + .expect("Failed to serialize custom state event content"), + state_key: Some($state_key.to_owned().into()), + ..PduBuilder::default() + } + }; +} + +#[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, + }, +} + +#[admin_command] +async fn list(&self, space: OwnedRoomOrAliasId) -> Result { + let space_id = self.services.rooms.alias.resolve(&space).await?; + let roles_event_type = StateEventType::from("m.space.roles".to_owned()); + + 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 {}:\n```\n", space_id); + for (name, def) in &content.roles { + let pl = def + .power_level + .map(|p| format!(" (power_level: {p})")) + .unwrap_or_default(); + msg.push_str(&format!("- {name}: {}{pl}\n", 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 = self.services.rooms.alias.resolve(&space).await?; + let roles_event_type = StateEventType::from("m.space.roles".to_owned()); + + 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, + }); + + 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!("m.space.roles", "", &content), + server_user, + Some(&space_id), + &state_lock, + ) + .await?; + + 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 = self.services.rooms.alias.resolve(&space).await?; + let roles_event_type = StateEventType::from("m.space.roles".to_owned()); + + 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."); + } + + 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!("m.space.roles", "", &content), + server_user, + Some(&space_id), + &state_lock, + ) + .await?; + + 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 = self.services.rooms.alias.resolve(&space).await?; + let member_event_type = StateEventType::from("m.space.role.member".to_owned()); + + 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()); + + 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!("m.space.role.member", user_id.as_str(), &content), + server_user, + Some(&space_id), + &state_lock, + ) + .await?; + + 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 = self.services.rooms.alias.resolve(&space).await?; + let member_event_type = StateEventType::from("m.space.role.member".to_owned()); + + 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."); + } + + 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!("m.space.role.member", user_id.as_str(), &content), + server_user, + Some(&space_id), + &state_lock, + ) + .await?; + + 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 = self.services.rooms.alias.resolve(&space).await?; + let room_event_type = StateEventType::from("m.space.role.room".to_owned()); + + 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()); + + 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!("m.space.role.room", room_id.as_str(), &content), + server_user, + Some(&space_id), + &state_lock, + ) + .await?; + + 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 = self.services.rooms.alias.resolve(&space).await?; + let room_event_type = StateEventType::from("m.space.role.room".to_owned()); + + 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."); + } + + 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!("m.space.role.room", room_id.as_str(), &content), + server_user, + Some(&space_id), + &state_lock, + ) + .await?; + + 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 = self.services.rooms.alias.resolve(&space).await?; + + let user_roles = self.services.rooms.roles.user_roles.read().await; + let roles = user_roles + .get(&space_id) + .and_then(|space_users| space_users.get(&user_id)); + + match roles { + | Some(roles) if !roles.is_empty() => { + let roles_list: Vec<&String> = roles.iter().collect(); + self.write_str(&format!( + "Roles for {user_id} in space {space_id}:\n```\n{}\n```", + roles_list + .iter() + .map(|r| format!("- {r}")) + .collect::>() + .join("\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 = self.services.rooms.alias.resolve(&space).await?; + + let room_reqs = self.services.rooms.roles.room_requirements.read().await; + let requirements = room_reqs + .get(&space_id) + .and_then(|space_rooms| space_rooms.get(&room_id)); + + match requirements { + | Some(reqs) if !reqs.is_empty() => { + let reqs_list: Vec<&String> = reqs.iter().collect(); + self.write_str(&format!( + "Required roles for room {room_id} in space {space_id}:\n```\n{}\n```", + reqs_list + .iter() + .map(|r| format!("- {r}")) + .collect::>() + .join("\n") + )) + .await + }, + | _ => { + self.write_str(&format!( + "Room {room_id} has no role requirements in space {space_id}." + )) + .await + }, + } +}