Compare commits

...

9 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
700838e837 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>
2026-03-20 15:26:33 +00:00
copilot-swe-agent[bot]
4431f5934d Initial plan 2026-03-20 14:51:33 +00:00
renovate[bot]
c9951a24a4 chore(deps): update anchore/sbom-action digest to a0a6512 (#1887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-19 14:07:49 +02:00
renovate[bot]
b246ce4b0c chore(deps): update actions/stale digest to db5d06a (#1886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-19 14:07:30 +02:00
renovate[bot]
9c04867fd0 chore(deps): update anchore/sbom-action digest to 57aae52 (#1882)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 19:16:27 +02:00
renovate[bot]
3c99667577 chore(deps): update github/codeql-action digest to 0ec47d0 (#1858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 09:17:50 +02:00
renovate[bot]
4fd384bacd chore(deps): update anchore/sbom-action digest to 17ae174 (#1876)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 09:16:55 +02:00
renovate[bot]
0cd90760ac chore(deps): update anchore/sbom-action digest to 6d473d3 (#1860)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 20:09:16 +02:00
renovate[bot]
7837940cba chore(deps): update amannn/action-semantic-pull-request digest to ac7e3fc (#1871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 20:08:04 +02:00
24 changed files with 784 additions and 5 deletions

View File

@@ -15,7 +15,7 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@b439535a8eb2122b748ed2b45d1693aaabe5b0aa
- uses: amannn/action-semantic-pull-request@ac7e3fc035c47465748bbcb1a822c1583cf79bbc
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@@ -56,7 +56,7 @@ jobs:
with:
args: '-no-fail -fmt sarif -out gosec.sarif ./...'
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@8aac4e47ac8ace7d9e0e0b4ef7407aff0ceb5e87
uses: github/codeql-action/upload-sarif@0ec47d036c68ae0cf94c629009b1029407111281
with:
sarif_file: gosec.sarif
unit_tests:

View File

@@ -40,6 +40,6 @@ jobs:
# See: https://github.com/aquasecurity/trivy-action/issues/389#issuecomment-2385416577
TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@8aac4e47ac8ace7d9e0e0b4ef7407aff0ceb5e87
uses: github/codeql-action/upload-sarif@0ec47d036c68ae0cf94c629009b1029407111281
with:
sarif_file: 'trivy-results.sarif'

View File

@@ -30,7 +30,7 @@ jobs:
timeout-minutes: 5
continue-on-error: true
- uses: creekorful/goreportcard-action@1f35ced8cdac2cba28c9a2f2288a16aacfd507f9 # v1.0
- uses: anchore/sbom-action/download-syft@5620efe7f17de3b70cbc020fc49ce9048f1bbacf
- uses: anchore/sbom-action/download-syft@a0a65128ee20bfc2cba8a1e7fc6ca46a88149706
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Run GoReleaser

View File

@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- name: Close stale pull requests
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f
uses: actions/stale@db5d06a4c82d5e94513c09c406638111df61f63e
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 30 days.'
stale-pr-message: 'This pull request has been marked as stale because it has been inactive for more than 30 days. Please update this pull request or it will be automatically closed in 30 days.'

View File

@@ -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"`
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(

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"
}

View File

@@ -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, " ")
}

83
pkg/api/gateway_rule.go Normal file
View File

@@ -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"`
}

View File

@@ -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

View File

@@ -33,6 +33,7 @@ const (
ReasonMissingGatewayClass string = "MissingGatewayClass"
ReasonMissingDeviceClass string = "MissingDeviceClass"
ReasonForbiddenDeviceClass string = "ForbiddenDeviceClass"
ReasonForbiddenGateway string = "ForbiddenGateway"
// Pods.
ReasonMissingFQCI string = "MissingFQCI"

View File

@@ -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