271 lines
11 KiB
Rust
271 lines
11 KiB
Rust
use crate::common::errors::DomainError;
|
|
use crate::domain::entities::user::{User, UserRole};
|
|
use chrono::{DateTime, Utc};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum UserRepositoryError {
|
|
#[error("User not found: {0}")]
|
|
NotFound(String),
|
|
|
|
#[error("User already exists: {0}")]
|
|
AlreadyExists(String),
|
|
|
|
#[error("Database error: {0}")]
|
|
DatabaseError(String),
|
|
|
|
#[error("Validation error: {0}")]
|
|
ValidationError(String),
|
|
|
|
#[error("Timeout error: {0}")]
|
|
Timeout(String),
|
|
|
|
#[error("Operation not allowed: {0}")]
|
|
OperationNotAllowed(String),
|
|
}
|
|
|
|
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.
|
|
///
|
|
/// 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).
|
|
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,
|
|
/// 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
|
|
impl From<UserRepositoryError> for DomainError {
|
|
fn from(err: UserRepositoryError) -> Self {
|
|
match err {
|
|
UserRepositoryError::NotFound(msg) => DomainError::not_found("User", msg),
|
|
UserRepositoryError::AlreadyExists(msg) => DomainError::already_exists("User", msg),
|
|
UserRepositoryError::DatabaseError(msg) => DomainError::internal_error("Database", msg),
|
|
UserRepositoryError::ValidationError(msg) => DomainError::validation_error(msg),
|
|
UserRepositoryError::Timeout(msg) => DomainError::timeout("Database", msg),
|
|
UserRepositoryError::OperationNotAllowed(msg) => {
|
|
DomainError::access_denied("User", msg)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait UserRepository: Send + Sync + 'static {
|
|
/// Creates a new user
|
|
async fn create_user(&self, user: User) -> UserRepositoryResult<User>;
|
|
|
|
/// Gets a user by ID
|
|
async fn get_user_by_id(&self, id: Uuid) -> UserRepositoryResult<User>;
|
|
|
|
/// 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
|
|
/// vec when given an empty input. Used by group-recipient expansion
|
|
/// in `RecipientNotificationService` to avoid N+1 queries.
|
|
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> UserRepositoryResult<Vec<User>>;
|
|
|
|
/// Gets a user by username
|
|
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User>;
|
|
|
|
/// Gets a user by email
|
|
async fn get_user_by_email(&self, email: &str) -> UserRepositoryResult<User>;
|
|
|
|
/// Returns every user whose email normalizes to `normalized_email`.
|
|
///
|
|
/// Normalization matches `common::text::normalize_email_for_link` —
|
|
/// lowercase + strip `+alias` sub-addressing — so
|
|
/// `Alice+work@Example.com` and `alice@example.com` collapse to the
|
|
/// same key. Used by the OIDC auto-link decision tree to detect
|
|
/// ambiguity: two local rows normalizing to the IdP-returned email
|
|
/// means we can't safely pick one to auto-link, and the callback
|
|
/// must refuse (`email_ambiguous`).
|
|
///
|
|
/// Caller passes the already-normalized value; the SQL applies the
|
|
/// same normalization to the stored side symmetrically so casing
|
|
/// and `+alias` differences on either side collapse.
|
|
async fn list_users_by_normalized_email(
|
|
&self,
|
|
normalized_email: &str,
|
|
) -> UserRepositoryResult<Vec<User>>;
|
|
|
|
/// Updates an existing user
|
|
async fn update_user(&self, user: User) -> UserRepositoryResult<User>;
|
|
|
|
/// Updates only a user's storage usage
|
|
async fn update_storage_usage(
|
|
&self,
|
|
user_id: Uuid,
|
|
usage_bytes: i64,
|
|
) -> UserRepositoryResult<()>;
|
|
|
|
/// Updates the last login date
|
|
async fn update_last_login(&self, user_id: Uuid) -> UserRepositoryResult<()>;
|
|
|
|
/// Lists users with pagination.
|
|
///
|
|
/// `include_external` controls whether external (grant-only) users
|
|
/// appear in the result. Default callers should pass `false` so
|
|
/// external users stay invisible to internal-user surfaces (system
|
|
/// address book autocomplete, sharee search, etc.). Only the admin
|
|
/// management UI should request `true`.
|
|
async fn list_users(
|
|
&self,
|
|
limit: i64,
|
|
offset: i64,
|
|
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(
|
|
&self,
|
|
limit: i64,
|
|
offset: i64,
|
|
include_external: bool,
|
|
) -> UserRepositoryResult<Vec<UserListEntry>>;
|
|
|
|
/// Searches users by username or email (SQL ILIKE) with a limit.
|
|
/// See [`list_users`] for the meaning of `include_external`.
|
|
async fn search_users(
|
|
&self,
|
|
query: &str,
|
|
limit: i64,
|
|
include_external: bool,
|
|
) -> UserRepositoryResult<Vec<User>>;
|
|
|
|
/// Activates or deactivates a user
|
|
async fn set_user_active_status(&self, user_id: Uuid, active: bool)
|
|
-> UserRepositoryResult<()>;
|
|
|
|
/// Changes a user's password
|
|
async fn change_password(&self, user_id: Uuid, password_hash: &str)
|
|
-> UserRepositoryResult<()>;
|
|
|
|
/// Changes a user's role
|
|
async fn change_role(&self, user_id: Uuid, role: UserRole) -> UserRepositoryResult<()>;
|
|
|
|
/// Lists users by role (admin or user)
|
|
async fn list_users_by_role(&self, role: &str) -> UserRepositoryResult<Vec<User>>;
|
|
|
|
/// Counts users with a given role via a scalar `COUNT(*)` — no row
|
|
/// hydration (benches/ROUND29.md §G).
|
|
async fn count_users_by_role(&self, role: &str) -> UserRepositoryResult<i64>;
|
|
|
|
/// Deletes a user
|
|
async fn delete_user(&self, user_id: Uuid) -> UserRepositoryResult<()>;
|
|
|
|
/// Finds a user by federation (issuer, subject) pair.
|
|
async fn get_user_by_federation_subject(
|
|
&self,
|
|
issuer: &str,
|
|
subject: &str,
|
|
) -> UserRepositoryResult<User>;
|
|
|
|
/// Updates a user's storage quota
|
|
async fn update_storage_quota(
|
|
&self,
|
|
user_id: Uuid,
|
|
quota_bytes: i64,
|
|
) -> UserRepositoryResult<()>;
|
|
|
|
/// Counts the total number of users
|
|
async fn count_users(&self) -> UserRepositoryResult<i64>;
|
|
|
|
/// Gets aggregated storage statistics
|
|
async fn get_storage_stats(&self) -> UserRepositoryResult<StorageStats>;
|
|
}
|
|
|
|
/// Aggregated storage statistics
|
|
#[derive(Debug, Clone)]
|
|
pub struct StorageStats {
|
|
pub total_users: i64,
|
|
pub active_users: i64,
|
|
pub total_quota_bytes: i64,
|
|
pub total_used_bytes: i64,
|
|
pub users_over_80_percent: i64,
|
|
pub users_over_quota: i64,
|
|
}
|