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:
Edouard Vanbelle
2026-06-02 11:20:44 +02:00
parent 03f63ad103
commit ec72374651
10 changed files with 414 additions and 3 deletions
@@ -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,
+1
View File
@@ -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))
}
+7
View File
@@ -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.