feat: add metadata enforcement (#1990)

* fix(controller): decode old object for delete requests

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: modernize golang

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* fix: preserve ca-bundles injected from external providers

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add metadata enforcement

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* feat: add metadata enforcement

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* fix: add resourcepoolclaim validation

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* fix: add resourcepoolclaim validation

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* fix: add resourcepoolclaim validation

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: add resourcepoolclaim validation

Signed-off-by: Oliver Baehler <oliver@sudo-i.net>

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver Bähler
2026-07-02 13:42:08 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 6fbd472f27
commit 77d1810bb9
89 changed files with 12731 additions and 556 deletions
+1
View File
@@ -33,6 +33,7 @@ const (
CreatedByCapsuleLabel = "projectcapsule.dev/created-by"
CustomResourcesLabel = "projectcapsule.dev/custom-resources"
ResourceOriginLabel = "projectcapsule.dev/resource-origin"
NewManagedByCapsuleLabel = "projectcapsule.dev/managed-by"
ManagedByCapsuleLabel = "capsule.clastix.io/managed-by"
+213
View File
@@ -0,0 +1,213 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package meta
import (
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type ManagedMetadata struct {
labels map[string]struct{}
annotations map[string]struct{}
annotationPrefixes []string
}
func NewManagedMetadata(
labels []string,
annotations []string,
) ManagedMetadata {
m := ManagedMetadata{
labels: stringSet(
ResourcesLabel,
TenantNameLabel,
TenantLabel,
NewTenantLabel,
ResourcePoolLabel,
FreezeLabel,
OwnerPromotionLabel,
ServiceAccountPromotionLabel,
CordonedLabel,
CapsuleNameLabel,
CreatedByCapsuleLabel,
CustomResourcesLabel,
NewManagedByCapsuleLabel,
ManagedByCapsuleLabel,
LimitRangeLabel,
NetworkPolicyLabel,
ResourceQuotaLabel,
RolebindingLabel,
),
annotations: stringSet(
ReleaseAnnotation,
ReconcileAnnotation,
AvailableIngressClassesAnnotation,
AvailableIngressClassesRegexpAnnotation,
AvailableStorageClassesAnnotation,
AvailableStorageClassesRegexpAnnotation,
AllowedRegistriesAnnotation,
AllowedRegistriesRegexpAnnotation,
ForbiddenNamespaceLabelsAnnotation,
ForbiddenNamespaceLabelsRegexpAnnotation,
ForbiddenNamespaceAnnotationsAnnotation,
ForbiddenNamespaceAnnotationsRegexpAnnotation,
ProtectedTenantAnnotation,
),
annotationPrefixes: compactStrings(
ResourceQuotaAnnotationPrefix,
ResourceUsedAnnotationPrefix,
),
}
m.addLabels(labels...)
m.addAnnotations(annotations...)
return m
}
func (m ManagedMetadata) HasLabel(key string) bool {
_, ok := m.labels[key]
return ok
}
func (m ManagedMetadata) HasAnnotation(key string) bool {
if _, ok := m.annotations[key]; ok {
return true
}
for _, prefix := range m.annotationPrefixes {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}
func (m ManagedMetadata) addLabels(values ...string) {
addStrings(m.labels, values...)
}
func (m ManagedMetadata) addAnnotations(values ...string) {
addStrings(m.annotations, values...)
}
func addStrings(set map[string]struct{}, values ...string) {
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
set[value] = struct{}{}
}
}
func stringSet(values ...string) map[string]struct{} {
if len(values) == 0 {
return nil
}
out := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
out[value] = struct{}{}
}
return out
}
func compactStrings(values ...string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
out = append(out, value)
}
return out
}
type ObjectSkipRule struct {
// Labels with values which indicate a skip condition.
Labels map[string]string
// Annotations with values which indicate a skip condition.
Annotations map[string]string
}
func (s *ObjectSkipRule) ShouldSkip(
labels map[string]string,
annotations map[string]string,
) bool {
if s == nil {
return false
}
for key, expected := range s.Labels {
value, ok := labels[key]
if !ok || value != expected {
return false
}
}
for key, expected := range s.Annotations {
value, ok := annotations[key]
if !ok || value != expected {
return false
}
}
return len(s.Labels) > 0 || len(s.Annotations) > 0
}
func DefaultObjectSkipRules() []ObjectSkipRule {
return []ObjectSkipRule{
{
Labels: map[string]string{
NewManagedByCapsuleLabel: ValueController,
},
},
{
Labels: map[string]string{
NewManagedByCapsuleLabel: ValueControllerResources,
},
},
}
}
func ShouldSkipObjectByRules(
obj *metav1.PartialObjectMetadata,
rules []ObjectSkipRule,
) bool {
if obj == nil || len(rules) == 0 {
return false
}
labels := obj.GetLabels()
annotations := obj.GetAnnotations()
for _, rule := range rules {
if rule.ShouldSkip(labels, annotations) {
return true
}
}
return false
}
+850
View File
@@ -0,0 +1,850 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package meta
import (
"reflect"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestNewManagedMetadata(t *testing.T) {
t.Parallel()
t.Run("contains default managed labels", func(t *testing.T) {
t.Parallel()
m := NewManagedMetadata(nil, nil)
for _, key := range []string{
ResourcesLabel,
TenantNameLabel,
TenantLabel,
NewTenantLabel,
ResourcePoolLabel,
FreezeLabel,
OwnerPromotionLabel,
ServiceAccountPromotionLabel,
CordonedLabel,
CapsuleNameLabel,
CreatedByCapsuleLabel,
CustomResourcesLabel,
NewManagedByCapsuleLabel,
ManagedByCapsuleLabel,
LimitRangeLabel,
NetworkPolicyLabel,
ResourceQuotaLabel,
RolebindingLabel,
} {
if !m.HasLabel(key) {
t.Fatalf("expected managed label %q", key)
}
}
})
t.Run("contains default managed annotations", func(t *testing.T) {
t.Parallel()
m := NewManagedMetadata(nil, nil)
for _, key := range []string{
ReleaseAnnotation,
ReconcileAnnotation,
AvailableIngressClassesAnnotation,
AvailableIngressClassesRegexpAnnotation,
AvailableStorageClassesAnnotation,
AvailableStorageClassesRegexpAnnotation,
AllowedRegistriesAnnotation,
AllowedRegistriesRegexpAnnotation,
ForbiddenNamespaceLabelsAnnotation,
ForbiddenNamespaceLabelsRegexpAnnotation,
ForbiddenNamespaceAnnotationsAnnotation,
ForbiddenNamespaceAnnotationsRegexpAnnotation,
ProtectedTenantAnnotation,
} {
if !m.HasAnnotation(key) {
t.Fatalf("expected managed annotation %q", key)
}
}
})
t.Run("contains default managed annotation prefixes", func(t *testing.T) {
t.Parallel()
m := NewManagedMetadata(nil, nil)
for _, key := range []string{
ResourceQuotaAnnotationPrefix + "cpu",
ResourceQuotaAnnotationPrefix + "memory",
ResourceUsedAnnotationPrefix + "cpu",
ResourceUsedAnnotationPrefix + "memory",
} {
if !m.HasAnnotation(key) {
t.Fatalf("expected managed annotation prefix match for %q", key)
}
}
})
t.Run("adds custom managed labels and annotations", func(t *testing.T) {
t.Parallel()
m := NewManagedMetadata(
[]string{
"example.corp/label",
" example.corp/trimmed-label ",
"",
" ",
},
[]string{
"example.corp/annotation",
" example.corp/trimmed-annotation ",
"",
" ",
},
)
for _, key := range []string{
"example.corp/label",
"example.corp/trimmed-label",
} {
if !m.HasLabel(key) {
t.Fatalf("expected custom managed label %q", key)
}
}
for _, key := range []string{
"example.corp/annotation",
"example.corp/trimmed-annotation",
} {
if !m.HasAnnotation(key) {
t.Fatalf("expected custom managed annotation %q", key)
}
}
if m.HasLabel("") {
t.Fatalf("empty label must not be managed")
}
if m.HasAnnotation("") {
t.Fatalf("empty annotation must not be managed")
}
})
t.Run("matching is exact and case-sensitive", func(t *testing.T) {
t.Parallel()
m := NewManagedMetadata(
[]string{
"example.corp/managed",
},
[]string{
"example.corp/managed",
},
)
if !m.HasLabel("example.corp/managed") {
t.Fatalf("expected exact label match")
}
if m.HasLabel("Example.Corp/managed") {
t.Fatalf("expected label lookup to be case-sensitive")
}
if m.HasLabel(" example.corp/managed ") {
t.Fatalf("expected label lookup not to trim lookup key")
}
if !m.HasAnnotation("example.corp/managed") {
t.Fatalf("expected exact annotation match")
}
if m.HasAnnotation("Example.Corp/managed") {
t.Fatalf("expected annotation lookup to be case-sensitive")
}
if m.HasAnnotation(" example.corp/managed ") {
t.Fatalf("expected annotation lookup not to trim lookup key")
}
})
t.Run("unknown metadata is not managed", func(t *testing.T) {
t.Parallel()
m := NewManagedMetadata(nil, nil)
if m.HasLabel("example.corp/not-managed") {
t.Fatalf("unexpected managed label")
}
if m.HasAnnotation("example.corp/not-managed") {
t.Fatalf("unexpected managed annotation")
}
})
}
func TestStringSet(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in []string
want map[string]struct{}
}{
{
name: "empty input returns nil",
in: nil,
want: nil,
},
{
name: "trims values skips blanks and deduplicates",
in: []string{
"alpha",
" alpha ",
"",
" ",
"beta",
},
want: map[string]struct{}{
"alpha": {},
"beta": {},
},
},
{
name: "case sensitive",
in: []string{
"alpha",
"Alpha",
},
want: map[string]struct{}{
"alpha": {},
"Alpha": {},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := stringSet(tt.in...)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("expected %#v, got %#v", tt.want, got)
}
})
}
}
func TestCompactStrings(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in []string
want []string
}{
{
name: "empty input returns nil",
in: nil,
want: nil,
},
{
name: "trims values and skips blanks",
in: []string{
"alpha",
" alpha ",
"",
" ",
"beta",
},
want: []string{
"alpha",
"alpha",
"beta",
},
},
{
name: "does not deduplicate prefixes",
in: []string{
"alpha/",
"alpha/",
},
want: []string{
"alpha/",
"alpha/",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := compactStrings(tt.in...)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("expected %#v, got %#v", tt.want, got)
}
})
}
}
func TestAddStrings(t *testing.T) {
t.Parallel()
set := map[string]struct{}{
"existing": {},
}
addStrings(
set,
"alpha",
" alpha ",
"",
" ",
"beta",
)
want := map[string]struct{}{
"existing": {},
"alpha": {},
"beta": {},
}
if !reflect.DeepEqual(set, want) {
t.Fatalf("expected %#v, got %#v", want, set)
}
}
func TestManagedMetadataAddMethods(t *testing.T) {
t.Parallel()
m := ManagedMetadata{
labels: map[string]struct{}{},
annotations: map[string]struct{}{},
}
m.addLabels("example.corp/label", " example.corp/trimmed-label ", "")
m.addAnnotations("example.corp/annotation", " example.corp/trimmed-annotation ", "")
if !m.HasLabel("example.corp/label") {
t.Fatalf("expected added label")
}
if !m.HasLabel("example.corp/trimmed-label") {
t.Fatalf("expected trimmed added label")
}
if !m.HasAnnotation("example.corp/annotation") {
t.Fatalf("expected added annotation")
}
if !m.HasAnnotation("example.corp/trimmed-annotation") {
t.Fatalf("expected trimmed added annotation")
}
if m.HasLabel("") {
t.Fatalf("empty label must not be added")
}
if m.HasAnnotation("") {
t.Fatalf("empty annotation must not be added")
}
}
func TestManagedMetadataHasAnnotationPrefix(t *testing.T) {
t.Parallel()
m := ManagedMetadata{
annotations: map[string]struct{}{
"example.corp/exact": {},
},
annotationPrefixes: []string{
"example.corp/prefix/",
},
}
tests := []struct {
name string
key string
want bool
}{
{
name: "exact annotation",
key: "example.corp/exact",
want: true,
},
{
name: "prefix annotation",
key: "example.corp/prefix/value",
want: true,
},
{
name: "prefix itself also matches",
key: "example.corp/prefix/",
want: true,
},
{
name: "similar prefix does not match",
key: "example.corp/prefix-other/value",
want: false,
},
{
name: "unknown annotation",
key: "example.corp/unknown",
want: false,
},
{
name: "case sensitive prefix",
key: "Example.Corp/prefix/value",
want: false,
},
{
name: "empty key",
key: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := m.HasAnnotation(tt.key)
if got != tt.want {
t.Fatalf("expected %t, got %t", tt.want, got)
}
})
}
}
func TestObjectSkipRuleShouldSkip(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rule ObjectSkipRule
labels map[string]string
annotations map[string]string
want bool
}{
{
name: "empty rule does not skip",
rule: ObjectSkipRule{},
want: false,
},
{
name: "matching label skips",
rule: ObjectSkipRule{
Labels: map[string]string{
"managed-by": "controller",
},
},
labels: map[string]string{
"managed-by": "controller",
},
want: true,
},
{
name: "missing label does not skip",
rule: ObjectSkipRule{
Labels: map[string]string{
"managed-by": "controller",
},
},
labels: nil,
want: false,
},
{
name: "non matching label value does not skip",
rule: ObjectSkipRule{
Labels: map[string]string{
"managed-by": "controller",
},
},
labels: map[string]string{
"managed-by": "human",
},
want: false,
},
{
name: "label match is case-sensitive",
rule: ObjectSkipRule{
Labels: map[string]string{
"managed-by": "controller",
},
},
labels: map[string]string{
"managed-by": "Controller",
},
want: false,
},
{
name: "matching annotation skips",
rule: ObjectSkipRule{
Annotations: map[string]string{
"example.corp/skip": "true",
},
},
annotations: map[string]string{
"example.corp/skip": "true",
},
want: true,
},
{
name: "missing annotation does not skip",
rule: ObjectSkipRule{
Annotations: map[string]string{
"example.corp/skip": "true",
},
},
annotations: nil,
want: false,
},
{
name: "non matching annotation value does not skip",
rule: ObjectSkipRule{
Annotations: map[string]string{
"example.corp/skip": "true",
},
},
annotations: map[string]string{
"example.corp/skip": "false",
},
want: false,
},
{
name: "all labels must match",
rule: ObjectSkipRule{
Labels: map[string]string{
"managed-by": "controller",
"owner": "capsule",
},
},
labels: map[string]string{
"managed-by": "controller",
"owner": "other",
},
want: false,
},
{
name: "all annotations must match",
rule: ObjectSkipRule{
Annotations: map[string]string{
"example.corp/skip": "true",
"example.corp/owner": "capsule",
},
},
annotations: map[string]string{
"example.corp/skip": "true",
"example.corp/owner": "other",
},
want: false,
},
{
name: "labels and annotations must both match",
rule: ObjectSkipRule{
Labels: map[string]string{
"managed-by": "controller",
},
Annotations: map[string]string{
"example.corp/skip": "true",
},
},
labels: map[string]string{
"managed-by": "controller",
},
annotations: map[string]string{
"example.corp/skip": "true",
},
want: true,
},
{
name: "matching label but missing required annotation does not skip",
rule: ObjectSkipRule{
Labels: map[string]string{
"managed-by": "controller",
},
Annotations: map[string]string{
"example.corp/skip": "true",
},
},
labels: map[string]string{
"managed-by": "controller",
},
annotations: nil,
want: false,
},
{
name: "missing key must not match empty expected value",
rule: ObjectSkipRule{
Labels: map[string]string{
"empty": "",
},
},
labels: nil,
want: false,
},
{
name: "present empty value matches empty expected value",
rule: ObjectSkipRule{
Labels: map[string]string{
"empty": "",
},
},
labels: map[string]string{
"empty": "",
},
want: true,
},
{
name: "extra labels and annotations do not prevent match",
rule: ObjectSkipRule{
Labels: map[string]string{
"managed-by": "controller",
},
},
labels: map[string]string{
"managed-by": "controller",
"extra": "value",
},
annotations: map[string]string{
"extra": "value",
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.rule.ShouldSkip(tt.labels, tt.annotations)
if got != tt.want {
t.Fatalf("expected %t, got %t", tt.want, got)
}
})
}
}
func TestObjectSkipRuleShouldSkipNilReceiver(t *testing.T) {
t.Parallel()
var rule *ObjectSkipRule
if rule.ShouldSkip(
map[string]string{
"managed-by": "controller",
},
nil,
) {
t.Fatalf("nil rule must not skip")
}
}
func TestDefaultObjectSkipRules(t *testing.T) {
t.Parallel()
rules := DefaultObjectSkipRules()
if len(rules) != 2 {
t.Fatalf("expected three default skip rules, got %d", len(rules))
}
if got := rules[0].Labels[NewManagedByCapsuleLabel]; got != ValueController {
t.Fatalf("expected default skip label %q=%q, got %q", NewManagedByCapsuleLabel, ValueController, got)
}
if got := rules[1].Labels[NewManagedByCapsuleLabel]; got != ValueControllerResources {
t.Fatalf("expected legacy default skip label %q=%q, got %q", NewManagedByCapsuleLabel, ValueControllerResources, got)
}
for _, rule := range rules {
if len(rule.Annotations) != 0 {
t.Fatalf("expected no default skip annotations, got %#v", rule.Annotations)
}
}
}
func TestShouldSkipObjectByRules(t *testing.T) {
t.Parallel()
tests := []struct {
name string
obj *metav1.PartialObjectMetadata
rules []ObjectSkipRule
want bool
}{
{
name: "nil object does not skip",
obj: nil,
rules: DefaultObjectSkipRules(),
want: false,
},
{
name: "nil rules do not skip",
obj: objectWithMetadata(
map[string]string{
NewManagedByCapsuleLabel: ValueController,
},
nil,
),
rules: nil,
want: false,
},
{
name: "empty rules do not skip",
obj: objectWithMetadata(
map[string]string{
NewManagedByCapsuleLabel: ValueController,
},
nil,
),
rules: []ObjectSkipRule{},
want: false,
},
{
name: "default Capsule controller-managed object skips",
obj: objectWithMetadata(
map[string]string{
NewManagedByCapsuleLabel: ValueController,
},
nil,
),
rules: DefaultObjectSkipRules(),
want: true,
},
{
name: "legacy Capsule resources object skips",
obj: objectWithMetadata(
map[string]string{
NewManagedByCapsuleLabel: ValueControllerResources,
},
nil,
),
rules: DefaultObjectSkipRules(),
want: true,
},
{
name: "default skip is case-sensitive",
obj: objectWithMetadata(
map[string]string{
NewManagedByCapsuleLabel: "Controller",
},
nil,
),
rules: DefaultObjectSkipRules(),
want: false,
},
{
name: "plain managed-by controller is not skipped by default",
obj: objectWithMetadata(
map[string]string{
"managed-by": "controller",
},
nil,
),
rules: DefaultObjectSkipRules(),
want: false,
},
{
name: "non matching object does not skip",
obj: objectWithMetadata(
map[string]string{
NewManagedByCapsuleLabel: "human",
},
nil,
),
rules: DefaultObjectSkipRules(),
want: false,
},
{
name: "any matching rule skips",
obj: objectWithMetadata(
map[string]string{
"app": "demo",
},
map[string]string{
"example.corp/skip": "true",
},
),
rules: []ObjectSkipRule{
{
Labels: map[string]string{
"app": "other",
},
},
{
Annotations: map[string]string{
"example.corp/skip": "true",
},
},
},
want: true,
},
{
name: "object without labels and annotations does not skip",
obj: objectWithMetadata(nil, nil),
rules: []ObjectSkipRule{
{
Labels: map[string]string{
"managed-by": "controller",
},
},
},
want: false,
},
{
name: "empty skip rule does not skip object",
obj: objectWithMetadata(
map[string]string{
"app": "demo",
},
nil,
),
rules: []ObjectSkipRule{
{},
},
want: false,
},
{
name: "custom plain managed-by rule skips",
obj: objectWithMetadata(
map[string]string{
"managed-by": "controller",
},
nil,
),
rules: []ObjectSkipRule{
{
Labels: map[string]string{
"managed-by": "controller",
},
},
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ShouldSkipObjectByRules(tt.obj, tt.rules)
if got != tt.want {
t.Fatalf("expected %t, got %t", tt.want, got)
}
})
}
}
func objectWithMetadata(
labels map[string]string,
annotations map[string]string,
) *metav1.PartialObjectMetadata {
return &metav1.PartialObjectMetadata{
ObjectMeta: metav1.ObjectMeta{
Labels: labels,
Annotations: annotations,
},
}
}
-2
View File
@@ -14,7 +14,6 @@ var WithoutCapsuleManagedResourcesLabelSelector = func() string {
selection.NotIn,
[]string{
ValueController,
ValueControllerResources,
},
)
@@ -27,7 +26,6 @@ var WithCapsuleManagedResourcesLabelSelector = func() string {
selection.In,
[]string{
ValueController,
ValueControllerResources,
},
)
-36
View File
@@ -1,36 +0,0 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package api
import corev1 "k8s.io/api/core/v1"
// +kubebuilder:validation:Enum=Always;Never;IfNotPresent
type ImagePullPolicySpec string
func (i ImagePullPolicySpec) String() string {
return string(i)
}
// +kubebuilder:validation:Enum=pod/images;pod/volumes
type RegistryValidationTarget string
const (
ValidateImages RegistryValidationTarget = "pod/images"
ValidateVolumes RegistryValidationTarget = "pod/volumes"
)
// +kubebuilder:object:generate=true
type OCIRegistry struct {
// OCI Registry endpoint, is treated as regular expression.
Registry string `json:"url,omitzero"`
// Allowed PullPolicy for the given registry. Supplying no value allows all policies.
// +optional
// +kubebuilder:validation:Items:Enum=Always;Never;IfNotPresent
Policy []corev1.PullPolicy `json:"policy,omitempty"`
// Requesting Resources
//+kubebuilder:default:={pod/images,pod/volumes}
Validation []RegistryValidationTarget `json:"validation,omitempty"`
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rules
import (
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
// MetadataRule defines metadata constraints for namespaced resources.
//
// +kubebuilder:object:generate=true
// +kubebuilder:validation:XValidation:rule="has(self.labels) || has(self.annotations)",message="at least one of labels or annotations must be set"
type MetadataRule struct {
runtime.VersionKinds `json:",inline"`
// Labels defines metadata policies by label key.
//
// +optional
Labels map[string]MetadataValueRule `json:"labels,omitempty"`
// Annotations defines metadata policies by annotation key.
//
// +optional
Annotations map[string]MetadataValueRule `json:"annotations,omitempty"`
}
// +kubebuilder:object:generate=true
type MetadataValueRule struct {
// Required enforces that the metadata key must be present.
//
// This is mainly meaningful with action=allow. Deny and audit rules remain
// value matchers and do not require missing metadata to exist.
//
// +optional
// +kubebuilder:default:=false
Required bool `json:"required,omitempty"`
// Values defines allowed, denied, or audited values for the metadata key.
//
// If Required=true and Values is empty, only presence is enforced.
//
// +optional
Values []runtime.ExpressionMatch `json:"values,omitempty"`
}
+2 -2
View File
@@ -3,7 +3,7 @@
package rules
import "github.com/projectcapsule/capsule/pkg/api"
import "github.com/projectcapsule/capsule/pkg/api/runtime"
// +kubebuilder:object:generate=true
type NamespaceRuleEnforceServicesBody struct {
@@ -55,7 +55,7 @@ type ServiceExternalNameRule struct {
// Hostnames restricts spec.externalName.
// Empty means no additional hostname restriction once ExternalName is allowed by types.
// +optional
Hostnames []api.ExpressionMatch `json:"hostnames,omitempty"`
Hostnames []runtime.ExpressionMatch `json:"hostnames,omitempty"`
}
// +kubebuilder:object:generate=true
+5
View File
@@ -18,4 +18,9 @@ type NamespaceRuleEnforceBody struct {
// Enforcement for Services.
// +optional
Services NamespaceRuleEnforceServicesBody `json:"services,omitempty"`
// Enforcement for object metadata on namespaced resources.
//
// +optional
Metadata []MetadataRule `json:"metadata,omitempty"`
}
@@ -6,7 +6,7 @@ package rules
import (
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
// +kubebuilder:validation:Enum=Always;Never;IfNotPresent
@@ -18,7 +18,7 @@ func (i ImagePullPolicySpec) String() string {
// +kubebuilder:object:generate=true
type OCIRegistry struct {
api.ExpressionMatch `json:",inline"`
runtime.ExpressionMatch `json:",inline"`
// Allowed PullPolicy for the given registry. Supplying no value allows all policies.
// +optional
+2 -2
View File
@@ -6,7 +6,7 @@ package rules
import (
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
// +kubebuilder:validation:Enum=pod/initcontainers;pod/ephemeralcontainers;pod/containers;pod/volumes
@@ -44,5 +44,5 @@ type NamespaceRuleEnforceWorkloadsBody struct {
// Empty schedulerName is ignored and is not normalized to default-scheduler.
//
// +optional
Schedulers []api.ExpressionMatch `json:"schedulers,omitempty"`
Schedulers []runtime.ExpressionMatch `json:"schedulers,omitempty"`
}
+62 -3
View File
@@ -8,11 +8,63 @@
package rules
import (
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/runtime"
"k8s.io/api/core/v1"
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 *MetadataRule) DeepCopyInto(out *MetadataRule) {
*out = *in
in.VersionKinds.DeepCopyInto(&out.VersionKinds)
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make(map[string]MetadataValueRule, len(*in))
for key, val := range *in {
(*out)[key] = *val.DeepCopy()
}
}
if in.Annotations != nil {
in, out := &in.Annotations, &out.Annotations
*out = make(map[string]MetadataValueRule, len(*in))
for key, val := range *in {
(*out)[key] = *val.DeepCopy()
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetadataRule.
func (in *MetadataRule) DeepCopy() *MetadataRule {
if in == nil {
return nil
}
out := new(MetadataRule)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *MetadataValueRule) DeepCopyInto(out *MetadataValueRule) {
*out = *in
if in.Values != nil {
in, out := &in.Values, &out.Values
*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 MetadataValueRule.
func (in *MetadataValueRule) DeepCopy() *MetadataValueRule {
if in == nil {
return nil
}
out := new(MetadataValueRule)
in.DeepCopyInto(out)
return out
}
// 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
@@ -64,6 +116,13 @@ func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody)
*out = *in
in.Workloads.DeepCopyInto(&out.Workloads)
in.Services.DeepCopyInto(&out.Services)
if in.Metadata != nil {
in, out := &in.Metadata, &out.Metadata
*out = make([]MetadataRule, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody.
@@ -133,7 +192,7 @@ func (in *NamespaceRuleEnforceWorkloadsBody) DeepCopyInto(out *NamespaceRuleEnfo
}
if in.Schedulers != nil {
in, out := &in.Schedulers, &out.Schedulers
*out = make([]api.ExpressionMatch, len(*in))
*out = make([]runtime.ExpressionMatch, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
@@ -227,7 +286,7 @@ func (in *ServiceExternalNameRule) DeepCopyInto(out *ServiceExternalNameRule) {
*out = *in
if in.Hostnames != nil {
in, out := &in.Hostnames, &out.Hostnames
*out = make([]api.ExpressionMatch, len(*in))
*out = make([]runtime.ExpressionMatch, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
@@ -1,12 +1,13 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package api
package runtime
import (
"fmt"
"regexp"
"slices"
"strings"
)
// At least one of Exact or Exp must be set.
@@ -79,6 +80,10 @@ func (m ExpressionMatch) MatchesWithExpressionMatcher(
return matched, nil
}
func (m ExpressionMatch) Describe() string {
return DescribeExpressionMatch(m)
}
func (m ExpressionMatch) matches(value string) (bool, error) {
if len(m.Exact) == 0 && m.Expression == "" {
return false, fmt.Errorf("expression match must define at least one of exact or exp")
@@ -111,3 +116,26 @@ func (m ExpressionMatch) applyNegate(matched bool) bool {
return matched
}
func DescribeExpressionMatch(match ExpressionMatch) string {
parts := make([]string, 0, 3)
prefix := ""
if match.Negate {
prefix = "not "
}
if len(match.Exact) > 0 {
parts = append(parts, fmt.Sprintf("%sexact: %s", prefix, strings.Join(match.Exact, ", ")))
}
if match.Expression != "" {
parts = append(parts, fmt.Sprintf("%sexp: %s", prefix, match.Expression))
}
if len(parts) == 0 && match.Negate {
return "not <empty>"
}
return strings.Join(parts, "; ")
}
@@ -1,7 +1,7 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package api
package runtime
import (
"errors"
+440
View File
@@ -0,0 +1,440 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package runtime
import (
"fmt"
"strings"
apimeta "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
WildcardVersionKindMatcher = "*"
CoreAPIVersion = "v1"
)
// +kubebuilder:object:generate=true
type VersionKind struct {
// Kind of the referent.
//
// Use "*" to match all kinds.
//
// +kubebuilder:validation:MinLength=1
Kind string `json:"kind" protobuf:"bytes,1,opt,name=kind"`
// API version, API group, or API group/version selector of the referent.
//
// Empty APIVersion means the core Kubernetes API version "v1".
// Use "*" to explicitly match all API groups and versions.
//
// Examples:
// - "" means core "v1".
// - "v1" means core "v1".
// - "apps" means any version in the "apps" API group.
// - "apps/v1" means the "apps/v1" API group/version.
// - "apps/*" means any version in the "apps" API group.
//
// +optional
APIVersion string `json:"apiVersion,omitempty" protobuf:"bytes,5,opt,name=apiVersion"`
}
func (s VersionKind) GroupVersionKind() schema.GroupVersionKind {
apiVersion := normalizeAPIVersion(s.APIVersion)
if apiVersion == CoreAPIVersion {
return schema.GroupVersionKind{
Group: "",
Version: CoreAPIVersion,
Kind: s.Kind,
}
}
if apiVersion == WildcardVersionKindMatcher {
return schema.GroupVersionKind{
Group: "",
Version: WildcardVersionKindMatcher,
Kind: s.Kind,
}
}
if strings.Contains(apiVersion, "/") {
gv, err := schema.ParseGroupVersion(apiVersion)
if err != nil {
return schema.GroupVersionKind{
Kind: s.Kind,
}
}
return gv.WithKind(s.Kind)
}
return schema.GroupVersionKind{
Group: apiVersion,
Kind: s.Kind,
}
}
// MatchesGroupVersionKind returns true when the receiver matches the provided GVK.
//
// Matching is exact unless the receiver contains '*'.
// Empty APIVersion is treated as "v1".
// Kind must be set. Use "*" to explicitly match all kinds.
func (s VersionKind) MatchesGroupVersionKind(gvk schema.GroupVersionKind) bool {
return matchAPIGroupPattern(normalizeAPIVersion(s.APIVersion), gvk) &&
matchPattern(s.Kind, gvk.Kind)
}
// MatchesVersionKind returns true when the receiver matches another VersionKind.
//
// The receiver is interpreted as the pattern.
// The provided VersionKind is interpreted as the concrete value.
func (s VersionKind) MatchesVersionKind(value VersionKind) bool {
return s.MatchesGroupVersionKind(value.GroupVersionKind())
}
// HasWildcard returns true when APIVersion or Kind contains a wildcard matcher.
func (s VersionKind) HasWildcard() bool {
return strings.Contains(s.APIVersion, WildcardVersionKindMatcher) ||
strings.Contains(s.Kind, WildcardVersionKindMatcher)
}
// +kubebuilder:object:generate=true
type VersionKinds struct {
// API groups or API group/version selectors of the referents.
//
// Empty or omitted APIGroups means the core Kubernetes API version "v1".
// Use "*" to match all API groups and versions.
//
// Examples:
// - [] or [""] means core "v1".
// - ["v1"] means core "v1".
// - ["apps"] means any version in the "apps" API group.
// - ["apps/v1"] means only "apps/v1".
// - ["apps", "batch/v1"] means any "apps" version and "batch/v1".
// - ["*"] means all API groups and versions.
//
// +optional
APIGroups []string `json:"apiGroups,omitempty"`
// Kinds of the referents.
//
// Use "*" to match all kinds.
//
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:items:MinLength=1
Kinds []string `json:"kinds"`
}
func (s VersionKinds) VersionKinds() []VersionKind {
apiGroups := s.NormalizedAPIGroups()
kinds := s.normalizedKinds()
out := make([]VersionKind, 0, len(apiGroups)*len(kinds))
for _, apiGroup := range apiGroups {
for _, kind := range kinds {
out = append(out, VersionKind{
APIVersion: apiGroupPatternToAPIVersionPattern(apiGroup),
Kind: kind,
})
}
}
return out
}
func (s VersionKinds) MatchesGroupVersionKind(gvk schema.GroupVersionKind) bool {
for _, kind := range s.normalizedKinds() {
if !matchPattern(kind, gvk.Kind) {
continue
}
for _, apiGroup := range s.NormalizedAPIGroups() {
if matchAPIGroupPattern(apiGroup, gvk) {
return true
}
}
}
return false
}
func (s VersionKinds) HasWildcard() bool {
for _, apiGroup := range s.APIGroups {
if strings.Contains(apiGroup, WildcardVersionKindMatcher) {
return true
}
}
for _, kind := range s.Kinds {
if strings.Contains(kind, WildcardVersionKindMatcher) {
return true
}
}
return false
}
// ValidateKnownKinds validates concrete apiGroup/kind or apiGroupVersion/kind combinations against the RESTMapper.
// 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 {
if mapper == nil {
return nil
}
kinds := s.normalizedKinds()
apiGroups := s.NormalizedAPIGroups()
for kindIndex, kind := range kinds {
if strings.Contains(kind, WildcardVersionKindMatcher) {
continue
}
for apiGroupIndex, apiGroup := range apiGroups {
if strings.Contains(apiGroup, WildcardVersionKindMatcher) {
continue
}
if err := validateKnownKindForAPIGroup(mapper, apiGroup, kind); err != nil {
return fmt.Errorf(
"%s.kinds[%d] %q for apiGroups[%d] %q is invalid: %w",
fieldPath,
kindIndex,
kind,
apiGroupIndex,
apiGroup,
err,
)
}
}
}
return nil
}
func (s VersionKinds) StatusAPIGroups() []string {
apiGroups := s.NormalizedAPIGroups()
if len(apiGroups) == 0 {
return []string{CoreAPIVersion}
}
out := make([]string, 0, len(apiGroups))
seen := make(map[string]struct{}, len(apiGroups))
for _, apiGroup := range apiGroups {
apiGroup = strings.TrimSpace(apiGroup)
if apiGroup == "" {
apiGroup = CoreAPIVersion
}
if _, ok := seen[apiGroup]; ok {
continue
}
seen[apiGroup] = struct{}{}
out = append(out, apiGroup)
}
if len(out) == 0 {
return []string{CoreAPIVersion}
}
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}
}
out := make([]string, 0, len(s.APIGroups))
for _, apiGroup := range s.APIGroups {
apiGroup = strings.TrimSpace(apiGroup)
if apiGroup == "" {
apiGroup = CoreAPIVersion
}
out = append(out, apiGroup)
}
if len(out) == 0 {
return []string{CoreAPIVersion}
}
return out
}
func (s VersionKinds) normalizedKinds() []string {
if len(s.Kinds) == 0 {
return nil
}
out := make([]string, 0, len(s.Kinds))
for _, kind := range s.Kinds {
kind = strings.TrimSpace(kind)
if kind == "" {
continue
}
out = append(out, kind)
}
return out
}
func apiGroupPatternToAPIVersionPattern(apiGroup string) string {
apiGroup = normalizeAPIVersion(apiGroup)
if apiGroup == CoreAPIVersion {
return ""
}
if apiGroup == WildcardVersionKindMatcher {
return WildcardVersionKindMatcher
}
if strings.Contains(apiGroup, "/") {
return apiGroup
}
return apiGroup + "/" + WildcardVersionKindMatcher
}
func normalizeAPIVersion(apiVersion string) string {
if apiVersion == "" {
return CoreAPIVersion
}
return apiVersion
}
func matchAPIGroupPattern(pattern string, gvk schema.GroupVersionKind) bool {
pattern = normalizeAPIVersion(strings.TrimSpace(pattern))
if pattern == WildcardVersionKindMatcher {
return true
}
target := gvk.Group
if pattern == CoreAPIVersion || strings.Contains(pattern, "/") {
target = gvk.GroupVersion().String()
}
return matchPattern(pattern, target)
}
func matchPattern(pattern, value string) bool {
if pattern == WildcardVersionKindMatcher {
return true
}
if !strings.Contains(pattern, WildcardVersionKindMatcher) {
return pattern == value
}
parts := strings.Split(pattern, WildcardVersionKindMatcher)
if len(parts) == 2 {
if parts[0] == "" {
return strings.HasSuffix(value, parts[1])
}
if parts[1] == "" {
return strings.HasPrefix(value, parts[0])
}
}
idx := 0
if parts[0] != "" {
if !strings.HasPrefix(value, parts[0]) {
return false
}
idx = len(parts[0])
}
lastPartIndex := len(parts) - 1
suffix := parts[lastPartIndex]
limit := len(value)
if suffix != "" {
if !strings.HasSuffix(value, suffix) {
return false
}
limit -= len(suffix)
}
for _, part := range parts[1:lastPartIndex] {
if part == "" {
continue
}
if idx > limit {
return false
}
found := strings.Index(value[idx:limit], part)
if found < 0 {
return false
}
idx += found + len(part)
}
return true
}
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
//go:build !ignore_autogenerated
// Copyright 2020-2023 Project Capsule Authors.
// SPDX-License-Identifier: Apache-2.0
// Code generated by controller-gen. DO NOT EDIT.
package runtime
import ()
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExpressionMatch) DeepCopyInto(out *ExpressionMatch) {
*out = *in
out.ExpressionRegex = in.ExpressionRegex
if in.Exact != nil {
in, out := &in.Exact, &out.Exact
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExpressionMatch.
func (in *ExpressionMatch) DeepCopy() *ExpressionMatch {
if in == nil {
return nil
}
out := new(ExpressionMatch)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VersionKind) DeepCopyInto(out *VersionKind) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersionKind.
func (in *VersionKind) DeepCopy() *VersionKind {
if in == nil {
return nil
}
out := new(VersionKind)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VersionKinds) DeepCopyInto(out *VersionKinds) {
*out = *in
if in.APIGroups != nil {
in, out := &in.APIGroups, &out.APIGroups
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Kinds != nil {
in, out := &in.Kinds, &out.Kinds
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersionKinds.
func (in *VersionKinds) DeepCopy() *VersionKinds {
if in == nil {
return nil
}
out := new(VersionKinds)
in.DeepCopyInto(out)
return out
}
-46
View File
@@ -142,27 +142,6 @@ func (in *DefaultAllowedListSpec) DeepCopy() *DefaultAllowedListSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExpressionMatch) DeepCopyInto(out *ExpressionMatch) {
*out = *in
out.ExpressionRegex = in.ExpressionRegex
if in.Exact != nil {
in, out := &in.Exact, &out.Exact
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExpressionMatch.
func (in *ExpressionMatch) DeepCopy() *ExpressionMatch {
if in == nil {
return nil
}
out := new(ExpressionMatch)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ExternalServiceIPsSpec) DeepCopyInto(out *ExternalServiceIPsSpec) {
*out = *in
@@ -247,31 +226,6 @@ func (in *NetworkPolicySpec) DeepCopy() *NetworkPolicySpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *OCIRegistry) DeepCopyInto(out *OCIRegistry) {
*out = *in
if in.Policy != nil {
in, out := &in.Policy, &out.Policy
*out = make([]corev1.PullPolicy, len(*in))
copy(*out, *in)
}
if in.Validation != nil {
in, out := &in.Validation, &out.Validation
*out = make([]RegistryValidationTarget, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCIRegistry.
func (in *OCIRegistry) DeepCopy() *OCIRegistry {
if in == nil {
return nil
}
out := new(OCIRegistry)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PodOptions) DeepCopyInto(out *PodOptions) {
*out = *in
+228
View File
@@ -903,6 +903,234 @@ func TestEvaluation_Append(t *testing.T) {
})
}
func TestEvaluateEnforce_SkipsEmptyExtractedValues(t *testing.T) {
t.Parallel()
type testCase struct {
name string
action api.ActionType
values []Value
rules []string
wantMatcherCalls int
wantBlocking bool
wantFinal bool
wantAudits int
wantBlockingPath string
}
tests := []testCase{
{
name: "empty value is skipped before deny evaluation",
action: api.ActionTypeDeny,
values: []Value{
{
Value: "",
Path: "spec.value",
},
},
rules: []string{
"",
},
wantMatcherCalls: 0,
wantBlocking: false,
wantFinal: false,
wantAudits: 0,
},
{
name: "empty value is skipped before allow miss",
action: api.ActionTypeAllow,
values: []Value{
{
Value: "",
Path: "spec.value",
},
},
rules: []string{
"allowed",
},
wantMatcherCalls: 0,
wantBlocking: false,
wantFinal: false,
wantAudits: 0,
},
{
name: "empty value is skipped before audit evaluation",
action: api.ActionTypeAudit,
values: []Value{
{
Value: "",
Path: "spec.value",
},
},
rules: []string{
"",
},
wantMatcherCalls: 0,
wantBlocking: false,
wantFinal: false,
wantAudits: 0,
},
{
name: "empty value is skipped but later non empty value is evaluated",
action: api.ActionTypeDeny,
values: []Value{
{
Value: "",
Path: "spec.empty",
},
{
Value: "deny",
Path: "spec.nonEmpty",
},
},
rules: []string{
"deny",
},
wantMatcherCalls: 1,
wantBlocking: true,
wantFinal: true,
wantAudits: 0,
wantBlockingPath: "spec.nonEmpty",
},
{
name: "whitespace value is not skipped",
action: api.ActionTypeDeny,
values: []Value{
{
Value: " ",
Path: "spec.value",
},
},
rules: []string{
" ",
},
wantMatcherCalls: 1,
wantBlocking: true,
wantFinal: true,
wantAudits: 0,
wantBlockingPath: "spec.value",
},
{
name: "empty values are skipped before non matching allow value triggers allow miss",
action: api.ActionTypeAllow,
values: []Value{
{
Value: "",
Path: "spec.empty",
},
{
Value: "actual",
Path: "spec.actual",
},
},
rules: []string{
"allowed",
},
wantMatcherCalls: 1,
wantBlocking: true,
wantFinal: false,
wantAudits: 0,
wantBlockingPath: "spec.actual",
},
{
name: "all empty values are skipped",
action: api.ActionTypeDeny,
values: []Value{
{
Value: "",
Path: "spec.first",
},
{
Value: "",
Path: "spec.second",
},
},
rules: []string{
"",
},
wantMatcherCalls: 0,
wantBlocking: false,
wantFinal: false,
wantAudits: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
matcherCalls := 0
evaluation, err := EvaluateEnforce(
struct{}{},
[]*api.NamespaceRuleEnforceBody{
{
Action: tt.action,
},
},
Set[string, struct{}]{
Name: "registry",
EventReason: "NamespaceRuleViolation",
Values: func(struct{}) []Value {
return tt.values
},
Rules: func(*api.NamespaceRuleEnforceBody) []string {
return tt.rules
},
Matches: func(rule string, value Value) (Match, error) {
matcherCalls++
return Match{
Matched: value.Value == rule,
MatchedValue: rule,
}, nil
},
RuleDescription: func(rule string) string {
return rule
},
},
)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if evaluation == nil {
t.Fatalf("expected evaluation")
}
if matcherCalls != tt.wantMatcherCalls {
t.Fatalf("expected %d matcher calls, got %d", tt.wantMatcherCalls, matcherCalls)
}
if got := evaluation.Blocking != nil; got != tt.wantBlocking {
t.Fatalf("expected blocking=%t, got %t: %#v", tt.wantBlocking, got, evaluation.Blocking)
}
if got := evaluation.Final != nil; got != tt.wantFinal {
t.Fatalf("expected final=%t, got %t: %#v", tt.wantFinal, got, evaluation.Final)
}
if len(evaluation.Audits) != tt.wantAudits {
t.Fatalf("expected %d audits, got %d", tt.wantAudits, len(evaluation.Audits))
}
if tt.wantBlockingPath != "" {
if evaluation.Blocking == nil {
t.Fatalf("expected blocking decision")
}
if evaluation.Blocking.Value.Path != tt.wantBlockingPath {
t.Fatalf("expected blocking path %q, got %q", tt.wantBlockingPath, evaluation.Blocking.Value.Path)
}
}
})
}
}
func TestMessageHelpers(t *testing.T) {
t.Parallel()
+110 -3
View File
@@ -3,16 +3,23 @@
package ruleengine
import (
"errors"
"fmt"
"net"
"regexp"
"strings"
"github.com/projectcapsule/capsule/pkg/api"
k8smeta "k8s.io/apimachinery/pkg/api/meta"
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
func ValidateRuleStatusBody(bodies []*rules.NamespaceRuleBodyNamespace) error {
func ValidateRuleStatusBody(
mapper k8smeta.RESTMapper,
bodies []*rules.NamespaceRuleBodyNamespace,
) error {
for i, rule := range bodies {
if rule == nil || rule.Enforce == nil {
continue
@@ -25,6 +32,10 @@ func ValidateRuleStatusBody(bodies []*rules.NamespaceRuleBodyNamespace) error {
if err := validateServiceRules(i, rule.Enforce.Services); err != nil {
return err
}
if err := validateMetadataRules(i, rule.Enforce.Metadata, mapper); err != nil {
return err
}
}
return nil
@@ -112,7 +123,76 @@ func validateServiceRules(
return nil
}
func validateExpressionMatch(match api.ExpressionMatch, fieldPath string) error {
func validateMetadataRules(
ruleIndex int,
metadata []rules.MetadataRule,
mapper k8smeta.RESTMapper,
) error {
for j, rule := range metadata {
fieldPath := fmt.Sprintf("rules[%d].enforce.metadata[%d]", ruleIndex, j)
if err := validateMetadataTargets(fieldPath, rule, mapper); err != nil {
return err
}
for key, policy := range rule.Labels {
if err := validateMetadataKey(key); err != nil {
return fmt.Errorf(
"%s.labels[%q] is invalid: %w",
fieldPath,
key,
err,
)
}
for k, matcher := range policy.Values {
if err := validateExpressionMatch(
matcher,
fmt.Sprintf("%s.labels[%q].values[%d]", fieldPath, key, k),
); err != nil {
return err
}
}
}
for key, policy := range rule.Annotations {
if err := validateMetadataKey(key); err != nil {
return fmt.Errorf(
"%s.annotations[%q] is invalid: %w",
fieldPath,
key,
err,
)
}
for k, matcher := range policy.Values {
if err := validateExpressionMatch(
matcher,
fmt.Sprintf("%s.annotations[%q].values[%d]", fieldPath, key, k),
); err != nil {
return err
}
}
}
}
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, ", "))
}
return nil
}
func validateExpressionMatch(match runtime.ExpressionMatch, fieldPath string) error {
if err := validateExpression(match.Expression, fieldPath+".exp"); err != nil {
return err
}
@@ -181,3 +261,30 @@ func validateNodePortRange(portRange rules.ServiceNodePortRange) error {
return nil
}
func validateMetadataTargets(
fieldPath string,
rule rules.MetadataRule,
mapper k8smeta.RESTMapper,
) error {
if len(rule.Kinds) == 0 {
return fmt.Errorf("%s.kinds is invalid: at least one kind must be configured", fieldPath)
}
for i, kind := range rule.Kinds {
kind = strings.TrimSpace(kind)
if kind == "" {
return fmt.Errorf("%s.kinds[%d] is invalid: kind is empty", fieldPath, i)
}
}
if mapper == nil {
return nil
}
if err := rule.ValidateKnownKinds(mapper, fieldPath); err != nil {
return err
}
return nil
}
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -8,11 +8,14 @@ const (
ReasonTenantResourceWriteOp string = "TenantResourceWriteOp"
ReasonOverprovision string = "Overprovisioned"
ReasonCordoning string = "Cordoned"
// ForbiddenLabelReason used as reason string to deny forbidden labels.
ReasonForbiddenLabel string = "ForbiddenLabel"
// ForbiddenAnnotationReason used as reason string to deny forbidden annotations.
ReasonForbiddenAnnotation string = "ForbiddenAnnotation"
ReasonAdmissionFailure string = "AdmissionFailed"
ReasonForbiddenMetadata string = "ForbiddenMetadata"
ReasonAdmissionFailure string = "AdmissionFailed"
// RuleStatus.
ReasonNamespaceRuleAudit string = "NamespaceRuleAudit"
-25
View File
@@ -1,25 +0,0 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package gvk
import "k8s.io/apimachinery/pkg/runtime/schema"
type VersionKind struct {
// Kind of the referent.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
Kind string `json:"kind" protobuf:"bytes,1,opt,name=kind"`
// API version of the referent.
APIVersion string `json:"apiVersion" protobuf:"bytes,5,opt,name=apiVersion"`
}
func (s VersionKind) GroupVersionKind() schema.GroupVersionKind {
gv, err := schema.ParseGroupVersion(s.APIVersion)
if err != nil {
return schema.GroupVersionKind{
Kind: s.Kind,
}
}
return gv.WithKind(s.Kind)
}
+2 -2
View File
@@ -16,14 +16,14 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"github.com/projectcapsule/capsule/pkg/runtime/gvk"
"github.com/projectcapsule/capsule/pkg/api/runtime"
"github.com/projectcapsule/capsule/pkg/runtime/selectors"
)
// Reference
// +kubebuilder:object:generate=true
type ResourceReference struct {
gvk.VersionKind `json:",inline"`
runtime.VersionKind `json:",inline"`
// Name of the values referent. This is useful
// when you traying to get a specific resource
+9 -9
View File
@@ -9,8 +9,8 @@ import (
corev1 "k8s.io/api/core/v1"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
func TestRenderNamespaceRuleBodies(t *testing.T) {
@@ -57,7 +57,7 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
Registries: []rules.OCIRegistry{
{
ExpressionMatch: api.ExpressionMatch{
ExpressionMatch: runtime.ExpressionMatch{
Exact: []string{
"{{ .tenant.metadata.name }}/{{ .namespace.metadata.name }}/app:1",
},
@@ -114,7 +114,7 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
Registries: []rules.OCIRegistry{
{
ExpressionMatch: api.ExpressionMatch{
ExpressionMatch: runtime.ExpressionMatch{
Exact: []string{
`{{ index .namespace.metadata.labels "registry-prefix" }}/app:1`,
},
@@ -151,8 +151,8 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
Registries: []rules.OCIRegistry{
{
ExpressionMatch: api.ExpressionMatch{
ExpressionRegex: api.ExpressionRegex{
ExpressionMatch: runtime.ExpressionMatch{
ExpressionRegex: runtime.ExpressionRegex{
Expression: "{{ .tenant.metadata.name }}/allow/.*",
},
},
@@ -170,8 +170,8 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
},
Registries: []rules.OCIRegistry{
{
ExpressionMatch: api.ExpressionMatch{
ExpressionRegex: api.ExpressionRegex{
ExpressionMatch: runtime.ExpressionMatch{
ExpressionRegex: runtime.ExpressionRegex{
Expression: "{{ .tenant.metadata.name }}/deny/.*",
},
},
@@ -229,7 +229,7 @@ func TestRenderNamespaceRuleBodies(t *testing.T) {
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
Registries: []rules.OCIRegistry{
{
ExpressionMatch: api.ExpressionMatch{
ExpressionMatch: runtime.ExpressionMatch{
Exact: []string{
"{{ .namespace.metadata.labels.registry }}/app:1",
},
@@ -284,7 +284,7 @@ func TestRenderNamespaceRuleBodies_DoesNotMutateInput(t *testing.T) {
Workloads: rules.NamespaceRuleEnforceWorkloadsBody{
Registries: []rules.OCIRegistry{
{
ExpressionMatch: api.ExpressionMatch{
ExpressionMatch: runtime.ExpressionMatch{
Exact: []string{
"{{ .tenant.metadata.name }}/app:1",
},