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
+12
View File
@@ -24,6 +24,16 @@ pub struct UserDto {
/// can't own storage; their quota is always 0. Internal users
/// default to `false`.
pub is_external: bool,
/// Optional first/given name. Populated from the OIDC `given_name`
/// claim at JIT provisioning, or via a profile-edit endpoint.
/// `None` until explicitly set — `skip_serializing_if = "Option::is_none"`
/// keeps the wire format compact for the common case.
#[serde(skip_serializing_if = "Option::is_none")]
pub given_name: Option<String>,
/// Optional last/family name. Same provenance + serde rules as
/// `given_name`.
#[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>,
}
impl From<User> for UserDto {
@@ -43,6 +53,8 @@ impl From<User> for UserDto {
image: user.image().map(|s| s.to_string()),
can_edit_image: !user.is_oidc_user(),
is_external: user.is_external(),
given_name: user.given_name().map(str::to_string),
family_name: user.family_name().map(str::to_string),
}
}
}
@@ -897,6 +897,115 @@ impl AuthApplicationService {
self.get_user(user_id).await
}
/// Visibility-checked profile lookup for `GET /api/users/{id}`.
///
/// Returns `NotFound` (not `AccessDenied`) when the caller has no
/// legitimate relationship with the target — anti-enumeration: an
/// attacker probing random UUIDs cannot distinguish "user doesn't
/// exist" from "exists but you can't see them".
///
/// External callers (`is_external = TRUE`) are locked out of the
/// endpoint entirely. They have no legitimate need to enumerate
/// users — their session exists only to interact with resources
/// they were explicitly granted. Returns `AccessDenied`, which the
/// handler surfaces as 403; the external caller's own role is not
/// a secret to themselves, so the honest status is appropriate.
///
/// Visibility rule for internal callers:
/// 1. caller_id == target_id → always visible (self).
/// 2. caller is admin → always visible (admin needs every user).
/// 3. target is internal AND `expose_system_users` is on → already
/// broadly visible via the system address book; no extra check.
/// 4. caller and target share at least one grant — either
/// direction, either as subject or granter. Subject-group
/// co-membership is intentionally NOT included in v1; can be
/// added later if a concrete need surfaces.
/// 5. Anything else → `NotFound`.
pub async fn get_user_profile(
&self,
caller_id: Uuid,
target_id: Uuid,
expose_system_users: bool,
pool: &sqlx::PgPool,
) -> Result<UserDto, DomainError> {
// External-caller lockout. Load the caller eagerly so the
// is_external check covers every branch (including self-lookup
// — an external user reading their own profile via this route
// is still off-limits; the frontend should rely on the existing
// /api/auth/me endpoint for that).
let caller = self.user_storage.get_user_by_id(caller_id).await?;
if caller.is_external() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"User",
"External users cannot query /api/users/{id}",
));
}
// Self: always (now that the external lockout already filtered
// external self-lookups above).
if caller_id == target_id {
return Ok(UserDto::from(caller));
}
// Anti-enumeration: NotFound for everything that doesn't pass.
// Convert a real NotFound on `target` to the same anonymous 404,
// so existence isn't leaked through differential responses.
let target = match self.user_storage.get_user_by_id(target_id).await {
Ok(u) => u,
Err(e) if e.kind == ErrorKind::NotFound => {
return Err(DomainError::new(
ErrorKind::NotFound,
"User",
"User not found",
));
}
Err(e) => return Err(e),
};
// Internal target + system-address-book exposed: already public.
if !target.is_external() && expose_system_users {
return Ok(UserDto::from(target));
}
// Admin caller: always visible.
if caller.role() == UserRole::Admin {
return Ok(UserDto::from(target));
}
// Shared grant: caller and target appear together in at least one
// access_grants row (either as the granted-by + user-subject pair,
// or symmetrically). LIMIT 1 + the (granted_by) + (subject_type,
// subject_id) indexes keep this cheap.
let related: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM storage.access_grants
WHERE (granted_by = $1 AND subject_type = 'user' AND subject_id = $2)
OR (granted_by = $2 AND subject_type = 'user' AND subject_id = $1)
LIMIT 1
"#,
)
.bind(caller_id)
.bind(target_id)
.fetch_optional(pool)
.await
.map_err(|e| {
DomainError::internal_error("UserProfile", format!("visibility query: {}", e))
})?;
if related.is_some() {
return Ok(UserDto::from(target));
}
// No relationship — anti-enumeration NotFound.
Err(DomainError::new(
ErrorKind::NotFound,
"User",
"User not found",
))
}
// New method to get user by username - needed for admin user handling
pub async fn get_user_by_username(&self, username: &str) -> Result<UserDto, DomainError> {
let user = self.user_storage.get_user_by_username(username).await?;