Compare commits

..

4 commits

Author SHA1 Message Date
11c04d1458 feat(spaces): wire up enforcement hooks in join, append, and build paths
Some checks failed
Documentation / Build and Deploy Documentation (pull_request) Has been skipped
Checks / Prek / Pre-commit & Formatting (pull_request) Failing after 6s
Checks / Prek / Clippy and Cargo Tests (pull_request) Failing after 6s
Update flake hashes / update-flake-hashes (pull_request) Failing after 6s
Add minimal integration points in existing files:
- append.rs: call on_pdu_appended for event-driven enforcement
- build.rs: call validate_pl_change to protect space-managed PLs
- join.rs: call check_join_allowed to gate joins on role requirements
- timeline/mod.rs: add roles service dependency
2026-03-19 22:43:23 +01:00
4b37877bc3 feat(spaces): add admin commands for space role management
Add !admin space roles subcommands: list, add, remove, assign, revoke,
require, unrequire, user, room, enable, disable, status. Role
definitions, assignments, and room requirements are managed via state
events. Enable/disable controls per-space cascading override.
2026-03-19 22:43:15 +01:00
f4ab456bbd feat(spaces): add space roles service with enforcement and caching
Implement the roles service that manages space permission cascading:
- In-memory cache populated from state events, rebuilt on startup
- Join gating, power level sync (highest-wins across parent spaces),
  auto-join on role grant, auto-kick on role revocation
- Per-space enable/disable via com.continuwuity.space.cascading event
- Background enforcement tasks with semaphore-limited concurrency
- Graceful shutdown support via server.running() checks
2026-03-19 22:43:08 +01:00
87ad6f156c feat(spaces): add custom state event types and config for space permission cascading
Add four custom Matrix state event content types for space role
management: space roles definitions, per-user role assignments,
per-room role requirements, and per-space cascading override.

Add server config options: space_permission_cascading (default false)
as the server-wide toggle, and space_roles_cache_flush_threshold
(default 1000) for cache management.
2026-03-19 22:42:57 +01:00
2 changed files with 346 additions and 409 deletions

View file

@ -81,54 +81,6 @@ macro_rules! custom_state_pdu {
};
}
/// Cascade-remove a role name from all state events of a given type. For each
/// event that contains the role, the `$field` is filtered and the updated
/// content is sent back as a new state event.
macro_rules! cascade_remove_role {
(
$self:expr,
$shortstatehash:expr,
$event_type_fn:expr,
$event_type_const:expr,
$content_ty:ty,
$field:ident,
$role_name:expr,
$space_id:expr,
$state_lock:expr,
$server_user:expr
) => {{
let ev_type = $event_type_fn;
let entries: Vec<(_, ruma::OwnedEventId)> = $self
.services
.rooms
.state_accessor
.state_keys_with_ids($shortstatehash, &ev_type)
.collect()
.await;
for (state_key, event_id) in entries {
if let Ok(pdu) = $self.services.rooms.timeline.get_pdu(&event_id).await {
if let Ok(mut content) = pdu.get_content::<$content_ty>() {
if content.$field.contains($role_name) {
content.$field.retain(|r| r != $role_name);
$self
.services
.rooms
.timeline
.build_and_append_pdu(
custom_state_pdu!($event_type_const, &state_key, &content),
$server_user,
Some(&$space_id),
&$state_lock,
)
.await?;
}
}
}
}
}};
}
macro_rules! send_space_state {
($self:expr, $space_id:expr, $event_type:expr, $state_key:expr, $content:expr) => {{
let state_lock = $self.services.rooms.state.mutex.lock(&$space_id).await;
@ -313,7 +265,7 @@ async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result {
send_space_state!(self, space_id, SPACE_ROLES_EVENT_TYPE, "", &content);
// Cascade: remove the deleted role from all member and room events
let member_event_type = member_event_type();
let server_user = &self.services.globals.server_user;
if let Ok(shortstatehash) = self
.services
@ -323,32 +275,72 @@ async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result {
.await
{
let state_lock = self.services.rooms.state.mutex.lock(&space_id).await;
let user_entries: Vec<(_, ruma::OwnedEventId)> = self
.services
.rooms
.state_accessor
.state_keys_with_ids(shortstatehash, &member_event_type)
.collect()
.await;
cascade_remove_role!(
self,
shortstatehash,
member_event_type(),
SPACE_ROLE_MEMBER_EVENT_TYPE,
SpaceRoleMemberEventContent,
roles,
&role_name,
space_id,
state_lock,
server_user
);
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_role!(
self,
shortstatehash,
room_event_type(),
SPACE_ROLE_ROOM_EVENT_TYPE,
SpaceRoleRoomEventContent,
required_roles,
&role_name,
space_id,
state_lock,
server_user
);
// Cascade: remove the role from all rooms' role requirement events
let room_event_type = room_event_type();
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}."))

View file

@ -13,12 +13,18 @@ use conduwuit::{
matrix::pdu::{PduBuilder, PduEvent},
warn,
};
use conduwuit_core::matrix::space_roles::{
RoleDefinition, SPACE_CASCADING_EVENT_TYPE, SPACE_ROLE_MEMBER_EVENT_TYPE,
SPACE_ROLE_ROOM_EVENT_TYPE, SPACE_ROLES_EVENT_TYPE, SpaceCascadingEventContent,
SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, SpaceRolesEventContent,
use conduwuit_core::{
matrix::space_roles::{
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,
stream::{BroadbandExt, ReadyExt},
},
};
use futures::StreamExt;
use futures::{StreamExt, TryFutureExt};
use ruma::{
Int, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId,
events::{
@ -246,118 +252,6 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result {
Ok(())
}
/// Returns all `(state_key, pdu)` pairs for state events of the given type
/// within the room state identified by `shortstatehash`.
#[implement(Service)]
async fn load_state_pdus(
&self,
shortstatehash: u64,
event_type: &StateEventType,
) -> Vec<(conduwuit_core::matrix::StateKey, PduEvent)> {
let entries: Vec<(_, OwnedEventId)> = self
.services
.state_accessor
.state_keys_with_ids(shortstatehash, event_type)
.collect()
.await;
let mut result = Vec::with_capacity(entries.len());
for (state_key, event_id) in entries {
if let Ok(pdu) = self.services.timeline.get_pdu(&event_id).await {
result.push((state_key, pdu));
}
}
result
}
/// Loads all `SpaceRoleMember` state events and writes the resulting
/// user-to-roles mapping into the `user_roles` cache.
#[implement(Service)]
async fn load_user_roles(&self, space_id: &RoomId, shortstatehash: u64) {
let member_event_type = member_event_type();
let mut user_roles_map: HashMap<OwnedUserId, HashSet<String>> = HashMap::new();
for (state_key, pdu) in self
.load_state_pdus(shortstatehash, &member_event_type)
.await
{
if let Ok(content) = pdu.get_content::<SpaceRoleMemberEventContent>() {
if let Ok(user_id) = UserId::parse(&*state_key) {
user_roles_map.insert(user_id.to_owned(), content.roles.into_iter().collect());
}
}
}
self.user_roles
.write()
.await
.insert(space_id.to_owned(), user_roles_map);
}
/// Loads all `SpaceRoleRoom` state events and writes the resulting
/// room-to-required-roles mapping into the `room_requirements` cache.
#[implement(Service)]
async fn load_room_requirements(&self, space_id: &RoomId, shortstatehash: u64) {
let room_event_type = room_event_type();
let mut room_reqs_map: HashMap<OwnedRoomId, HashSet<String>> = HashMap::new();
for (state_key, pdu) in self.load_state_pdus(shortstatehash, &room_event_type).await {
if let Ok(content) = pdu.get_content::<SpaceRoleRoomEventContent>() {
if let Ok(room_id) = RoomId::parse(&*state_key) {
room_reqs_map
.insert(room_id.to_owned(), content.required_roles.into_iter().collect());
}
}
}
self.room_requirements
.write()
.await
.insert(space_id.to_owned(), room_reqs_map);
}
/// Loads all `SpaceChild` state events and updates both the
/// `room_to_space` and `space_to_rooms` indexes.
#[implement(Service)]
async fn load_child_rooms_index(&self, space_id: &RoomId, shortstatehash: u64) {
let mut child_rooms: Vec<OwnedRoomId> = Vec::new();
for (state_key, pdu) in self
.load_state_pdus(shortstatehash, &StateEventType::SpaceChild)
.await
{
if let Ok(content) = pdu.get_content::<SpaceChildEventContent>() {
if content.via.is_empty() {
continue;
}
} else {
continue;
}
if let Ok(child_room_id) = RoomId::parse(&*state_key) {
child_rooms.push(child_room_id.to_owned());
}
}
{
let mut room_to_space = self.room_to_space.write().await;
room_to_space.retain(|_, parents| {
parents.remove(space_id);
!parents.is_empty()
});
for child_room_id in &child_rooms {
room_to_space
.entry(child_room_id.clone())
.or_default()
.insert(space_id.to_owned());
}
}
{
let mut space_to_rooms = self.space_to_rooms.write().await;
space_to_rooms.insert(space_id.to_owned(), child_rooms.into_iter().collect())
};
}
#[implement(Service)]
pub async fn populate_space(&self, space_id: &RoomId) {
if !self.is_enabled_for_space(space_id).await {
@ -385,6 +279,7 @@ pub async fn populate_space(&self, space_id: &RoomId) {
.insert(space_id.to_owned(), content.roles);
}
let member_event_type = member_event_type();
let shortstatehash = match self.services.state.get_room_shortstatehash(space_id).await {
| Ok(hash) => hash,
| Err(e) => {
@ -392,10 +287,118 @@ pub async fn populate_space(&self, space_id: &RoomId) {
return;
},
};
{
let mut user_roles_map: HashMap<OwnedUserId, HashSet<String>> = HashMap::new();
self.load_user_roles(space_id, shortstatehash).await;
self.load_room_requirements(space_id, shortstatehash).await;
self.load_child_rooms_index(space_id, shortstatehash).await;
self.services
.state_accessor
.state_keys_with_ids(shortstatehash, &member_event_type)
.boxed()
.broad_filter_map(|(state_key, event_id): (_, OwnedEventId)| async move {
self.services
.timeline
.get_pdu(&event_id)
.map_ok(move |pdu| (state_key, pdu))
.ok()
.await
})
.ready_filter_map(|(state_key, pdu)| {
let content = pdu.get_content::<SpaceRoleMemberEventContent>().ok()?;
let user_id = UserId::parse(&*state_key).ok()?.to_owned();
Some((user_id, content.roles))
})
.for_each(|(user_id, roles)| {
user_roles_map.insert(user_id, roles.into_iter().collect());
async {}
})
.await;
self.user_roles
.write()
.await
.insert(space_id.to_owned(), user_roles_map);
let room_event_type = room_event_type();
let mut room_reqs_map: HashMap<OwnedRoomId, HashSet<String>> = HashMap::new();
self.services
.state_accessor
.state_keys_with_ids(shortstatehash, &room_event_type)
.boxed()
.broad_filter_map(|(state_key, event_id): (_, OwnedEventId)| async move {
self.services
.timeline
.get_pdu(&event_id)
.map_ok(move |pdu| (state_key, pdu))
.ok()
.await
})
.ready_filter_map(|(state_key, pdu)| {
let content = pdu.get_content::<SpaceRoleRoomEventContent>().ok()?;
let room_id = RoomId::parse(&*state_key).ok()?.to_owned();
Some((room_id, content.required_roles))
})
.for_each(|(room_id, required_roles)| {
room_reqs_map.insert(room_id, required_roles.into_iter().collect());
async {}
})
.await;
self.room_requirements
.write()
.await
.insert(space_id.to_owned(), room_reqs_map);
let mut child_rooms: Vec<OwnedRoomId> = Vec::new();
self.services
.state_accessor
.state_keys_with_ids(shortstatehash, &StateEventType::SpaceChild)
.boxed()
.broad_filter_map(|(state_key, event_id): (_, OwnedEventId)| async move {
self.services
.timeline
.get_pdu(&event_id)
.map_ok(move |pdu| (state_key, pdu))
.ok()
.await
})
.ready_filter_map(|(state_key, pdu)| {
if let Ok(content) = pdu.get_content::<SpaceChildEventContent>() {
if content.via.is_empty() {
return None;
}
} else {
return None;
}
let child_room_id = RoomId::parse(&*state_key).ok()?.to_owned();
Some(child_room_id)
})
.for_each(|child_room_id| {
child_rooms.push(child_room_id);
async {}
})
.await;
{
let mut room_to_space = self.room_to_space.write().await;
room_to_space.retain(|_, parents| {
parents.remove(space_id);
!parents.is_empty()
});
for child_room_id in &child_rooms {
room_to_space
.entry(child_room_id.clone())
.or_default()
.insert(space_id.to_owned());
}
}
{
let mut space_to_rooms = self.space_to_rooms.write().await;
space_to_rooms.insert(space_id.to_owned(), child_rooms.into_iter().collect())
};
}
}
#[must_use]
@ -557,7 +560,14 @@ pub async fn sync_power_levels(&self, room_id: &RoomId) -> Result {
continue;
}
if let Some(effective_pl) = self.compute_effective_pl(&all_parents, user_id).await {
let mut max_pl: Option<i64> = None;
for parent in &all_parents {
if let Some(pl) = self.get_user_power_level(parent, user_id).await {
max_pl = Some(max_pl.map_or(pl, |current| current.max(pl)));
}
}
if let Some(effective_pl) = max_pl {
let effective_pl_int = Int::new_saturating(effective_pl);
let current_pl = power_levels_content
.users
@ -591,24 +601,6 @@ pub async fn sync_power_levels(&self, room_id: &RoomId) -> Result {
Ok(())
}
/// Computes the maximum effective power level for a user across all given
/// parent spaces. Returns `None` if the user has no power level defined in
/// any parent.
#[implement(Service)]
async fn compute_effective_pl(
&self,
parent_spaces: &[OwnedRoomId],
user_id: &UserId,
) -> Option<i64> {
let mut max_pl: Option<i64> = None;
for parent in parent_spaces {
if let Some(pl) = self.get_user_power_level(parent, user_id).await {
max_pl = Some(max_pl.map_or(pl, |current| current.max(pl)));
}
}
max_pl
}
#[implement(Service)]
pub async fn auto_join_qualifying_rooms(&self, space_id: &RoomId, user_id: &UserId) -> Result {
if !self.is_enabled_for_space(space_id).await {
@ -860,66 +852,6 @@ async fn sync_power_levels_for_children(&self, space_id: &RoomId) {
}
}
/// Enforces a change to the space role definitions: syncs power levels to
/// all child rooms and kicks members who no longer qualify.
#[implement(Service)]
async fn enforce_roles_change(&self, space_id: &RoomId) {
self.sync_power_levels_for_children(space_id).await;
let space_members: Vec<OwnedUserId> = self
.services
.state_cache
.room_members(space_id)
.map(ToOwned::to_owned)
.collect()
.await;
for member in &space_members {
if let Err(e) = Box::pin(self.kick_unqualified_from_rooms(space_id, member)).await {
debug_warn!(user_id = %member, error = ?e, "Role definition revalidation kick failed");
}
}
}
/// Enforces a change to a user's role membership: auto-joins qualifying
/// rooms, kicks from rooms they no longer qualify for, and syncs power
/// levels.
#[implement(Service)]
async fn enforce_member_change(&self, space_id: &RoomId, user_id: &UserId) {
if let Err(e) = self.auto_join_qualifying_rooms(space_id, user_id).await {
debug_warn!(user_id = %user_id, error = ?e, "Space role auto-join failed");
}
if let Err(e) = Box::pin(self.kick_unqualified_from_rooms(space_id, user_id)).await {
debug_warn!(user_id = %user_id, error = ?e, "Space role auto-kick failed");
}
self.sync_power_levels_for_children(space_id).await;
}
/// Enforces a change to a room's role requirements: kicks all members of
/// the target room who no longer meet the updated requirements.
#[implement(Service)]
async fn enforce_room_change(&self, space_id: &RoomId, target_room: &RoomId) {
let members: Vec<OwnedUserId> = self
.services
.state_cache
.room_members(target_room)
.map(ToOwned::to_owned)
.collect()
.await;
for member in &members {
if let Err(e) = Box::pin(self.kick_unqualified_from_rooms(space_id, member)).await {
debug_warn!(user_id = %member, error = ?e, "Space role requirement kick failed");
}
}
}
/// Enforces a toggle of the cascading feature: if cascading was disabled,
/// flushes the space from the cache.
#[implement(Service)]
async fn enforce_cascading_toggle(&self, space_id: &RoomId) {
if !self.is_enabled_for_space(space_id).await {
self.flush_space_from_cache(space_id).await;
}
}
impl Service {
pub fn handle_state_event_change(
self: &Arc<Self>,
@ -954,20 +886,62 @@ impl Service {
match event_type.as_str() {
| SPACE_ROLES_EVENT_TYPE => {
this.enforce_roles_change(&space_id).await;
this.sync_power_levels_for_children(&space_id).await;
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) =
Box::pin(this.kick_unqualified_from_rooms(&space_id, member))
.await
{
debug_warn!(user_id = %member, error = ?e, "Role definition revalidation kick failed");
}
}
},
| SPACE_ROLE_MEMBER_EVENT_TYPE => {
if let Ok(user_id) = UserId::parse(state_key.as_str()) {
this.enforce_member_change(&space_id, user_id).await;
if let Err(e) =
this.auto_join_qualifying_rooms(&space_id, user_id).await
{
debug_warn!(user_id = %user_id, error = ?e, "Space role auto-join failed");
}
if let Err(e) =
Box::pin(this.kick_unqualified_from_rooms(&space_id, user_id))
.await
{
debug_warn!(user_id = %user_id, error = ?e, "Space role auto-kick failed");
}
this.sync_power_levels_for_children(&space_id).await;
}
},
| SPACE_ROLE_ROOM_EVENT_TYPE => {
if let Ok(target_room) = RoomId::parse(state_key.as_str()) {
this.enforce_room_change(&space_id, target_room).await;
let members: Vec<OwnedUserId> = this
.services
.state_cache
.room_members(target_room)
.map(ToOwned::to_owned)
.collect()
.await;
for member in &members {
if let Err(e) =
Box::pin(this.kick_unqualified_from_rooms(&space_id, member))
.await
{
debug_warn!(user_id = %member, error = ?e, "Space role requirement kick failed");
}
}
}
},
| SPACE_CASCADING_EVENT_TYPE => {
this.enforce_cascading_toggle(&space_id).await;
if !this.is_enabled_for_space(&space_id).await {
this.flush_space_from_cache(&space_id).await;
}
},
| _ => {},
}
@ -1011,9 +985,69 @@ impl Service {
};
if is_removal {
this.handle_child_removed(&space_id, &child_room_id).await;
} else {
this.handle_child_added(&space_id, &child_room_id).await;
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);
}
}
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);
}
return;
}
this.room_to_space
.write()
.await
.entry(child_room_id.clone())
.or_default()
.insert(space_id.clone());
this.space_to_rooms
.write()
.await
.entry(space_id.clone())
.or_default()
.insert(child_room_id.clone());
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!(room_id = %child_room_id, "Server user is not joined, skipping auto-join enforcement for new child");
return;
}
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 this
.user_qualifies_for_room(&space_id, &child_room_id, member)
.await && !this
.services
.state_cache
.is_joined(member, &child_room_id)
.await
{
if let Err(e) = this
.invite_and_join_user(&child_room_id, member, server_user)
.await
{
debug_warn!(user_id = %member, room_id = %child_room_id, error = ?e, "Failed to auto-join user");
}
}
}
});
}
@ -1077,75 +1111,6 @@ impl Service {
}
}
#[implement(Service)]
async fn handle_child_removed(&self, space_id: &RoomId, child_room_id: &RoomId) {
let mut room_to_space = self.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);
}
}
let mut space_to_rooms = self.space_to_rooms.write().await;
if let Some(children) = space_to_rooms.get_mut(space_id) {
children.remove(child_room_id);
}
}
#[implement(Service)]
async fn handle_child_added(&self, space_id: &RoomId, child_room_id: &RoomId) {
self.room_to_space
.write()
.await
.entry(child_room_id.to_owned())
.or_default()
.insert(space_id.to_owned());
self.space_to_rooms
.write()
.await
.entry(space_id.to_owned())
.or_default()
.insert(child_room_id.to_owned());
let server_user = self.services.globals.server_user.as_ref();
if !self
.services
.state_cache
.is_joined(server_user, child_room_id)
.await
{
debug_warn!(room_id = %child_room_id, "Server user is not joined, skipping auto-join enforcement for new child");
return;
}
let space_members: Vec<OwnedUserId> = self
.services
.state_cache
.room_members(space_id)
.map(ToOwned::to_owned)
.collect()
.await;
for member in &space_members {
if self
.user_qualifies_for_room(space_id, child_room_id, member)
.await && !self
.services
.state_cache
.is_joined(member, child_room_id)
.await
{
if let Err(e) = self
.invite_and_join_user(child_room_id, member, server_user)
.await
{
debug_warn!(user_id = %member, room_id = %child_room_id, error = ?e, "Failed to auto-join user");
}
}
}
}
#[implement(Service)]
pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &UserId) -> Result {
if !self.is_enabled_for_space(space_id).await {
@ -1184,74 +1149,54 @@ pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &Use
continue;
}
if self
.user_qualifies_in_any_parent(child_room_id, user_id)
.await
{
let all_parents = self.get_parent_spaces(child_room_id).await;
let mut qualifies_in_any = false;
for parent in &all_parents {
if self
.user_qualifies_for_room(parent, child_room_id, user_id)
.await
{
qualifies_in_any = true;
break;
}
}
if qualifies_in_any {
continue;
}
self.kick_user_from_room(child_room_id, user_id, server_user)
.await?;
}
let Ok(member_content) = self
.services
.state_accessor
.get_member(child_room_id, user_id)
.await
else {
debug_warn!(user_id = %user_id, room_id = %child_room_id, "Could not get member event, skipping kick");
continue;
};
Ok(())
}
let state_lock = self.services.state.mutex.lock(child_room_id).await;
/// Checks whether the user qualifies for the given room through any of its
/// parent spaces.
#[implement(Service)]
async fn user_qualifies_in_any_parent(&self, room_id: &RoomId, user_id: &UserId) -> bool {
let all_parents = self.get_parent_spaces(room_id).await;
for parent in &all_parents {
if self.user_qualifies_for_room(parent, room_id, user_id).await {
return true;
if let Err(e) = self
.services
.timeline
.build_and_append_pdu(
PduBuilder::state(user_id.to_string(), &RoomMemberEventContent {
membership: MembershipState::Leave,
reason: Some("No longer has required Space roles".into()),
is_direct: None,
join_authorized_via_users_server: None,
third_party_invite: None,
..member_content
}),
server_user,
Some(child_room_id),
&state_lock,
)
.await
{
warn!(user_id = %user_id, room_id = %child_room_id, error = ?e, "Failed to kick user for missing roles");
}
}
false
}
/// Sends a kick (membership=leave) PDU to remove a user from a room for
/// missing required Space roles.
#[implement(Service)]
async fn kick_user_from_room(
&self,
room_id: &RoomId,
user_id: &UserId,
server_user: &UserId,
) -> Result {
let Ok(member_content) = self
.services
.state_accessor
.get_member(room_id, user_id)
.await
else {
debug_warn!(user_id = %user_id, room_id = %room_id, "Could not get member event, skipping kick");
return Ok(());
};
let state_lock = self.services.state.mutex.lock(room_id).await;
if let Err(e) = self
.services
.timeline
.build_and_append_pdu(
PduBuilder::state(user_id.to_string(), &RoomMemberEventContent {
membership: MembershipState::Leave,
reason: Some("No longer has required Space roles".into()),
is_direct: None,
join_authorized_via_users_server: None,
third_party_invite: None,
..member_content
}),
server_user,
Some(room_id),
&state_lock,
)
.await
{
warn!(user_id = %user_id, room_id = %room_id, error = ?e, "Failed to kick user for missing roles");
}
Ok(())
}