Files
Oliver BählerandGitHub 755cef54bf feat(rules): add service enforcement rules (#1982)
* fix(controller): decode old object for delete requests

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* fix: preserve ca-bundles injected from external providers

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

* feat(rules): add service enforcement rules

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

* feat(rules): add service enforcement rules

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

* feat(rules): add service enforcement rules

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

* feat(rules): add service enforcement rules

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

* feat(rules): add service enforcement rules

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

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
2026-06-24 11:20:23 +02:00

205 lines
4.8 KiB
Go

// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"fmt"
"net"
"strings"
corev1 "k8s.io/api/core/v1"
apirules "github.com/projectcapsule/capsule/pkg/api/rules"
ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine"
"github.com/projectcapsule/capsule/pkg/runtime/events"
)
type loadBalancerCIDRRule struct {
CIDRs []string
}
func (h *serviceRules) validateLoadBalancers(
svc *corev1.Service,
enforceBodies []*apirules.NamespaceRuleEnforceBody,
) (*ruleengine.Evaluation, error) {
if svc == nil || svc.Spec.Type != corev1.ServiceTypeLoadBalancer {
return nil, nil
}
if requiresLoadBalancerCIDRs(enforceBodies) && len(loadBalancerCIDRValues(svc)) == 0 {
return &ruleengine.Evaluation{
Blocking: &ruleengine.Decision{
SetName: "loadBalancer CIDR",
EventReason: events.ReasonForbiddenLoadBalancerCIDR,
Action: apirules.ActionTypeDeny,
Value: ruleengine.Value{
Value: string(corev1.ServiceTypeLoadBalancer),
Path: "spec.type",
},
Message: "loadBalancer service requires spec.loadBalancerIP or spec.loadBalancerSourceRanges because loadBalancer CIDR constraints are enforced by namespace rule",
},
}, nil
}
values := loadBalancerCIDRValues(svc)
if len(values) == 0 {
return nil, nil
}
return evaluateServiceRules[loadBalancerCIDRRule](
svc,
enforceBodies,
serviceRuleSet[loadBalancerCIDRRule]{
Name: "loadBalancer CIDR",
EventReason: events.ReasonForbiddenLoadBalancerCIDR,
Values: func(_ *corev1.Service) []ruleengine.Value {
return values
},
Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []loadBalancerCIDRRule {
if enforce == nil ||
enforce.Services.LoadBalancers == nil ||
len(enforce.Services.LoadBalancers.CIDRs) == 0 {
return nil
}
cidrs := make([]string, 0, len(enforce.Services.LoadBalancers.CIDRs))
for _, cidr := range enforce.Services.LoadBalancers.CIDRs {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
cidrs = append(cidrs, cidr)
}
if len(cidrs) == 0 {
return nil
}
return []loadBalancerCIDRRule{
{
CIDRs: cidrs,
},
}
},
Matches: func(rule loadBalancerCIDRRule, value ruleengine.Value) (ruleengine.Match, error) {
if len(rule.CIDRs) == 0 {
return ruleengine.Match{}, nil
}
for _, rawCIDR := range rule.CIDRs {
allowedCIDR, err := parseCIDR(rawCIDR)
if err != nil {
return ruleengine.Match{}, fmt.Errorf("invalid loadBalancer CIDR %q: %w", rawCIDR, err)
}
if ip := net.ParseIP(value.Value); ip != nil {
if !cidrContainsIP(allowedCIDR, ip) {
continue
}
return ruleengine.Match{
Matched: true,
MatchedValue: rawCIDR,
Detail: fmt.Sprintf("%s is contained in %s", value.Value, rawCIDR),
}, nil
}
_, requestedCIDR, err := net.ParseCIDR(value.Value)
if err != nil {
return ruleengine.Match{}, fmt.Errorf(
"%s contains invalid IP or CIDR %q: %w",
value.Path,
value.Value,
err,
)
}
if !cidrContainsCIDR(allowedCIDR, requestedCIDR) {
continue
}
return ruleengine.Match{
Matched: true,
MatchedValue: rawCIDR,
Detail: fmt.Sprintf("%s is contained in %s", value.Value, rawCIDR),
}, nil
}
return ruleengine.Match{
Matched: false,
}, nil
},
RuleDescription: func(rule loadBalancerCIDRRule) string {
return strings.Join(rule.CIDRs, ", ")
},
AllowedDescription: "Allowed CIDRs",
},
)
}
func requiresLoadBalancerCIDRs(
enforceBodies []*apirules.NamespaceRuleEnforceBody,
) bool {
for _, enforce := range enforceBodies {
if enforce == nil ||
enforce.Services.LoadBalancers == nil {
continue
}
if len(enforce.Services.LoadBalancers.CIDRs) > 0 {
return true
}
}
return false
}
func parseCIDR(raw string) (*net.IPNet, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("CIDR is empty")
}
if !strings.Contains(raw, "/") {
ip := net.ParseIP(raw)
if ip == nil {
return nil, fmt.Errorf("invalid CIDR %q", raw)
}
if ip.To4() != nil {
raw += "/32"
} else {
raw += "/128"
}
}
_, network, err := net.ParseCIDR(raw)
if err != nil {
return nil, err
}
return network, nil
}
func loadBalancerCIDRValues(svc *corev1.Service) []ruleengine.Value {
out := make([]ruleengine.Value, 0, 1+len(svc.Spec.LoadBalancerSourceRanges))
if svc.Spec.LoadBalancerIP != "" {
out = append(out, ruleengine.Value{
Value: svc.Spec.LoadBalancerIP,
Path: "spec.loadBalancerIP",
})
}
for i, sourceRange := range svc.Spec.LoadBalancerSourceRanges {
out = append(out, ruleengine.Value{
Value: sourceRange,
Path: fmt.Sprintf("spec.loadBalancerSourceRanges[%d]", i),
})
}
return out
}