diff --git a/docs/plans/2026-03-17-space-permission-cascading-design.md b/docs/plans/2026-03-17-space-permission-cascading-design.md index b8cc5375..bec7ec9a 100644 --- a/docs/plans/2026-03-17-space-permission-cascading-design.md +++ b/docs/plans/2026-03-17-space-permission-cascading-design.md @@ -40,7 +40,7 @@ space_permission_cascading = false All events live in the Space room. -### `m.space.roles` (state key: `""`) +### `com.continuwuity.space.roles` (state key: `""`) Defines the available roles for the Space. Two default roles (`admin` and `mod`) are created automatically when a Space is first encountered with the feature @@ -72,7 +72,7 @@ enabled. this power level in all child rooms. When a user holds multiple roles with power levels, the highest value wins. -### `m.space.role.member` (state key: user ID) +### `com.continuwuity.space.role.member` (state key: user ID) Assigns roles to a user within the Space. @@ -82,7 +82,7 @@ Assigns roles to a user within the Space. } ``` -### `m.space.role.room` (state key: room ID) +### `com.continuwuity.space.role.room` (state key: room ID) Declares which roles a child room requires. A user must hold **all** listed roles to access the room. @@ -101,15 +101,15 @@ All enforcement is skipped when `space_permission_cascading = false`. When a user attempts to join a room that is a direct child of a Space: -- Look up the room's `m.space.role.room` event in the parent Space. -- If the room has `required_roles`, check the user's `m.space.role.member`. +- Look up the room's `com.continuwuity.space.role.room` event in the parent Space. +- If the room has `required_roles`, check the user's `com.continuwuity.space.role.member`. - Reject the join if the user is missing any required role. ### 2. Power level override For every user in a child room of a Space: -- Look up their roles via `m.space.role.member` in the parent Space. +- Look up their roles via `com.continuwuity.space.role.member` in the parent Space. - For each role that has a `power_level`, take the highest value. - Override the user's power level in the child room's `m.room.power_levels`. - Reject attempts to manually set per-room power levels that conflict with @@ -117,7 +117,7 @@ For every user in a child room of a Space: ### 3. Role revocation -When an `m.space.role.member` event is updated and a role is removed: +When an `com.continuwuity.space.role.member` event is updated and a role is removed: - Identify all child rooms that require the removed role. - Auto-kick the user from rooms they no longer qualify for. @@ -125,14 +125,14 @@ When an `m.space.role.member` event is updated and a role is removed: ### 4. Room requirement change -When an `m.space.role.room` event is updated with new requirements: +When an `com.continuwuity.space.role.room` event is updated with new requirements: - Check all current members of the room. - Auto-kick members who do not hold all newly required roles. ### 5. Auto-join on role grant -When an `m.space.role.member` event is updated and a role is added: +When an `com.continuwuity.space.role.member` event is updated and a role is added: - Find all child rooms where the user now meets all required roles. - Auto-join the user to qualifying rooms they are not already in. @@ -157,9 +157,9 @@ existing `roomid_spacehierarchy_cache`. | Index | Source event | |------------------------------|------------------------| -| Space → roles defined | `m.space.roles` | -| Space → user → roles | `m.space.role.member` | -| Space → room → required roles| `m.space.role.room` | +| Space → roles defined | `com.continuwuity.space.roles` | +| Space → user → roles | `com.continuwuity.space.role.member` | +| Space → room → required roles| `com.continuwuity.space.role.room` | | Room → parent Space | `m.space.child` (reverse lookup) | The Space → child rooms mapping already exists. @@ -168,9 +168,9 @@ The Space → child rooms mapping already exists. | Event changed | Action | |----------------------------|-----------------------------------------------------| -| `m.space.roles` | Refresh role definitions, revalidate all members | -| `m.space.role.member` | Refresh user's roles, trigger auto-join/kick | -| `m.space.role.room` | Refresh room requirements, trigger auto-join/kick | +| `com.continuwuity.space.roles` | Refresh role definitions, revalidate all members | +| `com.continuwuity.space.role.member` | Refresh user's roles, trigger auto-join/kick | +| `com.continuwuity.space.role.room` | Refresh room requirements, trigger auto-join/kick | | `m.space.child` added | Index new child, auto-join qualifying members | | `m.space.child` removed | Remove from index (no auto-kick) | | Server startup | Full rebuild from state events | diff --git a/docs/plans/2026-03-17-space-permission-cascading.md b/docs/plans/2026-03-17-space-permission-cascading.md index 3d5c232b..2cc47dc4 100644 --- a/docs/plans/2026-03-17-space-permission-cascading.md +++ b/docs/plans/2026-03-17-space-permission-cascading.md @@ -4,7 +4,7 @@ **Goal:** Implement server-side Space permission cascading — power levels and role-based access flow from Spaces to their direct child rooms. -**Architecture:** Custom state events (`m.space.roles`, `m.space.role.member`, `m.space.role.room`) define roles in Space rooms. An in-memory cache indexes these for fast enforcement. The server intercepts joins, membership changes, and state event updates to enforce cascading. A server-wide config flag (`space_permission_cascading`) gates the entire feature. +**Architecture:** Custom state events (`com.continuwuity.space.roles`, `com.continuwuity.space.role.member`, `com.continuwuity.space.role.room`) define roles in Space rooms. An in-memory cache indexes these for fast enforcement. The server intercepts joins, membership changes, and state event updates to enforce cascading. A server-wide config flag (`space_permission_cascading`) gates the entire feature. **Tech Stack:** Rust, ruma (Matrix types), conduwuit service layer, clap (admin commands), serde, LruCache/HashMap, tokio async @@ -65,7 +65,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -/// Content for `m.space.roles` (state key: "") +/// Content for `com.continuwuity.space.roles` (state key: "") /// /// Defines available roles for a Space. #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -84,7 +84,7 @@ pub struct RoleDefinition { pub power_level: Option, } -/// Content for `m.space.role.member` (state key: user ID) +/// 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)] @@ -92,7 +92,7 @@ pub struct SpaceRoleMemberEventContent { pub roles: Vec, } -/// Content for `m.space.role.room` (state key: room ID) +/// 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)] @@ -340,11 +340,11 @@ pub async fn populate_space(&self, space_id: &RoomId) -> Result { return Ok(()); } - // Load role definitions from m.space.roles + // Load role definitions from com.continuwuity.space.roles let roles_content: Option = self .services .state_accessor - .room_state_get_content(space_id, &StateEventType::from("m.space.roles"), "") + .room_state_get_content(space_id, &StateEventType::from("com.continuwuity.space.roles"), "") .await .ok(); @@ -355,9 +355,9 @@ pub async fn populate_space(&self, space_id: &RoomId) -> Result { .insert(space_id.to_owned(), content.roles); } - // Load user role assignments from m.space.role.member state events - // Iterate all state events of type m.space.role.member - // Load room requirements from m.space.role.room state events + // Load user role assignments from com.continuwuity.space.role.member state events + // Iterate all state events of type com.continuwuity.space.role.member + // Load room requirements from com.continuwuity.space.role.room state events // Build room_to_space reverse index from m.space.child events Ok(()) @@ -446,7 +446,7 @@ git commit -m "feat(spaces): add cache population and lookup methods for space r **Step 1: Add default role creation** ```rust -/// Ensure a Space has the default admin/mod roles. Sends an m.space.roles +/// Ensure a Space has the default admin/mod roles. Sends an com.continuwuity.space.roles /// state event if none exists. #[implement(Service)] pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { @@ -454,11 +454,11 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { return Ok(()); } - // Check if m.space.roles already exists + // Check if com.continuwuity.space.roles already exists let existing: Result = self .services .state_accessor - .room_state_get_content(space_id, &StateEventType::from("m.space.roles"), "") + .room_state_get_content(space_id, &StateEventType::from("com.continuwuity.space.roles"), "") .await; if existing.is_ok() { @@ -750,13 +750,13 @@ In the `append_pdu()` function, after the event is successfully appended, add a if self.services.roles.is_enabled() { if let Some(state_key) = &pdu.state_key { match pdu.event_type() { - // m.space.roles changed -> revalidate all members - t if t == "m.space.roles" => { + // com.continuwuity.space.roles changed -> revalidate all members + t if t == "com.continuwuity.space.roles" => { self.services.roles.populate_space(&pdu.room_id).await?; // Revalidate all members against all child rooms } - // m.space.role.member changed -> auto-join/kick that user - t if t == "m.space.role.member" => { + // com.continuwuity.space.role.member changed -> auto-join/kick that user + t if t == "com.continuwuity.space.role.member" => { if let Ok(user_id) = UserId::parse(state_key) { self.services.roles.populate_space(&pdu.room_id).await?; self.services @@ -770,8 +770,8 @@ if self.services.roles.is_enabled() { // Sync power levels in all child rooms for this user } } - // m.space.role.room changed -> auto-join/kick for that room - t if t == "m.space.role.room" => { + // com.continuwuity.space.role.room changed -> auto-join/kick for that room + t if t == "com.continuwuity.space.role.room" => { if let Ok(room_id) = RoomId::parse(state_key) { self.services.roles.populate_space(&pdu.room_id).await?; // Check all members of room_id against new requirements @@ -1052,7 +1052,7 @@ pub(super) async fn list(&self, space: OwnedRoomOrAliasId) -> Result { .services .rooms .state_accessor - .room_state_get_content(&space_id, &StateEventType::from("m.space.roles"), "") + .room_state_get_content(&space_id, &StateEventType::from("com.continuwuity.space.roles"), "") .await .unwrap_or_default(); diff --git a/src/admin/space/roles.rs b/src/admin/space/roles.rs index bf12c4e2..f5df1f9d 100644 --- a/src/admin/space/roles.rs +++ b/src/admin/space/roles.rs @@ -29,7 +29,9 @@ macro_rules! custom_state_pdu { PduBuilder { event_type: $event_type.to_owned().into(), content: to_raw_value($content) - .expect("Failed to serialize custom state event content"), + .map_err(|e| conduwuit::Error::Err(format!( + "Failed to serialize custom state event content: {e}" + ).into()))?, state_key: Some($state_key.to_owned().into()), ..PduBuilder::default() } @@ -103,7 +105,7 @@ async fn list(&self, space: OwnedRoomOrAliasId) -> Result { ) { return self.write_str("Error: The specified room is not a Space.").await; } - let roles_event_type = StateEventType::from("m.space.roles".to_owned()); + let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned()); let content: SpaceRolesEventContent = self .services @@ -146,7 +148,7 @@ async fn add( ) { return self.write_str("Error: The specified room is not a Space.").await; } - let roles_event_type = StateEventType::from("m.space.roles".to_owned()); + let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned()); let mut content: SpaceRolesEventContent = self .services @@ -172,7 +174,7 @@ async fn add( .rooms .timeline .build_and_append_pdu( - custom_state_pdu!("m.space.roles", "", &content), + custom_state_pdu!("com.continuwuity.space.roles", "", &content), server_user, Some(&space_id), &state_lock, @@ -193,7 +195,7 @@ async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result { ) { return self.write_str("Error: The specified room is not a Space.").await; } - let roles_event_type = StateEventType::from("m.space.roles".to_owned()); + let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned()); let mut content: SpaceRolesEventContent = self .services @@ -214,7 +216,7 @@ async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result { .rooms .timeline .build_and_append_pdu( - custom_state_pdu!("m.space.roles", "", &content), + custom_state_pdu!("com.continuwuity.space.roles", "", &content), server_user, Some(&space_id), &state_lock, @@ -240,7 +242,7 @@ async fn assign( ) { return self.write_str("Error: The specified room is not a Space.").await; } - let member_event_type = StateEventType::from("m.space.role.member".to_owned()); + let member_event_type = StateEventType::from("com.continuwuity.space.role.member".to_owned()); let mut content: SpaceRoleMemberEventContent = self .services @@ -263,7 +265,7 @@ async fn assign( .rooms .timeline .build_and_append_pdu( - custom_state_pdu!("m.space.role.member", user_id.as_str(), &content), + custom_state_pdu!("com.continuwuity.space.role.member", user_id.as_str(), &content), server_user, Some(&space_id), &state_lock, @@ -291,7 +293,7 @@ async fn revoke( ) { return self.write_str("Error: The specified room is not a Space.").await; } - let member_event_type = StateEventType::from("m.space.role.member".to_owned()); + let member_event_type = StateEventType::from("com.continuwuity.space.role.member".to_owned()); let mut content: SpaceRoleMemberEventContent = self .services @@ -315,7 +317,7 @@ async fn revoke( .rooms .timeline .build_and_append_pdu( - custom_state_pdu!("m.space.role.member", user_id.as_str(), &content), + custom_state_pdu!("com.continuwuity.space.role.member", user_id.as_str(), &content), server_user, Some(&space_id), &state_lock, @@ -343,7 +345,7 @@ async fn require( ) { return self.write_str("Error: The specified room is not a Space.").await; } - let room_event_type = StateEventType::from("m.space.role.room".to_owned()); + let room_event_type = StateEventType::from("com.continuwuity.space.role.room".to_owned()); let mut content: SpaceRoleRoomEventContent = self .services @@ -366,7 +368,7 @@ async fn require( .rooms .timeline .build_and_append_pdu( - custom_state_pdu!("m.space.role.room", room_id.as_str(), &content), + custom_state_pdu!("com.continuwuity.space.role.room", room_id.as_str(), &content), server_user, Some(&space_id), &state_lock, @@ -394,7 +396,7 @@ async fn unrequire( ) { return self.write_str("Error: The specified room is not a Space.").await; } - let room_event_type = StateEventType::from("m.space.role.room".to_owned()); + let room_event_type = StateEventType::from("com.continuwuity.space.role.room".to_owned()); let mut content: SpaceRoleRoomEventContent = self .services @@ -418,7 +420,7 @@ async fn unrequire( .rooms .timeline .build_and_append_pdu( - custom_state_pdu!("m.space.role.room", room_id.as_str(), &content), + custom_state_pdu!("com.continuwuity.space.role.room", room_id.as_str(), &content), server_user, Some(&space_id), &state_lock, diff --git a/src/api/client/membership/join.rs b/src/api/client/membership/join.rs index f9a5b421..3d5ed9b6 100644 --- a/src/api/client/membership/join.rs +++ b/src/api/client/membership/join.rs @@ -348,14 +348,23 @@ 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() { - if let Some(parent_space) = services.rooms.roles.get_parent_space(room_id).await { - if !services - .rooms - .roles - .user_qualifies_for_room(&parent_space, room_id, sender_user) - .await - { + let parent_spaces = services.rooms.roles.get_parent_spaces(room_id).await; + if !parent_spaces.is_empty() { + let mut qualifies_in_any = false; + for parent_space in &parent_spaces { + if services + .rooms + .roles + .user_qualifies_for_room(parent_space, room_id, sender_user) + .await + { + qualifies_in_any = true; + break; + } + } + if !qualifies_in_any { return Err!(Request(Forbidden( "You do not have the required Space roles to join this room" ))); diff --git a/src/core/matrix/space_roles.rs b/src/core/matrix/space_roles.rs index aaadcf9f..420f7cb9 100644 --- a/src/core/matrix/space_roles.rs +++ b/src/core/matrix/space_roles.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -/// Content for `m.space.roles` (state key: "") +/// Content for `com.continuwuity.space.roles` (state key: "") /// /// Defines available roles for a Space. #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -26,7 +26,7 @@ pub struct RoleDefinition { pub power_level: Option, } -/// Content for `m.space.role.member` (state key: user ID) +/// 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)] @@ -34,7 +34,7 @@ pub struct SpaceRoleMemberEventContent { pub roles: Vec, } -/// Content for `m.space.role.room` (state key: room ID) +/// 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)] diff --git a/src/service/rooms/roles/cache_tests.rs b/src/service/rooms/roles/cache_tests.rs index 53f152ca..2ff2592e 100644 --- a/src/service/rooms/roles/cache_tests.rs +++ b/src/service/rooms/roles/cache_tests.rs @@ -4,6 +4,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use conduwuit_core::matrix::space_roles::RoleDefinition; use ruma::{room_id, user_id, OwnedRoomId, OwnedUserId}; +use std::collections::HashSet as StdHashSet; use super::tests::{make_requirements, make_roles, make_user_roles}; @@ -12,7 +13,7 @@ struct MockCache { roles: HashMap>, user_roles: HashMap>>, room_requirements: HashMap>>, - room_to_space: HashMap, + room_to_space: HashMap>, } impl MockCache { @@ -30,7 +31,10 @@ impl MockCache { } fn add_child(&mut self, space: &OwnedRoomId, child: OwnedRoomId) { - self.room_to_space.insert(child, space.clone()); + self.room_to_space + .entry(child) + .or_default() + .insert(space.clone()); } fn assign_role(&mut self, space: &OwnedRoomId, user: OwnedUserId, role: String) { @@ -194,11 +198,10 @@ fn cache_reverse_lookup_consistency() { cache.add_child(&space, child1.clone()); cache.add_child(&space, child2.clone()); - assert_eq!(cache.room_to_space.get(&child1), Some(&space)); - assert_eq!(cache.room_to_space.get(&child2), Some(&space)); - assert_eq!( - cache.room_to_space.get(room_id!("!unknown:example.com")), - None + assert!(cache.room_to_space.get(&child1).unwrap().contains(&space)); + assert!(cache.room_to_space.get(&child2).unwrap().contains(&space)); + assert!( + cache.room_to_space.get(room_id!("!unknown:example.com")).is_none() ); } diff --git a/src/service/rooms/roles/mod.rs b/src/service/rooms/roles/mod.rs index 1dd40c4c..55b29311 100644 --- a/src/service/rooms/roles/mod.rs +++ b/src/service/rooms/roles/mod.rs @@ -19,6 +19,7 @@ use conduwuit::{ }; use serde_json::value::to_raw_value; use conduwuit_core::{ + Err, matrix::space_roles::{ RoleDefinition, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, SpaceRolesEventContent, @@ -53,8 +54,10 @@ pub struct Service { pub user_roles: RwLock>>>, /// Space ID -> child room ID -> required role names pub room_requirements: RwLock>>>, - /// Child room ID -> parent space ID - pub room_to_space: RwLock>, + /// Child room ID -> parent space IDs (a room can be in multiple spaces) + pub room_to_space: RwLock>>, + /// Semaphore to limit concurrent enforcement tasks + pub enforcement_semaphore: tokio::sync::Semaphore, } struct Services { @@ -87,6 +90,7 @@ impl crate::Service for Service { user_roles: RwLock::new(HashMap::new()), room_requirements: RwLock::new(HashMap::new()), room_to_space: RwLock::new(HashMap::new()), + enforcement_semaphore: tokio::sync::Semaphore::new(4), })) } @@ -159,7 +163,7 @@ pub fn is_enabled(&self) -> bool { self.server.config.space_permission_cascading /// Ensure a Space has the default admin/mod roles defined. /// -/// Checks whether an `m.space.roles` state event exists in the given space. +/// 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)] @@ -168,8 +172,8 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { return Ok(()); } - // Check if m.space.roles already exists - let roles_event_type = StateEventType::from("m.space.roles".to_owned()); + // Check if com.continuwuity.space.roles already exists + let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned()); if self .services .state_accessor @@ -203,9 +207,9 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { let state_lock = self.services.state.mutex.lock(space_id).await; let pdu = PduBuilder { - event_type: ruma::events::TimelineEventType::from("m.space.roles".to_owned()), + event_type: ruma::events::TimelineEventType::from("com.continuwuity.space.roles".to_owned()), content: to_raw_value(&content) - .expect("Failed to serialize SpaceRolesEventContent"), + .map_err(|e| conduwuit::Error::Err(format!("Failed to serialize SpaceRolesEventContent: {e}").into()))?, state_key: Some(String::new().into()), ..PduBuilder::default() }; @@ -215,14 +219,14 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { .build_and_append_pdu(pdu, sender, Some(space_id), &state_lock) .await?; - debug!("Sent default m.space.roles event for {space_id}"); + debug!("Sent default com.continuwuity.space.roles event for {space_id}"); Ok(()) } /// Populate the in-memory caches from state events for a single Space room. /// -/// Reads `m.space.roles`, `m.space.role.member`, `m.space.role.room`, and +/// 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) { @@ -230,8 +234,8 @@ pub async fn populate_space(&self, space_id: &RoomId) { return; } - // 1. Read m.space.roles (state key: "") - let roles_event_type = StateEventType::from("m.space.roles".to_owned()); + // 1. Read com.continuwuity.space.roles (state key: "") + let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned()); if let Ok(content) = self .services .state_accessor @@ -244,8 +248,8 @@ pub async fn populate_space(&self, space_id: &RoomId) { .insert(space_id.to_owned(), content.roles); } - // 2. Read all m.space.role.member state events (state key: user ID) - let member_event_type = StateEventType::from("m.space.role.member".to_owned()); + // 2. Read all com.continuwuity.space.role.member state events (state key: user ID) + let member_event_type = StateEventType::from("com.continuwuity.space.role.member".to_owned()); if let Ok(shortstatehash) = self .services .state @@ -282,8 +286,8 @@ pub async fn populate_space(&self, space_id: &RoomId) { .await .insert(space_id.to_owned(), user_roles_map); - // 3. Read all m.space.role.room state events (state key: room ID) - let room_event_type = StateEventType::from("m.space.role.room".to_owned()); + // 3. Read all com.continuwuity.space.role.room state events (state key: room ID) + let room_event_type = StateEventType::from("com.continuwuity.space.role.room".to_owned()); let mut room_reqs_map: HashMap> = HashMap::new(); self.services @@ -350,7 +354,10 @@ pub async fn populate_space(&self, space_id: &RoomId) { { let mut room_to_space = self.room_to_space.write().await; for child_room_id in child_rooms { - room_to_space.insert(child_room_id, space_id.to_owned()); + room_to_space + .entry(child_room_id) + .or_default() + .insert(space_id.to_owned()); } } } @@ -412,14 +419,19 @@ pub async fn user_qualifies_for_room( required.iter().all(|r| user_assigned.contains(r)) } -/// Get the parent Space of a child room, if any. +/// Get the parent Spaces of a child room, if any. #[implement(Service)] -pub async fn get_parent_space(&self, room_id: &RoomId) -> Option { +pub async fn get_parent_spaces(&self, room_id: &RoomId) -> Vec { if !self.is_enabled() { - return None; + return Vec::new(); } - self.room_to_space.read().await.get(room_id).cloned() + self.room_to_space + .read() + .await + .get(room_id) + .map(|set| set.iter().cloned().collect()) + .unwrap_or_default() } /// Synchronize power levels in a child room based on Space roles. @@ -430,6 +442,20 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re return Ok(()); } + // Check if server user is joined to the room + let server_user = self.services.globals.server_user.as_ref(); + if !self + .services + .state_cache + .is_joined(server_user, room_id) + .await + { + debug_warn!( + "Server user is not joined to {room_id}, skipping PL sync" + ); + return Ok(()); + } + // 1. Get current power levels for the room let mut power_levels_content: RoomPowerLevelsEventContent = self .services @@ -479,7 +505,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; - let server_user = self.services.globals.server_user.as_ref(); self.services .timeline @@ -517,7 +542,7 @@ pub async fn auto_join_qualifying_rooms( .read() .await .iter() - .filter(|(_, parent)| **parent == *space_id) + .filter(|(_, parents)| parents.contains(space_id)) .map(|(child, _)| child.clone()) .collect(); @@ -542,6 +567,19 @@ pub async fn auto_join_qualifying_rooms( continue; } + // Check if server user is joined to the child room + if !self + .services + .state_cache + .is_joined(server_user, child_room_id) + .await + { + debug_warn!( + "Server user is not joined to {child_room_id}, skipping auto-join" + ); + continue; + } + let state_lock = self.services.state.mutex.lock(child_room_id).await; // First invite the user (server user as sender) @@ -603,18 +641,20 @@ impl Service { let this = Arc::clone(self); self.server.runtime().spawn(async move { + let _permit = this.enforcement_semaphore.acquire().await; + // Always repopulate cache first this.populate_space(&space_id).await; match event_type.as_str() { - | "m.space.roles" => { + | "com.continuwuity.space.roles" => { // Role definitions changed — sync PLs in all child rooms let child_rooms: Vec = this .room_to_space .read() .await .iter() - .filter(|(_, parent)| **parent == *space_id) + .filter(|(_, parents)| parents.contains(&space_id)) .map(|(child, _)| child.clone()) .collect(); for child_room_id in &child_rooms { @@ -625,7 +665,7 @@ impl Service { } } }, - | "m.space.role.member" => { + | "com.continuwuity.space.role.member" => { // User's roles changed — auto-join/kick + PL sync if let Ok(user_id) = UserId::parse(state_key.as_str()) { if let Err(e) = @@ -648,7 +688,7 @@ impl Service { .read() .await .iter() - .filter(|(_, parent)| **parent == *space_id) + .filter(|(_, parents)| parents.contains(&space_id)) .map(|(child, _)| child.clone()) .collect(); for child_room_id in &child_rooms { @@ -662,7 +702,7 @@ impl Service { } } }, - | "m.space.role.room" => { + | "com.continuwuity.space.role.room" => { // Room requirements changed — kick unqualified members if let Ok(target_room) = RoomId::parse(state_key.as_str()) { let members: Vec = this @@ -699,7 +739,7 @@ impl Service { } /// Handle a new m.space.child event — update index and auto-join qualifying - /// members. + /// members, or remove child from index if `via` is empty. pub fn handle_space_child_change( self: &Arc, space_id: OwnedRoomId, @@ -711,13 +751,60 @@ impl Service { let this = Arc::clone(self); self.server.runtime().spawn(async move { - // Update the reverse index + let _permit = this.enforcement_semaphore.acquire().await; + + // Read the actual m.space.child state event to check via + let child_event_type = StateEventType::SpaceChild; + let is_removal = match this + .services + .state_accessor + .room_state_get_content::( + &space_id, + &child_event_type, + child_room_id.as_str(), + ) + .await + { + | Ok(content) => content.via.is_empty(), + | Err(_) => true, // If we can't read it, treat as removal + }; + + if is_removal { + // Remove child from room_to_space reverse index + 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); + if parents.is_empty() { + room_to_space.remove(&child_room_id); + } + } + return; + } + + // Add child to reverse index this.room_to_space .write() .await - .insert(child_room_id.clone(), space_id.clone()); + .entry(child_room_id.clone()) + .or_default() + .insert(space_id.clone()); - // Auto-join all qualifying space members + // 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 + .state_cache + .is_joined(server_user, &child_room_id) + .await + { + debug_warn!( + "Server user is not joined to {child_room_id}, \ + skipping auto-join enforcement for new child" + ); + return; + } + + // Auto-join qualifying space members to this specific child room let space_members: Vec = this .services .state_cache @@ -737,11 +824,51 @@ impl Service { .is_joined(member, &child_room_id) .await { - if let Err(e) = - this.auto_join_qualifying_rooms(&space_id, member).await + let state_lock = + this.services.state.mutex.lock(&child_room_id).await; + + // Invite + if let Err(e) = this + .services + .timeline + .build_and_append_pdu( + PduBuilder::state( + member.to_string(), + &RoomMemberEventContent::new( + MembershipState::Invite, + ), + ), + server_user, + Some(&child_room_id), + &state_lock, + ) + .await { debug_warn!( - "Auto-join failed for {member} on new child room: {e}" + "Failed to invite {member} to {child_room_id}: {e}" + ); + continue; + } + + // Join + if let Err(e) = this + .services + .timeline + .build_and_append_pdu( + PduBuilder::state( + member.to_string(), + &RoomMemberEventContent::new( + MembershipState::Join, + ), + ), + member, + Some(&child_room_id), + &state_lock, + ) + .await + { + warn!( + "Failed to auto-join {member} to {child_room_id}: {e}" ); } } @@ -762,6 +889,8 @@ impl Service { let this = Arc::clone(self); self.server.runtime().spawn(async move { + let _permit = this.enforcement_semaphore.acquire().await; + if let Err(e) = this.auto_join_qualifying_rooms(&space_id, &user_id).await { debug_warn!("Auto-join on Space join failed for {user_id}: {e}"); } @@ -771,7 +900,7 @@ impl Service { .read() .await .iter() - .filter(|(_, parent)| **parent == *space_id) + .filter(|(_, parents)| parents.contains(&space_id)) .map(|(child, _)| child.clone()) .collect(); for child_room_id in &child_rooms { @@ -814,6 +943,18 @@ pub async fn kick_unqualified_from_rooms( let server_user = self.services.globals.server_user.as_ref(); for child_room_id in &child_rooms { + // Check if server user is joined to the child room + if !self + .services + .state_cache + .is_joined(server_user, child_room_id) + .await + { + debug_warn!( + "Server user is not joined to {child_room_id}, skipping kick enforcement" + ); + continue; + } // Skip if not joined if !self .services diff --git a/src/service/rooms/roles/tests.rs b/src/service/rooms/roles/tests.rs index 0af8f742..471ec581 100644 --- a/src/service/rooms/roles/tests.rs +++ b/src/service/rooms/roles/tests.rs @@ -134,12 +134,15 @@ fn qualifies_empty_requirements_empty_roles() { #[test] fn room_to_space_lookup() { - let mut room_to_space: HashMap = HashMap::new(); + let mut room_to_space: HashMap> = HashMap::new(); let space = room_id!("!space:example.com").to_owned(); let child = room_id!("!child:example.com").to_owned(); - room_to_space.insert(child.clone(), space.clone()); - assert_eq!(room_to_space.get(&child), Some(&space)); - assert_eq!(room_to_space.get(room_id!("!unknown:example.com")), None); + room_to_space + .entry(child.clone()) + .or_default() + .insert(space.clone()); + assert!(room_to_space.get(&child).unwrap().contains(&space)); + assert!(room_to_space.get(room_id!("!unknown:example.com")).is_none()); } #[test] diff --git a/src/service/rooms/timeline/append.rs b/src/service/rooms/timeline/append.rs index 74e3d251..20aaa350 100644 --- a/src/service/rooms/timeline/append.rs +++ b/src/service/rooms/timeline/append.rs @@ -364,7 +364,9 @@ where if let Some(state_key) = pdu.state_key() { let event_type_str = pdu.event_type().to_string(); match event_type_str.as_str() { - | "m.space.roles" | "m.space.role.member" | "m.space.role.room" => { + | "com.continuwuity.space.roles" + | "com.continuwuity.space.role.member" + | "com.continuwuity.space.role.room" => { let roles: Arc = Arc::clone(&*self.services.roles); roles.handle_state_event_change( diff --git a/src/service/rooms/timeline/build.rs b/src/service/rooms/timeline/build.rs index 9af5d5ce..8fffe766 100644 --- a/src/service/rooms/timeline/build.rs +++ b/src/service/rooms/timeline/build.rs @@ -8,7 +8,7 @@ use conduwuit_core::{ }; use futures::{FutureExt, StreamExt}; use ruma::{ - OwnedEventId, OwnedServerName, RoomId, RoomVersionId, UserId, + OwnedEventId, OwnedServerName, OwnedUserId, RoomId, RoomVersionId, UserId, events::{ TimelineEventType, room::{ @@ -98,17 +98,20 @@ pub async fn build_and_append_pdu( } } // Space permission cascading: reject power level changes that conflict - // with Space-granted levels + // with Space-granted levels (exempt the server user so sync_power_levels works) if self.services.roles.is_enabled() && *pdu.kind() == TimelineEventType::RoomPowerLevels + && pdu.sender() != >::as_ref(&self.services.globals.server_user) { - if let Some(parent_space) = self.services.roles.get_parent_space(&room_id).await { - use ruma::events::room::power_levels::RoomPowerLevelsEventContent; + use ruma::events::room::power_levels::RoomPowerLevelsEventContent; - if let Ok(proposed) = pdu.get_content::() { + let parent_spaces = self.services.roles.get_parent_spaces(&room_id).await; + if let Ok(proposed) = pdu.get_content::() { + for parent_space in &parent_spaces { + // Check proposed users don't conflict with space-granted PLs for (user_id, proposed_pl) in &proposed.users { if let Some(space_pl) = - self.services.roles.get_user_power_level(&parent_space, user_id).await + self.services.roles.get_user_power_level(parent_space, user_id).await { if i64::from(*proposed_pl) != space_pl { return Err!(Request(Forbidden( @@ -117,6 +120,36 @@ pub async fn build_and_append_pdu( } } } + + // Also check that space-managed users aren't omitted + let user_roles_guard = self.services.roles.user_roles.read().await; + if let Some(space_users) = user_roles_guard.get(parent_space) { + let roles_guard = self.services.roles.roles.read().await; + if let Some(role_defs) = roles_guard.get(parent_space) { + for (user_id, assigned_roles) in space_users { + let space_pl = assigned_roles + .iter() + .filter_map(|r| role_defs.get(r)?.power_level) + .max(); + if let Some(space_pl) = space_pl { + // This user should be in the proposed event + match proposed.users.get(user_id) { + | None => { + return Err!(Request(Forbidden( + "Cannot omit a user whose power level is managed by Space roles" + ))); + }, + | Some(pl) if i64::from(*pl) != space_pl => { + return Err!(Request(Forbidden( + "Cannot change power level that is set by Space roles" + ))); + }, + | _ => {}, + } + } + } + } + } } } } diff --git a/src/service/rooms/timeline/mod.rs b/src/service/rooms/timeline/mod.rs index 8930e5c6..e97f703e 100644 --- a/src/service/rooms/timeline/mod.rs +++ b/src/service/rooms/timeline/mod.rs @@ -113,7 +113,7 @@ impl crate::Service for Service { threads: args.depend::("rooms::threads"), search: args.depend::("rooms::search"), spaces: args.depend::("rooms::spaces"), - roles: args.depend::("rooms::roles"), + roles: args.depend::("rooms::roles"), event_handler: args .depend::("rooms::event_handler"), },