feat: support allowed gateways in tenant namespace rules (issue #1885)

- Add GatewayNamespacedName, AllowedGatewaySpec, GatewayRuleSpec types to pkg/api/
- Add Gateways field to NamespaceRuleEnforceBody for per-namespace gateway rules
- Add HTTPRoute validation webhook to enforce allowed gateways
- Add HTTPRoute default mutation webhook to inject default Gateway parentRef
- Update Helm chart with validating/mutating webhook configurations for httproutes
- Update CRD schemas for tenants and rulestatuses
- Add GatewayForbiddenError and ReasonForbiddenGateway event reason

Co-authored-by: oliverbaehler <26610571+oliverbaehler@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 15:26:33 +00:00
parent 4431f5934d
commit 700838e837
19 changed files with 779 additions and 0 deletions

View File

@@ -58,6 +58,8 @@ func (h *handler) mutate(ctx context.Context, req admission.Request, c client.Cl
response = mutateIngressDefaults(ctx, req, h.version, c, decoder, req.Namespace)
case metav1.GroupVersionResource{Group: "gateway.networking.k8s.io", Version: "v1", Resource: "gateways"}:
response = mutateGatewayDefaults(ctx, req, c, decoder, req.Namespace)
case metav1.GroupVersionResource{Group: "gateway.networking.k8s.io", Version: "v1", Resource: "httproutes"}:
response = mutateHTTPRouteDefaults(ctx, req, c, decoder, req.Namespace)
}
if response == nil {

View File

@@ -0,0 +1,102 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package defaults
import (
"context"
"encoding/json"
"net/http"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
capsulehttproute "github.com/projectcapsule/capsule/internal/webhook/httproute"
"github.com/projectcapsule/capsule/internal/webhook/utils"
"github.com/projectcapsule/capsule/pkg/tenant"
)
func mutateHTTPRouteDefaults(ctx context.Context, req admission.Request, c client.Client, decoder admission.Decoder, namespace string) *admission.Response {
routeObj := &gatewayv1.HTTPRoute{}
if err := decoder.Decode(req, routeObj); err != nil {
return utils.ErroredResponse(err)
}
routeObj.SetNamespace(namespace)
tnt, err := capsulehttproute.TenantFromHTTPRoute(ctx, c, routeObj)
if err != nil {
return utils.ErroredResponse(err)
}
if tnt == nil {
return nil
}
// Resolve namespace-level rules to find the gateway default.
ns := &corev1.Namespace{}
if err = c.Get(ctx, types.NamespacedName{Name: namespace}, ns); err != nil {
return utils.ErroredResponse(err)
}
ruleBody, err := tenant.BuildNamespaceRuleBodyForNamespace(ns, tnt)
if err != nil {
return utils.ErroredResponse(err)
}
if ruleBody == nil || ruleBody.Enforce.Gateways == nil || ruleBody.Enforce.Gateways.Gateway == nil {
return nil
}
gwDefault := ruleBody.Enforce.Gateways.Gateway.Default
if gwDefault == nil {
return nil
}
// Only inject the default when the HTTPRoute has no parentRefs.
if len(routeObj.Spec.ParentRefs) > 0 {
return nil
}
defaultNamespace := gwDefault.Namespace
if defaultNamespace == "" {
defaultNamespace = namespace
}
ns16 := gatewayv1.Namespace(defaultNamespace)
routeObj.Spec.ParentRefs = []gatewayv1.ParentReference{
{
Group: groupPtr(gatewayv1.GroupName),
Kind: kindPtr("Gateway"),
Name: gatewayv1.ObjectName(gwDefault.Name),
Namespace: &ns16,
},
}
marshaled, err := json.Marshal(routeObj)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}
func groupPtr(g string) *gatewayv1.Group {
v := gatewayv1.Group(g)
return &v
}
func kindPtr(k string) *gatewayv1.Kind {
v := gatewayv1.Kind(k)
return &v
}

View File

@@ -0,0 +1,21 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package httproute
import (
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)
// Handler builds a handlers.Handler that wraps all provided
// TypedHandlerWithTenantWithRuleset[*gatewayv1.HTTPRoute] implementations.
func Handler(handler ...handlers.TypedHandlerWithTenantWithRuleset[*gatewayv1.HTTPRoute]) handlers.Handler {
return &handlers.TypedTenantWithRulesetHandler[*gatewayv1.HTTPRoute]{
Factory: func() *gatewayv1.HTTPRoute {
return &gatewayv1.HTTPRoute{}
},
Handlers: handler,
}
}

View File

@@ -0,0 +1,28 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package httproute
import (
"context"
"sigs.k8s.io/controller-runtime/pkg/client"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
)
// TenantFromHTTPRoute returns the Tenant owning the namespace of the given HTTPRoute,
// or nil if the namespace does not belong to any Tenant.
func TenantFromHTTPRoute(ctx context.Context, c client.Client, route *gatewayv1.HTTPRoute) (*capsulev1beta2.Tenant, error) {
tenantList := &capsulev1beta2.TenantList{}
if err := c.List(ctx, tenantList, client.MatchingFields{".status.namespaces": route.Namespace}); err != nil {
return nil, err
}
if len(tenantList.Items) == 0 {
return nil, nil //nolint:nilnil
}
return &tenantList.Items[0], nil
}

View File

@@ -0,0 +1,142 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package httproute
import (
"context"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
caperrors "github.com/projectcapsule/capsule/pkg/api/errors"
evt "github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)
type gatewayValidator struct{}
// GatewayValidator returns a TypedHandlerWithTenantWithRuleset that validates
// HTTPRoute parentRefs against the gateway rules configured in namespace rules.
func GatewayValidator() handlers.TypedHandlerWithTenantWithRuleset[*gatewayv1.HTTPRoute] {
return &gatewayValidator{}
}
func (h *gatewayValidator) OnCreate(
c client.Client,
obj *gatewayv1.HTTPRoute,
_ admission.Decoder,
recorder events.EventRecorder,
tnt *capsulev1beta2.Tenant,
rule *capsulev1beta2.NamespaceRuleBody,
) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, c, req, obj, tnt, recorder, rule)
}
}
func (h *gatewayValidator) OnUpdate(
c client.Client,
_ *gatewayv1.HTTPRoute,
obj *gatewayv1.HTTPRoute,
_ admission.Decoder,
recorder events.EventRecorder,
tnt *capsulev1beta2.Tenant,
rule *capsulev1beta2.NamespaceRuleBody,
) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
return h.validate(ctx, c, req, obj, tnt, recorder, rule)
}
}
func (h *gatewayValidator) OnDelete(
_ client.Client,
_ *gatewayv1.HTTPRoute,
_ admission.Decoder,
_ events.EventRecorder,
_ *capsulev1beta2.Tenant,
_ *capsulev1beta2.NamespaceRuleBody,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
func (h *gatewayValidator) validate(
ctx context.Context,
c client.Client,
req admission.Request,
route *gatewayv1.HTTPRoute,
tnt *capsulev1beta2.Tenant,
recorder events.EventRecorder,
rule *capsulev1beta2.NamespaceRuleBody,
) *admission.Response {
if rule == nil || rule.Enforce.Gateways == nil || rule.Enforce.Gateways.Gateway == nil {
return nil
}
allowed := rule.Enforce.Gateways.Gateway
for _, parentRef := range route.Spec.ParentRefs {
// Only validate parentRefs that point to a Gateway resource.
if parentRef.Kind != nil && *parentRef.Kind != gatewayv1.Kind("Gateway") {
continue
}
if parentRef.Group != nil && string(*parentRef.Group) != gatewayv1.GroupName {
continue
}
gwName := string(parentRef.Name)
gwNamespace := route.Namespace
if parentRef.Namespace != nil {
gwNamespace = string(*parentRef.Namespace)
}
// Try to fetch the Gateway object for label-selector matching.
gw := &gatewayv1.Gateway{}
var gwObj client.Object
if err := c.Get(ctx, types.NamespacedName{Namespace: gwNamespace, Name: gwName}, gw); err != nil {
if !k8serrors.IsNotFound(err) {
return errResponse(err)
}
} else {
gwObj = gw
}
if !allowed.MatchGateway(gwNamespace, gwName, gwObj) {
recorder.Eventf(
tnt,
nil,
corev1.EventTypeWarning,
evt.ReasonForbiddenGateway,
evt.ActionValidationDenied,
"HTTPRoute %s/%s references forbidden Gateway %s/%s",
req.Namespace, req.Name, gwNamespace, gwName,
)
response := admission.Denied(
caperrors.NewGatewayForbidden(gwName, gwNamespace, *allowed).Error(),
)
return &response
}
}
return nil
}
func errResponse(err error) *admission.Response {
resp := admission.Errored(500, err)
return &resp
}

View File

@@ -0,0 +1,24 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package route
import "github.com/projectcapsule/capsule/pkg/runtime/handlers"
type httproute struct {
handlers []handlers.Handler
}
// HTTPRoute returns a Webhook that handles admission requests for
// gateway.networking.k8s.io/v1 HTTPRoute resources.
func HTTPRoute(handler ...handlers.Handler) handlers.Webhook {
return &httproute{handlers: handler}
}
func (w *httproute) GetHandlers() []handlers.Handler {
return w.handlers
}
func (w *httproute) GetPath() string {
return "/httproutes/validating"
}