fix(spaces): address third review - event loops, PL guards, namespace, concurrency
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>
This commit is contained in:
parent
6fa67ed489
commit
939f7e3d72
11 changed files with 305 additions and 112 deletions
|
|
@ -40,7 +40,7 @@ space_permission_cascading = false
|
||||||
|
|
||||||
All events live in the Space room.
|
All events live in the Space room.
|
||||||
|
|
||||||
### `m.space.roles` (state key: `""`)
|
### `com.continuwuity.space.roles` (state key: `""`)
|
||||||
|
|
||||||
Defines the available roles for the Space. Two default roles (`admin` and `mod`)
|
Defines the available roles for the Space. Two default roles (`admin` and `mod`)
|
||||||
are created automatically when a Space is first encountered with the feature
|
are created automatically when a Space is first encountered with the feature
|
||||||
|
|
@ -72,7 +72,7 @@ enabled.
|
||||||
this power level in all child rooms. When a user holds multiple roles with
|
this power level in all child rooms. When a user holds multiple roles with
|
||||||
power levels, the highest value wins.
|
power levels, the highest value wins.
|
||||||
|
|
||||||
### `m.space.role.member` (state key: user ID)
|
### `com.continuwuity.space.role.member` (state key: user ID)
|
||||||
|
|
||||||
Assigns roles to a user within the Space.
|
Assigns roles to a user within the Space.
|
||||||
|
|
||||||
|
|
@ -82,7 +82,7 @@ Assigns roles to a user within the Space.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `m.space.role.room` (state key: room ID)
|
### `com.continuwuity.space.role.room` (state key: room ID)
|
||||||
|
|
||||||
Declares which roles a child room requires. A user must hold **all** listed
|
Declares which roles a child room requires. A user must hold **all** listed
|
||||||
roles to access the room.
|
roles to access the room.
|
||||||
|
|
@ -101,15 +101,15 @@ All enforcement is skipped when `space_permission_cascading = false`.
|
||||||
|
|
||||||
When a user attempts to join a room that is a direct child of a Space:
|
When a user attempts to join a room that is a direct child of a Space:
|
||||||
|
|
||||||
- Look up the room's `m.space.role.room` event in the parent Space.
|
- Look up the room's `com.continuwuity.space.role.room` event in the parent Space.
|
||||||
- If the room has `required_roles`, check the user's `m.space.role.member`.
|
- If the room has `required_roles`, check the user's `com.continuwuity.space.role.member`.
|
||||||
- Reject the join if the user is missing any required role.
|
- Reject the join if the user is missing any required role.
|
||||||
|
|
||||||
### 2. Power level override
|
### 2. Power level override
|
||||||
|
|
||||||
For every user in a child room of a Space:
|
For every user in a child room of a Space:
|
||||||
|
|
||||||
- Look up their roles via `m.space.role.member` in the parent Space.
|
- Look up their roles via `com.continuwuity.space.role.member` in the parent Space.
|
||||||
- For each role that has a `power_level`, take the highest value.
|
- For each role that has a `power_level`, take the highest value.
|
||||||
- Override the user's power level in the child room's `m.room.power_levels`.
|
- Override the user's power level in the child room's `m.room.power_levels`.
|
||||||
- Reject attempts to manually set per-room power levels that conflict with
|
- Reject attempts to manually set per-room power levels that conflict with
|
||||||
|
|
@ -117,7 +117,7 @@ For every user in a child room of a Space:
|
||||||
|
|
||||||
### 3. Role revocation
|
### 3. Role revocation
|
||||||
|
|
||||||
When an `m.space.role.member` event is updated and a role is removed:
|
When an `com.continuwuity.space.role.member` event is updated and a role is removed:
|
||||||
|
|
||||||
- Identify all child rooms that require the removed role.
|
- Identify all child rooms that require the removed role.
|
||||||
- Auto-kick the user from rooms they no longer qualify for.
|
- Auto-kick the user from rooms they no longer qualify for.
|
||||||
|
|
@ -125,14 +125,14 @@ When an `m.space.role.member` event is updated and a role is removed:
|
||||||
|
|
||||||
### 4. Room requirement change
|
### 4. Room requirement change
|
||||||
|
|
||||||
When an `m.space.role.room` event is updated with new requirements:
|
When an `com.continuwuity.space.role.room` event is updated with new requirements:
|
||||||
|
|
||||||
- Check all current members of the room.
|
- Check all current members of the room.
|
||||||
- Auto-kick members who do not hold all newly required roles.
|
- Auto-kick members who do not hold all newly required roles.
|
||||||
|
|
||||||
### 5. Auto-join on role grant
|
### 5. Auto-join on role grant
|
||||||
|
|
||||||
When an `m.space.role.member` event is updated and a role is added:
|
When an `com.continuwuity.space.role.member` event is updated and a role is added:
|
||||||
|
|
||||||
- Find all child rooms where the user now meets all required roles.
|
- Find all child rooms where the user now meets all required roles.
|
||||||
- Auto-join the user to qualifying rooms they are not already in.
|
- Auto-join the user to qualifying rooms they are not already in.
|
||||||
|
|
@ -157,9 +157,9 @@ existing `roomid_spacehierarchy_cache`.
|
||||||
|
|
||||||
| Index | Source event |
|
| Index | Source event |
|
||||||
|------------------------------|------------------------|
|
|------------------------------|------------------------|
|
||||||
| Space → roles defined | `m.space.roles` |
|
| Space → roles defined | `com.continuwuity.space.roles` |
|
||||||
| Space → user → roles | `m.space.role.member` |
|
| Space → user → roles | `com.continuwuity.space.role.member` |
|
||||||
| Space → room → required roles| `m.space.role.room` |
|
| Space → room → required roles| `com.continuwuity.space.role.room` |
|
||||||
| Room → parent Space | `m.space.child` (reverse lookup) |
|
| Room → parent Space | `m.space.child` (reverse lookup) |
|
||||||
|
|
||||||
The Space → child rooms mapping already exists.
|
The Space → child rooms mapping already exists.
|
||||||
|
|
@ -168,9 +168,9 @@ The Space → child rooms mapping already exists.
|
||||||
|
|
||||||
| Event changed | Action |
|
| Event changed | Action |
|
||||||
|----------------------------|-----------------------------------------------------|
|
|----------------------------|-----------------------------------------------------|
|
||||||
| `m.space.roles` | Refresh role definitions, revalidate all members |
|
| `com.continuwuity.space.roles` | Refresh role definitions, revalidate all members |
|
||||||
| `m.space.role.member` | Refresh user's roles, trigger auto-join/kick |
|
| `com.continuwuity.space.role.member` | Refresh user's roles, trigger auto-join/kick |
|
||||||
| `m.space.role.room` | Refresh room requirements, trigger auto-join/kick |
|
| `com.continuwuity.space.role.room` | Refresh room requirements, trigger auto-join/kick |
|
||||||
| `m.space.child` added | Index new child, auto-join qualifying members |
|
| `m.space.child` added | Index new child, auto-join qualifying members |
|
||||||
| `m.space.child` removed | Remove from index (no auto-kick) |
|
| `m.space.child` removed | Remove from index (no auto-kick) |
|
||||||
| Server startup | Full rebuild from state events |
|
| Server startup | Full rebuild from state events |
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
**Goal:** Implement server-side Space permission cascading — power levels and role-based access flow from Spaces to their direct child rooms.
|
**Goal:** Implement server-side Space permission cascading — power levels and role-based access flow from Spaces to their direct child rooms.
|
||||||
|
|
||||||
**Architecture:** Custom state events (`m.space.roles`, `m.space.role.member`, `m.space.role.room`) define roles in Space rooms. An in-memory cache indexes these for fast enforcement. The server intercepts joins, membership changes, and state event updates to enforce cascading. A server-wide config flag (`space_permission_cascading`) gates the entire feature.
|
**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
|
**Tech Stack:** Rust, ruma (Matrix types), conduwuit service layer, clap (admin commands), serde, LruCache/HashMap, tokio async
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ use std::collections::BTreeMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Content for `m.space.roles` (state key: "")
|
/// Content for `com.continuwuity.space.roles` (state key: "")
|
||||||
///
|
///
|
||||||
/// Defines available roles for a Space.
|
/// Defines available roles for a Space.
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
|
@ -84,7 +84,7 @@ pub struct RoleDefinition {
|
||||||
pub power_level: Option<i64>,
|
pub power_level: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content for `m.space.role.member` (state key: user ID)
|
/// Content for `com.continuwuity.space.role.member` (state key: user ID)
|
||||||
///
|
///
|
||||||
/// Assigns roles to a user within a Space.
|
/// Assigns roles to a user within a Space.
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
|
@ -92,7 +92,7 @@ pub struct SpaceRoleMemberEventContent {
|
||||||
pub roles: Vec<String>,
|
pub roles: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content for `m.space.role.room` (state key: room ID)
|
/// Content for `com.continuwuity.space.role.room` (state key: room ID)
|
||||||
///
|
///
|
||||||
/// Declares which roles a child room requires for access.
|
/// Declares which roles a child room requires for access.
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
|
@ -340,11 +340,11 @@ pub async fn populate_space(&self, space_id: &RoomId) -> Result {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load role definitions from m.space.roles
|
// Load role definitions from com.continuwuity.space.roles
|
||||||
let roles_content: Option<SpaceRolesEventContent> = self
|
let roles_content: Option<SpaceRolesEventContent> = self
|
||||||
.services
|
.services
|
||||||
.state_accessor
|
.state_accessor
|
||||||
.room_state_get_content(space_id, &StateEventType::from("m.space.roles"), "")
|
.room_state_get_content(space_id, &StateEventType::from("com.continuwuity.space.roles"), "")
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
|
|
@ -355,9 +355,9 @@ pub async fn populate_space(&self, space_id: &RoomId) -> Result {
|
||||||
.insert(space_id.to_owned(), content.roles);
|
.insert(space_id.to_owned(), content.roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user role assignments from m.space.role.member state events
|
// Load user role assignments from com.continuwuity.space.role.member state events
|
||||||
// Iterate all state events of type m.space.role.member
|
// Iterate all state events of type com.continuwuity.space.role.member
|
||||||
// Load room requirements from m.space.role.room state events
|
// Load room requirements from com.continuwuity.space.role.room state events
|
||||||
// Build room_to_space reverse index from m.space.child events
|
// Build room_to_space reverse index from m.space.child events
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -446,7 +446,7 @@ git commit -m "feat(spaces): add cache population and lookup methods for space r
|
||||||
**Step 1: Add default role creation**
|
**Step 1: Add default role creation**
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
/// Ensure a Space has the default admin/mod roles. Sends an m.space.roles
|
/// Ensure a Space has the default admin/mod roles. Sends an com.continuwuity.space.roles
|
||||||
/// state event if none exists.
|
/// state event if none exists.
|
||||||
#[implement(Service)]
|
#[implement(Service)]
|
||||||
pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result {
|
pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result {
|
||||||
|
|
@ -454,11 +454,11 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if m.space.roles already exists
|
// Check if com.continuwuity.space.roles already exists
|
||||||
let existing: Result<SpaceRolesEventContent> = self
|
let existing: Result<SpaceRolesEventContent> = self
|
||||||
.services
|
.services
|
||||||
.state_accessor
|
.state_accessor
|
||||||
.room_state_get_content(space_id, &StateEventType::from("m.space.roles"), "")
|
.room_state_get_content(space_id, &StateEventType::from("com.continuwuity.space.roles"), "")
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if existing.is_ok() {
|
if existing.is_ok() {
|
||||||
|
|
@ -750,13 +750,13 @@ In the `append_pdu()` function, after the event is successfully appended, add a
|
||||||
if self.services.roles.is_enabled() {
|
if self.services.roles.is_enabled() {
|
||||||
if let Some(state_key) = &pdu.state_key {
|
if let Some(state_key) = &pdu.state_key {
|
||||||
match pdu.event_type() {
|
match pdu.event_type() {
|
||||||
// m.space.roles changed -> revalidate all members
|
// com.continuwuity.space.roles changed -> revalidate all members
|
||||||
t if t == "m.space.roles" => {
|
t if t == "com.continuwuity.space.roles" => {
|
||||||
self.services.roles.populate_space(&pdu.room_id).await?;
|
self.services.roles.populate_space(&pdu.room_id).await?;
|
||||||
// Revalidate all members against all child rooms
|
// Revalidate all members against all child rooms
|
||||||
}
|
}
|
||||||
// m.space.role.member changed -> auto-join/kick that user
|
// com.continuwuity.space.role.member changed -> auto-join/kick that user
|
||||||
t if t == "m.space.role.member" => {
|
t if t == "com.continuwuity.space.role.member" => {
|
||||||
if let Ok(user_id) = UserId::parse(state_key) {
|
if let Ok(user_id) = UserId::parse(state_key) {
|
||||||
self.services.roles.populate_space(&pdu.room_id).await?;
|
self.services.roles.populate_space(&pdu.room_id).await?;
|
||||||
self.services
|
self.services
|
||||||
|
|
@ -770,8 +770,8 @@ if self.services.roles.is_enabled() {
|
||||||
// Sync power levels in all child rooms for this user
|
// Sync power levels in all child rooms for this user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// m.space.role.room changed -> auto-join/kick for that room
|
// com.continuwuity.space.role.room changed -> auto-join/kick for that room
|
||||||
t if t == "m.space.role.room" => {
|
t if t == "com.continuwuity.space.role.room" => {
|
||||||
if let Ok(room_id) = RoomId::parse(state_key) {
|
if let Ok(room_id) = RoomId::parse(state_key) {
|
||||||
self.services.roles.populate_space(&pdu.room_id).await?;
|
self.services.roles.populate_space(&pdu.room_id).await?;
|
||||||
// Check all members of room_id against new requirements
|
// Check all members of room_id against new requirements
|
||||||
|
|
@ -1052,7 +1052,7 @@ pub(super) async fn list(&self, space: OwnedRoomOrAliasId) -> Result {
|
||||||
.services
|
.services
|
||||||
.rooms
|
.rooms
|
||||||
.state_accessor
|
.state_accessor
|
||||||
.room_state_get_content(&space_id, &StateEventType::from("m.space.roles"), "")
|
.room_state_get_content(&space_id, &StateEventType::from("com.continuwuity.space.roles"), "")
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ macro_rules! custom_state_pdu {
|
||||||
PduBuilder {
|
PduBuilder {
|
||||||
event_type: $event_type.to_owned().into(),
|
event_type: $event_type.to_owned().into(),
|
||||||
content: to_raw_value($content)
|
content: to_raw_value($content)
|
||||||
.expect("Failed to serialize custom state event content"),
|
.map_err(|e| conduwuit::Error::Err(format!(
|
||||||
|
"Failed to serialize custom state event content: {e}"
|
||||||
|
).into()))?,
|
||||||
state_key: Some($state_key.to_owned().into()),
|
state_key: Some($state_key.to_owned().into()),
|
||||||
..PduBuilder::default()
|
..PduBuilder::default()
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +105,7 @@ async fn list(&self, space: OwnedRoomOrAliasId) -> Result {
|
||||||
) {
|
) {
|
||||||
return self.write_str("Error: The specified room is not a Space.").await;
|
return self.write_str("Error: The specified room is not a Space.").await;
|
||||||
}
|
}
|
||||||
let roles_event_type = StateEventType::from("m.space.roles".to_owned());
|
let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned());
|
||||||
|
|
||||||
let content: SpaceRolesEventContent = self
|
let content: SpaceRolesEventContent = self
|
||||||
.services
|
.services
|
||||||
|
|
@ -146,7 +148,7 @@ async fn add(
|
||||||
) {
|
) {
|
||||||
return self.write_str("Error: The specified room is not a Space.").await;
|
return self.write_str("Error: The specified room is not a Space.").await;
|
||||||
}
|
}
|
||||||
let roles_event_type = StateEventType::from("m.space.roles".to_owned());
|
let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned());
|
||||||
|
|
||||||
let mut content: SpaceRolesEventContent = self
|
let mut content: SpaceRolesEventContent = self
|
||||||
.services
|
.services
|
||||||
|
|
@ -172,7 +174,7 @@ async fn add(
|
||||||
.rooms
|
.rooms
|
||||||
.timeline
|
.timeline
|
||||||
.build_and_append_pdu(
|
.build_and_append_pdu(
|
||||||
custom_state_pdu!("m.space.roles", "", &content),
|
custom_state_pdu!("com.continuwuity.space.roles", "", &content),
|
||||||
server_user,
|
server_user,
|
||||||
Some(&space_id),
|
Some(&space_id),
|
||||||
&state_lock,
|
&state_lock,
|
||||||
|
|
@ -193,7 +195,7 @@ async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result {
|
||||||
) {
|
) {
|
||||||
return self.write_str("Error: The specified room is not a Space.").await;
|
return self.write_str("Error: The specified room is not a Space.").await;
|
||||||
}
|
}
|
||||||
let roles_event_type = StateEventType::from("m.space.roles".to_owned());
|
let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned());
|
||||||
|
|
||||||
let mut content: SpaceRolesEventContent = self
|
let mut content: SpaceRolesEventContent = self
|
||||||
.services
|
.services
|
||||||
|
|
@ -214,7 +216,7 @@ async fn remove(&self, space: OwnedRoomOrAliasId, role_name: String) -> Result {
|
||||||
.rooms
|
.rooms
|
||||||
.timeline
|
.timeline
|
||||||
.build_and_append_pdu(
|
.build_and_append_pdu(
|
||||||
custom_state_pdu!("m.space.roles", "", &content),
|
custom_state_pdu!("com.continuwuity.space.roles", "", &content),
|
||||||
server_user,
|
server_user,
|
||||||
Some(&space_id),
|
Some(&space_id),
|
||||||
&state_lock,
|
&state_lock,
|
||||||
|
|
@ -240,7 +242,7 @@ async fn assign(
|
||||||
) {
|
) {
|
||||||
return self.write_str("Error: The specified room is not a Space.").await;
|
return self.write_str("Error: The specified room is not a Space.").await;
|
||||||
}
|
}
|
||||||
let member_event_type = StateEventType::from("m.space.role.member".to_owned());
|
let member_event_type = StateEventType::from("com.continuwuity.space.role.member".to_owned());
|
||||||
|
|
||||||
let mut content: SpaceRoleMemberEventContent = self
|
let mut content: SpaceRoleMemberEventContent = self
|
||||||
.services
|
.services
|
||||||
|
|
@ -263,7 +265,7 @@ async fn assign(
|
||||||
.rooms
|
.rooms
|
||||||
.timeline
|
.timeline
|
||||||
.build_and_append_pdu(
|
.build_and_append_pdu(
|
||||||
custom_state_pdu!("m.space.role.member", user_id.as_str(), &content),
|
custom_state_pdu!("com.continuwuity.space.role.member", user_id.as_str(), &content),
|
||||||
server_user,
|
server_user,
|
||||||
Some(&space_id),
|
Some(&space_id),
|
||||||
&state_lock,
|
&state_lock,
|
||||||
|
|
@ -291,7 +293,7 @@ async fn revoke(
|
||||||
) {
|
) {
|
||||||
return self.write_str("Error: The specified room is not a Space.").await;
|
return self.write_str("Error: The specified room is not a Space.").await;
|
||||||
}
|
}
|
||||||
let member_event_type = StateEventType::from("m.space.role.member".to_owned());
|
let member_event_type = StateEventType::from("com.continuwuity.space.role.member".to_owned());
|
||||||
|
|
||||||
let mut content: SpaceRoleMemberEventContent = self
|
let mut content: SpaceRoleMemberEventContent = self
|
||||||
.services
|
.services
|
||||||
|
|
@ -315,7 +317,7 @@ async fn revoke(
|
||||||
.rooms
|
.rooms
|
||||||
.timeline
|
.timeline
|
||||||
.build_and_append_pdu(
|
.build_and_append_pdu(
|
||||||
custom_state_pdu!("m.space.role.member", user_id.as_str(), &content),
|
custom_state_pdu!("com.continuwuity.space.role.member", user_id.as_str(), &content),
|
||||||
server_user,
|
server_user,
|
||||||
Some(&space_id),
|
Some(&space_id),
|
||||||
&state_lock,
|
&state_lock,
|
||||||
|
|
@ -343,7 +345,7 @@ async fn require(
|
||||||
) {
|
) {
|
||||||
return self.write_str("Error: The specified room is not a Space.").await;
|
return self.write_str("Error: The specified room is not a Space.").await;
|
||||||
}
|
}
|
||||||
let room_event_type = StateEventType::from("m.space.role.room".to_owned());
|
let room_event_type = StateEventType::from("com.continuwuity.space.role.room".to_owned());
|
||||||
|
|
||||||
let mut content: SpaceRoleRoomEventContent = self
|
let mut content: SpaceRoleRoomEventContent = self
|
||||||
.services
|
.services
|
||||||
|
|
@ -366,7 +368,7 @@ async fn require(
|
||||||
.rooms
|
.rooms
|
||||||
.timeline
|
.timeline
|
||||||
.build_and_append_pdu(
|
.build_and_append_pdu(
|
||||||
custom_state_pdu!("m.space.role.room", room_id.as_str(), &content),
|
custom_state_pdu!("com.continuwuity.space.role.room", room_id.as_str(), &content),
|
||||||
server_user,
|
server_user,
|
||||||
Some(&space_id),
|
Some(&space_id),
|
||||||
&state_lock,
|
&state_lock,
|
||||||
|
|
@ -394,7 +396,7 @@ async fn unrequire(
|
||||||
) {
|
) {
|
||||||
return self.write_str("Error: The specified room is not a Space.").await;
|
return self.write_str("Error: The specified room is not a Space.").await;
|
||||||
}
|
}
|
||||||
let room_event_type = StateEventType::from("m.space.role.room".to_owned());
|
let room_event_type = StateEventType::from("com.continuwuity.space.role.room".to_owned());
|
||||||
|
|
||||||
let mut content: SpaceRoleRoomEventContent = self
|
let mut content: SpaceRoleRoomEventContent = self
|
||||||
.services
|
.services
|
||||||
|
|
@ -418,7 +420,7 @@ async fn unrequire(
|
||||||
.rooms
|
.rooms
|
||||||
.timeline
|
.timeline
|
||||||
.build_and_append_pdu(
|
.build_and_append_pdu(
|
||||||
custom_state_pdu!("m.space.role.room", room_id.as_str(), &content),
|
custom_state_pdu!("com.continuwuity.space.role.room", room_id.as_str(), &content),
|
||||||
server_user,
|
server_user,
|
||||||
Some(&space_id),
|
Some(&space_id),
|
||||||
&state_lock,
|
&state_lock,
|
||||||
|
|
|
||||||
|
|
@ -348,14 +348,23 @@ pub async fn join_room_by_id_helper(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Space permission cascading: check if user has required roles
|
// Space permission cascading: check if user has required roles
|
||||||
|
// User must qualify in at least one parent space (if any exist)
|
||||||
if services.rooms.roles.is_enabled() {
|
if services.rooms.roles.is_enabled() {
|
||||||
if let Some(parent_space) = services.rooms.roles.get_parent_space(room_id).await {
|
let parent_spaces = services.rooms.roles.get_parent_spaces(room_id).await;
|
||||||
if !services
|
if !parent_spaces.is_empty() {
|
||||||
|
let mut qualifies_in_any = false;
|
||||||
|
for parent_space in &parent_spaces {
|
||||||
|
if services
|
||||||
.rooms
|
.rooms
|
||||||
.roles
|
.roles
|
||||||
.user_qualifies_for_room(&parent_space, room_id, sender_user)
|
.user_qualifies_for_room(parent_space, room_id, sender_user)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
qualifies_in_any = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !qualifies_in_any {
|
||||||
return Err!(Request(Forbidden(
|
return Err!(Request(Forbidden(
|
||||||
"You do not have the required Space roles to join this room"
|
"You do not have the required Space roles to join this room"
|
||||||
)));
|
)));
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use std::collections::BTreeMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Content for `m.space.roles` (state key: "")
|
/// Content for `com.continuwuity.space.roles` (state key: "")
|
||||||
///
|
///
|
||||||
/// Defines available roles for a Space.
|
/// Defines available roles for a Space.
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
|
@ -26,7 +26,7 @@ pub struct RoleDefinition {
|
||||||
pub power_level: Option<i64>,
|
pub power_level: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content for `m.space.role.member` (state key: user ID)
|
/// Content for `com.continuwuity.space.role.member` (state key: user ID)
|
||||||
///
|
///
|
||||||
/// Assigns roles to a user within a Space.
|
/// Assigns roles to a user within a Space.
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
|
@ -34,7 +34,7 @@ pub struct SpaceRoleMemberEventContent {
|
||||||
pub roles: Vec<String>,
|
pub roles: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content for `m.space.role.room` (state key: room ID)
|
/// Content for `com.continuwuity.space.role.room` (state key: room ID)
|
||||||
///
|
///
|
||||||
/// Declares which roles a child room requires for access.
|
/// Declares which roles a child room requires for access.
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
|
||||||
use conduwuit_core::matrix::space_roles::RoleDefinition;
|
use conduwuit_core::matrix::space_roles::RoleDefinition;
|
||||||
use ruma::{room_id, user_id, OwnedRoomId, OwnedUserId};
|
use ruma::{room_id, user_id, OwnedRoomId, OwnedUserId};
|
||||||
|
use std::collections::HashSet as StdHashSet;
|
||||||
|
|
||||||
use super::tests::{make_requirements, make_roles, make_user_roles};
|
use super::tests::{make_requirements, make_roles, make_user_roles};
|
||||||
|
|
||||||
|
|
@ -12,7 +13,7 @@ struct MockCache {
|
||||||
roles: HashMap<OwnedRoomId, BTreeMap<String, RoleDefinition>>,
|
roles: HashMap<OwnedRoomId, BTreeMap<String, RoleDefinition>>,
|
||||||
user_roles: HashMap<OwnedRoomId, HashMap<OwnedUserId, HashSet<String>>>,
|
user_roles: HashMap<OwnedRoomId, HashMap<OwnedUserId, HashSet<String>>>,
|
||||||
room_requirements: HashMap<OwnedRoomId, HashMap<OwnedRoomId, HashSet<String>>>,
|
room_requirements: HashMap<OwnedRoomId, HashMap<OwnedRoomId, HashSet<String>>>,
|
||||||
room_to_space: HashMap<OwnedRoomId, OwnedRoomId>,
|
room_to_space: HashMap<OwnedRoomId, StdHashSet<OwnedRoomId>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockCache {
|
impl MockCache {
|
||||||
|
|
@ -30,7 +31,10 @@ impl MockCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_child(&mut self, space: &OwnedRoomId, child: OwnedRoomId) {
|
fn add_child(&mut self, space: &OwnedRoomId, child: OwnedRoomId) {
|
||||||
self.room_to_space.insert(child, space.clone());
|
self.room_to_space
|
||||||
|
.entry(child)
|
||||||
|
.or_default()
|
||||||
|
.insert(space.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assign_role(&mut self, space: &OwnedRoomId, user: OwnedUserId, role: String) {
|
fn assign_role(&mut self, space: &OwnedRoomId, user: OwnedUserId, role: String) {
|
||||||
|
|
@ -194,11 +198,10 @@ fn cache_reverse_lookup_consistency() {
|
||||||
cache.add_child(&space, child1.clone());
|
cache.add_child(&space, child1.clone());
|
||||||
cache.add_child(&space, child2.clone());
|
cache.add_child(&space, child2.clone());
|
||||||
|
|
||||||
assert_eq!(cache.room_to_space.get(&child1), Some(&space));
|
assert!(cache.room_to_space.get(&child1).unwrap().contains(&space));
|
||||||
assert_eq!(cache.room_to_space.get(&child2), Some(&space));
|
assert!(cache.room_to_space.get(&child2).unwrap().contains(&space));
|
||||||
assert_eq!(
|
assert!(
|
||||||
cache.room_to_space.get(room_id!("!unknown:example.com")),
|
cache.room_to_space.get(room_id!("!unknown:example.com")).is_none()
|
||||||
None
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ use conduwuit::{
|
||||||
};
|
};
|
||||||
use serde_json::value::to_raw_value;
|
use serde_json::value::to_raw_value;
|
||||||
use conduwuit_core::{
|
use conduwuit_core::{
|
||||||
|
Err,
|
||||||
matrix::space_roles::{
|
matrix::space_roles::{
|
||||||
RoleDefinition, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent,
|
RoleDefinition, SpaceRoleMemberEventContent, SpaceRoleRoomEventContent,
|
||||||
SpaceRolesEventContent,
|
SpaceRolesEventContent,
|
||||||
|
|
@ -53,8 +54,10 @@ pub struct Service {
|
||||||
pub user_roles: RwLock<HashMap<OwnedRoomId, HashMap<OwnedUserId, HashSet<String>>>>,
|
pub user_roles: RwLock<HashMap<OwnedRoomId, HashMap<OwnedUserId, HashSet<String>>>>,
|
||||||
/// Space ID -> child room ID -> required role names
|
/// Space ID -> child room ID -> required role names
|
||||||
pub room_requirements: RwLock<HashMap<OwnedRoomId, HashMap<OwnedRoomId, HashSet<String>>>>,
|
pub room_requirements: RwLock<HashMap<OwnedRoomId, HashMap<OwnedRoomId, HashSet<String>>>>,
|
||||||
/// Child room ID -> parent space ID
|
/// Child room ID -> parent space IDs (a room can be in multiple spaces)
|
||||||
pub room_to_space: RwLock<HashMap<OwnedRoomId, OwnedRoomId>>,
|
pub room_to_space: RwLock<HashMap<OwnedRoomId, HashSet<OwnedRoomId>>>,
|
||||||
|
/// Semaphore to limit concurrent enforcement tasks
|
||||||
|
pub enforcement_semaphore: tokio::sync::Semaphore,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Services {
|
struct Services {
|
||||||
|
|
@ -87,6 +90,7 @@ impl crate::Service for Service {
|
||||||
user_roles: RwLock::new(HashMap::new()),
|
user_roles: RwLock::new(HashMap::new()),
|
||||||
room_requirements: RwLock::new(HashMap::new()),
|
room_requirements: RwLock::new(HashMap::new()),
|
||||||
room_to_space: RwLock::new(HashMap::new()),
|
room_to_space: RwLock::new(HashMap::new()),
|
||||||
|
enforcement_semaphore: tokio::sync::Semaphore::new(4),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,7 +163,7 @@ pub fn is_enabled(&self) -> bool { self.server.config.space_permission_cascading
|
||||||
|
|
||||||
/// Ensure a Space has the default admin/mod roles defined.
|
/// Ensure a Space has the default admin/mod roles defined.
|
||||||
///
|
///
|
||||||
/// Checks whether an `m.space.roles` state event exists in the given space.
|
/// Checks whether a `com.continuwuity.space.roles` state event exists in the given space.
|
||||||
/// If not, creates default roles (admin at PL 100, mod at PL 50) and sends
|
/// If not, creates default roles (admin at PL 100, mod at PL 50) and sends
|
||||||
/// the state event as the server user.
|
/// the state event as the server user.
|
||||||
#[implement(Service)]
|
#[implement(Service)]
|
||||||
|
|
@ -168,8 +172,8 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if m.space.roles already exists
|
// Check if com.continuwuity.space.roles already exists
|
||||||
let roles_event_type = StateEventType::from("m.space.roles".to_owned());
|
let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned());
|
||||||
if self
|
if self
|
||||||
.services
|
.services
|
||||||
.state_accessor
|
.state_accessor
|
||||||
|
|
@ -203,9 +207,9 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result {
|
||||||
let state_lock = self.services.state.mutex.lock(space_id).await;
|
let state_lock = self.services.state.mutex.lock(space_id).await;
|
||||||
|
|
||||||
let pdu = PduBuilder {
|
let pdu = PduBuilder {
|
||||||
event_type: ruma::events::TimelineEventType::from("m.space.roles".to_owned()),
|
event_type: ruma::events::TimelineEventType::from("com.continuwuity.space.roles".to_owned()),
|
||||||
content: to_raw_value(&content)
|
content: to_raw_value(&content)
|
||||||
.expect("Failed to serialize SpaceRolesEventContent"),
|
.map_err(|e| conduwuit::Error::Err(format!("Failed to serialize SpaceRolesEventContent: {e}").into()))?,
|
||||||
state_key: Some(String::new().into()),
|
state_key: Some(String::new().into()),
|
||||||
..PduBuilder::default()
|
..PduBuilder::default()
|
||||||
};
|
};
|
||||||
|
|
@ -215,14 +219,14 @@ pub async fn ensure_default_roles(&self, space_id: &RoomId) -> Result {
|
||||||
.build_and_append_pdu(pdu, sender, Some(space_id), &state_lock)
|
.build_and_append_pdu(pdu, sender, Some(space_id), &state_lock)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
debug!("Sent default m.space.roles event for {space_id}");
|
debug!("Sent default com.continuwuity.space.roles event for {space_id}");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Populate the in-memory caches from state events for a single Space room.
|
/// Populate the in-memory caches from state events for a single Space room.
|
||||||
///
|
///
|
||||||
/// Reads `m.space.roles`, `m.space.role.member`, `m.space.role.room`, and
|
/// Reads `com.continuwuity.space.roles`, `com.continuwuity.space.role.member`, `com.continuwuity.space.role.room`, and
|
||||||
/// `m.space.child` state events and indexes them for fast lookup.
|
/// `m.space.child` state events and indexes them for fast lookup.
|
||||||
#[implement(Service)]
|
#[implement(Service)]
|
||||||
pub async fn populate_space(&self, space_id: &RoomId) {
|
pub async fn populate_space(&self, space_id: &RoomId) {
|
||||||
|
|
@ -230,8 +234,8 @@ pub async fn populate_space(&self, space_id: &RoomId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Read m.space.roles (state key: "")
|
// 1. Read com.continuwuity.space.roles (state key: "")
|
||||||
let roles_event_type = StateEventType::from("m.space.roles".to_owned());
|
let roles_event_type = StateEventType::from("com.continuwuity.space.roles".to_owned());
|
||||||
if let Ok(content) = self
|
if let Ok(content) = self
|
||||||
.services
|
.services
|
||||||
.state_accessor
|
.state_accessor
|
||||||
|
|
@ -244,8 +248,8 @@ pub async fn populate_space(&self, space_id: &RoomId) {
|
||||||
.insert(space_id.to_owned(), content.roles);
|
.insert(space_id.to_owned(), content.roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Read all m.space.role.member state events (state key: user ID)
|
// 2. Read all com.continuwuity.space.role.member state events (state key: user ID)
|
||||||
let member_event_type = StateEventType::from("m.space.role.member".to_owned());
|
let member_event_type = StateEventType::from("com.continuwuity.space.role.member".to_owned());
|
||||||
if let Ok(shortstatehash) = self
|
if let Ok(shortstatehash) = self
|
||||||
.services
|
.services
|
||||||
.state
|
.state
|
||||||
|
|
@ -282,8 +286,8 @@ pub async fn populate_space(&self, space_id: &RoomId) {
|
||||||
.await
|
.await
|
||||||
.insert(space_id.to_owned(), user_roles_map);
|
.insert(space_id.to_owned(), user_roles_map);
|
||||||
|
|
||||||
// 3. Read all m.space.role.room state events (state key: room ID)
|
// 3. Read all com.continuwuity.space.role.room state events (state key: room ID)
|
||||||
let room_event_type = StateEventType::from("m.space.role.room".to_owned());
|
let room_event_type = StateEventType::from("com.continuwuity.space.role.room".to_owned());
|
||||||
let mut room_reqs_map: HashMap<OwnedRoomId, HashSet<String>> = HashMap::new();
|
let mut room_reqs_map: HashMap<OwnedRoomId, HashSet<String>> = HashMap::new();
|
||||||
|
|
||||||
self.services
|
self.services
|
||||||
|
|
@ -350,7 +354,10 @@ pub async fn populate_space(&self, space_id: &RoomId) {
|
||||||
{
|
{
|
||||||
let mut room_to_space = self.room_to_space.write().await;
|
let mut room_to_space = self.room_to_space.write().await;
|
||||||
for child_room_id in child_rooms {
|
for child_room_id in child_rooms {
|
||||||
room_to_space.insert(child_room_id, space_id.to_owned());
|
room_to_space
|
||||||
|
.entry(child_room_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(space_id.to_owned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -412,14 +419,19 @@ pub async fn user_qualifies_for_room(
|
||||||
required.iter().all(|r| user_assigned.contains(r))
|
required.iter().all(|r| user_assigned.contains(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the parent Space of a child room, if any.
|
/// Get the parent Spaces of a child room, if any.
|
||||||
#[implement(Service)]
|
#[implement(Service)]
|
||||||
pub async fn get_parent_space(&self, room_id: &RoomId) -> Option<OwnedRoomId> {
|
pub async fn get_parent_spaces(&self, room_id: &RoomId) -> Vec<OwnedRoomId> {
|
||||||
if !self.is_enabled() {
|
if !self.is_enabled() {
|
||||||
return None;
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
self.room_to_space.read().await.get(room_id).cloned()
|
self.room_to_space
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(room_id)
|
||||||
|
.map(|set| set.iter().cloned().collect())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronize power levels in a child room based on Space roles.
|
/// Synchronize power levels in a child room based on Space roles.
|
||||||
|
|
@ -430,6 +442,20 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if server user is joined to the room
|
||||||
|
let server_user = self.services.globals.server_user.as_ref();
|
||||||
|
if !self
|
||||||
|
.services
|
||||||
|
.state_cache
|
||||||
|
.is_joined(server_user, room_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
debug_warn!(
|
||||||
|
"Server user is not joined to {room_id}, skipping PL sync"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Get current power levels for the room
|
// 1. Get current power levels for the room
|
||||||
let mut power_levels_content: RoomPowerLevelsEventContent = self
|
let mut power_levels_content: RoomPowerLevelsEventContent = self
|
||||||
.services
|
.services
|
||||||
|
|
@ -479,7 +505,6 @@ pub async fn sync_power_levels(&self, space_id: &RoomId, room_id: &RoomId) -> Re
|
||||||
// 5. If changed, send updated power levels event
|
// 5. If changed, send updated power levels event
|
||||||
if changed {
|
if changed {
|
||||||
let state_lock = self.services.state.mutex.lock(room_id).await;
|
let state_lock = self.services.state.mutex.lock(room_id).await;
|
||||||
let server_user = self.services.globals.server_user.as_ref();
|
|
||||||
|
|
||||||
self.services
|
self.services
|
||||||
.timeline
|
.timeline
|
||||||
|
|
@ -517,7 +542,7 @@ pub async fn auto_join_qualifying_rooms(
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, parent)| **parent == *space_id)
|
.filter(|(_, parents)| parents.contains(space_id))
|
||||||
.map(|(child, _)| child.clone())
|
.map(|(child, _)| child.clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -542,6 +567,19 @@ pub async fn auto_join_qualifying_rooms(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if server user is joined to the child room
|
||||||
|
if !self
|
||||||
|
.services
|
||||||
|
.state_cache
|
||||||
|
.is_joined(server_user, child_room_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
debug_warn!(
|
||||||
|
"Server user is not joined to {child_room_id}, skipping auto-join"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let state_lock = self.services.state.mutex.lock(child_room_id).await;
|
let state_lock = self.services.state.mutex.lock(child_room_id).await;
|
||||||
|
|
||||||
// First invite the user (server user as sender)
|
// First invite the user (server user as sender)
|
||||||
|
|
@ -603,18 +641,20 @@ impl Service {
|
||||||
|
|
||||||
let this = Arc::clone(self);
|
let this = Arc::clone(self);
|
||||||
self.server.runtime().spawn(async move {
|
self.server.runtime().spawn(async move {
|
||||||
|
let _permit = this.enforcement_semaphore.acquire().await;
|
||||||
|
|
||||||
// Always repopulate cache first
|
// Always repopulate cache first
|
||||||
this.populate_space(&space_id).await;
|
this.populate_space(&space_id).await;
|
||||||
|
|
||||||
match event_type.as_str() {
|
match event_type.as_str() {
|
||||||
| "m.space.roles" => {
|
| "com.continuwuity.space.roles" => {
|
||||||
// Role definitions changed — sync PLs in all child rooms
|
// Role definitions changed — sync PLs in all child rooms
|
||||||
let child_rooms: Vec<OwnedRoomId> = this
|
let child_rooms: Vec<OwnedRoomId> = this
|
||||||
.room_to_space
|
.room_to_space
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, parent)| **parent == *space_id)
|
.filter(|(_, parents)| parents.contains(&space_id))
|
||||||
.map(|(child, _)| child.clone())
|
.map(|(child, _)| child.clone())
|
||||||
.collect();
|
.collect();
|
||||||
for child_room_id in &child_rooms {
|
for child_room_id in &child_rooms {
|
||||||
|
|
@ -625,7 +665,7 @@ impl Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
| "m.space.role.member" => {
|
| "com.continuwuity.space.role.member" => {
|
||||||
// User's roles changed — auto-join/kick + PL sync
|
// User's roles changed — auto-join/kick + PL sync
|
||||||
if let Ok(user_id) = UserId::parse(state_key.as_str()) {
|
if let Ok(user_id) = UserId::parse(state_key.as_str()) {
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
|
|
@ -648,7 +688,7 @@ impl Service {
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, parent)| **parent == *space_id)
|
.filter(|(_, parents)| parents.contains(&space_id))
|
||||||
.map(|(child, _)| child.clone())
|
.map(|(child, _)| child.clone())
|
||||||
.collect();
|
.collect();
|
||||||
for child_room_id in &child_rooms {
|
for child_room_id in &child_rooms {
|
||||||
|
|
@ -662,7 +702,7 @@ impl Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
| "m.space.role.room" => {
|
| "com.continuwuity.space.role.room" => {
|
||||||
// Room requirements changed — kick unqualified members
|
// Room requirements changed — kick unqualified members
|
||||||
if let Ok(target_room) = RoomId::parse(state_key.as_str()) {
|
if let Ok(target_room) = RoomId::parse(state_key.as_str()) {
|
||||||
let members: Vec<OwnedUserId> = this
|
let members: Vec<OwnedUserId> = this
|
||||||
|
|
@ -699,7 +739,7 @@ impl Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a new m.space.child event — update index and auto-join qualifying
|
/// Handle a new m.space.child event — update index and auto-join qualifying
|
||||||
/// members.
|
/// members, or remove child from index if `via` is empty.
|
||||||
pub fn handle_space_child_change(
|
pub fn handle_space_child_change(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
space_id: OwnedRoomId,
|
space_id: OwnedRoomId,
|
||||||
|
|
@ -711,13 +751,60 @@ impl Service {
|
||||||
|
|
||||||
let this = Arc::clone(self);
|
let this = Arc::clone(self);
|
||||||
self.server.runtime().spawn(async move {
|
self.server.runtime().spawn(async move {
|
||||||
// Update the reverse index
|
let _permit = this.enforcement_semaphore.acquire().await;
|
||||||
|
|
||||||
|
// Read the actual m.space.child state event to check via
|
||||||
|
let child_event_type = StateEventType::SpaceChild;
|
||||||
|
let is_removal = match this
|
||||||
|
.services
|
||||||
|
.state_accessor
|
||||||
|
.room_state_get_content::<SpaceChildEventContent>(
|
||||||
|
&space_id,
|
||||||
|
&child_event_type,
|
||||||
|
child_room_id.as_str(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
| Ok(content) => content.via.is_empty(),
|
||||||
|
| Err(_) => true, // If we can't read it, treat as removal
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_removal {
|
||||||
|
// Remove child from room_to_space reverse index
|
||||||
|
let mut room_to_space = this.room_to_space.write().await;
|
||||||
|
if let Some(parents) = room_to_space.get_mut(&child_room_id) {
|
||||||
|
parents.remove(&space_id);
|
||||||
|
if parents.is_empty() {
|
||||||
|
room_to_space.remove(&child_room_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add child to reverse index
|
||||||
this.room_to_space
|
this.room_to_space
|
||||||
.write()
|
.write()
|
||||||
.await
|
.await
|
||||||
.insert(child_room_id.clone(), space_id.clone());
|
.entry(child_room_id.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(space_id.clone());
|
||||||
|
|
||||||
// Auto-join all qualifying space members
|
// Check if server user is joined to the child room before enforcement
|
||||||
|
let server_user = this.services.globals.server_user.as_ref();
|
||||||
|
if !this
|
||||||
|
.services
|
||||||
|
.state_cache
|
||||||
|
.is_joined(server_user, &child_room_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
debug_warn!(
|
||||||
|
"Server user is not joined to {child_room_id}, \
|
||||||
|
skipping auto-join enforcement for new child"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-join qualifying space members to this specific child room
|
||||||
let space_members: Vec<OwnedUserId> = this
|
let space_members: Vec<OwnedUserId> = this
|
||||||
.services
|
.services
|
||||||
.state_cache
|
.state_cache
|
||||||
|
|
@ -737,11 +824,51 @@ impl Service {
|
||||||
.is_joined(member, &child_room_id)
|
.is_joined(member, &child_room_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
if let Err(e) =
|
let state_lock =
|
||||||
this.auto_join_qualifying_rooms(&space_id, member).await
|
this.services.state.mutex.lock(&child_room_id).await;
|
||||||
|
|
||||||
|
// Invite
|
||||||
|
if let Err(e) = this
|
||||||
|
.services
|
||||||
|
.timeline
|
||||||
|
.build_and_append_pdu(
|
||||||
|
PduBuilder::state(
|
||||||
|
member.to_string(),
|
||||||
|
&RoomMemberEventContent::new(
|
||||||
|
MembershipState::Invite,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
server_user,
|
||||||
|
Some(&child_room_id),
|
||||||
|
&state_lock,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
debug_warn!(
|
debug_warn!(
|
||||||
"Auto-join failed for {member} on new child room: {e}"
|
"Failed to invite {member} to {child_room_id}: {e}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join
|
||||||
|
if let Err(e) = this
|
||||||
|
.services
|
||||||
|
.timeline
|
||||||
|
.build_and_append_pdu(
|
||||||
|
PduBuilder::state(
|
||||||
|
member.to_string(),
|
||||||
|
&RoomMemberEventContent::new(
|
||||||
|
MembershipState::Join,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
member,
|
||||||
|
Some(&child_room_id),
|
||||||
|
&state_lock,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
"Failed to auto-join {member} to {child_room_id}: {e}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -762,6 +889,8 @@ impl Service {
|
||||||
|
|
||||||
let this = Arc::clone(self);
|
let this = Arc::clone(self);
|
||||||
self.server.runtime().spawn(async move {
|
self.server.runtime().spawn(async move {
|
||||||
|
let _permit = this.enforcement_semaphore.acquire().await;
|
||||||
|
|
||||||
if let Err(e) = this.auto_join_qualifying_rooms(&space_id, &user_id).await {
|
if let Err(e) = this.auto_join_qualifying_rooms(&space_id, &user_id).await {
|
||||||
debug_warn!("Auto-join on Space join failed for {user_id}: {e}");
|
debug_warn!("Auto-join on Space join failed for {user_id}: {e}");
|
||||||
}
|
}
|
||||||
|
|
@ -771,7 +900,7 @@ impl Service {
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, parent)| **parent == *space_id)
|
.filter(|(_, parents)| parents.contains(&space_id))
|
||||||
.map(|(child, _)| child.clone())
|
.map(|(child, _)| child.clone())
|
||||||
.collect();
|
.collect();
|
||||||
for child_room_id in &child_rooms {
|
for child_room_id in &child_rooms {
|
||||||
|
|
@ -814,6 +943,18 @@ pub async fn kick_unqualified_from_rooms(
|
||||||
let server_user = self.services.globals.server_user.as_ref();
|
let server_user = self.services.globals.server_user.as_ref();
|
||||||
|
|
||||||
for child_room_id in &child_rooms {
|
for child_room_id in &child_rooms {
|
||||||
|
// Check if server user is joined to the child room
|
||||||
|
if !self
|
||||||
|
.services
|
||||||
|
.state_cache
|
||||||
|
.is_joined(server_user, child_room_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
debug_warn!(
|
||||||
|
"Server user is not joined to {child_room_id}, skipping kick enforcement"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Skip if not joined
|
// Skip if not joined
|
||||||
if !self
|
if !self
|
||||||
.services
|
.services
|
||||||
|
|
|
||||||
|
|
@ -134,12 +134,15 @@ fn qualifies_empty_requirements_empty_roles() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn room_to_space_lookup() {
|
fn room_to_space_lookup() {
|
||||||
let mut room_to_space: HashMap<OwnedRoomId, OwnedRoomId> = HashMap::new();
|
let mut room_to_space: HashMap<OwnedRoomId, HashSet<OwnedRoomId>> = HashMap::new();
|
||||||
let space = room_id!("!space:example.com").to_owned();
|
let space = room_id!("!space:example.com").to_owned();
|
||||||
let child = room_id!("!child:example.com").to_owned();
|
let child = room_id!("!child:example.com").to_owned();
|
||||||
room_to_space.insert(child.clone(), space.clone());
|
room_to_space
|
||||||
assert_eq!(room_to_space.get(&child), Some(&space));
|
.entry(child.clone())
|
||||||
assert_eq!(room_to_space.get(room_id!("!unknown:example.com")), None);
|
.or_default()
|
||||||
|
.insert(space.clone());
|
||||||
|
assert!(room_to_space.get(&child).unwrap().contains(&space));
|
||||||
|
assert!(room_to_space.get(room_id!("!unknown:example.com")).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -364,7 +364,9 @@ where
|
||||||
if let Some(state_key) = pdu.state_key() {
|
if let Some(state_key) = pdu.state_key() {
|
||||||
let event_type_str = pdu.event_type().to_string();
|
let event_type_str = pdu.event_type().to_string();
|
||||||
match event_type_str.as_str() {
|
match event_type_str.as_str() {
|
||||||
| "m.space.roles" | "m.space.role.member" | "m.space.role.room" => {
|
| "com.continuwuity.space.roles"
|
||||||
|
| "com.continuwuity.space.role.member"
|
||||||
|
| "com.continuwuity.space.role.room" => {
|
||||||
let roles: Arc<crate::rooms::roles::Service> =
|
let roles: Arc<crate::rooms::roles::Service> =
|
||||||
Arc::clone(&*self.services.roles);
|
Arc::clone(&*self.services.roles);
|
||||||
roles.handle_state_event_change(
|
roles.handle_state_event_change(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use conduwuit_core::{
|
||||||
};
|
};
|
||||||
use futures::{FutureExt, StreamExt};
|
use futures::{FutureExt, StreamExt};
|
||||||
use ruma::{
|
use ruma::{
|
||||||
OwnedEventId, OwnedServerName, RoomId, RoomVersionId, UserId,
|
OwnedEventId, OwnedServerName, OwnedUserId, RoomId, RoomVersionId, UserId,
|
||||||
events::{
|
events::{
|
||||||
TimelineEventType,
|
TimelineEventType,
|
||||||
room::{
|
room::{
|
||||||
|
|
@ -98,17 +98,20 @@ pub async fn build_and_append_pdu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Space permission cascading: reject power level changes that conflict
|
// Space permission cascading: reject power level changes that conflict
|
||||||
// with Space-granted levels
|
// with Space-granted levels (exempt the server user so sync_power_levels works)
|
||||||
if self.services.roles.is_enabled()
|
if self.services.roles.is_enabled()
|
||||||
&& *pdu.kind() == TimelineEventType::RoomPowerLevels
|
&& *pdu.kind() == TimelineEventType::RoomPowerLevels
|
||||||
|
&& pdu.sender() != <OwnedUserId as AsRef<UserId>>::as_ref(&self.services.globals.server_user)
|
||||||
{
|
{
|
||||||
if let Some(parent_space) = self.services.roles.get_parent_space(&room_id).await {
|
|
||||||
use ruma::events::room::power_levels::RoomPowerLevelsEventContent;
|
use ruma::events::room::power_levels::RoomPowerLevelsEventContent;
|
||||||
|
|
||||||
|
let parent_spaces = self.services.roles.get_parent_spaces(&room_id).await;
|
||||||
if let Ok(proposed) = pdu.get_content::<RoomPowerLevelsEventContent>() {
|
if let Ok(proposed) = pdu.get_content::<RoomPowerLevelsEventContent>() {
|
||||||
|
for parent_space in &parent_spaces {
|
||||||
|
// Check proposed users don't conflict with space-granted PLs
|
||||||
for (user_id, proposed_pl) in &proposed.users {
|
for (user_id, proposed_pl) in &proposed.users {
|
||||||
if let Some(space_pl) =
|
if let Some(space_pl) =
|
||||||
self.services.roles.get_user_power_level(&parent_space, user_id).await
|
self.services.roles.get_user_power_level(parent_space, user_id).await
|
||||||
{
|
{
|
||||||
if i64::from(*proposed_pl) != space_pl {
|
if i64::from(*proposed_pl) != space_pl {
|
||||||
return Err!(Request(Forbidden(
|
return Err!(Request(Forbidden(
|
||||||
|
|
@ -117,6 +120,36 @@ pub async fn build_and_append_pdu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also check that space-managed users aren't omitted
|
||||||
|
let user_roles_guard = self.services.roles.user_roles.read().await;
|
||||||
|
if let Some(space_users) = user_roles_guard.get(parent_space) {
|
||||||
|
let roles_guard = self.services.roles.roles.read().await;
|
||||||
|
if let Some(role_defs) = roles_guard.get(parent_space) {
|
||||||
|
for (user_id, assigned_roles) in space_users {
|
||||||
|
let space_pl = assigned_roles
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| role_defs.get(r)?.power_level)
|
||||||
|
.max();
|
||||||
|
if let Some(space_pl) = space_pl {
|
||||||
|
// This user should be in the proposed event
|
||||||
|
match proposed.users.get(user_id) {
|
||||||
|
| None => {
|
||||||
|
return Err!(Request(Forbidden(
|
||||||
|
"Cannot omit a user whose power level is managed by Space roles"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
| Some(pl) if i64::from(*pl) != space_pl => {
|
||||||
|
return Err!(Request(Forbidden(
|
||||||
|
"Cannot change power level that is set by Space roles"
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
| _ => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue