Files
Oxicloud/migrations/20260620000000_username_optional_no_email_shape.sql
T
Edouard Vanbelle d57a50d056 feat(username|email): pass1: normalize auth.user data
username: now optional, if defined 2..64 chars
    password: now optional (no mode __NO_PASSWORD...__)
    oidc: now optional

    important: if need Nextcloud, username must be defined
2026-06-03 00:35:25 +02:00

57 lines
3.2 KiB
SQL

-- ════════════════════════════════════════════════════════════════════════════
-- Auth simplification — username + password_hash become nullable
-- ════════════════════════════════════════════════════════════════════════════
-- This migration completes the auth-simplification design (PR 16 of the
-- auth-simplification plan):
--
-- * `username` becomes NULLABLE. External users get NULL; internal users
-- keep their handles. Multiple NULLs coexist under the existing UNIQUE
-- index (Postgres allows this by default).
-- * `password_hash` becomes NULLABLE. The sentinel strings
-- `__EXTERNAL_NO_PASSWORD__` and `__OIDC_NO_PASSWORD__` are NULL'd out;
-- the entity-level checks switch from string comparison to
-- `Option::is_some`.
-- * Username format CHECK tightens: 2-64 chars, no `@` (banning `@`
-- keeps the username and email namespaces provably disjoint and
-- prevents the cross-collision attack class described in the
-- auth-simplification plan).
--
-- Forward-only — do NOT squash with `20260612000003_users_username_email_login.sql`.
-- That migration has already been applied to dev / CI environments;
-- squashing would invalidate `_sqlx_migrations` checksums and lock down
-- the migration runner.
-- 1. Drop NOT NULL on the two columns we're loosening.
ALTER TABLE auth.users ALTER COLUMN username DROP NOT NULL;
ALTER TABLE auth.users ALTER COLUMN password_hash DROP NOT NULL;
-- 2. NULL out the email-shaped usernames that PR 9 stamped onto external
-- users. Their identity is the email column; the username field carried
-- a redundant duplicate that was only ever used to satisfy NOT NULL.
UPDATE auth.users
SET username = NULL
WHERE is_external = TRUE;
-- 3. NULL out the placeholder password_hash sentinels. After this migration
-- `password_hash IS NULL` means "no password set"; non-NULL means
-- "argon2 hash". No more string-comparison gymnastics in the entity.
UPDATE auth.users
SET password_hash = NULL
WHERE password_hash IN ('__EXTERNAL_NO_PASSWORD__', '__OIDC_NO_PASSWORD__');
-- 4. Tighten username format. The CHECK fires only when username IS NOT
-- NULL (existing externals stay NULL; new email-shaped values are
-- rejected at write time). Length 2-64 matches the entity validator's
-- new range. Existing internal usernames are all ≥3 and ≤32 chars,
-- so this is non-breaking for current data.
ALTER TABLE auth.users
ADD CONSTRAINT users_username_shape_v2
CHECK (username IS NULL
OR (username !~ '@' AND char_length(username) BETWEEN 2 AND 64));
COMMENT ON COLUMN auth.users.username IS
'Optional handle (2-64 chars, no `@`). NULL for external users and for users who haven''t claimed one yet. UNIQUE allows multiple NULLs by default.';
COMMENT ON COLUMN auth.users.password_hash IS
'Argon2 password hash. NULL when the user has no password (externals, OIDC-only users, or post-PR-18 email-only signups awaiting their welcome magic-link).';