continuwuity/docs/plans/2026-03-17-space-permission-cascading.md
ember33 3bfd10efab 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) <noreply@anthropic.com>
2026-03-17 15:04:32 +01:00

1206 lines
30 KiB
Markdown

# 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<String, RoleDefinition>,
}
/// 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<i64>,
}
/// 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<String>,
}
/// 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<String>,
}
```
**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<HashMap<OwnedRoomId, BTreeMap<String, RoleDefinition>>>,
/// Space ID -> user ID -> assigned roles
pub user_roles: RwLock<HashMap<OwnedRoomId, HashMap<OwnedUserId, HashSet<String>>>>,
/// Space ID -> child room ID -> required roles
pub room_requirements: RwLock<HashMap<OwnedRoomId, HashMap<OwnedRoomId, HashSet<String>>>>,
/// Child room ID -> parent Space ID (reverse lookup)
pub room_to_space: RwLock<HashMap<OwnedRoomId, OwnedRoomId>>,
}
struct Services {
state_accessor: Dep<rooms::state_accessor::Service>,
state_cache: Dep<rooms::state_cache::Service>,
state: Dep<rooms::state::Service>,
spaces: Dep<rooms::spaces::Service>,
timeline: Dep<rooms::timeline::Service>,
server: Arc<conduwuit_core::Server>,
}
#[async_trait]
impl crate::Service for Service {
fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self {
services: Services {
state_accessor: args
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
state_cache: args.depend::<rooms::state_cache::Service>("rooms::state_cache"),
state: args.depend::<rooms::state::Service>("rooms::state"),
spaces: args.depend::<rooms::spaces::Service>("rooms::spaces"),
timeline: args.depend::<rooms::timeline::Service>("rooms::timeline"),
server: args.server.clone(),
},
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<SpaceRolesEventContent> = 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<i64> {
let roles_map = self.roles.read().await;
let user_roles_map = self.user_roles.read().await;
let role_defs = roles_map.get(space_id)?;
let user_assigned = user_roles_map.get(space_id)?.get(user_id)?;
user_assigned
.iter()
.filter_map(|role_name| role_defs.get(role_name)?.power_level)
.max()
}
/// Check if a user has all required roles for a room.
#[implement(Service)]
pub async fn user_qualifies_for_room(
&self,
space_id: &RoomId,
room_id: &RoomId,
user_id: &UserId,
) -> bool {
let reqs = self.room_requirements.read().await;
let Some(space_reqs) = reqs.get(space_id) else {
return true; // 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<OwnedRoomId> {
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<SpaceRolesEventContent> = 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<OwnedRoomId> = 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<OwnedRoomId> = 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<Self>) -> 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<String>,
/// Power level to grant in child rooms
#[arg(long)]
power_level: Option<i64>,
},
/// 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.