add test and permission check

This commit is contained in:
2026-09-24 21:10:11 +02:00
parent 844eaadee0
commit 910eb2b169
31 changed files with 1527 additions and 217 deletions
+5 -3
View File
@@ -23,9 +23,10 @@ pub struct RequestContext {
/// ///
/// **Usage :** /// **Usage :**
/// ```rust /// ```rust
/// pub async fn ma_vue(user: CurrentUser) { /// use oxspeak_server_lib::http::context::CurrentUser;
/// if user.is_superuser { ... } /// # fn check(user: CurrentUser) {
/// } /// # let _ = user.is_superuser;
/// # }
/// ``` /// ```
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CurrentUser(pub user::Model); pub struct CurrentUser(pub user::Model);
@@ -71,6 +72,7 @@ where
/// ///
/// **Usage :** /// **Usage :**
/// ```rust /// ```rust
/// use oxspeak_server_lib::http::context::Superuser;
/// pub async fn suppression_globale(admin: Superuser) { /// pub async fn suppression_globale(admin: Superuser) {
/// // Ici, nous sommes certains que admin.is_superuser est true. /// // Ici, nous sommes certains que admin.is_superuser est true.
/// } /// }
+1 -3
View File
@@ -79,9 +79,7 @@ impl IntoResponse for HTTPError {
.into_response(); .into_response();
} }
HTTPError::Internal(err) => { HTTPError::Internal(err) => {
// On utilise %err pour un message d'erreur clair sans backtrace brute tracing::error!(error = %format_args!("{err:#}"), "Request error");
// mais on garde les détails pour le span tracing si besoin.
tracing::error!(%err, "Request error");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")
} }
}; };
+2
View File
@@ -8,6 +8,8 @@ pub mod middleware;
pub mod permissions; pub mod permissions;
pub mod server; pub mod server;
mod tls; mod tls;
#[cfg(test)]
pub(crate) mod test_support;
pub mod validation; pub mod validation;
pub use permissions::{RequireChannelPermission, RequireServerPermission}; pub use permissions::{RequireChannelPermission, RequireServerPermission};
+81 -88
View File
@@ -1,11 +1,11 @@
// Unused
use super::context::CurrentUser; use super::context::CurrentUser;
use super::error::HTTPError; use super::error::HTTPError;
use crate::core::AppState; use crate::core::AppState;
use crate::permissions::{ChannelPermission, ServerPermission}; use crate::permissions::{ChannelPermission, ServerPermission};
use axum::extract::FromRequestParts; use axum::extract::{FromRequestParts, RawPathParams};
use axum::http::request::Parts; 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 std::ops::Deref;
use uuid::Uuid; use uuid::Uuid;
@@ -15,23 +15,21 @@ use uuid::Uuid;
/// The target `server_id` is automatically extracted from path parameters (supporting /// The target `server_id` is automatically extracted from path parameters (supporting
/// path parameters named `server_id` or `id`). /// 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 /// # Usage Example
/// ```rust /// ```rust
/// use axum::extract::State; /// use axum::extract::{Path, State};
/// use uuid::Uuid; /// use uuid::Uuid;
/// use crate::http::permissions::RequireServerPermission; /// use oxspeak_server_lib::http::permissions::RequireServerPermission;
/// use crate::permissions::ServerPermission; /// use oxspeak_server_lib::http::error::HTTPError;
/// use crate::core::AppState; /// use oxspeak_server_lib::permissions::ServerPermission;
/// use oxspeak_server_lib::core::AppState;
/// ///
/// pub async fn update_server_settings( /// 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<AppState>, /// State(state): State<AppState>,
/// Path(server_id): Path<Uuid>, /// Path(_server_id): Path<Uuid>,
/// ) -> Result<(), HTTPError> { /// ) -> Result<(), HTTPError> {
/// // User has MANAGE_SERVER or is a superuser /// // User has MANAGE_SERVER
/// Ok(()) /// Ok(())
/// } /// }
/// ``` /// ```
@@ -46,30 +44,15 @@ impl<const PERM: u64> Deref for RequireServerPermission<PERM> {
} }
} }
impl<S, const PERM: u64> FromRequestParts<S> for RequireServerPermission<PERM> impl<const PERM: u64> FromRequestParts<AppState> for RequireServerPermission<PERM> {
where
S: Send + Sync,
{
type Rejection = HTTPError; type Rejection = HTTPError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
// 1. Extract CurrentUser (which validates authentication and returns 401 if missing) // 1. Extract CurrentUser (which validates authentication and returns 401 if missing)
let current_user = CurrentUser::from_request_parts(parts, state).await?; 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 // 3. Get AppState from extensions
let app_state = match parts.extensions.get::<AppState>() {
Some(s) => s.clone(),
None => {
return Err(HTTPError::InternalServerError(
"AppState missing in request extensions".to_string(),
));
}
};
// 4. Extract server_id from path parameters. // 4. Extract server_id from path parameters.
let server_id = match extract_path_param_uuid(parts, &["server_id", "id"]) { let server_id = match extract_path_param_uuid(parts, &["server_id", "id"]) {
@@ -82,22 +65,7 @@ where
}; };
// 5. Check user permission via server repository // 5. Check user permission via server repository
let permission_result = app_state if check_server_permission(state, current_user.id, server_id, ServerPermission::from_bits_truncate(PERM)).await? {
.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) {
Ok(RequireServerPermission(current_user)) Ok(RequireServerPermission(current_user))
} else { } else {
Err(HTTPError::Forbidden) Err(HTTPError::Forbidden)
@@ -110,23 +78,21 @@ where
/// ///
/// The target `channel_id` (or `id`) is automatically extracted from path parameters. /// 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 /// # Usage Example
/// ```rust /// ```rust
/// use axum::extract::State; /// use axum::extract::{Path, State};
/// use uuid::Uuid; /// use uuid::Uuid;
/// use crate::http::permissions::RequireChannelPermission; /// use oxspeak_server_lib::http::permissions::RequireChannelPermission;
/// use crate::permissions::ChannelPermission; /// use oxspeak_server_lib::http::error::HTTPError;
/// use crate::core::AppState; /// use oxspeak_server_lib::permissions::ChannelPermission;
/// use oxspeak_server_lib::core::AppState;
/// ///
/// pub async fn read_channel_messages( /// 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<AppState>, /// State(state): State<AppState>,
/// Path(channel_id): Path<Uuid>, /// Path(_channel_id): Path<Uuid>,
/// ) -> Result<(), HTTPError> { /// ) -> Result<(), HTTPError> {
/// // User has READ_CHANNEL or is a superuser /// // User has READ_CHANNEL
/// Ok(()) /// Ok(())
/// } /// }
/// ``` /// ```
@@ -141,27 +107,13 @@ impl<const PERM: u64> Deref for RequireChannelPermission<PERM> {
} }
} }
impl<S, const PERM: u64> FromRequestParts<S> for RequireChannelPermission<PERM> impl<const PERM: u64> FromRequestParts<AppState> for RequireChannelPermission<PERM> {
where
S: Send + Sync,
{
type Rejection = HTTPError; type Rejection = HTTPError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let current_user = CurrentUser::from_request_parts(parts, state).await?; 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::<AppState>() {
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"]) { let channel_id = match extract_path_param_uuid(parts, &["channel_id", "id"]) {
Some(id) => id, Some(id) => id,
@@ -172,22 +124,7 @@ where
} }
}; };
let permission_result = app_state if check_channel_permission(state, current_user.id, channel_id, ChannelPermission::from_bits_truncate(PERM)).await? {
.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) {
Ok(RequireChannelPermission(current_user)) Ok(RequireChannelPermission(current_user))
} else { } else {
Err(HTTPError::Forbidden) Err(HTTPError::Forbidden)
@@ -198,6 +135,13 @@ where
/// Helper function to extract a Uuid path parameter matching any of the given key names /// Helper function to extract a Uuid path parameter matching any of the given key names
/// from Axum request extensions. /// from Axum request extensions.
fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> { fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> {
if let Some(params) = parts.extensions.get::<RawPathParams>() {
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 if let Some(map) = parts
.extensions .extensions
.get::<std::collections::HashMap<String, String>>() .get::<std::collections::HashMap<String, String>>()
@@ -223,3 +167,52 @@ fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> {
None None
} }
pub async fn check_server_permission(state: &AppState, user_id: Uuid, server_id: Uuid, required: ServerPermission) -> Result<bool, HTTPError> {
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<bool, HTTPError> {
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))
}
+48
View File
@@ -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<user::Model>) -> Request<Body> {
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
}
+27 -7
View File
@@ -3,8 +3,9 @@ use crate::domain::dto::attachment::AttachmentUploadResponse;
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::models::attachment; use crate::models::attachment;
use crate::permissions::ChannelPermission;
use crate::routes::attachment::mapper; 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 crate::services::media::{self, PendingMediaFile};
use axum::body::Body; use axum::body::Body;
use axum::extract::{Multipart, Path, State}; use axum::extract::{Multipart, Path, State};
@@ -75,9 +76,14 @@ pub async fn create(
.content_type() .content_type()
.unwrap_or("application/octet-stream") .unwrap_or("application/octet-stream")
.to_string(); .to_string();
if !can_access(&state, channel, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} channel,
user.id,
user.is_superuser,
ChannelPermission::ATTACH_FILES,
)
.await?;
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let mut output = PendingMediaFile::begin( let mut output = PendingMediaFile::begin(
PathBuf::from(&state.config.media.root).as_path(), PathBuf::from(&state.config.media.root).as_path(),
@@ -128,9 +134,14 @@ pub async fn create(
} }
let channel_id = let channel_id =
channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?; channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?;
if !can_access(&state, channel_id, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} channel_id,
user.id,
user.is_superuser,
ChannelPermission::ATTACH_FILES,
)
.await?;
if created.is_empty() { if created.is_empty() {
return Err(HTTPError::BadRequest( return Err(HTTPError::BadRequest(
"at least one file is required".into(), "at least one file is required".into(),
@@ -145,6 +156,7 @@ pub async fn create(
} }
pub async fn file( pub async fn file(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Response, HTTPError> { ) -> Result<Response, HTTPError> {
@@ -152,6 +164,14 @@ pub async fn file(
.one(&state.db) .one(&state.db)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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)) let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(&item.file_path))
.await .await
.map_err(|_| HTTPError::NotFound)?; .map_err(|_| HTTPError::NotFound)?;
+94
View File
@@ -15,3 +15,97 @@ pub fn secure_router() -> Router<AppState> {
pub fn public_router() -> Router<AppState> { pub fn public_router() -> Router<AppState> {
Router::new().route("/attachments/{id}/file", get(handlers::file)) 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();
}
}
+22
View File
@@ -9,3 +9,25 @@ pub fn router() -> OxRouter {
.route("/auth/bearer-login", post(handlers::login_bearer)) .route("/auth/bearer-login", post(handlers::login_bearer))
.route("/auth/me", get(handlers::me)) .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);
}
}
+16 -5
View File
@@ -2,8 +2,10 @@ use crate::core::state::AppState;
use crate::domain::dto::category::{ use crate::domain::dto::category::{
CategoryQueryParams, CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest, CategoryQueryParams, CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest,
}; };
use crate::http::context::Superuser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::permissions::ServerPermission;
use crate::routes::server::handlers::require_server_permission;
use crate::routes::category::mapper; use crate::routes::category::mapper;
use axum::{ use axum::{
Json, Json,
@@ -26,9 +28,12 @@ use uuid::Uuid;
tag = "Categories" tag = "Categories"
)] )]
pub async fn get_all( pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<CategoryQueryParams>, Query(filters): Query<CategoryQueryParams>,
) -> Result<Json<Vec<CategoryResponse>>, HTTPError> { ) -> Result<Json<Vec<CategoryResponse>>, 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 let categories = state
.repositories .repositories
.category .category
@@ -57,6 +62,7 @@ pub async fn get_all(
tag = "Categories" tag = "Categories"
)] )]
pub async fn get_by_id( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<CategoryResponse>, HTTPError> { ) -> Result<Json<CategoryResponse>, HTTPError> {
@@ -66,6 +72,7 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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))) Ok(Json(mapper::category_model_to_category_response(category)))
} }
@@ -86,7 +93,7 @@ pub async fn get_by_id(
) )
)] )]
pub async fn create( pub async fn create(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<CreateCategoryRequest>, Json(payload): Json<CreateCategoryRequest>,
) -> Result<(StatusCode, Json<CategoryResponse>), HTTPError> { ) -> Result<(StatusCode, Json<CategoryResponse>), HTTPError> {
@@ -97,6 +104,7 @@ pub async fn create(
.get_by_id(payload.server_id) .get_by_id(payload.server_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?; .ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
require_server_permission(&state, &user, payload.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
let category = state let category = state
.services .services
@@ -128,18 +136,19 @@ pub async fn create(
) )
)] )]
pub async fn update( pub async fn update(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateCategoryRequest>, Json(payload): Json<UpdateCategoryRequest>,
) -> Result<Json<CategoryResponse>, HTTPError> { ) -> Result<Json<CategoryResponse>, HTTPError> {
// Vérifier l'existence // Vérifier l'existence
let _category = state let category = state
.repositories .repositories
.category .category
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_server_permission(&state, &user, category.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
let category = state let category = state
.services .services
@@ -168,10 +177,12 @@ pub async fn update(
) )
)] )]
pub async fn delete( pub async fn delete(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
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? { if state.services.category.delete_category(id).await? {
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} else { } else {
+3
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper; pub mod mapper;
pub mod routes; pub mod routes;
pub mod service; pub mod service;
#[cfg(test)]
mod tests;
+88
View File
@@ -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
);
}
+70 -13
View File
@@ -4,8 +4,11 @@ use crate::domain::dto::channel::{
ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse, ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse,
SetChannelPermissionRequest, SetReadStateRequest, UpdateChannelRequest, SetChannelPermissionRequest, SetReadStateRequest, UpdateChannelRequest,
}; };
use crate::http::context::{CurrentUser, Superuser}; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; 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::models::{channel, channel_user};
use crate::routes::channel::mapper; use crate::routes::channel::mapper;
use axum::{ use axum::{
@@ -16,6 +19,23 @@ use axum::{
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid; use uuid::Uuid;
async fn require_channel_permission(
state: &AppState,
user: &CurrentUser,
channel_id: Uuid,
permission: ChannelPermission,
) -> Result<channel::Model, HTTPError> {
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<channel::Model, HTTPError> {
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( async fn require_channel_member(
state: &AppState, state: &AppState,
channel_id: Uuid, channel_id: Uuid,
@@ -54,16 +74,26 @@ async fn require_channel_member(
tag = "Channels" tag = "Channels"
)] )]
pub async fn get_all( pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<ChannelQueryParams>, Query(filters): Query<ChannelQueryParams>,
) -> Result<Json<Vec<ChannelResponse>>, HTTPError> { ) -> Result<Json<Vec<ChannelResponse>>, 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 params = mapper::query_params_to_channel_filter(filters);
let channels = state.repositories.channel.filter(params).await?; let channels = state.repositories.channel.filter(params).await?;
Ok(Json( Ok(Json(
channels {
.into_iter() let mut visible = Vec::new();
.map(mapper::channel_model_to_channel_response) for channel in channels {
.collect(), 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<AppState>, State(state): State<AppState>,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
) -> Result<Json<ReadStateResponse>, HTTPError> { ) -> Result<Json<ReadStateResponse>, 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 let read_state = state
.repositories .repositories
.read_state .read_state
@@ -120,7 +150,7 @@ pub async fn set_read_state(
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
Json(payload): Json<SetReadStateRequest>, Json(payload): Json<SetReadStateRequest>,
) -> Result<Json<ReadStateResponse>, HTTPError> { ) -> Result<Json<ReadStateResponse>, 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 { if let Some(message_id) = payload.last_read_message_id {
let message = state let message = state
@@ -173,6 +203,7 @@ pub async fn set_read_state(
tag = "Channels" tag = "Channels"
)] )]
pub async fn get_by_id( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<ChannelResponse>, HTTPError> { ) -> Result<Json<ChannelResponse>, HTTPError> {
@@ -182,6 +213,7 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_channel_permission(&state, &user, id, ChannelPermission::READ_CHANNEL).await?;
Ok(Json(mapper::channel_model_to_channel_response(channel))) Ok(Json(mapper::channel_model_to_channel_response(channel)))
} }
@@ -195,9 +227,11 @@ pub async fn get_by_id(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn list_permissions( pub async fn list_permissions(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
) -> Result<Json<ChannelPermissionsResponse>, HTTPError> { ) -> Result<Json<ChannelPermissionsResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
state state
.repositories .repositories
.channel .channel
@@ -227,7 +261,7 @@ pub async fn list_permissions(
) )
)] )]
pub async fn create( pub async fn create(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<CreateChannelRequest>, Json(payload): Json<CreateChannelRequest>,
) -> Result<(StatusCode, Json<ChannelResponse>), HTTPError> { ) -> Result<(StatusCode, Json<ChannelResponse>), HTTPError> {
@@ -239,16 +273,20 @@ pub async fn create(
.get_by_id(server_id) .get_by_id(server_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?; .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 // Vérifier que la catégorie existe si fournie
if let Some(category_id) = payload.category_id { if let Some(category_id) = payload.category_id {
state let category = state
.repositories .repositories
.category .category
.get_by_id(category_id) .get_by_id(category_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?; .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?; let channel = state.services.channel.create_channel(payload).await?;
@@ -278,18 +316,23 @@ pub async fn create(
) )
)] )]
pub async fn update( pub async fn update(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateChannelRequest>, Json(payload): Json<UpdateChannelRequest>,
) -> Result<Json<ChannelResponse>, HTTPError> { ) -> Result<Json<ChannelResponse>, HTTPError> {
// Vérifier l'existence // Vérifier l'existence
state let original = state
.repositories .repositories
.channel .channel
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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 // Vérifier que le serveur existe si fourni
if let Some(server_id) = payload.server_id { 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 // Vérifier que la catégorie existe si fournie
if let Some(category_id) = payload.category_id { if let Some(category_id) = payload.category_id {
state let category = state
.repositories .repositories
.category .category
.get_by_id(category_id) .get_by_id(category_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?; .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?; let channel = state.services.channel.update_channel(id, payload).await?;
@@ -334,10 +378,11 @@ pub async fn update(
) )
)] )]
pub async fn delete( pub async fn delete(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_channel_manager(&state, &user, id).await?;
if state.services.channel.delete_channel(id).await? { if state.services.channel.delete_channel(id).await? {
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} else { } else {
@@ -361,9 +406,11 @@ pub async fn delete(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn get_user_permission( pub async fn get_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, user_id)): Path<(Uuid, Uuid)>, Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> { ) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
let permission = state let permission = state
.repositories .repositories
.channel .channel
@@ -392,10 +439,12 @@ pub async fn get_user_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn set_user_permission( pub async fn set_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, user_id)): Path<(Uuid, Uuid)>, Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SetChannelPermissionRequest>, Json(payload): Json<SetChannelPermissionRequest>,
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> { ) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
state state
.services .services
.channel .channel
@@ -430,9 +479,11 @@ pub async fn set_user_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn remove_user_permission( pub async fn remove_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, user_id)): Path<(Uuid, Uuid)>, Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
if state if state
.repositories .repositories
.channel .channel
@@ -468,9 +519,11 @@ pub async fn remove_user_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn get_role_permission( pub async fn get_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, role_id)): Path<(Uuid, Uuid)>, Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> { ) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
let permission = state let permission = state
.repositories .repositories
.channel .channel
@@ -499,10 +552,12 @@ pub async fn get_role_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn set_role_permission( pub async fn set_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, role_id)): Path<(Uuid, Uuid)>, Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SetChannelPermissionRequest>, Json(payload): Json<SetChannelPermissionRequest>,
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> { ) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
state state
.services .services
.channel .channel
@@ -537,9 +592,11 @@ pub async fn set_role_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn remove_role_permission( pub async fn remove_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, role_id)): Path<(Uuid, Uuid)>, Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
if state if state
.repositories .repositories
.channel .channel
+3
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper; pub mod mapper;
pub mod routes; pub mod routes;
pub mod service; pub mod service;
#[cfg(test)]
mod tests;
+126
View File
@@ -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
);
}
+38
View File
@@ -10,3 +10,41 @@ pub fn router() -> Router<AppState> {
.route("/conversations", get(handlers::list).post(handlers::create)) .route("/conversations", get(handlers::list).post(handlers::create))
.route("/conversations/{id}/fork", post(handlers::fork)) .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::<serde_json::Value>(&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}");
}
}
}
+38 -1
View File
@@ -1,4 +1,7 @@
use crate::domain::events::emoji::{EmojiCreatedEvent, EmojiDeletedEvent, EmojiUpdatedEvent}; 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::services::media;
use crate::{ use crate::{
core::state::AppState, core::state::AppState,
@@ -29,20 +32,38 @@ fn normalize_type(value: &str) -> Result<String, HTTPError> {
} }
} }
async fn require_manage(
state: &AppState,
user: &CurrentUser,
server_id: Option<Uuid>,
) -> 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")] #[utoipa::path(get, path = "/emojis", params(EmojiQueryParams), responses((status = 200, body = [crate::domain::dto::emoji::EmojiResponse])), tag = "Emojis")]
pub async fn get_all( pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<EmojiQueryParams>, Query(query): Query<EmojiQueryParams>,
) -> Result<Json<Vec<crate::domain::dto::emoji::EmojiResponse>>, HTTPError> { ) -> Result<Json<Vec<crate::domain::dto::emoji::EmojiResponse>>, HTTPError> {
let mut result = Vec::new(); let mut result = Vec::new();
for emoji in state.repositories.emoji.list(query.server_id).await? { 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)) Ok(Json(result))
} }
#[utoipa::path(get, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] #[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( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<crate::domain::dto::emoji::EmojiResponse>, HTTPError> { ) -> Result<Json<crate::domain::dto::emoji::EmojiResponse>, HTTPError> {
@@ -52,11 +73,15 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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))) Ok(Json(mapper::response(emoji)))
} }
#[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] #[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")]
pub async fn create( pub async fn create(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<(StatusCode, Json<crate::domain::dto::emoji::EmojiResponse>), HTTPError> { ) -> Result<(StatusCode, Json<crate::domain::dto::emoji::EmojiResponse>), HTTPError> {
@@ -112,6 +137,7 @@ pub async fn create(
"file is required for custom emojis".into(), "file is required for custom emojis".into(),
)); ));
} }
require_manage(&state, &user, server_id).await?;
if let Some(server_id) = server_id { if let Some(server_id) = server_id {
state state
.repositories .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")] #[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( pub async fn update(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateEmojiRequest>, Json(payload): Json<UpdateEmojiRequest>,
@@ -173,6 +200,10 @@ pub async fn update(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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_server_id = payload.server_id.or(existing.server_id);
let target_name = payload.name.as_deref().unwrap_or(&existing.name); let target_name = payload.name.as_deref().unwrap_or(&existing.name);
state state
@@ -219,6 +250,7 @@ fn detect_mime(bytes: &[u8]) -> Option<String> {
#[utoipa::path(delete, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 204)), tag = "Emojis")] #[utoipa::path(delete, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 204)), tag = "Emojis")]
pub async fn delete( pub async fn delete(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
@@ -228,6 +260,7 @@ pub async fn delete(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_manage(&state, &user, model.server_id).await?;
let deleted = state.repositories.emoji.delete(id).await?; let deleted = state.repositories.emoji.delete(id).await?;
if deleted { if deleted {
EmojiService::remove_asset( EmojiService::remove_asset(
@@ -243,6 +276,7 @@ pub async fn delete(
} }
pub async fn asset( pub async fn asset(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Response, HTTPError> { ) -> Result<Response, HTTPError> {
@@ -252,6 +286,9 @@ pub async fn asset(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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 path = model.file_path.ok_or(HTTPError::NotFound)?;
let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(path)) let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(path))
.await .await
+161
View File
@@ -13,3 +13,164 @@ pub fn router() -> Router<AppState> {
) )
.route("/emojis/{id}/asset", get(handlers::asset)) .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
);
}
}
}
+112 -41
View File
@@ -6,36 +6,59 @@ use crate::domain::dto::message::{
use crate::domain::dto::reaction::{CreateReactionRequest, DeleteReactionQuery, ReactionResponse}; use crate::domain::dto::reaction::{CreateReactionRequest, DeleteReactionQuery, ReactionResponse};
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; 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 crate::routes::message::mapper;
use axum::{ use axum::{
Json, Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid; use uuid::Uuid;
pub(crate) async fn can_access( pub(crate) async fn require_channel_permission(
state: &AppState, state: &AppState,
channel_id: Uuid, channel_id: Uuid,
user_id: Uuid, user_id: Uuid,
) -> Result<bool, HTTPError> { is_superuser: bool,
let Some(channel) = channel::Entity::find_by_id(channel_id) required: ChannelPermission,
.one(&state.db) ) -> Result<(), HTTPError> {
.await? let _ = is_superuser;
else { if check_channel_permission(state, user_id, channel_id, required).await? {
return Ok(false); Ok(())
}; } else {
if channel.channel_type != channel::ChannelType::DM { Err(HTTPError::Forbidden)
return Ok(true); }
}
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 /// 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); let params = mapper::query_params_to_message_filter(filters);
if let Some(channel_id) = params.channel_id { let channel_id = params
if !can_access(&state, channel_id, user.id).await? { .channel_id
return Err(HTTPError::Forbidden); .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 page = state.repositories.message.filter(params).await?;
let message_ids: Vec<_> = page.messages.iter().map(|message| message.id).collect(); let message_ids: Vec<_> = page.messages.iter().map(|message| message.id).collect();
let mut reactions = state let mut reactions = state
@@ -126,9 +155,14 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
if !can_access(&state, message.channel_id, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} message.channel_id,
user.id,
user.is_superuser,
ChannelPermission::READ_CHANNEL,
)
.await?;
let reactions = state let reactions = state
.services .services
@@ -180,9 +214,11 @@ pub async fn create(
.get_by_id(payload.channel_id) .get_by_id(payload.channel_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Channel not found".to_string()))?; .ok_or(HTTPError::BadRequest("Channel not found".to_string()))?;
if !can_access(&state, channel.id, user.id).await? { let mut required = ChannelPermission::SEND_MESSAGE;
return Err(HTTPError::Forbidden); 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() { if payload.content.trim().is_empty() && payload.file_ids.is_empty() {
return Err(HTTPError::BadRequest( return Err(HTTPError::BadRequest(
@@ -192,7 +228,7 @@ pub async fn create(
// Optionnel: vérifier reply_to_id // Optionnel: vérifier reply_to_id
if let Some(reply_id) = payload.reply_to_id { if let Some(reply_id) = payload.reply_to_id {
state let parent = state
.repositories .repositories
.message .message
.get_by_id(reply_id) .get_by_id(reply_id)
@@ -200,6 +236,11 @@ pub async fn create(
.ok_or(HTTPError::BadRequest( .ok_or(HTTPError::BadRequest(
"Parent message not found".to_string(), "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 let message = state
@@ -274,9 +315,19 @@ pub async fn update(
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
// Vérifier que l'utilisateur est l'auteur // Vérifier que l'utilisateur est l'auteur
if message.user_id != user.id && !user.is_superuser { let required = if message.user_id == user.id {
return Err(HTTPError::Forbidden); 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 let message = state
.services .services
@@ -332,9 +383,14 @@ pub async fn add_reaction(
.get_by_id(message_id) .get_by_id(message_id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
if !can_access(&state, message.channel_id, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} message.channel_id,
user.id,
user.is_superuser,
ChannelPermission::READ_CHANNEL | ChannelPermission::ADD_REACTIONS,
)
.await?;
let (reaction, created) = state let (reaction, created) = state
.services .services
.message_reaction .message_reaction
@@ -378,9 +434,14 @@ pub async fn remove_reaction(
.get_by_id(message_id) .get_by_id(message_id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
if !can_access(&state, message.channel_id, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} message.channel_id,
user.id,
user.is_superuser,
ChannelPermission::READ_CHANNEL,
)
.await?;
state state
.services .services
.message_reaction .message_reaction
@@ -420,9 +481,19 @@ pub async fn delete(
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
if message.user_id != user.id && !user.is_superuser { let required = if message.user_id == user.id {
return Err(HTTPError::Forbidden); 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 let attachment_ids: Vec<_> = state
.repositories .repositories
+324
View File
@@ -20,3 +20,327 @@ pub fn router() -> Router<AppState> {
axum::routing::delete(handlers::remove_reaction), 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<AppState>,
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
);
}
}
+12 -30
View File
@@ -6,6 +6,7 @@ use crate::domain::dto::user::UserResponse;
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::permissions::ServerPermission; use crate::permissions::ServerPermission;
use crate::routes::server::handlers::require_server_permission;
use crate::routes::role::mapper; use crate::routes::role::mapper;
use crate::routes::user::mapper as user_mapper; use crate::routes::user::mapper as user_mapper;
use axum::{ use axum::{
@@ -15,36 +16,15 @@ use axum::{
}; };
use uuid::Uuid; 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")] #[utoipa::path(get, path = "/roles", params(RoleQueryParams), responses((status = 200, body = [RoleResponse])), tag = "Roles")]
pub async fn get_all( pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<RoleQueryParams>, Query(filters): Query<RoleQueryParams>,
) -> Result<Json<Vec<RoleResponse>>, HTTPError> { ) -> Result<Json<Vec<RoleResponse>>, 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 { let roles = match filters.server_id {
Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?, Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?,
None => state.repositories.role.get_all().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")] #[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( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<RoleResponse>, HTTPError> { ) -> Result<Json<RoleResponse>, HTTPError> {
@@ -69,6 +50,7 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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))) Ok(Json(mapper::role_model_to_role_response(role)))
} }
@@ -84,7 +66,7 @@ pub async fn create(
.get_by_id(payload.server_id) .get_by_id(payload.server_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?; .ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
payload.server_id, payload.server_id,
@@ -116,7 +98,7 @@ pub async fn update(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
@@ -148,7 +130,7 @@ pub async fn delete(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
@@ -174,7 +156,7 @@ pub async fn get_members(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
@@ -201,7 +183,7 @@ pub async fn add_member(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
@@ -235,7 +217,7 @@ pub async fn remove_member(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
+3
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper; pub mod mapper;
pub mod routes; pub mod routes;
pub mod service; pub mod service;
#[cfg(test)]
mod tests;
+89
View File
@@ -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
);
}
+14 -16
View File
@@ -4,9 +4,10 @@ use crate::domain::dto::server::{
ServerTreeResponse, ServerUserPermissionResponse, SetServerPermissionRequest, ServerTreeResponse, ServerUserPermissionResponse, SetServerPermissionRequest,
UpdateServerRequest, UpdateServerRequest,
}; };
use crate::http::context::{CurrentUser, Superuser}; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::permissions::ServerPermission; use crate::permissions::ServerPermission;
use crate::http::permissions::check_server_permission;
use crate::routes::server::mapper; use crate::routes::server::mapper;
use axum::{ use axum::{
Json, Json,
@@ -15,25 +16,13 @@ use axum::{
}; };
use uuid::Uuid; use uuid::Uuid;
async fn require_server_permission( pub(crate) async fn require_server_permission(
state: &AppState, state: &AppState,
user: &CurrentUser, user: &CurrentUser,
server_id: Uuid, server_id: Uuid,
permission: ServerPermission, permission: ServerPermission,
) -> Result<(), HTTPError> { ) -> Result<(), HTTPError> {
if user.is_superuser { if check_server_permission(state, user.id, server_id, permission).await? {
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(()) Ok(())
} else { } else {
Err(HTTPError::Forbidden) Err(HTTPError::Forbidden)
@@ -90,9 +79,11 @@ pub async fn get_all(
tag = "Servers" tag = "Servers"
)] )]
pub async fn get_by_id( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<ServerResponse>, HTTPError> { ) -> Result<Json<ServerResponse>, HTTPError> {
state.repositories.server.get_user(id, user.id).await?.ok_or(HTTPError::NotFound)?;
let server = state let server = state
.repositories .repositories
.server .server
@@ -229,10 +220,11 @@ pub async fn update(
) )
)] )]
pub async fn delete( pub async fn delete(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_server_permission(&state, &user, id, ServerPermission::MANAGE_SERVER).await?;
if state.services.server.delete_server(id).await? { if state.services.server.delete_server(id).await? {
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} else { } else {
@@ -256,9 +248,11 @@ pub async fn delete(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn get_user_permission( pub async fn get_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((server_id, user_id)): Path<(Uuid, Uuid)>, Path((server_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ServerUserPermissionResponse>, HTTPError> { ) -> Result<Json<ServerUserPermissionResponse>, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
let permission = state let permission = state
.repositories .repositories
.server .server
@@ -278,9 +272,11 @@ pub async fn get_user_permission(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn list_user_permissions( pub async fn list_user_permissions(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(server_id): Path<Uuid>, Path(server_id): Path<Uuid>,
) -> Result<Json<Vec<ServerUserPermissionResponse>>, HTTPError> { ) -> Result<Json<Vec<ServerUserPermissionResponse>>, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
state state
.repositories .repositories
.server .server
@@ -416,9 +412,11 @@ pub async fn remove_user_permission(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn get_role_permission( pub async fn get_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((server_id, role_id)): Path<(Uuid, Uuid)>, Path((server_id, role_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ServerRolePermissionResponse>, HTTPError> { ) -> Result<Json<ServerRolePermissionResponse>, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
let permission = state let permission = state
.repositories .repositories
.server .server
+3
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper; pub mod mapper;
pub mod routes; pub mod routes;
pub mod service; pub mod service;
#[cfg(test)]
pub(crate) mod tests;
+54
View File
@@ -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());
}
+4 -1
View File
@@ -3,6 +3,8 @@ use crate::domain::dto::server_item_order::ReorderServerItemRequest;
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent; use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::permissions::ServerPermission;
use crate::routes::server::handlers::require_server_permission;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::{Json, extract::State}; use axum::{Json, extract::State};
@@ -15,7 +17,7 @@ use axum::{Json, extract::State};
security(("bearerAuth" = [])) security(("bearerAuth" = []))
)] )]
pub async fn reorder( pub async fn reorder(
_user: CurrentUser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ReorderServerItemRequest>, Json(payload): Json<ReorderServerItemRequest>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
@@ -26,6 +28,7 @@ pub async fn reorder(
.get_by_id(server_id) .get_by_id(server_id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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.services.server_order.reorder(payload).await?;
state.event_bus.emit(ServerTreeInvalidatedEvent { state.event_bus.emit(ServerTreeInvalidatedEvent {
+3
View File
@@ -1,2 +1,5 @@
pub mod handlers; pub mod handlers;
pub mod routes; pub mod routes;
#[cfg(test)]
mod tests;
+35
View File
@@ -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);
}
+1
View File
@@ -28,6 +28,7 @@ use uuid::Uuid;
) )
)] )]
pub async fn get_all( pub async fn get_all(
_admin: Superuser,
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<UserQueryParams>, Query(filters): Query<UserQueryParams>,
) -> Result<Json<Vec<UserResponse>>, HTTPError> { ) -> Result<Json<Vec<UserResponse>>, HTTPError> {
+35
View File
@@ -13,3 +13,38 @@ pub fn router() -> Router<AppState> {
.delete(handlers::delete), .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}");
}
}
}
+15 -9
View File
@@ -11,6 +11,7 @@ use crate::models::{channel, role};
use crate::permissions::PermissionSet; use crate::permissions::PermissionSet;
use crate::services::ServicesContext; use crate::services::ServicesContext;
use crate::services::permission::PermissionService; use crate::services::permission::PermissionService;
use anyhow::Context;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -154,12 +155,7 @@ impl ChannelService {
let db = &self.service_context.repositories.server.context.db; let db = &self.service_context.repositories.server.context.db;
let event_bus = &self.service_context.event_bus; let event_bus = &self.service_context.event_bus;
let txn = db.begin().await?; let txn = db.begin().await.context("begin channel deletion transaction")?;
let existing = channel::Entity::find_by_id(id)
.one(&txn)
.await?
.ok_or_else(|| anyhow::anyhow!("Channel not found"))?;
self.service_context self.service_context
.services .services
@@ -167,13 +163,23 @@ impl ChannelService {
.expect("services initialized") .expect("services initialized")
.server_order .server_order
.remove(&txn, id, OrderedResourceType::Channel) .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; let deleted = res.rows_affected > 0;
txn.commit().await?; txn.commit().await.context("commit channel deletion")?;
if deleted { if deleted {
event_bus.emit(ChannelDeletedEvent { channel: existing }); event_bus.emit(ChannelDeletedEvent { channel: existing });