fix!: deprecate login_type=none, convert existing users to password login (#26851)

> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell.

Deprecates `login_type=none` (legacy passwordless machine users) in
favour of premium **service accounts**, and migrates existing accounts
off the deprecated path while preserving their identity. Resolves
[DEVEX-226].

## What this does

- **Creation is gated** — `POST /users` and `coder users create` reject
`login_type=none` (and the deprecated `--disable-login`) unless a
service account is requested.
- **Existing users are converted** — migration
`000554_legacy_none_login_to_password` rewrites legacy non-system,
non–service-account `login_type='none'` accounts to
`login_type='password'`. Email addresses are **preserved** and existing
API tokens remain valid. Admins can set a password if interactive login
is desired.

## Why convert to `password` and not `is_service_account`?

Migration `000433_add_is_service_account_to_users` adds two CHECK
constraints:

- `users_email_not_empty`: `(is_service_account = true) = (email = '')`
- `users_service_account_login_type`: `is_service_account = false OR
login_type = 'none'`

Turning a real, email-bearing `login_type=none` user into a service
account would require **blanking their email**. Converting to `password`
instead preserves the account and its email.

> ⚠️ **Breaking / one-way.** The `down` migration cannot restore which
users originally had `login_type='none'`.


Decision log

- **Goal:** move existing `login_type=none` users off the deprecated
path while preserving their identity/email.
- **Constraint discovered:** the `is_service_account` CHECK constraints
(migration `000433`) make a literal `none → service account` conversion
require blanking emails, so this PR converts to `password` instead to
keep emails intact.
- **Implementation:** creation-gating in `cli/usercreate.go` and
`coderd/users.go`, matching test updates, plus the
`000554_legacy_none_login_to_password.{up,down}.sql` migration.
- **CI fix:** the branch was behind `main` and its migration originally
numbered `000534`, which collided with main's
`000534_drop_chat_model_configs_provider`. Merged `main` and renumbered
to `000554` (next free after main's `000553`). `make gen` produces no
drift (the migration is data-only).



> The service-account conversion alternative (#27182, which blanked
emails) was closed in favour of this password-preserving approach.
>
> Docs follow-up: #27333.

[DEVEX-226]: https://linear.app/issue/DEVEX-226

---------

Co-authored-by: Sushant P <zenithwolf1000@users.noreply.github.com>
This commit is contained in:
Jake Howell
2026-07-28 21:05:50 +10:00
committed by GitHub
co-authored by Sushant P
parent ed37483ff7
commit 0e104f38e0
8 changed files with 198 additions and 16 deletions
@@ -0,0 +1,2 @@
-- We do not track which users had login_type 'none' before this migration.
-- This is a destructive migration that cannot be undone.
@@ -0,0 +1,9 @@
-- Convert legacy users created with login_type 'none' to password auth.
-- OSS deployments cannot create service accounts without Premium. Existing
-- API tokens remain valid; admins can set a password if password login is
-- desired.
UPDATE users
SET login_type = 'password'
WHERE login_type = 'none'
AND is_service_account = false
AND is_system = false;
@@ -1717,6 +1717,89 @@ func TestMigration000546ChatHistoryAPIKeyConstraints(t *testing.T) {
}
}
func TestMigration000554LegacyNoneLoginToPassword(t *testing.T) {
t.Parallel()
const priorMigrationVersion = 553
sqlDB := testSQLDB(t)
next, err := migrations.Stepper(sqlDB)
require.NoError(t, err)
for {
version, more, err := next()
require.NoError(t, err)
if !more || version == priorMigrationVersion {
break
}
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
now := time.Now().UTC().Truncate(time.Microsecond)
legacyNoneID := uuid.New()
serviceAccountID := uuid.New()
systemID := uuid.New()
passwordID := uuid.New()
// A legacy machine user: login_type 'none', not a service account, not a
// system user. This is the only row the migration should convert.
_, err = sqlDB.ExecContext(ctx,
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type, is_service_account, is_system)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
legacyNoneID, "legacy-none", "legacy-none@test.com", []byte{}, now, now, "active", pq.StringArray{}, "none", false, false)
require.NoError(t, err)
// A service account must keep login_type 'none' (a CHECK constraint requires
// service accounts to use 'none' and an empty email).
_, err = sqlDB.ExecContext(ctx,
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type, is_service_account, is_system)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
serviceAccountID, "service-account", "", []byte{}, now, now, "active", pq.StringArray{}, "none", true, false)
require.NoError(t, err)
// A system user must be left untouched.
_, err = sqlDB.ExecContext(ctx,
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type, is_service_account, is_system)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
systemID, "system-user", "system@test.com", []byte{}, now, now, "active", pq.StringArray{}, "none", false, true)
require.NoError(t, err)
// An existing password user must be left untouched.
_, err = sqlDB.ExecContext(ctx,
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type, is_service_account, is_system)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
passwordID, "password-user", "password@test.com", []byte("hashed"), now, now, "active", pq.StringArray{}, "password", false, false)
require.NoError(t, err)
migrationSQL, err := os.ReadFile("000554_legacy_none_login_to_password.up.sql")
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, string(migrationSQL))
require.NoError(t, err)
getUser := func(t *testing.T, id uuid.UUID) (loginType, email string) {
t.Helper()
err := sqlDB.QueryRowContext(ctx,
`SELECT login_type::text, email FROM users WHERE id = $1`, id).Scan(&loginType, &email)
require.NoError(t, err)
return loginType, email
}
// The legacy machine user is converted to password auth with its email
// preserved.
gotLoginType, gotEmail := getUser(t, legacyNoneID)
require.Equal(t, "password", gotLoginType)
require.Equal(t, "legacy-none@test.com", gotEmail)
// Service accounts, system users, and existing password users are unchanged.
gotLoginType, _ = getUser(t, serviceAccountID)
require.Equal(t, "none", gotLoginType)
gotLoginType, _ = getUser(t, systemID)
require.Equal(t, "none", gotLoginType)
gotLoginType, _ = getUser(t, passwordID)
require.Equal(t, "password", gotLoginType)
}
func TestMigration000498SoftDeleteStaleWorkspaceAgents(t *testing.T) {
t.Parallel()