diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml index ac5c20e8..839c8068 100644 --- a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -295,6 +295,19 @@ spec: services: description: Enforcement for Services. properties: + externalIPs: + description: ExternalIPs defines constraints for spec.externalIPs. + properties: + cidrs: + description: |- + CIDRs restricts spec.externalIPs. Individual IP addresses are treated as + host CIDRs (/32 for IPv4 and /128 for IPv6). + For deny rules, empty means all external IPs are denied. For allow and + audit rules, empty means no external IP restriction. + items: + type: string + type: array + type: object externalNames: description: ExternalNames defines additional constraints for Services of type ExternalName. @@ -972,6 +985,19 @@ spec: services: description: Enforcement for Services. properties: + externalIPs: + description: ExternalIPs defines constraints for spec.externalIPs. + properties: + cidrs: + description: |- + CIDRs restricts spec.externalIPs. Individual IP addresses are treated as + host CIDRs (/32 for IPv4 and /128 for IPv6). + For deny rules, empty means all external IPs are denied. For allow and + audit rules, empty means no external IP restriction. + items: + type: string + type: array + type: object externalNames: description: ExternalNames defines additional constraints for Services of type ExternalName. @@ -1588,6 +1614,19 @@ spec: services: description: Enforcement for Services. properties: + externalIPs: + description: ExternalIPs defines constraints for spec.externalIPs. + properties: + cidrs: + description: |- + CIDRs restricts spec.externalIPs. Individual IP addresses are treated as + host CIDRs (/32 for IPv4 and /128 for IPv6). + For deny rules, empty means all external IPs are denied. For allow and + audit rules, empty means no external IP restriction. + items: + type: string + type: array + type: object externalNames: description: ExternalNames defines additional constraints for Services of type ExternalName. diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 467138c2..e27ffac4 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -2752,6 +2752,19 @@ spec: services: description: Enforcement for Services. properties: + externalIPs: + description: ExternalIPs defines constraints for spec.externalIPs. + properties: + cidrs: + description: |- + CIDRs restricts spec.externalIPs. Individual IP addresses are treated as + host CIDRs (/32 for IPv4 and /128 for IPv6). + For deny rules, empty means all external IPs are denied. For allow and + audit rules, empty means no external IP restriction. + items: + type: string + type: array + type: object externalNames: description: ExternalNames defines additional constraints for Services of type ExternalName. diff --git a/e2e/rules_enforce_services_test.go b/e2e/rules_enforce_services_test.go index 05ae498b..4b9e80e3 100644 --- a/e2e/rules_enforce_services_test.go +++ b/e2e/rules_enforce_services_test.go @@ -96,6 +96,21 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", " } } + externalIPCIDRRule := func(action rules.ActionType, cidrs ...string) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalIPs: &rules.ServiceExternalIPRule{ + CIDRs: cidrs, + }, + }, + }, + }, + } + } + externalNameRule := func(action rules.ActionType, hostnames ...runtime.ExpressionMatch) *rules.NamespaceRuleBodyTenant { return &rules.NamespaceRuleBodyTenant{ NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ @@ -260,6 +275,8 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", " action rules.ActionType types []rules.ServiceType loadBalancerCIDRs []string + externalIPs bool + externalIPCIDRs []string nodePortRanges []rules.ServiceNodePortRange externalExpressions []string externalExact [][]string @@ -303,6 +320,13 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", " g.Expect(got.Enforce.Services.LoadBalancers.CIDRs).To(Equal(expected.loadBalancerCIDRs)) } + if expected.externalIPs { + g.Expect(got.Enforce.Services.ExternalIPs).NotTo(BeNil()) + g.Expect(got.Enforce.Services.ExternalIPs.CIDRs).To(Equal(expected.externalIPCIDRs)) + } else { + g.Expect(got.Enforce.Services.ExternalIPs).To(BeNil()) + } + if len(expected.nodePortRanges) == 0 { if got.Enforce.Services.NodePorts != nil { g.Expect(got.Enforce.Services.NodePorts.Ports).To(BeEmpty()) @@ -593,6 +617,13 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", " } } + externalIPService := func(name string, externalIPs ...string) *corev1.Service { + svc := clusterIPService(name) + svc.Spec.ExternalIPs = externalIPs + + return svc + } + nodePortService := func(name string, nodePort int32) *corev1.Service { port := servicePort() port.NodePort = nodePort @@ -859,6 +890,105 @@ var _ = Describe("enforcing service namespace rules", Ordered, Label("tenant", " ) }) + It("allows only Service external IPs contained in configured CIDRs", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + ), + externalIPCIDRRule( + rules.ActionTypeAllow, + "10.20.0.0/16", + "192.168.1.2", + ), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + }, + }, + { + action: rules.ActionTypeAllow, + externalIPs: true, + externalIPCIDRs: []string{ + "10.20.0.0/16", + "192.168.1.2", + }, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, clusterIPService("external-ip-omitted-allowed")) + + allowed := externalIPService("external-ip-cidr-allowed", "10.20.1.44") + createServiceAndExpectAllowed(cs, ns.Name, allowed) + createServiceAndExpectAllowed(cs, ns.Name, externalIPService("external-ip-host-allowed", "192.168.1.2")) + + createServiceAndExpectDenied(cs, ns.Name, externalIPService("external-ip-denied", "8.8.8.8"), + "external IP", + "8.8.8.8", + "spec.externalIPs[0]", + "not allowed", + "Allowed CIDRs", + "10.20.0.0/16", + "192.168.1.2", + ) + + updateServiceAndExpectDenied( + cs, + ns.Name, + allowed.Name, + func(svc *corev1.Service) { + svc.Spec.ExternalIPs = []string{"8.8.4.4"} + }, + "external IP", + "8.8.4.4", + "spec.externalIPs[0]", + "not allowed", + ) + }) + + It("denies every specified Service external IP when a deny rule has empty CIDRs", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + serviceTypeRule( + rules.ActionTypeAllow, + rules.ServiceTypeClusterIP, + ), + externalIPCIDRRule(rules.ActionTypeDeny), + }) + + ns := createNamespace(nil) + + expectNamespaceStatusRules(ns.Name, []expectedServiceStatusRule{ + { + action: rules.ActionTypeAllow, + types: []rules.ServiceType{ + rules.ServiceTypeClusterIP, + }, + }, + { + action: rules.ActionTypeDeny, + externalIPs: true, + }, + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createServiceAndExpectAllowed(cs, ns.Name, clusterIPService("external-ip-empty-deny-omitted")) + createServiceAndExpectDenied(cs, ns.Name, externalIPService("external-ip-empty-denied", "10.20.1.44"), + "external IP", + "10.20.1.44", + "denied", + "all external IPs", + ) + }) + It("allows exact, regex, and combined ExternalName hostname matchers", func() { ns := createNamespace(nil) cs := ownerClient(tnt.Spec.Owners[0].UserSpec) diff --git a/internal/webhook/rules/services/validation/external_ip.go b/internal/webhook/rules/services/validation/external_ip.go new file mode 100644 index 00000000..67039cd8 --- /dev/null +++ b/internal/webhook/rules/services/validation/external_ip.go @@ -0,0 +1,131 @@ +// 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 externalIPCIDRRule struct { + CIDRs []string + MatchAll bool +} + +func (h *serviceRules) validateExternalIPs( + svc *corev1.Service, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + return evaluateServiceRules[externalIPCIDRRule]( + svc, + enforceBodies, + serviceRuleSet[externalIPCIDRRule]{ + Name: "external IP", + EventReason: events.ReasonForbiddenExternalServiceIP, + Values: externalIPValues, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []externalIPCIDRRule { + if enforce == nil || enforce.Services.ExternalIPs == nil { + return nil + } + + if len(enforce.Services.ExternalIPs.CIDRs) == 0 { + if enforce.Action.OrDefault() == apirules.ActionTypeDeny { + return []externalIPCIDRRule{ + { + MatchAll: true, + }, + } + } + + return nil + } + + cidrs := make([]string, 0, len(enforce.Services.ExternalIPs.CIDRs)) + for _, cidr := range enforce.Services.ExternalIPs.CIDRs { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + + cidrs = append(cidrs, cidr) + } + + if len(cidrs) == 0 { + return nil + } + + return []externalIPCIDRRule{ + { + CIDRs: cidrs, + }, + } + }, + Matches: func(rule externalIPCIDRRule, value ruleengine.Value) (ruleengine.Match, error) { + if rule.MatchAll { + return ruleengine.Match{ + Matched: true, + MatchedValue: "all external IPs", + Detail: "deny rule applies to all external IPs", + }, nil + } + + ip := net.ParseIP(value.Value) + if ip == nil { + return ruleengine.Match{}, fmt.Errorf( + "%s contains invalid IP %q", + value.Path, + value.Value, + ) + } + + for _, rawCIDR := range rule.CIDRs { + allowedCIDR, err := parseCIDR(rawCIDR) + if err != nil { + return ruleengine.Match{}, fmt.Errorf("invalid external IP CIDR %q: %w", rawCIDR, err) + } + + if !cidrContainsIP(allowedCIDR, ip) { + continue + } + + return ruleengine.Match{ + Matched: true, + MatchedValue: rawCIDR, + Detail: fmt.Sprintf("%s is contained in %s", value.Value, rawCIDR), + }, nil + } + + return ruleengine.Match{}, nil + }, + RuleDescription: func(rule externalIPCIDRRule) string { + if rule.MatchAll { + return "all external IPs" + } + + return strings.Join(rule.CIDRs, ", ") + }, + AllowedDescription: "Allowed CIDRs", + }, + ) +} + +func externalIPValues(svc *corev1.Service) []ruleengine.Value { + out := make([]ruleengine.Value, 0, len(svc.Spec.ExternalIPs)) + + for i, externalIP := range svc.Spec.ExternalIPs { + out = append(out, ruleengine.Value{ + Value: externalIP, + Path: fmt.Sprintf("spec.externalIPs[%d]", i), + }) + } + + return out +} diff --git a/internal/webhook/rules/services/validation/external_ip_test.go b/internal/webhook/rules/services/validation/external_ip_test.go new file mode 100644 index 00000000..3157b643 --- /dev/null +++ b/internal/webhook/rules/services/validation/external_ip_test.go @@ -0,0 +1,350 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + 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" +) + +func TestServiceRulesValidateExternalIPs(t *testing.T) { + tests := []struct { + name string + svc *corev1.Service + enforceBodies []*apirules.NamespaceRuleEnforceBody + wantNil bool + wantBlocking bool + wantFinal bool + wantAudits int + wantErr string + wantMessage []string + }{ + { + name: "nil service returns nil evaluation", + svc: nil, + wantNil: true, + }, + { + name: "service without external IPs remains unrestricted", + svc: externalIPServiceForTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAllow, "10.0.0.0/8"), + }, + }, + { + name: "external IP without external IP rules remains unrestricted", + svc: externalIPServiceForTest("10.0.0.2"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + {Action: apirules.ActionTypeAllow}, + }, + }, + { + name: "allows IPv4 address inside configured CIDR", + svc: externalIPServiceForTest("10.20.1.44"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16"), + }, + wantFinal: true, + wantMessage: []string{ + `external IP "10.20.1.44" at spec.externalIPs[0] is allowed by namespace rule`, + "10.20.1.44 is contained in 10.20.0.0/16", + }, + }, + { + name: "allows plain IPv4 rule as a host CIDR", + svc: externalIPServiceForTest("192.168.1.2"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAllow, "192.168.1.2"), + }, + wantFinal: true, + }, + { + name: "allows IPv6 address inside configured CIDR", + svc: externalIPServiceForTest("2001:db8::2"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAllow, "2001:db8::/32"), + }, + wantFinal: true, + wantMessage: []string{ + "2001:db8::2 is contained in 2001:db8::/32", + }, + }, + { + name: "allow miss denies address outside configured CIDRs", + svc: externalIPServiceForTest("8.8.8.8"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16", "192.168.1.2"), + }, + wantBlocking: true, + wantMessage: []string{ + `external IP "8.8.8.8" at spec.externalIPs[0] is not allowed by namespace rule`, + "Allowed CIDRs", + "10.20.0.0/16", + "192.168.1.2", + }, + }, + { + name: "all external IPs must match an allow rule", + svc: externalIPServiceForTest("10.20.1.44", "8.8.8.8"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16"), + }, + wantFinal: true, + wantBlocking: true, + wantMessage: []string{ + `external IP "8.8.8.8" at spec.externalIPs[1] is not allowed by namespace rule`, + }, + }, + { + name: "matching deny rule blocks address", + svc: externalIPServiceForTest("10.20.66.4"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeDeny, "10.20.66.0/24"), + }, + wantFinal: true, + wantBlocking: true, + wantMessage: []string{ + `external IP "10.20.66.4" at spec.externalIPs[0] is denied by namespace rule`, + }, + }, + { + name: "deny with omitted CIDRs blocks every external IP", + svc: externalIPServiceForTest("10.20.1.44"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeDeny), + }, + wantFinal: true, + wantBlocking: true, + wantMessage: []string{ + `external IP "10.20.1.44" at spec.externalIPs[0] is denied by namespace rule`, + "all external IPs", + "deny rule applies to all external IPs", + }, + }, + { + name: "deny with explicit empty CIDRs blocks every external IP", + svc: externalIPServiceForTest("2001:db8::2"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + { + Action: apirules.ActionTypeDeny, + Services: apirules.NamespaceRuleEnforceServicesBody{ + ExternalIPs: &apirules.ServiceExternalIPRule{ + CIDRs: []string{}, + }, + }, + }, + }, + wantFinal: true, + wantBlocking: true, + }, + { + name: "default deny action with empty CIDRs blocks every external IP", + svc: externalIPServiceForTest("10.20.1.44"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(""), + }, + wantFinal: true, + wantBlocking: true, + }, + { + name: "deny with empty CIDRs does not require an external IP", + svc: externalIPServiceForTest(), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeDeny), + }, + }, + { + name: "later allow overrides earlier matching deny", + svc: externalIPServiceForTest("10.20.66.4"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeDeny, "10.20.0.0/16"), + externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.66.0/24"), + }, + wantFinal: true, + }, + { + name: "matching audit rule is observational", + svc: externalIPServiceForTest("10.20.66.4"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAudit, "10.20.66.0/24"), + }, + wantAudits: 1, + wantMessage: []string{ + `external IP "10.20.66.4" at spec.externalIPs[0] matched audit namespace rule`, + }, + }, + { + name: "audit does not satisfy a non-matching allow rule", + svc: externalIPServiceForTest("10.20.66.4"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAudit, "10.20.66.0/24"), + externalIPEnforceForTest(apirules.ActionTypeAllow, "192.168.0.0/16"), + }, + wantAudits: 1, + wantBlocking: true, + }, + { + name: "blank configured CIDRs are ignored", + svc: externalIPServiceForTest("10.20.1.44"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAllow, "", " "), + }, + }, + { + name: "invalid configured CIDR returns matcher error", + svc: externalIPServiceForTest("10.20.1.44"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/33"), + }, + wantErr: `external IP: invalid rule: invalid external IP CIDR "10.20.0.0/33"`, + }, + { + name: "invalid requested external IP returns matcher error", + svc: externalIPServiceForTest("not-an-ip"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16"), + }, + wantErr: `external IP: invalid rule: spec.externalIPs[0] contains invalid IP "not-an-ip"`, + }, + { + name: "nil enforce body is ignored", + svc: externalIPServiceForTest("10.20.1.44"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + nil, + externalIPEnforceForTest(apirules.ActionTypeAllow, "10.20.0.0/16"), + }, + wantFinal: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := serviceRulesForTest() + + evaluation, err := h.validateExternalIPs(tt.svc, tt.enforceBodies) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if tt.wantNil { + if evaluation != nil { + t.Fatalf("expected nil evaluation, got %#v", evaluation) + } + + return + } + + if evaluation == nil { + t.Fatalf("expected evaluation, got nil") + } + + if (evaluation.Blocking != nil) != tt.wantBlocking { + t.Fatalf("blocking decision = %#v, want presence %t", evaluation.Blocking, tt.wantBlocking) + } + + if (evaluation.Final != nil) != tt.wantFinal { + t.Fatalf("final decision = %#v, want presence %t", evaluation.Final, tt.wantFinal) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("audit decisions = %d, want %d", len(evaluation.Audits), tt.wantAudits) + } + + message := externalIPDecisionMessagesForTest(evaluation) + for _, expected := range tt.wantMessage { + if !strings.Contains(message, expected) { + t.Fatalf("expected message %q to contain %q", message, expected) + } + } + + if evaluation.Final != nil && evaluation.Final.EventReason != events.ReasonForbiddenExternalServiceIP { + t.Fatalf("final event reason = %q, want %q", evaluation.Final.EventReason, events.ReasonForbiddenExternalServiceIP) + } + + if evaluation.Blocking != nil && evaluation.Blocking.EventReason != events.ReasonForbiddenExternalServiceIP { + t.Fatalf("blocking event reason = %q, want %q", evaluation.Blocking.EventReason, events.ReasonForbiddenExternalServiceIP) + } + + for _, audit := range evaluation.Audits { + if audit.EventReason != events.ReasonForbiddenExternalServiceIP { + t.Fatalf("audit event reason = %q, want %q", audit.EventReason, events.ReasonForbiddenExternalServiceIP) + } + } + }) + } +} + +func TestExternalIPValues(t *testing.T) { + values := externalIPValues(externalIPServiceForTest("10.20.1.44", "2001:db8::2")) + if len(values) != 2 { + t.Fatalf("expected 2 values, got %d", len(values)) + } + + if values[0].Value != "10.20.1.44" || values[0].Path != "spec.externalIPs[0]" { + t.Fatalf("unexpected first value: %#v", values[0]) + } + + if values[1].Value != "2001:db8::2" || values[1].Path != "spec.externalIPs[1]" { + t.Fatalf("unexpected second value: %#v", values[1]) + } +} + +func externalIPEnforceForTest( + action apirules.ActionType, + cidrs ...string, +) *apirules.NamespaceRuleEnforceBody { + return &apirules.NamespaceRuleEnforceBody{ + Action: action, + Services: apirules.NamespaceRuleEnforceServicesBody{ + ExternalIPs: &apirules.ServiceExternalIPRule{ + CIDRs: cidrs, + }, + }, + } +} + +func externalIPServiceForTest(externalIPs ...string) *corev1.Service { + return &corev1.Service{ + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + ExternalIPs: externalIPs, + }, + } +} + +func externalIPDecisionMessagesForTest(evaluation *ruleengine.Evaluation) string { + messages := make([]string, 0, len(evaluation.Audits)+2) + + for _, audit := range evaluation.Audits { + messages = append(messages, audit.Message) + } + + if evaluation.Final != nil { + messages = append(messages, evaluation.Final.Message) + } + + if evaluation.Blocking != nil && evaluation.Blocking != evaluation.Final { + messages = append(messages, evaluation.Blocking.Message) + } + + return strings.Join(messages, "\n") +} diff --git a/internal/webhook/rules/services/validation/factory.go b/internal/webhook/rules/services/validation/factory.go index 7df917e6..1228484c 100644 --- a/internal/webhook/rules/services/validation/factory.go +++ b/internal/webhook/rules/services/validation/factory.go @@ -62,6 +62,7 @@ func ServiceRules( h.rules = []serviceRuleValidator{ h.validateServiceTypes, h.validateLoadBalancers, + h.validateExternalIPs, h.validateExternalNames, h.validateNodePorts, } diff --git a/pkg/api/rules/enforce_services_types.go b/pkg/api/rules/enforce_services_types.go index 19dacee7..61c8a325 100644 --- a/pkg/api/rules/enforce_services_types.go +++ b/pkg/api/rules/enforce_services_types.go @@ -23,6 +23,10 @@ type NamespaceRuleEnforceServicesBody struct { // +optional LoadBalancers *ServiceLoadBalancerRule `json:"loadBalancers,omitempty"` + // ExternalIPs defines constraints for spec.externalIPs. + // +optional + ExternalIPs *ServiceExternalIPRule `json:"externalIPs,omitempty"` + // ExternalNames defines additional constraints for Services of type ExternalName. // +optional ExternalNames *ServiceExternalNameRule `json:"externalNames,omitempty"` @@ -50,6 +54,16 @@ type ServiceLoadBalancerRule struct { CIDRs []string `json:"cidrs,omitempty"` } +// +kubebuilder:object:generate=true +type ServiceExternalIPRule struct { + // CIDRs restricts spec.externalIPs. Individual IP addresses are treated as + // host CIDRs (/32 for IPv4 and /128 for IPv6). + // For deny rules, empty means all external IPs are denied. For allow and + // audit rules, empty means no external IP restriction. + // +optional + CIDRs []string `json:"cidrs,omitempty"` +} + // +kubebuilder:object:generate=true type ServiceExternalNameRule struct { // Hostnames restricts spec.externalName. diff --git a/pkg/api/rules/zz_generated.deepcopy.go b/pkg/api/rules/zz_generated.deepcopy.go index 30871b2a..c6914cfc 100644 --- a/pkg/api/rules/zz_generated.deepcopy.go +++ b/pkg/api/rules/zz_generated.deepcopy.go @@ -214,6 +214,11 @@ func (in *NamespaceRuleEnforceServicesBody) DeepCopyInto(out *NamespaceRuleEnfor *out = new(ServiceLoadBalancerRule) (*in).DeepCopyInto(*out) } + if in.ExternalIPs != nil { + in, out := &in.ExternalIPs, &out.ExternalIPs + *out = new(ServiceExternalIPRule) + (*in).DeepCopyInto(*out) + } if in.ExternalNames != nil { in, out := &in.ExternalNames, &out.ExternalNames *out = new(ServiceExternalNameRule) @@ -375,6 +380,26 @@ func (in *ResourceQuotaRule) DeepCopy() *ResourceQuotaRule { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceExternalIPRule) DeepCopyInto(out *ServiceExternalIPRule) { + *out = *in + if in.CIDRs != nil { + in, out := &in.CIDRs, &out.CIDRs + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceExternalIPRule. +func (in *ServiceExternalIPRule) DeepCopy() *ServiceExternalIPRule { + if in == nil { + return nil + } + out := new(ServiceExternalIPRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceExternalNameRule) DeepCopyInto(out *ServiceExternalNameRule) { *out = *in diff --git a/pkg/ruleengine/validate.go b/pkg/ruleengine/validate.go index c0c36d8c..d9fb8621 100644 --- a/pkg/ruleengine/validate.go +++ b/pkg/ruleengine/validate.go @@ -415,6 +415,20 @@ func validateServiceRules( } } + if services.ExternalIPs != nil { + for j, cidr := range services.ExternalIPs.CIDRs { + if err := validateCIDR(cidr); err != nil { + return fmt.Errorf( + "rules[%d].enforce.services.externalIPs.cidrs[%d] %q is invalid: %w", + ruleIndex, + j, + cidr, + err, + ) + } + } + } + if services.ExternalNames != nil { for j, hostname := range services.ExternalNames.Hostnames { if err := validateExpressionMatch( diff --git a/pkg/ruleengine/validate_test.go b/pkg/ruleengine/validate_test.go index ca3041c6..7d8bf0b4 100644 --- a/pkg/ruleengine/validate_test.go +++ b/pkg/ruleengine/validate_test.go @@ -647,6 +647,78 @@ func TestValidateRuleStatusBody(t *testing.T) { }, wantErr: `rules[0].enforce.services.loadBalancers.cidrs[0] "" is invalid: CIDR is empty`, }, + { + name: "invalid external IP CIDR", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalIPs: &rules.ServiceExternalIPRule{ + CIDRs: []string{ + "10.20.0.0/33", + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.externalIPs.cidrs[0] "10.20.0.0/33" is invalid`, + }, + { + name: "empty external IP CIDR entry", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalIPs: &rules.ServiceExternalIPRule{ + CIDRs: []string{ + "", + }, + }, + }, + }, + }, + }, + wantErr: `rules[0].enforce.services.externalIPs.cidrs[0] "" is invalid: CIDR is empty`, + }, + { + name: "external IP CIDRs are valid", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalIPs: &rules.ServiceExternalIPRule{ + CIDRs: []string{ + "10.20.0.0/16", + "192.168.1.2", + "2001:db8::/32", + }, + }, + }, + }, + }, + }, + }, + { + name: "empty external IP CIDR deny rule is valid", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Services: rules.NamespaceRuleEnforceServicesBody{ + ExternalIPs: &rules.ServiceExternalIPRule{ + CIDRs: []string{}, + }, + }, + }, + }, + }, + }, { name: "invalid externalName hostname regex", mapper: mapper,