Files
capsule/internal/webhook/rules/generic/validation/metadata.go
T
77d1810bb9 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>
2026-07-02 13:42:08 +02:00

271 lines
5.6 KiB
Go

// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
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 := h.controlledMetadataEntries(obj, gvk, enforceBodies)
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,
},
}
},
Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch {
if enforce == nil || len(enforce.Metadata) == 0 {
return nil
}
var out []runtime.ExpressionMatch
for i := range enforce.Metadata {
rule := enforce.Metadata[i]
if !rule.MatchesGroupVersionKind(gvk) {
continue
}
switch entry.Field {
case metadataFieldLabel:
policy, ok := rule.Labels[entry.Key]
if ok {
out = append(out, policy.Values...)
}
case metadataFieldAnnotation:
policy, ok := rule.Annotations[entry.Key]
if ok {
out = append(out, policy.Values...)
}
}
}
return out
},
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",
}
}
func (h *genericRules) controlledMetadataEntries(
obj genericObject,
gvk schema.GroupVersionKind,
enforceBodies []*apirules.NamespaceRuleEnforceBody,
) []metadataEntry {
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 key, policy := range rule.Labels {
if h.managedMetadata.HasLabel(key) {
continue
}
value, exists := labels[key]
required := action == apirules.ActionTypeAllow && policy.Required
if !exists && !required {
continue
}
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
}
for key, policy := range rule.Annotations {
if h.managedMetadata.HasAnnotation(key) {
continue
}
value, exists := annotations[key]
required := action == apirules.ActionTypeAllow && policy.Required
if !exists && !required {
continue
}
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 len(seen) == 0 {
return 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
}
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)
}