Merge pull request #683 from EdouardVanbelle/refactor/userdto

This commit is contained in:
Dionisio Pozo
2026-08-22 23:31:25 +02:00
committed by GitHub
83 changed files with 2484 additions and 1068 deletions
+35 -5
View File
@@ -108,14 +108,15 @@ pub struct AdminResetPasswordDto {
pub new_password: String,
}
/// Query parameters for listing users
/// Query parameters for listing users. `/api/admin/users` used to
/// bifurcate on `?summary=` (flat `PublicUserDto` vs nested
/// `FullUserDto`); that split was retired — the endpoint now always
/// returns `FullUserDto`. Unknown query params are ignored, so
/// existing callers still passing `?summary=true` keep working.
#[derive(Debug, Serialize, Deserialize)]
pub struct ListUsersQueryDto {
pub limit: Option<i64>,
pub offset: Option<i64>,
/// Return only the fields rendered by the paginated management table.
/// Defaults to `false` so existing API clients keep the full user shape.
pub summary: Option<bool>,
}
/// Query parameters for the admin sessions listing.
@@ -163,10 +164,39 @@ pub struct DashboardStatsDto {
pub auth_enabled: bool,
pub oidc_configured: bool,
pub quotas_enabled: bool,
// User stats
// ── User accounts (static breakdown of auth.users) ──
// All four are counts of the SAME table under different
// predicates. `active`, `admin`, `external` are all subsets of
// `total`. `external` is disjoint from `admin` by DB constraint
// (`users_external_not_admin`). The dashboard renders these as
// one grouped section separate from the live-activity section
// below, so admins don't confuse "as-of-now row count" with
// "who's here right now".
pub total_users: i64,
pub active_users: i64,
pub admin_users: i64,
/// Grant-only accounts (magic-link / OIDC-only / OCM recipients).
/// Filtered out of `total_users` / `active_users` since those
/// columns count operational seats (see the SELECT comment). Here
/// as its own metric because operators of external-heavy
/// deployments (public shares, invited-collab shops) need to see
/// the invited population at a glance.
pub external_users: i64,
// ── Live activity (projection over auth.sessions) ──
// Both fields change minute-to-minute, unlike the user counts
// above which only move on register/deactivate/role-toggle.
// Same 5-min window as the Prometheus gauges
// (`oxicloud_sessions_online[_users]` in
// `session_liveness_gauges.rs`), computed via the shared
// `ONLINE_WINDOW` constant so per-user badges + aggregate
// counts + this dashboard number stay consistent by construction.
/// Distinct users behind non-revoked sessions active in the last
/// 5 min. Answers "how many humans are here right now?".
pub online_users: i64,
/// Non-revoked sessions active in the last 5 min. Answers "how
/// many concurrent connections must I serve?". Ratio
/// `online_sessions / online_users` is the multi-device factor.
pub online_sessions: i64,
// ── Per-drive-kind quota accounting ──
// One row per drive kind (personal, shared). Pre-dedup, logical
// file sizes summed from `drives.used_bytes` (personal rolls up
+379 -240
View File
@@ -1,5 +1,4 @@
use crate::domain::entities::user::User;
use crate::domain::repositories::user_repository::UserListEntry;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
@@ -7,287 +6,283 @@ use std::sync::Arc;
use utoipa::ToSchema;
use uuid::Uuid;
// ────────────────────────────────────────────────────────────────────────
// Three-layer user DTO family — see docs/plan/userdto-refactor.md.
//
// `PublicUserDto` — public identity. Every authenticated caller may see it.
// Returned by /api/users/{id}, share responses, group
// members, magic-link invitees, recipient enrichment.
// `FullUserDto` — `{ user: PublicUserDto, ...admin+self extras }`.
// Returned as `Vec<FullUserDto>` by /api/admin/users;
// embedded in `SelfUserDto`. Closest DTO to the
// `auth.users` row.
// `SelfUserDto` — `{ full: FullUserDto, ...self-only extras }`. Returned
// by /api/auth/me and by every AuthResponseDto path.
//
// Adding a field? Decide by audience:
// * Any authenticated caller may see it about another user → `PublicUserDto`.
// * Only admin (about another user) AND self (about self) → `FullUserDto`.
// * Only self about themselves → `SelfUserDto`.
// ────────────────────────────────────────────────────────────────────────
/// Public identity — what any authenticated caller may see about ANOTHER
/// user. Returned by `/api/users/{id}` and everywhere a user is
/// referenced by another surface (share responses, group members,
/// magic-link invitees, recipient enrichment).
///
/// This is the audience-narrowest DTO: adding a field here means every
/// authenticated caller can see it about every visible user. Fields that
/// are meaningful only to the subject themselves (preferences, session
/// state) or only to an admin (auth adoption signals) belong on
/// [`SelfUserDto`] or [`FullUserDto`] respectively.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserDto {
pub struct PublicUserDto {
pub id: String,
/// Optional handle. `None` for users who have not claimed one
/// (externals, fresh email-only signups). Frontend display callers
/// should walk `username → given/family → email` as their fallback
/// chain. Omitted from JSON when None (consistent with the existing
/// given_name / family_name fields).
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub email: String,
/// Role string ("admin" | "user"). Kept public because the sharee /
/// group-member vignette renders an admin badge.
pub role: String,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
/// Which trust chain minted this user's federation identity —
/// `"oidc" | "ocm" | "magic_link"` — or `None` for pure local
/// users. Load-bearing for "is this user OIDC?"-shape predicates:
/// use `federation_kind == "oidc"` rather than string-scraping
/// `federation_issuer`. Serialized only when populated.
///
/// Mirrors `auth.users.federation_kind` verbatim — same name at
/// DB, entity, and wire layers so there's no translation to reason
/// about. See docs/plan/ocm.md § Identity & auth model.
#[serde(skip_serializing_if = "Option::is_none")]
pub federation_kind: Option<String>,
/// The authority that mints this user's `federation_subject` —
/// issuer URL for OIDC (id_token `iss` claim), peer domain for
/// OCM, `null` for local users (password / OPAQUE only).
///
/// Renamed from `auth_provider` (which was a `String` with the
/// sentinel `"local"` for non-federated users, and a human-readable
/// label like `"MockSSO"` before Phase B). This shape mirrors the
/// `auth.users.federation_issuer` column directly: nullable when
/// there's no federation involved. FE predicates for "is this user
/// federated?" should read `federation_kind`, not
/// string-compare this value.
///
/// When populated, FE code that wants a friendly display label
/// looks this value up against `OidcProviderInfoDto.issuer →
/// provider_name` to render the deployment's configured display
/// name; falls back to the raw issuer for foreign IdPs / legacy
/// rows still holding a pre-Phase-B label.
#[serde(skip_serializing_if = "Option::is_none")]
pub federation_issuer: Option<String>,
/// Avatar payload (base64 data-URI up to 512 KiB). Public so a share
/// picker can render the recipient's face directly. Will move to a
/// dedicated avatar endpoint in a future refactor — this shape is
/// transitional.
pub image: Option<String>,
pub can_edit_image: bool,
/// `true` for grant-only external recipients (magic-link, OIDC-only,
/// future OCM federated). External users have no home folder and
/// can't own storage; their quota is always 0. Internal users
/// default to `false`.
/// future OCM federated). Renders the "external" badge on the vignette.
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.
/// Optional first/given name. Social identity.
#[serde(skip_serializing_if = "Option::is_none")]
pub given_name: Option<String>,
/// Optional last/family name. Same provenance + serde rules as
/// `given_name`.
/// Optional last/family name. Social identity.
#[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>,
/// When the user first demonstrated control of their email (PR 23).
/// `None` = unverified (omitted from JSON). Stamped on the first
/// successful magic-link redemption or OIDC JIT with verified
/// claim. Idempotent — the original timestamp is preserved on
/// subsequent verifications.
#[serde(skip_serializing_if = "Option::is_none")]
pub email_verified_at: Option<DateTime<Utc>>,
/// User-chosen locale for server-rendered surfaces (emails,
/// future authenticated HTML). `None` = no preference (the server
/// resolves to `OXICLOUD_DEFAULT_LOCALE` when rendering). Round-trips
/// through `/api/auth/me` and `PATCH /api/auth/me/profile`.
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_locale: Option<String>,
/// Whether the user wants an email when someone shares a resource
/// with them. `true` (default) = receive share-notification mails;
/// `false` = grants are still created but no email is sent. Honored
/// only on the plain-notification path — magic-link first-invitations
/// to brand-new external users always send, otherwise the recipient
/// could never claim the share. Round-trips through `/api/auth/me`
/// and `PATCH /api/auth/me/profile`.
pub notify_on_share: bool,
/// Opaque UI preferences bag. Cross-device store for pure UI
/// toggles (hide dotfiles, view mode, sidebar collapse, …). The
/// server never inspects the contents — this DTO field just echoes
/// what was PATCHed via `PATCH /api/auth/me/profile`. Shape is a
/// JSON object; the frontend defines the keys it cares about (see
/// `frontend/src/lib/stores/preferences.svelte.ts`). Always present
/// on the wire; empty bag is `{}`, never `null`.
pub ui_preferences: serde_json::Value,
/// Mirrors `auth.users.force_password_change_at_next_login`. Set
/// TRUE by the admin password-reset flow (see
/// `AuthApplicationService::admin_reset_password`) and cleared by
/// a successful self-service `POST /api/auth/change-password`.
///
/// Populated only by the `/api/auth/me` handler and the login
/// response minter (via a distinct code path). `From<User>` — used
/// by admin listings, share-recipient responses, group-member DTOs,
/// etc. — leaves it at `false`. The flag is a per-session-account
/// concern (does *this* user need to change their password before
/// they can proceed?), not a general user attribute worth
/// surfacing on every list row.
///
/// The load-bearing consumer is the SPA's session store: on
/// startup and after every refresh, `/me` returns the current
/// flag value and the SPA's nav-guard blocks navigation to
/// anything but the change-password surface until it flips
/// back to false. Backend enforcement is separate (see the
/// `require_no_password_change_pending` middleware) — this DTO
/// field is what the SPA reads to render the mandatory-mode UI.
/// Presence signal — TRUE when the server observed a request on any
/// of this user's non-revoked sessions within the last
/// [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW)
/// (5 min). Sourced from an EXISTS subquery when the DTO is built
/// from a list-projection path; single-user endpoints that don't
/// enrich presence ship `false`.
#[serde(default)]
pub force_password_change: bool,
/// TRUE when the account has a local Argon2id `password_hash` on
/// file. Distinct from `federation_kind`: an OIDC-linked account
/// (`federation_kind == "oidc"`) can ALSO carry a local password if
/// it was set at signup or later — a hybrid posture. The SPA
/// gates the profile page's change-password card on this flag,
/// so hybrid users can rotate their local password even though
/// they normally sign in via SSO.
///
/// Populated only by the `/api/auth/me` handler. `From<User>` in
/// this file leaves it `false` — other UserDto emitters (admin
/// listings, share-recipient responses, group members) do not
/// need to surface per-user credential state.
#[serde(default)]
pub has_password: bool,
/// TRUE when the caller's current session carries a DPoP JWK
/// thumbprint (`session.dpop_jkt IS NOT NULL`). Sourced from the
/// caller's JWT `cnf.jkt` claim — `is_some()` means the session
/// was bound at token-mint time.
///
/// Populated only by the `/api/auth/me` handler; other UserDto
/// emitters leave it `false`. The SPA reads this on `session.load()`
/// to skip a redundant `POST /api/auth/dpop/bind` call when the
/// session is already bound (which would 409 and log noisily under
/// the audit stream — see the `already_bound` reject). Only the
/// OIDC / magic-link redirect flows land here as `false` on first
/// visit; password login binds at session-mint time so the very
/// first `/me` after login already reports `true`.
#[serde(default)]
pub is_dpop_bound: bool,
pub is_online: bool,
}
/// Compact row returned by the paginated admin user table.
/// Full user record — public identity + all fields BOTH an admin
/// (viewing another user) AND the subject themselves may see. Returned
/// as `Vec<FullUserDto>` by `/api/admin/users`; embedded in
/// [`SelfUserDto`] for `/api/auth/me`.
///
/// Account-detail fields deliberately do not appear here. In particular,
/// omitting `image` and `ui_preferences` prevents a 100-row page from turning
/// into tens of MiB when users have uploaded avatars. `GET /api/admin/users/:id`
/// remains the full-detail endpoint.
/// This is the DTO closest to the underlying `auth.users` row. Adding a
/// field here means an admin looking at any user can see it, and the
/// subject themselves can see it in their `/me` response — but the field
/// stays off the public [`PublicUserDto`] surface.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminUserSummaryDto {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub email: String,
pub role: String,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
/// See `UserDto::federation_kind` — same semantics, same wire spelling.
pub struct FullUserDto {
/// Public identity — same set every authenticated caller sees.
pub user: PublicUserDto,
/// Which trust chain minted this user's federation identity —
/// `"oidc" | "ocm" | "magic_link"` — or `None` for pure local users.
/// Kept off `PublicUserDto` because a peer's federation kind is a
/// soft org-affiliation leak; only self + admin need it.
#[serde(skip_serializing_if = "Option::is_none")]
pub federation_kind: Option<String>,
/// See `UserDto::federation_issuer` — same semantics, same wire spelling.
/// The authority that minted this user's `federation_subject` —
/// issuer URL for OIDC (id_token `iss` claim), peer domain for OCM,
/// `None` for local users. Same rationale as `federation_kind`.
#[serde(skip_serializing_if = "Option::is_none")]
pub federation_issuer: Option<String>,
pub is_external: bool,
/// TRUE when the user has a server-verifiable password on file
/// (`password_hash IS NOT NULL`). The admin table uses this
/// alongside `federation_issuer` and `opaque_registered` to render
/// the user's full capability set: a `password` chip lights up
/// here, an OIDC provider name renders the SSO badge, an
/// envelope-on-file flips the OPAQUE chip. A user with none of
/// the three is passwordless (magic-link only — the SPA renders
/// a distinct `passwordless` chip in that case). Admin-only
/// exposure — see the DTO doc for why this isn't on `UserDto`.
#[serde(default)]
/// Subject's own locale preference. Only THEY or an admin managing
/// them needs this — other callers use their own locale.
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_locale: Option<String>,
/// When the user first demonstrated control of their email. Trust
/// signal — meaningful to admin (auditing verification status) and
/// to self (own record), but not to a share picker rendering a
/// vignette.
#[serde(skip_serializing_if = "Option::is_none")]
pub email_verified_at: Option<DateTime<Utc>>,
/// Row bookkeeping.
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Activity signal — private to the subject; admin sees it too.
pub last_login_at: Option<DateTime<Utc>>,
/// Account-active flag — a deactivated user couldn't reach `/me`
/// anyway, but admin needs to see it.
pub active: bool,
/// Storage quotas — personal financials. Admin manages others';
/// self sees own.
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
/// TRUE when the account has a server-verifiable password
/// (`password_hash IS NOT NULL`). Kept off `PublicUserDto` because
/// per-user auth adoption leaks through directory endpoints.
pub has_password: bool,
/// Mirrors `UserListEntry::opaque_registered` — TRUE when the user
/// has an OPAQUE envelope on file. Surfaced on the admin table so
/// operators can see per-user rollout progress during the
/// migration window. **Admin-only exposure**: this field is NOT
/// on `UserDto` — putting it there would leak adoption status
/// through every user-directory-adjacent endpoint (share targets,
/// group members, invite listings). `#[serde(default)]` keeps
/// older SPA builds tolerant of the added field.
#[serde(default)]
/// TRUE when the user has an OPAQUE envelope on file.
pub opaque_registered: bool,
/// Mirrors `UserListEntry::opaque_migrated` — TRUE when the user
/// has completed at least one successful OPAQUE login. Distinct
/// from `opaque_registered`: an admin can invalidate the envelope
/// (`clear_registration`) leaving the user registered=false but
/// with a historical migrated=true; the SPA's admin table shows
/// both so this operational nuance is visible.
#[serde(default)]
/// TRUE when the user has completed ≥1 successful OPAQUE login.
/// Distinct from `opaque_registered`: an admin can invalidate the
/// envelope leaving the user registered=false but with historical
/// migrated=true.
pub opaque_migrated: bool,
}
impl From<UserListEntry> for AdminUserSummaryDto {
fn from(entry: UserListEntry) -> Self {
Self {
id: entry.id.to_string(),
username: entry.username,
email: entry.email,
role: entry.role.to_string(),
storage_quota_bytes: entry.storage_quota_bytes,
storage_used_bytes: entry.storage_used_bytes,
last_login_at: entry.last_login_at,
active: entry.active,
federation_kind: entry.federation_kind,
federation_issuer: entry.federation_issuer,
is_external: entry.is_external,
has_password: entry.has_password,
opaque_registered: entry.opaque_registered,
opaque_migrated: entry.opaque_migrated,
}
}
/// Self view — everything the caller may see about themselves.
/// Returned by `/api/auth/me` and by every `AuthResponseDto` path
/// (login / refresh / OIDC callback / magic-link redemption).
///
/// Composed on top of [`FullUserDto`] so `/me` and `/admin/users` share
/// the SAME "full profile" contract for the fields both need — new
/// self+admin-visible fields go on `FullUserDto` and both endpoints get
/// them together. Fields here are pure self-scoped state: preferences,
/// session-scoped flags, and caller-scoped permissions.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SelfUserDto {
/// Full profile — same shape as one row of `/api/admin/users`.
pub full: FullUserDto,
/// Opaque UI preferences bag — my own UI state. Cross-device store
/// for pure UI toggles (view mode, sidebar collapse, hide dotfiles,
/// …). The server never inspects the contents. Always present on
/// the wire; empty bag is `{}`, never `null`.
pub ui_preferences: serde_json::Value,
/// Whether I want share-notification emails.
pub notify_on_share: bool,
/// Session-scoped: my current session carries a DPoP thumbprint.
/// SPA reads this on `session.load()` to skip a redundant
/// `POST /api/auth/dpop/bind` when the session is already bound.
pub is_dpop_bound: bool,
/// Admin-set temp-password gate — SPA nav guard blocks everything
/// but `/change-password` until this flips back. Cleared by a
/// successful `POST /api/auth/change-password`.
pub force_password_change: bool,
/// Caller-scoped permission: can I edit my own avatar? `false` for
/// OIDC users whose avatar comes from the IdP. Only meaningful when
/// caller == subject; nonsense on any other DTO.
pub can_edit_image: bool,
}
impl From<User> for UserDto {
fn from(user: User) -> Self {
// `user` is owned and dropped here, so every owned field is MOVED out
// via `into_parts` rather than cloned through the borrowing accessors —
// the accessor form deep-cloned `image` (a data URI up to 512 KiB) and
// the whole `ui_preferences` JSON tree on every `/api/auth/me` and admin
// user listing (benches/ROUND20.md §A2). The two derived values read the
// entity before the move.
impl PublicUserDto {
/// Construct a `PublicUserDto` from a `User` entity + an explicit
/// `is_online` signal.
///
/// **Why not `From<User>`?** The `User` entity models a row in
/// `auth.users`; `is_online` is a cross-table lookup on
/// `auth.sessions` (see the EXISTS subquery in
/// `list_users_with_derived_flags` and `get_user_with_derived_flags`
/// on the user repo). A `From<User>` impl couldn't compute it
/// honestly — it would have to ship a `false` default that lies to
/// the FE presence dot on every emitter that didn't remember to
/// override. Making presence a required constructor argument
/// removes that footgun: every callsite has to declare its intent.
///
/// Two shapes at the callsite:
///
/// - Presence matters (single-user `/api/users/{id}`, list
/// projections, self-view): pair with
/// `user_storage.get_user_with_derived_flags(id)` and pass
/// `flags.is_online`.
/// - Presence is out of scope (register / update-profile response,
/// post-mutation echo where the FE ignores the field): pass
/// `false` with a short comment explaining why. The receiver's
/// presence read is a no-op — no dot lights up on the stale
/// value.
pub fn new(user: User, is_online: bool) -> Self {
let role = format!("{}", user.role());
let can_edit_image = !user.is_oidc_user();
// has_password is derivable from the entity — read before the
// move. Cheap (bool from Option::is_some), no extra DB round-
// trip, so From<User> can populate it uniformly rather than
// leaving it false and requiring per-call-site backfill.
let has_password = user.has_password();
let p = user.into_parts();
Self {
id: p.id.to_string(),
username: p.username,
email: p.email,
role,
storage_quota_bytes: p.storage_quota_bytes,
storage_used_bytes: p.storage_used_bytes,
image: p.image,
is_external: p.is_external,
given_name: p.given_name,
family_name: p.family_name,
is_online,
}
}
}
impl FullUserDto {
/// Construct a `FullUserDto` from a `User` entity plus the DB-derived
/// flags the entity doesn't carry (`has_password`, OPAQUE flags,
/// `is_online`). Both are typically produced together by the users
/// list repo projection.
///
/// Not a `From` impl because it takes two arguments; not a `From
/// <(User, UserDerivedFlags)>` because that reads awkwardly at
/// callsites — `FullUserDto::build(user, flags)` is clearer.
pub fn build(
user: User,
flags: crate::domain::repositories::user_repository::UserDerivedFlags,
) -> Self {
let role = format!("{}", user.role());
let p = user.into_parts();
Self {
user: PublicUserDto {
id: p.id.to_string(),
username: p.username,
email: p.email,
role,
image: p.image,
is_external: p.is_external,
given_name: p.given_name,
family_name: p.family_name,
is_online: flags.is_online,
},
federation_kind: p.federation_kind.map(|k| k.as_str().to_string()),
federation_issuer: p.federation_issuer,
preferred_locale: p.preferred_locale,
email_verified_at: p.email_verified_at,
created_at: p.created_at,
updated_at: p.updated_at,
last_login_at: p.last_login_at,
active: p.active,
// NULL on both fields for local users (no federation wired).
// FE predicates use `!!federation_kind` for "is federated?" —
// no "local" sentinel string; the null tells the whole story.
federation_kind: p.federation_kind.map(|k| k.as_str().to_string()),
federation_issuer: p.federation_issuer,
image: p.image,
can_edit_image,
is_external: p.is_external,
given_name: p.given_name,
family_name: p.family_name,
email_verified_at: p.email_verified_at,
preferred_locale: p.preferred_locale,
notify_on_share: p.notify_on_share,
ui_preferences: p.ui_preferences,
// Defaults to false. The `/me` handler + the login-response
// minter populate this via a distinct code path (a
// repo read that goes through the auth service's cache);
// admin listings and other UserDto consumers deliberately
// leave it false — the flag is per-session-account state,
// not a general user attribute.
force_password_change: false,
has_password,
// Populated only by `/api/auth/me` — the handler overlays
// the caller's session's actual DPoP binding state after
// this `From<User>` runs. Other UserDto emitters leave
// this at `false` (they lack session context).
is_dpop_bound: false,
storage_quota_bytes: p.storage_quota_bytes,
storage_used_bytes: p.storage_used_bytes,
has_password: flags.has_password,
opaque_registered: flags.opaque_registered,
opaque_migrated: flags.opaque_migrated,
}
}
}
impl SelfUserDto {
/// Assemble the `/me` response from a `FullUserDto` plus the two
/// session-scoped booleans that can't be derived from `User` alone:
/// the caller's DPoP-binding state (from the JWT `cnf.jkt` claim)
/// and the admin-set force-password-change flag (from the auth
/// service's cache).
///
/// The other self-only fields (`ui_preferences`, `notify_on_share`,
/// `can_edit_image`) come from `User` and are read off the entity
/// before it's moved into the FullUserDto; this method takes those
/// as explicit parameters so the caller can decide when to read
/// them (typically at the same point they read the DPoP-binding
/// state).
pub fn build(
full: FullUserDto,
ui_preferences: serde_json::Value,
notify_on_share: bool,
is_dpop_bound: bool,
force_password_change: bool,
can_edit_image: bool,
) -> Self {
Self {
full,
ui_preferences,
notify_on_share,
is_dpop_bound,
force_password_change,
can_edit_image,
}
}
}
// ────────────────────────────────────────────────────────────────────────
// End of three-layer user DTO family.
// ────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct LoginDto {
/// Identifier the user typed. Accepts BOTH a username (no `@`) and
@@ -425,7 +420,12 @@ impl UpdateProfileDto {
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AuthResponseDto {
pub user: UserDto,
/// Full self view — identical shape to `/api/auth/me`. Every
/// login / refresh / OIDC-callback / magic-link redemption ships
/// this so the SPA's post-auth state matches its post-`/me` state
/// (no UI race between `AuthResponseDto` and the first `/me`
/// fetch). See `docs/plan/userdto-refactor.md` § Endpoint mapping.
pub user: SelfUserDto,
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
@@ -562,7 +562,7 @@ pub struct OidcProviderInfoDto {
/// users JIT-provisioned via this IdP.
///
/// Populated so the frontend can resolve display: when
/// `UserDto.federation_issuer` equals this `issuer`, render
/// `PublicUserDto.federation_issuer` equals this `issuer`, render
/// `provider_name` as the human-friendly label (avoids showing raw
/// issuer URLs like `https://sso.example.com/realms/main` in the
/// admin badge / profile view). Falls back to the raw issuer when
@@ -606,3 +606,142 @@ pub struct OidcUserInfoDto {
pub name: Option<String>,
pub groups: Vec<String>,
}
#[cfg(test)]
mod three_layer_quarantine {
use super::*;
use serde_json::Value;
/// Structural-quarantine guard for `SelfUserDto`. The self-only
/// bag (`ui_preferences`, `notify_on_share`, `is_dpop_bound`,
/// `force_password_change`, `can_edit_image`) MUST live at the
/// top level, NOT nested inside `.full` or `.full.user`. If a
/// future refactor accidentally moves one of them down, the
/// wire shape leaks it through every `PublicUserDto` /
/// `FullUserDto` emitter (share responses, group members,
/// `/api/admin/users`, magic-link invitees) — exactly what the
/// three-layer split exists to prevent. Fails loudly here.
#[test]
fn self_only_fields_stay_at_top_level_of_self_user_dto() {
let self_dto = SelfUserDto {
full: FullUserDto {
user: PublicUserDto {
id: "00000000-0000-0000-0000-000000000001".into(),
username: None,
email: "self@example.invalid".into(),
role: "user".into(),
image: None,
is_external: false,
given_name: None,
family_name: None,
is_online: false,
},
federation_kind: None,
federation_issuer: None,
preferred_locale: None,
email_verified_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
last_login_at: None,
active: true,
storage_quota_bytes: 0,
storage_used_bytes: 0,
has_password: true,
opaque_registered: false,
opaque_migrated: false,
},
ui_preferences: serde_json::json!({}),
notify_on_share: true,
is_dpop_bound: false,
force_password_change: false,
can_edit_image: true,
};
let json: Value = serde_json::to_value(&self_dto).expect("SelfUserDto serialises");
assert!(
json.get("ui_preferences").is_some(),
"top-level ui_preferences"
);
assert!(
json.get("full")
.expect("full block")
.get("ui_preferences")
.is_none(),
"ui_preferences must NOT appear inside `.full`"
);
assert!(
json.pointer("/full/user/ui_preferences").is_none(),
"ui_preferences must NOT appear inside `.full.user`"
);
// Same guard for the other self-only fields.
for k in [
"notify_on_share",
"is_dpop_bound",
"force_password_change",
"can_edit_image",
] {
assert!(json.get(k).is_some(), "{k} at top level");
assert!(
json.pointer(&format!("/full/{k}")).is_none(),
"{k} must NOT nest in .full"
);
assert!(
json.pointer(&format!("/full/user/{k}")).is_none(),
"{k} must NOT nest in .full.user"
);
}
}
/// Structural-quarantine guard for `FullUserDto`. Admin-visible
/// extras (`has_password`, OPAQUE flags, `federation_*`,
/// `last_login_at`, `active`, quotas, `preferred_locale`,
/// `email_verified_at`) MUST live at the top level of
/// `FullUserDto`, NOT inside `.user`. If a future refactor
/// accidentally lifts one of them onto `PublicUserDto` (the
/// embedded `user` field), it leaks through `/api/users/{id}`
/// and every other public directory endpoint.
#[test]
fn admin_only_fields_stay_at_top_level_of_full_user_dto() {
let full = FullUserDto {
user: PublicUserDto {
id: "00000000-0000-0000-0000-000000000002".into(),
username: Some("bob".into()),
email: "bob@example.invalid".into(),
role: "user".into(),
image: None,
is_external: false,
given_name: None,
family_name: None,
is_online: false,
},
federation_kind: None,
federation_issuer: None,
preferred_locale: None,
email_verified_at: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
last_login_at: None,
active: true,
storage_quota_bytes: 10_737_418_240,
storage_used_bytes: 0,
has_password: true,
opaque_registered: false,
opaque_migrated: false,
};
let json: Value = serde_json::to_value(&full).expect("FullUserDto serialises");
for k in [
"has_password",
"opaque_registered",
"opaque_migrated",
"last_login_at",
"active",
"storage_quota_bytes",
"storage_used_bytes",
] {
assert!(json.get(k).is_some(), "{k} at top level of FullUserDto");
assert!(
json.pointer(&format!("/user/{k}")).is_none(),
"{k} must NOT nest in .user"
);
}
}
}
+30 -10
View File
@@ -3,7 +3,6 @@ use crate::domain::entities::app_password::AppPassword;
use crate::domain::entities::device_code::DeviceCode;
use crate::domain::entities::session::Session;
use crate::domain::entities::user::User;
use crate::domain::repositories::user_repository::UserListEntry;
use std::sync::Arc;
use uuid::Uuid;
@@ -123,6 +122,36 @@ pub trait UserStoragePort: Send + Sync + 'static {
/// Gets a user by ID
async fn get_user_by_id(&self, id: Uuid) -> Result<User, DomainError>;
/// Fetch the full `User` + [`UserDerivedFlags`] in one query. See
/// [`UserRepository::get_user_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::get_user_with_derived_flags)
/// for the contract and the rationale for the single-query shape.
async fn get_user_with_derived_flags(
&self,
id: Uuid,
) -> Result<
(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
),
DomainError,
>;
/// Paginated admin user listing with derived flags. See
/// [`UserRepository::list_users_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::list_users_with_derived_flags)
/// for the contract and rationale.
async fn list_users_with_derived_flags(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> Result<
Vec<(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
)>,
DomainError,
>;
/// Batch-loads users by id. Order is unspecified; missing ids are
/// silently dropped. Used by group-recipient expansion in
/// `RecipientNotificationService` to avoid N+1 lookups when notifying
@@ -164,15 +193,6 @@ pub trait UserStoragePort: Send + Sync + 'static {
include_external: bool,
) -> Result<Vec<User>, DomainError>;
/// Narrow user-list projection for management tables. Keeps heavyweight
/// account-detail fields off the database and JSON hot path.
async fn list_user_summaries(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> Result<Vec<UserListEntry>, DomainError>;
/// Searches users by username or email (SQL ILIKE) with a limit.
/// See [`list_users`] for the meaning of `include_external`.
async fn search_users(
@@ -1,6 +1,6 @@
use crate::application::dtos::user_dto::{
AdminUserSummaryDto, AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto,
RegisterDto, UpgradeToInternalDto, UserDto,
AuthResponseDto, ChangePasswordDto, FullUserDto, LoginDto, PublicUserDto, RefreshTokenDto,
RegisterDto, SelfUserDto, UpgradeToInternalDto,
};
use crate::application::ports::auth_ports::{
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
@@ -319,11 +319,11 @@ pub enum OidcCallbackResult {
#[derive(Debug, Clone)]
pub enum RegisterResult {
/// Boxed to avoid the `large_enum_variant` clippy warning —
/// `UserDto` is ~250 bytes, the other variants are zero-sized,
/// `PublicUserDto` is ~250 bytes, the other variants are zero-sized,
/// so a heap-pointer indirection keeps the enum's stack size
/// small. `register` is called once per request; the
/// allocation cost is negligible.
Created(Box<UserDto>),
Created(Box<PublicUserDto>),
UsernameTaken,
EmailTaken,
}
@@ -876,8 +876,9 @@ impl AuthApplicationService {
is_external = false,
"🛂 user registered",
);
Ok(RegisterResult::Created(Box::new(UserDto::from(
Ok(RegisterResult::Created(Box::new(PublicUserDto::new(
created_user,
false,
))))
}
@@ -894,7 +895,7 @@ impl AuthApplicationService {
username: String,
email: String,
password: String,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
// Validate username
if username.len() < 3 || username.len() > 254 {
return Err(DomainError::new(
@@ -981,7 +982,7 @@ impl AuthApplicationService {
username,
created_user.id()
);
Ok(UserDto::from(created_user))
Ok(PublicUserDto::new(created_user, false))
}
pub async fn login(
@@ -1320,12 +1321,16 @@ impl AuthApplicationService {
session = session.with_dpop_jkt(jkt);
}
// Build the SelfUserDto BEFORE `session` moves into
// `create_session` — the builder reads `session.dpop_jkt()`.
let user_id = user.id();
let user_dto = self.build_self_user_dto(user_id, &session).await?;
self.session_storage.create_session(session).await?;
// Authentication response
let force_password_change = self.read_force_password_change(user.id()).await;
let force_password_change = self.read_force_password_change(user_id).await;
Ok(AuthResponseDto {
user: UserDto::from(user),
user: user_dto,
access_token,
refresh_token,
token_type: "Bearer".to_string(),
@@ -1334,6 +1339,69 @@ impl AuthApplicationService {
})
}
/// Assemble a `SelfUserDto` for the given user + the session that
/// mints them. Called by every `AuthResponseDto` path
/// (login / refresh / OIDC / magic-link) so the wire shape stays
/// consistent across login flavours and matches what `/api/auth/me`
/// would return.
///
/// Costs one wide SELECT (`get_user_with_derived_flags`) even when
/// the caller already has a `User` in hand — acceptable because
/// `/login`, `/refresh`, and the OIDC/magic-link callbacks are
/// not hot inner loops. In exchange the composition stays uniform
/// across all four callsites and OPAQUE / `is_online` flags land
/// on the wire without a second lookup at each site.
///
/// `is_dpop_bound` is derived from the session's own DPoP
/// thumbprint — the session was just constructed, so this reads
/// exactly the binding that will govern subsequent requests.
async fn build_self_user_dto(
&self,
user_id: Uuid,
session: &crate::domain::entities::session::Session,
) -> Result<SelfUserDto, DomainError> {
// Session-context flavour — delegates to the shared builder
// with the DPoP-bound flag derived from the session row's
// thumbprint. See [`build_self_user_dto_for_id`] for the
// handler-context flavour.
self.build_self_user_dto_for_id(user_id, session.dpop_jkt().is_some())
.await
}
/// Handler-context variant of [`build_self_user_dto`]. Called by
/// every endpoint that returns a `SelfUserDto` from a REST handler
/// (`GET /me`, `PATCH /me/profile`, `POST /upgrade-to-internal`)
/// so the wire shape is byte-for-byte identical across them —
/// avoids a "quiet lie" where a client PATCHes one shape and
/// reads another on the very next `/me`.
///
/// `is_dpop_bound` is passed in by the handler because the JWT
/// `cnf.jkt` claim is where handler-scope code learns the caller's
/// binding state (via `auth_user.dpop_jkt.is_some()`). Session-
/// mint paths use [`build_self_user_dto`] and derive the flag from
/// the freshly-created `Session` row instead.
pub async fn build_self_user_dto_for_id(
&self,
user_id: Uuid,
is_dpop_bound: bool,
) -> Result<SelfUserDto, DomainError> {
let (user, flags) =
UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await?;
let can_edit_image = !user.is_oidc_user();
let ui_preferences = user.ui_preferences().clone();
let notify_on_share = user.notify_on_share();
let force_password_change = self.read_force_password_change(user_id).await;
let full = FullUserDto::build(user, flags);
Ok(SelfUserDto::build(
full,
ui_preferences,
notify_on_share,
is_dpop_bound,
force_password_change,
can_edit_image,
))
}
/// Read `force_password_change_at_next_login` for the given user,
/// with fail-open semantics on repo error (returns `false` and
/// logs a warn). Every callsite that builds an `AuthResponseDto`
@@ -1586,22 +1654,29 @@ impl AuthApplicationService {
let access_token =
self.token_service
.generate_access_token(&user, Some(session.id()), None)?;
// Snapshot fields still needed for logging + DTO before
// `session` and `user` are consumed by the storage call and
// the DTO builder below.
let user_id = user.id();
let user_display = user.display_for_audit().to_string();
let is_external = user.is_external();
let user_dto = self.build_self_user_dto(user_id, &session).await?;
self.session_storage.create_session(session).await?;
tracing::info!(
target: "audit",
event = "magic_link.redeemed",
user_id = %user.id(),
username = %user.display_for_audit(),
is_external = user.is_external(),
user_id = %user_id,
username = %user_display,
is_external = is_external,
resource_kind = ?mlt.resource_kind(),
resource_id = ?mlt.resource_id(),
cross_browser_confirmed = cross_browser_confirmed,
);
let force_password_change = self.read_force_password_change(user.id()).await;
let force_password_change = self.read_force_password_change(user_id).await;
let auth = AuthResponseDto {
user: UserDto::from(user),
user: user_dto,
access_token,
refresh_token,
token_type: "Bearer".to_string(),
@@ -1789,6 +1864,12 @@ impl AuthApplicationService {
session.dpop_jkt(),
)?;
// Build the SelfUserDto before `new_session` is consumed by
// the rotate call — the builder reads `session.dpop_jkt()`
// to compute `is_dpop_bound`.
let user_id = user.id();
let user_dto = self.build_self_user_dto(user_id, &new_session).await?;
self.session_storage
.rotate_session(session.id(), new_session)
.await?;
@@ -1798,9 +1879,9 @@ impl AuthApplicationService {
// initial login. The SPA's post-refresh flow (silent, on
// its own timer) can then route the user to change-password
// without waiting for an explicit re-login.
let force_password_change = self.read_force_password_change(user.id()).await;
let force_password_change = self.read_force_password_change(user_id).await;
Ok(AuthResponseDto {
user: UserDto::from(user),
user: user_dto,
access_token,
refresh_token: new_refresh_token,
token_type: "Bearer".to_string(),
@@ -2025,7 +2106,7 @@ impl AuthApplicationService {
&self,
caller_id: Uuid,
dto: UpgradeToInternalDto,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
// Precondition: caller is currently external. Fast-path 409 so
@@ -2126,7 +2207,7 @@ impl AuthApplicationService {
lc.dispatch_upgraded_to_internal(&updated).await;
}
Ok(UserDto::from(updated))
Ok(PublicUserDto::new(updated, false))
}
/// Admin-driven external → internal promotion.
@@ -2155,7 +2236,7 @@ impl AuthApplicationService {
&self,
admin_id: Uuid,
target_id: Uuid,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
let mut user = self.user_storage.get_user_by_id(target_id).await?;
if !user.is_external() {
@@ -2237,7 +2318,7 @@ impl AuthApplicationService {
"👮🏻‍♂️ external user promoted to internal by admin",
);
Ok(UserDto::from(updated))
Ok(PublicUserDto::new(updated, false))
}
/// `keep_session_id` — when `Some`, revoke every OTHER session for
@@ -2492,9 +2573,9 @@ impl AuthApplicationService {
Ok(())
}
pub async fn get_user(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
pub async fn get_user(&self, user_id: Uuid) -> Result<PublicUserDto, DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
Ok(UserDto::from(user))
Ok(PublicUserDto::new(user, false))
}
/// Cached, image-free lookup of the caller's authorization flags
@@ -2643,7 +2724,7 @@ impl AuthApplicationService {
caller_id: Uuid,
dto: crate::application::dtos::user_dto::UpdateProfileDto,
locale_registry: &crate::common::locale::LocaleRegistry,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
// For OIDC-managed users, refuse the patch ONLY when it touches
@@ -2819,7 +2900,7 @@ impl AuthApplicationService {
if changed.is_empty() && ui_prefs_patch.is_none() {
// No-op — return the current user without a DB write.
return Ok(UserDto::from(user));
return Ok(PublicUserDto::new(user, false));
}
// Persist the typed-field changes first (if any). Skip the
@@ -2850,11 +2931,11 @@ impl AuthApplicationService {
// Refetch so the returned DTO reflects the merged JSONB bag
// (the in-memory `user` above holds the pre-merge value).
let refreshed = self.user_storage.get_user_by_id(caller_id).await?;
Ok(UserDto::from(refreshed))
Ok(PublicUserDto::new(refreshed, false))
}
// Alias for consistency with handler method
pub async fn get_user_by_id(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
pub async fn get_user_by_id(&self, user_id: Uuid) -> Result<PublicUserDto, DomainError> {
self.get_user(user_id).await
}
@@ -2871,6 +2952,26 @@ impl AuthApplicationService {
UserStoragePort::get_user_by_id(&*self.user_storage, user_id).await
}
/// Load the full `User` entity + `UserDerivedFlags` for the given
/// id in ONE query. See
/// [`UserRepository::get_user_with_derived_flags`](crate::domain::repositories::user_repository::UserRepository::get_user_with_derived_flags)
/// for the shape and the SELECT that drives it. Used by
/// `/api/auth/me` to build a `SelfUserDto` and by future admin
/// single-user views to build a `FullUserDto` without paying two
/// round-trips.
pub async fn get_user_with_derived_flags(
&self,
user_id: Uuid,
) -> Result<
(
crate::domain::entities::user::User,
crate::domain::repositories::user_repository::UserDerivedFlags,
),
DomainError,
> {
UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await
}
/// Login-style identifier lookup: dispatches on `@` in the input
/// (email path when present, username path when not), identical
/// to `login()`'s dispatch. Exposed so the OPAQUE login handler
@@ -2927,12 +3028,23 @@ impl AuthApplicationService {
target_id: Uuid,
expose_system_users: bool,
pool: &sqlx::PgPool,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
// (1) Self — a single fetch suffices (the check compares the input
// UUIDs, so the target read is never needed on this path).
//
// Both branches use `get_user_with_derived_flags` (not the narrow
// `get_user_by_id`) so `PublicUserDto.is_online` on the wire
// reflects the same EXISTS subquery the admin list uses. Without
// this the FE presence dot would only light up on list-derived
// paths (admin seed); single fetches from share pickers / group
// members would show every user as offline regardless of real
// state. See `docs/plan/userdto-refactor.md`.
if caller_id == target_id {
let caller = self.user_storage.get_user_by_id(caller_id).await?;
return Ok(UserDto::from(caller));
let (caller, flags) = self
.user_storage
.get_user_with_derived_flags(caller_id)
.await?;
return Ok(PublicUserDto::new(caller, flags.is_online));
}
// Caller and target are independent point reads (the self-case already
@@ -2940,16 +3052,26 @@ impl AuthApplicationService {
// overlap them with `join!` instead of two serial round-trips.
// `caller_res?` first preserves the caller-error precedence of the old
// sequential form. (benches/ROUND23.md §P1)
//
// `caller` uses the narrow `get_user_by_id` because we only read
// `is_external()` off it for the visibility gate; nothing about
// the caller ships on the wire. Only `target` needs the wider
// projection.
let (caller_res, target_res) = tokio::join!(
self.user_storage.get_user_by_id(caller_id),
self.user_storage.get_user_by_id(target_id)
self.user_storage.get_user_with_derived_flags(target_id)
);
let caller = caller_res?;
// 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 target_res {
//
// Destructure the (User, UserDerivedFlags) tuple immediately so
// `target` keeps its historical `User` shape (accessors still
// work below); the flags come along as `target_flags` for the
// `is_online` propagation into the returned `PublicUserDto`.
let (target, target_flags) = match target_res {
Ok(u) => u,
Err(e) if e.kind == ErrorKind::NotFound => {
tracing::info!(
@@ -2993,7 +3115,7 @@ impl AuthApplicationService {
})?;
if related.is_some() {
return Ok(UserDto::from(target));
return Ok(PublicUserDto::new(target, target_flags.is_online));
}
// (3) External callers stop here — no directory enumeration.
@@ -3021,12 +3143,12 @@ impl AuthApplicationService {
// (4) Internal target + system-address-book exposed: already public.
if !target.is_external() && expose_system_users {
return Ok(UserDto::from(target));
return Ok(PublicUserDto::new(target, target_flags.is_online));
}
// (5) Admin caller: always visible.
if caller.role() == UserRole::Admin {
return Ok(UserDto::from(target));
return Ok(PublicUserDto::new(target, target_flags.is_online));
}
// (6) No relationship — anti-enumeration NotFound.
@@ -3079,7 +3201,7 @@ impl AuthApplicationService {
username: &str,
expose_system_users: bool,
pool: &sqlx::PgPool,
) -> Result<UserDto, DomainError> {
) -> Result<PublicUserDto, DomainError> {
let target = match self.user_storage.get_user_by_username(username).await {
Ok(u) => u,
Err(e) if e.kind == ErrorKind::NotFound => {
@@ -3106,9 +3228,9 @@ impl AuthApplicationService {
}
// 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> {
pub async fn get_user_by_username(&self, username: &str) -> Result<PublicUserDto, DomainError> {
let user = self.user_storage.get_user_by_username(username).await?;
Ok(UserDto::from(user))
Ok(PublicUserDto::new(user, false))
}
// Method to count how many admin users exist in the system
@@ -3125,42 +3247,46 @@ impl AuthApplicationService {
/// out so that internal-user surfaces — system address book, OCS
/// sharee search, etc. — never expose external identities. Admin
/// surfaces that need the full list should call
/// [`list_users_including_external_with_perms`] instead.
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<UserDto>, DomainError> {
let users = self.user_storage.list_users(limit, offset, false).await?;
Ok(users.into_iter().map(UserDto::from).collect())
}
/// Admin-only: lists users including external (grant-only) recipients.
/// Used by the admin user-management UI.
pub async fn list_users_including_external_with_perms<A: AuthorizationEngine>(
/// [`list_user_summaries_including_external_with_perms`] instead.
pub async fn list_users(
&self,
authorization: &A,
caller_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<UserDto>, DomainError> {
self.require_admin_caller(authorization, caller_id).await?;
let users = self.user_storage.list_users(limit, offset, true).await?;
Ok(users.into_iter().map(UserDto::from).collect())
) -> Result<Vec<PublicUserDto>, DomainError> {
let users = self.user_storage.list_users(limit, offset, false).await?;
Ok(users
.into_iter()
.map(|u| PublicUserDto::new(u, false))
.collect())
}
/// Admin-only compact listing. The detail endpoint retains the complete
/// [`UserDto`]; this path projects only what the management table renders so
/// PostgreSQL never detoasts or transfers avatars/preferences for a page.
/// Admin-only user listing. Returns `Vec<FullUserDto>` — same
/// `FullUserDto` shape [`SelfUserDto`] embeds, so the FE reads
/// admin table rows and `/me` responses through identical field
/// paths. Includes the avatar (`user.image`) and presence
/// (`user.is_online`) so the admin table renders the vignette +
/// green dot without per-row follow-up fetches to
/// `/api/users/{id}` (the N+1 that motivated the widening — see
/// `docs/plan/userdto-refactor.md` § N+1). This is the sole
/// admin-visible listing path; the former flat
/// `list_users_including_external_with_perms` variant was
/// retired when `?summary` was dropped.
pub async fn list_user_summaries_including_external_with_perms<A: AuthorizationEngine>(
&self,
authorization: &A,
caller_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<AdminUserSummaryDto>, DomainError> {
) -> Result<Vec<FullUserDto>, DomainError> {
self.require_admin_caller(authorization, caller_id).await?;
let users = self
let rows = self
.user_storage
.list_user_summaries(limit, offset, true)
.list_users_with_derived_flags(limit, offset, true)
.await?;
Ok(users.into_iter().map(AdminUserSummaryDto::from).collect())
Ok(rows
.into_iter()
.map(|(user, flags)| FullUserDto::build(user, flags))
.collect())
}
/// Service-layer gate for administrator-scoped user-directory operations.
@@ -3183,9 +3309,16 @@ impl AuthApplicationService {
}
/// Searches internal users only. See [`list_users`] for the rationale.
pub async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<UserDto>, DomainError> {
pub async fn search_users(
&self,
query: &str,
limit: i64,
) -> Result<Vec<PublicUserDto>, DomainError> {
let users = self.user_storage.search_users(query, limit, false).await?;
Ok(users.into_iter().map(UserDto::from).collect())
Ok(users
.into_iter()
.map(|u| PublicUserDto::new(u, false))
.collect())
}
/// Username-only search for the NC sharee autocomplete: identical
@@ -3215,8 +3348,8 @@ impl AuthApplicationService {
// `interfaces/api/routes.rs::admin_router`) — but every admin
// method here still calls `require_admin_caller` as a
// defense-in-depth check, matching the pattern
// `list_users_including_external_with_perms` established. If a
// handler is ever wired outside the /admin subtree, the AuthZ
// `list_user_summaries_including_external_with_perms` established.
// If a handler is ever wired outside the /admin subtree, the AuthZ
// still holds.
/// List sessions for the admin panel. `user_id_filter = Some(uuid)`
@@ -3291,7 +3424,7 @@ impl AuthApplicationService {
pub async fn admin_create_user(
&self,
dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
) -> Result<UserDto, DomainError> {
) -> Result<FullUserDto, DomainError> {
// Validate username length
if dto.username.len() < 3 || dto.username.len() > 254 {
return Err(DomainError::new(
@@ -3449,7 +3582,16 @@ impl AuthApplicationService {
created.id(),
created.is_external()
);
Ok(UserDto::from(created))
// Return `FullUserDto` — same shape as `GET /api/admin/users/{id}`
// and one row of the admin list. Admin surfaces uniformly return
// FullUserDto so the SPA / test asserts don't need to know which
// admin endpoint they came from. Fresh user has no session yet
// (`is_online = false`) and no OPAQUE registration; `has_password`
// reflects whatever the admin passed in the DTO.
let created_id = created.id();
let (user, flags) =
UserStoragePort::get_user_with_derived_flags(&*self.user_storage, created_id).await?;
Ok(FullUserDto::build(user, flags))
}
/// Admin-only: reset a user's password.
@@ -3545,10 +3687,20 @@ impl AuthApplicationService {
Ok(())
}
/// Get a single user by ID (for admin panel)
pub async fn get_user_admin(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
Ok(UserDto::from(user))
/// Get a single user by ID (for admin panel).
///
/// Returns `FullUserDto` — same shape as one row of
/// `/api/admin/users` — so admin single-user views (detail modal,
/// per-user edit page) render the same fields the list surfaces.
/// The single-row admin view is the canonical observation surface
/// for admin-visible signals like `email_verified_at` /
/// `has_password` / `opaque_registered` / `last_login_at` — none
/// of which live on the peer-view `PublicUserDto`. See
/// `docs/plan/userdto-refactor.md`.
pub async fn get_user_admin(&self, user_id: Uuid) -> Result<FullUserDto, DomainError> {
let (user, flags) =
UserStoragePort::get_user_with_derived_flags(&*self.user_storage, user_id).await?;
Ok(FullUserDto::build(user, flags))
}
/// Delete a user by ID (admin only).
@@ -4654,11 +4806,17 @@ impl AuthApplicationService {
let access_token =
self.token_service
.generate_access_token(&user, Some(session.id()), None)?;
// Build the SelfUserDto before `session` is consumed by the
// storage call — the builder reads `session.dpop_jkt()`
// (None here since OIDC callbacks land unbound and the SPA
// finishes binding post-redirect).
let user_id = user.id();
let user_dto = self.build_self_user_dto(user_id, &session).await?;
self.session_storage.create_session(session).await?;
let force_password_change = self.read_force_password_change(user.id()).await;
let force_password_change = self.read_force_password_change(user_id).await;
let auth_response = AuthResponseDto {
user: UserDto::from(user),
user: user_dto,
access_token,
refresh_token,
token_type: "Bearer".to_string(),
+1 -1
View File
@@ -207,7 +207,7 @@ pub struct User {
/// Owned decomposition of a [`User`] (mirrors `FileParts` / `FolderParts` /
/// `ContactParts`). Lets a consumer MOVE the heap fields out instead of cloning
/// them through the borrowing accessors — notably `image` (a data URI up to
/// 512 KiB) and `ui_preferences` (a JSON tree). See `UserDto::from`
/// 512 KiB) and `ui_preferences` (a JSON tree). See `PublicUserDto::from`
/// (benches/ROUND20.md §A2).
pub struct UserParts {
pub id: Uuid,
+42 -45
View File
@@ -1,6 +1,5 @@
use crate::common::errors::DomainError;
use crate::domain::entities::user::{User, UserRole};
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
@@ -26,49 +25,26 @@ pub enum UserRepositoryError {
pub type UserRepositoryResult<T> = Result<T, UserRepositoryError>;
/// Narrow projection for user-directory tables that do not need secrets,
/// profile pictures, or the cross-device UI-preferences document.
/// DB-computed booleans about a user that aren't fields on the
/// [`User`](crate::domain::entities::user::User) entity itself —
/// either derived from column presence (`password_hash IS NOT NULL`)
/// or from a cross-table lookup (`auth.sessions.last_seen_at` for
/// `is_online`). Companion to `User` on the list projection: the
/// repo computes both, the application layer packs them into
/// [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto).
///
/// The full [`User`] row intentionally carries all of those fields for account
/// detail and the system address book. Reusing it for the paginated admin
/// table made PostgreSQL detoast and transfer an avatar of up to 512 KiB per
/// row, only for the handler to serialize it back to the browser where the
/// table never reads it. Keeping the projection explicit prevents a future
/// full-row field from silently returning to that hot path.
#[derive(Debug, Clone)]
pub struct UserListEntry {
pub id: Uuid,
pub username: Option<String>,
pub email: String,
pub role: UserRole,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
pub federation_kind: Option<String>,
pub federation_issuer: Option<String>,
pub is_external: bool,
/// TRUE when `auth.users.password_hash IS NOT NULL` — user has a
/// server-verifiable password on file (legacy or admin-set).
/// Distinct from `opaque_registered` (which is the zero-knowledge
/// envelope): a fully-migrated user carries BOTH — password for
/// the fallback / operator flows, envelope for the actual login.
/// A user with `has_password = false AND !opaque_registered AND
/// federation_issuer IS NULL` is passwordless — the only path in is
/// via magic-link (or, for externals, whatever grant they hold).
/// Not "admin-only" — every field ends up on `FullUserDto`, which
/// both admin AND self read. The name reflects "derived from the DB
/// row, not intrinsic to the User entity".
///
/// See `docs/plan/userdto-refactor.md` for the design; this type
/// replaced the earlier `UserListEntry` narrow projection as of P6.
#[derive(Debug, Clone, Copy)]
pub struct UserDerivedFlags {
pub has_password: bool,
/// TRUE when `auth.users.opaque_envelope IS NOT NULL` — the user
/// has completed OPAQUE registration (typically via the Phase 2
/// silent-migration hook after a successful legacy login). Surfaced
/// on the admin user table so operators can see rollout progress
/// per-user. Admin-only exposure — see `AdminUserSummaryDto`.
pub opaque_registered: bool,
/// TRUE when `auth.users.opaque_migrated_at IS NOT NULL` — the
/// user has completed at least one successful OPAQUE login. Distinct
/// from `opaque_registered` because a user can have an envelope on
/// file without having actually logged in via OPAQUE yet (e.g.
/// admin cleared the envelope, silent-migration hasn't re-run).
pub opaque_migrated: bool,
pub is_online: bool,
}
// Conversion from UserRepositoryError to DomainError
@@ -94,6 +70,20 @@ pub trait UserRepository: Send + Sync + 'static {
/// Gets a user by ID
async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult<User>;
/// Fetch the full `User` entity + the [`UserDerivedFlags`] in a
/// single query. Used by `/api/auth/me` and future admin single-user
/// views — anywhere the caller needs both the row itself AND the
/// derived booleans (`has_password`, OPAQUE flags, `is_online`) to
/// build a [`FullUserDto`](crate::application::dtos::user_dto::FullUserDto)
/// or [`SelfUserDto`](crate::application::dtos::user_dto::SelfUserDto).
/// Single query is cheaper than `get_user_by_id` + separate lookups
/// for OPAQUE state + `is_online`; the EXISTS subquery is cheap
/// thanks to the partial index `idx_sessions_last_seen_at`.
async fn get_user_with_derived_flags(
&self,
id: Uuid,
) -> UserRepositoryResult<(User, UserDerivedFlags)>;
/// Batch-loads a set of users by id, preserving no particular order
/// and silently skipping ids that don't match any row. Caller is
/// responsible for de-duplicating the input vec. Returns an empty
@@ -152,15 +142,22 @@ pub trait UserRepository: Send + Sync + 'static {
include_external: bool,
) -> UserRepositoryResult<Vec<User>>;
/// Lists the columns needed by compact user-management tables. Unlike
/// [`Self::list_users`], this never fetches password hashes, OIDC subjects,
/// avatars, names, locale state, or UI preferences.
async fn list_user_summaries(
/// Paginated admin user listing — full `User` entity + the derived
/// booleans (`has_password`, OPAQUE flags, `is_online`) in one wide
/// SELECT. Called by the admin service to build
/// `Vec<FullUserDto>` for `/api/admin/users` without paying two
/// round-trips per row (once for User, once for derived flags).
///
/// Same `include_external` semantics as [`Self::list_users`]:
/// admin management UI passes `true`; every other caller passes
/// `false` so external / grant-only users stay off internal-user
/// surfaces.
async fn list_users_with_derived_flags(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> UserRepositoryResult<Vec<UserListEntry>>;
) -> UserRepositoryResult<Vec<(User, UserDerivedFlags)>>;
/// Searches users by username or email (SQL ILIKE) with a limit.
/// See [`list_users`] for the meaning of `include_external`.
@@ -7,7 +7,7 @@ use crate::application::ports::auth_ports::UserStoragePort;
use crate::common::errors::DomainError;
use crate::domain::entities::user::{User, UserFlags, UserRole};
use crate::domain::repositories::user_repository::{
StorageStats, UserListEntry, UserRepository, UserRepositoryError, UserRepositoryResult,
StorageStats, UserRepository, UserRepositoryError, UserRepositoryResult,
};
use crate::infrastructure::repositories::pg::transaction_utils::with_transaction;
@@ -388,6 +388,90 @@ impl UserRepository for UserPgRepository {
))
}
async fn get_user_with_derived_flags(
&self,
id: Uuid,
) -> UserRepositoryResult<(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
)> {
// Same column set as `get_user_by_id` plus the three IS-NOT-NULL
// derivations for auth-capability flags AND the EXISTS scalar
// for `is_online`. The `interval` argument is bound as `$2`
// (seconds, `ONLINE_WINDOW.as_secs_f64()`) via
// `make_interval(secs => $2)` — same pattern as
// `session_liveness_gauges.rs`. Partial index
// `idx_sessions_last_seen_at WHERE revoked = FALSE` covers the
// EXISTS scan, so per-row cost is ~μs.
let row = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences,
(password_hash IS NOT NULL) AS has_password,
(opaque_envelope IS NOT NULL) AS opaque_registered,
(opaque_migrated_at IS NOT NULL) AS opaque_migrated,
EXISTS (
SELECT 1 FROM auth.sessions s
WHERE s.user_id = auth.users.id
AND s.revoked = FALSE
AND s.last_seen_at > NOW() - make_interval(secs => $2)
) AS is_online
FROM auth.users
WHERE id = $1
"#,
)
.bind(id)
.bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64())
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
let role = match role_str.as_deref() {
Some("admin") => UserRole::Admin,
_ => UserRole::User,
};
let user = User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
role,
row.get("storage_quota_bytes"),
row.get("storage_used_bytes"),
row.get("created_at"),
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
);
let flags = crate::domain::repositories::user_repository::UserDerivedFlags {
has_password: row.get("has_password"),
opaque_registered: row.get("opaque_registered"),
opaque_migrated: row.get("opaque_migrated"),
is_online: row.get("is_online"),
};
Ok((user, flags))
}
/// Gets a user by username
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User> {
let row = sqlx::query(
@@ -835,103 +919,107 @@ impl UserRepository for UserPgRepository {
Ok(users)
}
async fn list_user_summaries(
async fn list_users_with_derived_flags(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> UserRepositoryResult<Vec<UserListEntry>> {
let rows = sqlx::query_as::<
_,
(
Uuid,
Option<String>,
String,
String,
i64,
i64,
Option<chrono::DateTime<chrono::Utc>>,
bool,
Option<String>,
Option<String>,
bool,
bool,
bool,
bool,
),
>(
// Auth-credential columns projected as booleans via `IS NOT
// NULL` rather than as timestamps / hashes so the row-mapping
// tuple stays small and the wire shape is exactly what the
// admin table needs. Per-row scalar tests — no cost beyond
// the full-table sequential scan the LIMIT/OFFSET already
// pays. `has_password` on the password_hash column tells
// the admin table whether a server-verifiable password is
// on file; combined with the two OPAQUE flags and
// federation_kind / federation_issuer, the SPA derives the
// full "capability set" per user (password / OPAQUE / SSO /
// passwordless).
) -> UserRepositoryResult<
Vec<(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
)>,
> {
// Full `User` column set (matches `get_user_by_id`) + the four
// derived booleans (IS-NOT-NULL for auth capability, EXISTS for
// `is_online`) in one SELECT. Same rationale as the single-user
// `get_user_with_derived_flags` variant. Widened over the older
// `list_user_summaries` projection because the FE now consumes
// the full user profile from these rows (killing the per-row
// `/api/users/{id}` fetch the admin table used to fire for
// avatars — see docs/plan/userdto-refactor.md § N+1).
//
// `interval` bound as `$4` seconds
// (`ONLINE_WINDOW.as_secs_f64()`), same pattern as
// `session_liveness_gauges.rs` and `get_user_with_derived_flags`.
let rows = sqlx::query(
r#"
SELECT
id, username, email, role::text,
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
last_login_at, active,
federation_kind, federation_issuer, is_external,
(password_hash IS NOT NULL) AS has_password,
created_at, updated_at, last_login_at, active,
federation_kind, federation_issuer, federation_subject, image, is_external,
given_name, family_name, email_verified_at, preferred_locale, notify_on_share,
ui_preferences,
(password_hash IS NOT NULL) AS has_password_flag,
(opaque_envelope IS NOT NULL) AS opaque_registered,
(opaque_migrated_at IS NOT NULL) AS opaque_migrated
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2
(opaque_migrated_at IS NOT NULL) AS opaque_migrated,
EXISTS (
SELECT 1 FROM auth.sessions s
WHERE s.user_id = auth.users.id
AND s.revoked = FALSE
AND s.last_seen_at > NOW() - make_interval(secs => $4)
) AS is_online
FROM auth.users
WHERE ($3 OR is_external = FALSE)
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(limit)
.bind(offset)
.bind(include_external)
.fetch_all(self.pool.as_ref())
.bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64())
.fetch_all(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
// Note: the `has_password_flag` alias avoids colliding with the
// `password_hash` column selected above (the tuple destructure
// in `list_user_summaries` uses a shorter projection so it
// could reuse the raw `has_password` alias; here we keep both).
Ok(rows
.into_iter()
.map(
|(
id,
username,
email,
.map(|row| {
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
let role = match role_str.as_deref() {
Some("admin") => UserRole::Admin,
_ => UserRole::User,
};
let user = User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
role,
storage_quota_bytes,
storage_used_bytes,
last_login_at,
active,
federation_kind,
federation_issuer,
is_external,
has_password,
opaque_registered,
opaque_migrated,
)| UserListEntry {
id,
username,
email,
role: if role == "admin" {
UserRole::Admin
} else {
UserRole::User
},
storage_quota_bytes,
storage_used_bytes,
last_login_at,
active,
federation_kind,
federation_issuer,
is_external,
has_password,
opaque_registered,
opaque_migrated,
},
)
row.get("storage_quota_bytes"),
row.get("storage_used_bytes"),
row.get("created_at"),
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get::<Option<String>, _>("federation_kind")
.as_deref()
.and_then(crate::domain::entities::user::FederationKind::parse),
row.get("federation_issuer"),
row.get("federation_subject"),
row.get("image"),
row.get("is_external"),
row.get("given_name"),
row.get("family_name"),
row.get("email_verified_at"),
row.get("preferred_locale"),
row.get("notify_on_share"),
row.get::<serde_json::Value, _>("ui_preferences"),
);
let flags = crate::domain::repositories::user_repository::UserDerivedFlags {
has_password: row.get("has_password_flag"),
opaque_registered: row.get("opaque_registered"),
opaque_migrated: row.get("opaque_migrated"),
is_online: row.get("is_online"),
};
(user, flags)
})
.collect())
}
@@ -1302,6 +1390,21 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from)
}
async fn get_user_with_derived_flags(
&self,
id: Uuid,
) -> Result<
(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
),
DomainError,
> {
UserRepository::get_user_with_derived_flags(self, id)
.await
.map_err(DomainError::from)
}
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<User>, DomainError> {
UserRepository::get_users_by_ids(self, ids)
.await
@@ -1356,13 +1459,19 @@ impl UserStoragePort for UserPgRepository {
.map_err(DomainError::from)
}
async fn list_user_summaries(
async fn list_users_with_derived_flags(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> Result<Vec<UserListEntry>, DomainError> {
UserRepository::list_user_summaries(self, limit, offset, include_external)
) -> Result<
Vec<(
User,
crate::domain::repositories::user_repository::UserDerivedFlags,
)>,
DomainError,
> {
UserRepository::list_users_with_derived_flags(self, limit, offset, include_external)
.await
.map_err(DomainError::from)
}
@@ -1743,26 +1852,27 @@ mod integration_tests {
)
.await;
let page = UserRepository::list_user_summaries(&repo, 3, 0, true)
// Migrated from the (now-deleted) `list_user_summaries` +
// `UserListEntry` to `list_users_with_derived_flags`, which
// returns `Vec<(User, UserDerivedFlags)>`. Field checks read
// through the `User` accessors instead of struct-field access.
let page = UserRepository::list_users_with_derived_flags(&repo, 3, 0, true)
.await
.expect("compact projection query must decode");
assert_eq!(page.iter().map(|entry| entry.id).collect::<Vec<_>>(), ids);
assert_eq!(page[0].username.as_deref(), Some(username_a.as_str()));
assert_eq!(page[0].role, UserRole::Admin);
assert_eq!(page[0].storage_quota_bytes, 10_737_418_240);
assert_eq!(page[1].username, None);
assert!(page[1].is_external);
assert_eq!(
page[1].federation_issuer.as_deref(),
Some("integration-idp")
);
assert_eq!(page.iter().map(|(u, _)| u.id()).collect::<Vec<_>>(), ids);
assert_eq!(page[0].0.username(), Some(username_a.as_str()));
assert_eq!(page[0].0.role(), UserRole::Admin);
assert_eq!(page[0].0.storage_quota_bytes(), 10_737_418_240);
assert_eq!(page[1].0.username(), None);
assert!(page[1].0.is_external());
assert_eq!(page[1].0.federation_issuer(), Some("integration-idp"));
let internal = UserRepository::list_user_summaries(&repo, 10, 0, false)
let internal = UserRepository::list_users_with_derived_flags(&repo, 10, 0, false)
.await
.expect("internal compact projection query must decode");
assert!(internal.iter().any(|entry| entry.id == ids[0]));
assert!(internal.iter().any(|entry| entry.id == ids[2]));
assert!(!internal.iter().any(|entry| entry.id == ids[1]));
assert!(internal.iter().any(|(u, _)| u.id() == ids[0]));
assert!(internal.iter().any(|(u, _)| u.id() == ids[2]));
assert!(!internal.iter().any(|(u, _)| u.id() == ids[1]));
sqlx::query("DELETE FROM auth.users WHERE id = ANY($1)")
.bind(ids.as_slice())
+74 -36
View File
@@ -22,7 +22,7 @@ use crate::application::dtos::settings_dto::{
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
UpdateUserRoleDto,
};
use crate::application::dtos::user_dto::{AdminUserSummaryDto, UserDto};
use crate::application::dtos::user_dto::{FullUserDto, PublicUserDto};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::plugin_ports::{LogQuery, PluginManagementPort, PluginMgmtError};
// JobStoreProvider is used only by the storage-migration shims below,
@@ -39,16 +39,14 @@ use crate::interfaces::middleware::auth::AuthUser;
use std::sync::Arc;
use uuid::Uuid;
#[derive(serde::Serialize)]
#[serde(untagged)]
enum AdminUsersPayload {
Full(Vec<UserDto>),
Summary(Vec<AdminUserSummaryDto>),
}
/// Response envelope for `GET /api/admin/users`. `users` is always
/// `Vec<FullUserDto>` — same shape one row of `/me`'s embedded
/// `full` block carries; the FE seeds `resolveUser` cache from
/// `row.user` (kills the per-row `/api/users/{id}` fetch). See
/// `docs/plan/userdto-refactor.md`.
#[derive(serde::Serialize)]
struct AdminUsersPageResponse {
users: AdminUsersPayload,
users: Vec<FullUserDto>,
total: i64,
limit: i64,
offset: i64,
@@ -931,6 +929,51 @@ pub async fn get_dashboard_stats(
.await
.map_err(|e| AppError::internal_error(format!("Database query failed: {}", e)))?;
// External account count — distinct query (not FILTERed into
// `stats_row` above) because `stats_row` scopes to
// `is_external = false` for the operational-seat counts.
// Externals form their own population; the dashboard renders them
// as a separate stat card in the "User accounts" section.
let external_users: i64 =
sqlx::query_scalar(r#"SELECT COUNT(*)::INT8 FROM auth.users WHERE is_external = true"#)
.fetch_one(db_pool.as_ref())
.await
.map_err(|e| AppError::internal_error(format!("External user count failed: {}", e)))?;
// Live-activity counts — projection over auth.sessions, same
// `ONLINE_WINDOW` (5 min) the Prometheus gauges use so the
// dashboard number, admin-table green dot, and
// `oxicloud_sessions_online` scrape all agree by construction.
// Bound as `$1 = window_secs` via `make_interval(secs => $1)`
// to keep the single-source-of-truth pattern (no SQL literal
// for the window). Both queries hit the partial index
// `idx_sessions_last_seen_at WHERE revoked = FALSE` so per-run
// cost is ~μs even at tens of thousands of session rows.
let online_window_secs: f64 =
crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64();
let online_sessions: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)::INT8 FROM auth.sessions
WHERE revoked = FALSE
AND last_seen_at > NOW() - make_interval(secs => $1)
"#,
)
.bind(online_window_secs)
.fetch_one(db_pool.as_ref())
.await
.map_err(|e| AppError::internal_error(format!("Online session count failed: {}", e)))?;
let online_users: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(DISTINCT user_id)::INT8 FROM auth.sessions
WHERE revoked = FALSE
AND last_seen_at > NOW() - make_interval(secs => $1)
"#,
)
.bind(online_window_secs)
.fetch_one(db_pool.as_ref())
.await
.map_err(|e| AppError::internal_error(format!("Online user count failed: {}", e)))?;
use sqlx::Row;
// Per-drive-kind quota panel:
@@ -1015,6 +1058,9 @@ pub async fn get_dashboard_stats(
total_users: stats_row.get("total_users"),
active_users: stats_row.get("active_users"),
admin_users: stats_row.get("admin_users"),
external_users,
online_users,
online_sessions,
drive_usage,
users_over_80_percent: stats_row.get("users_over_80"),
users_over_quota: stats_row.get("users_over_quota"),
@@ -1037,13 +1083,20 @@ pub async fn get_dashboard_stats(
// ============================================================================
/// GET /api/admin/users?limit=50&offset=0 — list all users
///
/// Always returns `Vec<FullUserDto>` — the shape one row of the
/// `/me` response's embedded `full` block carries. The former
/// `?summary` toggle (flat `PublicUserDto` vs nested `FullUserDto`)
/// has been retired: admin listing is low-volume and the FE always
/// asked for the nested shape anyway, so the two-shape split served
/// no caller and only invited jq-path bugs. See
/// `docs/plan/userdto-refactor.md`.
#[utoipa::path(
get,
path = "/api/admin/users",
params(
("limit" = Option<i64>, Query, description = "Max users to return (default 100, max 500)"),
("offset" = Option<i64>, Query, description = "Pagination offset"),
("summary" = Option<bool>, Query, description = "Return the compact management-table projection")
("offset" = Option<i64>, Query, description = "Pagination offset")
),
responses(
(status = 200, description = "List of users"),
@@ -1071,31 +1124,16 @@ pub async fn list_users(
// internal-only variant is used by system address book / sharee
// search, where surfacing externals would leak identities. See
// `auth_application_service::list_users` doc for the split.
let users = if query.summary.unwrap_or(false) {
AdminUsersPayload::Summary(
auth.auth_application_service
.list_user_summaries_including_external_with_perms(
state.authorization.as_ref(),
auth_user.id,
limit,
offset,
)
.await
.map_err(AppError::from)?,
let users = auth
.auth_application_service
.list_user_summaries_including_external_with_perms(
state.authorization.as_ref(),
auth_user.id,
limit,
offset,
)
} else {
AdminUsersPayload::Full(
auth.auth_application_service
.list_users_including_external_with_perms(
state.authorization.as_ref(),
auth_user.id,
limit,
offset,
)
.await
.map_err(AppError::from)?,
)
};
.await
.map_err(AppError::from)?;
let total = auth
.auth_application_service
@@ -1562,7 +1600,7 @@ pub async fn reset_user_password(
path = "/api/admin/users/{id}/promote-to-internal",
params(("id" = String, Path, description = "Target user id")),
responses(
(status = 200, description = "User promoted", body = UserDto),
(status = 200, description = "User promoted", body = PublicUserDto),
(status = 400, description = "Magic-link login is disabled on this deployment"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Admin required (or target is OIDC-linked)"),
@@ -61,7 +61,7 @@ async fn create_app_password(
}
// Require a claimed username. NextCloud Basic Auth resolves users by
// username; an app password is unusable without one. UserDto carries
// username; an app password is unusable without one. PublicUserDto carries
// an empty string when the underlying `users.username` is NULL — the
// entity rejects empty strings on construction, so empty here is an
// unambiguous signal that the column is NULL.
+62 -46
View File
@@ -12,8 +12,8 @@ use uuid::Uuid;
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto,
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UpgradeToInternalDto,
UserDto,
OidcProviderInfoDto, PublicUserDto, RefreshTokenDto, RegisterDto, SelfUserDto, SetupAdminDto,
UpgradeToInternalDto,
};
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
use crate::common::di::AppState;
@@ -89,7 +89,7 @@ pub fn setup_route() -> Router<Arc<AppState>> {
/// the `audit` channel as `auth.register` with `reason` one of
/// `created`, `email_taken`, `username_taken`.
/// - **SMTP not configured**: there is no welcome-mail cover story, so
/// the classic `201 + UserDto` on success and `409` on collision
/// the classic `201 + PublicUserDto` on success and `409` on collision
/// apply. Anti-enumeration would just be misleading UX (telling the
/// user to check an email that will never arrive). Email-only
/// signup is **503** in this mode because the user would otherwise
@@ -106,7 +106,7 @@ pub fn setup_route() -> Router<Arc<AppState>> {
request_body = RegisterDto,
responses(
(status = 200, description = "Uniform registration response (SMTP configured, anti-enumeration mode)"),
(status = 201, description = "User registered successfully (SMTP not configured)", body = UserDto),
(status = 201, description = "User registered successfully (SMTP not configured)", body = PublicUserDto),
(status = 400, description = "Validation error (malformed request body)"),
(status = 403, description = "Registration disabled (admin setting or OIDC-only mode)"),
(status = 409, description = "Username or email already taken (SMTP not configured)"),
@@ -280,7 +280,7 @@ pub async fn register(
}
Ok(resp)
} else {
// Classic mode: clear 201 + UserDto so the frontend can
// Classic mode: clear 201 + PublicUserDto so the frontend can
// log the user in directly with the password they just
// submitted. Unbox the DTO for the JSON serialisation.
Ok((StatusCode::CREATED, Json(*user)).into_response())
@@ -626,7 +626,7 @@ pub async fn refresh_token(
get,
path = "/api/auth/me",
responses(
(status = 200, description = "Current user profile", body = UserDto),
(status = 200, description = "Current user profile", body = SelfUserDto),
(status = 401, description = "Not authenticated"),
),
security(("bearerAuth" = [])),
@@ -652,37 +652,21 @@ pub async fn get_current_user(
// Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM
// of `used_bytes` across the user's personal drives only. Shared drives
// never count against this envelope — collaborating in a team drive
// costs no personal bytes. The matching cap is
// `storage_quota_bytes` (admin-only mutation).
let mut user = auth_service
// costs no personal bytes.
//
// Delegate to the shared `build_self_user_dto_for_id` — same code
// path `PATCH /me/profile` and `POST /upgrade-to-internal` use so
// all three self endpoints ship byte-for-byte identical shapes.
// The DPoP-bound signal comes from the JWT `cnf.jkt` claim
// (surfaced by the auth middleware into `AuthUser.dpop_jkt`);
// when present the session that minted this JWT is bound and
// the SPA can skip a redundant `/dpop/bind` call.
let self_dto = auth_service
.auth_application_service
.get_user_by_id(user_id)
.build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some())
.await?;
// Overlay the cached `force_password_change` flag (see UserFlags).
// `From<User>` defaults to false; the SPA reads this field on
// startup to decide whether to enter mandatory change-password
// mode. Using the cached path (`get_user_flags` → `user_flags_cache`)
// avoids a second DB round-trip on this hot endpoint.
if let Ok(flags) = auth_service
.auth_application_service
.get_user_flags(user_id)
.await
{
user.force_password_change = flags.force_password_change;
}
// Session-binding state — read from the JWT `cnf.jkt` claim
// (surfaced by the auth middleware into `CurrentUser.dpop_jkt`).
// Present ⇒ the session that minted this JWT was bound; absent ⇒
// the session is unbound and the SPA should call `/dpop/bind`
// to attach the browser's keypair (OIDC / magic-link redirect
// flow). Skips an otherwise-redundant `POST /dpop/bind` on every
// page load which would return 409 `already_bound` and litter
// the audit stream.
user.is_dpop_bound = auth_user.dpop_jkt.is_some();
Ok((StatusCode::OK, Json(user)))
Ok((StatusCode::OK, Json(self_dto)))
}
/// DTO for updating the user's profile image.
@@ -837,14 +821,16 @@ pub async fn change_password(
/// self-registration policy. Refused with 403
/// `error_type = "RegistrationDomainNotAllowed"`.
///
/// Response: the updated `UserDto` (post-upgrade view — `is_external`
/// is false, `storage_quota_bytes` is set).
/// Response: the updated `SelfUserDto` (same shape as `GET /me`) so the SPA
/// absorbs the post-upgrade state — new `storage_quota_bytes`,
/// `is_external = false`, updated OPAQUE / auth capability flags — in one
/// round trip without a follow-up `/me` fetch.
#[utoipa::path(
post,
path = "/api/auth/upgrade-to-internal",
request_body = UpgradeToInternalDto,
responses(
(status = 200, description = "Upgrade succeeded", body = UserDto),
(status = 200, description = "Upgrade succeeded — returns SelfUserDto (same shape as GET /me)", body = SelfUserDto),
(status = 400, description = "Password missing / too short"),
(status = 401, description = "Not authenticated"),
(status = 403, description = "OIDC user, or domain not in allowlist"),
@@ -855,9 +841,10 @@ pub async fn change_password(
)]
pub async fn upgrade_to_internal(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
auth_user: AuthUser,
Json(dto): Json<UpgradeToInternalDto>,
) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state
.auth_service
.as_ref()
@@ -905,7 +892,11 @@ pub async fn upgrade_to_internal(
}
}
let updated = auth_service
// Apply the upgrade. Service returns the updated `PublicUserDto`;
// we discard it and rebuild the full self view via the shared
// `build_self_user_dto_for_id` helper so the wire shape matches
// `GET /me` and `PATCH /me/profile` byte-for-byte.
let _ = auth_service
.auth_application_service
.upgrade_to_internal(user_id, dto)
.await
@@ -924,7 +915,11 @@ pub async fn upgrade_to_internal(
_ => AppError::from(err),
})?;
Ok((StatusCode::OK, Json(updated)))
let self_dto = auth_service
.auth_application_service
.build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some())
.await?;
Ok((StatusCode::OK, Json(self_dto)))
}
/// Update the caller's profile (PR 24).
@@ -942,7 +937,7 @@ pub async fn upgrade_to_internal(
path = "/api/auth/me/profile",
request_body = crate::application::dtos::user_dto::UpdateProfileDto,
responses(
(status = 200, description = "Updated profile (UserDto)", body = UserDto),
(status = 200, description = "Updated profile (SelfUserDto) — same shape as GET /me so the SPA sees the just-written state without a follow-up fetch", body = SelfUserDto),
(status = 400, description = "Validation error (e.g. invalid handle format, empty given_name)"),
(status = 401, description = "Not authenticated"),
(status = 403, description = "OIDC-managed profile — edit at the IdP"),
@@ -953,20 +948,39 @@ pub async fn upgrade_to_internal(
)]
pub async fn update_profile(
State(state): State<Arc<AppState>>,
CurrentUserId(user_id): CurrentUserId,
auth_user: AuthUser,
Json(dto): Json<crate::application::dtos::user_dto::UpdateProfileDto>,
) -> Result<impl IntoResponse, AppError> {
let user_id = auth_user.id;
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
let updated = auth_service
// Apply the patch. The service returns the updated `PublicUserDto`
// internally; we discard it and re-fetch the full self view below
// so the response matches `GET /me`'s `SelfUserDto` shape.
//
// Why SelfUserDto instead of PublicUserDto: a self-write endpoint
// whose response mirrors GET /me lets the SPA update its session
// store in one round trip. Returning a slim PublicUserDto would
// force the SPA to follow up with GET /me anyway to observe the
// just-written `ui_preferences` / `notify_on_share` / etc — those
// fields live on SelfUserDto only, not on the public identity
// slice. Same shape for both endpoints avoids "quiet lie" reads
// where a client PATCHes and then reads a stale local value.
let _ = auth_service
.auth_application_service
.update_profile_with_perms(user_id, dto, &state.locale_registry)
.await?;
Ok((StatusCode::OK, Json(updated)))
// Rebuild via the shared helper so the wire shape matches
// `GET /me` and `POST /upgrade-to-internal` byte-for-byte.
let self_dto = auth_service
.auth_application_service
.build_self_user_dto_for_id(user_id, auth_user.dpop_jkt.is_some())
.await?;
Ok((StatusCode::OK, Json(self_dto)))
}
// TODO: add utoipa
@@ -1226,7 +1240,7 @@ pub struct BackchannelLogoutForm {
path = "/api/setup",
request_body = SetupAdminDto,
responses(
(status = 201, description = "First admin created and system initialized", body = UserDto),
(status = 201, description = "First admin created and system initialized", body = PublicUserDto),
(status = 403, description = "System already initialized"),
(status = 503, description = "Auth service not configured"),
),
@@ -1820,10 +1834,12 @@ pub async fn oidc_exchange(
tracing::info!(
"OIDC token exchange successful for user: {}",
auth_response
.user
.full
.user
.username
.as_deref()
.unwrap_or(&auth_response.user.email)
.unwrap_or(&auth_response.user.full.user.email)
);
// Set HttpOnly cookies for the browser
@@ -16,7 +16,7 @@ use crate::application::dtos::contact_dto::{
AddressDto, ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, EmailDto,
GroupMembershipDto, PhoneDto, UpdateContactDto, UpdateContactGroupDto,
};
use crate::application::dtos::user_dto::UserDto;
use crate::application::dtos::user_dto::PublicUserDto;
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::application::services::contact_service::ContactService;
@@ -185,14 +185,14 @@ fn if_match_passes(if_match: Option<&str>, stored_etag: &str) -> bool {
}
}
/// Map a `UserDto` to a `ContactDto` so OxiCloud users appear as contacts
/// Map a `PublicUserDto` to a `ContactDto` so OxiCloud users appear as contacts
/// inside the virtual system address book.
///
/// `given_name`/`family_name` come from OIDC standard claims at JIT
/// provisioning (or NULL for password-only or pre-OIDC users). When
/// they're present, prefer a "First Last" full name; otherwise fall
/// back to the username (which is always present).
fn user_to_contact(user: UserDto) -> ContactDto {
fn user_to_contact(user: PublicUserDto) -> ContactDto {
// Display fallback chain: given+family name → username → email.
// Username is `Option<String>` post PR 16; externals start with None.
let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) {
@@ -222,8 +222,19 @@ fn user_to_contact(user: UserDto) -> ContactDto {
photo_url: user.image.clone(),
birthday: None,
anniversary: None,
created_at: user.created_at,
updated_at: user.updated_at,
// System-book contacts are VIRTUAL projections of the user
// directory — they have no independent creation history. Stamp
// both timestamps with `Utc::now()` so the ContactDto shape is
// satisfied; CardDAV clients ETag on the vCard content (see
// `etag` below, keyed on the stable user id), not on these
// wrapper timestamps.
//
// Previously read `user.created_at` / `user.updated_at` from the
// fat `UserDto`; those fields moved to `FullUserDto` under the
// three-layer refactor (docs/plan/userdto-refactor.md) and are
// not exposed on the slim `PublicUserDto` this function receives.
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
etag: user.id,
}
}
@@ -625,7 +625,7 @@ fn redirect_target(redemption: &MagicLinkRedemption) -> String {
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
format!("/files/{}", folder_id)
}
_ if redemption.auth.user.is_external => "/shared-with-me".to_string(),
_ if redemption.auth.user.full.user.is_external => "/shared-with-me".to_string(),
_ => "/files".to_string(),
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! User-profile lookup for the frontend.
//!
//! `GET /api/users/{id}` returns a [`UserDto`] for the target user iff
//! `GET /api/users/{id}` returns a [`PublicUserDto`] 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
+3 -3
View File
@@ -46,7 +46,7 @@ use crate::application::dtos::trash_dto::{
};
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto,
RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto,
PublicUserDto, RefreshTokenDto, RegisterDto, SetupAdminDto,
};
use crate::application::ports::chunked_upload_ports::{
ChunkUploadResponseDto, CreateUploadResponseDto, UploadStatusResponseDto,
@@ -367,7 +367,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
PaginationDto,
PaginationRequestDto,
// User / Auth schemas
UserDto,
PublicUserDto,
LoginDto,
RegisterDto,
SetupAdminDto,
@@ -583,7 +583,7 @@ mod tests {
"FolderDto",
"ShareDto",
"TrashedItemDto",
"UserDto",
"PublicUserDto",
] {
assert!(schemas.contains_key(name), "missing schema: {name}");
}
+33 -7
View File
@@ -191,7 +191,15 @@ async fn user_provisioning_response(
return Json(ocs_err(997, "Database pool not available")).into_response();
};
let user_dto = match auth_service
// Two-step lookup: (1) `get_user_profile_by_username_with_perms`
// gates access via the same visibility engine the REST endpoint
// uses; (2) if visibility passes, `get_user_with_derived_flags`
// hydrates the OCS-specific fields (federation_kind / last_login_at
// / active) that live on `FullUserDto` but not on the slim
// `PublicUserDto` returned by the visibility gate. Second call is
// ~1 DB round-trip on the maintenance pool; NC OCS provisioning is
// not on any hot inner loop.
let public = match auth_service
.get_user_profile_by_username_with_perms(
user.id,
&userid,
@@ -205,9 +213,27 @@ async fn user_provisioning_response(
return Json(ocs_err(404, "User not found")).into_response();
}
};
let target_id = match uuid::Uuid::parse_str(&public.id) {
Ok(u) => u,
Err(_) => {
// Should be unreachable — PublicUserDto.id is always the
// serialised form of a Uuid. Fail closed if this invariant
// is ever violated.
return Json(ocs_err(500, "Malformed user id")).into_response();
}
};
let user_dto = match auth_service.get_user_with_derived_flags(target_id).await {
Ok((user, flags)) => crate::application::dtos::user_dto::FullUserDto::build(user, flags),
Err(_) => {
// Visibility already passed above; a miss here would mean
// the user was deleted between the two round-trips. Fall
// back to the 404 shape (anti-enum invariant still holds).
return Json(ocs_err(404, "User not found")).into_response();
}
};
// Determine groups based on role
let groups = if user_dto.role == "admin" {
let groups = if user_dto.user.role == "admin" {
vec!["admin", "users"]
} else {
vec!["users"]
@@ -235,7 +261,7 @@ async fn user_provisioning_response(
// Fetch quota from storage usage service
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
Some(service) => match service
.get_user_storage_info(uuid::Uuid::parse_str(&user_dto.id).unwrap_or_default())
.get_user_storage_info(uuid::Uuid::parse_str(&user_dto.user.id).unwrap_or_default())
.await
{
Ok((used, total)) => (used, total),
@@ -256,10 +282,10 @@ async fn user_provisioning_response(
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
"data": {
"enabled": user_dto.active,
"id": user_dto.username,
"display-name": user_dto.username,
"displayname": user_dto.username,
"email": user_dto.email,
"id": user_dto.user.username,
"display-name": user_dto.user.username,
"displayname": user_dto.user.username,
"email": user_dto.user.email,
"phone": "",
"address": "",
"website": "",