Compare commits

...

4 commits

Author SHA1 Message Date
11c04d1458 feat(spaces): wire up enforcement hooks in join, append, and build paths
Some checks failed
Documentation / Build and Deploy Documentation (pull_request) Has been skipped
Checks / Prek / Pre-commit & Formatting (pull_request) Failing after 6s
Checks / Prek / Clippy and Cargo Tests (pull_request) Failing after 6s
Update flake hashes / update-flake-hashes (pull_request) Failing after 6s
Add minimal integration points in existing files:
- append.rs: call on_pdu_appended for event-driven enforcement
- build.rs: call validate_pl_change to protect space-managed PLs
- join.rs: call check_join_allowed to gate joins on role requirements
- timeline/mod.rs: add roles service dependency
2026-03-19 22:43:23 +01:00
4b37877bc3 feat(spaces): add admin commands for space role management
Add !admin space roles subcommands: list, add, remove, assign, revoke,
require, unrequire, user, room, enable, disable, status. Role
definitions, assignments, and room requirements are managed via state
events. Enable/disable controls per-space cascading override.
2026-03-19 22:43:15 +01:00
f4ab456bbd feat(spaces): add space roles service with enforcement and caching
Implement the roles service that manages space permission cascading:
- In-memory cache populated from state events, rebuilt on startup
- Join gating, power level sync (highest-wins across parent spaces),
  auto-join on role grant, auto-kick on role revocation
- Per-space enable/disable via com.continuwuity.space.cascading event
- Background enforcement tasks with semaphore-limited concurrency
- Graceful shutdown support via server.running() checks
2026-03-19 22:43:08 +01:00
87ad6f156c feat(spaces): add custom state event types and config for space permission cascading
Add four custom Matrix state event content types for space role
management: space roles definitions, per-user role assignments,
per-room role requirements, and per-space cascading override.

Add server config options: space_permission_cascading (default false)
as the server-wide toggle, and space_roles_cache_flush_threshold
(default 1000) for cache management.
2026-03-19 22:42:57 +01:00
17 changed files with 2193 additions and 1 deletions

View file

@ -0,0 +1 @@
Add Space permission cascading: power levels cascade from Spaces to child rooms, role-based room access with custom roles, continuous enforcement (auto-join/kick), and admin commands for role management. Server-wide default controlled by `space_permission_cascading` config flag (off by default), with per-Space overrides via `!admin space roles enable/disable <space>`.

View file

@ -470,6 +470,18 @@
# #
#suspend_on_register = false #suspend_on_register = false
# Server-wide default for space permission cascading (power levels and
# role-based access). Individual Spaces can override this via the
# `com.continuwuity.space.cascading` state event or the admin command
# `!admin space roles enable/disable <space>`.
#
#space_permission_cascading = false
# Maximum number of spaces to cache role data for. When exceeded the
# cache is cleared and repopulated on demand.
#
#space_roles_cache_flush_threshold = 1000
# Enabling this setting opens registration to anyone without restrictions. # Enabling this setting opens registration to anyone without restrictions.
# This makes your server vulnerable to abuse # This makes your server vulnerable to abuse
# #

View file

@ -11,6 +11,7 @@ use crate::{
query::{self, QueryCommand}, query::{self, QueryCommand},
room::{self, RoomCommand}, room::{self, RoomCommand},
server::{self, ServerCommand}, server::{self, ServerCommand},
space::{self, SpaceCommand},
token::{self, TokenCommand}, token::{self, TokenCommand},
user::{self, UserCommand}, user::{self, UserCommand},
}; };
@ -34,6 +35,10 @@ pub enum AdminCommand {
/// Commands for managing rooms /// Commands for managing rooms
Rooms(RoomCommand), Rooms(RoomCommand),
#[command(subcommand)]
/// Commands for managing space permissions
Spaces(SpaceCommand),
#[command(subcommand)] #[command(subcommand)]
/// Commands for managing federation /// Commands for managing federation
Federation(FederationCommand), Federation(FederationCommand),
@ -81,6 +86,10 @@ pub(super) async fn process(command: AdminCommand, context: &Context<'_>) -> Res
token::process(command, context).await token::process(command, context).await
}, },
| Rooms(command) => room::process(command, context).await, | Rooms(command) => room::process(command, context).await,
| Spaces(command) => {
context.bail_restricted()?;
space::process(command, context).await
},
| Federation(command) => federation::process(command, context).await, | Federation(command) => federation::process(command, context).await,
| Server(command) => server::process(command, context).await, | Server(command) => server::process(command, context).await,
| Debug(command) => debug::process(command, context).await, | Debug(command) => debug::process(command, context).await,

View file

@ -17,6 +17,7 @@ pub(crate) mod media;
pub(crate) mod query; pub(crate) mod query;
pub(crate) mod room; pub(crate) mod room;
pub(crate) mod server; pub(crate) mod server;
pub(crate) mod space;
pub(crate) mod token; pub(crate) mod token;
pub(crate) mod user; pub(crate) mod user;

15
src/admin/space/mod.rs Normal file
View file

@ -0,0 +1,15 @@
pub(super) mod roles;
use clap::Subcommand;
use conduwuit::Result;
use self::roles::SpaceRolesCommand;
use crate::admin_command_dispatch;
#[admin_command_dispatch]
#[derive(Debug, Subcommand)]
pub enum SpaceCommand {
#[command(subcommand)]
/// Manage space roles and permissions
Roles(SpaceRolesCommand),
}

624
src/admin/space/roles.rs Normal file
View file

@ -0,0 +1,624 @@
use std::fmt::Write;
use clap::Subcommand;
use conduwuit::{Err, Event, Result, matrix::pdu::PduBuilder};
use conduwuit_core::matrix::space_roles::{
RoleDefinition, SPACE_CASCADING_EVENT_TYPE, SPACE_ROLE_MEMBER_EVENT_TYPE,
SPACE_ROLE_ROOM_EVENT_TYPE, SPACE_ROLES_EVENT_TYPE, SpaceCascadingEventContent,
SpaceRoleMemberEventContent, SpaceRoleRoomEventContent, SpaceRolesEventContent,
};
use futures::StreamExt;
use ruma::{OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId, events::StateEventType};
use serde_json::value::to_raw_value;
use crate::{admin_command, admin_command_dispatch};
fn roles_event_type() -> StateEventType {
StateEventType::from(SPACE_ROLES_EVENT_TYPE.to_owned())
}
fn member_event_type() -> StateEventType {
StateEventType::from(SPACE_ROLE_MEMBER_EVENT_TYPE.to_owned())
}
fn room_event_type() -> StateEventType {
StateEventType::from(SPACE_ROLE_ROOM_EVENT_TYPE.to_owned())
}
fn cascading_event_type() -> StateEventType {
StateEventType::from(SPACE_CASCADING_EVENT_TYPE.to_owned())
}
macro_rules! resolve_room_as_space {
($self:expr, $space:expr) => {{
let space_id = $self.services.rooms.alias.resolve(&$space).await?;
if !matches!(
$self
.services
.rooms
.state_accessor
.get_room_type(&space_id)
.await,
Ok(ruma::room::RoomType::Space)
) {
return Err!("The specified room is not a Space.");
}
space_id
}};
}
macro_rules! resolve_space {
($self:expr, $space:expr) => {{
let space_id = resolve_room_as_space!($self, $space);
if !$self
.services
.rooms
.roles
.is_enabled_for_space(&space_id)
.await
{
return $self
.write_str(
"Space permission cascading is disabled for this Space. Enable it \
server-wide with `space_permission_cascading = true` in your config, or \
per-Space with `!admin space roles enable <space>`.",
)
.await;
}
space_id
}};
}
macro_rules! custom_state_pdu {
($event_type:expr, $state_key:expr, $content:expr) => {
PduBuilder {
event_type: $event_type.to_owned().into(),
content: to_raw_value($content)
.map_err(|e| conduwuit::err!("Failed to serialize state event content: {e}"))?,
state_key: Some($state_key.to_owned().into()),
..PduBuilder::default()
}
};
}
macro_rules! send_space_state {
($self:expr, $space_id:expr, $event_type:expr, $state_key:expr, $content:expr) => {{
let state_lock = $self.services.rooms.state.mutex.lock(&$space_id).await;
let server_user = &$self.services.globals.server_user;
$self
.services
.rooms
.timeline
.build_and_append_pdu(
custom_state_pdu!($event_type, $state_key, $content),
server_user,
Some(&$space_id),
&state_lock,
)
.await?
}};
}
#[admin_command_dispatch]
#[derive(Debug, Subcommand)]
pub enum SpaceRolesCommand {
/// List all roles defined in a space
List {
space: OwnedRoomOrAliasId,
},
/// Add a new role to a space
Add {
space: OwnedRoomOrAliasId,
role_name: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
power_level: Option<i64>,
},
/// Remove a role from a space
Remove {
space: OwnedRoomOrAliasId,
role_name: String,
},
/// Assign a role to a user
Assign {
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
role_name: String,
},
/// Revoke a role from a user
Revoke {
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
role_name: String,
},
/// Require a role for a room
Require {
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
role_name: String,
},
/// Remove a role requirement from a room
Unrequire {
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
role_name: String,
},
/// Show a user's roles in a space
User {
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
},
/// Show a room's role requirements in a space
Room {
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
},
/// Enable space permission cascading for a specific space (overrides
/// server config)
Enable {
space: OwnedRoomOrAliasId,
},
/// Disable space permission cascading for a specific space (overrides
/// server config)
Disable {
space: OwnedRoomOrAliasId,
},
/// Show whether cascading is enabled for a space and the source (server
/// default or per-space override)
Status {
space: OwnedRoomOrAliasId,
},
}
#[admin_command]
async fn list(&self, space: OwnedRoomOrAliasId) -> Result {
let space_id = resolve_space!(self, space);
let roles_event_type = roles_event_type();
let content: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if content.roles.is_empty() {
return self.write_str("No roles defined in this space.").await;
}
let mut msg = format!("Roles in {space_id}:\n```\n");
for (name, def) in &content.roles {
let pl = def
.power_level
.map(|p| format!(" (power_level: {p})"))
.unwrap_or_default();
let _ = writeln!(msg, "- {name}: {}{pl}", def.description);
}
msg.push_str("```");
self.write_str(&msg).await
}
#[admin_command]
async fn add(
&self,
space: OwnedRoomOrAliasId,
role_name: String,
description: Option<String>,
power_level: Option<i64>,
) -> Result {
let space_id = resolve_space!(self, space);
if let Some(pl) = power_level {
if pl > i64::from(ruma::Int::MAX) || pl < i64::from(ruma::Int::MIN) {
return Err!(
"Power level must be between {} and {}.",
ruma::Int::MIN,
ruma::Int::MAX
);
}
}
let roles_event_type = roles_event_type();
let mut content: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if content.roles.contains_key(&role_name) {
return Err!("Role '{role_name}' already exists in this space.");
}
content.roles.insert(role_name.clone(), RoleDefinition {
description: description.unwrap_or_else(|| role_name.clone()),
power_level,
});
send_space_state!(self, space_id, SPACE_ROLES_EVENT_TYPE, "", &content);
self.write_str(&format!("Added role '{role_name}' to space {space_id}."))
.await
}
#[admin_command]
async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result {
let space_id = resolve_space!(self, space);
let roles_event_type = roles_event_type();
let mut content: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if content.roles.remove(&role_name).is_none() {
return Err!("Role '{role_name}' does not exist in this space.");
}
send_space_state!(self, space_id, SPACE_ROLES_EVENT_TYPE, "", &content);
let member_event_type = member_event_type();
let server_user = &self.services.globals.server_user;
if let Ok(shortstatehash) = self
.services
.rooms
.state
.get_room_shortstatehash(&space_id)
.await
{
let state_lock = self.services.rooms.state.mutex.lock(&space_id).await;
let user_entries: Vec<(_, ruma::OwnedEventId)> = self
.services
.rooms
.state_accessor
.state_keys_with_ids(shortstatehash, &member_event_type)
.collect()
.await;
for (state_key, event_id) in user_entries {
if let Ok(pdu) = self.services.rooms.timeline.get_pdu(&event_id).await {
if let Ok(mut member_content) = pdu.get_content::<SpaceRoleMemberEventContent>() {
if member_content.roles.contains(&role_name) {
member_content.roles.retain(|r| r != &role_name);
self.services
.rooms
.timeline
.build_and_append_pdu(
custom_state_pdu!(
SPACE_ROLE_MEMBER_EVENT_TYPE,
&state_key,
&member_content
),
server_user,
Some(&space_id),
&state_lock,
)
.await?;
}
}
}
}
// Cascade: remove the role from all rooms' role requirement events
let room_event_type = room_event_type();
let room_entries: Vec<(_, ruma::OwnedEventId)> = self
.services
.rooms
.state_accessor
.state_keys_with_ids(shortstatehash, &room_event_type)
.collect()
.await;
for (state_key, event_id) in room_entries {
if let Ok(pdu) = self.services.rooms.timeline.get_pdu(&event_id).await {
if let Ok(mut room_content) = pdu.get_content::<SpaceRoleRoomEventContent>() {
if room_content.required_roles.contains(&role_name) {
room_content.required_roles.retain(|r| r != &role_name);
self.services
.rooms
.timeline
.build_and_append_pdu(
custom_state_pdu!(
SPACE_ROLE_ROOM_EVENT_TYPE,
&state_key,
&room_content
),
server_user,
Some(&space_id),
&state_lock,
)
.await?;
}
}
}
}
}
self.write_str(&format!("Removed role '{role_name}' from space {space_id}."))
.await
}
#[admin_command]
async fn assign(
&self,
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
role_name: String,
) -> Result {
let space_id = resolve_space!(self, space);
let roles_event_type = roles_event_type();
let role_defs: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if !role_defs.roles.contains_key(&role_name) {
return Err!("Role '{role_name}' does not exist in this space.");
}
let member_event_type = member_event_type();
let mut content: SpaceRoleMemberEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &member_event_type, user_id.as_str())
.await
.unwrap_or_default();
if content.roles.contains(&role_name) {
return Err!("User {user_id} already has role '{role_name}' in this space.");
}
content.roles.push(role_name.clone());
send_space_state!(self, space_id, SPACE_ROLE_MEMBER_EVENT_TYPE, user_id.as_str(), &content);
self.write_str(&format!("Assigned role '{role_name}' to {user_id} in space {space_id}."))
.await
}
#[admin_command]
async fn revoke(
&self,
space: OwnedRoomOrAliasId,
user_id: OwnedUserId,
role_name: String,
) -> Result {
let space_id = resolve_space!(self, space);
let member_event_type = member_event_type();
let mut content: SpaceRoleMemberEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &member_event_type, user_id.as_str())
.await
.unwrap_or_default();
let original_len = content.roles.len();
content.roles.retain(|r| r != &role_name);
if content.roles.len() == original_len {
return Err!("User {user_id} does not have role '{role_name}' in this space.");
}
send_space_state!(self, space_id, SPACE_ROLE_MEMBER_EVENT_TYPE, user_id.as_str(), &content);
self.write_str(&format!("Revoked role '{role_name}' from {user_id} in space {space_id}."))
.await
}
#[admin_command]
async fn require(
&self,
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
role_name: String,
) -> Result {
let space_id = resolve_space!(self, space);
let child_rooms = self.services.rooms.roles.get_child_rooms(&space_id).await;
if !child_rooms.contains(&room_id) {
return Err!("Room {room_id} is not a child of space {space_id}.");
}
let roles_event_type = roles_event_type();
let role_defs: SpaceRolesEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &roles_event_type, "")
.await
.unwrap_or_default();
if !role_defs.roles.contains_key(&role_name) {
return Err!("Role '{role_name}' does not exist in this space.");
}
let room_event_type = room_event_type();
let mut content: SpaceRoleRoomEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &room_event_type, room_id.as_str())
.await
.unwrap_or_default();
if content.required_roles.contains(&role_name) {
return Err!("Room {room_id} already requires role '{role_name}' in this space.");
}
content.required_roles.push(role_name.clone());
send_space_state!(self, space_id, SPACE_ROLE_ROOM_EVENT_TYPE, room_id.as_str(), &content);
self.write_str(&format!(
"Room {room_id} now requires role '{role_name}' in space {space_id}."
))
.await
}
#[admin_command]
async fn unrequire(
&self,
space: OwnedRoomOrAliasId,
room_id: OwnedRoomId,
role_name: String,
) -> Result {
let space_id = resolve_space!(self, space);
let room_event_type = room_event_type();
let mut content: SpaceRoleRoomEventContent = self
.services
.rooms
.state_accessor
.room_state_get_content(&space_id, &room_event_type, room_id.as_str())
.await
.unwrap_or_default();
let original_len = content.required_roles.len();
content.required_roles.retain(|r| r != &role_name);
if content.required_roles.len() == original_len {
return Err!("Room {room_id} does not require role '{role_name}' in this space.");
}
send_space_state!(self, space_id, SPACE_ROLE_ROOM_EVENT_TYPE, room_id.as_str(), &content);
self.write_str(&format!(
"Removed role requirement '{role_name}' from room {room_id} in space {space_id}."
))
.await
}
#[admin_command]
async fn user(&self, space: OwnedRoomOrAliasId, user_id: OwnedUserId) -> Result {
let space_id = resolve_space!(self, space);
let roles = self
.services
.rooms
.roles
.get_user_roles_in_space(&space_id, &user_id)
.await;
match roles {
| Some(roles) if !roles.is_empty() => {
let list: String = roles
.iter()
.map(|r| format!("- {r}"))
.collect::<Vec<_>>()
.join("\n");
self.write_str(&format!("Roles for {user_id} in space {space_id}:\n```\n{list}\n```"))
.await
},
| _ =>
self.write_str(&format!("User {user_id} has no roles in space {space_id}."))
.await,
}
}
#[admin_command]
async fn room(&self, space: OwnedRoomOrAliasId, room_id: OwnedRoomId) -> Result {
let space_id = resolve_space!(self, space);
let reqs = self
.services
.rooms
.roles
.get_room_requirements_in_space(&space_id, &room_id)
.await;
match reqs {
| Some(reqs) if !reqs.is_empty() => {
let list: String = reqs
.iter()
.map(|r| format!("- {r}"))
.collect::<Vec<_>>()
.join("\n");
self.write_str(&format!(
"Required roles for room {room_id} in space {space_id}:\n```\n{list}\n```"
))
.await
},
| _ =>
self.write_str(&format!(
"Room {room_id} has no role requirements in space {space_id}."
))
.await,
}
}
#[admin_command]
async fn enable(&self, space: OwnedRoomOrAliasId) -> Result {
let space_id = resolve_room_as_space!(self, space);
self.services
.rooms
.roles
.ensure_default_roles(&space_id)
.await?;
let content = SpaceCascadingEventContent { enabled: true };
send_space_state!(self, space_id, SPACE_CASCADING_EVENT_TYPE, "", &content);
self.write_str(&format!("Space permission cascading enabled for {space_id}."))
.await
}
#[admin_command]
async fn disable(&self, space: OwnedRoomOrAliasId) -> Result {
let space_id = resolve_room_as_space!(self, space);
let content = SpaceCascadingEventContent { enabled: false };
send_space_state!(self, space_id, SPACE_CASCADING_EVENT_TYPE, "", &content);
self.write_str(&format!("Space permission cascading disabled for {space_id}."))
.await
}
#[admin_command]
async fn status(&self, space: OwnedRoomOrAliasId) -> Result {
let space_id = resolve_room_as_space!(self, space);
let global_default = self.services.rooms.roles.is_enabled();
let cascading_event_type = cascading_event_type();
let per_space_override: Option<bool> = self
.services
.rooms
.state_accessor
.room_state_get_content::<SpaceCascadingEventContent>(
&space_id,
&cascading_event_type,
"",
)
.await
.ok()
.map(|c| c.enabled);
let effective = per_space_override.unwrap_or(global_default);
let source = match per_space_override {
| Some(v) => format!("per-Space override (enabled: {v})"),
| None => format!("server default (space_permission_cascading: {global_default})"),
};
self.write_str(&format!(
"Cascading status for {space_id}:\n- Effective: **{effective}**\n- Source: {source}"
))
.await
}

View file

@ -347,6 +347,12 @@ pub async fn join_room_by_id_helper(
} }
} }
services
.rooms
.roles
.check_join_allowed(room_id, sender_user)
.await?;
if server_in_room { if server_in_room {
join_room_by_id_helper_local(services, sender_user, room_id, reason, servers, state_lock) join_room_by_id_helper_local(services, sender_user, room_id, reason, servers, state_lock)
.boxed() .boxed()

View file

@ -603,6 +603,22 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub suspend_on_register: bool, pub suspend_on_register: bool,
/// Server-wide default for space permission cascading (power levels and
/// role-based access). Individual Spaces can override this via the
/// `com.continuwuity.space.cascading` state event or the admin command
/// `!admin space roles enable/disable <space>`.
///
/// default: false
#[serde(default)]
pub space_permission_cascading: bool,
/// Maximum number of spaces to cache role data for. When exceeded the
/// cache is cleared and repopulated on demand.
///
/// default: 1000
#[serde(default = "default_space_roles_cache_flush_threshold")]
pub space_roles_cache_flush_threshold: u32,
/// Enabling this setting opens registration to anyone without restrictions. /// Enabling this setting opens registration to anyone without restrictions.
/// This makes your server vulnerable to abuse /// This makes your server vulnerable to abuse
#[serde(default)] #[serde(default)]
@ -2826,3 +2842,5 @@ fn default_ldap_search_filter() -> String { "(objectClass=*)".to_owned() }
fn default_ldap_uid_attribute() -> String { String::from("uid") } fn default_ldap_uid_attribute() -> String { String::from("uid") }
fn default_ldap_name_attribute() -> String { String::from("givenName") } fn default_ldap_name_attribute() -> String { String::from("givenName") }
fn default_space_roles_cache_flush_threshold() -> u32 { 1000 }

View file

@ -2,6 +2,7 @@
pub mod event; pub mod event;
pub mod pdu; pub mod pdu;
pub mod space_roles;
pub mod state_key; pub mod state_key;
pub mod state_res; pub mod state_res;

View file

@ -0,0 +1,81 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const SPACE_ROLES_EVENT_TYPE: &str = "com.continuwuity.space.roles";
pub const SPACE_ROLE_MEMBER_EVENT_TYPE: &str = "com.continuwuity.space.role.member";
pub const SPACE_ROLE_ROOM_EVENT_TYPE: &str = "com.continuwuity.space.role.room";
pub const SPACE_CASCADING_EVENT_TYPE: &str = "com.continuwuity.space.cascading";
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceRolesEventContent {
pub roles: BTreeMap<String, RoleDefinition>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct RoleDefinition {
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub power_level: Option<i64>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceRoleMemberEventContent {
pub roles: Vec<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceRoleRoomEventContent {
pub required_roles: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct SpaceCascadingEventContent {
pub enabled: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn space_roles_roundtrip() {
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["admin"].power_level, Some(100));
assert!(deserialized.roles["nsfw"].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 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 missing_description_fails() {
let json = r#"{"power_level":100}"#;
serde_json::from_str::<RoleDefinition>(json).unwrap_err();
}
}

View file

@ -7,6 +7,7 @@ pub mod metadata;
pub mod outlier; pub mod outlier;
pub mod pdu_metadata; pub mod pdu_metadata;
pub mod read_receipt; pub mod read_receipt;
pub mod roles;
pub mod search; pub mod search;
pub mod short; pub mod short;
pub mod spaces; pub mod spaces;
@ -31,6 +32,7 @@ pub struct Service {
pub outlier: Arc<outlier::Service>, pub outlier: Arc<outlier::Service>,
pub pdu_metadata: Arc<pdu_metadata::Service>, pub pdu_metadata: Arc<pdu_metadata::Service>,
pub read_receipt: Arc<read_receipt::Service>, pub read_receipt: Arc<read_receipt::Service>,
pub roles: Arc<roles::Service>,
pub search: Arc<search::Service>, pub search: Arc<search::Service>,
pub short: Arc<short::Service>, pub short: Arc<short::Service>,
pub spaces: Arc<spaces::Service>, pub spaces: Arc<spaces::Service>,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,204 @@
use std::collections::{BTreeMap, HashSet};
use conduwuit_core::matrix::space_roles::RoleDefinition;
use super::{compute_user_power_level, roles_satisfy_requirements};
pub(super) 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()
}
pub(super) fn make_set(items: &[&str]) -> HashSet<String> {
items.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn power_level_single_role() {
let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50))]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["admin"])), Some(100));
}
#[test]
fn power_level_multiple_roles_takes_highest() {
let roles = make_roles(&[("admin", Some(100)), ("mod", Some(50)), ("helper", Some(25))]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["mod", "helper"])), Some(50));
}
#[test]
fn power_level_no_power_roles() {
let roles = make_roles(&[("nsfw", None), ("vip", None)]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["nsfw", "vip"])), None);
}
#[test]
fn power_level_mixed_roles() {
let roles = make_roles(&[("mod", Some(50)), ("nsfw", None)]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["mod", "nsfw"])), Some(50));
}
#[test]
fn power_level_no_roles_assigned() {
let roles = make_roles(&[("admin", Some(100))]);
assert_eq!(compute_user_power_level(&roles, &HashSet::new()), None);
}
#[test]
fn power_level_unknown_role_ignored() {
let roles = make_roles(&[("admin", Some(100))]);
assert_eq!(compute_user_power_level(&roles, &make_set(&["nonexistent"])), None);
}
#[test]
fn qualifies_with_all_required_roles() {
assert!(roles_satisfy_requirements(
&make_set(&["nsfw", "vip"]),
&make_set(&["nsfw", "vip", "extra"]),
));
}
#[test]
fn does_not_qualify_missing_one_role() {
assert!(!roles_satisfy_requirements(&make_set(&["nsfw", "vip"]), &make_set(&["nsfw"]),));
}
#[test]
fn qualifies_with_no_requirements() {
assert!(roles_satisfy_requirements(&HashSet::new(), &make_set(&["nsfw"])));
}
#[test]
fn does_not_qualify_with_no_roles() {
assert!(!roles_satisfy_requirements(&make_set(&["nsfw"]), &HashSet::new()));
}
// Multi-space scenarios
#[test]
fn multi_space_highest_pl_wins() {
let space_a_roles = make_roles(&[("mod", Some(50))]);
let space_b_roles = make_roles(&[("admin", Some(100))]);
let user_roles_a = make_set(&["mod"]);
let user_roles_b = make_set(&["admin"]);
let pl_a = compute_user_power_level(&space_a_roles, &user_roles_a);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles_b);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, Some(100));
}
#[test]
fn multi_space_one_space_has_no_pl() {
let space_a_roles = make_roles(&[("nsfw", None)]);
let space_b_roles = make_roles(&[("mod", Some(50))]);
let user_roles_a = make_set(&["nsfw"]);
let user_roles_b = make_set(&["mod"]);
let pl_a = compute_user_power_level(&space_a_roles, &user_roles_a);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles_b);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, Some(50));
}
#[test]
fn multi_space_neither_has_pl() {
let space_a_roles = make_roles(&[("nsfw", None)]);
let space_b_roles = make_roles(&[("vip", None)]);
let user_roles_a = make_set(&["nsfw"]);
let user_roles_b = make_set(&["vip"]);
let pl_a = compute_user_power_level(&space_a_roles, &user_roles_a);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles_b);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, None);
}
#[test]
fn multi_space_user_only_in_one_space() {
let space_a_roles = make_roles(&[("admin", Some(100))]);
let space_b_roles = make_roles(&[("mod", Some(50))]);
let user_roles_a = make_set(&["admin"]);
let user_roles_b: HashSet<String> = HashSet::new();
let pl_a = compute_user_power_level(&space_a_roles, &user_roles_a);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles_b);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, Some(100));
}
#[test]
fn multi_space_qualifies_in_one_not_other() {
let space_a_reqs = make_set(&["staff"]);
let space_b_reqs = make_set(&["nsfw"]);
let user_roles = make_set(&["nsfw"]);
assert!(!roles_satisfy_requirements(&space_a_reqs, &user_roles));
assert!(roles_satisfy_requirements(&space_b_reqs, &user_roles));
}
#[test]
fn multi_space_qualifies_after_role_revoke_via_other_space() {
let space_a_reqs = make_set(&["nsfw"]);
let space_b_reqs = make_set(&["vip"]);
let user_roles_after_revoke = make_set(&["vip"]);
assert!(!roles_satisfy_requirements(&space_a_reqs, &user_roles_after_revoke));
assert!(roles_satisfy_requirements(&space_b_reqs, &user_roles_after_revoke));
}
#[test]
fn multi_space_room_has_reqs_in_one_space_only() {
let space_a_reqs = make_set(&["admin"]);
let space_b_reqs: HashSet<String> = HashSet::new();
let user_roles = make_set(&["nsfw"]);
assert!(!roles_satisfy_requirements(&space_a_reqs, &user_roles));
assert!(roles_satisfy_requirements(&space_b_reqs, &user_roles));
}
#[test]
fn multi_space_no_qualification_anywhere() {
let space_a_reqs = make_set(&["staff"]);
let space_b_reqs = make_set(&["admin"]);
let user_roles = make_set(&["nsfw"]);
let qualifies_a = roles_satisfy_requirements(&space_a_reqs, &user_roles);
let qualifies_b = roles_satisfy_requirements(&space_b_reqs, &user_roles);
assert!(!qualifies_a);
assert!(!qualifies_b);
assert!(!(qualifies_a || qualifies_b));
}
#[test]
fn multi_space_same_role_different_pl() {
let space_a_roles = make_roles(&[("mod", Some(50))]);
let space_b_roles = make_roles(&[("mod", Some(75))]);
let user_roles = make_set(&["mod"]);
let pl_a = compute_user_power_level(&space_a_roles, &user_roles);
let pl_b = compute_user_power_level(&space_b_roles, &user_roles);
let effective = [pl_a, pl_b].into_iter().flatten().max();
assert_eq!(effective, Some(75));
}

View file

@ -327,7 +327,7 @@ where
} }
}, },
| TimelineEventType::SpaceChild => | TimelineEventType::SpaceChild =>
if let Some(_state_key) = pdu.state_key() { if pdu.state_key().is_some() {
self.services self.services
.spaces .spaces
.roomid_spacehierarchy_cache .roomid_spacehierarchy_cache
@ -359,6 +359,8 @@ where
| _ => {}, | _ => {},
} }
self.services.roles.on_pdu_appended(room_id, &pdu);
// CONCERN: If we receive events with a relation out-of-order, we never write // CONCERN: If we receive events with a relation out-of-order, we never write
// their relation / thread. We need some kind of way to trigger when we receive // their relation / thread. We need some kind of way to trigger when we receive
// this event, and potentially a way to rebuild the table entirely. // this event, and potentially a way to rebuild the table entirely.

View file

@ -97,6 +97,17 @@ pub async fn build_and_append_pdu(
))); )));
} }
} }
if *pdu.kind() == TimelineEventType::RoomPowerLevels {
if let Ok(proposed) =
pdu.get_content::<ruma::events::room::power_levels::RoomPowerLevelsEventContent>()
{
self.services
.roles
.validate_pl_change(&room_id, pdu.sender(), &proposed)
.await?;
}
}
if *pdu.kind() == TimelineEventType::RoomCreate { if *pdu.kind() == TimelineEventType::RoomCreate {
trace!("Creating shortroomid for {room_id}"); trace!("Creating shortroomid for {room_id}");
self.services self.services

View file

@ -80,6 +80,7 @@ struct Services {
threads: Dep<rooms::threads::Service>, threads: Dep<rooms::threads::Service>,
search: Dep<rooms::search::Service>, search: Dep<rooms::search::Service>,
spaces: Dep<rooms::spaces::Service>, spaces: Dep<rooms::spaces::Service>,
roles: Dep<rooms::roles::Service>,
event_handler: Dep<rooms::event_handler::Service>, event_handler: Dep<rooms::event_handler::Service>,
} }
@ -112,6 +113,7 @@ impl crate::Service for Service {
threads: args.depend::<rooms::threads::Service>("rooms::threads"), threads: args.depend::<rooms::threads::Service>("rooms::threads"),
search: args.depend::<rooms::search::Service>("rooms::search"), search: args.depend::<rooms::search::Service>("rooms::search"),
spaces: args.depend::<rooms::spaces::Service>("rooms::spaces"), spaces: args.depend::<rooms::spaces::Service>("rooms::spaces"),
roles: args.depend::<rooms::roles::Service>("rooms::roles"),
event_handler: args event_handler: args
.depend::<rooms::event_handler::Service>("rooms::event_handler"), .depend::<rooms::event_handler::Service>("rooms::event_handler"),
}, },

View file

@ -94,6 +94,7 @@ impl Services {
outlier: build!(rooms::outlier::Service), outlier: build!(rooms::outlier::Service),
pdu_metadata: build!(rooms::pdu_metadata::Service), pdu_metadata: build!(rooms::pdu_metadata::Service),
read_receipt: build!(rooms::read_receipt::Service), read_receipt: build!(rooms::read_receipt::Service),
roles: build!(rooms::roles::Service),
search: build!(rooms::search::Service), search: build!(rooms::search::Service),
short: build!(rooms::short::Service), short: build!(rooms::short::Service),
spaces: build!(rooms::spaces::Service), spaces: build!(rooms::spaces::Service),