mirror of https://github.com/coder/coder.git
422 lines
12 KiB
Go
422 lines
12 KiB
Go
package coderd
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd"
|
|
"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/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// @Summary Create group for organization
|
|
// @ID create-group-for-organization
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param request body codersdk.CreateGroupRequest true "Create group request"
|
|
// @Param organization path string true "Organization ID"
|
|
// @Success 201 {object} codersdk.Group
|
|
// @Router /organizations/{organization}/groups [post]
|
|
func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
org = httpmw.OrganizationParam(r)
|
|
auditor = api.AGPL.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.AuditableGroup](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionCreate,
|
|
OrganizationID: org.ID,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
var req codersdk.CreateGroupRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Name == database.EveryoneGroup {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid group name.",
|
|
Validations: []codersdk.ValidationError{{Field: "name", Detail: fmt.Sprintf("%q is a reserved group name", req.Name)}},
|
|
})
|
|
return
|
|
}
|
|
|
|
group, err := api.Database.InsertGroup(ctx, database.InsertGroupParams{
|
|
ID: uuid.New(),
|
|
Name: req.Name,
|
|
DisplayName: req.DisplayName,
|
|
OrganizationID: org.ID,
|
|
AvatarURL: req.AvatarURL,
|
|
QuotaAllowance: int32(req.QuotaAllowance),
|
|
})
|
|
if database.IsUniqueViolation(err) {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: fmt.Sprintf("A group named %q already exists.", req.Name),
|
|
Validations: []codersdk.ValidationError{{Field: "name", Detail: "Group names must be unique"}},
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
var emptyUsers []database.User
|
|
aReq.New = group.Auditable(emptyUsers)
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.Group(group, nil))
|
|
}
|
|
|
|
// @Summary Update group by name
|
|
// @ID update-group-by-name
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param group path string true "Group name"
|
|
// @Param request body codersdk.PatchGroupRequest true "Patch group request"
|
|
// @Success 200 {object} codersdk.Group
|
|
// @Router /groups/{group} [patch]
|
|
func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
group = httpmw.GroupParam(r)
|
|
auditor = api.AGPL.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.AuditableGroup](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
OrganizationID: group.OrganizationID,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
var req codersdk.PatchGroupRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
// If the name matches the existing group name pretend we aren't
|
|
// updating the name at all.
|
|
if req.Name == group.Name {
|
|
req.Name = ""
|
|
}
|
|
|
|
if group.IsEveryone() && req.Name != "" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Cannot rename the %q group!", database.EveryoneGroup),
|
|
})
|
|
return
|
|
}
|
|
|
|
if group.IsEveryone() && (req.DisplayName != nil && *req.DisplayName != "") {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Cannot update the Display Name for the %q group!", database.EveryoneGroup),
|
|
})
|
|
return
|
|
}
|
|
|
|
if req.Name == database.EveryoneGroup {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("%q is a reserved group name!", database.EveryoneGroup),
|
|
})
|
|
return
|
|
}
|
|
|
|
users := make([]string, 0, len(req.AddUsers)+len(req.RemoveUsers))
|
|
users = append(users, req.AddUsers...)
|
|
users = append(users, req.RemoveUsers...)
|
|
|
|
if len(users) > 0 && group.Name == database.EveryoneGroup {
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
|
Message: fmt.Sprintf("Cannot add or remove users from the %q group!", database.EveryoneGroup),
|
|
})
|
|
return
|
|
}
|
|
|
|
currentMembers, err := api.Database.GetGroupMembers(ctx, group.ID)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
aReq.Old = group.Auditable(currentMembers)
|
|
|
|
for _, id := range users {
|
|
if _, err := uuid.Parse(id); err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("ID %q must be a valid user UUID.", id),
|
|
})
|
|
return
|
|
}
|
|
// TODO: It would be nice to enforce this at the schema level
|
|
// but unfortunately our org_members table does not have an ID.
|
|
_, err := api.Database.GetOrganizationMemberByUserID(ctx, database.GetOrganizationMemberByUserIDParams{
|
|
OrganizationID: group.OrganizationID,
|
|
UserID: uuid.MustParse(id),
|
|
})
|
|
if xerrors.Is(err, sql.ErrNoRows) {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("User %q must be a member of organization %q", id, group.ID),
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if req.Name != "" && req.Name != group.Name {
|
|
_, err := api.Database.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{
|
|
OrganizationID: group.OrganizationID,
|
|
Name: req.Name,
|
|
})
|
|
if err == nil {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: fmt.Sprintf("A group with name %q already exists.", req.Name),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
err = database.ReadModifyUpdate(api.Database, func(tx database.Store) error {
|
|
group, err = tx.GetGroupByID(ctx, group.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("get group by ID: %w", err)
|
|
}
|
|
|
|
updateGroupParams := database.UpdateGroupByIDParams{
|
|
ID: group.ID,
|
|
AvatarURL: group.AvatarURL,
|
|
Name: group.Name,
|
|
DisplayName: group.DisplayName,
|
|
QuotaAllowance: group.QuotaAllowance,
|
|
}
|
|
|
|
// TODO: Do we care about validating this?
|
|
if req.AvatarURL != nil {
|
|
updateGroupParams.AvatarURL = *req.AvatarURL
|
|
}
|
|
if req.Name != "" {
|
|
updateGroupParams.Name = req.Name
|
|
}
|
|
if req.QuotaAllowance != nil {
|
|
updateGroupParams.QuotaAllowance = int32(*req.QuotaAllowance)
|
|
}
|
|
if req.DisplayName != nil {
|
|
updateGroupParams.DisplayName = *req.DisplayName
|
|
}
|
|
|
|
group, err = tx.UpdateGroupByID(ctx, updateGroupParams)
|
|
if err != nil {
|
|
return xerrors.Errorf("update group by ID: %w", err)
|
|
}
|
|
|
|
for _, id := range req.AddUsers {
|
|
userID, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return xerrors.Errorf("parse user ID %q: %w", id, err)
|
|
}
|
|
err = tx.InsertGroupMember(ctx, database.InsertGroupMemberParams{
|
|
GroupID: group.ID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert group member %q: %w", id, err)
|
|
}
|
|
}
|
|
for _, id := range req.RemoveUsers {
|
|
userID, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return xerrors.Errorf("parse user ID %q: %w", id, err)
|
|
}
|
|
err = tx.DeleteGroupMemberFromGroup(ctx, database.DeleteGroupMemberFromGroupParams{
|
|
UserID: userID,
|
|
GroupID: group.ID,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert group member %q: %w", id, err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if database.IsUniqueViolation(err) {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Cannot add the same user to a group twice!",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Failed to add or remove non-existent group member",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
patchedMembers, err := api.Database.GetGroupMembers(ctx, group.ID)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
aReq.New = group.Auditable(patchedMembers)
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Group(group, patchedMembers))
|
|
}
|
|
|
|
// @Summary Delete group by name
|
|
// @ID delete-group-by-name
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param group path string true "Group name"
|
|
// @Success 200 {object} codersdk.Group
|
|
// @Router /groups/{group} [delete]
|
|
func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
group = httpmw.GroupParam(r)
|
|
auditor = api.AGPL.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.AuditableGroup](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionDelete,
|
|
OrganizationID: group.OrganizationID,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
if group.Name == database.EveryoneGroup {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("%q is a reserved group and cannot be deleted!", database.EveryoneGroup),
|
|
})
|
|
return
|
|
}
|
|
|
|
groupMembers, getMembersErr := api.Database.GetGroupMembers(ctx, group.ID)
|
|
if getMembersErr != nil {
|
|
httpapi.InternalServerError(rw, getMembersErr)
|
|
return
|
|
}
|
|
|
|
aReq.Old = group.Auditable(groupMembers)
|
|
|
|
err := api.Database.DeleteGroupByID(ctx, group.ID)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
|
Message: "Successfully deleted group!",
|
|
})
|
|
}
|
|
|
|
// @Summary Get group by organization and group name
|
|
// @ID get-group-by-organization-and-group-name
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param organization path string true "Organization ID" format(uuid)
|
|
// @Param groupName path string true "Group name"
|
|
// @Success 200 {object} codersdk.Group
|
|
// @Router /organizations/{organization}/groups/{groupName} [get]
|
|
func (api *API) groupByOrganization(rw http.ResponseWriter, r *http.Request) {
|
|
api.group(rw, r)
|
|
}
|
|
|
|
// @Summary Get group by ID
|
|
// @ID get-group-by-id
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param group path string true "Group id"
|
|
// @Success 200 {object} codersdk.Group
|
|
// @Router /groups/{group} [get]
|
|
func (api *API) group(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
group = httpmw.GroupParam(r)
|
|
)
|
|
|
|
users, err := api.Database.GetGroupMembers(ctx, group.ID)
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Group(group, users))
|
|
}
|
|
|
|
// @Summary Get groups by organization
|
|
// @ID get-groups-by-organization
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Enterprise
|
|
// @Param organization path string true "Organization ID" format(uuid)
|
|
// @Success 200 {array} codersdk.Group
|
|
// @Router /organizations/{organization}/groups [get]
|
|
func (api *API) groupsByOrganization(rw http.ResponseWriter, r *http.Request) {
|
|
api.groups(rw, r)
|
|
}
|
|
|
|
func (api *API) groups(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
org = httpmw.OrganizationParam(r)
|
|
)
|
|
|
|
groups, err := api.Database.GetGroupsByOrganizationID(ctx, org.ID)
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
// Filter groups based on rbac permissions
|
|
groups, err = coderd.AuthorizeFilter(api.AGPL.HTTPAuth, r, rbac.ActionRead, groups)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching groups.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
resp := make([]codersdk.Group, 0, len(groups))
|
|
for _, group := range groups {
|
|
members, err := api.Database.GetGroupMembers(ctx, group.ID)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
resp = append(resp, db2sdk.Group(group, members))
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
|
}
|