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
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rules
import "github.com/projectcapsule/capsule/pkg/api/runtime"
// +kubebuilder:validation:Enum=Ingress;Route;ListenerSet;HTTPRoute;Gateway;TLSRoute;GRPCRoute
type IngressType string
const (
IngressTypeIngress IngressType = "Ingress"
IngressTypeRoute IngressType = "Route"
IngressTypeListenerSet IngressType = "ListenerSet"
IngressTypeHTTPRoute IngressType = "HTTPRoute"
IngressTypeGateway IngressType = "Gateway"
IngressTypeTLSRoute IngressType = "TLSRoute"
IngressTypeGRPCRoute IngressType = "GRPCRoute"
)
// NamespaceRuleEnforceIngressBody defines hostname enforcement for Kubernetes
// Ingress and Gateway API resources.
//
// +kubebuilder:object:generate=true
type NamespaceRuleEnforceIngressBody struct {
// Types defines the resource kinds to which hostname enforcement applies.
//
// +kubebuilder:validation:MinItems=1
Types []IngressType `json:"types,omitempty"`
// Hostnames defines allowed, denied, or audited hostname expressions.
// A resource targeted by an allow or deny rule must declare non-empty values
// in all hostname fields. Audit-only rules record missing hostnames without
// denying them.
//
// +kubebuilder:validation:MinItems=1
Hostnames []runtime.ExpressionMatch `json:"hostnames,omitempty"`
}
+60
View File
@@ -4,6 +4,10 @@
package rules
import (
"strings"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
@@ -25,6 +29,29 @@ type MetadataRule struct {
Annotations map[string]MetadataValueRule `json:"annotations,omitempty"`
}
// MatchesGroupVersionKind matches metadata targets. Namespace is deliberately
// opt-in: wildcard kind selectors never include it, so cluster-scoped
// namespace admission cannot be enabled accidentally.
func (r MetadataRule) MatchesGroupVersionKind(gvk schema.GroupVersionKind) bool {
if gvk.Group == "" && gvk.Version == "v1" && gvk.Kind == "Namespace" {
explicit := false
for _, kind := range r.Kinds {
if strings.TrimSpace(kind) == "Namespace" {
explicit = true
break
}
}
if !explicit {
return false
}
}
return r.VersionKinds.MatchesGroupVersionKind(gvk)
}
// +kubebuilder:object:generate=true
type MetadataValueRule struct {
// Required enforces that the metadata key must be present.
@@ -42,4 +69,37 @@ type MetadataValueRule struct {
//
// +optional
Values []runtime.ExpressionMatch `json:"values,omitempty"`
// Default is applied by admission mutation when the concrete metadata key is absent.
// It is not reconciled after admission.
// +optional
Default *string `json:"default,omitempty"`
// Managed is enforced by admission mutation and reconciled by the RuleStatus
// controller using server-side apply when the rule configuration changes.
// +optional
Managed *string `json:"managed,omitempty"`
}
// MetadataKeyExpression converts a metadata key selector into the regular
// expression used by admission validation and runtime matching. Asterisks are
// convenient wildcards, while the rest of the selector retains regexp syntax.
func MetadataKeyExpression(selector string) runtime.ExpressionRegex {
selector = strings.TrimSpace(selector)
var expression strings.Builder
for i, char := range selector {
if char == '*' && (i == 0 || selector[i-1] != '.') {
expression.WriteString(".*")
continue
}
expression.WriteRune(char)
}
return runtime.ExpressionRegex{
Expression: "^(?:" + expression.String() + ")$",
}
}
@@ -0,0 +1,41 @@
// Copyright 2020-2026 Project Capsule Authors
// SPDX-License-Identifier: Apache-2.0
package rules
import (
"testing"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/projectcapsule/capsule/pkg/api/runtime"
)
func TestMetadataRuleNamespaceRequiresExplicitKind(t *testing.T) {
t.Parallel()
namespace := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}
tests := []struct {
name string
kinds []string
want bool
}{
{name: "explicit namespace", kinds: []string{"Namespace"}, want: true},
{name: "wildcard only", kinds: []string{"*"}, want: false},
{name: "partial wildcard only", kinds: []string{"Name*"}, want: false},
{name: "explicit alongside wildcard", kinds: []string{"*", "Namespace"}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
rule := MetadataRule{VersionKinds: runtime.VersionKinds{
APIGroups: []string{"*"},
Kinds: tt.kinds,
}}
if got := rule.MatchesGroupVersionKind(namespace); got != tt.want {
t.Fatalf("MatchesGroupVersionKind() = %v, want %v", got, tt.want)
}
})
}
}
+29
View File
@@ -3,6 +3,31 @@
package rules
type AudienceKind string
const (
AudienceKindUser AudienceKind = "User"
AudienceKindGroup AudienceKind = "Group"
AudienceKindServiceAccount AudienceKind = "ServiceAccount"
AudienceKindCustom AudienceKind = "Custom"
)
type CustomAudience string
const (
CustomAudienceCapsuleUser CustomAudience = "CapsuleUser"
CustomAudienceAdministrator CustomAudience = "Administrator"
CustomAudienceTenantOwner CustomAudience = "TenantOwner"
CustomAudienceController CustomAudience = "Controller"
)
// +kubebuilder:object:generate=true
type Audience struct {
// +kubebuilder:validation:Enum=User;Group;ServiceAccount;Custom
Kind AudienceKind `json:"kind"`
Name string `json:"name"`
}
// +kubebuilder:object:generate=true
type NamespaceRuleEnforceBody struct {
// Declare the action being performed on the enforcement rule:
@@ -23,4 +48,8 @@ type NamespaceRuleEnforceBody struct {
//
// +optional
Metadata []MetadataRule `json:"metadata,omitempty"`
// Enforcement for Ingress and Gateway API resource hostnames.
// +optional
Ingress NamespaceRuleEnforceIngressBody `json:"ingress,omitempty"`
}
+5
View File
@@ -10,6 +10,11 @@ import (
// For future implementation where users might manage RuleStatus CRs themselves
// +kubebuilder:object:generate=true
type NamespaceRuleBodyNamespace struct {
// Audience limits this rule to matching request subjects.
// An empty audience matches every request.
// +optional
Audience []Audience `json:"audience,omitempty"`
// Enforcement for given rule
//+optional
Enforce *NamespaceRuleEnforceBody `json:"enforce,omitzero"`
+58
View File
@@ -14,6 +14,21 @@ import (
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 *Audience) DeepCopyInto(out *Audience) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Audience.
func (in *Audience) DeepCopy() *Audience {
if in == nil {
return nil
}
out := new(Audience)
in.DeepCopyInto(out)
return out
}
// 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
@@ -54,6 +69,16 @@ func (in *MetadataValueRule) DeepCopyInto(out *MetadataValueRule) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Default != nil {
in, out := &in.Default, &out.Default
*out = new(string)
**out = **in
}
if in.Managed != nil {
in, out := &in.Managed, &out.Managed
*out = new(string)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetadataValueRule.
@@ -69,6 +94,11 @@ func (in *MetadataValueRule) DeepCopy() *MetadataValueRule {
// 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
if in.Audience != nil {
in, out := &in.Audience, &out.Audience
*out = make([]Audience, len(*in))
copy(*out, *in)
}
if in.Enforce != nil {
in, out := &in.Enforce, &out.Enforce
*out = new(NamespaceRuleEnforceBody)
@@ -124,6 +154,7 @@ func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody)
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
in.Ingress.DeepCopyInto(&out.Ingress)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody.
@@ -136,6 +167,33 @@ func (in *NamespaceRuleEnforceBody) DeepCopy() *NamespaceRuleEnforceBody {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NamespaceRuleEnforceIngressBody) DeepCopyInto(out *NamespaceRuleEnforceIngressBody) {
*out = *in
if in.Types != nil {
in, out := &in.Types, &out.Types
*out = make([]IngressType, len(*in))
copy(*out, *in)
}
if in.Hostnames != nil {
in, out := &in.Hostnames, &out.Hostnames
*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 NamespaceRuleEnforceIngressBody.
func (in *NamespaceRuleEnforceIngressBody) DeepCopy() *NamespaceRuleEnforceIngressBody {
if in == nil {
return nil
}
out := new(NamespaceRuleEnforceIngressBody)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NamespaceRuleEnforceServicesBody) DeepCopyInto(out *NamespaceRuleEnforceServicesBody) {
*out = *in
+41 -47
View File
@@ -182,6 +182,17 @@ func (s VersionKinds) HasWildcard() bool {
// 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 {
return s.ValidateKnownKindsWithScope(mapper, fieldPath, nil)
}
// ValidateKnownKindsWithScope validates concrete targets and optionally their
// REST scope. Wildcard selectors are skipped because discovery cannot enumerate
// their complete set reliably.
func (s VersionKinds) ValidateKnownKindsWithScope(
mapper apimeta.RESTMapper,
fieldPath string,
allowScope func(schema.GroupVersionKind, apimeta.RESTScope) bool,
) error {
if mapper == nil {
return nil
}
@@ -199,7 +210,8 @@ func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath st
continue
}
if err := validateKnownKindForAPIGroup(mapper, apiGroup, kind); err != nil {
mapping, err := restMappingForAPIGroup(mapper, apiGroup, kind)
if err != nil {
return fmt.Errorf(
"%s.kinds[%d] %q for apiGroups[%d] %q is invalid: %w",
fieldPath,
@@ -210,12 +222,40 @@ func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath st
err,
)
}
if allowScope != nil && !allowScope(mapping.GroupVersionKind, mapping.Scope) {
return fmt.Errorf(
"%s.kinds[%d] %q for apiGroups[%d] %q is invalid: GVK %s has unsupported scope %q",
fieldPath, kindIndex, kind, apiGroupIndex, apiGroup,
mapping.GroupVersionKind.String(), mapping.Scope.Name(),
)
}
}
}
return nil
}
func restMappingForAPIGroup(
mapper apimeta.RESTMapper,
apiGroup string,
kind string,
) (*apimeta.RESTMapping, error) {
apiGroup = strings.TrimSpace(apiGroup)
apiGroup = normalizeAPIVersion(apiGroup)
if apiGroup == CoreAPIVersion {
return mapper.RESTMapping(schema.GroupKind{Kind: kind}, CoreAPIVersion)
}
if gv, err := schema.ParseGroupVersion(apiGroup); err == nil && strings.Contains(apiGroup, "/") {
return mapper.RESTMapping(schema.GroupKind{Group: gv.Group, Kind: kind}, gv.Version)
}
return mapper.RESTMapping(schema.GroupKind{Group: apiGroup, Kind: kind})
}
func (s VersionKinds) StatusAPIGroups() []string {
apiGroups := s.NormalizedAPIGroups()
if len(apiGroups) == 0 {
@@ -247,52 +287,6 @@ func (s VersionKinds) StatusAPIGroups() []string {
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}
-103
View File
@@ -1818,109 +1818,6 @@ func TestVersionKindsValidateKnownKinds(t *testing.T) {
}
}
func TestValidateKnownKindForAPIGroup(t *testing.T) {
t.Parallel()
mapper := newVersionKindTestRESTMapper()
tests := []struct {
name string
apiGroup string
kind string
wantErr bool
}{
{
name: "core v1 kind",
apiGroup: "",
kind: "ConfigMap",
},
{
name: "explicit core v1 kind",
apiGroup: "v1",
kind: "Service",
},
{
name: "group only kind",
apiGroup: "apps",
kind: "Deployment",
},
{
name: "exact group version kind",
apiGroup: "apps/v1",
kind: "Deployment",
},
{
name: "batch exact group version kind",
apiGroup: "batch/v1",
kind: "Job",
},
{
name: "unknown core kind",
apiGroup: "",
kind: "NotAThing",
wantErr: true,
},
{
name: "wrong group kind",
apiGroup: "batch/v1",
kind: "Deployment",
wantErr: true,
},
{
name: "wrong exact version",
apiGroup: "apps/v1beta1",
kind: "StatefulSet",
wantErr: true,
},
{
name: "unknown group",
apiGroup: "example.corp",
kind: "Widget",
wantErr: true,
},
{
name: "invalid group version",
apiGroup: "apps/v1/extra",
kind: "Deployment",
wantErr: true,
},
{
name: "empty kind fails",
apiGroup: "v1",
kind: "",
wantErr: true,
},
{
name: "case-sensitive kind fails",
apiGroup: "v1",
kind: "configmap",
wantErr: true,
},
{
name: "case-sensitive group fails",
apiGroup: "Apps",
kind: "Deployment",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateKnownKindForAPIGroup(mapper, tt.apiGroup, tt.kind)
if tt.wantErr && err == nil {
t.Fatalf("expected error")
}
if !tt.wantErr && err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}
}
func TestVersionKindsNormalizedAPIGroups(t *testing.T) {
t.Parallel()