From f7cfc9d35da45b39422284eb2a53da34861e972a Mon Sep 17 00:00:00 2001 From: ember33 Date: Wed, 18 Mar 2026 09:58:12 +0100 Subject: [PATCH] feat(spaces): reject power level changes that conflict with space roles Checks proposed m.room.power_levels events against Space-granted power levels. Rejects if any user's proposed PL is below their Space role PL. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/service/rooms/timeline/append.rs | 8 ++++---- src/service/rooms/timeline/build.rs | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/service/rooms/timeline/append.rs b/src/service/rooms/timeline/append.rs index e657575d..3b07bdea 100644 --- a/src/service/rooms/timeline/append.rs +++ b/src/service/rooms/timeline/append.rs @@ -127,7 +127,7 @@ where // Make unsigned fields correct. This is not properly documented in the spec, // but state events need to have previous content in the unsigned field, so // clients can easily interpret things like membership changes - if let Some(_state_key) = pdu.state_key() { + if let Some(state_key) = pdu.state_key() { if let CanonicalJsonValue::Object(unsigned) = pdu_json .entry("unsigned".to_owned()) .or_insert_with(|| CanonicalJsonValue::Object(BTreeMap::default())) @@ -226,7 +226,7 @@ where let mut highlights = Vec::with_capacity(push_target.len().saturating_add(1)); if *pdu.kind() == TimelineEventType::RoomMember { - if let Some(_state_key) = pdu.state_key() { + if let Some(state_key) = pdu.state_key() { let target_user_id = UserId::parse(state_key)?; if self.services.users.is_active_local(target_user_id).await { @@ -327,7 +327,7 @@ where } }, | TimelineEventType::SpaceChild => - if let Some(_state_key) = pdu.state_key() { + if let Some(state_key) = pdu.state_key() { self.services .spaces .roomid_spacehierarchy_cache @@ -336,7 +336,7 @@ where .remove(room_id); }, | TimelineEventType::RoomMember => { - if let Some(_state_key) = pdu.state_key() { + if let Some(state_key) = pdu.state_key() { // if the state_key fails let target_user_id = UserId::parse(state_key).expect("This state_key was previously validated"); diff --git a/src/service/rooms/timeline/build.rs b/src/service/rooms/timeline/build.rs index 51162de9..15a44d90 100644 --- a/src/service/rooms/timeline/build.rs +++ b/src/service/rooms/timeline/build.rs @@ -97,6 +97,30 @@ pub async fn build_and_append_pdu( ))); } } + // Space permission cascading: reject power level changes that conflict + // with Space-granted levels + if self.services.roles.is_enabled() + && *pdu.kind() == TimelineEventType::RoomPowerLevels + { + if let Some(parent_space) = self.services.roles.get_parent_space(&room_id).await { + use ruma::events::room::power_levels::RoomPowerLevelsEventContent; + + if let Ok(proposed) = pdu.get_content::() { + 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 + { + if i64::from(*proposed_pl) < space_pl { + return Err!(Request(Forbidden( + "Cannot set power level below Space-granted level" + ))); + } + } + } + } + } + } + if *pdu.kind() == TimelineEventType::RoomCreate { trace!("Creating shortroomid for {room_id}"); self.services