add test and permission check
This commit is contained in:
+5
-3
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user