-
Notifications
You must be signed in to change notification settings - Fork 1.5k
fix!: deprecate login_type=none, convert existing users to password login
#26851
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jakehwll
merged 13 commits into
main
from
jakehwll/DEVEX-226-all-services-accounts-all-the-time
Jul 28, 2026
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
51ca71c
feat: database migration
jakehwll 8285fcc
feat: align cli and backend to disallow `login_type = none`
jakehwll 519607e
fix: rename migration to `legacy_none_login_to_password.*`
jakehwll 0006c54
🤖 fix(coderd/database)!: renumber legacy none->password migration to …
jakehwll 0957f63
Merge remote-tracking branch 'origin/main' into jakehwll/DEVEX-226-al…
jakehwll 3ceb721
🤖 test(coderd): converted login_type=none users cannot authenticate w…
jakehwll 5bc9794
🤖 test(coderd/database/migrations): verify 000548 converts only legac…
jakehwll f66df67
🤖 docs(cli/usercreate): point deprecated --disable-login help at --se…
jakehwll 0e0b4f7
🤖 fix(cli/usercreate): address review, group non-service-account chec…
jakehwll 1170581
Merge remote-tracking branch 'origin/main' into jakehwll/DEVEX-226-al…
jakehwll ef67964
🤖 fix(coderd/database)!: renumber legacy none->password migration to …
jakehwll f9fd828
Merge branch 'main' into jakehwll/DEVEX-226-all-services-accounts-all…
zenithwolf1000 854ce1c
🤖 chore(coderd/database/migrations): renumber legacy none login migra…
jakehwll File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2 changes: 2 additions & 0 deletions
2
coderd/database/migrations/000554_legacy_none_login_to_password.down.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
9 changes: 9 additions & 0 deletions
9
coderd/database/migrations/000554_legacy_none_login_to_password.up.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", "[email protected]", []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", "[email protected]", []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", "[email protected]", []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, "[email protected]", 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() | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -488,6 +488,13 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { | |
| req.UserLoginType = codersdk.LoginTypePassword | ||
| } | ||
|
|
||
| if !req.ServiceAccount && req.UserLoginType == codersdk.LoginTypeNone { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nbd at all but this could be an |
||
| httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ | ||
| Message: "Login type 'none' requires a service account.", | ||
| }) | ||
| return | ||
| } | ||
|
|
||
| if req.UserLoginType != codersdk.LoginTypePassword && req.Password != "" { | ||
| httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ | ||
| Message: fmt.Sprintf("Password cannot be set for non-password (%q) authentication.", req.UserLoginType), | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -287,6 +287,62 @@ func TestPostLogin(t *testing.T) { | |
| require.NotContains(t, apiErr.Message, string(codersdk.LoginTypeOIDC)) | ||
| }) | ||
|
|
||
| // Regression: the legacy `login_type = 'none'` migration converts these | ||
| // accounts to password auth, but they have no password hash. Converting | ||
| // login type must never let someone authenticate with an empty or guessed | ||
| // password. | ||
| t.Run("ConvertedNoneUserHasNoUsablePassword", func(t *testing.T) { | ||
| t.Parallel() | ||
| client, db := coderdtest.NewWithDatabase(t, nil) | ||
| ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) | ||
| defer cancel() | ||
|
|
||
| // A legacy machine user was created with login_type 'none' and no | ||
| // password. dbgen.User substitutes a random hash for an empty one, so | ||
| // clear it explicitly to match the real account. | ||
| noneUser := dbgen.User(t, db, database.User{ | ||
| Email: "[email protected]", | ||
| LoginType: database.LoginTypeNone, | ||
| }) | ||
| //nolint:gocritic // Test setup requires a system context to clear the hash. | ||
| err := db.UpdateUserHashedPassword(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedPasswordParams{ | ||
| ID: noneUser.ID, | ||
| HashedPassword: []byte{}, | ||
| }) | ||
| require.NoError(t, err) | ||
|
|
||
| // Apply the migration's conversion: login_type 'none' -> 'password'. | ||
| //nolint:gocritic // Test setup requires a system context to convert the login type. | ||
| _, err = db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ | ||
| NewLoginType: database.LoginTypePassword, | ||
| UserID: noneUser.ID, | ||
| }) | ||
| require.NoError(t, err) | ||
|
|
||
| // Neither an empty password nor a guessed one may authenticate. An empty | ||
| // password is rejected by request validation (400); a non-empty guess | ||
| // fails the hash comparison against the empty stored hash (401). Both must | ||
| // deny access. | ||
| cases := []struct { | ||
| name string | ||
| password string | ||
| wantStatus int | ||
| }{ | ||
| {"EmptyPassword", "", http.StatusBadRequest}, | ||
| {"GuessedPassword", "hunter2", http.StatusUnauthorized}, | ||
| } | ||
| for _, tc := range cases { | ||
| anonClient := codersdk.New(client.URL) | ||
| _, err := anonClient.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ | ||
| Email: noneUser.Email, | ||
| Password: tc.password, | ||
| }) | ||
| var apiErr *codersdk.Error | ||
| require.ErrorAs(t, err, &apiErr, "%s must not authenticate", tc.name) | ||
| require.Equal(t, tc.wantStatus, apiErr.StatusCode(), "%s", tc.name) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("Suspended", func(t *testing.T) { | ||
| t.Parallel() | ||
| auditor := audit.NewMock() | ||
|
|
@@ -952,18 +1008,17 @@ func TestPostUsers(t *testing.T) { | |
| ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) | ||
| defer cancel() | ||
|
|
||
| user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ | ||
| _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ | ||
| OrganizationIDs: []uuid.UUID{first.OrganizationID}, | ||
| Email: "[email protected]", | ||
| Username: "someone-else", | ||
| Password: "", | ||
| UserLoginType: codersdk.LoginTypeNone, | ||
| }) | ||
| require.NoError(t, err) | ||
|
|
||
| found, err := client.User(ctx, user.ID.String()) | ||
| require.NoError(t, err) | ||
| require.Equal(t, found.LoginType, codersdk.LoginTypeNone) | ||
| var apiErr *codersdk.Error | ||
| require.ErrorAs(t, err, &apiErr) | ||
| require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) | ||
| require.Contains(t, apiErr.Message, "service account") | ||
| }) | ||
|
|
||
| t.Run("CreateOIDCLoginType", func(t *testing.T) { | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see that we convert to password to avoid losing the email but why is that important? It seems to me like converting these to system accounts would be a seamless transition; do admins actually care if these types of users have emails?
At least, for premium users it would be seamless. For non-premium users I am not sure what happens if you have service accounts. Is the problem that we only prevent creating the accounts so a non-premium user would be grandfathered into keeping these service accounts? To me that seems reasonable though, and definitely less bad than a breaking change forcing premium users to recreate their accounts.
(I read through this description and the other PR; apologies if I missed some discussion.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are not internal coder system accounts, I'm not sure we should blur the line between
is_systemhere.This was a product decision, we did discuss handing out Service Accounts being grandfathered in but if license enforcement changes in the future we could possibly breaking OSS instances.
There will be product discussion sent out to customers about the migration path here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ooops sorry I meant service accounts, not system accounts 🤦
Gotcha if this was the product decision then so be it 😄
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fwiw though since we are already breaking both oss and premium instances with this change, seems less worse to break only oss instances later down the line instead 🤷
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@code-asher yeah that was essentially the choice. Having service accounts in OSS is not great because we are introducing an account into OSS that they can't actually get more of (service accounts are premium).