Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I28cf5b7b7cff8e90e123d624d97cf9656a6a6964
337 lines
9.8 KiB
Rust
337 lines
9.8 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::{Path, State},
|
|
};
|
|
use pinakes_core::users::{CreateUserRequest, UpdateUserRequest, UserId};
|
|
|
|
use crate::{
|
|
dto::{
|
|
GrantLibraryAccessRequest,
|
|
RevokeLibraryAccessRequest,
|
|
UserLibraryResponse,
|
|
UserResponse,
|
|
},
|
|
error::ApiError,
|
|
state::AppState,
|
|
};
|
|
|
|
/// List all users (admin only)
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/v1/admin/users",
|
|
tag = "users",
|
|
responses(
|
|
(status = 200, description = "List of users", body = Vec<UserResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn list_users(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<UserResponse>>, ApiError> {
|
|
let users = state.storage.list_users().await?;
|
|
Ok(Json(users.into_iter().map(UserResponse::from).collect()))
|
|
}
|
|
|
|
/// Create a new user (admin only)
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/v1/admin/users",
|
|
tag = "users",
|
|
request_body(
|
|
content = inline(serde_json::Value),
|
|
description = "username, password, role, and optional profile fields",
|
|
content_type = "application/json"
|
|
),
|
|
responses(
|
|
(status = 200, description = "User created", body = UserResponse),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
(status = 500, description = "Internal server error"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn create_user(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<CreateUserRequest>,
|
|
) -> Result<Json<UserResponse>, ApiError> {
|
|
// Validate username
|
|
if req.username.is_empty() || req.username.len() > 255 {
|
|
return Err(ApiError(
|
|
pinakes_core::error::PinakesError::InvalidOperation(
|
|
"username must be 1-255 characters".into(),
|
|
),
|
|
));
|
|
}
|
|
|
|
// Validate password
|
|
if req.password.len() < 8 {
|
|
return Err(ApiError(
|
|
pinakes_core::error::PinakesError::InvalidOperation(
|
|
"password must be at least 8 characters".into(),
|
|
),
|
|
));
|
|
}
|
|
|
|
// Hash password
|
|
let password_hash = pinakes_core::users::auth::hash_password(&req.password)?;
|
|
|
|
// Create user - rely on DB unique constraint for username to avoid TOCTOU
|
|
// race
|
|
let user = state
|
|
.storage
|
|
.create_user(&req.username, &password_hash, req.role, req.profile)
|
|
.await
|
|
.map_err(|e| {
|
|
// Map unique constraint violations to a user-friendly conflict error
|
|
let err_str = e.to_string();
|
|
if err_str.contains("UNIQUE")
|
|
|| err_str.contains("unique")
|
|
|| err_str.contains("duplicate key")
|
|
{
|
|
ApiError(pinakes_core::error::PinakesError::DuplicateHash(
|
|
"username already exists".into(),
|
|
))
|
|
} else {
|
|
ApiError(e)
|
|
}
|
|
})?;
|
|
|
|
Ok(Json(UserResponse::from(user)))
|
|
}
|
|
|
|
/// Get a specific user by ID
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/v1/admin/users/{id}",
|
|
tag = "users",
|
|
params(("id" = String, Path, description = "User ID")),
|
|
responses(
|
|
(status = 200, description = "User details", body = UserResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
(status = 404, description = "Not found"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn get_user(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<UserResponse>, ApiError> {
|
|
let user_id: UserId =
|
|
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
"Invalid user ID".into(),
|
|
))
|
|
})?;
|
|
|
|
let user = state.storage.get_user(user_id).await?;
|
|
Ok(Json(UserResponse::from(user)))
|
|
}
|
|
|
|
/// Update a user
|
|
#[utoipa::path(
|
|
patch,
|
|
path = "/api/v1/admin/users/{id}",
|
|
tag = "users",
|
|
params(("id" = String, Path, description = "User ID")),
|
|
request_body(
|
|
content = inline(serde_json::Value),
|
|
description = "Optional password, role, or profile fields to update",
|
|
content_type = "application/json"
|
|
),
|
|
responses(
|
|
(status = 200, description = "User updated", body = UserResponse),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
(status = 404, description = "Not found"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn update_user(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<UpdateUserRequest>,
|
|
) -> Result<Json<UserResponse>, ApiError> {
|
|
let user_id: UserId =
|
|
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
"Invalid user ID".into(),
|
|
))
|
|
})?;
|
|
|
|
// Hash password if provided
|
|
let password_hash = if let Some(ref password) = req.password {
|
|
if password.len() < 8 {
|
|
return Err(ApiError(
|
|
pinakes_core::error::PinakesError::InvalidOperation(
|
|
"password must be at least 8 characters".into(),
|
|
),
|
|
));
|
|
}
|
|
Some(pinakes_core::users::auth::hash_password(password)?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let user = state
|
|
.storage
|
|
.update_user(user_id, password_hash.as_deref(), req.role, req.profile)
|
|
.await?;
|
|
|
|
Ok(Json(UserResponse::from(user)))
|
|
}
|
|
|
|
/// Delete a user (admin only)
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/v1/admin/users/{id}",
|
|
tag = "users",
|
|
params(("id" = String, Path, description = "User ID")),
|
|
responses(
|
|
(status = 200, description = "User deleted"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
(status = 404, description = "Not found"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn delete_user(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let user_id: UserId =
|
|
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
"Invalid user ID".into(),
|
|
))
|
|
})?;
|
|
|
|
state.storage.delete_user(user_id).await?;
|
|
Ok(Json(serde_json::json!({"deleted": true})))
|
|
}
|
|
|
|
/// Get user's accessible libraries
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/v1/admin/users/{id}/libraries",
|
|
tag = "users",
|
|
params(("id" = String, Path, description = "User ID")),
|
|
responses(
|
|
(status = 200, description = "User libraries", body = Vec<UserLibraryResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn get_user_libraries(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<Vec<UserLibraryResponse>>, ApiError> {
|
|
let user_id: UserId =
|
|
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
"Invalid user ID".into(),
|
|
))
|
|
})?;
|
|
|
|
let libraries = state.storage.get_user_libraries(user_id).await?;
|
|
Ok(Json(
|
|
libraries
|
|
.into_iter()
|
|
.map(UserLibraryResponse::from)
|
|
.collect(),
|
|
))
|
|
}
|
|
|
|
fn validate_root_path(path: &str) -> Result<(), ApiError> {
|
|
if path.is_empty() || path.len() > 4096 {
|
|
return Err(ApiError::bad_request("root_path must be 1-4096 bytes"));
|
|
}
|
|
if !path.starts_with('/') {
|
|
return Err(ApiError::bad_request("root_path must be an absolute path"));
|
|
}
|
|
if path.split('/').any(|segment| segment == "..") {
|
|
return Err(ApiError::bad_request(
|
|
"root_path must not contain '..' traversal components",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Grant library access to a user (admin only)
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/v1/admin/users/{id}/libraries",
|
|
tag = "users",
|
|
params(("id" = String, Path, description = "User ID")),
|
|
request_body = GrantLibraryAccessRequest,
|
|
responses(
|
|
(status = 200, description = "Access granted"),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn grant_library_access(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<GrantLibraryAccessRequest>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
validate_root_path(&req.root_path)?;
|
|
let user_id: UserId =
|
|
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
"Invalid user ID".into(),
|
|
))
|
|
})?;
|
|
|
|
state
|
|
.storage
|
|
.grant_library_access(user_id, &req.root_path, req.permission)
|
|
.await?;
|
|
|
|
Ok(Json(serde_json::json!({"granted": true})))
|
|
}
|
|
|
|
/// Revoke library access from a user (admin only)
|
|
///
|
|
/// Uses a JSON body instead of a path parameter because `root_path` may contain
|
|
/// slashes that conflict with URL routing.
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/v1/admin/users/{id}/libraries",
|
|
tag = "users",
|
|
params(("id" = String, Path, description = "User ID")),
|
|
request_body = RevokeLibraryAccessRequest,
|
|
responses(
|
|
(status = 200, description = "Access revoked"),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn revoke_library_access(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<RevokeLibraryAccessRequest>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
validate_root_path(&req.root_path)?;
|
|
let user_id: UserId =
|
|
id.parse::<uuid::Uuid>().map(UserId::from).map_err(|_| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
"Invalid user ID".into(),
|
|
))
|
|
})?;
|
|
|
|
state
|
|
.storage
|
|
.revoke_library_access(user_id, &req.root_path)
|
|
.await?;
|
|
Ok(Json(serde_json::json!({"revoked": true})))
|
|
}
|