From 3bfd10efab3132579a68b70bf904ec3c08b6ef82 Mon Sep 17 00:00:00 2001 From: ember33 Date: Tue, 17 Mar 2026 15:04:32 +0100 Subject: [PATCH] docs: add implementation plan for space permission cascading 15-task plan covering config flag, custom event types, service layer, cache, enforcement hooks, admin commands, and testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-17-space-permission-cascading.md | 1206 +++++++++++++++++ 1 file changed, 1206 insertions(+) create mode 100644 docs/plans/2026-03-17-space-permission-cascading.md diff --git a/docs/plans/2026-03-17-space-permission-cascading.md b/docs/plans/2026-03-17-space-permission-cascading.md new file mode 100644 index 00000000..75ae0a9e --- /dev/null +++ b/docs/plans/2026-03-17-space-permission-cascading.md @@ -0,0 +1,1206 @@ +# 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 (`m.space.roles`, `m.space.role.member`, `m.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 `m.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 `m.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 `m.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 m.space.roles + let roles_content: Option = self + .services + .state_accessor + .room_state_get_content(space_id, &StateEventType::from("m.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 m.space.role.member state events + // Iterate all state events of type m.space.role.member + // Load room requirements from m.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 m.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 m.space.roles already exists + let existing: Result = self + .services + .state_accessor + .room_state_get_content(space_id, &StateEventType::from("m.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() { + // m.space.roles changed -> revalidate all members + t if t == "m.space.roles" => { + self.services.roles.populate_space(&pdu.room_id).await?; + // Revalidate all members against all child rooms + } + // m.space.role.member changed -> auto-join/kick that user + t if t == "m.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 + } + } + // m.space.role.room changed -> auto-join/kick for that room + t if t == "m.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("m.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: Integration Testing + +**Files:** +- Create: `src/service/rooms/roles/tests.rs` + +**Step 1: Write unit tests for the roles service** + +Test the lookup methods with pre-populated cache data: + +```rust +#[cfg(test)] +mod tests { + // Test user_qualifies_for_room with various role combinations + // Test get_user_power_level with multiple roles + // Test cache invalidation paths + // Test default role creation +} +``` + +**Step 2: Run tests** + +Run: `cargo test -p conduwuit-service roles 2>&1 | tail -20` +Expected: All tests pass. + +**Step 3: Commit** + +```bash +git add src/service/rooms/roles/tests.rs +git commit -m "test(spaces): add unit tests for space roles service" +``` + +--- + +### Task 15: 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) + └─> 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 11 (admin cmd structure) + └─> Task 12 (admin cmd handlers) +Task 14 (tests) - can run after Task 8 +Task 15 (docs) - final +``` + +Tasks 5-8, 11, and 13 can be worked on in parallel after Task 4 is complete.