diff --git a/changelog.d/+space-permission-cascading.feature.md b/changelog.d/+space-permission-cascading.feature.md new file mode 100644 index 00000000..b60fcd13 --- /dev/null +++ b/changelog.d/+space-permission-cascading.feature.md @@ -0,0 +1 @@ +Add Space permission cascading: power levels cascade from Spaces to child rooms, role-based room access with custom roles, continuous enforcement (auto-join/kick), and admin commands for role management. Controlled by `space_permission_cascading` config flag (off by default). diff --git a/docs/plans/2026-03-17-space-permission-cascading-design.md b/docs/plans/2026-03-17-space-permission-cascading-design.md deleted file mode 100644 index c69f3877..00000000 --- a/docs/plans/2026-03-17-space-permission-cascading-design.md +++ /dev/null @@ -1,225 +0,0 @@ -# Space Permission Cascading — Design Document - -**Date:** 2026-03-17 -**Status:** Implemented - -## Overview - -Server-side feature that allows user rights in a Space to cascade down to its -direct child rooms. Includes power level cascading and role-based room access -control. Enabled via a server-wide configuration flag, disabled by default. - -## Requirements - -1. Power levels defined in a Space cascade to all direct child rooms (Space - always wins over per-room overrides). -2. Admins can define custom roles in a Space and assign them to users. -3. Child rooms can require one or more roles for access. -4. Enforcement is continuous — role revocation auto-kicks users from rooms they - no longer qualify for. -5. Users are auto-joined to all qualifying child rooms when they join a Space or - receive a new role. -6. Cascading applies to direct parent Space only; no nested cascade through - sub-spaces. -7. Feature is toggled by a single server-wide config flag - (`space_permission_cascading`), off by default. - -## Configuration - -```toml -# conduwuit-example.toml - -# Enable space permission cascading (power levels and role-based access). -# When enabled, power levels cascade from Spaces to child rooms and rooms -# can require roles for access. Applies to all Spaces on this server. -# Default: false -space_permission_cascading = false -``` - -## Custom State Events - -All events live in the Space room. - -### `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 -enabled. - -```json -{ - "roles": { - "admin": { - "description": "Space administrator", - "power_level": 100 - }, - "mod": { - "description": "Space moderator", - "power_level": 50 - }, - "nsfw": { - "description": "Access to NSFW content" - }, - "vip": { - "description": "VIP member" - } - } -} -``` - -- `description` (string, required): Human-readable description. -- `power_level` (integer, optional): If present, users with this role receive - this power level in all child rooms. When a user holds multiple roles with - power levels, the highest value wins. - -### `com.continuwuity.space.role.member` (state key: user ID) - -Assigns roles to a user within the Space. - -```json -{ - "roles": ["nsfw", "vip"] -} -``` - -### `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. - -```json -{ - "required_roles": ["nsfw"] -} -``` - -## Enforcement Rules - -All enforcement is skipped when `space_permission_cascading = false`. - -### 1. Join gating - -When a user attempts to join a room that is a direct child of a Space: - -- 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 `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 - Space-granted levels. - -### 3. Role revocation - -When a `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. -- Recalculate and update the user's power level in all child rooms. - -### 4. Room requirement change - -When a `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 a `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. - -This also applies when a user first joins the Space — they are auto-joined to -all child rooms they qualify for. Rooms with no role requirements auto-join all -Space members. - -### 6. New child room - -When a new `m.space.child` event is added to a Space: - -- Auto-join all qualifying Space members to the new child room. - -## Caching & Indexing - -The source of truth is always the state events. The server maintains an -in-memory index for fast enforcement lookups, following the same patterns as the -existing `roomid_spacehierarchy_cache`. - -### Index structures - -| Index | Source event | -|------------------------------|------------------------| -| 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 Spaces | `m.space.child` (reverse lookup) | -| Space → child rooms | `m.space.child` (forward index) | - -### Cache invalidation triggers - -| Event changed | Action | -|----------------------------|-----------------------------------------------------| -| `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 | - -## Admin Room Commands - -Roles are managed via the existing admin room interface, which sends the -appropriate state events under the hood and triggers enforcement. - -``` -!admin space roles list -!admin space roles add [description] [power_level] -!admin space roles remove -!admin space roles assign -!admin space roles revoke -!admin space roles require -!admin space roles unrequire -!admin space roles user -!admin space roles room -``` - -## Architecture - -**Approach:** Hybrid — state events for definition, database cache for -enforcement. - -- State events are the source of truth and federate normally. -- The server maintains an in-memory cache/index for fast enforcement. -- Cache is invalidated on relevant state event changes and fully rebuilt on - startup. -- All enforcement hooks (join gating, PL override, auto-join, auto-kick) check - the feature flag first and no-op when disabled. -- Existing clients can manage roles via Developer Tools (custom state events). - The admin room commands provide a user-friendly interface. - -## Scope - -### In scope - -- Server-wide feature flag -- Custom state events for role definition, assignment, and room requirements -- Power level cascading (Space always wins) -- Continuous enforcement (auto-join, auto-kick) -- Admin room commands -- In-memory caching with invalidation -- Default `admin` (PL 100) and `mod` (PL 50) roles - -### Out of scope - -- Client-side UI for role management -- Nested cascade through sub-spaces -- Per-space opt-in/opt-out (it is server-wide) -- Federation-specific logic beyond normal state event replication diff --git a/docs/plans/2026-03-17-space-permission-cascading.md b/docs/plans/2026-03-17-space-permission-cascading.md deleted file mode 100644 index 2cc47dc4..00000000 --- a/docs/plans/2026-03-17-space-permission-cascading.md +++ /dev/null @@ -1,2203 +0,0 @@ -# Space Permission Cascading Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**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 (`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 - -**Design doc:** `docs/plans/2026-03-17-space-permission-cascading-design.md` - ---- - -### Task 1: Add Config Flag - -**Files:** -- Modify: `src/core/config/mod.rs` (add field to Config struct, near line 604) - -**Step 1: Add the config field** - -Add after the `suspend_on_register` field (around line 604) in the `Config` struct: - -```rust -/// Enable space permission cascading (power levels and role-based access). -/// When enabled, power levels cascade from Spaces to child rooms and rooms -/// can require roles for access. Applies to all Spaces on this server. -/// -/// default: false -#[serde(default)] -pub space_permission_cascading: bool, -``` - -**Step 2: Verify build** - -Run: `cargo check -p conduwuit-core 2>&1 | tail -20` -Expected: Compiles successfully. The `conduwuit-example.toml` is auto-generated from doc comments by the `#[config_example_generator]` macro. - -**Step 3: Commit** - -```bash -git add src/core/config/mod.rs -git commit -m "feat(spaces): add space_permission_cascading config flag" -``` - ---- - -### Task 2: Define Custom State Event Content Types - -**Files:** -- Create: `src/core/matrix/space_roles.rs` -- Modify: `src/core/matrix/mod.rs` (add module declaration) - -**Step 1: Create the event content types** - -Create `src/core/matrix/space_roles.rs` with serde types for the three custom state events: - -```rust -//! Custom state event content types for space permission cascading. -//! -//! These events live in Space rooms and define roles, user-role assignments, -//! and room-role requirements. - -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; - -/// Content for `com.continuwuity.space.roles` (state key: "") -/// -/// Defines available roles for a Space. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct SpaceRolesEventContent { - pub roles: BTreeMap, -} - -/// A single role definition within a Space. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct RoleDefinition { - pub description: String, - - /// If present, users with this role receive this power level in child - /// rooms. - #[serde(skip_serializing_if = "Option::is_none")] - pub power_level: Option, -} - -/// 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)] -pub struct SpaceRoleMemberEventContent { - pub roles: Vec, -} - -/// 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)] -pub struct SpaceRoleRoomEventContent { - pub required_roles: Vec, -} -``` - -**Step 2: Register the module** - -In `src/core/matrix/mod.rs`, add: - -```rust -pub mod space_roles; -``` - -**Step 3: Write tests for serde round-tripping** - -Add to the bottom of `src/core/matrix/space_roles.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn serialize_space_roles() { - let mut roles = BTreeMap::new(); - roles.insert( - "admin".to_owned(), - RoleDefinition { - description: "Space administrator".to_owned(), - power_level: Some(100), - }, - ); - roles.insert( - "nsfw".to_owned(), - RoleDefinition { - description: "NSFW access".to_owned(), - power_level: None, - }, - ); - let content = SpaceRolesEventContent { roles }; - let json = serde_json::to_string(&content).unwrap(); - let deserialized: SpaceRolesEventContent = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.roles.len(), 2); - assert_eq!(deserialized.roles["admin"].power_level, Some(100)); - assert!(deserialized.roles["nsfw"].power_level.is_none()); - } - - #[test] - fn serialize_role_member() { - let content = SpaceRoleMemberEventContent { - roles: vec!["nsfw".to_owned(), "vip".to_owned()], - }; - let json = serde_json::to_string(&content).unwrap(); - let deserialized: SpaceRoleMemberEventContent = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.roles, vec!["nsfw", "vip"]); - } - - #[test] - fn serialize_role_room() { - let content = SpaceRoleRoomEventContent { - required_roles: vec!["nsfw".to_owned()], - }; - let json = serde_json::to_string(&content).unwrap(); - let deserialized: SpaceRoleRoomEventContent = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.required_roles, vec!["nsfw"]); - } - - #[test] - fn empty_roles_deserialize() { - let json = r#"{"roles":{}}"#; - let content: SpaceRolesEventContent = serde_json::from_str(json).unwrap(); - assert!(content.roles.is_empty()); - } -} -``` - -**Step 4: Run tests** - -Run: `cargo test -p conduwuit-core space_roles 2>&1 | tail -20` -Expected: All 4 tests pass. - -**Step 5: Commit** - -```bash -git add src/core/matrix/space_roles.rs src/core/matrix/mod.rs -git commit -m "feat(spaces): add custom state event types for space roles" -``` - ---- - -### Task 3: Create the Space Roles Service - -**Files:** -- Create: `src/service/rooms/roles/mod.rs` -- Modify: `src/service/rooms/mod.rs` (add module) -- Modify: `src/service/service.rs` or equivalent service registry (register new service) - -This is the core service that manages the in-memory cache and provides lookup methods. - -**Step 1: Create the service skeleton** - -Create `src/service/rooms/roles/mod.rs`: - -```rust -//! Space permission cascading service. -//! -//! Maintains an in-memory index of space roles, user-role assignments, and -//! room-role requirements. Source of truth is always the state events in the -//! Space room. - -use std::{ - collections::{BTreeMap, HashMap, HashSet}, - fmt::Write, - sync::Arc, -}; - -use async_trait::async_trait; -use conduwuit_core::{ - Result, implement, - matrix::space_roles::{ - SpaceRoleRoomEventContent, SpaceRoleMemberEventContent, SpaceRolesEventContent, - RoleDefinition, - }, -}; -use ruma::{OwnedRoomId, OwnedUserId, RoomId, UserId}; -use tokio::sync::RwLock; - -use crate::{Dep, rooms}; - -pub struct Service { - services: Services, - /// Space ID -> role definitions - pub roles: RwLock>>, - /// Space ID -> user ID -> assigned roles - pub user_roles: RwLock>>>, - /// Space ID -> child room ID -> required roles - pub room_requirements: RwLock>>>, - /// Child room ID -> parent Space ID (reverse lookup) - pub room_to_space: RwLock>, -} - -struct Services { - state_accessor: Dep, - state_cache: Dep, - state: Dep, - spaces: Dep, - timeline: Dep, - server: Arc, -} - -#[async_trait] -impl crate::Service for Service { - fn build(args: crate::Args<'_>) -> Result> { - Ok(Arc::new(Self { - services: Services { - state_accessor: args - .depend::("rooms::state_accessor"), - state_cache: args.depend::("rooms::state_cache"), - state: args.depend::("rooms::state"), - spaces: args.depend::("rooms::spaces"), - timeline: args.depend::("rooms::timeline"), - server: args.server.clone(), - }, - roles: RwLock::new(HashMap::new()), - user_roles: RwLock::new(HashMap::new()), - room_requirements: RwLock::new(HashMap::new()), - room_to_space: RwLock::new(HashMap::new()), - })) - } - - async fn memory_usage(&self, out: &mut (dyn Write + Send)) -> Result { - let roles = self.roles.read().await.len(); - let user_roles = self.user_roles.read().await.len(); - let room_requirements = self.room_requirements.read().await.len(); - let room_to_space = self.room_to_space.read().await.len(); - - writeln!(out, "space_roles_definitions: {roles}")?; - writeln!(out, "space_user_roles: {user_roles}")?; - writeln!(out, "space_room_requirements: {room_requirements}")?; - writeln!(out, "space_room_to_space_index: {room_to_space}")?; - Ok(()) - } - - async fn clear_cache(&self) { - self.roles.write().await.clear(); - self.user_roles.write().await.clear(); - self.room_requirements.write().await.clear(); - self.room_to_space.write().await.clear(); - } - - fn name(&self) -> &str { crate::service::make_name(std::module_path!()) } -} -``` - -**Step 2: Register the module in rooms** - -In `src/service/rooms/mod.rs`, add: - -```rust -pub mod roles; -``` - -**Step 3: Register the service in the service registry** - -Find where services are registered (likely in `src/service/services.rs` or similar) and add the `rooms::roles::Service` following the same pattern as other room services. This requires reading the exact registration pattern used. - -**Step 4: Verify build** - -Run: `cargo check -p conduwuit-service 2>&1 | tail -20` -Expected: Compiles successfully. - -**Step 5: Commit** - -```bash -git add src/service/rooms/roles/ src/service/rooms/mod.rs -git commit -m "feat(spaces): add space roles service skeleton with cache structures" -``` - ---- - -### Task 4: Implement Cache Population and Lookup Methods - -**Files:** -- Modify: `src/service/rooms/roles/mod.rs` - -**Step 1: Add the `is_enabled()` check** - -```rust -#[implement(Service)] -pub fn is_enabled(&self) -> bool { - self.services.server.config.space_permission_cascading -} -``` - -**Step 2: Add cache population from state events** - -```rust -/// Rebuild the cache for a single Space by reading its state events. -#[implement(Service)] -pub async fn populate_space(&self, space_id: &RoomId) -> Result { - if !self.is_enabled() { - return Ok(()); - } - - // Load role definitions from com.continuwuity.space.roles - let roles_content: Option = self - .services - .state_accessor - .room_state_get_content(space_id, &StateEventType::from("com.continuwuity.space.roles"), "") - .await - .ok(); - - if let Some(content) = roles_content { - self.roles - .write() - .await - .insert(space_id.to_owned(), content.roles); - } - - // 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(()) -} -``` - -**Step 3: Add lookup methods** - -```rust -/// 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 { - 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; // no requirements tracked for this space - }; - let Some(required) = space_reqs.get(room_id) else { - return true; // room has no role requirements - }; - 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 { - self.room_to_space.read().await.get(room_id).cloned() -} -``` - -**Step 4: Verify build** - -Run: `cargo check -p conduwuit-service 2>&1 | tail -20` -Expected: Compiles successfully. - -**Step 5: Commit** - -```bash -git add src/service/rooms/roles/mod.rs -git commit -m "feat(spaces): add cache population and lookup methods for space roles" -``` - ---- - -### Task 5: Implement Default Roles Initialization - -**Files:** -- Modify: `src/service/rooms/roles/mod.rs` - -**Step 1: Add default role creation** - -```rust -/// 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 { - if !self.is_enabled() { - return Ok(()); - } - - // Check if com.continuwuity.space.roles already exists - let existing: Result = self - .services - .state_accessor - .room_state_get_content(space_id, &StateEventType::from("com.continuwuity.space.roles"), "") - .await; - - if existing.is_ok() { - return Ok(()); - } - - // Create default roles - let mut roles = BTreeMap::new(); - roles.insert( - "admin".to_owned(), - RoleDefinition { - description: "Space administrator".to_owned(), - power_level: Some(100), - }, - ); - roles.insert( - "mod".to_owned(), - RoleDefinition { - description: "Space moderator".to_owned(), - power_level: Some(50), - }, - ); - - let content = SpaceRolesEventContent { roles }; - - // Send the state event as the server user - // This requires finding or creating a suitable sender - // Use the server's service user or the space creator - - Ok(()) -} -``` - -**Step 2: Verify build** - -Run: `cargo check -p conduwuit-service 2>&1 | tail -20` -Expected: Compiles. - -**Step 3: Commit** - -```bash -git add src/service/rooms/roles/mod.rs -git commit -m "feat(spaces): add default admin/mod role initialization" -``` - ---- - -### Task 6: Implement Join Gating - -**Files:** -- Modify: `src/api/client/membership/join.rs` (add role check before join) - -**Step 1: Add role-based join check** - -In `join_room_by_id_helper()` or equivalent join path, after existing authorization checks and before the actual join, add: - -```rust -// Space permission cascading: check if user has required roles -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 - { - return Err!(Request(Forbidden( - "You do not have the required Space roles to join this room" - ))); - } - } -} -``` - -**Step 2: Verify build** - -Run: `cargo check -p conduwuit-api 2>&1 | tail -20` -Expected: Compiles. - -**Step 3: Commit** - -```bash -git add src/api/client/membership/join.rs -git commit -m "feat(spaces): add role-based join gating for space child rooms" -``` - ---- - -### Task 7: Implement Power Level Override - -**Files:** -- Modify: `src/service/rooms/roles/mod.rs` (add PL sync method) - -**Step 1: Add power level synchronization method** - -```rust -/// Synchronize power levels in a child room based on Space roles. -/// This overrides per-room power levels with Space-granted levels. -#[implement(Service)] -pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Result { - if !self.is_enabled() { - return Ok(()); - } - - let state_lock = self.services.state.mutex.lock(room_id).await; - - // Get current power levels for the room - let mut power_levels: RoomPowerLevelsEventContent = self - .services - .state_accessor - .room_state_get_content(room_id, &StateEventType::RoomPowerLevels, "") - .await - .unwrap_or_default(); - - let mut changed = false; - - // Get all members of the room - let members: Vec<_> = self - .services - .state_cache - .room_members(room_id) - .collect() - .await; - - for user_id in &members { - if let Some(pl) = self.get_user_power_level(space_id, user_id).await { - let current = power_levels - .users - .get(user_id) - .copied() - .unwrap_or(power_levels.users_default); - - if i64::from(current) != pl { - power_levels.users.insert(user_id.to_owned(), pl.into()); - changed = true; - } - } - } - - if changed { - // Send updated power levels as the server/space admin - // Use PduBuilder::state to create the event - // timeline.build_and_append_pdu(...) - } - - Ok(()) -} -``` - -**Step 2: Verify build** - -Run: `cargo check -p conduwuit-service 2>&1 | tail -20` -Expected: Compiles. - -**Step 3: Commit** - -```bash -git add src/service/rooms/roles/mod.rs -git commit -m "feat(spaces): add power level synchronization from space roles" -``` - ---- - -### Task 8: Implement Auto-Join and Auto-Kick - -**Files:** -- Modify: `src/service/rooms/roles/mod.rs` (add enforcement methods) - -**Step 1: Add auto-join method** - -```rust -/// Auto-join a user to all qualifying child rooms of a Space. -#[implement(Service)] -pub async fn auto_join_qualifying_rooms( - &self, - space_id: &RoomId, - user_id: &UserId, -) -> Result { - if !self.is_enabled() { - return Ok(()); - } - - // Get all child rooms from m.space.child events - let child_rooms: Vec = self - .services - .spaces - .get_space_child_events(space_id) - .filter_map(|pdu| { - RoomId::parse(pdu.state_key()?).ok().map(|r| r.to_owned()) - }) - .collect() - .await; - - for child_room_id in &child_rooms { - // Skip if already joined - if self - .services - .state_cache - .is_joined(user_id, child_room_id) - .await - { - continue; - } - - // Check if user qualifies - if self - .user_qualifies_for_room(space_id, child_room_id, user_id) - .await - { - // Perform the join via the membership service - // This needs to create a join membership event - } - } - - Ok(()) -} -``` - -**Step 2: Add auto-kick method** - -```rust -/// Remove a user from all child rooms they no longer qualify for. -#[implement(Service)] -pub async fn kick_unqualified_from_rooms( - &self, - space_id: &RoomId, - user_id: &UserId, -) -> Result { - if !self.is_enabled() { - return Ok(()); - } - - let child_rooms: Vec = self - .room_requirements - .read() - .await - .get(space_id) - .map(|reqs| reqs.keys().cloned().collect()) - .unwrap_or_default(); - - for child_room_id in &child_rooms { - if !self - .services - .state_cache - .is_joined(user_id, child_room_id) - .await - { - continue; - } - - if !self - .user_qualifies_for_room(space_id, child_room_id, user_id) - .await - { - // Kick the user by sending a leave membership event - // with reason "No longer has required Space roles" - } - } - - Ok(()) -} -``` - -**Step 3: Verify build** - -Run: `cargo check -p conduwuit-service 2>&1 | tail -20` -Expected: Compiles. - -**Step 4: Commit** - -```bash -git add src/service/rooms/roles/mod.rs -git commit -m "feat(spaces): add auto-join and auto-kick enforcement methods" -``` - ---- - -### Task 9: Hook State Event Changes for Enforcement - -**Files:** -- Modify: `src/service/rooms/timeline/append.rs` (add hooks after PDU append) - -**Step 1: Add enforcement hook after event append** - -In the `append_pdu()` function, after the event is successfully appended, add a check for space role events: - -```rust -// Space permission cascading: react to role-related state events -if self.services.roles.is_enabled() { - if let Some(state_key) = &pdu.state_key { - match pdu.event_type() { - // 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 - } - // 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 - .roles - .auto_join_qualifying_rooms(&pdu.room_id, &user_id) - .await?; - self.services - .roles - .kick_unqualified_from_rooms(&pdu.room_id, &user_id) - .await?; - // Sync power levels in all child rooms for this user - } - } - // 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 - } - } - // m.space.child added/removed -> update room_to_space index - t if t == StateEventType::SpaceChild.to_string() => { - self.services.roles.populate_space(&pdu.room_id).await?; - // If new child, auto-join qualifying members - } - // m.room.member join in a Space -> auto-join child rooms - t if t == StateEventType::RoomMember.to_string() => { - // Check if this room is a Space and user just joined - // If so, auto-join them to qualifying child rooms - } - _ => {} - } - } -} -``` - -**Step 2: Verify build** - -Run: `cargo check -p conduwuit-service 2>&1 | tail -20` -Expected: Compiles. - -**Step 3: Commit** - -```bash -git add src/service/rooms/timeline/append.rs -git commit -m "feat(spaces): hook state event changes for role enforcement" -``` - ---- - -### Task 10: Implement Cache Rebuild on Startup - -**Files:** -- Modify: `src/service/rooms/roles/mod.rs` (add `worker()` implementation) - -**Step 1: Add startup rebuild in the `worker()` method** - -In the `Service` trait impl, add: - -```rust -async fn worker(self: Arc) -> Result { - if !self.is_enabled() { - return Ok(()); - } - - // Find all spaces (rooms with type m.space) and populate cache - // Iterate all rooms, check room type, populate if space - // This can use rooms::metadata to list all rooms - - Ok(()) -} -``` - -**Step 2: Verify build** - -Run: `cargo check -p conduwuit-service 2>&1 | tail -20` -Expected: Compiles. - -**Step 3: Commit** - -```bash -git add src/service/rooms/roles/mod.rs -git commit -m "feat(spaces): rebuild role cache from state events on startup" -``` - ---- - -### Task 11: Add Admin Commands — Module Structure - -**Files:** -- Create: `src/admin/space/mod.rs` -- Create: `src/admin/space/commands.rs` -- Modify: `src/admin/mod.rs` (add module declaration) -- Modify: `src/admin/admin.rs` (add to AdminCommand enum) - -**Step 1: Create the command enum** - -Create `src/admin/space/mod.rs`: - -```rust -mod commands; - -use clap::Subcommand; -use ruma::{OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId}; - -use crate::admin_command_dispatch; - -#[admin_command_dispatch] -#[derive(Debug, Subcommand)] -pub enum SpaceCommand { - #[command(subcommand)] - /// Manage space roles and permissions - Roles(SpaceRolesCommand), -} - -#[admin_command_dispatch] -#[derive(Debug, Subcommand)] -pub enum SpaceRolesCommand { - /// List all roles defined in a space - List { - /// The space room ID or alias - space: OwnedRoomOrAliasId, - }, - - /// Add a new role to a space - Add { - /// The space room ID or alias - space: OwnedRoomOrAliasId, - - /// Role name - role_name: String, - - /// Human-readable description - #[arg(long)] - description: Option, - - /// Power level to grant in child rooms - #[arg(long)] - power_level: Option, - }, - - /// Remove a role from a space - Remove { - /// The space room ID or alias - space: OwnedRoomOrAliasId, - - /// Role name to remove - role_name: String, - }, - - /// Assign a role to a user - Assign { - /// The space room ID or alias - space: OwnedRoomOrAliasId, - - /// User to assign the role to - user_id: OwnedUserId, - - /// Role name to assign - role_name: String, - }, - - /// Revoke a role from a user - Revoke { - /// The space room ID or alias - space: OwnedRoomOrAliasId, - - /// User to revoke the role from - user_id: OwnedUserId, - - /// Role name to revoke - role_name: String, - }, - - /// Require a role for a room - Require { - /// The space room ID or alias - space: OwnedRoomOrAliasId, - - /// Child room ID - room_id: OwnedRoomId, - - /// Role name to require - role_name: String, - }, - - /// Remove a role requirement from a room - Unrequire { - /// The space room ID or alias - space: OwnedRoomOrAliasId, - - /// Child room ID - room_id: OwnedRoomId, - - /// Role name to remove from requirements - role_name: String, - }, - - /// Show a user's roles in a space - User { - /// The space room ID or alias - space: OwnedRoomOrAliasId, - - /// User to check - user_id: OwnedUserId, - }, - - /// Show a room's role requirements in a space - Room { - /// The space room ID or alias - space: OwnedRoomOrAliasId, - - /// Room to check - room_id: OwnedRoomId, - }, -} -``` - -**Step 2: Register in admin module** - -In `src/admin/mod.rs`, add: - -```rust -pub(crate) mod space; -``` - -In `src/admin/admin.rs`, add to imports: - -```rust -use crate::space::{self, SpaceCommand}; -``` - -Add to `AdminCommand` enum: - -```rust -#[command(subcommand)] -/// Commands for managing space permissions -Spaces(SpaceCommand), -``` - -Add to the `process()` match: - -```rust -| Spaces(command) => { - context.bail_restricted()?; - space::process(command, context).await -}, -``` - -**Step 3: Verify build** - -Run: `cargo check -p conduwuit-admin 2>&1 | tail -20` -Expected: Compiles (commands.rs can have stub implementations initially). - -**Step 4: Commit** - -```bash -git add src/admin/space/ src/admin/mod.rs src/admin/admin.rs -git commit -m "feat(spaces): add admin command structure for space role management" -``` - ---- - -### Task 12: Implement Admin Command Handlers - -**Files:** -- Modify: `src/admin/space/commands.rs` - -**Step 1: Implement the command handlers** - -Create `src/admin/space/commands.rs` with handlers for each command. Each handler should: - -1. Resolve the space room alias to ID -2. Read the current state event -3. Modify the content -4. Send the updated state event via `PduBuilder::state()` -5. Return a success message - -Example for the `list` command: - -```rust -use conduwuit::Result; -use conduwuit_core::matrix::space_roles::SpaceRolesEventContent; -use ruma::events::StateEventType; - -use crate::admin_command; - -#[admin_command] -pub(super) async fn list(&self, space: OwnedRoomOrAliasId) -> Result { - let space_id = self.services.rooms.alias.resolve(&space).await?; - - let content: SpaceRolesEventContent = self - .services - .rooms - .state_accessor - .room_state_get_content(&space_id, &StateEventType::from("com.continuwuity.space.roles"), "") - .await - .unwrap_or_default(); - - if content.roles.is_empty() { - return self.write_str("No roles defined in this space.").await; - } - - let mut output = String::from("Roles:\n"); - for (name, def) in &content.roles { - output.push_str(&format!( - "- **{}**: {} {}\n", - name, - def.description, - def.power_level - .map(|pl| format!("(PL {pl})")) - .unwrap_or_default() - )); - } - - self.write_str(&output).await -} -``` - -Implement similar handlers for: `add`, `remove`, `assign`, `revoke`, `require`, `unrequire`, `user`, `room`. - -Each mutation handler follows this pattern: -1. Read current state → modify → send updated state event via `PduBuilder::state()` -2. The state event hook (Task 9) will handle cache invalidation and enforcement - -**Step 2: Verify build** - -Run: `cargo check -p conduwuit-admin 2>&1 | tail -20` -Expected: Compiles. - -**Step 3: Commit** - -```bash -git add src/admin/space/commands.rs -git commit -m "feat(spaces): implement admin command handlers for space roles" -``` - ---- - -### Task 13: Implement Power Level Override Rejection - -**Files:** -- Modify: `src/service/rooms/timeline/append.rs` or `src/core/matrix/state_res/event_auth.rs` - -**Step 1: Add PL override rejection** - -When a `m.room.power_levels` event is submitted for a child room of a Space, check that it doesn't conflict with Space-granted power levels. If a user's PL is being set lower than their Space-granted level, reject the event. - -Add this check in the event append path or auth check path: - -```rust -// Reject power level changes that conflict with Space roles -if pdu.event_type() == StateEventType::RoomPowerLevels.to_string() { - if let Some(parent_space) = self.services.roles.get_parent_space(&pdu.room_id).await { - // Parse the proposed power levels - // For each user, check if proposed PL < Space-granted PL - // If so, reject - } -} -``` - -**Step 2: Verify build** - -Run: `cargo check -p conduwuit-service 2>&1 | tail -20` -Expected: Compiles. - -**Step 3: Commit** - -```bash -git add -A -git commit -m "feat(spaces): reject power level changes that conflict with space roles" -``` - ---- - -### Task 14: Unit Tests — Event Content Types - -**Files:** -- Modify: `src/core/matrix/space_roles.rs` (tests already added in Task 2, expand here) - -**Step 1: Add edge case and validation tests** - -Expand the `#[cfg(test)] mod tests` in `src/core/matrix/space_roles.rs`: - -```rust -#[test] -fn deserialize_role_with_power_level() { - let json = r#"{"description":"Admin","power_level":100}"#; - let role: RoleDefinition = serde_json::from_str(json).unwrap(); - assert_eq!(role.description, "Admin"); - assert_eq!(role.power_level, Some(100)); -} - -#[test] -fn deserialize_role_without_power_level() { - let json = r#"{"description":"NSFW access"}"#; - let role: RoleDefinition = serde_json::from_str(json).unwrap(); - assert!(role.power_level.is_none()); -} - -#[test] -fn power_level_omitted_in_serialization_when_none() { - let role = RoleDefinition { - description: "Test".to_owned(), - power_level: None, - }; - let json = serde_json::to_string(&role).unwrap(); - assert!(!json.contains("power_level")); -} - -#[test] -fn empty_member_roles() { - let content = SpaceRoleMemberEventContent { roles: vec![] }; - let json = serde_json::to_string(&content).unwrap(); - let deserialized: SpaceRoleMemberEventContent = serde_json::from_str(&json).unwrap(); - assert!(deserialized.roles.is_empty()); -} - -#[test] -fn empty_room_requirements() { - let content = SpaceRoleRoomEventContent { - required_roles: vec![], - }; - let json = serde_json::to_string(&content).unwrap(); - let deserialized: SpaceRoleRoomEventContent = serde_json::from_str(&json).unwrap(); - assert!(deserialized.required_roles.is_empty()); -} - -#[test] -fn roles_ordering_preserved() { - // BTreeMap maintains sorted order - let mut roles = BTreeMap::new(); - roles.insert("zebra".to_owned(), RoleDefinition { - description: "Z".to_owned(), - power_level: None, - }); - roles.insert("alpha".to_owned(), RoleDefinition { - description: "A".to_owned(), - power_level: None, - }); - let content = SpaceRolesEventContent { roles }; - let json = serde_json::to_string(&content).unwrap(); - let deserialized: SpaceRolesEventContent = serde_json::from_str(&json).unwrap(); - let keys: Vec<_> = deserialized.roles.keys().collect(); - assert_eq!(keys, vec!["alpha", "zebra"]); -} - -#[test] -fn negative_power_level() { - let json = r#"{"description":"Restricted","power_level":-10}"#; - let role: RoleDefinition = serde_json::from_str(json).unwrap(); - assert_eq!(role.power_level, Some(-10)); -} - -#[test] -fn unknown_fields_ignored() { - let json = r#"{"roles":["nsfw"],"extra_field":"ignored"}"#; - let content: SpaceRoleMemberEventContent = serde_json::from_str(json).unwrap(); - assert_eq!(content.roles, vec!["nsfw"]); -} -``` - -**Step 2: Run tests** - -Run: `cargo test -p conduwuit-core space_roles 2>&1 | tail -20` -Expected: All tests pass. - -**Step 3: Commit** - -```bash -git add src/core/matrix/space_roles.rs -git commit -m "test(spaces): expand unit tests for space role event content types" -``` - ---- - -### Task 15: Unit Tests — Roles Service Lookups - -**Files:** -- Create: `src/service/rooms/roles/tests.rs` -- Modify: `src/service/rooms/roles/mod.rs` (add `#[cfg(test)] mod tests;`) - -These tests operate directly on the cache structures without needing a running -server. They test the pure logic of lookups and qualification checks. - -**Step 1: Create test file with cache-based tests** - -Create `src/service/rooms/roles/tests.rs`: - -```rust -use std::collections::{BTreeMap, HashMap, HashSet}; - -use conduwuit_core::matrix::space_roles::RoleDefinition; -use ruma::{room_id, user_id, OwnedRoomId, OwnedUserId}; - -/// Helper to build a role definitions map. -fn make_roles(entries: &[(&str, Option)]) -> BTreeMap { - entries - .iter() - .map(|(name, pl)| { - ( - (*name).to_owned(), - RoleDefinition { - description: format!("{name} role"), - power_level: *pl, - }, - ) - }) - .collect() -} - -/// Helper to build a user roles set. -fn make_user_roles(roles: &[&str]) -> HashSet { - roles.iter().map(|s| (*s).to_owned()).collect() -} - -/// Helper to build room requirements set. -fn make_requirements(roles: &[&str]) -> HashSet { - roles.iter().map(|s| (*s).to_owned()).collect() -} - -// --- Power level calculation tests --- - -#[test] -fn power_level_single_role() { - let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50))]); - let user_assigned = make_user_roles(&["admin"]); - - let max_pl = user_assigned - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - - assert_eq!(max_pl, Some(100)); -} - -#[test] -fn power_level_multiple_roles_takes_highest() { - let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50)), ("helper", Some(25))]); - let user_assigned = make_user_roles(&["mod", "helper"]); - - let max_pl = user_assigned - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - - assert_eq!(max_pl, Some(50)); -} - -#[test] -fn power_level_no_power_roles() { - let roles = make_roles(&[("nsfw", None), ("vip", None)]); - let user_assigned = make_user_roles(&["nsfw", "vip"]); - - let max_pl = user_assigned - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - - assert_eq!(max_pl, None); -} - -#[test] -fn power_level_mixed_roles() { - let roles = make_roles(&[("mod", Some(50)), ("nsfw", None)]); - let user_assigned = make_user_roles(&["mod", "nsfw"]); - - let max_pl = user_assigned - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - - assert_eq!(max_pl, Some(50)); -} - -#[test] -fn power_level_no_roles_assigned() { - let roles = make_roles(&[("admin", Some(100))]); - let user_assigned: HashSet = HashSet::new(); - - let max_pl = user_assigned - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - - assert_eq!(max_pl, None); -} - -#[test] -fn power_level_unknown_role_ignored() { - let roles = make_roles(&[("admin", Some(100))]); - let user_assigned = make_user_roles(&["nonexistent"]); - - let max_pl = user_assigned - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - - assert_eq!(max_pl, None); -} - -// --- Room qualification tests --- - -#[test] -fn qualifies_with_all_required_roles() { - let required = make_requirements(&["nsfw", "vip"]); - let user_assigned = make_user_roles(&["nsfw", "vip", "extra"]); - - assert!(required.iter().all(|r| user_assigned.contains(r))); -} - -#[test] -fn does_not_qualify_missing_one_role() { - let required = make_requirements(&["nsfw", "vip"]); - let user_assigned = make_user_roles(&["nsfw"]); - - assert!(!required.iter().all(|r| user_assigned.contains(r))); -} - -#[test] -fn qualifies_with_no_requirements() { - let required: HashSet = HashSet::new(); - let user_assigned = make_user_roles(&["nsfw"]); - - assert!(required.iter().all(|r| user_assigned.contains(r))); -} - -#[test] -fn does_not_qualify_with_no_roles() { - let required = make_requirements(&["nsfw"]); - let user_assigned: HashSet = HashSet::new(); - - assert!(!required.iter().all(|r| user_assigned.contains(r))); -} - -#[test] -fn qualifies_empty_requirements_empty_roles() { - let required: HashSet = HashSet::new(); - let user_assigned: HashSet = HashSet::new(); - - // No requirements means everyone qualifies - assert!(required.iter().all(|r| user_assigned.contains(r))); -} - -// --- Reverse lookup tests --- - -#[test] -fn room_to_space_lookup() { - 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 - ); -} - -// --- Default roles tests --- - -#[test] -fn default_roles_contain_admin_and_mod() { - let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50))]); - - assert!(roles.contains_key("admin")); - assert!(roles.contains_key("mod")); - assert_eq!(roles["admin"].power_level, Some(100)); - assert_eq!(roles["mod"].power_level, Some(50)); -} -``` - -**Step 2: Register the test module** - -In `src/service/rooms/roles/mod.rs`, add near the top: - -```rust -#[cfg(test)] -mod tests; -``` - -**Step 3: Run tests** - -Run: `cargo test -p conduwuit-service roles::tests 2>&1 | tail -20` -Expected: All tests pass. - -**Step 4: Commit** - -```bash -git add src/service/rooms/roles/tests.rs src/service/rooms/roles/mod.rs -git commit -m "test(spaces): add unit tests for space roles service lookups" -``` - ---- - -### Task 16: Unit Tests — Admin Commands - -**Files:** -- Create: `src/admin/space/tests.rs` - -Tests that the clap command parsing works correctly for all space role commands. -Follows the pattern in `src/admin/tests.rs`. - -**Step 1: Create admin command parsing tests** - -Create `src/admin/space/tests.rs`: - -```rust -use clap::Parser; - -use super::{SpaceCommand, SpaceRolesCommand}; -use crate::admin::AdminCommand; - -fn parse(input: &str) -> AdminCommand { - let argv = std::iter::once("admin").chain(input.split_whitespace()); - AdminCommand::parse_from(argv) -} - -#[test] -fn parse_roles_list() { - let cmd = parse("spaces roles list !space:example.com"); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::List { .. })) - )); -} - -#[test] -fn parse_roles_add_basic() { - let cmd = parse("spaces roles add !space:example.com nsfw"); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Add { .. })) - )); -} - -#[test] -fn parse_roles_add_with_power_level() { - let cmd = parse( - "spaces roles add !space:example.com helper --description \"Helper role\" --power-level 25" - ); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Add { .. })) - )); -} - -#[test] -fn parse_roles_assign() { - let cmd = parse("spaces roles assign !space:example.com @alice:example.com nsfw"); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Assign { .. })) - )); -} - -#[test] -fn parse_roles_revoke() { - let cmd = parse("spaces roles revoke !space:example.com @alice:example.com nsfw"); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Revoke { .. })) - )); -} - -#[test] -fn parse_roles_require() { - let cmd = parse("spaces roles require !space:example.com !room:example.com nsfw"); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Require { .. })) - )); -} - -#[test] -fn parse_roles_unrequire() { - let cmd = parse("spaces roles unrequire !space:example.com !room:example.com nsfw"); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Unrequire { .. })) - )); -} - -#[test] -fn parse_roles_user() { - let cmd = parse("spaces roles user !space:example.com @alice:example.com"); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::User { .. })) - )); -} - -#[test] -fn parse_roles_room() { - let cmd = parse("spaces roles room !space:example.com !room:example.com"); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Room { .. })) - )); -} - -#[test] -fn parse_roles_remove() { - let cmd = parse("spaces roles remove !space:example.com nsfw"); - assert!(matches!( - cmd, - AdminCommand::Spaces(SpaceCommand::Roles(SpaceRolesCommand::Remove { .. })) - )); -} -``` - -**Step 2: Run tests** - -Run: `cargo test -p conduwuit-admin space::tests 2>&1 | tail -20` -Expected: All tests pass. - -**Step 3: Commit** - -```bash -git add src/admin/space/tests.rs -git commit -m "test(spaces): add admin command parsing tests for space roles" -``` - ---- - -### Task 17: Integration Tests — Enforcement Scenarios - -**Files:** -- Create: `src/service/rooms/roles/integration_tests.rs` - -These tests validate the enforcement logic end-to-end by operating on the cache -structures and verifying the expected outcomes. Since the full service stack -requires a running server, these tests mock the data layer and test the -decision-making logic. - -**Step 1: Create enforcement scenario tests** - -Create `src/service/rooms/roles/integration_tests.rs`: - -```rust -//! Integration-style tests for space permission cascading enforcement logic. -//! -//! These tests verify the correctness of enforcement decisions by operating -//! on the in-memory cache structures directly. They test multi-step scenarios -//! that span role assignment, room requirements, and qualification checks. - -use std::collections::{BTreeMap, HashMap, HashSet}; - -use conduwuit_core::matrix::space_roles::RoleDefinition; -use ruma::{room_id, user_id}; - -use super::tests::{make_requirements, make_roles, make_user_roles}; - -// --- Scenario: Full lifecycle of role-based access --- - -#[test] -fn scenario_user_gains_and_loses_access() { - let space_id = room_id!("!space:example.com"); - let nsfw_room = room_id!("!nsfw:example.com"); - let alice = user_id!("@alice:example.com"); - - let roles = make_roles(&[("admin", Some(100)), ("nsfw", None)]); - let room_reqs = make_requirements(&["nsfw"]); - - // Step 1: Alice has no roles -> cannot access NSFW room - let alice_roles: HashSet = HashSet::new(); - assert!(!room_reqs.iter().all(|r| alice_roles.contains(r))); - - // Step 2: Alice gets nsfw role -> can access - let alice_roles = make_user_roles(&["nsfw"]); - assert!(room_reqs.iter().all(|r| alice_roles.contains(r))); - - // Step 3: Alice loses nsfw role -> cannot access again - let alice_roles: HashSet = HashSet::new(); - assert!(!room_reqs.iter().all(|r| alice_roles.contains(r))); -} - -#[test] -fn scenario_room_adds_requirement_existing_members_checked() { - let alice = user_id!("@alice:example.com"); - let bob = user_id!("@bob:example.com"); - - let alice_roles = make_user_roles(&["vip"]); - let bob_roles = make_user_roles(&["vip", "nsfw"]); - - // Room initially has no requirements -> both qualify - let empty_reqs: HashSet = HashSet::new(); - assert!(empty_reqs.iter().all(|r| alice_roles.contains(r))); - assert!(empty_reqs.iter().all(|r| bob_roles.contains(r))); - - // Room adds nsfw requirement -> only Bob qualifies - let new_reqs = make_requirements(&["nsfw"]); - assert!(!new_reqs.iter().all(|r| alice_roles.contains(r))); - assert!(new_reqs.iter().all(|r| bob_roles.contains(r))); -} - -#[test] -fn scenario_multiple_rooms_different_requirements() { - let alice_roles = make_user_roles(&["nsfw", "vip"]); - let bob_roles = make_user_roles(&["nsfw"]); - - let nsfw_room_reqs = make_requirements(&["nsfw"]); - let vip_room_reqs = make_requirements(&["vip"]); - let both_room_reqs = make_requirements(&["nsfw", "vip"]); - - // Alice qualifies for all three rooms - assert!(nsfw_room_reqs.iter().all(|r| alice_roles.contains(r))); - assert!(vip_room_reqs.iter().all(|r| alice_roles.contains(r))); - assert!(both_room_reqs.iter().all(|r| alice_roles.contains(r))); - - // Bob only qualifies for the nsfw room - assert!(nsfw_room_reqs.iter().all(|r| bob_roles.contains(r))); - assert!(!vip_room_reqs.iter().all(|r| bob_roles.contains(r))); - assert!(!both_room_reqs.iter().all(|r| bob_roles.contains(r))); -} - -// --- Scenario: Power level cascading --- - -#[test] -fn scenario_power_level_cascading_highest_wins() { - let roles = make_roles(&[ - ("admin", Some(100)), - ("mod", Some(50)), - ("helper", Some(25)), - ]); - - // User with admin + mod -> PL 100 - let admin_mod = make_user_roles(&["admin", "mod"]); - let pl = admin_mod - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - assert_eq!(pl, Some(100)); - - // User with just helper -> PL 25 - let helper = make_user_roles(&["helper"]); - let pl = helper - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - assert_eq!(pl, Some(25)); - - // User with nsfw (no PL) + mod -> PL 50 - let roles_with_nsfw = make_roles(&[ - ("mod", Some(50)), - ("nsfw", None), - ]); - let mod_nsfw = make_user_roles(&["mod", "nsfw"]); - let pl = mod_nsfw - .iter() - .filter_map(|r| roles_with_nsfw.get(r)?.power_level) - .max(); - assert_eq!(pl, Some(50)); -} - -#[test] -fn scenario_power_level_override_space_always_wins() { - let roles = make_roles(&[("mod", Some(50))]); - let user_assigned = make_user_roles(&["mod"]); - - let space_pl = user_assigned - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max() - .unwrap(); - - // Simulate: room has user at PL 0, Space says PL 50 - let room_pl: i64 = 0; - // Space always wins - let effective_pl = space_pl.max(room_pl); - assert_eq!(effective_pl, 50); - - // Even if room tries to set PL 100, Space role of 50 overrides - // (Space always wins means Space PL is authoritative, not max) - assert_eq!(space_pl, 50); -} - -// --- Scenario: Role definition changes --- - -#[test] -fn scenario_role_removed_from_definitions() { - let mut roles = make_roles(&[("admin", Some(100)), ("nsfw", None)]); - let user_assigned = make_user_roles(&["nsfw"]); - let room_reqs = make_requirements(&["nsfw"]); - - // User qualifies before role removal - assert!(room_reqs.iter().all(|r| user_assigned.contains(r))); - - // Remove nsfw from definitions - roles.remove("nsfw"); - - // User still has "nsfw" in their assignment but the role doesn't exist - // Qualification should check against defined roles - let qualifies = room_reqs.iter().all(|r| { - user_assigned.contains(r) && roles.contains_key(r) - }); - assert!(!qualifies); -} - -#[test] -fn scenario_role_power_level_changed() { - let mut roles = make_roles(&[("mod", Some(50))]); - let user_assigned = make_user_roles(&["mod"]); - - let pl_before = user_assigned - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - assert_eq!(pl_before, Some(50)); - - // Change mod PL to 75 - roles.get_mut("mod").unwrap().power_level = Some(75); - - let pl_after = user_assigned - .iter() - .filter_map(|r| roles.get(r)?.power_level) - .max(); - assert_eq!(pl_after, Some(75)); -} - -// --- Scenario: Multiple spaces (no cascade) --- - -#[test] -fn scenario_roles_do_not_cascade_across_spaces() { - let space_a = room_id!("!space_a:example.com"); - let space_b = room_id!("!space_b:example.com"); - let alice = user_id!("@alice:example.com"); - - // Space A gives Alice nsfw role - let mut space_user_roles: HashMap<_, HashMap<_, HashSet>> = HashMap::new(); - space_user_roles - .entry(space_a.to_owned()) - .or_default() - .insert(alice.to_owned(), make_user_roles(&["nsfw"])); - - // Space B room requires nsfw - let mut space_room_reqs: HashMap<_, HashMap<_, HashSet>> = HashMap::new(); - let child_of_b = room_id!("!child_b:example.com"); - space_room_reqs - .entry(space_b.to_owned()) - .or_default() - .insert(child_of_b.to_owned(), make_requirements(&["nsfw"])); - - // Alice has nsfw in Space A but NOT in Space B - let alice_roles_in_b = space_user_roles - .get(space_b) - .and_then(|users| users.get(alice)); - assert!(alice_roles_in_b.is_none()); - - // So Alice does NOT qualify for child_of_b - let reqs = &space_room_reqs[space_b][child_of_b]; - let qualifies = match alice_roles_in_b { - Some(assigned) => reqs.iter().all(|r| assigned.contains(r)), - None => reqs.is_empty(), - }; - assert!(!qualifies); -} - -// --- Scenario: Auto-join candidate identification --- - -#[test] -fn scenario_identify_auto_join_candidates() { - let alice_roles = make_user_roles(&["nsfw", "vip"]); - - let mut room_requirements: HashMap> = HashMap::new(); - room_requirements.insert("general".to_owned(), HashSet::new()); // no reqs - room_requirements.insert("nsfw-chat".to_owned(), make_requirements(&["nsfw"])); - room_requirements.insert("vip-lounge".to_owned(), make_requirements(&["vip"])); - room_requirements.insert("staff-only".to_owned(), make_requirements(&["staff"])); - - let qualifying_rooms: Vec<_> = room_requirements - .iter() - .filter(|(_, reqs)| reqs.iter().all(|r| alice_roles.contains(r))) - .map(|(name, _)| name.clone()) - .collect(); - - // Alice should qualify for: general (no reqs), nsfw-chat, vip-lounge - // But NOT staff-only - assert!(qualifying_rooms.contains(&"general".to_owned())); - assert!(qualifying_rooms.contains(&"nsfw-chat".to_owned())); - assert!(qualifying_rooms.contains(&"vip-lounge".to_owned())); - assert!(!qualifying_rooms.contains(&"staff-only".to_owned())); -} - -// --- Scenario: Kick candidate identification --- - -#[test] -fn scenario_identify_kick_candidates_after_role_revocation() { - // Before: Alice has nsfw + vip - // After: Alice only has vip (nsfw revoked) - - let alice_roles_after = make_user_roles(&["vip"]); - - let mut rooms_alice_is_in: HashMap> = HashMap::new(); - rooms_alice_is_in.insert("general".to_owned(), HashSet::new()); - rooms_alice_is_in.insert("nsfw-chat".to_owned(), make_requirements(&["nsfw"])); - rooms_alice_is_in.insert("vip-lounge".to_owned(), make_requirements(&["vip"])); - rooms_alice_is_in.insert( - "nsfw-vip".to_owned(), - make_requirements(&["nsfw", "vip"]), - ); - - let kick_from: Vec<_> = rooms_alice_is_in - .iter() - .filter(|(_, reqs)| !reqs.iter().all(|r| alice_roles_after.contains(r))) - .map(|(name, _)| name.clone()) - .collect(); - - // Should be kicked from: nsfw-chat and nsfw-vip - assert!(kick_from.contains(&"nsfw-chat".to_owned())); - assert!(kick_from.contains(&"nsfw-vip".to_owned())); - assert!(!kick_from.contains(&"general".to_owned())); - assert!(!kick_from.contains(&"vip-lounge".to_owned())); -} -``` - -**Step 2: Register the test module** - -In `src/service/rooms/roles/mod.rs`, add: - -```rust -#[cfg(test)] -mod integration_tests; -``` - -**Step 3: Run tests** - -Run: `cargo test -p conduwuit-service roles::integration_tests 2>&1 | tail -30` -Expected: All tests pass. - -**Step 4: Commit** - -```bash -git add src/service/rooms/roles/integration_tests.rs src/service/rooms/roles/mod.rs -git commit -m "test(spaces): add integration tests for enforcement scenarios" -``` - ---- - -### Task 18: Integration Tests — Cache Consistency - -**Files:** -- Create: `src/service/rooms/roles/cache_tests.rs` - -Tests that verify cache operations behave correctly: population, invalidation, -and consistency between the four index structures. - -**Step 1: Create cache consistency tests** - -Create `src/service/rooms/roles/cache_tests.rs`: - -```rust -//! Tests for cache consistency of the space roles index structures. - -use std::collections::{BTreeMap, HashMap, HashSet}; - -use conduwuit_core::matrix::space_roles::RoleDefinition; -use ruma::{room_id, user_id, OwnedRoomId, OwnedUserId}; - -use super::tests::{make_requirements, make_roles, make_user_roles}; - -/// Simulates the full cache state for a space. -struct MockCache { - roles: HashMap>, - user_roles: HashMap>>, - room_requirements: HashMap>>, - room_to_space: HashMap, -} - -impl MockCache { - fn new() -> Self { - Self { - roles: HashMap::new(), - user_roles: HashMap::new(), - room_requirements: HashMap::new(), - room_to_space: HashMap::new(), - } - } - - fn add_space(&mut self, space: OwnedRoomId, roles: BTreeMap) { - self.roles.insert(space, roles); - } - - fn add_child(&mut self, space: &OwnedRoomId, child: OwnedRoomId) { - self.room_to_space.insert(child, space.clone()); - } - - fn assign_role(&mut self, space: &OwnedRoomId, user: OwnedUserId, role: String) { - self.user_roles - .entry(space.clone()) - .or_default() - .entry(user) - .or_default() - .insert(role); - } - - fn revoke_role(&mut self, space: &OwnedRoomId, user: &OwnedUserId, role: &str) { - if let Some(space_users) = self.user_roles.get_mut(space) { - if let Some(user_roles) = space_users.get_mut(user) { - user_roles.remove(role); - } - } - } - - fn set_room_requirements( - &mut self, - space: &OwnedRoomId, - room: OwnedRoomId, - reqs: HashSet, - ) { - self.room_requirements - .entry(space.clone()) - .or_default() - .insert(room, reqs); - } - - fn user_qualifies(&self, space: &OwnedRoomId, room: &OwnedRoomId, user: &OwnedUserId) -> bool { - let reqs = self - .room_requirements - .get(space) - .and_then(|r| r.get(room)); - - match reqs { - None => true, - Some(required) if required.is_empty() => true, - Some(required) => { - let assigned = self - .user_roles - .get(space) - .and_then(|u| u.get(user)); - match assigned { - None => false, - Some(roles) => required.iter().all(|r| roles.contains(r)), - } - } - } - } - - fn get_power_level(&self, space: &OwnedRoomId, user: &OwnedUserId) -> Option { - let role_defs = self.roles.get(space)?; - let assigned = self.user_roles.get(space)?.get(user)?; - assigned - .iter() - .filter_map(|r| role_defs.get(r)?.power_level) - .max() - } - - fn clear(&mut self) { - self.roles.clear(); - self.user_roles.clear(); - self.room_requirements.clear(); - self.room_to_space.clear(); - } -} - -#[test] -fn cache_populate_and_lookup() { - let mut cache = MockCache::new(); - let space = room_id!("!space:example.com").to_owned(); - let child = room_id!("!child:example.com").to_owned(); - let alice = user_id!("@alice:example.com").to_owned(); - - cache.add_space(space.clone(), make_roles(&[("admin", Some(100)), ("nsfw", None)])); - cache.add_child(&space, child.clone()); - cache.assign_role(&space, alice.clone(), "nsfw".to_owned()); - cache.set_room_requirements(&space, child.clone(), make_requirements(&["nsfw"])); - - assert!(cache.user_qualifies(&space, &child, &alice)); - assert_eq!(cache.get_power_level(&space, &alice), None); // nsfw has no PL -} - -#[test] -fn cache_invalidation_on_role_revoke() { - let mut cache = MockCache::new(); - let space = room_id!("!space:example.com").to_owned(); - let child = room_id!("!nsfw:example.com").to_owned(); - let alice = user_id!("@alice:example.com").to_owned(); - - cache.add_space(space.clone(), make_roles(&[("nsfw", None)])); - cache.assign_role(&space, alice.clone(), "nsfw".to_owned()); - cache.set_room_requirements(&space, child.clone(), make_requirements(&["nsfw"])); - - assert!(cache.user_qualifies(&space, &child, &alice)); - - // Revoke - cache.revoke_role(&space, &alice, "nsfw"); - assert!(!cache.user_qualifies(&space, &child, &alice)); -} - -#[test] -fn cache_invalidation_on_requirement_change() { - let mut cache = MockCache::new(); - let space = room_id!("!space:example.com").to_owned(); - let child = room_id!("!room:example.com").to_owned(); - let alice = user_id!("@alice:example.com").to_owned(); - - cache.add_space(space.clone(), make_roles(&[("nsfw", None), ("vip", None)])); - cache.assign_role(&space, alice.clone(), "vip".to_owned()); - cache.set_room_requirements(&space, child.clone(), make_requirements(&["vip"])); - - assert!(cache.user_qualifies(&space, &child, &alice)); - - // Add nsfw requirement - cache.set_room_requirements( - &space, - child.clone(), - make_requirements(&["vip", "nsfw"]), - ); - assert!(!cache.user_qualifies(&space, &child, &alice)); -} - -#[test] -fn cache_clear_empties_all() { - let mut cache = MockCache::new(); - let space = room_id!("!space:example.com").to_owned(); - cache.add_space(space.clone(), make_roles(&[("admin", Some(100))])); - cache.assign_role( - &space, - user_id!("@alice:example.com").to_owned(), - "admin".to_owned(), - ); - - cache.clear(); - - assert!(cache.roles.is_empty()); - assert!(cache.user_roles.is_empty()); - assert!(cache.room_requirements.is_empty()); - assert!(cache.room_to_space.is_empty()); -} - -#[test] -fn cache_reverse_lookup_consistency() { - let mut cache = MockCache::new(); - let space = room_id!("!space:example.com").to_owned(); - let child1 = room_id!("!child1:example.com").to_owned(); - let child2 = room_id!("!child2:example.com").to_owned(); - - 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 - ); -} - -#[test] -fn cache_power_level_updates_on_role_change() { - let mut cache = MockCache::new(); - let space = room_id!("!space:example.com").to_owned(); - let alice = user_id!("@alice:example.com").to_owned(); - - cache.add_space( - space.clone(), - make_roles(&[("admin", Some(100)), ("mod", Some(50))]), - ); - - // No roles -> no PL - assert_eq!(cache.get_power_level(&space, &alice), None); - - // Assign mod -> PL 50 - cache.assign_role(&space, alice.clone(), "mod".to_owned()); - assert_eq!(cache.get_power_level(&space, &alice), Some(50)); - - // Also assign admin -> PL 100 (highest wins) - cache.assign_role(&space, alice.clone(), "admin".to_owned()); - assert_eq!(cache.get_power_level(&space, &alice), Some(100)); - - // Revoke admin -> back to PL 50 - cache.revoke_role(&space, &alice, "admin"); - assert_eq!(cache.get_power_level(&space, &alice), Some(50)); -} -``` - -**Step 2: Register the test module** - -In `src/service/rooms/roles/mod.rs`, add: - -```rust -#[cfg(test)] -mod cache_tests; -``` - -**Step 3: Run tests** - -Run: `cargo test -p conduwuit-service roles::cache_tests 2>&1 | tail -30` -Expected: All tests pass. - -**Step 4: Commit** - -```bash -git add src/service/rooms/roles/cache_tests.rs src/service/rooms/roles/mod.rs -git commit -m "test(spaces): add cache consistency tests for space roles" -``` - ---- - -### Task 19: Documentation - -**Files:** -- Modify: `docs/plans/2026-03-17-space-permission-cascading-design.md` (mark as implemented) - -**Step 1: Update design doc status** - -Change `**Status:** Approved` to `**Status:** Implemented` - -**Step 2: Commit** - -```bash -git add docs/plans/2026-03-17-space-permission-cascading-design.md -git commit -m "docs: mark space permission cascading design as implemented" -``` - ---- - -## Task Dependency Graph - -``` -Task 1 (config flag) - └─> Task 2 (event types + serde tests) - ├─> Task 14 (expanded event type tests) - └─> Task 3 (service skeleton) - └─> Task 4 (cache + lookups) - ├─> Task 5 (default roles) - ├─> Task 6 (join gating) - ├─> Task 7 (PL override) - ├─> Task 8 (auto-join/kick) - │ └─> Task 9 (state event hooks) - │ └─> Task 10 (startup rebuild) - ├─> Task 13 (PL rejection) - ├─> Task 15 (service lookup unit tests) - ├─> Task 17 (enforcement integration tests) - ├─> Task 18 (cache consistency tests) - └─> Task 11 (admin cmd structure) - └─> Task 12 (admin cmd handlers) - └─> Task 16 (admin cmd parsing tests) -Task 19 (docs) - final -``` - -Tasks 5-8, 11, 13-15, 17-18 can be worked on in parallel after Task 4 is complete. diff --git a/src/admin/space/roles.rs b/src/admin/space/roles.rs index ca7e8690..0be207c9 100644 --- a/src/admin/space/roles.rs +++ b/src/admin/space/roles.rs @@ -1,18 +1,16 @@ use std::fmt::Write; use clap::Subcommand; -use conduwuit::{Err, Event, Result}; +use conduwuit::{Err, Event, Result, matrix::pdu::PduBuilder}; use conduwuit_core::matrix::space_roles::{ - RoleDefinition, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, - SpaceRolesEventContent, SPACE_ROLES_EVENT_TYPE, SPACE_ROLE_MEMBER_EVENT_TYPE, - SPACE_ROLE_ROOM_EVENT_TYPE, + RoleDefinition, SPACE_ROLE_MEMBER_EVENT_TYPE, SPACE_ROLE_ROOM_EVENT_TYPE, + SPACE_ROLES_EVENT_TYPE, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, + SpaceRolesEventContent, }; +use futures::StreamExt; use ruma::{OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId, events::StateEventType}; use serde_json::value::to_raw_value; -use conduwuit::matrix::pdu::PduBuilder; -use futures::StreamExt; - use crate::{admin_command, admin_command_dispatch}; macro_rules! require_enabled { @@ -20,8 +18,8 @@ macro_rules! require_enabled { if !$self.services.rooms.roles.is_enabled() { return $self .write_str( - "Space permission cascading is disabled. \ - Enable it with `space_permission_cascading = true` in your config.", + "Space permission cascading is disabled. Enable it with \ + `space_permission_cascading = true` in your config.", ) .await; } @@ -51,10 +49,11 @@ macro_rules! custom_state_pdu { ($event_type:expr, $state_key:expr, $content:expr) => { PduBuilder { event_type: $event_type.to_owned().into(), - content: to_raw_value($content) - .map_err(|e| conduwuit::Error::Err(format!( - "Failed to serialize custom state event content: {e}" - ).into()))?, + content: to_raw_value($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() } @@ -244,9 +243,7 @@ async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result { 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::() - { + if let Ok(mut member_content) = pdu.get_content::() { if member_content.roles.contains(&role_name) { member_content.roles.retain(|r| r != &role_name); self.services @@ -281,9 +278,7 @@ async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result { 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::() - { + if let Ok(mut room_content) = pdu.get_content::() { if room_content.required_roles.contains(&role_name) { room_content.required_roles.retain(|r| r != &role_name); self.services @@ -363,10 +358,8 @@ async fn assign( ) .await?; - self.write_str(&format!( - "Assigned role '{role_name}' to {user_id} in space {space_id}." - )) - .await + self.write_str(&format!("Assigned role '{role_name}' to {user_id} in space {space_id}.")) + .await } #[admin_command] @@ -408,10 +401,8 @@ async fn revoke( ) .await?; - self.write_str(&format!( - "Revoked role '{role_name}' from {user_id} in space {space_id}." - )) - .await + self.write_str(&format!("Revoked role '{role_name}' from {user_id} in space {space_id}.")) + .await } #[admin_command] @@ -540,10 +531,9 @@ async fn user(&self, space: OwnedRoomOrAliasId, user_id: OwnedUserId) -> Result )) .await }, - | _ => { + | _ => self.write_str(&format!("User {user_id} has no roles in space {space_id}.")) - .await - }, + .await, } } @@ -569,11 +559,10 @@ async fn room(&self, space: OwnedRoomOrAliasId, room_id: OwnedRoomId) -> Result )) .await }, - | _ => { + | _ => self.write_str(&format!( "Room {room_id} has no role requirements in space {space_id}." )) - .await - }, + .await, } } diff --git a/src/core/matrix/space_roles.rs b/src/core/matrix/space_roles.rs index 4ee1a03a..2857f5c8 100644 --- a/src/core/matrix/space_roles.rs +++ b/src/core/matrix/space_roles.rs @@ -58,20 +58,14 @@ mod tests { #[test] fn serialize_space_roles() { let mut roles = BTreeMap::new(); - roles.insert( - "admin".to_owned(), - RoleDefinition { - description: "Space administrator".to_owned(), - power_level: Some(100), - }, - ); - roles.insert( - "nsfw".to_owned(), - RoleDefinition { - description: "NSFW access".to_owned(), - power_level: None, - }, - ); + roles.insert("admin".to_owned(), RoleDefinition { + description: "Space administrator".to_owned(), + power_level: Some(100), + }); + roles.insert("nsfw".to_owned(), RoleDefinition { + description: "NSFW access".to_owned(), + power_level: None, + }); let content = SpaceRolesEventContent { roles }; let json = serde_json::to_string(&content).unwrap(); let deserialized: SpaceRolesEventContent = serde_json::from_str(&json).unwrap(); @@ -92,9 +86,7 @@ mod tests { #[test] fn serialize_role_room() { - let content = SpaceRoleRoomEventContent { - required_roles: vec!["nsfw".to_owned()], - }; + let content = SpaceRoleRoomEventContent { required_roles: vec!["nsfw".to_owned()] }; let json = serde_json::to_string(&content).unwrap(); let deserialized: SpaceRoleRoomEventContent = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.required_roles, vec!["nsfw"]); @@ -142,9 +134,7 @@ mod tests { #[test] fn empty_room_requirements() { - let content = SpaceRoleRoomEventContent { - required_roles: vec![], - }; + let content = SpaceRoleRoomEventContent { required_roles: vec![] }; let json = serde_json::to_string(&content).unwrap(); let deserialized: SpaceRoleRoomEventContent = serde_json::from_str(&json).unwrap(); assert!(deserialized.required_roles.is_empty()); diff --git a/src/service/rooms/roles/cache_tests.rs b/src/service/rooms/roles/cache_tests.rs index bb869e20..6e709351 100644 --- a/src/service/rooms/roles/cache_tests.rs +++ b/src/service/rooms/roles/cache_tests.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use conduwuit_core::matrix::space_roles::RoleDefinition; -use ruma::{room_id, user_id, OwnedRoomId, OwnedUserId}; +use ruma::{OwnedRoomId, OwnedUserId, room_id, user_id}; use super::tests::{make_requirements, make_roles, make_user_roles}; @@ -75,10 +75,7 @@ impl MockCache { room: &OwnedRoomId, user: &OwnedUserId, ) -> bool { - let reqs = self - .room_requirements - .get(space) - .and_then(|r| r.get(room)); + let reqs = self.room_requirements.get(space).and_then(|r| r.get(room)); match reqs { | None => true, @@ -117,10 +114,7 @@ fn cache_populate_and_lookup() { let child = room_id!("!child:example.com").to_owned(); let alice = user_id!("@alice:example.com").to_owned(); - cache.add_space( - space.clone(), - make_roles(&[("admin", Some(100)), ("nsfw", None)]), - ); + cache.add_space(space.clone(), make_roles(&[("admin", Some(100)), ("nsfw", None)])); cache.add_child(&space, child.clone()); cache.assign_role(&space, alice.clone(), "nsfw".to_owned()); cache.set_room_requirements(&space, child.clone(), make_requirements(&["nsfw"])); @@ -154,21 +148,14 @@ fn cache_invalidation_on_requirement_change() { let child = room_id!("!room:example.com").to_owned(); let alice = user_id!("@alice:example.com").to_owned(); - cache.add_space( - space.clone(), - make_roles(&[("nsfw", None), ("vip", None)]), - ); + cache.add_space(space.clone(), make_roles(&[("nsfw", None), ("vip", None)])); cache.assign_role(&space, alice.clone(), "vip".to_owned()); cache.set_room_requirements(&space, child.clone(), make_requirements(&["vip"])); assert!(cache.user_qualifies(&space, &child, &alice)); // Add nsfw requirement - cache.set_room_requirements( - &space, - child.clone(), - make_requirements(&["vip", "nsfw"]), - ); + cache.set_room_requirements(&space, child.clone(), make_requirements(&["vip", "nsfw"])); assert!(!cache.user_qualifies(&space, &child, &alice)); } @@ -177,11 +164,7 @@ fn cache_clear_empties_all() { let mut cache = MockCache::new(); let space = room_id!("!space:example.com").to_owned(); cache.add_space(space.clone(), make_roles(&[("admin", Some(100))])); - cache.assign_role( - &space, - user_id!("@alice:example.com").to_owned(), - "admin".to_owned(), - ); + cache.assign_role(&space, user_id!("@alice:example.com").to_owned(), "admin".to_owned()); cache.clear(); @@ -204,7 +187,10 @@ fn cache_reverse_lookup_consistency() { 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() + cache + .room_to_space + .get(room_id!("!unknown:example.com")) + .is_none() ); } @@ -214,10 +200,7 @@ fn cache_power_level_updates_on_role_change() { let space = room_id!("!space:example.com").to_owned(); let alice = user_id!("@alice:example.com").to_owned(); - cache.add_space( - space.clone(), - make_roles(&[("admin", Some(100)), ("mod", Some(50))]), - ); + cache.add_space(space.clone(), make_roles(&[("admin", Some(100)), ("mod", Some(50))])); // No roles -> no PL assert_eq!(cache.get_power_level(&space, &alice), None); diff --git a/src/service/rooms/roles/integration_tests.rs b/src/service/rooms/roles/integration_tests.rs index d5f8f3d2..dbdbe547 100644 --- a/src/service/rooms/roles/integration_tests.rs +++ b/src/service/rooms/roles/integration_tests.rs @@ -2,8 +2,10 @@ use std::collections::{HashMap, HashSet}; use ruma::{room_id, user_id}; -use super::{compute_user_power_level, roles_satisfy_requirements}; -use super::tests::{make_requirements, make_roles, make_user_roles}; +use super::{ + compute_user_power_level, roles_satisfy_requirements, + tests::{make_requirements, make_roles, make_user_roles}, +}; #[test] fn scenario_user_gains_and_loses_access() { @@ -53,11 +55,7 @@ fn scenario_multiple_rooms_different_requirements() { #[test] fn scenario_power_level_cascading_highest_wins() { - let roles = make_roles(&[ - ("admin", Some(100)), - ("mod", Some(50)), - ("helper", Some(25)), - ]); + let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50)), ("helper", Some(25))]); let admin_mod = make_user_roles(&["admin", "mod"]); assert_eq!(compute_user_power_level(&roles, &admin_mod), Some(100)); @@ -114,10 +112,7 @@ fn scenario_identify_kick_candidates_after_role_revocation() { rooms.insert("general".to_owned(), HashSet::new()); rooms.insert("nsfw-chat".to_owned(), make_requirements(&["nsfw"])); rooms.insert("vip-lounge".to_owned(), make_requirements(&["vip"])); - rooms.insert( - "nsfw-vip".to_owned(), - make_requirements(&["nsfw", "vip"]), - ); + rooms.insert("nsfw-vip".to_owned(), make_requirements(&["nsfw", "vip"])); let kick_from: Vec<_> = rooms .iter() diff --git a/src/service/rooms/roles/mod.rs b/src/service/rooms/roles/mod.rs index bb973ba8..daf6511d 100644 --- a/src/service/rooms/roles/mod.rs +++ b/src/service/rooms/roles/mod.rs @@ -13,15 +13,13 @@ use std::{ use async_trait::async_trait; use conduwuit::{ - Event, Result, Server, debug, debug_warn, implement, info, - matrix::pdu::PduBuilder, - warn, + Event, Result, Server, debug, debug_warn, implement, info, matrix::pdu::PduBuilder, warn, }; use conduwuit_core::{ matrix::space_roles::{ - RoleDefinition, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, - SpaceRolesEventContent, SPACE_ROLES_EVENT_TYPE, SPACE_ROLE_MEMBER_EVENT_TYPE, - SPACE_ROLE_ROOM_EVENT_TYPE, + RoleDefinition, SPACE_ROLE_MEMBER_EVENT_TYPE, SPACE_ROLE_ROOM_EVENT_TYPE, + SPACE_ROLES_EVENT_TYPE, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, + SpaceRolesEventContent, }, utils::{ future::TryExtExt, @@ -30,7 +28,7 @@ use conduwuit_core::{ }; use futures::{StreamExt, TryFutureExt}; use ruma::{ - Int, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId, room::RoomType, + Int, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId, events::{ StateEventType, room::{ @@ -39,6 +37,7 @@ use ruma::{ }, space::child::SpaceChildEventContent, }, + room::RoomType, }; use serde_json::value::to_raw_value; use tokio::sync::RwLock; @@ -169,9 +168,9 @@ pub fn is_enabled(&self) -> bool { self.server.config.space_permission_cascading /// Ensure a Space has the default admin/mod roles defined. /// -/// 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. +/// 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)] pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { if !self.is_enabled() { @@ -192,20 +191,14 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { // Create default roles let mut roles = BTreeMap::new(); - roles.insert( - "admin".to_owned(), - RoleDefinition { - description: "Space administrator".to_owned(), - power_level: Some(100), - }, - ); - roles.insert( - "mod".to_owned(), - RoleDefinition { - description: "Space moderator".to_owned(), - power_level: Some(50), - }, - ); + roles.insert("admin".to_owned(), RoleDefinition { + description: "Space administrator".to_owned(), + power_level: Some(100), + }); + roles.insert("mod".to_owned(), RoleDefinition { + description: "Space moderator".to_owned(), + power_level: Some(50), + }); let content = SpaceRolesEventContent { roles }; @@ -214,8 +207,11 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { let pdu = PduBuilder { event_type: ruma::events::TimelineEventType::from(SPACE_ROLES_EVENT_TYPE.to_owned()), - content: to_raw_value(&content) - .map_err(|e| conduwuit::Error::Err(format!("Failed to serialize SpaceRolesEventContent: {e}").into()))?, + content: to_raw_value(&content).map_err(|e| { + conduwuit::Error::Err( + format!("Failed to serialize SpaceRolesEventContent: {e}").into(), + ) + })?, state_key: Some(String::new().into()), ..PduBuilder::default() }; @@ -232,16 +228,20 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result { /// Populate the in-memory caches from state events for a single Space room. /// -/// 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. +/// 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) { if !self.is_enabled() { return; } - // Check cache capacity — if over limit, clear and let spaces repopulate on demand - if self.roles.read().await.len() >= usize::try_from(self.server.config.space_roles_cache_capacity).unwrap_or(usize::MAX) { + // Check cache capacity — if over limit, clear and let spaces repopulate on + // demand + if self.roles.read().await.len() + >= usize::try_from(self.server.config.space_roles_cache_capacity).unwrap_or(usize::MAX) + { self.roles.write().await.clear(); self.user_roles.write().await.clear(); self.room_requirements.write().await.clear(); @@ -264,14 +264,10 @@ pub async fn populate_space(&self, space_id: &RoomId) { .insert(space_id.to_owned(), content.roles); } - // 2. Read all com.continuwuity.space.role.member state events (state key: user ID) + // 2. Read all com.continuwuity.space.role.member state events (state key: user + // ID) let member_event_type = StateEventType::from(SPACE_ROLE_MEMBER_EVENT_TYPE.to_owned()); - let shortstatehash = match self - .services - .state - .get_room_shortstatehash(space_id) - .await - { + let shortstatehash = match self.services.state.get_room_shortstatehash(space_id).await { | Ok(hash) => hash, | Err(e) => { debug_warn!(space_id = %space_id, error = ?e, "Failed to get shortstatehash, cache may be stale"); @@ -309,7 +305,8 @@ pub async fn populate_space(&self, space_id: &RoomId) { .await .insert(space_id.to_owned(), user_roles_map); - // 3. Read all com.continuwuity.space.role.room state events (state key: room ID) + // 3. Read all com.continuwuity.space.role.room state events (state key: room + // ID) let room_event_type = StateEventType::from(SPACE_ROLE_ROOM_EVENT_TYPE.to_owned()); let mut room_reqs_map: HashMap> = HashMap::new(); @@ -423,13 +420,16 @@ pub fn roles_satisfy_requirements( /// 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 { +pub async fn get_user_power_level(&self, space_id: &RoomId, user_id: &UserId) -> Option { let role_defs = { self.roles.read().await.get(space_id).cloned()? }; - let user_assigned = { self.user_roles.read().await.get(space_id)?.get(user_id).cloned()? }; + let user_assigned = { + self.user_roles + .read() + .await + .get(space_id)? + .get(user_id) + .cloned()? + }; compute_user_power_level(&role_defs, &user_assigned) } @@ -599,11 +599,7 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re /// checks whether the user qualifies via their assigned roles, and /// force-joins them if they are not already a member. #[implement(Service)] -pub async fn auto_join_qualifying_rooms( - &self, - space_id: &RoomId, - user_id: &UserId, -) -> Result { +pub async fn auto_join_qualifying_rooms(&self, space_id: &RoomId, user_id: &UserId) -> Result { if !self.is_enabled() { return Ok(()); } @@ -731,9 +727,7 @@ impl Service { // Role definitions changed — sync PLs in all child rooms let child_rooms = this.get_child_rooms(&space_id).await; for child_room_id in &child_rooms { - if let Err(e) = - this.sync_power_levels(&space_id, child_room_id).await - { + if let Err(e) = this.sync_power_levels(&space_id, child_room_id).await { debug_warn!(room_id = %child_room_id, error = ?e, "Failed to sync power levels"); } } @@ -756,8 +750,7 @@ impl Service { | SPACE_ROLE_MEMBER_EVENT_TYPE => { // User's roles changed — auto-join/kick + PL sync if let Ok(user_id) = UserId::parse(state_key.as_str()) { - if let Err(e) = - this.auto_join_qualifying_rooms(&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"); } @@ -769,8 +762,7 @@ impl Service { // Sync power levels in all child rooms let child_rooms = this.get_child_rooms(&space_id).await; for child_room_id in &child_rooms { - if let Err(e) = - this.sync_power_levels(&space_id, child_room_id).await + if let Err(e) = this.sync_power_levels(&space_id, child_room_id).await { debug_warn!(room_id = %child_room_id, error = ?e, "Failed to sync power levels"); } @@ -789,16 +781,12 @@ impl Service { .await; for member in &members { if !this - .user_qualifies_for_room( - &space_id, - target_room, - member, - ) + .user_qualifies_for_room(&space_id, target_room, member) .await { - if let Err(e) = Box::pin(this - .kick_unqualified_from_rooms(&space_id, member)) - .await + 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"); } @@ -998,9 +986,7 @@ impl Service { // Also sync their power levels let child_rooms = this.get_child_rooms(&space_id).await; for child_room_id in &child_rooms { - if let Err(e) = - this.sync_power_levels(&space_id, child_room_id).await - { + if let Err(e) = this.sync_power_levels(&space_id, child_room_id).await { debug_warn!(room_id = %child_room_id, error = ?e, "Failed to sync power levels on join"); } } @@ -1014,11 +1000,7 @@ impl Service { /// space, checks whether the user still qualifies, and kicks them with a /// reason if they do not. #[implement(Service)] -pub async fn kick_unqualified_from_rooms( - &self, - space_id: &RoomId, - user_id: &UserId, -) -> Result { +pub async fn kick_unqualified_from_rooms(&self, space_id: &RoomId, user_id: &UserId) -> Result { if !self.is_enabled() { return Ok(()); } @@ -1084,17 +1066,14 @@ pub async fn kick_unqualified_from_rooms( .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 - }, - ), + 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, diff --git a/src/service/rooms/roles/tests.rs b/src/service/rooms/roles/tests.rs index a9b0068b..1579cdb6 100644 --- a/src/service/rooms/roles/tests.rs +++ b/src/service/rooms/roles/tests.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use conduwuit_core::matrix::space_roles::RoleDefinition; -use ruma::{room_id, OwnedRoomId}; +use ruma::{OwnedRoomId, room_id}; use super::{compute_user_power_level, roles_satisfy_requirements}; @@ -10,13 +10,10 @@ pub fn make_roles(entries: &[(&str, Option)]) -> BTreeMap = Arc::clone(&*self.services.roles); - roles.handle_space_child_change( - room_id.to_owned(), - child_room_id.to_owned(), - ); + roles.handle_space_child_change(room_id.to_owned(), child_room_id.to_owned()); } } } @@ -409,10 +406,8 @@ where && matches!( self.services.state_accessor.get_room_type(room_id).await, Ok(ruma::room::RoomType::Space) - ) - { - let roles: Arc = - Arc::clone(&*self.services.roles); + ) { + let roles: Arc = Arc::clone(&*self.services.roles); roles.handle_space_member_join(room_id.to_owned(), user_id.to_owned()); } } diff --git a/src/service/rooms/timeline/build.rs b/src/service/rooms/timeline/build.rs index 95781371..57ff7886 100644 --- a/src/service/rooms/timeline/build.rs +++ b/src/service/rooms/timeline/build.rs @@ -3,12 +3,10 @@ use std::{ iter::once, }; -use conduwuit_core::matrix::space_roles::RoleDefinition; - use conduwuit::{debug_warn, trace}; use conduwuit_core::{ Err, Result, implement, - matrix::{event::Event, pdu::PduBuilder}, + matrix::{event::Event, pdu::PduBuilder, space_roles::RoleDefinition}, utils::{IterStream, ReadyExt}, }; use futures::{FutureExt, StreamExt}; @@ -104,12 +102,16 @@ pub async fn build_and_append_pdu( } // Space permission cascading: reject power level changes that conflict // with Space-granted levels (exempt the server user so sync_power_levels works) - type SpaceEnforcementData = - (ruma::OwnedRoomId, Vec<(OwnedUserId, HashSet)>, BTreeMap); + type SpaceEnforcementData = ( + ruma::OwnedRoomId, + Vec<(OwnedUserId, HashSet)>, + BTreeMap, + ); if self.services.roles.is_enabled() && *pdu.kind() == TimelineEventType::RoomPowerLevels - && pdu.sender() != >::as_ref(&self.services.globals.server_user) + && pdu.sender() + != >::as_ref(&self.services.globals.server_user) { use ruma::events::room::power_levels::RoomPowerLevelsEventContent; @@ -118,8 +120,11 @@ pub async fn build_and_append_pdu( 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 + if let Some(space_pl) = self + .services + .roles + .get_user_power_level(parent_space, user_id) + .await { if i64::from(*proposed_pl) != space_pl { debug_warn!( @@ -142,15 +147,21 @@ pub async fn build_and_append_pdu( let space_data: Vec = { let user_roles_guard = self.services.roles.user_roles.read().await; let roles_guard = self.services.roles.roles.read().await; - parent_spaces.iter().filter_map(|ps| { - let space_users = user_roles_guard.get(ps)?; - let role_defs = roles_guard.get(ps)?; - Some(( - ps.clone(), - space_users.iter().map(|(u, r)| (u.clone(), r.clone())).collect(), - role_defs.clone(), - )) - }).collect() + parent_spaces + .iter() + .filter_map(|ps| { + let space_users = user_roles_guard.get(ps)?; + let role_defs = roles_guard.get(ps)?; + Some(( + ps.clone(), + space_users + .iter() + .map(|(u, r)| (u.clone(), r.clone())) + .collect(), + role_defs.clone(), + )) + }) + .collect() }; // Guards dropped here @@ -174,7 +185,8 @@ pub async fn build_and_append_pdu( "Rejecting PL change: space-managed user omitted" ); return Err!(Request(Forbidden( - "Cannot omit a user whose power level is managed by Space roles" + "Cannot omit a user whose power level is managed by Space \ + roles" ))); }, | Some(pl) if i64::from(*pl) != space_pl => {