BEGIN; -- One address, one account. -- -- 001 made the address unique PER CLIENT, so two companies could each have a -- user called alice@example.com and a platform admin could share an address -- with a tenant user. The intent was reasonable; it is not implementable. Sign -- in takes an email and a password and nothing else - no company field, no -- subdomain - so `UserByEmail` looks up `WHERE lower(email) = $1` and takes the -- first row Postgres happens to return. -- -- Measured on a real database with one address held by a platform admin and a -- tenant owner: the first sign-in succeeded as the admin, `TouchUserLogin` -- rewrote that row, which moved it to the end of the heap, and every later -- sign-in with the SAME password returned "Email or password is incorrect." -- because the other account's hash was now first. The account was not locked, -- disabled, or wrong - it had simply stopped being the row the query found. -- Nothing in a log would explain that to anyone. -- -- So: global uniqueness. Somebody who genuinely needs an account in two -- companies needs two addresses, which is the ordinary answer everywhere else -- and is honest about what the sign-in form can express. -- Fail loudly and name the addresses rather than leaving a half-applied schema -- for the operator to work out from a constraint violation. DO $$ DECLARE dupes text; BEGIN SELECT string_agg(e, ', ') INTO dupes FROM ( SELECT lower(email) AS e FROM app_users GROUP BY 1 HAVING count(*) > 1 ) d; IF dupes IS NOT NULL THEN RAISE EXCEPTION 'these addresses have an account in more than one company: %. ' 'Give each account its own address before applying this migration.', dupes; END IF; END $$; DROP INDEX IF EXISTS app_users_email_idx; -- Same NAME as before on purpose: the API turns a violation of this index into -- "That email address already has an account", by matching the name. CREATE UNIQUE INDEX app_users_email_idx ON app_users (lower(email)); COMMIT;