diff --git a/docs/plan/username-lowercase.md b/docs/plan/username-lowercase.md new file mode 100644 index 00000000..310cd25f --- /dev/null +++ b/docs/plan/username-lowercase.md @@ -0,0 +1,524 @@ +# Plan — Case-insensitive usernames (lowercase-on-ingest) + +## Context + +Feature ask: [issue #691](https://github.com/AtalayaLabs/OxiCloud/issues/691). +Usernames are currently case-sensitive, so `Alice`, `alice`, and `ALICE` +refer to three different accounts. Users hit this as a login friction — +they type their name with different capitalization on different clients +and get "invalid credentials" instead of a successful login. + +## Why this is simpler than it looks in this codebase specifically + +- `validate_username` in `src/domain/entities/user.rs:884` already + restricts usernames to ASCII-only `[a-zA-Z0-9._-]{2,64}` with no `@`. + The Unicode case-folding minefield (Turkish dotted-I, German ß, Greek + final sigma, NFC vs NFD) does not apply — ASCII case-folding is + trivial (`to_ascii_lowercase`), deterministic, and locale-independent. +- OIDC identity binds via `(iss, sub)` in + `get_user_by_federation_subject` at + `src/infrastructure/repositories/pg/user_pg_repository.rs:1257-1303` — + case-sensitivity of the local username is orthogonal to OIDC identity + matching. No OIDC breakage risk. +- Password verification runs through Argon2's `verify_password` + (constant-time by construction). Not affected. +- `@`-forbidden rule in usernames is the disjoint namespace with email + lookup (`dispatch_login` at `auth_application_service.rs:1018`). + Case-insensitive usernames align semantics with email addresses + (already case-insensitive in practice), so any future + `groupname@domain` composition stays consistent. +- NextCloud URL `/remote.php/dav/files/{user}/…` uses `{user}` as an + informational / consistency-check marker, not a security boundary — + the chroot ACL is the real authz. Handling case in the URL segment is + a small local change (documented in `session.rs:33-34`). + +## Design decisions + +1. **Silently lowercase on ingest** (registration, admin-create, OIDC + provisioning, rename). Never reject uppercase input from clients — + accept liberally, store strictly (Postel's Law). +2. **Explicit migration** (`oxicloud migrate lowercase-usernames + [--dry-run]`). The server never mutates `auth.users` at boot. Ops + MUST run the migration explicitly. Follows the + [[feedback_no_silent_auto_repair]] rule: consistency tenants are + discovery-only by default; mutation is opt-in. +3. **Refuse-to-boot** if any active-user username is not already + lowercase. Boot error message shows the exact CLI command to run. + Boot performs a read-only verification only. +4. **Collision tiebreak** on migration: `(last_login_at DESC NULLS + LAST, created_at ASC)`. Winner keeps the canonical lowercased name. + Losers get `-2`, `-3`, … suffix (increment until free), matching the + pattern in `oxicloud migrate nfc-filenames`. +5. **Active accounts only** in the boot check + migration. Soft-deleted + / disabled rows are skipped (they don't block usable logins). NULL + usernames (OPAQUE-migrated accounts) are skipped in every layer — + the boot verifier, the migration UPDATE, the CLI report. The + `WHERE username <> LOWER(username)` predicate is already NULL-safe + by SQL semantics (NULL comparisons yield NULL, filtered out); state + it explicitly so a reviewer isn't left wondering. +6. **Rename-only** on collision resolution — sessions are not + invalidated. Sessions key on `user_id` so they survive the rename. +7. **Un-soft-delete of a mixed-case account uses the SAME suffix + scheme.** If `Alice` is soft-deleted (skipped by migration) and + later un-soft-deleted while `alice` already exists, the un-soft- + delete path re-normalizes via `set_username` and, on collision, + assigns `alice-2` / `alice-3` / … — the same helper the migration + CLI calls. Both callers reach for a shared + `find_free_username_suffix(pool, base) -> String` in + `src/common/username_migration.rs` so migration + un-soft-delete + agree by construction. Without this, an un-soft-delete of the + only pre-migration mixed-case survivor would refuse-to-boot on + the next restart. + +## Not in scope + +- Unicode case-folding (usernames are ASCII-only by validation). +- `display_name` split (usernames were already just identifiers; + free-form display is a separate future feature if a user asks for + it — deferred pending real demand signal). +- OIDC provisioning behaviour change beyond the ingest-normalize point. +- Case-insensitivity for emails (already achieved in practice; not + touched). +- Any change to `validate_username`'s character-class rules. +- Any change to the WebDAV URL shape `/dav/files/{user}/…` (client + compat; drop deferred separately per [[project_nc_multidrive_poc]]). +- Group names (`SubjectGroup`). Lowercase by convention today; no + runtime enforcement, no migration. If group-name case-insensitivity + becomes a real ask, it lands as a sibling plan doc with the same + shape. + +## Deliverables + +### 1. Ingest normalization + +Change `validate_username` to return the canonical form instead of +`()`: + +```rust +// src/domain/entities/user.rs — new signature +fn validate_username(username: &str) -> UserResult { + let normalized = username.trim().to_ascii_lowercase(); + // ... existing length + charset + boundary checks apply to `normalized` ... + Ok(normalized) +} +``` + +Every caller that today does `Self::validate_username(u)?;` becomes +`let u = Self::validate_username(&u)?;` — the returned canonical form +is what gets stored. Because the return type changes from `Result<()>` +to `Result`, any caller that ignores the result now becomes a +compile error — the type system forces every write path through the +normalizer. + +Write sites all funnel through `User::new` (`src/domain/entities/user.rs:313`) +or `User::set_username` (`:811`), so the signature change catches the +entity-write path automatically. Callers to touch: + +- Application services calling `User::new`: + - `auth_application_service.rs:840` — `register()` public signup + - `auth_application_service.rs:944` — `setup_create_admin()` + first-boot admin + - `auth_application_service.rs:3520`, `:3532` — `admin_create_user()` + external + internal branches + - `auth_application_service.rs:4692` — OIDC JIT provisioning + - `magic_link_invite_service.rs:233` — magic-link external invite + +- User-driven rename (calls `User::set_username`): + - `auth_application_service.rs:2756-2801` — `update_profile()` + +- Repository-write compile-error catches: + - `src/infrastructure/repositories/pg/user_pg_repository.rs:281` + (`create_user` INSERT) and `:740` (`update_user` UPDATE) — these + bind `user_clone.username()`, which is now guaranteed lowercase by + the entity constructor. + +### 2. OIDC JIT derivation + +`auth_application_service.rs:4649-4690` derives a local username from +the OIDC `preferred_username` / `name` / `sub` claims, filters to +`[a-zA-Z0-9._-]`, and truncates. **It does not currently lowercase.** +Add `to_ascii_lowercase()` on the derived string before passing to +`User::new`. This is beyond what the entity signature change catches — +explicit fix required. + +### 3. Lookup normalization + +Repository `find_by_username`-style methods internally lowercase the +input before the SQL query, so callers don't have to remember. One-line +change per method: + +- `src/infrastructure/repositories/pg/user_pg_repository.rs:487` + (`get_user_by_username`) — add `let username = username.trim(). + to_ascii_lowercase();` before the `.bind(&username)` at line 488. +- `src/infrastructure/repositories/pg/user_pg_repository.rs:1043` + (`search_users`) — `ILIKE` is already case-insensitive by + construction; verify nothing regresses. +- `src/infrastructure/repositories/pg/user_pg_repository.rs:1504` + (`search_usernames`) — same as above. +- `src/application/services/storage_usage_service.rs:145-149` + (`update_user_storage_usage_by_username`) — raw SQL bind; normalize + before `.bind()`. +- `src/cli/opaque.rs:125`, `:202` — `opaque reset` CLI identifier + dispatch on `@`; lowercase the username branch input. + +Post-migration, the DB is fully lowercase so `WHERE username = 'alice'` +matches. Pre-migration users are blocked from booting by the boot-time +check, so the mixed-case-DB-during-transition state cannot serve +traffic. + +### 4. NextCloud DAV surface + +Two coordinated changes on the NC surface: + +- `src/interfaces/nextcloud/basic_auth_middleware.rs:94-134` — decoded + `raw_username` from the Basic Auth header, lowercase the whole + string. Safe for the `user~drive_uuid` multi-drive format because + UUID hex is `[0-9a-f-]` which lowercases to itself. +- `src/interfaces/nextcloud/basic_auth_middleware.rs:307-323` + (`parse_basic_auth` helper) — lowercase the username portion before + returning. +- `src/interfaces/nextcloud/session.rs:90-111` + (`extract_url_user`) — lowercase the returned `Cow<'_, str>` value + from URL decode. The cross-check comparison at `session.rs:157-161` + (`url_user != session.raw_username`) then compares normalized vs + normalized — no change needed at the comparison site itself. + +Downstream `session.raw_username` consumers (WebDAV / OCS href +builders, MOVE Destination parsers, avatar / trashbin handlers) all +pass through and emit lowercase automatically — no per-site change +needed. + +**Client compatibility:** NC / DAVX5 clients that cached URLs like +`/remote.php/dav/files/Alice/…` continue to work through the migration +because the server accepts uppercase URL segments **indefinitely** +(the Basic Auth middleware + `extract_url_user` both lowercase on +decode). No forced client upgrade or reconfiguration. PROPFIND +response bodies emit lowercase hrefs (from canonical +`session.raw_username`), which well-behaved clients update on next +sync. + +Expected per-client behavior on first PROPFIND after upgrade: + +- **Nextcloud desktop** — prompts a one-time re-sync notification + when it notices the account URL case changed. Files re-verify + via ETag, so no re-upload; the re-sync completes in + seconds-to-minutes depending on file count. Users click through + the reconnect dialog. +- **DAVX5** (calendars, contacts) — silent update of the internal + `principal-URL`; user sees no dialog. +- **NC mobile app** — silent refresh of the account tile. +- **Older / misbehaving clients** — may create a duplicate account + profile (rare, cosmetic, not destructive). + +**Zero data risk in every path.** The chroot ACL keys on +`user_id`, not username, so files, calendars, contacts, and +grants all follow the user across the rename. The blast radius +is a one-time UX notification, not lost bytes. + +**Power-user pre-emption** (worth documenting in CHANGELOG): ops +who want to avoid the re-sync prompt entirely can, before +upgrading, log into each NC desktop client and manually update +the account URL from `.../USERNAME` to lowercase. Cheap +prophylactic for organizations rolling out to non-technical +users. + +### 5. Chunked-upload directory rename + +`src/infrastructure/services/nextcloud_chunked_upload_service.rs:99-103` +uses `user.username` as an on-disk directory name AND as an in-memory +cache key. Post-migration, `user.username` becomes lowercase; any +in-flight upload for `Alice` at migration time strands the on-disk +`base_dir/Alice/upload_xxx/` directory and orphans its cache entry. + +The migration command must ALSO walk `base_dir/*/` and rename any +mixed-case subdirectory to its lowercase form. Collision handling +(both `Alice/` and `alice/` present) → merge contents; else simple +rename. In practice this is likely a no-op — chunked-upload state is +ephemeral, and simultaneous mixed-case uploads by the same user are +rare. + +### 6. Boot-time verification + +New module `src/common/username_migration.rs` exposing: + +```rust +pub async fn verify_all_usernames_lowercase(pool: &PgPool) -> Result<(), String> +``` + +Runs after `sqlx::migrate!()` completes, before `AppState` is +assembled. Query: + +```sql +SELECT id, username, created_at, last_login_at + FROM auth.users + WHERE username <> LOWER(username) + -- NULL usernames (OPAQUE-migrated accounts) are already filtered + -- out by SQL semantics: NULL <> anything yields NULL, which + -- WHERE excludes. Explicit for the reviewer's benefit. + -- add is_deleted / disabled filter if such a flag exists + ORDER BY LOWER(username), + (last_login_at IS NULL), + last_login_at DESC NULLS LAST, + created_at ASC + LIMIT 200; -- soft cap on error-message size +``` + +If empty → boot proceeds. If non-empty → format the FATAL error and +return `Err(String)`. `main.rs` propagates via `?` to a non-zero +process exit. + +Boot only READS `auth.users`; never WRITES. This is the "explicit +migration required" enforcement layer. + +**Error message format** (self-sufficient — no docs required at 3 AM): + +``` +FATAL: cannot start — user account(s) have non-lowercase usernames. + +Before this version can boot, run the migration: + + oxicloud migrate lowercase-usernames --dry-run # preview + oxicloud migrate lowercase-usernames # apply + +Affected accounts (up to 20 shown; full list via the dry-run): + + Alice (id: a1b2c3d4-... last_login: 2026-08-01) + BOB (id: 9abc0000-... last_login: never) + ... + +The migration handles case-collisions (Alice + alice → alice keeps +the name based on most recent login; the other gets alice-2 suffix). +Sessions and grants survive the rename (they key on user_id). +``` + +### 7. Migration CLI + +New action under `oxicloud migrate`: + +```rust +// src/cli/migrate.rs — extend the Action enum +Action::LowercaseUsernames { dry_run: bool } +``` + +Following the shape of `run_nfc_filenames`: + +- Load all active users (skip soft-deleted / disabled AND rows + where `username IS NULL` — OPAQUE-migrated accounts have no + username string to normalize) +- Group by `LOWER(username)` +- For each group: + - Single-member group with mixed-case name → UPDATE to lowercase + - Multi-member group (collision) → apply tiebreak + `(last_login_at DESC NULLS LAST, created_at ASC)`, winner UPDATEs + to lowercase, losers UPDATE to `-2`, `-3`, … (increment + until free) +- Per-row `println!` log: + `NORMALIZE user= '' ({}B) → '' ({}B)` +- Summary at end: scanned / already-lowercase / normalized / + collision-resolved / renamed-to-suffix +- `--dry-run` guards all UPDATEs + +After the DB pass, run the chunked-upload directory rename step (see +Deliverable 5). + +Suffix search reuses the pattern from +`find_free_folder_duplicate_name` in the existing NFC migration — +increment-until-free loop, starting at `-2`, probing until an +unused suffix is found. Robust against pre-existing rows like +`alice-2` already being taken (the probe just steps past them +to `-3`, `-4`, …). + +Extracted into a shared public helper in +`src/common/username_migration.rs`: + +```rust +pub async fn find_free_username_suffix(pool: &PgPool, base: &str) -> Result +``` + +Both the migration CLI AND the un-soft-delete API (Design decision +7) call this helper — same collision-resolution behavior by +construction, no drift risk between the two paths. + +Bounded at 10,000 as a safety cap. The probability of reaching +that in a real deployment is negligible — it would require ~10 K +distinct accounts all originally cased differently but sharing +the same lowercase form (a normal collision is 2-3 accounts, not +10 K). If the cap ever fires, something is very wrong with the +account universe and the migration ABORTs with a loud error +rather than silently truncating — the loud abort IS the +detection mechanism. + +### 8. Test seed audit + +Sweep-verified: existing test seeds all produce lowercase or NULL +usernames. Worth one more grep pass to ensure no test fixture INSERTs +`INSERT INTO auth.users … 'AliceTest'` — if any exist, lowercase them +in the same commit to avoid CI refuse-to-boot regressions. + +Files verified (all safe): +- `src/infrastructure/repositories/pg/user_pg_repository.rs:1786` +- `src/infrastructure/repositories/pg/opaque_pg_repository.rs:339` + (NULL) +- `src/application/services/auth_application_service.rs:4982` (NULL) +- `src/application/services/subject_group_service.rs:796` (NULL) +- `src/bin/load-seed.rs:414`, `:446` (`load_user_XXXX` — lowercase) +- `src/mount_it_support.rs:61` (`make_user(name)` — verify callers) +- `tests/common/init-test-schema.sh:40` (`ci-admin` — lowercase) + +### 9. Cosmetic side-effects (worth noting in CHANGELOG, non-blocking) + +- `src/interfaces/nextcloud/avatar_handler.rs:283` — `pick_color` + derives a deterministic tile color from username bytes. Users whose + canonical username had uppercase letters will get a different + fallback-avatar tile color after the migration. One-time cosmetic + change. +- **NC desktop may perform a one-time re-sync** — see Deliverable 4. + +### 10. Documentation + +- `CHANGELOG.md` — user-visible note: + - Migration required; server refuses to boot until it's been run. + - Exact CLI command shown in the refusal message. + - Nextcloud desktop clients will prompt for a one-time re-sync + on first PROPFIND after upgrade. Files are ETag-verified, not + re-uploaded. DAVX5 and NC mobile handle the URL case change + silently. **No forced client upgrade or reconfiguration** — + server accepts uppercase URL segments indefinitely. + - Optional pre-emption for non-technical users: ops can manually + update the account URL to lowercase in each NC desktop client + before upgrading, avoiding the re-sync prompt entirely. + - Usernames become lowercase in ALL UI display surfaces (share + dialogs, activity feeds, admin panels, PROPFIND response + bodies, notification bell). Login identity unchanged from the + user's POV (they can still type any case at the login form). + - Avatar fallback color may change for users with previously- + uppercase usernames. + - Preamble noting `display_name` is a possible follow-up if + users miss capitalization for display — deferred pending + demand signal, no compat cost to adding later. +- `docs/config/env.md` — note the boot-time check + migration command. +- `docs/install/binary.md` — upgrade-from-case-sensitive section. + +### 11. Test coverage + +- **Unit**: `validate_username("Alice")` returns `Ok("alice")`; + `validate_username("alice-")` returns `Err(...)` unchanged; + `validate_username(" Alice ")` returns `Ok("alice")`. +- **Unit**: `verify_all_usernames_lowercase` with mocked pool — empty + result → Ok; non-empty → Err with formatted message. +- **Hurl** (`tests/api/lowercase_usernames.hurl`, new): register a + user with `MixedCase`, assert DB stores `mixedcase`; log in with + `MIXEDCASE` and `mixedcase` — both succeed; rename to `NewName`, + assert `newname` stored; NC Basic Auth accepts `MixedCase:pass`, + `MIXEDCASE:pass`, `mixedcase:pass`. +- **Manual** (against dev DB, not CI): + - Induce a collision via `INSERT INTO auth.users … 'Alice'` on top + of `alice`; boot server → verify refusal message + exact CLI shown + - `oxicloud migrate lowercase-usernames --dry-run` → verify report + - `oxicloud migrate lowercase-usernames` → verify apply + - Boot again → succeeds + - `curl -u ALICE:pass https://oxicloud/remote.php/dav/files/ALICE/…` + → succeeds (accepts uppercase input, resolves to lowercase user) + +## Cache-and-consistency observations (informational) + +- `src/infrastructure/services/login_lockout_service.rs:33,68-89` — + already lowercases the key at line 58. No code change; comment + becomes factual not incidental. +- `src/application/services/app_password_service.rs:89,317-323` — + BLAKE3-keyed cache using the raw wire username. Post-normalization, + both sides normalize consistently → cache stays coherent. 300 s TTL + self-heals any transitional window. +- `NC_CHROOT_CACHE` in `basic_auth_middleware.rs:34-40` — keyed on + `Uuid`, not username. Unaffected. + +## Critical files + +Full enumeration in the Deliverables sections above. Grouped summary: + +**Ingest normalizer:** +- `src/domain/entities/user.rs` (signature change + callers) + +**Application services (write callers):** +- `src/application/services/auth_application_service.rs` +- `src/application/services/magic_link_invite_service.rs` + +**Repositories (lookup normalization):** +- `src/infrastructure/repositories/pg/user_pg_repository.rs` +- `src/application/services/storage_usage_service.rs` +- `src/cli/opaque.rs` + +**NextCloud DAV surface:** +- `src/interfaces/nextcloud/basic_auth_middleware.rs` +- `src/interfaces/nextcloud/session.rs` +- `src/infrastructure/services/nextcloud_chunked_upload_service.rs` + +**New files:** +- `src/common/username_migration.rs` +- `tests/api/lowercase_usernames.hurl` + +**Main entry:** +- `src/main.rs` (call verifier after `sqlx::migrate!()`) + +**Migration CLI:** +- `src/cli/migrate.rs` + +**Docs:** +- `CHANGELOG.md` +- `docs/config/env.md` +- `docs/install/binary.md` + +## Delivery order + +1. Change `validate_username` signature to return `Result` — + one file. +2. Fix OIDC JIT derivation + (`auth_application_service.rs:4649-4690`) to lowercase before + passing to `User::new` — explicit change beyond the entity + normalizer's compile-time catches. +3. Iterate on compile errors — the return-type change catches every + downstream write-site. +4. Update repository lookup methods (`user_pg_repository.rs`, + `storage_usage_service.rs`, `cli/opaque.rs`) to internally + lowercase input before `.bind()`. +5. Update NC `basic_auth_middleware.rs` (lowercase `raw_username` at + decode) + `session.rs::extract_url_user` (lowercase return). +6. Add the boot-time verification helper (`src/common/username_migration.rs`) + + wire into `main.rs`. +7. Extend `oxicloud migrate` with `lowercase-usernames [--dry-run]` — + DB pass + chunked-upload directory rename. +8. Test seed audit (grep pass). +9. Add hurl coverage. +10. CHANGELOG entry + admin docs update. +11. Manual smoke test against dev DB. +12. PR to canonical. + +## Total scope estimate + +~6-8 hours of careful work. Larger than the initial estimate because +of these sweep-surfaced additions: + +- OIDC JIT explicit fix (small). +- Chunked-upload directory rename step in the migration (~30 min). +- Test-seed audit (~15 min). +- More lookup callsites than initially thought. + +The shape is uniform (`to_ascii_lowercase()` at every touchpoint) and +the compiler catches missed entity-write sites via the +`Result` signature change. The parts NOT caught by the +compiler (OIDC JIT, lookup normalizers, NC URL segment, chunked-upload +directory) are the ones needing careful review — enumerated above. + +## References + +- Issue: [#691](https://github.com/AtalayaLabs/OxiCloud/issues/691) +- Related feature restrictions today: + - `validate_username` at + `src/domain/entities/user.rs:884-916` + - `@`-disjoint dispatch at + `src/application/services/auth_application_service.rs:1018` +- Related project docs: + - `docs/plan/auth-simplification.md` — the broader auth surface this + fits within + - Prior similar migration: + `oxicloud migrate nfc-filenames` in `src/cli/migrate.rs`