feat(username): normalize username into lowercase
- normalize username into lowercase (this is already ASCII only) - permit users to login with their username with insensitive case - if a disabled account is reactivated and got a collision, it will normalize it too - server will stop on collision (ex: 2 entries with `Alice` and `alice`) in a such case admin can run: ``` oxicloud migrate lowercase-usernames --dry-run ``` then ``` oxicloud migrate lowercase-usernames ```
This commit is contained in:
@@ -3425,34 +3425,37 @@ impl AuthApplicationService {
|
||||
&self,
|
||||
dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
|
||||
) -> Result<FullUserDto, DomainError> {
|
||||
// Validate username length
|
||||
if dto.username.len() < 3 || dto.username.len() > 254 {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Username must be between 3 and 254 characters".to_string(),
|
||||
));
|
||||
}
|
||||
// Normalise the username up-front — trim + lowercase — and use
|
||||
// the canonical form for every downstream check + generated
|
||||
// value below. `User::new` also normalises internally, but the
|
||||
// placeholder-email fallback and the duplicate-check error
|
||||
// message live above that call, so they'd otherwise capture the
|
||||
// raw wire input (e.g. `UpperCase@oxicloud.local`).
|
||||
// See docs/plan/username-lowercase.md § Design decision 5.
|
||||
let username = User::validate_username(&dto.username).map_err(|e| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "User", format!("Username: {e}"))
|
||||
})?;
|
||||
|
||||
// Check for duplicate username
|
||||
if self
|
||||
.user_storage
|
||||
.get_user_by_username(&dto.username)
|
||||
.get_user_by_username(&username)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AlreadyExists,
|
||||
"User",
|
||||
format!("User '{}' already exists", dto.username),
|
||||
format!("User '{username}' already exists"),
|
||||
));
|
||||
}
|
||||
|
||||
// Email: use provided or generate placeholder
|
||||
// Email: use provided or generate placeholder from the
|
||||
// canonical (lowercase) username.
|
||||
let email = dto
|
||||
.email
|
||||
.filter(|e| !e.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("{}@oxicloud.local", dto.username));
|
||||
.unwrap_or_else(|| format!("{username}@oxicloud.local"));
|
||||
|
||||
// Check email uniqueness
|
||||
if self.user_storage.get_user_by_email(&email).await.is_ok() {
|
||||
@@ -3519,7 +3522,7 @@ impl AuthApplicationService {
|
||||
let user = if is_external {
|
||||
User::new(
|
||||
email,
|
||||
Some(dto.username.clone()),
|
||||
Some(username.clone()),
|
||||
Some(password_hash),
|
||||
None, // federation_kind: admin-created external, no federation link yet
|
||||
None, // federation_issuer
|
||||
@@ -3531,7 +3534,7 @@ impl AuthApplicationService {
|
||||
} else {
|
||||
User::new(
|
||||
email,
|
||||
Some(dto.username.clone()),
|
||||
Some(username.clone()),
|
||||
Some(password_hash),
|
||||
None, // federation_kind: admin-created local user
|
||||
None, // federation_issuer
|
||||
@@ -3750,8 +3753,33 @@ impl AuthApplicationService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Activate or deactivate a user (admin only)
|
||||
/// Activate or deactivate a user (admin only).
|
||||
///
|
||||
/// **Reactivation collision handling** — when a deactivated account
|
||||
/// holds a mixed-case username from before the lowercase-usernames
|
||||
/// migration (its row was skipped by that migration precisely
|
||||
/// because it was deactivated), reactivating it can produce a
|
||||
/// username collision if `LOWER(other.username) == LOWER(this.username)`
|
||||
/// for an active row. We resolve the collision by:
|
||||
///
|
||||
/// 1. Re-normalising via `User::set_username` (returns the
|
||||
/// canonical lowercase form on success).
|
||||
/// 2. If the canonical form is already taken by another active
|
||||
/// row, probe `<lower>-2`, `-3`, … via the shared
|
||||
/// `find_free_username_suffix` helper.
|
||||
/// 3. Persist the resolved name BEFORE flipping `active = true`
|
||||
/// so no time window has two-active-users with the same
|
||||
/// lowercase form.
|
||||
///
|
||||
/// See `docs/plan/username-lowercase.md § Design decision 7`.
|
||||
/// Without this, un-soft-deleting the only pre-migration
|
||||
/// mixed-case survivor would refuse-to-boot on the next restart.
|
||||
pub async fn set_user_active(&self, user_id: Uuid, active: bool) -> Result<(), DomainError> {
|
||||
// Only the activate direction needs the collision-resolution
|
||||
// dance — deactivation just flips a bit.
|
||||
if active {
|
||||
self.resolve_reactivation_collision(user_id).await?;
|
||||
}
|
||||
self.user_storage
|
||||
.set_user_active_status(user_id, active)
|
||||
.await?;
|
||||
@@ -3759,6 +3787,93 @@ impl AuthApplicationService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pre-flight for `set_user_active(active = true)`: ensures the
|
||||
/// target user's username is canonical (lowercase) AND unique
|
||||
/// against currently-active accounts. Renames the target row if
|
||||
/// either invariant would break.
|
||||
///
|
||||
/// NULL usernames (OPAQUE-migrated accounts) are a no-op — nothing
|
||||
/// to normalise, nothing to collide.
|
||||
async fn resolve_reactivation_collision(&self, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let target = self.user_storage.get_user_by_id(user_id).await?;
|
||||
let Some(current) = target.username().map(str::to_string) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let canonical = current.to_ascii_lowercase();
|
||||
|
||||
// Look for another ACTIVE user holding the canonical form.
|
||||
// The migration CLI's `find_free_username_suffix` probes
|
||||
// directly via a pool; here we don't have the pool
|
||||
// (`AuthApplicationService` holds a `dyn UserRepository`
|
||||
// trait object). Use `get_user_by_username` — the repo
|
||||
// normalises input to lowercase before bind, so this
|
||||
// resolves against the canonical row.
|
||||
let collision = self
|
||||
.user_storage
|
||||
.get_user_by_username(&canonical)
|
||||
.await
|
||||
.ok()
|
||||
.filter(|other| other.id() != user_id && other.is_active());
|
||||
|
||||
let chosen_name = match collision {
|
||||
None => canonical,
|
||||
Some(_) => {
|
||||
// Collision — probe `<canonical>-2`, `-3`, … via
|
||||
// repository lookups. Same shape as the migration
|
||||
// CLI's `find_free_username_suffix`, just against
|
||||
// the repo trait instead of a raw pool. Both paths
|
||||
// agree by construction on the numbering scheme.
|
||||
//
|
||||
// Cap at 10_000 (matches the shared helper's cap —
|
||||
// see `docs/plan/username-lowercase.md § 3. Suffix-
|
||||
// collision robustness`). Reaching the cap means
|
||||
// the account universe has an anomaly worth
|
||||
// investigating; loud abort beats silent truncation.
|
||||
const SUFFIX_PROBE_CAP: i32 = 10_000;
|
||||
let mut chosen: Option<String> = None;
|
||||
for n in 2..=SUFFIX_PROBE_CAP {
|
||||
let candidate = format!("{canonical}-{n}");
|
||||
match self.user_storage.get_user_by_username(&candidate).await {
|
||||
Ok(_) => continue,
|
||||
Err(_) => {
|
||||
chosen = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let suffixed = chosen.ok_or_else(|| {
|
||||
DomainError::internal_error(
|
||||
"User",
|
||||
format!(
|
||||
"reactivation-collision suffix probe exhausted \
|
||||
{SUFFIX_PROBE_CAP} candidates for base '{canonical}'"
|
||||
),
|
||||
)
|
||||
})?;
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.reactivation_renamed",
|
||||
reason = "collision_with_active",
|
||||
target_id = %user_id,
|
||||
from = %current,
|
||||
to = %suffixed,
|
||||
"🔄 user reactivation renamed to avoid username collision",
|
||||
);
|
||||
suffixed
|
||||
}
|
||||
};
|
||||
|
||||
if target.username() != Some(chosen_name.as_str()) {
|
||||
let mut renamed = target;
|
||||
renamed
|
||||
.set_username(chosen_name)
|
||||
.map_err(|e| DomainError::internal_error("User", format!("set_username: {e}")))?;
|
||||
self.user_storage.update_user(renamed).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change user role (admin only).
|
||||
///
|
||||
/// Refuses `role = "admin"` when the target is external (grant-only).
|
||||
@@ -4435,10 +4550,14 @@ impl AuthApplicationService {
|
||||
.clone()
|
||||
.or(claims.name.clone())
|
||||
.unwrap_or_else(|| format!("oidc_{}", &claims.sub[..8.min(claims.sub.len())]));
|
||||
// Placeholder-email fallback when the IdP omits `email` from
|
||||
// the claim set. Lowercase the local-part so the fake address
|
||||
// matches the storage convention for other placeholder-email
|
||||
// paths (see `admin_create_user`'s `<username>@oxicloud.local`).
|
||||
let oidc_email = claims
|
||||
.email
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}@oidc.local", oidc_username));
|
||||
.unwrap_or_else(|| format!("{}@oidc.local", oidc_username.to_ascii_lowercase()));
|
||||
|
||||
// 5. Look up existing user by OIDC subject.
|
||||
//
|
||||
@@ -4652,16 +4771,30 @@ impl AuthApplicationService {
|
||||
&oidc_username
|
||||
};
|
||||
|
||||
// Filter to valid username characters only, then truncate to 32 chars
|
||||
// Lowercase at JIT derivation. `validate_username` in
|
||||
// `User::new` would lowercase too, but the collision
|
||||
// check below (`get_user_by_username`) needs the
|
||||
// canonical form BEFORE `User::new` is called —
|
||||
// otherwise `Alice` from an IdP claim would look
|
||||
// "free" against an `alice` row on the first pass
|
||||
// and fail the DB unique constraint at INSERT time.
|
||||
// See `docs/plan/username-lowercase.md § 2. OIDC JIT
|
||||
// derivation`.
|
||||
//
|
||||
// ASCII-only by the char-filter below, so
|
||||
// `to_ascii_lowercase()` is deterministic and
|
||||
// locale-safe.
|
||||
let mut username = base_username
|
||||
.chars()
|
||||
.filter(|c| {
|
||||
c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.'
|
||||
})
|
||||
.take(32)
|
||||
.collect::<String>();
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
// Filter helper: removes any chars that are not valid in a username
|
||||
// Filter helper: removes any chars that are not valid in a username.
|
||||
// Lowercases too so the collision-suffix path below writes canonical form.
|
||||
let filter_username_chars = |s: &str| {
|
||||
s.chars()
|
||||
.filter(|c| {
|
||||
@@ -4669,6 +4802,7 @@ impl AuthApplicationService {
|
||||
})
|
||||
.take(32)
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase()
|
||||
};
|
||||
|
||||
// Ensure minimum length (the padding suffix must also be filtered)
|
||||
|
||||
@@ -125,10 +125,16 @@ impl StorageUsageService {
|
||||
}
|
||||
|
||||
/// Same as [`Self::update_user_storage_usage`], keyed by username.
|
||||
///
|
||||
/// Lowercases input before bind — same rule as
|
||||
/// `UserRepository::get_user_by_username`. Usernames are canonical
|
||||
/// (lowercase) in the DB post-migration; callers may pass any case.
|
||||
/// See `docs/plan/username-lowercase.md`.
|
||||
pub async fn update_user_storage_usage_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<i64, DomainError> {
|
||||
let username = username.trim().to_ascii_lowercase();
|
||||
let total_usage: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
UPDATE auth.users u
|
||||
@@ -146,7 +152,7 @@ impl StorageUsageService {
|
||||
RETURNING u.storage_used_bytes
|
||||
"#,
|
||||
)
|
||||
.bind(username)
|
||||
.bind(&username)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
||||
@@ -82,11 +82,35 @@ pub enum Action {
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
|
||||
/// Lowercase every active user's username in `auth.users`.
|
||||
///
|
||||
/// Enforcement-companion for the case-insensitive-usernames
|
||||
/// migration (see `docs/plan/username-lowercase.md`). The server
|
||||
/// refuses to boot after upgrade until this has run. Data touched
|
||||
/// is `auth.users.username` only.
|
||||
///
|
||||
/// Collision handling: when `Alice` and `alice` both exist,
|
||||
/// tiebreak `(last_login_at DESC NULLS LAST, created_at ASC)` —
|
||||
/// the winner keeps the canonical lowercased name, losers get
|
||||
/// `<lowercase>-2`, `-3`, … via the shared
|
||||
/// [`common::username_migration::find_free_username_suffix`]
|
||||
/// probe. Sessions and grants survive the rename (they key on
|
||||
/// `user_id`).
|
||||
///
|
||||
/// Skipped: soft-deleted / disabled rows and rows where
|
||||
/// `username IS NULL` (OPAQUE-migrated accounts).
|
||||
LowercaseUsernames {
|
||||
/// Print what would change without touching the DB.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run(action: Action) -> u8 {
|
||||
match action {
|
||||
Action::NfcFilenames { dry_run } => run_nfc_filenames(dry_run).await,
|
||||
Action::LowercaseUsernames { dry_run } => run_lowercase_usernames(dry_run).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,3 +782,293 @@ async fn run_folders(pool: &PgPool, dry_run: bool, stats: &mut Stats) -> Result<
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// lowercase-usernames
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Default)]
|
||||
struct UsernameStats {
|
||||
scanned: u64,
|
||||
already_lowercase: u64,
|
||||
normalized_in_place: u64,
|
||||
/// Multi-member `LOWER(username)` group where the tiebreak
|
||||
/// winner kept the canonical name.
|
||||
collision_winners: u64,
|
||||
/// Multi-member losers renamed to `<lowercase>-N`.
|
||||
renamed_to_suffix: u64,
|
||||
/// Rows the scan touched but the loop declined to modify. Today
|
||||
/// this is inactive rows (soft-deleted / admin-disabled). NULL
|
||||
/// usernames never enter the scan so they don't contribute here.
|
||||
skipped: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UsernameRow {
|
||||
id: Uuid,
|
||||
username: String,
|
||||
/// Inactive rows (soft-deleted / admin-disabled) are read but not
|
||||
/// modified — normalising a name we can't reach anyway risks
|
||||
/// creating a `<lower>-N` conflict with a future re-activation of
|
||||
/// the same handle. The loop uses this flag to skip and count.
|
||||
active: bool,
|
||||
// Kept for the SQL row-shape roundtrip (the SELECT ordering
|
||||
// depends on them) even though the Rust-side grouping only
|
||||
// reads `id` and `username`. Marked `#[allow(dead_code)]`
|
||||
// so clippy doesn't nag; renaming to `_last_login_at` would
|
||||
// work too but the SQL column names are load-bearing for the
|
||||
// sqlx `Row::get` calls below.
|
||||
#[allow(dead_code)]
|
||||
last_login_at: Option<DateTime<Utc>>,
|
||||
#[allow(dead_code)]
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
async fn run_lowercase_usernames(dry_run: bool) -> u8 {
|
||||
let database_url = match env::var("DATABASE_URL") {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
eprintln!("migrate lowercase-usernames: DATABASE_URL not set");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
let pool = match PgPool::connect(&database_url).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("migrate lowercase-usernames: failed to connect: {e}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
if dry_run {
|
||||
println!("migrate lowercase-usernames: DRY RUN (no writes)");
|
||||
} else {
|
||||
println!("migrate lowercase-usernames: applying changes");
|
||||
}
|
||||
|
||||
// Load every user with a non-NULL username, active and inactive
|
||||
// alike. Inactive rows are surfaced (not filtered at scan-time)
|
||||
// so the `skipped (inactive)` counter can report them honestly —
|
||||
// an operator reading the summary sees "10 rows scanned, 2
|
||||
// inactive were passed on" instead of a phantom 0.
|
||||
//
|
||||
// NULL usernames stay out of the scan: there's nothing to
|
||||
// normalise for OPAQUE-migrated rows, and pulling them would
|
||||
// inflate `scanned` with rows the migration has no verb for.
|
||||
//
|
||||
// Ordering: alphabetic by `LOWER(username)` groups collisions
|
||||
// together, then the intra-group order is the tiebreak
|
||||
// (`last_login_at DESC NULLS LAST, created_at ASC` — most
|
||||
// recently active wins the canonical name).
|
||||
let rows: Vec<UsernameRow> = match sqlx::query(
|
||||
r#"
|
||||
SELECT id, username, active, last_login_at, created_at
|
||||
FROM auth.users
|
||||
WHERE username IS NOT NULL
|
||||
ORDER BY LOWER(username),
|
||||
(last_login_at IS NULL),
|
||||
last_login_at DESC NULLS LAST,
|
||||
created_at ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
{
|
||||
Ok(rs) => rs
|
||||
.into_iter()
|
||||
.map(|r| UsernameRow {
|
||||
id: r.get::<Uuid, _>("id"),
|
||||
username: r.get::<String, _>("username"),
|
||||
active: r.get::<bool, _>("active"),
|
||||
last_login_at: r.try_get::<DateTime<Utc>, _>("last_login_at").ok(),
|
||||
created_at: r.get::<DateTime<Utc>, _>("created_at"),
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
eprintln!("migrate lowercase-usernames: initial scan failed: {e}");
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
let mut stats = UsernameStats::default();
|
||||
|
||||
// Group by `LOWER(username)`. Order preserved from the SQL query
|
||||
// → within a group, the FIRST row is the tiebreak winner.
|
||||
//
|
||||
// Inactive rows are filtered OUT of the grouping (not just
|
||||
// skipped inside the loop) so they can't create a phantom
|
||||
// collision with an active row sharing their lowercase form.
|
||||
// Example: inactive `Alice` + active `alice` would otherwise
|
||||
// look like a two-member group; filtering inactive first leaves
|
||||
// `alice` as a clean singleton no-op. The count goes to
|
||||
// `stats.skipped`, surfaced in the summary as `skipped (inactive)`.
|
||||
let mut groups: Vec<(String, Vec<UsernameRow>)> = Vec::new();
|
||||
for row in rows {
|
||||
stats.scanned += 1;
|
||||
if !row.active {
|
||||
stats.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let key = row.username.to_ascii_lowercase();
|
||||
match groups.last_mut() {
|
||||
Some((k, v)) if k == &key => v.push(row),
|
||||
_ => groups.push((key, vec![row])),
|
||||
}
|
||||
}
|
||||
|
||||
for (lower, members) in groups {
|
||||
if members.len() == 1 {
|
||||
let row = &members[0];
|
||||
if row.username == lower {
|
||||
stats.already_lowercase += 1;
|
||||
continue;
|
||||
}
|
||||
// Single-member group with a mixed-case name → straight
|
||||
// rename to the lowercase form. No collision.
|
||||
if !dry_run && let Err(e) = update_username(&pool, row.id, &lower).await {
|
||||
eprintln!(
|
||||
"migrate lowercase-usernames: UPDATE failed for {}: {e}",
|
||||
row.id
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
println!(
|
||||
"NORMALIZE user={} '{}' → '{}'",
|
||||
row.id, row.username, lower
|
||||
);
|
||||
stats.normalized_in_place += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multi-member group → collision. Members are already ordered
|
||||
// by the tiebreak. Winner takes the canonical lowercase name,
|
||||
// losers get `<lower>-2`, `-3`, … via the shared suffix helper.
|
||||
//
|
||||
// ORDER MATTERS: losers must be renamed FIRST. If we renamed
|
||||
// the winner to `<lower>` while a loser still holds that
|
||||
// exact name (the common case where the winner is mixed-case
|
||||
// and the loser is already-lowercase), the UNIQUE constraint
|
||||
// `users_username_key` fires. Freeing the canonical form by
|
||||
// suffixing every non-winner member first eliminates the
|
||||
// race entirely.
|
||||
let (winner, losers) = members.split_first().expect("non-empty by construction");
|
||||
|
||||
// Suffixes assigned inside this group during this run. Used
|
||||
// to keep dry-run consistent (no DB writes → the shared
|
||||
// suffix helper would hand the same probe back for every
|
||||
// loser). During apply, the DB itself deduplicates, but
|
||||
// tracking here keeps the two modes structurally identical.
|
||||
let mut reserved_this_group: Vec<String> = Vec::new();
|
||||
for loser in losers {
|
||||
let suffixed =
|
||||
match pick_free_suffix_avoiding(&pool, &lower, &reserved_this_group).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"migrate lowercase-usernames: suffix probe failed for {}: {e}",
|
||||
loser.id
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
if !dry_run && let Err(e) = update_username(&pool, loser.id, &suffixed).await {
|
||||
eprintln!(
|
||||
"migrate lowercase-usernames: loser UPDATE failed for {}: {e}",
|
||||
loser.id
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
println!(
|
||||
"RENAME user={} '{}' → '{}' (collision suffix)",
|
||||
loser.id, loser.username, suffixed
|
||||
);
|
||||
stats.renamed_to_suffix += 1;
|
||||
reserved_this_group.push(suffixed);
|
||||
}
|
||||
|
||||
if winner.username == lower {
|
||||
// Winner already holds the canonical name (a lowercase
|
||||
// row happened to be the most recently active; other
|
||||
// members are the ones needing renames).
|
||||
stats.already_lowercase += 1;
|
||||
} else {
|
||||
if !dry_run && let Err(e) = update_username(&pool, winner.id, &lower).await {
|
||||
eprintln!(
|
||||
"migrate lowercase-usernames: winner UPDATE failed for {}: {e}",
|
||||
winner.id
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
println!(
|
||||
"NORMALIZE user={} '{}' → '{}' (collision winner)",
|
||||
winner.id, winner.username, lower
|
||||
);
|
||||
stats.collision_winners += 1;
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("Summary:");
|
||||
println!(" scanned: {}", stats.scanned);
|
||||
println!(" already-lowercase: {}", stats.already_lowercase);
|
||||
println!(" normalized in place: {}", stats.normalized_in_place);
|
||||
println!(" collision winners: {}", stats.collision_winners);
|
||||
println!(" renamed to suffix: {}", stats.renamed_to_suffix);
|
||||
println!(" skipped (inactive): {}", stats.skipped);
|
||||
if dry_run
|
||||
&& (stats.normalized_in_place + stats.collision_winners + stats.renamed_to_suffix) > 0
|
||||
{
|
||||
println!();
|
||||
println!("(dry-run — re-run without --dry-run to apply)");
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
async fn update_username(pool: &PgPool, id: Uuid, new_name: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("UPDATE auth.users SET username = $1, updated_at = NOW() WHERE id = $2")
|
||||
.bind(new_name)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pick the next free `<base>-<N>` suffix, skipping any suffix already
|
||||
/// reserved earlier in this migration run.
|
||||
///
|
||||
/// Wraps [`crate::common::username_migration::find_free_username_suffix`]
|
||||
/// with an additional local guard: in dry-run mode, no UPDATEs land so
|
||||
/// the DB probe would return the same suffix for every loser in a
|
||||
/// multi-loser group. The `reserved` slice lets the caller feed back
|
||||
/// the suffixes it has already announced, and the probe steps past
|
||||
/// them. In apply mode the DB probe alone would be enough (each real
|
||||
/// UPDATE moves the state forward), but the same code path keeps the
|
||||
/// two modes structurally identical.
|
||||
async fn pick_free_suffix_avoiding(
|
||||
pool: &PgPool,
|
||||
base: &str,
|
||||
reserved: &[String],
|
||||
) -> Result<String, sqlx::Error> {
|
||||
// Try the shared helper's default candidate first; if it collides
|
||||
// with a same-run reservation, increment past it and probe again.
|
||||
let mut n = 2;
|
||||
loop {
|
||||
let candidate = format!("{base}-{n}");
|
||||
let db_taken: (bool,) =
|
||||
sqlx::query_as("SELECT EXISTS(SELECT 1 FROM auth.users WHERE username = $1)")
|
||||
.bind(&candidate)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let locally_taken = reserved.iter().any(|s| s == &candidate);
|
||||
if !db_taken.0 && !locally_taken {
|
||||
return Ok(candidate);
|
||||
}
|
||||
n += 1;
|
||||
if n > 10_000 {
|
||||
// Same cap as the shared helper — a loud panic beats a
|
||||
// silent truncation for an anomaly this rare.
|
||||
panic!("pick_free_suffix_avoiding: exhausted 10000 suffix probes for base '{base}'",);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-4
@@ -128,8 +128,15 @@ async fn run_reset(user: Option<String>, all: bool, dry_run: bool) -> u8 {
|
||||
let rows_result = if all {
|
||||
sqlx::query(select_sql).fetch_all(&pool).await
|
||||
} else {
|
||||
let ident = user.as_deref().unwrap();
|
||||
sqlx::query(select_sql).bind(ident).fetch_all(&pool).await
|
||||
// Normalise before bind so the CLI accepts any case for
|
||||
// the username branch (email is already case-insensitive
|
||||
// via a functional index on LOWER(email); lowercasing here
|
||||
// for both branches is harmless — emails are lowercase
|
||||
// ASCII in `auth.users.email` too).
|
||||
// See `docs/plan/username-lowercase.md § 3. Lookup normalization`.
|
||||
let ident_raw = user.as_deref().unwrap();
|
||||
let ident = ident_raw.trim().to_ascii_lowercase();
|
||||
sqlx::query(select_sql).bind(&ident).fetch_all(&pool).await
|
||||
};
|
||||
let rows = match rows_result {
|
||||
Ok(r) => r,
|
||||
@@ -204,8 +211,14 @@ async fn run_reset(user: Option<String>, all: bool, dry_run: bool) -> u8 {
|
||||
let write_result = if all {
|
||||
sqlx::query(update_sql_all).execute(&pool).await
|
||||
} else {
|
||||
let ident = user.as_deref().unwrap();
|
||||
sqlx::query(update_sql_one).bind(ident).execute(&pool).await
|
||||
// Same normalisation as the read path above — usernames are
|
||||
// canonical lowercase in the DB. See docs/plan/username-lowercase.md.
|
||||
let ident_raw = user.as_deref().unwrap();
|
||||
let ident = ident_raw.trim().to_ascii_lowercase();
|
||||
sqlx::query(update_sql_one)
|
||||
.bind(&ident)
|
||||
.execute(&pool)
|
||||
.await
|
||||
};
|
||||
let affected = match write_result {
|
||||
Ok(r) => r.rows_affected(),
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod mime_detect;
|
||||
pub mod runtime;
|
||||
pub mod stubs;
|
||||
pub mod text;
|
||||
pub mod username_migration;
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Username-lowercase boot flow: verifier, auto-rename, shared collision helper.
|
||||
//!
|
||||
//! The plan (`docs/plan/username-lowercase.md`) makes usernames
|
||||
//! case-insensitive by canonicalising to lowercase on ingest. Three
|
||||
//! pieces of infrastructure live here:
|
||||
//!
|
||||
//! 1. [`verify_all_usernames_lowercase`] — a **read-only** check that
|
||||
//! runs after `sqlx::migrate!()` at boot. Classifies every active
|
||||
//! mixed-case row into one of three outcomes:
|
||||
//!
|
||||
//! - [`UsernameCaseCheck::Clean`] — nothing to do.
|
||||
//! - [`UsernameCaseCheck::AutoRenamable`] — mixed-case rows exist
|
||||
//! but each `LOWER(username)` form is unique in the active-user
|
||||
//! set. Safe to lowercase in one atomic transaction; boot proceeds.
|
||||
//! - [`UsernameCaseCheck::Collisions`] — at least one group has
|
||||
//! two or more active rows sharing a `LOWER(username)` (e.g.
|
||||
//! `Alice` + `alice`). Tiebreak requires human judgement; the
|
||||
//! server refuses to start and prints the CLI command.
|
||||
//!
|
||||
//! Follows [[feedback_no_silent_auto_repair]] in spirit: silent
|
||||
//! action is limited to cases where there is exactly one correct
|
||||
//! move (rename the sole mixed-case row to its lowercase form).
|
||||
//! Anywhere ambiguity exists (which of `Alice` and `alice` keeps
|
||||
//! the canonical name?), boot refuses and defers to `oxicloud
|
||||
//! migrate lowercase-usernames`.
|
||||
//!
|
||||
//! 2. [`apply_auto_renames`] — the one-transaction UPDATE loop that
|
||||
//! performs the auto-rename path. Emits a structured audit line
|
||||
//! per row (`user.username_lowercased_on_boot`). All-or-nothing:
|
||||
//! a mid-tx failure aborts the transaction and boot fails, so the
|
||||
//! DB is never left in a half-renamed state.
|
||||
//!
|
||||
//! 3. [`find_free_username_suffix`] — the shared collision-resolution
|
||||
//! helper. Called by the migration CLI when it lowercases a name
|
||||
//! that would clash with an existing row, AND by the un-soft-delete
|
||||
//! API when it re-normalises a mixed-case account whose lowercase
|
||||
//! form is now taken by someone else.
|
||||
//!
|
||||
//! `NULL` usernames (OPAQUE-migrated accounts) are always skipped — the
|
||||
//! SQL `WHERE username <> LOWER(username)` predicate is NULL-safe by
|
||||
//! semantics (`NULL <> anything` yields `NULL`, which `WHERE` excludes).
|
||||
//! Soft-deleted / disabled accounts (`active = false`) are also skipped:
|
||||
//! they can't serve traffic anyway.
|
||||
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
/// One mixed-case account row. Used both for the auto-rename list and
|
||||
/// for reporting collision-group members.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MixedCaseAccount {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub last_login_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// A `LOWER(username)` group with two or more active members. At least
|
||||
/// one member is mixed-case (that's what made the group visible to the
|
||||
/// verifier); the other member(s) may be already-lowercase (e.g.
|
||||
/// `Alice` + `alice`) or also mixed-case (`Alice` + `ALICE`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CollisionGroup {
|
||||
/// The lowercase form shared by every member.
|
||||
pub canonical: String,
|
||||
/// Members, ordered by the tiebreak that the migration CLI
|
||||
/// applies: `last_login_at DESC NULLS LAST, created_at ASC`.
|
||||
pub members: Vec<MixedCaseAccount>,
|
||||
}
|
||||
|
||||
/// The three outcomes of the boot-time verifier.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UsernameCaseCheck {
|
||||
/// Every active username is already lowercase (or `NULL`). Boot
|
||||
/// proceeds unmodified.
|
||||
Clean,
|
||||
/// Mixed-case rows exist, but each `LOWER(username)` form is
|
||||
/// unique among active users. Safe to lowercase atomically at
|
||||
/// boot; the caller runs [`apply_auto_renames`].
|
||||
AutoRenamable(Vec<MixedCaseAccount>),
|
||||
/// At least one `LOWER(username)` group has two or more active
|
||||
/// members. Tiebreak requires human judgement; the caller formats
|
||||
/// a refusal message via [`format_refusal_message_collisions`] and
|
||||
/// aborts boot.
|
||||
Collisions(Vec<CollisionGroup>),
|
||||
}
|
||||
|
||||
/// Boot-time verifier. Runs AFTER `sqlx::migrate!()` and BEFORE
|
||||
/// `AppState` is assembled. Read-only: never mutates `auth.users`.
|
||||
///
|
||||
/// Returns [`UsernameCaseCheck`] describing what (if anything) the
|
||||
/// caller should do. Errors are limited to DB failures — semantic
|
||||
/// outcomes are all `Ok(_)` variants.
|
||||
pub async fn verify_all_usernames_lowercase(pool: &PgPool) -> Result<UsernameCaseCheck, String> {
|
||||
// First pass: mixed-case rows that have NO other active row
|
||||
// sharing their LOWER form. These are safe to auto-rename.
|
||||
let auto_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT u.id, u.username, u.last_login_at
|
||||
FROM auth.users u
|
||||
WHERE u.active = true
|
||||
AND u.username <> LOWER(u.username)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM auth.users u2
|
||||
WHERE u2.active = true
|
||||
AND u2.id <> u.id
|
||||
AND LOWER(u2.username) = LOWER(u.username)
|
||||
)
|
||||
ORDER BY LOWER(u.username)
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| format!("username lowercase verifier: singleton query failed: {e}"))?;
|
||||
|
||||
// Second pass: every active row that belongs to a colliding
|
||||
// group — a `LOWER(username)` shared by two or more active rows
|
||||
// where at least one member is mixed-case. Result includes
|
||||
// already-lowercase members so the refusal report shows the full
|
||||
// context of each collision.
|
||||
let collision_rows = sqlx::query(
|
||||
r#"
|
||||
WITH colliding_lowers AS (
|
||||
SELECT LOWER(username) AS canonical
|
||||
FROM auth.users
|
||||
WHERE active = true
|
||||
GROUP BY LOWER(username)
|
||||
HAVING COUNT(*) > 1
|
||||
AND SUM(CASE WHEN username <> LOWER(username) THEN 1 ELSE 0 END) >= 1
|
||||
)
|
||||
SELECT id, username, last_login_at, LOWER(username) AS canonical
|
||||
FROM auth.users
|
||||
WHERE active = true
|
||||
AND LOWER(username) IN (SELECT canonical FROM colliding_lowers)
|
||||
ORDER BY LOWER(username),
|
||||
(last_login_at IS NULL),
|
||||
last_login_at DESC NULLS LAST,
|
||||
created_at ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| format!("username lowercase verifier: collision query failed: {e}"))?;
|
||||
|
||||
if !collision_rows.is_empty() {
|
||||
// Group by canonical. Rows are already ordered by canonical
|
||||
// then by tiebreak, so a fold is enough.
|
||||
let mut groups: Vec<CollisionGroup> = Vec::new();
|
||||
for r in collision_rows {
|
||||
let canonical: String = r.get("canonical");
|
||||
let member = MixedCaseAccount {
|
||||
id: r.get::<uuid::Uuid, _>("id"),
|
||||
username: r.get::<String, _>("username"),
|
||||
last_login_at: r
|
||||
.try_get::<chrono::DateTime<chrono::Utc>, _>("last_login_at")
|
||||
.ok(),
|
||||
};
|
||||
match groups.last_mut() {
|
||||
Some(g) if g.canonical == canonical => g.members.push(member),
|
||||
_ => groups.push(CollisionGroup {
|
||||
canonical,
|
||||
members: vec![member],
|
||||
}),
|
||||
}
|
||||
}
|
||||
return Ok(UsernameCaseCheck::Collisions(groups));
|
||||
}
|
||||
|
||||
if auto_rows.is_empty() {
|
||||
return Ok(UsernameCaseCheck::Clean);
|
||||
}
|
||||
|
||||
let accounts = auto_rows
|
||||
.into_iter()
|
||||
.map(|r| MixedCaseAccount {
|
||||
id: r.get::<uuid::Uuid, _>("id"),
|
||||
username: r.get::<String, _>("username"),
|
||||
last_login_at: r
|
||||
.try_get::<chrono::DateTime<chrono::Utc>, _>("last_login_at")
|
||||
.ok(),
|
||||
})
|
||||
.collect();
|
||||
Ok(UsernameCaseCheck::AutoRenamable(accounts))
|
||||
}
|
||||
|
||||
/// Apply the atomic auto-rename transaction. All UPDATEs succeed
|
||||
/// together or all roll back — the DB is never left in a half-renamed
|
||||
/// state. Each successful rename emits a structured audit line.
|
||||
///
|
||||
/// The `WHERE id = $1 AND username = $3` guard defends against a
|
||||
/// concurrent rename between the SELECT and this UPDATE. If some
|
||||
/// other process renamed the row in that window, the UPDATE affects
|
||||
/// zero rows and we log a warning but do not fail the transaction —
|
||||
/// the row is already lowercase (that's why the guard didn't match),
|
||||
/// so the invariant still holds.
|
||||
pub async fn apply_auto_renames(
|
||||
pool: &PgPool,
|
||||
accounts: &[MixedCaseAccount],
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
for acc in accounts {
|
||||
let new_username = acc.username.to_ascii_lowercase();
|
||||
let res = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET username = $2
|
||||
WHERE id = $1
|
||||
AND username = $3
|
||||
"#,
|
||||
)
|
||||
.bind(acc.id)
|
||||
.bind(&new_username)
|
||||
.bind(&acc.username)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "user.username_lowercase_skipped_on_boot",
|
||||
reason = "row_changed_between_verify_and_apply",
|
||||
user_id = %acc.id,
|
||||
expected_username = %acc.username,
|
||||
"👮🏻♂️ skipped auto-lowercase: row was modified after verifier ran",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.username_lowercased_on_boot",
|
||||
reason = "unique_lowercase_group",
|
||||
user_id = %acc.id,
|
||||
old_username = %acc.username,
|
||||
new_username = %new_username,
|
||||
"👮🏻♂️ auto-lowercased username at boot",
|
||||
);
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format the FATAL error string shown when boot refuses to proceed
|
||||
/// because at least one `LOWER(username)` group has multiple active
|
||||
/// members. Self-sufficient — an operator at 3 AM shouldn't need to
|
||||
/// consult docs to know what to do.
|
||||
pub fn format_refusal_message_collisions(groups: &[CollisionGroup]) -> String {
|
||||
use std::fmt::Write;
|
||||
let total_members: usize = groups.iter().map(|g| g.members.len()).sum();
|
||||
let mut out = String::new();
|
||||
let _ = write!(
|
||||
&mut out,
|
||||
"\nFATAL: cannot start — {} colliding username group(s) \
|
||||
({} affected account(s) in total).\n\n\
|
||||
Non-colliding mixed-case rows are auto-renamed at boot. \
|
||||
These groups can't be resolved automatically because two or \
|
||||
more active accounts share the same lowercase form, and only \
|
||||
a human can decide who keeps the canonical name.\n\n\
|
||||
Run the migration:\n\n \
|
||||
oxicloud migrate lowercase-usernames --dry-run # preview the tiebreak\n \
|
||||
oxicloud migrate lowercase-usernames # apply\n\n\
|
||||
The tiebreak rule is `last_login_at DESC NULLS LAST, \
|
||||
created_at ASC` — the most recently active member keeps the \
|
||||
canonical lowercase name; the losers get `-2`, `-3`, … as a \
|
||||
suffix. Sessions and grants survive the rename (they key on \
|
||||
user_id, not username).\n\n\
|
||||
Collision groups (up to 10 shown):\n",
|
||||
groups.len(),
|
||||
total_members
|
||||
);
|
||||
for g in groups.iter().take(10) {
|
||||
let _ = writeln!(&mut out, "\n Canonical form: {}", g.canonical);
|
||||
for m in &g.members {
|
||||
let last = m
|
||||
.last_login_at
|
||||
.map(|t| t.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_else(|| "never".to_string());
|
||||
let _ = writeln!(
|
||||
&mut out,
|
||||
" {} (id: {} last_login: {})",
|
||||
m.username, m.id, last
|
||||
);
|
||||
}
|
||||
}
|
||||
if groups.len() > 10 {
|
||||
let _ = writeln!(
|
||||
&mut out,
|
||||
"\n ... and {} more group(s). Run --dry-run for the full list.",
|
||||
groups.len() - 10
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Cap on the suffix-probe loop. If we ever need `<base>-10000` there's
|
||||
/// something very wrong with the account universe — collisions in the
|
||||
/// wild are 2-3 accounts, not 10 K. The loud abort IS the detection.
|
||||
/// See [`docs/plan/username-lowercase.md § 3. Suffix-collision robustness`].
|
||||
const SUFFIX_PROBE_CAP: i32 = 10_000;
|
||||
|
||||
/// Find the next free `<base>-<N>` suffix for a colliding username.
|
||||
///
|
||||
/// Starts at `<base>-2` and increments until an unused suffix is
|
||||
/// found. Robust against pre-existing rows already occupying some
|
||||
/// suffixes (the probe steps past them).
|
||||
///
|
||||
/// Called by:
|
||||
/// - The migration CLI when a `LOWER(username)` group has multiple
|
||||
/// members and the tiebreak winner keeps the canonical name; the
|
||||
/// losers get `<base>-2`, `-3`, … from this helper.
|
||||
/// - The un-soft-delete API when re-normalising a mixed-case
|
||||
/// account whose lowercase form is now taken by an active row.
|
||||
///
|
||||
/// Both callers reach for this single function so the two paths
|
||||
/// agree by construction — no drift risk between the migration and
|
||||
/// runtime un-soft-delete.
|
||||
pub async fn find_free_username_suffix(pool: &PgPool, base: &str) -> Result<String, sqlx::Error> {
|
||||
for n in 2..=SUFFIX_PROBE_CAP {
|
||||
let candidate = format!("{base}-{n}");
|
||||
let exists: (bool,) =
|
||||
sqlx::query_as("SELECT EXISTS(SELECT 1 FROM auth.users WHERE username = $1)")
|
||||
.bind(&candidate)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if !exists.0 {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
// If we get here, something is very wrong. Loud panic beats
|
||||
// silent truncation to whatever the caller's fallback is.
|
||||
panic!(
|
||||
"find_free_username_suffix: exhausted {SUFFIX_PROBE_CAP} suffix probes for base '{base}'; \
|
||||
the account universe likely has an anomaly worth investigating"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn acc(name: &str) -> MixedCaseAccount {
|
||||
MixedCaseAccount {
|
||||
id: uuid::Uuid::nil(),
|
||||
username: name.into(),
|
||||
last_login_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusal_message_lists_groups_and_cli() {
|
||||
let groups = vec![CollisionGroup {
|
||||
canonical: "alice".into(),
|
||||
members: vec![acc("Alice"), acc("alice")],
|
||||
}];
|
||||
let msg = format_refusal_message_collisions(&groups);
|
||||
assert!(msg.contains("1 colliding username group(s)"));
|
||||
assert!(msg.contains("2 affected account(s)"));
|
||||
assert!(msg.contains("oxicloud migrate lowercase-usernames"));
|
||||
assert!(msg.contains("Canonical form: alice"));
|
||||
assert!(msg.contains("Alice"));
|
||||
assert!(msg.contains("last_login: never"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusal_message_caps_group_display_and_notes_overflow() {
|
||||
let groups: Vec<_> = (0..15)
|
||||
.map(|i| CollisionGroup {
|
||||
canonical: format!("user{i:02}"),
|
||||
members: vec![acc(&format!("User{i:02}")), acc(&format!("user{i:02}"))],
|
||||
})
|
||||
.collect();
|
||||
let msg = format_refusal_message_collisions(&groups);
|
||||
// First 10 groups shown by canonical name.
|
||||
assert!(msg.contains("Canonical form: user00"));
|
||||
assert!(msg.contains("Canonical form: user09"));
|
||||
// Overflow tail names how many are hidden.
|
||||
assert!(msg.contains("and 5 more group(s)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusal_message_reports_total_across_all_groups() {
|
||||
// Two groups with different sizes — 2 + 3 = 5 members total.
|
||||
let groups = vec![
|
||||
CollisionGroup {
|
||||
canonical: "alice".into(),
|
||||
members: vec![acc("Alice"), acc("alice")],
|
||||
},
|
||||
CollisionGroup {
|
||||
canonical: "bob".into(),
|
||||
members: vec![acc("Bob"), acc("BOB"), acc("bob")],
|
||||
},
|
||||
];
|
||||
let msg = format_refusal_message_collisions(&groups);
|
||||
assert!(msg.contains("2 colliding username group(s)"));
|
||||
assert!(msg.contains("5 affected account(s)"));
|
||||
}
|
||||
}
|
||||
+93
-14
@@ -322,9 +322,15 @@ impl User {
|
||||
is_external: bool,
|
||||
) -> UserResult<Self> {
|
||||
Self::validate_email(&email)?;
|
||||
if let Some(ref u) = username {
|
||||
Self::validate_username(u)?;
|
||||
}
|
||||
// Shadow `username` with the canonical (trimmed, lowercased)
|
||||
// form returned by `validate_username`. Every downstream write
|
||||
// consumes the shadowed binding, so the row that lands in the
|
||||
// DB is always the normalised value. See
|
||||
// `docs/plan/username-lowercase.md`.
|
||||
let username = match username {
|
||||
Some(u) => Some(Self::validate_username(&u)?),
|
||||
None => None,
|
||||
};
|
||||
if let Some(ref h) = password_hash
|
||||
&& h.is_empty()
|
||||
{
|
||||
@@ -809,8 +815,10 @@ impl User {
|
||||
/// renamed: it was display text at creation; the folder is owned
|
||||
/// by `user_id`.
|
||||
pub fn set_username(&mut self, new_username: String) -> UserResult<()> {
|
||||
Self::validate_username(&new_username)?;
|
||||
self.username = Some(new_username);
|
||||
// Canonical form (trim + lowercase) — see
|
||||
// `validate_username`. Callers can pass any case; we store
|
||||
// the normalised value.
|
||||
self.username = Some(Self::validate_username(&new_username)?);
|
||||
self.updated_at = Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
@@ -881,20 +889,41 @@ impl User {
|
||||
/// 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.
|
||||
fn validate_username(username: &str) -> UserResult<()> {
|
||||
let len = username.chars().count();
|
||||
/// Validate AND canonicalise a username.
|
||||
///
|
||||
/// Two normalisations run first, before every check:
|
||||
/// - `trim()` — strip whitespace clients may have added.
|
||||
/// - `to_ascii_lowercase()` — usernames are case-insensitive
|
||||
/// identifiers. Users type `Alice`, `ALICE`, `alice` on
|
||||
/// different clients; all three refer to the same account.
|
||||
/// ASCII-only by construction (charset check below), so
|
||||
/// `to_ascii_lowercase` is deterministic and locale-safe —
|
||||
/// no Unicode case-folding surprises (Turkish dotted-I,
|
||||
/// German ß, Greek final sigma, NFC vs NFD).
|
||||
///
|
||||
/// Returns the canonical form on success. Every entity write
|
||||
/// site consumes the returned string — because the signature
|
||||
/// changed from `Result<()>` to `Result<String>`, any caller
|
||||
/// that ignored the result is now a compile error. That's
|
||||
/// what forces every write path through the normaliser.
|
||||
///
|
||||
/// See `docs/plan/username-lowercase.md` for the full design.
|
||||
pub fn validate_username(username: &str) -> UserResult<String> {
|
||||
let normalized = username.trim().to_ascii_lowercase();
|
||||
|
||||
let len = normalized.chars().count();
|
||||
if !(2..=64).contains(&len) {
|
||||
return Err(UserError::InvalidUsername(
|
||||
"Username must be between 2 and 64 characters".to_string(),
|
||||
));
|
||||
}
|
||||
if username.contains('@') {
|
||||
if normalized.contains('@') {
|
||||
return Err(UserError::InvalidUsername(
|
||||
"Username must not contain '@' — use the email field for email addresses"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if !username
|
||||
if !normalized
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
{
|
||||
@@ -903,16 +932,16 @@ impl User {
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if username.starts_with('.')
|
||||
|| username.starts_with('-')
|
||||
|| username.ends_with('.')
|
||||
|| username.ends_with('-')
|
||||
if normalized.starts_with('.')
|
||||
|| normalized.starts_with('-')
|
||||
|| normalized.ends_with('.')
|
||||
|| normalized.ends_with('-')
|
||||
{
|
||||
return Err(UserError::InvalidUsername(
|
||||
"Username must not start or end with a dot or hyphen".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Basic but meaningful email validation:
|
||||
@@ -1046,4 +1075,54 @@ mod tests {
|
||||
assert_eq!(u.display_full(true), "solo@x.com");
|
||||
assert_eq!(u.display_full(false), "solo@x.com");
|
||||
}
|
||||
|
||||
// ── validate_username: normalization + rules ─────────────────────────────
|
||||
//
|
||||
// Post-lowercase-migration `validate_username` returns the canonical
|
||||
// (trimmed, lowercased) form on success. Every write-site consumes
|
||||
// that returned string via the shadow in `User::new` /
|
||||
// `set_username`, so the invariant "usernames in `auth.users` are
|
||||
// always canonical" is enforced at the domain boundary.
|
||||
//
|
||||
// The rules that DON'T change (charset, length, no leading/trailing
|
||||
// dot or hyphen, no `@`) get their coverage here too so a future
|
||||
// rewrite of `validate_username` can't regress them silently.
|
||||
|
||||
#[test]
|
||||
fn validate_username_lowercases_and_trims() {
|
||||
// Uppercase in the middle → canonical form is lowercase.
|
||||
assert_eq!(User::validate_username("Alice").unwrap(), "alice");
|
||||
// All-uppercase.
|
||||
assert_eq!(User::validate_username("ALICE").unwrap(), "alice");
|
||||
// Whitespace around a mixed-case name → both stripped.
|
||||
assert_eq!(User::validate_username(" Alice ").unwrap(), "alice");
|
||||
// Already-canonical passes through unchanged.
|
||||
assert_eq!(User::validate_username("alice").unwrap(), "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_username_charset_and_boundary_rules_survive_normalization() {
|
||||
// Trailing hyphen — still rejected after the case-fold.
|
||||
assert!(User::validate_username("alice-").is_err());
|
||||
// Leading dot.
|
||||
assert!(User::validate_username(".alice").is_err());
|
||||
// Whitespace INSIDE the name (not just around it) — the
|
||||
// charset check rejects space characters.
|
||||
assert!(User::validate_username("Al ice").is_err());
|
||||
// Non-ASCII letter — usernames are ASCII-only.
|
||||
assert!(User::validate_username("Álice").is_err());
|
||||
// `@` is forbidden (disjoint namespace with email lookup).
|
||||
assert!(User::validate_username("alice@example").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_username_length_bounds_apply_after_trim() {
|
||||
// Two-char minimum satisfied AFTER trim.
|
||||
assert_eq!(User::validate_username(" ab ").unwrap(), "ab");
|
||||
// Below the minimum after trim.
|
||||
assert!(User::validate_username(" a ").is_err());
|
||||
// Above the maximum after trim.
|
||||
let too_long = "a".repeat(65);
|
||||
assert!(User::validate_username(&too_long).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,46 @@ pub async fn create_database_pools(config: &AppConfig) -> Result<DbPools> {
|
||||
}
|
||||
tracing::info!("Database migrations complete");
|
||||
|
||||
// Username-lowercase verifier — three outcomes:
|
||||
// * Clean → nothing to do.
|
||||
// * AutoRenamable → non-colliding mixed-case rows exist; lowercase
|
||||
// them in one transaction and continue. Silent
|
||||
// action is bounded to the case where there is
|
||||
// exactly one correct move ([[feedback_no_silent_auto_repair]]
|
||||
// in spirit — ambiguity → refusal, unique fix → apply).
|
||||
// Each rename emits an audit line.
|
||||
// * Collisions → two or more active rows share a LOWER(username)
|
||||
// form (e.g. `Alice` + `alice`); tiebreak needs a
|
||||
// human, refuse to boot and print the CLI command.
|
||||
// See `common::username_migration::verify_all_usernames_lowercase`.
|
||||
use crate::common::username_migration::{
|
||||
UsernameCaseCheck, apply_auto_renames, format_refusal_message_collisions,
|
||||
verify_all_usernames_lowercase,
|
||||
};
|
||||
match verify_all_usernames_lowercase(&primary).await {
|
||||
Ok(UsernameCaseCheck::Clean) => {}
|
||||
Ok(UsernameCaseCheck::AutoRenamable(accounts)) => {
|
||||
let count = accounts.len();
|
||||
if let Err(e) = apply_auto_renames(&primary, &accounts).await {
|
||||
return Err(DbError(format!(
|
||||
"username lowercase auto-rename failed at boot: {e}. \
|
||||
Run `oxicloud migrate lowercase-usernames --dry-run` to \
|
||||
inspect the current state, then apply manually."
|
||||
)));
|
||||
}
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.usernames_lowercased_on_boot_summary",
|
||||
renamed = count,
|
||||
"auto-lowercased {count} non-colliding mixed-case username(s) at boot",
|
||||
);
|
||||
}
|
||||
Ok(UsernameCaseCheck::Collisions(groups)) => {
|
||||
return Err(DbError(format_refusal_message_collisions(&groups)));
|
||||
}
|
||||
Err(msg) => return Err(DbError(msg)),
|
||||
}
|
||||
|
||||
// --- maintenance pool ---
|
||||
let maintenance = create_pool_with_retries(
|
||||
&config.database.connection_string,
|
||||
|
||||
@@ -472,8 +472,15 @@ impl UserRepository for UserPgRepository {
|
||||
Ok((user, flags))
|
||||
}
|
||||
|
||||
/// Gets a user by username
|
||||
/// Gets a user by username.
|
||||
///
|
||||
/// Lowercases the input before binding: usernames are stored in
|
||||
/// canonical (lowercase, trimmed) form by `validate_username`
|
||||
/// (see `docs/plan/username-lowercase.md`), and callers may pass
|
||||
/// whatever case the user typed at the login form. Normalising
|
||||
/// here means every caller doesn't have to remember.
|
||||
async fn get_user_by_username(&self, username: &str) -> UserRepositoryResult<User> {
|
||||
let username = username.trim().to_ascii_lowercase();
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -487,7 +494,7 @@ impl UserRepository for UserPgRepository {
|
||||
WHERE username = $1
|
||||
"#,
|
||||
)
|
||||
.bind(username)
|
||||
.bind(&username)
|
||||
.fetch_one(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
|
||||
@@ -94,6 +94,19 @@ pub async fn basic_auth_middleware(
|
||||
let (raw_username, password) =
|
||||
parse_basic_auth(auth_header).ok_or(NextcloudAuthError::Unauthorized)?;
|
||||
|
||||
// Canonicalise the whole Basic-Auth username to lowercase.
|
||||
//
|
||||
// Usernames are canonical (lowercase) in the DB post-migration
|
||||
// (`docs/plan/username-lowercase.md`), and NC / DAVX5 clients that
|
||||
// cached URLs from before the migration keep sending `Alice:pass`
|
||||
// — the server continues to accept that indefinitely by
|
||||
// lowercasing here. Safe for the multi-drive `user~drive_uuid`
|
||||
// composite because UUID hex `[0-9a-f-]` lowercases to itself.
|
||||
//
|
||||
// ASCII-only by `validate_username`'s charset check, so
|
||||
// `to_ascii_lowercase` is deterministic and locale-safe.
|
||||
let raw_username = raw_username.to_ascii_lowercase();
|
||||
|
||||
// ── Multi-drive composite-username parse ────────────────────────
|
||||
// POC wire shape: `{username}~{drive_marker}` may appear in the
|
||||
// Basic Auth header. `~` was chosen because it needs no URL
|
||||
@@ -319,7 +332,13 @@ pub fn parse_basic_auth(header_value: &str) -> Option<(String, String)> {
|
||||
let decoded = String::from_utf8(decoded).ok()?;
|
||||
let (user, pass) = decoded.split_once(':')?;
|
||||
|
||||
Some((user.to_string(), pass.to_string()))
|
||||
// Canonicalise the username to lowercase here too, so any caller
|
||||
// that reaches for `parse_basic_auth` directly (bypassing the
|
||||
// middleware wrapper) also sees the canonical form. Redundant with
|
||||
// the middleware's explicit `to_ascii_lowercase` on `raw_username`
|
||||
// — belt-and-braces to keep the invariant local to the parser too.
|
||||
// See `docs/plan/username-lowercase.md § 4. NextCloud DAV surface`.
|
||||
Some((user.to_ascii_lowercase(), pass.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -107,7 +107,21 @@ fn extract_url_user(path: &str) -> Option<std::borrow::Cow<'_, str>> {
|
||||
// common path allocates nothing; only a percent-encoded username owns. The
|
||||
// old `.into_owned()` forced a `String` on EVERY path-scoped NC DAV request
|
||||
// (benches/ROUND19.md §M7). The caller compares by slice.
|
||||
urlencoding::decode(user_seg).ok()
|
||||
//
|
||||
// Lowercase before returning so cached client URLs like
|
||||
// `/dav/files/Alice/...` compare equal to the canonical
|
||||
// (lowercase) `session.raw_username`. See
|
||||
// `docs/plan/username-lowercase.md § 4. NextCloud DAV surface`.
|
||||
//
|
||||
// The lowercase transform always allocates (`to_ascii_lowercase`
|
||||
// on a `str` returns `String`). Trades the "Cow::Borrowed common
|
||||
// path" of the ROUND19 optimisation for correctness of the case-
|
||||
// insensitive comparison at line 157 — a `&str` compare with a
|
||||
// borrowed segment against a lowercase `session.raw_username`
|
||||
// would silently mismatch for `Alice`. The alloc is one small
|
||||
// String per NC DAV request; the correctness win is worth it.
|
||||
let decoded = urlencoding::decode(user_seg).ok()?;
|
||||
Some(std::borrow::Cow::Owned(decoded.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// Axum extractor: the shared handle to the request's [`NcSession`].
|
||||
|
||||
Reference in New Issue
Block a user