Files
capsule/e2e/rules_enforce_ingress_test.go
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

911 lines
27 KiB
Go

// Copyright 2020-2026 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0
package e2e
import (
"context"
"fmt"
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rbac"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
"github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/utils"
)
var _ = Describe("enforcing ingress hostname namespace rules", Ordered, Label("tenant", "rules", "enforce", "ingress"), func() {
const (
ownerName = "e2e-rules-ingress"
gatewayAPIClusterRole = "e2e-rules-ingress-gateway-api"
)
var tnt *capsulev1beta2.Tenant
hostnameByExact := func(hostnames ...string) runtime.ExpressionMatch {
return runtime.ExpressionMatch{Exact: hostnames}
}
hostnameByExpression := func(expression string) runtime.ExpressionMatch {
return runtime.ExpressionMatch{
ExpressionRegex: runtime.ExpressionRegex{Expression: expression},
}
}
hostnameByMatch := func(exact []string, expression string) runtime.ExpressionMatch {
return runtime.ExpressionMatch{
Exact: exact,
ExpressionRegex: runtime.ExpressionRegex{
Expression: expression,
},
}
}
hostnameByNegatedExpression := func(expression string) runtime.ExpressionMatch {
return runtime.ExpressionMatch{
ExpressionRegex: runtime.ExpressionRegex{
Expression: expression,
Negate: true,
},
}
}
ingressRule := func(
action rules.ActionType,
types []rules.IngressType,
hostnames ...runtime.ExpressionMatch,
) *rules.NamespaceRuleBodyTenant {
return &rules.NamespaceRuleBodyTenant{
NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
Enforce: &rules.NamespaceRuleEnforceBody{
Action: action,
Ingress: rules.NamespaceRuleEnforceIngressBody{
Types: types,
Hostnames: hostnames,
},
},
},
}
}
selectedRule := func(
selector map[string]string,
rule *rules.NamespaceRuleBodyTenant,
) *rules.NamespaceRuleBodyTenant {
rule.NamespaceSelector = &metav1.LabelSelector{MatchLabels: selector}
return rule
}
targetTypes := []rules.IngressType{
rules.IngressTypeIngress,
rules.IngressTypeHTTPRoute,
rules.IngressTypeGateway,
}
baseTenantRules := func() []*rules.NamespaceRuleBodyTenant {
return []*rules.NamespaceRuleBodyTenant{
ingressRule(
rules.ActionTypeAudit,
targetTypes,
hostnameByExact("audited.example.com", "audited.example.net"),
),
selectedRule(
map[string]string{"enforce-hostnames": "true"},
ingressRule(
rules.ActionTypeAllow,
targetTypes,
hostnameByExact("internal.example.com"),
hostnameByExpression("^[a-z0-9-]+\\.example\\.com$"),
),
),
selectedRule(
map[string]string{"enforce-hostnames": "true"},
ingressRule(
rules.ActionTypeDeny,
targetTypes,
hostnameByExact("blocked.example.com"),
),
),
selectedRule(
map[string]string{"allow-blocked-hostname": "true"},
ingressRule(
rules.ActionTypeAllow,
targetTypes,
hostnameByExact("blocked.example.com"),
),
),
selectedRule(
map[string]string{"combined-hostname-match": "true"},
ingressRule(
rules.ActionTypeAllow,
targetTypes,
hostnameByMatch(
[]string{"combined-exact.example.net"},
"^combined-[a-z0-9-]+\\.example\\.org$",
),
),
),
selectedRule(
map[string]string{"negated-hostname-match": "true"},
ingressRule(
rules.ActionTypeDeny,
targetTypes,
hostnameByNegatedExpression("^([a-z0-9-]+\\.)*trusted\\.example$"),
),
),
}
}
newTenant := func() *capsulev1beta2.Tenant {
return &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{
Name: "e2e-rule-ingress",
Labels: map[string]string{
"env": "e2e",
},
},
Spec: capsulev1beta2.TenantSpec{
Owners: rbac.OwnerListSpec{
{
CoreOwnerSpec: rbac.CoreOwnerSpec{
UserSpec: rbac.UserSpec{
Name: ownerName,
Kind: "User",
},
},
},
},
AdditionalRoleBindings: []rbac.AdditionalRoleBindingsSpec{
{
ClusterRoleName: gatewayAPIClusterRole,
Subjects: []rbacv1.Subject{
{
APIGroup: rbacv1.GroupName,
Kind: rbacv1.UserKind,
Name: ownerName,
},
},
},
},
Rules: baseTenantRules(),
},
}
}
type expectedIngressStatusRule struct {
action rules.ActionType
hostnames []runtime.ExpressionMatch
}
expectNamespaceStatusRules := func(nsName string, want []expectedIngressStatusRule) {
Eventually(func(g Gomega) {
nsStatus := &capsulev1beta2.RuleStatus{}
g.Expect(k8sClient.Get(
context.Background(),
client.ObjectKey{Name: meta.NameForManagedRuleStatus(), Namespace: nsName},
nsStatus,
)).To(Succeed())
g.Expect(nsStatus.Status.Rules).To(HaveLen(len(want)))
for i, expected := range want {
got := nsStatus.Status.Rules[i]
g.Expect(got).NotTo(BeNil())
g.Expect(got.Enforce).NotTo(BeNil())
g.Expect(got.Enforce.Action).To(Equal(expected.action))
g.Expect(got.Enforce.Ingress.Types).To(Equal(targetTypes))
g.Expect(got.Enforce.Ingress.Hostnames).To(Equal(expected.hostnames))
}
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
}
auditStatusRule := expectedIngressStatusRule{
action: rules.ActionTypeAudit,
hostnames: []runtime.ExpressionMatch{
hostnameByExact("audited.example.com", "audited.example.net"),
},
}
allowStatusRule := expectedIngressStatusRule{
action: rules.ActionTypeAllow,
hostnames: []runtime.ExpressionMatch{
hostnameByExact("internal.example.com"),
hostnameByExpression("^[a-z0-9-]+\\.example\\.com$"),
},
}
denyStatusRule := expectedIngressStatusRule{
action: rules.ActionTypeDeny,
hostnames: []runtime.ExpressionMatch{
hostnameByExact("blocked.example.com"),
},
}
exceptionStatusRule := expectedIngressStatusRule{
action: rules.ActionTypeAllow,
hostnames: []runtime.ExpressionMatch{
hostnameByExact("blocked.example.com"),
},
}
combinedStatusRule := expectedIngressStatusRule{
action: rules.ActionTypeAllow,
hostnames: []runtime.ExpressionMatch{
hostnameByMatch(
[]string{"combined-exact.example.net"},
"^combined-[a-z0-9-]+\\.example\\.org$",
),
},
}
negatedStatusRule := expectedIngressStatusRule{
action: rules.ActionTypeDeny,
hostnames: []runtime.ExpressionMatch{
hostnameByNegatedExpression("^([a-z0-9-]+\\.)*trusted\\.example$"),
},
}
createNamespace := func(labels map[string]string, expectedRules ...expectedIngressStatusRule) *corev1.Namespace {
if labels == nil {
labels = map[string]string{}
}
labels[meta.TenantLabel] = tnt.GetName()
ns := NewNamespace("", labels)
NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
NamespaceIsPartOfTenant(tnt, ns).Should(Succeed())
expectNamespaceStatusRules(ns.Name, expectedRules)
return ns
}
ingress := func(name string, hostnames ...string) *networkingv1.Ingress {
obj := &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{Name: name},
}
for _, hostname := range hostnames {
obj.Spec.Rules = append(obj.Spec.Rules, networkingv1.IngressRule{
Host: hostname,
IngressRuleValue: networkingv1.IngressRuleValue{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{
Path: "/",
PathType: ptr.To(networkingv1.PathTypePrefix),
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "tenant-api",
Port: networkingv1.ServiceBackendPort{Number: 8080},
},
},
},
},
},
},
})
}
return obj
}
hostlessIngress := func(name string) *networkingv1.Ingress {
obj := ingress(name)
obj.Spec.DefaultBackend = &networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: "tenant-api",
Port: networkingv1.ServiceBackendPort{Number: 8080},
},
}
return obj
}
createIngressAndExpectAllowed := func(cs kubernetes.Interface, nsName string, obj *networkingv1.Ingress) {
EventuallyCreation(func() error {
_, err := cs.NetworkingV1().Ingresses(nsName).Create(context.Background(), obj, metav1.CreateOptions{})
return err
}).Should(Succeed())
}
createIngressAndExpectDenied := func(
cs kubernetes.Interface,
nsName string,
obj *networkingv1.Ingress,
substrings ...string,
) {
base := obj.DeepCopy()
baseName := base.Name
Eventually(func() error {
candidate := base.DeepCopy()
candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6)
_, err := cs.NetworkingV1().Ingresses(nsName).Create(context.Background(), candidate, metav1.CreateOptions{})
if err == nil {
_ = cs.NetworkingV1().Ingresses(nsName).Delete(context.Background(), candidate.Name, metav1.DeleteOptions{})
return fmt.Errorf("expected ingress create to be denied, but it succeeded")
}
if apierrors.IsAlreadyExists(err) {
return fmt.Errorf("unexpected AlreadyExists: %v", err)
}
for _, substring := range substrings {
if !strings.Contains(err.Error(), substring) {
return fmt.Errorf("expected error to contain %q, got: %s", substring, err)
}
}
return nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
}
expectAuditEvent := func(cs kubernetes.Interface, nsName, kind, objectName string, substrings ...string) {
Eventually(func() error {
eventList, err := cs.EventsV1().Events(nsName).List(context.Background(), metav1.ListOptions{})
if err != nil {
return err
}
for _, event := range eventList.Items {
if event.Reason != events.ReasonNamespaceRuleAudit ||
event.Regarding.Kind != kind ||
event.Regarding.Name != objectName {
continue
}
matched := true
for _, substring := range substrings {
if !strings.Contains(event.Note, substring) {
matched = false
break
}
}
if matched {
return nil
}
}
return fmt.Errorf("expected audit event for %s %q containing %q", kind, objectName, substrings)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
}
requireOptionalAPI := func(nsName string, list client.ObjectList, description string) {
if err := k8sClient.List(context.Background(), list, client.InNamespace(nsName)); err != nil {
if utils.IsUnsupportedAPI(err) {
Skip(fmt.Sprintf("%s is not available: %s", description, err))
}
Expect(err).NotTo(HaveOccurred())
}
}
BeforeAll(func() {
utilruntime.Must(gatewayv1.Install(scheme.Scheme))
role := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{Name: gatewayAPIClusterRole},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{"gateway.networking.k8s.io"},
Resources: []string{"gateways", "httproutes"},
Verbs: []string{"create", "delete"},
},
},
}
Eventually(func() error {
current := &rbacv1.ClusterRole{}
err := k8sClient.Get(context.Background(), client.ObjectKey{Name: gatewayAPIClusterRole}, current)
if apierrors.IsNotFound(err) {
return k8sClient.Create(context.Background(), role)
}
if err != nil {
return err
}
current.Rules = role.Rules
return k8sClient.Update(context.Background(), current)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
AfterAll(func() {
err := k8sClient.Delete(context.Background(), &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{Name: gatewayAPIClusterRole},
})
if !apierrors.IsNotFound(err) {
Expect(err).NotTo(HaveOccurred())
}
})
JustBeforeEach(func() {
tnt = newTenant()
EventuallyCreation(func() error {
tnt.ResourceVersion = ""
return k8sClient.Create(context.Background(), tnt)
}).Should(Succeed())
TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval)
})
JustAfterEach(func() {
EventuallyDeletion(tnt)
})
It("projects ingress rules into namespace RuleStatus", func() {
createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
})
It("allows exact and regex hostnames across Ingress rules and TLS hosts", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
obj := ingress("allowed-hostnames", "internal.example.com", "api.example.com")
obj.Spec.TLS = []networkingv1.IngressTLS{{Hosts: []string{"api.example.com"}}}
createIngressAndExpectAllowed(cs, ns.Name, obj)
})
It("supports exact and regex alternatives in the same hostname matcher", func() {
ns := createNamespace(
map[string]string{"combined-hostname-match": "true"},
auditStatusRule,
combinedStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
createIngressAndExpectAllowed(cs, ns.Name, ingress("combined-exact", "combined-exact.example.net"))
createIngressAndExpectAllowed(cs, ns.Name, ingress("combined-regex", "combined-team-a.example.org"))
createIngressAndExpectDenied(cs, ns.Name, ingress("combined-miss", "combined.example.com"),
"combined.example.com",
"not allowed",
"combined-exact.example.net",
"combined-[a-z0-9-]+",
)
})
It("applies negation to hostname expressions", func() {
ns := createNamespace(
map[string]string{"negated-hostname-match": "true"},
auditStatusRule,
negatedStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
createIngressAndExpectAllowed(cs, ns.Name, ingress("negated-trusted", "api.trusted.example"))
createIngressAndExpectDenied(cs, ns.Name, ingress("negated-denied", "api.untrusted.example"),
"api.untrusted.example",
"denied",
"not exp:",
)
})
It("denies a hostname outside the allow-list", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
createIngressAndExpectDenied(cs, ns.Name, ingress("allow-miss", "api.example.net"),
"api.example.net",
"spec.rules[0].host",
"not allowed",
)
})
It("reports the index of a rejected hostname in a multi-rule Ingress", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
createIngressAndExpectDenied(
cs,
ns.Name,
ingress("multi-rule-allow-miss", "api.example.com", "api.example.net"),
"api.example.net",
"spec.rules[1].host",
"not allowed",
)
})
It("denies a disallowed TLS hostname even when the routing hostname is allowed", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
obj := ingress("tls-allow-miss", "api.example.com")
obj.Spec.TLS = []networkingv1.IngressTLS{{Hosts: []string{"legacy.example.net"}}}
createIngressAndExpectDenied(cs, ns.Name, obj,
"legacy.example.net",
"spec.tls[0].hosts[0]",
"not allowed",
)
})
It("reports the index of a rejected hostname in a multi-host TLS entry", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
obj := ingress("multi-tls-allow-miss", "api.example.com")
obj.Spec.TLS = []networkingv1.IngressTLS{{
Hosts: []string{"api.example.com", "legacy.example.net"},
}}
createIngressAndExpectDenied(cs, ns.Name, obj,
"legacy.example.net",
"spec.tls[0].hosts[1]",
"not allowed",
)
})
It("applies later deny and namespace-selected allow precedence", func() {
deniedNS := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
allowedNS := createNamespace(
map[string]string{
"enforce-hostnames": "true",
"allow-blocked-hostname": "true",
},
auditStatusRule,
allowStatusRule,
denyStatusRule,
exceptionStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
createIngressAndExpectDenied(cs, deniedNS.Name, ingress("blocked-denied", "blocked.example.com"),
"blocked.example.com",
"denied",
)
createIngressAndExpectAllowed(cs, allowedNS.Name, ingress("blocked-allowed", "blocked.example.com"))
})
It("denies an update that changes an allowed hostname to an allow-list miss", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
obj := ingress("hostname-update", "api.example.com")
createIngressAndExpectAllowed(cs, ns.Name, obj)
Eventually(func() error {
current, err := cs.NetworkingV1().Ingresses(ns.Name).Get(context.Background(), obj.Name, metav1.GetOptions{})
if err != nil {
return err
}
current.Spec.Rules[0].Host = "api.example.net"
_, err = cs.NetworkingV1().Ingresses(ns.Name).Update(context.Background(), current, metav1.UpdateOptions{})
if err == nil {
return fmt.Errorf("expected ingress update to be denied, but it succeeded")
}
for _, substring := range []string{"api.example.net", "spec.rules[0].host", "not allowed"} {
if !strings.Contains(err.Error(), substring) {
return fmt.Errorf("expected error to contain %q, got: %s", substring, err)
}
}
return nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("audits a matching hostname without blocking admission", func() {
ns := createNamespace(nil, auditStatusRule)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
obj := ingress("hostname-audited", "audited.example.com")
createIngressAndExpectAllowed(cs, ns.Name, obj)
expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name,
`ingress hostname "audited.example.com"`,
"spec.rules[0].host",
"matched audit namespace rule",
)
})
It("emits an audit event even when an allow-list denies the request", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
obj := ingress("audited-and-denied", "audited.example.net")
_, err := cs.NetworkingV1().Ingresses(ns.Name).Create(context.Background(), obj, metav1.CreateOptions{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("audited.example.net"))
Expect(err.Error()).To(ContainSubstring("not allowed"))
expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name,
`ingress hostname "audited.example.net"`,
"matched audit namespace rule",
)
})
It("admits an empty hostname for audit-only rules and emits an audit event", func() {
ns := createNamespace(nil, auditStatusRule)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
obj := hostlessIngress("empty-hostname-audited")
createIngressAndExpectAllowed(cs, ns.Name, obj)
expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name,
"empty hostname detected",
"spec.rules[].host",
"audit namespace rule",
)
})
It("audits an empty TLS hosts entry without blocking audit-only admission", func() {
ns := createNamespace(nil, auditStatusRule)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
obj := ingress("empty-tls-hosts-audited", "neutral.example.org")
obj.Spec.TLS = []networkingv1.IngressTLS{{SecretName: "tenant-api-tls"}}
createIngressAndExpectAllowed(cs, ns.Name, obj)
expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name,
"empty hostname detected",
"spec.tls[0].hosts",
"audit namespace rule",
)
})
It("denies an empty TLS hosts entry when allow or deny rules apply", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
obj := ingress("empty-tls-hosts-denied", "api.example.com")
obj.Spec.TLS = []networkingv1.IngressTLS{{SecretName: "tenant-api-tls"}}
createIngressAndExpectDenied(cs, ns.Name, obj,
"hostname is required",
"spec.tls[0].hosts",
"Ingress",
)
})
It("denies an empty hostname when allow or deny rules apply", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
createIngressAndExpectDenied(cs, ns.Name, hostlessIngress("empty-hostname-denied"),
"hostname is required",
"spec.rules[].host",
"Ingress",
)
})
It("enforces every HTTPRoute hostname when Gateway API is available", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
requireOptionalAPI(ns.Name, &gatewayv1.HTTPRouteList{}, "Gateway API HTTPRoute")
owner := impersonationClient(ownerName, withDefaultGroups(nil))
allowed := &gatewayv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Name: "route-hostnames-allowed", Namespace: ns.Name},
Spec: gatewayv1.HTTPRouteSpec{
Hostnames: []gatewayv1.Hostname{"api.example.com", "internal.example.com"},
},
}
EventuallyCreation(func() error {
return owner.Create(context.Background(), allowed)
}).Should(Succeed())
Eventually(func() error {
denied := &gatewayv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("route-hostnames-denied-%d", time.Now().UnixNano()%1e6),
Namespace: ns.Name,
},
Spec: gatewayv1.HTTPRouteSpec{
Hostnames: []gatewayv1.Hostname{"api.example.com", "api.example.net"},
},
}
err := owner.Create(context.Background(), denied)
if err == nil {
_ = owner.Delete(context.Background(), denied)
return fmt.Errorf("expected HTTPRoute create to be denied, but it succeeded")
}
for _, substring := range []string{"api.example.net", "spec.hostnames[1]", "not allowed"} {
if !strings.Contains(err.Error(), substring) {
return fmt.Errorf("expected error to contain %q, got: %s", substring, err)
}
}
return nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("audits or rejects an HTTPRoute without hostnames according to action", func() {
auditNS := createNamespace(nil, auditStatusRule)
enforceNS := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
requireOptionalAPI(auditNS.Name, &gatewayv1.HTTPRouteList{}, "Gateway API HTTPRoute")
owner := impersonationClient(ownerName, withDefaultGroups(nil))
audited := &gatewayv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Name: "route-empty-hostnames-audited", Namespace: auditNS.Name},
}
EventuallyCreation(func() error {
return owner.Create(context.Background(), audited)
}).Should(Succeed())
expectAuditEvent(clusterAdminClient(), auditNS.Name, "HTTPRoute", audited.Name,
"empty hostname detected",
"spec.hostnames[]",
"audit namespace rule",
)
denied := &gatewayv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Name: "route-empty-hostnames-denied", Namespace: enforceNS.Name},
}
err := owner.Create(context.Background(), denied)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("hostname is required"))
Expect(err.Error()).To(ContainSubstring("spec.hostnames[]"))
Expect(err.Error()).To(ContainSubstring("HTTPRoute"))
})
It("enforces every Gateway listener hostname when Gateway API is available", func() {
ns := createNamespace(
map[string]string{"enforce-hostnames": "true"},
auditStatusRule,
allowStatusRule,
denyStatusRule,
)
requireOptionalAPI(ns.Name, &gatewayv1.GatewayList{}, "Gateway API Gateway")
owner := impersonationClient(ownerName, withDefaultGroups(nil))
allowed := &gatewayv1.Gateway{
ObjectMeta: metav1.ObjectMeta{Name: "gateway-hostnames-allowed", Namespace: ns.Name},
Spec: gatewayv1.GatewaySpec{
GatewayClassName: "unmanaged-e2e-class",
Listeners: []gatewayv1.Listener{
{
Name: "http",
Protocol: gatewayv1.HTTPProtocolType,
Port: 80,
Hostname: ptr.To(gatewayv1.Hostname("api.example.com")),
},
{
Name: "http-alt",
Protocol: gatewayv1.HTTPProtocolType,
Port: 8080,
Hostname: ptr.To(gatewayv1.Hostname("internal.example.com")),
},
},
},
}
EventuallyCreation(func() error {
return owner.Create(context.Background(), allowed)
}).Should(Succeed())
Eventually(func() error {
denied := allowed.DeepCopy()
denied.ResourceVersion = ""
denied.Name = fmt.Sprintf("gateway-hostnames-denied-%d", time.Now().UnixNano()%1e6)
denied.Spec.Listeners[1].Hostname = ptr.To(gatewayv1.Hostname("api.example.net"))
err := owner.Create(context.Background(), denied)
if err == nil {
_ = owner.Delete(context.Background(), denied)
return fmt.Errorf("expected Gateway create to be denied, but it succeeded")
}
for _, substring := range []string{"api.example.net", "spec.listeners[1].hostname", "not allowed"} {
if !strings.Contains(err.Error(), substring) {
return fmt.Errorf("expected error to contain %q, got: %s", substring, err)
}
}
return nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("audits a Gateway listener without a hostname without blocking admission", func() {
ns := createNamespace(nil, auditStatusRule)
requireOptionalAPI(ns.Name, &gatewayv1.GatewayList{}, "Gateway API Gateway")
owner := impersonationClient(ownerName, withDefaultGroups(nil))
obj := &gatewayv1.Gateway{
ObjectMeta: metav1.ObjectMeta{Name: "gateway-empty-hostname-audited", Namespace: ns.Name},
Spec: gatewayv1.GatewaySpec{
GatewayClassName: "unmanaged-e2e-class",
Listeners: []gatewayv1.Listener{
{
Name: "http",
Protocol: gatewayv1.HTTPProtocolType,
Port: 80,
},
},
},
}
EventuallyCreation(func() error {
return owner.Create(context.Background(), obj)
}).Should(Succeed())
expectAuditEvent(clusterAdminClient(), ns.Name, "Gateway", obj.Name,
"empty hostname detected",
"spec.listeners[0].hostname",
"audit namespace rule",
)
})
})