From 8997086021cbd67fa39b41805692f6839a2d1299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6sters?= Date: Tue, 14 Jan 2025 12:49:06 +0100 Subject: [PATCH] Roles-based room policy without expiring roles --- Cargo.lock | 67 --------- Cargo.toml | 3 +- src/lib.rs | 406 +++++++++++++++++++++++++++++++++++++++-------------- 3 files changed, 298 insertions(+), 178 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c8d87b..5fc8fcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,70 +5,3 @@ version = 4 [[package]] name = "mimi-room-policy" version = "0.1.0" -dependencies = [ - "serde_bytes", -] - -[[package]] -name = "proc-macro2" -version = "1.0.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.216" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "387cc504cb06bb40a96c8e04e951fe01854cf6bc921053c954e4a606d9675c6a" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_derive" -version = "1.0.216" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "syn" -version = "2.0.90" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" diff --git a/Cargo.toml b/Cargo.toml index bacbf53..c66d9ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2024 Phoenix R&D GmbH +# SPDX-FileCopyrightText: 2025 Phoenix R&D GmbH # # SPDX-License-Identifier: AGPL-3.0-or-later @@ -8,4 +8,3 @@ version = "0.1.0" edition = "2021" [dependencies] -serde_bytes = "0.11.15" diff --git a/src/lib.rs b/src/lib.rs index 706f242..94251d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,22 +2,45 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use std::{ - collections::{HashMap, HashSet}, - iter, -}; +use std::collections::{HashMap, HashSet}; -type Timestamp = u32; +type Error = (); +type Result = std::result::Result; + +/// The specified roles have a special features in the room policy +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum RoleIndex { + /// Automatically given to users not in the room. + Outsider = 0, + + /// Meant for new members in public rooms or invited users in private rooms. + Visitor = 1, + + /// Meant for approved members or new members in private rooms. + Regular = 2, + + /// Meant for members trusted enough to moderate other users. + /// They are protected from other moderators. + Moderator = 3, + + /// Meant for members trusted to configure the entire room. + /// They are protected from each moderators and other admins. + Admin = 4, + + /// Assigned to the creator of the room and there can only be one. + /// They are protected from everyone. + Owner = 5, + + /// Custom roles + Custom(u16), +} #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub enum Capability { // TYPE 1: Enforced by hub - // Membership - // Join, - // Knock, - // Invite, - // Timeout { max_duration: u32 }, // Applies the role Disabled for the duration, then the user can remove the role by themselves + // Timeout { max_duration: u32 }, Kick, // Removes all their roles GiveRole { role: RoleIndex }, // E.g. Admins can add moderators DropRole { role: RoleIndex }, // E.g. Admins can remove moderators @@ -25,40 +48,44 @@ pub enum Capability { GiveRoleSelf { role: RoleIndex }, // E.g. Alice assigns the role Artist to herself DropRoleSelf { role: RoleIndex }, - Disabled, - // TYPE 2: Enforced by clients, the hubs helps if it can ReadMessage, SendMessage, EditMessage, + EditMessageSelf, + DeleteMessage, + DeleteMessageSelf, IgnoreRatelimit, } #[derive(Clone, PartialEq, Eq, Hash)] pub enum Action { - Join, // Give set of default roles - Kick, // Drops all their roles - GiveRole { target: String, role: RoleIndex }, // E.g. Admins can add moderators - DropRole { target: String, role: RoleIndex }, // E.g. Admins can remove moderators + /// Give set of default roles. + Join, + + /// Drops all their roles. + Kick { target: String }, + + /// Add a role to a user. + // E.g. Admins can add the moderator role to regular users. + GiveRole { target: String, role: RoleIndex }, + + /// Remove a role from a user. + DropRole { target: String, role: RoleIndex }, // TYPE 2: Enforced by clients, the hubs helps if it can + /// Read messages in the room. ReadMessage, + + /// Send messages in the room. SendMessage, - EditMessage, -} -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum RoleIndex { - Outsider = 0, // For users not in the room - Disabled = 1, // For who were timed out - Visitor = 2, // Default for new members in public rooms - Regular = 3, // For approved members or new members in invite-only rooms - Moderator = 4, // Extends regular - Admin = 5, // Extends moderator - Owner = 6, // Extends owner - Custom(u16), // Custom roles + /// Edit messages from yourself or others + EditMessage { target: String }, + + /// Delete messages from yourself or others + DeleteMessage { target: String }, } #[derive(Clone, PartialEq, Eq, Hash)] @@ -67,38 +94,38 @@ pub struct Role { role_description: String, role_capabilities: Vec, // TODO: This could also be a bitvector dependencies: Vec, // What roles are required to have this role - auto_expires_after: Option, // Timestamp after which this role is no longer active min: u32, max: Option, // min_active_participants_constraint: Option, // TODO: What does this mean? // max_active_participants_constraint: Option, } -#[derive(Clone)] -pub struct PreAuthPerRoleList { - target_role: RoleIndex, // TODO: RFC writes type Role, we use role index - preauth_domain: String, - preauth_workgroup: String, // Uri - preauth_group: String, // Uri - preauth_user: String, // Uri -} +// #[derive(Clone)] +// pub struct PreAuthPerRoleList { +// target_role: RoleIndex, // TODO: RFC writes type Role, we use role index +// preauth_domain: String, +// preauth_workgroup: String, // Uri +// preauth_group: String, // Uri +// preauth_user: String, // Uri +// } #[derive(Clone)] pub struct RoomPolicy { roles: HashMap, - pre_auth_list: Vec, - main_rate_limit_ms: u32, - thread_rate_limit_ms: u32, + default_roles: HashSet, + // pre_auth_list: Vec, + // main_rate_limit_ms: u32, + // thread_rate_limit_ms: u32, } #[derive(Clone)] pub struct RoomState { policy: RoomPolicy, - user_roles: HashMap>, + user_roles: HashMap>, } impl RoomState { - pub fn new(policy: &RoomPolicy, creator: &str) -> Self { + pub fn new(policy: &RoomPolicy, owner: &str) -> Self { let mut user_roles = HashMap::new(); let mut owner_roles = HashSet::new(); @@ -108,24 +135,60 @@ impl RoomState { owner_roles.insert(RoleIndex::Admin); owner_roles.insert(RoleIndex::Owner); - Self { + user_roles.insert(owner.to_owned(), owner_roles); + + let this = Self { policy: policy.clone(), user_roles, - } + }; + + this.role_dependency_checks().unwrap(); + + this + } + + pub fn is_mod(&self, user_id: &str) -> Result { + Ok(self + .user_roles + .get(user_id) + .ok_or(())? + .contains(&RoleIndex::Moderator)) + } + + pub fn is_admin(&self, user_id: &str) -> Result { + Ok(self + .user_roles + .get(user_id) + .ok_or(())? + .contains(&RoleIndex::Admin)) + } + + pub fn is_owner(&self, user_id: &str) -> Result { + Ok(self + .user_roles + .get(user_id) + .ok_or(())? + .contains(&RoleIndex::Owner)) + } + + pub fn is_protected_from(&self, actor: &str, target: &str) -> Result { + Ok(self.is_owner(target)? + || self.is_admin(target)? && !self.is_admin(actor)? + || self.is_mod(target)? && !self.is_mod(actor)?) } pub fn user_capabilities(&self, user_id: &str) -> HashSet { - let mut roles = HashMap::new(); + let mut roles = HashSet::new(); if let Some(assigned_roles) = self.user_roles.get(user_id) { roles.extend(assigned_roles); } else { - roles.insert(RoleIndex::Outsider, 0); + roles.insert(RoleIndex::Outsider); } let mut capabilities = HashSet::new(); - for (role, timestamp) in roles { + for role in roles { let role_info = self.policy.roles.get(&role).ok_or(()).unwrap(); capabilities.extend(role_info.role_capabilities.iter()) @@ -134,34 +197,25 @@ impl RoomState { capabilities } - pub fn is_role_active(&self, user_id: &str, role: RoleIndex) -> Result { - let Some(role_timestamp) = self.user_roles.get(user_id).ok_or(())?.get(&role) else { - // User does not have this role - return Ok(false); - }; + pub fn is_capable(&self, user_id: &str, capability: Capability) -> Result { + Ok(self.is_admin(user_id)? || self.user_capabilities(user_id).contains(&capability)) + } - let role_info = self - .policy - .roles - .get(&role) - .expect("all assigned roles are defined in the policy"); + /// Returns the sorted list of all users + pub fn joined_users(&self) -> Vec { + let mut list = self.user_roles.keys().cloned().collect::>(); - if let Some(expires_after) = role_info.auto_expires_after { - // Role may have expired - Ok(self.timestamp - role_timestamp <= &expires_after) - } else { - // Role cannot expire - Ok(true) - } + list.sort(); + + list } - pub fn give_user_role(&mut self, user_id: &str, role: RoleIndex) -> Result<(), ()> { + fn give_user_role(&mut self, user_id: &str, role: RoleIndex) -> Result<()> { if self .user_roles .entry(user_id.to_owned()) .or_default() - .insert(role, self.current_timestamp) - .is_none() + .insert(role) { Ok(()) } else { @@ -169,17 +223,20 @@ impl RoomState { } } - pub fn role_dependency_checks(&self, user_ids: &[&str]) -> Result<(), ()> { - for (user_id, roles) in self.user_roles { - for (role, timestamp) in roles { - if !self.is_role_active(&user_id, role)? { - continue; - } + fn drop_user_role(&mut self, user_id: &str, role: RoleIndex) -> Result<()> { + if self.user_roles.get_mut(user_id).ok_or(())?.remove(&role) { + Ok(()) + } else { + Err(()) + } + } - let role_info = self.policy.roles.get(&role).ok_or(())?; + fn role_dependency_checks(&self) -> Result<()> { + for roles in self.user_roles.values() { + for role in roles { + let role_info = self.policy.roles.get(role).ok_or(())?; for dependency in &role_info.dependencies { - if roles.contains_key(dependency) - if !self.user_has_role(user_id, *dependency) { + if !roles.contains(dependency) { return Err(()); } } @@ -189,50 +246,117 @@ impl RoomState { Ok(()) } - pub fn make_actions(&mut self, user_id: &str, actions: &[Action]) -> Result<(), ()> { - let user_capabilities = self.user_capabilities(user_id); + fn drop_kicked_users(&mut self) -> Result<()> { + self.user_roles.retain(|_user_id, roles| !roles.is_empty()); - if user_capabilities.contains(&Capability::Disabled) { - return Err(()); - } + Ok(()) + } + pub fn make_actions(&mut self, user_id: &str, actions: &[Action]) -> Result<()> { let mut new_state = self.clone(); for action in actions { match action { - Action::Join => {} - Action::Kick => { - if !user_capabilities.contains(&Capability::Kick) { + Action::Join => { + // TODO: Check capability + + for role in new_state.policy.default_roles.clone() { + new_state.give_user_role(user_id, role)?; + } + } + Action::Kick { target } => { + if !new_state.is_capable(user_id, Capability::Kick)? { + return Err(()); + } + + if new_state.is_protected_from(user_id, target)? { return Err(()); } - todo!(); + for role in new_state.user_roles.get(target).ok_or(())?.clone() { + new_state.drop_user_role(target, role)?; + } } Action::GiveRole { target, role } => { - new_state.give_user_role(user_id, *role)?; + let valid_to_self = target == user_id + && new_state + .is_capable(user_id, Capability::GiveRoleSelf { role: *role })?; + + let valid_to_other = + new_state.is_capable(user_id, Capability::GiveRole { role: *role })?; + // TODO: Do we want protection here? && !new_state.is_protected_from(user_id, target)?; + + if !valid_to_self && !valid_to_other { + return Err(()); + } + + new_state.give_user_role(target, *role)?; } Action::DropRole { target, role } => { - new_state.drop_user_role(user_id, *role)?; + let valid_to_self = target == user_id + && new_state + .is_capable(user_id, Capability::DropRoleSelf { role: *role })?; + + let valid_to_other = new_state + .is_capable(user_id, Capability::DropRole { role: *role })? + && !new_state.is_protected_from(user_id, target)?; + + if !valid_to_self && !valid_to_other { + return Err(()); + } + + new_state.drop_user_role(target, *role)?; } Action::ReadMessage => { - if !user_capabilities.contains(&Capability::ReadMessage) { + if !new_state.is_capable(user_id, Capability::ReadMessage)? { return Err(()); } } Action::SendMessage => { - if !user_capabilities.contains(&Capability::SendMessage) { + if !new_state.is_capable(user_id, Capability::SendMessage)? { + return Err(()); + } + + // TODO: Check + // - rate limit + // - max message size + // - allowed content types + } + Action::EditMessage { target } => { + let valid_to_self = target == user_id + && new_state.is_capable(user_id, Capability::EditMessageSelf)?; + + let valid_to_other = new_state.is_capable(user_id, Capability::EditMessage)? + && !new_state.is_protected_from(user_id, target)?; + + if !valid_to_self && !valid_to_other { return Err(()); } + + // TODO: Check + // - max message size + // - allowed content types } - Action::EditMessage => { - if !user_capabilities.contains(&Capability::EditMessage) { + + Action::DeleteMessage { target } => { + let valid_to_self = target == user_id + && new_state.is_capable(user_id, Capability::DeleteMessageSelf)?; + + let valid_to_other = new_state + .is_capable(user_id, Capability::DeleteMessage)? + && !new_state.is_protected_from(user_id, target)?; + + if !valid_to_self && !valid_to_other { return Err(()); } } } } - self.role_dependency_checks()?; + new_state.role_dependency_checks()?; + new_state.drop_kicked_users()?; + + *self = new_state; Ok(()) } @@ -246,6 +370,8 @@ mod tests { fn roles() { let mut roles = HashMap::new(); + // TODO: Should some roles be hardcoded? + roles.insert( RoleIndex::Outsider, Role { @@ -275,7 +401,13 @@ mod tests { Role { role_name: "Regular user".to_owned(), role_description: "Can read and send messages".to_owned(), - role_capabilities: vec![Capability::ReadMessage, Capability::SendMessage], + role_capabilities: vec![ + Capability::ReadMessage, + Capability::SendMessage, + Capability::GiveRole { + role: RoleIndex::Visitor, + }, + ], dependencies: vec![], min: 1, max: None, @@ -298,12 +430,8 @@ mod tests { RoleIndex::Admin, Role { role_name: "Admin".to_owned(), - role_description: "Can do anything".to_owned(), - role_capabilities: vec![ - Capability::EditMessage, - Capability::GiveAnyRole, - Capability::DropAnyRole, - ], + role_description: "Has all capabilities".to_owned(), + role_capabilities: vec![], dependencies: vec![RoleIndex::Regular], min: 1, max: None, @@ -314,29 +442,89 @@ mod tests { RoleIndex::Owner, Role { role_name: "Owner".to_owned(), - role_description: "Cannot be influenced by admins".to_owned(), - role_capabilities: vec![ - Capability::EditMessage, - Capability::GiveAnyRole, - Capability::DropAnyRole, - ], + role_description: "Is protected from admins".to_owned(), + role_capabilities: vec![], dependencies: vec![RoleIndex::Admin], min: 1, max: None, }, ); + let mut default_roles = HashSet::new(); + default_roles.insert(RoleIndex::Visitor); + let policy = RoomPolicy { roles, - main_rate_limit_ms: 10000, // wait 10 seconds after every message - thread_rate_limit_ms: 100, // almost no delay in threads - pre_auth_list: vec![], + default_roles, + //pre_auth_list: vec![], + //main_rate_limit_ms: 10000, // wait 10 seconds after every message + //thread_rate_limit_ms: 100, // almost no delay in threads }; let mut room_state = RoomState::new(&policy, "@timo:phnx.im"); + // Only the owner is in the room + assert_eq!(room_state.joined_users(), vec!["@timo:phnx.im".to_owned()]); + + // @spam joins room_state .make_actions("@spam:phnx.im", &[Action::Join]) .unwrap(); + + // Now both are in the room + assert_eq!( + room_state.joined_users(), + vec!["@spam:phnx.im".to_owned(), "@timo:phnx.im".to_owned()] + ); + + // @spam has the default role: Visitor + assert!(room_state + .user_roles + .get("@spam:phnx.im") + .unwrap() + .contains(&RoleIndex::Visitor)); + + // Visitors can only read, not send + room_state + .make_actions("@spam:phnx.im", &[Action::ReadMessage]) + .unwrap(); + room_state + .make_actions("@spam:phnx.im", &[Action::SendMessage]) + .unwrap_err(); + + // The owner promotes @spam to a regular user + room_state + .make_actions( + "@timo:phnx.im", + &[ + Action::DropRole { + target: "@spam:phnx.im".to_owned(), + role: RoleIndex::Visitor, + }, + Action::GiveRole { + target: "@spam:phnx.im".to_owned(), + role: RoleIndex::Regular, + }, + ], + ) + .unwrap(); + + // @spam can send messages now + room_state + .make_actions("@spam:phnx.im", &[Action::SendMessage]) + .unwrap(); + + // The owner can kick @spam, removing all the roles + room_state + .make_actions( + "@timo:phnx.im", + &[Action::Kick { + target: "@spam:phnx.im".to_owned(), + }], + ) + .unwrap(); + + // Only the owner is in the room + assert_eq!(room_state.joined_users(), vec!["@timo:phnx.im".to_owned()]); } }