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