Files
capsule/internal/webhook/rules/generic/validation/metadata.go
T
Oliver BählerandGitHub f1b760a87a 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>
2026-07-22 22:24:54 +02:00

335 lines
7.2 KiB
Go

// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
//nolint:dupl
package validation
import (
"fmt"
"sort"
"k8s.io/apimachinery/pkg/runtime/schema"
apirules "github.com/projectcapsule/capsule/pkg/api/rules"
"github.com/projectcapsule/capsule/pkg/api/runtime"
"github.com/projectcapsule/capsule/pkg/ruleengine"
"github.com/projectcapsule/capsule/pkg/runtime/events"
)
type metadataField string
const (
metadataFieldLabel metadataField = "label"
metadataFieldAnnotation metadataField = "annotation"
)
type metadataEntry struct {
Field metadataField
Key string
Value string
Path string
Present bool
Required bool
}
func (h *genericRules) validateMetadata(
obj genericObject,
gvk schema.GroupVersionKind,
enforceBodies []*apirules.NamespaceRuleEnforceBody,
) (*ruleengine.Evaluation, error) {
if obj == nil || len(enforceBodies) == 0 {
return nil, nil
}
entries, err := h.controlledMetadataEntries(obj, gvk, enforceBodies)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, nil
}
out := &ruleengine.Evaluation{}
for i := range entries {
entry := entries[i]
if !entry.Present {
if entry.Required {
out.Blocking = metadataRequiredDecision(entry)
return out, nil
}
continue
}
evaluation, err := evaluateGenericRules(
obj,
enforceBodies,
h.metadataSet(gvk, entry),
)
if err != nil {
return out, err
}
out.Append(evaluation)
//nolint:nilerr
if evaluation != nil && evaluation.BlockingError() != nil {
return out, nil
}
}
return out, nil
}
func metadataRequiredDecision(entry metadataEntry) *ruleengine.Decision {
return &ruleengine.Decision{
SetName: metadataSetName(entry.Field),
EventReason: events.ReasonForbiddenMetadata,
Action: apirules.ActionTypeDeny,
Value: ruleengine.Value{
Value: "",
Path: entry.Path,
},
Message: fmt.Sprintf(
"metadata %s %q is required at %s",
entry.Field,
entry.Key,
entry.Path,
),
}
}
func (h *genericRules) metadataSet(
gvk schema.GroupVersionKind,
entry metadataEntry,
) genericRuleSet[runtime.ExpressionMatch] {
return genericRuleSet[runtime.ExpressionMatch]{
Name: metadataSetName(entry.Field),
EventReason: events.ReasonForbiddenMetadata,
Values: func(_ genericObject) []ruleengine.Value {
return []ruleengine.Value{
{
Value: entry.Value,
Path: entry.Path,
},
}
},
RulesWithError: func(enforce *apirules.NamespaceRuleEnforceBody) ([]runtime.ExpressionMatch, error) {
if enforce == nil || len(enforce.Metadata) == 0 {
return nil, nil
}
var out []runtime.ExpressionMatch
for i := range enforce.Metadata {
rule := enforce.Metadata[i]
if !rule.MatchesGroupVersionKind(gvk) {
continue
}
switch entry.Field {
case metadataFieldLabel:
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:
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, nil
},
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,
}, nil
},
RuleDescription: runtime.DescribeExpressionMatch,
AllowedDescription: "Allowed metadata values",
}
}
//nolint:gocognit
func (h *genericRules) controlledMetadataEntries(
obj genericObject,
gvk schema.GroupVersionKind,
enforceBodies []*apirules.NamespaceRuleEnforceBody,
) ([]metadataEntry, error) {
labels := obj.GetLabels()
annotations := obj.GetAnnotations()
seen := make(map[string]metadataEntry)
for _, enforce := range enforceBodies {
if enforce == nil || len(enforce.Metadata) == 0 {
continue
}
action := enforce.Action.OrDefault()
for i := range enforce.Metadata {
rule := enforce.Metadata[i]
if !rule.MatchesGroupVersionKind(gvk) {
continue
}
for selector, policy := range rule.Labels {
if h.managedMetadata.HasLabel(selector) {
continue
}
required := action == apirules.ActionTypeAllow && policy.Required
matchedAny := false
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)
}
if !matchedAny && required {
h.addMetadataEntry(seen, metadataFieldLabel, selector, "", false, true)
}
}
for selector, policy := range rule.Annotations {
if h.managedMetadata.HasAnnotation(selector) {
continue
}
required := action == apirules.ActionTypeAllow && policy.Required
matchedAny := false
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)
}
if !matchedAny && required {
h.addMetadataEntry(seen, metadataFieldAnnotation, selector, "", false, true)
}
}
}
}
if len(seen) == 0 {
return nil, nil
}
out := make([]metadataEntry, 0, len(seen))
for _, entry := range seen {
out = append(out, entry)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Path < out[j].Path
})
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 {
switch field {
case metadataFieldLabel:
return "metadata label"
case metadataFieldAnnotation:
return "metadata annotation"
default:
return "metadata"
}
}
func metadataLabelPath(key string) string {
return fmt.Sprintf("metadata.labels[%q]", key)
}
func metadataAnnotationPath(key string) string {
return fmt.Sprintf("metadata.annotations[%q]", key)
}