package coderd import ( "context" "database/sql" "fmt" "net/http" "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" ) // @Summary Add organization member // @ID add-organization-member // @Security CoderSessionToken // @Produce json // @Tags Members // @Param organization path string true "Organization ID" // @Param user path string true "User ID, name, or me" // @Success 200 {object} codersdk.OrganizationMember // @Router /api/v2/organizations/{organization}/members/{user} [post] func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() organization = httpmw.OrganizationParam(r) user = httpmw.UserParam(r) auditor = api.Auditor.Load() aReq, commitAudit = audit.InitRequest[database.AuditableOrganizationMember](rw, &audit.RequestParams{ OrganizationID: organization.ID, Audit: *auditor, Log: api.Logger, Request: r, Action: database.AuditActionCreate, }) ) aReq.Old = database.AuditableOrganizationMember{} defer commitAudit() if !api.manualOrganizationMembership(ctx, rw, user) { return } member, err := api.Database.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ OrganizationID: organization.ID, UserID: user.ID, CreatedAt: dbtime.Now(), UpdatedAt: dbtime.Now(), Roles: []string{}, }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } if database.IsUniqueViolation(err, database.UniqueOrganizationMembersPkey) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "User is already an organization member", Detail: fmt.Sprintf("%s is already a member of %s", user.Username, organization.DisplayName), }) return } if err != nil { httpapi.InternalServerError(rw, err) return } aReq.New = member.Auditable(user.Username) resp, err := convertOrganizationMembers(ctx, api.Database, []database.OrganizationMember{member}) if err != nil { httpapi.InternalServerError(rw, err) return } if len(resp) == 0 { httpapi.InternalServerError(rw, xerrors.Errorf("marshal member")) return } httpapi.Write(ctx, rw, http.StatusOK, resp[0]) } // @Summary Remove organization member // @ID remove-organization-member // @Security CoderSessionToken // @Tags Members // @Param organization path string true "Organization ID" // @Param user path string true "User ID, name, or me" // @Success 204 // @Router /api/v2/organizations/{organization}/members/{user} [delete] func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() apiKey = httpmw.APIKey(r) organization = httpmw.OrganizationParam(r) member = httpmw.OrganizationMemberParam(r) auditor = api.Auditor.Load() aReq, commitAudit = audit.InitRequest[database.AuditableOrganizationMember](rw, &audit.RequestParams{ OrganizationID: organization.ID, Audit: *auditor, Log: api.Logger, Request: r, Action: database.AuditActionDelete, }) ) aReq.Old = member.OrganizationMember.Auditable(member.Username) defer commitAudit() // Note: we disallow adding OIDC users if organization sync is enabled. // For removing members, do not have this same enforcement. As long as a user // does not re-login, they will not be immediately removed from the organization. // There might be an urgent need to revoke access. // A user can re-login if they are removed in error. // If we add a feature to force logout a user, then we can prevent manual // member removal when organization sync is enabled, and use force logout instead. if member.UserID == apiKey.UserID { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{Message: "cannot remove self from an organization"}) return } err := api.Database.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{ OrganizationID: organization.ID, UserID: member.UserID, }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } if err != nil { httpapi.InternalServerError(rw, err) return } aReq.New = database.AuditableOrganizationMember{} rw.WriteHeader(http.StatusNoContent) } // @Summary Get organization member // @ID get-organization-member // @Security CoderSessionToken // @Tags Members // @Param organization path string true "Organization ID" // @Param user path string true "User ID, name, or me" // @Success 200 {object} codersdk.OrganizationMemberWithUserData // @Produce json // @Router /api/v2/organizations/{organization}/members/{user} [get] func (api *API) organizationMember(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() organization = httpmw.OrganizationParam(r) member = httpmw.OrganizationMemberParam(r) ) // This is unfortunate to fetch like this, but we need the user table data. // The listing route uses this data format, so it is just easier to reuse the // list query. rows, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ OrganizationID: organization.ID, UserID: member.UserID, IncludeSystem: false, GithubUserID: 0, }) if httpapi.Is404Error(err) || len(rows) == 0 { httpapi.ResourceNotFound(rw) return } if err != nil { httpapi.InternalServerError(rw, err) return } var aiSeatSet map[uuid.UUID]struct{} if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) { //nolint:gocritic // AI seat state is a system-level read gated by entitlement. aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, []uuid.UUID{member.UserID}) if err != nil { httpapi.InternalServerError(rw, err) return } } resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows, aiSeatSet) if err != nil { httpapi.InternalServerError(rw, err) return } if len(resp) != 1 { httpapi.InternalServerError(rw, xerrors.Errorf("unexpected organization members, something went wrong")) return } httpapi.Write(ctx, rw, http.StatusOK, resp[0]) } // @Deprecated use /organizations/{organization}/paginated-members [get] // @Summary List organization members // @ID list-organization-members // @Security CoderSessionToken // @Produce json // @Tags Members // @Param organization path string true "Organization ID" // @Success 200 {object} []codersdk.OrganizationMemberWithUserData // @Router /api/v2/organizations/{organization}/members [get] func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() organization = httpmw.OrganizationParam(r) ) params, errors := searchquery.Members(r.URL.Query().Get("q"), organization.ID) if len(errors) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid organization member search query.", Validations: errors, }) return } members, err := api.Database.OrganizationMembers(ctx, params) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } if err != nil { httpapi.InternalServerError(rw, err) return } userIDs := make([]uuid.UUID, 0, len(members)) for _, member := range members { userIDs = append(userIDs, member.OrganizationMember.UserID) } var aiSeatSet map[uuid.UUID]struct{} if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) { //nolint:gocritic // AI seat state is a system-level read gated by entitlement. aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, userIDs) if err != nil { httpapi.InternalServerError(rw, err) return } } resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, members, aiSeatSet) if err != nil { httpapi.InternalServerError(rw, err) return } httpapi.Write(ctx, rw, http.StatusOK, resp) } // @Summary Paginated organization members // @ID paginated-organization-members // @Security CoderSessionToken // @Produce json // @Tags Members // @Param organization path string true "Organization ID" // @Param q query string false "Member search query" // @Param after_id query string false "After ID" format(uuid) // @Param limit query int false "Page limit, if 0 returns all members" // @Param offset query int false "Page offset" // @Success 200 {object} []codersdk.PaginatedMembersResponse // @Router /api/v2/organizations/{organization}/paginated-members [get] func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() organization = httpmw.OrganizationParam(r) ) filterQuery := r.URL.Query().Get("q") userFilterParams, filterErrs := searchquery.Users(filterQuery) if len(filterErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid member search query.", Validations: filterErrs, }) return } paginationParams, ok := ParsePagination(rw, r) if !ok { return } paginatedMemberRows, err := api.Database.PaginatedOrganizationMembers(ctx, database.PaginatedOrganizationMembersParams{ AfterID: paginationParams.AfterID, OrganizationID: organization.ID, IncludeSystem: false, Search: userFilterParams.Search, Name: userFilterParams.Name, Status: userFilterParams.Status, IsServiceAccount: userFilterParams.IsServiceAccount, RbacRole: userFilterParams.RbacRole, LastSeenBefore: userFilterParams.LastSeenBefore, LastSeenAfter: userFilterParams.LastSeenAfter, CreatedAfter: userFilterParams.CreatedAfter, CreatedBefore: userFilterParams.CreatedBefore, GithubComUserID: userFilterParams.GithubComUserID, LoginType: userFilterParams.LoginType, // #nosec G115 - Pagination offsets are small and fit in int32 OffsetOpt: int32(paginationParams.Offset), // #nosec G115 - Pagination limits are small and fit in int32 LimitOpt: int32(paginationParams.Limit), }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } if err != nil { httpapi.InternalServerError(rw, err) return } memberRows := make([]database.OrganizationMembersRow, len(paginatedMemberRows)) for i, pRow := range paginatedMemberRows { memberRows[i] = database.OrganizationMembersRow{ OrganizationMember: pRow.OrganizationMember, Username: pRow.Username, AvatarURL: pRow.AvatarURL, Name: pRow.Name, Email: pRow.Email, GlobalRoles: pRow.GlobalRoles, LastSeenAt: pRow.LastSeenAt, Status: pRow.Status, IsServiceAccount: pRow.IsServiceAccount, LoginType: pRow.LoginType, UserCreatedAt: pRow.UserCreatedAt, UserUpdatedAt: pRow.UserUpdatedAt, } } if len(paginatedMemberRows) == 0 { httpapi.Write(ctx, rw, http.StatusOK, codersdk.PaginatedMembersResponse{ Members: []codersdk.OrganizationMemberWithUserData{}, Count: 0, }) return } userIDs := make([]uuid.UUID, 0, len(memberRows)) for _, member := range memberRows { userIDs = append(userIDs, member.OrganizationMember.UserID) } var aiSeatSet map[uuid.UUID]struct{} if api.Entitlements.Enabled(codersdk.FeatureAIGovernanceUserLimit) { //nolint:gocritic // AI seat state is a system-level read gated by entitlement. aiSeatSet, err = getAISeatSetByUserIDs(dbauthz.AsSystemRestricted(ctx), api.Database, userIDs) if err != nil { httpapi.InternalServerError(rw, err) return } } members, err := convertOrganizationMembersWithUserData(ctx, api.Database, memberRows, aiSeatSet) if err != nil { httpapi.InternalServerError(rw, err) return } resp := codersdk.PaginatedMembersResponse{ Members: members, Count: int(paginatedMemberRows[0].Count), } httpapi.Write(ctx, rw, http.StatusOK, resp) } func getAISeatSetByUserIDs(ctx context.Context, db database.Store, userIDs []uuid.UUID) (map[uuid.UUID]struct{}, error) { aiSeatUserIDs, err := db.GetUserAISeatStates(ctx, userIDs) if xerrors.Is(err, sql.ErrNoRows) { err = nil } if err != nil { return nil, err } aiSeatSet := make(map[uuid.UUID]struct{}, len(aiSeatUserIDs)) for _, uid := range aiSeatUserIDs { aiSeatSet[uid] = struct{}{} } return aiSeatSet, nil } // @Summary Assign role to organization member // @ID assign-role-to-organization-member // @Security CoderSessionToken // @Accept json // @Produce json // @Tags Members // @Param organization path string true "Organization ID" // @Param user path string true "User ID, name, or me" // @Param request body codersdk.UpdateRoles true "Update roles request" // @Success 200 {object} codersdk.OrganizationMember // @Router /api/v2/organizations/{organization}/members/{user}/roles [put] func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() organization = httpmw.OrganizationParam(r) member = httpmw.OrganizationMemberParam(r) apiKey = httpmw.APIKey(r) auditor = api.Auditor.Load() aReq, commitAudit = audit.InitRequest[database.AuditableOrganizationMember](rw, &audit.RequestParams{ OrganizationID: organization.ID, Audit: *auditor, Log: api.Logger, Request: r, Action: database.AuditActionWrite, }) ) aReq.Old = member.OrganizationMember.Auditable(member.Username) defer commitAudit() // Check if changing roles is allowed if !api.allowChangingMemberRoles(ctx, rw, member, organization) { return } if apiKey.UserID == member.OrganizationMember.UserID { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "You cannot change your own organization roles.", Detail: "Another user with the appropriate permissions must change your roles.", }) return } var params codersdk.UpdateRoles if !httpapi.Read(ctx, rw, r, ¶ms) { return } updatedUser, err := api.Database.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{ GrantedRoles: params.Roles, UserID: member.UserID, OrgID: organization.ID, }) if httpapi.Is404Error(err) { httpapi.Forbidden(rw) return } if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: err.Error(), }) return } aReq.New = database.AuditableOrganizationMember{ OrganizationMember: updatedUser, Username: member.Username, } resp, err := convertOrganizationMembers(ctx, api.Database, []database.OrganizationMember{updatedUser}) if err != nil { httpapi.InternalServerError(rw, err) return } if len(resp) != 1 { httpapi.InternalServerError(rw, xerrors.Errorf("failed to serialize member to response, update still succeeded")) return } httpapi.Write(ctx, rw, http.StatusOK, resp[0]) } func (api *API) allowChangingMemberRoles(ctx context.Context, rw http.ResponseWriter, member httpmw.OrganizationMember, organization database.Organization) bool { // nolint:gocritic // The caller could be an org admin without this perm. // We need to disable manual role assignment if role sync is enabled for // the given organization. user, err := api.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), member.UserID) if err != nil { httpapi.InternalServerError(rw, err) return false } if user.LoginType == database.LoginTypeOIDC { // nolint:gocritic // fetching settings orgSync, err := api.IDPSync.OrganizationRoleSyncEnabled(dbauthz.AsSystemRestricted(ctx), api.Database, organization.ID) if err != nil { httpapi.InternalServerError(rw, err) return false } if orgSync { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Cannot modify roles for OIDC users when role sync is enabled. This organization member's roles are managed by the identity provider. Have the user re-login to refresh their roles.", Detail: "'User Role Field' is set in the organization settings. Ask an administrator to adjust or disable these settings.", }) return false } } return true } // convertOrganizationMembers batches the role lookup to make only 1 sql call // We func convertOrganizationMembers(ctx context.Context, db database.Store, mems []database.OrganizationMember) ([]codersdk.OrganizationMember, error) { converted := make([]codersdk.OrganizationMember, 0, len(mems)) roleLookup := make([]database.NameOrganizationPair, 0) for _, m := range mems { converted = append(converted, codersdk.OrganizationMember{ UserID: m.UserID, OrganizationID: m.OrganizationID, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, Roles: slice.List(m.Roles, func(r string) codersdk.SlimRole { // If it is a built-in role, no lookups are needed. rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: r, OrganizationID: m.OrganizationID}) if err == nil { return db2sdk.SlimRole(rbacRole) } // We know the role name and the organization ID. We are missing the // display name. Append the lookup parameter, so we can get the display name roleLookup = append(roleLookup, database.NameOrganizationPair{ Name: r, OrganizationID: m.OrganizationID, }) return codersdk.SlimRole{ Name: r, DisplayName: "", OrganizationID: m.OrganizationID.String(), } }), }) } customRoles, err := db.CustomRoles(ctx, database.CustomRolesParams{ LookupRoles: roleLookup, ExcludeOrgRoles: false, OrganizationID: uuid.Nil, IncludeSystemRoles: false, }) if err != nil { // We are missing the display names, but that is not absolutely required. So just // return the converted and the names will be used instead of the display names. return converted, xerrors.Errorf("lookup custom roles: %w", err) } // Now map the customRoles back to the slimRoles for their display name. customRolesMap := make(map[string]database.CustomRole) for _, role := range customRoles { customRolesMap[role.RoleIdentifier().UniqueName()] = role } for i := range converted { for j, role := range converted[i].Roles { if cr, ok := customRolesMap[role.UniqueName()]; ok { converted[i].Roles[j].DisplayName = cr.DisplayName } } } return converted, nil } func convertOrganizationMembersWithUserData(ctx context.Context, db database.Store, rows []database.OrganizationMembersRow, aiSeatSet map[uuid.UUID]struct{}) ([]codersdk.OrganizationMemberWithUserData, error) { members := make([]database.OrganizationMember, 0) for _, row := range rows { members = append(members, row.OrganizationMember) } convertedMembers, err := convertOrganizationMembers(ctx, db, members) if err != nil { return nil, err } if len(convertedMembers) != len(rows) { return nil, xerrors.Errorf("conversion failed, mismatch slice lengths") } converted := make([]codersdk.OrganizationMemberWithUserData, 0) for i := range convertedMembers { _, hasAISeat := aiSeatSet[rows[i].OrganizationMember.UserID] converted = append(converted, codersdk.OrganizationMemberWithUserData{ Username: rows[i].Username, AvatarURL: rows[i].AvatarURL, Name: rows[i].Name, Email: rows[i].Email, GlobalRoles: db2sdk.SlimRolesFromNames(rows[i].GlobalRoles), HasAISeat: hasAISeat, LastSeenAt: rows[i].LastSeenAt, Status: codersdk.UserStatus(rows[i].Status), IsServiceAccount: rows[i].IsServiceAccount, LoginType: codersdk.LoginType(rows[i].LoginType), UserCreatedAt: rows[i].UserCreatedAt, UserUpdatedAt: rows[i].UserUpdatedAt, OrganizationMember: convertedMembers[i], }) } return converted, nil } // manualOrganizationMembership checks if the user is an OIDC user and if organization sync is enabled. // If organization sync is enabled, manual organization assignment is not allowed, // since all organization membership is controlled by the external IDP. func (api *API) manualOrganizationMembership(ctx context.Context, rw http.ResponseWriter, user database.User) bool { if user.LoginType == database.LoginTypeOIDC && api.IDPSync.OrganizationSyncEnabled(ctx, api.Database) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Organization sync is enabled for OIDC users, meaning manual organization assignment is not allowed for this user. Have the user re-login to refresh their organizations.", Detail: fmt.Sprintf("User %s is an OIDC user and organization sync is enabled. Ask an administrator to resolve the membership in your external IDP.", user.Username), }) return false } return true }