diff --git a/README.md b/README.md index 9555076..a7773cd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ +## Architecture & Philosophie + +Ce projet s'inspire de la philosophie de Rust : **"Rendre les états illégaux impossibles à représenter"**. + +Si vous venez de **Django**, voici les principales différences à garder en tête : + +### 1. Extracteurs vs Objet Request + +Contrairement à Django qui passe un objet `request` "fourre-tout" à chaque vue, ce projet utilise des **Extracteurs Axum +** (dans la signature des fonctions). + +- **Le "Contrat" par la signature :** Si une vue demande `user: CurrentUser`, Axum garantit que l'utilisateur est + authentifié. Si ce n'est pas le cas, la vue n'est pas appelée et une erreur 401 est retournée automatiquement. +- **Auto-documentation :** En regardant simplement la signature d'une fonction, on sait exactement ce dont elle a + besoin (Base de données, Utilisateur, JSON, etc.). + +### 2. Le Flux d'Authentification + +1. **Middleware (`context_middleware`) :** Intercepte la requête, vérifie le JWT, récupère l'utilisateur complet en base + de données, et injecte le tout dans `RequestContext`. +2. **Extensions :** Le `RequestContext` est stocké dans les "extensions" de la requête (un espace de stockage de type + Map). +3. **Extracteurs :** L'extracteur `CurrentUser` récupère les données depuis les extensions et les rend disponibles dans + la signature de votre vue. + +### 3. Transparence (Deref) + +L'objet `CurrentUser` enveloppe le modèle de base de données. Grâce à l'implémentation de `Deref`, vous pouvez accéder +aux champs de l'utilisateur (ex: `user.is_superuser`) directement comme s'il s'agissait du modèle lui-même. + +--- + ## TODO Terminer le système d'authentification (login, changement de mot de passe...) diff --git a/migration/src/m20220101_000001_create_table.rs b/migration/src/m20220101_000001_create_table.rs index e43c69a..6f3f664 100644 --- a/migration/src/m20220101_000001_create_table.rs +++ b/migration/src/m20220101_000001_create_table.rs @@ -1,4 +1,4 @@ -use sea_orm_migration::{prelude::*, schema::*}; +use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; @@ -150,7 +150,7 @@ impl MigrationTrait for Migration { .col( ColumnDef::new(Alias::new("pub_key")) .text() - .null() + .not_null() .unique_key(), ) .col( @@ -165,6 +165,12 @@ impl MigrationTrait for Migration { .not_null() .default(Expr::current_timestamp()), ) + .col( + ColumnDef::new(Alias::new("is_superuser")) + .boolean() + .not_null() + .default(false), + ) .to_owned(), ) .await?; @@ -284,7 +290,14 @@ impl MigrationTrait for Migration { .col( ColumnDef::new(Alias::new("updated_at")) .timestamp_with_time_zone() - .not_null(), + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("is_admin")) + .boolean() + .not_null() + .default(false), ) // Indexes créés après .foreign_key( @@ -325,6 +338,12 @@ impl MigrationTrait for Migration { .not_null() .default("member".to_owned()), ) + .col( + ColumnDef::new(Alias::new("permissions")) + .big_integer() + .not_null() + .default(0), + ) .col( ColumnDef::new(Alias::new("joined_at")) .timestamp_with_time_zone() @@ -501,10 +520,108 @@ impl MigrationTrait for Migration { ) .await?; + // Create table `group` + manager + .create_table( + Table::create() + .table(Alias::new("group")) + .if_not_exists() + .col(ColumnDef::new("id").uuid().primary_key().not_null()) + .col(ColumnDef::new("server_id").uuid().not_null()) + .col(ColumnDef::new("name").string().not_null()) + .col( + ColumnDef::new("permissions") + .big_integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new("is_default") + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new("created_at") + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_group_server") + .from(Alias::new("group"), Alias::new("server_id")) + .to(Alias::new("server"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Create table `group_member` + manager + .create_table( + Table::create() + .table(Alias::new("group_member")) + .if_not_exists() + .col(ColumnDef::new("group_id").uuid().not_null()) + .col(ColumnDef::new("user_id").uuid().not_null()) + .primary_key( + Index::create() + .col(Alias::new("group_id")) + .col(Alias::new("user_id")), + ) + .foreign_key( + ForeignKey::create() + .name("fk_group_member_group") + .from(Alias::new("group_member"), Alias::new("group_id")) + .to(Alias::new("group"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_group_member_user") + .from(Alias::new("group_member"), Alias::new("user_id")) + .to(Alias::new("user"), Alias::new("id")) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // Index: idx_group_server_id + manager + .create_index( + Index::create() + .name("idx_group_server_id") + .table(Alias::new("group")) + .col(Alias::new("server_id")) + .to_owned(), + ) + .await?; + + // Index: idx_group_member_user_id + manager + .create_index( + Index::create() + .name("idx_group_member_user_id") + .table(Alias::new("group_member")) + .col(Alias::new("user_id")) + .to_owned(), + ) + .await?; + Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("group_member")).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Alias::new("group")).to_owned()) + .await?; + manager .drop_table(Table::drop().table(Alias::new("channel_user")).to_owned()) .await?; diff --git a/src/app/app.rs b/src/app/app.rs index 07cb444..9131aea 100644 --- a/src/app/app.rs +++ b/src/app/app.rs @@ -6,6 +6,9 @@ use crate::hub::Clients; use crate::network::http::HTTPServer; use crate::network::udp::UDPServer; use crate::repositories::Repositories; +use std::sync::Arc; +use tokio::sync::Mutex; +use uuid::Uuid; pub struct App { config: Config, @@ -28,12 +31,33 @@ impl App { .expect("Failed to initialize database"); let repositories = Repositories::new(db.get_connection(), event_bus.clone()); + let init_token = if repositories + .user + .count() + .await + .expect("Failed to count users") + == 0 + { + let token = Uuid::new_v4().to_string(); + println!("+------------------------------------------------------------+"); + println!("| NO USER FOUND IN DATABASE |"); + println!("| Use the following token to create the first admin user: |"); + println!("| |"); + println!("| TOKEN: {} |", token); + println!("| |"); + println!("+------------------------------------------------------------+"); + Some(token) + } else { + None + }; + let state = AppState { db: db.clone(), event_bus: event_bus.clone(), repositories: repositories.clone(), clients: Clients::new(), config: config.clone(), + init_token: Arc::new(Mutex::new(init_token)), }; let udp_server = UDPServer::new(config.bind_addr()); diff --git a/src/app/state.rs b/src/app/state.rs index e64073c..2b153ae 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -4,6 +4,9 @@ use crate::event_bus::EventBus; use crate::hub::Clients; use crate::repositories::Repositories; +use std::sync::Arc; +use tokio::sync::Mutex; + #[derive(Clone)] pub struct AppState { pub db: Database, @@ -11,6 +14,7 @@ pub struct AppState { pub repositories: Repositories, pub clients: Clients, pub config: Config, + pub init_token: Arc>>, } impl AppState { diff --git a/src/interfaces/http/dto/group.rs b/src/interfaces/http/dto/group.rs new file mode 100644 index 0000000..5050ba1 --- /dev/null +++ b/src/interfaces/http/dto/group.rs @@ -0,0 +1,52 @@ +use crate::models::group; +use sea_orm::Set; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Serialize, ToSchema)] +pub struct GroupResponse { + pub id: Uuid, + pub server_id: Uuid, + pub name: String, + pub permissions: i64, + pub is_default: bool, + pub created_at: String, +} + +impl From for GroupResponse { + fn from(model: group::Model) -> Self { + Self { + id: model.id, + server_id: model.server_id, + name: model.name, + permissions: model.permissions, + is_default: model.is_default, + created_at: model.created_at.to_rfc3339(), + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateGroupRequest { + pub server_id: Uuid, + pub name: String, + pub permissions: i64, +} + +impl CreateGroupRequest { + pub fn into_active_model(self) -> group::ActiveModel { + group::ActiveModel { + server_id: Set(self.server_id), + name: Set(self.name), + permissions: Set(self.permissions), + ..Default::default() + } + } + + pub fn apply_to(self, mut am: group::ActiveModel) -> group::ActiveModel { + am.name = Set(self.name); + am.permissions = Set(self.permissions); + am + } +} diff --git a/src/interfaces/http/dto/mod.rs b/src/interfaces/http/dto/mod.rs index dede070..3dd3c53 100644 --- a/src/interfaces/http/dto/mod.rs +++ b/src/interfaces/http/dto/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod category; pub mod channel; +pub mod group; pub mod message; pub mod server; pub mod user; diff --git a/src/models/channel_user.rs b/src/models/channel_user.rs index 39accca..27cedb1 100644 --- a/src/models/channel_user.rs +++ b/src/models/channel_user.rs @@ -12,6 +12,7 @@ pub struct Model { pub channel_id: Uuid, pub user_id: Uuid, pub role: String, + pub permissions: i64, pub joined_at: DateTimeUtc, } diff --git a/src/models/group.rs b/src/models/group.rs new file mode 100644 index 0000000..b324309 --- /dev/null +++ b/src/models/group.rs @@ -0,0 +1,53 @@ +//! `SeaORM` Entity. + +use sea_orm::entity::prelude::*; +use sea_orm::prelude::async_trait::async_trait; +use sea_orm::Set; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "group")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub server_id: Uuid, + pub name: String, + pub permissions: i64, + pub is_default: bool, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::server::Entity", + from = "Column::ServerId", + to = "super::server::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Server, + #[sea_orm(has_many = "super::group_member::Entity")] + GroupMember, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Server.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::GroupMember.def() + } +} + +#[async_trait] +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + id: Set(Uuid::new_v4()), + ..ActiveModelTrait::default() + } + } +} diff --git a/src/models/group_member.rs b/src/models/group_member.rs new file mode 100644 index 0000000..847c178 --- /dev/null +++ b/src/models/group_member.rs @@ -0,0 +1,46 @@ +//! `SeaORM` Entity. + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "group_member")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub group_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: Uuid, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::group::Entity", + from = "Column::GroupId", + to = "super::group::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Group, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Group.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/models/mod.rs b/src/models/mod.rs index 0375251..1c3d3ff 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -6,6 +6,8 @@ pub mod attachment; pub mod category; pub mod channel; pub mod channel_user; +pub mod group; +pub mod group_member; pub mod message; pub mod server; pub mod server_user; diff --git a/src/models/prelude.rs b/src/models/prelude.rs index d05c522..cd05ed3 100644 --- a/src/models/prelude.rs +++ b/src/models/prelude.rs @@ -4,6 +4,8 @@ pub use super::attachment::Entity as Attachment; pub use super::category::Entity as Category; pub use super::channel::Entity as Channel; pub use super::channel_user::Entity as ChannelUser; +pub use super::group::Entity as Group; +pub use super::group_member::Entity as GroupMember; pub use super::message::Entity as Message; pub use super::server::Entity as Server; pub use super::server_user::Entity as ServerUser; diff --git a/src/models/server_user.rs b/src/models/server_user.rs index e78205b..c2f5209 100644 --- a/src/models/server_user.rs +++ b/src/models/server_user.rs @@ -14,6 +14,7 @@ pub struct Model { pub username: Option, pub joined_at: DateTimeUtc, pub updated_at: DateTimeUtc, + pub is_admin: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/models/user.rs b/src/models/user.rs index 47053b8..089d85a 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -15,6 +15,7 @@ pub struct Model { pub pub_key: String, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, + pub is_superuser: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/network/http/context.rs b/src/network/http/context.rs index e044eec..f19db1d 100644 --- a/src/network/http/context.rs +++ b/src/network/http/context.rs @@ -1,9 +1,11 @@ - +use crate::models::user; +use crate::network::http::HTTPError; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use std::ops::Deref; use std::time::Instant; use uuid::Uuid; -use crate::app::AppState; - #[derive(Clone, Debug)] pub struct RequestContext { pub request_id: Uuid, @@ -13,8 +15,54 @@ pub struct RequestContext { pub user: Option, } +/// Représente l'utilisateur actuellement authentifié, enveloppant le modèle de base de données. +/// +/// **Philosophie (vs Django) :** +/// Au lieu de passer une `request` entière, on demande `user: CurrentUser` dans la signature +/// de la vue. C'est un **contrat** : si l'utilisateur n'est pas là, la vue n'est pas appelée (401). +/// +/// **Usage :** +/// ```rust +/// pub async fn ma_vue(user: CurrentUser) { +/// if user.is_superuser { ... } +/// } +/// ``` #[derive(Clone, Debug)] -pub struct CurrentUser { - pub id: Uuid, - pub username: String, -} \ No newline at end of file +pub struct CurrentUser(pub user::Model); + +impl Deref for CurrentUser { + type Target = user::Model; + + /// Permet d'accéder aux champs du modèle (`user.username`, etc.) directement + /// sur l'objet `CurrentUser`, sans avoir à faire `user.0.username`. + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Le "Moteur" derrière la magie des signatures de fonction d'Axum. +/// +/// Implémenter ce trait permet à Axum d'extraire automatiquement l'utilisateur +/// depuis les extensions de la requête (injectées par le middleware). +impl FromRequestParts for CurrentUser +where + S: Send + Sync, +{ + type Rejection = HTTPError; + + /// Cette méthode est appelée par Axum AVANT d'exécuter votre vue. + /// 1. On cherche le `RequestContext` dans les extensions. + /// 2. On vérifie si un utilisateur y est présent. + /// 3. Si oui, on le retourne (succès). + /// 4. Si non, on retourne une erreur 401 (rejet de la requête). + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // On récupère le contexte injecté par le middleware + let context = parts + .extensions + .get::() + .ok_or(HTTPError::Unauthorized)?; + + // On retourne l'utilisateur cloné s'il existe, sinon on rejette avec Unauthorized + context.user.clone().ok_or(HTTPError::Unauthorized) + } +} diff --git a/src/network/http/error.rs b/src/network/http/error.rs index 8388962..8dbb691 100644 --- a/src/network/http/error.rs +++ b/src/network/http/error.rs @@ -11,6 +11,7 @@ pub enum HTTPError { BadRequest(String), InternalServerError(String), Unauthorized, + Forbidden, } // Conversion automatique depuis DbErr (erreurs SeaORM) @@ -37,6 +38,7 @@ impl IntoResponse for HTTPError { } HTTPError::NotFound => (StatusCode::NOT_FOUND, "Resource not found"), HTTPError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"), + HTTPError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"), HTTPError::BadRequest(msg) => { return (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response(); } diff --git a/src/network/http/middleware.rs b/src/network/http/middleware.rs index a33dc43..b37aa68 100644 --- a/src/network/http/middleware.rs +++ b/src/network/http/middleware.rs @@ -20,27 +20,34 @@ pub async fn context_middleware( let uri = req.uri().clone(); // Authentification par JWT - let user: Option = { - req.headers() - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|auth_header| { - if auth_header.starts_with("Bearer ") { - Some(&auth_header[7..]) - } else { - None - } - }) - .and_then(|token| verify_jwt(token, &app_state.config.jwt.secret).ok()) - .map(|claims| CurrentUser { - id: claims.user_id, - username: claims.username, - }) + let user: Option = match req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|auth_header| { + if auth_header.starts_with("Bearer ") { + Some(&auth_header[7..]) + } else { + None + } + }) + .and_then(|token| verify_jwt(token, &app_state.config.jwt.secret).ok()) + { + Some(claims) => app_state + .repositories + .user + .get_by_id(claims.user_id) + .await + .ok() + .flatten() + .map(CurrentUser), + None => None, }; let user_id = user.as_ref().map(|u| u.id); - // Injecte le contexte dans la requête + // Injecte le contexte dans la requête (espace de stockage partagé) + // C'est ce qui permettra aux extracteurs comme 'CurrentUser' de retrouver ces données plus tard. req.extensions_mut().insert(RequestContext { request_id, started_at, diff --git a/src/network/http/web/api/auth.rs b/src/network/http/web/api/auth.rs index c941c72..a9057db 100644 --- a/src/network/http/web/api/auth.rs +++ b/src/network/http/web/api/auth.rs @@ -1,10 +1,13 @@ use crate::app::AppState; +use crate::models::user; use crate::network::http::{AppRouter, HTTPError}; use crate::utils::auth::create_jwt; +use crate::utils::password::hash_password; use crate::utils::toolbox::ssh_generate_challenge; use axum::extract::State; use axum::routing::post; use axum::Json; +use sea_orm::{ActiveModelTrait, Set}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -12,6 +15,14 @@ pub fn setup_route() -> AppRouter { AppRouter::new() .route("/login", post(login)) .route("/ssh-challenge", post(ssh_challenge)) + .route("/claim-admin", post(claim_admin)) +} + +#[derive(Deserialize, ToSchema)] +pub struct ClaimAdminRequest { + token: String, + username: String, + password: String, } #[derive(Deserialize, ToSchema)] @@ -92,3 +103,82 @@ pub async fn ssh_challenge( Ok(Json(SshChallengeResponse { challenge })) } + +#[utoipa::path( + post, + path = "/api/auth/claim-admin", + responses( + (status = 200, description = "Admin created successfully"), + (status = 400, description = "Bad request (token mismatch or user already exists)"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn claim_admin( + State(state): State, + Json(payload): Json, +) -> Result, HTTPError> { + let mut init_token = state.init_token.lock().await; + + // Check if a token exists and matches + let token_valid = match &*init_token { + Some(t) => t == &payload.token, + None => false, + }; + + if !token_valid { + return Err(HTTPError::BadRequest( + "Invalid or expired initialization token".to_string(), + )); + } + + // Double check if any user exists in database + let user_count = state + .repositories + .user + .count() + .await + .map_err(|e| HTTPError::InternalServerError(e.to_string()))?; + + if user_count > 0 { + *init_token = None; // Invalidate token if users already exist + return Err(HTTPError::BadRequest( + "Users already exist in database".to_string(), + )); + } + + // Create the admin user + let hashed_password = hash_password(&payload.password) + .map_err(|_| HTTPError::InternalServerError("Failed to hash password".to_string()))?; + + let new_user = user::ActiveModel { + username: Set(payload.username), + password: Set(hashed_password), + is_superuser: Set(true), + pub_key: Set("".to_string()), // Default empty pub_key as it's required in model + ..Default::default() + }; + + let user = state + .repositories + .user + .create(new_user) + .await + .map_err(|e| HTTPError::InternalServerError(e.to_string()))?; + + // Invalidate the token + *init_token = None; + + // Generate JWT for the new admin + let token = create_jwt( + user.id, + &user.username, + &state.config.jwt.secret, + state.config.jwt.expiration, + ) + .map_err(|e| HTTPError::InternalServerError("Failed to generate token".to_string()))?; + + Ok(Json(LoginResponse { + token, + username: user.username, + })) +} diff --git a/src/network/http/web/api/category.rs b/src/network/http/web/api/category.rs index c1627a5..f72b293 100644 --- a/src/network/http/web/api/category.rs +++ b/src/network/http/web/api/category.rs @@ -1,12 +1,13 @@ use crate::app::AppState; use crate::interfaces::http::dto::category::{CategoryResponse, CreateCategoryRequest}; use crate::models::category; -use crate::network::http::RequestContext; +use crate::network::http::context::CurrentUser; use crate::network::http::{AppRouter, HTTPError}; +use crate::utils::permissions::Permission; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; -use axum::routing::{delete, get, post, put}; -use axum::{Extension, Json}; +use axum::routing::get; +use axum::Json; use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel}; use serde::Deserialize; use utoipa::ToSchema; @@ -40,7 +41,6 @@ pub struct CategoryQuery { )] pub async fn category_list( State(app_state): State, - Extension(_ctx): Extension, Query(query): Query, ) -> Result>, HTTPError> { let categories = if let Some(server_id) = query.server_id { @@ -88,14 +88,38 @@ pub async fn category_detail( path = "/api/category", request_body = CreateCategoryRequest, responses( - (status = 200, description = "Category created", body = CategoryResponse) + (status = 200, description = "Category created", body = CategoryResponse), + (status = 403, description = "Forbidden") ) )] pub async fn category_create( State(app_state): State, - Json(serializer): Json, + user: CurrentUser, + Json(payload): Json, ) -> Result, HTTPError> { - let active: category::ActiveModel = serializer.into(); + let is_admin = app_state + .repositories + .permission + .is_server_admin(user.id, payload.server_id) + .await?; + let is_superuser = user.is_superuser; + + let has_permission = if is_admin || is_superuser { + true + } else { + let ctx = app_state + .repositories + .permission + .load_for_server_user(user.id, payload.server_id) + .await?; + ctx.has(Permission::ManageServer, None) + }; + + if !has_permission { + return Err(HTTPError::Forbidden); + } + + let active: category::ActiveModel = payload.into(); let category: category::Model = active.insert(app_state.db.get_connection()).await?; Ok(Json(CategoryResponse::from(category))) @@ -107,6 +131,7 @@ pub async fn category_create( request_body = CreateCategoryRequest, responses( (status = 200, description = "Category updated", body = CategoryResponse), + (status = 403, description = "Forbidden"), (status = 404, description = "Category not found") ), params( @@ -115,18 +140,40 @@ pub async fn category_create( )] pub async fn category_update( State(app_state): State, + user: CurrentUser, Path(id): Path, - Json(serializer): Json, + Json(payload): Json, ) -> Result, HTTPError> { let category = category::Entity::find_by_id(id) .one(app_state.db.get_connection()) .await? .ok_or_else(|| HTTPError::NotFound)?; + let is_admin = app_state + .repositories + .permission + .is_server_admin(user.id, category.server_id) + .await?; + let is_superuser = user.is_superuser; + + let has_permission = if is_admin || is_superuser { + true + } else { + let ctx = app_state + .repositories + .permission + .load_for_server_user(user.id, category.server_id) + .await?; + ctx.has(Permission::ManageServer, None) + }; + + if !has_permission { + return Err(HTTPError::Forbidden); + } + let active = category.into_active_model(); - // todo : voir pour virer le into_active_model pour utiliser le dto - let category: category::Model = serializer + let category: category::Model = payload .apply_to(active) .update(app_state.db.get_connection()) .await?; @@ -139,6 +186,7 @@ pub async fn category_update( path = "/api/category/{id}", responses( (status = 204, description = "Category deleted"), + (status = 403, description = "Forbidden"), (status = 404, description = "Category not found") ), params( @@ -147,8 +195,36 @@ pub async fn category_update( )] pub async fn category_delete( State(app_state): State, + user: CurrentUser, Path(id): Path, ) -> Result { + let category = category::Entity::find_by_id(id) + .one(app_state.db.get_connection()) + .await? + .ok_or_else(|| HTTPError::NotFound)?; + + let is_admin = app_state + .repositories + .permission + .is_server_admin(user.id, category.server_id) + .await?; + let is_superuser = user.is_superuser; + + let has_permission = if is_admin || is_superuser { + true + } else { + let ctx = app_state + .repositories + .permission + .load_for_server_user(user.id, category.server_id) + .await?; + ctx.has(Permission::ManageServer, None) + }; + + if !has_permission { + return Err(HTTPError::Forbidden); + } + let result = category::Entity::delete_by_id(id) .exec(app_state.db.get_connection()) .await?; diff --git a/src/network/http/web/api/channel.rs b/src/network/http/web/api/channel.rs index 720bf1c..2a619b2 100644 --- a/src/network/http/web/api/channel.rs +++ b/src/network/http/web/api/channel.rs @@ -1,10 +1,12 @@ use crate::app::AppState; use crate::interfaces::http::dto::channel::{ChannelResponse, CreateChannelRequest}; use crate::models::channel; +use crate::network::http::context::CurrentUser; use crate::network::http::{AppRouter, HTTPError}; +use crate::utils::permissions::Permission; use axum::extract::{Path, State}; use axum::http::StatusCode; -use axum::routing::{delete, get, post, put}; +use axum::routing::get; use axum::Json; use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel}; use uuid::Uuid; @@ -67,13 +69,39 @@ pub async fn channel_detail( path = "/api/channel", request_body = CreateChannelRequest, responses( - (status = 200, description = "Channel created", body = ChannelResponse) + (status = 200, description = "Channel created", body = ChannelResponse), + (status = 403, description = "Forbidden") ) )] pub async fn channel_create( State(app_state): State, + user: CurrentUser, Json(dto): Json, ) -> Result, HTTPError> { + if let Some(server_id) = dto.server_id { + let is_admin = app_state + .repositories + .permission + .is_server_admin(user.id, server_id) + .await?; + let is_superuser = user.is_superuser; + + let has_permission = if is_admin || is_superuser { + true + } else { + let ctx = app_state + .repositories + .permission + .load_for_server_user(user.id, server_id) + .await?; + ctx.has(Permission::ManageServer, None) + }; + + if !has_permission { + return Err(HTTPError::Forbidden); + } + } + let active: channel::ActiveModel = dto.into(); let channel: channel::Model = active.insert(app_state.db.get_connection()).await?; @@ -86,6 +114,7 @@ pub async fn channel_create( request_body = CreateChannelRequest, responses( (status = 200, description = "Channel updated", body = ChannelResponse), + (status = 403, description = "Forbidden"), (status = 404, description = "Channel not found") ), params( @@ -94,6 +123,7 @@ pub async fn channel_create( )] pub async fn channel_update( State(app_state): State, + user: CurrentUser, Path(id): Path, Json(dto): Json, ) -> Result, HTTPError> { @@ -102,6 +132,30 @@ pub async fn channel_update( .await? .ok_or(HTTPError::NotFound)?; + if let Some(server_id) = channel.server_id { + let is_admin = app_state + .repositories + .permission + .is_server_admin(user.id, server_id) + .await?; + let is_superuser = user.is_superuser; + + let has_permission = if is_admin || is_superuser { + true + } else { + let ctx = app_state + .repositories + .permission + .load_for_server_user(user.id, server_id) + .await?; + ctx.has(Permission::ManageChannel, Some(id)) || ctx.has(Permission::ManageServer, None) + }; + + if !has_permission { + return Err(HTTPError::Forbidden); + } + } + let active = channel.into_active_model(); let channel: channel::Model = dto @@ -117,6 +171,7 @@ pub async fn channel_update( path = "/api/channel/{id}", responses( (status = 204, description = "Channel deleted"), + (status = 403, description = "Forbidden"), (status = 404, description = "Channel not found") ), params( @@ -125,8 +180,38 @@ pub async fn channel_update( )] pub async fn channel_delete( State(app_state): State, + user: CurrentUser, Path(id): Path, ) -> Result { + let channel = channel::Entity::find_by_id(id) + .one(app_state.db.get_connection()) + .await? + .ok_or(HTTPError::NotFound)?; + + if let Some(server_id) = channel.server_id { + let is_admin = app_state + .repositories + .permission + .is_server_admin(user.id, server_id) + .await?; + let is_superuser = user.is_superuser; + + let has_permission = if is_admin || is_superuser { + true + } else { + let ctx = app_state + .repositories + .permission + .load_for_server_user(user.id, server_id) + .await?; + ctx.has(Permission::ManageChannel, Some(id)) || ctx.has(Permission::ManageServer, None) + }; + + if !has_permission { + return Err(HTTPError::Forbidden); + } + } + let result = channel::Entity::delete_by_id(id) .exec(app_state.db.get_connection()) .await?; diff --git a/src/network/http/web/api/group.rs b/src/network/http/web/api/group.rs new file mode 100644 index 0000000..4fdd1a3 --- /dev/null +++ b/src/network/http/web/api/group.rs @@ -0,0 +1,227 @@ +use crate::app::AppState; +use crate::interfaces::http::dto::group::{CreateGroupRequest, GroupResponse}; +use crate::network::http::context::CurrentUser; +use crate::network::http::{AppRouter, HTTPError}; +use crate::utils::permissions::Permission; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::get; +use axum::Json; +use sea_orm::IntoActiveModel; +use serde::Deserialize; +use uuid::Uuid; + +pub fn setup_route() -> AppRouter { + AppRouter::new() + .route("/", get(group_list).post(group_create)) + .route( + "/{id}", + get(group_detail).put(group_update).delete(group_delete), + ) +} + +#[derive(Deserialize)] +pub struct GroupListQuery { + pub server_id: Uuid, +} + +#[utoipa::path( + get, + path = "/api/group", + responses( + (status = 200, description = "List of groups", body = [GroupResponse]) + ), + params( + ("server_id" = Uuid, Query, description = "Server ID to list groups for") + ) +)] +pub async fn group_list( + State(state): State, + Query(query): Query, +) -> Result>, HTTPError> { + let groups = state + .repositories + .group + .get_all_by_server(query.server_id) + .await?; + + Ok(Json(groups.into_iter().map(GroupResponse::from).collect())) +} + +#[utoipa::path( + get, + path = "/api/group/{id}", + responses( + (status = 200, description = "Group details", body = GroupResponse), + (status = 404, description = "Group not found") + ), + params( + ("id" = Uuid, Path, description = "Group ID") + ) +)] +pub async fn group_detail( + State(state): State, + Path(id): Path, +) -> Result, HTTPError> { + let group = state + .repositories + .group + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + + Ok(Json(GroupResponse::from(group))) +} + +#[utoipa::path( + post, + path = "/api/group", + request_body = CreateGroupRequest, + responses( + (status = 200, description = "Group created", body = GroupResponse), + (status = 403, description = "Forbidden") + ) +)] +pub async fn group_create( + State(state): State, + user: CurrentUser, + Json(payload): Json, +) -> Result, HTTPError> { + let is_admin = state + .repositories + .permission + .is_server_admin(user.id, payload.server_id) + .await?; + let is_superuser = user.is_superuser; + + let has_permission = if is_admin || is_superuser { + true + } else { + let ctx = state + .repositories + .permission + .load_for_server_user(user.id, payload.server_id) + .await?; + ctx.has(Permission::ManageRoles, None) + }; + + if !has_permission { + return Err(HTTPError::Forbidden); + } + + let active = payload.into_active_model(); + let group = state.repositories.group.create(active).await?; + + Ok(Json(GroupResponse::from(group))) +} + +#[utoipa::path( + put, + path = "/api/group/{id}", + request_body = CreateGroupRequest, + responses( + (status = 200, description = "Group updated", body = GroupResponse), + (status = 403, description = "Forbidden"), + (status = 404, description = "Group not found") + ), + params( + ("id" = Uuid, Path, description = "Group ID") + ) +)] +pub async fn group_update( + State(state): State, + user: CurrentUser, + Path(id): Path, + Json(payload): Json, +) -> Result, HTTPError> { + let group = state + .repositories + .group + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + + let is_admin = state + .repositories + .permission + .is_server_admin(user.id, group.server_id) + .await?; + let is_superuser = user.is_superuser; + + let has_permission = if is_admin || is_superuser { + true + } else { + let ctx = state + .repositories + .permission + .load_for_server_user(user.id, group.server_id) + .await?; + ctx.has(Permission::ManageRoles, None) + }; + + if !has_permission { + return Err(HTTPError::Forbidden); + } + + let active = group.into_active_model(); + let group = state + .repositories + .group + .update(payload.apply_to(active)) + .await?; + + Ok(Json(GroupResponse::from(group))) +} + +#[utoipa::path( + delete, + path = "/api/group/{id}", + responses( + (status = 204, description = "Group deleted"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Group not found") + ), + params( + ("id" = Uuid, Path, description = "Group ID") + ) +)] +pub async fn group_delete( + State(state): State, + user: CurrentUser, + Path(id): Path, +) -> Result { + let group = state + .repositories + .group + .get_by_id(id) + .await? + .ok_or(HTTPError::NotFound)?; + + let is_admin = state + .repositories + .permission + .is_server_admin(user.id, group.server_id) + .await?; + let is_superuser = user.is_superuser; + + let has_permission = if is_admin || is_superuser { + true + } else { + let ctx = state + .repositories + .permission + .load_for_server_user(user.id, group.server_id) + .await?; + ctx.has(Permission::ManageRoles, None) + }; + + if !has_permission { + return Err(HTTPError::Forbidden); + } + + if state.repositories.group.delete(id).await? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(HTTPError::NotFound) + } +} diff --git a/src/network/http/web/api/message.rs b/src/network/http/web/api/message.rs index b282501..69c02e3 100644 --- a/src/network/http/web/api/message.rs +++ b/src/network/http/web/api/message.rs @@ -1,11 +1,13 @@ use crate::app::AppState; use crate::interfaces::http::dto::message::{CreateMessageRequest, MessageResponse}; use crate::models::message; -use crate::network::http::{AppRouter, HTTPError, RequestContext}; +use crate::network::http::context::CurrentUser; +use crate::network::http::{AppRouter, HTTPError}; +use crate::utils::permissions::Permission; use axum::extract::{Path, State}; use axum::http::StatusCode; -use axum::routing::{delete, get, post, put}; -use axum::{Extension, Json}; +use axum::routing::get; +use axum::Json; use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel}; use uuid::Uuid; @@ -67,7 +69,9 @@ pub async fn message_detail( path = "/api/message", request_body = CreateMessageRequest, responses( - (status = 200, description = "Message created", body = MessageResponse) + (status = 200, description = "Message created", body = MessageResponse), + (status = 403, description = "Forbidden"), + (status = 404, description = "Channel not found") ), security( ("jwt" = []) @@ -75,11 +79,35 @@ pub async fn message_detail( )] pub async fn message_create( State(app_state): State, - Extension(ctx): Extension, + user: CurrentUser, Json(dto): Json, ) -> Result, HTTPError> { - let author_id = ctx.user.map(|u| u.id).unwrap_or_else(Uuid::new_v4); - let active = dto.into_active_model(author_id); + let channel = crate::models::channel::Entity::find_by_id(dto.channel_id) + .one(app_state.db.get_connection()) + .await? + .ok_or(HTTPError::NotFound)?; + + if let Some(server_id) = channel.server_id { + let is_admin = app_state + .repositories + .permission + .is_server_admin(user.id, server_id) + .await?; + let is_superuser = user.is_superuser; + + if !is_admin && !is_superuser { + let ctx = app_state + .repositories + .permission + .load_for_server_user(user.id, server_id) + .await?; + if !ctx.has(Permission::SendMessage, Some(channel.id)) { + return Err(HTTPError::Forbidden); + } + } + } + + let active = dto.into_active_model(user.id); let message: message::Model = active.insert(app_state.db.get_connection()).await?; Ok(Json(MessageResponse::from(message))) @@ -91,6 +119,7 @@ pub async fn message_create( request_body = CreateMessageRequest, responses( (status = 200, description = "Message updated", body = MessageResponse), + (status = 403, description = "Forbidden"), (status = 404, description = "Message not found") ), params( @@ -99,6 +128,7 @@ pub async fn message_create( )] pub async fn message_update( State(app_state): State, + user: CurrentUser, Path(id): Path, Json(dto): Json, ) -> Result, HTTPError> { @@ -107,6 +137,10 @@ pub async fn message_update( .await? .ok_or(HTTPError::NotFound)?; + if message.user_id != user.id { + return Err(HTTPError::Forbidden); + } + let active = message.into_active_model(); let message: message::Model = dto .apply_to(active) @@ -121,6 +155,7 @@ pub async fn message_update( path = "/api/message/{id}", responses( (status = 204, description = "Message deleted"), + (status = 403, description = "Forbidden"), (status = 404, description = "Message not found") ), params( @@ -129,8 +164,44 @@ pub async fn message_update( )] pub async fn message_delete( State(app_state): State, + user: CurrentUser, Path(id): Path, ) -> Result { + let message = message::Entity::find_by_id(id) + .one(app_state.db.get_connection()) + .await? + .ok_or(HTTPError::NotFound)?; + + if message.user_id != user.id { + // Si ce n'est pas l'auteur, on regarde si c'est un modérateur + let channel = crate::models::channel::Entity::find_by_id(message.channel_id) + .one(app_state.db.get_connection()) + .await? + .ok_or(HTTPError::NotFound)?; + + if let Some(server_id) = channel.server_id { + let is_admin = app_state + .repositories + .permission + .is_server_admin(user.id, server_id) + .await?; + let is_superuser = user.is_superuser; + + if !is_admin && !is_superuser { + let ctx = app_state + .repositories + .permission + .load_for_server_user(user.id, server_id) + .await?; + if !ctx.has(Permission::DeleteOthersMessage, Some(channel.id)) { + return Err(HTTPError::Forbidden); + } + } + } else { + return Err(HTTPError::Forbidden); + } + } + let result = message::Entity::delete_by_id(id) .exec(app_state.db.get_connection()) .await?; diff --git a/src/network/http/web/api/mod.rs b/src/network/http/web/api/mod.rs index 9be0a2a..5358b97 100644 --- a/src/network/http/web/api/mod.rs +++ b/src/network/http/web/api/mod.rs @@ -3,6 +3,7 @@ use crate::network::http::AppRouter; mod auth; mod category; mod channel; +mod group; mod message; pub mod openapi; mod server; @@ -12,6 +13,7 @@ pub fn setup_route() -> AppRouter { AppRouter::new() .nest("/category", category::setup_route()) .nest("/channel", channel::setup_route()) + .nest("/group", group::setup_route()) .nest("/message", message::setup_route()) .nest("/server", server::setup_route()) .nest("/user", user::setup_route()) diff --git a/src/network/http/web/api/openapi.rs b/src/network/http/web/api/openapi.rs new file mode 100644 index 0000000..10e1140 --- /dev/null +++ b/src/network/http/web/api/openapi.rs @@ -0,0 +1,87 @@ +use crate::interfaces::http::dto::{ + auth as auth_dto, category as category_dto, channel as channel_dto, message as message_dto, + server as server_dto, user as user_dto, +}; +use crate::network::http::web::api::{auth, category, channel, message, server, user}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + auth::login, + auth::ssh_challenge, + auth::claim_admin, + category::category_list, + category::category_detail, + category::category_create, + category::category_update, + category::category_delete, + channel::channel_list, + channel::channel_detail, + channel::channel_create, + channel::channel_update, + channel::channel_delete, + message::message_list, + message::message_detail, + message::message_create, + message::message_update, + message::message_delete, + server::server_list, + server::server_detail, + server::server_create, + server::server_update, + server::server_delete, + server::server_password, + server::tree, + user::get_me, + user::user_list, + user::user_detail, + ), + components( + schemas( + auth::LoginRequest, + auth::LoginResponse, + auth::SshChallengeRequest, + auth::SshChallengeResponse, + auth::ClaimAdminRequest, + category_dto::CategoryResponse, + category_dto::CreateCategoryRequest, + category_dto::ListCategoryQuery, + category::CategoryQuery, + channel_dto::ChannelResponse, + channel_dto::CreateChannelRequest, + crate::models::channel::ChannelType, + message_dto::MessageResponse, + message_dto::CreateMessageRequest, + server_dto::ServerResponse, + server_dto::CreateServerRequest, + server_dto::ServerTreeResponse, + server_dto::TreeItemType, + user_dto::UserResponse, + user_dto::CreateUserRequest, + ) + ), + tags( + (name = "ox-speak", description = "Ox Speak API") + ), + modifiers(&SecurityAddon) +)] +pub struct ApiDoc; + +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "jwt", + utoipa::openapi::security::SecurityScheme::Http( + utoipa::openapi::security::HttpBuilder::new() + .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ) + } + } +} diff --git a/src/network/http/web/api/server.rs b/src/network/http/web/api/server.rs index 0601db0..0d7ca88 100644 --- a/src/network/http/web/api/server.rs +++ b/src/network/http/web/api/server.rs @@ -3,6 +3,7 @@ use crate::interfaces::http::dto::server::{ CreateServerRequest, ServerResponse, ServerTreeResponse, }; use crate::models::server; +use crate::network::http::context::CurrentUser; use crate::network::http::{AppRouter, HTTPError}; use axum::extract::{Path, State}; use axum::http::StatusCode; @@ -69,13 +70,18 @@ pub async fn server_detail( path = "/api/server", request_body = CreateServerRequest, responses( - (status = 200, description = "Server created", body = ServerResponse) + (status = 200, description = "Server created", body = ServerResponse), + (status = 403, description = "Forbidden") ) )] pub async fn server_create( State(state): State, + user: CurrentUser, Json(serializer): Json, ) -> Result, HTTPError> { + if !user.is_superuser { + return Err(HTTPError::Forbidden); + } let active: server::ActiveModel = serializer.into(); let server = state.repositories.server.create(active).await?; @@ -88,6 +94,7 @@ pub async fn server_create( request_body = CreateServerRequest, responses( (status = 200, description = "Server updated", body = ServerResponse), + (status = 403, description = "Forbidden"), (status = 404, description = "Server not found") ), params( @@ -96,9 +103,21 @@ pub async fn server_create( )] pub async fn server_update( State(state): State, + user: CurrentUser, Path(id): Path, Json(serializer): Json, ) -> Result, HTTPError> { + let is_admin = state + .repositories + .permission + .is_server_admin(user.id, id) + .await?; + let is_superuser = user.is_superuser; + + if !is_admin && !is_superuser { + return Err(HTTPError::Forbidden); + } + let am_server = state .repositories .server @@ -121,6 +140,7 @@ pub async fn server_update( path = "/api/server/{id}", responses( (status = 204, description = "Server deleted"), + (status = 403, description = "Forbidden"), (status = 404, description = "Server not found") ), params( @@ -129,8 +149,20 @@ pub async fn server_update( )] pub async fn server_delete( State(state): State, + user: CurrentUser, Path(id): Path, ) -> Result { + let is_admin = state + .repositories + .permission + .is_server_admin(user.id, id) + .await?; + let is_superuser = user.is_superuser; + + if !is_admin && !is_superuser { + return Err(HTTPError::Forbidden); + } + if state.repositories.server.delete(id).await? { Ok(StatusCode::NO_CONTENT) } else { diff --git a/src/network/http/web/api/user.rs b/src/network/http/web/api/user.rs index 70b5400..f443c9b 100644 --- a/src/network/http/web/api/user.rs +++ b/src/network/http/web/api/user.rs @@ -1,9 +1,10 @@ use crate::app::AppState; use crate::interfaces::http::dto::user::UserResponse; +use crate::network::http::context::CurrentUser; use crate::network::http::{AppRouter, HTTPError}; use axum::extract::{Path, State}; use axum::routing::get; -use axum::{Extension, Json}; +use axum::Json; use uuid::Uuid; pub fn setup_route() -> AppRouter { @@ -24,14 +25,11 @@ pub fn setup_route() -> AppRouter { ("jwt" = []) ) )] -pub async fn get_me( - Extension(ctx): Extension, -) -> Result, HTTPError> { - let user = ctx.user.ok_or(HTTPError::Unauthorized)?; +pub async fn get_me(user: CurrentUser) -> Result, HTTPError> { Ok(Json(UserResponse { id: user.id, - username: user.username, - pub_key: "".to_string(), // On peut laisser vide ou charger en DB si besoin + username: user.username.clone(), + pub_key: user.pub_key.clone(), })) } @@ -39,13 +37,17 @@ pub async fn get_me( get, path = "/api/user", responses( - (status = 200, description = "List of all users", body = [UserResponse]) + (status = 200, description = "List of all users", body = [UserResponse]), + (status = 403, description = "Forbidden") ) )] pub async fn user_list( State(app_state): State, - Extension(_ctx): Extension, + user: CurrentUser, ) -> Result>, HTTPError> { + if !user.is_superuser { + return Err(HTTPError::Forbidden); + } let users = app_state.repositories.user.get_all().await?; Ok(Json(users.into_iter().map(UserResponse::from).collect())) diff --git a/src/repositories/group.rs b/src/repositories/group.rs new file mode 100644 index 0000000..d602842 --- /dev/null +++ b/src/repositories/group.rs @@ -0,0 +1,43 @@ +use crate::models::group; +use crate::repositories::RepositoryContext; +use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter}; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone)] +pub struct GroupRepository { + pub context: Arc, +} + +impl GroupRepository { + pub async fn get_all_by_server(&self, server_id: Uuid) -> Result, DbErr> { + group::Entity::find() + .filter(group::Column::ServerId.eq(server_id)) + .all(&self.context.db) + .await + } + + pub async fn get_by_id(&self, id: Uuid) -> Result, DbErr> { + group::Entity::find_by_id(id).one(&self.context.db).await + } + + pub async fn create(&self, active: group::ActiveModel) -> Result { + let group = active.insert(&self.context.db).await?; + self.context.events.emit("group_created", group.clone()); + Ok(group) + } + + pub async fn update(&self, active: group::ActiveModel) -> Result { + let group = active.update(&self.context.db).await?; + self.context.events.emit("group_updated", group.clone()); + Ok(group) + } + + pub async fn delete(&self, id: Uuid) -> Result { + let res = group::Entity::delete_by_id(id) + .exec(&self.context.db) + .await?; + self.context.events.emit("group_deleted", id); + Ok(res.rows_affected > 0) + } +} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index aa6cf21..ec7e0ca 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -1,18 +1,22 @@ -use std::sync::Arc; -use sea_orm::DatabaseConnection; use crate::event_bus::EventBus; use crate::repositories::category::CategoryRepository; use crate::repositories::channel::ChannelRepository; +use crate::repositories::group::GroupRepository; use crate::repositories::message::MessageRepository; +use crate::repositories::permission::PermissionRepository; use crate::repositories::server::ServerRepository; use crate::repositories::user::UserRepository; +use sea_orm::DatabaseConnection; +use std::sync::Arc; -mod server; mod category; mod channel; +mod group; mod message; -mod user; +mod permission; +mod server; pub mod types; +mod user; #[derive(Clone)] pub struct RepositoryContext { @@ -25,20 +29,41 @@ pub struct Repositories { pub server: ServerRepository, pub category: CategoryRepository, pub channel: ChannelRepository, + pub group: GroupRepository, pub message: MessageRepository, pub user: UserRepository, + pub permission: PermissionRepository, } impl Repositories { pub fn new(db: &DatabaseConnection, events: EventBus) -> Self { - let context = Arc::new(RepositoryContext { db: db.clone(), events }); + let context = Arc::new(RepositoryContext { + db: db.clone(), + events, + }); Self { - server: ServerRepository {context: context.clone()}, - category: CategoryRepository {context: context.clone()}, - channel: ChannelRepository {context: context.clone()}, - message: MessageRepository {context: context.clone()}, - user: UserRepository {context: context.clone()}, + server: ServerRepository { + context: context.clone(), + }, + category: CategoryRepository { + context: context.clone(), + }, + channel: ChannelRepository { + context: context.clone(), + }, + group: GroupRepository { + context: context.clone(), + }, + message: MessageRepository { + context: context.clone(), + }, + user: UserRepository { + context: context.clone(), + }, + permission: PermissionRepository { + context: context.clone(), + }, } } -} \ No newline at end of file +} diff --git a/src/repositories/permission.rs b/src/repositories/permission.rs new file mode 100644 index 0000000..8f67106 --- /dev/null +++ b/src/repositories/permission.rs @@ -0,0 +1,82 @@ +use crate::models::{channel_user, group, group_member, server_user}; +use crate::repositories::RepositoryContext; +use crate::utils::permissions::PermissionContext; +use sea_orm::{ColumnTrait, DbErr, EntityTrait, QueryFilter, QuerySelect}; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone)] +pub struct PermissionRepository { + pub context: Arc, +} + +impl PermissionRepository { + pub async fn is_server_admin(&self, user_id: Uuid, server_id: Uuid) -> Result { + let res = server_user::Entity::find() + .filter(server_user::Column::UserId.eq(user_id)) + .filter(server_user::Column::ServerId.eq(server_id)) + .one(&self.context.db) + .await?; + + Ok(res.map(|su| su.is_admin).unwrap_or(false)) + } + + pub async fn load_for_user(&self, user_id: Uuid) -> Result { + // Requête 1 : JOIN group_member → group filtre group_member.user_id = user_id + // collecter les group.permissions: i64 dans un Vec + let group_masks: Vec = group::Entity::find() + .inner_join(group_member::Entity) + .filter(group_member::Column::UserId.eq(user_id)) + .select_only() + .column(group::Column::Permissions) + .into_tuple() + .all(&self.context.db) + .await?; + + // Requête 2 : SELECT channel_id, permissions FROM channel_user WHERE user_id = user_id + // collecter en Vec<(Uuid, i64)> + let channel_permissions: Vec<(Uuid, i64)> = channel_user::Entity::find() + .filter(channel_user::Column::UserId.eq(user_id)) + .select_only() + .column(channel_user::Column::ChannelId) + .column(channel_user::Column::Permissions) + .into_tuple() + .all(&self.context.db) + .await?; + + Ok(PermissionContext::new(&group_masks, channel_permissions)) + } + + pub async fn load_for_server_user( + &self, + user_id: Uuid, + server_id: Uuid, + ) -> Result { + // Requête 1 : JOIN group_member → group filtre group_member.user_id = user_id ET group.server_id = server_id + let group_masks: Vec = group::Entity::find() + .inner_join(group_member::Entity) + .filter(group_member::Column::UserId.eq(user_id)) + .filter(group::Column::ServerId.eq(server_id)) + .select_only() + .column(group::Column::Permissions) + .into_tuple() + .all(&self.context.db) + .await?; + + // Requête 2 : SELECT channel_id, permissions FROM channel_user + // JOIN channel ON channel_user.channel_id = channel.id + // WHERE user_id = user_id AND channel.server_id = server_id + let channel_permissions: Vec<(Uuid, i64)> = channel_user::Entity::find() + .inner_join(crate::models::channel::Entity) + .filter(channel_user::Column::UserId.eq(user_id)) + .filter(crate::models::channel::Column::ServerId.eq(server_id)) + .select_only() + .column(channel_user::Column::ChannelId) + .column(channel_user::Column::Permissions) + .into_tuple() + .all(&self.context.db) + .await?; + + Ok(PermissionContext::new(&group_masks, channel_permissions)) + } +} diff --git a/src/repositories/server.rs b/src/repositories/server.rs index ef1d2c8..c3e465b 100644 --- a/src/repositories/server.rs +++ b/src/repositories/server.rs @@ -1,20 +1,21 @@ -use std::sync::Arc; -use sea_orm::{DbErr, EntityTrait, ActiveModelTrait, QueryFilter, ColumnTrait, QueryOrder}; -use uuid::Uuid; -use crate::models::{category, channel, server}; -use super::RepositoryContext; use super::types::{ServerExplorerItem, ServerTree}; +use super::RepositoryContext; +use crate::models::{category, channel, group, server}; +use crate::utils::permissions::Permission; +use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set}; +use std::sync::Arc; +use uuid::Uuid; #[derive(Clone)] pub struct ServerRepository { - pub context: Arc + pub context: Arc, } impl ServerRepository { pub async fn get_all(&self) -> Result, DbErr> { server::Entity::find().all(&self.context.db).await } - + pub async fn get_by_id(&self, id: uuid::Uuid) -> Result, DbErr> { server::Entity::find_by_id(id).one(&self.context.db).await } @@ -27,12 +28,25 @@ impl ServerRepository { pub async fn create(&self, active: server::ActiveModel) -> Result { let server = active.insert(&self.context.db).await?; + + // Créer le groupe par défaut pour le serveur + let default_group = group::ActiveModel { + server_id: Set(server.id), + name: Set("Membres".to_string()), + permissions: Set(Permission::default_permissions() as i64), + is_default: Set(true), + ..Default::default() + }; + default_group.insert(&self.context.db).await?; + self.context.events.emit("server_created", server.clone()); Ok(server) } pub async fn delete(&self, id: uuid::Uuid) -> Result { - let res = server::Entity::delete_by_id(id).exec(&self.context.db).await?; + let res = server::Entity::delete_by_id(id) + .exec(&self.context.db) + .await?; self.context.events.emit("server_deleted", id); Ok(res.rows_affected > 0) } @@ -61,7 +75,9 @@ impl ServerRepository { for (cat, mut channels) in categories_with_channels { // On trie les channels internes (obligatoire car SQL ne garantit aucun ordre ici) channels.sort_by(|a, b| { - a.position.cmp(&b.position).then(a.created_at.cmp(&b.created_at)) + a.position + .cmp(&b.position) + .then(a.created_at.cmp(&b.created_at)) }); items.push(ServerExplorerItem::Category(cat, channels)); } @@ -92,4 +108,4 @@ impl ServerRepository { Ok(ServerTree { items }) } -} \ No newline at end of file +} diff --git a/src/repositories/user.rs b/src/repositories/user.rs index e53c686..0dafe5b 100644 --- a/src/repositories/user.rs +++ b/src/repositories/user.rs @@ -2,7 +2,8 @@ use crate::models::user; use crate::repositories::RepositoryContext; use crate::utils::password; use sea_orm::{ - ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, IntoActiveModel, QueryFilter, Set, + ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, IntoActiveModel, PaginatorTrait, + QueryFilter, Set, }; use std::sync::Arc; @@ -16,6 +17,10 @@ impl UserRepository { user::Entity::find().all(&self.context.db).await } + pub async fn count(&self) -> Result { + user::Entity::find().count(&self.context.db).await + } + pub async fn get_by_id(&self, id: uuid::Uuid) -> Result, DbErr> { user::Entity::find_by_id(id).one(&self.context.db).await } @@ -50,7 +55,7 @@ impl UserRepository { Ok(user) } - pub async fn create(&self, mut active: user::ActiveModel) -> Result { + pub async fn create(&self, active: user::ActiveModel) -> Result { let user = active.insert(&self.context.db).await?; self.context.events.emit("user_created", user.clone()); Ok(user) diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 3fd5a8d..01d1157 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod auth; pub mod password; +pub mod permissions; pub mod ssh_auth; pub mod toolbox; diff --git a/src/utils/permissions.rs b/src/utils/permissions.rs new file mode 100644 index 0000000..ae4a3e3 --- /dev/null +++ b/src/utils/permissions.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; +use uuid::Uuid; + +/// # Système de Permissions par Bitmask +/// +/// Un bitmask (masque de bits) est une manière compacte et efficace de stocker plusieurs +/// valeurs booléennes dans un seul nombre entier. +/// +/// ## Comment ça fonctionne ? +/// Chaque permission est associée à une puissance de 2 (1, 2, 4, 8, 16, etc.), ce qui +/// correspond à un bit unique dans la représentation binaire du nombre. +/// +/// - `ReadChannel` (1 << 0) = `00000001` (1 en décimal) +/// - `JoinChannel` (1 << 1) = `00000010` (2 en décimal) +/// - `SendMessage` (1 << 2) = `00000100` (4 en décimal) +/// +/// ## Opérations courantes : +/// +/// ### 1. Combiner des permissions (OU binaire `|`) +/// Pour donner à la fois `ReadChannel` et `SendMessage` : +/// `let mask = Permission::ReadChannel as u64 | Permission::SendMessage as u64;` +/// Résultat : `00000101` (5 en décimal) +/// +/// ### 2. Vérifier une permission (ET binaire `&`) +/// Pour savoir si un utilisateur a `SendMessage` dans son `mask` : +/// `(mask & Permission::SendMessage as u64) != 0` +/// +/// ### 3. Retirer une permission (NON `!`, ET `&`) +/// `mask &= !(Permission::SendMessage as u64);` +/// +#[derive(Debug, Clone, Copy)] +#[repr(u64)] +pub enum Permission { + /// Pouvoir voir le canal dans la liste et lire les messages + ReadChannel = 1 << 0, + /// Pouvoir rejoindre le canal vocal + JoinChannel = 1 << 1, + /// Pouvoir envoyer des messages + SendMessage = 1 << 2, + /// Pouvoir supprimer ses propres messages + DeleteMessage = 1 << 3, + /// Pouvoir supprimer les messages des autres (Modérateur) + DeleteOthersMessage = 1 << 4, + /// Pouvoir modifier les paramètres du canal + ManageChannel = 1 << 5, + /// Pouvoir gérer les groupes/permissions du serveur + ManageRoles = 1 << 6, + /// Pouvoir expulser des membres du serveur + KickMember = 1 << 7, + /// Pouvoir parler en vocal + VoiceSpeak = 1 << 8, + /// Pouvoir rendre muet les autres utilisateurs en vocal + VoiceMuteOthers = 1 << 9, + /// Pouvoir modifier les paramètres globaux du serveur + ManageServer = 1 << 10, +} + +impl Permission { + /// Retourne un bitmask contenant toutes les permissions "standard" + /// (Tout sauf les permissions de gestion "Manage..."). + pub fn default_permissions() -> u64 { + Permission::ReadChannel as u64 + | Permission::JoinChannel as u64 + | Permission::SendMessage as u64 + | Permission::DeleteMessage as u64 + | Permission::DeleteOthersMessage as u64 + | Permission::KickMember as u64 + | Permission::VoiceSpeak as u64 + | Permission::VoiceMuteOthers as u64 + } +} + +/// Contexte de permissions calculé pour un utilisateur donné. +/// +/// Ce contexte regroupe les permissions globales (issues de la fusion de tous les groupes +/// de l'utilisateur) et les surcharges spécifiques par canal (overrides). +#[derive(Debug, Clone)] +pub struct PermissionContext { + /// Union des masques de tous les groupes auxquels appartient l'utilisateur. + pub global: u64, + /// Masques spécifiques par canal (si présents, ils remplacent totalement le masque global + /// pour ce canal spécifique). + pub channel_overrides: HashMap, +} + +impl PermissionContext { + /// Crée un nouveau contexte à partir des masques bruts de la base de données. + /// + /// - `group_masks` : Liste des permissions de chaque groupe de l'utilisateur. + /// - `channel_permissions` : Liste de (ID Canal, Masque) pour les permissions spécifiques. + pub fn new(group_masks: &[i64], channel_permissions: Vec<(Uuid, i64)>) -> Self { + // On combine tous les groupes avec l'opérateur OR (|) + let global = group_masks.iter().fold(0u64, |acc, &m| acc | m as u64); + + let channel_overrides = channel_permissions + .into_iter() + .map(|(id, m)| (id, m as u64)) + .collect(); + + Self { + global, + channel_overrides, + } + } + + /// Vérifie si une permission spécifique est accordée, optionnellement pour un canal précis. + /// + /// Si `channel_id` est fourni et qu'une surcharge existe pour ce canal, la surcharge + /// est utilisée. Sinon, on utilise les permissions globales du serveur. + /// + /// ### Exemple d'utilisation : + /// ```rust + /// if ctx.has(Permission::SendMessage, Some(channel_id)) { + /// // Autorisé ! + /// } + /// ``` + #[inline] + pub fn has(&self, permission: Permission, channel_id: Option) -> bool { + let mask = if let Some(cid) = channel_id { + self.channel_overrides + .get(&cid) + .copied() + .unwrap_or(self.global) + } else { + self.global + }; + + mask & (permission as u64) != 0 + } + + /// Méthode utilitaire pour vérifier rapidement l'accès en lecture à un canal. + pub fn can_see_channel(&self, channel_id: Uuid) -> bool { + self.has(Permission::ReadChannel, Some(channel_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_permission_context() { + let channel_id = Uuid::new_v4(); + let other_channel_id = Uuid::new_v4(); + + // On imagine un utilisateur avec 2 groupes + let role_masks = vec![ + Permission::ReadChannel as i64 | Permission::SendMessage as i64, // Groupe 1 : lecture + envoi + Permission::VoiceSpeak as i64, // Groupe 2 : vocal + ]; + + // Et une permission spécifique sur un canal (Lecture + Envoi + Gestion) + let channel_permissions = vec![( + channel_id, + Permission::ReadChannel as i64 + | Permission::SendMessage as i64 + | Permission::ManageChannel as i64, + )]; + + let ctx = PermissionContext::new(&role_masks, channel_permissions); + + // Test des permissions globales + assert!(ctx.has(Permission::ReadChannel, None)); + assert!(ctx.has(Permission::SendMessage, None)); + assert!(ctx.has(Permission::VoiceSpeak, None)); + assert!(!ctx.has(Permission::ManageChannel, None)); + + // Test des surcharges par canal (L'override remplace le masque global) + assert!(ctx.has(Permission::ReadChannel, Some(channel_id))); + assert!(ctx.has(Permission::SendMessage, Some(channel_id))); + assert!(ctx.has(Permission::ManageChannel, Some(channel_id))); + assert!(!ctx.has(Permission::VoiceSpeak, Some(channel_id))); // Ici on perd le vocal car l'override remplace tout + + // Test d'un autre canal (pas de surcharge, on retombe sur le global) + assert!(ctx.has(Permission::ReadChannel, Some(other_channel_id))); + assert!(ctx.has(Permission::VoiceSpeak, Some(other_channel_id))); + assert!(!ctx.has(Permission::ManageChannel, Some(other_channel_id))); + + // Test des fonctions sucre + assert!(ctx.can_see_channel(channel_id)); + assert!(ctx.can_see_channel(other_channel_id)); + } +}