From 1741ec80a74a2ca8df80a325fe2ae0d245af41b7 Mon Sep 17 00:00:00 2001 From: Tamal Saha Date: Thu, 25 Jun 2026 19:50:17 +0600 Subject: [PATCH 1/2] Enforce authz on cost, policy and scanner report endpoints The CostReport, PolicyReport and CVEReport (scanner) endpoints returned cluster data for a user-supplied scope without checking whether the caller was allowed to read it. Unlike the core genericresource/podview storages, none of them received an authorizer. Add an authorizer to each storage (wired in apiserver.go) and gate every request, with a small shared helper (pkg/shared/authz.go): - scanner CVEReport: authorize the pods that back the requested scope, the same access graph.LocatePods reads - cluster/image/cluster-CVE require cluster-wide "list pods", namespace scopes require "list pods" in that namespace, a pod request requires "get" on the pod, and an object request requires "get" on the referenced object. - policy PolicyReport: cluster scope requires cluster-wide read, namespace scope requires access to the namespace, and a resource scope requires "get" on the referenced object. - cost CostReport: aggregates whole-cluster spend with no single resource to scope to, so require cluster-wide read access. Cluster-wide read is modeled as get on */* (effectively cluster-admin), used only where the response exposes whole-cluster data. Signed-off-by: Tamal Saha --- pkg/apiserver/apiserver.go | 6 +-- pkg/registry/cost/reports/storage.go | 12 ++++- pkg/registry/policy/reports/storage.go | 48 +++++++++++++++++- pkg/registry/scanner/reports/storage.go | 65 ++++++++++++++++++++++++- pkg/shared/authz.go | 64 ++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 pkg/shared/authz.go diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index c2933e231a..a928c20b5c 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -402,7 +402,7 @@ func (c completedConfig) New(ctx context.Context) (*UIServer, error) { v1alpha1storage := map[string]rest.Storage{} v1alpha1storage[scannerreportsapi.ResourceImages] = imagestorage.NewStorage(ctrlClient) - v1alpha1storage[scannerreportsapi.ResourceCVEReports] = reportstorage.NewStorage(ctrlClient) + v1alpha1storage[scannerreportsapi.ResourceCVEReports] = reportstorage.NewStorage(ctrlClient, rbacAuthorizer) apiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = v1alpha1storage if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil { @@ -413,7 +413,7 @@ func (c completedConfig) New(ctx context.Context) (*UIServer, error) { apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(policyapi.GroupName, Scheme, metav1.ParameterCodec, Codecs) v1alpha1storage := map[string]rest.Storage{} - v1alpha1storage[policyapi.ResourcePolicyReports] = policystorage.NewStorage(ctrlClient) + v1alpha1storage[policyapi.ResourcePolicyReports] = policystorage.NewStorage(ctrlClient, rbacAuthorizer) apiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = v1alpha1storage if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil { @@ -424,7 +424,7 @@ func (c completedConfig) New(ctx context.Context) (*UIServer, error) { apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(costapi.GroupName, Scheme, metav1.ParameterCodec, Codecs) v1alpha1storage := map[string]rest.Storage{} - v1alpha1storage[costapi.ResourceCostReports] = coststorage.NewStorage(ctrlClient) + v1alpha1storage[costapi.ResourceCostReports] = coststorage.NewStorage(ctrlClient, rbacAuthorizer) apiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = v1alpha1storage if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil { diff --git a/pkg/registry/cost/reports/storage.go b/pkg/registry/cost/reports/storage.go index 07a270fd1a..be27750ee4 100644 --- a/pkg/registry/cost/reports/storage.go +++ b/pkg/registry/cost/reports/storage.go @@ -25,6 +25,7 @@ import ( "strings" costapi "kubeops.dev/ui-server/apis/cost/v1alpha1" + "kubeops.dev/ui-server/pkg/shared" gs "github.com/gorilla/schema" "github.com/pkg/errors" @@ -34,6 +35,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/rest" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -45,6 +47,7 @@ var ( type Storage struct { kc client.Client + a authorizer.Authorizer } var ( @@ -55,9 +58,10 @@ var ( _ rest.SingularNameProvider = &Storage{} ) -func NewStorage(kc client.Client) *Storage { +func NewStorage(kc client.Client, a authorizer.Authorizer) *Storage { return &Storage{ kc: kc, + a: a, } } @@ -82,6 +86,12 @@ func (r *Storage) Destroy() {} func (r *Storage) Create(ctx context.Context, obj runtime.Object, _ rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) { in := obj.(*costapi.CostReport) + // The cost report aggregates spend across the whole cluster and has no single + // resource to scope to, so require cluster-wide read access. + if err := shared.Authorize(ctx, r.a, shared.ClusterReadAttributes()); err != nil { + return nil, err + } + once.Do(func() error { var svcs core.ServiceList err := r.kc.List(ctx, &svcs, client.MatchingLabels{ diff --git a/pkg/registry/policy/reports/storage.go b/pkg/registry/policy/reports/storage.go index 4c8ce7317a..c35ce07b41 100644 --- a/pkg/registry/policy/reports/storage.go +++ b/pkg/registry/policy/reports/storage.go @@ -29,10 +29,12 @@ import ( "kubeops.dev/ui-server/pkg/shared" "gomodules.xyz/sets" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/rest" kmapi "kmodules.xyz/client-go/api/v1" "kmodules.xyz/resource-metadata/apis/meta/v1alpha1" @@ -41,6 +43,7 @@ import ( type Storage struct { kc client.Client + a authorizer.Authorizer } var ( @@ -51,9 +54,10 @@ var ( _ rest.SingularNameProvider = &Storage{} ) -func NewStorage(kc client.Client) *Storage { +func NewStorage(kc client.Client, a authorizer.Authorizer) *Storage { return &Storage{ kc: kc, + a: a, } } @@ -75,9 +79,51 @@ func (r *Storage) New() runtime.Object { func (r *Storage) Destroy() {} +// authorize ensures the caller may read the scope whose policy violations are reported: +// cluster scope requires cluster-wide read, namespace scope requires access to the +// namespace, and a resource scope requires get access to the referenced object. +func (r *Storage) authorize(ctx context.Context, req *policyapi.PolicyReportRequest) error { + if req == nil || shared.IsClusterRequest(&req.ObjectInfo) { + return shared.Authorize(ctx, r.a, shared.ClusterReadAttributes()) + } + if shared.IsNamespaceRequest(&req.ObjectInfo) { + return shared.Authorize(ctx, r.a, authorizer.AttributesRecord{ + Verb: "get", + Resource: "namespaces", + Name: req.Ref.Name, + }) + } + + rid := req.Resource + if rid.Kind == "" { + r2, err := kmapi.ExtractResourceID(r.kc.RESTMapper(), req.Resource) + if err != nil { + return err + } + rid = *r2 + } + mapping, err := r.kc.RESTMapper().RESTMapping(schema.GroupKind{Group: rid.Group, Kind: rid.Kind}) + if err != nil { + return apierrors.NewInternalError(err) + } + return shared.Authorize(ctx, r.a, authorizer.AttributesRecord{ + Verb: "get", + APIGroup: mapping.Resource.Group, + Resource: mapping.Resource.Resource, + Namespace: req.Ref.Namespace, + Name: req.Ref.Name, + }) +} + func (r *Storage) Create(ctx context.Context, obj runtime.Object, _ rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) { in := obj.(*policyapi.PolicyReport) + // The policy report exposes constraint violations for the requested scope, so require + // the caller to be allowed to read that scope before returning it. + if err := r.authorize(ctx, in.Request); err != nil { + return nil, err + } + var ( scp scopeDetails resourceGraph *v1alpha1.ResourceGraphResponse diff --git a/pkg/registry/scanner/reports/storage.go b/pkg/registry/scanner/reports/storage.go index b651f406e7..dcd49291fa 100644 --- a/pkg/registry/scanner/reports/storage.go +++ b/pkg/registry/scanner/reports/storage.go @@ -33,6 +33,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/rest" kmapi "kmodules.xyz/client-go/api/v1" "kmodules.xyz/client-go/client/apiutil" @@ -42,6 +43,7 @@ import ( type Storage struct { kc client.Client + a authorizer.Authorizer } var ( @@ -52,9 +54,10 @@ var ( _ rest.SingularNameProvider = &Storage{} ) -func NewStorage(kc client.Client) *Storage { +func NewStorage(kc client.Client, a authorizer.Authorizer) *Storage { return &Storage{ kc: kc, + a: a, } } @@ -76,6 +79,59 @@ func (r *Storage) New() runtime.Object { func (r *Storage) Destroy() {} +// authorize ensures the caller may read the pods that back the requested CVE report. +// The required access mirrors what graph.LocatePods reads for each request scope. +func (r *Storage) authorize(ctx context.Context, oi *kmapi.ObjectInfo) error { + switch { + case shared.IsClusterRequest(oi), shared.IsImageRequest(oi), shared.IsClusterCVERequest(oi): + // enumerates pods across the whole cluster + return shared.Authorize(ctx, r.a, authorizer.AttributesRecord{ + Verb: "list", + Resource: "pods", + }) + case shared.IsNamespaceRequest(oi): + return shared.Authorize(ctx, r.a, authorizer.AttributesRecord{ + Verb: "list", + Resource: "pods", + Namespace: oi.Ref.Name, + }) + case shared.IsNamespaceCVERequest(oi): + return shared.Authorize(ctx, r.a, authorizer.AttributesRecord{ + Verb: "list", + Resource: "pods", + Namespace: oi.Ref.Namespace, + }) + case shared.IsPodRequest(oi): + return shared.Authorize(ctx, r.a, authorizer.AttributesRecord{ + Verb: "get", + Resource: "pods", + Namespace: oi.Ref.Namespace, + Name: oi.Ref.Name, + }) + default: + // object request (e.g. a workload): require get access to the referenced object + rid := oi.Resource + if rid.Kind == "" { + r2, err := kmapi.ExtractResourceID(r.kc.RESTMapper(), oi.Resource) + if err != nil { + return err + } + rid = *r2 + } + mapping, err := r.kc.RESTMapper().RESTMapping(schema.GroupKind{Group: rid.Group, Kind: rid.Kind}) + if err != nil { + return apierrors.NewInternalError(err) + } + return shared.Authorize(ctx, r.a, authorizer.AttributesRecord{ + Verb: "get", + APIGroup: mapping.Resource.Group, + Resource: mapping.Resource.Resource, + Namespace: oi.Ref.Namespace, + Name: oi.Ref.Name, + }) + } +} + func (r *Storage) Create(ctx context.Context, obj runtime.Object, _ rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) { in := obj.(*reportsapi.CVEReport) @@ -83,6 +139,13 @@ func (r *Storage) Create(ctx context.Context, obj runtime.Object, _ rest.Validat if in.Request != nil { oi = &in.Request.ObjectInfo } + + // The CVE report enumerates the pods (and therefore the images) of the requested + // scope, so require the caller to be allowed to read that scope before returning it. + if err := r.authorize(ctx, oi); err != nil { + return nil, err + } + pods, err := graph.LocatePods(ctx, r.kc, oi) if err != nil { return nil, err diff --git a/pkg/shared/authz.go b/pkg/shared/authz.go new file mode 100644 index 0000000000..4ec306f388 --- /dev/null +++ b/pkg/shared/authz.go @@ -0,0 +1,64 @@ +/* +Copyright AppsCode Inc. and Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package shared + +import ( + "context" + "errors" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authorization/authorizer" + apirequest "k8s.io/apiserver/pkg/endpoints/request" +) + +// Authorize resolves the requesting user from ctx, fills it into attrs, and asks the +// authorizer for a decision. It returns a Forbidden error when access is denied so the +// aggregated apiserver surfaces a proper 403 to the caller. +// +// Callers only need to set the resource-identifying fields of attrs (Verb, APIGroup, +// Resource, Namespace, Name); User and ResourceRequest are set here. +func Authorize(ctx context.Context, a authorizer.Authorizer, attrs authorizer.AttributesRecord) error { + user, ok := apirequest.UserFrom(ctx) + if !ok { + return apierrors.NewBadRequest("missing user info in request context") + } + attrs.User = user + attrs.ResourceRequest = true + + decision, why, err := a.Authorize(ctx, attrs) + if err != nil { + return apierrors.NewInternalError(err) + } + if decision != authorizer.DecisionAllow { + gr := schema.GroupResource{Group: attrs.APIGroup, Resource: attrs.Resource} + return apierrors.NewForbidden(gr, attrs.Name, errors.New(why)) + } + return nil +} + +// ClusterReadAttributes returns attributes representing cluster-wide read access +// (get on all resources in all groups). Only callers effectively granted cluster-admin +// satisfy this check. Used to gate endpoints that expose whole-cluster data with no +// single resource to authorize against. +func ClusterReadAttributes() authorizer.AttributesRecord { + return authorizer.AttributesRecord{ + Verb: "get", + APIGroup: "*", + Resource: "*", + } +} From d362ae57144744d5d4c1c01c397e4c920683c2db Mon Sep 17 00:00:00 2001 From: Tamal Saha Date: Thu, 25 Jun 2026 20:12:30 +0600 Subject: [PATCH 2/2] test: cover authz gating of cost, policy and scanner reports Add unit tests using a fake authorizer (and a fake client with a static RESTMapper) that assert each report endpoint denies with a Forbidden status and authorizes against the expected attributes for every request scope: - shared.Authorize: allow, deny->Forbidden, missing-user->BadRequest, and ClusterReadAttributes shape. - scanner CVEReport: cluster/namespace/pod/object scopes map to the right pod/object access. - policy PolicyReport: cluster/namespace/resource scopes. - cost CostReport: requires cluster-wide read; missing user is a BadRequest. Signed-off-by: Tamal Saha --- pkg/registry/cost/reports/storage_test.go | 74 +++++++++++ pkg/registry/policy/reports/storage_test.go | 121 +++++++++++++++++ pkg/registry/scanner/reports/storage_test.go | 132 +++++++++++++++++++ pkg/shared/authz_test.go | 79 +++++++++++ 4 files changed, 406 insertions(+) create mode 100644 pkg/registry/cost/reports/storage_test.go create mode 100644 pkg/registry/policy/reports/storage_test.go create mode 100644 pkg/registry/scanner/reports/storage_test.go create mode 100644 pkg/shared/authz_test.go diff --git a/pkg/registry/cost/reports/storage_test.go b/pkg/registry/cost/reports/storage_test.go new file mode 100644 index 0000000000..4025328451 --- /dev/null +++ b/pkg/registry/cost/reports/storage_test.go @@ -0,0 +1,74 @@ +/* +Copyright AppsCode Inc. and Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reports + +import ( + "context" + "testing" + + costapi "kubeops.dev/ui-server/apis/cost/v1alpha1" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + apirequest "k8s.io/apiserver/pkg/endpoints/request" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type fakeAuthorizer struct { + decision authorizer.Decision + got authorizer.Attributes +} + +func (f *fakeAuthorizer) Authorize(_ context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + f.got = a + return f.decision, "because test", nil +} + +func ctxWithUser() context.Context { + return apirequest.WithUser(context.Background(), &user.DefaultInfo{Name: "tester"}) +} + +// TestCreateDeniedWithoutClusterReadAccess verifies the cost report requires cluster-wide +// read access: a denied authorizer yields a Forbidden response, and the check asks for +// "get" on */*. Because the report has no single resource to scope to, the gate must run +// before any OpenCost call is attempted. +func TestCreateDeniedWithoutClusterReadAccess(t *testing.T) { + fa := &fakeAuthorizer{decision: authorizer.DecisionDeny} + kc := fake.NewClientBuilder().Build() + r := NewStorage(kc, fa) + + _, err := r.Create(ctxWithUser(), &costapi.CostReport{}, nil, nil) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected Forbidden, got %v", err) + } + if fa.got.GetVerb() != "get" || fa.got.GetAPIGroup() != "*" || fa.got.GetResource() != "*" { + t.Errorf("expected cluster-wide read (get */*), got verb=%q group=%q resource=%q", + fa.got.GetVerb(), fa.got.GetAPIGroup(), fa.got.GetResource()) + } +} + +func TestCreateMissingUserIsBadRequest(t *testing.T) { + fa := &fakeAuthorizer{decision: authorizer.DecisionAllow} + kc := fake.NewClientBuilder().Build() + r := NewStorage(kc, fa) + + _, err := r.Create(context.Background(), &costapi.CostReport{}, nil, nil) + if !apierrors.IsBadRequest(err) { + t.Fatalf("expected BadRequest for missing user, got %v", err) + } +} diff --git a/pkg/registry/policy/reports/storage_test.go b/pkg/registry/policy/reports/storage_test.go new file mode 100644 index 0000000000..0ef2869f24 --- /dev/null +++ b/pkg/registry/policy/reports/storage_test.go @@ -0,0 +1,121 @@ +/* +Copyright AppsCode Inc. and Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reports + +import ( + "context" + "testing" + + policyapi "kubeops.dev/ui-server/apis/policy/v1alpha1" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + apirequest "k8s.io/apiserver/pkg/endpoints/request" + kmapi "kmodules.xyz/client-go/api/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type fakeAuthorizer struct { + decision authorizer.Decision + got authorizer.Attributes +} + +func (f *fakeAuthorizer) Authorize(_ context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + f.got = a + return f.decision, "because test", nil +} + +func ctxWithUser() context.Context { + return apirequest.WithUser(context.Background(), &user.DefaultInfo{Name: "tester"}) +} + +func testMapper() meta.RESTMapper { + m := meta.NewDefaultRESTMapper([]schema.GroupVersion{{Group: "apps", Version: "v1"}}) + m.Add(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, meta.RESTScopeNamespace) + return m +} + +// TestAuthorizeScopes verifies that each PolicyReport scope is gated against the expected +// access before any violation data is read. The authorizer denies, so Create returns +// Forbidden and we assert on the attributes it was asked about. +func TestAuthorizeScopes(t *testing.T) { + cases := []struct { + name string + req *policyapi.PolicyReportRequest + wantVerb string + wantAPIGroup string + wantResource string + wantNS string + wantName string + }{ + { + name: "cluster", + req: nil, + wantVerb: "get", + wantAPIGroup: "*", + wantResource: "*", + }, + { + name: "namespace", + req: &policyapi.PolicyReportRequest{ObjectInfo: kmapi.ObjectInfo{Resource: kmapi.ResourceID{Name: "namespaces"}, Ref: kmapi.ObjectReference{Name: "ns1"}}}, + wantVerb: "get", + wantResource: "namespaces", + wantName: "ns1", + }, + { + name: "resource", + req: &policyapi.PolicyReportRequest{ObjectInfo: kmapi.ObjectInfo{Resource: kmapi.ResourceID{Group: "apps", Kind: "Deployment"}, Ref: kmapi.ObjectReference{Namespace: "ns3", Name: "dep1"}}}, + wantVerb: "get", + wantAPIGroup: "apps", + wantResource: "deployments", + wantNS: "ns3", + wantName: "dep1", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fa := &fakeAuthorizer{decision: authorizer.DecisionDeny} + kc := fake.NewClientBuilder().WithRESTMapper(testMapper()).Build() + r := NewStorage(kc, fa) + + in := &policyapi.PolicyReport{Request: tc.req} + _, err := r.Create(ctxWithUser(), in, nil, nil) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected Forbidden, got %v", err) + } + if got := fa.got.GetVerb(); got != tc.wantVerb { + t.Errorf("verb: got %q want %q", got, tc.wantVerb) + } + if got := fa.got.GetAPIGroup(); got != tc.wantAPIGroup { + t.Errorf("apiGroup: got %q want %q", got, tc.wantAPIGroup) + } + if got := fa.got.GetResource(); got != tc.wantResource { + t.Errorf("resource: got %q want %q", got, tc.wantResource) + } + if got := fa.got.GetNamespace(); got != tc.wantNS { + t.Errorf("namespace: got %q want %q", got, tc.wantNS) + } + if got := fa.got.GetName(); got != tc.wantName { + t.Errorf("name: got %q want %q", got, tc.wantName) + } + }) + } +} diff --git a/pkg/registry/scanner/reports/storage_test.go b/pkg/registry/scanner/reports/storage_test.go new file mode 100644 index 0000000000..3b30cb4d9f --- /dev/null +++ b/pkg/registry/scanner/reports/storage_test.go @@ -0,0 +1,132 @@ +/* +Copyright AppsCode Inc. and Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reports + +import ( + "context" + "testing" + + reportsapi "kubeops.dev/scanner/apis/reports/v1alpha1" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + apirequest "k8s.io/apiserver/pkg/endpoints/request" + kmapi "kmodules.xyz/client-go/api/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type fakeAuthorizer struct { + decision authorizer.Decision + got authorizer.Attributes +} + +func (f *fakeAuthorizer) Authorize(_ context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + f.got = a + return f.decision, "because test", nil +} + +func ctxWithUser() context.Context { + return apirequest.WithUser(context.Background(), &user.DefaultInfo{Name: "tester"}) +} + +func testMapper() meta.RESTMapper { + m := meta.NewDefaultRESTMapper([]schema.GroupVersion{{Group: "apps", Version: "v1"}}) + m.Add(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, meta.RESTScopeNamespace) + return m +} + +// TestAuthorizeScopes verifies that each CVEReport request scope is gated against the +// expected access before any cluster data is read. The authorizer denies, so Create +// returns Forbidden and we assert on the attributes it was asked about. +func TestAuthorizeScopes(t *testing.T) { + cases := []struct { + name string + oi *kmapi.ObjectInfo + wantVerb string + wantAPIGroup string + wantResource string + wantNS string + wantName string + }{ + { + name: "cluster", + oi: nil, + wantVerb: "list", + wantResource: "pods", + }, + { + name: "namespace", + oi: &kmapi.ObjectInfo{Resource: kmapi.ResourceID{Name: "namespaces"}, Ref: kmapi.ObjectReference{Name: "ns1"}}, + wantVerb: "list", + wantResource: "pods", + wantNS: "ns1", + }, + { + name: "pod", + oi: &kmapi.ObjectInfo{Resource: kmapi.ResourceID{Name: "pods"}, Ref: kmapi.ObjectReference{Namespace: "ns2", Name: "pod1"}}, + wantVerb: "get", + wantResource: "pods", + wantNS: "ns2", + wantName: "pod1", + }, + { + name: "object", + oi: &kmapi.ObjectInfo{Resource: kmapi.ResourceID{Group: "apps", Kind: "Deployment"}, Ref: kmapi.ObjectReference{Namespace: "ns3", Name: "dep1"}}, + wantVerb: "get", + wantAPIGroup: "apps", + wantResource: "deployments", + wantNS: "ns3", + wantName: "dep1", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fa := &fakeAuthorizer{decision: authorizer.DecisionDeny} + kc := fake.NewClientBuilder().WithRESTMapper(testMapper()).Build() + r := NewStorage(kc, fa) + + in := &reportsapi.CVEReport{} + if tc.oi != nil { + in.Request = &reportsapi.CVEReportRequest{ObjectInfo: *tc.oi} + } + + _, err := r.Create(ctxWithUser(), in, nil, nil) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected Forbidden, got %v", err) + } + if got := fa.got.GetVerb(); got != tc.wantVerb { + t.Errorf("verb: got %q want %q", got, tc.wantVerb) + } + if got := fa.got.GetAPIGroup(); got != tc.wantAPIGroup { + t.Errorf("apiGroup: got %q want %q", got, tc.wantAPIGroup) + } + if got := fa.got.GetResource(); got != tc.wantResource { + t.Errorf("resource: got %q want %q", got, tc.wantResource) + } + if got := fa.got.GetNamespace(); got != tc.wantNS { + t.Errorf("namespace: got %q want %q", got, tc.wantNS) + } + if got := fa.got.GetName(); got != tc.wantName { + t.Errorf("name: got %q want %q", got, tc.wantName) + } + }) + } +} diff --git a/pkg/shared/authz_test.go b/pkg/shared/authz_test.go new file mode 100644 index 0000000000..9171a2ac85 --- /dev/null +++ b/pkg/shared/authz_test.go @@ -0,0 +1,79 @@ +/* +Copyright AppsCode Inc. and Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package shared + +import ( + "context" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/authorization/authorizer" + apirequest "k8s.io/apiserver/pkg/endpoints/request" +) + +type fakeAuthorizer struct { + decision authorizer.Decision + err error + got authorizer.Attributes +} + +func (f *fakeAuthorizer) Authorize(_ context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + f.got = a + return f.decision, "because test", f.err +} + +func ctxWithUser() context.Context { + return apirequest.WithUser(context.Background(), &user.DefaultInfo{Name: "tester"}) +} + +func TestAuthorizeAllow(t *testing.T) { + fa := &fakeAuthorizer{decision: authorizer.DecisionAllow} + err := Authorize(ctxWithUser(), fa, authorizer.AttributesRecord{Verb: "get", Resource: "pods"}) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if !fa.got.IsResourceRequest() { + t.Error("expected ResourceRequest to be set") + } + if fa.got.GetUser() == nil || fa.got.GetUser().GetName() != "tester" { + t.Errorf("user not propagated to authorizer: %+v", fa.got.GetUser()) + } +} + +func TestAuthorizeDenyIsForbidden(t *testing.T) { + fa := &fakeAuthorizer{decision: authorizer.DecisionDeny} + err := Authorize(ctxWithUser(), fa, authorizer.AttributesRecord{Verb: "get", Resource: "pods"}) + if !apierrors.IsForbidden(err) { + t.Fatalf("expected Forbidden, got %v", err) + } +} + +func TestAuthorizeMissingUserIsBadRequest(t *testing.T) { + fa := &fakeAuthorizer{decision: authorizer.DecisionAllow} + err := Authorize(context.Background(), fa, authorizer.AttributesRecord{Verb: "get", Resource: "pods"}) + if !apierrors.IsBadRequest(err) { + t.Fatalf("expected BadRequest, got %v", err) + } +} + +func TestClusterReadAttributes(t *testing.T) { + a := ClusterReadAttributes() + if a.Verb != "get" || a.APIGroup != "*" || a.Resource != "*" { + t.Errorf("unexpected cluster read attributes: %+v", a) + } +}