Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions cli/usercreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,15 @@ func (r *RootCmd) userCreate() *serpent.Command {
case disableLogin:
return xerrors.New("You cannot use --disable-login with --service-account")
}
}

if disableLogin && loginType != "" {
return xerrors.New("You cannot specify both --disable-login and --login-type")
} else {
switch {
case disableLogin && loginType != "":
return xerrors.New("You cannot specify both --disable-login and --login-type")
case disableLogin:
return xerrors.New("--disable-login is deprecated. Use --service-account for machine-to-machine access.")
case loginType == string(codersdk.LoginTypeNone):
return xerrors.New("Login type 'none' is deprecated. Use --service-account for machine-to-machine access.")
}
}

client, err := r.InitClient(inv)
Expand Down Expand Up @@ -200,7 +205,7 @@ Create a workspace `+pretty.Sprint(cliui.DefaultStyles.Code, "coder create")+`!
{
Flag: "disable-login",
Hidden: true,
Description: "Deprecated: Use '--login-type=none'. \nDisabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " +
Description: "Deprecated: Use --service-account (requires Premium) for machine-to-machine access. \nDisabling login for a user prevents the user from authenticating via password or IdP login. Authentication requires an API key/token generated by an admin. " +
"Be careful when using this flag as it can lock the user out of their account.",
Value: serpent.BoolOf(&disableLogin),
},
Expand Down
15 changes: 15 additions & 0 deletions cli/usercreate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,21 @@ func TestUserCreate(t *testing.T) {
args: []string{"--service-account", "-u", "dean", "--password", "1n5ecureP4ssw0rd!"},
err: "You cannot use --password with --service-account",
},
{
name: "DisableLogin",
args: []string{"--disable-login", "-u", "dean"},
err: "--disable-login is deprecated. Use --service-account for machine-to-machine access.",
},
{
name: "LoginTypeNone",
args: []string{"--login-type", "none", "-u", "dean"},
err: "Login type 'none' is deprecated. Use --service-account for machine-to-machine access.",
},
{
name: "DisableLoginWithLoginType",
args: []string{"--disable-login", "--login-type", "password", "-u", "dean"},
err: "You cannot specify both --disable-login and --login-type",
},
}

for _, tt := range tests {
Expand Down
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.
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

@code-asher code-asher Jul 20, 2026

Copy link
Copy Markdown
Member

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.)

Copy link
Copy Markdown
Contributor Author

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?

These are not internal coder system accounts, I'm not sure we should blur the line between is_system here.

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?

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.

Copy link
Copy Markdown
Member

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 😄

@code-asher code-asher Jul 21, 2026

Copy link
Copy Markdown
Member

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 🤷

Copy link
Copy Markdown
Contributor

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).

-- 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;
83 changes: 83 additions & 0 deletions coderd/database/migrations/migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
16 changes: 11 additions & 5 deletions coderd/userauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,19 @@ func TestUserLogin(t *testing.T) {

t.Run("LoginTypeNone", func(t *testing.T) {
t.Parallel()
anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
r.Password = ""
r.UserLoginType = codersdk.LoginTypeNone
client, db := coderdtest.NewWithDatabase(t, nil)
first := coderdtest.CreateFirstUser(t, client)

noneUser := dbgen.User(t, db, database.User{
LoginType: database.LoginTypeNone,
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: first.OrganizationID,
UserID: noneUser.ID,
})

_, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: anotherUser.Email,
_, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: noneUser.Email,
Password: "SomeSecurePassword!",
})
require.Error(t, err)
Expand Down
7 changes: 7 additions & 0 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nbd at all but this could be an else and skip the duplicate req.ServiceAccount check.

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),
Expand Down
67 changes: 61 additions & 6 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
Loading