removing rbac cos its not needed at all

This commit is contained in:
Priec
2026-05-17 14:58:13 +02:00
parent 35f0e7af00
commit 0bfd2f8674
17 changed files with 219 additions and 65 deletions

1
Cargo.lock generated
View File

@@ -5071,6 +5071,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serial_test", "serial_test",
"time",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",

View File

@@ -32,6 +32,7 @@ sea-orm = { version = "1.1", features = [
"macros", "macros",
] } ] }
chrono = { version = "0.4" } chrono = { version = "0.4" }
time = { version = "0.3" }
validator = { version = "0.20" } validator = { version = "0.20" }
uuid = { version = "1.6", features = ["v4"] } uuid = { version = "1.6", features = ["v4"] }
include_dir = { version = "0.7" } include_dir = { version = "0.7" }

View File

@@ -93,7 +93,14 @@ database:
auth: auth:
# JWT authentication # JWT authentication
jwt: jwt:
location:
- from: Cookie
name: auth_token
- from: Bearer
# Secret key for token generation and verification # Secret key for token generation and verification
secret: A6ECni63rt2Jb00tX9Hf secret: A6ECni63rt2Jb00tX9Hf
# Token expiration time in seconds # Token expiration time in seconds
expiration: 604800 # 7 days expiration: 604800 # 7 days
settings:
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}

View File

@@ -90,7 +90,14 @@ database:
auth: auth:
# JWT authentication # JWT authentication
jwt: jwt:
location:
- from: Cookie
name: auth_token
- from: Bearer
# Secret key for token generation and verification # Secret key for token generation and verification
secret: 0yWwoflcGiAhonIzhQyQ secret: 0yWwoflcGiAhonIzhQyQ
# Token expiration time in seconds # Token expiration time in seconds
expiration: 604800 # 7 days expiration: 604800 # 7 days
settings:
admin_email: admin@example.com

View File

@@ -10,6 +10,8 @@ mod m20260517_000005_audio_albums;
mod m20260517_000006_audio_tracks; mod m20260517_000006_audio_tracks;
mod m20260517_000007_audio_tags; mod m20260517_000007_audio_tags;
mod m20260517_000008_audio_track_tags; mod m20260517_000008_audio_track_tags;
mod m20260517_000009_simple_constraints;
mod m20260517_000010_drop_user_roles;
pub struct Migrator; pub struct Migrator;
@@ -26,6 +28,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260517_000006_audio_tracks::Migration), Box::new(m20260517_000006_audio_tracks::Migration),
Box::new(m20260517_000007_audio_tags::Migration), Box::new(m20260517_000007_audio_tags::Migration),
Box::new(m20260517_000008_audio_track_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) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -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(())
}
}

View File

@@ -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(())
}
}

View File

@@ -52,6 +52,7 @@ impl Hooks for App {
fn routes(_ctx: &AppContext) -> AppRoutes { fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::auth::routes()) .add_route(controllers::auth::routes())
.add_route(controllers::admin::routes())
} }
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?; queue.register(DownloadWorker::build(ctx)).await?;

57
src/controllers/admin.rs Normal file
View File

@@ -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<users::Model> {
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<AppContext>) -> Result<Response> {
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))
}

View File

@@ -6,12 +6,15 @@ use crate::{
}, },
views::auth::{CurrentResponse, LoginResponse}, views::auth::{CurrentResponse, LoginResponse},
}; };
use axum_extra::extract::cookie::{Cookie, SameSite};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::OnceLock; use std::sync::OnceLock;
use time::Duration as TimeDuration;
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new(); pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
const AUTH_COOKIE: &str = "auth_token";
fn get_allow_email_domain_re() -> &'static Regex { fn get_allow_email_domain_re() -> &'static Regex {
EMAIL_DOMAIN_RE.get_or_init(|| { 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)] #[derive(Debug, Deserialize, Serialize)]
pub struct ForgotParams { pub struct ForgotParams {
pub email: String, pub email: String,
@@ -155,13 +188,20 @@ async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration) .generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?; .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] #[debug_handler]
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> { async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?; 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<Response> {
format::render().cookies(&[clear_auth_cookie()])?.json(())
} }
/// Magic link authentication provides a secure and passwordless way to log in to the application. /// 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) .generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?; .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] #[debug_handler]
@@ -264,6 +306,7 @@ pub fn routes() -> Routes {
.add("/register", post(register)) .add("/register", post(register))
.add("/verify/{token}", get(verify)) .add("/verify/{token}", get(verify))
.add("/login", post(login)) .add("/login", post(login))
.add("/logout", post(logout))
.add("/forgot", post(forgot)) .add("/forgot", post(forgot))
.add("/reset", post(reset)) .add("/reset", post(reset))
.add("/current", get(current)) .add("/current", get(current))

View File

@@ -1 +1,2 @@
pub mod admin;
pub mod auth; pub mod auth;

View File

@@ -8,5 +8,4 @@ pub mod audio_track_tags;
pub mod audio_tracks; pub mod audio_tracks;
pub mod audit_logs; pub mod audit_logs;
pub mod blog_articles; pub mod blog_articles;
pub mod user_roles;
pub mod users; pub mod users;

View File

@@ -6,5 +6,4 @@ pub use super::audio_track_tags::Entity as AudioTrackTags;
pub use super::audio_tracks::Entity as AudioTracks; pub use super::audio_tracks::Entity as AudioTracks;
pub use super::audit_logs::Entity as AuditLogs; pub use super::audit_logs::Entity as AuditLogs;
pub use super::blog_articles::Entity as BlogArticles; pub use super::blog_articles::Entity as BlogArticles;
pub use super::user_roles::Entity as UserRoles;
pub use super::users::Entity as Users; pub use super::users::Entity as Users;

View File

@@ -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<i32>,
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,
}

View File

@@ -1,6 +1,5 @@
pub mod _entities; pub mod _entities;
pub mod users; pub mod users;
pub mod user_roles;
pub mod audio_tags; pub mod audio_tags;
pub mod audio_tracks; pub mod audio_tracks;
pub mod audio_track_tags; pub mod audio_track_tags;

View File

@@ -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<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
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 {}

View File

@@ -8,16 +8,18 @@ pub struct LoginResponse {
pub pid: String, pub pid: String,
pub name: String, pub name: String,
pub is_verified: bool, pub is_verified: bool,
pub is_admin: bool,
} }
impl LoginResponse { impl LoginResponse {
#[must_use] #[must_use]
pub fn new(user: &users::Model, token: &String) -> Self { pub fn new(user: &users::Model, token: &String, is_admin: bool) -> Self {
Self { Self {
token: token.to_string(), token: token.to_string(),
pid: user.pid.to_string(), pid: user.pid.to_string(),
name: user.name.clone(), name: user.name.clone(),
is_verified: user.email_verified_at.is_some(), is_verified: user.email_verified_at.is_some(),
is_admin,
} }
} }
} }
@@ -27,15 +29,17 @@ pub struct CurrentResponse {
pub pid: String, pub pid: String,
pub name: String, pub name: String,
pub email: String, pub email: String,
pub is_admin: bool,
} }
impl CurrentResponse { impl CurrentResponse {
#[must_use] #[must_use]
pub fn new(user: &users::Model) -> Self { pub fn new(user: &users::Model, is_admin: bool) -> Self {
Self { Self {
pid: user.pid.to_string(), pid: user.pid.to_string(),
name: user.name.clone(), name: user.name.clone(),
email: user.email.clone(), email: user.email.clone(),
is_admin,
} }
} }
} }