From 84dedac146d49c3a51507019e1a7b7f9e1dc3192 Mon Sep 17 00:00:00 2001
From: Colleen Murphy <colleen.murphy@suse.com>
Date: Wed, 10 May 2023 15:31:01 -0700
Subject: [PATCH] Add projectsornamespaces query parameter

Add a new query parameter to filter resources by their namespace or
their namespace's project. This parameter is separate from the existing
`filter` parameter.

Filter by a comma-separated list of projects and/or namespaces with:

?projectsornamespaces=p1,n1,n2

The result can be negated with the ! operator:

?projectsornamespaces!=p1,n1,n2
---
 README.md                                     |  30 +
 pkg/resources/common/formatter.go             |   6 +-
 pkg/resources/schema.go                       |   6 +-
 pkg/server/server.go                          |   2 +-
 .../partition/listprocessor/processor.go      | 108 ++-
 .../partition/listprocessor/processor_test.go | 670 ++++++++++++++++++
 pkg/stores/partition/store.go                 |  16 +-
 pkg/stores/partition/store_test.go            | 181 ++++-
 pkg/stores/proxy/proxy_store.go               |   4 +-
 9 files changed, 985 insertions(+), 38 deletions(-)

diff --git a/README.md b/README.md
index 935b79e..49347ae 100644
--- a/README.md
+++ b/README.md
@@ -117,6 +117,36 @@ item is included in the list.
 /v1/{type}?filter=spec.containers.image=alpine
 ```
 
+#### `projectsornamespaces`
+
+Resources can also be filtered by the Rancher projects their namespaces belong
+to. Since a project isn't an intrinsic part of the resource itself, the filter
+parameter for filtering by projects is separate from the main `filter`
+parameter. This query parameter is only applicable when steve is runnning in
+concert with Rancher.
+
+The list can be filtered by either projects or namespaces or both.
+
+Filtering by a single project or a single namespace:
+
+```
+/v1/{type}?projectsornamespaces=p1
+```
+
+Filtering by multiple projects or namespaces is done with a comma separated
+list. A resource matching any project or namespace in the list is included in
+the result:
+
+```
+/v1/{type}?projectsornamespaces=p1,n1,n2
+```
+
+The list can be negated to exclude results:
+
+```
+/v1/{type}?projectsornamespaces!=p1,n1,n2
+```
+
 #### `sort`
 
 Only applicable to list requests (`/v1/{type}` and `/v1/{type}/{namespace}`).
diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go
index 2922481..6ac320a 100644
--- a/pkg/resources/common/formatter.go
+++ b/pkg/resources/common/formatter.go
@@ -11,6 +11,7 @@ import (
 	"github.com/rancher/steve/pkg/stores/proxy"
 	"github.com/rancher/steve/pkg/summarycache"
 	"github.com/rancher/wrangler/pkg/data"
+	corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1"
 	"github.com/rancher/wrangler/pkg/slice"
 	"github.com/rancher/wrangler/pkg/summary"
 	"k8s.io/apimachinery/pkg/api/meta"
@@ -21,9 +22,10 @@ import (
 
 func DefaultTemplate(clientGetter proxy.ClientGetter,
 	summaryCache *summarycache.SummaryCache,
-	asl accesscontrol.AccessSetLookup) schema.Template {
+	asl accesscontrol.AccessSetLookup,
+	namespaceCache corecontrollers.NamespaceCache) schema.Template {
 	return schema.Template{
-		Store:     metricsStore.NewMetricsStore(proxy.NewProxyStore(clientGetter, summaryCache, asl)),
+		Store:     metricsStore.NewMetricsStore(proxy.NewProxyStore(clientGetter, summaryCache, asl, namespaceCache)),
 		Formatter: formatter(summaryCache),
 	}
 }
diff --git a/pkg/resources/schema.go b/pkg/resources/schema.go
index ff3fee1..148f9e2 100644
--- a/pkg/resources/schema.go
+++ b/pkg/resources/schema.go
@@ -19,6 +19,7 @@ import (
 	steveschema "github.com/rancher/steve/pkg/schema"
 	"github.com/rancher/steve/pkg/stores/proxy"
 	"github.com/rancher/steve/pkg/summarycache"
+	corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1"
 	"k8s.io/apiserver/pkg/endpoints/request"
 	"k8s.io/client-go/discovery"
 )
@@ -46,9 +47,10 @@ func DefaultSchemaTemplates(cf *client.Factory,
 	baseSchemas *types.APISchemas,
 	summaryCache *summarycache.SummaryCache,
 	lookup accesscontrol.AccessSetLookup,
-	discovery discovery.DiscoveryInterface) []schema.Template {
+	discovery discovery.DiscoveryInterface,
+	namespaceCache corecontrollers.NamespaceCache) []schema.Template {
 	return []schema.Template{
-		common.DefaultTemplate(cf, summaryCache, lookup),
+		common.DefaultTemplate(cf, summaryCache, lookup, namespaceCache),
 		apigroups.Template(discovery),
 		{
 			ID:        "configmap",
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 788e0fc..06f1c0e 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -145,7 +145,7 @@ func setup(ctx context.Context, server *Server) error {
 	summaryCache := summarycache.New(sf, ccache)
 	summaryCache.Start(ctx)
 
-	for _, template := range resources.DefaultSchemaTemplates(cf, server.BaseSchemas, summaryCache, asl, server.controllers.K8s.Discovery()) {
+	for _, template := range resources.DefaultSchemaTemplates(cf, server.BaseSchemas, summaryCache, asl, server.controllers.K8s.Discovery(), server.controllers.Core.Namespace().Cache()) {
 		sf.AddTemplate(template)
 	}
 
diff --git a/pkg/stores/partition/listprocessor/processor.go b/pkg/stores/partition/listprocessor/processor.go
index a316a53..f028e5a 100644
--- a/pkg/stores/partition/listprocessor/processor.go
+++ b/pkg/stores/partition/listprocessor/processor.go
@@ -10,19 +10,24 @@ import (
 	"github.com/rancher/apiserver/pkg/types"
 	"github.com/rancher/wrangler/pkg/data"
 	"github.com/rancher/wrangler/pkg/data/convert"
+	corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 )
 
 const (
-	defaultLimit  = 100000
-	continueParam = "continue"
-	limitParam    = "limit"
-	filterParam   = "filter"
-	sortParam     = "sort"
-	pageSizeParam = "pagesize"
-	pageParam     = "page"
-	revisionParam = "revision"
-	orOp          = ","
+	defaultLimit            = 100000
+	continueParam           = "continue"
+	limitParam              = "limit"
+	filterParam             = "filter"
+	sortParam               = "sort"
+	pageSizeParam           = "pagesize"
+	pageParam               = "page"
+	revisionParam           = "revision"
+	projectsOrNamespacesVar = "projectsornamespaces"
+	projectIDFieldLabel     = "field.cattle.io/projectId"
+
+	orOp  = ","
+	notOp = "!"
 )
 
 var opReg = regexp.MustCompile(`[!]?=`)
@@ -36,12 +41,13 @@ const (
 
 // ListOptions represents the query parameters that may be included in a list request.
 type ListOptions struct {
-	ChunkSize  int
-	Resume     string
-	Filters    []OrFilter
-	Sort       Sort
-	Pagination Pagination
-	Revision   string
+	ChunkSize            int
+	Resume               string
+	Filters              []OrFilter
+	Sort                 Sort
+	Pagination           Pagination
+	Revision             string
+	ProjectsOrNamespaces ProjectsOrNamespacesFilter
 }
 
 // Filter represents a field to filter by.
@@ -127,11 +133,21 @@ func (p Pagination) PageSize() int {
 	return p.pageSize
 }
 
+type ProjectsOrNamespacesFilter struct {
+	filter map[string]struct{}
+	op     op
+}
+
 // ParseQuery parses the query params of a request and returns a ListOptions.
 func ParseQuery(apiOp *types.APIRequest) *ListOptions {
-	chunkSize := getLimit(apiOp)
+	opts := ListOptions{}
+
+	opts.ChunkSize = getLimit(apiOp)
+
 	q := apiOp.Request.URL.Query()
 	cont := q.Get(continueParam)
+	opts.Resume = cont
+
 	filterParams := q[filterParam]
 	filterOpts := []OrFilter{}
 	for _, filters := range filterParams {
@@ -168,6 +184,8 @@ func ParseQuery(apiOp *types.APIRequest) *ListOptions {
 		}
 		return fieldI.String() < fieldJ.String()
 	})
+	opts.Filters = filterOpts
+
 	sortOpts := Sort{}
 	sortKeys := q.Get(sortParam)
 	if sortKeys != "" {
@@ -191,6 +209,8 @@ func ParseQuery(apiOp *types.APIRequest) *ListOptions {
 			}
 		}
 	}
+	opts.Sort = sortOpts
+
 	var err error
 	pagination := Pagination{}
 	pagination.pageSize, err = strconv.Atoi(q.Get(pageSizeParam))
@@ -201,15 +221,29 @@ func ParseQuery(apiOp *types.APIRequest) *ListOptions {
 	if err != nil {
 		pagination.page = 1
 	}
+	opts.Pagination = pagination
+
 	revision := q.Get(revisionParam)
-	return &ListOptions{
-		ChunkSize:  chunkSize,
-		Resume:     cont,
-		Filters:    filterOpts,
-		Sort:       sortOpts,
-		Pagination: pagination,
-		Revision:   revision,
+	opts.Revision = revision
+
+	projectsOptions := ProjectsOrNamespacesFilter{}
+	var op op
+	projectsOrNamespaces := q.Get(projectsOrNamespacesVar)
+	if projectsOrNamespaces == "" {
+		projectsOrNamespaces = q.Get(projectsOrNamespacesVar + notOp)
+		if projectsOrNamespaces != "" {
+			op = notEq
+		}
 	}
+	if projectsOrNamespaces != "" {
+		projectsOptions.filter = make(map[string]struct{})
+		for _, pn := range strings.Split(projectsOrNamespaces, ",") {
+			projectsOptions.filter[pn] = struct{}{}
+		}
+		projectsOptions.op = op
+		opts.ProjectsOrNamespaces = projectsOptions
+	}
+	return &opts
 }
 
 // getLimit extracts the limit parameter from the request or sets a default of 100000.
@@ -360,3 +394,31 @@ func PaginateList(list []unstructured.Unstructured, p Pagination) ([]unstructure
 	}
 	return list[offset : offset+p.pageSize], pages
 }
+
+func FilterByProjectsAndNamespaces(list []unstructured.Unstructured, projectsOrNamespaces ProjectsOrNamespacesFilter, namespaceCache corecontrollers.NamespaceCache) []unstructured.Unstructured {
+	if len(projectsOrNamespaces.filter) == 0 {
+		return list
+	}
+	result := []unstructured.Unstructured{}
+	for _, obj := range list {
+		namespaceName := obj.GetNamespace()
+		if namespaceName == "" {
+			continue
+		}
+		namespace, err := namespaceCache.Get(namespaceName)
+		if namespace == nil || err != nil {
+			continue
+		}
+		projectLabel, _ := namespace.GetLabels()[projectIDFieldLabel]
+		_, matchesProject := projectsOrNamespaces.filter[projectLabel]
+		_, matchesNamespace := projectsOrNamespaces.filter[namespaceName]
+		matches := matchesProject || matchesNamespace
+		if projectsOrNamespaces.op == eq && matches {
+			result = append(result, obj)
+		}
+		if projectsOrNamespaces.op == notEq && !matches {
+			result = append(result, obj)
+		}
+	}
+	return result
+}
diff --git a/pkg/stores/partition/listprocessor/processor_test.go b/pkg/stores/partition/listprocessor/processor_test.go
index a5e7c96..d17c0ca 100644
--- a/pkg/stores/partition/listprocessor/processor_test.go
+++ b/pkg/stores/partition/listprocessor/processor_test.go
@@ -3,8 +3,12 @@ package listprocessor
 import (
 	"testing"
 
+	corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1"
 	"github.com/stretchr/testify/assert"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/labels"
 )
 
 func TestFilterList(t *testing.T) {
@@ -2567,3 +2571,669 @@ func TestPaginateList(t *testing.T) {
 		})
 	}
 }
+
+func TestFilterByProjectsAndNamespaces(t *testing.T) {
+	tests := []struct {
+		name    string
+		objects []unstructured.Unstructured
+		filter  ProjectsOrNamespacesFilter
+		want    []unstructured.Unstructured
+	}{
+		{
+			name: "filter by one project",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n2",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"p-abcde": struct{}{},
+				},
+				op: eq,
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "filter by multiple projects",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"p-abcde": struct{}{},
+					"p-fghij": struct{}{},
+				},
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "filter by one namespace",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n2",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"n1": struct{}{},
+				},
+				op: eq,
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "filter by multiple namespaces",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"n1": struct{}{},
+					"n2": struct{}{},
+				},
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "filter by namespaces and projects",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"n1":      struct{}{},
+					"p-fghij": struct{}{},
+				},
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "no matches",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"foobar": struct{}{},
+				},
+			},
+			want: []unstructured.Unstructured{},
+		},
+		{
+			name: "no filters",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "filter by one project negated",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"p-abcde": struct{}{},
+				},
+				op: notEq,
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "filter by multiple projects negated",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"p-abcde": struct{}{},
+					"p-fghij": struct{}{},
+				},
+				op: notEq,
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "filter by one namespace negated",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n2",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"n1": struct{}{},
+				},
+				op: notEq,
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n2",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "filter by multiple namespaces negated",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"n1": struct{}{},
+					"n2": struct{}{},
+				},
+				op: notEq,
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "filter by namespaces and projects negated",
+			objects: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "fuji",
+							"namespace": "n1",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "honeycrisp",
+							"namespace": "n2",
+						},
+					},
+				},
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+			filter: ProjectsOrNamespacesFilter{
+				filter: map[string]struct{}{
+					"n1":      struct{}{},
+					"p-fghij": struct{}{},
+				},
+				op: notEq,
+			},
+			want: []unstructured.Unstructured{
+				{
+					Object: map[string]interface{}{
+						"kind": "apple",
+						"metadata": map[string]interface{}{
+							"name":      "granny-smith",
+							"namespace": "n3",
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			got := FilterByProjectsAndNamespaces(test.objects, test.filter, mockNamespaceCache{})
+			assert.Equal(t, test.want, got)
+		})
+	}
+}
+
+var namespaces = map[string]*corev1.Namespace{
+	"n1": &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "n1",
+			Labels: map[string]string{
+				"field.cattle.io/projectId": "p-abcde",
+			},
+		},
+	},
+	"n2": &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "n2",
+			Labels: map[string]string{
+				"field.cattle.io/projectId": "p-fghij",
+			},
+		},
+	},
+	"n3": &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "n3",
+			Labels: map[string]string{
+				"field.cattle.io/projectId": "p-klmno",
+			},
+		},
+	},
+	"n4": &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "n4",
+		},
+	},
+}
+
+type mockNamespaceCache struct{}
+
+func (m mockNamespaceCache) Get(name string) (*corev1.Namespace, error) {
+	return namespaces[name], nil
+}
+
+func (m mockNamespaceCache) List(selector labels.Selector) ([]*corev1.Namespace, error) {
+	panic("not implemented")
+}
+func (m mockNamespaceCache) AddIndexer(indexName string, indexer corecontrollers.NamespaceIndexer) {
+	panic("not implemented")
+}
+func (m mockNamespaceCache) GetByIndex(indexName, key string) ([]*corev1.Namespace, error) {
+	panic("not implemented")
+}
diff --git a/pkg/stores/partition/store.go b/pkg/stores/partition/store.go
index 185a6cb..482d838 100644
--- a/pkg/stores/partition/store.go
+++ b/pkg/stores/partition/store.go
@@ -13,6 +13,7 @@ import (
 	"github.com/rancher/apiserver/pkg/types"
 	"github.com/rancher/steve/pkg/accesscontrol"
 	"github.com/rancher/steve/pkg/stores/partition/listprocessor"
+	corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1"
 	"github.com/sirupsen/logrus"
 	"golang.org/x/sync/errgroup"
 	"k8s.io/apimachinery/pkg/api/meta"
@@ -42,13 +43,14 @@ type Partitioner interface {
 
 // Store implements types.Store for partitions.
 type Store struct {
-	Partitioner Partitioner
-	listCache   *cache.LRUExpireCache
-	asl         accesscontrol.AccessSetLookup
+	Partitioner    Partitioner
+	listCache      *cache.LRUExpireCache
+	asl            accesscontrol.AccessSetLookup
+	namespaceCache corecontrollers.NamespaceCache
 }
 
 // NewStore creates a types.Store implementation with a partitioner and an LRU expiring cache for list responses.
-func NewStore(partitioner Partitioner, asl accesscontrol.AccessSetLookup) *Store {
+func NewStore(partitioner Partitioner, asl accesscontrol.AccessSetLookup, namespaceCache corecontrollers.NamespaceCache) *Store {
 	cacheSize := defaultCacheSize
 	if v := os.Getenv(cacheSizeEnv); v != "" {
 		sizeInt, err := strconv.Atoi(v)
@@ -57,8 +59,9 @@ func NewStore(partitioner Partitioner, asl accesscontrol.AccessSetLookup) *Store
 		}
 	}
 	s := &Store{
-		Partitioner: partitioner,
-		asl:         asl,
+		Partitioner:    partitioner,
+		asl:            asl,
+		namespaceCache: namespaceCache,
 	}
 	if v := os.Getenv(cacheDisableEnv); v == "" {
 		s.listCache = cache.NewLRUExpireCache(cacheSize)
@@ -203,6 +206,7 @@ func (s *Store) List(apiOp *types.APIRequest, schema *types.APISchema) (types.AP
 		listToCache := &unstructured.UnstructuredList{
 			Items: list,
 		}
+		list = listprocessor.FilterByProjectsAndNamespaces(list, opts.ProjectsOrNamespaces, s.namespaceCache)
 		c := lister.Continue()
 		if c != "" {
 			listToCache.SetContinue(c)
diff --git a/pkg/stores/partition/store_test.go b/pkg/stores/partition/store_test.go
index fdd666c..6d557bd 100644
--- a/pkg/stores/partition/store_test.go
+++ b/pkg/stores/partition/store_test.go
@@ -12,9 +12,13 @@ import (
 
 	"github.com/rancher/apiserver/pkg/types"
 	"github.com/rancher/steve/pkg/accesscontrol"
+	corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1"
 	"github.com/rancher/wrangler/pkg/schemas"
 	"github.com/stretchr/testify/assert"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/watch"
 	"k8s.io/apiserver/pkg/authentication/user"
 	"k8s.io/apiserver/pkg/endpoints/request"
@@ -1928,6 +1932,118 @@ func TestList(t *testing.T) {
 				{"all": 1},
 			},
 		},
+		{
+			name: "with project filters",
+			apiOps: []*types.APIRequest{
+				newRequest("projectsornamespaces=p-abcde", "user1"),
+				newRequest("projectsornamespaces=p-abcde,p-fghij", "user1"),
+				newRequest("projectsornamespaces=p-abcde,n2", "user1"),
+				newRequest("projectsornamespaces!=p-abcde", "user1"),
+				newRequest("projectsornamespaces!=p-abcde,p-fghij", "user1"),
+				newRequest("projectsornamespaces!=p-abcde,n2", "user1"),
+				newRequest("projectsornamespaces=foobar", "user1"),
+				newRequest("projectsornamespaces!=foobar", "user1"),
+			},
+			access: []map[string]string{
+				{
+					"user1": "roleA",
+				},
+				{
+					"user1": "roleA",
+				},
+				{
+					"user1": "roleA",
+				},
+				{
+					"user1": "roleA",
+				},
+				{
+					"user1": "roleA",
+				},
+				{
+					"user1": "roleA",
+				},
+				{
+					"user1": "roleA",
+				},
+				{
+					"user1": "roleA",
+				},
+			},
+			partitions: map[string][]Partition{
+				"user1": {
+					mockPartition{
+						name: "all",
+					},
+				},
+			},
+			objects: map[string]*unstructured.UnstructuredList{
+				"all": {
+					Items: []unstructured.Unstructured{
+						newApple("fuji").withNamespace("n1").Unstructured,
+						newApple("granny-smith").withNamespace("n1").Unstructured,
+						newApple("bramley").withNamespace("n2").Unstructured,
+						newApple("crispin").withNamespace("n3").Unstructured,
+					},
+				},
+			},
+			want: []types.APIObjectList{
+				{
+					Count: 2,
+					Objects: []types.APIObject{
+						newApple("fuji").withNamespace("n1").toObj(),
+						newApple("granny-smith").withNamespace("n1").toObj(),
+					},
+				},
+				{
+					Count: 3,
+					Objects: []types.APIObject{
+						newApple("fuji").withNamespace("n1").toObj(),
+						newApple("granny-smith").withNamespace("n1").toObj(),
+						newApple("bramley").withNamespace("n2").toObj(),
+					},
+				},
+				{
+					Count: 3,
+					Objects: []types.APIObject{
+						newApple("fuji").withNamespace("n1").toObj(),
+						newApple("granny-smith").withNamespace("n1").toObj(),
+						newApple("bramley").withNamespace("n2").toObj(),
+					},
+				},
+				{
+					Count: 2,
+					Objects: []types.APIObject{
+						newApple("bramley").withNamespace("n2").toObj(),
+						newApple("crispin").withNamespace("n3").toObj(),
+					},
+				},
+				{
+					Count: 1,
+					Objects: []types.APIObject{
+						newApple("crispin").withNamespace("n3").toObj(),
+					},
+				},
+				{
+					Count: 1,
+					Objects: []types.APIObject{
+						newApple("crispin").withNamespace("n3").toObj(),
+					},
+				},
+				{
+					Count: 0,
+				},
+				{
+					Count: 4,
+					Objects: []types.APIObject{
+						newApple("fuji").withNamespace("n1").toObj(),
+						newApple("granny-smith").withNamespace("n1").toObj(),
+						newApple("bramley").withNamespace("n2").toObj(),
+						newApple("crispin").withNamespace("n3").toObj(),
+					},
+				},
+			},
+		},
 	}
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
@@ -1947,7 +2063,7 @@ func TestList(t *testing.T) {
 			store := NewStore(mockPartitioner{
 				stores:     stores,
 				partitions: test.partitions,
-			}, asl)
+			}, asl, mockNamespaceCache{})
 			for i, req := range test.apiOps {
 				got, gotErr := store.List(req, schema)
 				assert.Nil(t, gotErr)
@@ -2022,7 +2138,7 @@ func TestListByRevision(t *testing.T) {
 				},
 			},
 		},
-	}, asl)
+	}, asl, mockNamespaceCache{})
 	req := newRequest("", "user1")
 	t.Setenv("CATTLE_REQUEST_CACHE_DISABLED", "Y")
 
@@ -2215,9 +2331,15 @@ func newApple(name string) apple {
 }
 
 func (a apple) toObj() types.APIObject {
+	meta := a.Object["metadata"].(map[string]interface{})
+	id := meta["name"].(string)
+	ns, ok := meta["namespace"]
+	if ok {
+		id = ns.(string) + "/" + id
+	}
 	return types.APIObject{
 		Type:   "apple",
-		ID:     a.Object["metadata"].(map[string]interface{})["name"].(string),
+		ID:     id,
 		Object: &a.Unstructured,
 	}
 }
@@ -2229,6 +2351,11 @@ func (a apple) with(data map[string]string) apple {
 	return a
 }
 
+func (a apple) withNamespace(namespace string) apple {
+	a.Object["metadata"].(map[string]interface{})["namespace"] = namespace
+	return a
+}
+
 type mockAccessSetLookup struct {
 	accessID  string
 	userRoles []map[string]string
@@ -2251,3 +2378,51 @@ func getAccessID(user, role string) string {
 	h := sha256.Sum256([]byte(user + role))
 	return string(h[:])
 }
+
+var namespaces = map[string]*corev1.Namespace{
+	"n1": &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "n1",
+			Labels: map[string]string{
+				"field.cattle.io/projectId": "p-abcde",
+			},
+		},
+	},
+	"n2": &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "n2",
+			Labels: map[string]string{
+				"field.cattle.io/projectId": "p-fghij",
+			},
+		},
+	},
+	"n3": &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "n3",
+			Labels: map[string]string{
+				"field.cattle.io/projectId": "p-klmno",
+			},
+		},
+	},
+	"n4": &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "n4",
+		},
+	},
+}
+
+type mockNamespaceCache struct{}
+
+func (m mockNamespaceCache) Get(name string) (*corev1.Namespace, error) {
+	return namespaces[name], nil
+}
+
+func (m mockNamespaceCache) List(selector labels.Selector) ([]*corev1.Namespace, error) {
+	panic("not implemented")
+}
+func (m mockNamespaceCache) AddIndexer(indexName string, indexer corecontrollers.NamespaceIndexer) {
+	panic("not implemented")
+}
+func (m mockNamespaceCache) GetByIndex(indexName, key string) ([]*corev1.Namespace, error) {
+	panic("not implemented")
+}
diff --git a/pkg/stores/proxy/proxy_store.go b/pkg/stores/proxy/proxy_store.go
index b32dfc6..13b18da 100644
--- a/pkg/stores/proxy/proxy_store.go
+++ b/pkg/stores/proxy/proxy_store.go
@@ -19,6 +19,7 @@ import (
 	metricsStore "github.com/rancher/steve/pkg/stores/metrics"
 	"github.com/rancher/steve/pkg/stores/partition"
 	"github.com/rancher/wrangler/pkg/data"
+	corecontrollers "github.com/rancher/wrangler/pkg/generated/controllers/core/v1"
 	"github.com/rancher/wrangler/pkg/schemas/validation"
 	"github.com/rancher/wrangler/pkg/summary"
 	"github.com/sirupsen/logrus"
@@ -85,7 +86,7 @@ type Store struct {
 }
 
 // NewProxyStore returns a wrapped types.Store.
-func NewProxyStore(clientGetter ClientGetter, notifier RelationshipNotifier, lookup accesscontrol.AccessSetLookup) types.Store {
+func NewProxyStore(clientGetter ClientGetter, notifier RelationshipNotifier, lookup accesscontrol.AccessSetLookup, namespaceCache corecontrollers.NamespaceCache) types.Store {
 	return &errorStore{
 		Store: &WatchRefresh{
 			Store: partition.NewStore(
@@ -96,6 +97,7 @@ func NewProxyStore(clientGetter ClientGetter, notifier RelationshipNotifier, loo
 					},
 				},
 				lookup,
+				namespaceCache,
 			),
 			asl: lookup,
 		},