Compare commits

...

11 Commits

Author SHA1 Message Date
256d12c9c8 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m11s
2026-05-12 00:55:10 +02:00
65edef47b2 user permission management over api 2026-05-12 00:54:52 +02:00
9b69a0a5ee actually use default permissions, misc 2026-05-12 00:08:20 +02:00
e2e9a3efb5 Admin permission, grant/revoke/reset permission helpers 2026-05-06 18:19:09 +02:00
1f07952973 proper permission checking 2026-05-06 03:07:03 +02:00
b1ccd21068 Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m14s
2026-05-06 02:50:44 +02:00
7d284f0777 prioritize special uuids in user page display 2026-05-06 02:49:42 +02:00
84dde9cc4b require permission to delete quotes 2026-05-06 02:24:05 +02:00
e7c0523841 quotelink hover, also make dashboard quotes into links 2026-05-06 02:00:23 +02:00
7fe1b6f8be Merge branch 'master' into gractwo
All checks were successful
mnemo-build-and-publish / gractwo-mnemo-build (push) Successful in 1m13s
2026-05-06 00:58:28 +02:00
ca726c8e8b quote deletion confirmation 2026-05-06 00:58:07 +02:00
10 changed files with 313 additions and 17 deletions

View File

@@ -28,6 +28,10 @@ pub fn api_router() -> Router<MnemoState> {
.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))

View File

@@ -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<MnemoState>,
Path((uid, perm)): Path<(Uuid, Permission)>,
headers: HeaderMap,
) -> Result<Response, CompositeError> {
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<MnemoState>,
Path((uid, perm)): Path<(Uuid, Permission)>,
headers: HeaderMap,
Json(newstate): Json<PermissionState>,
) -> Result<Response, CompositeError> {
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())
}

View File

@@ -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",

View File

@@ -1,9 +1,23 @@
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,
Display,
serde::Deserialize,
serde::Serialize,
)]
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,
// All Users have the right to revoke their own sessions
@@ -16,26 +30,130 @@ 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,
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
strum::IntoStaticStr,
Display,
serde::Deserialize,
serde::Serialize,
)]
pub enum PermissionState {
ExplicitlyGranted,
ExplicitlyRevoked,
Implicit,
}
impl From<Option<bool>> for PermissionState {
fn from(state: Option<bool>) -> Self {
match state {
Some(true) => Self::ExplicitlyGranted,
Some(false) => Self::ExplicitlyRevoked,
None => Self::Implicit,
}
}
}
impl User {
pub async fn permission_dbstate(
&self,
conn: &mut PgConnection,
permission: Permission,
) -> Result<Option<bool>, DatabaseError> {
let permission_key: &'static str = (&permission).into();
let state: Option<bool> = 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)
}
pub async fn has_permission(
&self,
#[allow(unused)] conn: &mut PgConnection,
#[allow(unused)] permission: Permission,
conn: &mut PgConnection,
permission: Permission,
) -> Result<bool, DatabaseError> {
// Infradmin and systemuser have all permissions
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(false)
// todo!("Do the permission checking here once permissions are modeled in the DB")
Ok(self
.permission_dbstate(conn, permission)
.await?
.unwrap_or(permission.is_default_permission()))
}
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(())
}
}

View File

@@ -4,7 +4,7 @@ use crate::{quotes::Quote, web::icons};
pub fn quote(quote: &Quote) -> Markup {
html!(
div class="border border-neutral-200/25 bg-neutral-200/5 p-3 pb-1 overflow-clip rounded-md relative flex flex-col" {
div class="border border-neutral-200/25 bg-neutral-200/5 p-3 pb-1 overflow-clip rounded-md relative flex flex-col transition-colors group-hover/a:border-neutral-200/35 group-hover/a:bg-neutral-200/10" {
div class="absolute top-4 right-6 -rotate-12 opacity-[.025] scale-x-[4.5] scale-y-[4]" {
(PreEscaped(icons::QUOTE))
}

View File

@@ -61,7 +61,9 @@ pub async fn page(
"This just in! This quote was added "
(format_time_ago(q.get_creation_timestamp())) " ago."
}
div class="flex-1 [&>div]:h-full" {(quote(q))}
div class="flex-1 [&>div]:h-full" {
a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(q))}
}
} @else {
p class="text-neutral-500 font-light mb-4" {"No quotes yet."}
}
@@ -76,7 +78,9 @@ pub async fn page(
"This quote was added "
(format_time_ago(q.get_creation_timestamp())) " ago."
}
div class="flex-1 [&>div]:h-full" {(quote(&q))}
div class="flex-1 [&>div]:h-full" {
a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(&q))}
}
}
}
}

View File

@@ -51,7 +51,10 @@ pub fn pages() -> Router<MnemoState> {
//
.route("/quotes", get(quotes::page))
.route("/quotes/{id}", get(quotes::id::page))
.route("/quotes/{id}/delete", post(quotes::id::delete))
.route(
"/quotes/{id}/delete",
get(quotes::id::delete_confirm).post(quotes::id::delete),
)
.route("/quotes/add", get(quotes::add::page))
.route("/quotes/add-form", post(quotes::add::form))
//

View File

@@ -86,7 +86,7 @@ pub async fn page(
}
div class="flex flex-col gap-4 mb-8" {
@for q in &quotes {
a href=(format!("/quotes/{}", q.id)) {(quote(q))}
a href=(format!("/quotes/{}", q.id)) class="group/a" {(quote(q))}
}
div class="flex justify-between items-center mt-4 text-neutral-400" {

View File

@@ -3,6 +3,7 @@ use axum::{
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
};
use http::StatusCode;
use maud::{PreEscaped, html};
use uuid::Uuid;
@@ -14,6 +15,7 @@ use crate::{
users::{
User,
auth::{UserAuthRequired, UserAuthenticate},
permissions::Permission,
},
web::{
components::{nav::nav, quote::quote},
@@ -33,6 +35,9 @@ pub async fn page(
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
};
let q = Quote::get_by_id(&mut conn, id).await;
let can_delete = u
.has_permission(&mut conn, Permission::DeleteQuotes)
.await?;
Ok(base(
"Add Quote | Mnemosyne",
@@ -53,8 +58,8 @@ pub async fn page(
span class="scale-[.75]" {(PreEscaped(icons::PEN))}
"Edit"
}
form method="post" action=(format!("/quotes/{id}/delete")) {
button type="submit" class="px-2 py-1 border rounded flex flex-row gap-1 bg-pink-400/10 border-pink-400/25 hover:bg-pink-400/20 hover:border-pink-400/45" {
@if can_delete {
a href=(format!("/quotes/{id}/delete")) class="px-2 py-1 cursor-pointer border rounded flex flex-row gap-1 bg-pink-400/10 border-pink-400/25 hover:bg-pink-400/20 hover:border-pink-400/45" {
span class="scale-[.75]" {(PreEscaped(icons::TRASH))}
"Delete"
}
@@ -69,6 +74,58 @@ pub async fn page(
.into_response())
}
pub async fn delete_confirm(
State(state): State<MnemoState>,
Path(id): Path<Uuid>,
req: Request,
) -> Result<Response, CompositeError> {
let mut conn = state.pool.acquire().await?;
let u = match User::authenticate(&mut *conn, req.headers()).await? {
Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
};
let q = Quote::get_by_id(&mut conn, id).await;
Ok(base(
"Delete Quote | Mnemosyne",
html!(
(nav(&mut conn, Some(&u), req.uri().path()).await)
div class="max-w-4xl mx-auto px-2" {
div class="my-4 flex justify-between" {
p class="flex items-center gap-2 text-neutral-500" {
(PreEscaped(icons::TRASH))
span class="font-lora" {"Deleting quote of ID " (id)}
}
}
@if let Ok(q) = q {
div class="border border-pink-400/25 bg-pink-400/10 rounded-md p-3 mb-4" {
p class="flex flex-wrap items-center gap-2 text-pink-200" {
span class="font-semibold" {"Are you sure you want to delete this quote?"}
span class="text-pink-300/80" {"This cannot be undone."}
}
}
(quote(&q))
div class="flex flex-row w-full flex-wrap justify-start gap-2 mt-2" {
form method="post" action=(format!("/quotes/{id}/delete")) {
button type="submit" class="px-2 py-1 cursor-pointer border rounded flex flex-row gap-1 bg-pink-400/10 border-pink-400/25 hover:bg-pink-400/20 hover:border-pink-400/45" {
span class="scale-[.75]" {(PreEscaped(icons::TRASH))}
"Delete"
}
}
a href=(format!("/quotes/{id}")) class="px-2 py-1 border rounded flex flex-row gap-1 bg-neutral-200/5 border-neutral-200/25 hover:bg-neutral-200/15 hover:border-neutral-200/45" {
"Cancel"
}
}
} @else {
"Failed to fetch quote. Are you sure it exists?"
}
}
),
)
.into_response())
}
pub async fn delete(
State(state): State<MnemoState>,
Path(id): Path<Uuid>,
@@ -76,6 +133,9 @@ pub async fn delete(
) -> Result<Response, CompositeError> {
let mut tx = state.pool.begin().await?;
let u = User::authenticate(&mut *tx, &headers).await?.required()?;
if !u.has_permission(&mut tx, Permission::DeleteQuotes).await? {
return Ok((StatusCode::FORBIDDEN, "No permission.").into_response());
}
let q = Quote::get_by_id(&mut *tx, id).await?;
LogEntry::new(&mut *tx, u, LogAction::DeleteQuote { quote: q.clone() }).await?;

View File

@@ -3,6 +3,7 @@ use axum::{
response::{IntoResponse, Redirect, Response},
};
use maud::{PreEscaped, html};
use uuid::Uuid;
use crate::{
MnemoState,
@@ -27,7 +28,14 @@ pub async fn page(
Some(u) => u,
None => return Ok(Redirect::to(&format!("/login?r={}", req.uri().path())).into_response()),
};
let us = User::get_all(&mut *conn).await;
let us = User::get_all(&mut *conn).await.map(|mut v| {
v.sort_by_key(|p| match p.id {
id if id == Uuid::nil() => (0, p.id),
id if id == Uuid::max() => (1, p.id),
_ => (2, p.id),
});
v
});
let can_create_users = u
.has_permission(&mut *conn, Permission::ManuallyCreateUsers)
.await;