feat(api): can grant external user (via email)
- add possibility to grant an external user.
- route /api/users/{id} added (rate limited for security)
- security: start route limitation for external users
ex: they must not browse /api/users/{id} nor addressbook
This commit is contained in:
@@ -254,7 +254,22 @@ pub async fn list_address_books(
|
||||
})
|
||||
.collect();
|
||||
|
||||
if state.expose_system_users && state.auth_service.is_some() {
|
||||
// Skip the system address book for external callers so they
|
||||
// don't see an internal-user directory entry (let alone its
|
||||
// contents). The system book is only useful to internal
|
||||
// users picking sharees out of the directory.
|
||||
let hide_system_for_external = match state.auth_service.as_ref() {
|
||||
Some(svc) => {
|
||||
crate::interfaces::middleware::user::require_internal_user(svc, auth_user.id)
|
||||
.await
|
||||
.is_err()
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if state.expose_system_users
|
||||
&& state.auth_service.is_some()
|
||||
&& !hide_system_for_external
|
||||
{
|
||||
let now = Utc::now();
|
||||
response.push(AddressBookResponse {
|
||||
id: SYSTEM_BOOK_ID.to_string(),
|
||||
@@ -451,6 +466,14 @@ pub async fn list_contacts(
|
||||
let Some(auth_service) = &state.auth_service else {
|
||||
return system_book_unavailable();
|
||||
};
|
||||
// External callers must not enumerate the internal-user
|
||||
// directory through the system address book.
|
||||
if let Err(e) =
|
||||
crate::interfaces::middleware::user::require_internal_user(auth_service, auth_user.id)
|
||||
.await
|
||||
{
|
||||
return e.into_response();
|
||||
}
|
||||
let caller_id = auth_user.id.to_string();
|
||||
match auth_service.list_users(params.limit, params.offset).await {
|
||||
Ok(users) => {
|
||||
@@ -562,6 +585,12 @@ pub async fn get_contact(
|
||||
let Some(auth_service) = &state.auth_service else {
|
||||
return system_book_unavailable();
|
||||
};
|
||||
if let Err(e) =
|
||||
crate::interfaces::middleware::user::require_internal_user(auth_service, auth_user.id)
|
||||
.await
|
||||
{
|
||||
return e.into_response();
|
||||
}
|
||||
let Ok(uuid) = Uuid::parse_str(&contact_id) else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod search_handler;
|
||||
pub mod share_handler;
|
||||
pub mod subject_group_handler;
|
||||
pub mod trash_handler;
|
||||
pub mod users_handler;
|
||||
pub mod webdav_handler;
|
||||
pub mod wopi_handler;
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! User-profile lookup for the frontend.
|
||||
//!
|
||||
//! `GET /api/users/{id}` returns a [`UserDto`] for the target user iff
|
||||
//! the authenticated caller has a legitimate relationship with them.
|
||||
//! The visibility rule lives in
|
||||
//! [`AuthApplicationService::get_user_profile`] — handlers never embed
|
||||
//! their own authz check (CLAUDE.md § Authorization).
|
||||
//!
|
||||
//! In addition to the per-request visibility check, every call is
|
||||
//! throttled by a per-caller sliding-window limiter (60/min) so that a
|
||||
//! stale JWT can't iterate UUIDs against the related-by-grant branch
|
||||
//! of the visibility rule. The limiter shares the same `RateLimiter`
|
||||
//! type as the login / register / refresh middlewares; this handler
|
||||
//! invokes it inline rather than through a layer because the key is
|
||||
//! the authenticated caller_id (not the client IP).
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Build the `/users` router — mounted at the `/api/users` prefix by
|
||||
/// `main.rs`. Auth + CSRF middlewares are applied by the caller.
|
||||
pub fn user_routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/{id}", get(get_user_profile))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/users/{id}",
|
||||
params(("id" = String, Path, description = "User UUID")),
|
||||
responses(
|
||||
(status = 200, description = "Profile of a user the caller can see"),
|
||||
(status = 404, description = "User does not exist OR caller has no visibility (anti-enumeration: indistinguishable)"),
|
||||
(status = 429, description = "Per-caller rate limit exceeded"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "users",
|
||||
)]
|
||||
async fn get_user_profile(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(target_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let caller_id = auth_user.id;
|
||||
|
||||
// Rate limit FIRST so an attacker can't exhaust the visibility
|
||||
// query (which touches `access_grants`) by hammering with random
|
||||
// UUIDs.
|
||||
if let Err(()) = state
|
||||
.user_profile_rate_limiter
|
||||
.check_and_increment(&caller_id.to_string())
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Too many user lookups; please retry shortly",
|
||||
"RateLimited",
|
||||
));
|
||||
}
|
||||
|
||||
let auth_svc = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
|
||||
let pool = state
|
||||
.db_pool
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Database pool not available"))?;
|
||||
|
||||
let dto = auth_svc
|
||||
.auth_application_service
|
||||
.get_user_profile(
|
||||
caller_id,
|
||||
target_id,
|
||||
state.core.config.features.expose_system_users,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(Json(dto))
|
||||
}
|
||||
@@ -571,6 +571,13 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.with_state(app_state.clone());
|
||||
router = router.nest("/groups", group_router);
|
||||
|
||||
// Per-user profile lookup `/api/users/{id}` — authenticated only,
|
||||
// throttled by a per-caller limiter inside the handler. External
|
||||
// callers are 403'd in the service layer.
|
||||
let users_router = crate::interfaces::api::handlers::users_handler::user_routes()
|
||||
.with_state(app_state.clone());
|
||||
router = router.nest("/users", users_router);
|
||||
|
||||
// Transparent compression (gzip + brotli) for all API responses.
|
||||
// tower-http negotiates via Accept-Encoding and skips already-compressed
|
||||
// content types automatically. No manual compression in handlers.
|
||||
|
||||
Reference in New Issue
Block a user