mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-25 16:07:24 +00:00
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:
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user