mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-18 12:06:43 +00:00
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:
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: "",
|
||||
|
||||
@@ -55,6 +55,7 @@ const (
|
||||
ReasonIngressHostnameNotValid string = "IngressHostnameNotValid"
|
||||
ReasonIngressHostnameEmpty string = "IngressHostnameEmpty"
|
||||
ReasonIngressHostnameCollision string = "IngressHostnameCollision"
|
||||
ReasonForbiddenIngressHostname string = "ForbiddenIngressHostname"
|
||||
|
||||
// Services.
|
||||
ReasonForbiddenExternalServiceIP string = "ForbiddenExternalServiceIP"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user