Critical fixes: - handle_space_child_change now reads the actual m.space.child state event and checks if via is empty; removes child from index on removal instead of unconditionally adding - Server user is exempted from PL rejection guard so sync_power_levels can function without being blocked by its own protection - PL rejection now also checks that space-managed users aren't omitted from proposed power level events Important fixes: - room_to_space changed from 1:1 to 1:many (HashMap<RoomId, HashSet<RoomId>>) so a room can belong to multiple parent spaces; get_parent_space renamed to get_parent_spaces; join gating checks all parents (qualify in any) - All custom event types renamed from m.space.* to com.continuwuity.space.* to avoid squatting on the Matrix namespace - Cache cleanup on child removal from space - Added tokio Semaphore (capacity 4) to limit concurrent enforcement tasks - Server user membership checked before enforcement in auto_join, kick, and sync_power_levels to avoid noisy errors Suggestions: - Replaced expect() calls with proper error propagation via map_err/? - Fixed indentation in timeline/mod.rs line 116 - handle_space_child_change now directly joins users to the specific new child room instead of scanning all children via auto_join_qualifying_rooms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2203 lines
57 KiB
Markdown
2203 lines
57 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 (`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<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 `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<String>,
|
|
}
|
|
|
|
/// 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<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 com.continuwuity.space.roles
|
|
let roles_content: Option<SpaceRolesEventContent> = 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<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 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<SpaceRolesEventContent> = 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<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() {
|
|
// 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<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("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<i64>)]) -> BTreeMap<String, RoleDefinition> {
|
|
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<String> {
|
|
roles.iter().map(|s| (*s).to_owned()).collect()
|
|
}
|
|
|
|
/// Helper to build room requirements set.
|
|
fn make_requirements(roles: &[&str]) -> HashSet<String> {
|
|
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<String> = 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<String> = 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<String> = HashSet::new();
|
|
|
|
assert!(!required.iter().all(|r| user_assigned.contains(r)));
|
|
}
|
|
|
|
#[test]
|
|
fn qualifies_empty_requirements_empty_roles() {
|
|
let required: HashSet<String> = HashSet::new();
|
|
let user_assigned: HashSet<String> = 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<OwnedRoomId, OwnedRoomId> = 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<String> = 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<String> = 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<String> = 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<String>>> = 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<String>>> = 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<String, HashSet<String>> = 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<String, HashSet<String>> = 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<OwnedRoomId, BTreeMap<String, RoleDefinition>>,
|
|
user_roles: HashMap<OwnedRoomId, HashMap<OwnedUserId, HashSet<String>>>,
|
|
room_requirements: HashMap<OwnedRoomId, HashMap<OwnedRoomId, HashSet<String>>>,
|
|
room_to_space: HashMap<OwnedRoomId, OwnedRoomId>,
|
|
}
|
|
|
|
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<String, RoleDefinition>) {
|
|
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<String>,
|
|
) {
|
|
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<i64> {
|
|
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.
|