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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: "",
|
||||
|
||||
@@ -55,6 +55,7 @@ const (
|
||||
ReasonIngressHostnameNotValid string = "IngressHostnameNotValid"
|
||||
ReasonIngressHostnameEmpty string = "IngressHostnameEmpty"
|
||||
ReasonIngressHostnameCollision string = "IngressHostnameCollision"
|
||||
ReasonForbiddenIngressHostname string = "ForbiddenIngressHostname"
|
||||
|
||||
// Services.
|
||||
ReasonForbiddenExternalServiceIP string = "ForbiddenExternalServiceIP"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//nolint:dupl
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -16,6 +15,8 @@ import (
|
||||
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
|
||||
"github.com/projectcapsule/capsule/pkg/api/meta"
|
||||
"github.com/projectcapsule/capsule/pkg/api/rules"
|
||||
"github.com/projectcapsule/capsule/pkg/ruleengine"
|
||||
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
|
||||
"github.com/projectcapsule/capsule/pkg/runtime/events"
|
||||
"github.com/projectcapsule/capsule/pkg/tenant"
|
||||
)
|
||||
@@ -54,8 +55,9 @@ type TypedHandlerWithTenantWithRuleset[T client.Object] interface {
|
||||
}
|
||||
|
||||
type TypedTenantWithRulesetHandler[T client.Object] struct {
|
||||
Factory NewObjectFunc[T]
|
||||
Handlers []TypedHandlerWithTenantWithRuleset[T]
|
||||
Factory NewObjectFunc[T]
|
||||
Handlers []TypedHandlerWithTenantWithRuleset[T]
|
||||
Configuration configuration.Configuration
|
||||
}
|
||||
|
||||
func (h *TypedTenantWithRulesetHandler[T]) OnCreate(
|
||||
@@ -79,7 +81,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnCreate(
|
||||
return ErroredResponse(err)
|
||||
}
|
||||
|
||||
ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
|
||||
ruleBlocks, err := h.resolveRuleset(ctx, c, reader, req, req.Namespace, tnt)
|
||||
if err != nil {
|
||||
return ErroredResponse(err)
|
||||
}
|
||||
|
||||
ruleBlocks, err = ruleengine.FilterNamespaceRulesByAudience(h.Configuration, tnt, req, ruleBlocks)
|
||||
if err != nil {
|
||||
return ErroredResponse(err)
|
||||
}
|
||||
@@ -120,7 +127,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnUpdate(
|
||||
return ErroredResponse(err)
|
||||
}
|
||||
|
||||
ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
|
||||
ruleBlocks, err := h.resolveRuleset(ctx, c, reader, req, req.Namespace, tnt)
|
||||
if err != nil {
|
||||
return ErroredResponse(err)
|
||||
}
|
||||
|
||||
ruleBlocks, err = ruleengine.FilterNamespaceRulesByAudience(h.Configuration, tnt, req, ruleBlocks)
|
||||
if err != nil {
|
||||
return ErroredResponse(err)
|
||||
}
|
||||
@@ -136,37 +148,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnUpdate(
|
||||
}
|
||||
|
||||
func (h *TypedTenantWithRulesetHandler[T]) OnDelete(
|
||||
c client.Client,
|
||||
reader client.Reader,
|
||||
decoder admission.Decoder,
|
||||
recorder events.EventRecorder,
|
||||
client.Client,
|
||||
client.Reader,
|
||||
admission.Decoder,
|
||||
events.EventRecorder,
|
||||
) Func {
|
||||
return func(ctx context.Context, req admission.Request) *admission.Response {
|
||||
tnt, err := h.resolveTenant(ctx, reader, req)
|
||||
if err != nil {
|
||||
return ErroredResponse(err)
|
||||
}
|
||||
|
||||
if tnt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
obj := h.Factory()
|
||||
if err := decoder.Decode(req, obj); err != nil {
|
||||
return ErroredResponse(err)
|
||||
}
|
||||
|
||||
ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt)
|
||||
if err != nil {
|
||||
return ErroredResponse(err)
|
||||
}
|
||||
|
||||
for _, hndl := range h.Handlers {
|
||||
if response := hndl.OnDelete(c, reader, obj, decoder, recorder, tnt, ruleBlocks)(ctx, req); response != nil {
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
return func(context.Context, admission.Request) *admission.Response {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -188,6 +175,7 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveTenant(
|
||||
func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset(
|
||||
ctx context.Context,
|
||||
c client.Client,
|
||||
reader client.Reader,
|
||||
req admission.Request,
|
||||
namespace string,
|
||||
tnt *capsulev1beta2.Tenant,
|
||||
@@ -198,7 +186,7 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset(
|
||||
Name: meta.NameForManagedRuleStatus(),
|
||||
}
|
||||
|
||||
if err := c.Get(ctx, key, rs); err == nil {
|
||||
if err := reader.Get(ctx, key, rs); err == nil {
|
||||
return rs.Status.Rules, nil
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2020-2026 Project Capsule Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
admissionv1 "k8s.io/api/admission/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
|
||||
|
||||
"github.com/projectcapsule/capsule/pkg/runtime/handlers"
|
||||
)
|
||||
|
||||
func TestTypedTenantWithRulesetHandlerSkipsDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := &handlers.TypedTenantWithRulesetHandler[*corev1.ConfigMap]{
|
||||
Factory: func() *corev1.ConfigMap { return &corev1.ConfigMap{} },
|
||||
}
|
||||
request := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
|
||||
Operation: admissionv1.Delete,
|
||||
Namespace: "solar-system",
|
||||
}}
|
||||
|
||||
if response := handler.OnDelete(nil, nil, nil, nil)(context.Background(), request); response != nil {
|
||||
t.Fatalf("OnDelete() response = %#v, want nil", response)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user