Files
capsule/pkg/ruleengine/validate.go
T
Oliver BählerandGitHub f1b760a87a feat(rules): improve metadata enforcement and add ingress rules (#2050)
* feat: implement namespace metadata enforcement

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add ingress enforcment

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

---------

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
2026-07-22 22:24:54 +02:00

428 lines
9.9 KiB
Go

// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ruleengine
import (
"errors"
"fmt"
"net"
"regexp"
"strings"
k8smeta "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
func ValidateRuleStatusBody(
mapper k8smeta.RESTMapper,
bodies []*rules.NamespaceRuleBodyNamespace,
) error {
for i, rule := range bodies {
if rule == nil {
continue
}
if err := validateAudience(i, rule.Audience); err != nil {
return err
}
if rule.Enforce == nil {
continue
}
if err := validateWorkloadRules(i, rule.Enforce.Workloads); err != nil {
return err
}
if err := validateServiceRules(i, rule.Enforce.Services); err != nil {
return err
}
if err := validateIngressRules(i, rule.Enforce.Ingress); err != nil {
return err
}
if err := validateMetadataRules(i, rule.Enforce.Metadata, mapper); err != nil {
return err
}
}
return nil
}
func validateIngressRules(
ruleIndex int,
ingress rules.NamespaceRuleEnforceIngressBody,
) error {
if len(ingress.Types) == 0 && len(ingress.Hostnames) > 0 {
return fmt.Errorf(
"rules[%d].enforce.ingress.types is invalid: types must be configured when hostnames are configured",
ruleIndex,
)
}
if len(ingress.Types) > 0 && len(ingress.Hostnames) == 0 {
return fmt.Errorf(
"rules[%d].enforce.ingress.hostnames is invalid: hostnames must be configured when types are configured",
ruleIndex,
)
}
for i, resourceType := range ingress.Types {
switch resourceType {
case rules.IngressTypeIngress, rules.IngressTypeRoute,
rules.IngressTypeListenerSet,
rules.IngressTypeHTTPRoute,
rules.IngressTypeGateway,
rules.IngressTypeTLSRoute,
rules.IngressTypeGRPCRoute:
default:
return fmt.Errorf(
"rules[%d].enforce.ingress.types[%d] %q is invalid: unsupported ingress resource type",
ruleIndex,
i,
resourceType,
)
}
}
for i, hostname := range ingress.Hostnames {
if err := validateExpressionMatch(
hostname,
fmt.Sprintf("rules[%d].enforce.ingress.hostnames[%d]", ruleIndex, i),
); err != nil {
return err
}
}
return nil
}
func validateAudience(ruleIndex int, audience []rules.Audience) error {
for i, subject := range audience {
path := fmt.Sprintf("rules[%d].audience[%d]", ruleIndex, i)
if strings.TrimSpace(subject.Name) == "" {
return fmt.Errorf("%s.name is invalid: name is empty", path)
}
switch subject.Kind {
case rules.AudienceKindUser, rules.AudienceKindGroup, rules.AudienceKindServiceAccount:
case rules.AudienceKindCustom:
switch rules.CustomAudience(subject.Name) {
case rules.CustomAudienceCapsuleUser, rules.CustomAudienceAdministrator, rules.CustomAudienceTenantOwner, rules.CustomAudienceController:
default:
return fmt.Errorf("%s.name %q is invalid: unsupported custom audience", path, subject.Name)
}
default:
return fmt.Errorf("%s.kind %q is invalid: unsupported audience kind", path, subject.Kind)
}
}
return nil
}
func validateWorkloadRules(
ruleIndex int,
workloads rules.NamespaceRuleEnforceWorkloadsBody,
) error {
for j, registry := range workloads.Registries {
if err := validateExpression(
registry.Expression,
fmt.Sprintf("rules[%d].enforce.workloads.registries[%d].exp", ruleIndex, j),
); err != nil {
return err
}
}
for j, scheduler := range workloads.Schedulers {
if err := validateExpression(
scheduler.Expression,
fmt.Sprintf("rules[%d].enforce.workloads.schedulers[%d].exp", ruleIndex, j),
); err != nil {
return err
}
}
return nil
}
func validateServiceRules(
ruleIndex int,
services rules.NamespaceRuleEnforceServicesBody,
) error {
for j, serviceType := range services.Types {
if err := validateServiceType(serviceType); err != nil {
return fmt.Errorf(
"rules[%d].enforce.services.types[%d] %q is invalid: %w",
ruleIndex,
j,
serviceType,
err,
)
}
}
if services.LoadBalancers != nil {
for j, cidr := range services.LoadBalancers.CIDRs {
if err := validateCIDR(cidr); err != nil {
return fmt.Errorf(
"rules[%d].enforce.services.loadBalancers.cidrs[%d] %q is invalid: %w",
ruleIndex,
j,
cidr,
err,
)
}
}
}
if services.ExternalNames != nil {
for j, hostname := range services.ExternalNames.Hostnames {
if err := validateExpressionMatch(
hostname,
fmt.Sprintf("rules[%d].enforce.services.externalNames.hostnames[%d]", ruleIndex, j),
); err != nil {
return err
}
}
}
if services.NodePorts != nil {
for j, portRange := range services.NodePorts.Ports {
if err := validateNodePortRange(portRange); err != nil {
return fmt.Errorf(
"rules[%d].enforce.services.nodePorts.ports[%d] is invalid: %w",
ruleIndex,
j,
err,
)
}
}
}
return nil
}
func validateMetadataRules(
ruleIndex int,
metadata []rules.MetadataRule,
mapper k8smeta.RESTMapper,
) error {
for j, rule := range metadata {
fieldPath := fmt.Sprintf("rules[%d].enforce.metadata[%d]", ruleIndex, j)
if rule.HasWildcard() && metadataRuleHasManagedValues(rule) {
return fmt.Errorf("%s is invalid: managed metadata requires concrete apiGroups and kinds", fieldPath)
}
if err := validateMetadataTargets(fieldPath, rule, mapper); err != nil {
return err
}
for key, policy := range rule.Labels {
if err := validateMetadataKey(key); err != nil {
return fmt.Errorf(
"%s.labels[%q] is invalid: %w",
fieldPath,
key,
err,
)
}
if err := validateMutableMetadataKey(key, policy); err != nil {
return fmt.Errorf("%s.labels[%q] is invalid: %w", fieldPath, key, err)
}
for k, matcher := range policy.Values {
if err := validateExpressionMatch(
matcher,
fmt.Sprintf("%s.labels[%q].values[%d]", fieldPath, key, k),
); err != nil {
return err
}
}
}
for key, policy := range rule.Annotations {
if err := validateMetadataKey(key); err != nil {
return fmt.Errorf(
"%s.annotations[%q] is invalid: %w",
fieldPath,
key,
err,
)
}
if err := validateMutableMetadataKey(key, policy); err != nil {
return fmt.Errorf("%s.annotations[%q] is invalid: %w", fieldPath, key, err)
}
for k, matcher := range policy.Values {
if err := validateExpressionMatch(
matcher,
fmt.Sprintf("%s.annotations[%q].values[%d]", fieldPath, key, k),
); err != nil {
return err
}
}
}
}
return nil
}
func metadataRuleHasManagedValues(rule rules.MetadataRule) bool {
for _, policy := range rule.Labels {
if policy.Managed != nil {
return true
}
}
for _, policy := range rule.Annotations {
if policy.Managed != nil {
return true
}
}
return false
}
func validateMutableMetadataKey(key string, policy rules.MetadataValueRule) error {
if policy.Default == nil && policy.Managed == nil {
return nil
}
if errs := k8svalidation.IsQualifiedName(strings.TrimSpace(key)); len(errs) > 0 {
return errors.New("default and managed require a concrete metadata key")
}
return nil
}
func validateMetadataKey(key string) error {
key = strings.TrimSpace(key)
if key == "" {
return errors.New("key is empty")
}
if !strings.ContainsAny(key, "*[](){}+?|^$\\") {
if errs := k8svalidation.IsQualifiedName(key); len(errs) > 0 {
return errors.New(strings.Join(errs, ", "))
}
}
expression := rules.MetadataKeyExpression(key)
if _, err := regexp.Compile(expression.Expression); err != nil {
return fmt.Errorf("invalid key expression %q: %w", key, err)
}
return nil
}
func validateExpressionMatch(match runtime.ExpressionMatch, fieldPath string) error {
if err := validateExpression(match.Expression, fieldPath+".exp"); err != nil {
return err
}
return nil
}
func validateExpression(expression string, fieldPath string) error {
if strings.TrimSpace(expression) == "" {
return nil
}
if _, err := regexp.Compile(expression); err != nil {
return fmt.Errorf("%s %q is invalid: %w", fieldPath, expression, err)
}
return nil
}
func validateServiceType(serviceType rules.ServiceType) error {
switch serviceType {
case rules.ServiceTypeClusterIP,
rules.ServiceTypeNodePort,
rules.ServiceTypeLoadBalancer,
rules.ServiceTypeExternalName:
return nil
default:
return fmt.Errorf("unsupported service type")
}
}
func validateCIDR(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("CIDR is empty")
}
if !strings.Contains(raw, "/") {
ip := net.ParseIP(raw)
if ip == nil {
return fmt.Errorf("must be a valid IP or CIDR")
}
return nil
}
if _, _, err := net.ParseCIDR(raw); err != nil {
return err
}
return nil
}
func validateNodePortRange(portRange rules.ServiceNodePortRange) error {
if portRange.From < 1 || portRange.From > 65535 {
return fmt.Errorf("from %d must be between 1 and 65535", portRange.From)
}
if portRange.To < 1 || portRange.To > 65535 {
return fmt.Errorf("to %d must be between 1 and 65535", portRange.To)
}
if portRange.From > portRange.To {
return fmt.Errorf("from %d must be lower than or equal to %d", portRange.From, portRange.To)
}
return nil
}
func validateMetadataTargets(
fieldPath string,
rule rules.MetadataRule,
mapper k8smeta.RESTMapper,
) error {
if len(rule.Kinds) == 0 {
return fmt.Errorf("%s.kinds is invalid: at least one kind must be configured", fieldPath)
}
for i, kind := range rule.Kinds {
kind = strings.TrimSpace(kind)
if kind == "" {
return fmt.Errorf("%s.kinds[%d] is invalid: kind is empty", fieldPath, i)
}
}
if mapper == nil {
return nil
}
if err := rule.ValidateKnownKindsWithScope(mapper, fieldPath, func(
gvk schema.GroupVersionKind,
scope k8smeta.RESTScope,
) bool {
return scope.Name() == k8smeta.RESTScopeNameNamespace ||
(gvk.Group == "" && gvk.Version == "v1" && gvk.Kind == "Namespace")
}); err != nil {
return err
}
return nil
}