incus-mirror/internal/server/project/permissions.go
Stéphane Graber 8066e7707c
incusd/project: Enforce isolated restriction when idmap key omitted
restricted.containers.privilege=isolated only rejected an explicit
security.idmap.isolated=false, but the key defaults to non-isolated when
omitted. Require containers to explicitly enable isolation.

This addresses CVE-2026-62313

Signed-off-by: Stéphane Graber <stgraber@stgraber.org>
2026-07-30 09:45:42 -04:00

1936 lines
54 KiB
Go

package project
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/lxc/incus/v7/internal/instance"
"github.com/lxc/incus/v7/internal/server/auth"
"github.com/lxc/incus/v7/internal/server/db"
"github.com/lxc/incus/v7/internal/server/db/cluster"
deviceconfig "github.com/lxc/incus/v7/internal/server/device/config"
"github.com/lxc/incus/v7/internal/server/instance/instancetype"
"github.com/lxc/incus/v7/shared/api"
"github.com/lxc/incus/v7/shared/idmap"
"github.com/lxc/incus/v7/shared/units"
"github.com/lxc/incus/v7/shared/util"
)
// HiddenStoragePools returns a list of storage pools that should be hidden from users of the project.
func HiddenStoragePools(ctx context.Context, tx *db.ClusterTx, projectName string, allPoolNames []string) ([]string, error) {
dbProject, err := cluster.GetProject(ctx, tx.Tx(), projectName)
if err != nil {
return nil, fmt.Errorf("Failed getting project: %w", err)
}
project, err := dbProject.ToAPI(ctx, tx.Tx())
if err != nil {
return nil, err
}
hiddenPools := []string{}
for _, poolName := range allPoolNames {
if !StoragePoolAllowed(project.Config, poolName) {
hiddenPools = append(hiddenPools, poolName)
}
}
return hiddenPools, nil
}
// AllowImageDownload returns an error if any project-specific restriction is violated when downloading a new image.
func AllowImageDownload(tx *db.ClusterTx, projectName string, uri string) error {
info, err := fetchProject(tx, projectName, true)
if err != nil {
return err
}
if info == nil {
return nil
}
// Check if we have image server restrictions.
if util.IsTrue(info.Project.Config["restricted"]) && info.Project.Config["restricted.images.servers"] != "" {
u, err := url.Parse(uri)
if err != nil {
return err
}
servers := util.SplitNTrimSpace(info.Project.Config["restricted.images.servers"], ",", -1, false)
if !slices.Contains(servers, u.Host) {
return fmt.Errorf("Image server %q isn't allowed in this project", u.Host)
}
}
return nil
}
// AllowInstanceCreation returns an error if any project-specific limit or
// restriction is violated when creating a new instance.
func AllowInstanceCreation(tx *db.ClusterTx, projectName string, req api.InstancesPost) error {
info, err := fetchProject(tx, projectName, true)
if err != nil {
return err
}
if info == nil {
return nil
}
var instanceType instancetype.Type
switch req.Type {
case api.InstanceTypeContainer:
instanceType = instancetype.Container
case api.InstanceTypeVM:
instanceType = instancetype.VM
default:
return fmt.Errorf("Unexpected instance type %q", req.Type)
}
if req.Profiles == nil {
req.Profiles = []string{"default"}
}
if util.IsTrue(info.Project.Config["restricted"]) {
// Restricted projects aren't allowed to use pull migration.
if req.Source.Type == "migration" && req.Source.Mode == "pull" {
return errors.New("Restricted projects aren't allowed to use pull mode migration")
}
// Check if we have image server restrictions.
if req.Source.Type == "image" && info.Project.Config["restricted.images.servers"] != "" {
u, err := url.Parse(req.Source.Server)
if err != nil {
return err
}
servers := util.SplitNTrimSpace(info.Project.Config["restricted.images.servers"], ",", -1, false)
if !slices.Contains(servers, u.Host) {
return fmt.Errorf("Image server %q isn't allowed in this project", u.Host)
}
}
}
err = checkInstanceCountLimit(info, instanceType)
if err != nil {
return err
}
err = checkTotalInstanceCountLimit(info)
if err != nil {
return err
}
// Add the instance being created.
info.Instances = append(info.Instances, api.Instance{
Name: req.Name,
Project: projectName,
InstancePut: req.InstancePut,
Type: string(req.Type),
})
// Special case restriction checks on volatile.* keys.
strip := false // nolint:staticcheck
if slices.Contains([]string{"copy", "migration"}, req.Source.Type) {
// Allow stripping volatile keys if dealing with a copy or migration.
strip = true
}
err = checkRestrictionsOnVolatileConfig(
info.Project, instanceType, req.Name, req.Config, map[string]string{}, strip,
)
if err != nil {
return err
}
err = checkRestrictionsAndAggregateLimits(tx, info)
if err != nil {
return fmt.Errorf("Failed checking if instance creation allowed: %w", err)
}
return nil
}
// Check that we have not exceeded the maximum total allotted number of instances for both containers and vms.
func checkTotalInstanceCountLimit(info *projectInfo) error {
count, limit, err := getTotalInstanceCountLimit(info)
if err != nil {
return err
}
if limit >= 0 && count >= limit {
return fmt.Errorf("Reached maximum number of instances in project %q", info.Project.Name)
}
return nil
}
func getTotalInstanceCountLimit(info *projectInfo) (int, int, error) {
overallValue, ok := info.Project.Config["limits.instances"]
if ok {
limit, err := strconv.Atoi(overallValue)
if err != nil {
return -1, -1, err
}
return len(info.Instances), limit, nil
}
return len(info.Instances), -1, nil
}
// Check that we have not reached the maximum number of instances for this type.
func checkInstanceCountLimit(info *projectInfo, instanceType instancetype.Type) error {
count, limit, err := getInstanceCountLimit(info, instanceType)
if err != nil {
return err
}
if limit >= 0 && count >= limit {
return fmt.Errorf("Reached maximum number of instances of type %q in project %q", instanceType, info.Project.Name)
}
return nil
}
func getInstanceCountLimit(info *projectInfo, instanceType instancetype.Type) (int, int, error) {
var key string
switch instanceType {
case instancetype.Container:
key = "limits.containers"
case instancetype.VM:
key = "limits.virtual-machines"
default:
return -1, -1, fmt.Errorf("Unexpected instance type %q", instanceType)
}
instanceCount := 0
for _, inst := range info.Instances {
if inst.Type == instanceType.String() {
instanceCount++
}
}
value, ok := info.Project.Config[key]
if ok {
limit, err := strconv.Atoi(value)
if err != nil || limit < 0 {
return -1, -1, fmt.Errorf("Unexpected %q value: %q", key, value)
}
return instanceCount, limit, nil
}
return instanceCount, -1, nil
}
// Check restrictions on setting volatile.* keys.
func checkRestrictionsOnVolatileConfig(project api.Project, instanceType instancetype.Type, instanceName string, config, currentConfig map[string]string, strip bool) error {
if project.Config["restrict"] == "false" {
return nil
}
var restrictedLowLevel string
switch instanceType {
case instancetype.Container:
restrictedLowLevel = "restricted.containers.lowlevel"
case instancetype.VM:
restrictedLowLevel = "restricted.virtual-machines.lowlevel"
}
if project.Config[restrictedLowLevel] == "allow" {
return nil
}
// Checker for safe volatile keys.
isSafeKey := func(key string) bool {
if slices.Contains([]string{"volatile.apply_template", "volatile.base_image", "volatile.last_state.power"}, key) {
return true
}
if key == "volatile.selinux.context" {
return true
}
if strings.HasPrefix(key, instance.ConfigVolatilePrefix) {
if strings.HasSuffix(key, ".apply_quota") {
return true
}
if strings.HasSuffix(key, ".hwaddr") {
return true
}
}
return false
}
for key, value := range config {
if !strings.HasPrefix(key, instance.ConfigVolatilePrefix) {
continue
}
// Allow given safe volatile keys to be set
if isSafeKey(key) {
continue
}
if strip {
delete(config, key)
continue
}
currentValue, ok := currentConfig[key]
if !ok {
return fmt.Errorf("Setting %q on %s %q in project %q is forbidden", key, instanceType, instanceName, project.Name)
}
if currentValue != value {
return fmt.Errorf("Changing %q on %s %q in project %q is forbidden", key, instanceType, instanceName, project.Name)
}
}
return nil
}
// AllowVolumeCreation returns an error if any project-specific limit or
// restriction is violated when creating a new custom volume in a project.
func AllowVolumeCreation(tx *db.ClusterTx, projectName string, poolName string, req api.StorageVolumesPost) error {
info, err := fetchProject(tx, projectName, true)
if err != nil {
return err
}
if info == nil {
return nil
}
// Restricted projects aren't allowed to use pull migration.
if util.IsTrue(info.Project.Config["restricted"]) && req.Source.Type == "migration" && req.Source.Mode == "pull" {
return errors.New("Restricted projects aren't allowed to use pull mode migration")
}
// Restricted projects can't override low-level volume options that are passed to
// filesystem tooling running as root; they may only use the pool's configured default.
if util.IsTrue(info.Project.Config["restricted"]) {
_, pool, _, err := tx.GetStoragePool(context.Background(), poolName)
if err != nil {
return err
}
if req.Config["block.create_options"] != "" && req.Config["block.create_options"] != pool.Config["volume.block.create_options"] {
return errors.New(`Storage volume option "block.create_options" cannot be set in a restricted project`)
}
}
// Add the volume being created.
info.Volumes = append(info.Volumes, db.StorageVolumeArgs{
Name: req.Name,
Config: req.Config,
PoolName: poolName,
})
err = checkRestrictionsAndAggregateLimits(tx, info)
if err != nil {
return fmt.Errorf("Failed checking if volume creation allowed: %w", err)
}
return nil
}
// GetImageSpaceBudget returns how much disk space is left in the given project
// for writing images.
//
// If no limit is in place, return -1.
func GetImageSpaceBudget(tx *db.ClusterTx, projectName string) (int64, error) {
info, err := fetchProject(tx, projectName, true)
if err != nil {
return -1, err
}
if info == nil {
return -1, nil
}
// If "features.images" is not enabled, the budget is unlimited.
if util.IsFalse(info.Project.Config["features.images"]) {
return -1, nil
}
return getSpaceBudget(info)
}
// GetSpaceBudget returns how much disk space is left in the given project.
//
// If no limit is in place, return -1.
func GetSpaceBudget(tx *db.ClusterTx, projectName string) (int64, error) {
info, err := fetchProject(tx, projectName, true)
if err != nil {
return -1, err
}
if info == nil {
return -1, nil
}
return getSpaceBudget(info)
}
func getSpaceBudget(info *projectInfo) (int64, error) {
// If "limits.disk" is not set, the budget is unlimited.
if info.Project.Config["limits.disk"] == "" {
return -1, nil
}
parser := aggregateLimitConfigValueParsers["limits.disk"]
quota, err := parser(info.Project.Config["limits.disk"])
if err != nil {
return -1, err
}
instances, err := expandInstancesConfigAndDevices(info.Instances, info.Profiles)
if err != nil {
return -1, err
}
info.Instances = instances
totals, err := getTotalsAcrossProjectEntities(info, []string{"limits.disk"}, false)
if err != nil {
return -1, err
}
if totals["limits.disk"] < quota {
return quota - totals["limits.disk"], nil
}
return 0, nil
}
// Check that we would not violate the project limits or restrictions if we
// were to commit the given instances and profiles.
func checkRestrictionsAndAggregateLimits(tx *db.ClusterTx, info *projectInfo) error {
// List of config keys for which we need to check aggregate values
// across all project instances.
aggregateKeys := []string{}
isRestricted := false
for key, value := range info.Project.Config {
if slices.Contains(allAggregateLimits, key) || strings.HasPrefix(key, projectLimitDiskPool) {
aggregateKeys = append(aggregateKeys, key)
continue
}
if key == "restricted" && util.IsTrue(value) {
isRestricted = true
continue
}
}
if len(aggregateKeys) == 0 && !isRestricted {
return nil
}
// Check pool usage restrictions.
if isRestricted && info.Project.Config["restricted.storage-pools.access"] != "" {
// Build a list of all the storage pools in use.
pools := map[string]int{}
for _, profile := range info.Profiles {
for _, dev := range profile.Devices {
if dev["type"] == "disk" && dev["pool"] != "" {
pools[dev["pool"]]++
}
}
}
for _, inst := range info.Instances {
for _, dev := range inst.Devices {
if dev["type"] == "disk" && dev["pool"] != "" {
pools[dev["pool"]]++
}
}
}
for _, vol := range info.Volumes {
pools[vol.PoolName]++
}
// Check that those pools are allowed.
for poolName := range pools {
if !StoragePoolAllowed(info.Project.Config, poolName) {
return fmt.Errorf("Storage pool %q is not accessible from this project", poolName)
}
}
}
instances, err := expandInstancesConfigAndDevices(info.Instances, info.Profiles)
if err != nil {
return err
}
info.Instances = instances
err = checkAggregateLimits(info, aggregateKeys)
if err != nil {
return err
}
if isRestricted {
err = checkRestrictions(info.Project, info.Instances, info.Profiles)
if err != nil {
return err
}
}
return nil
}
func getAggregateLimits(info *projectInfo, aggregateKeys []string) (map[string]api.ProjectStateResource, error) {
result := map[string]api.ProjectStateResource{}
if len(aggregateKeys) == 0 {
return result, nil
}
totals, err := getTotalsAcrossProjectEntities(info, aggregateKeys, true)
if err != nil {
return nil, err
}
for _, key := range aggregateKeys {
limit := int64(-1)
limitStr := info.Project.Config[key]
if limitStr != "" {
keyName := key
// Handle pool-specific limits.
if strings.HasPrefix(key, projectLimitDiskPool) {
keyName = "limits.disk"
}
parser := aggregateLimitConfigValueParsers[keyName]
limit, err = parser(limitStr)
if err != nil {
return nil, err
}
}
resource := api.ProjectStateResource{
Usage: totals[key],
Limit: limit,
}
result[key] = resource
}
return result, nil
}
func checkAggregateLimits(info *projectInfo, aggregateKeys []string) error {
if len(aggregateKeys) == 0 {
return nil
}
totals, err := getTotalsAcrossProjectEntities(info, aggregateKeys, false)
if err != nil {
return fmt.Errorf("Failed getting usage of project entities: %w", err)
}
for _, key := range aggregateKeys {
keyName := key
// Handle pool-specific limits.
if strings.HasPrefix(key, projectLimitDiskPool) {
keyName = "limits.disk"
}
parser := aggregateLimitConfigValueParsers[keyName]
limit, err := parser(info.Project.Config[key])
if err != nil {
return err
}
if totals[key] > limit {
return fmt.Errorf("Reached maximum aggregate value %q for %q in project %q", info.Project.Config[key], key, info.Project.Name)
}
}
return nil
}
// parseHostIDMapRange parse the supplied list of host ID map ranges into a idmap.Entry slice.
func parseHostIDMapRange(isUID bool, isGID bool, listValue string) ([]idmap.Entry, error) {
var idmaps []idmap.Entry
for _, listItem := range util.SplitNTrimSpace(listValue, ",", -1, true) {
rangeStart, rangeSize, err := util.ParseUint32Range(listItem)
if err != nil {
return nil, err
}
idmaps = append(idmaps, idmap.Entry{
HostID: int64(rangeStart),
MapRange: int64(rangeSize),
IsUID: isUID,
IsGID: isGID,
NSID: -1, // We don't have this as we are just parsing host IDs.
})
}
return idmaps, nil
}
// Check that the project's restrictions are not violated across the given
// instances and profiles.
func checkRestrictions(project api.Project, instances []api.Instance, profiles []api.Profile) error {
containerConfigChecks := map[string]func(value string) error{}
devicesChecks := map[string]func(value map[string]string) error{}
allowContainerLowLevel := false
allowVMLowLevel := false
blockVMNesting := false
requireIsolated := false
var allowedIDMapHostUIDs, allowedIDMapHostGIDs []idmap.Entry
for i := range allRestrictions {
// Check if this particular restriction is defined explicitly in the project config.
// If not, use the default value. Assign to local var so it doesn't change to the default value of
// another restriction by time check functions run.
restrictionKey := i
restrictionValue, ok := project.Config[restrictionKey]
if !ok {
restrictionValue = allRestrictions[restrictionKey]
}
switch restrictionKey {
case "restricted.containers.interception":
for _, key := range allowableIntercept {
containerConfigChecks[key] = func(instanceValue string) error {
disabled := util.IsFalseOrEmpty(instanceValue)
if restrictionValue != "allow" && !disabled {
return errors.New("Container syscall interception is forbidden")
}
return nil
}
}
case "restricted.containers.nesting":
containerConfigChecks["security.nesting"] = func(instanceValue string) error {
if restrictionValue == "block" && util.IsTrue(instanceValue) {
return errors.New("Container nesting is forbidden")
}
return nil
}
case "restricted.containers.lowlevel":
if restrictionValue == "allow" {
allowContainerLowLevel = true
}
case "restricted.containers.privilege":
containerConfigChecks["security.privileged"] = func(instanceValue string) error {
if restrictionValue != "allow" && util.IsTrue(instanceValue) {
return errors.New("Privileged containers are forbidden")
}
return nil
}
requireIsolated = restrictionValue == "isolated"
containerConfigChecks["security.idmap.isolated"] = func(instanceValue string) error {
if restrictionValue == "isolated" && util.IsFalseOrEmpty(instanceValue) {
return errors.New("Non-isolated containers are forbidden")
}
return nil
}
case "restricted.virtual-machines.lowlevel":
if restrictionValue == "allow" {
allowVMLowLevel = true
}
case "restricted.virtual-machines.nesting":
if restrictionValue == "block" {
blockVMNesting = true
}
case "restricted.devices.unix-char":
devicesChecks["unix-char"] = func(device map[string]string) error {
if restrictionValue != "allow" {
return errors.New("Unix character devices are forbidden")
}
return nil
}
case "restricted.devices.unix-block":
devicesChecks["unix-block"] = func(device map[string]string) error {
if restrictionValue != "allow" {
return errors.New("Unix block devices are forbidden")
}
return nil
}
case "restricted.devices.unix-hotplug":
devicesChecks["unix-hotplug"] = func(device map[string]string) error {
if restrictionValue != "allow" {
return errors.New("Unix hotplug devices are forbidden")
}
return nil
}
case "restricted.devices.infiniband":
devicesChecks["infiniband"] = func(device map[string]string) error {
if restrictionValue != "allow" {
return errors.New("Infiniband devices are forbidden")
}
return nil
}
case "restricted.devices.gpu":
devicesChecks["gpu"] = func(device map[string]string) error {
if restrictionValue != "allow" {
return errors.New("GPU devices are forbidden")
}
return nil
}
case "restricted.devices.usb":
devicesChecks["usb"] = func(device map[string]string) error {
if restrictionValue != "allow" {
return errors.New("USB devices are forbidden")
}
return nil
}
case "restricted.devices.pci":
devicesChecks["pci"] = func(device map[string]string) error {
if restrictionValue != "allow" {
return errors.New("PCI devices are forbidden")
}
return nil
}
case "restricted.devices.proxy":
devicesChecks["proxy"] = func(device map[string]string) error {
if restrictionValue != "allow" {
return errors.New("Proxy devices are forbidden")
}
return nil
}
case "restricted.devices.nic":
devicesChecks["nic"] = func(device map[string]string) error {
// Check if the NICs are allowed at all.
switch restrictionValue {
case "block":
return errors.New("Network devices are forbidden")
case "managed":
if device["network"] == "" {
return errors.New("Only managed network devices are allowed")
}
}
// Check if the NIC's parent/network setting is allowed based on the
// restricted.devices.nic and restricted.networks.access settings.
if device["network"] != "" {
if !NetworkAllowed(project.Config, device["network"], true) {
return errors.New("Network not allowed in project")
}
} else if device["parent"] != "" {
if !NetworkAllowed(project.Config, device["parent"], false) {
return errors.New("Network not allowed in project")
}
}
return nil
}
case "restricted.devices.disk":
devicesChecks["disk"] = func(device map[string]string) error {
// The root device is always allowed, but the pool it references
// must still be accessible (not equivalent to a size limit of 0).
if device["path"] == "/" && device["pool"] != "" {
if !StoragePoolAllowed(project.Config, device["pool"]) {
return fmt.Errorf("Storage pool %q is not accessible from this project", device["pool"])
}
return nil
}
// Always allow the cloud-init config drive.
if device["path"] == "" && device["source"] == "cloud-init:config" {
return nil
}
// Always allow the agent config drive.
if device["path"] == "" && device["source"] == "agent:config" {
return nil
}
switch restrictionValue {
case "block":
return errors.New("Disk devices are forbidden")
case "managed":
if device["pool"] == "" {
return errors.New("Attaching disks not backed by a pool is forbidden")
}
case "allow":
if device["pool"] == "" {
allowed, _ := CheckRestrictedDevicesDiskPaths(project.Config, device["source"])
if !allowed {
return fmt.Errorf("Disk source path %q not allowed", device["source"])
}
}
}
if device["pool"] != "" && !StoragePoolAllowed(project.Config, device["pool"]) {
return fmt.Errorf("Storage pool %q is not accessible from this project", device["pool"])
}
return nil
}
case "restricted.idmap.uid":
var err error
allowedIDMapHostUIDs, err = parseHostIDMapRange(true, false, restrictionValue)
if err != nil {
return fmt.Errorf("Failed parsing %q: %w", "restricted.idmap.uid", err)
}
case "restricted.idmap.gid":
var err error
allowedIDMapHostGIDs, err = parseHostIDMapRange(false, true, restrictionValue)
if err != nil {
return fmt.Errorf("Failed parsing %q: %w", "restricted.idmap.uid", err)
}
}
}
// Common config check logic between instances and profiles.
entityConfigChecker := func(instType instancetype.Type, entityName string, config map[string]string) error {
entityTypeLabel := instType.String()
if instType == instancetype.Any {
entityTypeLabel = "profile"
}
isContainerOrProfile := instType == instancetype.Container || instType == instancetype.Any
isVMOrProfile := instType == instancetype.VM || instType == instancetype.Any
// VM nesting is on by default, so when blocked, require it to be explicitly disabled.
if blockVMNesting && instType == instancetype.VM && !util.IsFalse(config["security.nesting"]) {
return fmt.Errorf(`Virtual machine nesting is forbidden on %s %q of project %q ("security.nesting" must be set to "false")`, entityTypeLabel, entityName, project.Name)
}
// Non-isolation is the default, so when isolation is required, the container must explicitly enable it.
if requireIsolated && instType == instancetype.Container && util.IsFalseOrEmpty(config["security.idmap.isolated"]) {
return fmt.Errorf(`Non-isolated containers are forbidden on %s %q of project %q ("security.idmap.isolated" must be set to "true")`, entityTypeLabel, entityName, project.Name)
}
for key, value := range config {
if ((isContainerOrProfile && !allowContainerLowLevel) || (isVMOrProfile && !allowVMLowLevel)) && key == "raw.idmap" {
// If the low-level raw.idmap is used check whether the raw.idmap host IDs
// are allowed based on the project's allowed ID map Host UIDs and GIDs.
idmaps, err := idmap.NewSetFromIncusIDMap(value)
if err != nil {
return err
}
for i, entry := range idmaps.Entries {
if !entry.HostIDsCoveredBy(allowedIDMapHostUIDs, allowedIDMapHostGIDs) {
return fmt.Errorf(`Use of low-level "raw.idmap" element %d on %s %q of project %q is forbidden`, i, entityTypeLabel, entityName, project.Name)
}
}
// Skip the other checks.
continue
}
if isContainerOrProfile && !allowContainerLowLevel && isContainerLowLevelOptionForbidden(key) {
return fmt.Errorf("Use of low-level config %q on %s %q of project %q is forbidden", key, entityTypeLabel, entityName, project.Name)
}
if isVMOrProfile && !allowVMLowLevel && isVMLowLevelOptionForbidden(key) {
return fmt.Errorf("Use of low-level config %q on %s %q of project %q is forbidden", key, entityTypeLabel, entityName, project.Name)
}
var checker func(value string) error
if isContainerOrProfile {
checker = containerConfigChecks[key]
}
if checker == nil {
continue
}
err := checker(value)
if err != nil {
return fmt.Errorf("Invalid value %q for config %q on %s %q of project %q: %w", value, key, instType, entityName, project.Name, err)
}
}
return nil
}
// Common devices check logic between instances and profiles.
entityDevicesChecker := func(instType instancetype.Type, entityName string, devices map[string]map[string]string) error {
entityTypeLabel := instType.String()
if instType == instancetype.Any {
entityTypeLabel = "profile"
}
for name, device := range devices {
check, ok := devicesChecks[device["type"]]
if !ok {
continue
}
err := check(device)
if err != nil {
return fmt.Errorf("Invalid device %q on %s %q of project %q: %w", name, entityTypeLabel, entityName, project.Name, err)
}
}
return nil
}
for _, inst := range instances {
instType, err := instancetype.New(inst.Type)
if err != nil {
return err
}
err = entityConfigChecker(instType, inst.Name, inst.Config)
if err != nil {
return err
}
err = entityDevicesChecker(instType, inst.Name, inst.Devices)
if err != nil {
return err
}
}
for _, profile := range profiles {
err := entityConfigChecker(instancetype.Any, profile.Name, profile.Config)
if err != nil {
return err
}
err = entityDevicesChecker(instancetype.Any, profile.Name, profile.Devices)
if err != nil {
return err
}
}
return nil
}
// CheckRestrictedDevicesDiskPaths checks whether the disk's source path is within the allowed paths specified in
// the project's restricted.devices.disk.paths config setting.
// If no allowed paths are specified in project, then it allows all paths, and returns true and empty string.
// If allowed paths are specified, and one matches, returns true and the matching allowed parent source path.
// Otherwise if sourcePath not allowed returns false and empty string.
func CheckRestrictedDevicesDiskPaths(projectConfig map[string]string, sourcePath string) (bool, string) {
if projectConfig["restricted.devices.disk.paths"] == "" {
return true, ""
}
// Clean, then add trailing slash, to ensure we are prefix matching on whole path.
sourcePath = fmt.Sprintf("%s/", filepath.Clean(sourcePath))
for _, parentSourcePath := range strings.Split(projectConfig["restricted.devices.disk.paths"], ",") {
// Clean, then add trailing slash, to ensure we are prefix matching on whole path.
parentSourcePathTrailing := fmt.Sprintf("%s/", filepath.Clean(parentSourcePath))
if strings.HasPrefix(sourcePath, parentSourcePathTrailing) {
return true, parentSourcePath
}
}
return false, ""
}
var allAggregateLimits = []string{
"limits.cpu",
"limits.disk",
"limits.memory",
"limits.processes",
}
// allRestrictions lists all available 'restrict.*' config keys along with their default setting.
var allRestrictions = map[string]string{
"restricted.backups": "block",
"restricted.cluster.groups": "",
"restricted.cluster.target": "block",
"restricted.containers.nesting": "block",
"restricted.containers.interception": "block",
"restricted.containers.lowlevel": "block",
"restricted.containers.privilege": "unprivileged",
"restricted.virtual-machines.lowlevel": "block",
"restricted.virtual-machines.nesting": "allow",
"restricted.devices.unix-char": "block",
"restricted.devices.unix-block": "block",
"restricted.devices.unix-hotplug": "block",
"restricted.devices.infiniband": "block",
"restricted.devices.gpu": "block",
"restricted.devices.usb": "block",
"restricted.devices.pci": "block",
"restricted.devices.proxy": "block",
"restricted.devices.nic": "managed",
"restricted.devices.disk": "managed",
"restricted.devices.disk.paths": "",
"restricted.idmap.uid": "",
"restricted.idmap.gid": "",
"restricted.images.servers": "",
"restricted.networks.access": "",
"restricted.snapshots": "block",
"restricted.storage-pools.access": "",
}
// allowableIntercept lists all syscall interception keys which may be allowed.
var allowableIntercept = []string{
"security.syscalls.intercept.bpf",
"security.syscalls.intercept.bpf.devices",
"security.syscalls.intercept.mknod",
"security.syscalls.intercept.mount",
"security.syscalls.intercept.mount.fuse",
"security.syscalls.intercept.setxattr",
"security.syscalls.intercept.sysinfo",
}
// Return true if a low-level container option is forbidden.
func isContainerLowLevelOptionForbidden(key string) bool {
if strings.HasPrefix(key, "security.syscalls.intercept") && !slices.Contains(allowableIntercept, key) {
return true
}
if strings.HasPrefix(key, "security.bpffs") {
return true
}
if slices.Contains([]string{
"boot.host_shutdown_action",
"boot.host_shutdown_timeout",
"linux.kernel_modules",
"limits.memory.swap",
"limits.memory.oom_priority",
"raw.apparmor",
"raw.idmap",
"raw.lxc",
"raw.seccomp",
"security.guestapi.images",
"security.idmap.base",
"security.idmap.size",
"security.selinux.domain",
"security.selinux.label_rootfs",
"security.selinux.level",
"security.selinux.type",
},
key) {
return true
}
return false
}
// Return true if a low-level VM option is forbidden.
func isVMLowLevelOptionForbidden(key string) bool {
return slices.Contains([]string{
"boot.host_shutdown_action",
"boot.host_shutdown_timeout",
"limits.memory.hugepages",
"limits.memory.oom_priority",
"raw.apparmor",
"raw.idmap",
"raw.qemu",
"raw.qemu.conf",
"raw.qemu.qmp.early",
"raw.qemu.qmp.post-start",
"raw.qemu.qmp.pre-start",
"raw.qemu.scriptlet",
"security.selinux.domain",
"security.selinux.label_rootfs",
"security.selinux.level",
"security.selinux.type",
},
key)
}
// AllowInstanceUpdate returns an error if any project-specific limit or
// restriction is violated when updating an existing instance.
func AllowInstanceUpdate(tx *db.ClusterTx, projectName, instanceName string, req api.InstancePut, currentConfig map[string]string) error {
var updatedInstance *api.Instance
info, err := fetchProject(tx, projectName, true)
if err != nil {
return err
}
if info == nil {
return nil
}
// Change the instance being updated.
for i, inst := range info.Instances {
if inst.Name != instanceName {
continue
}
info.Instances[i].Profiles = req.Profiles
info.Instances[i].Config = req.Config
info.Instances[i].Devices = req.Devices
updatedInstance = &info.Instances[i]
}
instType, err := instancetype.New(updatedInstance.Type)
if err != nil {
return err
}
// Special case restriction checks on volatile.* keys, since we want to
// detect if they were changed or added.
err = checkRestrictionsOnVolatileConfig(
info.Project, instType, updatedInstance.Name, req.Config, currentConfig, false,
)
if err != nil {
return err
}
err = checkRestrictionsAndAggregateLimits(tx, info)
if err != nil {
return fmt.Errorf("Failed checking if instance update allowed: %w", err)
}
return nil
}
// AllowVolumeUpdate returns an error if any project-specific limit or
// restriction is violated when updating an existing custom volume.
func AllowVolumeUpdate(tx *db.ClusterTx, projectName, volumeName string, req api.StorageVolumePut, currentConfig map[string]string) error {
info, err := fetchProject(tx, projectName, true)
if err != nil {
return err
}
if info == nil {
return nil
}
// If "limits.disk" is not set, there's nothing to do.
if info.Project.Config["limits.disk"] == "" {
return nil
}
// Change the volume being updated.
for i, volume := range info.Volumes {
if volume.Name != volumeName {
continue
}
info.Volumes[i].Config = req.Config
}
err = checkRestrictionsAndAggregateLimits(tx, info)
if err != nil {
return fmt.Errorf("Failed checking if volume update allowed: %w", err)
}
return nil
}
// AllowProfileUpdate checks that project limits and restrictions are not
// violated when changing a profile.
func AllowProfileUpdate(tx *db.ClusterTx, projectName, profileName string, req api.ProfilePut) error {
info, err := fetchProject(tx, projectName, true)
if err != nil {
return err
}
if info == nil {
return nil
}
// Change the profile being updated.
for i, profile := range info.Profiles {
if profile.Name != profileName {
continue
}
info.Profiles[i].Config = req.Config
info.Profiles[i].Devices = req.Devices
}
err = checkRestrictionsAndAggregateLimits(tx, info)
if err != nil {
return fmt.Errorf("Failed checking if profile update allowed: %w", err)
}
return nil
}
// AllowProjectUpdate checks the new config to be set on a project is valid.
func AllowProjectUpdate(tx *db.ClusterTx, projectName string, config map[string]string, changed []string) error {
info, err := fetchProject(tx, projectName, false)
if err != nil {
return err
}
info.Instances, err = expandInstancesConfigAndDevices(info.Instances, info.Profiles)
if err != nil {
return err
}
// List of keys that need to check aggregate values across all project
// instances.
aggregateKeys := []string{}
for _, key := range changed {
if strings.HasPrefix(key, "restricted.") {
project := api.Project{
Name: projectName,
ProjectPut: api.ProjectPut{
Config: config,
},
}
err := checkRestrictions(project, info.Instances, info.Profiles)
if err != nil {
return fmt.Errorf("Conflict detected when changing %q in project %q: %w", key, projectName, err)
}
continue
}
switch key {
case "limits.instances":
err := validateTotalInstanceCountLimit(info.Instances, config[key], projectName)
if err != nil {
return fmt.Errorf("Can't change limits.instances in project %q: %w", projectName, err)
}
case "limits.containers":
fallthrough
case "limits.virtual-machines":
err := validateInstanceCountLimit(info.Instances, key, config[key], projectName)
if err != nil {
return fmt.Errorf("Can't change %q in project %q: %w", key, projectName, err)
}
case "limits.processes":
fallthrough
case "limits.cpu":
fallthrough
case "limits.memory":
fallthrough
case "limits.disk":
aggregateKeys = append(aggregateKeys, key)
}
}
if len(aggregateKeys) > 0 {
totals, err := getTotalsAcrossProjectEntities(info, aggregateKeys, false)
if err != nil {
return err
}
for _, key := range aggregateKeys {
err := validateAggregateLimit(totals, key, config[key])
if err != nil {
return err
}
}
}
return nil
}
// Check that limits.instances, i.e. the total limit of containers/virtual machines allocated
// to the user is equal to or above the current count.
func validateTotalInstanceCountLimit(instances []api.Instance, value, project string) error {
if value == "" {
return nil
}
limit, err := strconv.Atoi(value)
if err != nil {
return err
}
count := len(instances)
if limit < count {
return fmt.Errorf(`"limits.instances" is too low: there currently are %d total instances in project %q`, count, project)
}
return nil
}
// Check that limits.containers or limits.virtual-machines is equal or above
// the current count.
func validateInstanceCountLimit(instances []api.Instance, key, value, project string) error {
if value == "" {
return nil
}
instanceType := countConfigInstanceType[key]
limit, err := strconv.Atoi(value)
if err != nil {
return err
}
dbType, err := instancetype.New(string(instanceType))
if err != nil {
return err
}
count := 0
for _, inst := range instances {
if inst.Type == dbType.String() {
count++
}
}
if limit < count {
return fmt.Errorf(`%q is too low: there currently are %d instances of type %s in project %q`, key, count, instanceType, project)
}
return nil
}
var countConfigInstanceType = map[string]api.InstanceType{
"limits.containers": api.InstanceTypeContainer,
"limits.virtual-machines": api.InstanceTypeVM,
}
// Validates an aggregate limit, checking that the new value is not below the
// current total amount.
func validateAggregateLimit(totals map[string]int64, key, value string) error {
if value == "" {
return nil
}
keyName := key
// Handle pool-specific limits.
if strings.HasPrefix(key, projectLimitDiskPool) {
keyName = "limits.disk"
}
parser := aggregateLimitConfigValueParsers[keyName]
limit, err := parser(value)
if err != nil {
return fmt.Errorf("Invalid value %q for limit %q: %w", value, key, err)
}
total := totals[key]
if limit < total {
keyName := key
// Handle pool-specific limits.
if strings.HasPrefix(key, projectLimitDiskPool) {
keyName = "limits.disk"
}
printer := aggregateLimitConfigValuePrinters[keyName]
return fmt.Errorf("%q is too low: current total is %q", key, printer(total))
}
return nil
}
// Return true if the project has some limits or restrictions set.
func projectHasLimitsOrRestrictions(project api.Project) bool {
for k, v := range project.Config {
if strings.HasPrefix(k, "limits.") {
return true
}
if k == "restricted" && util.IsTrue(v) {
return true
}
}
return false
}
// Hold information associated with the project, such as profiles and
// instances.
type projectInfo struct {
Project api.Project
Profiles []api.Profile
Instances []api.Instance
Volumes []db.StorageVolumeArgs
}
// Fetch the given project from the database along with its profiles, instances
// and possibly custom volumes.
//
// If the skipIfNoLimits flag is true, then profiles, instances and volumes
// won't be loaded if the profile has no limits set on it, and nil will be
// returned.
func fetchProject(tx *db.ClusterTx, projectName string, skipIfNoLimits bool) (*projectInfo, error) {
ctx := context.Background()
dbProject, err := cluster.GetProject(ctx, tx.Tx(), projectName)
if err != nil {
return nil, fmt.Errorf("Fetch project database object: %w", err)
}
project, err := dbProject.ToAPI(ctx, tx.Tx())
if err != nil {
return nil, err
}
if skipIfNoLimits && !projectHasLimitsOrRestrictions(*project) {
return nil, nil
}
profilesFilter := cluster.ProfileFilter{}
// If the project has the profiles feature enabled, we use its own
// profiles to expand the instances configs, otherwise we use the
// profiles from the default project.
defaultProject := api.ProjectDefaultName
if projectName == api.ProjectDefaultName || util.IsTrue(project.Config["features.profiles"]) {
profilesFilter.Project = &projectName
} else {
profilesFilter.Project = &defaultProject
}
dbProfiles, err := cluster.GetProfiles(ctx, tx.Tx(), profilesFilter)
if err != nil {
return nil, fmt.Errorf("Fetch profiles from database: %w", err)
}
dbProfileConfigs, err := cluster.GetReferencedProfileConfigs(ctx, tx.Tx(), dbProfiles)
if err != nil {
return nil, fmt.Errorf("Fetch profile configs from database: %w", err)
}
dbProfileDevices, err := cluster.GetReferencedProfileDevices(ctx, tx.Tx(), dbProfiles)
if err != nil {
return nil, fmt.Errorf("Fetch profile devices from database: %w", err)
}
profiles := make([]api.Profile, 0, len(dbProfiles))
for _, profile := range dbProfiles {
apiProfile, err := profile.ToAPI(ctx, tx.Tx(), dbProfileConfigs, dbProfileDevices)
if err != nil {
return nil, err
}
profiles = append(profiles, *apiProfile)
}
dbInstances, err := cluster.GetInstances(ctx, tx.Tx(), cluster.InstanceFilter{Project: &projectName})
if err != nil {
return nil, fmt.Errorf("Fetch project instances from database: %w", err)
}
dbInstanceIDs := make([]int, 0, len(dbInstances))
for _, dbInstance := range dbInstances {
dbInstanceIDs = append(dbInstanceIDs, dbInstance.ID)
}
dbInstanceDevices, err := cluster.GetDevices(ctx, tx.Tx(), "instances", "instance", cluster.DeviceFilter{ReferenceID: dbInstanceIDs})
if err != nil {
return nil, fmt.Errorf("Fetch instance devices from database: %w", err)
}
instances := make([]api.Instance, 0, len(dbInstances))
for _, inst := range dbInstances {
apiInstance, err := inst.ToAPI(ctx, tx.Tx(), dbInstanceDevices, dbProfileConfigs, dbProfileDevices)
if err != nil {
return nil, fmt.Errorf("Failed to get API data for instance %q in project %q: %w", inst.Name, inst.Project, err)
}
instances = append(instances, *apiInstance)
}
volumes, err := tx.GetCustomVolumesInProject(ctx, projectName)
if err != nil {
return nil, fmt.Errorf("Fetch project custom volumes from database: %w", err)
}
info := &projectInfo{
Project: *project,
Profiles: profiles,
Instances: instances,
Volumes: volumes,
}
return info, nil
}
// Expand the configuration and devices of the given instances, taking the give
// project profiles into account.
func expandInstancesConfigAndDevices(instances []api.Instance, profiles []api.Profile) ([]api.Instance, error) {
expandedInstances := make([]api.Instance, len(instances))
// Index of all profiles by name.
profilesByName := map[string]api.Profile{}
for _, profile := range profiles {
profilesByName[profile.Name] = profile
}
for i, inst := range instances {
apiProfiles := make([]api.Profile, len(inst.Profiles))
for j, name := range inst.Profiles {
profile := profilesByName[name]
apiProfiles[j] = profile
}
expandedInstances[i] = inst
expandedInstances[i].Config = db.ExpandInstanceConfig(inst.Config, apiProfiles)
expandedInstances[i].Devices = db.ExpandInstanceDevices(deviceconfig.NewDevices(inst.Devices), apiProfiles).CloneNative()
}
return expandedInstances, nil
}
// Sum of the effective values for the given limits across all project
// entities (instances and custom volumes).
func getTotalsAcrossProjectEntities(info *projectInfo, keys []string, skipUnset bool) (map[string]int64, error) {
totals := map[string]int64{}
for _, key := range keys {
totals[key] = 0
if key == "limits.disk" || strings.HasPrefix(key, projectLimitDiskPool) {
poolName := ""
fields := strings.SplitN(key, projectLimitDiskPool, 2)
if len(fields) == 2 {
poolName = fields[1]
}
for _, volume := range info.Volumes {
if poolName != "" && volume.PoolName != poolName {
continue
}
value, ok := volume.Config["size"]
if !ok {
if skipUnset {
continue
}
return nil, fmt.Errorf(`Custom volume %q in project %q has no "size" config set`, volume.Name, info.Project.Name)
}
limit, err := units.ParseByteSizeString(value)
if err != nil {
return nil, fmt.Errorf(`Parse "size" for custom volume %q in project %q: %w`, volume.Name, info.Project.Name, err)
}
totals[key] += limit
}
}
}
for _, inst := range info.Instances {
limits, err := getInstanceLimits(inst, keys, skipUnset)
if err != nil {
return nil, err
}
for _, key := range keys {
totals[key] += limits[key]
}
}
return totals, nil
}
// Return the effective instance-level values for the limits with the given keys.
func getInstanceLimits(inst api.Instance, keys []string, skipUnset bool) (map[string]int64, error) {
var err error
limits := map[string]int64{}
for _, key := range keys {
var limit int64
keyName := key
// Handle pool-specific limits.
if strings.HasPrefix(key, projectLimitDiskPool) {
keyName = "limits.disk"
}
parser := aggregateLimitConfigValueParsers[keyName]
if key == "limits.disk" || strings.HasPrefix(key, projectLimitDiskPool) {
poolName := ""
fields := strings.SplitN(key, projectLimitDiskPool, 2)
if len(fields) == 2 {
poolName = fields[1]
}
_, device, err := instance.GetRootDiskDevice(inst.Devices)
if err != nil {
return nil, fmt.Errorf("Failed getting root disk device for instance %q in project %q: %w", inst.Name, inst.Project, err)
}
if poolName != "" && device["pool"] != poolName {
continue
}
value, ok := device["size"]
if !ok || value == "" {
if skipUnset {
continue
}
return nil, fmt.Errorf(`Instance %q in project %q has no "size" config set on the root device either directly or via a profile`, inst.Name, inst.Project)
}
limit, err = parser(value)
if err != nil {
if skipUnset {
continue
}
return nil, fmt.Errorf("Failed parsing %q for instance %q in project %q", key, inst.Name, inst.Project)
}
// Add size.state accounting for VM root disks.
if inst.Type == instancetype.VM.String() {
sizeStateValue, ok := device["size.state"]
if !ok {
// TODO: In case the VMs storage drivers config drive size isn't the default,
// the limits accounting will be incorrect.
sizeStateValue = deviceconfig.DefaultVMBlockFilesystemSize
}
sizeStateLimit, err := parser(sizeStateValue)
if err != nil {
if skipUnset {
continue
}
return nil, fmt.Errorf("Failed parsing %q for instance %q in project %q", "size.state", inst.Name, inst.Project)
}
limit += sizeStateLimit
}
} else {
// Skip processing for 'limits.processes' if the instance type is VM,
// as this limit is only applicable to containers.
if key == "limits.processes" && inst.Type == instancetype.VM.String() {
continue
}
value, ok := inst.Config[key]
if !ok || value == "" {
if skipUnset {
continue
}
return nil, fmt.Errorf("Instance %q in project %q has no %q config, either directly or via a profile", inst.Name, inst.Project, key)
}
limit, err = parser(value)
if err != nil {
if skipUnset {
continue
}
return nil, fmt.Errorf("Failed parsing %q for instance %q in project %q", key, inst.Name, inst.Project)
}
}
limits[key] = limit
}
return limits, nil
}
var aggregateLimitConfigValueParsers = map[string]func(string) (int64, error){
"limits.memory": func(value string) (int64, error) {
if strings.HasSuffix(value, "%") {
return -1, errors.New("Value can't be a percentage")
}
return units.ParseByteSizeString(value)
},
"limits.processes": func(value string) (int64, error) {
limit, err := strconv.Atoi(value)
if err != nil {
return -1, err
}
return int64(limit), nil
},
"limits.cpu": func(value string) (int64, error) {
if strings.Contains(value, ",") || strings.Contains(value, "-") {
return -1, errors.New("CPUs can't be pinned if project limits are used")
}
limit, err := strconv.Atoi(value)
if err != nil {
return -1, err
}
return int64(limit), nil
},
"limits.disk": func(value string) (int64, error) {
return units.ParseByteSizeString(value)
},
}
var aggregateLimitConfigValuePrinters = map[string]func(int64) string{
"limits.memory": func(limit int64) string {
return units.GetByteSizeStringIEC(limit, 1)
},
"limits.processes": func(limit int64) string {
return fmt.Sprintf("%d", limit)
},
"limits.cpu": func(limit int64) string {
return fmt.Sprintf("%d", limit)
},
"limits.disk": func(limit int64) string {
return units.GetByteSizeStringIEC(limit, 1)
},
}
// FilterUsedBy filters a UsedBy list based on project access.
func FilterUsedBy(authorizer auth.Authorizer, r *http.Request, entries []string) []string {
// Filter the entries.
usedBy := []string{}
for _, entry := range entries {
entityType, projectName, location, pathArgs, err := cluster.URLToEntityType(entry)
if err != nil {
continue
}
var object auth.Object
switch entityType {
case cluster.TypeImage:
object = auth.ObjectImage(projectName, pathArgs[0])
case cluster.TypeInstance:
object = auth.ObjectInstance(projectName, pathArgs[0])
case cluster.TypeNetwork:
object = auth.ObjectNetwork(projectName, pathArgs[0])
case cluster.TypeProfile:
object = auth.ObjectProfile(projectName, pathArgs[0])
case cluster.TypeStoragePool:
object = auth.ObjectStoragePool(pathArgs[0])
case cluster.TypeStorageVolume:
object = auth.ObjectStorageVolume(projectName, pathArgs[0], pathArgs[1], pathArgs[2], location)
case cluster.TypeStorageBucket:
object = auth.ObjectStorageBucket(projectName, pathArgs[0], pathArgs[1], location)
case cluster.TypeServer:
object = auth.ObjectServer()
default:
continue
}
err = authorizer.CheckPermission(r.Context(), r, object, auth.EntitlementCanView)
if err != nil {
continue
}
usedBy = append(usedBy, entry)
}
return usedBy
}
// Return true if particular restriction in project is violated.
func projectHasRestriction(project *api.Project, restrictionKey string, blockValue string) bool {
if util.IsFalseOrEmpty(project.Config["restricted"]) {
return false
}
restrictionValue, ok := project.Config[restrictionKey]
if !ok {
restrictionValue = allRestrictions[restrictionKey]
}
if restrictionValue == blockValue {
return true
}
return false
}
// CheckClusterTargetRestriction check if user is allowed to use cluster member targeting.
func CheckClusterTargetRestriction(authorizer auth.Authorizer, r *http.Request, project *api.Project, targetFlag string) error {
if projectHasRestriction(project, "restricted.cluster.target", "block") && targetFlag != "" {
// Allow server administrators to move instances around even when restricted (node evacuation, ...)
err := authorizer.CheckPermission(r.Context(), r, auth.ObjectServer(), auth.EntitlementCanOverrideClusterTargetRestriction)
if err != nil && api.StatusErrorCheck(err, http.StatusForbidden) {
return api.StatusErrorf(http.StatusForbidden, "This project doesn't allow cluster member targeting")
} else if err != nil {
return err
}
}
return nil
}
// AllowBackupCreation returns an error if any project-specific restriction is violated
// when creating a new backup in a project.
func AllowBackupCreation(tx *db.ClusterTx, projectName string) error {
ctx := context.Background()
dbProject, err := cluster.GetProject(ctx, tx.Tx(), projectName)
if err != nil {
return err
}
project, err := dbProject.ToAPI(ctx, tx.Tx())
if err != nil {
return err
}
if projectHasRestriction(project, "restricted.backups", "block") {
return fmt.Errorf("Project %q doesn't allow for backup creation", projectName)
}
return nil
}
// AllowSnapshotCreation returns an error if any project-specific restriction is violated
// when creating a new snapshot in a project.
func AllowSnapshotCreation(p *api.Project) error {
if projectHasRestriction(p, "restricted.snapshots", "block") {
return fmt.Errorf("Project %q doesn't allow for snapshot creation", p.Name)
}
return nil
}
// GetRestrictedClusterGroups returns a slice of restricted cluster groups for the given project.
func GetRestrictedClusterGroups(p *api.Project) []string {
return util.SplitNTrimSpace(p.Config["restricted.cluster.groups"], ",", -1, true)
}
// AllowClusterMember returns nil if the given project is allowed to use the cluster member.
func AllowClusterMember(p *api.Project, member *db.NodeInfo) error {
clusterGroupsAllowed := GetRestrictedClusterGroups(p)
if util.IsTrue(p.Config["restricted"]) && len(clusterGroupsAllowed) > 0 {
for _, memberGroupName := range member.Groups {
if slices.Contains(clusterGroupsAllowed, memberGroupName) {
return nil
}
}
return fmt.Errorf("Project isn't allowed to use this cluster member: %q", member.Name)
}
return nil
}
// AllowClusterGroup returns nil if the given project is allowed to use the cluster groupName.
func AllowClusterGroup(p *api.Project, groupName string) error {
clusterGroupsAllowed := GetRestrictedClusterGroups(p)
// Skip the check if the project is not restricted
if util.IsFalseOrEmpty(p.Config["restricted"]) {
return nil
}
if len(clusterGroupsAllowed) > 0 && !slices.Contains(clusterGroupsAllowed, groupName) {
return fmt.Errorf("Project isn't allowed to use this cluster group: %q", groupName)
}
return nil
}
// CheckTargetMember checks if the given targetMemberName is present in allMembers
// and is allowed for the project.
// If the target member is allowed it returns the resolved node information.
func CheckTargetMember(p *api.Project, targetMemberName string, allMembers []db.NodeInfo) (*db.NodeInfo, error) {
// Find target member.
for _, potentialMember := range allMembers {
if potentialMember.Name == targetMemberName {
// If restricted groups are specified then check member is in at least one of them.
err := AllowClusterMember(p, &potentialMember)
if err != nil {
return nil, api.StatusErrorf(http.StatusForbidden, "%s", err.Error())
}
return &potentialMember, nil
}
}
return nil, api.StatusErrorf(http.StatusNotFound, "Cluster member %q not found", targetMemberName)
}
// CheckTargetGroup checks if the given groupName is allowed for the project.
func CheckTargetGroup(ctx context.Context, tx *db.ClusterTx, p *api.Project, groupName string) error {
// If restricted groups are specified then check the requested group is in the list.
err := AllowClusterGroup(p, groupName)
if err != nil {
return api.StatusErrorf(http.StatusForbidden, "%s", err.Error())
}
// Check if the target group exists.
targetGroupExists, err := cluster.ClusterGroupExists(ctx, tx.Tx(), groupName)
if err != nil {
return err
}
if !targetGroupExists {
return api.StatusErrorf(http.StatusBadRequest, "Cluster group %q doesn't exist", groupName)
}
return nil
}
// CheckTarget checks if the given cluster target (member or group) is allowed.
// If target is a cluster member and is found in allMembers it returns the resolved node information object.
// If target is a cluster group it returns the cluster group name.
// In case of error, neither node information nor cluster group name gets returned.
func CheckTarget(ctx context.Context, authorizer auth.Authorizer, r *http.Request, tx *db.ClusterTx, p *api.Project, target string, allMembers []db.NodeInfo) (*db.NodeInfo, string, error) {
// Extract the target.
var targetGroupName string
var targetMemberName string
after, ok := strings.CutPrefix(target, "@")
if ok {
targetGroupName = after
} else {
targetMemberName = target
}
// Check manual cluster member targeting restrictions.
err := CheckClusterTargetRestriction(authorizer, r, p, target)
if err != nil {
return nil, "", err
}
if targetMemberName != "" {
member, err := CheckTargetMember(p, targetMemberName, allMembers)
if err != nil {
return nil, "", err
}
return member, "", nil
} else if targetGroupName != "" {
err := CheckTargetGroup(ctx, tx, p, targetGroupName)
if err != nil {
return nil, "", err
}
return nil, targetGroupName, nil
}
return nil, "", nil
}