From 0bfd2f8674050babbcd4012250461888436b2bc9 Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 17 May 2026 14:58:13 +0200 Subject: [PATCH] removing rbac cos its not needed at all --- Cargo.lock | 1 + Cargo.toml | 1 + config/development.yaml | 7 +++ config/test.yaml | 7 +++ migration/src/lib.rs | 4 ++ .../m20260517_000009_simple_constraints.rs | 60 +++++++++++++++++++ .../src/m20260517_000010_drop_user_roles.rs | 28 +++++++++ src/app.rs | 1 + src/controllers/admin.rs | 57 ++++++++++++++++++ src/controllers/auth.rs | 49 ++++++++++++++- src/controllers/mod.rs | 1 + src/models/_entities/mod.rs | 1 - src/models/_entities/prelude.rs | 1 - src/models/_entities/user_roles.rs | 35 ----------- src/models/mod.rs | 1 - src/models/user_roles.rs | 22 ------- src/views/auth.rs | 8 ++- 17 files changed, 219 insertions(+), 65 deletions(-) create mode 100644 migration/src/m20260517_000009_simple_constraints.rs create mode 100644 migration/src/m20260517_000010_drop_user_roles.rs create mode 100644 src/controllers/admin.rs delete mode 100644 src/models/_entities/user_roles.rs delete mode 100644 src/models/user_roles.rs diff --git a/Cargo.lock b/Cargo.lock index 4341cd9..c2bfde1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5071,6 +5071,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "time", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index aa0b331..d16be96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ sea-orm = { version = "1.1", features = [ "macros", ] } chrono = { version = "0.4" } +time = { version = "0.3" } validator = { version = "0.20" } uuid = { version = "1.6", features = ["v4"] } include_dir = { version = "0.7" } diff --git a/config/development.yaml b/config/development.yaml index e72816c..da570e3 100644 --- a/config/development.yaml +++ b/config/development.yaml @@ -93,7 +93,14 @@ database: auth: # JWT authentication jwt: + location: + - from: Cookie + name: auth_token + - from: Bearer # Secret key for token generation and verification secret: A6ECni63rt2Jb00tX9Hf # Token expiration time in seconds expiration: 604800 # 7 days + +settings: + admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }} diff --git a/config/test.yaml b/config/test.yaml index fef596c..b20f887 100644 --- a/config/test.yaml +++ b/config/test.yaml @@ -90,7 +90,14 @@ database: auth: # JWT authentication jwt: + location: + - from: Cookie + name: auth_token + - from: Bearer # Secret key for token generation and verification secret: 0yWwoflcGiAhonIzhQyQ # Token expiration time in seconds expiration: 604800 # 7 days + +settings: + admin_email: admin@example.com diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 6efbd62..c2dc720 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -10,6 +10,8 @@ mod m20260517_000005_audio_albums; mod m20260517_000006_audio_tracks; mod m20260517_000007_audio_tags; mod m20260517_000008_audio_track_tags; +mod m20260517_000009_simple_constraints; +mod m20260517_000010_drop_user_roles; pub struct Migrator; @@ -26,6 +28,8 @@ impl MigratorTrait for Migrator { Box::new(m20260517_000006_audio_tracks::Migration), Box::new(m20260517_000007_audio_tags::Migration), Box::new(m20260517_000008_audio_track_tags::Migration), + Box::new(m20260517_000009_simple_constraints::Migration), + Box::new(m20260517_000010_drop_user_roles::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260517_000009_simple_constraints.rs b/migration/src/m20260517_000009_simple_constraints.rs new file mode 100644 index 0000000..e8d5477 --- /dev/null +++ b/migration/src/m20260517_000009_simple_constraints.rs @@ -0,0 +1,60 @@ +use sea_orm_migration::{prelude::*, sea_orm::ConnectionTrait}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +enum AudioTrackTags { + Table, + TagId, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + if matches!( + m.get_database_backend(), + sea_orm_migration::sea_orm::DatabaseBackend::Postgres + ) { + m.get_connection() + .execute_unprepared( + "ALTER TABLE users \ + ADD CONSTRAINT chk_users_theme \ + CHECK (theme IN ('light', 'dark'))", + ) + .await?; + } + + m.create_index( + Index::create() + .name("idx-audio_track_tags-tag_id") + .table(AudioTrackTags::Table) + .col(AudioTrackTags::TagId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + m.drop_index( + Index::drop() + .name("idx-audio_track_tags-tag_id") + .table(AudioTrackTags::Table) + .to_owned(), + ) + .await?; + + if matches!( + m.get_database_backend(), + sea_orm_migration::sea_orm::DatabaseBackend::Postgres + ) { + m.get_connection() + .execute_unprepared("ALTER TABLE users DROP CONSTRAINT chk_users_theme") + .await?; + } + + Ok(()) + } +} diff --git a/migration/src/m20260517_000010_drop_user_roles.rs b/migration/src/m20260517_000010_drop_user_roles.rs new file mode 100644 index 0000000..0ca2dd1 --- /dev/null +++ b/migration/src/m20260517_000010_drop_user_roles.rs @@ -0,0 +1,28 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +enum UserRoles { + Table, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + m.drop_table( + Table::drop() + .table(UserRoles::Table) + .if_exists() + .cascade() + .to_owned(), + ) + .await?; + Ok(()) + } + + async fn down(&self, _m: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} diff --git a/src/app.rs b/src/app.rs index 29d5f00..17cb446 100644 --- a/src/app.rs +++ b/src/app.rs @@ -52,6 +52,7 @@ impl Hooks for App { fn routes(_ctx: &AppContext) -> AppRoutes { AppRoutes::with_default_routes() // controller routes below .add_route(controllers::auth::routes()) + .add_route(controllers::admin::routes()) } async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { queue.register(DownloadWorker::build(ctx)).await?; diff --git a/src/controllers/admin.rs b/src/controllers/admin.rs new file mode 100644 index 0000000..ad9d940 --- /dev/null +++ b/src/controllers/admin.rs @@ -0,0 +1,57 @@ +use crate::models::{ + _entities::{audio_albums, audio_tracks, audit_logs, blog_articles, users}, + users as users_model, +}; +use loco_rs::prelude::*; +use sea_orm::{EntityTrait, PaginatorTrait}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +struct DashboardResponse { + users: u64, + blog_articles: u64, + audio_albums: u64, + audio_tracks: u64, + audit_logs: u64, +} + +fn admin_email(ctx: &AppContext) -> Option<&str> { + ctx.config + .settings + .as_ref() + .and_then(|settings| settings.get("admin_email")) + .and_then(|email| email.as_str()) +} + +fn is_admin(ctx: &AppContext, user: &users::Model) -> bool { + admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email)) +} + +async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result { + let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; + + if !is_admin(ctx, &user) { + return unauthorized("admin only"); + } + + Ok(user) +} + +#[debug_handler] +async fn dashboard(auth: auth::JWT, State(ctx): State) -> Result { + current_admin(auth, &ctx).await?; + + format::json(DashboardResponse { + users: users::Entity::find().count(&ctx.db).await?, + blog_articles: blog_articles::Entity::find().count(&ctx.db).await?, + audio_albums: audio_albums::Entity::find().count(&ctx.db).await?, + audio_tracks: audio_tracks::Entity::find().count(&ctx.db).await?, + audit_logs: audit_logs::Entity::find().count(&ctx.db).await?, + }) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("/api/admin") + .add("/dashboard", get(dashboard)) +} diff --git a/src/controllers/auth.rs b/src/controllers/auth.rs index e66d448..b413c17 100644 --- a/src/controllers/auth.rs +++ b/src/controllers/auth.rs @@ -6,12 +6,15 @@ use crate::{ }, views::auth::{CurrentResponse, LoginResponse}, }; +use axum_extra::extract::cookie::{Cookie, SameSite}; use loco_rs::prelude::*; use regex::Regex; use serde::{Deserialize, Serialize}; use std::sync::OnceLock; +use time::Duration as TimeDuration; pub static EMAIL_DOMAIN_RE: OnceLock = OnceLock::new(); +const AUTH_COOKIE: &str = "auth_token"; fn get_allow_email_domain_re() -> &'static Regex { EMAIL_DOMAIN_RE.get_or_init(|| { @@ -19,6 +22,36 @@ fn get_allow_email_domain_re() -> &'static Regex { }) } +fn admin_email(ctx: &AppContext) -> Option<&str> { + ctx.config + .settings + .as_ref() + .and_then(|settings| settings.get("admin_email")) + .and_then(|email| email.as_str()) +} + +fn is_admin(ctx: &AppContext, user: &users::Model) -> bool { + admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email)) +} + +fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> { + Cookie::build((AUTH_COOKIE, token.to_string())) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .max_age(TimeDuration::seconds(max_age_seconds as i64)) + .build() +} + +fn clear_auth_cookie() -> Cookie<'static> { + Cookie::build((AUTH_COOKIE, "")) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .max_age(TimeDuration::seconds(0)) + .build() +} + #[derive(Debug, Deserialize, Serialize)] pub struct ForgotParams { pub email: String, @@ -155,13 +188,20 @@ async fn login(State(ctx): State, Json(params): Json) - .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; - format::json(LoginResponse::new(&user, &token)) + format::render() + .cookies(&[auth_cookie(&token, jwt_secret.expiration)])? + .json(LoginResponse::new(&user, &token, is_admin(&ctx, &user))) } #[debug_handler] async fn current(auth: auth::JWT, State(ctx): State) -> Result { let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; - format::json(CurrentResponse::new(&user)) + format::json(CurrentResponse::new(&user, is_admin(&ctx, &user))) +} + +#[debug_handler] +async fn logout() -> Result { + format::render().cookies(&[clear_auth_cookie()])?.json(()) } /// Magic link authentication provides a secure and passwordless way to log in to the application. @@ -223,7 +263,9 @@ async fn magic_link_verify( .generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .or_else(|_| unauthorized("unauthorized!"))?; - format::json(LoginResponse::new(&user, &token)) + format::render() + .cookies(&[auth_cookie(&token, jwt_secret.expiration)])? + .json(LoginResponse::new(&user, &token, is_admin(&ctx, &user))) } #[debug_handler] @@ -264,6 +306,7 @@ pub fn routes() -> Routes { .add("/register", post(register)) .add("/verify/{token}", get(verify)) .add("/login", post(login)) + .add("/logout", post(logout)) .add("/forgot", post(forgot)) .add("/reset", post(reset)) .add("/current", get(current)) diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 0e4a05d..52815c1 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1 +1,2 @@ +pub mod admin; pub mod auth; diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs index 2a6ae8d..1df2611 100644 --- a/src/models/_entities/mod.rs +++ b/src/models/_entities/mod.rs @@ -8,5 +8,4 @@ pub mod audio_track_tags; pub mod audio_tracks; pub mod audit_logs; pub mod blog_articles; -pub mod user_roles; pub mod users; diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs index 61f6b71..c1e363c 100644 --- a/src/models/_entities/prelude.rs +++ b/src/models/_entities/prelude.rs @@ -6,5 +6,4 @@ pub use super::audio_track_tags::Entity as AudioTrackTags; pub use super::audio_tracks::Entity as AudioTracks; pub use super::audit_logs::Entity as AuditLogs; pub use super::blog_articles::Entity as BlogArticles; -pub use super::user_roles::Entity as UserRoles; pub use super::users::Entity as Users; diff --git a/src/models/_entities/user_roles.rs b/src/models/_entities/user_roles.rs deleted file mode 100644 index 7d0b607..0000000 --- a/src/models/_entities/user_roles.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] -#[sea_orm(table_name = "user_roles")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub user_id: i32, - #[sea_orm(primary_key, auto_increment = false)] - pub role: String, - pub assigned_by: Option, - pub assigned_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::users::Entity", - from = "Column::AssignedBy", - to = "super::users::Column::Id", - on_update = "NoAction", - on_delete = "SetNull" - )] - Users2, - #[sea_orm( - belongs_to = "super::users::Entity", - from = "Column::UserId", - to = "super::users::Column::Id", - on_update = "Cascade", - on_delete = "Cascade" - )] - Users1, -} diff --git a/src/models/mod.rs b/src/models/mod.rs index 445e29d..b516710 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,5 @@ pub mod _entities; pub mod users; -pub mod user_roles; pub mod audio_tags; pub mod audio_tracks; pub mod audio_track_tags; diff --git a/src/models/user_roles.rs b/src/models/user_roles.rs deleted file mode 100644 index 5ea7002..0000000 --- a/src/models/user_roles.rs +++ /dev/null @@ -1,22 +0,0 @@ -use sea_orm::entity::prelude::*; -pub use super::_entities::user_roles::{ActiveModel, Model, Entity}; -pub type UserRoles = Entity; - -#[async_trait::async_trait] -impl ActiveModelBehavior for ActiveModel { - async fn before_save(self, _db: &C, _insert: bool) -> std::result::Result - where - C: ConnectionTrait, - { - Ok(self) - } -} - -// implement your read-oriented logic here -impl Model {} - -// implement your write-oriented logic here -impl ActiveModel {} - -// implement your custom finders, selectors oriented logic here -impl Entity {} diff --git a/src/views/auth.rs b/src/views/auth.rs index 3d2d74f..991df2f 100644 --- a/src/views/auth.rs +++ b/src/views/auth.rs @@ -8,16 +8,18 @@ pub struct LoginResponse { pub pid: String, pub name: String, pub is_verified: bool, + pub is_admin: bool, } impl LoginResponse { #[must_use] - pub fn new(user: &users::Model, token: &String) -> Self { + pub fn new(user: &users::Model, token: &String, is_admin: bool) -> Self { Self { token: token.to_string(), pid: user.pid.to_string(), name: user.name.clone(), is_verified: user.email_verified_at.is_some(), + is_admin, } } } @@ -27,15 +29,17 @@ pub struct CurrentResponse { pub pid: String, pub name: String, pub email: String, + pub is_admin: bool, } impl CurrentResponse { #[must_use] - pub fn new(user: &users::Model) -> Self { + pub fn new(user: &users::Model, is_admin: bool) -> Self { Self { pid: user.pid.to_string(), name: user.name.clone(), email: user.email.clone(), + is_admin, } } }