From 1f07952973c8e52b059c80798a44cbec070330e8 Mon Sep 17 00:00:00 2001 From: jmanczak Date: Wed, 6 May 2026 03:07:03 +0200 Subject: [PATCH 1/4] proper permission checking --- src/users/permissions.rs | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/users/permissions.rs b/src/users/permissions.rs index 78ff463..2a0e804 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -3,6 +3,7 @@ use sqlx::PgConnection; use crate::{database::DatabaseError, users::User}; /// Infradmin and systemuser have all permissions. +#[derive(strum::IntoStaticStr)] pub enum Permission { // All Users have the right to observe their own sessions ListOthersSessions, @@ -16,26 +17,41 @@ pub enum Permission { CreateTags, RenameTags, DeleteTags, - #[allow(unused)] + CreateQuotes, DeleteQuotes, ChangePersonPrimaryName, - #[allow(unused)] BrowseServerLogs, ConfigureInstance, } +impl Permission { + pub fn is_default_permission(&self) -> bool { + match self { + Self::CreateTags | Self::CreateQuotes => true, + _ => false, + } + } +} + impl User { pub async fn has_permission( &self, - #[allow(unused)] conn: &mut PgConnection, - #[allow(unused)] permission: Permission, + conn: &mut PgConnection, + permission: Permission, ) -> Result { - // Infradmin and systemuser have all permissions if self.is_infradmin() || self.is_systemuser() { return Ok(true); } - Ok(false) - // todo!("Do the permission checking here once permissions are modeled in the DB") + let permission_key: &'static str = (&permission).into(); + let state: Option = sqlx::query_scalar( + "SELECT state FROM user_permissions WHERE user_id = $1 AND permission = $2", + ) + .bind(self.id) + .bind(permission_key) + .fetch_optional(&mut *conn) + .await?; + + Ok(state.unwrap_or_else(|| permission.is_default_permission())) } } From e2e9a3efb5ca1ac44ccc6521b8226e6eac8e6554 Mon Sep 17 00:00:00 2001 From: jmanczak Date: Wed, 6 May 2026 18:19:09 +0200 Subject: [PATCH 2/4] Admin permission, grant/revoke/reset permission helpers --- src/users/permissions.rs | 81 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/src/users/permissions.rs b/src/users/permissions.rs index 2a0e804..1ba9810 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -3,8 +3,10 @@ use sqlx::PgConnection; use crate::{database::DatabaseError, users::User}; /// Infradmin and systemuser have all permissions. -#[derive(strum::IntoStaticStr)] +#[derive(Debug, Clone, PartialEq, strum::IntoStaticStr)] pub enum Permission { + // Pass all the permission checks + Admin, // All Users have the right to observe their own sessions ListOthersSessions, // All Users have the right to revoke their own sessions @@ -34,15 +36,11 @@ impl Permission { } impl User { - pub async fn has_permission( + async fn permission_dbstate( &self, conn: &mut PgConnection, permission: Permission, - ) -> Result { - if self.is_infradmin() || self.is_systemuser() { - return Ok(true); - } - + ) -> Result, DatabaseError> { let permission_key: &'static str = (&permission).into(); let state: Option = sqlx::query_scalar( "SELECT state FROM user_permissions WHERE user_id = $1 AND permission = $2", @@ -52,6 +50,73 @@ impl User { .fetch_optional(&mut *conn) .await?; - Ok(state.unwrap_or_else(|| permission.is_default_permission())) + Ok(state) + } + + pub async fn has_permission( + &self, + conn: &mut PgConnection, + permission: Permission, + ) -> Result { + if self.is_infradmin() || self.is_systemuser() { + return Ok(true); + } + if let Some(true) = self.permission_dbstate(conn, Permission::Admin).await? { + return Ok(true); + } + + Ok(self + .permission_dbstate(conn, permission) + .await? + .unwrap_or(false)) + } + + pub async fn grant_permission( + &self, + conn: &mut PgConnection, + permission: Permission, + ) -> Result<(), DatabaseError> { + let permission_key: &'static str = (&permission).into(); + sqlx::query( + "INSERT INTO user_permissions (user_id, permission, state) VALUES ($1, $2, TRUE) ON CONFLICT (user_id, permission) DO UPDATE SET state = EXCLUDED.state", + ) + .bind(self.id) + .bind(permission_key) + .execute(&mut *conn) + .await?; + + Ok(()) + } + + pub async fn revoke_permission( + &self, + conn: &mut PgConnection, + permission: Permission, + ) -> Result<(), DatabaseError> { + let permission_key: &'static str = (&permission).into(); + sqlx::query( + "INSERT INTO user_permissions (user_id, permission, state) VALUES ($1, $2, FALSE) ON CONFLICT (user_id, permission) DO UPDATE SET state = EXCLUDED.state", + ) + .bind(self.id) + .bind(permission_key) + .execute(&mut *conn) + .await?; + + Ok(()) + } + + pub async fn reset_permission( + &self, + conn: &mut PgConnection, + permission: Permission, + ) -> Result<(), DatabaseError> { + let permission_key: &'static str = (&permission).into(); + sqlx::query("DELETE FROM user_permissions WHERE user_id = $1 AND permission = $2") + .bind(self.id) + .bind(permission_key) + .execute(&mut *conn) + .await?; + + Ok(()) } } From 9b69a0a5ee17873343704387b8bdbf279dbfd7f7 Mon Sep 17 00:00:00 2001 From: jmanczak Date: Tue, 12 May 2026 00:08:20 +0200 Subject: [PATCH 3/4] actually use default permissions, misc --- src/users/permissions.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/users/permissions.rs b/src/users/permissions.rs index 1ba9810..571eaea 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -3,9 +3,10 @@ use sqlx::PgConnection; use crate::{database::DatabaseError, users::User}; /// Infradmin and systemuser have all permissions. -#[derive(Debug, Clone, PartialEq, strum::IntoStaticStr)] +#[derive(Debug, Clone, Copy, PartialEq, strum::IntoStaticStr)] pub enum Permission { // Pass all the permission checks + // Additionally, only Admins can manage others' permissions. Admin, // All Users have the right to observe their own sessions ListOthersSessions, @@ -68,7 +69,7 @@ impl User { Ok(self .permission_dbstate(conn, permission) .await? - .unwrap_or(false)) + .unwrap_or(permission.is_default_permission())) } pub async fn grant_permission( From 65edef47b2b7464941a6edc1d040cfae703ad610 Mon Sep 17 00:00:00 2001 From: jmanczak Date: Tue, 12 May 2026 00:54:52 +0200 Subject: [PATCH 4/4] user permission management over api --- src/api/mod.rs | 4 ++ src/api/users.rs | 82 +++++++++++++++++++++++++++++++++++++++- src/logs.rs | 21 +++++++++- src/users/permissions.rs | 40 +++++++++++++++++++- 4 files changed, 143 insertions(+), 4 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 1e74aaf..297d405 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -28,6 +28,10 @@ pub fn api_router() -> Router { .route("/api/users/@{handle}", get(users::get_by_handle)) .route("/api/users/{id}/setpassw", post(users::change_password)) .route("/api/users/{id}/sethandle", post(users::change_handle)) + .route( + "/api/users/{id}/permissions/{perm}", + get(users::get_permission).put(users::put_permission), + ) // sessions .route("/api/sessions/{id}", get(sessions::get_by_id)) .route("/api/sessions/{id}/revoke", post(sessions::revoke_by_id)) diff --git a/src/api/users.rs b/src/api/users.rs index f1ba2dd..3bf1279 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -15,13 +15,14 @@ use crate::{ User, auth::{UserAuthRequired, UserAuthenticate}, handle::UserHandle, - permissions::Permission, + permissions::{Permission, PermissionState}, }, }; const CANT_CHANGE_OTHERS_HANDLE: &str = "You don't have permission to change this user's handle."; const CANT_CHANGE_OTHERS_PASSW: &str = "You don't have permission to change this user's password."; const CANT_MANUALLY_MAKE_USERS: &str = "You don't have permission to manually create new users."; +const GO_AWAY: &str = "You don't have permission to look into permissions!"; const HANDLE_CHANGED_SUCCESS: &str = "Handle changed successfully."; const PASSW_CHANGED_SUCCESS: &str = "Password changed successfully."; @@ -170,3 +171,82 @@ pub async fn change_password( Ok(PASSW_CHANGED_SUCCESS.into_response()) } + +pub async fn get_permission( + State(state): State, + Path((uid, perm)): Path<(Uuid, Permission)>, + headers: HeaderMap, +) -> Result { + let mut conn = state.pool.acquire().await?; + let u = User::authenticate(&mut conn, &headers).await?.required()?; + if !u.has_permission(&mut conn, Permission::Admin).await? { + return Ok((StatusCode::FORBIDDEN, GO_AWAY).into_response()); + } + let target = User::get_by_id(&mut conn, uid).await?; + let has: PermissionState = target.permission_dbstate(&mut conn, perm).await?.into(); + + Ok((StatusCode::OK, Json(has)).into_response()) +} + +pub async fn put_permission( + State(state): State, + Path((uid, perm)): Path<(Uuid, Permission)>, + headers: HeaderMap, + Json(newstate): Json, +) -> Result { + let mut tx = state.pool.begin().await?; + let u = User::authenticate(&mut tx, &headers).await?.required()?; + if !u.has_permission(&mut tx, Permission::Admin).await? { + return Ok((StatusCode::FORBIDDEN, GO_AWAY).into_response()); + } + + let target = User::get_by_id(&mut tx, uid).await?; + let os: PermissionState = target.permission_dbstate(&mut tx, perm).await?.into(); + match newstate { + PermissionState::ExplicitlyGranted => { + target.grant_permission(&mut tx, perm).await?; + LogEntry::new( + &mut tx, + u, + LogAction::UpdatePermission { + id: target.id, + os, + ns: newstate, + p: perm, + }, + ) + .await?; + } + PermissionState::ExplicitlyRevoked => { + target.revoke_permission(&mut tx, perm).await?; + LogEntry::new( + &mut tx, + u, + LogAction::UpdatePermission { + id: target.id, + os, + ns: newstate, + p: perm, + }, + ) + .await?; + } + PermissionState::Implicit => { + target.reset_permission(&mut tx, perm).await?; + LogEntry::new( + &mut tx, + u, + LogAction::UpdatePermission { + id: target.id, + os, + ns: newstate, + p: perm, + }, + ) + .await?; + } + }; + + tx.commit().await?; + Ok((StatusCode::OK, Json(newstate)).into_response()) +} diff --git a/src/logs.rs b/src/logs.rs index 8d13732..5c2a5c0 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -4,7 +4,15 @@ use strum::{EnumDiscriminants, EnumIter, IntoStaticStr, VariantNames}; use url::Url; use uuid::Uuid; -use crate::{database::DatabaseError, quotes::Quote, users::User, web::icons}; +use crate::{ + database::DatabaseError, + quotes::Quote, + users::{ + User, + permissions::{Permission, PermissionState}, + }, + web::icons, +}; #[derive(Debug)] pub struct LogEntry { @@ -106,6 +114,12 @@ pub enum LogAction { id: Uuid, handle: String, }, + UpdatePermission { + id: Uuid, + os: PermissionState, + ns: PermissionState, + p: Permission, + }, ManuallyChangeUsersPassword { id: Uuid, }, @@ -180,6 +194,7 @@ impl LogAction { | Self::ManuallyRevokeSession { id } | Self::RenameTag { id, .. } | Self::DeleteTag { id, .. } + | Self::UpdatePermission { id, .. } | Self::ManuallyChangeUsersPassword { id } => Some(*id), Self::DeleteQuote { quote } => Some(quote.id), @@ -200,6 +215,9 @@ impl LogAction { LogAction::CreateUser { id, handle } => { format!("Created user @{handle} (uid: {id})") } + LogAction::UpdatePermission { id, os, ns, p } => { + format!("Updated permission {p} of user with id {id} from {os} to {ns}") + } LogAction::ManuallyChangeUsersPassword { id } => { format!("Manually changed password of user with id: {id}") } @@ -251,6 +269,7 @@ impl LogActionDiscriminant { LAD::Initialize => "Mnemosyne Initialization", LAD::RegenInfradmin => "Infradmin Regeneration", LAD::CreateUser => "User Creation", + LAD::UpdatePermission => "Permission Update", LAD::ManuallyChangeUsersPassword => "Password Override", LAD::CreateTag => "Tag Creation", LAD::RenameTag => "Tag Rename", diff --git a/src/users/permissions.rs b/src/users/permissions.rs index 571eaea..8b003eb 100644 --- a/src/users/permissions.rs +++ b/src/users/permissions.rs @@ -1,9 +1,19 @@ use sqlx::PgConnection; +use strum::Display; use crate::{database::DatabaseError, users::User}; /// Infradmin and systemuser have all permissions. -#[derive(Debug, Clone, Copy, PartialEq, strum::IntoStaticStr)] +#[derive( + Debug, + Clone, + Copy, + PartialEq, + strum::IntoStaticStr, + Display, + serde::Deserialize, + serde::Serialize, +)] pub enum Permission { // Pass all the permission checks // Additionally, only Admins can manage others' permissions. @@ -36,8 +46,34 @@ impl Permission { } } +#[derive( + Debug, + Clone, + Copy, + PartialEq, + strum::IntoStaticStr, + Display, + serde::Deserialize, + serde::Serialize, +)] +pub enum PermissionState { + ExplicitlyGranted, + ExplicitlyRevoked, + Implicit, +} + +impl From> for PermissionState { + fn from(state: Option) -> Self { + match state { + Some(true) => Self::ExplicitlyGranted, + Some(false) => Self::ExplicitlyRevoked, + None => Self::Implicit, + } + } +} + impl User { - async fn permission_dbstate( + pub async fn permission_dbstate( &self, conn: &mut PgConnection, permission: Permission,