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/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" ) // @Summary Get template available acl users/groups // @ID get-template-available-acl-usersgroups // @Security CoderSessionToken // @Produce json // @Tags Enterprise // @Param template path string true "Template ID" format(uuid) // @Success 200 {array} codersdk.ACLAvailable // @Router /templates/{template}/acl/available [get] func (api *API) templateAvailablePermissions(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() template = httpmw.TemplateParam(r) ) // Requires update permission on the template to list all avail users/groups // for assignment. if !api.Authorize(r, rbac.ActionUpdate, template) { httpapi.ResourceNotFound(rw) return } // We have to use the system restricted context here because the caller // might not have permission to read all users. // nolint:gocritic users, _, ok := api.AGPL.GetUsers(rw, r.WithContext(dbauthz.AsSystemRestricted(ctx))) if !ok { return } // Perm check is the template update check. // nolint:gocritic groups, err := api.Database.GetGroupsByOrganizationID(dbauthz.AsSystemRestricted(ctx), template.OrganizationID) if err != nil { httpapi.InternalServerError(rw, err) return } sdkGroups := make([]codersdk.Group, 0, len(groups)) for _, group := range groups { // nolint:gocritic members, err := api.Database.GetGroupMembers(dbauthz.AsSystemRestricted(ctx), group.ID) if err != nil { httpapi.InternalServerError(rw, err) return } sdkGroups = append(sdkGroups, db2sdk.Group(group, members)) } httpapi.Write(ctx, rw, http.StatusOK, codersdk.ACLAvailable{ // TODO: @emyrk we should return a MinimalUser here instead of a full user. // The FE requires the `email` field, so this cannot be done without // a UI change. Users: db2sdk.ReducedUsers(users), Groups: sdkGroups, }) } // @Summary Get template ACLs // @ID get-template-acls // @Security CoderSessionToken // @Produce json // @Tags Enterprise // @Param template path string true "Template ID" format(uuid) // @Success 200 {array} codersdk.TemplateUser // @Router /templates/{template}/acl [get] func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() template = httpmw.TemplateParam(r) ) users, err := api.Database.GetTemplateUserRoles(ctx, template.ID) if err != nil { httpapi.InternalServerError(rw, err) return } dbGroups, err := api.Database.GetTemplateGroupRoles(ctx, template.ID) if err != nil { httpapi.InternalServerError(rw, err) return } userIDs := make([]uuid.UUID, 0, len(users)) for _, user := range users { userIDs = append(userIDs, user.ID) } orgIDsByMemberIDsRows, err := api.Database.GetOrganizationIDsByMemberIDs(r.Context(), userIDs) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) return } organizationIDsByUserID := map[uuid.UUID][]uuid.UUID{} for _, organizationIDsByMemberIDsRow := range orgIDsByMemberIDsRows { organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs } groups := make([]codersdk.TemplateGroup, 0, len(dbGroups)) for _, group := range dbGroups { var members []database.User // This is a bit of a hack. The caller might not have permission to do this, // but they can read the acl list if the function got this far. So we let // them read the group members. // We should probably at least return more truncated user data here. // nolint:gocritic members, err = api.Database.GetGroupMembers(dbauthz.AsSystemRestricted(ctx), group.ID) if err != nil { httpapi.InternalServerError(rw, err) return } groups = append(groups, codersdk.TemplateGroup{ Group: db2sdk.Group(group.Group, members), Role: convertToTemplateRole(group.Actions), }) } httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateACL{ Users: convertTemplateUsers(users, organizationIDsByUserID), Groups: groups, }) } // @Summary Update template ACL // @ID update-template-acl // @Security CoderSessionToken // @Accept json // @Produce json // @Tags Enterprise // @Param template path string true "Template ID" format(uuid) // @Param request body codersdk.UpdateTemplateACL true "Update template request" // @Success 200 {object} codersdk.Response // @Router /templates/{template}/acl [patch] func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() template = httpmw.TemplateParam(r) auditor = api.AGPL.Auditor.Load() aReq, commitAudit = audit.InitRequest[database.Template](rw, &audit.RequestParams{ Audit: *auditor, Log: api.Logger, Request: r, Action: database.AuditActionWrite, OrganizationID: template.OrganizationID, }) ) defer commitAudit() aReq.Old = template var req codersdk.UpdateTemplateACL if !httpapi.Read(ctx, rw, r, &req) { return } validErrs := validateTemplateACLPerms(ctx, api.Database, req.UserPerms, "user_perms", true) validErrs = append(validErrs, validateTemplateACLPerms(ctx, api.Database, req.GroupPerms, "group_perms", false)...) if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid request to update template metadata!", Validations: validErrs, }) return } err := api.Database.InTx(func(tx database.Store) error { var err error template, err = tx.GetTemplateByID(ctx, template.ID) if err != nil { return xerrors.Errorf("get template by ID: %w", err) } if len(req.UserPerms) > 0 { for id, role := range req.UserPerms { // A user with an empty string implies // deletion. if role == "" { delete(template.UserACL, id) continue } template.UserACL[id] = convertSDKTemplateRole(role) } } if len(req.GroupPerms) > 0 { for id, role := range req.GroupPerms { // An id with an empty string implies // deletion. if role == "" { delete(template.GroupACL, id) continue } template.GroupACL[id] = convertSDKTemplateRole(role) } } err = tx.UpdateTemplateACLByID(ctx, database.UpdateTemplateACLByIDParams{ ID: template.ID, UserACL: template.UserACL, GroupACL: template.GroupACL, }) if err != nil { return xerrors.Errorf("update template ACL by ID: %w", err) } template, err = tx.GetTemplateByID(ctx, template.ID) if err != nil { return xerrors.Errorf("get updated template by ID: %w", err) } return nil }, nil) if err != nil { httpapi.InternalServerError(rw, err) return } aReq.New = template httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ Message: "Successfully updated template ACL list.", }) } // nolint TODO fix stupid flag. func validateTemplateACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.TemplateRole, field string, isUser bool) []codersdk.ValidationError { // Validate requires full read access to users and groups // nolint:gocritic ctx = dbauthz.AsSystemRestricted(ctx) var validErrs []codersdk.ValidationError for k, v := range perms { if err := validateTemplateRole(v); err != nil { validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: err.Error()}) continue } id, err := uuid.Parse(k) if err != nil { validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "ID " + k + "must be a valid UUID."}) continue } if isUser { // This could get slow if we get a ton of user perm updates. _, err = db.GetUserByID(ctx, id) if err != nil { validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", k, err.Error())}) continue } } else { // This could get slow if we get a ton of group perm updates. _, err = db.GetGroupByID(ctx, id) if err != nil { validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", k, err.Error())}) continue } } } return validErrs } func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid.UUID][]uuid.UUID) []codersdk.TemplateUser { users := make([]codersdk.TemplateUser, 0, len(tus)) for _, tu := range tus { users = append(users, codersdk.TemplateUser{ User: db2sdk.User(tu.User, orgIDsByUserIDs[tu.User.ID]), Role: convertToTemplateRole(tu.Actions), }) } return users } func validateTemplateRole(role codersdk.TemplateRole) error { actions := convertSDKTemplateRole(role) if actions == nil && role != codersdk.TemplateRoleDeleted { return xerrors.Errorf("role %q is not a valid Template role", role) } return nil } func convertToTemplateRole(actions []rbac.Action) codersdk.TemplateRole { switch { case len(actions) == 1 && actions[0] == rbac.ActionRead: return codersdk.TemplateRoleUse case len(actions) == 1 && actions[0] == rbac.WildcardSymbol: return codersdk.TemplateRoleAdmin } return "" } func convertSDKTemplateRole(role codersdk.TemplateRole) []rbac.Action { switch role { case codersdk.TemplateRoleAdmin: return []rbac.Action{rbac.WildcardSymbol} case codersdk.TemplateRoleUse: return []rbac.Action{rbac.ActionRead} } return nil } // TODO reduce the duplication across all of these. func (api *API) templateRBACEnabledMW(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { api.entitlementsMu.RLock() rbac := api.entitlements.Features[codersdk.FeatureTemplateRBAC].Enabled api.entitlementsMu.RUnlock() if !rbac { httpapi.RouteNotFound(rw) return } next.ServeHTTP(rw, r) }) } func (api *API) moonsEnabledMW(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { // Entitlement must be enabled. api.entitlementsMu.RLock() proxy := api.entitlements.Features[codersdk.FeatureWorkspaceProxy].Enabled api.entitlementsMu.RUnlock() if !proxy { httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ Message: "External workspace proxies is an Enterprise feature. Contact sales!", }) return } next.ServeHTTP(rw, r) }) }