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

#47483 - Adding NonResourceURLs support to AccessStore (#299)

* adding NonResourceURLs support to access_store

* added tests to AccessSet NonResourceURLs handling

* change on test script suggested by @tomleb + go mod tidy

* added nonresource to ext api authorization

* added NonResourceURLs implementation in Authorizes + test

* removed non-resource-url tests from the main test

* added new tests for non-resource-urls

* removed unused test data

* changed nonResourceKey to point to struct{}

* addressed comments from @tomleb

* addressed more comments

* fixing typo

* check for empty accessSet
This commit is contained in:
Felipe Gehrke 2024-11-04 23:47:48 -03:00 committed by GitHub
parent 2175e090fe
commit 6ee8201c8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 588 additions and 39 deletions

1
go.mod
View File

@ -43,6 +43,7 @@ require (
k8s.io/klog v1.0.0
k8s.io/kube-aggregator v0.31.1
k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3
k8s.io/kubernetes v1.31.1
sigs.k8s.io/controller-runtime v0.19.0
)

2
go.sum
View File

@ -486,6 +486,8 @@ k8s.io/kube-aggregator v0.31.1/go.mod h1:+aW4NX50uneozN+BtoCxI4g7ND922p8Wy3tWKFD
k8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E=
k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3 h1:SbdLaI6mM6ffDSJCadEaD4IkuPzepLDGlkd2xV0t1uA=
k8s.io/kube-openapi v0.0.0-20240411171206-dc4e619f62f3/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
k8s.io/kubernetes v1.31.1 h1:1fcYJe8SAhtannpChbmnzHLwAV9Je99PrGaFtBvCxms=
k8s.io/kubernetes v1.31.1/go.mod h1:/YGPL//Fb9mdv5vukvAQ7Xon+Bqwry52bmjTdORAw+Q=
k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=

View File

@ -3,15 +3,19 @@ package accesscontrol
import (
"sort"
"github.com/rancher/apiserver/pkg/types"
"github.com/rancher/steve/pkg/attributes"
v1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/sets"
rbacv1 "k8s.io/kubernetes/pkg/apis/rbac/v1"
"github.com/rancher/apiserver/pkg/types"
"github.com/rancher/steve/pkg/attributes"
)
type AccessSet struct {
ID string
set map[key]resourceAccessSet
ID string
set map[key]resourceAccessSet
nonResourceSet map[nonResourceKey]struct{}
}
type resourceAccessSet map[Access]bool
@ -21,6 +25,11 @@ type key struct {
gr schema.GroupResource
}
type nonResourceKey struct {
verb string
url string
}
func (a *AccessSet) Namespaces() (result []string) {
set := map[string]bool{}
for k, as := range a.set {
@ -56,6 +65,17 @@ func (a *AccessSet) Merge(right *AccessSet) {
m[k] = v
}
}
if a.nonResourceSet == nil {
a.nonResourceSet = map[nonResourceKey]struct{}{}
}
for k, v := range right.nonResourceSet {
_, ok := a.nonResourceSet[k]
if !ok {
a.nonResourceSet[k] = v
}
}
}
func (a AccessSet) Grants(verb string, gr schema.GroupResource, namespace, name string) bool {
@ -80,6 +100,26 @@ func (a AccessSet) Grants(verb string, gr schema.GroupResource, namespace, name
return false
}
func (a *AccessSet) GrantsNonResource(verb, url string) bool {
if a.nonResourceSet == nil {
return false
}
if _, ok := a.nonResourceSet[nonResourceKey{url: url, verb: verb}]; ok {
rule := &v1.PolicyRule{NonResourceURLs: []string{url}, Verbs: []string{verb}}
return rbacv1.NonResourceURLMatches(rule, url) && rbacv1.VerbMatches(rule, verb)
}
for key := range a.nonResourceSet {
rule := &v1.PolicyRule{NonResourceURLs: []string{key.url}, Verbs: []string{key.verb}}
if rbacv1.NonResourceURLMatches(rule, url) && rbacv1.VerbMatches(rule, verb) {
return true
}
}
return false
}
func (a AccessSet) AccessListFor(verb string, gr schema.GroupResource) (result AccessList) {
dedup := map[Access]bool{}
for _, v := range []string{All, verb} {
@ -120,6 +160,25 @@ func (a *AccessSet) Add(verb string, gr schema.GroupResource, access Access) {
}
}
func (a *AccessSet) AddNonResourceURLs(verbs, urls []string) {
if len(verbs) == 0 || len(urls) == 0 {
return
}
if a.nonResourceSet == nil {
a.nonResourceSet = map[nonResourceKey]struct{}{}
}
for _, verb := range verbs {
for _, url := range urls {
a.nonResourceSet[nonResourceKey{
verb: verb,
url: url,
}] = struct{}{}
}
}
}
type AccessListByVerb map[string]AccessList
func (a AccessListByVerb) Grants(verb, namespace, name string) bool {

View File

@ -0,0 +1,212 @@
package accesscontrol
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAccessSet_AddNonResourceURLs(t *testing.T) {
testCases := []struct {
name string
verbs []string
urls []string
want []nonResourceKey
}{
{
name: "valid case",
verbs: []string{"get", "post"},
urls: []string{"/healthz", "/metrics"},
want: []nonResourceKey{
{"get", "/healthz"},
{"get", "/metrics"},
{"post", "/healthz"},
{"post", "/metrics"},
},
},
{
name: "url wildcard",
verbs: []string{"get"},
urls: []string{"/metrics/*"},
want: []nonResourceKey{
{"get", "/metrics/*"},
},
},
{
name: "verb wildcard",
verbs: []string{"*"},
urls: []string{"/metrics"},
want: []nonResourceKey{
{"*", "/metrics"},
},
},
{
name: "empty urls",
verbs: []string{"get", "post"},
urls: []string{},
want: nil,
},
{
name: "empty verbs",
verbs: []string{},
urls: []string{"/healthz", "/metrics"},
want: nil,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
accessSet := &AccessSet{}
accessSet.AddNonResourceURLs(tt.verbs, tt.urls)
if len(tt.want) > 0 {
for _, key := range tt.want {
assert.Contains(t, accessSet.nonResourceSet, key)
}
} else {
assert.Len(t, accessSet.nonResourceSet, 0)
}
})
}
}
func TestAccessSet_GrantsNonResource(t *testing.T) {
testCases := []struct {
name string
verb string
url string
keys map[nonResourceKey]struct{}
expect bool
}{
{
name: "direct match",
verb: "get",
url: "/healthz",
keys: map[nonResourceKey]struct{}{
{verb: "get", url: "/healthz"}: {},
},
expect: true,
},
{
name: "wildcard in url",
verb: "get",
url: "/api/resource",
keys: map[nonResourceKey]struct{}{
{verb: "get", url: "/api/*"}: {},
},
expect: true,
},
{
name: "wildcard in verb",
verb: "get",
url: "/healthz",
keys: map[nonResourceKey]struct{}{
{verb: "*", url: "/healthz"}: {},
},
expect: true,
},
{
name: "invalid wildcard",
verb: "get",
url: "/*", // that's invalid according to k8s rules
keys: map[nonResourceKey]struct{}{
{verb: "get", url: "/api/*"}: {},
},
expect: false,
},
{
name: "wrong verb",
verb: "post",
url: "/healthz",
keys: map[nonResourceKey]struct{}{
{verb: "get", url: "/healthz"}: {},
},
expect: false,
},
{
name: "wrong url",
verb: "post",
url: "/metrics",
keys: map[nonResourceKey]struct{}{
{verb: "post", url: "/healthz"}: {},
},
expect: false,
},
{
name: "no matching rule",
verb: "post",
url: "/healthz",
keys: map[nonResourceKey]struct{}{},
expect: false,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
accessSet := &AccessSet{}
for rule := range tt.keys {
accessSet.AddNonResourceURLs([]string{rule.verb}, []string{rule.url})
}
res := accessSet.GrantsNonResource(tt.verb, tt.url)
assert.Equal(t, tt.expect, res)
})
}
}
func TestAccessSet_Merge(t *testing.T) {
testCases := []struct {
name string
left *AccessSet
right *AccessSet
want *AccessSet
}{
{
name: "merging NonResouceURLs",
left: &AccessSet{
nonResourceSet: map[nonResourceKey]struct{}{
{url: "/healthz", verb: "get"}: {},
},
},
right: &AccessSet{
nonResourceSet: map[nonResourceKey]struct{}{
{url: "/metrics", verb: "post"}: {},
},
},
want: &AccessSet{
nonResourceSet: map[nonResourceKey]struct{}{
{url: "/healthz", verb: "get"}: {},
{url: "/metrics", verb: "post"}: {},
},
},
},
{
name: "merging NonResouceURLs - repeated items",
left: &AccessSet{
nonResourceSet: map[nonResourceKey]struct{}{
{url: "/healthz", verb: "get"}: {},
{url: "/metrics", verb: "post"}: {},
},
},
right: &AccessSet{
nonResourceSet: map[nonResourceKey]struct{}{
{url: "/metrics", verb: "post"}: {},
},
},
want: &AccessSet{
nonResourceSet: map[nonResourceKey]struct{}{
{url: "/healthz", verb: "get"}: {},
{url: "/metrics", verb: "post"}: {},
},
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
tt.left.Merge(tt.right)
assert.Equal(t, tt.want, tt.left)
})
}
}

View File

@ -16,6 +16,9 @@ const (
groupKind = rbacv1.GroupKind
userKind = rbacv1.UserKind
svcAccountKind = rbacv1.ServiceAccountKind
clusterRoleKind = "ClusterRole"
roleKind = "Role"
)
type policyRuleIndex struct {
@ -75,6 +78,40 @@ func indexSubjects(kind string, subjects []rbacv1.Subject) []string {
return result
}
// addAccess appends a set of PolicyRules to a given AccessSet
func addAccess(accessSet *AccessSet, namespace string, roleRef roleRef) {
for _, rule := range roleRef.rules {
if len(rule.Resources) > 0 {
addResourceAccess(accessSet, namespace, rule)
} else if roleRef.kind == clusterRoleKind {
accessSet.AddNonResourceURLs(rule.Verbs, rule.NonResourceURLs)
}
}
}
func addResourceAccess(accessSet *AccessSet, namespace string, rule rbacv1.PolicyRule) {
for _, group := range rule.APIGroups {
for _, resource := range rule.Resources {
names := rule.ResourceNames
if len(names) == 0 {
names = []string{All}
}
for _, resourceName := range names {
for _, verb := range rule.Verbs {
accessSet.Add(verb,
schema.GroupResource{
Group: group,
Resource: resource,
}, Access{
Namespace: namespace,
ResourceName: resourceName,
})
}
}
}
}
}
func subjectIs(kind string, subject rbacv1.Subject) bool {
return subject.APIGroup == rbacGroup && subject.Kind == kind
}
@ -83,32 +120,6 @@ func subjectIsServiceAccount(subject rbacv1.Subject) bool {
return subject.APIGroup == "" && subject.Kind == svcAccountKind && subject.Namespace != ""
}
// addAccess appends a set of PolicyRules to a given AccessSet
func addAccess(accessSet *AccessSet, namespace string, rules []rbacv1.PolicyRule) {
for _, rule := range rules {
for _, group := range rule.APIGroups {
for _, resource := range rule.Resources {
names := rule.ResourceNames
if len(names) == 0 {
names = []string{All}
}
for _, resourceName := range names {
for _, verb := range rule.Verbs {
accessSet.Add(verb,
schema.GroupResource{
Group: group,
Resource: resource,
}, Access{
Namespace: namespace,
ResourceName: resourceName,
})
}
}
}
}
}
}
// getRules obtain the actual Role or ClusterRole pointed at by a RoleRef, and returns PolicyRules and the resource version
func (p *policyRuleIndex) getRules(namespace string, roleRef rbacv1.RoleRef) ([]rbacv1.PolicyRule, string) {
switch roleRef.Kind {
@ -160,6 +171,7 @@ func (p *policyRuleIndex) getRoleRefs(subjectName string) subjectGrants {
roleName: crb.RoleRef.Name,
resourceVersion: resourceVersion,
rules: rules,
kind: clusterRoleKind,
})
}
@ -171,6 +183,7 @@ func (p *policyRuleIndex) getRoleRefs(subjectName string) subjectGrants {
namespace: rb.Namespace,
resourceVersion: resourceVersion,
rules: rules,
kind: roleKind,
})
}

View File

@ -22,8 +22,8 @@ type subjectGrants struct {
// roleRef contains information from a Role or ClusterRole
type roleRef struct {
namespace, roleName, resourceVersion string
rules []rbacv1.PolicyRule
namespace, roleName, resourceVersion, kind string
rules []rbacv1.PolicyRule
}
// hash calculates a unique identifier from all the grants for a user
@ -51,11 +51,11 @@ func (b subjectGrants) toAccessSet() *AccessSet {
result := new(AccessSet)
for _, binding := range b.roleBindings {
addAccess(result, binding.namespace, binding.rules)
addAccess(result, binding.namespace, binding)
}
for _, binding := range b.clusterRoleBindings {
addAccess(result, All, binding.rules)
addAccess(result, All, binding)
}
return result

View File

@ -22,12 +22,21 @@ func NewAccessSetAuthorizer(asl accesscontrol.AccessSetLookup) *AccessSetAuthori
// Authorize implements [authorizer.Authorizer].
func (a *AccessSetAuthorizer) Authorize(ctx context.Context, attrs authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
verb := attrs.GetVerb()
path := attrs.GetPath()
accessSet := a.asl.AccessFor(attrs.GetUser())
if !attrs.IsResourceRequest() {
// XXX: Implement
return authorizer.DecisionDeny, "AccessSetAuthorizer does not support nonResourceURLs requests", nil
if accessSet.GrantsNonResource(verb, path) {
return authorizer.DecisionAllow, "", nil
}
// An empty string reason will still provide enough information such as:
//
// forbidden: User "unknown-user" cannot post path /openapi/v3
return authorizer.DecisionDeny, "", nil
}
verb := attrs.GetVerb()
namespace := attrs.GetNamespace()
name := attrs.GetName()
gr := schema.GroupResource{
@ -35,7 +44,6 @@ func (a *AccessSetAuthorizer) Authorize(ctx context.Context, attrs authorizer.At
Resource: attrs.GetResource(),
}
accessSet := a.asl.AccessFor(attrs.GetUser())
if accessSet.Grants(verb, gr, namespace, name) {
return authorizer.DecisionAllow, "", nil
}

View File

@ -2,6 +2,7 @@ package ext
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@ -15,9 +16,11 @@ import (
"github.com/rancher/lasso/pkg/controller"
"github.com/rancher/steve/pkg/accesscontrol"
"github.com/rancher/steve/pkg/accesscontrol/fake"
wrbacv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -311,6 +314,66 @@ func (s *ExtensionAPIServerSuite) TestAuthorization() {
},
expectedStatusCode: http.StatusForbidden,
},
{
name: "authorized access to non-resource url",
user: &user.DefaultInfo{
Name: "openapi-v2-only",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/openapi/v2", nil)
},
expectedStatusCode: http.StatusOK,
},
{
name: "unauthorized verb to non-resource url",
user: &user.DefaultInfo{
Name: "openapi-v2-only",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodPost, "/openapi/v2", nil)
},
expectedStatusCode: http.StatusForbidden,
},
{
name: "unauthorized access to non-resource url (user can access only openapi/v2)",
user: &user.DefaultInfo{
Name: "openapi-v2-only",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/openapi/v3", nil)
},
expectedStatusCode: http.StatusForbidden,
},
{
name: "authorized user can access both openapi v2 and v3 (v2)",
user: &user.DefaultInfo{
Name: "openapi-v2-v3",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/openapi/v2", nil)
},
expectedStatusCode: http.StatusOK,
},
{
name: "authorized user can access both openapi v2 and v3 (v3)",
user: &user.DefaultInfo{
Name: "openapi-v2-v3",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/openapi/v3", nil)
},
expectedStatusCode: http.StatusOK,
},
{
name: "authorized user can access url based in wildcard rule",
user: &user.DefaultInfo{
Name: "openapi-v2-v3",
},
createRequest: func() *http.Request {
return httptest.NewRequest(http.MethodGet, "/openapi/v3/apis/ext.cattle.io/v1", nil)
},
expectedStatusCode: http.StatusOK,
},
}
for _, test := range tests {
@ -342,3 +405,152 @@ func (s *ExtensionAPIServerSuite) TestAuthorization() {
})
}
}
func TestAuthorization_NonResourceURLs(t *testing.T) {
type input struct {
ctx context.Context
attrs authorizer.Attributes
}
type expected struct {
authorized authorizer.Decision
reason string
err error
}
sampleReadOnlyUser := &user.DefaultInfo{
Name: "read-only-user",
}
sampleReadOnlyAccessSet := func() *accesscontrol.AccessSet {
accessSet := &accesscontrol.AccessSet{}
accessSet.AddNonResourceURLs([]string{
"get",
}, []string{
"/metrics",
"/healthz",
})
return accessSet
}()
sampleReadWriteUser := &user.DefaultInfo{
Name: "read-write-user",
}
sampleReadWriteAccessSet := func() *accesscontrol.AccessSet {
accessSet := &accesscontrol.AccessSet{}
accessSet.AddNonResourceURLs([]string{
"get", "post",
}, []string{
"/metrics",
"/healthz",
})
return accessSet
}()
tests := []struct {
name string
input input
expected expected
mockUsername *user.DefaultInfo
mockAccessSet *accesscontrol.AccessSet
}{
{
name: "authorized read-only user to read data",
input: input{
ctx: context.TODO(),
attrs: authorizer.AttributesRecord{
User: sampleReadOnlyUser,
ResourceRequest: false,
Path: "/healthz",
Verb: "get",
},
},
expected: expected{
authorized: authorizer.DecisionAllow,
reason: "",
err: nil,
},
mockUsername: sampleReadOnlyUser,
mockAccessSet: sampleReadOnlyAccessSet,
},
{
name: "unauthorized read-only user to write data",
input: input{
ctx: context.TODO(),
attrs: authorizer.AttributesRecord{
User: sampleReadOnlyUser,
ResourceRequest: false,
Path: "/metrics",
Verb: "post",
},
},
expected: expected{
authorized: authorizer.DecisionDeny,
reason: "",
err: nil,
},
mockUsername: sampleReadOnlyUser,
mockAccessSet: sampleReadOnlyAccessSet,
},
{
name: "authorized read-write user to read data",
input: input{
ctx: context.TODO(),
attrs: authorizer.AttributesRecord{
User: sampleReadWriteUser,
ResourceRequest: false,
Path: "/metrics",
Verb: "get",
},
},
expected: expected{
authorized: authorizer.DecisionAllow,
reason: "",
err: nil,
},
mockUsername: sampleReadWriteUser,
mockAccessSet: sampleReadWriteAccessSet,
},
{
name: "authorized read-write user to write data",
input: input{
ctx: context.TODO(),
attrs: authorizer.AttributesRecord{
User: sampleReadWriteUser,
ResourceRequest: false,
Path: "/metrics",
Verb: "post",
},
},
expected: expected{
authorized: authorizer.DecisionAllow,
reason: "",
err: nil,
},
mockUsername: sampleReadWriteUser,
mockAccessSet: sampleReadWriteAccessSet,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
crtl := gomock.NewController(t)
asl := fake.NewMockAccessSetLookup(crtl)
asl.EXPECT().AccessFor(tt.mockUsername).Return(tt.mockAccessSet)
auth := NewAccessSetAuthorizer(asl)
authorized, reason, err := auth.Authorize(tt.input.ctx, tt.input.attrs)
require.Equal(t, tt.expected.authorized, authorized)
require.Equal(t, tt.expected.reason, reason)
if tt.expected.err != nil {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View File

@ -121,3 +121,45 @@ subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: read-only-error
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: openapi-v2-only-read
rules:
- nonResourceURLs: ["/openapi/v2"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: openapi-read
rules:
- nonResourceURLs: ["/openapi/v2", "/openapi/v3", "/openapi/v3/*"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: openapi-v2
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: openapi-v2-only-read
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: openapi-v2-only
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: openapi-v3
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: openapi-read
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: openapi-v2-v3

View File

@ -6,7 +6,7 @@ if ! command -v setup-envtest; then
exit 127
fi
minor=$(go list -m all | grep 'k8s.io/client-go' | cut -d ' ' -f 2 | cut -d '.' -f 2)
minor=$(go mod graph | grep ' k8s.io/client-go@' | head -n1 | cut -d@ -f2 | cut -d '.' -f 2)
version="1.$minor.x"
export KUBEBUILDER_ASSETS=$(setup-envtest use -p path "$version")