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>
This commit is contained in:
Oliver Bähler
2026-07-22 22:24:54 +02:00
committed by GitHub
parent 8c26b409a1
commit f1b760a87a
47 changed files with 5007 additions and 298 deletions
+1
View File
@@ -114,6 +114,7 @@ helm-test-exec: ct helm-controller-version ko-build-all
# Setup development env
dev-build: kind
$(KIND) create cluster --wait=60s --name $(CLUSTER_NAME) --image kindest/node:$(KUBERNETES_SUPPORTED_VERSION) --config ./hack/kind-cluster.yaml
$(KUBECTL) apply --force-conflicts --server-side=true -f ./e2e/rbac.yaml
$(MAKE) dev-install-gw-api-crds
.PHONY: dev-destroy
@@ -53,6 +53,26 @@ spec:
description: For future implementation where users might manage RuleStatus
CRs themselves
properties:
audience:
description: |-
Audience limits this rule to matching request subjects.
An empty audience matches every request.
items:
properties:
kind:
enum:
- User
- Group
- ServiceAccount
- Custom
type: string
name:
type: string
required:
- kind
- name
type: object
type: array
enforce:
description: Enforcement for given rule
properties:
@@ -68,6 +88,58 @@ spec:
- deny
- audit
type: string
ingress:
description: Enforcement for Ingress and Gateway API resource
hostnames.
properties:
hostnames:
description: |-
Hostnames defines allowed, denied, or audited hostname expressions.
A resource targeted by an allow or deny rule must declare non-empty values
in all hostname fields. Audit-only rules record missing hostnames without
denying them.
items:
description: |-
At least one of Exact or Exp must be set.
Both may be set together.
properties:
exact:
description: Exact matches one of the provided values
exactly.
items:
type: string
minItems: 1
type: array
exp:
description: Exp matches regular expression.
minLength: 1
type: string
negate:
default: false
description: Negate regular Expression
type: boolean
type: object
x-kubernetes-validations:
- message: at least one of exact or exp must be set
rule: has(self.exact) || has(self.exp)
minItems: 1
type: array
types:
description: Types defines the resource kinds to which hostname
enforcement applies.
items:
enum:
- Ingress
- Route
- ListenerSet
- HTTPRoute
- Gateway
- TLSRoute
- GRPCRoute
type: string
minItems: 1
type: array
type: object
metadata:
description: Enforcement for object metadata on namespaced resources.
items:
@@ -77,6 +149,16 @@ spec:
annotations:
additionalProperties:
properties:
default:
description: |-
Default is applied by admission mutation when the concrete metadata key is absent.
It is not reconciled after admission.
type: string
managed:
description: |-
Managed is enforced by admission mutation and reconciled by the RuleStatus
controller using server-side apply when the rule configuration changes.
type: string
required:
default: false
description: |-
@@ -150,6 +232,16 @@ spec:
labels:
additionalProperties:
properties:
default:
description: |-
Default is applied by admission mutation when the concrete metadata key is absent.
It is not reconciled after admission.
type: string
managed:
description: |-
Managed is enforced by admission mutation and reconciled by the RuleStatus
controller using server-side apply when the rule configuration changes.
type: string
required:
default: false
description: |-
@@ -457,6 +549,26 @@ spec:
Deprecated: use Rules.
Rule contains a legacy flattened view and cannot fully represent action-aware rules.
properties:
audience:
description: |-
Audience limits this rule to matching request subjects.
An empty audience matches every request.
items:
properties:
kind:
enum:
- User
- Group
- ServiceAccount
- Custom
type: string
name:
type: string
required:
- kind
- name
type: object
type: array
enforce:
description: Enforcement for given rule
properties:
@@ -472,6 +584,58 @@ spec:
- deny
- audit
type: string
ingress:
description: Enforcement for Ingress and Gateway API resource
hostnames.
properties:
hostnames:
description: |-
Hostnames defines allowed, denied, or audited hostname expressions.
A resource targeted by an allow or deny rule must declare non-empty values
in all hostname fields. Audit-only rules record missing hostnames without
denying them.
items:
description: |-
At least one of Exact or Exp must be set.
Both may be set together.
properties:
exact:
description: Exact matches one of the provided values
exactly.
items:
type: string
minItems: 1
type: array
exp:
description: Exp matches regular expression.
minLength: 1
type: string
negate:
default: false
description: Negate regular Expression
type: boolean
type: object
x-kubernetes-validations:
- message: at least one of exact or exp must be set
rule: has(self.exact) || has(self.exp)
minItems: 1
type: array
types:
description: Types defines the resource kinds to which
hostname enforcement applies.
items:
enum:
- Ingress
- Route
- ListenerSet
- HTTPRoute
- Gateway
- TLSRoute
- GRPCRoute
type: string
minItems: 1
type: array
type: object
metadata:
description: Enforcement for object metadata on namespaced
resources.
@@ -482,6 +646,16 @@ spec:
annotations:
additionalProperties:
properties:
default:
description: |-
Default is applied by admission mutation when the concrete metadata key is absent.
It is not reconciled after admission.
type: string
managed:
description: |-
Managed is enforced by admission mutation and reconciled by the RuleStatus
controller using server-side apply when the rule configuration changes.
type: string
required:
default: false
description: |-
@@ -555,6 +729,16 @@ spec:
labels:
additionalProperties:
properties:
default:
description: |-
Default is applied by admission mutation when the concrete metadata key is absent.
It is not reconciled after admission.
type: string
managed:
description: |-
Managed is enforced by admission mutation and reconciled by the RuleStatus
controller using server-side apply when the rule configuration changes.
type: string
required:
default: false
description: |-
@@ -800,6 +984,26 @@ spec:
description: For future implementation where users might manage
RuleStatus CRs themselves
properties:
audience:
description: |-
Audience limits this rule to matching request subjects.
An empty audience matches every request.
items:
properties:
kind:
enum:
- User
- Group
- ServiceAccount
- Custom
type: string
name:
type: string
required:
- kind
- name
type: object
type: array
enforce:
description: Enforcement for given rule
properties:
@@ -815,6 +1019,58 @@ spec:
- deny
- audit
type: string
ingress:
description: Enforcement for Ingress and Gateway API resource
hostnames.
properties:
hostnames:
description: |-
Hostnames defines allowed, denied, or audited hostname expressions.
A resource targeted by an allow or deny rule must declare non-empty values
in all hostname fields. Audit-only rules record missing hostnames without
denying them.
items:
description: |-
At least one of Exact or Exp must be set.
Both may be set together.
properties:
exact:
description: Exact matches one of the provided
values exactly.
items:
type: string
minItems: 1
type: array
exp:
description: Exp matches regular expression.
minLength: 1
type: string
negate:
default: false
description: Negate regular Expression
type: boolean
type: object
x-kubernetes-validations:
- message: at least one of exact or exp must be set
rule: has(self.exact) || has(self.exp)
minItems: 1
type: array
types:
description: Types defines the resource kinds to which
hostname enforcement applies.
items:
enum:
- Ingress
- Route
- ListenerSet
- HTTPRoute
- Gateway
- TLSRoute
- GRPCRoute
type: string
minItems: 1
type: array
type: object
metadata:
description: Enforcement for object metadata on namespaced
resources.
@@ -825,6 +1081,16 @@ spec:
annotations:
additionalProperties:
properties:
default:
description: |-
Default is applied by admission mutation when the concrete metadata key is absent.
It is not reconciled after admission.
type: string
managed:
description: |-
Managed is enforced by admission mutation and reconciled by the RuleStatus
controller using server-side apply when the rule configuration changes.
type: string
required:
default: false
description: |-
@@ -898,6 +1164,16 @@ spec:
labels:
additionalProperties:
properties:
default:
description: |-
Default is applied by admission mutation when the concrete metadata key is absent.
It is not reconciled after admission.
type: string
managed:
description: |-
Managed is enforced by admission mutation and reconciled by the RuleStatus
controller using server-side apply when the rule configuration changes.
type: string
required:
default: false
description: |-
@@ -2500,6 +2500,26 @@ spec:
items:
description: Rules Distributed via Tenants
properties:
audience:
description: |-
Audience limits this rule to matching request subjects.
An empty audience matches every request.
items:
properties:
kind:
enum:
- User
- Group
- ServiceAccount
- Custom
type: string
name:
type: string
required:
- kind
- name
type: object
type: array
enforce:
description: Enforcement for given rule
properties:
@@ -2515,6 +2535,58 @@ spec:
- deny
- audit
type: string
ingress:
description: Enforcement for Ingress and Gateway API resource
hostnames.
properties:
hostnames:
description: |-
Hostnames defines allowed, denied, or audited hostname expressions.
A resource targeted by an allow or deny rule must declare non-empty values
in all hostname fields. Audit-only rules record missing hostnames without
denying them.
items:
description: |-
At least one of Exact or Exp must be set.
Both may be set together.
properties:
exact:
description: Exact matches one of the provided
values exactly.
items:
type: string
minItems: 1
type: array
exp:
description: Exp matches regular expression.
minLength: 1
type: string
negate:
default: false
description: Negate regular Expression
type: boolean
type: object
x-kubernetes-validations:
- message: at least one of exact or exp must be set
rule: has(self.exact) || has(self.exp)
minItems: 1
type: array
types:
description: Types defines the resource kinds to which
hostname enforcement applies.
items:
enum:
- Ingress
- Route
- ListenerSet
- HTTPRoute
- Gateway
- TLSRoute
- GRPCRoute
type: string
minItems: 1
type: array
type: object
metadata:
description: Enforcement for object metadata on namespaced
resources.
@@ -2525,6 +2597,16 @@ spec:
annotations:
additionalProperties:
properties:
default:
description: |-
Default is applied by admission mutation when the concrete metadata key is absent.
It is not reconciled after admission.
type: string
managed:
description: |-
Managed is enforced by admission mutation and reconciled by the RuleStatus
controller using server-side apply when the rule configuration changes.
type: string
required:
default: false
description: |-
@@ -2598,6 +2680,16 @@ spec:
labels:
additionalProperties:
properties:
default:
description: |-
Default is applied by admission mutation when the concrete metadata key is absent.
It is not reconciled after admission.
type: string
managed:
description: |-
Managed is enforced by admission mutation and reconciled by the RuleStatus
controller using server-side apply when the rule configuration changes.
type: string
required:
default: false
description: |-
@@ -972,6 +972,39 @@ spec:
{{- end }}
webhooks:
{{- $any := false -}}
{{- with .Values.webhooks.hooks.generic }}
{{- if .enabled }}
{{- $any = true }}
- name: generic.rules.mutating.projectcapsule.dev
{{- with .opts }}
opts:
{{- toYaml . | nindent 10 }}
{{- end }}
admissionReviewVersions:
- v1
- v1beta1
path: "/rules/generic/mutating"
failurePolicy: {{ .failurePolicy }}
matchPolicy: {{ .matchPolicy }}
reinvocationPolicy: {{ .reinvocationPolicy }}
{{- with .namespaceSelector }}
namespaceSelector:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- with .objectSelector }}
objectSelector:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- with .matchConditions }}
matchConditions:
{{- toYaml . | nindent 10 }}
{{- end }}
rules:
{{- toYaml .rules | nindent 10 }}
sideEffects: None
timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }}
{{- end }}
{{- end }}
{{- with (mergeOverwrite .Values.webhooks.hooks.namespaces .Values.webhooks.hooks.namespaceOwnerReference) }}
{{- if .enabled }}
{{- $any = true }}
-2
View File
@@ -1002,7 +1002,6 @@ webhooks:
resources:
- '*'
scope: Namespaced
# -- Generic Rules API
generic:
# -- Enable the Hook
@@ -1046,7 +1045,6 @@ webhooks:
- '*'
scope: Namespaced
resourcepools:
pools:
# -- Enable the Hook
+12 -6
View File
@@ -76,6 +76,7 @@ import (
"github.com/projectcapsule/capsule/internal/webhook/pvc"
"github.com/projectcapsule/capsule/internal/webhook/resourcepool"
"github.com/projectcapsule/capsule/internal/webhook/route"
rulesgenericmutation "github.com/projectcapsule/capsule/internal/webhook/rules/generic/mutation"
rulesgenericvalidation "github.com/projectcapsule/capsule/internal/webhook/rules/generic/validation"
podrules "github.com/projectcapsule/capsule/internal/webhook/rules/pods/validation"
servicerules "github.com/projectcapsule/capsule/internal/webhook/rules/services/validation"
@@ -685,11 +686,12 @@ func main() {
// webhooks: the order matters, don't change it and just append
webhooksList := append(
make([]handlers.Webhook, 0),
rulesgenericvalidation.Register(regexCache),
rulesgenericmutation.Register(cfg),
rulesgenericvalidation.Register(regexCache, cfg),
route.GenericReplicasHandler(),
route.GenericManagedHandler(cfg),
route.Pod(
pod.Handler(
pod.Handler(cfg,
podrules.PodRules(regexCache, registryCache),
pod.ImagePullPolicy(),
pod.ContainerRegistryLegacy(cfg),
@@ -710,7 +712,7 @@ func main() {
),
),
route.Service(
service.Handler(
service.Handler(cfg,
servicerules.ServiceRules(regexCache),
service.Validating(),
),
@@ -759,6 +761,7 @@ func main() {
namespacevalidation.CordoningHandler(cfg),
namespacevalidation.QuotaHandler(),
namespacevalidation.PrefixHandler(cfg),
namespacevalidation.RulesMetadataHandler(regexCache, cfg),
namespacevalidation.UserMetadataHandler(),
namespacevalidation.RequiredMetadataHandler(),
),
@@ -768,6 +771,8 @@ func main() {
cfg,
namespacemutation.OwnerReferenceHandler(cfg),
namespacemutation.MetadataHandler(cfg),
// Tenant metadata must be resolved before applying namespace rules.
namespacemutation.RulesMetadataHandler(cfg),
namespacemutation.NamespacePatchGuardHandler(cfg),
),
),
@@ -887,9 +892,10 @@ func main() {
}
if err = (&rulestatuscontroller.Manager{
Client: manager.GetClient(),
Log: ctrl.Log.WithName("capsule.ctrl").WithName("ruleset"),
Metrics: metrics.MustMakeRuleStatusRecorder(),
Client: manager.GetClient(),
RESTConfig: manager.GetConfig(),
Log: ctrl.Log.WithName("capsule.ctrl").WithName("ruleset"),
Metrics: metrics.MustMakeRuleStatusRecorder(),
}).SetupWithManager(manager, controllerConfig); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "RuleSet")
os.Exit(1)
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"github.com/projectcapsule/capsule/pkg/api/rbac"
)
var _ = Describe("creating a Namespace with user-specified labels and annotations", Ordered, Label("namespace", "metadata", "forbidden"), func() {
var _ = Describe("creating a Namespace with user-specified labels and annotations", Ordered, Label("config", "namespace", "metadata", "forbidden"), func() {
originConfig := &capsulev1beta2.CapsuleConfiguration{}
tnt := &capsulev1beta2.Tenant{
+34
View File
@@ -0,0 +1,34 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: gateway-permissions
labels:
projectcapsule.dev/aggregate-to-controller: "true"
rules:
- apiGroups: ["gateway.networking.k8s.io"]
resources: ["*"]
verbs: ["get", "create", "update", "patch", "watch", "list", "delete"]
# Fix Kubernetes 1.34
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: admin-patch
rules:
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: admin-patch
subjects:
- kind: User
name: admin
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: admin-patch
apiGroup: rbac.authorization.k8s.io
+910
View File
@@ -0,0 +1,910 @@
// 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",
)
})
})
+473
View File
@@ -15,6 +15,7 @@ import (
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8stypes "k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -363,6 +364,36 @@ var _ = Describe("enforcing generic metadata namespace rules", Ordered, Label("t
tnt.Spec.Rules = next
}
waitForProjectedMetadata := func(nsName, key string, wantDefault, wantManaged *string) {
Eventually(func(g Gomega) {
status := &capsulev1beta2.RuleStatus{}
g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{
Name: meta.NameForManagedRuleStatus(),
Namespace: nsName,
}, status)).To(Succeed())
for _, body := range status.Status.Rules {
if body == nil || body.Enforce == nil {
continue
}
for _, metadata := range body.Enforce.Metadata {
if policy, ok := metadata.Labels[key]; ok {
g.Expect(policy.Default).To(Equal(wantDefault))
g.Expect(policy.Managed).To(Equal(wantManaged))
return
}
if policy, ok := metadata.Annotations[key]; ok {
g.Expect(policy.Default).To(Equal(wantDefault))
g.Expect(policy.Managed).To(Equal(wantManaged))
return
}
}
}
g.Expect(false).To(BeTrue(), "metadata policy %q was not projected", key)
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
}
createNamespace := func(labels map[string]string) *corev1.Namespace {
if labels == nil {
labels = map[string]string{}
@@ -378,6 +409,34 @@ var _ = Describe("enforcing generic metadata namespace rules", Ordered, Label("t
return ns
}
createNamespaceAndExpectDenied := func(labels map[string]string, substrings ...string) {
if labels == nil {
labels = map[string]string{}
}
labels[meta.TenantLabel] = tnt.GetName()
base := NewNamespace("", labels)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
Eventually(func() error {
candidate := base.DeepCopy()
candidate.Name = fmt.Sprintf("%s-%d", base.Name, time.Now().UnixNano()%1e6)
_, err := cs.CoreV1().Namespaces().Create(context.Background(), candidate, metav1.CreateOptions{})
if err == nil {
_ = cs.CoreV1().Namespaces().Delete(context.Background(), candidate.Name, metav1.DeleteOptions{})
return fmt.Errorf("expected namespace create to be denied, but it succeeded")
}
msg := err.Error()
for _, substring := range substrings {
if !strings.Contains(msg, substring) {
return fmt.Errorf("expected error to contain %q, got: %s", substring, msg)
}
}
return nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
}
configMap := func(name string, labels map[string]string, annotations map[string]string) *corev1.ConfigMap {
return &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
@@ -823,6 +882,65 @@ var _ = Describe("enforcing generic metadata namespace rules", Ordered, Label("t
)
})
It("matches label and annotation key expressions through the regex cache", func() {
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
metadataRule(
rules.ActionTypeAllow,
"v1",
[]string{"ConfigMap"},
map[string]rules.MetadataValueRule{
`example\.corp/label-.*`: metadataValueRule(false, metadataByExact("allowed")),
},
map[string]rules.MetadataValueRule{
"example.corp/*": metadataValueRule(false, metadataByExpression("^INV-[0-9]{4}$")),
},
),
})
ns := createNamespace(nil)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
createConfigMapAndExpectAllowed(cs, ns.Name, configMap(
"key-regex-allowed",
map[string]string{"example.corp/label-team": "allowed"},
map[string]string{"example.corp/cost-center": "INV-1234"},
))
createConfigMapAndExpectDenied(cs, ns.Name, configMap(
"key-regex-label-denied",
map[string]string{"example.corp/label-team": "blocked"},
nil,
), "metadata", "example.corp/label-team", "blocked", "not allowed")
createConfigMapAndExpectDenied(cs, ns.Name, configMap(
"key-regex-annotation-denied",
nil,
map[string]string{"example.corp/cost-center": "BAD-1234"},
), "metadata", "example.corp/cost-center", "BAD-1234", "not allowed")
})
It("requires Namespace to be explicitly included in kinds", func() {
policy := map[string]rules.MetadataValueRule{
"example.corp/namespace-policy": metadataValueRule(true, metadataByExact("allowed")),
}
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
metadataRule(rules.ActionTypeAllow, "*", []string{"*"}, policy, nil),
})
createNamespace(nil)
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
metadataRule(rules.ActionTypeAllow, "*", []string{"*", "Namespace"}, policy, nil),
})
createNamespaceAndExpectDenied(
nil,
"metadata",
"example.corp/namespace-policy",
"required",
)
createNamespace(map[string]string{"example.corp/namespace-policy": "allowed"})
})
It("applies one metadata rule to multiple core kinds", func() {
ns := createNamespace(nil)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
@@ -2179,4 +2297,359 @@ var _ = Describe("enforcing generic metadata namespace rules", Ordered, Label("t
"denied",
)
})
It("applies Namespace defaults only when Namespace is explicitly targeted", func() {
defaultValue := "baseline"
policy := map[string]rules.MetadataValueRule{
"example.corp/namespace-default": {
Default: &defaultValue,
},
}
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
metadataRule(rules.ActionTypeAllow, "*", []string{"*"}, policy, nil),
})
withoutExplicitKind := createNamespace(nil)
Eventually(func(g Gomega) {
current := &corev1.Namespace{}
g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: withoutExplicitKind.Name}, current)).To(Succeed())
g.Expect(current.Labels).NotTo(HaveKey("example.corp/namespace-default"))
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
metadataRule(rules.ActionTypeAllow, "*", []string{"*", "Namespace"}, policy, nil),
})
waitForProjectedMetadata(withoutExplicitKind.Name, "example.corp/namespace-default", &defaultValue, nil)
withExplicitKind := createNamespace(nil)
Eventually(func(g Gomega) {
current := &corev1.Namespace{}
g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: withExplicitKind.Name}, current)).To(Succeed())
g.Expect(current.Labels).To(HaveKeyWithValue("example.corp/namespace-default", defaultValue))
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("applies defaults, preserves supplied values, and lets managed values win in combination", func() {
defaultOnly := "defaulted"
combinedDefault := "fallback"
combinedManaged := "controlled"
standaloneManaged := "managed-annotation"
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
metadataRule(
rules.ActionTypeAllow,
"v1",
[]string{"ConfigMap"},
map[string]rules.MetadataValueRule{
"example.corp/default": {
Default: &defaultOnly,
},
"example.corp/combined": {
Default: &combinedDefault,
Managed: &combinedManaged,
},
},
map[string]rules.MetadataValueRule{
"example.corp/managed": {
Managed: &standaloneManaged,
},
},
),
})
ns := createNamespace(nil)
waitForProjectedMetadata(ns.Name, "example.corp/combined", &combinedDefault, &combinedManaged)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
created, err := cs.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap(
"metadata-default-managed-combination",
map[string]string{
"example.corp/combined": "user-value",
},
map[string]string{
"example.corp/managed": "user-value",
},
), metav1.CreateOptions{})
Expect(err).NotTo(HaveOccurred())
Expect(created.Labels).To(HaveKeyWithValue("example.corp/default", defaultOnly))
Expect(created.Labels).To(HaveKeyWithValue("example.corp/combined", combinedManaged))
Expect(created.Annotations).To(HaveKeyWithValue("example.corp/managed", standaloneManaged))
preserved, err := cs.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap(
"metadata-default-preserves-user-value",
map[string]string{
"example.corp/default": "supplied",
},
nil,
), metav1.CreateOptions{})
Expect(err).NotTo(HaveOccurred())
Expect(preserved.Labels).To(HaveKeyWithValue("example.corp/default", "supplied"))
Expect(preserved.Labels).To(HaveKeyWithValue("example.corp/combined", combinedManaged))
Expect(preserved.Annotations).To(HaveKeyWithValue("example.corp/managed", standaloneManaged))
})
It("reconciles standalone managed metadata and removes it when the rule is removed", func() {
managedLabel := "managed-label"
managedAnnotation := "managed-annotation"
managedNamespace := "managed-namespace"
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
metadataRule(
rules.ActionTypeAllow,
"v1",
[]string{"ConfigMap"},
map[string]rules.MetadataValueRule{
"example.corp/lifecycle": {Managed: &managedLabel},
},
map[string]rules.MetadataValueRule{
"example.corp/lifecycle": {Managed: &managedAnnotation},
},
),
metadataRule(
rules.ActionTypeAllow,
"v1",
[]string{"Namespace"},
map[string]rules.MetadataValueRule{
"example.corp/namespace-lifecycle": {Managed: &managedNamespace},
},
nil,
),
})
ns := createNamespace(map[string]string{"example.corp/user-owned": "keep"})
waitForProjectedMetadata(ns.Name, "example.corp/lifecycle", nil, &managedLabel)
cs := ownerClient(tnt.Spec.Owners[0].UserSpec)
cm, err := cs.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap(
"managed-metadata-lifecycle",
map[string]string{"example.corp/user-owned": "keep"},
map[string]string{"example.corp/user-annotation": "keep"},
), metav1.CreateOptions{})
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
current, getErr := cs.CoreV1().ConfigMaps(ns.Name).Get(context.Background(), cm.Name, metav1.GetOptions{})
g.Expect(getErr).NotTo(HaveOccurred())
g.Expect(current.Labels).To(HaveKeyWithValue("example.corp/lifecycle", managedLabel))
g.Expect(current.Annotations).To(HaveKeyWithValue("example.corp/lifecycle", managedAnnotation))
currentNamespace := &corev1.Namespace{}
g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: ns.Name}, currentNamespace)).To(Succeed())
g.Expect(currentNamespace.Labels).To(HaveKeyWithValue("example.corp/namespace-lifecycle", managedNamespace))
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
updateTenantRules(nil)
Eventually(func(g Gomega) {
current, getErr := cs.CoreV1().ConfigMaps(ns.Name).Get(context.Background(), cm.Name, metav1.GetOptions{})
g.Expect(getErr).NotTo(HaveOccurred())
g.Expect(current.Labels).NotTo(HaveKey("example.corp/lifecycle"))
g.Expect(current.Annotations).NotTo(HaveKey("example.corp/lifecycle"))
g.Expect(current.Labels).To(HaveKeyWithValue("example.corp/user-owned", "keep"))
g.Expect(current.Annotations).To(HaveKeyWithValue("example.corp/user-annotation", "keep"))
currentNamespace := &corev1.Namespace{}
g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: ns.Name}, currentNamespace)).To(Succeed())
g.Expect(currentNamespace.Labels).NotTo(HaveKey("example.corp/namespace-lifecycle"))
g.Expect(currentNamespace.Labels).To(HaveKeyWithValue("example.corp/user-owned", "keep"))
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("filters mutation and validation by audience across metadata, Service, and workload rules", func() {
defaultValue := "owner-default"
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
{
NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
Audience: []rules.Audience{{Kind: rules.AudienceKindUser, Name: ownerName}},
Enforce: &rules.NamespaceRuleEnforceBody{
Action: rules.ActionTypeDeny,
Metadata: []rules.MetadataRule{{
VersionKinds: runtime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"ConfigMap"}},
Labels: map[string]rules.MetadataValueRule{
"example.corp/audience-default": {Default: &defaultValue},
"example.corp/audience-blocked": metadataValueRule(false, metadataByExact("true")),
},
}},
},
},
},
{
NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
Audience: []rules.Audience{{Kind: rules.AudienceKindGroup, Name: ownerName}},
Enforce: &rules.NamespaceRuleEnforceBody{
Action: rules.ActionTypeDeny,
Services: rules.NamespaceRuleEnforceServicesBody{
Types: []rules.ServiceType{rules.ServiceTypeClusterIP},
},
},
},
},
{
NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
Audience: []rules.Audience{{Kind: rules.AudienceKindCustom, Name: string(rules.CustomAudienceTenantOwner)}},
Enforce: &rules.NamespaceRuleEnforceBody{
Action: rules.ActionTypeDeny,
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
QoSClasses: []corev1.PodQOSClass{corev1.PodQOSBestEffort},
},
},
},
},
})
ns := createNamespace(nil)
waitForProjectedMetadata(ns.Name, "example.corp/audience-default", &defaultValue, nil)
owner := ownerClient(tnt.Spec.Owners[0].UserSpec)
admin := clusterAdminClient()
ownerCM, err := owner.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap(
"audience-owner-mutated",
nil,
nil,
), metav1.CreateOptions{})
Expect(err).NotTo(HaveOccurred())
Expect(ownerCM.Labels).To(HaveKeyWithValue("example.corp/audience-default", defaultValue))
createConfigMapAndExpectDenied(owner, ns.Name, configMap(
"audience-owner-denied",
map[string]string{"example.corp/audience-blocked": "true"},
nil,
), "example.corp/audience-blocked", "denied")
adminCM, err := admin.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap(
"audience-admin-bypasses-metadata",
map[string]string{"example.corp/audience-blocked": "true"},
nil,
), metav1.CreateOptions{})
Expect(err).NotTo(HaveOccurred())
Expect(adminCM.Labels).NotTo(HaveKey("example.corp/audience-default"))
createServiceAndExpectDenied(owner, ns.Name, service("audience-owner-service-denied", nil, nil), "ClusterIP", "denied")
createServiceAndExpectAllowed(admin, ns.Name, service("audience-admin-service-allowed", nil, nil))
ownerPod := MakePod(ns.Name, "audience-owner-pod-denied", nil, nil, "nginx:1.25", "", "")
Eventually(func() error {
_, createErr := owner.CoreV1().Pods(ns.Name).Create(context.Background(), ownerPod, metav1.CreateOptions{})
if createErr == nil {
_ = owner.CoreV1().Pods(ns.Name).Delete(context.Background(), ownerPod.Name, metav1.DeleteOptions{})
return fmt.Errorf("expected TenantOwner audience to deny the BestEffort Pod")
}
if !strings.Contains(createErr.Error(), "BestEffort") || !strings.Contains(createErr.Error(), "denied") {
return createErr
}
return nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
adminPod := MakePod(ns.Name, "audience-admin-pod-allowed", nil, nil, "nginx:1.25", "", "")
EventuallyCreation(func() error {
_, createErr := admin.CoreV1().Pods(ns.Name).Create(context.Background(), adminPod, metav1.CreateOptions{})
return createErr
}).Should(Succeed())
})
It("rejects forbidden namespace metadata injected through the status subresource", func() {
const policyKey = "pod-security.kubernetes.io/enforce"
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
metadataRule(
rules.ActionTypeAllow,
"v1",
[]string{"Namespace"},
map[string]rules.MetadataValueRule{
policyKey: metadataValueRule(true, metadataByExact("baseline", "restricted")),
},
nil,
),
})
ns := createNamespace(map[string]string{policyKey: "baseline"})
waitForProjectedMetadata(ns.Name, policyKey, nil, nil)
createNamespaceStatusRBACForOwner(tnt)
DeferCleanup(deleteNamespaceStatusRBACForOwner, tnt)
owner := ownerClient(tnt.Spec.Owners[0].UserSpec)
patchStatus := func(value string) error {
patch := []byte(fmt.Sprintf(
`[{"op":"add","path":"/metadata/labels/pod-security.kubernetes.io~1enforce","value":%q}]`,
value,
))
_, err := owner.CoreV1().Namespaces().Patch(
context.Background(),
ns.Name,
k8stypes.JSONPatchType,
patch,
metav1.PatchOptions{},
"status",
)
return err
}
By("allowing a compliant metadata update through namespaces/status")
Eventually(func() error {
return patchStatus("restricted")
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
By("rejecting a forbidden metadata update through namespaces/status")
Eventually(func() error {
err := patchStatus("privileged")
if err == nil {
return fmt.Errorf("expected namespace status patch to be denied")
}
if !strings.Contains(err.Error(), policyKey) ||
!strings.Contains(err.Error(), "privileged") ||
!strings.Contains(err.Error(), "not allowed") {
return err
}
return nil
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
By("preserving the last allowed metadata value")
Eventually(func(g Gomega) {
current := &corev1.Namespace{}
g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: ns.Name}, current)).To(Succeed())
g.Expect(current.Labels).To(HaveKeyWithValue(policyKey, "restricted"))
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
It("ignores metadata enforcement for non-Namespace subresource updates", func() {
updateTenantRules([]*rules.NamespaceRuleBodyTenant{
metadataRule(
rules.ActionTypeAllow,
"*",
[]string{"*"},
map[string]rules.MetadataValueRule{
"example.corp/subresource-required": metadataValueRule(true, metadataByExact("true")),
},
nil,
),
})
ns := createNamespace(nil)
owner := ownerClient(tnt.Spec.Owners[0].UserSpec)
deploy := deployment(
"metadata-scale-subresource",
map[string]string{"example.corp/subresource-required": "true"},
nil,
)
deploy.Namespace = ns.Name
EventuallyCreation(func() error {
_, err := owner.AppsV1().Deployments(ns.Name).Create(context.Background(), deploy, metav1.CreateOptions{})
return err
}).Should(Succeed())
Eventually(func() error {
scale, err := owner.AppsV1().Deployments(ns.Name).GetScale(context.Background(), deploy.Name, metav1.GetOptions{})
if err != nil {
return err
}
scale.Spec.Replicas = 2
_, err = owner.AppsV1().Deployments(ns.Name).UpdateScale(context.Background(), deploy.Name, scale, metav1.UpdateOptions{})
return err
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
})
})
+24 -30
View File
@@ -7,29 +7,43 @@ metadata:
customer: a
spec:
owners:
- name: alice
kind: User
- name: alice
kind: User
rules:
- permissions:
bindings:
- clusterRoleName: 'custom:proxy-viewer'
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: joe
- apiGroup: rbac.authorization.k8s.io
kind: User
name: joe
labels:
reflection.proxy.projectcapsule.dev/enabled: "true"
enforce:
action: deny
metadata:
- apiGroups:
- "rbac.authorization.k8s.io/v1"
- "v1"
kinds:
- "RoleBinding"
- "Namespace"
labels:
reflection.proxy.projectcapsule.dev/enabled:
pod-security.kubernetes.io/enforce:
managed: "restricted"
- audience:
- kind: "Custom"
name: "CapsuleUser"
enforce:
action: deny
metadata:
- apiGroups:
- "v1"
kinds:
- "Namespace"
labels:
"openshift.io/*":
required: false
values:
- exp: ".*"
- exp: "^.*$"
- namespaceSelector:
matchExpressions:
- key: env
@@ -39,17 +53,7 @@ spec:
permissions:
promotions:
- clusterRoles:
- "secret-replicator"
- namespaceSelector:
matchExpressions:
- key: env
operator: In
values:
- "prod"
permissions:
promotions:
- clusterRoles:
- "sade-boi"
- "secret-replicator"
permissions:
matchOwners:
- matchLabels:
@@ -121,16 +125,6 @@ spec:
to: 32767
- enforce:
action: "allow"
# generic:
# - kinds:
# - "ConfigMap"
# path: ".data.key"
# entity: "Any/TenantOwner/CapsuleUser"
# operator: "Equals/Truthy"
# values:
# - exact:
# - "sad"
# from: ".status.classes.ingress"
metadata:
- kinds:
- "ConfigMap"
@@ -89,4 +89,16 @@ func collectRegexExpressionsFromNamespaceRule(
set[cache.HashRegex(expr)] = expr
}
for _, metadataRule := range rule.Enforce.Metadata {
for selector := range metadataRule.Labels {
expr := rules.MetadataKeyExpression(selector)
set[cache.HashRegex(expr)] = expr
}
for selector := range metadataRule.Annotations {
expr := rules.MetadataKeyExpression(selector)
set[cache.HashRegex(expr)] = expr
}
}
}
@@ -0,0 +1,382 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rulestatus
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
k8smeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/dynamic"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/rules"
apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime"
)
const managedMetadataListPageSize int64 = 500
type managedMetadataTarget struct {
gvr schema.GroupVersionResource
gvk schema.GroupVersionKind
}
func (r Manager) reconcileManagedMetadata(ctx context.Context, instance *capsulev1beta2.RuleStatus, previous, current []*rules.NamespaceRuleBodyNamespace) error {
if r.RESTConfig == nil {
return fmt.Errorf("REST config is required for managed metadata reconciliation")
}
dynamicClient, err := dynamic.NewForConfig(r.RESTConfig)
if err != nil {
return err
}
manager := ruleStatusFieldManager(instance)
namespaceGVK := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}
previousLabels, previousAnnotations := managedMetadataForGVK(namespaceGVK, previous)
labels, annotations := managedMetadataForGVK(namespaceGVK, current)
if hasMetadata(previousLabels, previousAnnotations) || hasMetadata(labels, annotations) {
if err := reconcileObjectManagedMetadata(ctx, dynamicClient, schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, namespaceGVK, "", instance.GetNamespace(), previousLabels, previousAnnotations, labels, annotations, manager); err != nil {
return err
}
}
targets, err := managedMetadataTargets(r.RESTMapper, previous, current)
if err != nil {
return err
}
for _, target := range targets {
previousLabels, previousAnnotations := managedMetadataForGVK(target.gvk, previous)
labels, annotations := managedMetadataForGVK(target.gvk, current)
if err := reconcileManagedMetadataTarget(
ctx,
dynamicClient,
target,
instance.GetNamespace(),
previousLabels,
previousAnnotations,
labels,
annotations,
manager,
); err != nil {
return err
}
}
return nil
}
func reconcileManagedMetadataTarget(
ctx context.Context,
dynamicClient dynamic.Interface,
target managedMetadataTarget,
namespace string,
previousLabels, previousAnnotations, labels, annotations map[string]string,
manager string,
) error {
continueToken := ""
for {
items, err := dynamicClient.Resource(target.gvr).Namespace(namespace).List(ctx, metav1.ListOptions{
Limit: managedMetadataListPageSize,
Continue: continueToken,
})
if err != nil {
if isManagedMetadataObjectGone(err) {
return nil
}
return fmt.Errorf("list %s in namespace %q: %w", target.gvr.String(), namespace, err)
}
for i := range items.Items {
if err := reconcileObjectManagedMetadata(
ctx,
dynamicClient,
target.gvr,
target.gvk,
namespace,
items.Items[i].GetName(),
previousLabels,
previousAnnotations,
labels,
annotations,
manager,
); err != nil {
return err
}
}
continueToken = items.GetContinue()
if continueToken == "" {
return nil
}
}
}
func managedMetadataTargets(
mapper k8smeta.RESTMapper,
ruleSets ...[]*rules.NamespaceRuleBodyNamespace,
) ([]managedMetadataTarget, error) {
targets := make(map[schema.GroupVersionResource]managedMetadataTarget)
for _, bodies := range ruleSets {
for _, body := range bodies {
if body == nil || body.Enforce == nil {
continue
}
for _, rule := range body.Enforce.Metadata {
if !metadataRuleHasManagedValues(rule) {
continue
}
if rule.HasWildcard() {
return nil, fmt.Errorf("managed metadata requires concrete apiGroups and kinds")
}
for _, kind := range rule.Kinds {
kind = strings.TrimSpace(kind)
for _, apiGroup := range rule.StatusAPIGroups() {
mapping, err := managedMetadataRESTMapping(mapper, apiGroup, kind)
if err != nil {
return nil, fmt.Errorf("resolve managed metadata target %q/%q: %w", apiGroup, kind, err)
}
if mapping.GroupVersionKind.Group == "" &&
mapping.GroupVersionKind.Version == apiruntime.CoreAPIVersion &&
mapping.GroupVersionKind.Kind == "Namespace" {
continue
}
if mapping.Scope.Name() != k8smeta.RESTScopeNameNamespace {
return nil, fmt.Errorf("managed metadata target %s is not namespaced", mapping.GroupVersionKind.String())
}
targets[mapping.Resource] = managedMetadataTarget{
gvr: mapping.Resource,
gvk: mapping.GroupVersionKind,
}
}
}
}
}
}
out := make([]managedMetadataTarget, 0, len(targets))
for _, target := range targets {
out = append(out, target)
}
sort.Slice(out, func(i, j int) bool {
return out[i].gvr.String() < out[j].gvr.String()
})
return out, nil
}
func managedMetadataRESTMapping(
mapper k8smeta.RESTMapper,
apiGroup string,
kind string,
) (*k8smeta.RESTMapping, error) {
if mapper == nil {
return nil, fmt.Errorf("REST mapper is required for managed metadata reconciliation")
}
apiGroup = strings.TrimSpace(apiGroup)
if apiGroup == "" || apiGroup == apiruntime.CoreAPIVersion {
return mapper.RESTMapping(schema.GroupKind{Kind: kind}, apiruntime.CoreAPIVersion)
}
if gv, err := schema.ParseGroupVersion(apiGroup); err == nil && strings.Contains(apiGroup, "/") {
return mapper.RESTMapping(schema.GroupKind{Group: gv.Group, Kind: kind}, gv.Version)
}
return mapper.RESTMapping(schema.GroupKind{Group: apiGroup, Kind: kind})
}
func reconcileObjectManagedMetadata(ctx context.Context, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, gvk schema.GroupVersionKind, namespace, name string, previousLabels, previousAnnotations, labels, annotations map[string]string, manager string) error {
removedLabels := removedMetadataKeys(previousLabels, labels)
removedAnnotations := removedMetadataKeys(previousAnnotations, annotations)
if hasRemovedMetadata(removedLabels, removedAnnotations) {
if err := removeManagedMetadata(ctx, dynamicClient, gvr, namespace, name, removedLabels, removedAnnotations); err != nil {
if isManagedMetadataObjectGone(err) {
return nil
}
return err
}
}
return applyManagedMetadata(ctx, dynamicClient, gvr, gvk, namespace, name, labels, annotations, manager)
}
func removedMetadataKeys(previous, current map[string]string) map[string]any {
removed := map[string]any{}
for key := range previous {
if _, ok := current[key]; !ok {
removed[key] = nil
}
}
return removed
}
func hasRemovedMetadata(labels, annotations map[string]any) bool {
return len(labels) > 0 || len(annotations) > 0
}
func removeManagedMetadata(ctx context.Context, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, namespace, name string, labels, annotations map[string]any) error {
metadata := map[string]any{}
if len(labels) > 0 {
metadata["labels"] = labels
}
if len(annotations) > 0 {
metadata["annotations"] = annotations
}
raw, err := json.Marshal(map[string]any{"metadata": metadata})
if err != nil {
return err
}
var resource dynamic.ResourceInterface = dynamicClient.Resource(gvr)
if namespace != "" {
resource = dynamicClient.Resource(gvr).Namespace(namespace)
}
_, err = resource.Patch(ctx, name, types.MergePatchType, raw, metav1.PatchOptions{})
return err
}
func hasMetadata(labels, annotations map[string]string) bool {
return len(labels) > 0 || len(annotations) > 0
}
func metadataRuleHasManagedValues(rule rules.MetadataRule) bool {
for _, policy := range rule.Labels {
if policy.Managed != nil {
return true
}
}
for _, policy := range rule.Annotations {
if policy.Managed != nil {
return true
}
}
return false
}
func managedMetadataForGVK(gvk schema.GroupVersionKind, bodies []*rules.NamespaceRuleBodyNamespace) (map[string]string, map[string]string) {
labels, annotations := map[string]string{}, map[string]string{}
for _, body := range bodies {
if body == nil || body.Enforce == nil {
continue
}
for _, rule := range body.Enforce.Metadata {
if !rule.MatchesGroupVersionKind(gvk) {
continue
}
for key, policy := range rule.Labels {
if policy.Managed != nil {
labels[key] = *policy.Managed
}
}
for key, policy := range rule.Annotations {
if policy.Managed != nil {
annotations[key] = *policy.Managed
}
}
}
}
return labels, annotations
}
func hasManagedMetadata(bodies []*rules.NamespaceRuleBodyNamespace) bool {
for _, body := range bodies {
if body == nil || body.Enforce == nil {
continue
}
for _, rule := range body.Enforce.Metadata {
for _, policy := range rule.Labels {
if policy.Managed != nil {
return true
}
}
for _, policy := range rule.Annotations {
if policy.Managed != nil {
return true
}
}
}
}
return false
}
func applyManagedMetadata(ctx context.Context, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, gvk schema.GroupVersionKind, namespace, name string, labels, annotations map[string]string, manager string) error {
metadata := map[string]any{"name": name, "labels": labels, "annotations": annotations}
var resource dynamic.ResourceInterface = dynamicClient.Resource(gvr)
if namespace != "" {
metadata["namespace"] = namespace
resource = dynamicClient.Resource(gvr).Namespace(namespace)
}
payload := map[string]any{"apiVersion": gvk.GroupVersion().String(), "kind": gvk.Kind, "metadata": metadata}
raw, err := json.Marshal(payload)
if err != nil {
return err
}
force := true
_, err = resource.Patch(ctx, name, types.ApplyPatchType, raw, metav1.PatchOptions{FieldManager: manager, Force: &force})
if isManagedMetadataObjectGone(err) {
return nil
}
return err
}
func isManagedMetadataObjectGone(err error) bool {
return apierrors.IsNotFound(err)
}
func ruleStatusFieldManager(instance *capsulev1beta2.RuleStatus) string {
sum := sha256.Sum256([]byte(instance.GetNamespace() + "/" + instance.GetName() + "/" + string(instance.GetUID())))
return "projectcapsule.dev/rulestatus-" + hex.EncodeToString(sum[:8])
}
@@ -0,0 +1,323 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rulestatus
import (
"context"
"errors"
"strings"
"testing"
apierrors "k8s.io/apimachinery/pkg/api/errors"
k8smeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
dynamicfake "k8s.io/client-go/dynamic/fake"
k8stesting "k8s.io/client-go/testing"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/rules"
apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime"
)
func TestReconcileManagedMetadataRequiresRESTConfig(t *testing.T) {
t.Parallel()
err := (Manager{}).reconcileManagedMetadata(
context.Background(),
&capsulev1beta2.RuleStatus{},
nil,
nil,
)
if err == nil || !strings.Contains(err.Error(), "REST config is required") {
t.Fatalf("expected missing REST config error, got %v", err)
}
}
func TestRemovedMetadataKeys(t *testing.T) {
t.Parallel()
removed := removedMetadataKeys(
map[string]string{"kept": "old", "removed": "value"},
map[string]string{"kept": "new", "added": "value"},
)
if len(removed) != 1 {
t.Fatalf("expected one removed key, got %#v", removed)
}
if value, ok := removed["removed"]; !ok || value != nil {
t.Fatalf("expected removed key to have null merge-patch value, got %#v", removed)
}
}
func TestRemoveManagedMetadata(t *testing.T) {
t.Parallel()
gvk := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}
gvr := schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}
obj := &unstructured.Unstructured{}
obj.SetGroupVersionKind(gvk)
obj.SetName("solar-test")
obj.SetLabels(map[string]string{"kept": "value", "test": "true"})
dynamicClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), obj)
err := removeManagedMetadata(
context.Background(),
dynamicClient,
gvr,
"",
obj.GetName(),
map[string]any{"test": nil},
nil,
)
if err != nil {
t.Fatalf("removeManagedMetadata() error = %v", err)
}
got, err := dynamicClient.Resource(gvr).Get(context.Background(), obj.GetName(), metav1.GetOptions{})
if err != nil {
t.Fatalf("get patched Namespace: %v", err)
}
if _, ok := got.GetLabels()["test"]; ok {
t.Fatalf("managed label was not removed: %#v", got.GetLabels())
}
if got.GetLabels()["kept"] != "value" {
t.Fatalf("unmanaged label changed: %#v", got.GetLabels())
}
}
func TestApplyManagedMetadataSkipsGoneObjects(t *testing.T) {
t.Parallel()
dynamicClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
dynamicClient.PrependReactor("patch", "configmaps", func(k8stesting.Action) (bool, runtime.Object, error) {
return true, nil, apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, "settings")
})
err := applyManagedMetadata(
context.Background(),
dynamicClient,
schema.GroupVersionResource{Version: "v1", Resource: "configmaps"},
schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"},
"solar-test",
"settings",
map[string]string{"managed": "true"},
nil,
"test-manager",
)
if err != nil {
t.Fatalf("applyManagedMetadata() error = %v", err)
}
}
func TestApplyManagedMetadataReturnsNonNotFoundPatchErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
patchErr error
}{
{name: "forbidden", patchErr: apierrors.NewForbidden(schema.GroupResource{Resource: "configmaps"}, "settings", errors.New("denied"))},
{name: "other error", patchErr: errors.New("patch failed")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dynamicClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
dynamicClient.PrependReactor("patch", "configmaps", func(k8stesting.Action) (bool, runtime.Object, error) {
return true, nil, tt.patchErr
})
err := applyManagedMetadata(
context.Background(),
dynamicClient,
schema.GroupVersionResource{Version: "v1", Resource: "configmaps"},
schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"},
"solar-test",
"settings",
map[string]string{"managed": "true"},
nil,
"test-manager",
)
if !errors.Is(err, tt.patchErr) {
t.Fatalf("applyManagedMetadata() error = %v, want %v", err, tt.patchErr)
}
})
}
}
func TestReconcileObjectManagedMetadataStopsAfterGoneRemoval(t *testing.T) {
t.Parallel()
patches := 0
dynamicClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
dynamicClient.PrependReactor("patch", "configmaps", func(k8stesting.Action) (bool, runtime.Object, error) {
patches++
return true, nil, apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, "settings")
})
err := reconcileObjectManagedMetadata(
context.Background(),
dynamicClient,
schema.GroupVersionResource{Version: "v1", Resource: "configmaps"},
schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"},
"solar-test",
"settings",
map[string]string{"removed": "value"},
nil,
nil,
nil,
"test-manager",
)
if err != nil {
t.Fatalf("reconcileObjectManagedMetadata() error = %v", err)
}
if patches != 1 {
t.Fatalf("reconcileObjectManagedMetadata() made %d patches, want 1", patches)
}
}
func TestManagedMetadataTargetsOnlyReferencedGVKs(t *testing.T) {
t.Parallel()
mapper := k8smeta.NewDefaultRESTMapper([]schema.GroupVersion{
{Version: "v1"},
{Group: "apps", Version: "v1"},
})
mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, k8smeta.RESTScopeRoot)
mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, k8smeta.RESTScopeNamespace)
mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "Secret"}, k8smeta.RESTScopeNamespace)
mapper.Add(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, k8smeta.RESTScopeNamespace)
managed := "controlled"
bodies := []*rules.NamespaceRuleBodyNamespace{{
Enforce: &rules.NamespaceRuleEnforceBody{
Metadata: []rules.MetadataRule{
{
VersionKinds: apiruntime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"Namespace", "ConfigMap"}},
Labels: map[string]rules.MetadataValueRule{"example.com/managed": {Managed: &managed}},
},
{
VersionKinds: apiruntime.VersionKinds{APIGroups: []string{"apps/v1"}, Kinds: []string{"Deployment"}},
Annotations: map[string]rules.MetadataValueRule{"example.com/managed": {Managed: &managed}},
},
},
},
}}
targets, err := managedMetadataTargets(mapper, bodies, bodies)
if err != nil {
t.Fatalf("managedMetadataTargets() error = %v", err)
}
want := []managedMetadataTarget{
{
gvr: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"},
gvk: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"},
},
{
gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"},
gvk: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
},
}
if len(targets) != len(want) {
t.Fatalf("managedMetadataTargets() = %#v, want %#v", targets, want)
}
for i := range want {
if targets[i] != want[i] {
t.Fatalf("managedMetadataTargets()[%d] = %#v, want %#v", i, targets[i], want[i])
}
}
}
func TestManagedMetadataTargetsRejectWildcards(t *testing.T) {
t.Parallel()
managed := "controlled"
bodies := []*rules.NamespaceRuleBodyNamespace{{
Enforce: &rules.NamespaceRuleEnforceBody{
Metadata: []rules.MetadataRule{{
VersionKinds: apiruntime.VersionKinds{APIGroups: []string{"*"}, Kinds: []string{"ConfigMap"}},
Labels: map[string]rules.MetadataValueRule{"example.com/managed": {Managed: &managed}},
}},
},
}}
_, err := managedMetadataTargets(nil, bodies)
if err == nil || !strings.Contains(err.Error(), "requires concrete apiGroups and kinds") {
t.Fatalf("managedMetadataTargets() error = %v", err)
}
}
func TestReconcileManagedMetadataTargetUsesPagination(t *testing.T) {
t.Parallel()
gvr := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}
dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
runtime.NewScheme(),
map[schema.GroupVersionResource]string{gvr: "ConfigMapList"},
)
listCalls := 0
patchCalls := 0
dynamicClient.PrependReactor("list", "configmaps", func(action k8stesting.Action) (bool, runtime.Object, error) {
listCalls++
listAction, ok := action.(interface{ GetListOptions() metav1.ListOptions })
if !ok {
t.Fatalf("list action does not expose list options: %T", action)
}
opts := listAction.GetListOptions()
if opts.Limit != managedMetadataListPageSize {
t.Fatalf("list limit = %d, want %d", opts.Limit, managedMetadataListPageSize)
}
item := unstructured.Unstructured{}
item.SetName("settings")
items := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{item}}
if listCalls == 1 {
if opts.Continue != "" {
t.Fatalf("first list continue token = %q, want empty", opts.Continue)
}
items.SetContinue("next-page")
} else if opts.Continue != "next-page" {
t.Fatalf("second list continue token = %q, want next-page", opts.Continue)
}
return true, items, nil
})
dynamicClient.PrependReactor("patch", "configmaps", func(k8stesting.Action) (bool, runtime.Object, error) {
patchCalls++
return true, &unstructured.Unstructured{}, nil
})
err := reconcileManagedMetadataTarget(
context.Background(),
dynamicClient,
managedMetadataTarget{
gvr: gvr,
gvk: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"},
},
"solar-test",
nil,
nil,
map[string]string{"managed": "true"},
nil,
"test-manager",
)
if err != nil {
t.Fatalf("reconcileManagedMetadataTarget() error = %v", err)
}
if listCalls != 2 {
t.Fatalf("list calls = %d, want 2", listCalls)
}
if patchCalls != 2 {
t.Fatalf("patch calls = %d, want 2", patchCalls)
}
}
+33 -4
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
k8smeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
@@ -43,10 +44,12 @@ type Manager struct {
Recorder events.EventRecorder
Configuration configuration.Configuration
RESTConfig *rest.Config
RESTMapper k8smeta.RESTMapper
}
func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error {
r.reader = mgr.GetAPIReader()
r.RESTMapper = mgr.GetRESTMapper()
ctrlBuilder := ctrl.NewControllerManagedBy(mgr).
Named("capsule/rule-status").
@@ -145,6 +148,8 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct
}
func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleStatus) error {
previousRules := instance.Status.Rules
hadManagedMetadata := hasManagedMetadata(previousRules)
ruleStatus := make([]*rules.NamespaceRuleBodyNamespace, 0, len(instance.Spec))
for _, rule := range instance.Spec {
@@ -158,19 +163,43 @@ func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleSta
enforce.Metadata[i].APIGroups = enforce.Metadata[i].StatusAPIGroups()
}
ruleStatus = append(ruleStatus, &rules.NamespaceRuleBodyNamespace{
Enforce: enforce,
})
statusRule := rule.DeepCopy()
statusRule.Enforce = enforce
ruleStatus = append(ruleStatus, statusRule)
}
instance.Status.Rules = ruleStatus
//nolint:staticcheck
instance.Status.Rule = rules.NamespaceRuleBodyNamespace{}
if hadManagedMetadata || hasManagedMetadata(ruleStatus) {
if err := r.publishRulesStatus(ctx, instance); err != nil {
return fmt.Errorf("publish rules before managed metadata reconciliation: %w", err)
}
if err := r.reconcileManagedMetadata(ctx, instance, previousRules, ruleStatus); err != nil {
return fmt.Errorf("reconcile managed metadata: %w", err)
}
}
return nil
}
func (r *Manager) publishRulesStatus(ctx context.Context, instance *capsulev1beta2.RuleStatus) error {
return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
latest := &capsulev1beta2.RuleStatus{}
if err := r.reader.Get(ctx, client.ObjectKeyFromObject(instance), latest); err != nil {
return err
}
latest.Status.Rules = instance.Status.Rules
//nolint:staticcheck
latest.Status.Rule = instance.Status.Rule
return r.Client.Status().Update(ctx, latest)
})
}
func (r *Manager) updateStatus(ctx context.Context, instance *capsulev1beta2.RuleStatus, reconcileError error) error {
return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) {
latest := &capsulev1beta2.RuleStatus{}
@@ -0,0 +1,70 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
rulesmutation "github.com/projectcapsule/capsule/internal/webhook/rules/generic/mutation"
"github.com/projectcapsule/capsule/pkg/ruleengine"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
"github.com/projectcapsule/capsule/pkg/tenant"
"github.com/projectcapsule/capsule/pkg/users"
)
type rulesMetadataMutation struct{ configuration configuration.Configuration }
func RulesMetadataHandler(cfg configuration.Configuration) handlers.TypedHandlerWithUser[*corev1.Namespace] {
return &rulesMetadataMutation{configuration: cfg}
}
func (h *rulesMetadataMutation) OnCreate(c client.Client, reader client.Reader, _ users.AdmissionUser, ns *corev1.Namespace, _ admission.Decoder, _ events.EventRecorder) handlers.Func {
return mutateNamespaceRules(c, reader, h.configuration, ns)
}
func (h *rulesMetadataMutation) OnUpdate(c client.Client, reader client.Reader, _ users.AdmissionUser, _ *corev1.Namespace, ns *corev1.Namespace, _ admission.Decoder, _ events.EventRecorder) handlers.Func {
return mutateNamespaceRules(c, reader, h.configuration, ns)
}
func (*rulesMetadataMutation) OnDelete(client.Client, client.Reader, users.AdmissionUser, *corev1.Namespace, admission.Decoder, events.EventRecorder) handlers.Func {
return func(context.Context, admission.Request) *admission.Response { return nil }
}
func mutateNamespaceRules(c client.Client, reader client.Reader, cfg configuration.Configuration, ns *corev1.Namespace) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if req.SubResource == "finalize" {
return nil
}
tnt, err := tenant.GetTenantByLabels(ctx, reader, ns)
if err != nil {
return handlers.ErroredResponse(err)
}
if tnt == nil {
return nil
}
bodies, err := tenant.BuildNamespaceRuleBodyStatus(c.Scheme(), ns, tnt)
if err != nil {
return handlers.ErroredResponse(err)
}
bodies, err = ruleengine.FilterNamespaceRulesByAudience(cfg, tnt, req, bodies)
if err != nil {
return handlers.ErroredResponse(err)
}
rulesmutation.MutateMetadata(ns, schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, bodies)
return nil
}
}
@@ -0,0 +1,82 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
"testing"
admissionv1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rules"
apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime"
)
func TestMutateNamespaceRulesSkipsFinalize(t *testing.T) {
t.Parallel()
request := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
Operation: admissionv1.Update,
SubResource: "finalize",
}}
if response := mutateNamespaceRules(nil, nil, nil, nil)(context.Background(), request); response != nil {
t.Fatalf("mutateNamespaceRules() response = %#v, want nil", response)
}
}
func TestMutateNamespaceRules(t *testing.T) {
t.Parallel()
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatalf("add core API to scheme: %v", err)
}
if err := capsulev1beta2.AddToScheme(scheme); err != nil {
t.Fatalf("add Capsule API to scheme: %v", err)
}
tnt := &capsulev1beta2.Tenant{
ObjectMeta: metav1.ObjectMeta{Name: "solar"},
Spec: capsulev1beta2.TenantSpec{
Rules: []*rules.NamespaceRuleBodyTenant{{
NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
Enforce: &rules.NamespaceRuleEnforceBody{
Metadata: []rules.MetadataRule{{
VersionKinds: apiruntime.VersionKinds{
APIGroups: []string{"v1"},
Kinds: []string{"Namespace"},
},
Labels: map[string]rules.MetadataValueRule{
"rules.example.com/managed": {Managed: ptr.To("true")},
},
}},
},
},
}},
},
}
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Name: "solar-production",
Labels: map[string]string{meta.TenantLabel: tnt.Name},
}}
client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tnt).Build()
response := mutateNamespaceRules(client, client, nil, ns)(context.Background(), admission.Request{})
if response != nil {
t.Fatalf("mutateNamespaceRules() response = %#v", response)
}
if got := ns.Labels["rules.example.com/managed"]; got != "true" {
t.Fatalf("managed namespace label = %q, want true", got)
}
}
@@ -0,0 +1,114 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/internal/cache"
genericvalidation "github.com/projectcapsule/capsule/internal/webhook/rules/generic/validation"
"github.com/projectcapsule/capsule/pkg/ruleengine"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
"github.com/projectcapsule/capsule/pkg/tenant"
"github.com/projectcapsule/capsule/pkg/users"
)
type rulesMetadataHandler struct {
generic handlers.TypedHandlerWithTenantWithRuleset[*metav1.PartialObjectMetadata]
configuration configuration.Configuration
}
func RulesMetadataHandler(regexCache *cache.RegexCache, cfg configuration.Configuration) handlers.TypedHandlerWithTenantUser[*corev1.Namespace] {
return &rulesMetadataHandler{generic: genericvalidation.GenericRules(regexCache), configuration: cfg}
}
func (h *rulesMetadataHandler) OnCreate(
c client.Client,
reader client.Reader,
_ users.AdmissionUser,
ns *corev1.Namespace,
decoder admission.Decoder,
recorder events.EventRecorder,
tnt *capsulev1beta2.Tenant,
) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
bodies, err := tenant.BuildNamespaceRuleBodyStatus(c.Scheme(), ns, tnt)
if err != nil {
return handlers.ErroredResponse(err)
}
bodies, err = ruleengine.FilterNamespaceRulesByAudience(h.configuration, tnt, req, bodies)
if err != nil {
return handlers.ErroredResponse(err)
}
return h.generic.OnCreate(c, reader, partialNamespaceMetadata(ns), decoder, recorder, tnt, bodies)(ctx, req)
}
}
func (h *rulesMetadataHandler) OnUpdate(
c client.Client,
reader client.Reader,
_ users.AdmissionUser,
ns *corev1.Namespace,
old *corev1.Namespace,
decoder admission.Decoder,
recorder events.EventRecorder,
tnt *capsulev1beta2.Tenant,
) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
if req.SubResource == "finalize" {
return nil
}
bodies, err := tenant.BuildNamespaceRuleBodyStatus(c.Scheme(), ns, tnt)
if err != nil {
return handlers.ErroredResponse(err)
}
bodies, err = ruleengine.FilterNamespaceRulesByAudience(h.configuration, tnt, req, bodies)
if err != nil {
return handlers.ErroredResponse(err)
}
return h.generic.OnUpdate(
c,
reader,
partialNamespaceMetadata(old),
partialNamespaceMetadata(ns),
decoder,
recorder,
tnt,
bodies,
)(ctx, req)
}
}
func (h *rulesMetadataHandler) OnDelete(
client.Client,
client.Reader,
users.AdmissionUser,
*corev1.Namespace,
admission.Decoder,
events.EventRecorder,
*capsulev1beta2.Tenant,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response { return nil }
}
func partialNamespaceMetadata(ns *corev1.Namespace) *metav1.PartialObjectMetadata {
if ns == nil {
return nil
}
return &metav1.PartialObjectMetadata{ObjectMeta: *ns.ObjectMeta.DeepCopy()}
}
@@ -0,0 +1,108 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
"testing"
"github.com/go-logr/logr"
admissionv1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/internal/cache"
"github.com/projectcapsule/capsule/pkg/api/rules"
apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime"
"github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/users"
)
func TestRulesMetadataHandlerSkipsFinalize(t *testing.T) {
t.Parallel()
handler := RulesMetadataHandler(nil, nil)
request := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
Operation: admissionv1.Update,
SubResource: "finalize",
}}
response := handler.OnUpdate(
nil,
nil,
users.AdmissionUser{},
nil,
nil,
nil,
nil,
nil,
)(context.Background(), request)
if response != nil {
t.Fatalf("OnUpdate() response = %#v, want nil", response)
}
}
func TestRulesMetadataHandlerValidatesStatusMetadata(t *testing.T) {
t.Parallel()
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatalf("add core API to scheme: %v", err)
}
if err := capsulev1beta2.AddToScheme(scheme); err != nil {
t.Fatalf("add Capsule API to scheme: %v", err)
}
tnt := &capsulev1beta2.Tenant{ObjectMeta: metav1.ObjectMeta{Name: "solar"}}
tnt.Spec.Rules = []*rules.NamespaceRuleBodyTenant{{
NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{
Enforce: &rules.NamespaceRuleEnforceBody{
Action: rules.ActionTypeAllow,
Metadata: []rules.MetadataRule{{
VersionKinds: apiruntime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"Namespace"}},
Labels: map[string]rules.MetadataValueRule{
"pod-security.kubernetes.io/enforce": {
Required: true,
Values: []apiruntime.ExpressionMatch{{Exact: []string{"restricted", "baseline"}}},
},
},
}},
},
},
}}
oldNs := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
Name: "solar-system",
Labels: map[string]string{"pod-security.kubernetes.io/enforce": "baseline"},
}}
newNs := oldNs.DeepCopy()
newNs.Labels["pod-security.kubernetes.io/enforce"] = "privileged"
client := fake.NewClientBuilder().WithScheme(scheme).Build()
recorder := events.NewEventRecorder(nil, logr.Discard(), nil, nil)
handler := RulesMetadataHandler(cache.NewRegexCache(), nil)
request := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
Kind: metav1.GroupVersionKind{Version: "v1", Kind: "Namespace"},
Operation: admissionv1.Update,
SubResource: "status",
}}
response := handler.OnUpdate(
client,
client,
users.AdmissionUser{},
newNs,
oldNs,
nil,
recorder,
tnt,
)(context.Background(), request)
if response == nil || response.Allowed {
t.Fatalf("OnUpdate() response = %#v, want metadata injection denied", response)
}
}
+4 -2
View File
@@ -6,14 +6,16 @@ package pod
import (
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)
func Handler(handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod]) handlers.Handler {
func Handler(cfg configuration.Configuration, handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod]) handlers.Handler {
return &handlers.TypedTenantWithRulesetHandler[*corev1.Pod]{
Factory: func() *corev1.Pod {
return &corev1.Pod{}
},
Handlers: handler,
Handlers: handler,
Configuration: cfg,
}
}
@@ -0,0 +1,128 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
apirules "github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)
type metadataRules struct{}
func MetadataRules() handlers.TypedHandlerWithTenantWithRuleset[*unstructured.Unstructured] {
return &metadataRules{}
}
func (h *metadataRules) OnCreate(_ client.Client, _ client.Reader, obj *unstructured.Unstructured, _ admission.Decoder, _ events.EventRecorder, _ *capsulev1beta2.Tenant, bodies []*apirules.NamespaceRuleBodyNamespace) handlers.Func {
return h.mutate(obj, bodies)
}
func (h *metadataRules) OnUpdate(_ client.Client, _ client.Reader, _ *unstructured.Unstructured, obj *unstructured.Unstructured, _ admission.Decoder, _ events.EventRecorder, _ *capsulev1beta2.Tenant, bodies []*apirules.NamespaceRuleBodyNamespace) handlers.Func {
return h.mutate(obj, bodies)
}
func (*metadataRules) OnDelete(client.Client, client.Reader, *unstructured.Unstructured, admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, []*apirules.NamespaceRuleBodyNamespace) handlers.Func {
return func(context.Context, admission.Request) *admission.Response { return nil }
}
func (*metadataRules) mutate(obj *unstructured.Unstructured, bodies []*apirules.NamespaceRuleBodyNamespace) handlers.Func {
return func(_ context.Context, req admission.Request) *admission.Response {
gvk := schema.GroupVersionKind{Group: req.Kind.Group, Version: req.Kind.Version, Kind: req.Kind.Kind}
if gvk.Version == "" || gvk.Kind == "" {
response := admission.Errored(http.StatusBadRequest, fmt.Errorf("admission request kind is incomplete: %s", gvk.String()))
return &response
}
MutateMetadata(obj, gvk, bodies)
marshaled, err := json.Marshal(obj)
if err != nil {
response := admission.Errored(http.StatusInternalServerError, err)
return &response
}
response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
return &response
}
}
func MutateMetadata(obj metav1.Object, gvk schema.GroupVersionKind, bodies []*apirules.NamespaceRuleBodyNamespace) {
if obj == nil {
return
}
labels, annotations := obj.GetLabels(), obj.GetAnnotations()
defaultLabels, managedLabels := map[string]string{}, map[string]string{}
defaultAnnotations, managedAnnotations := map[string]string{}, map[string]string{}
for _, body := range bodies {
if body == nil || body.Enforce == nil {
continue
}
for _, rule := range body.Enforce.Metadata {
if !rule.MatchesGroupVersionKind(gvk) {
continue
}
collectMutation(rule.Labels, defaultLabels, managedLabels)
collectMutation(rule.Annotations, defaultAnnotations, managedAnnotations)
}
}
labels = applyMutation(labels, defaultLabels, managedLabels)
annotations = applyMutation(annotations, defaultAnnotations, managedAnnotations)
obj.SetLabels(labels)
obj.SetAnnotations(annotations)
}
func collectMutation(policies map[string]apirules.MetadataValueRule, defaults, managed map[string]string) {
for key, policy := range policies {
if policy.Default != nil {
defaults[key] = *policy.Default
}
if policy.Managed != nil {
managed[key] = *policy.Managed
}
}
}
func applyMutation(current, defaults, managed map[string]string) map[string]string {
if len(defaults) == 0 && len(managed) == 0 {
return current
}
if current == nil {
current = map[string]string{}
}
for key, value := range defaults {
if _, ok := current[key]; !ok {
current[key] = value
}
}
maps.Copy(current, managed)
return current
}
@@ -0,0 +1,55 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/utils/ptr"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
func TestMutateMetadataDefaultsAndManaged(t *testing.T) {
t.Parallel()
obj := &unstructured.Unstructured{}
obj.SetLabels(map[string]string{"default-present": "user", "managed": "user"})
obj.Object["roleRef"] = map[string]any{
"apiGroup": "rbac.authorization.k8s.io",
"kind": "ClusterRole",
"name": "admin",
}
obj.Object["subjects"] = []any{map[string]any{"kind": "User", "name": "alice"}}
bodies := []*rules.NamespaceRuleBodyNamespace{{Enforce: &rules.NamespaceRuleEnforceBody{
Metadata: []rules.MetadataRule{{
VersionKinds: runtime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"ConfigMap"}},
Labels: map[string]rules.MetadataValueRule{
"default-missing": {Default: ptr.To("fallback")},
"default-present": {Default: ptr.To("fallback")},
"managed": {Default: ptr.To("fallback"), Managed: ptr.To("controlled")},
},
}},
}}}
MutateMetadata(obj, schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, bodies)
if got := obj.GetLabels()["default-missing"]; got != "fallback" {
t.Fatalf("default = %q", got)
}
if got := obj.GetLabels()["default-present"]; got != "user" {
t.Fatalf("present default = %q", got)
}
if got := obj.GetLabels()["managed"]; got != "controlled" {
t.Fatalf("managed = %q", got)
}
roleRef, ok := obj.Object["roleRef"].(map[string]any)
if !ok || roleRef["kind"] != "ClusterRole" || roleRef["name"] != "admin" {
t.Fatalf("roleRef was changed: %#v", obj.Object["roleRef"])
}
if _, ok := obj.Object["subjects"]; !ok {
t.Fatal("subjects were removed")
}
}
@@ -0,0 +1,33 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package mutation
import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)
const Path = "/rules/generic/mutating"
type genericMutating struct{ configuration configuration.Configuration }
func Register(cfg configuration.Configuration) handlers.Webhook {
return &genericMutating{configuration: cfg}
}
func (g genericMutating) GetHandlers() []handlers.Handler {
return []handlers.Handler{genericHandler(g.configuration, MetadataRules())}
}
func (genericMutating) GetPath() string { return Path }
func genericHandler(cfg configuration.Configuration, handler ...handlers.TypedHandlerWithTenantWithRuleset[*unstructured.Unstructured]) handlers.Handler {
return &handlers.TypedTenantWithRulesetHandler[*unstructured.Unstructured]{
Factory: func() *unstructured.Unstructured { return &unstructured.Unstructured{} },
Handlers: handler,
Configuration: cfg,
}
}
@@ -0,0 +1,447 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
"fmt"
"slices"
"strings"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/internal/cache"
apirules "github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
"github.com/projectcapsule/capsule/pkg/ruleengine"
ad "github.com/projectcapsule/capsule/pkg/runtime/admission"
"github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)
type ingressRules struct {
regexCache *cache.RegexCache
}
func IngressRules(regexCache *cache.RegexCache) handlers.TypedHandlerWithTenantWithRuleset[*unstructured.Unstructured] {
if regexCache == nil {
regexCache = cache.NewRegexCache()
}
return &ingressRules{regexCache: regexCache}
}
func (h *ingressRules) OnCreate(
_ client.Client,
_ client.Reader,
obj *unstructured.Unstructured,
_ admission.Decoder,
recorder events.EventRecorder,
tnt *capsulev1beta2.Tenant,
bodies []*apirules.NamespaceRuleBodyNamespace,
) handlers.Func {
return h.validate(obj, recorder, tnt, bodies)
}
func (h *ingressRules) OnUpdate(
_ client.Client,
_ client.Reader,
_ *unstructured.Unstructured,
obj *unstructured.Unstructured,
_ admission.Decoder,
recorder events.EventRecorder,
tnt *capsulev1beta2.Tenant,
bodies []*apirules.NamespaceRuleBodyNamespace,
) handlers.Func {
return h.validate(obj, recorder, tnt, bodies)
}
func (*ingressRules) OnDelete(
client.Client,
client.Reader,
*unstructured.Unstructured,
admission.Decoder,
events.EventRecorder,
*capsulev1beta2.Tenant,
[]*apirules.NamespaceRuleBodyNamespace,
) handlers.Func {
return func(context.Context, admission.Request) *admission.Response { return nil }
}
func (h *ingressRules) validate(
obj *unstructured.Unstructured,
recorder events.EventRecorder,
tnt *capsulev1beta2.Tenant,
bodies []*apirules.NamespaceRuleBodyNamespace,
) handlers.Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
gvk := schema.GroupVersionKind{Group: req.Kind.Group, Version: req.Kind.Version, Kind: req.Kind.Kind}
resourceType, supported := ingressTypeForGVK(gvk)
if !supported {
return nil
}
enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies)
evaluation, err := h.evaluate(obj, resourceType, enforceBodies)
if err != nil {
return ad.Deny(err.Error())
}
if evaluation == nil {
return nil
}
for _, audit := range evaluation.Audits {
recorder.LabeledEvent(
obj,
corev1.EventTypeNormal,
events.ReasonNamespaceRuleAudit,
events.ActionRuleAudit,
audit.Message,
).
WithRelated(tnt).
WithTenantLabel(tnt).
WithRequestAnnotations(req).
Emit(ctx)
}
if err := evaluation.BlockingError(); err != nil {
recorder.LabeledEvent(
obj,
corev1.EventTypeWarning,
events.ReasonForbiddenIngressHostname,
events.ActionValidationDenied,
err.Error(),
).
WithRelated(tnt).
WithTenantLabel(tnt).
WithRequestAnnotations(req).
Emit(ctx)
return ad.Deny(err.Error())
}
return nil
}
}
func (h *ingressRules) evaluate(
obj *unstructured.Unstructured,
resourceType apirules.IngressType,
enforceBodies []*apirules.NamespaceRuleEnforceBody,
) (*ruleengine.Evaluation, error) {
if obj == nil || !hasIngressHostnameRules(resourceType, enforceBodies) {
return nil, nil
}
values, err := ingressHostnameValues(obj, resourceType)
if err != nil {
return nil, err
}
if len(values) == 0 {
values = []ruleengine.Value{{Path: hostnameRootPath(resourceType)}}
}
evaluation := &ruleengine.Evaluation{}
hasAuditRules := hasIngressHostnameRulesForAction(resourceType, enforceBodies, apirules.ActionTypeAudit)
hasEnforcingRules := hasEnforcingIngressHostnameRules(resourceType, enforceBodies)
for _, value := range values {
if strings.TrimSpace(value.Value) != "" {
continue
}
if hasAuditRules {
evaluation.Audits = append(evaluation.Audits, missingHostnameAuditDecision(value, resourceType))
}
if hasEnforcingRules {
evaluation.Blocking = missingHostnameDecision(value, resourceType)
return evaluation, nil
}
}
hostnameEvaluation, err := ruleengine.EvaluateEnforce(
obj,
enforceBodies,
ruleengine.Set[runtime.ExpressionMatch, *unstructured.Unstructured]{
Name: "ingress hostname",
EventReason: events.ReasonForbiddenIngressHostname,
Values: func(*unstructured.Unstructured) []ruleengine.Value {
return values
},
Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch {
if enforce == nil || !containsIngressType(enforce.Ingress.Types, resourceType) {
return nil
}
return enforce.Ingress.Hostnames
},
Matches: func(match runtime.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) {
matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value)
if err != nil {
return ruleengine.Match{}, err
}
return ruleengine.Match{Matched: matched, MatchedValue: runtime.DescribeExpressionMatch(match)}, nil
},
RuleDescription: runtime.DescribeExpressionMatch,
AllowedDescription: "Allowed hostnames",
},
)
if err != nil {
return nil, err
}
evaluation.Append(hostnameEvaluation)
return evaluation, nil
}
//nolint:exhaustive
func ingressTypeForGVK(gvk schema.GroupVersionKind) (apirules.IngressType, bool) {
switch {
case gvk.Group == "networking.k8s.io" && gvk.Version == "v1" && gvk.Kind == string(apirules.IngressTypeIngress):
return apirules.IngressTypeIngress, true
case gvk.Group == "route.openshift.io" && gvk.Version == "v1" && gvk.Kind == string(apirules.IngressTypeRoute):
return apirules.IngressTypeRoute, true
case gvk.Group == "gateway.networking.k8s.io" && gvk.Version == "v1":
resourceType := apirules.IngressType(gvk.Kind)
switch resourceType {
case apirules.IngressTypeListenerSet,
apirules.IngressTypeHTTPRoute,
apirules.IngressTypeGateway,
apirules.IngressTypeTLSRoute,
apirules.IngressTypeGRPCRoute:
return resourceType, true
}
}
return "", false
}
func hasIngressHostnameRules(resourceType apirules.IngressType, bodies []*apirules.NamespaceRuleEnforceBody) bool {
for _, body := range bodies {
if body != nil && containsIngressType(body.Ingress.Types, resourceType) && len(body.Ingress.Hostnames) > 0 {
return true
}
}
return false
}
func hasIngressHostnameRulesForAction(
resourceType apirules.IngressType,
bodies []*apirules.NamespaceRuleEnforceBody,
action apirules.ActionType,
) bool {
for _, body := range bodies {
if body != nil &&
body.Action.OrDefault() == action &&
containsIngressType(body.Ingress.Types, resourceType) &&
len(body.Ingress.Hostnames) > 0 {
return true
}
}
return false
}
func hasEnforcingIngressHostnameRules(
resourceType apirules.IngressType,
bodies []*apirules.NamespaceRuleEnforceBody,
) bool {
for _, body := range bodies {
if body != nil &&
body.Action.OrDefault() != apirules.ActionTypeAudit &&
containsIngressType(body.Ingress.Types, resourceType) &&
len(body.Ingress.Hostnames) > 0 {
return true
}
}
return false
}
func containsIngressType(types []apirules.IngressType, expected apirules.IngressType) bool {
return slices.Contains(types, expected)
}
func missingHostnameDecision(value ruleengine.Value, resourceType apirules.IngressType) *ruleengine.Decision {
return &ruleengine.Decision{
SetName: "ingress hostname",
EventReason: events.ReasonForbiddenIngressHostname,
Action: apirules.ActionTypeDeny,
Value: value,
Message: fmt.Sprintf(
"hostname is required at %s because hostname rules target %s",
value.Path,
resourceType,
),
}
}
func missingHostnameAuditDecision(value ruleengine.Value, resourceType apirules.IngressType) *ruleengine.Decision {
return &ruleengine.Decision{
SetName: "ingress hostname",
EventReason: events.ReasonNamespaceRuleAudit,
Action: apirules.ActionTypeAudit,
Value: value,
Message: fmt.Sprintf(
"empty hostname detected at %s for %s by audit namespace rule",
value.Path,
resourceType,
),
}
}
//nolint:exhaustive
func hostnameRootPath(resourceType apirules.IngressType) string {
switch resourceType {
case apirules.IngressTypeIngress:
return "spec.rules[].host"
case apirules.IngressTypeRoute:
return "spec.host"
case apirules.IngressTypeGateway, apirules.IngressTypeListenerSet:
return "spec.listeners[].hostname"
default:
return "spec.hostnames[]"
}
}
func ingressHostnameValues(obj *unstructured.Unstructured, resourceType apirules.IngressType) ([]ruleengine.Value, error) {
switch resourceType {
case apirules.IngressTypeIngress:
return ingressValues(obj)
case apirules.IngressTypeRoute:
return routeObjectValues(obj)
case apirules.IngressTypeGateway, apirules.IngressTypeListenerSet:
return listenerValues(obj)
case apirules.IngressTypeHTTPRoute, apirules.IngressTypeTLSRoute, apirules.IngressTypeGRPCRoute:
return routeValues(obj)
default:
return nil, nil
}
}
func routeObjectValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) {
host, _, err := unstructured.NestedString(obj.Object, "spec", "host")
if err != nil {
return nil, fmt.Errorf("read spec.host: %w", err)
}
return []ruleengine.Value{{Value: strings.TrimSpace(host), Path: "spec.host"}}, nil
}
func ingressValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) {
values := make([]ruleengine.Value, 0)
rules, found, err := unstructured.NestedSlice(obj.Object, "spec", "rules")
if err != nil {
return nil, fmt.Errorf("read spec.rules: %w", err)
}
if found {
for i, item := range rules {
rule, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("spec.rules[%d] is not an object", i)
}
host, _, err := unstructured.NestedString(rule, "host")
if err != nil {
return nil, fmt.Errorf("read spec.rules[%d].host: %w", i, err)
}
values = append(values, ruleengine.Value{Value: strings.TrimSpace(host), Path: fmt.Sprintf("spec.rules[%d].host", i)})
}
}
tls, found, err := unstructured.NestedSlice(obj.Object, "spec", "tls")
if err != nil {
return nil, fmt.Errorf("read spec.tls: %w", err)
}
if found {
for i, item := range tls {
entry, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("spec.tls[%d] is not an object", i)
}
hosts, hostsFound, err := unstructured.NestedStringSlice(entry, "hosts")
if err != nil {
return nil, fmt.Errorf("read spec.tls[%d].hosts: %w", i, err)
}
if !hostsFound || len(hosts) == 0 {
values = append(values, ruleengine.Value{Path: fmt.Sprintf("spec.tls[%d].hosts", i)})
continue
}
for j, host := range hosts {
values = append(values, ruleengine.Value{Value: strings.TrimSpace(host), Path: fmt.Sprintf("spec.tls[%d].hosts[%d]", i, j)})
}
}
}
return values, nil
}
func listenerValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) {
listeners, found, err := unstructured.NestedSlice(obj.Object, "spec", "listeners")
if err != nil {
return nil, fmt.Errorf("read spec.listeners: %w", err)
}
if !found {
return nil, nil
}
values := make([]ruleengine.Value, 0, len(listeners))
for i, item := range listeners {
listener, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("spec.listeners[%d] is not an object", i)
}
hostname, _, err := unstructured.NestedString(listener, "hostname")
if err != nil {
return nil, fmt.Errorf("read spec.listeners[%d].hostname: %w", i, err)
}
values = append(values, ruleengine.Value{Value: strings.TrimSpace(hostname), Path: fmt.Sprintf("spec.listeners[%d].hostname", i)})
}
return values, nil
}
func routeValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) {
hostnames, found, err := unstructured.NestedStringSlice(obj.Object, "spec", "hostnames")
if err != nil {
return nil, fmt.Errorf("read spec.hostnames: %w", err)
}
if !found {
return nil, nil
}
values := make([]ruleengine.Value, 0, len(hostnames))
for i, hostname := range hostnames {
values = append(values, ruleengine.Value{Value: strings.TrimSpace(hostname), Path: fmt.Sprintf("spec.hostnames[%d]", i)})
}
return values, nil
}
@@ -0,0 +1,246 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package validation
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/projectcapsule/capsule/internal/cache"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
func TestIngressTypeForGVK(t *testing.T) {
t.Parallel()
tests := []struct {
gvk schema.GroupVersionKind
want rules.IngressType
ok bool
}{
{schema.GroupVersionKind{Group: "networking.k8s.io", Version: "v1", Kind: "Ingress"}, rules.IngressTypeIngress, true},
{schema.GroupVersionKind{Group: "route.openshift.io", Version: "v1", Kind: "Route"}, rules.IngressTypeRoute, true},
{schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "Gateway"}, rules.IngressTypeGateway, true},
{schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "ListenerSet"}, rules.IngressTypeListenerSet, true},
{schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "HTTPRoute"}, rules.IngressTypeHTTPRoute, true},
{schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "TLSRoute"}, rules.IngressTypeTLSRoute, true},
{schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "GRPCRoute"}, rules.IngressTypeGRPCRoute, true},
{schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1beta1", Kind: "Gateway"}, "", false},
{schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "Ingress"}, "", false},
}
for _, tt := range tests {
got, ok := ingressTypeForGVK(tt.gvk)
if ok != tt.ok || got != tt.want {
t.Fatalf("ingressTypeForGVK(%s) = (%q, %v), want (%q, %v)", tt.gvk, got, ok, tt.want, tt.ok)
}
}
}
func TestIngressHostnameEvaluationSupportsAllResourceShapes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
resourceType rules.IngressType
spec map[string]any
}{
{"Ingress", rules.IngressTypeIngress, map[string]any{
"rules": []any{map[string]any{"host": "prod.example.com"}},
"tls": []any{map[string]any{"hosts": []any{"test.example.com"}}},
}},
{"OpenShift Route", rules.IngressTypeRoute, map[string]any{"host": "prod.example.com"}},
{"Gateway", rules.IngressTypeGateway, listenerSpec("prod.example.com")},
{"ListenerSet", rules.IngressTypeListenerSet, listenerSpec("test.example.com")},
{"HTTPRoute", rules.IngressTypeHTTPRoute, routeSpec("prod.example.com")},
{"TLSRoute", rules.IngressTypeTLSRoute, routeSpec("test.example.com")},
{"GRPCRoute", rules.IngressTypeGRPCRoute, routeSpec("prod.example.com")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
evaluation, err := testIngressRules().evaluate(
objectWithSpec(tt.spec),
tt.resourceType,
ingressRuleBodies(rules.ActionTypeAllow, tt.resourceType, runtime.ExpressionMatch{Exact: []string{"prod.example.com", "test.example.com"}}),
)
if err != nil {
t.Fatalf("evaluate() error = %v", err)
}
if evaluation == nil || evaluation.Blocking != nil {
t.Fatalf("evaluate() = %#v, want allowed", evaluation)
}
})
}
}
func TestIngressHostnameEvaluationRegexAndAllowMiss(t *testing.T) {
t.Parallel()
body := ingressRuleBodies(
rules.ActionTypeAllow,
rules.IngressTypeHTTPRoute,
runtime.ExpressionMatch{ExpressionRegex: runtime.ExpressionRegex{Expression: ".*\\.example\\.com"}},
)
allowed, err := testIngressRules().evaluate(objectWithSpec(routeSpec("api.example.com")), rules.IngressTypeHTTPRoute, body)
if err != nil || allowed == nil || allowed.Blocking != nil {
t.Fatalf("allowed evaluation = %#v, err = %v", allowed, err)
}
denied, err := testIngressRules().evaluate(objectWithSpec(routeSpec("api.example.net")), rules.IngressTypeHTTPRoute, body)
if err != nil {
t.Fatalf("denied evaluation error = %v", err)
}
if denied == nil || denied.Blocking == nil || !strings.Contains(denied.Blocking.Message, "not allowed") {
t.Fatalf("denied evaluation = %#v, want allow-list denial", denied)
}
}
func TestIngressHostnameEvaluationRejectsMissingValues(t *testing.T) {
t.Parallel()
tests := []struct {
name string
resourceType rules.IngressType
spec map[string]any
wantPath string
}{
{"Ingress without rules", rules.IngressTypeIngress, map[string]any{}, "spec.rules[].host"},
{"Ingress rule without host", rules.IngressTypeIngress, map[string]any{"rules": []any{map[string]any{}}}, "spec.rules[0].host"},
{"OpenShift Route without host", rules.IngressTypeRoute, map[string]any{}, "spec.host"},
{"Gateway listener without hostname", rules.IngressTypeGateway, listenerSpec(""), "spec.listeners[0].hostname"},
{"ListenerSet listener without hostname", rules.IngressTypeListenerSet, listenerSpec(""), "spec.listeners[0].hostname"},
{"HTTPRoute without hostnames", rules.IngressTypeHTTPRoute, map[string]any{}, "spec.hostnames[]"},
{"TLSRoute without hostnames", rules.IngressTypeTLSRoute, map[string]any{}, "spec.hostnames[]"},
{"GRPCRoute without hostnames", rules.IngressTypeGRPCRoute, map[string]any{}, "spec.hostnames[]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
evaluation, err := testIngressRules().evaluate(
objectWithSpec(tt.spec),
tt.resourceType,
ingressRuleBodies(rules.ActionTypeDeny, tt.resourceType, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}}),
)
if err != nil {
t.Fatalf("evaluate() error = %v", err)
}
if evaluation == nil || evaluation.Blocking == nil || !strings.Contains(evaluation.Blocking.Message, tt.wantPath) {
t.Fatalf("evaluation = %#v, want missing hostname at %q", evaluation, tt.wantPath)
}
})
}
}
func TestIngressHostnameEvaluationAuditsMissingValuesWithoutBlocking(t *testing.T) {
t.Parallel()
tests := []struct {
name string
resourceType rules.IngressType
spec map[string]any
wantPath string
}{
{"Ingress without rules", rules.IngressTypeIngress, map[string]any{}, "spec.rules[].host"},
{"Ingress rule without host", rules.IngressTypeIngress, map[string]any{"rules": []any{map[string]any{}}}, "spec.rules[0].host"},
{"OpenShift Route without host", rules.IngressTypeRoute, map[string]any{}, "spec.host"},
{"Gateway listener without hostname", rules.IngressTypeGateway, listenerSpec(""), "spec.listeners[0].hostname"},
{"HTTPRoute without hostnames", rules.IngressTypeHTTPRoute, map[string]any{}, "spec.hostnames[]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
evaluation, err := testIngressRules().evaluate(
objectWithSpec(tt.spec),
tt.resourceType,
ingressRuleBodies(rules.ActionTypeAudit, tt.resourceType, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}}),
)
if err != nil {
t.Fatalf("evaluate() error = %v", err)
}
if evaluation == nil || evaluation.Blocking != nil {
t.Fatalf("evaluation = %#v, want non-blocking audit", evaluation)
}
if len(evaluation.Audits) != 1 || !strings.Contains(evaluation.Audits[0].Message, tt.wantPath) {
t.Fatalf("audits = %#v, want missing hostname audit at %q", evaluation.Audits, tt.wantPath)
}
})
}
}
func TestIngressHostnameEvaluationAuditsAndRejectsMissingValueWithEnforcingRule(t *testing.T) {
t.Parallel()
bodies := append(
ingressRuleBodies(rules.ActionTypeAudit, rules.IngressTypeGateway, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}}),
ingressRuleBodies(rules.ActionTypeAllow, rules.IngressTypeGateway, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}})...,
)
evaluation, err := testIngressRules().evaluate(
objectWithSpec(listenerSpec("")),
rules.IngressTypeGateway,
bodies,
)
if err != nil {
t.Fatalf("evaluate() error = %v", err)
}
if evaluation == nil || evaluation.Blocking == nil {
t.Fatalf("evaluation = %#v, want blocking decision", evaluation)
}
if len(evaluation.Audits) != 1 || !strings.Contains(evaluation.Audits[0].Message, "empty hostname detected") {
t.Fatalf("audits = %#v, want missing hostname audit", evaluation.Audits)
}
}
func TestIngressHostnameEvaluationIgnoresUntargetedTypes(t *testing.T) {
t.Parallel()
evaluation, err := testIngressRules().evaluate(
objectWithSpec(map[string]any{}),
rules.IngressTypeGateway,
ingressRuleBodies(rules.ActionTypeAllow, rules.IngressTypeIngress, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}}),
)
if err != nil || evaluation != nil {
t.Fatalf("evaluate() = %#v, err = %v, want nil", evaluation, err)
}
}
func testIngressRules() *ingressRules {
return &ingressRules{regexCache: cache.NewRegexCache()}
}
func objectWithSpec(spec map[string]any) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]any{"spec": spec}}
}
func listenerSpec(hostname string) map[string]any {
listener := map[string]any{"name": "https"}
if hostname != "" {
listener["hostname"] = hostname
}
return map[string]any{"listeners": []any{listener}}
}
func routeSpec(hostname string) map[string]any {
return map[string]any{"hostnames": []any{hostname}}
}
func ingressRuleBodies(action rules.ActionType, resourceType rules.IngressType, hostnames ...runtime.ExpressionMatch) []*rules.NamespaceRuleEnforceBody {
return []*rules.NamespaceRuleEnforceBody{{
Action: action,
Ingress: rules.NamespaceRuleEnforceIngressBody{
Types: []rules.IngressType{resourceType},
Hostnames: hostnames,
},
}}
}
@@ -1,6 +1,7 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package validation
import (
@@ -40,7 +41,11 @@ func (h *genericRules) validateMetadata(
return nil, nil
}
entries := h.controlledMetadataEntries(obj, gvk, enforceBodies)
entries, err := h.controlledMetadataEntries(obj, gvk, enforceBodies)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, nil
}
@@ -115,9 +120,9 @@ func (h *genericRules) metadataSet(
}
},
Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch {
RulesWithError: func(enforce *apirules.NamespaceRuleEnforceBody) ([]runtime.ExpressionMatch, error) {
if enforce == nil || len(enforce.Metadata) == 0 {
return nil
return nil, nil
}
var out []runtime.ExpressionMatch
@@ -130,19 +135,31 @@ func (h *genericRules) metadataSet(
switch entry.Field {
case metadataFieldLabel:
policy, ok := rule.Labels[entry.Key]
if ok {
out = append(out, policy.Values...)
for selector, policy := range rule.Labels {
matched, err := h.matchesMetadataKey(selector, entry.Key)
if err != nil {
return nil, fmt.Errorf("invalid label selector %q: %w", selector, err)
}
if matched {
out = append(out, policy.Values...)
}
}
case metadataFieldAnnotation:
policy, ok := rule.Annotations[entry.Key]
if ok {
out = append(out, policy.Values...)
for selector, policy := range rule.Annotations {
matched, err := h.matchesMetadataKey(selector, entry.Key)
if err != nil {
return nil, fmt.Errorf("invalid annotation selector %q: %w", selector, err)
}
if matched {
out = append(out, policy.Values...)
}
}
}
}
return out
return out, nil
},
Matches: func(match runtime.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) {
matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value)
@@ -159,11 +176,12 @@ func (h *genericRules) metadataSet(
}
}
//nolint:gocognit
func (h *genericRules) controlledMetadataEntries(
obj genericObject,
gvk schema.GroupVersionKind,
enforceBodies []*apirules.NamespaceRuleEnforceBody,
) []metadataEntry {
) ([]metadataEntry, error) {
labels := obj.GetLabels()
annotations := obj.GetAnnotations()
@@ -182,60 +200,74 @@ func (h *genericRules) controlledMetadataEntries(
continue
}
for key, policy := range rule.Labels {
if h.managedMetadata.HasLabel(key) {
for selector, policy := range rule.Labels {
if h.managedMetadata.HasLabel(selector) {
continue
}
value, exists := labels[key]
required := action == apirules.ActionTypeAllow && policy.Required
matchedAny := false
if !exists && !required {
continue
for key, value := range labels {
matched, err := h.matchesMetadataKey(selector, key)
if err != nil {
return nil, fmt.Errorf("invalid label selector %q: %w", selector, err)
}
if !matched {
continue
}
if h.managedMetadata.HasLabel(key) {
continue
}
matchedAny = true
h.addMetadataEntry(seen, metadataFieldLabel, key, value, true, required)
}
path := metadataLabelPath(key)
entry := seen[path]
entry.Field = metadataFieldLabel
entry.Key = key
entry.Path = path
entry.Present = exists
entry.Value = value
entry.Required = entry.Required || required
seen[path] = entry
if !matchedAny && required {
h.addMetadataEntry(seen, metadataFieldLabel, selector, "", false, true)
}
}
for key, policy := range rule.Annotations {
if h.managedMetadata.HasAnnotation(key) {
for selector, policy := range rule.Annotations {
if h.managedMetadata.HasAnnotation(selector) {
continue
}
value, exists := annotations[key]
required := action == apirules.ActionTypeAllow && policy.Required
matchedAny := false
if !exists && !required {
continue
for key, value := range annotations {
matched, err := h.matchesMetadataKey(selector, key)
if err != nil {
return nil, fmt.Errorf("invalid label selector %q: %w", selector, err)
}
if !matched {
continue
}
if h.managedMetadata.HasAnnotation(key) {
continue
}
matchedAny = true
h.addMetadataEntry(seen, metadataFieldAnnotation, key, value, true, required)
}
path := metadataAnnotationPath(key)
entry := seen[path]
entry.Field = metadataFieldAnnotation
entry.Key = key
entry.Path = path
entry.Present = exists
entry.Value = value
entry.Required = entry.Required || required
seen[path] = entry
if !matchedAny && required {
h.addMetadataEntry(seen, metadataFieldAnnotation, selector, "", false, true)
}
}
}
}
if len(seen) == 0 {
return nil
return nil, nil
}
out := make([]metadataEntry, 0, len(seen))
@@ -247,7 +279,39 @@ func (h *genericRules) controlledMetadataEntries(
return out[i].Path < out[j].Path
})
return out
return out, nil
}
func (h *genericRules) matchesMetadataKey(selector, key string) (bool, error) {
return h.regexCache.MatchRegex(apirules.MetadataKeyExpression(selector), key)
}
func (h *genericRules) addMetadataEntry(
seen map[string]metadataEntry,
field metadataField,
key string,
value string,
present bool,
required bool,
) {
path := metadataLabelPath(key)
if field == metadataFieldAnnotation {
path = metadataAnnotationPath(key)
}
entry := seen[path]
entry.Field = field
entry.Key = key
entry.Path = path
entry.Present = entry.Present || present
if present {
entry.Value = value
}
entry.Required = entry.Required || required
seen[path] = entry
}
func metadataSetName(field metadataField) string {
@@ -419,6 +419,41 @@ func TestValidateMetadata(t *testing.T) {
}
}
func TestControlledMetadataEntriesMatchesKeyPatternsWithRegexCache(t *testing.T) {
t.Parallel()
h := newMetadataTestRules(nil, nil)
obj := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
"example.corp/cost-center": "INV-1234",
"unrelated": "ignored",
},
}}
enforce := []*apirules.NamespaceRuleEnforceBody{{
Metadata: []apirules.MetadataRule{{
VersionKinds: runtime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"Namespace"}},
Annotations: map[string]apirules.MetadataValueRule{
"example.corp/*": {},
},
}},
}}
entries, err := h.controlledMetadataEntries(
obj,
schema.GroupVersionKind{Version: "v1", Kind: "Namespace"},
enforce,
)
if err != nil {
t.Fatalf("controlledMetadataEntries() error = %v", err)
}
if len(entries) != 1 || entries[0].Key != "example.corp/cost-center" {
t.Fatalf("unexpected pattern-matched entries: %#v", entries)
}
if h.regexCache.Stats() != 1 {
t.Fatalf("expected key expression to use regex cache, got %d entries", h.regexCache.Stats())
}
}
func TestControlledMetadataEntries(t *testing.T) {
t.Parallel()
@@ -741,6 +776,45 @@ func TestControlledMetadataEntries(t *testing.T) {
},
want: nil,
},
{
name: "managed metadata does not satisfy required selectors",
obj: metadataObject(
map[string]string{
meta.TenantLabel: "tenant-a",
},
map[string]string{
meta.ReconcileAnnotation: "true",
},
),
gvk: coreGVK("ConfigMap"),
enforceBodies: []*apirules.NamespaceRuleEnforceBody{
enforceMetadata(
apirules.ActionTypeAllow,
[]string{"*"},
[]string{"ConfigMap"},
map[string]apirules.MetadataValueRule{
"capsule.clastix.io/*": metadataPolicy(true, exact("tenant-a")),
},
map[string]apirules.MetadataValueRule{
"reconcile.projectcapsule.dev/*": metadataPolicy(true, exact("true")),
},
),
},
want: []metadataEntry{
{
Field: metadataFieldAnnotation,
Key: "reconcile.projectcapsule.dev/*",
Path: `metadata.annotations["reconcile.projectcapsule.dev/*"]`,
Required: true,
},
{
Field: metadataFieldLabel,
Key: "capsule.clastix.io/*",
Path: `metadata.labels["capsule.clastix.io/*"]`,
Required: true,
},
},
},
{
name: "custom parameters add label and annotation to managed metadata",
managedLabels: []string{
@@ -892,7 +966,10 @@ func TestControlledMetadataEntries(t *testing.T) {
h := newMetadataTestRules(tt.managedLabels, tt.managedAnnotations)
got := h.controlledMetadataEntries(tt.obj, tt.gvk, tt.enforceBodies)
got, err := h.controlledMetadataEntries(tt.obj, tt.gvk, tt.enforceBodies)
if err != nil {
t.Fatalf("controlledMetadataEntries() error = %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("unexpected entries\nwant: %#v\n got: %#v", tt.want, got)
}
@@ -942,7 +1019,7 @@ func TestMetadataSet(t *testing.T) {
set := h.metadataSet(coreGVK("ConfigMap"), entry)
got := set.Rules(enforceMetadata(
got, err := set.RulesWithError(enforceMetadata(
apirules.ActionTypeAllow,
[]string{"*"},
[]string{"ConfigMap"},
@@ -951,6 +1028,9 @@ func TestMetadataSet(t *testing.T) {
},
nil,
))
if err != nil {
t.Fatalf("RulesWithError() error = %v", err)
}
if len(got) != 2 {
t.Fatalf("expected two matchers, got %d", len(got))
@@ -976,7 +1056,7 @@ func TestMetadataSet(t *testing.T) {
set := h.metadataSet(coreGVK("ConfigMap"), entry)
got := set.Rules(enforceMetadata(
got, err := set.RulesWithError(enforceMetadata(
apirules.ActionTypeAllow,
[]string{"*"},
[]string{"ConfigMap"},
@@ -985,6 +1065,9 @@ func TestMetadataSet(t *testing.T) {
"cost-center": metadataPolicy(true, expression("^INV-[0-9]{4}$")),
},
))
if err != nil {
t.Fatalf("RulesWithError() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("expected one matcher, got %d", len(got))
@@ -1003,7 +1086,11 @@ func TestMetadataSet(t *testing.T) {
Key: "env",
})
if got := set.Rules(nil); got != nil {
got, err := set.RulesWithError(nil)
if err != nil {
t.Fatalf("RulesWithError() error = %v", err)
}
if got != nil {
t.Fatalf("expected nil, got %#v", got)
}
})
@@ -1017,7 +1104,7 @@ func TestMetadataSet(t *testing.T) {
Key: "env",
})
got := set.Rules(enforceMetadata(
got, err := set.RulesWithError(enforceMetadata(
apirules.ActionTypeAllow,
[]string{"*"},
[]string{"ConfigMap"},
@@ -1026,12 +1113,59 @@ func TestMetadataSet(t *testing.T) {
},
nil,
))
if err != nil {
t.Fatalf("RulesWithError() error = %v", err)
}
if got != nil {
t.Fatalf("expected nil, got %#v", got)
}
})
t.Run("rules returns invalid key selector errors", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
entry metadataEntry
body *apirules.NamespaceRuleEnforceBody
}{
{
name: "label",
entry: metadataEntry{Field: metadataFieldLabel, Key: "env"},
body: enforceMetadata(
apirules.ActionTypeAllow,
[]string{"*"},
[]string{"ConfigMap"},
map[string]apirules.MetadataValueRule{"[": metadataPolicy(false, exact("prod"))},
nil,
),
},
{
name: "annotation",
entry: metadataEntry{Field: metadataFieldAnnotation, Key: "env"},
body: enforceMetadata(
apirules.ActionTypeAllow,
[]string{"*"},
[]string{"ConfigMap"},
nil,
map[string]apirules.MetadataValueRule{"[": metadataPolicy(false, exact("prod"))},
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
set := newMetadataTestRules(nil, nil).metadataSet(coreGVK("ConfigMap"), tt.entry)
if _, err := set.RulesWithError(tt.body); err == nil {
t.Fatal("RulesWithError() error = nil, want invalid selector error")
}
})
}
})
t.Run("matches exact value", func(t *testing.T) {
t.Parallel()
@@ -5,28 +5,35 @@ package validation
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/projectcapsule/capsule/internal/cache"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)
const Path = "/rules/generic/validating"
type genericValidating struct {
regexCache *cache.RegexCache
regexCache *cache.RegexCache
configuration configuration.Configuration
}
func Register(regexCache *cache.RegexCache) handlers.Webhook {
func Register(regexCache *cache.RegexCache, cfg configuration.Configuration) handlers.Webhook {
return &genericValidating{
regexCache: regexCache,
regexCache: regexCache,
configuration: cfg,
}
}
func (w *genericValidating) GetHandlers() []handlers.Handler {
return []handlers.Handler{
genericHandler(
genericHandler(w.configuration,
GenericRules(w.regexCache),
),
ingressHandler(w.configuration,
IngressRules(w.regexCache),
),
}
}
@@ -34,13 +41,24 @@ func (genericValidating) GetPath() string {
return Path
}
func genericHandler(
func ingressHandler(cfg configuration.Configuration,
handler ...handlers.TypedHandlerWithTenantWithRuleset[*unstructured.Unstructured],
) handlers.Handler {
return &handlers.TypedTenantWithRulesetHandler[*unstructured.Unstructured]{
Factory: func() *unstructured.Unstructured { return &unstructured.Unstructured{} },
Handlers: handler,
Configuration: cfg,
}
}
func genericHandler(cfg configuration.Configuration,
handler ...handlers.TypedHandlerWithTenantWithRuleset[*metav1.PartialObjectMetadata],
) handlers.Handler {
return &handlers.TypedTenantWithRulesetHandler[*metav1.PartialObjectMetadata]{
Factory: func() *metav1.PartialObjectMetadata {
return &metav1.PartialObjectMetadata{}
},
Handlers: handler,
Handlers: handler,
Configuration: cfg,
}
}
+4 -2
View File
@@ -6,14 +6,16 @@ package service
import (
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)
func Handler(handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service]) handlers.Handler {
func Handler(cfg configuration.Configuration, handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service]) handlers.Handler {
return &handlers.TypedTenantWithRulesetHandler[*corev1.Service]{
Factory: func() *corev1.Service {
return &corev1.Service{}
},
Handlers: handler,
Handlers: handler,
Configuration: cfg,
}
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rules
import "github.com/projectcapsule/capsule/pkg/api/runtime"
// +kubebuilder:validation:Enum=Ingress;Route;ListenerSet;HTTPRoute;Gateway;TLSRoute;GRPCRoute
type IngressType string
const (
IngressTypeIngress IngressType = "Ingress"
IngressTypeRoute IngressType = "Route"
IngressTypeListenerSet IngressType = "ListenerSet"
IngressTypeHTTPRoute IngressType = "HTTPRoute"
IngressTypeGateway IngressType = "Gateway"
IngressTypeTLSRoute IngressType = "TLSRoute"
IngressTypeGRPCRoute IngressType = "GRPCRoute"
)
// NamespaceRuleEnforceIngressBody defines hostname enforcement for Kubernetes
// Ingress and Gateway API resources.
//
// +kubebuilder:object:generate=true
type NamespaceRuleEnforceIngressBody struct {
// Types defines the resource kinds to which hostname enforcement applies.
//
// +kubebuilder:validation:MinItems=1
Types []IngressType `json:"types,omitempty"`
// Hostnames defines allowed, denied, or audited hostname expressions.
// A resource targeted by an allow or deny rule must declare non-empty values
// in all hostname fields. Audit-only rules record missing hostnames without
// denying them.
//
// +kubebuilder:validation:MinItems=1
Hostnames []runtime.ExpressionMatch `json:"hostnames,omitempty"`
}
+60
View File
@@ -4,6 +4,10 @@
package rules
import (
"strings"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
@@ -25,6 +29,29 @@ type MetadataRule struct {
Annotations map[string]MetadataValueRule `json:"annotations,omitempty"`
}
// MatchesGroupVersionKind matches metadata targets. Namespace is deliberately
// opt-in: wildcard kind selectors never include it, so cluster-scoped
// namespace admission cannot be enabled accidentally.
func (r MetadataRule) MatchesGroupVersionKind(gvk schema.GroupVersionKind) bool {
if gvk.Group == "" && gvk.Version == "v1" && gvk.Kind == "Namespace" {
explicit := false
for _, kind := range r.Kinds {
if strings.TrimSpace(kind) == "Namespace" {
explicit = true
break
}
}
if !explicit {
return false
}
}
return r.VersionKinds.MatchesGroupVersionKind(gvk)
}
// +kubebuilder:object:generate=true
type MetadataValueRule struct {
// Required enforces that the metadata key must be present.
@@ -42,4 +69,37 @@ type MetadataValueRule struct {
//
// +optional
Values []runtime.ExpressionMatch `json:"values,omitempty"`
// Default is applied by admission mutation when the concrete metadata key is absent.
// It is not reconciled after admission.
// +optional
Default *string `json:"default,omitempty"`
// Managed is enforced by admission mutation and reconciled by the RuleStatus
// controller using server-side apply when the rule configuration changes.
// +optional
Managed *string `json:"managed,omitempty"`
}
// MetadataKeyExpression converts a metadata key selector into the regular
// expression used by admission validation and runtime matching. Asterisks are
// convenient wildcards, while the rest of the selector retains regexp syntax.
func MetadataKeyExpression(selector string) runtime.ExpressionRegex {
selector = strings.TrimSpace(selector)
var expression strings.Builder
for i, char := range selector {
if char == '*' && (i == 0 || selector[i-1] != '.') {
expression.WriteString(".*")
continue
}
expression.WriteRune(char)
}
return runtime.ExpressionRegex{
Expression: "^(?:" + expression.String() + ")$",
}
}
@@ -0,0 +1,41 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rules
import (
"testing"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
func TestMetadataRuleNamespaceRequiresExplicitKind(t *testing.T) {
t.Parallel()
namespace := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}
tests := []struct {
name string
kinds []string
want bool
}{
{name: "explicit namespace", kinds: []string{"Namespace"}, want: true},
{name: "wildcard only", kinds: []string{"*"}, want: false},
{name: "partial wildcard only", kinds: []string{"Name*"}, want: false},
{name: "explicit alongside wildcard", kinds: []string{"*", "Namespace"}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rule := MetadataRule{VersionKinds: runtime.VersionKinds{
APIGroups: []string{"*"},
Kinds: tt.kinds,
}}
if got := rule.MatchesGroupVersionKind(namespace); got != tt.want {
t.Fatalf("MatchesGroupVersionKind() = %v, want %v", got, tt.want)
}
})
}
}
+29
View File
@@ -3,6 +3,31 @@
package rules
type AudienceKind string
const (
AudienceKindUser AudienceKind = "User"
AudienceKindGroup AudienceKind = "Group"
AudienceKindServiceAccount AudienceKind = "ServiceAccount"
AudienceKindCustom AudienceKind = "Custom"
)
type CustomAudience string
const (
CustomAudienceCapsuleUser CustomAudience = "CapsuleUser"
CustomAudienceAdministrator CustomAudience = "Administrator"
CustomAudienceTenantOwner CustomAudience = "TenantOwner"
CustomAudienceController CustomAudience = "Controller"
)
// +kubebuilder:object:generate=true
type Audience struct {
// +kubebuilder:validation:Enum=User;Group;ServiceAccount;Custom
Kind AudienceKind `json:"kind"`
Name string `json:"name"`
}
// +kubebuilder:object:generate=true
type NamespaceRuleEnforceBody struct {
// Declare the action being performed on the enforcement rule:
@@ -23,4 +48,8 @@ type NamespaceRuleEnforceBody struct {
//
// +optional
Metadata []MetadataRule `json:"metadata,omitempty"`
// Enforcement for Ingress and Gateway API resource hostnames.
// +optional
Ingress NamespaceRuleEnforceIngressBody `json:"ingress,omitempty"`
}
+5
View File
@@ -10,6 +10,11 @@ import (
// For future implementation where users might manage RuleStatus CRs themselves
// +kubebuilder:object:generate=true
type NamespaceRuleBodyNamespace struct {
// Audience limits this rule to matching request subjects.
// An empty audience matches every request.
// +optional
Audience []Audience `json:"audience,omitempty"`
// Enforcement for given rule
//+optional
Enforce *NamespaceRuleEnforceBody `json:"enforce,omitzero"`
+58
View File
@@ -14,6 +14,21 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Audience) DeepCopyInto(out *Audience) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Audience.
func (in *Audience) DeepCopy() *Audience {
if in == nil {
return nil
}
out := new(Audience)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MetadataRule) DeepCopyInto(out *MetadataRule) {
*out = *in
@@ -54,6 +69,16 @@ func (in *MetadataValueRule) DeepCopyInto(out *MetadataValueRule) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Default != nil {
in, out := &in.Default, &out.Default
*out = new(string)
**out = **in
}
if in.Managed != nil {
in, out := &in.Managed, &out.Managed
*out = new(string)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetadataValueRule.
@@ -69,6 +94,11 @@ func (in *MetadataValueRule) DeepCopy() *MetadataValueRule {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NamespaceRuleBodyNamespace) DeepCopyInto(out *NamespaceRuleBodyNamespace) {
*out = *in
if in.Audience != nil {
in, out := &in.Audience, &out.Audience
*out = make([]Audience, len(*in))
copy(*out, *in)
}
if in.Enforce != nil {
in, out := &in.Enforce, &out.Enforce
*out = new(NamespaceRuleEnforceBody)
@@ -124,6 +154,7 @@ func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody)
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
in.Ingress.DeepCopyInto(&out.Ingress)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody.
@@ -136,6 +167,33 @@ func (in *NamespaceRuleEnforceBody) DeepCopy() *NamespaceRuleEnforceBody {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NamespaceRuleEnforceIngressBody) DeepCopyInto(out *NamespaceRuleEnforceIngressBody) {
*out = *in
if in.Types != nil {
in, out := &in.Types, &out.Types
*out = make([]IngressType, len(*in))
copy(*out, *in)
}
if in.Hostnames != nil {
in, out := &in.Hostnames, &out.Hostnames
*out = make([]runtime.ExpressionMatch, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceIngressBody.
func (in *NamespaceRuleEnforceIngressBody) DeepCopy() *NamespaceRuleEnforceIngressBody {
if in == nil {
return nil
}
out := new(NamespaceRuleEnforceIngressBody)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NamespaceRuleEnforceServicesBody) DeepCopyInto(out *NamespaceRuleEnforceServicesBody) {
*out = *in
+41 -47
View File
@@ -182,6 +182,17 @@ func (s VersionKinds) HasWildcard() bool {
// Wildcard API groups or wildcard kinds are intentionally skipped because they are selectors,
// not concrete Kubernetes resources.
func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath string) error {
return s.ValidateKnownKindsWithScope(mapper, fieldPath, nil)
}
// ValidateKnownKindsWithScope validates concrete targets and optionally their
// REST scope. Wildcard selectors are skipped because discovery cannot enumerate
// their complete set reliably.
func (s VersionKinds) ValidateKnownKindsWithScope(
mapper apimeta.RESTMapper,
fieldPath string,
allowScope func(schema.GroupVersionKind, apimeta.RESTScope) bool,
) error {
if mapper == nil {
return nil
}
@@ -199,7 +210,8 @@ func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath st
continue
}
if err := validateKnownKindForAPIGroup(mapper, apiGroup, kind); err != nil {
mapping, err := restMappingForAPIGroup(mapper, apiGroup, kind)
if err != nil {
return fmt.Errorf(
"%s.kinds[%d] %q for apiGroups[%d] %q is invalid: %w",
fieldPath,
@@ -210,12 +222,40 @@ func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath st
err,
)
}
if allowScope != nil && !allowScope(mapping.GroupVersionKind, mapping.Scope) {
return fmt.Errorf(
"%s.kinds[%d] %q for apiGroups[%d] %q is invalid: GVK %s has unsupported scope %q",
fieldPath, kindIndex, kind, apiGroupIndex, apiGroup,
mapping.GroupVersionKind.String(), mapping.Scope.Name(),
)
}
}
}
return nil
}
func restMappingForAPIGroup(
mapper apimeta.RESTMapper,
apiGroup string,
kind string,
) (*apimeta.RESTMapping, error) {
apiGroup = strings.TrimSpace(apiGroup)
apiGroup = normalizeAPIVersion(apiGroup)
if apiGroup == CoreAPIVersion {
return mapper.RESTMapping(schema.GroupKind{Kind: kind}, CoreAPIVersion)
}
if gv, err := schema.ParseGroupVersion(apiGroup); err == nil && strings.Contains(apiGroup, "/") {
return mapper.RESTMapping(schema.GroupKind{Group: gv.Group, Kind: kind}, gv.Version)
}
return mapper.RESTMapping(schema.GroupKind{Group: apiGroup, Kind: kind})
}
func (s VersionKinds) StatusAPIGroups() []string {
apiGroups := s.NormalizedAPIGroups()
if len(apiGroups) == 0 {
@@ -247,52 +287,6 @@ func (s VersionKinds) StatusAPIGroups() []string {
return out
}
func validateKnownKindForAPIGroup(
mapper apimeta.RESTMapper,
apiGroup string,
kind string,
) error {
apiGroup = normalizeAPIVersion(apiGroup)
if apiGroup == CoreAPIVersion {
_, err := mapper.RESTMapping(
schema.GroupKind{
Group: "",
Kind: kind,
},
CoreAPIVersion,
)
return err
}
if strings.Contains(apiGroup, "/") {
gv, err := schema.ParseGroupVersion(apiGroup)
if err != nil {
return err
}
_, err = mapper.RESTMapping(
schema.GroupKind{
Group: gv.Group,
Kind: kind,
},
gv.Version,
)
return err
}
_, err := mapper.RESTMapping(
schema.GroupKind{
Group: apiGroup,
Kind: kind,
},
)
return err
}
func (s VersionKinds) NormalizedAPIGroups() []string {
if len(s.APIGroups) == 0 {
return []string{CoreAPIVersion}
-103
View File
@@ -1818,109 +1818,6 @@ func TestVersionKindsValidateKnownKinds(t *testing.T) {
}
}
func TestValidateKnownKindForAPIGroup(t *testing.T) {
t.Parallel()
mapper := newVersionKindTestRESTMapper()
tests := []struct {
name string
apiGroup string
kind string
wantErr bool
}{
{
name: "core v1 kind",
apiGroup: "",
kind: "ConfigMap",
},
{
name: "explicit core v1 kind",
apiGroup: "v1",
kind: "Service",
},
{
name: "group only kind",
apiGroup: "apps",
kind: "Deployment",
},
{
name: "exact group version kind",
apiGroup: "apps/v1",
kind: "Deployment",
},
{
name: "batch exact group version kind",
apiGroup: "batch/v1",
kind: "Job",
},
{
name: "unknown core kind",
apiGroup: "",
kind: "NotAThing",
wantErr: true,
},
{
name: "wrong group kind",
apiGroup: "batch/v1",
kind: "Deployment",
wantErr: true,
},
{
name: "wrong exact version",
apiGroup: "apps/v1beta1",
kind: "StatefulSet",
wantErr: true,
},
{
name: "unknown group",
apiGroup: "example.corp",
kind: "Widget",
wantErr: true,
},
{
name: "invalid group version",
apiGroup: "apps/v1/extra",
kind: "Deployment",
wantErr: true,
},
{
name: "empty kind fails",
apiGroup: "v1",
kind: "",
wantErr: true,
},
{
name: "case-sensitive kind fails",
apiGroup: "v1",
kind: "configmap",
wantErr: true,
},
{
name: "case-sensitive group fails",
apiGroup: "Apps",
kind: "Deployment",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateKnownKindForAPIGroup(mapper, tt.apiGroup, tt.kind)
if tt.wantErr && err == nil {
t.Fatalf("expected error")
}
if !tt.wantErr && err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}
}
func TestVersionKindsNormalizedAPIGroups(t *testing.T) {
t.Parallel()
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ruleengine
import (
"fmt"
"slices"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/rbac"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/users"
)
func FilterNamespaceRulesByAudience(
cfg configuration.Configuration,
tnt *capsulev1beta2.Tenant,
req admission.Request,
bodies []*rules.NamespaceRuleBodyNamespace,
) ([]*rules.NamespaceRuleBodyNamespace, error) {
out := make([]*rules.NamespaceRuleBodyNamespace, 0, len(bodies))
for _, body := range bodies {
if body == nil || len(body.Audience) == 0 {
out = append(out, body)
continue
}
matched, err := matchesAudience(cfg, tnt, req, body.Audience)
if err != nil {
return nil, err
}
if matched {
out = append(out, body)
}
}
return out, nil
}
func matchesAudience(cfg configuration.Configuration, tnt *capsulev1beta2.Tenant, req admission.Request, audience []rules.Audience) (bool, error) {
for _, subject := range audience {
switch subject.Kind {
case rules.AudienceKindUser:
if req.UserInfo.Username == subject.Name {
return true, nil
}
case rules.AudienceKindGroup:
if slices.Contains(req.UserInfo.Groups, subject.Name) {
return true, nil
}
case rules.AudienceKindServiceAccount:
if (rbac.UserListSpec{{Kind: rbac.ServiceAccountOwner, Name: subject.Name}}).IsPresent(req.UserInfo.Username, req.UserInfo.Groups) {
return true, nil
}
case rules.AudienceKindCustom:
if cfg == nil {
return false, fmt.Errorf("configuration is required for custom audience %q", subject.Name)
}
matched, err := matchesCustomAudience(cfg, tnt, req, rules.CustomAudience(subject.Name))
if err != nil {
return false, err
}
if matched {
return true, nil
}
default:
return false, fmt.Errorf("unsupported audience kind %q", subject.Kind)
}
}
return false, nil
}
func matchesCustomAudience(cfg configuration.Configuration, tnt *capsulev1beta2.Tenant, req admission.Request, custom rules.CustomAudience) (bool, error) {
switch custom {
case rules.CustomAudienceCapsuleUser:
return cfg.Users().IsPresent(req.UserInfo.Username, req.UserInfo.Groups), nil
case rules.CustomAudienceAdministrator:
return cfg.Administrators().IsPresent(req.UserInfo.Username, req.UserInfo.Groups), nil
case rules.CustomAudienceTenantOwner:
if tnt == nil {
return false, nil
}
return tnt.Spec.Owners.IsOwner(req.UserInfo.Username, req.UserInfo.Groups) ||
tnt.Status.Owners.IsOwner(req.UserInfo.Username, req.UserInfo.Groups), nil
case rules.CustomAudienceController:
return users.IsControllerServiceAccount(req.UserInfo.Username), nil
default:
return false, fmt.Errorf("unsupported custom audience %q", custom)
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ruleengine
import (
"testing"
admissionv1 "k8s.io/api/admission/v1"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/rbac"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
)
func TestMatchesAudience(t *testing.T) {
t.Parallel()
cfg := audienceConfiguration(t)
req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{UserInfo: authenticationv1.UserInfo{Username: "alice", Groups: []string{"developers"}}}}
tests := []struct {
name string
tnt *capsulev1beta2.Tenant
audience []rules.Audience
want bool
}{
{name: "user", audience: []rules.Audience{{Kind: rules.AudienceKindUser, Name: "alice"}}, want: true},
{name: "group", audience: []rules.Audience{{Kind: rules.AudienceKindGroup, Name: "developers"}}, want: true},
{name: "no match", audience: []rules.Audience{{Kind: rules.AudienceKindUser, Name: "bob"}}},
{name: "tenant owner", tnt: &capsulev1beta2.Tenant{Spec: capsulev1beta2.TenantSpec{Owners: rbac.OwnerListSpec{{CoreOwnerSpec: rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Kind: rbac.UserOwner, Name: "alice"}}}}}}, audience: []rules.Audience{{Kind: rules.AudienceKindCustom, Name: string(rules.CustomAudienceTenantOwner)}}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matched, err := matchesAudience(cfg, tt.tnt, req, tt.audience)
if err != nil {
t.Fatalf("matchesAudience() error = %v", err)
}
if matched != tt.want {
t.Fatalf("matchesAudience() = %v, want %v", matched, tt.want)
}
})
}
}
func TestFilterNamespaceRulesUsesRootAudience(t *testing.T) {
t.Parallel()
cfg := audienceConfiguration(t)
req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
UserInfo: authenticationv1.UserInfo{Username: "alice", Groups: []string{"developers"}},
}}
matching := &rules.NamespaceRuleBodyNamespace{
Audience: []rules.Audience{{Kind: rules.AudienceKindGroup, Name: "developers"}},
Enforce: &rules.NamespaceRuleEnforceBody{},
}
nonMatching := &rules.NamespaceRuleBodyNamespace{
Audience: []rules.Audience{{Kind: rules.AudienceKindUser, Name: "bob"}},
Enforce: &rules.NamespaceRuleEnforceBody{},
}
unscoped := &rules.NamespaceRuleBodyNamespace{Enforce: &rules.NamespaceRuleEnforceBody{}}
got, err := FilterNamespaceRulesByAudience(cfg, nil, req, []*rules.NamespaceRuleBodyNamespace{matching, nonMatching, unscoped})
if err != nil {
t.Fatalf("FilterNamespaceRulesByAudience() error = %v", err)
}
if len(got) != 2 || got[0] != matching || got[1] != unscoped {
t.Fatalf("unexpected filtered rules: %#v", got)
}
}
func audienceConfiguration(t *testing.T) configuration.Configuration {
t.Helper()
scheme := runtime.NewScheme()
if err := capsulev1beta2.AddToScheme(scheme); err != nil {
t.Fatalf("adding capsule scheme: %v", err)
}
config := &capsulev1beta2.CapsuleConfiguration{
ObjectMeta: metav1.ObjectMeta{Name: "capsule"},
Spec: configuration.DefaultCapsuleConfiguration(),
}
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(config).Build()
return configuration.NewCapsuleConfiguration(t.Context(), cl, cl, &rest.Config{}, config.Name)
}
+21 -5
View File
@@ -94,9 +94,13 @@ type Set[R any, O any] struct {
Name string
EventReason string
Values func(O) []Value
Rules func(*api.NamespaceRuleEnforceBody) []R
Matches func(R, Value) (Match, error)
Values func(O) []Value
// Rules extracts rules when extraction cannot fail.
Rules func(*api.NamespaceRuleEnforceBody) []R
// RulesWithError extracts rules that require runtime parsing or matching.
// When set, it takes precedence over Rules.
RulesWithError func(*api.NamespaceRuleEnforceBody) ([]R, error)
Matches func(R, Value) (Match, error)
// Message can fully override the default message.
// Prefer leaving this nil unless a rule requires very specific wording.
@@ -124,7 +128,7 @@ func EvaluateEnforce[R any, T any](
return nil, fmt.Errorf("%s: values extractor is nil", set.Name)
}
if set.Rules == nil {
if set.Rules == nil && set.RulesWithError == nil {
return nil, fmt.Errorf("%s: rules extractor is nil", set.Name)
}
@@ -154,7 +158,19 @@ func EvaluateEnforce[R any, T any](
continue
}
items := set.Rules(enforce)
var items []R
if set.RulesWithError != nil {
var err error
items, err = set.RulesWithError(enforce)
if err != nil {
return evaluation, fmt.Errorf("%s: invalid rules: %w", set.Name, err)
}
} else {
items = set.Rules(enforce)
}
if len(items) == 0 {
continue
}
+28
View File
@@ -108,6 +108,34 @@ func TestEvaluateEnforce_ValidationErrors(t *testing.T) {
}
}
func TestEvaluateEnforce_RulesWithError(t *testing.T) {
t.Parallel()
wantErr := errors.New("invalid selector")
evaluation, err := EvaluateEnforce(
testObject{Values: []Value{{Value: "prod", Path: "metadata.labels[env]"}}},
[]*api.NamespaceRuleEnforceBody{{Action: api.ActionTypeAllow}},
Set[testRule, testObject]{
Name: "metadata labels",
Values: func(obj testObject) []Value {
return obj.Values
},
RulesWithError: func(*api.NamespaceRuleEnforceBody) ([]testRule, error) {
return nil, wantErr
},
Matches: func(testRule, Value) (Match, error) {
return Match{}, nil
},
},
)
if !errors.Is(err, wantErr) {
t.Fatalf("EvaluateEnforce() error = %v, want %v", err, wantErr)
}
if evaluation == nil {
t.Fatal("EvaluateEnforce() evaluation = nil, want partial evaluation")
}
}
func TestEvaluateEnforce_EmptyInputs(t *testing.T) {
t.Parallel()
+141 -4
View File
@@ -10,6 +10,7 @@ import (
"strings"
k8smeta "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
"github.com/projectcapsule/capsule/pkg/api/rules"
@@ -21,7 +22,15 @@ func ValidateRuleStatusBody(
bodies []*rules.NamespaceRuleBodyNamespace,
) error {
for i, rule := range bodies {
if rule == nil || rule.Enforce == nil {
if rule == nil {
continue
}
if err := validateAudience(i, rule.Audience); err != nil {
return err
}
if rule.Enforce == nil {
continue
}
@@ -33,6 +42,10 @@ func ValidateRuleStatusBody(
return err
}
if err := validateIngressRules(i, rule.Enforce.Ingress); err != nil {
return err
}
if err := validateMetadataRules(i, rule.Enforce.Metadata, mapper); err != nil {
return err
}
@@ -41,6 +54,77 @@ func ValidateRuleStatusBody(
return nil
}
func validateIngressRules(
ruleIndex int,
ingress rules.NamespaceRuleEnforceIngressBody,
) error {
if len(ingress.Types) == 0 && len(ingress.Hostnames) > 0 {
return fmt.Errorf(
"rules[%d].enforce.ingress.types is invalid: types must be configured when hostnames are configured",
ruleIndex,
)
}
if len(ingress.Types) > 0 && len(ingress.Hostnames) == 0 {
return fmt.Errorf(
"rules[%d].enforce.ingress.hostnames is invalid: hostnames must be configured when types are configured",
ruleIndex,
)
}
for i, resourceType := range ingress.Types {
switch resourceType {
case rules.IngressTypeIngress, rules.IngressTypeRoute,
rules.IngressTypeListenerSet,
rules.IngressTypeHTTPRoute,
rules.IngressTypeGateway,
rules.IngressTypeTLSRoute,
rules.IngressTypeGRPCRoute:
default:
return fmt.Errorf(
"rules[%d].enforce.ingress.types[%d] %q is invalid: unsupported ingress resource type",
ruleIndex,
i,
resourceType,
)
}
}
for i, hostname := range ingress.Hostnames {
if err := validateExpressionMatch(
hostname,
fmt.Sprintf("rules[%d].enforce.ingress.hostnames[%d]", ruleIndex, i),
); err != nil {
return err
}
}
return nil
}
func validateAudience(ruleIndex int, audience []rules.Audience) error {
for i, subject := range audience {
path := fmt.Sprintf("rules[%d].audience[%d]", ruleIndex, i)
if strings.TrimSpace(subject.Name) == "" {
return fmt.Errorf("%s.name is invalid: name is empty", path)
}
switch subject.Kind {
case rules.AudienceKindUser, rules.AudienceKindGroup, rules.AudienceKindServiceAccount:
case rules.AudienceKindCustom:
switch rules.CustomAudience(subject.Name) {
case rules.CustomAudienceCapsuleUser, rules.CustomAudienceAdministrator, rules.CustomAudienceTenantOwner, rules.CustomAudienceController:
default:
return fmt.Errorf("%s.name %q is invalid: unsupported custom audience", path, subject.Name)
}
default:
return fmt.Errorf("%s.kind %q is invalid: unsupported audience kind", path, subject.Kind)
}
}
return nil
}
func validateWorkloadRules(
ruleIndex int,
workloads rules.NamespaceRuleEnforceWorkloadsBody,
@@ -131,6 +215,10 @@ func validateMetadataRules(
for j, rule := range metadata {
fieldPath := fmt.Sprintf("rules[%d].enforce.metadata[%d]", ruleIndex, j)
if rule.HasWildcard() && metadataRuleHasManagedValues(rule) {
return fmt.Errorf("%s is invalid: managed metadata requires concrete apiGroups and kinds", fieldPath)
}
if err := validateMetadataTargets(fieldPath, rule, mapper); err != nil {
return err
}
@@ -145,6 +233,10 @@ func validateMetadataRules(
)
}
if err := validateMutableMetadataKey(key, policy); err != nil {
return fmt.Errorf("%s.labels[%q] is invalid: %w", fieldPath, key, err)
}
for k, matcher := range policy.Values {
if err := validateExpressionMatch(
matcher,
@@ -165,6 +257,10 @@ func validateMetadataRules(
)
}
if err := validateMutableMetadataKey(key, policy); err != nil {
return fmt.Errorf("%s.annotations[%q] is invalid: %w", fieldPath, key, err)
}
for k, matcher := range policy.Values {
if err := validateExpressionMatch(
matcher,
@@ -179,14 +275,49 @@ func validateMetadataRules(
return nil
}
func metadataRuleHasManagedValues(rule rules.MetadataRule) bool {
for _, policy := range rule.Labels {
if policy.Managed != nil {
return true
}
}
for _, policy := range rule.Annotations {
if policy.Managed != nil {
return true
}
}
return false
}
func validateMutableMetadataKey(key string, policy rules.MetadataValueRule) error {
if policy.Default == nil && policy.Managed == nil {
return nil
}
if errs := k8svalidation.IsQualifiedName(strings.TrimSpace(key)); len(errs) > 0 {
return errors.New("default and managed require a concrete metadata key")
}
return nil
}
func validateMetadataKey(key string) error {
key = strings.TrimSpace(key)
if key == "" {
return errors.New("key is empty")
}
if errs := k8svalidation.IsQualifiedName(key); len(errs) > 0 {
return errors.New(strings.Join(errs, ", "))
if !strings.ContainsAny(key, "*[](){}+?|^$\\") {
if errs := k8svalidation.IsQualifiedName(key); len(errs) > 0 {
return errors.New(strings.Join(errs, ", "))
}
}
expression := rules.MetadataKeyExpression(key)
if _, err := regexp.Compile(expression.Expression); err != nil {
return fmt.Errorf("invalid key expression %q: %w", key, err)
}
return nil
@@ -282,7 +413,13 @@ func validateMetadataTargets(
return nil
}
if err := rule.ValidateKnownKinds(mapper, fieldPath); err != nil {
if err := rule.ValidateKnownKindsWithScope(mapper, fieldPath, func(
gvk schema.GroupVersionKind,
scope k8smeta.RESTScope,
) bool {
return scope.Name() == k8smeta.RESTScopeNameNamespace ||
(gvk.Group == "" && gvk.Version == "v1" && gvk.Kind == "Namespace")
}); err != nil {
return err
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package ruleengine
import (
"strings"
"testing"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
func TestValidateIngressRules(t *testing.T) {
t.Parallel()
valid := []*rules.NamespaceRuleBodyNamespace{{
Enforce: &rules.NamespaceRuleEnforceBody{
Ingress: rules.NamespaceRuleEnforceIngressBody{
Types: []rules.IngressType{
rules.IngressTypeIngress,
rules.IngressTypeListenerSet,
rules.IngressTypeHTTPRoute,
rules.IngressTypeGateway,
rules.IngressTypeTLSRoute,
rules.IngressTypeGRPCRoute,
},
Hostnames: []runtime.ExpressionMatch{{
Exact: []string{"prod", "test"},
ExpressionRegex: runtime.ExpressionRegex{
Expression: ".*\\.example\\.com",
},
}},
},
},
}}
if err := ValidateRuleStatusBody(nil, valid); err != nil {
t.Fatalf("ValidateRuleStatusBody(valid) error = %v", err)
}
invalidType := valid[0].DeepCopy()
invalidType.Enforce.Ingress.Types = []rules.IngressType{"TCPRoute"}
if err := ValidateRuleStatusBody(nil, []*rules.NamespaceRuleBodyNamespace{invalidType}); err == nil || !strings.Contains(err.Error(), "unsupported ingress resource type") {
t.Fatalf("ValidateRuleStatusBody(invalid type) error = %v", err)
}
invalidRegex := valid[0].DeepCopy()
invalidRegex.Enforce.Ingress.Hostnames = []runtime.ExpressionMatch{{
ExpressionRegex: runtime.ExpressionRegex{Expression: "("},
}}
if err := ValidateRuleStatusBody(nil, []*rules.NamespaceRuleBodyNamespace{invalidRegex}); err == nil || !strings.Contains(err.Error(), "ingress.hostnames[0].exp") {
t.Fatalf("ValidateRuleStatusBody(invalid regex) error = %v", err)
}
missingTypes := valid[0].DeepCopy()
missingTypes.Enforce.Ingress.Types = nil
if err := ValidateRuleStatusBody(nil, []*rules.NamespaceRuleBodyNamespace{missingTypes}); err == nil || !strings.Contains(err.Error(), "ingress.types") {
t.Fatalf("ValidateRuleStatusBody(missing types) error = %v", err)
}
missingHostnames := valid[0].DeepCopy()
missingHostnames.Enforce.Ingress.Hostnames = nil
if err := ValidateRuleStatusBody(nil, []*rules.NamespaceRuleBodyNamespace{missingHostnames}); err == nil || !strings.Contains(err.Error(), "ingress.hostnames") {
t.Fatalf("ValidateRuleStatusBody(missing hostnames) error = %v", err)
}
}
+58
View File
@@ -9,6 +9,7 @@ import (
apimeta "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/utils/ptr"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
@@ -745,6 +746,42 @@ func TestValidateRuleStatusBody(t *testing.T) {
}
}
func TestValidateMetadataKeyPatterns(t *testing.T) {
t.Parallel()
if err := validateMetadataKey("example.corp/*"); err != nil {
t.Fatalf("expected wildcard key selector to be valid: %v", err)
}
if err := validateMetadataKey(`example\.corp/.*`); err != nil {
t.Fatalf("expected regexp key selector to be valid: %v", err)
}
if err := validateMetadataKey("example.corp/["); err == nil {
t.Fatal("expected invalid regexp key selector to be rejected")
}
}
func TestMutableMetadataRequiresConcreteKey(t *testing.T) {
t.Parallel()
policy := rules.MetadataValueRule{Managed: ptr.To("controlled")}
if err := validateMutableMetadataKey("example.corp/key", policy); err != nil {
t.Fatalf("concrete key rejected: %v", err)
}
if err := validateMutableMetadataKey("example.corp/*", policy); err == nil {
t.Fatal("wildcard managed key was accepted")
}
if err := validateMutableMetadataKey(`example\.corp/.*`, policy); err == nil {
t.Fatal("regexp managed key was accepted")
}
rule := rules.MetadataRule{
VersionKinds: runtime.VersionKinds{APIGroups: []string{"*"}, Kinds: []string{"ConfigMap"}},
Labels: map[string]rules.MetadataValueRule{"example.corp/key": policy},
}
if err := validateMetadataRules(0, []rules.MetadataRule{rule}, nil); err == nil || !strings.Contains(err.Error(), "managed metadata requires concrete apiGroups and kinds") {
t.Fatalf("wildcard managed target error = %v", err)
}
}
func TestValidateRuleStatusBodyWithRESTMapper(t *testing.T) {
t.Parallel()
@@ -819,6 +856,18 @@ func TestValidateRuleStatusBodyWithRESTMapper(t *testing.T) {
},
},
},
{
name: "core namespace is a valid metadata target",
mapper: mapper,
bodies: []*rules.NamespaceRuleBodyNamespace{{
Enforce: &rules.NamespaceRuleEnforceBody{Metadata: []rules.MetadataRule{{
VersionKinds: runtime.VersionKinds{APIGroups: []string{""}, Kinds: []string{"Namespace"}},
Annotations: map[string]rules.MetadataValueRule{
"example.corp/*": {Values: []runtime.ExpressionMatch{{Exact: []string{"allowed"}}}},
},
}}},
}},
},
{
name: "known grouped apiVersion kind is valid",
mapper: mapper,
@@ -1146,6 +1195,15 @@ func newRuleValidationRESTMapper() apimeta.RESTMapper {
},
})
mapper.Add(
schema.GroupVersionKind{
Group: "",
Version: "v1",
Kind: "Namespace",
},
apimeta.RESTScopeRoot,
)
mapper.Add(
schema.GroupVersionKind{
Group: "",
+1
View File
@@ -55,6 +55,7 @@ const (
ReasonIngressHostnameNotValid string = "IngressHostnameNotValid"
ReasonIngressHostnameEmpty string = "IngressHostnameEmpty"
ReasonIngressHostnameCollision string = "IngressHostnameCollision"
ReasonForbiddenIngressHostname string = "ForbiddenIngressHostname"
// Services.
ReasonForbiddenExternalServiceIP string = "ForbiddenExternalServiceIP"
+24 -36
View File
@@ -1,7 +1,6 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package handlers
import (
@@ -16,6 +15,8 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/ruleengine"
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
"github.com/projectcapsule/capsule/pkg/runtime/events"
"github.com/projectcapsule/capsule/pkg/tenant"
)
@@ -54,8 +55,9 @@ type TypedHandlerWithTenantWithRuleset[T client.Object] interface {
}
type TypedTenantWithRulesetHandler[T client.Object] struct {
Factory NewObjectFunc[T]
Handlers []TypedHandlerWithTenantWithRuleset[T]
Factory NewObjectFunc[T]
Handlers []TypedHandlerWithTenantWithRuleset[T]
Configuration configuration.Configuration
}
func (h *TypedTenantWithRulesetHandler[T]) OnCreate(
@@ -79,7 +81,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnCreate(
return ErroredResponse(err)
}
ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
ruleBlocks, err := h.resolveRuleset(ctx, c, reader, req, req.Namespace, tnt)
if err != nil {
return ErroredResponse(err)
}
ruleBlocks, err = ruleengine.FilterNamespaceRulesByAudience(h.Configuration, tnt, req, ruleBlocks)
if err != nil {
return ErroredResponse(err)
}
@@ -120,7 +127,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnUpdate(
return ErroredResponse(err)
}
ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
ruleBlocks, err := h.resolveRuleset(ctx, c, reader, req, req.Namespace, tnt)
if err != nil {
return ErroredResponse(err)
}
ruleBlocks, err = ruleengine.FilterNamespaceRulesByAudience(h.Configuration, tnt, req, ruleBlocks)
if err != nil {
return ErroredResponse(err)
}
@@ -136,37 +148,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnUpdate(
}
func (h *TypedTenantWithRulesetHandler[T]) OnDelete(
c client.Client,
reader client.Reader,
decoder admission.Decoder,
recorder events.EventRecorder,
client.Client,
client.Reader,
admission.Decoder,
events.EventRecorder,
) Func {
return func(ctx context.Context, req admission.Request) *admission.Response {
tnt, err := h.resolveTenant(ctx, reader, req)
if err != nil {
return ErroredResponse(err)
}
if tnt == nil {
return nil
}
obj := h.Factory()
if err := decoder.Decode(req, obj); err != nil {
return ErroredResponse(err)
}
ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
if err != nil {
return ErroredResponse(err)
}
for _, hndl := range h.Handlers {
if response := hndl.OnDelete(c, reader, obj, decoder, recorder, tnt, ruleBlocks)(ctx, req); response != nil {
return response
}
}
return func(context.Context, admission.Request) *admission.Response {
return nil
}
}
@@ -188,6 +175,7 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveTenant(
func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset(
ctx context.Context,
c client.Client,
reader client.Reader,
req admission.Request,
namespace string,
tnt *capsulev1beta2.Tenant,
@@ -198,7 +186,7 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset(
Name: meta.NameForManagedRuleStatus(),
}
if err := c.Get(ctx, key, rs); err == nil {
if err := reader.Get(ctx, key, rs); err == nil {
return rs.Status.Rules, nil
} else if !apierrors.IsNotFound(err) {
return nil, err
@@ -0,0 +1,31 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package handlers_test
import (
"context"
"testing"
admissionv1 "k8s.io/api/admission/v1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
)
func TestTypedTenantWithRulesetHandlerSkipsDelete(t *testing.T) {
t.Parallel()
handler := &handlers.TypedTenantWithRulesetHandler[*corev1.ConfigMap]{
Factory: func() *corev1.ConfigMap { return &corev1.ConfigMap{} },
}
request := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
Operation: admissionv1.Delete,
Namespace: "solar-system",
}}
if response := handler.OnDelete(nil, nil, nil, nil)(context.Background(), request); response != nil {
t.Fatalf("OnDelete() response = %#v, want nil", response)
}
}