mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-05-01 23:16:36 +00:00
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:
@@ -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 {
|
||||
|
||||
102
internal/webhook/defaults/httproute.go
Normal file
102
internal/webhook/defaults/httproute.go
Normal 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
|
||||
}
|
||||
21
internal/webhook/httproute/handler.go
Normal file
21
internal/webhook/httproute/handler.go
Normal 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,
|
||||
}
|
||||
}
|
||||
28
internal/webhook/httproute/utils.go
Normal file
28
internal/webhook/httproute/utils.go
Normal 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
|
||||
}
|
||||
142
internal/webhook/httproute/validate_gateway.go
Normal file
142
internal/webhook/httproute/validate_gateway.go
Normal 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
|
||||
}
|
||||
24
internal/webhook/route/httproute.go
Normal file
24
internal/webhook/route/httproute.go
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user