diff --git a/src/http/context.rs b/src/http/context.rs index 6d47082..8dc3779 100644 --- a/src/http/context.rs +++ b/src/http/context.rs @@ -23,9 +23,10 @@ pub struct RequestContext { /// /// **Usage :** /// ```rust -/// pub async fn ma_vue(user: CurrentUser) { -/// if user.is_superuser { ... } -/// } +/// use oxspeak_server_lib::http::context::CurrentUser; +/// # fn check(user: CurrentUser) { +/// # let _ = user.is_superuser; +/// # } /// ``` #[derive(Clone, Debug)] pub struct CurrentUser(pub user::Model); @@ -71,6 +72,7 @@ where /// /// **Usage :** /// ```rust +/// use oxspeak_server_lib::http::context::Superuser; /// pub async fn suppression_globale(admin: Superuser) { /// // Ici, nous sommes certains que admin.is_superuser est true. /// } diff --git a/src/http/error.rs b/src/http/error.rs index 29308c6..c167ac4 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -79,9 +79,7 @@ impl IntoResponse for HTTPError { .into_response(); } HTTPError::Internal(err) => { - // On utilise %err pour un message d'erreur clair sans backtrace brute - // mais on garde les détails pour le span tracing si besoin. - tracing::error!(%err, "Request error"); + tracing::error!(error = %format_args!("{err:#}"), "Request error"); (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") } }; diff --git a/src/http/mod.rs b/src/http/mod.rs index 64eaacb..edf2c10 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -8,6 +8,8 @@ pub mod middleware; pub mod permissions; pub mod server; mod tls; +#[cfg(test)] +pub(crate) mod test_support; pub mod validation; pub use permissions::{RequireChannelPermission, RequireServerPermission}; diff --git a/src/http/permissions.rs b/src/http/permissions.rs index 2701196..2b626fa 100644 --- a/src/http/permissions.rs +++ b/src/http/permissions.rs @@ -1,11 +1,11 @@ -// Unused - use super::context::CurrentUser; use super::error::HTTPError; use crate::core::AppState; use crate::permissions::{ChannelPermission, ServerPermission}; -use axum::extract::FromRequestParts; +use axum::extract::{FromRequestParts, RawPathParams}; use axum::http::request::Parts; +use crate::models::{channel_user, role, role_user, server_user, server_role_permission, channel_role_permission}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use std::ops::Deref; use uuid::Uuid; @@ -15,23 +15,21 @@ use uuid::Uuid; /// The target `server_id` is automatically extracted from path parameters (supporting /// path parameters named `server_id` or `id`). /// -/// # Superuser Bypass -/// If the user is a superuser (`is_superuser == true`), the permission check automatically passes. -/// /// # Usage Example /// ```rust -/// use axum::extract::State; +/// use axum::extract::{Path, State}; /// use uuid::Uuid; -/// use crate::http::permissions::RequireServerPermission; -/// use crate::permissions::ServerPermission; -/// use crate::core::AppState; +/// use oxspeak_server_lib::http::permissions::RequireServerPermission; +/// use oxspeak_server_lib::http::error::HTTPError; +/// use oxspeak_server_lib::permissions::ServerPermission; +/// use oxspeak_server_lib::core::AppState; /// /// pub async fn update_server_settings( -/// RequireServerPermission::<{ ServerPermission::MANAGE_SERVER.bits() }>(user): RequireServerPermission<{ ServerPermission::MANAGE_SERVER.bits() }>, +/// RequireServerPermission::<{ ServerPermission::MANAGE_SERVER.bits() }>(_user): RequireServerPermission<{ ServerPermission::MANAGE_SERVER.bits() }>, /// State(state): State, -/// Path(server_id): Path, +/// Path(_server_id): Path, /// ) -> Result<(), HTTPError> { -/// // User has MANAGE_SERVER or is a superuser +/// // User has MANAGE_SERVER /// Ok(()) /// } /// ``` @@ -46,30 +44,15 @@ impl Deref for RequireServerPermission { } } -impl FromRequestParts for RequireServerPermission -where - S: Send + Sync, -{ +impl FromRequestParts for RequireServerPermission { type Rejection = HTTPError; - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { // 1. Extract CurrentUser (which validates authentication and returns 401 if missing) let current_user = CurrentUser::from_request_parts(parts, state).await?; - // 2. Superuser bypasses all checks - if current_user.is_superuser { - return Ok(RequireServerPermission(current_user)); - } // 3. Get AppState from extensions - let app_state = match parts.extensions.get::() { - Some(s) => s.clone(), - None => { - return Err(HTTPError::InternalServerError( - "AppState missing in request extensions".to_string(), - )); - } - }; // 4. Extract server_id from path parameters. let server_id = match extract_path_param_uuid(parts, &["server_id", "id"]) { @@ -82,22 +65,7 @@ where }; // 5. Check user permission via server repository - let permission_result = app_state - .repositories - .server - .get_user_permission(server_id, current_user.id) - .await; - - let permission_bits = match permission_result { - Ok(Some(p)) => p.permissions, - Ok(None) => 0, - Err(e) => return Err(HTTPError::InternalServerError(e.to_string())), - }; - - let required = ServerPermission::from_bits_truncate(PERM); - let granted = ServerPermission::from_bits_truncate(permission_bits as u64); - - if granted.contains(required) { + if check_server_permission(state, current_user.id, server_id, ServerPermission::from_bits_truncate(PERM)).await? { Ok(RequireServerPermission(current_user)) } else { Err(HTTPError::Forbidden) @@ -110,23 +78,21 @@ where /// /// The target `channel_id` (or `id`) is automatically extracted from path parameters. /// -/// # Superuser Bypass -/// If the user is a superuser (`is_superuser == true`), the permission check automatically passes. -/// /// # Usage Example /// ```rust -/// use axum::extract::State; +/// use axum::extract::{Path, State}; /// use uuid::Uuid; -/// use crate::http::permissions::RequireChannelPermission; -/// use crate::permissions::ChannelPermission; -/// use crate::core::AppState; +/// use oxspeak_server_lib::http::permissions::RequireChannelPermission; +/// use oxspeak_server_lib::http::error::HTTPError; +/// use oxspeak_server_lib::permissions::ChannelPermission; +/// use oxspeak_server_lib::core::AppState; /// /// pub async fn read_channel_messages( -/// RequireChannelPermission::<{ ChannelPermission::READ_CHANNEL.bits() }>(user): RequireChannelPermission<{ ChannelPermission::READ_CHANNEL.bits() }>, +/// RequireChannelPermission::<{ ChannelPermission::READ_CHANNEL.bits() }>(_user): RequireChannelPermission<{ ChannelPermission::READ_CHANNEL.bits() }>, /// State(state): State, -/// Path(channel_id): Path, +/// Path(_channel_id): Path, /// ) -> Result<(), HTTPError> { -/// // User has READ_CHANNEL or is a superuser +/// // User has READ_CHANNEL /// Ok(()) /// } /// ``` @@ -141,27 +107,13 @@ impl Deref for RequireChannelPermission { } } -impl FromRequestParts for RequireChannelPermission -where - S: Send + Sync, -{ +impl FromRequestParts for RequireChannelPermission { type Rejection = HTTPError; - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { let current_user = CurrentUser::from_request_parts(parts, state).await?; - if current_user.is_superuser { - return Ok(RequireChannelPermission(current_user)); - } - let app_state = match parts.extensions.get::() { - Some(s) => s.clone(), - None => { - return Err(HTTPError::InternalServerError( - "AppState missing in request extensions".to_string(), - )); - } - }; let channel_id = match extract_path_param_uuid(parts, &["channel_id", "id"]) { Some(id) => id, @@ -172,22 +124,7 @@ where } }; - let permission_result = app_state - .repositories - .channel - .get_user_permission(channel_id, current_user.id) - .await; - - let permission_bits = match permission_result { - Ok(Some(p)) => p.permissions, - Ok(None) => 0, - Err(e) => return Err(HTTPError::InternalServerError(e.to_string())), - }; - - let required = ChannelPermission::from_bits_truncate(PERM); - let granted = ChannelPermission::from_bits_truncate(permission_bits as u64); - - if granted.contains(required) { + if check_channel_permission(state, current_user.id, channel_id, ChannelPermission::from_bits_truncate(PERM)).await? { Ok(RequireChannelPermission(current_user)) } else { Err(HTTPError::Forbidden) @@ -198,6 +135,13 @@ where /// Helper function to extract a Uuid path parameter matching any of the given key names /// from Axum request extensions. fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option { + if let Some(params) = parts.extensions.get::() { + for (key, value) in params.iter() { + if keys.contains(&key) { + if let Ok(id) = Uuid::parse_str(value) { return Some(id); } + } + } + } if let Some(map) = parts .extensions .get::>() @@ -223,3 +167,52 @@ fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option { None } + +pub async fn check_server_permission(state: &AppState, user_id: Uuid, server_id: Uuid, required: ServerPermission) -> Result { + let member = server_user::Entity::find() + .filter(server_user::Column::ServerId.eq(server_id)) + .filter(server_user::Column::UserId.eq(user_id)) + .one(&state.db).await?; + if member.is_none() { return Ok(false); } + let mut bits = state.repositories.server.get_user_permission(server_id, user_id).await + .map_err(|e| HTTPError::InternalServerError(e.to_string()))? + .map_or(0, |p| p.permissions as u64); + let roles = role_user::Entity::find().filter(role_user::Column::UserId.eq(user_id)).all(&state.db).await?; + for assignment in roles { + if role::Entity::find_by_id(assignment.role_id).one(&state.db).await?.is_some_and(|r| r.server_id == server_id) { + if let Some(p) = server_role_permission::Entity::find() + .filter(server_role_permission::Column::ServerId.eq(server_id)) + .filter(server_role_permission::Column::RoleId.eq(assignment.role_id)) + .one(&state.db).await? { bits |= p.permissions as u64; } + } + } + Ok(ServerPermission::from_bits_truncate(bits).contains(required)) +} + +pub async fn check_channel_permission(state: &AppState, user_id: Uuid, channel_id: Uuid, required: ChannelPermission) -> Result { + let channel = state.repositories.channel.get_by_id(channel_id).await + .map_err(|e| HTTPError::InternalServerError(e.to_string()))?; + let Some(channel) = channel else { return Ok(false) }; + if let Some(server_id) = channel.server_id { + if !check_server_permission(state, user_id, server_id, ServerPermission::empty()).await? { return Ok(false); } + } else if channel_user::Entity::find() + .filter(channel_user::Column::ChannelId.eq(channel_id)) + .filter(channel_user::Column::UserId.eq(user_id)) + .one(&state.db).await?.is_none() { return Ok(false); } + let mut bits = if channel.server_id.is_none() { crate::permissions::PermissionSet::DEFAULT.channel.bits() } else { 0 }; + bits |= state.repositories.channel.get_user_permission(channel_id, user_id).await + .map_err(|e| HTTPError::InternalServerError(e.to_string()))? + .map_or(0, |p| p.permissions as u64); + if let Some(server_id) = channel.server_id { + let roles = role_user::Entity::find().filter(role_user::Column::UserId.eq(user_id)).all(&state.db).await?; + for assignment in roles { + if role::Entity::find_by_id(assignment.role_id).one(&state.db).await?.is_some_and(|r| r.server_id == server_id) { + if let Some(p) = channel_role_permission::Entity::find() + .filter(channel_role_permission::Column::ChannelId.eq(channel_id)) + .filter(channel_role_permission::Column::RoleId.eq(assignment.role_id)) + .one(&state.db).await? { bits |= p.permissions as u64; } + } + } + } + Ok(ChannelPermission::from_bits_truncate(bits).contains(required)) +} diff --git a/src/http/test_support.rs b/src/http/test_support.rs new file mode 100644 index 0000000..6a35960 --- /dev/null +++ b/src/http/test_support.rs @@ -0,0 +1,48 @@ +use crate::config::{AppConfig, DEFAULT_CONFIG_TOML}; +use crate::core::{App, AppState}; +use crate::http::context::{CurrentUser, RequestContext}; +use crate::models::user; +use axum::body::Body; +use axum::http::{Method, Request}; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, Set}; +use std::time::Instant; +use uuid::Uuid; + +pub async fn state() -> AppState { + let path = format!("{}/target/permissions-{}.db", env!("CARGO_MANIFEST_DIR"), Uuid::new_v4()); + let config: AppConfig = toml::from_str(&DEFAULT_CONFIG_TOML.replace( + "sqlite://oxspeak.db", + &format!("sqlite://{path}"), + )) + .unwrap(); + App::build(config).await.unwrap().state +} + +pub async fn user(state: &AppState, admin: bool) -> user::Model { + user::ActiveModel { + username: Set(format!("test-{}", Uuid::new_v4())), + password: Set("unused".into()), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + is_superuser: Set(admin), + ..Default::default() + } + .insert(&state.db) + .await + .unwrap() +} + +pub fn request(method: Method, uri: &str, body: Body, user: Option) -> Request { + let mut request = Request::builder().method(method).uri(uri).body(body).unwrap(); + let method = request.method().clone(); + let uri = request.uri().clone(); + request.extensions_mut().insert(RequestContext { + request_id: Uuid::new_v4(), + started_at: Instant::now(), + method, + uri, + user: user.map(CurrentUser), + }); + request +} \ No newline at end of file diff --git a/src/routes/attachment/handlers.rs b/src/routes/attachment/handlers.rs index bd01a68..dc886f2 100644 --- a/src/routes/attachment/handlers.rs +++ b/src/routes/attachment/handlers.rs @@ -3,8 +3,9 @@ use crate::domain::dto::attachment::AttachmentUploadResponse; use crate::http::context::CurrentUser; use crate::http::error::HTTPError; use crate::models::attachment; +use crate::permissions::ChannelPermission; use crate::routes::attachment::mapper; -use crate::routes::message::handlers::can_access; +use crate::routes::message::handlers::require_channel_permission; use crate::services::media::{self, PendingMediaFile}; use axum::body::Body; use axum::extract::{Multipart, Path, State}; @@ -75,9 +76,14 @@ pub async fn create( .content_type() .unwrap_or("application/octet-stream") .to_string(); - if !can_access(&state, channel, user.id).await? { - return Err(HTTPError::Forbidden); - } + require_channel_permission( + &state, + channel, + user.id, + user.is_superuser, + ChannelPermission::ATTACH_FILES, + ) + .await?; let id = Uuid::new_v4(); let mut output = PendingMediaFile::begin( PathBuf::from(&state.config.media.root).as_path(), @@ -128,9 +134,14 @@ pub async fn create( } let channel_id = channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?; - if !can_access(&state, channel_id, user.id).await? { - return Err(HTTPError::Forbidden); - } + require_channel_permission( + &state, + channel_id, + user.id, + user.is_superuser, + ChannelPermission::ATTACH_FILES, + ) + .await?; if created.is_empty() { return Err(HTTPError::BadRequest( "at least one file is required".into(), @@ -145,6 +156,7 @@ pub async fn create( } pub async fn file( + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result { @@ -152,6 +164,14 @@ pub async fn file( .one(&state.db) .await? .ok_or(HTTPError::NotFound)?; + require_channel_permission( + &state, + item.channel_id, + user.id, + user.is_superuser, + ChannelPermission::READ_CHANNEL, + ) + .await?; let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(&item.file_path)) .await .map_err(|_| HTTPError::NotFound)?; diff --git a/src/routes/attachment/routes.rs b/src/routes/attachment/routes.rs index 2233c3d..93d329e 100644 --- a/src/routes/attachment/routes.rs +++ b/src/routes/attachment/routes.rs @@ -15,3 +15,97 @@ pub fn secure_router() -> Router { pub fn public_router() -> Router { Router::new().route("/attachments/{id}/file", get(handlers::file)) } + +#[cfg(test)] +mod permission_tests { + use super::*; + use crate::models::attachment; + use crate::permissions::ChannelPermission; + use crate::routes::message::routes::permission_tests::Fixture; + use axum::{ + body::Body, + http::{Method, StatusCode}, + }; + use sea_orm::{ActiveModelTrait, Set}; + use uuid::Uuid; + + #[tokio::test] + async fn upload_requires_attach_files() { + let fixture = Fixture::new().await; + // Le contenu multipart est rejoué avant et après l'octroi du droit. + let boundary = "attachment-permission-test"; + let body = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"channel_id\"\r\n\r\n{}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\nContent-Type: text/plain\r\n\r\nhello\r\n--{boundary}--\r\n", + fixture.channel_id + ); + let mime = format!("multipart/form-data; boundary={boundary}"); + assert_eq!( + fixture + .request( + secure_router(), + Method::POST, + "/attachments", + Body::from(body.clone()), + Some(&mime) + ) + .await, + StatusCode::FORBIDDEN + ); + fixture.grant(ChannelPermission::ATTACH_FILES).await; + assert_eq!( + fixture + .request( + secure_router(), + Method::POST, + "/attachments", + Body::from(body), + Some(&mime) + ) + .await, + StatusCode::CREATED + ); + std::fs::remove_dir_all(&fixture.state.config.media.root).unwrap(); + } + + #[tokio::test] + async fn download_requires_read_channel() { + let fixture = Fixture::new().await; + // Associe un fichier à un canal pour vérifier que son téléchargement suit READ_CHANNEL. + let id = Uuid::new_v4(); + let path = format!("attachments/{id}.txt"); + let full_path = std::path::Path::new(&fixture.state.config.media.root).join(&path); + tokio::fs::create_dir_all(full_path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&full_path, b"hello").await.unwrap(); + attachment::ActiveModel { + id: Set(id), + message_id: Set(None), + channel_id: Set(fixture.channel_id), + user_id: Set(fixture.user.id), + filename: Set("hello.txt".into()), + file_size: Set(5), + mime_type: Set("text/plain".into()), + file_path: Set(path), + created_at: Set(chrono::Utc::now()), + } + .insert(&fixture.state.db) + .await + .unwrap(); + let uri = format!("/attachments/{id}/file"); + assert_eq!( + fixture + .request(public_router(), Method::GET, &uri, Body::empty(), None) + .await, + StatusCode::FORBIDDEN + ); + fixture.grant(ChannelPermission::READ_CHANNEL).await; + assert_eq!( + fixture + .request(public_router(), Method::GET, &uri, Body::empty(), None) + .await, + StatusCode::OK + ); + std::fs::remove_dir_all(&fixture.state.config.media.root).unwrap(); + } +} diff --git a/src/routes/auth/routes.rs b/src/routes/auth/routes.rs index c84f9d0..0587fd7 100644 --- a/src/routes/auth/routes.rs +++ b/src/routes/auth/routes.rs @@ -9,3 +9,25 @@ pub fn router() -> OxRouter { .route("/auth/bearer-login", post(handlers::login_bearer)) .route("/auth/me", get(handlers::me)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::test_support::{request, state, user}; + use axum::{body::Body, http::{Method, StatusCode}}; + use tower::ServiceExt; + + #[tokio::test] + async fn login_is_public_and_me_requires_authentication() { + let state = state().await; + let member = user(&state, false).await; + let routes = router().with_state(state); + for uri in ["/auth/login", "/auth/bearer-login"] { + let mut login = request(Method::POST, uri, Body::from(r#"{"username":"nobody","password":"wrong"}"#), None); + login.headers_mut().insert("content-type", "application/json".parse().unwrap()); + assert_eq!(routes.clone().oneshot(login).await.unwrap().status(), StatusCode::UNAUTHORIZED, "{uri}"); + } + assert_eq!(routes.clone().oneshot(request(Method::GET, "/auth/me", Body::empty(), None)).await.unwrap().status(), StatusCode::UNAUTHORIZED); + assert_eq!(routes.oneshot(request(Method::GET, "/auth/me", Body::empty(), Some(member))).await.unwrap().status(), StatusCode::OK); + } +} diff --git a/src/routes/category/handlers.rs b/src/routes/category/handlers.rs index 53bd107..a790344 100644 --- a/src/routes/category/handlers.rs +++ b/src/routes/category/handlers.rs @@ -2,8 +2,10 @@ use crate::core::state::AppState; use crate::domain::dto::category::{ CategoryQueryParams, CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest, }; -use crate::http::context::Superuser; +use crate::http::context::CurrentUser; use crate::http::error::HTTPError; +use crate::permissions::ServerPermission; +use crate::routes::server::handlers::require_server_permission; use crate::routes::category::mapper; use axum::{ Json, @@ -26,9 +28,12 @@ use uuid::Uuid; tag = "Categories" )] pub async fn get_all( + user: CurrentUser, State(state): State, Query(filters): Query, ) -> Result>, HTTPError> { + let server_id = filters.server_id.ok_or(HTTPError::Forbidden)?; + state.repositories.server.get_user(server_id, user.id).await?.ok_or(HTTPError::Forbidden)?; let categories = state .repositories .category @@ -57,6 +62,7 @@ pub async fn get_all( tag = "Categories" )] pub async fn get_by_id( + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, HTTPError> { @@ -66,6 +72,7 @@ pub async fn get_by_id( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; + state.repositories.server.get_user(category.server_id, user.id).await?.ok_or(HTTPError::Forbidden)?; Ok(Json(mapper::category_model_to_category_response(category))) } @@ -86,7 +93,7 @@ pub async fn get_by_id( ) )] pub async fn create( - _admin: Superuser, + user: CurrentUser, State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json), HTTPError> { @@ -97,6 +104,7 @@ pub async fn create( .get_by_id(payload.server_id) .await? .ok_or(HTTPError::BadRequest("Server not found".to_string()))?; + require_server_permission(&state, &user, payload.server_id, ServerPermission::MANAGE_CATEGORIES).await?; let category = state .services @@ -128,18 +136,19 @@ pub async fn create( ) )] pub async fn update( - _admin: Superuser, + user: CurrentUser, State(state): State, Path(id): Path, Json(payload): Json, ) -> Result, HTTPError> { // Vérifier l'existence - let _category = state + let category = state .repositories .category .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; + require_server_permission(&state, &user, category.server_id, ServerPermission::MANAGE_CATEGORIES).await?; let category = state .services @@ -168,10 +177,12 @@ pub async fn update( ) )] pub async fn delete( - _admin: Superuser, + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result { + let category = state.repositories.category.get_by_id(id).await?.ok_or(HTTPError::NotFound)?; + require_server_permission(&state, &user, category.server_id, ServerPermission::MANAGE_CATEGORIES).await?; if state.services.category.delete_category(id).await? { Ok(StatusCode::NO_CONTENT) } else { diff --git a/src/routes/category/mod.rs b/src/routes/category/mod.rs index a376414..5406b88 100644 --- a/src/routes/category/mod.rs +++ b/src/routes/category/mod.rs @@ -3,3 +3,6 @@ pub mod handlers; pub mod mapper; pub mod routes; pub mod service; + +#[cfg(test)] +mod tests; diff --git a/src/routes/category/tests.rs b/src/routes/category/tests.rs new file mode 100644 index 0000000..dfb106e --- /dev/null +++ b/src/routes/category/tests.rs @@ -0,0 +1,88 @@ +use crate::http::test_support::{request, state, user}; +use crate::permissions::ServerPermission; +use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}, Router}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +async fn call(router: &Router, method: Method, uri: &str, body: Value, actor: crate::models::user::Model) -> axum::response::Response { + let mut req = request(method, uri, Body::from(body.to_string()), Some(actor)); + req.headers_mut().insert("content-type", "application/json".parse().unwrap()); + router.clone().oneshot(req).await.unwrap() +} + +#[tokio::test] +async fn category_permissions_over_http() { + let state = state().await; + let server = state.default_server.id; + let actor = user(&state, false).await; + let router = super::routes::router().with_state(state.clone()); + let list = format!("/categories?server_id={server}"); + + // La lecture est réservée aux membres du serveur. + assert_eq!( + call(&router, Method::GET, &list, json!(null), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + state.repositories.server.add_user(server, actor.id).await.unwrap(); + assert_eq!( + call(&router, Method::GET, &list, json!(null), actor.clone()) + .await + .status(), + StatusCode::OK + ); + + let payload = json!({"server_id":server,"name":"test-category"}); + + // Être membre suffit pour lire, mais pas pour gérer les catégories. + assert_eq!( + call(&router, Method::POST, "/categories", payload.clone(), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CATEGORIES.bits()).await.unwrap(); + let created = call(&router, Method::POST, "/categories", payload, actor.clone()).await; + + assert_eq!(created.status(), StatusCode::CREATED); + let id: Value = serde_json::from_slice(&to_bytes(created.into_body(), 1024 * 1024).await.unwrap()).unwrap(); + let uri = format!("/categories/{}", id["id"].as_str().unwrap()); + assert_eq!( + call(&router, Method::GET, &uri, json!(null), actor.clone()) + .await + .status(), + StatusCode::OK + ); + + // Le retrait du droit de gestion bloque modification et suppression. + state.repositories.server.set_user_permission(server, actor.id, 0).await.unwrap(); + assert_eq!( + call(&router, Method::PUT, &uri, json!({"name":"renamed"}), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + call(&router, Method::DELETE, &uri, json!(null), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CATEGORIES.bits()).await.unwrap(); + assert_eq!( + call(&router, Method::PUT, &uri, json!({"name":"renamed"}), actor.clone()) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + call(&router, Method::DELETE, &uri, json!(null), actor) + .await + .status(), + StatusCode::NO_CONTENT + ); +} \ No newline at end of file diff --git a/src/routes/channel/handlers.rs b/src/routes/channel/handlers.rs index 8ae8c50..9945415 100644 --- a/src/routes/channel/handlers.rs +++ b/src/routes/channel/handlers.rs @@ -4,8 +4,11 @@ use crate::domain::dto::channel::{ ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse, SetChannelPermissionRequest, SetReadStateRequest, UpdateChannelRequest, }; -use crate::http::context::{CurrentUser, Superuser}; +use crate::http::context::CurrentUser; use crate::http::error::HTTPError; +use crate::permissions::{ChannelPermission, ServerPermission}; +use crate::http::permissions::check_channel_permission; +use crate::routes::server::handlers::require_server_permission; use crate::models::{channel, channel_user}; use crate::routes::channel::mapper; use axum::{ @@ -16,6 +19,23 @@ use axum::{ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use uuid::Uuid; +async fn require_channel_permission( + state: &AppState, + user: &CurrentUser, + channel_id: Uuid, + permission: ChannelPermission, +) -> Result { + let channel = state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?; + if check_channel_permission(state, user.id, channel_id, permission).await? { Ok(channel) } else { Err(HTTPError::Forbidden) } +} + +async fn require_channel_manager(state: &AppState, user: &CurrentUser, channel_id: Uuid) -> Result { + let channel = state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?; + let server_id = channel.server_id.ok_or(HTTPError::Forbidden)?; + require_server_permission(state, user, server_id, ServerPermission::MANAGE_CHANNELS).await?; + Ok(channel) +} + async fn require_channel_member( state: &AppState, channel_id: Uuid, @@ -54,16 +74,26 @@ async fn require_channel_member( tag = "Channels" )] pub async fn get_all( + user: CurrentUser, State(state): State, Query(filters): Query, ) -> Result>, HTTPError> { + let server_id = filters.server_id.ok_or(HTTPError::Forbidden)?; + state.repositories.server.get_user(server_id, user.id).await?.ok_or(HTTPError::Forbidden)?; let params = mapper::query_params_to_channel_filter(filters); let channels = state.repositories.channel.filter(params).await?; Ok(Json( - channels - .into_iter() - .map(mapper::channel_model_to_channel_response) - .collect(), + { + let mut visible = Vec::new(); + for channel in channels { + match require_channel_permission(&state, &user, channel.id, ChannelPermission::READ_CHANNEL).await { + Ok(_) => visible.push(mapper::channel_model_to_channel_response(channel)), + Err(HTTPError::Forbidden) => {}, + Err(error) => return Err(error), + } + } + visible + }, )) } @@ -80,7 +110,7 @@ pub async fn get_read_state( State(state): State, Path(channel_id): Path, ) -> Result, HTTPError> { - require_channel_member(&state, channel_id, user.id).await?; + require_channel_permission(&state, &user, channel_id, ChannelPermission::READ_CHANNEL).await?; let read_state = state .repositories .read_state @@ -120,7 +150,7 @@ pub async fn set_read_state( Path(channel_id): Path, Json(payload): Json, ) -> Result, HTTPError> { - require_channel_member(&state, channel_id, user.id).await?; + require_channel_permission(&state, &user, channel_id, ChannelPermission::READ_CHANNEL).await?; if let Some(message_id) = payload.last_read_message_id { let message = state @@ -173,6 +203,7 @@ pub async fn set_read_state( tag = "Channels" )] pub async fn get_by_id( + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, HTTPError> { @@ -182,6 +213,7 @@ pub async fn get_by_id( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; + require_channel_permission(&state, &user, id, ChannelPermission::READ_CHANNEL).await?; Ok(Json(mapper::channel_model_to_channel_response(channel))) } @@ -195,9 +227,11 @@ pub async fn get_by_id( tag = "Channel Permissions" )] pub async fn list_permissions( + user: CurrentUser, State(state): State, Path(channel_id): Path, ) -> Result, HTTPError> { + require_channel_manager(&state, &user, channel_id).await?; state .repositories .channel @@ -227,7 +261,7 @@ pub async fn list_permissions( ) )] pub async fn create( - _admin: Superuser, + user: CurrentUser, State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json), HTTPError> { @@ -239,16 +273,20 @@ pub async fn create( .get_by_id(server_id) .await? .ok_or(HTTPError::BadRequest("Server not found".to_string()))?; + require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_CHANNELS).await?; + } else { + return Err(HTTPError::Forbidden); } // Vérifier que la catégorie existe si fournie if let Some(category_id) = payload.category_id { - state + let category = state .repositories .category .get_by_id(category_id) .await? .ok_or(HTTPError::BadRequest("Category not found".to_string()))?; + if category.server_id != payload.server_id.ok_or(HTTPError::Forbidden)? { return Err(HTTPError::BadRequest("Category belongs to another server".to_string())); } } let channel = state.services.channel.create_channel(payload).await?; @@ -278,18 +316,23 @@ pub async fn create( ) )] pub async fn update( - _admin: Superuser, + user: CurrentUser, State(state): State, Path(id): Path, Json(payload): Json, ) -> Result, HTTPError> { // Vérifier l'existence - state + let original = state .repositories .channel .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; + require_channel_manager(&state, &user, id).await?; + if payload.server_id != original.server_id { + let server_id = payload.server_id.ok_or(HTTPError::Forbidden)?; + require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_CHANNELS).await?; + } // Vérifier que le serveur existe si fourni if let Some(server_id) = payload.server_id { @@ -303,12 +346,13 @@ pub async fn update( // Vérifier que la catégorie existe si fournie if let Some(category_id) = payload.category_id { - state + let category = state .repositories .category .get_by_id(category_id) .await? .ok_or(HTTPError::BadRequest("Category not found".to_string()))?; + if Some(category.server_id) != payload.server_id { return Err(HTTPError::BadRequest("Category belongs to another server".to_string())); } } let channel = state.services.channel.update_channel(id, payload).await?; @@ -334,10 +378,11 @@ pub async fn update( ) )] pub async fn delete( - _admin: Superuser, + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result { + require_channel_manager(&state, &user, id).await?; if state.services.channel.delete_channel(id).await? { Ok(StatusCode::NO_CONTENT) } else { @@ -361,9 +406,11 @@ pub async fn delete( tag = "Channel Permissions" )] pub async fn get_user_permission( + user: CurrentUser, State(state): State, Path((channel_id, user_id)): Path<(Uuid, Uuid)>, ) -> Result, HTTPError> { + require_channel_manager(&state, &user, channel_id).await?; let permission = state .repositories .channel @@ -392,10 +439,12 @@ pub async fn get_user_permission( tag = "Channel Permissions" )] pub async fn set_user_permission( + user: CurrentUser, State(state): State, Path((channel_id, user_id)): Path<(Uuid, Uuid)>, Json(payload): Json, ) -> Result, HTTPError> { + require_channel_manager(&state, &user, channel_id).await?; state .services .channel @@ -430,9 +479,11 @@ pub async fn set_user_permission( tag = "Channel Permissions" )] pub async fn remove_user_permission( + user: CurrentUser, State(state): State, Path((channel_id, user_id)): Path<(Uuid, Uuid)>, ) -> Result { + require_channel_manager(&state, &user, channel_id).await?; if state .repositories .channel @@ -468,9 +519,11 @@ pub async fn remove_user_permission( tag = "Channel Permissions" )] pub async fn get_role_permission( + user: CurrentUser, State(state): State, Path((channel_id, role_id)): Path<(Uuid, Uuid)>, ) -> Result, HTTPError> { + require_channel_manager(&state, &user, channel_id).await?; let permission = state .repositories .channel @@ -499,10 +552,12 @@ pub async fn get_role_permission( tag = "Channel Permissions" )] pub async fn set_role_permission( + user: CurrentUser, State(state): State, Path((channel_id, role_id)): Path<(Uuid, Uuid)>, Json(payload): Json, ) -> Result, HTTPError> { + require_channel_manager(&state, &user, channel_id).await?; state .services .channel @@ -537,9 +592,11 @@ pub async fn set_role_permission( tag = "Channel Permissions" )] pub async fn remove_role_permission( + user: CurrentUser, State(state): State, Path((channel_id, role_id)): Path<(Uuid, Uuid)>, ) -> Result { + require_channel_manager(&state, &user, channel_id).await?; if state .repositories .channel diff --git a/src/routes/channel/mod.rs b/src/routes/channel/mod.rs index a376414..5406b88 100644 --- a/src/routes/channel/mod.rs +++ b/src/routes/channel/mod.rs @@ -3,3 +3,6 @@ pub mod handlers; pub mod mapper; pub mod routes; pub mod service; + +#[cfg(test)] +mod tests; diff --git a/src/routes/channel/tests.rs b/src/routes/channel/tests.rs new file mode 100644 index 0000000..5f25abb --- /dev/null +++ b/src/routes/channel/tests.rs @@ -0,0 +1,126 @@ +use crate::http::test_support::{request, state, user}; +use crate::permissions::ServerPermission; +use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}, Router}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +async fn call(router: &Router, method: Method, uri: &str, body: Value, actor: crate::models::user::Model) -> axum::response::Response { + let mut req = request(method, uri, Body::from(body.to_string()), Some(actor)); + req.headers_mut().insert("content-type", "application/json".parse().unwrap()); + router.clone().oneshot(req).await.unwrap() +} + +#[tokio::test] +async fn channel_permissions_over_http() { + let state = state().await; + let server = state.default_server.id; + let actor = user(&state, false).await; + let router = super::routes::router().with_state(state.clone()); + let list = format!("/channels?server_id={server}"); + + // Un non-membre ne peut pas consulter les canaux du serveur. + assert_eq!( + call(&router, Method::GET, &list, json!(null), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + state.repositories.server.add_user(server, actor.id).await.unwrap(); + assert_eq!( + call(&router, Method::GET, &list, json!(null), actor.clone()) + .await + .status(), + StatusCode::OK + ); + + let payload = json!({"server_id":server,"category_id":null,"channel_type":"text","name":"test-channel"}); + + // La création requiert MANAGE_CHANNELS, même pour un membre. + assert_eq!( + call(&router, Method::POST, "/channels", payload.clone(), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CHANNELS.bits()).await.unwrap(); + let created = call(&router, Method::POST, "/channels", payload.clone(), actor.clone()).await; + + assert_eq!(created.status(), StatusCode::CREATED); + let data: Value = serde_json::from_slice(&to_bytes(created.into_body(), 1024 * 1024).await.unwrap()).unwrap(); + let uri = format!("/channels/{}", data["id"].as_str().unwrap()); + let permissions = format!("{uri}/permissions"); + + // Le droit de gestion permet de consulter et modifier les permissions du canal. + assert_eq!( + call(&router, Method::GET, &permissions, json!(null), actor.clone()) + .await + .status(), + StatusCode::OK + ); + let direct = format!("{permissions}/users/{}", actor.id); + assert_eq!( + call(&router, Method::PUT, &direct, json!({"permissions":1}), actor.clone()) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + call(&router, Method::GET, &direct, json!(null), actor.clone()) + .await + .status(), + StatusCode::OK + ); + + // Sans droit de gestion, les opérations sur le canal et ses permissions sont refusées. + state.repositories.server.set_user_permission(server, actor.id, 0).await.unwrap(); + assert_eq!( + call(&router, Method::GET, &permissions, json!(null), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + for method in [Method::GET, Method::PUT, Method::DELETE] { + assert_eq!( + call(&router, method, &direct, json!({"permissions":1}), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + } + + assert_eq!( + call(&router, Method::PUT, &uri, payload.clone(), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + call(&router, Method::DELETE, &uri, json!(null), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CHANNELS.bits()).await.unwrap(); + assert_eq!( + call(&router, Method::PUT, &uri, payload, actor.clone()) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + call(&router, Method::DELETE, &direct, json!(null), actor.clone()) + .await + .status(), + StatusCode::NO_CONTENT + ); + assert_eq!( + call(&router, Method::DELETE, &uri, json!(null), actor) + .await + .status(), + StatusCode::NO_CONTENT + ); +} \ No newline at end of file diff --git a/src/routes/conversation/routes.rs b/src/routes/conversation/routes.rs index d5eb4ac..b077b82 100644 --- a/src/routes/conversation/routes.rs +++ b/src/routes/conversation/routes.rs @@ -10,3 +10,41 @@ pub fn router() -> Router { .route("/conversations", get(handlers::list).post(handlers::create)) .route("/conversations/{id}/fork", post(handlers::fork)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::test_support::{request, state, user}; + use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}}; + use tower::ServiceExt; + + #[tokio::test] + async fn conversation_routes_respect_authentication_and_membership() { + let state = state().await; + let owner = user(&state, false).await; + let guest = user(&state, false).await; + let outsider = user(&state, true).await; + let routes = router().with_state(state); + let list = routes.clone().oneshot(request(Method::GET, "/conversations", Body::empty(), Some(owner.clone()))).await.unwrap(); + assert_eq!(list.status(), StatusCode::OK); + let create_body = format!(r#"{{"user_ids":["{}"]}}"#, guest.id); + let mut create = request(Method::POST, "/conversations", Body::from(create_body.clone()), Some(owner.clone())); + create.headers_mut().insert("content-type", "application/json".parse().unwrap()); + let created = routes.clone().oneshot(create).await.unwrap(); + assert_eq!(created.status(), StatusCode::OK); + let bytes = to_bytes(created.into_body(), 1024 * 1024).await.unwrap(); + let id = serde_json::from_slice::(&bytes).unwrap()["id"].as_str().unwrap().to_string(); + let fork_uri = format!("/conversations/{id}/fork"); + let fork_body = r#"{"user_ids":[]}"#; + let mut forbidden = request(Method::POST, &fork_uri, Body::from(fork_body), Some(outsider)); + forbidden.headers_mut().insert("content-type", "application/json".parse().unwrap()); + assert_eq!(routes.clone().oneshot(forbidden).await.unwrap().status(), StatusCode::FORBIDDEN); + let mut allowed = request(Method::POST, &fork_uri, Body::from(fork_body), Some(guest)); + allowed.headers_mut().insert("content-type", "application/json".parse().unwrap()); + assert_eq!(routes.clone().oneshot(allowed).await.unwrap().status(), StatusCode::OK); + for (method, uri, body) in [(Method::GET, "/conversations", ""), (Method::POST, "/conversations", create_body.as_str()), (Method::POST, fork_uri.as_str(), fork_body)] { + let result = routes.clone().oneshot(request(method, uri, Body::from(body.to_string()), None)).await.unwrap(); + assert_eq!(result.status(), StatusCode::UNAUTHORIZED, "{uri}"); + } + } +} diff --git a/src/routes/emoji/handlers.rs b/src/routes/emoji/handlers.rs index 7115fe0..72af152 100644 --- a/src/routes/emoji/handlers.rs +++ b/src/routes/emoji/handlers.rs @@ -1,4 +1,7 @@ use crate::domain::events::emoji::{EmojiCreatedEvent, EmojiDeletedEvent, EmojiUpdatedEvent}; +use crate::http::context::CurrentUser; +use crate::http::permissions::check_server_permission; +use crate::permissions::ServerPermission; use crate::services::media; use crate::{ core::state::AppState, @@ -29,20 +32,38 @@ fn normalize_type(value: &str) -> Result { } } +async fn require_manage( + state: &AppState, + user: &CurrentUser, + server_id: Option, +) -> Result<(), HTTPError> { + let server_id = server_id.ok_or(HTTPError::Forbidden)?; + if check_server_permission(state, user.id, server_id, ServerPermission::MANAGE_SERVER).await? { + Ok(()) + } else { + Err(HTTPError::Forbidden) + } +} + #[utoipa::path(get, path = "/emojis", params(EmojiQueryParams), responses((status = 200, body = [crate::domain::dto::emoji::EmojiResponse])), tag = "Emojis")] pub async fn get_all( + user: CurrentUser, State(state): State, Query(query): Query, ) -> Result>, HTTPError> { let mut result = Vec::new(); for emoji in state.repositories.emoji.list(query.server_id).await? { - result.push(mapper::response(emoji)); + if match emoji.server_id { + Some(id) => check_server_permission(&state, user.id, id, ServerPermission::empty()).await?, + None => true, + } { result.push(mapper::response(emoji)); } } Ok(Json(result)) } #[utoipa::path(get, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] pub async fn get_by_id( + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, HTTPError> { @@ -52,11 +73,15 @@ pub async fn get_by_id( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; + if let Some(server_id) = emoji.server_id { + if !check_server_permission(&state, user.id, server_id, ServerPermission::empty()).await? { return Err(HTTPError::Forbidden); } + } Ok(Json(mapper::response(emoji))) } #[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] pub async fn create( + user: CurrentUser, State(state): State, mut multipart: Multipart, ) -> Result<(StatusCode, Json), HTTPError> { @@ -112,6 +137,7 @@ pub async fn create( "file is required for custom emojis".into(), )); } + require_manage(&state, &user, server_id).await?; if let Some(server_id) = server_id { state .repositories @@ -163,6 +189,7 @@ pub async fn create( #[utoipa::path(put, path = "/emojis/{id}", request_body = UpdateEmojiRequest, params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] pub async fn update( + user: CurrentUser, State(state): State, Path(id): Path, Json(payload): Json, @@ -173,6 +200,10 @@ pub async fn update( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; + require_manage(&state, &user, existing.server_id).await?; + if let Some(target) = payload.server_id { + require_manage(&state, &user, Some(target)).await?; + } let target_server_id = payload.server_id.or(existing.server_id); let target_name = payload.name.as_deref().unwrap_or(&existing.name); state @@ -219,6 +250,7 @@ fn detect_mime(bytes: &[u8]) -> Option { #[utoipa::path(delete, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 204)), tag = "Emojis")] pub async fn delete( + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result { @@ -228,6 +260,7 @@ pub async fn delete( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; + require_manage(&state, &user, model.server_id).await?; let deleted = state.repositories.emoji.delete(id).await?; if deleted { EmojiService::remove_asset( @@ -243,6 +276,7 @@ pub async fn delete( } pub async fn asset( + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result { @@ -252,6 +286,9 @@ pub async fn asset( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; + if let Some(server_id) = model.server_id { + if !check_server_permission(&state, user.id, server_id, ServerPermission::empty()).await? { return Err(HTTPError::Forbidden); } + } let path = model.file_path.ok_or(HTTPError::NotFound)?; let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(path)) .await diff --git a/src/routes/emoji/routes.rs b/src/routes/emoji/routes.rs index 61c8eed..2c7cbc0 100644 --- a/src/routes/emoji/routes.rs +++ b/src/routes/emoji/routes.rs @@ -13,3 +13,164 @@ pub fn router() -> Router { ) .route("/emojis/{id}/asset", get(handlers::asset)) } + +#[cfg(test)] +mod permission_tests { + use super::*; + use crate::models::{emoji, server_user, server_user_permission}; + use crate::permissions::ServerPermission; + use crate::routes::message::routes::permission_tests::Fixture; + use axum::{ + body::Body, + http::{Method, StatusCode}, + }; + use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + use uuid::Uuid; + + #[tokio::test] + async fn emoji_create_requires_manage_server() { + let fixture = Fixture::new().await; + // Un membre sans MANAGE_SERVER ne peut pas créer d'emoji. + let boundary = "emoji-permission-test"; + let body = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"server_id\"\r\n\r\n{}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nwave\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"emoji_type\"\r\n\r\nunicode\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"unicode_sequence\"\r\n\r\n👋\r\n--{boundary}--\r\n", + fixture.state.default_server.id + ); + let mime = format!("multipart/form-data; boundary={boundary}"); + assert_eq!( + fixture + .request( + router(), + Method::POST, + "/emojis", + Body::from(body.clone()), + Some(&mime) + ) + .await, + StatusCode::FORBIDDEN + ); + server_user_permission::ActiveModel { + id: Set(Uuid::new_v4()), + server_id: Set(fixture.state.default_server.id), + user_id: Set(fixture.user.id), + permissions: Set(ServerPermission::MANAGE_SERVER.bits() as i64), + } + .insert(&fixture.state.db) + .await + .unwrap(); + assert_eq!( + fixture + .request( + router(), + Method::POST, + "/emojis", + Body::from(body), + Some(&mime) + ) + .await, + StatusCode::CREATED + ); + } + + #[tokio::test] + async fn emoji_read_requires_membership() { + let fixture = Fixture::new().await; + // L'emoji de serveur est lisible par un membre, mais pas par un utilisateur retiré. + let item = emoji::ActiveModel { + id: Set(Uuid::new_v4()), + server_id: Set(Some(fixture.state.default_server.id)), + name: Set("wave".into()), + emoji_type: Set("unicode".into()), + unicode_sequence: Set(Some("👋".into())), + supports_skin_tone: Set(false), + file_path: Set(None), + mime_type: Set(None), + file_size: Set(None), + is_animated: Set(false), + sha256: Set(None), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + } + .insert(&fixture.state.db) + .await + .unwrap(); + let uri = format!("/emojis/{}", item.id); + assert_eq!( + fixture + .request(router(), Method::GET, &uri, Body::empty(), None) + .await, + StatusCode::OK + ); + server_user::Entity::delete_many() + .filter(server_user::Column::UserId.eq(fixture.user.id)) + .exec(&fixture.state.db) + .await + .unwrap(); + assert_eq!( + fixture + .request(router(), Method::GET, &uri, Body::empty(), None) + .await, + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn emoji_update_and_delete_require_manage_server() { + // Les deux opérations sont contrôlées séparément avec une fixture fraîche. + for method in [Method::PUT, Method::DELETE] { + let fixture = Fixture::new().await; + let item = emoji::ActiveModel { + id: Set(Uuid::new_v4()), + server_id: Set(Some(fixture.state.default_server.id)), + name: Set("wave".into()), + emoji_type: Set("unicode".into()), + unicode_sequence: Set(Some("👋".into())), + supports_skin_tone: Set(false), + file_path: Set(None), + mime_type: Set(None), + file_size: Set(None), + is_animated: Set(false), + sha256: Set(None), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + } + .insert(&fixture.state.db) + .await + .unwrap(); + let uri = format!("/emojis/{}", item.id); + let body = || Body::from(r#"{"name":"newwave"}"#); + assert_eq!( + fixture + .request( + router(), + method.clone(), + &uri, + body(), + Some("application/json") + ) + .await, + StatusCode::FORBIDDEN + ); + server_user_permission::ActiveModel { + id: Set(Uuid::new_v4()), + server_id: Set(fixture.state.default_server.id), + user_id: Set(fixture.user.id), + permissions: Set(ServerPermission::MANAGE_SERVER.bits() as i64), + } + .insert(&fixture.state.db) + .await + .unwrap(); + let expected = if method == Method::PUT { + StatusCode::OK + } else { + StatusCode::NO_CONTENT + }; + assert_eq!( + fixture + .request(router(), method, &uri, body(), Some("application/json")) + .await, + expected + ); + } + } +} diff --git a/src/routes/message/handlers.rs b/src/routes/message/handlers.rs index 1c05f5f..45f62ed 100644 --- a/src/routes/message/handlers.rs +++ b/src/routes/message/handlers.rs @@ -6,36 +6,59 @@ use crate::domain::dto::message::{ use crate::domain::dto::reaction::{CreateReactionRequest, DeleteReactionQuery, ReactionResponse}; use crate::http::context::CurrentUser; use crate::http::error::HTTPError; -use crate::models::{channel, channel_user}; +use crate::http::permissions::check_channel_permission; +use crate::permissions::ChannelPermission; use crate::routes::message::mapper; use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, }; -use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use uuid::Uuid; -pub(crate) async fn can_access( +pub(crate) async fn require_channel_permission( state: &AppState, channel_id: Uuid, user_id: Uuid, -) -> Result { - let Some(channel) = channel::Entity::find_by_id(channel_id) - .one(&state.db) - .await? - else { - return Ok(false); - }; - if channel.channel_type != channel::ChannelType::DM { - return Ok(true); + is_superuser: bool, + required: ChannelPermission, +) -> Result<(), HTTPError> { + let _ = is_superuser; + if check_channel_permission(state, user_id, channel_id, required).await? { + Ok(()) + } else { + Err(HTTPError::Forbidden) + } +} + +fn allows_channel_permission(granted: ChannelPermission, required: ChannelPermission) -> bool { + granted.contains(required) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_permissions_require_every_requested_bit() { + let granted = ChannelPermission::READ_CHANNEL | ChannelPermission::SEND_MESSAGE; + assert!(allows_channel_permission( + granted, + ChannelPermission::READ_CHANNEL + )); + assert!(!allows_channel_permission( + granted, + ChannelPermission::SEND_MESSAGE | ChannelPermission::ATTACH_FILES + )); + assert!(!allows_channel_permission( + granted, + ChannelPermission::EDIT_OTHERS_MESSAGES + )); + assert!(!allows_channel_permission( + ChannelPermission::empty(), + ChannelPermission::READ_CHANNEL + )); } - Ok(channel_user::Entity::find() - .filter(channel_user::Column::ChannelId.eq(channel_id)) - .filter(channel_user::Column::UserId.eq(user_id)) - .one(&state.db) - .await? - .is_some()) } /// Liste une fenêtre paginée de messages @@ -64,11 +87,17 @@ pub async fn get_all( } let params = mapper::query_params_to_message_filter(filters); - if let Some(channel_id) = params.channel_id { - if !can_access(&state, channel_id, user.id).await? { - return Err(HTTPError::Forbidden); - } - } + let channel_id = params + .channel_id + .ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?; + require_channel_permission( + &state, + channel_id, + user.id, + user.is_superuser, + ChannelPermission::READ_CHANNEL, + ) + .await?; let page = state.repositories.message.filter(params).await?; let message_ids: Vec<_> = page.messages.iter().map(|message| message.id).collect(); let mut reactions = state @@ -126,9 +155,14 @@ pub async fn get_by_id( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; - if !can_access(&state, message.channel_id, user.id).await? { - return Err(HTTPError::Forbidden); - } + require_channel_permission( + &state, + message.channel_id, + user.id, + user.is_superuser, + ChannelPermission::READ_CHANNEL, + ) + .await?; let reactions = state .services @@ -180,9 +214,11 @@ pub async fn create( .get_by_id(payload.channel_id) .await? .ok_or(HTTPError::BadRequest("Channel not found".to_string()))?; - if !can_access(&state, channel.id, user.id).await? { - return Err(HTTPError::Forbidden); + let mut required = ChannelPermission::SEND_MESSAGE; + if !payload.file_ids.is_empty() { + required |= ChannelPermission::ATTACH_FILES; } + require_channel_permission(&state, channel.id, user.id, user.is_superuser, required).await?; if payload.content.trim().is_empty() && payload.file_ids.is_empty() { return Err(HTTPError::BadRequest( @@ -192,7 +228,7 @@ pub async fn create( // Optionnel: vérifier reply_to_id if let Some(reply_id) = payload.reply_to_id { - state + let parent = state .repositories .message .get_by_id(reply_id) @@ -200,6 +236,11 @@ pub async fn create( .ok_or(HTTPError::BadRequest( "Parent message not found".to_string(), ))?; + if parent.channel_id != channel.id { + return Err(HTTPError::BadRequest( + "Parent message belongs to another channel".into(), + )); + } } let message = state @@ -274,9 +315,19 @@ pub async fn update( .ok_or(HTTPError::NotFound)?; // Vérifier que l'utilisateur est l'auteur - if message.user_id != user.id && !user.is_superuser { - return Err(HTTPError::Forbidden); - } + let required = if message.user_id == user.id { + ChannelPermission::EDIT_OWN_MESSAGE + } else { + ChannelPermission::EDIT_OTHERS_MESSAGES + }; + require_channel_permission( + &state, + message.channel_id, + user.id, + user.is_superuser, + required, + ) + .await?; let message = state .services @@ -332,9 +383,14 @@ pub async fn add_reaction( .get_by_id(message_id) .await? .ok_or(HTTPError::NotFound)?; - if !can_access(&state, message.channel_id, user.id).await? { - return Err(HTTPError::Forbidden); - } + require_channel_permission( + &state, + message.channel_id, + user.id, + user.is_superuser, + ChannelPermission::READ_CHANNEL | ChannelPermission::ADD_REACTIONS, + ) + .await?; let (reaction, created) = state .services .message_reaction @@ -378,9 +434,14 @@ pub async fn remove_reaction( .get_by_id(message_id) .await? .ok_or(HTTPError::NotFound)?; - if !can_access(&state, message.channel_id, user.id).await? { - return Err(HTTPError::Forbidden); - } + require_channel_permission( + &state, + message.channel_id, + user.id, + user.is_superuser, + ChannelPermission::READ_CHANNEL, + ) + .await?; state .services .message_reaction @@ -420,9 +481,19 @@ pub async fn delete( .await? .ok_or(HTTPError::NotFound)?; - if message.user_id != user.id && !user.is_superuser { - return Err(HTTPError::Forbidden); - } + let required = if message.user_id == user.id { + ChannelPermission::DELETE_OWN_MESSAGE + } else { + ChannelPermission::DELETE_OTHERS_MESSAGES + }; + require_channel_permission( + &state, + message.channel_id, + user.id, + user.is_superuser, + required, + ) + .await?; let attachment_ids: Vec<_> = state .repositories diff --git a/src/routes/message/routes.rs b/src/routes/message/routes.rs index 81cd29a..cd43657 100644 --- a/src/routes/message/routes.rs +++ b/src/routes/message/routes.rs @@ -20,3 +20,327 @@ pub fn router() -> Router { axum::routing::delete(handlers::remove_reaction), ) } + +#[cfg(test)] +pub(crate) mod permission_tests { + use super::*; + use crate::config::{AppConfig, DEFAULT_CONFIG_TOML}; + use crate::core::App; + use crate::http::context::{CurrentUser, RequestContext}; + use crate::models::{channel, channel_user_permission, emoji, server_user, user}; + use crate::permissions::ChannelPermission; + use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + }; + use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + use std::time::Instant; + use tower::ServiceExt; + use uuid::Uuid; + + pub(crate) struct Fixture { + pub state: AppState, + pub user: user::Model, + pub channel_id: Uuid, + } + + impl Fixture { + pub async fn new() -> Self { + let mut config: AppConfig = toml::from_str(DEFAULT_CONFIG_TOML).unwrap(); + config.database.url = "sqlite::memory:".into(); + config.network.stun_servers.clear(); + config.media.root = + format!("src/routes/attachment/.permission-test-{}", Uuid::new_v4()); + let state = App::build(config).await.unwrap().state; + let user = user::ActiveModel { + id: Set(Uuid::new_v4()), + username: Set(format!("test-{}", Uuid::new_v4())), + password: Set(String::new()), + pub_key: Set(None), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + is_superuser: Set(false), + } + .insert(&state.db) + .await + .unwrap(); + server_user::ActiveModel { + id: Set(Uuid::new_v4()), + server_id: Set(state.default_server.id), + user_id: Set(user.id), + username: Set(None), + joined_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + } + .insert(&state.db) + .await + .unwrap(); + let channel = channel::ActiveModel { + id: Set(Uuid::new_v4()), + server_id: Set(Some(state.default_server.id)), + category_id: Set(None), + channel_type: Set(channel::ChannelType::Text), + name: Set(Some("test".into())), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + } + .insert(&state.db) + .await + .unwrap(); + Self { + state, + user, + channel_id: channel.id, + } + } + + pub async fn grant(&self, permissions: ChannelPermission) { + channel_user_permission::ActiveModel { + id: Set(Uuid::new_v4()), + channel_id: Set(self.channel_id), + user_id: Set(self.user.id), + permissions: Set(permissions.bits() as i64), + } + .insert(&self.state.db) + .await + .unwrap(); + } + + pub async fn request( + &self, + router: Router, + method: Method, + uri: &str, + body: Body, + content_type: Option<&str>, + ) -> StatusCode { + let mut builder = Request::builder().method(method.clone()).uri(uri); + if let Some(content_type) = content_type { + builder = builder.header("content-type", content_type); + } + let mut request = builder.body(body).unwrap(); + request.extensions_mut().insert(RequestContext { + request_id: Uuid::new_v4(), + started_at: Instant::now(), + method, + uri: uri.parse().unwrap(), + user: Some(CurrentUser(self.user.clone())), + }); + router + .with_state(self.state.clone()) + .oneshot(request) + .await + .unwrap() + .status() + } + } + + #[tokio::test] + async fn message_list_requires_read_channel() { + let fixture = Fixture::new().await; + let uri = format!("/messages?channel_id={}", fixture.channel_id); + // L'accès aux messages du canal demande READ_CHANNEL. + assert_eq!( + fixture + .request(router(), Method::GET, &uri, Body::empty(), None) + .await, + StatusCode::FORBIDDEN + ); + fixture.grant(ChannelPermission::READ_CHANNEL).await; + assert_eq!( + fixture + .request(router(), Method::GET, &uri, Body::empty(), None) + .await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn message_create_requires_send_message() { + let fixture = Fixture::new().await; + let payload = format!( + r#"{{"channel_id":"{}","content":"hello","file_ids":[]}}"#, + fixture.channel_id + ); + let send = || Body::from(payload.clone()); + // SEND_MESSAGE est distinct du droit de lire le canal. + assert_eq!( + fixture + .request( + router(), + Method::POST, + "/messages", + send(), + Some("application/json") + ) + .await, + StatusCode::FORBIDDEN + ); + fixture.grant(ChannelPermission::SEND_MESSAGE).await; + assert_eq!( + fixture + .request( + router(), + Method::POST, + "/messages", + send(), + Some("application/json") + ) + .await, + StatusCode::CREATED + ); + } + + #[tokio::test] + async fn message_edit_and_delete_require_own_permissions() { + // L'édition et la suppression d'un message propre ont des permissions distinctes. + for (method, permission, success) in [ + ( + Method::PUT, + ChannelPermission::EDIT_OWN_MESSAGE, + StatusCode::OK, + ), + ( + Method::DELETE, + ChannelPermission::DELETE_OWN_MESSAGE, + StatusCode::NO_CONTENT, + ), + ] { + let fixture = Fixture::new().await; + let message = fixture + .state + .services + .message + .create_message_with_attachments( + fixture.channel_id, + fixture.user.id, + "hello".into(), + Vec::new(), + None, + ) + .await + .unwrap(); + let uri = format!("/messages/{}", message.id); + let body = || Body::from(r#"{"content":"edited"}"#); + assert_eq!( + fixture + .request( + router(), + method.clone(), + &uri, + body(), + Some("application/json") + ) + .await, + StatusCode::FORBIDDEN + ); + fixture.grant(permission).await; + assert_eq!( + fixture + .request(router(), method, &uri, body(), Some("application/json")) + .await, + success + ); + } + } + + #[tokio::test] + async fn reactions_require_read_and_add_permissions() { + let fixture = Fixture::new().await; + // Prépare un message et un emoji afin de tester les deux opérations de réaction. + let message = fixture + .state + .services + .message + .create_message_with_attachments( + fixture.channel_id, + fixture.user.id, + "hello".into(), + Vec::new(), + None, + ) + .await + .unwrap(); + let emoji = emoji::ActiveModel { + id: Set(Uuid::new_v4()), + server_id: Set(Some(fixture.state.default_server.id)), + name: Set("wave".into()), + emoji_type: Set("unicode".into()), + unicode_sequence: Set(Some("👋".into())), + supports_skin_tone: Set(false), + file_path: Set(None), + mime_type: Set(None), + file_size: Set(None), + is_animated: Set(false), + sha256: Set(None), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + } + .insert(&fixture.state.db) + .await + .unwrap(); + let add_uri = format!("/messages/{}/reactions", message.id); + let remove_uri = format!("{add_uri}/{}", emoji.id); + let payload = || Body::from(format!(r#"{{"emoji_id":"{}"}}"#, emoji.id)); + // Sans permission, l'ajout et le retrait sont refusés. + assert_eq!( + fixture + .request( + router(), + Method::POST, + &add_uri, + payload(), + Some("application/json") + ) + .await, + StatusCode::FORBIDDEN + ); + assert_eq!( + fixture + .request(router(), Method::DELETE, &remove_uri, Body::empty(), None) + .await, + StatusCode::FORBIDDEN + ); + fixture.grant(ChannelPermission::READ_CHANNEL).await; + // Lire le canal seul ne suffit pas pour ajouter une réaction. + assert_eq!( + fixture + .request( + router(), + Method::POST, + &add_uri, + payload(), + Some("application/json") + ) + .await, + StatusCode::FORBIDDEN + ); + channel_user_permission::Entity::delete_many() + .filter(channel_user_permission::Column::UserId.eq(fixture.user.id)) + .exec(&fixture.state.db) + .await + .unwrap(); + fixture + .grant(ChannelPermission::READ_CHANNEL | ChannelPermission::ADD_REACTIONS) + .await; + // L'ajout requiert les deux droits; le retrait est ensuite autorisé aussi. + assert_eq!( + fixture + .request( + router(), + Method::POST, + &add_uri, + payload(), + Some("application/json") + ) + .await, + StatusCode::CREATED + ); + assert_eq!( + fixture + .request(router(), Method::DELETE, &remove_uri, Body::empty(), None) + .await, + StatusCode::NO_CONTENT + ); + } +} diff --git a/src/routes/role/handlers.rs b/src/routes/role/handlers.rs index c498154..9c325a8 100644 --- a/src/routes/role/handlers.rs +++ b/src/routes/role/handlers.rs @@ -6,6 +6,7 @@ use crate::domain::dto::user::UserResponse; use crate::http::context::CurrentUser; use crate::http::error::HTTPError; use crate::permissions::ServerPermission; +use crate::routes::server::handlers::require_server_permission; use crate::routes::role::mapper; use crate::routes::user::mapper as user_mapper; use axum::{ @@ -15,36 +16,15 @@ use axum::{ }; use uuid::Uuid; -async fn require_permission( - state: &AppState, - user: &CurrentUser, - server_id: Uuid, - permission: ServerPermission, -) -> Result<(), HTTPError> { - if user.is_superuser { - return Ok(()); - } - - let granted = state - .repositories - .server - .get_user_permission(server_id, user.id) - .await? - .map(|value| ServerPermission::from_bits_truncate(value.permissions as u64)) - .unwrap_or_default(); - - if granted.contains(permission) { - Ok(()) - } else { - Err(HTTPError::Forbidden) - } -} #[utoipa::path(get, path = "/roles", params(RoleQueryParams), responses((status = 200, body = [RoleResponse])), tag = "Roles")] pub async fn get_all( + user: CurrentUser, State(state): State, Query(filters): Query, ) -> Result>, HTTPError> { + let server_id = filters.server_id.ok_or(HTTPError::Forbidden)?; + state.repositories.server.get_user(server_id, user.id).await?.ok_or(HTTPError::Forbidden)?; let roles = match filters.server_id { Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?, None => state.repositories.role.get_all().await?, @@ -60,6 +40,7 @@ pub async fn get_all( #[utoipa::path(get, path = "/roles/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles")] pub async fn get_by_id( + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, HTTPError> { @@ -69,6 +50,7 @@ pub async fn get_by_id( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; + state.repositories.server.get_user(role.server_id, user.id).await?.ok_or(HTTPError::Forbidden)?; Ok(Json(mapper::role_model_to_role_response(role))) } @@ -84,7 +66,7 @@ pub async fn create( .get_by_id(payload.server_id) .await? .ok_or(HTTPError::BadRequest("Server not found".to_string()))?; - require_permission( + require_server_permission( &state, &user, payload.server_id, @@ -116,7 +98,7 @@ pub async fn update( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; - require_permission( + require_server_permission( &state, &user, role.server_id, @@ -148,7 +130,7 @@ pub async fn delete( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; - require_permission( + require_server_permission( &state, &user, role.server_id, @@ -174,7 +156,7 @@ pub async fn get_members( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; - require_permission( + require_server_permission( &state, &user, role.server_id, @@ -201,7 +183,7 @@ pub async fn add_member( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; - require_permission( + require_server_permission( &state, &user, role.server_id, @@ -235,7 +217,7 @@ pub async fn remove_member( .get_by_id(id) .await? .ok_or(HTTPError::NotFound)?; - require_permission( + require_server_permission( &state, &user, role.server_id, diff --git a/src/routes/role/mod.rs b/src/routes/role/mod.rs index a376414..5406b88 100644 --- a/src/routes/role/mod.rs +++ b/src/routes/role/mod.rs @@ -3,3 +3,6 @@ pub mod handlers; pub mod mapper; pub mod routes; pub mod service; + +#[cfg(test)] +mod tests; diff --git a/src/routes/role/tests.rs b/src/routes/role/tests.rs new file mode 100644 index 0000000..c949cee --- /dev/null +++ b/src/routes/role/tests.rs @@ -0,0 +1,89 @@ +use crate::http::test_support::{request, state, user}; +use crate::permissions::ServerPermission; +use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}, Router}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +async fn call(router: &Router, method: Method, uri: &str, body: Value, actor: crate::models::user::Model) -> axum::response::Response { + let mut req = request(method, uri, Body::from(body.to_string()), Some(actor)); + req.headers_mut().insert("content-type", "application/json".parse().unwrap()); + router.clone().oneshot(req).await.unwrap() +} + +#[tokio::test] +async fn role_permissions_over_http() { + let state = state().await; + let server = state.default_server.id; + let actor = user(&state, false).await; + let router = super::routes::router().with_state(state.clone()); + let list = format!("/roles?server_id={server}"); + + // La liste est inaccessible avant l'adhésion au serveur. + assert_eq!( + call(&router, Method::GET, &list, json!(null), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + state.repositories.server.add_user(server, actor.id).await.unwrap(); + assert_eq!( + call(&router, Method::GET, &list, json!(null), actor.clone()) + .await + .status(), + StatusCode::OK + ); + + let payload = json!({"server_id":server,"name":"test-role"}); + + // Un membre doit aussi disposer de MANAGE_ROLES pour créer un rôle. + assert_eq!( + call(&router, Method::POST, "/roles", payload.clone(), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_ROLES.bits()).await.unwrap(); + let created = call(&router, Method::POST, "/roles", payload, actor.clone()).await; + + assert_eq!(created.status(), StatusCode::CREATED); + let id: Value = serde_json::from_slice(&to_bytes(created.into_body(), 1024 * 1024).await.unwrap()).unwrap(); + let uri = format!("/roles/{}", id["id"].as_str().unwrap()); + assert_eq!( + call(&router, Method::GET, &uri, json!(null), actor.clone()) + .await + .status(), + StatusCode::OK + ); + + // Sans MANAGE_ROLES, ni la modification ni la suppression ne sont permises. + state.repositories.server.set_user_permission(server, actor.id, 0).await.unwrap(); + let update = json!({"name":"renamed","is_default":false}); + assert_eq!( + call(&router, Method::PUT, &uri, update.clone(), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + call(&router, Method::DELETE, &uri, json!(null), actor.clone()) + .await + .status(), + StatusCode::FORBIDDEN + ); + + state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_ROLES.bits()).await.unwrap(); + assert_eq!( + call(&router, Method::PUT, &uri, update, actor.clone()) + .await + .status(), + StatusCode::OK + ); + assert_eq!( + call(&router, Method::DELETE, &uri, json!(null), actor) + .await + .status(), + StatusCode::NO_CONTENT + ); +} \ No newline at end of file diff --git a/src/routes/server/handlers.rs b/src/routes/server/handlers.rs index 4e8140e..e686a3f 100644 --- a/src/routes/server/handlers.rs +++ b/src/routes/server/handlers.rs @@ -4,9 +4,10 @@ use crate::domain::dto::server::{ ServerTreeResponse, ServerUserPermissionResponse, SetServerPermissionRequest, UpdateServerRequest, }; -use crate::http::context::{CurrentUser, Superuser}; +use crate::http::context::CurrentUser; use crate::http::error::HTTPError; use crate::permissions::ServerPermission; +use crate::http::permissions::check_server_permission; use crate::routes::server::mapper; use axum::{ Json, @@ -15,25 +16,13 @@ use axum::{ }; use uuid::Uuid; -async fn require_server_permission( +pub(crate) async fn require_server_permission( state: &AppState, user: &CurrentUser, server_id: Uuid, permission: ServerPermission, ) -> Result<(), HTTPError> { - if user.is_superuser { - return Ok(()); - } - - let granted = state - .repositories - .server - .get_user_permission(server_id, user.id) - .await? - .map(|value| ServerPermission::from_bits_truncate(value.permissions as u64)) - .unwrap_or_default(); - - if granted.contains(permission) { + if check_server_permission(state, user.id, server_id, permission).await? { Ok(()) } else { Err(HTTPError::Forbidden) @@ -90,9 +79,11 @@ pub async fn get_all( tag = "Servers" )] pub async fn get_by_id( + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, HTTPError> { + state.repositories.server.get_user(id, user.id).await?.ok_or(HTTPError::NotFound)?; let server = state .repositories .server @@ -229,10 +220,11 @@ pub async fn update( ) )] pub async fn delete( - _admin: Superuser, + user: CurrentUser, State(state): State, Path(id): Path, ) -> Result { + require_server_permission(&state, &user, id, ServerPermission::MANAGE_SERVER).await?; if state.services.server.delete_server(id).await? { Ok(StatusCode::NO_CONTENT) } else { @@ -256,9 +248,11 @@ pub async fn delete( tag = "Server Permissions" )] pub async fn get_user_permission( + user: CurrentUser, State(state): State, Path((server_id, user_id)): Path<(Uuid, Uuid)>, ) -> Result, HTTPError> { + require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?; let permission = state .repositories .server @@ -278,9 +272,11 @@ pub async fn get_user_permission( tag = "Server Permissions" )] pub async fn list_user_permissions( + user: CurrentUser, State(state): State, Path(server_id): Path, ) -> Result>, HTTPError> { + require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?; state .repositories .server @@ -416,9 +412,11 @@ pub async fn remove_user_permission( tag = "Server Permissions" )] pub async fn get_role_permission( + user: CurrentUser, State(state): State, Path((server_id, role_id)): Path<(Uuid, Uuid)>, ) -> Result, HTTPError> { + require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?; let permission = state .repositories .server diff --git a/src/routes/server/mod.rs b/src/routes/server/mod.rs index a376414..55abf81 100644 --- a/src/routes/server/mod.rs +++ b/src/routes/server/mod.rs @@ -3,3 +3,6 @@ pub mod handlers; pub mod mapper; pub mod routes; pub mod service; + +#[cfg(test)] +pub(crate) mod tests; diff --git a/src/routes/server/tests.rs b/src/routes/server/tests.rs new file mode 100644 index 0000000..d0fc5ff --- /dev/null +++ b/src/routes/server/tests.rs @@ -0,0 +1,54 @@ +use crate::config::AppConfig; +use crate::core::{App, AppState}; +use crate::http::context::CurrentUser; +use crate::http::error::HTTPError; +use crate::models::user; +use crate::permissions::ServerPermission; +use axum::extract::{Path, State}; +use sea_orm::{ActiveModelTrait, Set}; +use uuid::Uuid; + +pub(crate) async fn fixture() -> (AppState, CurrentUser, Uuid) { + let mut config = AppConfig::load().unwrap(); + config.database.url = "sqlite::memory:".to_string(); + let state = App::build(config).await.unwrap().state; + let account = user::ActiveModel { + username: Set(format!("test-{}", Uuid::new_v4())), + password: Set("unused".to_string()), + is_superuser: Set(false), + ..Default::default() + } + .insert(&state.db) + .await + .unwrap(); + let server_id = state.default_server.id; + (state, CurrentUser(account), server_id) +} + +pub(crate) async fn grant(state: &AppState, user: &CurrentUser, server_id: Uuid, permission: ServerPermission) { + state.repositories.server.add_user(server_id, user.id).await.unwrap(); + state.repositories.server.set_user_permission(server_id, user.id, permission.bits()).await.unwrap(); +} + +#[tokio::test] +async fn server_read_requires_membership_and_member_can_read() { + let (state, user, server_id) = fixture().await; + + // Un utilisateur extérieur ne voit pas le serveur; un membre peut le consulter. + assert!(matches!(super::handlers::get_by_id(user.clone(), State(state.clone()), Path(server_id)).await, Err(HTTPError::NotFound))); + + grant(&state, &user, server_id, ServerPermission::empty()).await; + assert!(super::handlers::get_by_id(user, State(state), Path(server_id)).await.is_ok()); +} + +#[tokio::test] +async fn server_permission_list_requires_manage_members() { + let (state, user, server_id) = fixture().await; + grant(&state, &user, server_id, ServerPermission::empty()).await; + + // L'appartenance seule ne permet pas de gérer les permissions des membres. + assert!(matches!(super::handlers::list_user_permissions(user.clone(), State(state.clone()), Path(server_id)).await, Err(HTTPError::Forbidden))); + + state.repositories.server.set_user_permission(server_id, user.id, ServerPermission::MANAGE_MEMBERS.bits()).await.unwrap(); + assert!(super::handlers::list_user_permissions(user, State(state), Path(server_id)).await.is_ok()); +} \ No newline at end of file diff --git a/src/routes/server_item_order/handlers.rs b/src/routes/server_item_order/handlers.rs index 47849ec..da1e516 100644 --- a/src/routes/server_item_order/handlers.rs +++ b/src/routes/server_item_order/handlers.rs @@ -3,6 +3,8 @@ use crate::domain::dto::server_item_order::ReorderServerItemRequest; use crate::domain::events::server_tree::ServerTreeInvalidatedEvent; use crate::http::context::CurrentUser; use crate::http::error::HTTPError; +use crate::permissions::ServerPermission; +use crate::routes::server::handlers::require_server_permission; use axum::http::StatusCode; use axum::{Json, extract::State}; @@ -15,7 +17,7 @@ use axum::{Json, extract::State}; security(("bearerAuth" = [])) )] pub async fn reorder( - _user: CurrentUser, + user: CurrentUser, State(state): State, Json(payload): Json, ) -> Result { @@ -26,6 +28,7 @@ pub async fn reorder( .get_by_id(server_id) .await? .ok_or(HTTPError::NotFound)?; + require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_CHANNELS | ServerPermission::MANAGE_CATEGORIES).await?; state.services.server_order.reorder(payload).await?; state.event_bus.emit(ServerTreeInvalidatedEvent { diff --git a/src/routes/server_item_order/mod.rs b/src/routes/server_item_order/mod.rs index c0c696a..82060ec 100644 --- a/src/routes/server_item_order/mod.rs +++ b/src/routes/server_item_order/mod.rs @@ -1,2 +1,5 @@ pub mod handlers; pub mod routes; + +#[cfg(test)] +mod tests; diff --git a/src/routes/server_item_order/tests.rs b/src/routes/server_item_order/tests.rs new file mode 100644 index 0000000..48e546b --- /dev/null +++ b/src/routes/server_item_order/tests.rs @@ -0,0 +1,35 @@ +use crate::http::test_support::{request, state, user}; +use crate::permissions::ServerPermission; +use axum::{body::Body, http::{Method, StatusCode}}; +use serde_json::json; +use tower::ServiceExt; + +#[tokio::test] +async fn reorder_requires_both_management_permissions_over_http() { + let state = state().await; + let server = state.default_server.id; + let actor = user(&state, false).await; + state.repositories.server.add_user(server, actor.id).await.unwrap(); + let router = super::routes::router().with_state(state.clone()); + let category = state.services.category.create_category(server, "reorder-test".into()).await.unwrap(); + let payload = json!({"server_id":server,"resource_id":category.id,"resource_type":"category","parent_category_id":null,"reference":null,"position":"after"}); + + // Le réordonnancement exige les droits adaptés à la catégorie et aux canaux. + for permissions in [ + ServerPermission::empty(), + ServerPermission::MANAGE_CHANNELS, + ServerPermission::MANAGE_CATEGORIES, + ] { + state.repositories.server.set_user_permission(server, actor.id, permissions.bits()).await.unwrap(); + + let mut req = request(Method::PUT, "/server-item-orders/reorder", Body::from(payload.to_string()), Some(actor.clone())); + req.headers_mut().insert("content-type", "application/json".parse().unwrap()); + assert_eq!(router.clone().oneshot(req).await.unwrap().status(), StatusCode::FORBIDDEN); + } + + state.repositories.server.set_user_permission(server, actor.id, (ServerPermission::MANAGE_CHANNELS | ServerPermission::MANAGE_CATEGORIES).bits()).await.unwrap(); + + let mut req = request(Method::PUT, "/server-item-orders/reorder", Body::from(payload.to_string()), Some(actor)); + req.headers_mut().insert("content-type", "application/json".parse().unwrap()); + assert_eq!(router.oneshot(req).await.unwrap().status(), StatusCode::NO_CONTENT); +} \ No newline at end of file diff --git a/src/routes/user/handlers.rs b/src/routes/user/handlers.rs index a173910..6b457e9 100644 --- a/src/routes/user/handlers.rs +++ b/src/routes/user/handlers.rs @@ -28,6 +28,7 @@ use uuid::Uuid; ) )] pub async fn get_all( + _admin: Superuser, State(state): State, Query(filters): Query, ) -> Result>, HTTPError> { diff --git a/src/routes/user/routes.rs b/src/routes/user/routes.rs index cc02820..707dafa 100644 --- a/src/routes/user/routes.rs +++ b/src/routes/user/routes.rs @@ -13,3 +13,38 @@ pub fn router() -> Router { .delete(handlers::delete), ) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::test_support::{request, state, user}; + use axum::{body::Body, http::{Method, StatusCode}}; + use tower::ServiceExt; + + #[tokio::test] + async fn user_routes_require_superuser() { + let state = state().await; + let admin = user(&state, true).await; + let member = user(&state, false).await; + let target = user(&state, false).await; + let routes = router().with_state(state); + let cases = [ + (Method::GET, "/users".to_string(), ""), + (Method::GET, format!("/users/{}", target.id), ""), + (Method::POST, "/users".to_string(), r#"{"username":"new-user","password":"password123","pub_key":null,"is_superuser":false}"#), + (Method::PUT, format!("/users/{}", target.id), r#"{"username":"renamed-user","pub_key":null,"is_superuser":false}"#), + (Method::DELETE, format!("/users/{}", target.id), ""), + ]; + for (method, uri, body) in cases { + let denied = routes.clone().oneshot(request(method.clone(), &uri, Body::from(body.to_string()), Some(member.clone()))).await.unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN, "{method} {uri}"); + let missing = routes.clone().oneshot(request(method.clone(), &uri, Body::from(body.to_string()), None)).await.unwrap(); + assert_eq!(missing.status(), StatusCode::UNAUTHORIZED, "{method} {uri}"); + let mut allowed = request(method.clone(), &uri, Body::from(body.to_string()), Some(admin.clone())); + allowed.headers_mut().insert("content-type", "application/json".parse().unwrap()); + let result = routes.clone().oneshot(allowed).await.unwrap(); + let expected = if method == Method::POST { StatusCode::CREATED } else if method == Method::DELETE { StatusCode::NO_CONTENT } else { StatusCode::OK }; + assert_eq!(result.status(), expected, "{method} {uri}"); + } + } +} diff --git a/src/services/channel.rs b/src/services/channel.rs index f60035d..1315963 100644 --- a/src/services/channel.rs +++ b/src/services/channel.rs @@ -11,6 +11,7 @@ use crate::models::{channel, role}; use crate::permissions::PermissionSet; use crate::services::ServicesContext; use crate::services::permission::PermissionService; +use anyhow::Context; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; use std::sync::Arc; use uuid::Uuid; @@ -154,12 +155,7 @@ impl ChannelService { let db = &self.service_context.repositories.server.context.db; let event_bus = &self.service_context.event_bus; - let txn = db.begin().await?; - - let existing = channel::Entity::find_by_id(id) - .one(&txn) - .await? - .ok_or_else(|| anyhow::anyhow!("Channel not found"))?; + let txn = db.begin().await.context("begin channel deletion transaction")?; self.service_context .services @@ -167,13 +163,23 @@ impl ChannelService { .expect("services initialized") .server_order .remove(&txn, id, OrderedResourceType::Channel) - .await?; + .await + .context("remove channel display order")?; - let res = channel::Entity::delete_by_id(id).exec(&txn).await?; + let existing = channel::Entity::find_by_id(id) + .one(&txn) + .await + .context("load channel for deletion")? + .ok_or_else(|| anyhow::anyhow!("Channel not found"))?; + + let res = channel::Entity::delete_by_id(id) + .exec(&txn) + .await + .context("delete channel record")?; let deleted = res.rows_affected > 0; - txn.commit().await?; + txn.commit().await.context("commit channel deletion")?; if deleted { event_bus.emit(ChannelDeletedEvent { channel: existing });