c51af68432
Benchmark-gated (benches/ROUND10.md; every change carries a BEFORE/AFTER harness with equivalence/safety gates — two designs were rejected or rewritten by their own benches before adoption): - Auth hot path: TokenClaims/CurrentUser display fields to Arc<str>, role to inline SmolStr end-to-end (Bearer, cookie, Basic-auth cache) — 4→1 allocs per authenticated request, 3→0 per warm DAV request; JWT Encoding/Decoding/Validation built once. - Cold shared-album herd: leader-inline parent batching in PgAclEngine (+ cascade try_get_with single-flight) — 100→2 parent queries per 100-thumb cold herd, herd wall 1.9x, sequential + warm paths unchanged, all ROUND8/9 safety gates plus new herd-equivalence gates. - Query-shape pack: share download double-fetch 2→1 (2.18x), contact-group COUNT(*) 14.9x, save_faces UNNEST 3.9x, playlist reorder UNNEST 63.7x (now atomic), search files∥folders join! 1.45x, move drive-lookup join! 2.14x, trash partial (drive_id, trashed_at) indexes, CalDAV event-gate narrow read, favorites/recents binary-decode port, dead count_files removed. - NC surface: preview + avatar honour If-None-Match (e2e: 5 KB and 197 KB → 0 bytes per revalidation), avatar WebP→PNG transcode memoised, PROPFIND/trashbin integer+date emits on stack formatters, folder-header enrichment join!, chunk-PUT retry stat folded into create_new open. - common::fmt integer rendering rewritten on the std 2-digit LUT after the round's own bench caught the div-loop losing to to_string (16.1 ns vs 22.5; speeds every prior-round call site). - Micro-pack: WebDAV scope probe borrow-only, ShareService base_url snapshot, cookie_secure OnceLock, Arc'd AES-GCM cipher, stack request-id, tantivy analyzer clone dropped. - SPA: search stale-guard + AbortController (10→1 completed round-trips, stale-clobber gone), getFolder in-flight dedup, gridColumns matchMedia hoist (10k→0 style reads). Backend: cargo fmt + clippy -D warnings clean, 524 tests green. Frontend: npm run check clean, 301 vitest green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DdM7V7M3QPW7HEHg3gLov
324 lines
13 KiB
Rust
324 lines
13 KiB
Rust
use crate::common::errors::DomainError;
|
|
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 std::sync::Arc;
|
|
use uuid::Uuid;
|
|
|
|
// ============================================================================
|
|
// Cryptography Ports - Extracted from Domain to maintain Clean Architecture
|
|
// ============================================================================
|
|
|
|
/// Port for password hashing operations.
|
|
///
|
|
/// This trait abstracts cryptographic password operations, allowing the domain
|
|
/// layer to remain independent of specific hashing implementations (argon2, bcrypt, etc.)
|
|
///
|
|
/// Methods are async because implementations (e.g. Argon2) are CPU-intensive
|
|
/// and must run on a blocking thread pool to avoid starving Tokio workers.
|
|
pub trait PasswordHasherPort: Send + Sync + 'static {
|
|
/// Hash a plain text password
|
|
async fn hash_password(&self, password: &str) -> Result<String, DomainError>;
|
|
|
|
/// Verify a plain text password against a hash
|
|
async fn verify_password(&self, password: &str, hash: &str) -> Result<bool, DomainError>;
|
|
}
|
|
|
|
/// Claims contained in a JWT token
|
|
///
|
|
/// `username` / `email` are `Arc<str>` so the per-request `CurrentUser`
|
|
/// build clones them with a refcount bump instead of copying the strings —
|
|
/// the validation cache already hands the whole struct out behind an `Arc`,
|
|
/// but the two display fields still had to be deep-cloned out of it on
|
|
/// EVERY authenticated request (the "2 allocs/request" item deferred since
|
|
/// ROUND6).
|
|
#[derive(Debug, Clone)]
|
|
pub struct TokenClaims {
|
|
/// Subject identifier (user ID)
|
|
pub sub: String,
|
|
/// Expiration timestamp (seconds since Unix epoch)
|
|
pub exp: i64,
|
|
/// Issued at timestamp (seconds since Unix epoch)
|
|
pub iat: i64,
|
|
/// JWT unique ID
|
|
pub jti: String,
|
|
/// Username
|
|
pub username: Arc<str>,
|
|
/// User email
|
|
pub email: Arc<str>,
|
|
/// User role
|
|
pub role: String,
|
|
}
|
|
|
|
/// Port for JWT token operations.
|
|
///
|
|
/// This trait abstracts token generation and validation, allowing the domain
|
|
/// layer to remain independent of specific JWT implementations.
|
|
pub trait TokenServicePort: Send + Sync + 'static {
|
|
/// Generate an access token for a user
|
|
fn generate_access_token(&self, user: &User) -> Result<String, DomainError>;
|
|
|
|
/// Validate a token and extract its claims.
|
|
///
|
|
/// Returns `Arc<TokenClaims>` so the implementation's validation cache can
|
|
/// hand back a hot entry with a refcount bump instead of deep-cloning the
|
|
/// (multi-`String`) claims on every authenticated request. Callers that
|
|
/// only read fields go through `Deref`; the few that retain a field clone
|
|
/// just that one.
|
|
fn validate_token(&self, token: &str) -> Result<Arc<TokenClaims>, DomainError>;
|
|
|
|
/// Generate a refresh token
|
|
fn generate_refresh_token(&self) -> String;
|
|
|
|
/// Get refresh token expiry in seconds
|
|
fn refresh_token_expiry_secs(&self) -> i64;
|
|
|
|
/// Get refresh token expiry in days
|
|
fn refresh_token_expiry_days(&self) -> i64;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Storage Ports
|
|
// ============================================================================
|
|
|
|
pub trait UserStoragePort: Send + Sync + 'static {
|
|
/// Creates a new user
|
|
async fn create_user(&self, user: User) -> Result<User, DomainError>;
|
|
|
|
/// Gets a user by ID
|
|
async fn get_user_by_id(&self, id: Uuid) -> Result<User, 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
|
|
/// a group of size N.
|
|
async fn get_users_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<User>, DomainError>;
|
|
|
|
/// Gets a user by username
|
|
async fn get_user_by_username(&self, username: &str) -> Result<User, DomainError>;
|
|
|
|
/// Gets a user by email
|
|
async fn get_user_by_email(&self, email: &str) -> Result<User, DomainError>;
|
|
|
|
/// Updates an existing user
|
|
async fn update_user(&self, user: User) -> Result<User, DomainError>;
|
|
|
|
/// Updates only the storage usage of a user
|
|
async fn update_storage_usage(
|
|
&self,
|
|
user_id: Uuid,
|
|
usage_bytes: i64,
|
|
) -> Result<(), DomainError>;
|
|
|
|
/// Lists users with pagination. `include_external` defaults to `false`
|
|
/// at every call site that surfaces users to other internal users
|
|
/// (autocomplete, sharee search, etc.); only the admin management UI
|
|
/// passes `true`. See [`UserRepository::list_users`] for the rationale.
|
|
async fn list_users(
|
|
&self,
|
|
limit: i64,
|
|
offset: i64,
|
|
include_external: bool,
|
|
) -> Result<Vec<User>, 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(
|
|
&self,
|
|
query: &str,
|
|
limit: i64,
|
|
include_external: bool,
|
|
) -> Result<Vec<User>, DomainError>;
|
|
|
|
/// Lists users by role (e.g., "admin" or "user")
|
|
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
|
|
|
|
/// Deletes a user by their ID
|
|
async fn delete_user(&self, user_id: Uuid) -> Result<(), DomainError>;
|
|
|
|
/// Changes a user's password
|
|
async fn change_password(&self, user_id: Uuid, password_hash: &str) -> Result<(), DomainError>;
|
|
|
|
/// Finds a user by OIDC provider + subject pair
|
|
async fn get_user_by_oidc_subject(
|
|
&self,
|
|
provider: &str,
|
|
subject: &str,
|
|
) -> Result<User, DomainError>;
|
|
|
|
/// Activates or deactivates a user
|
|
async fn set_user_active_status(&self, user_id: Uuid, active: bool) -> Result<(), DomainError>;
|
|
|
|
/// Changes a user's role
|
|
async fn change_role(&self, user_id: Uuid, role: &str) -> Result<(), DomainError>;
|
|
|
|
/// Updates a user's storage quota
|
|
async fn update_storage_quota(
|
|
&self,
|
|
user_id: Uuid,
|
|
quota_bytes: i64,
|
|
) -> Result<(), DomainError>;
|
|
|
|
/// Counts the total number of users
|
|
async fn count_users(&self) -> Result<i64, DomainError>;
|
|
}
|
|
|
|
// ============================================================================
|
|
// OIDC Port
|
|
// ============================================================================
|
|
|
|
/// Represents the token set returned by the OIDC provider after code exchange
|
|
#[derive(Debug, Clone)]
|
|
pub struct OidcTokenSet {
|
|
pub access_token: String,
|
|
pub id_token: String,
|
|
pub refresh_token: Option<String>,
|
|
}
|
|
|
|
/// Claims extracted from the validated OIDC ID token
|
|
#[derive(Debug, Clone)]
|
|
pub struct OidcIdClaims {
|
|
pub sub: String,
|
|
pub email: Option<String>,
|
|
pub email_verified: Option<bool>,
|
|
pub preferred_username: Option<String>,
|
|
pub name: Option<String>,
|
|
/// Standard OpenID claim `given_name` (first name). Populated on the
|
|
/// `User` row at JIT provisioning so the share-modal autocomplete and
|
|
/// the system address book can surface real names instead of just the
|
|
/// (often-cryptic) `preferred_username`.
|
|
pub given_name: Option<String>,
|
|
/// Standard OpenID claim `family_name` (last name). See `given_name`.
|
|
pub family_name: Option<String>,
|
|
pub groups: Vec<String>,
|
|
pub picture: Option<String>,
|
|
/// Standard OpenID claim `locale` (BCP-47 language tag, e.g.
|
|
/// `"fr"`, `"zh-TW"`). Populated on the new `User` row at OIDC JIT
|
|
/// provisioning if the claim resolves against the server's
|
|
/// `LocaleRegistry`; ignored on subsequent logins so a later
|
|
/// UI-driven choice isn't overwritten by the IdP.
|
|
pub locale: Option<String>,
|
|
}
|
|
|
|
/// Port for OIDC operations — implemented in infrastructure layer
|
|
pub trait OidcServicePort: Send + Sync + 'static {
|
|
/// Get the authorization URL for redirecting the user to the IdP.
|
|
/// Includes PKCE code_challenge (S256) and nonce for ID token binding.
|
|
/// This is async because it may need to fetch the OIDC discovery document.
|
|
async fn get_authorize_url(
|
|
&self,
|
|
state: &str,
|
|
nonce: &str,
|
|
pkce_challenge: &str,
|
|
) -> Result<String, DomainError>;
|
|
|
|
/// Exchange an authorization code for tokens, providing PKCE code_verifier.
|
|
async fn exchange_code(
|
|
&self,
|
|
code: &str,
|
|
pkce_verifier: &str,
|
|
) -> Result<OidcTokenSet, DomainError>;
|
|
|
|
/// Validate an ID token and extract claims.
|
|
/// If `expected_nonce` is provided, verifies the `nonce` claim matches.
|
|
async fn validate_id_token(
|
|
&self,
|
|
id_token: &str,
|
|
expected_nonce: Option<&str>,
|
|
) -> Result<OidcIdClaims, DomainError>;
|
|
|
|
/// Fetch user info from the UserInfo endpoint (fallback for missing ID token claims)
|
|
async fn fetch_user_info(&self, access_token: &str) -> Result<OidcIdClaims, DomainError>;
|
|
|
|
/// Get the OIDC provider display name
|
|
fn provider_name(&self) -> &str;
|
|
}
|
|
|
|
pub trait SessionStoragePort: Send + Sync + 'static {
|
|
/// Creates a new session
|
|
async fn create_session(&self, session: Session) -> Result<Session, DomainError>;
|
|
|
|
/// Gets a session by refresh token
|
|
async fn get_session_by_refresh_token(
|
|
&self,
|
|
refresh_token: &str,
|
|
) -> Result<Session, DomainError>;
|
|
|
|
/// Revokes a specific session
|
|
async fn revoke_session(&self, session_id: Uuid) -> Result<(), DomainError>;
|
|
|
|
/// Revokes all sessions of a user
|
|
async fn revoke_all_user_sessions(&self, user_id: Uuid) -> Result<u64, DomainError>;
|
|
|
|
/// Revokes all sessions in a token family (used when replay of a revoked token is detected)
|
|
async fn revoke_session_family(&self, family_id: Uuid) -> Result<u64, DomainError>;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Device Authorization Grant Port (RFC 8628)
|
|
// ============================================================================
|
|
|
|
pub trait DeviceCodeStoragePort: Send + Sync + 'static {
|
|
/// Persist a new device code flow
|
|
async fn create_device_code(&self, device_code: DeviceCode) -> Result<DeviceCode, DomainError>;
|
|
|
|
/// Find a device code by its opaque device_code token (used by client polling)
|
|
async fn get_by_device_code(&self, device_code: &str) -> Result<DeviceCode, DomainError>;
|
|
|
|
/// Find a pending device code by the short user_code (used on verification page)
|
|
async fn get_pending_by_user_code(&self, user_code: &str) -> Result<DeviceCode, DomainError>;
|
|
|
|
/// Update a device code (status change, token storage, poll timestamp, etc.)
|
|
async fn update_device_code(&self, device_code: DeviceCode) -> Result<(), DomainError>;
|
|
|
|
/// Delete expired device codes (cleanup job)
|
|
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
|
|
|
/// List authorized device codes for a user (for UI management)
|
|
async fn list_by_user(&self, user_id: Uuid) -> Result<Vec<DeviceCode>, DomainError>;
|
|
|
|
/// Delete a specific device code by ID (revocation)
|
|
async fn delete_by_id(&self, id: Uuid) -> Result<(), DomainError>;
|
|
}
|
|
|
|
// ============================================================================
|
|
// App Password Storage Port
|
|
// ============================================================================
|
|
|
|
/// Storage port for application-specific passwords (HTTP Basic Auth for DAV clients).
|
|
pub trait AppPasswordStoragePort: Send + Sync + 'static {
|
|
/// Persist a new app password (hash already computed).
|
|
async fn create(&self, app_password: AppPassword) -> Result<AppPassword, DomainError>;
|
|
|
|
/// Get all active (non-expired) app passwords for a user.
|
|
async fn list_by_user(&self, user_id: Uuid) -> Result<Vec<AppPassword>, DomainError>;
|
|
|
|
/// Get a specific app password by ID.
|
|
async fn get_by_id(&self, id: Uuid) -> Result<AppPassword, DomainError>;
|
|
|
|
/// Get all active app passwords for a user ID (for Basic auth verification).
|
|
/// This includes the password hash for verification.
|
|
async fn get_active_by_user_id(&self, user_id: Uuid) -> Result<Vec<AppPassword>, DomainError>;
|
|
|
|
/// Update the `last_used_at` timestamp after a successful authentication.
|
|
async fn touch_last_used(&self, id: Uuid) -> Result<(), DomainError>;
|
|
|
|
/// Get active app passwords for a user filtered by token prefix (first 8 chars).
|
|
/// More efficient than `get_active_by_user_id` when the password prefix is known.
|
|
async fn get_active_by_user_prefix(
|
|
&self,
|
|
user_id: Uuid,
|
|
prefix: &str,
|
|
) -> Result<Vec<AppPassword>, DomainError>;
|
|
|
|
/// Deactivate (soft-delete) an app password, scoped to the owning user.
|
|
async fn revoke(&self, id: Uuid, user_id: Uuid) -> Result<(), DomainError>;
|
|
|
|
/// Delete an app password owned by a specific user. Returns true if found and deleted.
|
|
async fn delete_by_user_and_id(&self, id: Uuid, user_id: Uuid) -> Result<bool, DomainError>;
|
|
|
|
/// Hard-delete expired/revoked app passwords (cleanup).
|
|
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
|
}
|