refactor(User): clear separation PublicUserDto, FullUserDto, SelfUserDto
This commit is contained in:
@@ -288,6 +288,262 @@ impl From<User> for UserDto {
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// 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.
|
||||
//
|
||||
// The fat `UserDto` above is being phased out — the three types will replace
|
||||
// it and its emitter sites migrate one at a time. Kept temporarily so this
|
||||
// PR compiles at every checkpoint; deleted at the end of the refactor.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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 PublicUserDto {
|
||||
pub id: String,
|
||||
#[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,
|
||||
/// 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>,
|
||||
/// `true` for grant-only external recipients (magic-link, OIDC-only,
|
||||
/// future OCM federated). Renders the "external" badge on the vignette.
|
||||
pub is_external: bool,
|
||||
/// Optional first/given name. Social identity.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub given_name: Option<String>,
|
||||
/// Optional last/family name. Social identity.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub family_name: Option<String>,
|
||||
/// 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 is_online: bool,
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
///
|
||||
/// 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 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>,
|
||||
/// 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>,
|
||||
/// 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,
|
||||
/// TRUE when the user has an OPAQUE envelope on file.
|
||||
pub opaque_registered: bool,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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 PublicUserDto {
|
||||
fn from(user: User) -> Self {
|
||||
let role = format!("{}", user.role());
|
||||
let p = user.into_parts();
|
||||
Self {
|
||||
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,
|
||||
// Single-user paths that don't enrich presence ship `false`.
|
||||
// List projections (admin users, sharees enriched with
|
||||
// presence) build via FullUserDto::build below, which
|
||||
// overrides this from UserDerivedFlags.
|
||||
is_online: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -69,6 +69,51 @@ pub struct UserListEntry {
|
||||
/// 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,
|
||||
/// Optional avatar payload (base64, up to 512 KiB per row). Included
|
||||
/// on the admin list projection so the SPA can seed its per-user
|
||||
/// `resolveUser` cache from the list row and skip the follow-up
|
||||
/// `/api/users/{id}` fetch UserVignette would otherwise trigger.
|
||||
/// The narrow-projection concern that motivated omitting this
|
||||
/// column originally is retired by that cache-seeding path — the
|
||||
/// bytes now do useful work per page load instead of being
|
||||
/// discarded. Deferred: moving avatar storage out of the row
|
||||
/// entirely (planned refactor); this shape is transitional.
|
||||
pub image: Option<String>,
|
||||
/// 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). Populated via an `EXISTS(...)` subquery on
|
||||
/// `auth.sessions` in the list projection — the partial index
|
||||
/// `idx_sessions_last_seen_at WHERE revoked = FALSE` covers the
|
||||
/// scan, so per-row cost is ~μs. Surfaces to the FE via
|
||||
/// `UserDto::is_online` so both `/api/users/{id}` and the admin
|
||||
/// listing carry it, and the admin table renders a green/grey
|
||||
/// presence dot next to each vignette.
|
||||
pub is_online: bool,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// 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 phasing that
|
||||
/// introduces this type; it will replace [`UserListEntry`] once the
|
||||
/// list repo is switched from narrow projection to
|
||||
/// `Vec<(User, UserDerivedFlags)>` (P6 of the refactor).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct UserDerivedFlags {
|
||||
pub has_password: bool,
|
||||
pub opaque_registered: bool,
|
||||
pub opaque_migrated: bool,
|
||||
pub is_online: bool,
|
||||
}
|
||||
|
||||
// Conversion from UserRepositoryError to DomainError
|
||||
|
||||
@@ -858,6 +858,8 @@ impl UserRepository for UserPgRepository {
|
||||
bool,
|
||||
bool,
|
||||
bool,
|
||||
Option<String>,
|
||||
bool,
|
||||
),
|
||||
>(
|
||||
// Auth-credential columns projected as booleans via `IS NOT
|
||||
@@ -871,6 +873,21 @@ impl UserRepository for UserPgRepository {
|
||||
// federation_kind / federation_issuer, the SPA derives the
|
||||
// full "capability set" per user (password / OPAQUE / SSO /
|
||||
// passwordless).
|
||||
//
|
||||
// `image` is projected too — the previous narrow projection
|
||||
// (ROUND12 §Q1 / ROUND13 §Q1) discarded up to 512 KiB per
|
||||
// row because the admin table never rendered it. That's now
|
||||
// reversed: the SPA seeds its per-user `resolveUser` cache
|
||||
// from these rows to kill the N+1 `/api/users/{id}` fetches
|
||||
// UserVignette would otherwise trigger.
|
||||
//
|
||||
// `is_online` uses an EXISTS scalar subquery against
|
||||
// `auth.sessions` — the partial index
|
||||
// `idx_sessions_last_seen_at WHERE revoked = FALSE` covers
|
||||
// the lookup, so per-row cost is ~μs. The window comes from
|
||||
// `application::dtos::session_dto::ONLINE_WINDOW` (bound as
|
||||
// `$4` seconds), same single-source-of-truth pattern the
|
||||
// `session_liveness_gauges` module uses.
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, role::text,
|
||||
@@ -879,7 +896,14 @@ impl UserRepository for UserPgRepository {
|
||||
federation_kind, federation_issuer, is_external,
|
||||
(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
|
||||
(opaque_migrated_at IS NOT NULL) AS opaque_migrated,
|
||||
image,
|
||||
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
|
||||
@@ -889,6 +913,7 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(include_external)
|
||||
.bind(crate::application::dtos::session_dto::ONLINE_WINDOW.as_secs_f64())
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -911,6 +936,8 @@ impl UserRepository for UserPgRepository {
|
||||
has_password,
|
||||
opaque_registered,
|
||||
opaque_migrated,
|
||||
image,
|
||||
is_online,
|
||||
)| UserListEntry {
|
||||
id,
|
||||
username,
|
||||
@@ -930,6 +957,8 @@ impl UserRepository for UserPgRepository {
|
||||
has_password,
|
||||
opaque_registered,
|
||||
opaque_migrated,
|
||||
image,
|
||||
is_online,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
|
||||
Reference in New Issue
Block a user