Merge pull request #728 from EdouardVanbelle/feat/username-lowercase
This commit is contained in:
@@ -123,6 +123,70 @@ rather than as a visible error.
|
||||
| `OXICLOUD_NOTIFICATIONS_RETENTION_DAYS` | `30` | Retention window for **read** notification rows (`notif.notifications`). The `notifications_cleanup` scheduled job runs daily and deletes rows where `read_at IS NOT NULL` and `read_at < now() - retention_days`. Unread rows are preserved unconditionally — the whole point of the durable table is that a user offline for a month still sees the share-granted notice on next login. Clamped to a minimum of 1 (0 would purge every read row on every tick). Adjust down for compliance-sensitive deployments where "cleared once seen" matters; adjust up when operators expect users to reference old notifications for support. |
|
||||
| `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive/<uuid\|name>/…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav/<uuid\|name>/…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. |
|
||||
|
||||
## Boot-time refuse-to-boot checks
|
||||
|
||||
Some upgrades add invariants the running database must satisfy
|
||||
BEFORE the new binary can serve traffic. These are enforced by
|
||||
read-only checks that run after `sqlx::migrate!()` and before the
|
||||
server binds a listen socket. If a check fails, the server exits
|
||||
with a FATAL message spelling out the exact CLI command to run.
|
||||
|
||||
The server **never silently mutates data** at boot — every fix is
|
||||
an explicit `oxicloud migrate <name>` invocation. Follows the
|
||||
"discovery-only by default, mutation opt-in" rule that also
|
||||
governs the consistency-check jobs.
|
||||
|
||||
### `lowercase-usernames`
|
||||
|
||||
Three outcomes at boot, only one of which stops the server:
|
||||
|
||||
- **All lowercase (or `NULL`)** — the check is a no-op, boot
|
||||
proceeds unchanged.
|
||||
- **Mixed-case rows exist, no `LOWER(username)` collision** —
|
||||
boot **auto-lowercases** them in one atomic transaction, emits
|
||||
a structured audit line per rename
|
||||
(`user.username_lowercased_on_boot`, INFO) plus an INFO summary
|
||||
(`user.usernames_lowercased_on_boot_summary` with `renamed=N`),
|
||||
and continues. Silent action is confined to the case with
|
||||
exactly one correct move: `Alice` (with no `alice` row) becomes
|
||||
`alice`.
|
||||
- **`LOWER(username)` collision** — two or more active rows share
|
||||
the same lowercase form (e.g. `Alice` + `alice`). Boot
|
||||
**refuses to start** with a FATAL message spelling out every
|
||||
collision group and the exact CLI command to resolve it.
|
||||
Tiebreak needs a human.
|
||||
|
||||
Soft-deleted / disabled accounts (`active = false`) and `NULL`
|
||||
usernames (OPAQUE-migrated) are skipped in every case.
|
||||
|
||||
**Refusal message pattern:** `FATAL: cannot start — N colliding
|
||||
username group(s) (M affected account(s) in total)`, followed by
|
||||
each group's canonical form and its members with `id` +
|
||||
`last_login`. Up to 10 groups shown; the `--dry-run` CLI reveals
|
||||
the full list.
|
||||
|
||||
**Fix (only required when the server refused):**
|
||||
```
|
||||
oxicloud migrate lowercase-usernames --dry-run # preview the tiebreak
|
||||
oxicloud migrate lowercase-usernames # apply
|
||||
```
|
||||
|
||||
**What the migration does:** lowercases every mixed-case
|
||||
username. On collision (`Alice` + `alice` both exist), the
|
||||
tiebreak `(last_login_at DESC NULLS LAST, created_at ASC)` picks
|
||||
a winner; losers get `alice-2`, `-3`, … as a suffix. Sessions
|
||||
and grants survive the rename — both key on the user's UUID.
|
||||
|
||||
**Client compat:** NextCloud clients that cached URLs like
|
||||
`/remote.php/dav/files/Alice/…` continue to work indefinitely —
|
||||
the Basic Auth middleware and URL parser both lowercase on
|
||||
decode. NC desktop clients will prompt a one-time re-sync on
|
||||
first PROPFIND after upgrade; DAVX5 and NC mobile handle it
|
||||
silently. See `docs/install/binary.md § Upgrading from a
|
||||
case-sensitive-usernames release` for the full upgrade flow.
|
||||
|
||||
**Design:** `docs/plan/username-lowercase.md`.
|
||||
|
||||
## Storage Entries (multi-entry, recommended)
|
||||
|
||||
Declare one or more **named** storage backends. The one the app runs on is picked from the DB (`admin_settings.storage.active_backend_name`); the admin panel's storage tab flips the pointer, and cross-backend migration is a recoverable job that copies blobs between two entries with a read-only safety window. See [Admin Settings — Storage & Migration](/config/admin-settings) for the operator flow and the [multi-entry design doc](https://github.com/oxicloud/oxicloud/blob/main/docs/plan/storage-multi-entry.md) for the full model.
|
||||
|
||||
@@ -217,6 +217,75 @@ supported by sqlx's migration model; if you need to roll back, stop
|
||||
the server, roll back your Postgres data directory to a snapshot, and
|
||||
install the previous binary.
|
||||
|
||||
### Upgrading from a case-sensitive-usernames release
|
||||
|
||||
Releases that predate the case-insensitive-usernames change stored
|
||||
`Alice`, `alice`, and `ALICE` as three separate accounts. The
|
||||
current release treats usernames as case-insensitive (canonical
|
||||
lowercase in the database). What happens on the first boot after
|
||||
upgrade depends on your data:
|
||||
|
||||
**No mixed-case usernames.** The check is a no-op; the server
|
||||
starts normally. Nothing to do.
|
||||
|
||||
**Mixed-case usernames with no collision.** The server
|
||||
**auto-lowercases** them in one atomic transaction at boot and
|
||||
continues. Each rename is recorded in the audit log
|
||||
(`user.username_lowercased_on_boot`) and a WARN summary line
|
||||
names the total count. `Alice` (with no `alice` row alongside)
|
||||
becomes `alice`; no ops action needed. This covers the vast
|
||||
majority of single-admin self-hosted deployments.
|
||||
|
||||
**Mixed-case usernames WITH a collision** (`Alice` + `alice`
|
||||
both exist as active accounts). The server **refuses to boot**
|
||||
— tiebreak requires a human. Run the migration:
|
||||
|
||||
```
|
||||
# Preview the tiebreak — no writes.
|
||||
sudo -u oxicloud DATABASE_URL="postgres://..." \
|
||||
/usr/local/bin/oxicloud migrate lowercase-usernames --dry-run
|
||||
|
||||
# Apply. Renames run in a single transaction; safe to re-run if aborted.
|
||||
sudo -u oxicloud DATABASE_URL="postgres://..." \
|
||||
/usr/local/bin/oxicloud migrate lowercase-usernames
|
||||
```
|
||||
|
||||
On collision, the migration picks a winner by `(last_login_at
|
||||
DESC NULLS LAST, created_at ASC)` — most recently active keeps
|
||||
the canonical lowercase name; the loser gets `alice-2`, `-3`, …
|
||||
as a suffix. **Sessions and grants survive the rename** — both
|
||||
key on the user's UUID, not the username.
|
||||
|
||||
Skipped in every path above: soft-deleted / disabled accounts
|
||||
and OPAQUE-migrated users whose `username` column is NULL.
|
||||
Neither blocks boot.
|
||||
|
||||
After the migration completes, restart the service:
|
||||
|
||||
```
|
||||
sudo systemctl start oxicloud
|
||||
```
|
||||
|
||||
**Nextcloud desktop clients** will prompt a one-time re-sync on
|
||||
their first PROPFIND after upgrade — the account URL case
|
||||
changed. Data is safe (files re-verify via ETag, not re-uploaded).
|
||||
DAVX5 (calendars, contacts) and NC mobile handle the URL case
|
||||
change silently. **No client upgrade or reconfiguration is
|
||||
required** — the server accepts uppercase URL segments (`Alice`
|
||||
in `/remote.php/dav/files/Alice/...`) indefinitely.
|
||||
|
||||
The refusal message printed by the server on boot (collision
|
||||
path only) includes the exact CLI command above, so you can't
|
||||
miss it. Full plan and rationale in
|
||||
`docs/plan/username-lowercase.md`.
|
||||
|
||||
**Explicit-preview path.** If you'd rather run the migration
|
||||
before the binary swap — to review renames on your own schedule
|
||||
or to gate a backup step — run `oxicloud migrate
|
||||
lowercase-usernames --dry-run` against the OLD binary's DB
|
||||
first, then apply. On next boot the new binary sees a
|
||||
lowercase-clean DB and the auto-rename path is a no-op.
|
||||
|
||||
## Installing via `cargo binstall`
|
||||
|
||||
If you already have the Rust toolchain and just want the binary
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
# 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 could
|
||||
create a fresh collision that the next boot's auto-rename
|
||||
couldn't resolve (auto-rename handles singletons only) — the
|
||||
server would then refuse-to-boot until an admin resolves the
|
||||
tiebreak.
|
||||
8. **Boot-time behaviour has three outcomes, not two.** The
|
||||
verifier categorises the DB into: (a) clean — nothing to do;
|
||||
(b) mixed-case rows with no `LOWER(username)` collision — the
|
||||
server **auto-lowercases them in one atomic transaction and
|
||||
continues**, emitting an audit line per rename; (c) at least
|
||||
one `LOWER(username)` collision — the server **refuses to
|
||||
boot** because tiebreak requires human judgement. Silent
|
||||
action is bounded to (b), where there is exactly one correct
|
||||
move. This is a narrower reading of
|
||||
[[feedback_no_silent_auto_repair]] than "no silent action
|
||||
ever": the rule targets consistency-check jobs where drift is
|
||||
a bug signal; a schema-adjacent boot invariant with a
|
||||
unique-correct-fix is a different situation. Making the
|
||||
trivial-case common path a no-op massively lowers upgrade
|
||||
friction for the 90% self-hosted deployment.
|
||||
|
||||
## 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<String> {
|
||||
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<String>`, 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. The mixed-case-DB-during-transition state cannot serve
|
||||
traffic because the boot flow either (a) auto-renames the singleton
|
||||
rows before `AppState` assembles, or (b) refuses to boot on collision
|
||||
groups.
|
||||
|
||||
### 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 SHOULD 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.
|
||||
|
||||
**Implementation status:** deferred. Chunked-upload state is
|
||||
ephemeral: any in-flight upload that gets stranded is retryable
|
||||
by the client (the upload session's timeout eventually purges the
|
||||
stale dir; the client retries with a fresh `upload_id`, this time
|
||||
under the lowercase username). Wiring the dir-walk into the CLI
|
||||
adds ~40 lines of async filesystem code (walk, collision merge,
|
||||
mtime-preserving move) and a new `--chunk-dir <path>` arg — the
|
||||
CLI otherwise doesn't need to know about the storage-path
|
||||
config layer. Not worth it for a rare no-op; add if user reports
|
||||
show a real problem.
|
||||
|
||||
**Ops manual step** — if a migration is run WHILE an upload is
|
||||
in flight, ops can either restart the affected client (the
|
||||
upload session is stateful across a `create → chunks → complete`
|
||||
cycle, so the client will retry from scratch) or manually
|
||||
`mv base_dir/Alice base_dir/alice` after the DB migration
|
||||
completes.
|
||||
|
||||
### 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 — <N> 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 `<lowercase>-2`, `-3`, … (increment
|
||||
until free)
|
||||
- Per-row `println!` log:
|
||||
`NORMALIZE user=<uuid> '<before>' ({}B) → '<after>' ({}B)`
|
||||
- Summary at end: scanned / already-lowercase / normalized /
|
||||
collision-resolved / renamed-to-suffix
|
||||
- `--dry-run` guards all UPDATEs
|
||||
|
||||
After the DB pass, the chunked-upload directory rename step (see
|
||||
Deliverable 5) is deferred; run manually only if in-flight uploads
|
||||
were live at migration time.
|
||||
|
||||
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<String, sqlx::Error>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Release notes / CHANGELOG entry is NOT part of this PR — the
|
||||
canonical repo's maintainer handles release notes at version-bump
|
||||
time. This PR just leaves the notes-worthy items enumerated here
|
||||
so the maintainer has the bullets to pick from when the next
|
||||
version ships:
|
||||
|
||||
- Server auto-lowercases non-colliding mixed-case usernames at
|
||||
first boot. No ops action needed for the common case.
|
||||
- On `LOWER(username)` collision (`Alice` + `alice` both active),
|
||||
the server refuses to boot; ops runs `oxicloud migrate
|
||||
lowercase-usernames`. Exact CLI command shown in the refusal.
|
||||
- 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.
|
||||
- Note: `display_name` is a possible follow-up if users miss
|
||||
capitalisation for display — deferred pending demand signal, no
|
||||
compat cost to adding later.
|
||||
|
||||
The two docs that DO ship with this PR:
|
||||
|
||||
- `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**: `format_refusal_message_collisions` — 1 group renders
|
||||
canonical + members + CLI; > 10 groups renders overflow tail;
|
||||
total-affected-count sums across groups.
|
||||
- **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):
|
||||
- Auto-rename path: `UPDATE auth.users SET username='Alice' WHERE
|
||||
username='alice'` (no collision); boot server → verify audit log
|
||||
line + WARN summary, row is `alice` after boot, service starts.
|
||||
- Collision path: `INSERT INTO auth.users … 'Alice'` on top of
|
||||
existing `alice`; boot server → verify refusal message names both
|
||||
rows + exact CLI shown, server exits non-zero.
|
||||
- `oxicloud migrate lowercase-usernames --dry-run` → verify report
|
||||
of collision + tiebreak decision
|
||||
- `oxicloud migrate lowercase-usernames` → verify apply, one row
|
||||
keeps `alice`, other gets `alice-2`
|
||||
- Boot again → succeeds (Clean outcome)
|
||||
- `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<String>` —
|
||||
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. Admin docs update (`docs/config/env.md` boot-check subsection +
|
||||
`docs/install/binary.md` upgrade section). CHANGELOG is Dio's
|
||||
job at version-bump time — not part of this PR.
|
||||
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<String>` 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`
|
||||
Reference in New Issue
Block a user