Some checks failed
Documentation / Build and Deploy Documentation (pull_request) Has been skipped
Checks / Prek / Pre-commit & Formatting (pull_request) Failing after 5s
Checks / Prek / Clippy and Cargo Tests (pull_request) Failing after 6s
Update flake hashes / update-flake-hashes (pull_request) Failing after 5s
Add handle_space_member_leave to kick users from child rooms when they leave or are banned from a Space. Handle both Join and Leave/Ban membership transitions in on_pdu_appended dispatch. Fix enable command to create default roles before sending the cascading enable event, preventing enforcement from running against empty roles.
624 lines
16 KiB
Rust
624 lines
16 KiB
Rust
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
|
|
}
|