mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
Not including an error detail asks the user to check the dev console. Which is unhelpful in this expected situation
375 lines
12 KiB
Go
375 lines
12 KiB
Go
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/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/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 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()
|
|
|
|
// 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.",
|
|
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: 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
|
|
}
|