2025-03-20 09:22:31 +01:00
|
|
|
use chrono::{DateTime, Utc};
|
2026-02-14 01:29:34 +01:00
|
|
|
use uuid::Uuid;
|
2025-03-20 09:22:31 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Re-export entity errors from the centralized module
|
2026-02-06 20:57:00 +01:00
|
|
|
pub use super::entity_errors::{UserError, UserResult};
|
2025-03-20 09:22:31 +01:00
|
|
|
|
2026-02-02 23:56:40 +01:00
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
2025-03-24 16:47:42 +01:00
|
|
|
// We'll handle conversion manually for now until the type is properly set up in the database
|
2025-03-20 09:22:31 +01:00
|
|
|
pub enum UserRole {
|
|
|
|
|
Admin,
|
|
|
|
|
User,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Display for UserRole {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
UserRole::Admin => write!(f, "admin"),
|
|
|
|
|
UserRole::User => write!(f, "user"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 23:56:40 +01:00
|
|
|
#[derive(Debug, Clone)]
|
2025-03-20 09:22:31 +01:00
|
|
|
pub struct User {
|
2026-03-07 14:59:32 +01:00
|
|
|
id: Uuid,
|
2026-06-02 21:21:24 +02:00
|
|
|
/// Optional handle (2-64 chars, no `@`). NULL for users created via
|
|
|
|
|
/// email-invitation (`is_external = true`) and for users who have
|
|
|
|
|
/// not yet claimed a handle (PR-18 email-only signups). When set, it
|
|
|
|
|
/// must satisfy `validate_username` and must NOT contain `@` —
|
|
|
|
|
/// keeping the username and email namespaces provably disjoint.
|
|
|
|
|
username: Option<String>,
|
2025-03-20 09:22:31 +01:00
|
|
|
email: String,
|
2026-06-02 21:21:24 +02:00
|
|
|
/// Optional Argon2 password hash. NULL when the user has no password
|
|
|
|
|
/// (externals, OIDC-only users, email-only signups awaiting their
|
|
|
|
|
/// welcome magic-link). After PR 16 this column carries no sentinel
|
|
|
|
|
/// strings — `is_some()` means "real argon2 hash"; `None` means "no
|
|
|
|
|
/// password configured".
|
|
|
|
|
password_hash: Option<String>,
|
2025-03-20 09:22:31 +01:00
|
|
|
role: UserRole,
|
|
|
|
|
storage_quota_bytes: i64,
|
|
|
|
|
storage_used_bytes: i64,
|
|
|
|
|
created_at: DateTime<Utc>,
|
|
|
|
|
updated_at: DateTime<Utc>,
|
|
|
|
|
last_login_at: Option<DateTime<Utc>>,
|
|
|
|
|
active: bool,
|
2026-02-10 20:32:32 +01:00
|
|
|
oidc_provider: Option<String>,
|
|
|
|
|
oidc_subject: Option<String>,
|
2026-05-26 00:50:38 +02:00
|
|
|
image: Option<String>,
|
2026-06-01 15:51:05 +02:00
|
|
|
/// TRUE = grant-only external recipient (magic-link, OIDC-only, OCM
|
|
|
|
|
/// federated). FALSE = storage-owning internal user. Hooks that
|
|
|
|
|
/// provision per-user resources (home folder, default calendar, …)
|
|
|
|
|
/// must short-circuit when `is_external` is TRUE — see tip #2 in
|
|
|
|
|
/// `application/ports/user_lifecycle.rs`. The DB CHECK constraint
|
|
|
|
|
/// `users_external_no_storage` is the schema-level safety net.
|
|
|
|
|
is_external: bool,
|
2026-06-01 20:37:36 +02:00
|
|
|
/// Optional human-readable first/given name. Populated from OIDC
|
|
|
|
|
/// standard claim `given_name` at JIT provisioning, or via the
|
|
|
|
|
/// profile-edit endpoint. External users start with `None`.
|
|
|
|
|
given_name: Option<String>,
|
|
|
|
|
/// Optional human-readable last/family name. Populated from OIDC
|
|
|
|
|
/// standard claim `family_name` at JIT provisioning, or via the
|
|
|
|
|
/// profile-edit endpoint. External users start with `None`.
|
|
|
|
|
family_name: Option<String>,
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl User {
|
2026-06-02 21:21:24 +02:00
|
|
|
/// Create a new user.
|
2026-02-14 01:29:34 +01:00
|
|
|
///
|
2026-06-02 21:21:24 +02:00
|
|
|
/// One unified constructor for every kind of user (internal, OIDC-linked,
|
|
|
|
|
/// external). The credential slots and the `is_external` marker are all
|
|
|
|
|
/// caller-controlled — what makes a user "OIDC" is `oidc_subject =
|
|
|
|
|
/// Some(_)`, what makes them "external" is `is_external = true`. There
|
|
|
|
|
/// are no hidden sentinel values; an absent credential is `None`.
|
2026-02-14 01:29:34 +01:00
|
|
|
///
|
2026-02-02 23:56:40 +01:00
|
|
|
/// # Arguments
|
2026-06-02 21:21:24 +02:00
|
|
|
/// * `email` — required, must satisfy `validate_email`
|
|
|
|
|
/// * `username` — optional handle (2-64 chars, no `@`)
|
|
|
|
|
/// * `password_hash` — pre-hashed via PasswordHasherPort, or `None` if
|
|
|
|
|
/// the user has no password yet (magic-link or OIDC bootstrap)
|
|
|
|
|
/// * `oidc_provider`, `oidc_subject` — both `Some` when the user is
|
|
|
|
|
/// linked to an external IdP, both `None` otherwise
|
|
|
|
|
/// * `role` — `Admin` is rejected when `is_external = true` (mirrors the
|
|
|
|
|
/// `users_external_not_admin` DB CHECK constraint)
|
|
|
|
|
/// * `storage_quota_bytes` — caller-set; external callers should pass 0
|
|
|
|
|
/// to satisfy the `users_external_no_storage` invariant
|
|
|
|
|
/// * `is_external` — TRUE for grant-only recipients (magic-link, OCM)
|
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn new(
|
2026-02-14 01:29:34 +01:00
|
|
|
email: String,
|
2026-06-02 21:21:24 +02:00
|
|
|
username: Option<String>,
|
|
|
|
|
password_hash: Option<String>,
|
|
|
|
|
oidc_provider: Option<String>,
|
|
|
|
|
oidc_subject: Option<String>,
|
2025-03-20 09:22:31 +01:00
|
|
|
role: UserRole,
|
|
|
|
|
storage_quota_bytes: i64,
|
2026-06-02 21:21:24 +02:00
|
|
|
is_external: bool,
|
2025-03-20 09:22:31 +01:00
|
|
|
) -> UserResult<Self> {
|
2026-03-05 14:52:11 +01:00
|
|
|
Self::validate_email(&email)?;
|
2026-06-02 21:21:24 +02:00
|
|
|
if let Some(ref u) = username {
|
|
|
|
|
Self::validate_username(u)?;
|
|
|
|
|
}
|
|
|
|
|
if let Some(ref h) = password_hash
|
|
|
|
|
&& h.is_empty()
|
|
|
|
|
{
|
2026-02-14 01:29:34 +01:00
|
|
|
return Err(UserError::InvalidPassword(
|
|
|
|
|
"Password hash cannot be empty".to_string(),
|
|
|
|
|
));
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
2026-06-02 21:21:24 +02:00
|
|
|
// Schema-level CHECKs are mirrored at the entity layer so callers
|
|
|
|
|
// get a typed error instead of an opaque DB rejection.
|
|
|
|
|
if is_external && matches!(role, UserRole::Admin) {
|
|
|
|
|
return Err(UserError::ValidationError(
|
|
|
|
|
"External users cannot hold the admin role".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if is_external && storage_quota_bytes != 0 {
|
|
|
|
|
return Err(UserError::ValidationError(
|
|
|
|
|
"External users must have storage_quota_bytes = 0".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
// OIDC linkage is all-or-nothing: both provider and subject set,
|
|
|
|
|
// or neither. The DB has a UNIQUE index on (provider, subject)
|
|
|
|
|
// WHERE both non-NULL; partial state would corrupt that.
|
|
|
|
|
if oidc_provider.is_some() != oidc_subject.is_some() {
|
|
|
|
|
return Err(UserError::ValidationError(
|
|
|
|
|
"oidc_provider and oidc_subject must both be set or both be None".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
let now = Utc::now();
|
|
|
|
|
Ok(Self {
|
2026-03-07 14:59:32 +01:00
|
|
|
id: Uuid::new_v4(),
|
2025-03-20 09:22:31 +01:00
|
|
|
username,
|
|
|
|
|
email,
|
|
|
|
|
password_hash,
|
|
|
|
|
role,
|
|
|
|
|
storage_quota_bytes,
|
|
|
|
|
storage_used_bytes: 0,
|
|
|
|
|
created_at: now,
|
|
|
|
|
updated_at: now,
|
|
|
|
|
last_login_at: None,
|
|
|
|
|
active: true,
|
2026-06-02 21:21:24 +02:00
|
|
|
oidc_provider,
|
|
|
|
|
oidc_subject,
|
2026-06-01 15:51:05 +02:00
|
|
|
image: None,
|
2026-06-02 21:21:24 +02:00
|
|
|
is_external,
|
2026-06-01 20:37:36 +02:00
|
|
|
given_name: None,
|
|
|
|
|
family_name: None,
|
2025-03-20 09:22:31 +01:00
|
|
|
})
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn from_data(
|
2026-03-07 14:59:32 +01:00
|
|
|
id: Uuid,
|
2026-06-02 21:21:24 +02:00
|
|
|
username: Option<String>,
|
2025-03-20 09:22:31 +01:00
|
|
|
email: String,
|
2026-06-02 21:21:24 +02:00
|
|
|
password_hash: Option<String>,
|
2025-03-20 09:22:31 +01:00
|
|
|
role: UserRole,
|
|
|
|
|
storage_quota_bytes: i64,
|
|
|
|
|
storage_used_bytes: i64,
|
|
|
|
|
created_at: DateTime<Utc>,
|
|
|
|
|
updated_at: DateTime<Utc>,
|
|
|
|
|
last_login_at: Option<DateTime<Utc>>,
|
|
|
|
|
active: bool,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
id,
|
|
|
|
|
username,
|
|
|
|
|
email,
|
|
|
|
|
password_hash,
|
|
|
|
|
role,
|
|
|
|
|
storage_quota_bytes,
|
|
|
|
|
storage_used_bytes,
|
|
|
|
|
created_at,
|
|
|
|
|
updated_at,
|
|
|
|
|
last_login_at,
|
|
|
|
|
active,
|
2026-02-10 20:32:32 +01:00
|
|
|
oidc_provider: None,
|
|
|
|
|
oidc_subject: None,
|
2026-05-26 00:50:38 +02:00
|
|
|
image: None,
|
2026-06-01 15:51:05 +02:00
|
|
|
// `from_data` is the minimal-args reconstruction path used by
|
|
|
|
|
// tests and JWT-claim-based principal hydration (which doesn't
|
|
|
|
|
// carry `is_external`). Default to FALSE — JWT-validated
|
|
|
|
|
// principals are existing internal users; magic-link external
|
|
|
|
|
// sessions take a different path that hydrates from DB via
|
|
|
|
|
// `from_data_full`.
|
|
|
|
|
is_external: false,
|
2026-06-01 20:37:36 +02:00
|
|
|
given_name: None,
|
|
|
|
|
family_name: None,
|
2026-02-10 20:32:32 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
2026-02-10 20:32:32 +01:00
|
|
|
pub fn from_data_full(
|
2026-03-07 14:59:32 +01:00
|
|
|
id: Uuid,
|
2026-06-02 21:21:24 +02:00
|
|
|
username: Option<String>,
|
2026-02-10 20:32:32 +01:00
|
|
|
email: String,
|
2026-06-02 21:21:24 +02:00
|
|
|
password_hash: Option<String>,
|
2026-02-10 20:32:32 +01:00
|
|
|
role: UserRole,
|
|
|
|
|
storage_quota_bytes: i64,
|
|
|
|
|
storage_used_bytes: i64,
|
|
|
|
|
created_at: DateTime<Utc>,
|
|
|
|
|
updated_at: DateTime<Utc>,
|
|
|
|
|
last_login_at: Option<DateTime<Utc>>,
|
|
|
|
|
active: bool,
|
|
|
|
|
oidc_provider: Option<String>,
|
|
|
|
|
oidc_subject: Option<String>,
|
2026-05-26 00:50:38 +02:00
|
|
|
image: Option<String>,
|
2026-06-01 15:51:05 +02:00
|
|
|
is_external: bool,
|
2026-06-01 20:37:36 +02:00
|
|
|
given_name: Option<String>,
|
|
|
|
|
family_name: Option<String>,
|
2026-02-10 20:32:32 +01:00
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
id,
|
|
|
|
|
username,
|
|
|
|
|
email,
|
|
|
|
|
password_hash,
|
|
|
|
|
role,
|
|
|
|
|
storage_quota_bytes,
|
|
|
|
|
storage_used_bytes,
|
|
|
|
|
created_at,
|
|
|
|
|
updated_at,
|
|
|
|
|
last_login_at,
|
|
|
|
|
active,
|
|
|
|
|
oidc_provider,
|
|
|
|
|
oidc_subject,
|
2026-05-26 00:50:38 +02:00
|
|
|
image,
|
2026-06-01 15:51:05 +02:00
|
|
|
is_external,
|
2026-06-01 20:37:36 +02:00
|
|
|
given_name,
|
|
|
|
|
family_name,
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
// Getters
|
2026-03-07 14:59:32 +01:00
|
|
|
pub fn id(&self) -> Uuid {
|
|
|
|
|
self.id
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-02 21:21:24 +02:00
|
|
|
/// The user's chosen handle. `None` for users who have not claimed
|
|
|
|
|
/// one (externals, fresh email-only signups). Display callers should
|
|
|
|
|
/// fall back through `given_name`/`family_name` to `email` when this
|
|
|
|
|
/// is `None`.
|
|
|
|
|
pub fn username(&self) -> Option<&str> {
|
|
|
|
|
self.username.as_deref()
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn email(&self) -> &str {
|
|
|
|
|
&self.email
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn role(&self) -> UserRole {
|
|
|
|
|
self.role
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn storage_quota_bytes(&self) -> i64 {
|
|
|
|
|
self.storage_quota_bytes
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn storage_used_bytes(&self) -> i64 {
|
|
|
|
|
self.storage_used_bytes
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn created_at(&self) -> DateTime<Utc> {
|
|
|
|
|
self.created_at
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn updated_at(&self) -> DateTime<Utc> {
|
|
|
|
|
self.updated_at
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn last_login_at(&self) -> Option<DateTime<Utc>> {
|
|
|
|
|
self.last_login_at
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn is_active(&self) -> bool {
|
|
|
|
|
self.active
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-02 21:21:24 +02:00
|
|
|
/// The Argon2 password hash, or `None` when the user has no password
|
|
|
|
|
/// configured (externals, OIDC-only users, post-PR-18 email-only
|
|
|
|
|
/// signups). `verify_password` callers must short-circuit to
|
|
|
|
|
/// "invalid credentials" when this is `None`.
|
|
|
|
|
pub fn password_hash(&self) -> Option<&str> {
|
|
|
|
|
self.password_hash.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convenience: does the user have a real password configured?
|
|
|
|
|
pub fn has_password(&self) -> bool {
|
|
|
|
|
self.password_hash.is_some()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Best-effort label for audit-log interpolation. Returns the
|
|
|
|
|
/// username when set; falls back to the user_id otherwise. Always
|
|
|
|
|
/// implements `Display` (returns `String`) so audit lines can stay
|
|
|
|
|
/// `username = %user.display_for_audit()` regardless of whether the
|
|
|
|
|
/// user has claimed a handle. Reserve this for `target: "audit"`
|
|
|
|
|
/// lines — user-facing display callers should walk the
|
|
|
|
|
/// `username → given/family → email` fallback chain themselves.
|
|
|
|
|
pub fn display_for_audit(&self) -> String {
|
|
|
|
|
match &self.username {
|
|
|
|
|
Some(u) => u.clone(),
|
|
|
|
|
None => self.id.to_string(),
|
|
|
|
|
}
|
2025-03-20 09:22:31 +01:00
|
|
|
}
|
2026-02-10 20:32:32 +01:00
|
|
|
|
|
|
|
|
pub fn oidc_provider(&self) -> Option<&str> {
|
|
|
|
|
self.oidc_provider.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn oidc_subject(&self) -> Option<&str> {
|
|
|
|
|
self.oidc_subject.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 00:50:38 +02:00
|
|
|
pub fn image(&self) -> Option<&str> {
|
|
|
|
|
self.image.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 15:51:05 +02:00
|
|
|
/// `TRUE` for grant-only external recipients (magic-link, OIDC-only,
|
|
|
|
|
/// OCM federated). Hooks provisioning per-user resources must
|
|
|
|
|
/// short-circuit when this returns `true` — see tip #2 in
|
|
|
|
|
/// `application/ports/user_lifecycle.rs`.
|
|
|
|
|
pub fn is_external(&self) -> bool {
|
|
|
|
|
self.is_external
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 20:37:36 +02:00
|
|
|
pub fn given_name(&self) -> Option<&str> {
|
|
|
|
|
self.given_name.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn family_name(&self) -> Option<&str> {
|
|
|
|
|
self.family_name.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 00:50:38 +02:00
|
|
|
pub fn set_image(&mut self, image: Option<String>) {
|
|
|
|
|
self.image = image;
|
|
|
|
|
self.updated_at = Utc::now();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 20:37:36 +02:00
|
|
|
pub fn set_given_name(&mut self, given_name: Option<String>) {
|
|
|
|
|
self.given_name = given_name;
|
|
|
|
|
self.updated_at = Utc::now();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn set_family_name(&mut self, family_name: Option<String>) {
|
|
|
|
|
self.family_name = family_name;
|
|
|
|
|
self.updated_at = Utc::now();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 21:21:24 +02:00
|
|
|
/// Claim or change the username. Runs the same validation as the
|
2026-06-01 20:37:36 +02:00
|
|
|
/// constructor — callers must still ensure uniqueness at the repo
|
|
|
|
|
/// level. Bumps `updated_at`. Used by the post-create profile-edit
|
2026-06-02 21:21:24 +02:00
|
|
|
/// endpoint so a user who started with `None` can claim a handle
|
|
|
|
|
/// later, or change to a different one. The home folder name is NOT
|
|
|
|
|
/// renamed: it was display text at creation; the folder is owned
|
|
|
|
|
/// by `user_id`.
|
2026-06-01 20:37:36 +02:00
|
|
|
pub fn set_username(&mut self, new_username: String) -> UserResult<()> {
|
|
|
|
|
Self::validate_username(&new_username)?;
|
2026-06-02 21:21:24 +02:00
|
|
|
self.username = Some(new_username);
|
2026-06-01 20:37:36 +02:00
|
|
|
self.updated_at = Utc::now();
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 21:21:24 +02:00
|
|
|
/// Unset the username (return to `None`). Use sparingly — most
|
|
|
|
|
/// users keep their handle once claimed. Mainly here so admin
|
|
|
|
|
/// tooling can clear a problematic handle without deleting the
|
|
|
|
|
/// account.
|
|
|
|
|
pub fn clear_username(&mut self) {
|
|
|
|
|
self.username = None;
|
|
|
|
|
self.updated_at = Utc::now();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 20:32:32 +01:00
|
|
|
/// Returns true if this is an OIDC-only user (no password)
|
|
|
|
|
pub fn is_oidc_user(&self) -> bool {
|
|
|
|
|
self.oidc_provider.is_some()
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-06-01 20:37:36 +02:00
|
|
|
/// Returns true iff this user has any non-magic-link authentication
|
2026-06-02 21:21:24 +02:00
|
|
|
/// method available — either a real password hash, or a linked OIDC
|
|
|
|
|
/// subject. Magic-link eligibility for "no other credential" mode is
|
|
|
|
|
/// the negation of this; the `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS`
|
|
|
|
|
/// flag widens the policy at the service layer (`magic_link_eligibility`).
|
2026-06-01 20:37:36 +02:00
|
|
|
pub fn has_login_credential(&self) -> bool {
|
2026-06-02 21:21:24 +02:00
|
|
|
self.password_hash.is_some() || self.oidc_subject.is_some()
|
2026-06-01 20:37:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 21:21:24 +02:00
|
|
|
/// Set the password hash. The new password must be hashed externally
|
|
|
|
|
/// via `PasswordHasherPort` before calling this. Passing `None`
|
|
|
|
|
/// clears the password (e.g. when a user opts back into magic-link-only
|
|
|
|
|
/// auth).
|
|
|
|
|
pub fn update_password_hash(&mut self, new_hash: Option<String>) {
|
2026-02-02 23:56:40 +01:00
|
|
|
self.password_hash = new_hash;
|
2025-03-20 09:22:31 +01:00
|
|
|
self.updated_at = Utc::now();
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Update storage usage
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn update_storage_used(&mut self, storage_used_bytes: i64) {
|
|
|
|
|
self.storage_used_bytes = storage_used_bytes;
|
|
|
|
|
self.updated_at = Utc::now();
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Register login
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn register_login(&mut self) {
|
|
|
|
|
let now = Utc::now();
|
|
|
|
|
self.last_login_at = Some(now);
|
|
|
|
|
self.updated_at = now;
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Deactivate user
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn deactivate(&mut self) {
|
|
|
|
|
self.active = false;
|
|
|
|
|
self.updated_at = Utc::now();
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
// Activate user
|
2025-03-20 09:22:31 +01:00
|
|
|
pub fn activate(&mut self) {
|
|
|
|
|
self.active = true;
|
|
|
|
|
self.updated_at = Utc::now();
|
|
|
|
|
}
|
2026-03-05 14:52:11 +01:00
|
|
|
|
|
|
|
|
// ── Shared validation helpers ──────────────────────────────────────
|
|
|
|
|
|
2026-06-02 21:21:24 +02:00
|
|
|
/// Usernames are 2-64 chars of `[A-Za-z0-9._-]`. The `@` character is
|
|
|
|
|
/// explicitly forbidden — keeping the username and email namespaces
|
|
|
|
|
/// provably disjoint is what closes the cross-collision attack class
|
|
|
|
|
/// described in the auth-simplification plan (a user can never claim
|
|
|
|
|
/// a handle that shadows another user's email). No leading/trailing
|
|
|
|
|
/// dot or hyphen. The character set also prevents XSS payloads from
|
|
|
|
|
/// being stored as usernames.
|
2026-03-05 14:52:11 +01:00
|
|
|
fn validate_username(username: &str) -> UserResult<()> {
|
2026-06-02 21:21:24 +02:00
|
|
|
let len = username.chars().count();
|
|
|
|
|
if !(2..=64).contains(&len) {
|
2026-03-05 14:52:11 +01:00
|
|
|
return Err(UserError::InvalidUsername(
|
2026-06-02 21:21:24 +02:00
|
|
|
"Username must be between 2 and 64 characters".to_string(),
|
2026-03-05 14:52:11 +01:00
|
|
|
));
|
|
|
|
|
}
|
2026-06-01 20:37:36 +02:00
|
|
|
if username.contains('@') {
|
2026-06-02 21:21:24 +02:00
|
|
|
return Err(UserError::InvalidUsername(
|
|
|
|
|
"Username must not contain '@' — use the email field for email addresses"
|
|
|
|
|
.to_string(),
|
|
|
|
|
));
|
2026-06-01 20:37:36 +02:00
|
|
|
}
|
2026-03-05 14:52:11 +01:00
|
|
|
if !username
|
|
|
|
|
.chars()
|
|
|
|
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
|
|
|
|
{
|
|
|
|
|
return Err(UserError::InvalidUsername(
|
|
|
|
|
"Username may only contain letters, digits, hyphens, underscores, and dots"
|
|
|
|
|
.to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-03-05 21:28:51 +01:00
|
|
|
if username.starts_with('.')
|
|
|
|
|
|| username.starts_with('-')
|
|
|
|
|
|| username.ends_with('.')
|
|
|
|
|
|| username.ends_with('-')
|
2026-03-05 14:52:11 +01:00
|
|
|
{
|
|
|
|
|
return Err(UserError::InvalidUsername(
|
|
|
|
|
"Username must not start or end with a dot or hyphen".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Basic but meaningful email validation:
|
|
|
|
|
/// - Must contain exactly one `@`
|
|
|
|
|
/// - Local part and domain must be non-empty
|
|
|
|
|
/// - Domain must contain at least one dot
|
|
|
|
|
/// - No angle brackets, spaces, or other characters used in XSS payloads
|
|
|
|
|
fn validate_email(email: &str) -> UserResult<()> {
|
|
|
|
|
let parts: Vec<&str> = email.splitn(2, '@').collect();
|
|
|
|
|
if parts.len() != 2 {
|
2026-03-05 21:28:51 +01:00
|
|
|
return Err(UserError::ValidationError(
|
|
|
|
|
"Invalid email: missing @".to_string(),
|
|
|
|
|
));
|
2026-03-05 14:52:11 +01:00
|
|
|
}
|
|
|
|
|
let (local, domain) = (parts[0], parts[1]);
|
|
|
|
|
if local.is_empty() || domain.is_empty() {
|
|
|
|
|
return Err(UserError::ValidationError(
|
|
|
|
|
"Invalid email: empty local part or domain".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !domain.contains('.') {
|
|
|
|
|
return Err(UserError::ValidationError(
|
|
|
|
|
"Invalid email: domain must contain a dot".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
// Reject characters commonly used in XSS / header injection
|
2026-03-05 21:28:51 +01:00
|
|
|
let forbidden = [
|
|
|
|
|
'<', '>', '"', '\'', '\\', ' ', '\t', '\n', '\r', '(', ')', ',', ';',
|
|
|
|
|
];
|
2026-03-05 14:52:11 +01:00
|
|
|
if email.chars().any(|c| forbidden.contains(&c)) {
|
|
|
|
|
return Err(UserError::ValidationError(
|
|
|
|
|
"Invalid email: contains forbidden characters".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if email.len() > 254 {
|
|
|
|
|
return Err(UserError::ValidationError(
|
|
|
|
|
"Invalid email: too long (max 254 characters)".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
}
|