package coderd import ( "context" "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/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/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 /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 user.LoginType == database.LoginTypeOIDC && api.IDPSync.OrganizationSyncEnabled() { 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.", Detail: fmt.Sprintf("User %s is an OIDC user and organization sync is enabled. Ask an administrator to resolve this in your external IDP.", user.ID), }) 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: "Organization member already exists in this organization", }) 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 /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() if organization.IsDefault { // Multi-organizations is currently an experiment, which means it is feasible // for a deployment to enable, then disable this. To maintain backwards // compatibility, this safety is necessary. // TODO: Remove this check when multi-organizations is fully supported. httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Removing members from the default organization is not supported.", Detail: "Multi-organizations is currently an experiment, and until it is fully supported, the default org should be protected.", Validations: nil, }) return } 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 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 /organizations/{organization}/members [get] func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() organization = httpmw.OrganizationParam(r) ) members, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ OrganizationID: organization.ID, UserID: uuid.Nil, }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return } if err != nil { httpapi.InternalServerError(rw, err) return } resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, members) if err != nil { httpapi.InternalServerError(rw, err) return } httpapi.Write(ctx, rw, http.StatusOK, resp) } // @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 /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() if apiKey.UserID == member.OrganizationMember.UserID { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "You cannot change your own organization 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]) } // 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: db2sdk.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.UUID{}, }) 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) ([]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 { 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), OrganizationMember: convertedMembers[i], }) } return converted, nil }