diff --git a/cli/clitest/golden.go b/cli/clitest/golden.go index db0bbeb43874e..5e154e6087e6f 100644 --- a/cli/clitest/golden.go +++ b/cli/clitest/golden.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/config" @@ -183,11 +184,11 @@ func prepareTestData(t *testing.T) (*codersdk.Client, map[string]string) { IncludeProvisionerDaemon: true, }) firstUser := coderdtest.CreateFirstUser(t, rootClient) - secondUser, err := rootClient.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "testuser2@coder.com", - Username: "testuser2", - Password: coderdtest.FirstUserParams.Password, - OrganizationID: firstUser.OrganizationID, + secondUser, err := rootClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "testuser2@coder.com", + Username: "testuser2", + Password: coderdtest.FirstUserParams.Password, + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, }) require.NoError(t, err) version := coderdtest.CreateTemplateVersion(t, rootClient, firstUser.OrganizationID, nil) diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 9ed44de9e467f..11e0171417c04 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -35,7 +35,7 @@ func setupTestSchedule(t *testing.T, sched *cron.Schedule) (ownerClient, memberC ownerClient, db = coderdtest.NewWithDatabase(t, nil) owner := coderdtest.CreateFirstUser(t, ownerClient) - memberClient, memberUser := coderdtest.CreateAnotherUserMutators(t, ownerClient, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + memberClient, memberUser := coderdtest.CreateAnotherUserMutators(t, ownerClient, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { r.Username = "testuser2" // ensure deterministic ordering }) _ = dbfake.WorkspaceBuild(t, db, database.Workspace{ diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index 19326ba728ce6..01eb56a83b7e8 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -83,12 +83,12 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { validateInputs := func(username, email, password string) error { // Use the validator tags so we match the API's validation. - req := codersdk.CreateUserRequest{ - Username: "username", - Name: "Admin User", - Email: "email@coder.com", - Password: "ValidPa$$word123!", - OrganizationID: uuid.New(), + req := codersdk.CreateUserRequestWithOrgs{ + Username: "username", + Name: "Admin User", + Email: "email@coder.com", + Password: "ValidPa$$word123!", + OrganizationIDs: []uuid.UUID{uuid.New()}, } if username != "" { req.Username = username diff --git a/cli/user_delete_test.go b/cli/user_delete_test.go index 9ee546ca7a925..e07d1e850e24d 100644 --- a/cli/user_delete_test.go +++ b/cli/user_delete_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/cli/clitest" @@ -26,13 +27,12 @@ func TestUserDelete(t *testing.T) { pw, err := cryptorand.String(16) require.NoError(t, err) - _, err = client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "colin5@coder.com", - Username: "coolin", - Password: pw, - UserLoginType: codersdk.LoginTypePassword, - OrganizationID: owner.OrganizationID, - DisableLogin: false, + _, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "colin5@coder.com", + Username: "coolin", + Password: pw, + UserLoginType: codersdk.LoginTypePassword, + OrganizationIDs: []uuid.UUID{owner.OrganizationID}, }) require.NoError(t, err) @@ -57,13 +57,12 @@ func TestUserDelete(t *testing.T) { pw, err := cryptorand.String(16) require.NoError(t, err) - user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "colin5@coder.com", - Username: "coolin", - Password: pw, - UserLoginType: codersdk.LoginTypePassword, - OrganizationID: owner.OrganizationID, - DisableLogin: false, + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "colin5@coder.com", + Username: "coolin", + Password: pw, + UserLoginType: codersdk.LoginTypePassword, + OrganizationIDs: []uuid.UUID{owner.OrganizationID}, }) require.NoError(t, err) @@ -88,13 +87,12 @@ func TestUserDelete(t *testing.T) { pw, err := cryptorand.String(16) require.NoError(t, err) - user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "colin5@coder.com", - Username: "coolin", - Password: pw, - UserLoginType: codersdk.LoginTypePassword, - OrganizationID: owner.OrganizationID, - DisableLogin: false, + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "colin5@coder.com", + Username: "coolin", + Password: pw, + UserLoginType: codersdk.LoginTypePassword, + OrganizationIDs: []uuid.UUID{owner.OrganizationID}, }) require.NoError(t, err) @@ -121,13 +119,12 @@ func TestUserDelete(t *testing.T) { // pw, err := cryptorand.String(16) // require.NoError(t, err) - // toDelete, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + // toDelete, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ // Email: "colin5@coder.com", // Username: "coolin", // Password: pw, // UserLoginType: codersdk.LoginTypePassword, // OrganizationID: aUser.OrganizationID, - // DisableLogin: false, // }) // require.NoError(t, err) diff --git a/cli/usercreate.go b/cli/usercreate.go index 257bb1634f1d8..78bb396916926 100644 --- a/cli/usercreate.go +++ b/cli/usercreate.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/go-playground/validator/v10" + "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/pretty" @@ -94,13 +95,13 @@ func (r *RootCmd) userCreate() *serpent.Command { } } - _, err = client.CreateUser(inv.Context(), codersdk.CreateUserRequest{ - Email: email, - Username: username, - Name: name, - Password: password, - OrganizationID: organization.ID, - UserLoginType: userLoginType, + _, err = client.CreateUserWithOrgs(inv.Context(), codersdk.CreateUserRequestWithOrgs{ + Email: email, + Username: username, + Name: name, + Password: password, + OrganizationIDs: []uuid.UUID{organization.ID}, + UserLoginType: userLoginType, }) if err != nil { return err diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 58196165db9ce..d0527df61bed3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4875,7 +4875,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateUserRequest" + "$ref": "#/definitions/codersdk.CreateUserRequestWithOrgs" } } ], @@ -9449,17 +9449,13 @@ const docTemplate = `{ } } }, - "codersdk.CreateUserRequest": { + "codersdk.CreateUserRequestWithOrgs": { "type": "object", "required": [ "email", "username" ], "properties": { - "disable_login": { - "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.\nDeprecated: Set UserLoginType=LoginTypeDisabled instead.", - "type": "boolean" - }, "email": { "type": "string", "format": "email" @@ -9475,9 +9471,13 @@ const docTemplate = `{ "name": { "type": "string" }, - "organization_id": { - "type": "string", - "format": "uuid" + "organization_ids": { + "description": "OrganizationIDs is a list of organization IDs that the user should be a member of.", + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } }, "password": { "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 8e53c856296fc..79fe02c6291d3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4305,7 +4305,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateUserRequest" + "$ref": "#/definitions/codersdk.CreateUserRequestWithOrgs" } } ], @@ -8413,14 +8413,10 @@ } } }, - "codersdk.CreateUserRequest": { + "codersdk.CreateUserRequestWithOrgs": { "type": "object", "required": ["email", "username"], "properties": { - "disable_login": { - "description": "DisableLogin sets the user's login type to 'none'. This prevents the user\nfrom being able to use a password or any other authentication method to login.\nDeprecated: Set UserLoginType=LoginTypeDisabled instead.", - "type": "boolean" - }, "email": { "type": "string", "format": "email" @@ -8436,9 +8432,13 @@ "name": { "type": "string" }, - "organization_id": { - "type": "string", - "format": "uuid" + "organization_ids": { + "description": "OrganizationIDs is a list of organization IDs that the user should be a member of.", + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } }, "password": { "type": "string" diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index afa2130212217..c38dd54d1d04d 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -646,11 +646,11 @@ func CreateFirstUser(t testing.TB, client *codersdk.Client) codersdk.CreateFirst // CreateAnotherUser creates and authenticates a new user. // Roles can include org scoped roles with 'roleName:' func CreateAnotherUser(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, roles ...rbac.RoleIdentifier) (*codersdk.Client, codersdk.User) { - return createAnotherUserRetry(t, client, organizationID, 5, roles) + return createAnotherUserRetry(t, client, []uuid.UUID{organizationID}, 5, roles) } -func CreateAnotherUserMutators(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, roles []rbac.RoleIdentifier, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) { - return createAnotherUserRetry(t, client, organizationID, 5, roles, mutators...) +func CreateAnotherUserMutators(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, roles []rbac.RoleIdentifier, mutators ...func(r *codersdk.CreateUserRequestWithOrgs)) (*codersdk.Client, codersdk.User) { + return createAnotherUserRetry(t, client, []uuid.UUID{organizationID}, 5, roles, mutators...) } // AuthzUserSubject does not include the user's groups. @@ -676,31 +676,31 @@ func AuthzUserSubject(user codersdk.User, orgID uuid.UUID) rbac.Subject { } } -func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, retries int, roles []rbac.RoleIdentifier, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) { - req := codersdk.CreateUserRequest{ - Email: namesgenerator.GetRandomName(10) + "@coder.com", - Username: RandomUsername(t), - Name: RandomName(t), - Password: "SomeSecurePassword!", - OrganizationID: organizationID, +func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationIDs []uuid.UUID, retries int, roles []rbac.RoleIdentifier, mutators ...func(r *codersdk.CreateUserRequestWithOrgs)) (*codersdk.Client, codersdk.User) { + req := codersdk.CreateUserRequestWithOrgs{ + Email: namesgenerator.GetRandomName(10) + "@coder.com", + Username: RandomUsername(t), + Name: RandomName(t), + Password: "SomeSecurePassword!", + OrganizationIDs: organizationIDs, } for _, m := range mutators { m(&req) } - user, err := client.CreateUser(context.Background(), req) + user, err := client.CreateUserWithOrgs(context.Background(), req) var apiError *codersdk.Error // If the user already exists by username or email conflict, try again up to "retries" times. if err != nil && retries >= 0 && xerrors.As(err, &apiError) { if apiError.StatusCode() == http.StatusConflict { retries-- - return createAnotherUserRetry(t, client, organizationID, retries, roles) + return createAnotherUserRetry(t, client, organizationIDs, retries, roles) } } require.NoError(t, err) var sessionToken string - if req.DisableLogin || req.UserLoginType == codersdk.LoginTypeNone { + if req.UserLoginType == codersdk.LoginTypeNone { // Cannot log in with a disabled login user. So make it an api key from // the client making this user. token, err := client.CreateToken(context.Background(), user.ID.String(), codersdk.CreateTokenRequest{ @@ -763,8 +763,9 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI require.NoError(t, err, "update site roles") // isMember keeps track of which orgs the user was added to as a member - isMember := map[uuid.UUID]bool{ - organizationID: true, + isMember := make(map[uuid.UUID]bool) + for _, orgID := range organizationIDs { + isMember[orgID] = true } // Update org roles diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 3c7c70e8c6743..40daed5d0ce02 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -523,7 +523,7 @@ func TestTemplateInsights_Golden(t *testing.T) { // Prepare all test users. for _, user := range users { - user.client, user.sdk = coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + user.client, user.sdk = coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { r.Username = user.name }) user.client.SetLogger(logger.Named("user").With(slog.Field{Name: "name", Value: user.name})) diff --git a/coderd/userauth.go b/coderd/userauth.go index f876bf7686341..1a5488d2d6ded 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1436,11 +1436,11 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C } //nolint:gocritic - user, _, err = api.CreateUser(dbauthz.AsSystemRestricted(ctx), tx, CreateUserRequest{ - CreateUserRequest: codersdk.CreateUserRequest{ - Email: params.Email, - Username: params.Username, - OrganizationID: defaultOrganization.ID, + user, err = api.CreateUser(dbauthz.AsSystemRestricted(ctx), tx, CreateUserRequest{ + CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{ + Email: params.Email, + Username: params.Username, + OrganizationIDs: []uuid.UUID{defaultOrganization.ID}, }, LoginType: params.LoginType, }) diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 5519cfd599015..8603cfcfb439a 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -106,28 +106,12 @@ func TestUserLogin(t *testing.T) { require.ErrorAs(t, err, &apiErr) require.Equal(t, http.StatusUnauthorized, apiErr.StatusCode()) }) - // Password auth should fail if the user is made without password login. - t.Run("DisableLoginDeprecatedField", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { - r.Password = "" - r.DisableLogin = true - }) - - _, err := anotherClient.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{ - Email: anotherUser.Email, - Password: "SomeSecurePassword!", - }) - require.Error(t, err) - }) t.Run("LoginTypeNone", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) - anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, client, user.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { r.Password = "" r.UserLoginType = codersdk.LoginTypeNone }) @@ -1470,11 +1454,11 @@ func TestUserLogout(t *testing.T) { //nolint:gosec password = "SomeSecurePassword123!" ) - newUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: email, - Username: username, - Password: password, - OrganizationID: firstUser.OrganizationID, + newUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: email, + Username: username, + Password: password, + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, }) require.NoError(t, err) diff --git a/coderd/users.go b/coderd/users.go index 07ec053ca44a7..3ae2d916e1de8 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -186,13 +186,13 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { } //nolint:gocritic // needed to create first user - user, organizationID, err := api.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, CreateUserRequest{ - CreateUserRequest: codersdk.CreateUserRequest{ - Email: createUser.Email, - Username: createUser.Username, - Name: createUser.Name, - Password: createUser.Password, - OrganizationID: defaultOrg.ID, + user, err := api.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, CreateUserRequest{ + CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{ + Email: createUser.Email, + Username: createUser.Username, + Name: createUser.Name, + Password: createUser.Password, + OrganizationIDs: []uuid.UUID{defaultOrg.ID}, }, LoginType: database.LoginTypePassword, }) @@ -240,7 +240,7 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateFirstUserResponse{ UserID: user.ID, - OrganizationID: organizationID, + OrganizationID: defaultOrg.ID, }) } @@ -342,7 +342,7 @@ func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.Us // @Accept json // @Produce json // @Tags Users -// @Param request body codersdk.CreateUserRequest true "Create user request" +// @Param request body codersdk.CreateUserRequestWithOrgs true "Create user request" // @Success 201 {object} codersdk.User // @Router /users [post] func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { @@ -356,15 +356,11 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { }) defer commitAudit() - var req codersdk.CreateUserRequest + var req codersdk.CreateUserRequestWithOrgs if !httpapi.Read(ctx, rw, r, &req) { return } - if req.UserLoginType == "" && req.DisableLogin { - // Handle the deprecated field - req.UserLoginType = codersdk.LoginTypeNone - } if req.UserLoginType == "" { // Default to password auth req.UserLoginType = codersdk.LoginTypePassword @@ -386,6 +382,20 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } + if len(req.OrganizationIDs) == 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "No organization specified to place the user as a member of. It is required to specify at least one organization id to place the user in.", + Detail: "required at least 1 value for the array 'organization_ids'", + Validations: []codersdk.ValidationError{ + { + Field: "organization_ids", + Detail: "Missing values, this cannot be empty", + }, + }, + }) + return + } + // TODO: @emyrk Authorize the organization create if the createUser will do that. _, err := api.Database.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{ @@ -406,44 +416,33 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } - if req.OrganizationID != uuid.Nil { - // If an organization was provided, make sure it exists. - _, err := api.Database.GetOrganizationByID(ctx, req.OrganizationID) - if err != nil { - if httpapi.Is404Error(err) { + // If an organization was provided, make sure it exists. + for i, orgID := range req.OrganizationIDs { + var orgErr error + if orgID != uuid.Nil { + _, orgErr = api.Database.GetOrganizationByID(ctx, orgID) + } else { + var defaultOrg database.Organization + defaultOrg, orgErr = api.Database.GetDefaultOrganization(ctx) + if orgErr == nil { + // converts uuid.Nil --> default org.ID + req.OrganizationIDs[i] = defaultOrg.ID + } + } + if orgErr != nil { + if httpapi.Is404Error(orgErr) { httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: fmt.Sprintf("Organization does not exist with the provided id %q.", req.OrganizationID), + Message: fmt.Sprintf("Organization does not exist with the provided id %q.", orgID), }) return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching organization.", - Detail: err.Error(), - }) - return - } - } else { - // If no organization is provided, add the user to the default - defaultOrg, err := api.Database.GetDefaultOrganization(ctx) - if err != nil { - if httpapi.Is404Error(err) { - httpapi.Write(ctx, rw, http.StatusNotFound, - codersdk.Response{ - Message: "Resource not found or you do not have access to this resource", - Detail: "Organization not found", - }, - ) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching orgs.", - Detail: err.Error(), + Detail: orgErr.Error(), }) return } - - req.OrganizationID = defaultOrg.ID } var loginType database.LoginType @@ -480,9 +479,9 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } - user, _, err := api.CreateUser(ctx, api.Database, CreateUserRequest{ - CreateUserRequest: req, - LoginType: loginType, + user, err := api.CreateUser(ctx, api.Database, CreateUserRequest{ + CreateUserRequestWithOrgs: req, + LoginType: loginType, }) if dbauthz.IsNotAuthorizedError(err) { httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ @@ -505,7 +504,7 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { Users: []telemetry.User{telemetry.ConvertUser(user)}, }) - httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.User(user, []uuid.UUID{req.OrganizationID})) + httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.User(user, req.OrganizationIDs)) } // @Summary Delete user @@ -1280,23 +1279,23 @@ func (api *API) organizationByUserAndName(rw http.ResponseWriter, r *http.Reques } type CreateUserRequest struct { - codersdk.CreateUserRequest + codersdk.CreateUserRequestWithOrgs LoginType database.LoginType SkipNotifications bool } -func (api *API) CreateUser(ctx context.Context, store database.Store, req CreateUserRequest) (database.User, uuid.UUID, error) { +func (api *API) CreateUser(ctx context.Context, store database.Store, req CreateUserRequest) (database.User, error) { // Ensure the username is valid. It's the caller's responsibility to ensure // the username is valid and unique. if usernameValid := httpapi.NameValid(req.Username); usernameValid != nil { - return database.User{}, uuid.Nil, xerrors.Errorf("invalid username %q: %w", req.Username, usernameValid) + return database.User{}, xerrors.Errorf("invalid username %q: %w", req.Username, usernameValid) } var user database.User err := store.InTx(func(tx database.Store) error { orgRoles := make([]string, 0) // Organization is required to know where to allocate the user. - if req.OrganizationID == uuid.Nil { + if len(req.OrganizationIDs) == 0 { return xerrors.Errorf("organization ID must be provided") } @@ -1341,26 +1340,30 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create if err != nil { return xerrors.Errorf("insert user gitsshkey: %w", err) } - _, err = tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ - OrganizationID: req.OrganizationID, - UserID: user.ID, - CreatedAt: dbtime.Now(), - UpdatedAt: dbtime.Now(), - // By default give them membership to the organization. - Roles: orgRoles, - }) - if err != nil { - return xerrors.Errorf("create organization member: %w", err) + + for _, orgID := range req.OrganizationIDs { + _, err = tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ + OrganizationID: orgID, + UserID: user.ID, + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + // By default give them membership to the organization. + Roles: orgRoles, + }) + if err != nil { + return xerrors.Errorf("create organization member for %q: %w", orgID.String(), err) + } } + return nil }, nil) if err != nil || req.SkipNotifications { - return user, req.OrganizationID, err + return user, err } userAdmins, err := findUserAdmins(ctx, store) if err != nil { - return user, req.OrganizationID, xerrors.Errorf("find user admins: %w", err) + return user, xerrors.Errorf("find user admins: %w", err) } for _, u := range userAdmins { @@ -1373,7 +1376,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create api.Logger.Warn(ctx, "unable to notify about created user", slog.F("created_user", user.Username), slog.Error(err)) } } - return user, req.OrganizationID, err + return user, err } // findUserAdmins fetches all users with user admin permission including owners. diff --git a/coderd/users_test.go b/coderd/users_test.go index 66eb2f8da1f94..622e2da54c3bc 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -220,11 +220,11 @@ func TestPostLogin(t *testing.T) { // With a user account. const password = "SomeSecurePassword!" - user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "test+user-@coder.com", - Username: "user", - Password: password, - OrganizationID: first.OrganizationID, + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "test+user-@coder.com", + Username: "user", + Password: password, + OrganizationIDs: []uuid.UUID{first.OrganizationID}, }) require.NoError(t, err) @@ -317,11 +317,11 @@ func TestDeleteUser(t *testing.T) { err := client.DeleteUser(context.Background(), another.ID) require.NoError(t, err) // Attempt to create a user with the same email and username, and delete them again. - another, err = client.CreateUser(context.Background(), codersdk.CreateUserRequest{ - Email: another.Email, - Username: another.Username, - Password: "SomeSecurePassword!", - OrganizationID: user.OrganizationID, + another, err = client.CreateUserWithOrgs(context.Background(), codersdk.CreateUserRequestWithOrgs{ + Email: another.Email, + Username: another.Username, + Password: "SomeSecurePassword!", + OrganizationIDs: []uuid.UUID{user.OrganizationID}, }) require.NoError(t, err) err = client.DeleteUser(context.Background(), another.ID) @@ -415,11 +415,11 @@ func TestNotifyUserStatusChanged(t *testing.T) { _, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin()) - member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: firstUser.OrganizationID, - Email: "another@user.org", - Username: "someone-else", - Password: "SomeSecurePassword!", + member, err := adminClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", }) require.NoError(t, err) @@ -452,11 +452,11 @@ func TestNotifyUserStatusChanged(t *testing.T) { _, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin()) - member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: firstUser.OrganizationID, - Email: "another@user.org", - Username: "someone-else", - Password: "SomeSecurePassword!", + member, err := adminClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", }) require.NoError(t, err) @@ -494,11 +494,11 @@ func TestNotifyDeletedUser(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - user, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: firstUser.OrganizationID, - Email: "another@user.org", - Username: "someone-else", - Password: "SomeSecurePassword!", + user, err := adminClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", }) require.NoError(t, err) @@ -530,11 +530,11 @@ func TestNotifyDeletedUser(t *testing.T) { _, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin()) - member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: firstUser.OrganizationID, - Email: "another@user.org", - Username: "someone-else", - Password: "SomeSecurePassword!", + member, err := adminClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", }) require.NoError(t, err) @@ -625,7 +625,7 @@ func TestPostUsers(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, err := client.CreateUser(ctx, codersdk.CreateUserRequest{}) + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{}) require.Error(t, err) }) @@ -639,11 +639,11 @@ func TestPostUsers(t *testing.T) { me, err := client.User(ctx, codersdk.Me) require.NoError(t, err) - _, err = client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: me.Email, - Username: me.Username, - Password: "MySecurePassword!", - OrganizationID: uuid.New(), + _, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: me.Email, + Username: me.Username, + Password: "MySecurePassword!", + OrganizationIDs: []uuid.UUID{uuid.New()}, }) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) @@ -658,11 +658,11 @@ func TestPostUsers(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: uuid.New(), - Email: "another@user.org", - Username: "someone-else", - Password: "SomeSecurePassword!", + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{uuid.New()}, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", }) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) @@ -682,11 +682,11 @@ func TestPostUsers(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: firstUser.OrganizationID, - Email: "another@user.org", - Username: "someone-else", - Password: "SomeSecurePassword!", + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", }) require.NoError(t, err) @@ -735,12 +735,12 @@ func TestPostUsers(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: first.OrganizationID, - Email: "another@user.org", - Username: "someone-else", - Password: "", - UserLoginType: codersdk.LoginTypeNone, + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Email: "another@user.org", + Username: "someone-else", + Password: "", + UserLoginType: codersdk.LoginTypeNone, }) require.NoError(t, err) @@ -767,12 +767,12 @@ func TestPostUsers(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: first.OrganizationID, - Email: email, - Username: "someone-else", - Password: "", - UserLoginType: codersdk.LoginTypeOIDC, + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Email: email, + Username: "someone-else", + Password: "", + UserLoginType: codersdk.LoginTypeOIDC, }) require.NoError(t, err) @@ -804,11 +804,11 @@ func TestNotifyCreatedUser(t *testing.T) { defer cancel() // when - user, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: firstUser.OrganizationID, - Email: "another@user.org", - Username: "someone-else", - Password: "SomeSecurePassword!", + user, err := adminClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", }) require.NoError(t, err) @@ -833,11 +833,11 @@ func TestNotifyCreatedUser(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - userAdmin, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: firstUser.OrganizationID, - Email: "user-admin@user.org", - Username: "mr-user-admin", - Password: "SomeSecurePassword!", + userAdmin, err := adminClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, + Email: "user-admin@user.org", + Username: "mr-user-admin", + Password: "SomeSecurePassword!", }) require.NoError(t, err) @@ -849,11 +849,11 @@ func TestNotifyCreatedUser(t *testing.T) { require.NoError(t, err) // when - member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: firstUser.OrganizationID, - Email: "another@user.org", - Username: "someone-else", - Password: "SomeSecurePassword!", + member, err := adminClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{firstUser.OrganizationID}, + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", }) require.NoError(t, err) @@ -908,11 +908,11 @@ func TestUpdateUserProfile(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - existentUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "bruno@coder.com", - Username: "bruno", - Password: "SomeSecurePassword!", - OrganizationID: user.OrganizationID, + existentUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "bruno@coder.com", + Username: "bruno", + Password: "SomeSecurePassword!", + OrganizationIDs: []uuid.UUID{user.OrganizationID}, }) require.NoError(t, err) _, err = client.UpdateUserProfile(ctx, codersdk.Me, codersdk.UpdateUserProfileRequest{ @@ -990,11 +990,11 @@ func TestUpdateUserProfile(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "john@coder.com", - Username: "john", - Password: "SomeSecurePassword!", - OrganizationID: user.OrganizationID, + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "john@coder.com", + Username: "john", + Password: "SomeSecurePassword!", + OrganizationIDs: []uuid.UUID{user.OrganizationID}, }) require.NoError(t, err) _, err = client.UpdateUserProfile(ctx, codersdk.Me, codersdk.UpdateUserProfileRequest{ @@ -1032,11 +1032,11 @@ func TestUpdateUserPassword(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - member, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "coder@coder.com", - Username: "coder", - Password: "SomeStrongPassword!", - OrganizationID: owner.OrganizationID, + member, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "coder@coder.com", + Username: "coder", + Password: "SomeStrongPassword!", + OrganizationIDs: []uuid.UUID{owner.OrganizationID}, }) require.NoError(t, err, "create member") err = client.UpdateUserPassword(ctx, member.ID.String(), codersdk.UpdateUserPasswordRequest{ @@ -1293,11 +1293,11 @@ func TestActivateDormantUser(t *testing.T) { me := coderdtest.CreateFirstUser(t, client) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - anotherUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "coder@coder.com", - Username: "coder", - Password: "SomeStrongPassword!", - OrganizationID: me.OrganizationID, + anotherUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "coder@coder.com", + Username: "coder", + Password: "SomeStrongPassword!", + OrganizationIDs: []uuid.UUID{me.OrganizationID}, }) require.NoError(t, err) @@ -1600,11 +1600,11 @@ func TestGetUsers(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "alice@email.com", - Username: "alice", - Password: "MySecurePassword!", - OrganizationID: user.OrganizationID, + client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "alice@email.com", + Username: "alice", + Password: "MySecurePassword!", + OrganizationIDs: []uuid.UUID{user.OrganizationID}, }) // No params is all users res, err := client.Users(ctx, codersdk.UsersRequest{}) @@ -1626,11 +1626,11 @@ func TestGetUsers(t *testing.T) { active = append(active, firstUser) // Alice will be suspended - alice, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "alice@email.com", - Username: "alice", - Password: "MySecurePassword!", - OrganizationID: first.OrganizationID, + alice, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "alice@email.com", + Username: "alice", + Password: "MySecurePassword!", + OrganizationIDs: []uuid.UUID{first.OrganizationID}, }) require.NoError(t, err) @@ -1638,11 +1638,11 @@ func TestGetUsers(t *testing.T) { require.NoError(t, err) // Tom will be active - tom, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "tom@email.com", - Username: "tom", - Password: "MySecurePassword!", - OrganizationID: first.OrganizationID, + tom, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "tom@email.com", + Username: "tom", + Password: "MySecurePassword!", + OrganizationIDs: []uuid.UUID{first.OrganizationID}, }) require.NoError(t, err) @@ -1669,11 +1669,11 @@ func TestGetUsersPagination(t *testing.T) { _, err := client.User(ctx, first.UserID.String()) require.NoError(t, err, "") - _, err = client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "alice@email.com", - Username: "alice", - Password: "MySecurePassword!", - OrganizationID: first.OrganizationID, + _, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "alice@email.com", + Username: "alice", + Password: "MySecurePassword!", + OrganizationIDs: []uuid.UUID{first.OrganizationID}, }) require.NoError(t, err) @@ -1750,11 +1750,11 @@ func TestWorkspacesByUser(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - newUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "test@coder.com", - Username: "someone", - Password: "MySecurePassword!", - OrganizationID: user.OrganizationID, + newUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "test@coder.com", + Username: "someone", + Password: "MySecurePassword!", + OrganizationIDs: []uuid.UUID{user.OrganizationID}, }) require.NoError(t, err) auth, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ @@ -1790,11 +1790,11 @@ func TestDormantUser(t *testing.T) { defer cancel() // Create a new user - newUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "test@coder.com", - Username: "someone", - Password: "MySecurePassword!", - OrganizationID: user.OrganizationID, + newUser, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "test@coder.com", + Username: "someone", + Password: "MySecurePassword!", + OrganizationIDs: []uuid.UUID{user.OrganizationID}, }) require.NoError(t, err) @@ -1841,11 +1841,11 @@ func TestSuspendedPagination(t *testing.T) { for i := 0; i < total; i++ { email := fmt.Sprintf("%d@coder.com", i) username := fmt.Sprintf("user%d", i) - user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: email, - Username: username, - Password: "MySecurePassword!", - OrganizationID: orgID, + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: email, + Username: username, + Password: "MySecurePassword!", + OrganizationIDs: []uuid.UUID{orgID}, }) require.NoError(t, err) users = append(users, user) diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index 3cd5e5a2f9935..14adf2d61d362 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -1206,11 +1206,11 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { // Create a template-admin user in the same org. We don't use an owner // since they have access to everything. ownerClient = appDetails.SDKClient - user, err := ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "user@coder.com", - Username: "user", - Password: password, - OrganizationID: appDetails.FirstUser.OrganizationID, + user, err := ownerClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "user@coder.com", + Username: "user", + Password: password, + OrganizationIDs: []uuid.UUID{appDetails.FirstUser.OrganizationID}, }) require.NoError(t, err) @@ -1258,11 +1258,11 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { Name: "a-different-org", }) require.NoError(t, err) - userInOtherOrg, err := ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "no-template-access@coder.com", - Username: "no-template-access", - Password: password, - OrganizationID: otherOrg.ID, + userInOtherOrg, err := ownerClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "no-template-access@coder.com", + Username: "no-template-access", + Password: password, + OrganizationIDs: []uuid.UUID{otherOrg.ID}, }) require.NoError(t, err) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index ec7c03dd53013..98f36c3b9a13e 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -451,7 +451,7 @@ func TestWorkspacesSortOrder(t *testing.T) { client, db := coderdtest.NewWithDatabase(t, nil) firstUser := coderdtest.CreateFirstUser(t, client) - secondUserClient, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []rbac.RoleIdentifier{rbac.RoleOwner()}, func(r *codersdk.CreateUserRequest) { + secondUserClient, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []rbac.RoleIdentifier{rbac.RoleOwner()}, func(r *codersdk.CreateUserRequestWithOrgs) { r.Username = "zzz" }) diff --git a/codersdk/users.go b/codersdk/users.go index a715194c11978..e35803abeb15e 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -113,6 +113,11 @@ type CreateFirstUserResponse struct { OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` } +// CreateUserRequest +// Deprecated: Use CreateUserRequestWithOrgs instead. This will be removed. +// TODO: When removing, we should rename CreateUserRequestWithOrgs -> CreateUserRequest +// Then alias CreateUserRequestWithOrgs to CreateUserRequest. +// @typescript-ignore CreateUserRequest type CreateUserRequest struct { Email string `json:"email" validate:"required,email" format:"email"` Username string `json:"username" validate:"required,username"` @@ -127,6 +132,45 @@ type CreateUserRequest struct { OrganizationID uuid.UUID `json:"organization_id" validate:"" format:"uuid"` } +type CreateUserRequestWithOrgs struct { + Email string `json:"email" validate:"required,email" format:"email"` + Username string `json:"username" validate:"required,username"` + Name string `json:"name" validate:"user_real_name"` + Password string `json:"password"` + // UserLoginType defaults to LoginTypePassword. + UserLoginType LoginType `json:"login_type"` + // OrganizationIDs is a list of organization IDs that the user should be a member of. + OrganizationIDs []uuid.UUID `json:"organization_ids" validate:"" format:"uuid"` +} + +// UnmarshalJSON implements the unmarshal for the legacy param "organization_id". +// To accommodate multiple organizations, the field has been switched to a slice. +// The previous field will just be appended to the slice. +// Note in the previous behavior, omitting the field would result in the +// default org being applied, but that is no longer the case. +// TODO: Remove this method in it's entirety after some period of time. +// This will be released in v1.16.0, and is associated with the multiple orgs +// feature. +func (r *CreateUserRequestWithOrgs) UnmarshalJSON(data []byte) error { + // By using a type alias, we prevent an infinite recursion when unmarshalling. + // This allows us to use the default unmarshal behavior of the original type. + type AliasedReq CreateUserRequestWithOrgs + type DeprecatedCreateUserRequest struct { + AliasedReq + OrganizationID *uuid.UUID `json:"organization_id" format:"uuid"` + } + var dep DeprecatedCreateUserRequest + err := json.Unmarshal(data, &dep) + if err != nil { + return err + } + *r = CreateUserRequestWithOrgs(dep.AliasedReq) + if dep.OrganizationID != nil { + r.OrganizationIDs = append(r.OrganizationIDs, *dep.OrganizationID) + } + return nil +} + type UpdateUserProfileRequest struct { Username string `json:"username" validate:"required,username"` Name string `json:"name" validate:"user_real_name"` @@ -288,8 +332,26 @@ func (c *Client) CreateFirstUser(ctx context.Context, req CreateFirstUserRequest return resp, json.NewDecoder(res.Body).Decode(&resp) } -// CreateUser creates a new user. +// CreateUser +// Deprecated: Use CreateUserWithOrgs instead. This will be removed. +// TODO: When removing, we should rename CreateUserWithOrgs -> CreateUser +// with an alias of CreateUserWithOrgs. func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, error) { + if req.DisableLogin { + req.UserLoginType = LoginTypeNone + } + return c.CreateUserWithOrgs(ctx, CreateUserRequestWithOrgs{ + Email: req.Email, + Username: req.Username, + Name: req.Name, + Password: req.Password, + UserLoginType: req.UserLoginType, + OrganizationIDs: []uuid.UUID{req.OrganizationID}, + }) +} + +// CreateUserWithOrgs creates a new user. +func (c *Client) CreateUserWithOrgs(ctx context.Context, req CreateUserRequestWithOrgs) (User, error) { res, err := c.Request(ctx, http.MethodPost, "/api/v2/users", req) if err != nil { return User{}, err diff --git a/codersdk/users_test.go b/codersdk/users_test.go new file mode 100644 index 0000000000000..f1c691323bffd --- /dev/null +++ b/codersdk/users_test.go @@ -0,0 +1,149 @@ +package codersdk_test + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" +) + +func TestDeprecatedCreateUserRequest(t *testing.T) { + t.Parallel() + + t.Run("DefaultOrganization", func(t *testing.T) { + t.Parallel() + + input := ` +{ + "email":"alice@coder.com", + "password":"hunter2", + "username":"alice", + "name":"alice", + "organization_id":"00000000-0000-0000-0000-000000000000", + "disable_login":false, + "login_type":"none" +} +` + var req codersdk.CreateUserRequestWithOrgs + err := json.Unmarshal([]byte(input), &req) + require.NoError(t, err) + require.Equal(t, req.Email, "alice@coder.com") + require.Equal(t, req.Password, "hunter2") + require.Equal(t, req.Username, "alice") + require.Equal(t, req.Name, "alice") + require.Equal(t, req.OrganizationIDs, []uuid.UUID{uuid.Nil}) + require.Equal(t, req.UserLoginType, codersdk.LoginTypeNone) + }) + + t.Run("MultipleOrganizations", func(t *testing.T) { + t.Parallel() + + input := ` +{ + "email":"alice@coder.com", + "password":"hunter2", + "username":"alice", + "name":"alice", + "organization_id":"00000000-0000-0000-0000-000000000000", + "organization_ids":["a618cb03-99fb-4380-adb6-aa801629a4cf","8309b0dc-44ea-435d-a9ff-72cb302835e4"], + "disable_login":false, + "login_type":"none" +} +` + var req codersdk.CreateUserRequestWithOrgs + err := json.Unmarshal([]byte(input), &req) + require.NoError(t, err) + require.Equal(t, req.Email, "alice@coder.com") + require.Equal(t, req.Password, "hunter2") + require.Equal(t, req.Username, "alice") + require.Equal(t, req.Name, "alice") + require.ElementsMatch(t, req.OrganizationIDs, + []uuid.UUID{ + uuid.Nil, + uuid.MustParse("a618cb03-99fb-4380-adb6-aa801629a4cf"), + uuid.MustParse("8309b0dc-44ea-435d-a9ff-72cb302835e4"), + }) + + require.Equal(t, req.UserLoginType, codersdk.LoginTypeNone) + }) + + t.Run("OmittedOrganizations", func(t *testing.T) { + t.Parallel() + + input := ` +{ + "email":"alice@coder.com", + "password":"hunter2", + "username":"alice", + "name":"alice", + "disable_login":false, + "login_type":"none" +} +` + var req codersdk.CreateUserRequestWithOrgs + err := json.Unmarshal([]byte(input), &req) + require.NoError(t, err) + + require.Empty(t, req.OrganizationIDs) + }) +} + +func TestCreateUserRequestJSON(t *testing.T) { + t.Parallel() + + marshalTest := func(t *testing.T, req codersdk.CreateUserRequestWithOrgs) { + t.Helper() + data, err := json.Marshal(req) + require.NoError(t, err) + var req2 codersdk.CreateUserRequestWithOrgs + err = json.Unmarshal(data, &req2) + require.NoError(t, err) + require.Equal(t, req, req2) + } + + t.Run("MultipleOrganizations", func(t *testing.T) { + t.Parallel() + + req := codersdk.CreateUserRequestWithOrgs{ + Email: coderdtest.RandomName(t), + Username: coderdtest.RandomName(t), + Name: coderdtest.RandomName(t), + Password: "", + UserLoginType: codersdk.LoginTypePassword, + OrganizationIDs: []uuid.UUID{uuid.New(), uuid.New()}, + } + marshalTest(t, req) + }) + + t.Run("SingleOrganization", func(t *testing.T) { + t.Parallel() + + req := codersdk.CreateUserRequestWithOrgs{ + Email: coderdtest.RandomName(t), + Username: coderdtest.RandomName(t), + Name: coderdtest.RandomName(t), + Password: "", + UserLoginType: codersdk.LoginTypePassword, + OrganizationIDs: []uuid.UUID{uuid.New()}, + } + marshalTest(t, req) + }) + + t.Run("NoOrganization", func(t *testing.T) { + t.Parallel() + + req := codersdk.CreateUserRequestWithOrgs{ + Email: coderdtest.RandomName(t), + Username: coderdtest.RandomName(t), + Name: coderdtest.RandomName(t), + Password: "", + UserLoginType: codersdk.LoginTypePassword, + OrganizationIDs: []uuid.UUID{}, + } + marshalTest(t, req) + }) +} diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9f9188ced1761..e3a09df883001 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1284,15 +1284,14 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `scope` | `all` | | `scope` | `application_connect` | -## codersdk.CreateUserRequest +## codersdk.CreateUserRequestWithOrgs ```json { - "disable_login": true, "email": "user@example.com", "login_type": "", "name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "password": "string", "username": "string" } @@ -1300,15 +1299,14 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | ---------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `disable_login` | boolean | false | | Disable login sets the user's login type to 'none'. This prevents the user from being able to use a password or any other authentication method to login. Deprecated: Set UserLoginType=LoginTypeDisabled instead. | -| `email` | string | true | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `password` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ---------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------- | +| `email` | string | true | | | +| `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 | | | +| `username` | string | true | | | ## codersdk.CreateWorkspaceBuildRequest diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 2cca07030cfd1..3979f5521b377 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -81,11 +81,10 @@ curl -X POST http://coder-server:8080/api/v2/users \ ```json { - "disable_login": true, "email": "user@example.com", "login_type": "", "name": "string", - "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], "password": "string", "username": "string" } @@ -93,9 +92,9 @@ curl -X POST http://coder-server:8080/api/v2/users \ ### Parameters -| Name | In | Type | Required | Description | -| ------ | ---- | ------------------------------------------------------------------ | -------- | ------------------- | -| `body` | body | [codersdk.CreateUserRequest](schemas.md#codersdkcreateuserrequest) | true | Create user request | +| Name | In | Type | Required | Description | +| ------ | ---- | ---------------------------------------------------------------------------------- | -------- | ------------------- | +| `body` | body | [codersdk.CreateUserRequestWithOrgs](schemas.md#codersdkcreateuserrequestwithorgs) | true | Create user request | ### Example responses diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 986b308b86fef..1bd5bb8249b58 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -757,11 +757,11 @@ func TestGroup(t *testing.T) { require.Contains(t, group.Members, user2.ReducedUser) // cannot explicitly set a dormant user status so must create a new user - anotherUser, err := userAdminClient.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "coder@coder.com", - Username: "coder", - Password: "SomeStrongPassword!", - OrganizationID: user.OrganizationID, + anotherUser, err := userAdminClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "coder@coder.com", + Username: "coder", + Password: "SomeStrongPassword!", + OrganizationIDs: []uuid.UUID{user.OrganizationID}, }) require.NoError(t, err) diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/scim.go index 9a803c51d9589..9ac9307ce2d12 100644 --- a/enterprise/coderd/scim.go +++ b/enterprise/coderd/scim.go @@ -232,11 +232,11 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) { } //nolint:gocritic // needed for SCIM - dbUser, _, err = api.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, agpl.CreateUserRequest{ - CreateUserRequest: codersdk.CreateUserRequest{ - Username: sUser.UserName, - Email: email, - OrganizationID: defaultOrganization.ID, + dbUser, err = api.AGPL.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, agpl.CreateUserRequest{ + CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{ + Username: sUser.UserName, + Email: email, + OrganizationIDs: []uuid.UUID{defaultOrganization.ID}, }, LoginType: database.LoginTypeOIDC, // Do not send notifications to user admins as SCIM endpoint might be called sequentially to all users. diff --git a/enterprise/coderd/userauth_test.go b/enterprise/coderd/userauth_test.go index 1f62235e5eb41..e5ca9c9180290 100644 --- a/enterprise/coderd/userauth_test.go +++ b/enterprise/coderd/userauth_test.go @@ -717,7 +717,7 @@ func TestEnterpriseUserLogin(t *testing.T) { Name: customRole.Name, OrganizationID: owner.OrganizationID, }, - }, func(r *codersdk.CreateUserRequest) { + }, func(r *codersdk.CreateUserRequestWithOrgs) { r.Password = "SomeSecurePassword!" r.UserLoginType = codersdk.LoginTypePassword }) @@ -752,7 +752,7 @@ func TestEnterpriseUserLogin(t *testing.T) { }, }) - anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, ownerClient, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + anotherClient, anotherUser := coderdtest.CreateAnotherUserMutators(t, ownerClient, owner.OrganizationID, nil, func(r *codersdk.CreateUserRequestWithOrgs) { r.Password = "SomeSecurePassword!" r.UserLoginType = codersdk.LoginTypePassword }) diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index eb0f23b278d92..54f2c8d0d3460 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/google/uuid" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" @@ -509,11 +508,11 @@ func TestEnterprisePostUser(t *testing.T) { request.Name = "another" }) - _, err := notInOrg.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "some@domain.com", - Username: "anotheruser", - Password: "SomeSecurePassword!", - OrganizationID: org.ID, + _, err := notInOrg.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "some@domain.com", + Username: "anotheruser", + Password: "SomeSecurePassword!", + OrganizationIDs: []uuid.UUID{org.ID}, }) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) @@ -543,11 +542,11 @@ func TestEnterprisePostUser(t *testing.T) { org := coderdenttest.CreateOrganization(t, other, coderdenttest.CreateOrganizationOptions{}) - _, err := notInOrg.CreateUser(ctx, codersdk.CreateUserRequest{ - Email: "some@domain.com", - Username: "anotheruser", - Password: "SomeSecurePassword!", - OrganizationID: org.ID, + _, err := notInOrg.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "some@domain.com", + Username: "anotheruser", + Password: "SomeSecurePassword!", + OrganizationIDs: []uuid.UUID{org.ID}, }) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) @@ -559,7 +558,7 @@ func TestEnterprisePostUser(t *testing.T) { dv := coderdtest.DeploymentValues(t) dv.Experiments = []string{string(codersdk.ExperimentMultiOrganization)} - client, firstUser := coderdenttest.New(t, &coderdenttest.Options{ + client, _ := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: dv, }, @@ -578,14 +577,53 @@ func TestEnterprisePostUser(t *testing.T) { // nolint:gocritic // intentional using the owner. // Manually making a user with the request instead of the coderdtest util - user, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + _, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ Email: "another@user.org", Username: "someone-else", Password: "SomeSecurePassword!", }) + require.ErrorContains(t, err, "No organization specified") + }) + + t.Run("MultipleOrganizations", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentMultiOrganization)} + + client, _ := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureMultipleOrganizations: 1, + }, + }, + }) + + // Add an extra org to assign member into + second := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) + third := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // nolint:gocritic // intentional using the owner. + // Manually making a user with the request instead of the coderdtest util + user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + Email: "another@user.org", + Username: "someone-else", + Password: "SomeSecurePassword!", + OrganizationIDs: []uuid.UUID{ + second.ID, + third.ID, + }, + }) require.NoError(t, err) - require.Len(t, user.OrganizationIDs, 1) - assert.Equal(t, firstUser.OrganizationID, user.OrganizationIDs[0]) + memberedOrgs, err := client.OrganizationsByUser(ctx, user.ID.String()) + require.NoError(t, err) + require.Len(t, memberedOrgs, 2) + require.ElementsMatch(t, []uuid.UUID{second.ID, third.ID}, []uuid.UUID{memberedOrgs[0].ID, memberedOrgs[1].ID}) }) } diff --git a/scaletest/createworkspaces/run.go b/scaletest/createworkspaces/run.go index 6793475012194..b31091f4984a1 100644 --- a/scaletest/createworkspaces/run.go +++ b/scaletest/createworkspaces/run.go @@ -72,11 +72,11 @@ func (r *Runner) Run(ctx context.Context, id string, logs io.Writer) error { _, _ = fmt.Fprintln(logs, "Creating user:") - user, err = r.client.CreateUser(ctx, codersdk.CreateUserRequest{ - OrganizationID: r.cfg.User.OrganizationID, - Username: r.cfg.User.Username, - Email: r.cfg.User.Email, - Password: password, + user, err = r.client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{r.cfg.User.OrganizationID}, + Username: r.cfg.User.Username, + Email: r.cfg.User.Email, + Password: password, }) if err != nil { return xerrors.Errorf("create user: %w", err) diff --git a/site/e2e/api.ts b/site/e2e/api.ts index 3f274a3c01910..da5a57dee007d 100644 --- a/site/e2e/api.ts +++ b/site/e2e/api.ts @@ -36,8 +36,7 @@ export const createUser = async (orgId: string) => { name: name, password: "s3cure&password!", login_type: "password", - disable_login: false, - organization_id: orgId, + organization_ids: [orgId], }); return user; }; diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 456607bf3d6cb..4c4abdc5b75c9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1130,7 +1130,7 @@ class ApiMethods { }; createUser = async ( - user: TypesGen.CreateUserRequest, + user: TypesGen.CreateUserRequestWithOrgs, ): Promise => { const response = await this.axios.post( "/api/v2/users", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1186f26ad90c8..5a27898ec3068 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -303,14 +303,13 @@ export interface CreateTokenRequest { } // From codersdk/users.go -export interface CreateUserRequest { +export interface CreateUserRequestWithOrgs { readonly email: string; readonly username: string; readonly name: string; readonly password: string; readonly login_type: LoginType; - readonly disable_login: boolean; - readonly organization_id: string; + readonly organization_ids: Readonly>; } // From codersdk/workspaces.go diff --git a/site/src/pages/CreateUserPage/CreateUserForm.tsx b/site/src/pages/CreateUserPage/CreateUserForm.tsx index 9d780f7355177..635c26387c00c 100644 --- a/site/src/pages/CreateUserPage/CreateUserForm.tsx +++ b/site/src/pages/CreateUserPage/CreateUserForm.tsx @@ -61,7 +61,7 @@ export const authMethodLanguage = { }; export interface CreateUserFormProps { - onSubmit: (user: TypesGen.CreateUserRequest) => void; + onSubmit: (user: TypesGen.CreateUserRequestWithOrgs) => void; onCancel: () => void; error?: unknown; isLoading: boolean; @@ -86,21 +86,20 @@ const validationSchema = Yup.object({ export const CreateUserForm: FC< React.PropsWithChildren > = ({ onSubmit, onCancel, error, isLoading, authMethods }) => { - const form: FormikContextType = - useFormik({ + const form: FormikContextType = + useFormik({ initialValues: { email: "", password: "", username: "", name: "", - organization_id: "00000000-0000-0000-0000-000000000000", - disable_login: false, + organization_ids: ["00000000-0000-0000-0000-000000000000"], login_type: "", }, validationSchema, onSubmit, }); - const getFieldHelpers = getFormHelpers( + const getFieldHelpers = getFormHelpers( form, error, );