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:
Oliver Bähler
2026-07-22 22:24:54 +02:00
committed by GitHub
parent 8c26b409a1
commit f1b760a87a
47 changed files with 5007 additions and 298 deletions
+101
View File
@@ -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)
}
}
+95
View File
@@ -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)
}
+21 -5
View File
@@ -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
}
+28
View File
@@ -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
View File
@@ -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
}
+66
View File
@@ -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)
}
}
+58
View File
@@ -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: "",