Files
coder/enterprise/coderd/groups.go
Danny Kopping 4c33846f6d chore: add prebuilds system user (#16916)
Pre-requisite for https://github.com/coder/coder/pull/16891

Closes https://github.com/coder/internal/issues/515

This PR introduces a new concept of a "system" user.

Our data model requires that all workspaces have an owner (a `users`
relation), and prebuilds is a feature that will spin up workspaces to be
claimed later by actual users - and thus needs to own the workspaces in
the interim.

Naturally, introducing a change like this touches a few aspects around
the codebase and we've taken the approach _default hidden_ here; in
other words, queries for users will by default _exclude_ all system
users, but there is a flag to ensure they can be displayed. This keeps
the changeset relatively small.

This user has minimal permissions (it's equivalent to a `member` since
it has no roles). It will be associated with the default org in the
initial migration, and thereafter we'll need to somehow ensure its
membership aligns with templates (which are org-scoped) for which it'll
need to provision prebuilds; that's a solution we'll have in a
subsequent PR.

---------

Signed-off-by: Danny Kopping <dannykopping@gmail.com>
Co-authored-by: Sas Swart <sas.swart.cdk@gmail.com>
2025-03-25 12:18:06 +00:00

527 lines
15 KiB
Go

package coderd
import (
"database/sql"
"errors"
"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/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"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 emptyMembers []database.GroupMember
aReq.New = group.Auditable(emptyMembers)
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.Group(database.GetGroupsRow{
Group: group,
OrganizationName: org.Name,
OrganizationDisplayName: org.DisplayName,
}, nil, 0))
}
// @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.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
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
}
_, err := database.ExpectOne(api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: group.OrganizationID,
UserID: uuid.MustParse(id),
IncludeSystem: false,
}))
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("User must be a member of organization %q", group.Name),
})
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
}
org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID)
if err != nil {
httpapi.InternalServerError(rw, err)
}
patchedMembers, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
aReq.New = group.Auditable(patchedMembers)
memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, database.GetGroupMembersCountByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Group(database.GetGroupsRow{
Group: group,
OrganizationName: org.Name,
OrganizationDisplayName: org.DisplayName,
}, patchedMembers, int(memberCount)))
}
// @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.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
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)
)
org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID)
if err != nil {
httpapi.InternalServerError(rw, err)
}
users, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
}
memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, database.GetGroupMembersCountByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Group(database.GetGroupsRow{
Group: group,
OrganizationName: org.Name,
OrganizationDisplayName: org.DisplayName,
}, users, int(memberCount)))
}
// @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) {
org := httpmw.OrganizationParam(r)
values := r.URL.Query()
values.Set("organization", org.ID.String())
r.URL.RawQuery = values.Encode()
api.groups(rw, r)
}
// @Summary Get groups
// @ID get-groups
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param organization query string true "Organization ID or name"
// @Param has_member query string true "User ID or name"
// @Param group_ids query string true "Comma separated list of group IDs"
// @Success 200 {array} codersdk.Group
// @Router /groups [get]
func (api *API) groups(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var filter database.GetGroupsParams
parser := httpapi.NewQueryParamParser()
// Organization selector can be an org ID or name
filter.OrganizationID = parser.UUIDorName(r.URL.Query(), uuid.Nil, "organization", func(orgName string) (uuid.UUID, error) {
org, err := api.Database.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{
Name: orgName,
Deleted: false,
})
if err != nil {
return uuid.Nil, xerrors.Errorf("organization %q not found", orgName)
}
return org.ID, nil
})
// has_member selector can be a user ID or username
filter.HasMemberID = parser.UUIDorName(r.URL.Query(), uuid.Nil, "has_member", func(username string) (uuid.UUID, error) {
user, err := api.Database.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
Username: username,
Email: "",
})
if err != nil {
return uuid.Nil, xerrors.Errorf("user %q not found", username)
}
return user.ID, nil
})
filter.GroupIds = parser.UUIDs(r.URL.Query(), []uuid.UUID{}, "group_ids")
parser.ErrorExcessParams(r.URL.Query())
if len(parser.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return
}
groups, err := api.Database.GetGroups(ctx, filter)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
resp := make([]codersdk.Group, 0, len(groups))
for _, group := range groups {
members, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.Group.ID,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, database.GetGroupMembersCountByGroupIDParams{
GroupID: group.Group.ID,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
resp = append(resp, db2sdk.Group(group, members, int(memberCount)))
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}