Add !admin space roles subcommands: list, add, remove, assign, revoke, require, unrequire, user, room, enable, disable, status. The remove command uses cascade_remove_role macro to deduplicate member and room cleanup loops. Role definitions, assignments, and room requirements are managed via state events.
632 lines
16 KiB
Rust
632 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()
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Cascade-remove a role name from all state events of a given type. For each
|
|
/// event that contains the role, the `$field` is filtered and the updated
|
|
/// content is sent back as a new state event.
|
|
macro_rules! cascade_remove_role {
|
|
(
|
|
$self:expr,
|
|
$shortstatehash:expr,
|
|
$event_type_fn:expr,
|
|
$event_type_const:expr,
|
|
$content_ty:ty,
|
|
$field:ident,
|
|
$role_name:expr,
|
|
$space_id:expr,
|
|
$state_lock:expr,
|
|
$server_user:expr
|
|
) => {{
|
|
let ev_type = $event_type_fn;
|
|
let entries: Vec<(_, ruma::OwnedEventId)> = $self
|
|
.services
|
|
.rooms
|
|
.state_accessor
|
|
.state_keys_with_ids($shortstatehash, &ev_type)
|
|
.collect()
|
|
.await;
|
|
|
|
for (state_key, event_id) in entries {
|
|
if let Ok(pdu) = $self.services.rooms.timeline.get_pdu(&event_id).await {
|
|
if let Ok(mut content) = pdu.get_content::<$content_ty>() {
|
|
if content.$field.contains($role_name) {
|
|
content.$field.retain(|r| r != $role_name);
|
|
$self
|
|
.services
|
|
.rooms
|
|
.timeline
|
|
.build_and_append_pdu(
|
|
custom_state_pdu!($event_type_const, &state_key, &content),
|
|
$server_user,
|
|
Some(&$space_id),
|
|
&$state_lock,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}};
|
|
}
|
|
|
|
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);
|
|
|
|
// Cascade: remove the deleted role from all member and room events
|
|
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;
|
|
|
|
cascade_remove_role!(
|
|
self,
|
|
shortstatehash,
|
|
member_event_type(),
|
|
SPACE_ROLE_MEMBER_EVENT_TYPE,
|
|
SpaceRoleMemberEventContent,
|
|
roles,
|
|
&role_name,
|
|
space_id,
|
|
state_lock,
|
|
server_user
|
|
);
|
|
|
|
cascade_remove_role!(
|
|
self,
|
|
shortstatehash,
|
|
room_event_type(),
|
|
SPACE_ROLE_ROOM_EVENT_TYPE,
|
|
SpaceRoleRoomEventContent,
|
|
required_roles,
|
|
&role_name,
|
|
space_id,
|
|
state_lock,
|
|
server_user
|
|
);
|
|
}
|
|
|
|
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
|
|
}
|