diff --git a/api/v1beta2/namespace_rule_type.go b/api/v1beta2/namespace_rule_type.go index 79a8632c..379368c2 100644 --- a/api/v1beta2/namespace_rule_type.go +++ b/api/v1beta2/namespace_rule_type.go @@ -30,4 +30,9 @@ type NamespaceRuleEnforceBody struct { // Define registries which are allowed to be used within this tenant // The rules are aggregated, since you can use Regular Expressions the match registry endpoints Registries []api.OCIRegistry `json:"registries,omitempty"` + + // Define gateway rules for this namespace, restricting which Gateway resources + // Routes may reference and optionally injecting a default Gateway parentRef. + // +optional + Gateways *api.GatewayRuleSpec `json:"gateways,omitempty"` } diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 3ecdc35d..ffacf67a 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -464,6 +464,11 @@ func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody) (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Gateways != nil { + in, out := &in.Gateways, &out.Gateways + *out = new(api.GatewayRuleSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody. diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml index 772d9f4e..49e1382b 100644 --- a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -51,6 +51,86 @@ spec: enforce: description: Enforcement Rules applied properties: + gateways: + description: Define gateway rules for this namespace, restricting + which Gateway resources Routes may reference and optionally + injecting a default Gateway parentRef. + properties: + gateway: + description: Gateway restricts which Gateways Routes may + reference and optionally specifies a default Gateway injected + when no parentRef is provided. + properties: + allowed: + description: Allowed is an explicit list of Gateways + (by name and optional namespace) that Routes in this + namespace may reference. + items: + properties: + name: + description: Name of the Gateway. + type: string + namespace: + description: Namespace of the Gateway. When empty, + the Route's namespace is assumed. + type: string + required: + - name + type: object + type: array + default: + description: Default is the Gateway injected as parentRef + when no parentRef is specified in a Route resource. + properties: + name: + description: Name of the Gateway. + type: string + namespace: + description: Namespace of the Gateway. When empty, + the Route's namespace is assumed. + type: string + required: + - name + type: object + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. + type: object + type: object + type: object registries: description: |- Define registries which are allowed to be used within this tenant diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index b54d64cb..9b824927 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -2492,6 +2492,86 @@ spec: enforce: description: Enforcement Rules applied properties: + gateways: + description: Define gateway rules for this namespace, restricting + which Gateway resources Routes may reference and optionally + injecting a default Gateway parentRef. + properties: + gateway: + description: Gateway restricts which Gateways Routes may + reference and optionally specifies a default Gateway + injected when no parentRef is provided. + properties: + allowed: + description: Allowed is an explicit list of Gateways + (by name and optional namespace) that Routes in this + namespace may reference. + items: + properties: + name: + description: Name of the Gateway. + type: string + namespace: + description: Namespace of the Gateway. When empty, + the Route's namespace is assumed. + type: string + required: + - name + type: object + type: array + default: + description: Default is the Gateway injected as parentRef + when no parentRef is specified in a Route resource. + properties: + name: + description: Name of the Gateway. + type: string + namespace: + description: Namespace of the Gateway. When empty, + the Route's namespace is assumed. + type: string + required: + - name + type: object + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. + type: object + type: object + type: object registries: description: |- Define registries which are allowed to be used within this tenant diff --git a/charts/capsule/templates/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml index 49c5bc20..2e6c3e3d 100644 --- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml @@ -157,6 +157,43 @@ webhooks: timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} +{{- with .Values.webhooks.hooks.httproutes }} + {{- if .enabled }} +- name: httproute.defaults.projectcapsule.dev + admissionReviewVersions: + - v1 + clientConfig: + {{- include "capsule.webhooks.service" (dict "path" "/defaults" "ctx" $) | nindent 4 }} + failurePolicy: {{ .failurePolicy }} + matchPolicy: {{ .matchPolicy }} + reinvocationPolicy: {{ .reinvocationPolicy }} + {{- with .namespaceSelector }} + namespaceSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .matchConditions }} + matchConditions: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - apiGroups: + - gateway.networking.k8s.io + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - httproutes + scope: "Namespaced" + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} + {{- end }} +{{- end }} {{- with (mergeOverwrite .Values.webhooks.hooks.namespaces .Values.webhooks.hooks.namespaceOwnerReference) }} {{- if .enabled }} - name: namespaces.tenants.projectcapsule.dev diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index f4e6a33b..675dee98 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -153,6 +153,43 @@ webhooks: timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} +{{- with .Values.webhooks.hooks.httproutes }} + {{- if .enabled }} +- name: httproute.projectcapsule.dev + admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + {{- include "capsule.webhooks.service" (dict "path" "/httproutes/validating" "ctx" $) | nindent 4 }} + failurePolicy: {{ .failurePolicy }} + matchPolicy: {{ .matchPolicy }} + {{- with .namespaceSelector }} + namespaceSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .matchConditions }} + matchConditions: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - apiGroups: + - gateway.networking.k8s.io + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - httproutes + scope: Namespaced + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} + {{- end }} +{{- end }} {{- with .Values.webhooks.hooks.ingresses }} {{- if .enabled }} - name: ingress.projectcapsule.dev diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 9f136118..88bd1f1a 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -623,6 +623,27 @@ webhooks: operator: Exists # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchConditions: [] + # -- [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) + reinvocationPolicy: Never + + httproutes: + # -- Enable the Hook + enabled: true + # -- [FailurePolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) + failurePolicy: Fail + # -- [MatchPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchPolicy: Equivalent + # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) + objectSelector: {} + # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) + namespaceSelector: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists + # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) + matchConditions: [] + # -- [ReinvocationPolicy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy) + reinvocationPolicy: Never ingresses: # -- Enable the Hook diff --git a/cmd/main.go b/cmd/main.go index 1147b369..eead9ff8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -49,6 +49,7 @@ import ( "github.com/projectcapsule/capsule/internal/webhook/defaults" "github.com/projectcapsule/capsule/internal/webhook/dra" "github.com/projectcapsule/capsule/internal/webhook/gateway" + "github.com/projectcapsule/capsule/internal/webhook/httproute" "github.com/projectcapsule/capsule/internal/webhook/ingress" "github.com/projectcapsule/capsule/internal/webhook/misc" namespacemutation "github.com/projectcapsule/capsule/internal/webhook/namespace/mutation" @@ -280,6 +281,11 @@ func main() { ), route.MiscCustomResources(misc.ResourceCounterHandler(manager.GetClient())), route.Gateway(gateway.Class(cfg)), + route.HTTPRoute( + httproute.Handler( + httproute.GatewayValidator(), + ), + ), route.DeviceClass(dra.DeviceClass()), route.Defaults(defaults.Handler(cfg, kubeVersion)), route.TenantMutation( diff --git a/internal/webhook/defaults/handler.go b/internal/webhook/defaults/handler.go index daec3d4a..7b09049d 100644 --- a/internal/webhook/defaults/handler.go +++ b/internal/webhook/defaults/handler.go @@ -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 { diff --git a/internal/webhook/defaults/httproute.go b/internal/webhook/defaults/httproute.go new file mode 100644 index 00000000..0d5f5f51 --- /dev/null +++ b/internal/webhook/defaults/httproute.go @@ -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 +} diff --git a/internal/webhook/httproute/handler.go b/internal/webhook/httproute/handler.go new file mode 100644 index 00000000..78e7bdf0 --- /dev/null +++ b/internal/webhook/httproute/handler.go @@ -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, + } +} diff --git a/internal/webhook/httproute/utils.go b/internal/webhook/httproute/utils.go new file mode 100644 index 00000000..75e32485 --- /dev/null +++ b/internal/webhook/httproute/utils.go @@ -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 +} diff --git a/internal/webhook/httproute/validate_gateway.go b/internal/webhook/httproute/validate_gateway.go new file mode 100644 index 00000000..4d35d170 --- /dev/null +++ b/internal/webhook/httproute/validate_gateway.go @@ -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 +} diff --git a/internal/webhook/route/httproute.go b/internal/webhook/route/httproute.go new file mode 100644 index 00000000..2e1ed8e5 --- /dev/null +++ b/internal/webhook/route/httproute.go @@ -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" +} diff --git a/pkg/api/errors/gateway.go b/pkg/api/errors/gateway.go index df292283..49afb857 100644 --- a/pkg/api/errors/gateway.go +++ b/pkg/api/errors/gateway.go @@ -6,6 +6,7 @@ package errors import ( "fmt" "reflect" + "strings" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -76,3 +77,41 @@ func NewGatewayClassUndefined(spec api.DefaultAllowedListSpec) error { func (i GatewayClassUndefinedError) Error() string { return utils.DefaultAllowedValuesErrorMessage(i.spec, "No gateway Class is forbidden for the current Tenant. Specify a gateway Class which is allowed within the Tenant: ") } + +// GatewayForbiddenError is returned when an HTTPRoute references a Gateway that +// is not in the allowed list for the Tenant's namespace rules. +type GatewayForbiddenError struct { + gatewayNamespace string + gatewayName string + spec api.AllowedGatewaySpec +} + +func NewGatewayForbidden(name, namespace string, spec api.AllowedGatewaySpec) error { + return &GatewayForbiddenError{ + gatewayNamespace: namespace, + gatewayName: name, + spec: spec, + } +} + +func (e GatewayForbiddenError) Error() string { + msg := fmt.Sprintf("Gateway %s/%s is forbidden for the current Tenant: ", e.gatewayNamespace, e.gatewayName) + + parts := []string{msg} + + if e.spec.Default != nil { + parts = append(parts, fmt.Sprintf("default: %s/%s", e.spec.Default.Namespace, e.spec.Default.Name)) + } + + if len(e.spec.Allowed) > 0 { + names := make([]string, 0, len(e.spec.Allowed)) + + for _, a := range e.spec.Allowed { + names = append(names, fmt.Sprintf("%s/%s", a.Namespace, a.Name)) + } + + parts = append(parts, fmt.Sprintf("allowed: [%s]", strings.Join(names, ", "))) + } + + return strings.Join(parts, " ") +} diff --git a/pkg/api/gateway_rule.go b/pkg/api/gateway_rule.go new file mode 100644 index 00000000..2dcf1cdb --- /dev/null +++ b/pkg/api/gateway_rule.go @@ -0,0 +1,83 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// GatewayNamespacedName is a namespaced reference to a Gateway resource. +// +kubebuilder:object:generate=true +type GatewayNamespacedName struct { + // Name of the Gateway. + Name string `json:"name"` + // Namespace of the Gateway. When empty, the Route's namespace is assumed. + // +optional + Namespace string `json:"namespace,omitempty"` +} + +// AllowedGatewaySpec defines which Gateway resources are allowed to be +// referenced by Routes in a namespace, and an optional default Gateway. +// +kubebuilder:object:generate=true +type AllowedGatewaySpec struct { + // Default is the Gateway injected as parentRef when no parentRef is specified + // in a Route resource. The Gateway must also be allowed via Allowed or the + // LabelSelector. + // +optional + Default *GatewayNamespacedName `json:"default,omitempty"` + + // LabelSelector matches Gateway resources by labels. + // +optional + metav1.LabelSelector `json:",inline"` + + // Allowed is an explicit list of Gateways (by name and optional namespace) + // that Routes in this namespace may reference. + // +optional + Allowed []GatewayNamespacedName `json:"allowed,omitempty"` +} + +// MatchDefault returns true when the given namespace/name matches the configured +// default Gateway. +func (in *AllowedGatewaySpec) MatchDefault(gwNamespace, gwName string) bool { + if in.Default == nil { + return false + } + + return in.Default.Name == gwName && (in.Default.Namespace == "" || in.Default.Namespace == gwNamespace) +} + +// MatchGateway returns true when the given Gateway is allowed by this spec. +// A Gateway is allowed if it is the default, appears in the Allowed list, or +// its labels match the LabelSelector. +func (in *AllowedGatewaySpec) MatchGateway(gwNamespace, gwName string, obj client.Object) bool { + if in.MatchDefault(gwNamespace, gwName) { + return true + } + + for _, a := range in.Allowed { + if a.Name == gwName && (a.Namespace == "" || a.Namespace == gwNamespace) { + return true + } + } + + if obj != nil && (len(in.MatchLabels) > 0 || len(in.MatchExpressions) > 0) { + selector, err := metav1.LabelSelectorAsSelector(&in.LabelSelector) + if err == nil && selector.Matches(labels.Set(obj.GetLabels())) { + return true + } + } + + return false +} + +// GatewayRuleSpec defines gateway-related enforcement rules for a namespace. +// +kubebuilder:object:generate=true +type GatewayRuleSpec struct { + // Gateway restricts which Gateways Routes may reference and optionally + // specifies a default Gateway injected when no parentRef is provided. + // +optional + Gateway *AllowedGatewaySpec `json:"gateway,omitempty"` +} diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index 7b0fe74d..25496f30 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -111,6 +111,32 @@ func (in *AdditionalRoleBindingsSpec) DeepCopy() *AdditionalRoleBindingsSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AllowedGatewaySpec) DeepCopyInto(out *AllowedGatewaySpec) { + *out = *in + if in.Default != nil { + in, out := &in.Default, &out.Default + *out = new(GatewayNamespacedName) + **out = **in + } + in.LabelSelector.DeepCopyInto(&out.LabelSelector) + if in.Allowed != nil { + in, out := &in.Allowed, &out.Allowed + *out = make([]GatewayNamespacedName, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AllowedGatewaySpec. +func (in *AllowedGatewaySpec) DeepCopy() *AllowedGatewaySpec { + if in == nil { + return nil + } + out := new(AllowedGatewaySpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AllowedListSpec) DeepCopyInto(out *AllowedListSpec) { *out = *in @@ -238,6 +264,41 @@ func (in *ForbiddenListSpec) DeepCopy() *ForbiddenListSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayNamespacedName) DeepCopyInto(out *GatewayNamespacedName) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayNamespacedName. +func (in *GatewayNamespacedName) DeepCopy() *GatewayNamespacedName { + if in == nil { + return nil + } + out := new(GatewayNamespacedName) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayRuleSpec) DeepCopyInto(out *GatewayRuleSpec) { + *out = *in + if in.Gateway != nil { + in, out := &in.Gateway, &out.Gateway + *out = new(AllowedGatewaySpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayRuleSpec. +func (in *GatewayRuleSpec) DeepCopy() *GatewayRuleSpec { + if in == nil { + return nil + } + out := new(GatewayRuleSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LimitRangesSpec) DeepCopyInto(out *LimitRangesSpec) { *out = *in diff --git a/pkg/runtime/events/reasons.go b/pkg/runtime/events/reasons.go index d39f02a7..3e29d9e2 100644 --- a/pkg/runtime/events/reasons.go +++ b/pkg/runtime/events/reasons.go @@ -33,6 +33,7 @@ const ( ReasonMissingGatewayClass string = "MissingGatewayClass" ReasonMissingDeviceClass string = "MissingDeviceClass" ReasonForbiddenDeviceClass string = "ForbiddenDeviceClass" + ReasonForbiddenGateway string = "ForbiddenGateway" // Pods. ReasonMissingFQCI string = "MissingFQCI" diff --git a/pkg/tenant/rules.go b/pkg/tenant/rules.go index 5f975634..b9df7563 100644 --- a/pkg/tenant/rules.go +++ b/pkg/tenant/rules.go @@ -58,6 +58,11 @@ func BuildNamespaceRuleBodyForNamespace( if len(rule.Enforce.Registries) > 0 { out.Enforce.Registries = append(out.Enforce.Registries, rule.Enforce.Registries...) } + + // Merge gateway rules: last non-nil gateway rule wins. + if rule.Enforce.Gateways != nil { + out.Enforce.Gateways = rule.Enforce.Gateways.DeepCopy() + } } return out, nil