diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 847b261c..1a183f2b 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -470,9 +470,10 @@ # #suspend_on_register = false -# Enable space permission cascading (power levels and role-based access). -# When enabled, power levels cascade from Spaces to child rooms and rooms -# can require roles for access. Applies to all Spaces on this server. +# Server-wide default for space permission cascading (power levels and +# role-based access). Individual Spaces can override this via the +# `com.continuwuity.space.cascading` state event or the admin command +# `!admin space roles enable/disable `. # #space_permission_cascading = false diff --git a/src/admin/space/roles.rs b/src/admin/space/roles.rs index 0be207c9..01d02d25 100644 --- a/src/admin/space/roles.rs +++ b/src/admin/space/roles.rs @@ -3,9 +3,9 @@ use std::fmt::Write; use clap::Subcommand; use conduwuit::{Err, Event, Result, matrix::pdu::PduBuilder}; use conduwuit_core::matrix::space_roles::{ - RoleDefinition, SPACE_ROLE_MEMBER_EVENT_TYPE, SPACE_ROLE_ROOM_EVENT_TYPE, - SPACE_ROLES_EVENT_TYPE, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, - SpaceRolesEventContent, + 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}; @@ -13,23 +13,24 @@ use serde_json::value::to_raw_value; use crate::{admin_command, admin_command_dispatch}; -macro_rules! require_enabled { - ($self:expr) => { - if !$self.services.rooms.roles.is_enabled() { +macro_rules! resolve_space { + ($self:expr, $space:expr) => {{ + let space_id = $self.services.rooms.alias.resolve(&$space).await?; + if !$self + .services + .rooms + .roles + .is_enabled_for_space(&space_id) + .await + { return $self .write_str( - "Space permission cascading is disabled. Enable it with \ - `space_permission_cascading = true` in your config.", + "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; } - }; -} - -macro_rules! resolve_space { - ($self:expr, $space:expr) => {{ - require_enabled!($self); - let space_id = $self.services.rooms.alias.resolve(&$space).await?; if !matches!( $self .services @@ -115,6 +116,21 @@ pub enum SpaceRolesCommand { 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] @@ -314,7 +330,6 @@ async fn assign( ) -> Result { let space_id = resolve_space!(self, space); - // Read current role definitions to validate the role name let roles_event_type = StateEventType::from(SPACE_ROLES_EVENT_TYPE.to_owned()); let role_defs: SpaceRolesEventContent = self .services @@ -414,7 +429,6 @@ async fn require( ) -> Result { let space_id = resolve_space!(self, space); - // Read current role definitions to validate the role name let roles_event_type = StateEventType::from(SPACE_ROLES_EVENT_TYPE.to_owned()); let role_defs: SpaceRolesEventContent = self .services @@ -566,3 +580,116 @@ async fn room(&self, space: OwnedRoomOrAliasId, room_id: OwnedRoomId) -> Result .await, } } + +#[admin_command] +async fn enable(&self, space: OwnedRoomOrAliasId) -> Result { + 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."); + } + + let content = SpaceCascadingEventContent { enabled: true }; + 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!(SPACE_CASCADING_EVENT_TYPE, "", &content), + server_user, + Some(&space_id), + &state_lock, + ) + .await?; + + self.services + .rooms + .roles + .ensure_default_roles(&space_id) + .await?; + + self.write_str(&format!("Space permission cascading enabled for {space_id}.")) + .await +} + +#[admin_command] +async fn disable(&self, space: OwnedRoomOrAliasId) -> Result { + 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."); + } + + let content = SpaceCascadingEventContent { enabled: false }; + 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!(SPACE_CASCADING_EVENT_TYPE, "", &content), + server_user, + Some(&space_id), + &state_lock, + ) + .await?; + + self.write_str(&format!("Space permission cascading disabled for {space_id}.")) + .await +} + +#[admin_command] +async fn status(&self, space: OwnedRoomOrAliasId) -> Result { + 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."); + } + + let global_default = self.services.rooms.roles.is_enabled(); + let cascading_event_type = StateEventType::from(SPACE_CASCADING_EVENT_TYPE.to_owned()); + 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 +} diff --git a/src/api/client/membership/join.rs b/src/api/client/membership/join.rs index 3d5ed9b6..1cb7b978 100644 --- a/src/api/client/membership/join.rs +++ b/src/api/client/membership/join.rs @@ -347,9 +347,7 @@ pub async fn join_room_by_id_helper( } } - // Space permission cascading: check if user has required roles - // User must qualify in at least one parent space (if any exist) - if services.rooms.roles.is_enabled() { + { let parent_spaces = services.rooms.roles.get_parent_spaces(room_id).await; if !parent_spaces.is_empty() { let mut qualifies_in_any = false; diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 1eca8b7b..7fea89a3 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -603,9 +603,10 @@ pub struct Config { #[serde(default)] pub suspend_on_register: bool, - /// Enable space permission cascading (power levels and role-based access). - /// When enabled, power levels cascade from Spaces to child rooms and rooms - /// can require roles for access. Applies to all Spaces on this server. + /// Server-wide default for space permission cascading (power levels and + /// role-based access). Individual Spaces can override this via the + /// `com.continuwuity.space.cascading` state event or the admin command + /// `!admin space roles enable/disable `. /// /// default: false #[serde(default)] diff --git a/src/core/matrix/space_roles.rs b/src/core/matrix/space_roles.rs index 2857f5c8..b900a053 100644 --- a/src/core/matrix/space_roles.rs +++ b/src/core/matrix/space_roles.rs @@ -1,56 +1,39 @@ -//! Custom state event content types for space permission cascading. -//! -//! These events live in Space rooms and define roles, user-role assignments, -//! and room-role requirements. - use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -/// Custom event type for space role definitions. pub const SPACE_ROLES_EVENT_TYPE: &str = "com.continuwuity.space.roles"; - -/// Custom event type for per-user role assignments within a space. pub const SPACE_ROLE_MEMBER_EVENT_TYPE: &str = "com.continuwuity.space.role.member"; - -/// Custom event type for per-room role requirements within a space. pub const SPACE_ROLE_ROOM_EVENT_TYPE: &str = "com.continuwuity.space.role.room"; +pub const SPACE_CASCADING_EVENT_TYPE: &str = "com.continuwuity.space.cascading"; -/// Content for `com.continuwuity.space.roles` (state key: "") -/// -/// Defines available roles for a Space. #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct SpaceRolesEventContent { pub roles: BTreeMap, } -/// A single role definition within a Space. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct RoleDefinition { pub description: String, - - /// If present, users with this role receive this power level in child - /// rooms. #[serde(skip_serializing_if = "Option::is_none")] pub power_level: Option, } -/// Content for `com.continuwuity.space.role.member` (state key: user ID) -/// -/// Assigns roles to a user within a Space. #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct SpaceRoleMemberEventContent { pub roles: Vec, } -/// Content for `com.continuwuity.space.role.room` (state key: room ID) -/// -/// Declares which roles a child room requires for access. #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct SpaceRoleRoomEventContent { pub required_roles: Vec, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct SpaceCascadingEventContent { + pub enabled: bool, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/service/rooms/roles/mod.rs b/src/service/rooms/roles/mod.rs index daf6511d..3f35a4cf 100644 --- a/src/service/rooms/roles/mod.rs +++ b/src/service/rooms/roles/mod.rs @@ -17,9 +17,9 @@ use conduwuit::{ }; use conduwuit_core::{ matrix::space_roles::{ - RoleDefinition, SPACE_ROLE_MEMBER_EVENT_TYPE, SPACE_ROLE_ROOM_EVENT_TYPE, - SPACE_ROLES_EVENT_TYPE, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, - SpaceRolesEventContent, + RoleDefinition, SPACE_CASCADING_EVENT_TYPE, SPACE_ROLE_MEMBER_EVENT_TYPE, + SPACE_ROLE_ROOM_EVENT_TYPE, SPACE_ROLES_EVENT_TYPE, SpaceCascadingEventContent, + SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, SpaceRolesEventContent, }, utils::{ future::TryExtExt, @@ -129,10 +129,6 @@ impl crate::Service for Service { } async fn worker(self: Arc) -> Result<()> { - if !self.is_enabled() { - return Ok(()); - } - info!("Rebuilding space roles cache from all known rooms"); let mut space_count: usize = 0; @@ -147,6 +143,11 @@ impl crate::Service for Service { for room_id in &room_ids { match self.services.state_accessor.get_room_type(room_id).await { | Ok(RoomType::Space) => { + // Check per-Space override — skip spaces where cascading is + // disabled + if !self.is_enabled_for_space(room_id).await { + continue; + } debug!(room_id = %room_id, "Populating space roles cache"); self.populate_space(room_id).await; space_count = space_count.saturating_add(1); @@ -162,22 +163,30 @@ impl crate::Service for Service { fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } } -/// Check whether space permission cascading is enabled in the server config. #[implement(Service)] pub fn is_enabled(&self) -> bool { self.server.config.space_permission_cascading } -/// Ensure a Space has the default admin/mod roles defined. -/// -/// Checks whether a `com.continuwuity.space.roles` state event exists in the -/// given space. If not, creates default roles (admin at PL 100, mod at PL 50) -/// and sends the state event as the server user. +#[implement(Service)] +pub async fn is_enabled_for_space(&self, space_id: &RoomId) -> bool { + let cascading_event_type = StateEventType::from(SPACE_CASCADING_EVENT_TYPE.to_owned()); + if let Ok(content) = self + .services + .state_accessor + .room_state_get_content::(space_id, &cascading_event_type, "") + .await + { + return content.enabled; + } + + self.server.config.space_permission_cascading +} + #[implement(Service)] pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { - if !self.is_enabled() { + if !self.is_enabled_for_space(space_id).await { return Ok(()); } - // Check if com.continuwuity.space.roles already exists let roles_event_type = StateEventType::from(SPACE_ROLES_EVENT_TYPE.to_owned()); if self .services @@ -189,7 +198,6 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { return Ok(()); } - // Create default roles let mut roles = BTreeMap::new(); roles.insert("admin".to_owned(), RoleDefinition { description: "Space administrator".to_owned(), @@ -226,19 +234,12 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { Ok(()) } -/// Populate the in-memory caches from state events for a single Space room. -/// -/// Reads `com.continuwuity.space.roles`, `com.continuwuity.space.role.member`, -/// `com.continuwuity.space.role.room`, and `m.space.child` state events and -/// indexes them for fast lookup. #[implement(Service)] pub async fn populate_space(&self, space_id: &RoomId) { - if !self.is_enabled() { + if !self.is_enabled_for_space(space_id).await { return; } - // Check cache capacity — if over limit, clear and let spaces repopulate on - // demand if self.roles.read().await.len() >= usize::try_from(self.server.config.space_roles_cache_capacity).unwrap_or(usize::MAX) { @@ -250,7 +251,6 @@ pub async fn populate_space(&self, space_id: &RoomId) { debug_warn!("Space roles cache exceeded capacity, cleared"); } - // 1. Read com.continuwuity.space.roles (state key: "") let roles_event_type = StateEventType::from(SPACE_ROLES_EVENT_TYPE.to_owned()); if let Ok(content) = self .services @@ -264,8 +264,6 @@ pub async fn populate_space(&self, space_id: &RoomId) { .insert(space_id.to_owned(), content.roles); } - // 2. Read all com.continuwuity.space.role.member state events (state key: user - // ID) let member_event_type = StateEventType::from(SPACE_ROLE_MEMBER_EVENT_TYPE.to_owned()); let shortstatehash = match self.services.state.get_room_shortstatehash(space_id).await { | Ok(hash) => hash, @@ -305,8 +303,6 @@ pub async fn populate_space(&self, space_id: &RoomId) { .await .insert(space_id.to_owned(), user_roles_map); - // 3. Read all com.continuwuity.space.role.room state events (state key: room - // ID) let room_event_type = StateEventType::from(SPACE_ROLE_ROOM_EVENT_TYPE.to_owned()); let mut room_reqs_map: HashMap> = HashMap::new(); @@ -338,7 +334,6 @@ pub async fn populate_space(&self, space_id: &RoomId) { .await .insert(space_id.to_owned(), room_reqs_map); - // 4. Read all m.space.child state events → build room_to_space reverse index let mut child_rooms: Vec = Vec::new(); self.services @@ -370,16 +365,12 @@ pub async fn populate_space(&self, space_id: &RoomId) { }) .await; - // Lock ordering: room_to_space before space_to_rooms. - // This order must be consistent to avoid deadlocks. { let mut room_to_space = self.room_to_space.write().await; - // Remove this space from all existing entries room_to_space.retain(|_, parents| { parents.remove(space_id); !parents.is_empty() }); - // Insert fresh children for child_room_id in &child_rooms { room_to_space .entry(child_room_id.clone()) @@ -388,7 +379,6 @@ pub async fn populate_space(&self, space_id: &RoomId) { } } - // Update forward index (after room_to_space to maintain lock ordering) { let mut space_to_rooms = self.space_to_rooms.write().await; space_to_rooms.insert(space_id.to_owned(), child_rooms.into_iter().collect()) @@ -396,7 +386,6 @@ pub async fn populate_space(&self, space_id: &RoomId) { } } -/// Compute the maximum power level from a user's assigned roles. #[must_use] pub fn compute_user_power_level( role_defs: &BTreeMap, @@ -408,7 +397,6 @@ pub fn compute_user_power_level( .max() } -/// Check if a set of assigned roles satisfies all requirements. #[must_use] pub fn roles_satisfy_requirements( required: &HashSet, @@ -417,8 +405,6 @@ pub fn roles_satisfy_requirements( required.iter().all(|r| assigned.contains(r)) } -/// Get a user's effective power level from Space roles. -/// Returns None if user has no roles with power levels. #[implement(Service)] pub async fn get_user_power_level(&self, space_id: &RoomId, user_id: &UserId) -> Option { let role_defs = { self.roles.read().await.get(space_id).cloned()? }; @@ -433,7 +419,6 @@ pub async fn get_user_power_level(&self, space_id: &RoomId, user_id: &UserId) -> compute_user_power_level(&role_defs, &user_assigned) } -/// Check if a user has all required roles for a room. #[implement(Service)] pub async fn user_qualifies_for_room( &self, @@ -467,25 +452,25 @@ pub async fn user_qualifies_for_room( roles_satisfy_requirements(&required, &user_assigned) } -/// Get the parent Spaces of a child room, if any. -/// -/// Only direct parent spaces are returned. Nested sub-space cascading -/// is not supported (see design doc requirement 6). #[implement(Service)] pub async fn get_parent_spaces(&self, room_id: &RoomId) -> Vec { - if !self.is_enabled() { - return Vec::new(); - } - - self.room_to_space + let all_parents: Vec = self + .room_to_space .read() .await .get(room_id) .map(|set| set.iter().cloned().collect()) - .unwrap_or_default() + .unwrap_or_default(); + + let mut enabled_parents = Vec::new(); + for parent in all_parents { + if self.is_enabled_for_space(&parent).await { + enabled_parents.push(parent); + } + } + enabled_parents } -/// Get all child rooms of a Space from the forward index. #[implement(Service)] pub async fn get_child_rooms(&self, space_id: &RoomId) -> Vec { self.space_to_rooms @@ -496,15 +481,12 @@ pub async fn get_child_rooms(&self, space_id: &RoomId) -> Vec { .unwrap_or_default() } -/// Synchronize power levels in a child room based on Space roles. -/// This overrides per-room power levels with Space-granted levels. #[implement(Service)] pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Result { - if !self.is_enabled() { + if !self.is_enabled_for_space(space_id).await { return Ok(()); } - // Check if server user is joined to the room let server_user = self.services.globals.server_user.as_ref(); if !self .services @@ -516,7 +498,6 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re return Ok(()); } - // 1. Get current power levels for the room let mut power_levels_content: RoomPowerLevelsEventContent = self .services .state_accessor @@ -524,7 +505,6 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re .await .unwrap_or_default(); - // 2. Get all members of the room let members: Vec = self .services .state_cache @@ -533,7 +513,6 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re .collect() .await; - // 3. For each member, check their space role power level let mut changed = false; for user_id in &members { if user_id == server_user { @@ -547,7 +526,6 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re .copied() .unwrap_or(power_levels_content.users_default); - // 4. If the space PL differs from room PL, update it if current_pl != space_pl_int { power_levels_content .users @@ -555,7 +533,6 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re changed = true; } } else { - // Check if any other parent space manages this user's PL let parents = self.get_parent_spaces(room_id).await; let mut managed_by_other = false; for parent in &parents { @@ -575,7 +552,6 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re } } - // 5. If changed, send updated power levels event if changed { let state_lock = self.services.state.mutex.lock(room_id).await; @@ -593,28 +569,20 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re Ok(()) } -/// Auto-join a user to all qualifying child rooms of a Space. -/// -/// Iterates over all child rooms in the `space_to_rooms` forward index, -/// checks whether the user qualifies via their assigned roles, and -/// force-joins them if they are not already a member. #[implement(Service)] pub async fn auto_join_qualifying_rooms(&self, space_id: &RoomId, user_id: &UserId) -> Result { - if !self.is_enabled() { + if !self.is_enabled_for_space(space_id).await { return Ok(()); } - // Skip server user — it doesn't need role-based auto-join let server_user = self.services.globals.server_user.as_ref(); if user_id == server_user { return Ok(()); } - // Get all child rooms via the space_to_rooms forward index let child_rooms = self.get_child_rooms(space_id).await; for child_room_id in &child_rooms { - // Skip if already joined if self .services .state_cache @@ -624,7 +592,6 @@ pub async fn auto_join_qualifying_rooms(&self, space_id: &RoomId, user_id: &User continue; } - // Check if user qualifies if !self .user_qualifies_for_room(space_id, child_room_id, user_id) .await @@ -632,7 +599,6 @@ pub async fn auto_join_qualifying_rooms(&self, space_id: &RoomId, user_id: &User continue; } - // Check if server user is joined to the child room if !self .services .state_cache @@ -645,7 +611,6 @@ pub async fn auto_join_qualifying_rooms(&self, space_id: &RoomId, user_id: &User let state_lock = self.services.state.mutex.lock(child_room_id).await; - // First invite the user (server user as sender) if let Err(e) = self .services .timeline @@ -664,7 +629,6 @@ pub async fn auto_join_qualifying_rooms(&self, space_id: &RoomId, user_id: &User continue; } - // Then join (user as sender) if let Err(e) = self .services .timeline @@ -686,12 +650,6 @@ pub async fn auto_join_qualifying_rooms(&self, space_id: &RoomId, user_id: &User Ok(()) } -/// Handle a state event change that may require enforcement. -/// -/// Spawns a background task (gated by the enforcement semaphore) to -/// repopulate the cache and trigger enforcement actions based on the -/// event type. Deduplicated per-space to avoid redundant work during -/// bulk operations. impl Service { pub fn handle_state_event_change( self: &Arc, @@ -699,14 +657,13 @@ impl Service { event_type: String, state_key: String, ) { - if !self.is_enabled() { - return; - } - let this = Arc::clone(self); self.server.runtime().spawn(async move { - // Deduplicate: if enforcement is already pending for this space, skip. - // The running task's populate_space will pick up the latest state. + if event_type != SPACE_CASCADING_EVENT_TYPE + && !this.is_enabled_for_space(&space_id).await + { + return; + } { let mut pending = this.pending_enforcement.write().await; if pending.contains(&space_id) { @@ -719,19 +676,16 @@ impl Service { return; }; - // Always repopulate cache first this.populate_space(&space_id).await; match event_type.as_str() { | SPACE_ROLES_EVENT_TYPE => { - // Role definitions changed — sync PLs in all child rooms let child_rooms = this.get_child_rooms(&space_id).await; for child_room_id in &child_rooms { if let Err(e) = this.sync_power_levels(&space_id, child_room_id).await { debug_warn!(room_id = %child_room_id, error = ?e, "Failed to sync power levels"); } } - // Revalidate all space members against all child rooms let space_members: Vec = this .services .state_cache @@ -748,7 +702,6 @@ impl Service { } }, | SPACE_ROLE_MEMBER_EVENT_TYPE => { - // User's roles changed — auto-join/kick + PL sync if let Ok(user_id) = UserId::parse(state_key.as_str()) { if let Err(e) = this.auto_join_qualifying_rooms(&space_id, user_id).await { @@ -759,7 +712,6 @@ impl Service { { debug_warn!(user_id = %user_id, error = ?e, "Space role auto-kick failed"); } - // Sync power levels in all child rooms let child_rooms = this.get_child_rooms(&space_id).await; for child_room_id in &child_rooms { if let Err(e) = this.sync_power_levels(&space_id, child_room_id).await @@ -770,7 +722,6 @@ impl Service { } }, | SPACE_ROLE_ROOM_EVENT_TYPE => { - // Room requirements changed — kick unqualified members if let Ok(target_room) = RoomId::parse(state_key.as_str()) { let members: Vec = this .services @@ -797,33 +748,24 @@ impl Service { | _ => {}, } - // Remove from pending set so future events can trigger enforcement this.pending_enforcement.write().await.remove(&space_id); }); } - /// Handle a new `m.space.child` event — update index and auto-join - /// qualifying members. - /// - /// If the child event's `via` field is empty the child is removed from - /// both the forward and reverse indexes. Otherwise the child is added - /// and all qualifying space members are auto-joined. pub fn handle_space_child_change( self: &Arc, space_id: OwnedRoomId, child_room_id: OwnedRoomId, ) { - if !self.is_enabled() { - return; - } - let this = Arc::clone(self); self.server.runtime().spawn(async move { + if !this.is_enabled_for_space(&space_id).await { + return; + } let Ok(_permit) = this.enforcement_semaphore.acquire().await else { return; }; - // Read the actual m.space.child state event to check via let child_event_type = StateEventType::SpaceChild; let is_removal = match this .services @@ -840,8 +782,6 @@ impl Service { }; if is_removal { - // Lock ordering: room_to_space before space_to_rooms. - // This order must be consistent to avoid deadlocks. let mut room_to_space = this.room_to_space.write().await; if let Some(parents) = room_to_space.get_mut(&child_room_id) { parents.remove(&space_id); @@ -849,7 +789,6 @@ impl Service { room_to_space.remove(&child_room_id); } } - // Remove child from space_to_rooms forward index let mut space_to_rooms = this.space_to_rooms.write().await; if let Some(children) = space_to_rooms.get_mut(&space_id) { children.remove(&child_room_id); @@ -857,7 +796,6 @@ impl Service { return; } - // Add child to reverse index this.room_to_space .write() .await @@ -865,7 +803,6 @@ impl Service { .or_default() .insert(space_id.clone()); - // Add child to forward index this.space_to_rooms .write() .await @@ -873,7 +810,6 @@ impl Service { .or_default() .insert(child_room_id.clone()); - // Check if server user is joined to the child room before enforcement let server_user = this.services.globals.server_user.as_ref(); if !this .services @@ -885,7 +821,6 @@ impl Service { return; } - // Auto-join qualifying space members to this specific child room let space_members: Vec = this .services .state_cache @@ -908,7 +843,6 @@ impl Service { let state_lock = this.services.state.mutex.lock(&child_room_id).await; - // Invite if let Err(e) = this .services .timeline @@ -929,7 +863,6 @@ impl Service { continue; } - // Join if let Err(e) = this .services .timeline @@ -954,28 +887,21 @@ impl Service { }); } - /// Handle a user joining a Space — auto-join them to qualifying child - /// rooms. - /// - /// Spawns a background task that auto-joins the user into every child - /// room they qualify for, then synchronizes their power levels across - /// all child rooms. pub fn handle_space_member_join( self: &Arc, space_id: OwnedRoomId, user_id: OwnedUserId, ) { - if !self.is_enabled() { - return; - } - - // Skip if the user is the server user if user_id == self.services.globals.server_user { return; } let this = Arc::clone(self); self.server.runtime().spawn(async move { + if !this.is_enabled_for_space(&space_id).await { + return; + } + let Ok(_permit) = this.enforcement_semaphore.acquire().await else { return; }; @@ -983,7 +909,6 @@ impl Service { if let Err(e) = this.auto_join_qualifying_rooms(&space_id, &user_id).await { debug_warn!(user_id = %user_id, error = ?e, "Auto-join on Space join failed"); } - // Also sync their power levels let child_rooms = this.get_child_rooms(&space_id).await; for child_room_id in &child_rooms { if let Err(e) = this.sync_power_levels(&space_id, child_room_id).await { @@ -994,14 +919,9 @@ impl Service { } } -/// Remove a user from all child rooms they no longer qualify for. -/// -/// Iterates over child rooms that have role requirements for the given -/// space, checks whether the user still qualifies, and kicks them with a -/// reason if they do not. #[implement(Service)] pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &UserId) -> Result { - if !self.is_enabled() { + if !self.is_enabled_for_space(space_id).await { return Ok(()); } @@ -1010,7 +930,6 @@ pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &Use return Ok(()); } - // Get child rooms that have requirements let child_rooms: Vec = self .room_requirements .read() @@ -1020,7 +939,6 @@ pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &Use .unwrap_or_default(); for child_room_id in &child_rooms { - // Check if server user is joined to the child room if !self .services .state_cache @@ -1030,7 +948,6 @@ pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &Use debug_warn!(room_id = %child_room_id, "Server user is not joined, skipping kick enforcement"); continue; } - // Skip if not joined if !self .services .state_cache @@ -1040,7 +957,6 @@ pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &Use continue; } - // Check if user still qualifies if self .user_qualifies_for_room(space_id, child_room_id, user_id) .await @@ -1048,7 +964,6 @@ pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &Use continue; } - // Get existing member event content for the kick let Ok(member_content) = self .services .state_accessor @@ -1061,7 +976,6 @@ pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &Use let state_lock = self.services.state.mutex.lock(child_room_id).await; - // Kick the user by setting membership to Leave with a reason if let Err(e) = self .services .timeline diff --git a/src/service/rooms/timeline/append.rs b/src/service/rooms/timeline/append.rs index 7dfb470f..ef537284 100644 --- a/src/service/rooms/timeline/append.rs +++ b/src/service/rooms/timeline/append.rs @@ -10,7 +10,8 @@ use conduwuit_core::{ event::Event, pdu::{PduCount, PduEvent, PduId, RawPduId}, space_roles::{ - SPACE_ROLE_MEMBER_EVENT_TYPE, SPACE_ROLE_ROOM_EVENT_TYPE, SPACE_ROLES_EVENT_TYPE, + SPACE_CASCADING_EVENT_TYPE, SPACE_ROLE_MEMBER_EVENT_TYPE, SPACE_ROLE_ROOM_EVENT_TYPE, + SPACE_ROLES_EVENT_TYPE, }, }, utils::{self, ReadyExt}, @@ -362,54 +363,49 @@ where | _ => {}, } - // Space permission cascading: react to role-related state events - if self.services.roles.is_enabled() { - if let Some(state_key) = pdu.state_key() { - let event_type_str = pdu.event_type().to_string(); - match event_type_str.as_str() { - | SPACE_ROLES_EVENT_TYPE - | SPACE_ROLE_MEMBER_EVENT_TYPE - | SPACE_ROLE_ROOM_EVENT_TYPE => { - if matches!( - self.services.state_accessor.get_room_type(room_id).await, - Ok(ruma::room::RoomType::Space) - ) { - let roles: Arc = - Arc::clone(&*self.services.roles); - roles.handle_state_event_change( - room_id.to_owned(), - event_type_str, - state_key.to_owned(), - ); - } - }, - | _ => {}, - } - } - // Handle m.space.child changes - if *pdu.kind() == TimelineEventType::SpaceChild { - if let Some(state_key) = pdu.state_key() { - if let Ok(child_room_id) = ruma::RoomId::parse(state_key) { + if let Some(state_key) = pdu.state_key() { + let event_type_str = pdu.event_type().to_string(); + match event_type_str.as_str() { + | SPACE_ROLES_EVENT_TYPE + | SPACE_ROLE_MEMBER_EVENT_TYPE + | SPACE_ROLE_ROOM_EVENT_TYPE + | SPACE_CASCADING_EVENT_TYPE => { + if matches!( + self.services.state_accessor.get_room_type(room_id).await, + Ok(ruma::room::RoomType::Space) + ) { let roles: Arc = Arc::clone(&*self.services.roles); - roles.handle_space_child_change(room_id.to_owned(), child_room_id.to_owned()); + roles.handle_state_event_change( + room_id.to_owned(), + event_type_str, + state_key.to_owned(), + ); } + }, + | _ => {}, + } + } + if *pdu.kind() == TimelineEventType::SpaceChild { + if let Some(state_key) = pdu.state_key() { + if let Ok(child_room_id) = ruma::RoomId::parse(state_key) { + let roles: Arc = Arc::clone(&*self.services.roles); + roles.handle_space_child_change(room_id.to_owned(), child_room_id.to_owned()); } } - // Handle m.room.member join in a Space — auto-join child rooms - if *pdu.kind() == TimelineEventType::RoomMember - && let Some(state_key) = pdu.state_key() - && let Ok(content) = - pdu.get_content::() - && content.membership == ruma::events::room::member::MembershipState::Join - && let Ok(user_id) = UserId::parse(state_key) - && matches!( - self.services.state_accessor.get_room_type(room_id).await, - Ok(ruma::room::RoomType::Space) - ) { - let roles: Arc = Arc::clone(&*self.services.roles); - roles.handle_space_member_join(room_id.to_owned(), user_id.to_owned()); - } + } + if *pdu.kind() == TimelineEventType::RoomMember + && let Some(state_key) = pdu.state_key() + && let Ok(content) = + pdu.get_content::() + && content.membership == ruma::events::room::member::MembershipState::Join + && let Ok(user_id) = UserId::parse(state_key) + && matches!( + self.services.state_accessor.get_room_type(room_id).await, + Ok(ruma::room::RoomType::Space) + ) { + let roles: Arc = Arc::clone(&*self.services.roles); + roles.handle_space_member_join(room_id.to_owned(), user_id.to_owned()); } // CONCERN: If we receive events with a relation out-of-order, we never write diff --git a/src/service/rooms/timeline/build.rs b/src/service/rooms/timeline/build.rs index 57ff7886..25ebbd6d 100644 --- a/src/service/rooms/timeline/build.rs +++ b/src/service/rooms/timeline/build.rs @@ -108,8 +108,7 @@ pub async fn build_and_append_pdu( BTreeMap, ); - if self.services.roles.is_enabled() - && *pdu.kind() == TimelineEventType::RoomPowerLevels + if *pdu.kind() == TimelineEventType::RoomPowerLevels && pdu.sender() != >::as_ref(&self.services.globals.server_user) {