diff --git a/Makefile b/Makefile index 82237541..14a8a7ba 100644 --- a/Makefile +++ b/Makefile @@ -190,7 +190,7 @@ ifeq ($(CAPSULE_PROXY_MODE),http) --set "serviceMonitor.enabled=false" \ --set "options.generateCertificates=false" \ --set "certManager.generateCertificates=false" \ - --set "options.extraArgs={--feature-gates=ProxyAllNamespaced=true}" + --set "options.extraArgs={--feature-gates=ProxyClusterScoped=true,--feature-gates=ProxyAllNamespaced=true}" else @echo "Running in HTTPS mode" @echo "Installing Capsule-Proxy using HELM..." @@ -206,7 +206,7 @@ else --set "serviceMonitor.enabled=false" \ --set "options.generateCertificates=false" \ --set "certManager.certificate.ipAddresses={127.0.0.1}" \ - --set "options.extraArgs={--feature-gates=ProxyAllNamespaced=true}" + --set "options.extraArgs={--feature-gates=ProxyClusterScoped=true,--feature-gates=ProxyAllNamespaced=true}" endif @kubectl rollout restart ds capsule-proxy -n capsule-system || true $(MAKE) generate-kubeconfigs diff --git a/api/v1beta1/proxysettings_types.go b/api/v1beta1/proxysettings_types.go index a9708b27..bf9ab52e 100644 --- a/api/v1beta1/proxysettings_types.go +++ b/api/v1beta1/proxysettings_types.go @@ -13,12 +13,10 @@ type OwnerSpec struct { Kind capsuleapi.OwnerKind `json:"kind"` // Name of tenant owner. Name string `json:"name"` - // Cluster Resources for tenant Owner. - ClusterResources []ClusterResource `json:"clusterResources,omitempty"` - // Deprecated: Use Global Proxy Settings instead (https://projectcapsule.dev/docs/proxy/proxysettings/#globalproxysettings) - // // Proxy settings for tenant owner. ProxyOperations []capsuleapi.ProxySettings `json:"proxySettings,omitempty"` + // Cluster Resources for tenant Owner. + ClusterResources []ClusterResource `json:"clusterResources,omitempty"` } // ProxySettingSpec defines the additional Capsule Proxy settings for additional users of the Tenant. diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 857990ab..a1cc31d9 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -173,16 +173,16 @@ func (in *GlobalSubjectSpec) DeepCopy() *GlobalSubjectSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OwnerSpec) DeepCopyInto(out *OwnerSpec) { *out = *in - if in.ClusterResources != nil { - in, out := &in.ClusterResources, &out.ClusterResources - *out = make([]ClusterResource, len(*in)) + if in.ProxyOperations != nil { + in, out := &in.ProxyOperations, &out.ProxyOperations + *out = make([]api.ProxySettings, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.ProxyOperations != nil { - in, out := &in.ProxyOperations, &out.ProxyOperations - *out = make([]api.ProxySettings, len(*in)) + if in.ClusterResources != nil { + in, out := &in.ClusterResources, &out.ClusterResources + *out = make([]ClusterResource, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } diff --git a/charts/capsule-proxy/crds/capsule.clastix.io_proxysettings.yaml b/charts/capsule-proxy/crds/capsule.clastix.io_proxysettings.yaml index 7a2928da..6cc29dbe 100644 --- a/charts/capsule-proxy/crds/capsule.clastix.io_proxysettings.yaml +++ b/charts/capsule-proxy/crds/capsule.clastix.io_proxysettings.yaml @@ -142,10 +142,7 @@ spec: description: Name of tenant owner. type: string proxySettings: - description: |- - Deprecated: Use Global Proxy Settings instead (https://projectcapsule.dev/docs/proxy/proxysettings/#globalproxysettings) - - Proxy settings for tenant owner. + description: Proxy settings for tenant owner. items: properties: kind: diff --git a/internal/authorization/middleware.go b/internal/authorization/middleware.go index 45e9dd8a..7921b36d 100644 --- a/internal/authorization/middleware.go +++ b/internal/authorization/middleware.go @@ -12,7 +12,7 @@ import ( "github.com/projectcapsule/capsule-proxy/internal/tenant" ) -func MutateAuthorization(proxyTenants []*tenant.ProxyTenant, obj *runtime.Object, gvk schema.GroupVersionKind) error { +func MutateAuthorization(proxyClusterScoped bool, proxyTenants []*tenant.ProxyTenant, obj *runtime.Object, gvk schema.GroupVersionKind) error { switch gvk.Kind { case "SelfSubjectAccessReview": //nolint:forcetypeassert @@ -21,6 +21,10 @@ func MutateAuthorization(proxyTenants []*tenant.ProxyTenant, obj *runtime.Object accessReview.Status.Allowed = true } + if !proxyClusterScoped { + return nil + } + accessReviewGvk := schema.GroupVersionKind{ Group: accessReview.Spec.ResourceAttributes.Group, Version: accessReview.Spec.ResourceAttributes.Version, @@ -44,8 +48,11 @@ func MutateAuthorization(proxyTenants []*tenant.ProxyTenant, obj *runtime.Object rules := (*obj).(*authorizationv1.SelfSubjectRulesReview) var resourceRules []authorizationv1.ResourceRule - - resourceRules = getAllResourceRules(proxyTenants) + if proxyClusterScoped { + resourceRules = getAllResourceRules(proxyTenants) + } else { + resourceRules = []authorizationv1.ResourceRule{} + } resourceRules = append(resourceRules, authorizationv1.ResourceRule{ APIGroups: []string{""}, diff --git a/internal/features/features.go b/internal/features/features.go index 4d2550b9..8e05d508 100644 --- a/internal/features/features.go +++ b/internal/features/features.go @@ -19,8 +19,7 @@ const ( // essentially bypassing any authorization. Only use this option in trusted environments // where authorization/authentication is offloaded to external systems. SkipImpersonationReview = "SkipImpersonationReview" - // Deprecated: Use Global Proxy Settings instead (https://projectcapsule.dev/docs/proxy/proxysettings/#globalproxysettings) - // + // ProxyClusterScoped allows to proxy all clusterScoped objects // for all tenant users. ProxyClusterScoped = "ProxyClusterScoped" diff --git a/internal/indexer/tenant_owner.go b/internal/indexer/tenant_owner.go deleted file mode 100644 index 1e180a61..00000000 --- a/internal/indexer/tenant_owner.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package indexer - -import ( - "fmt" - - capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -const ( - TenantOwnerKindField = ".status.owner.ownerkind" -) - -// TenantOwnerReference indexes Tenants by their status.owners (Kind:Name). -type TenantOwnerReference struct{} - -func (o TenantOwnerReference) Object() client.Object { - return &capsulev1beta2.Tenant{} -} - -func (o TenantOwnerReference) Field() string { - return TenantOwnerKindField -} - -func (o TenantOwnerReference) Func() client.IndexerFunc { - return func(object client.Object) []string { - tnt, ok := object.(*capsulev1beta2.Tenant) - if !ok { - panic(fmt.Errorf("expected type *capsulev1beta2.Tenant, got %T", object)) - } - - var owners []string - for _, owner := range tnt.Status.Owners { - owners = append(owners, fmt.Sprintf("%s:%s", owner.Kind.String(), owner.Name)) - } - - return owners - } -} diff --git a/internal/modules/ingressclass/get.go b/internal/modules/ingressclass/get.go new file mode 100644 index 00000000..af61d3d9 --- /dev/null +++ b/internal/modules/ingressclass/get.go @@ -0,0 +1,95 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ingressclass + +import ( + "net/http" + + "github.com/go-logr/logr" + "github.com/gorilla/mux" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type get struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func Get(client client.Reader) modules.Module { + return &get{ + client: client, + log: ctrl.Log.WithName("ingressclass_get"), + gk: schema.GroupVersionKind{ + Group: networkingv1.GroupName, + Version: "*", + Kind: "ingressclasses", + }, + } +} + +func (g get) GroupVersionKind() schema.GroupVersionKind { + return g.gk +} + +func (g get) GroupKind() schema.GroupKind { + return g.gk.GroupKind() +} + +func (g get) Path() string { + return "/apis/networking.k8s.io/{version}/{endpoint:ingressclasses}/{name}" +} + +func (g get) Methods() []string { + return []string{} +} + +func (g get) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + name := mux.Vars(httpRequest)["name"] + + _, exactMatch, regexMatch, requirements := getIngressClasses(httpRequest, proxyTenants) + if len(requirements) > 0 { + ic, errIc := getIngressClassFromRequest(httpRequest) + if errIc != nil { + return nil, errors.NewBadRequest(errIc, g.GroupKind()) + } + + return utils.HandleGetSelector(httpRequest.Context(), ic, g.client, requirements, name, g.GroupKind()) + } + + icl, err := getIngressClassListFromRequest(httpRequest) + if err != nil { + return nil, errors.NewBadRequest(err, g.GroupKind()) + } + + if err = g.client.List(httpRequest.Context(), icl, client.MatchingLabels{corev1.LabelMetadataName: name}); err != nil { + return nil, errors.NewBadRequest(err, g.GroupKind()) + } + + var r *labels.Requirement + + if r, err = getIngressClassSelector(icl, exactMatch, regexMatch); err == nil { + return labels.NewSelector().Add(*r), nil + } + + switch httpRequest.Method { + case http.MethodGet: + return nil, errors.NewNotFoundError(name, g.GroupKind()) + default: + return nil, nil + } +} diff --git a/internal/modules/ingressclass/list.go b/internal/modules/ingressclass/list.go new file mode 100644 index 00000000..2291ab6d --- /dev/null +++ b/internal/modules/ingressclass/list.go @@ -0,0 +1,84 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ingressclass + +import ( + "github.com/go-logr/logr" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/selection" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type list struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func List(client client.Reader) modules.Module { + return &list{ + client: client, + log: ctrl.Log.WithName("ingressclass_list"), + gk: schema.GroupVersionKind{ + Group: networkingv1.GroupName, + Version: "*", + Kind: "ingressclasses", + }, + } +} + +func (l list) GroupVersionKind() schema.GroupVersionKind { + return l.gk +} + +func (l list) GroupKind() schema.GroupKind { + return l.gk.GroupKind() +} + +func (l list) Path() string { + return "/apis/networking.k8s.io/{version}/{endpoint:ingressclasses/?}" +} + +func (l list) Methods() []string { + return []string{} +} + +func (l list) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + allowed, exactMatch, regexMatch, selectorsMatch := getIngressClasses(httpRequest, proxyTenants) + if len(selectorsMatch) > 0 { + return utils.HandleListSelector(selectorsMatch) + } + + icl, err := getIngressClassListFromRequest(httpRequest) + if err != nil { + return nil, errors.NewBadRequest(err, l.GroupKind()) + } + + if err = l.client.List(httpRequest.Context(), icl); err != nil { + return nil, errors.NewBadRequest(err, l.GroupKind()) + } + + var r *labels.Requirement + + if r, err = getIngressClassSelector(icl, exactMatch, regexMatch); err != nil { + if !allowed { + return nil, errors.NewNotAllowed(l.GroupKind()) + } + + r, _ = labels.NewRequirement("dontexistsignoreme", selection.Exists, []string{}) + } + + return labels.NewSelector().Add(*r), nil +} diff --git a/internal/modules/ingressclass/utils.go b/internal/modules/ingressclass/utils.go new file mode 100644 index 00000000..3d02bcbf --- /dev/null +++ b/internal/modules/ingressclass/utils.go @@ -0,0 +1,146 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ingressclass + +import ( + "fmt" + "net/http" + "regexp" + "sort" + + "github.com/gorilla/mux" + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + networkingv1beta1 "k8s.io/api/networking/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +func getIngressClasses(request *http.Request, proxyTenants []*tenant.ProxyTenant) (allowed bool, exact []string, regex []*regexp.Regexp, requirements []labels.Requirement) { + requirements = []labels.Requirement{} + + for _, pt := range proxyTenants { + if ok := pt.RequestAllowed(request, capsuleapi.IngressClassesProxy); !ok { + continue + } + + allowed = true + + ic := pt.Tenant.Spec.IngressOptions.AllowedClasses + if ic == nil { + continue + } + + if len(ic.Exact) > 0 { + exact = append(exact, ic.Exact...) + } + + if len(ic.Default) > 0 { + exact = append(exact, ic.Default) + } + + //nolint:staticcheck + if r := ic.Regex; len(r) > 0 { + regex = append(regex, regexp.MustCompile(r)) + } + + selector, err := metav1.LabelSelectorAsSelector(&ic.LabelSelector) + if err != nil { + continue + } + + reqs, selectable := selector.Requirements() + if !selectable { + continue + } + + requirements = append(requirements, reqs...) + } + + sort.SliceStable(exact, func(i, _ int) bool { + return exact[i] < exact[0] + }) + + return allowed, exact, regex, requirements +} + +func getIngressClassListFromRequest(request *http.Request) (ic client.ObjectList, err error) { + v := mux.Vars(request)["version"] + switch v { + case networkingv1.SchemeGroupVersion.Version: + ic = &networkingv1.IngressClassList{} + case networkingv1beta1.SchemeGroupVersion.Version: + ic = &networkingv1beta1.IngressClassList{} + default: + return nil, fmt.Errorf("ingressClass %s is not supported", v) + } + + return +} + +func getIngressClassFromRequest(request *http.Request) (ic client.Object, err error) { + v := mux.Vars(request)["version"] + switch v { + case "v1": + ic = &networkingv1.IngressClass{} + case "v1beta1": + ic = &networkingv1beta1.IngressClass{} + default: + return nil, fmt.Errorf("ingressClass %s is not supported", v) + } + + return +} + +func getIngressClassSelector(sc client.ObjectList, exact []string, regex []*regexp.Regexp) (*labels.Requirement, error) { + isIngressClassRegexed := func(name string, regex []*regexp.Regexp) bool { + for _, r := range regex { + if r.MatchString(name) { + return true + } + } + + return false + } + + var names []string + + switch t := sc.(type) { + case *networkingv1beta1.IngressClassList: + for _, i := range t.Items { + if isIngressClassRegexed(i.GetName(), regex) { + names = append(names, i.GetName()) + + continue + } + + if f := sort.SearchStrings(exact, i.GetName()); f < len(exact) && exact[f] == i.GetName() { + names = append(names, i.GetName()) + } + } + case *networkingv1.IngressClassList: + for _, i := range t.Items { + if isIngressClassRegexed(i.GetName(), regex) { + names = append(names, i.GetName()) + + continue + } + + if f := sort.SearchStrings(exact, i.GetName()); f < len(exact) && exact[f] == i.GetName() { + names = append(names, i.GetName()) + } + } + } + + if len(names) > 0 { + return labels.NewRequirement(corev1.LabelMetadataName, selection.In, names) + } + + return nil, fmt.Errorf("cannot create LabelSelector for the requested IngressClass requirement") +} diff --git a/internal/modules/lease/get.go b/internal/modules/lease/get.go new file mode 100644 index 00000000..85c2e859 --- /dev/null +++ b/internal/modules/lease/get.go @@ -0,0 +1,87 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package lease + +import ( + "github.com/go-logr/logr" + "github.com/gorilla/mux" + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type get struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func Get(client client.Reader) modules.Module { + return &get{ + client: client, + log: ctrl.Log.WithName("node_get"), + gk: schema.GroupVersionKind{ + Group: corev1.GroupName, + Version: "*", + Kind: "nodes", + }, + } +} + +func (g get) GroupVersionKind() schema.GroupVersionKind { + return g.gk +} + +func (g get) GroupKind() schema.GroupKind { + return g.gk.GroupKind() +} + +func (g get) Path() string { + return "/apis/coordination.k8s.io/v1/namespaces/kube-node-lease/leases/{name}" +} + +func (g get) Methods() []string { + return []string{"get"} +} + +func (g get) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + var selectors []map[string]string + + httpRequest := proxyRequest.GetHTTPRequest() + + for _, pt := range proxyTenants { + if ok := pt.RequestAllowed(httpRequest, capsuleapi.NodesProxy); ok { + selectors = append(selectors, pt.Tenant.Spec.NodeSelector) + } + } + + name := mux.Vars(httpRequest)["name"] + + node := &corev1.Node{} + //nolint:nilerr + if err = g.client.Get(httpRequest.Context(), types.NamespacedName{Name: name}, node); err != nil { + // offload failure to Kubernetes API due to missing RBAC + return nil, nil + } + + for _, sel := range selectors { + for k := range sel { + if sel[k] == node.GetLabels()[k] { + // We're matching the nodeSelector of the Tenant: + // adding an empty selector in order to decorate the request + return labels.NewSelector().Add(), nil + } + } + } + // requesting lease for a non owner Node: let Kubernetes deal with it + return nil, nil +} diff --git a/internal/modules/persistentvolume/get.go b/internal/modules/persistentvolume/get.go new file mode 100644 index 00000000..a1970791 --- /dev/null +++ b/internal/modules/persistentvolume/get.go @@ -0,0 +1,70 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package persistentvolume + +import ( + "github.com/go-logr/logr" + "github.com/gorilla/mux" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type get struct { + client client.Reader + log logr.Logger + labelKey string + gk schema.GroupVersionKind +} + +func Get(client client.Reader) modules.Module { + label, _ := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) + + return &get{ + client: client, + log: ctrl.Log.WithName("persistentvolume_get"), + labelKey: label, + gk: schema.GroupVersionKind{ + Group: corev1.GroupName, + Version: "*", + Kind: "persistentvolumes", + }, + } +} + +func (g get) GroupVersionKind() schema.GroupVersionKind { + return g.gk +} + +func (g get) GroupKind() schema.GroupKind { + return g.gk.GroupKind() +} + +func (g get) Path() string { + return "/api/v1/{endpoint:persistentvolumes}/{name}" +} + +func (g get) Methods() []string { + return []string{} +} + +func (g get) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + name := mux.Vars(httpRequest)["name"] + + _, requirement := getPersistentVolume(httpRequest, proxyTenants, g.labelKey) + + rc := &corev1.PersistentVolume{} + + return utils.HandleGetSelector(httpRequest.Context(), rc, g.client, []labels.Requirement{requirement}, name, g.GroupKind()) +} diff --git a/internal/modules/persistentvolume/list.go b/internal/modules/persistentvolume/list.go new file mode 100644 index 00000000..036426c5 --- /dev/null +++ b/internal/modules/persistentvolume/list.go @@ -0,0 +1,69 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package persistentvolume + +import ( + "github.com/go-logr/logr" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type list struct { + client client.Reader + log logr.Logger + labelKey string + gk schema.GroupVersionKind +} + +func List(client client.Reader) modules.Module { + label, _ := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) + + return &list{ + client: client, + log: ctrl.Log.WithName("persistentvolume_list"), + labelKey: label, + gk: schema.GroupVersionKind{ + Group: corev1.GroupName, + Version: "*", + Kind: "persistentvolumes", + }, + } +} + +func (l list) GroupVersionKind() schema.GroupVersionKind { + return l.gk +} + +func (l list) GroupKind() schema.GroupKind { + return l.gk.GroupKind() +} + +func (l list) Path() string { + return "/api/v1/{endpoint:persistentvolumes/?}" +} + +func (l list) Methods() []string { + return []string{} +} + +func (l list) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + allowed, requirement := getPersistentVolume(httpRequest, proxyTenants, l.labelKey) + if !allowed { + return nil, errors.NewNotAllowed(l.GroupKind()) + } + + return utils.HandleListSelector([]labels.Requirement{requirement}) +} diff --git a/internal/modules/persistentvolume/utils.go b/internal/modules/persistentvolume/utils.go new file mode 100644 index 00000000..b27725aa --- /dev/null +++ b/internal/modules/persistentvolume/utils.go @@ -0,0 +1,30 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package persistentvolume + +import ( + "net/http" + + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +func getPersistentVolume(req *http.Request, proxyTenants []*tenant.ProxyTenant, label string) (allowed bool, requirements labels.Requirement) { + var tenantNames []string + + for _, pt := range proxyTenants { + if ok := pt.RequestAllowed(req, capsuleapi.PersistentVolumesProxy); ok { + allowed = true + + tenantNames = append(tenantNames, pt.Tenant.Name) + } + } + + requirement, _ := labels.NewRequirement(label, selection.In, tenantNames) + + return allowed, *requirement +} diff --git a/internal/modules/pod/get.go b/internal/modules/pod/get.go new file mode 100644 index 00000000..b2c96579 --- /dev/null +++ b/internal/modules/pod/get.go @@ -0,0 +1,115 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package pod + +import ( + "github.com/go-logr/logr" + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +// get is the module that is going to be used when a `kubectl describe node` is issued by a Tenant owner. +// No other verbs are considered here, just the listing of Pods for the given node. +type get struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func Get(client client.Reader) modules.Module { + return &get{ + client: client, + log: ctrl.Log.WithName("node_get"), + gk: schema.GroupVersionKind{ + Group: corev1.GroupName, + Version: "*", + Kind: "nodes", + }, + } +} + +func (g get) GroupVersionKind() schema.GroupVersionKind { + return g.gk +} + +func (g get) GroupKind() schema.GroupKind { + return g.gk.GroupKind() +} + +func (g get) Path() string { + return "/api/v1/pods" +} + +func (g get) Methods() []string { + return []string{"get"} +} + +func (g get) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + rawFieldSelector, ok := httpRequest.URL.Query()["fieldSelector"] + // we want to process just the requests that are required by the kubectl describe feature and these contain the + // field selector in the query string: if it's not there, we can skip the processing. + if !ok || len(rawFieldSelector) == 0 { + return nil, nil + } + + var fieldSelector labels.Selector + + //nolint:nilerr + if fieldSelector, err = labels.Parse(rawFieldSelector[0]); err != nil { + // not valid labels, offloading Kubernetes to deal with the failure + return nil, nil + } + + var name string + + requirements, _ := fieldSelector.Requirements() + + for _, requirement := range requirements { + if requirement.Key() == "spec.nodeName" { + name = requirement.Values().List()[0] + + break + } + } + // the field selector is not matching any node, let Kubernetes deal the failure due to missing RBAC + if len(name) == 0 { + return nil, nil + } + + var selectors []map[string]string + // Ensuring the Tenant Owner can deal with the node listing + for _, pt := range proxyTenants { + if ok = pt.RequestAllowed(httpRequest, capsuleapi.NodesProxy); ok { + selectors = append(selectors, pt.Tenant.Spec.NodeSelector) + } + } + + node := &corev1.Node{} + if err = g.client.Get(httpRequest.Context(), types.NamespacedName{Name: name}, node); err != nil { + return nil, errors.NewBadRequest(err, g.GroupKind()) + } + + for _, sel := range selectors { + for k := range sel { + // If the node matches the label, adding an empty selector in order to decorate the request + if sel[k] == node.GetLabels()[k] { + return labels.NewSelector().Add(), nil + } + } + } + // offload to Kubernetes that will return the failure due to missing RBAC + return nil, nil +} diff --git a/internal/modules/priorityclass/get.go b/internal/modules/priorityclass/get.go new file mode 100644 index 00000000..d750a9c3 --- /dev/null +++ b/internal/modules/priorityclass/get.go @@ -0,0 +1,88 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package priorityclass + +import ( + "net/http" + + "github.com/go-logr/logr" + "github.com/gorilla/mux" + corev1 "k8s.io/api/core/v1" + schedulingv1 "k8s.io/api/scheduling/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type get struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func Get(client client.Reader) modules.Module { + return &get{ + client: client, + log: ctrl.Log.WithName("priorityclass_get"), + gk: schema.GroupVersionKind{ + Group: schedulingv1.GroupName, + Version: "*", + Kind: "priorityclasses", + }, + } +} + +func (g get) GroupVersionKind() schema.GroupVersionKind { + return g.gk +} + +func (g get) GroupKind() schema.GroupKind { + return g.gk.GroupKind() +} + +func (g get) Path() string { + return "/apis/scheduling.k8s.io/v1/{endpoint:priorityclasses}/{name}" +} + +func (g get) Methods() []string { + return []string{} +} + +func (g get) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + name := mux.Vars(httpRequest)["name"] + + _, exactMatch, regexMatch, requirements := getPriorityClass(httpRequest, proxyTenants) + if len(requirements) > 0 { + pc := &schedulingv1.PriorityClass{} + + return utils.HandleGetSelector(httpRequest.Context(), pc, g.client, requirements, name, g.GroupKind()) + } + + sc := &schedulingv1.PriorityClassList{} + if err = g.client.List(httpRequest.Context(), sc, client.MatchingLabels{corev1.LabelMetadataName: name}); err != nil { + return nil, errors.NewBadRequest(err, g.GroupKind()) + } + + var r *labels.Requirement + + r, err = getPriorityClassSelector(sc, exactMatch, regexMatch) + + switch { + case err == nil: + return labels.NewSelector().Add(*r), nil + case httpRequest.Method == http.MethodGet: + return nil, errors.NewNotFoundError(name, g.GroupKind()) + default: + return nil, nil + } +} diff --git a/internal/modules/priorityclass/list.go b/internal/modules/priorityclass/list.go new file mode 100644 index 00000000..491c383a --- /dev/null +++ b/internal/modules/priorityclass/list.go @@ -0,0 +1,81 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package priorityclass + +import ( + "github.com/go-logr/logr" + schedulingv1 "k8s.io/api/scheduling/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/selection" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type list struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func List(client client.Reader) modules.Module { + return &list{ + client: client, + log: ctrl.Log.WithName("priorityclass_list"), + gk: schema.GroupVersionKind{ + Group: schedulingv1.GroupName, + Version: "*", + Kind: "priorityclasses", + }, + } +} + +func (l list) GroupVersionKind() schema.GroupVersionKind { + return l.gk +} + +func (l list) GroupKind() schema.GroupKind { + return l.gk.GroupKind() +} + +func (l list) Path() string { + return "/apis/scheduling.k8s.io/v1/{endpoint:priorityclasses/?}" +} + +func (l list) Methods() []string { + return []string{} +} + +func (l list) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + allowed, exactMatch, regexMatch, selectorsMatch := getPriorityClass(httpRequest, proxyTenants) + if len(selectorsMatch) > 0 { + return utils.HandleListSelector(selectorsMatch) + } + + // Regex Deprecated, Therefor handeled last + sc := &schedulingv1.PriorityClassList{} + if err = l.client.List(httpRequest.Context(), sc); err != nil { + return nil, errors.NewBadRequest(err, l.GroupKind()) + } + + var r *labels.Requirement + + if r, err = getPriorityClassSelector(sc, exactMatch, regexMatch); err != nil { + if !allowed { + return nil, errors.NewNotAllowed(l.GroupKind()) + } + + r, _ = labels.NewRequirement("dontexistsignoreme", selection.Exists, []string{}) + } + + return labels.NewSelector().Add(*r), nil +} diff --git a/internal/modules/priorityclass/utils.go b/internal/modules/priorityclass/utils.go new file mode 100644 index 00000000..126536c0 --- /dev/null +++ b/internal/modules/priorityclass/utils.go @@ -0,0 +1,100 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package priorityclass + +import ( + "fmt" + "net/http" + "regexp" + "sort" + + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + corev1 "k8s.io/api/core/v1" + schedulingv1 "k8s.io/api/scheduling/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +func getPriorityClass(req *http.Request, proxyTenants []*tenant.ProxyTenant) (allowed bool, exact []string, regex []*regexp.Regexp, requirements []labels.Requirement) { + requirements = []labels.Requirement{} + + for _, pt := range proxyTenants { + if ok := pt.RequestAllowed(req, capsuleapi.PriorityClassesProxy); !ok { + continue + } + + allowed = true + + pc := pt.Tenant.Spec.PriorityClasses + if pc == nil { + continue + } + + if len(pc.Exact) > 0 { + exact = append(exact, pc.Exact...) + } + + if len(pc.Default) > 0 { + exact = append(exact, pc.Default) + } + + //nolint:staticcheck + if r := pc.Regex; len(r) > 0 { + regex = append(regex, regexp.MustCompile(r)) + } + + selector, err := metav1.LabelSelectorAsSelector(&pc.LabelSelector) + if err != nil { + continue + } + + reqs, selectable := selector.Requirements() + if !selectable { + continue + } + + requirements = append(requirements, reqs...) + } + + sort.SliceStable(exact, func(i, _ int) bool { + return exact[i] < exact[0] + }) + + return allowed, exact, regex, requirements +} + +func getPriorityClassSelector(classes *schedulingv1.PriorityClassList, exact []string, regex []*regexp.Regexp) (*labels.Requirement, error) { + isPriorityClassRegexed := func(name string, regex []*regexp.Regexp) bool { + for _, r := range regex { + if r.MatchString(name) { + return true + } + } + + return false + } + + var names []string + + for _, s := range classes.Items { + if isPriorityClassRegexed(s.GetName(), regex) { + names = append(names, s.GetName()) + + continue + } + + if f := sort.SearchStrings(exact, s.GetName()); f < len(exact) && exact[f] == s.GetName() { + names = append(names, s.GetName()) + } + } + + if len(names) > 0 { + return labels.NewRequirement(corev1.LabelMetadataName, selection.In, names) + } + + return nil, fmt.Errorf("cannot create LabelSelector for the requested PriorityClass requirement") +} diff --git a/internal/modules/runtimeclass/get.go b/internal/modules/runtimeclass/get.go new file mode 100644 index 00000000..20688940 --- /dev/null +++ b/internal/modules/runtimeclass/get.go @@ -0,0 +1,69 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package runtimeclass + +import ( + "github.com/go-logr/logr" + "github.com/gorilla/mux" + nodev1 "k8s.io/api/node/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type get struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func Get(client client.Reader) modules.Module { + return &get{ + client: client, + log: ctrl.Log.WithName("runtimeclass_get"), + gk: schema.GroupVersionKind{ + Group: nodev1.GroupName, + Version: "*", + Kind: "runtimeclasses", + }, + } +} + +func (g get) GroupVersionKind() schema.GroupVersionKind { + return g.gk +} + +func (g get) GroupKind() schema.GroupKind { + return g.gk.GroupKind() +} + +func (g get) Path() string { + return "/apis/node.k8s.io/v1/{endpoint:runtimeclasses}/{name}" +} + +func (g get) Methods() []string { + return []string{} +} + +func (g get) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + name := mux.Vars(httpRequest)["name"] + + _, requirements := getRuntimeClass(httpRequest, proxyTenants) + if len(requirements) == 0 { + return nil, errors.NewNotFoundError(name, g.GroupKind()) + } + + rc := &nodev1.RuntimeClass{} + + return utils.HandleGetSelector(httpRequest.Context(), rc, g.client, requirements, name, g.GroupKind()) +} diff --git a/internal/modules/runtimeclass/list.go b/internal/modules/runtimeclass/list.go new file mode 100644 index 00000000..ba2cd591 --- /dev/null +++ b/internal/modules/runtimeclass/list.go @@ -0,0 +1,72 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package runtimeclass + +import ( + "github.com/go-logr/logr" + nodev1 "k8s.io/api/node/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/selection" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type list struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func List(client client.Reader) modules.Module { + return &list{ + client: client, + log: ctrl.Log.WithName("runtimeclass_list"), + gk: schema.GroupVersionKind{ + Group: nodev1.GroupName, + Version: "*", + Kind: "runtimeclasses", + }, + } +} + +func (l list) GroupVersionKind() schema.GroupVersionKind { + return l.gk +} + +func (l list) GroupKind() schema.GroupKind { + return l.gk.GroupKind() +} + +func (l list) Path() string { + return "/apis/node.k8s.io/v1/{endpoint:runtimeclasses/?}" +} + +func (l list) Methods() []string { + return []string{} +} + +func (l list) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + allowed, selectorsMatch := getRuntimeClass(httpRequest, proxyTenants) + + if !allowed { + return nil, errors.NewNotAllowed(l.GroupKind()) + } + + if len(selectorsMatch) == 0 { + r, _ := labels.NewRequirement("dontexistsignoreme", selection.Exists, []string{}) + + return labels.NewSelector().Add(*r), nil + } + + return utils.HandleListSelector(selectorsMatch) +} diff --git a/internal/modules/runtimeclass/utils.go b/internal/modules/runtimeclass/utils.go new file mode 100644 index 00000000..29a76c46 --- /dev/null +++ b/internal/modules/runtimeclass/utils.go @@ -0,0 +1,43 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package runtimeclass + +import ( + "net/http" + + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +func getRuntimeClass(req *http.Request, proxyTenants []*tenant.ProxyTenant) (allowed bool, requirements []labels.Requirement) { + requirements = []labels.Requirement{} + + for _, pt := range proxyTenants { + if ok := pt.RequestAllowed(req, capsuleapi.RuntimeClassesProxy); ok { + allowed = true + + rc := pt.Tenant.Spec.RuntimeClasses + if rc == nil { + continue + } + + selector, err := metav1.LabelSelectorAsSelector(&rc.LabelSelector) + if err != nil { + continue + } + + reqs, selectable := selector.Requirements() + if !selectable { + continue + } + + requirements = append(requirements, reqs...) + } + } + + return allowed, requirements +} diff --git a/internal/modules/storageclass/get.go b/internal/modules/storageclass/get.go new file mode 100644 index 00000000..00faa280 --- /dev/null +++ b/internal/modules/storageclass/get.go @@ -0,0 +1,88 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package storageclass + +import ( + "net/http" + + "github.com/go-logr/logr" + "github.com/gorilla/mux" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type get struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func Get(client client.Reader) modules.Module { + return &get{ + client: client, + log: ctrl.Log.WithName("storageclass_get"), + gk: schema.GroupVersionKind{ + Group: storagev1.GroupName, + Version: "*", + Kind: "storageclasses", + }, + } +} + +func (g get) GroupVersionKind() schema.GroupVersionKind { + return g.gk +} + +func (g get) GroupKind() schema.GroupKind { + return g.gk.GroupKind() +} + +func (g get) Path() string { + return "/apis/storage.k8s.io/v1/{endpoint:storageclasses}/{name}" +} + +func (g get) Methods() []string { + return []string{} +} + +func (g get) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + name := mux.Vars(httpRequest)["name"] + + _, exactMatch, regexMatch, requirements := getStorageClasses(httpRequest, proxyTenants) + if len(requirements) > 0 { + sc := &storagev1.StorageClass{} + + return utils.HandleGetSelector(httpRequest.Context(), sc, g.client, requirements, name, g.GroupKind()) + } + + sc := &storagev1.StorageClassList{} + if err = g.client.List(httpRequest.Context(), sc, client.MatchingLabels{corev1.LabelMetadataName: name}); err != nil { + return nil, errors.NewBadRequest(err, g.GroupKind()) + } + + var r *labels.Requirement + + r, err = getStorageClassSelector(sc, exactMatch, regexMatch) + + switch { + case err == nil: + return labels.NewSelector().Add(*r), nil + case httpRequest.Method == http.MethodGet: + return nil, errors.NewNotFoundError(name, g.GroupKind()) + default: + return nil, nil + } +} diff --git a/internal/modules/storageclass/list.go b/internal/modules/storageclass/list.go new file mode 100644 index 00000000..5283fb51 --- /dev/null +++ b/internal/modules/storageclass/list.go @@ -0,0 +1,80 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package storageclass + +import ( + "github.com/go-logr/logr" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/selection" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule-proxy/internal/modules" + "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/utils" + "github.com/projectcapsule/capsule-proxy/internal/request" + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +type list struct { + client client.Reader + log logr.Logger + gk schema.GroupVersionKind +} + +func List(client client.Reader) modules.Module { + return &list{ + client: client, + log: ctrl.Log.WithName("storageclass_list"), + gk: schema.GroupVersionKind{ + Group: storagev1.GroupName, + Version: "*", + Kind: "storageclasses", + }, + } +} + +func (l list) GroupVersionKind() schema.GroupVersionKind { + return l.gk +} + +func (l list) GroupKind() schema.GroupKind { + return l.gk.GroupKind() +} + +func (l list) Path() string { + return "/apis/storage.k8s.io/v1/{endpoint:storageclasses/?}" +} + +func (l list) Methods() []string { + return []string{} +} + +func (l list) Handle(proxyTenants []*tenant.ProxyTenant, proxyRequest request.Request) (selector labels.Selector, err error) { + httpRequest := proxyRequest.GetHTTPRequest() + + allowed, exactMatch, regexMatch, selectorsMatch := getStorageClasses(httpRequest, proxyTenants) + if len(selectorsMatch) > 0 { + return utils.HandleListSelector(selectorsMatch) + } + + sc := &storagev1.StorageClassList{} + if err = l.client.List(httpRequest.Context(), sc); err != nil { + return nil, errors.NewBadRequest(err, l.GroupKind()) + } + + var r *labels.Requirement + + if r, err = getStorageClassSelector(sc, exactMatch, regexMatch); err != nil { + if !allowed { + return nil, errors.NewNotAllowed(l.GroupKind()) + } + + r, _ = labels.NewRequirement("dontexistsignoreme", selection.Exists, []string{}) + } + + return labels.NewSelector().Add(*r), nil +} diff --git a/internal/modules/storageclass/utils.go b/internal/modules/storageclass/utils.go new file mode 100644 index 00000000..5a759f1a --- /dev/null +++ b/internal/modules/storageclass/utils.go @@ -0,0 +1,100 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package storageclass + +import ( + "fmt" + "net/http" + "regexp" + "sort" + + capsuleapi "github.com/projectcapsule/capsule/pkg/api" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + + "github.com/projectcapsule/capsule-proxy/internal/tenant" +) + +func getStorageClasses(req *http.Request, proxyTenants []*tenant.ProxyTenant) (allowed bool, exact []string, regex []*regexp.Regexp, requirements []labels.Requirement) { + requirements = []labels.Requirement{} + + for _, pt := range proxyTenants { + if ok := pt.RequestAllowed(req, capsuleapi.StorageClassesProxy); !ok { + continue + } + + allowed = true + + sc := pt.Tenant.Spec.StorageClasses + if sc == nil { + continue + } + + if len(sc.Exact) > 0 { + exact = append(exact, sc.Exact...) + } + + if len(sc.Default) > 0 { + exact = append(exact, sc.Default) + } + + //nolint:staticcheck + if r := sc.Regex; len(r) > 0 { + regex = append(regex, regexp.MustCompile(r)) + } + + selector, err := metav1.LabelSelectorAsSelector(&sc.LabelSelector) + if err != nil { + continue + } + + reqs, selectable := selector.Requirements() + if !selectable { + continue + } + + requirements = append(requirements, reqs...) + } + + sort.SliceStable(exact, func(i, _ int) bool { + return exact[i] < exact[0] + }) + + return allowed, exact, regex, requirements +} + +func getStorageClassSelector(classes *storagev1.StorageClassList, exact []string, regex []*regexp.Regexp) (*labels.Requirement, error) { + isStorageClassRegexed := func(name string, regex []*regexp.Regexp) bool { + for _, r := range regex { + if r.MatchString(name) { + return true + } + } + + return false + } + + var names []string + + for _, s := range classes.Items { + if isStorageClassRegexed(s.GetName(), regex) { + names = append(names, s.GetName()) + + continue + } + + if f := sort.SearchStrings(exact, s.GetName()); f < len(exact) && exact[f] == s.GetName() { + names = append(names, s.GetName()) + } + } + + if len(names) > 0 { + return labels.NewRequirement(corev1.LabelMetadataName, selection.In, names) + } + + return nil, fmt.Errorf("cannot create LabelSelector for the requested StorageClass requirement") +} diff --git a/internal/modules/utils/node.go b/internal/modules/utils/node.go index 8814fd15..bf26db04 100644 --- a/internal/modules/utils/node.go +++ b/internal/modules/utils/node.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" + capsuleapi "github.com/projectcapsule/capsule/pkg/api" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/selection" @@ -40,9 +41,11 @@ func GetNodeSelector(nl *corev1.NodeList, selectors []map[string]string) (*label return nil, fmt.Errorf("cannot create LabelSelector for the requested Node requirement") } -func GetNodeSelectors(_ *http.Request, proxyTenants []*tenant.ProxyTenant) (selectors []map[string]string) { +func GetNodeSelectors(request *http.Request, proxyTenants []*tenant.ProxyTenant) (selectors []map[string]string) { for _, pt := range proxyTenants { - selectors = append(selectors, pt.Tenant.Spec.NodeSelector) + if ok := pt.RequestAllowed(request, capsuleapi.NodesProxy); ok { + selectors = append(selectors, pt.Tenant.Spec.NodeSelector) + } } return diff --git a/internal/tenant/operations.go b/internal/tenant/operations.go new file mode 100644 index 00000000..1b96d2ae --- /dev/null +++ b/internal/tenant/operations.go @@ -0,0 +1,52 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package tenant + +import ( + "net/http" + + capsuleapi "github.com/projectcapsule/capsule/pkg/api" +) + +type Operations struct { + List bool + Update bool + Delete bool +} + +func defaultOperations() *Operations { + return &Operations{ + List: false, + Update: false, + Delete: false, + } +} + +func (o *Operations) Allow(operation capsuleapi.ProxyOperation) { + switch operation { + case capsuleapi.ListOperation: + o.List = true + case capsuleapi.UpdateOperation: + o.Update = true + case capsuleapi.DeleteOperation: + o.Delete = true + } +} + +func (o *Operations) IsAllowed(request *http.Request) (ok bool) { + switch request.Method { + case http.MethodGet: + ok = o.List + case http.MethodPut, http.MethodPatch: + ok = o.List + ok = ok && o.Update + case http.MethodDelete: + ok = o.List + ok = ok && o.Delete + default: + break + } + + return +} diff --git a/internal/tenant/proxytenant.go b/internal/tenant/proxytenant.go index 20399df5..87a3c373 100644 --- a/internal/tenant/proxytenant.go +++ b/internal/tenant/proxytenant.go @@ -4,6 +4,8 @@ package tenant import ( + "net/http" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" capsuleapi "github.com/projectcapsule/capsule/pkg/api" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -13,26 +15,51 @@ import ( type ProxyTenant struct { Tenant capsulev1beta2.Tenant + ProxySetting map[capsuleapi.ProxyServiceKind]*Operations ClusterResources []v1beta1.ClusterResource } -func NewProxyTenant(tenant capsulev1beta2.Tenant, ownerName string, ownerKind capsuleapi.OwnerKind, owners []v1beta1.OwnerSpec) *ProxyTenant { - var tenantClusterResources []v1beta1.ClusterResource +func defaultProxySettings() map[capsuleapi.ProxyServiceKind]*Operations { + return map[capsuleapi.ProxyServiceKind]*Operations{ + capsuleapi.NodesProxy: defaultOperations(), + capsuleapi.StorageClassesProxy: defaultOperations(), + capsuleapi.IngressClassesProxy: defaultOperations(), + capsuleapi.PriorityClassesProxy: defaultOperations(), + capsuleapi.RuntimeClassesProxy: defaultOperations(), + capsuleapi.PersistentVolumesProxy: defaultOperations(), + } +} + +func NewProxyTenant(ownerName string, ownerKind capsuleapi.OwnerKind, tenant capsulev1beta2.Tenant, owners []v1beta1.OwnerSpec) *ProxyTenant { + var ( + tenantProxySettings []capsuleapi.ProxySettings + tenantClusterResources []v1beta1.ClusterResource + ) for _, owner := range owners { if owner.Name == ownerName && owner.Kind == ownerKind { + tenantProxySettings = owner.ProxyOperations tenantClusterResources = owner.ClusterResources } } + proxySettings := defaultProxySettings() + + for _, setting := range tenantProxySettings { + for _, operation := range setting.Operations { + proxySettings[setting.Kind].Allow(operation) + } + } + return &ProxyTenant{ Tenant: tenant, + ProxySetting: proxySettings, ClusterResources: tenantClusterResources, } } -// NewClusterProxy returns a ProxyTenant struct for GlobalProxySettings. These settings are currently not bound to a tenant and therefore -// an empty tenant is returned. +// This Function returns a ProxyTenant struct for GlobalProxySettings. These Settings are currently not bound to a tenant and therefor +// an empty tenant and empty ProxySettings are returned. func NewClusterProxy(ownerName string, ownerKind capsuleapi.OwnerKind, owners []v1beta1.GlobalSubjectSpec) *ProxyTenant { var tenantClusterResources []v1beta1.ClusterResource @@ -51,6 +78,11 @@ func NewClusterProxy(ownerName string, ownerKind capsuleapi.OwnerKind, owners [] }, Spec: capsulev1beta2.TenantSpec{}, }, + ProxySetting: defaultProxySettings(), ClusterResources: tenantClusterResources, } } + +func (p *ProxyTenant) RequestAllowed(request *http.Request, serviceKind capsuleapi.ProxyServiceKind) (ok bool) { + return p.ProxySetting[serviceKind].IsAllowed(request) +} diff --git a/internal/webserver/webserver.go b/internal/webserver/webserver.go index 18600a11..ab02a583 100644 --- a/internal/webserver/webserver.go +++ b/internal/webserver/webserver.go @@ -45,14 +45,20 @@ import ( "github.com/projectcapsule/capsule-proxy/api/v1beta1" "github.com/projectcapsule/capsule-proxy/internal/authorization" "github.com/projectcapsule/capsule-proxy/internal/controllers" + "github.com/projectcapsule/capsule-proxy/internal/features" "github.com/projectcapsule/capsule-proxy/internal/indexer" "github.com/projectcapsule/capsule-proxy/internal/modules" "github.com/projectcapsule/capsule-proxy/internal/modules/clusterscoped" moderrors "github.com/projectcapsule/capsule-proxy/internal/modules/errors" + "github.com/projectcapsule/capsule-proxy/internal/modules/ingressclass" "github.com/projectcapsule/capsule-proxy/internal/modules/metric" "github.com/projectcapsule/capsule-proxy/internal/modules/namespace" "github.com/projectcapsule/capsule-proxy/internal/modules/namespaced" "github.com/projectcapsule/capsule-proxy/internal/modules/node" + "github.com/projectcapsule/capsule-proxy/internal/modules/persistentvolume" + "github.com/projectcapsule/capsule-proxy/internal/modules/priorityclass" + "github.com/projectcapsule/capsule-proxy/internal/modules/runtimeclass" + "github.com/projectcapsule/capsule-proxy/internal/modules/storageclass" "github.com/projectcapsule/capsule-proxy/internal/modules/tenants" "github.com/projectcapsule/capsule-proxy/internal/options" req "github.com/projectcapsule/capsule-proxy/internal/request" @@ -350,7 +356,7 @@ func (n *kubeFilter) authorizationMiddleware(next http.Handler) http.Handler { n.log.Error(err, "cannot decode authorization object") } - err = authorization.MutateAuthorization(proxyTenants, &obj, *gvk) + err = authorization.MutateAuthorization(n.gates.Enabled(features.ProxyClusterScoped), proxyTenants, &obj, *gvk) if err != nil { n.log.Error(err, "cannot mutate authorization object") } @@ -452,35 +458,49 @@ func (n *kubeFilter) registerModules(ctx context.Context, root *mux.Router) { namespace.Get(n.roleBindingsReflector, n.reader), tenants.List(), tenants.Get(n.reader), - // Node and metric modules are kept as dedicated modules - // since they rely on Tenant.Spec.NodeSelector matching - node.List(n.reader), - node.Get(n.reader), - metric.Get(n.reader), - metric.List(n.reader), } // Discovery client discoveryClient := discovery.NewDiscoveryClientForConfigOrDie(ctrl.GetConfigOrDie()) - // Use the generic cluster scoped module for all remaining cluster-scoped resources. - // Resources already handled by dedicated modules above (namespaces, tenants, nodes, metrics) - // are skipped via moduleGroupKindPresent. - apis, err := serverPreferredResources(discoveryClient) - if err != nil { - panic(err) - } + // When the ProxyClusterScoped flag is enabled + // we are no longer respecting legacy proxysettings + if n.gates.Enabled(features.ProxyClusterScoped) { + apis, err := serverPreferredResources(discoveryClient) + if err != nil { + panic(err) + } - for _, api := range apis { - if !moduleGroupKindPresent(modList, api) { - n.log.V(6).Info("adding generic cluster scoped resource", "url", api.Path()) - modList = append(modList, clusterscoped.List(n.reader, n.writer, api.Path())) - modList = append(modList, clusterscoped.Get(discoveryClient, n.reader, n.writer, api.ResourcePath())) + for _, api := range apis { + if !moduleGroupKindPresent(modList, api) { + n.log.V(6).Info("adding generic cluster scoped resource", "url", api.Path()) + modList = append(modList, clusterscoped.List(n.reader, n.writer, api.Path())) + modList = append(modList, clusterscoped.Get(discoveryClient, n.reader, n.writer, api.ResourcePath())) + } } + } else { + // Adds all legacy routes + modList = append(modList, []modules.Module{ + node.List(n.reader), + node.Get(n.reader), + ingressclass.List(n.reader), + ingressclass.Get(n.reader), + storageclass.Get(n.reader), + storageclass.List(n.reader), + priorityclass.List(n.reader), + priorityclass.Get(n.reader), + runtimeclass.Get(n.reader), + runtimeclass.List(n.reader), + persistentvolume.Get(n.reader), + persistentvolume.List(n.reader), + metric.Get(n.reader), + metric.List(n.reader), + }..., + ) } // Get all API group resources - apis, err = discoverAPI(ctrl.GetConfigOrDie()) + apis, err := discoverAPI(ctrl.GetConfigOrDie()) if err != nil { panic(err) } @@ -575,14 +595,31 @@ func (n *kubeFilter) getTenantsForOwner(ctx context.Context, username string, gr return } +func (n *kubeFilter) ownerFromCapsuleToProxySetting(owners capsuleapi.OwnerListSpec) []v1beta1.OwnerSpec { + out := make([]v1beta1.OwnerSpec, 0, len(owners)) + + for _, owner := range owners { + out = append(out, v1beta1.OwnerSpec{ + Kind: owner.Kind, + Name: owner.Name, + ProxyOperations: owner.ProxyOperations, + }) + } + + return out +} + //nolint:funlen func (n *kubeFilter) getProxyTenantsForOwnerKind(ctx context.Context, ownerKind capsuleapi.OwnerKind, ownerName string) (proxyTenants []*tenant.ProxyTenant, err error) { + //nolint:prealloc + var tenants []string + ownerIndexValue := fmt.Sprintf("%s:%s", ownerKind.String(), ownerName) tl := &capsulev1beta2.TenantList{} f := client.MatchingFields{ - indexer.TenantOwnerKindField: ownerIndexValue, + ".spec.owner.ownerkind": ownerIndexValue, } if err = n.managerReader.List(ctx, tl, f); err != nil { return nil, fmt.Errorf("cannot retrieve Tenants list: %w", err) @@ -609,28 +646,29 @@ func (n *kubeFilter) getProxyTenantsForOwnerKind(ctx context.Context, ownerKind continue } - proxyTenants = append(proxyTenants, tenant.NewProxyTenant(tntList.Items[0], ownerName, ownerKind, proxySetting.Spec.Subjects)) + proxyTenants = append(proxyTenants, tenant.NewProxyTenant(ownerName, ownerKind, tntList.Items[0], proxySetting.Spec.Subjects)) } // Consider Global ProxySettings - globalProxySettings := &v1beta1.GlobalProxySettingsList{} - if err = n.managerReader.List(ctx, globalProxySettings, client.MatchingFields{indexer.GlobalKindField: ownerIndexValue}); err != nil { - n.log.Error(err, "cannot retrieve GlobalProxySettings", "owner", ownerKind, "name", ownerName) - } - // Convert GlobalProxySettings to TenantProxies - for _, globalProxySetting := range globalProxySettings.Items { - n.log.V(10).Info("Converting GlobalProxySettings", "Setting", globalProxySetting.Name) - - tProxy := tenant.NewClusterProxy(ownerName, ownerKind, globalProxySetting.Spec.Rules) - proxyTenants = append(proxyTenants, tProxy) - } + // Only consider GlobalProxySettings if the feature gate is enabled + if n.gates.Enabled(features.ProxyClusterScoped) { + globalProxySettings := &v1beta1.GlobalProxySettingsList{} + if err = n.managerReader.List(ctx, globalProxySettings, client.MatchingFields{indexer.GlobalKindField: ownerIndexValue}); err != nil { + n.log.Error(err, "cannot retrieve GlobalProxySettings", "owner", ownerKind, "name", ownerName) + } + // Convert GlobalProxySettings to TenantProxies + for _, globalProxySetting := range globalProxySettings.Items { + n.log.V(10).Info("Converting GlobalProxySettings", "Setting", globalProxySetting.Name) - n.log.V(10).Info("Collected GlobalProxySettings", "owner", ownerKind, "name", ownerName, "settings", len(globalProxySettings.Items)) + tProxy := tenant.NewClusterProxy(ownerName, ownerKind, globalProxySetting.Spec.Rules) + proxyTenants = append(proxyTenants, tProxy) + } - tenants := make([]string, 0, len(tl.Items)) + n.log.V(10).Info("Collected GlobalProxySettings", "owner", ownerKind, "name", ownerName, "settings", len(globalProxySettings.Items)) + } for _, t := range tl.Items { - proxyTenants = append(proxyTenants, tenant.NewProxyTenant(t, ownerName, ownerKind, nil)) + proxyTenants = append(proxyTenants, tenant.NewProxyTenant(ownerName, ownerKind, t, n.ownerFromCapsuleToProxySetting(t.Spec.Owners))) tenants = append(tenants, t.GetName()) } diff --git a/main.go b/main.go index 806f6e27..e95b4f5f 100644 --- a/main.go +++ b/main.go @@ -77,13 +77,12 @@ func main() { LockToDefault: false, PreRelease: featuregate.Alpha, }, - features.SkipImpersonationReview: { + features.ProxyClusterScoped: { Default: false, LockToDefault: false, PreRelease: featuregate.Alpha, }, - //nolint:staticcheck - features.ProxyClusterScoped: { + features.SkipImpersonationReview: { Default: false, LockToDefault: false, PreRelease: featuregate.Alpha, @@ -250,9 +249,12 @@ First match is used and can be specified multiple times as comma separated value indexers := []capsuleindexer.CustomIndexer{ &tenant.NamespacesReference{Obj: &capsulev1beta2.Tenant{}}, - &indexer.TenantOwnerReference{}, + &tenant.OwnerReference{}, &indexer.ProxySetting{}, - &indexer.GlobalProxySetting{}, + } + // Optional Indexers + if gates.Enabled(features.ProxyClusterScoped) { + indexers = append(indexers, &indexer.GlobalProxySetting{}) } for _, fieldIndex := range indexers {