feat(spaces): add cache population and lookup methods for space roles

Adds is_enabled(), populate_space(), get_user_power_level(),
user_qualifies_for_room(), and get_parent_space() methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ember33 2026-03-17 17:03:47 +01:00
parent aae610b3d2
commit 83eea18f3e

View file

@ -5,16 +5,32 @@ use std::{
};
use async_trait::async_trait;
use conduwuit::Result;
use conduwuit_core::matrix::space_roles::RoleDefinition;
use ruma::{OwnedRoomId, OwnedUserId};
use conduwuit::{Event, Result, Server, implement};
use conduwuit_core::{
matrix::space_roles::{
RoleDefinition, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent,
SpaceRolesEventContent,
},
utils::{
future::TryExtExt,
stream::{BroadbandExt, ReadyExt},
},
};
use futures::{StreamExt, TryFutureExt};
use ruma::{
OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId,
events::{
StateEventType,
space::child::SpaceChildEventContent,
},
};
use tokio::sync::RwLock;
use crate::{Dep, rooms};
pub struct Service {
#[allow(dead_code)]
services: Services,
server: Arc<Server>,
/// Space ID -> role name -> role definition
pub roles: RwLock<HashMap<OwnedRoomId, BTreeMap<String, RoleDefinition>>>,
/// Space ID -> user ID -> assigned role names
@ -25,11 +41,12 @@ pub struct Service {
pub room_to_space: RwLock<HashMap<OwnedRoomId, OwnedRoomId>>,
}
#[allow(dead_code)]
struct Services {
state_accessor: Dep<rooms::state_accessor::Service>,
#[allow(dead_code)]
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>,
}
@ -46,6 +63,7 @@ impl crate::Service for Service {
spaces: args.depend::<rooms::spaces::Service>("rooms::spaces"),
timeline: args.depend::<rooms::timeline::Service>("rooms::timeline"),
},
server: args.server.clone(),
roles: RwLock::new(HashMap::new()),
user_roles: RwLock::new(HashMap::new()),
room_requirements: RwLock::new(HashMap::new()),
@ -76,3 +94,187 @@ 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 }
/// 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
/// `m.space.child` state events and indexes them for fast lookup.
#[implement(Service)]
pub async fn populate_space(&self, space_id: &RoomId) {
// 1. Read m.space.roles (state key: "")
let roles_event_type = StateEventType::from("m.space.roles".to_owned());
if let Ok(content) = self
.services
.state_accessor
.room_state_get_content::<SpaceRolesEventContent>(space_id, &roles_event_type, "")
.await
{
self.roles
.write()
.await
.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());
if let Ok(shortstatehash) = self
.services
.state
.get_room_shortstatehash(space_id)
.await
{
let mut user_roles_map: HashMap<OwnedUserId, HashSet<String>> = HashMap::new();
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);
// 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());
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);
// 4. Read all m.space.child state events → build room_to_space reverse index
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)| {
// Only index children that have a valid via list
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| {
let space_owned = space_id.to_owned();
async move {
self.room_to_space
.write()
.await
.insert(child_room_id, space_owned);
}
})
.await;
}
}
/// 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<i64> {
let roles_map = self.roles.read().await;
let user_roles_map = self.user_roles.read().await;
let role_defs = roles_map.get(space_id)?;
let user_assigned = user_roles_map.get(space_id)?.get(user_id)?;
user_assigned
.iter()
.filter_map(|role_name| role_defs.get(role_name)?.power_level)
.max()
}
/// Check if a user has all required roles for a room.
#[implement(Service)]
pub async fn user_qualifies_for_room(
&self,
space_id: &RoomId,
room_id: &RoomId,
user_id: &UserId,
) -> bool {
let reqs = self.room_requirements.read().await;
let Some(space_reqs) = reqs.get(space_id) else {
return true;
};
let Some(required) = space_reqs.get(room_id) else {
return true;
};
if required.is_empty() {
return true;
}
let user_map = self.user_roles.read().await;
let Some(space_users) = user_map.get(space_id) else {
return false;
};
let Some(user_assigned) = space_users.get(user_id) else {
return false;
};
required.iter().all(|r| user_assigned.contains(r))
}
/// Get the parent Space of a child room, if any.
#[implement(Service)]
pub async fn get_parent_space(&self, room_id: &RoomId) -> Option<OwnedRoomId> {
self.room_to_space.read().await.get(room_id).cloned()
}