1
0
mirror of https://github.com/rancher/steve.git synced 2025-09-04 08:55:55 +00:00

Add vai access-control for ext Tokens and Kubeconfigs (#651)

* Add vai access-control for tokens.

* Check for both Token and Kubeconfig resources

* Add a unit test for verifying the generated filters for restricted resources.

* Remove a TODO comment as Tom points out we no longer need it.

* Return error if we can't get userinfo from apiOp.Request.Context

* Stop using camelCase for the user ID label.

* Add a test for the admin user.

* And fold the two user-access tests into a single parameterized test.

* Address reviewer comments.

* post-rebase merge fixes

* WIP - add a comment about determining admin users.

---------

Co-authored-by: Peter Matseykanets <peter.matseykanets@suse.com>
This commit is contained in:
Eric Promislow
2025-06-10 14:28:49 -07:00
committed by GitHub
parent f258ebcf31
commit 7db113a1fd
2 changed files with 168 additions and 28 deletions

View File

@@ -13,34 +13,21 @@ import (
"sync" "sync"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/rancher/steve/pkg/stores/queryhelper"
"github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
"k8s.io/apimachinery/pkg/api/meta"
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/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
apitypes "k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/cache"
"github.com/rancher/apiserver/pkg/apierror" "github.com/rancher/apiserver/pkg/apierror"
"github.com/rancher/apiserver/pkg/types" "github.com/rancher/apiserver/pkg/types"
"github.com/rancher/steve/pkg/accesscontrol"
"github.com/rancher/steve/pkg/sqlcache/informer" "github.com/rancher/steve/pkg/sqlcache/informer"
"github.com/rancher/steve/pkg/sqlcache/informer/factory" "github.com/rancher/steve/pkg/sqlcache/informer/factory"
"github.com/rancher/steve/pkg/sqlcache/partition" "github.com/rancher/steve/pkg/sqlcache/partition"
"github.com/rancher/steve/pkg/sqlcache/sqltypes" "github.com/rancher/steve/pkg/sqlcache/sqltypes"
"github.com/rancher/steve/pkg/stores/queryhelper"
"github.com/rancher/wrangler/v3/pkg/data" "github.com/rancher/wrangler/v3/pkg/data"
"github.com/rancher/wrangler/v3/pkg/kv" "github.com/rancher/wrangler/v3/pkg/kv"
"github.com/rancher/wrangler/v3/pkg/schemas" "github.com/rancher/wrangler/v3/pkg/schemas"
"github.com/rancher/wrangler/v3/pkg/schemas/validation" "github.com/rancher/wrangler/v3/pkg/schemas/validation"
"github.com/rancher/wrangler/v3/pkg/summary" "github.com/rancher/wrangler/v3/pkg/summary"
"github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
"github.com/rancher/steve/pkg/attributes" "github.com/rancher/steve/pkg/attributes"
controllerschema "github.com/rancher/steve/pkg/controllers/schema" controllerschema "github.com/rancher/steve/pkg/controllers/schema"
@@ -50,6 +37,21 @@ import (
metricsStore "github.com/rancher/steve/pkg/stores/metrics" metricsStore "github.com/rancher/steve/pkg/stores/metrics"
"github.com/rancher/steve/pkg/stores/sqlpartition/listprocessor" "github.com/rancher/steve/pkg/stores/sqlpartition/listprocessor"
"github.com/rancher/steve/pkg/stores/sqlproxy/tablelistconvert" "github.com/rancher/steve/pkg/stores/sqlproxy/tablelistconvert"
"k8s.io/apimachinery/pkg/api/meta"
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/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
apitypes "k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/cache"
) )
const ( const (
@@ -738,28 +740,50 @@ func (s *Store) Delete(apiOp *types.APIRequest, schema *types.APISchema, id stri
// - the total number of resources (returned list might be a subset depending on pagination options in apiOp) // - the total number of resources (returned list might be a subset depending on pagination options in apiOp)
// - a continue token, if there are more pages after the returned one // - a continue token, if there are more pages after the returned one
// - an error instead of all of the above if anything went wrong // - an error instead of all of the above if anything went wrong
func (s *Store) ListByPartitions(apiOp *types.APIRequest, schema *types.APISchema, partitions []partition.Partition) (*unstructured.UnstructuredList, int, string, error) { func (s *Store) ListByPartitions(apiOp *types.APIRequest, apiSchema *types.APISchema, partitions []partition.Partition) (*unstructured.UnstructuredList, int, string, error) {
opts, err := listprocessor.ParseQuery(apiOp, s.namespaceCache) opts, err := listprocessor.ParseQuery(apiOp, s.namespaceCache)
if err != nil { if err != nil {
return nil, 0, "", err return nil, 0, "", err
} }
// warnings from inside the informer are discarded // warnings from inside the informer are discarded
buffer := WarningBuffer{} buffer := WarningBuffer{}
client, err := s.clientGetter.TableAdminClient(apiOp, schema, "", &buffer) client, err := s.clientGetter.TableAdminClient(apiOp, apiSchema, "", &buffer)
if err != nil { if err != nil {
return nil, 0, "", err return nil, 0, "", err
} }
gvk := attributes.GVK(schema) gvk := attributes.GVK(apiSchema)
fields := getFieldsFromSchema(schema) fields := getFieldsFromSchema(apiSchema)
fields = append(fields, getFieldForGVK(gvk)...) fields = append(fields, getFieldForGVK(gvk)...)
transformFunc := s.transformBuilder.GetTransformFunc(gvk) transformFunc := s.transformBuilder.GetTransformFunc(gvk)
tableClient := &tablelistconvert.Client{ResourceInterface: client} tableClient := &tablelistconvert.Client{ResourceInterface: client}
attrs := attributes.GVK(schema) attrs := attributes.GVK(apiSchema)
ns := attributes.Namespaced(schema) ns := attributes.Namespaced(apiSchema)
inf, err := s.cacheFactory.CacheFor(s.ctx, fields, transformFunc, tableClient, attrs, ns, controllerschema.IsListWatchable(schema)) inf, err := s.cacheFactory.CacheFor(s.ctx, fields, transformFunc, tableClient, attrs, ns, controllerschema.IsListWatchable(apiSchema))
if err != nil { if err != nil {
return nil, 0, "", err return nil, 0, "", err
} }
if gvk.Group == "ext.cattle.io" && (gvk.Kind == "Token" || gvk.Kind == "Kubeconfig") {
accessSet := accesscontrol.AccessSetFromAPIRequest(apiOp)
// See https://github.com/rancher/rancher/blob/7266e5e624f0d610c76ab0af33e30f5b72e11f61/pkg/ext/stores/tokens/tokens.go#L1186C2-L1195C3
// for similar code on how we determine if a user is admin
if accessSet == nil || !accessSet.Grants("list", schema.GroupResource{
Resource: "*",
}, "", "") {
user, ok := request.UserFrom(apiOp.Request.Context())
if !ok {
return nil, 0, "", apierror.NewAPIError(validation.MissingRequired, "failed to get user info from the request.Context object")
}
opts.Filters = append(opts.Filters, sqltypes.OrFilter{
Filters: []sqltypes.Filter{
{
Field: []string{"metadata", "labels", "cattle.io/user-id"},
Matches: []string{user.GetName()},
Op: sqltypes.Eq,
},
},
})
}
}
list, total, continueToken, err := inf.ListByOptions(apiOp.Context(), &opts, partitions, apiOp.Namespace) list, total, continueToken, err := inf.ListByOptions(apiOp.Context(), &opts, partitions, apiOp.Namespace)
if err != nil { if err != nil {

View File

@@ -9,18 +9,18 @@ import (
"testing" "testing"
"time" "time"
"github.com/rancher/wrangler/v3/pkg/schemas/validation" "github.com/rancher/steve/pkg/accesscontrol"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"github.com/rancher/steve/pkg/attributes" "github.com/rancher/steve/pkg/attributes"
"github.com/rancher/steve/pkg/resources/common" "github.com/rancher/steve/pkg/resources/common"
"github.com/rancher/steve/pkg/sqlcache/informer" "github.com/rancher/steve/pkg/sqlcache/informer"
"github.com/rancher/steve/pkg/sqlcache/informer/factory" "github.com/rancher/steve/pkg/sqlcache/informer/factory"
"github.com/rancher/steve/pkg/sqlcache/partition" "github.com/rancher/steve/pkg/sqlcache/partition"
"github.com/rancher/steve/pkg/sqlcache/sqltypes"
"github.com/rancher/steve/pkg/stores/sqlpartition/listprocessor" "github.com/rancher/steve/pkg/stores/sqlpartition/listprocessor"
"github.com/rancher/steve/pkg/stores/sqlproxy/tablelistconvert" "github.com/rancher/steve/pkg/stores/sqlproxy/tablelistconvert"
"github.com/rancher/wrangler/v3/pkg/schemas/validation"
"go.uber.org/mock/gomock" "go.uber.org/mock/gomock"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/rancher/apiserver/pkg/apierror" "github.com/rancher/apiserver/pkg/apierror"
@@ -29,10 +29,14 @@ import (
"github.com/rancher/wrangler/v3/pkg/schemas" "github.com/rancher/wrangler/v3/pkg/schemas"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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"
schema2 "k8s.io/apimachinery/pkg/runtime/schema" schema2 "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch" "k8s.io/apimachinery/pkg/watch"
"k8s.io/apiserver/pkg/authentication/user"
krequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic"
"k8s.io/client-go/dynamic/fake" "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
@@ -621,6 +625,118 @@ func TestListByPartitions(t *testing.T) {
} }
} }
func TestListByPartitionWithUserAccess(t *testing.T) {
type testCase struct {
description string
accessSetSetter func(accessSet *accesscontrol.AccessSet)
orFilters []sqltypes.OrFilter
}
var tests []testCase
tests = append(tests, testCase{
description: "client ListByPartitions(), with a specified user for a restricted resource should filter for that user",
accessSetSetter: func(accessSet *accesscontrol.AccessSet) {},
orFilters: []sqltypes.OrFilter{
{
Filters: []sqltypes.Filter{
{
Field: []string{"metadata", "labels", "cattle.io/user-id"},
Matches: []string{"flip"},
Op: sqltypes.Eq,
},
},
},
},
})
tests = append(tests, testCase{
description: "client ListByPartitions(), with an admin user for a restricted resource will return all items regardless of user",
accessSetSetter: func(accessSet *accesscontrol.AccessSet) {
// admins also get this access-set
accessSet.Add("list",
schema2.GroupResource{Group: accesscontrol.All, Resource: accesscontrol.All},
accesscontrol.Access{Namespace: accesscontrol.All, ResourceName: accesscontrol.All},
)
},
orFilters: []sqltypes.OrFilter{},
})
t.Parallel()
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
nsi := NewMockCache(gomock.NewController(t))
cg := NewMockClientGetter(gomock.NewController(t))
cf := NewMockCacheFactory(gomock.NewController(t))
ri := NewMockResourceInterface(gomock.NewController(t))
bloi := NewMockByOptionsLister(gomock.NewController(t))
tb := NewMockTransformBuilder(gomock.NewController(t))
inf := &informer.Informer{
ByOptionsLister: bloi,
}
c := factory.Cache{
ByOptionsLister: inf,
}
s := &Store{
ctx: context.Background(),
namespaceCache: nsi,
clientGetter: cg,
cacheFactory: cf,
transformBuilder: tb,
}
var partitions []partition.Partition
username := "flip"
targetGroup := "ext.cattle.io"
targetKind := "Token"
accessSet := &accesscontrol.AccessSet{ID: username}
accessSet.Add("list",
schema2.GroupResource{Group: targetGroup, Resource: "token"},
accesscontrol.Access{Namespace: accesscontrol.All, ResourceName: "token"},
)
test.accessSetSetter(accessSet)
apiOpSchemas := &types.APISchemas{}
accesscontrol.SetAccessSetAttribute(apiOpSchemas, accessSet)
theRequest := &http.Request{
URL: &url.URL{},
}
userInfo := user.DefaultInfo{Name: username, UID: "Id"}
requestWithContext := krequest.WithUser(context.Background(), &userInfo)
theRequest = theRequest.WithContext(requestWithContext)
apiOp := &types.APIRequest{
Request: theRequest,
Schemas: apiOpSchemas,
}
theSchema := &types.APISchema{
Schema: &schemas.Schema{Attributes: map[string]interface{}{
"columns": []common.ColumnDefinition{
{
Field: "some.field",
},
},
"verbs": []string{"list", "watch"},
}},
}
gvk := schema2.GroupVersionKind{
Group: targetGroup,
Kind: targetKind,
}
opts := &sqltypes.ListOptions{
Filters: test.orFilters,
Pagination: sqltypes.Pagination{
Page: 1,
},
}
attributes.SetGVK(theSchema, gvk)
cg.EXPECT().TableAdminClient(apiOp, theSchema, "", &WarningBuffer{}).Return(ri, nil)
cf.EXPECT().CacheFor(context.Background(), [][]string{{"some", "field"}, {"id"}, {"metadata", "state", "name"}}, gomock.Any(), &tablelistconvert.Client{ResourceInterface: ri}, attributes.GVK(theSchema), attributes.Namespaced(theSchema), true).Return(c, nil)
tb.EXPECT().GetTransformFunc(attributes.GVK(theSchema)).Return(func(obj interface{}) (interface{}, error) { return obj, nil })
listToReturn := &unstructured.UnstructuredList{
Items: make([]unstructured.Unstructured, 0, 0),
}
bloi.EXPECT().ListByOptions(apiOp.Context(), opts, partitions, "").Return(listToReturn, len(listToReturn.Items), "", nil)
_, _, _, err := s.ListByPartitions(apiOp, theSchema, partitions)
assert.Nil(t, err)
})
}
}
func TestReset(t *testing.T) { func TestReset(t *testing.T) {
type testCase struct { type testCase struct {
description string description string