From e5c19d0af44f51dfbc064ae5e3ea53287a892a79 Mon Sep 17 00:00:00 2001 From: George K Date: Wed, 11 Mar 2026 10:19:08 -0700 Subject: [PATCH] feat: backend support for creating and storing service accounts (#22698) Add is_service_account column to users table with CHECK constraints enforcing login_type='none' and empty email for service accounts. Update user creation API to validate service account constraints. Related to: https://linear.app/codercom/issue/PLAT-27/feat-backend-support-for-creating-and-storing-service-accounts --- cli/server_createadminuser.go | 21 +-- coderd/apidoc/docs.go | 5 +- coderd/apidoc/swagger.json | 6 +- coderd/database/check_constraint.go | 2 + coderd/database/dbgen/dbgen.go | 30 ++-- coderd/database/dbgen/dbgen_test.go | 14 ++ coderd/database/dump.sql | 9 +- .../migrations/000299_user_configs.down.sql | 10 +- ...3_add_is_service_account_to_users.down.sql | 18 +++ ...433_add_is_service_account_to_users.up.sql | 23 +++ ...432_pre_service_account_constraints.up.sql | 27 ++++ .../fixtures/000433_service_accounts.up.sql | 41 +++++ coderd/database/modelmethods.go | 29 ++-- coderd/database/modelqueries.go | 1 + coderd/database/models.go | 2 + coderd/database/querier_test.go | 78 ++++++++++ coderd/database/queries.sql.go | 68 ++++---- coderd/database/queries/users.sql | 8 +- coderd/database/unique_constraint.go | 4 +- coderd/users.go | 49 ++++-- coderd/users_test.go | 146 ++++++++++++++++++ codersdk/users.go | 4 +- docs/admin/security/audit-logs.md | 2 +- docs/reference/api/schemas.md | 4 +- docs/reference/api/users.md | 1 + enterprise/audit/table.go | 1 + enterprise/coderd/workspaces_test.go | 2 + site/src/api/typesGenerated.ts | 4 + 28 files changed, 522 insertions(+), 87 deletions(-) create mode 100644 coderd/database/migrations/000433_add_is_service_account_to_users.down.sql create mode 100644 coderd/database/migrations/000433_add_is_service_account_to_users.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000432_pre_service_account_constraints.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000433_service_accounts.up.sql diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index c8daeb2ab5..c9a0b11b90 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -188,16 +188,17 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { _, _ = fmt.Fprintln(inv.Stderr, "Creating user...") newUser, err = tx.InsertUser(ctx, database.InsertUserParams{ - ID: uuid.New(), - Email: newUserEmail, - Username: newUserUsername, - Name: "Admin User", - HashedPassword: []byte(hashedPassword), - CreatedAt: dbtime.Now(), - UpdatedAt: dbtime.Now(), - RBACRoles: []string{rbac.RoleOwner().String()}, - LoginType: database.LoginTypePassword, - Status: "", + ID: uuid.New(), + Email: newUserEmail, + Username: newUserUsername, + Name: "Admin User", + HashedPassword: []byte(hashedPassword), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + RBACRoles: []string{rbac.RoleOwner().String()}, + LoginType: database.LoginTypePassword, + Status: "", + IsServiceAccount: false, }) if err != nil { return xerrors.Errorf("insert user: %w", err) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index de14a7dc30..4f9a7c96bc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -14312,7 +14312,6 @@ const docTemplate = `{ "codersdk.CreateUserRequestWithOrgs": { "type": "object", "required": [ - "email", "username" ], "properties": { @@ -14342,6 +14341,10 @@ const docTemplate = `{ "password": { "type": "string" }, + "service_account": { + "description": "Service accounts are admin-managed accounts that cannot login.", + "type": "boolean" + }, "user_status": { "description": "UserStatus defaults to UserStatusDormant.", "allOf": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5d49746cae..9280851f02 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -12856,7 +12856,7 @@ }, "codersdk.CreateUserRequestWithOrgs": { "type": "object", - "required": ["email", "username"], + "required": ["username"], "properties": { "email": { "type": "string", @@ -12884,6 +12884,10 @@ "password": { "type": "string" }, + "service_account": { + "description": "Service accounts are admin-managed accounts that cannot login.", + "type": "boolean" + }, "user_status": { "description": "UserStatus defaults to UserStatusDormant.", "allOf": [ diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 80dfd2bce4..9b738411ef 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -12,6 +12,8 @@ const ( CheckChatProvidersProviderCheck CheckConstraint = "chat_providers_provider_check" // chat_providers CheckOrganizationIDNotZero CheckConstraint = "organization_id_not_zero" // custom_roles CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users + CheckUsersEmailNotEmpty CheckConstraint = "users_email_not_empty" // users + CheckUsersServiceAccountLoginType CheckConstraint = "users_service_account_login_type" // users CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs CheckMaxLogsLength CheckConstraint = "max_logs_length" // workspace_agents diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 0502ed7105..69d84fafdf 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -578,17 +578,27 @@ func WorkspaceBuildParameters(t testing.TB, db database.Store, orig []database.W } func User(t testing.TB, db database.Store, orig database.User) database.User { + loginType := takeFirst(orig.LoginType, database.LoginTypePassword) + email := takeFirst(orig.Email, testutil.GetRandomName(t)) + // A DB constraint requires login_type = 'none' and email = '' for service + // accounts. + if orig.IsServiceAccount { + loginType = database.LoginTypeNone + email = "" + } + user, err := db.InsertUser(genCtx, database.InsertUserParams{ - ID: takeFirst(orig.ID, uuid.New()), - Email: takeFirst(orig.Email, testutil.GetRandomName(t)), - Username: takeFirst(orig.Username, testutil.GetRandomName(t)), - Name: takeFirst(orig.Name, testutil.GetRandomName(t)), - HashedPassword: takeFirstSlice(orig.HashedPassword, []byte(must(cryptorand.String(32)))), - CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), - UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()), - RBACRoles: takeFirstSlice(orig.RBACRoles, []string{}), - LoginType: takeFirst(orig.LoginType, database.LoginTypePassword), - Status: string(takeFirst(orig.Status, database.UserStatusDormant)), + ID: takeFirst(orig.ID, uuid.New()), + Email: email, + Username: takeFirst(orig.Username, testutil.GetRandomName(t)), + Name: takeFirst(orig.Name, testutil.GetRandomName(t)), + HashedPassword: takeFirstSlice(orig.HashedPassword, []byte(must(cryptorand.String(32)))), + CreatedAt: takeFirst(orig.CreatedAt, dbtime.Now()), + UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()), + RBACRoles: takeFirstSlice(orig.RBACRoles, []string{}), + LoginType: loginType, + Status: string(takeFirst(orig.Status, database.UserStatusDormant)), + IsServiceAccount: orig.IsServiceAccount, }) require.NoError(t, err, "insert user") diff --git a/coderd/database/dbgen/dbgen_test.go b/coderd/database/dbgen/dbgen_test.go index 872704fa1d..bd2e4ae36c 100644 --- a/coderd/database/dbgen/dbgen_test.go +++ b/coderd/database/dbgen/dbgen_test.go @@ -213,6 +213,20 @@ func TestGenerator(t *testing.T) { require.Equal(t, exp, must(db.GetUserByID(context.Background(), exp.ID))) }) + t.Run("ServiceAccountUser", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + user := dbgen.User(t, db, database.User{ + IsServiceAccount: true, + Email: "should-be-overridden@coder.com", + LoginType: database.LoginTypePassword, + }) + require.True(t, user.IsServiceAccount) + require.Empty(t, user.Email) + require.Equal(t, database.LoginTypeNone, user.LoginType) + require.Equal(t, user, must(db.GetUserByID(context.Background(), user.ID))) + }) + t.Run("SSHKey", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f01d33985c..a8c2b23549 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1471,7 +1471,10 @@ CREATE TABLE users ( hashed_one_time_passcode bytea, one_time_passcode_expires_at timestamp with time zone, is_system boolean DEFAULT false NOT NULL, + is_service_account boolean DEFAULT false NOT NULL, CONSTRAINT one_time_passcode_set CHECK ((((hashed_one_time_passcode IS NULL) AND (one_time_passcode_expires_at IS NULL)) OR ((hashed_one_time_passcode IS NOT NULL) AND (one_time_passcode_expires_at IS NOT NULL)))), + CONSTRAINT users_email_not_empty CHECK (((is_service_account = true) = (email = ''::text))), + CONSTRAINT users_service_account_login_type CHECK (((is_service_account = false) OR (login_type = 'none'::login_type))), CONSTRAINT users_username_min_length CHECK ((length(username) >= 1)) ); @@ -1487,6 +1490,8 @@ COMMENT ON COLUMN users.one_time_passcode_expires_at IS 'The time when the one-t COMMENT ON COLUMN users.is_system IS 'Determines if a user is a system user, and therefore cannot login or perform normal actions'; +COMMENT ON COLUMN users.is_service_account IS 'Determines if a user is an admin-managed account that cannot login'; + CREATE VIEW group_members_expanded AS WITH all_members AS ( SELECT group_members.user_id, @@ -3601,7 +3606,7 @@ CREATE INDEX idx_user_deleted_deleted_at ON user_deleted USING btree (deleted_at CREATE INDEX idx_user_status_changes_changed_at ON user_status_changes USING btree (changed_at); -CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); +CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text)); CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); @@ -3651,7 +3656,7 @@ CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); -CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false); +CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text)); CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); diff --git a/coderd/database/migrations/000299_user_configs.down.sql b/coderd/database/migrations/000299_user_configs.down.sql index c3ca42798e..a08a9477bc 100644 --- a/coderd/database/migrations/000299_user_configs.down.sql +++ b/coderd/database/migrations/000299_user_configs.down.sql @@ -4,10 +4,12 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS -- Copy "theme_preference" back to "users" UPDATE users - SET theme_preference = (SELECT value - FROM user_configs - WHERE user_configs.user_id = users.id - AND user_configs.key = 'theme_preference'); + -- Use COALESCE(SELECT, ) to avoid forcing an insert of user_configs + -- for every users insert in order for this down migration to succeed. + SET theme_preference = COALESCE( + (SELECT value FROM user_configs WHERE user_configs.user_id = users.id AND user_configs.key = 'theme_preference'), + '' + ); -- Drop the "user_configs" table. DROP TABLE user_configs; diff --git a/coderd/database/migrations/000433_add_is_service_account_to_users.down.sql b/coderd/database/migrations/000433_add_is_service_account_to_users.down.sql new file mode 100644 index 0000000000..18145e2cd3 --- /dev/null +++ b/coderd/database/migrations/000433_add_is_service_account_to_users.down.sql @@ -0,0 +1,18 @@ +-- Since we can't simply delete a user that potentially has all kinds of tables +-- referencing it, give service accounts with empty emails a unique placeholder +-- so the original unique indexes can be restored. We only run down migrations +-- in dev, so hopefully this is not a big deal. +UPDATE users SET + email = 'ex-service-account-' || id::text || '@localhost', + is_service_account = false +WHERE is_service_account = true AND email = ''; + +-- Restore original unique indexes. +DROP INDEX IF EXISTS idx_users_email; +DROP INDEX IF EXISTS users_email_lower_idx; +CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); +CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false); + +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_not_empty; +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_service_account_login_type; +ALTER TABLE users DROP COLUMN is_service_account; diff --git a/coderd/database/migrations/000433_add_is_service_account_to_users.up.sql b/coderd/database/migrations/000433_add_is_service_account_to_users.up.sql new file mode 100644 index 0000000000..ea30bdcf69 --- /dev/null +++ b/coderd/database/migrations/000433_add_is_service_account_to_users.up.sql @@ -0,0 +1,23 @@ +ALTER TABLE users ADD COLUMN is_service_account boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN users.is_service_account IS 'Determines if a user is an admin-managed account that cannot login'; + +-- Service accounts must use login_type 'none'. +ALTER TABLE users ADD CONSTRAINT users_service_account_login_type CHECK (is_service_account = false OR login_type = 'none'); + +-- Paranoia check: mark any (unlikely) existing user with an empty email as a +-- service account so that adding the constraint below does not fail. +-- NOTE: considered setting email to nobody@localhost instead but for all we +-- know it may already exist, so chose the lesser of two evils. +UPDATE users SET is_service_account = true, login_type = 'none' WHERE email = ''; + +-- Service accounts must have empty email; other users must not. +ALTER TABLE users ADD CONSTRAINT users_email_not_empty CHECK ((is_service_account = true) = (email = '')); + +-- Exclude empty emails from uniqueness so multiple service accounts can omit an +-- email without conflicting. This is the less invasive alternative to making +-- email nullable, which would require a big refactor. +DROP INDEX idx_users_email; +DROP INDEX users_email_lower_idx; +CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false AND email != ''); +CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false AND email != ''); diff --git a/coderd/database/migrations/testdata/fixtures/000432_pre_service_account_constraints.up.sql b/coderd/database/migrations/testdata/fixtures/000432_pre_service_account_constraints.up.sql new file mode 100644 index 0000000000..f7d57bdab1 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000432_pre_service_account_constraints.up.sql @@ -0,0 +1,27 @@ +-- Fixture for migration 000433_add_is_service_account_to_users. +-- Inserts a user with an empty email to ensure the migration +-- correctly marks them as a service account before adding the +-- users_email_not_empty constraint. + +INSERT INTO users ( + id, + email, + username, + hashed_password, + created_at, + updated_at, + status, + rbac_roles, + login_type +) +VALUES ( + '8ddb584a-68b8-48ac-998f-86f091ccb380', + '', + 'fixture-empty-email-user-to-service-account', + '', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00', + 'active', + '{}', + 'password' +); diff --git a/coderd/database/migrations/testdata/fixtures/000433_service_accounts.up.sql b/coderd/database/migrations/testdata/fixtures/000433_service_accounts.up.sql new file mode 100644 index 0000000000..96bde505d2 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000433_service_accounts.up.sql @@ -0,0 +1,41 @@ +-- Fixture for migration 000433_add_is_service_account_to_users. +-- Inserts multiple service accounts with empty emails to help test +-- the down migration, which must assign each a unique placeholder +-- email before restoring the original unique index on email. + +INSERT INTO users ( + id, + email, + username, + hashed_password, + created_at, + updated_at, + status, + rbac_roles, + login_type, + is_service_account +) +VALUES ( + 'b2ce097d-2287-4d64-a550-ed821969545d', + '', + 'fixture-service-account-1', + '', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00', + 'active', + '{}', + 'none', + true +), +( + '3e218a4a-3b4a-4242-b24e-9430277e619d', + '', + 'fixture-service-account-2', + '', + '2024-01-01 00:00:00+00', + '2024-01-01 00:00:00+00', + 'active', + '{}', + 'none', + true +); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index a978840726..e114c1085d 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -663,20 +663,21 @@ func ConvertUserRows(rows []GetUsersRow) []User { users := make([]User, len(rows)) for i, r := range rows { users[i] = User{ - ID: r.ID, - Email: r.Email, - Username: r.Username, - Name: r.Name, - HashedPassword: r.HashedPassword, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - Status: r.Status, - RBACRoles: r.RBACRoles, - LoginType: r.LoginType, - AvatarURL: r.AvatarURL, - Deleted: r.Deleted, - LastSeenAt: r.LastSeenAt, - IsSystem: r.IsSystem, + ID: r.ID, + Email: r.Email, + Username: r.Username, + Name: r.Name, + HashedPassword: r.HashedPassword, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + Status: r.Status, + RBACRoles: r.RBACRoles, + LoginType: r.LoginType, + AvatarURL: r.AvatarURL, + Deleted: r.Deleted, + LastSeenAt: r.LastSeenAt, + IsSystem: r.IsSystem, + IsServiceAccount: r.IsServiceAccount, } } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 8c6c23eac6..5f09cbc7cf 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -480,6 +480,7 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/models.go b/coderd/database/models.go index fd83bf8cbf..d59a5eda1f 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -4877,6 +4877,8 @@ type User struct { OneTimePasscodeExpiresAt sql.NullTime `db:"one_time_passcode_expires_at" json:"one_time_passcode_expires_at"` // Determines if a user is a system user, and therefore cannot login or perform normal actions IsSystem bool `db:"is_system" json:"is_system"` + // Determines if a user is an admin-managed account that cannot login + IsServiceAccount bool `db:"is_service_account" json:"is_service_account"` } type UserConfig struct { diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 07fbc2a4d1..1e5accda37 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -1854,6 +1854,84 @@ func TestUpdateSystemUser(t *testing.T) { require.NoError(t, err) } +func TestInsertUserServiceAccountConstraints(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + + // Happy path: should succeed. + t.Run("ServiceAccountWithEmptyEmailAndLoginNone", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + user, err := db.InsertUser(ctx, database.InsertUserParams{ + Email: "", + LoginType: database.LoginTypeNone, + ID: uuid.New(), + Username: "sa-ok", + RBACRoles: []string{}, + IsServiceAccount: true, + }) + require.NoError(t, err) + require.True(t, user.IsServiceAccount) + require.Empty(t, user.Email) + }) + + // Service account with a non-empty email should be rejected + // by the users_email_not_empty constraint. + t.Run("ServiceAccountWithNonEmptyEmail", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := db.InsertUser(ctx, database.InsertUserParams{ + Email: "sa@coder.com", + LoginType: database.LoginTypeNone, + ID: uuid.New(), + Username: "sa-with-email", + RBACRoles: []string{}, + IsServiceAccount: true, + }) + require.Error(t, err) + require.True(t, database.IsCheckViolation(err, database.CheckUsersEmailNotEmpty)) + }) + + // A non-service-account with empty email should be rejected + // by the users_email_not_empty constraint. + t.Run("RegularUserWithEmptyEmail", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := db.InsertUser(ctx, database.InsertUserParams{ + Email: "", + LoginType: database.LoginTypePassword, + ID: uuid.New(), + Username: "regular-no-email", + RBACRoles: []string{}, + IsServiceAccount: false, + }) + require.Error(t, err) + require.True(t, database.IsCheckViolation(err, database.CheckUsersEmailNotEmpty)) + }) + + // Service account with login_type!=none should be rejected + // by the users_service_account_login_type constraint. + t.Run("ServiceAccountWithPasswordLoginType", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := db.InsertUser(ctx, database.InsertUserParams{ + Email: "", + LoginType: database.LoginTypePassword, + ID: uuid.New(), + Username: "sa-with-password", + RBACRoles: []string{}, + IsServiceAccount: true, + }) + require.Error(t, err) + require.True(t, database.IsCheckViolation(err, database.CheckUsersServiceAccountLoginType)) + }) +} + func TestUserChangeLoginType(t *testing.T) { t.Parallel() if testing.Short() { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f104cb4b50..a9f6269dc3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -18822,19 +18822,19 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid. const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account FROM users WHERE - (LOWER(username) = LOWER($1) OR LOWER(email) = LOWER($2)) AND + (LOWER(username) = LOWER($1) OR ($2 != '' AND LOWER(email) = LOWER($2))) AND deleted = false LIMIT 1 ` type GetUserByEmailOrUsernameParams struct { - Username string `db:"username" json:"username"` - Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + Email interface{} `db:"email" json:"email"` } func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) { @@ -18859,13 +18859,14 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ) return i, err } const getUserByID = `-- name: GetUserByID :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account FROM users WHERE @@ -18896,6 +18897,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ) return i, err } @@ -18987,7 +18989,7 @@ func (q *sqlQuerier) GetUserThemePreference(ctx context.Context, userID uuid.UUI const getUsers = `-- name: GetUsers :many SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, COUNT(*) OVER() AS count + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account, COUNT(*) OVER() AS count FROM users WHERE @@ -19128,6 +19130,7 @@ type GetUsersRow struct { HashedOneTimePasscode []byte `db:"hashed_one_time_passcode" json:"hashed_one_time_passcode"` OneTimePasscodeExpiresAt sql.NullTime `db:"one_time_passcode_expires_at" json:"one_time_passcode_expires_at"` IsSystem bool `db:"is_system" json:"is_system"` + IsServiceAccount bool `db:"is_service_account" json:"is_service_account"` Count int64 `db:"count" json:"count"` } @@ -19175,6 +19178,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, &i.Count, ); err != nil { return nil, err @@ -19191,7 +19195,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse } const getUsersByIDs = `-- name: GetUsersByIDs :many -SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system FROM users WHERE id = ANY($1 :: uuid [ ]) +SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account FROM users WHERE id = ANY($1 :: uuid [ ]) ` // This shouldn't check for deleted, because it's frequently used @@ -19225,6 +19229,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ); err != nil { return nil, err } @@ -19251,27 +19256,30 @@ INSERT INTO updated_at, rbac_roles, login_type, - status + status, + is_service_account ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, -- if the status passed in is empty, fallback to dormant, which is what -- we were doing before. - COALESCE(NULLIF($10::text, '')::user_status, 'dormant'::user_status) - ) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system + COALESCE(NULLIF($10::text, '')::user_status, 'dormant'::user_status), + $11::bool + ) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account ` type InsertUserParams struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - Name string `db:"name" json:"name"` - HashedPassword []byte `db:"hashed_password" json:"hashed_password"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` - LoginType LoginType `db:"login_type" json:"login_type"` - Status string `db:"status" json:"status"` + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + Name string `db:"name" json:"name"` + HashedPassword []byte `db:"hashed_password" json:"hashed_password"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` + LoginType LoginType `db:"login_type" json:"login_type"` + Status string `db:"status" json:"status"` + IsServiceAccount bool `db:"is_service_account" json:"is_service_account"` } func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) { @@ -19286,6 +19294,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User arg.RBACRoles, arg.LoginType, arg.Status, + arg.IsServiceAccount, ) var i User err := row.Scan( @@ -19307,6 +19316,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ) return i, err } @@ -19473,7 +19483,7 @@ SET last_seen_at = $2, updated_at = $3 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account ` type UpdateUserLastSeenAtParams struct { @@ -19504,6 +19514,7 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ) return i, err } @@ -19523,7 +19534,7 @@ SET WHERE id = $2 AND NOT is_system -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account ` type UpdateUserLoginTypeParams struct { @@ -19553,6 +19564,7 @@ func (q *sqlQuerier) UpdateUserLoginType(ctx context.Context, arg UpdateUserLogi &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ) return i, err } @@ -19568,7 +19580,7 @@ SET name = $6 WHERE id = $1 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account ` type UpdateUserProfileParams struct { @@ -19609,6 +19621,7 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ) return i, err } @@ -19620,7 +19633,7 @@ SET quiet_hours_schedule = $2 WHERE id = $1 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account ` type UpdateUserQuietHoursScheduleParams struct { @@ -19650,6 +19663,7 @@ func (q *sqlQuerier) UpdateUserQuietHoursSchedule(ctx context.Context, arg Updat &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ) return i, err } @@ -19662,7 +19676,7 @@ SET rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[])) WHERE id = $2 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account ` type UpdateUserRolesParams struct { @@ -19692,6 +19706,7 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ) return i, err } @@ -19705,7 +19720,7 @@ SET -- If the user is logging in, set last_seen_at to updated_at. last_seen_at = CASE WHEN $4 :: boolean THEN $3 :: timestamptz ELSE last_seen_at END WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account ` type UpdateUserStatusParams struct { @@ -19742,6 +19757,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP &i.HashedOneTimePasscode, &i.OneTimePasscodeExpiresAt, &i.IsSystem, + &i.IsServiceAccount, ) return i, err } diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 26fc53084b..b392385ab2 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -57,7 +57,7 @@ SELECT FROM users WHERE - (LOWER(username) = LOWER(@username) OR LOWER(email) = LOWER(@email)) AND + (LOWER(username) = LOWER(@username) OR (@email != '' AND LOWER(email) = LOWER(@email))) AND deleted = false LIMIT 1; @@ -92,13 +92,15 @@ INSERT INTO updated_at, rbac_roles, login_type, - status + status, + is_service_account ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, -- if the status passed in is empty, fallback to dormant, which is what -- we were doing before. - COALESCE(NULLIF(@status::text, '')::user_status, 'dormant'::user_status) + COALESCE(NULLIF(@status::text, '')::user_status, 'dormant'::user_status), + @is_service_account::bool ) RETURNING *; -- name: UpdateUserProfile :one diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 0ecf890017..380d2526b3 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -125,7 +125,7 @@ const ( UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true); UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id); - UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false); + UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text)); UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash); UniqueOrganizationsSingleDefaultOrg UniqueConstraint = "organizations_single_default_org" // CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true); @@ -137,7 +137,7 @@ const ( UniqueUserSecretsUserEnvNameIndex UniqueConstraint = "user_secrets_user_env_name_idx" // CREATE UNIQUE INDEX user_secrets_user_env_name_idx ON user_secrets USING btree (user_id, env_name) WHERE (env_name <> ''::text); UniqueUserSecretsUserFilePathIndex UniqueConstraint = "user_secrets_user_file_path_idx" // CREATE UNIQUE INDEX user_secrets_user_file_path_idx ON user_secrets USING btree (user_id, file_path) WHERE (file_path <> ''::text); UniqueUserSecretsUserNameIndex UniqueConstraint = "user_secrets_user_name_idx" // CREATE UNIQUE INDEX user_secrets_user_name_idx ON user_secrets USING btree (user_id, name); - UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE (deleted = false); + UniqueUsersEmailLowerIndex UniqueConstraint = "users_email_lower_idx" // CREATE UNIQUE INDEX users_email_lower_idx ON users USING btree (lower(email)) WHERE ((deleted = false) AND (email <> ''::text)); UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false); UniqueWorkspaceAppAuditSessionsUniqueIndex UniqueConstraint = "workspace_app_audit_sessions_unique_index" // CREATE UNIQUE INDEX workspace_app_audit_sessions_unique_index ON workspace_app_audit_sessions USING btree (agent_id, app_id, user_id, ip, user_agent, slug_or_port, status_code); UniqueWorkspaceProxiesLowerNameIndex UniqueConstraint = "workspace_proxies_lower_name_idx" // CREATE UNIQUE INDEX workspace_proxies_lower_name_idx ON workspace_proxies USING btree (lower(name)) WHERE (deleted = false); diff --git a/coderd/users.go b/coderd/users.go index 1655dfd3a1..79b343525c 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -356,7 +356,33 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } - if req.UserLoginType == "" { + // Service accounts must use login_type 'none' and have no password + // or email. + if req.ServiceAccount { + // The client can omit login type for a service account and it will be + // set for them below. But if they request the wrong one, we have to let + // them know. + if req.UserLoginType != "" && req.UserLoginType != codersdk.LoginTypeNone { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Service accounts must use login type 'none'.", + }) + return + } + if req.Password != "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Password cannot be set for service accounts.", + }) + return + } + if req.Email != "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Email cannot be set for service accounts.", + }) + return + } + + req.UserLoginType = codersdk.LoginTypeNone + } else if req.UserLoginType == "" { // Default to password auth req.UserLoginType = codersdk.LoginTypePassword } @@ -1510,16 +1536,17 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create status = string(*req.UserStatus) } params := database.InsertUserParams{ - ID: uuid.New(), - Email: req.Email, - Username: req.Username, - Name: codersdk.NormalizeRealUsername(req.Name), - CreatedAt: dbtime.Now(), - UpdatedAt: dbtime.Now(), - HashedPassword: []byte{}, - RBACRoles: rbacRoles, - LoginType: req.LoginType, - Status: status, + ID: uuid.New(), + Email: req.Email, + Username: req.Username, + Name: codersdk.NormalizeRealUsername(req.Name), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + HashedPassword: []byte{}, + RBACRoles: rbacRoles, + LoginType: req.LoginType, + Status: status, + IsServiceAccount: req.ServiceAccount, } // If a user signs up with OAuth, they can have no password! if req.Password != "" { diff --git a/coderd/users_test.go b/coderd/users_test.go index 5355474d8e..80d9de2d73 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -881,6 +881,152 @@ func TestPostUsers(t *testing.T) { require.NoError(t, err) require.Equal(t, found.LoginType, codersdk.LoginTypeOIDC) }) + + t.Run("ServiceAccount/OK", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Username: "service-acct-ok", + UserLoginType: codersdk.LoginTypeNone, + ServiceAccount: true, + }) + require.NoError(t, err) + require.Equal(t, codersdk.LoginTypeNone, user.LoginType) + require.Empty(t, user.Email) + require.Equal(t, "service-acct-ok", user.Username) + require.Equal(t, codersdk.UserStatusDormant, user.Status) + }) + + t.Run("ServiceAccount/WithEmail", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Username: "service-acct-email", + Email: "should-not-have@email.com", + ServiceAccount: true, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Email cannot be set for service accounts") + }) + + t.Run("ServiceAccount/WithPassword", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Username: "service-acct-password", + Password: "ShouldNotHavePassword123!", + ServiceAccount: true, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Password cannot be set for service accounts") + }) + + t.Run("ServiceAccount/WithInvalidLoginType", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Username: "service-acct-login-type", + UserLoginType: codersdk.LoginTypePassword, + ServiceAccount: true, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Contains(t, apiErr.Message, "Service accounts must use login type 'none'") + }) + + t.Run("ServiceAccount/DefaultLoginType", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Username: "service-acct-default-login", + ServiceAccount: true, + }) + require.NoError(t, err) + + found, err := client.User(ctx, user.ID.String()) + require.NoError(t, err) + require.Equal(t, codersdk.LoginTypeNone, found.LoginType) + require.Empty(t, found.Email) + }) + + t.Run("NonServiceAccount/WithoutEmail", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Username: "regular-no-email", + UserLoginType: codersdk.LoginTypePassword, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("ServiceAccount/MultipleWithoutEmail", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + user1, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Username: "service-acct-multi-1", + ServiceAccount: true, + }) + require.NoError(t, err) + require.Empty(t, user1.Email) + + user2, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Username: "service-acct-multi-2", + ServiceAccount: true, + }) + require.NoError(t, err) + require.Empty(t, user2.Email) + require.NotEqual(t, user1.ID, user2.ID) + }) } func TestNotifyCreatedUser(t *testing.T) { diff --git a/codersdk/users.go b/codersdk/users.go index 4ecabc82af..6e123b0558 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -138,7 +138,7 @@ type CreateUserRequest struct { } type CreateUserRequestWithOrgs struct { - Email string `json:"email" validate:"required,email" format:"email"` + Email string `json:"email" validate:"required_unless=ServiceAccount true,omitempty,email" format:"email"` Username string `json:"username" validate:"required,username"` Name string `json:"name" validate:"user_real_name"` Password string `json:"password"` @@ -148,6 +148,8 @@ type CreateUserRequestWithOrgs struct { UserStatus *UserStatus `json:"user_status"` // OrganizationIDs is a list of organization IDs that the user should be a member of. OrganizationIDs []uuid.UUID `json:"organization_ids" validate:"" format:"uuid"` + // Service accounts are admin-managed accounts that cannot login. + ServiceAccount bool `json:"service_account,omitempty"` } // UnmarshalJSON implements the unmarshal for the legacy param "organization_id". diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 9d630765a8..5d45031086 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -35,7 +35,7 @@ We track the following resources: | TaskTable
| |
FieldTracked
created_atfalse
deleted_atfalse
display_nametrue
idtrue
nametrue
organization_idfalse
owner_idtrue
prompttrue
template_parameterstrue
template_version_idtrue
workspace_idtrue
| | Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| | TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| -| User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| | WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 6fa3bb121c..7b86dce0bd 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2166,6 +2166,7 @@ This is required on creation to enable a user-flow of validating a template work "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], "password": "string", + "service_account": true, "user_status": "active", "username": "string" } @@ -2175,11 +2176,12 @@ This is required on creation to enable a user-flow of validating a template work | Name | Type | Required | Restrictions | Description | |--------------------|--------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------| -| `email` | string | true | | | +| `email` | string | false | | | | `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. | | `name` | string | false | | | | `organization_ids` | array of string | false | | Organization ids is a list of organization IDs that the user should be a member of. | | `password` | string | false | | | +| `service_account` | boolean | false | | Service accounts are admin-managed accounts that cannot login. | | `user_status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | User status defaults to UserStatusDormant. | | `username` | string | true | | | diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 88523b96a3..3595544f63 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -90,6 +90,7 @@ curl -X POST http://coder-server:8080/api/v2/users \ "497f6eca-6276-4993-bfeb-53cbbbba6f08" ], "password": "string", + "service_account": true, "user_status": "active", "username": "string" } diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 913559ed7d..a226b5d630 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -160,6 +160,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "hashed_one_time_passcode": ActionIgnore, "one_time_passcode_expires_at": ActionTrack, "is_system": ActionTrack, // Should never change, but track it anyway. + "is_service_account": ActionTrack, // Should never change, but track it anyway. }, &database.WorkspaceTable{}: { "id": ActionTrack, diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 57855a3bd4..58ea5d78bf 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -4577,6 +4577,7 @@ func TestWorkspacesSharedWith(t *testing.T) { // Update a shared with user to have a name and avatar _, err := db.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{ ID: sharedWithUser.ID, + Email: sharedWithUser.Email, Username: sharedWithUser.Username, Name: "Shared User Name", AvatarURL: "/emojis/1fae1.png", @@ -4664,6 +4665,7 @@ func TestWorkspacesSharedWith(t *testing.T) { // Update a shared with user to have a name and avatar _, err := db.UpdateUserProfile(dbauthz.AsSystemRestricted(ctx), database.UpdateUserProfileParams{ ID: sharedWithUser.ID, + Email: sharedWithUser.Email, Username: sharedWithUser.Username, Name: "Shared User Name", AvatarURL: "/emojis/1fae1.png", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 690d329ef1..1f2fc7e8e8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2076,6 +2076,10 @@ export interface CreateUserRequestWithOrgs { * OrganizationIDs is a list of organization IDs that the user should be a member of. */ readonly organization_ids: readonly string[]; + /** + * Service accounts are admin-managed accounts that cannot login. + */ + readonly service_account?: boolean; } // From codersdk/workspaces.go