fix(spaces): cascade role removal, validate role names, gate on Space type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ember33 2026-03-18 11:19:44 +01:00
parent 898f4a470c
commit aa610b055a
3 changed files with 147 additions and 11 deletions

View file

@ -1,5 +1,5 @@
use clap::Subcommand;
use conduwuit::{Err, Result};
use conduwuit::{Err, Event, Result};
use conduwuit_core::matrix::space_roles::{
RoleDefinition, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent,
SpaceRolesEventContent, SPACE_ROLES_EVENT_TYPE, SPACE_ROLE_MEMBER_EVENT_TYPE,
@ -224,6 +224,89 @@ async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result {
)
.await?;
// Cascade: remove the role from all users' member events
let member_event_type = StateEventType::from(SPACE_ROLE_MEMBER_EVENT_TYPE.to_owned());
if let Ok(shortstatehash) = self
.services
.rooms
.state
.get_room_shortstatehash(&space_id)
.await
{
use futures::StreamExt;
let user_entries: Vec<(_, ruma::OwnedEventId)> = self
.services
.rooms
.state_accessor
.state_keys_with_ids(shortstatehash, &member_event_type)
.collect()
.await;
for (state_key, event_id) in user_entries {
if let Ok(pdu) = self.services.rooms.timeline.get_pdu(&event_id).await {
if let Ok(mut member_content) =
pdu.get_content::<SpaceRoleMemberEventContent>()
{
if member_content.roles.contains(&role_name) {
member_content.roles.retain(|r| r != &role_name);
self.services
.rooms
.timeline
.build_and_append_pdu(
custom_state_pdu!(
SPACE_ROLE_MEMBER_EVENT_TYPE,
&state_key,
&member_content
),
server_user,
Some(&space_id),
&state_lock,
)
.await?;
}
}
}
}
// Cascade: remove the role from all rooms' role requirement events
let room_event_type = StateEventType::from(SPACE_ROLE_ROOM_EVENT_TYPE.to_owned());
let room_entries: Vec<(_, ruma::OwnedEventId)> = self
.services
.rooms
.state_accessor
.state_keys_with_ids(shortstatehash, &room_event_type)
.collect()
.await;
for (state_key, event_id) in room_entries {
if let Ok(pdu) = self.services.rooms.timeline.get_pdu(&event_id).await {
if let Ok(mut room_content) =
pdu.get_content::<SpaceRoleRoomEventContent>()
{
if room_content.required_roles.contains(&role_name) {
room_content.required_roles.retain(|r| r != &role_name);
self.services
.rooms
.timeline
.build_and_append_pdu(
custom_state_pdu!(
SPACE_ROLE_ROOM_EVENT_TYPE,
&state_key,
&room_content
),
server_user,
Some(&space_id),
&state_lock,
)
.await?;
}
}
}
}
}
self.write_str(&format!("Removed role '{role_name}' from space {space_id}."))
.await
}
@ -236,6 +319,23 @@ async fn assign(
role_name: String,
) -> 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
.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 self
.write_str(&format!("Error: Role '{}' does not exist in this space.", role_name))
.await;
}
let member_event_type = StateEventType::from(SPACE_ROLE_MEMBER_EVENT_TYPE.to_owned());
let mut content: SpaceRoleMemberEventContent = self
@ -325,6 +425,23 @@ async fn require(
role_name: String,
) -> 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
.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 self
.write_str(&format!("Error: Role '{}' does not exist in this space.", role_name))
.await;
}
let room_event_type = StateEventType::from(SPACE_ROLE_ROOM_EVENT_TYPE.to_owned());
let mut content: SpaceRoleRoomEventContent = self

View file

@ -68,8 +68,6 @@ struct Services {
state_accessor: Dep<rooms::state_accessor::Service>,
state_cache: Dep<rooms::state_cache::Service>,
state: Dep<rooms::state::Service>,
#[allow(dead_code)]
spaces: Dep<rooms::spaces::Service>,
timeline: Dep<rooms::timeline::Service>,
}
@ -84,7 +82,6 @@ impl crate::Service for Service {
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
state_cache: args.depend::<rooms::state_cache::Service>("rooms::state_cache"),
state: args.depend::<rooms::state::Service>("rooms::state"),
spaces: args.depend::<rooms::spaces::Service>("rooms::spaces"),
timeline: args.depend::<rooms::timeline::Service>("rooms::timeline"),
},
server: args.server.clone(),
@ -698,6 +695,23 @@ impl Service {
debug_warn!("Failed to sync PLs in {child_room_id}: {e}");
}
}
// Revalidate all space members against all child rooms
let space_members: Vec<OwnedUserId> = this
.services
.state_cache
.room_members(&space_id)
.map(ToOwned::to_owned)
.collect()
.await;
for member in &space_members {
if let Err(e) =
this.kick_unqualified_from_rooms(&space_id, member).await
{
debug_warn!(
"Role definition revalidation kick failed for {member}: {e}"
);
}
}
},
| SPACE_ROLE_MEMBER_EVENT_TYPE => {
// User's roles changed — auto-join/kick + PL sync

View file

@ -370,13 +370,18 @@ where
| SPACE_ROLES_EVENT_TYPE
| SPACE_ROLE_MEMBER_EVENT_TYPE
| SPACE_ROLE_ROOM_EVENT_TYPE => {
let roles: Arc<crate::rooms::roles::Service> =
Arc::clone(&*self.services.roles);
roles.handle_state_event_change(
room_id.to_owned(),
event_type_str,
state_key.to_string(),
);
if matches!(
self.services.state_accessor.get_room_type(room_id).await,
Ok(ruma::room::RoomType::Space)
) {
let roles: Arc<crate::rooms::roles::Service> =
Arc::clone(&*self.services.roles);
roles.handle_state_event_change(
room_id.to_owned(),
event_type_str,
state_key.to_string(),
);
}
},
| _ => {},
}