diff --git a/pkg/accesscontrol/access_store.go b/pkg/accesscontrol/access_store.go index e219bc83..18a90545 100644 --- a/pkg/accesscontrol/access_store.go +++ b/pkg/accesscontrol/access_store.go @@ -2,15 +2,12 @@ package accesscontrol import ( "context" - "crypto/sha256" - "encoding/hex" - "hash" + "slices" "sort" "time" v1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1" "golang.org/x/sync/singleflight" - rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/util/cache" "k8s.io/apiserver/pkg/authentication/user" ) @@ -23,13 +20,7 @@ type AccessSetLookup interface { } type policyRules interface { - get(string) *AccessSet - getRoleBindings(string) []*rbacv1.RoleBinding - getClusterRoleBindings(string) []*rbacv1.ClusterRoleBinding -} - -type roleRevisions interface { - roleRevision(string, string) string + getRoleRefs(subjectName string) subjectGrants } // accessStoreCache is a subset of the methods implemented by LRUExpireCache @@ -42,21 +33,14 @@ type accessStoreCache interface { type AccessStore struct { usersPolicyRules policyRules groupsPolicyRules policyRules - roles roleRevisions cache accessStoreCache concurrentAccessFor *singleflight.Group } -type roleKey struct { - namespace string - name string -} - -func NewAccessStore(ctx context.Context, cacheResults bool, rbac v1.Interface) *AccessStore { +func NewAccessStore(_ context.Context, cacheResults bool, rbac v1.Interface) *AccessStore { as := &AccessStore{ usersPolicyRules: newPolicyRuleIndex(true, rbac), groupsPolicyRules: newPolicyRuleIndex(false, rbac), - roles: newRoleRevision(ctx, rbac), concurrentAccessFor: new(singleflight.Group), } if cacheResults { @@ -66,11 +50,12 @@ func NewAccessStore(ctx context.Context, cacheResults bool, rbac v1.Interface) * } func (l *AccessStore) AccessFor(user user.Info) *AccessSet { + info := l.userGrantsFor(user) if l.cache == nil { - return l.newAccessSet(user) + return l.newAccessSet(info) } - cacheKey := l.CacheKey(user) + cacheKey := info.hash() res, _, _ := l.concurrentAccessFor.Do(cacheKey, func() (interface{}, error) { if val, ok := l.cache.Get(cacheKey); ok { @@ -78,7 +63,7 @@ func (l *AccessStore) AccessFor(user user.Info) *AccessSet { return as, nil } - result := l.newAccessSet(user) + result := l.newAccessSet(info) result.ID = cacheKey l.cache.Add(cacheKey, result, 24*time.Hour) @@ -87,10 +72,10 @@ func (l *AccessStore) AccessFor(user user.Info) *AccessSet { return res.(*AccessSet) } -func (l *AccessStore) newAccessSet(user user.Info) *AccessSet { - result := l.usersPolicyRules.get(user.GetName()) - for _, group := range user.GetGroups() { - result.Merge(l.groupsPolicyRules.get(group)) +func (l *AccessStore) newAccessSet(info userGrants) *AccessSet { + result := info.user.toAccessSet() + for _, group := range info.groups { + result.Merge(group.toAccessSet()) } return result } @@ -99,33 +84,17 @@ func (l *AccessStore) PurgeUserData(id string) { l.cache.Remove(id) } -func (l *AccessStore) CacheKey(user user.Info) string { - d := sha256.New() +// userGrantsFor retrieves all the access information for a user +func (l *AccessStore) userGrantsFor(user user.Info) userGrants { + var res userGrants - groupBase := user.GetGroups() - groups := make([]string, len(groupBase)) - copy(groups, groupBase) + groups := slices.Clone(user.GetGroups()) sort.Strings(groups) - l.addRolesToHash(d, user.GetName(), l.usersPolicyRules) + res.user = l.usersPolicyRules.getRoleRefs(user.GetName()) for _, group := range groups { - l.addRolesToHash(d, group, l.groupsPolicyRules) + res.groups = append(res.groups, l.groupsPolicyRules.getRoleRefs(group)) } - return hex.EncodeToString(d.Sum(nil)) -} - -func (l *AccessStore) addRolesToHash(digest hash.Hash, subjectName string, rules policyRules) { - for _, crb := range rules.getClusterRoleBindings(subjectName) { - digest.Write([]byte(crb.RoleRef.Name)) - digest.Write([]byte(l.roles.roleRevision("", crb.RoleRef.Name))) - } - - for _, rb := range rules.getRoleBindings(subjectName) { - digest.Write([]byte(rb.RoleRef.Name)) - if rb.Namespace != "" { - digest.Write([]byte(rb.Namespace)) - } - digest.Write([]byte(l.roles.roleRevision(rb.Namespace, rb.RoleRef.Name))) - } + return res } diff --git a/pkg/accesscontrol/access_store_test.go b/pkg/accesscontrol/access_store_test.go index 5d81ec37..97e2243c 100644 --- a/pkg/accesscontrol/access_store_test.go +++ b/pkg/accesscontrol/access_store_test.go @@ -1,7 +1,6 @@ package accesscontrol import ( - "fmt" "slices" "sync" "testing" @@ -11,12 +10,19 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/cache" "k8s.io/apiserver/pkg/authentication/user" ) -func TestAccessStore_CacheKey(t *testing.T) { +type policyRulesMock struct { + roleRefs map[string]subjectGrants +} + +func (p policyRulesMock) getRoleRefs(s string) subjectGrants { + return p.roleRefs[s] +} + +func TestAccessStore_userGrantsFor(t *testing.T) { testUser := &user.DefaultInfo{ Name: "user-12345", Groups: []string{ @@ -34,27 +40,24 @@ func TestAccessStore_CacheKey(t *testing.T) { name: "consistently produces the same value", store: &AccessStore{ usersPolicyRules: &policyRulesMock{ - getRBFunc: func(s string) []*rbacv1.RoleBinding { - return []*rbacv1.RoleBinding{ - makeRB("testns", "testrb", testUser.Name, "testrole"), - } - }, - getCRBFunc: func(s string) []*rbacv1.ClusterRoleBinding { - return []*rbacv1.ClusterRoleBinding{ - makeCRB("testcrb", testUser.Name, "testclusterrole"), - } + roleRefs: map[string]subjectGrants{ + testUser.Name: { + roleBindings: []roleRef{ + {namespace: "testns", roleName: "testrole", resourceVersion: "testrolev1"}, + }, + clusterRoleBindings: []roleRef{ + {roleName: "testclusterrole", resourceVersion: "testclusterrolev1"}, + }, + }, }, }, groupsPolicyRules: &policyRulesMock{}, - roles: roleRevisionsMock(func(ns, name string) string { - return fmt.Sprintf("%s%srev", ns, name) - }), }, verify: func(t *testing.T, store *AccessStore, res string) { // iterate enough times to make possibly random iterators repeating order by coincidence for range 5 { - if res != store.CacheKey(testUser) { - t.Fatal("CacheKey is not the same on consecutive runs") + if res != store.userGrantsFor(testUser).hash() { + t.Fatal("hash is not the same on consecutive runs") } } }, @@ -64,27 +67,26 @@ func TestAccessStore_CacheKey(t *testing.T) { store: &AccessStore{ usersPolicyRules: &policyRulesMock{}, groupsPolicyRules: &policyRulesMock{ - getRBFunc: func(s string) []*rbacv1.RoleBinding { - return []*rbacv1.RoleBinding{ - makeRB("testns", "testrb", testUser.Name, "testrole"), - } - }, - getCRBFunc: func(s string) []*rbacv1.ClusterRoleBinding { - return []*rbacv1.ClusterRoleBinding{ - makeCRB("testcrb", testUser.Name, "testclusterrole"), - } + roleRefs: map[string]subjectGrants{ + testUser.Groups[0]: { + roleBindings: []roleRef{ + {namespace: "testns", roleName: "testrole", resourceVersion: "testrolegroup0"}, + }, + }, + testUser.Groups[1]: { + clusterRoleBindings: []roleRef{ + {roleName: "testclusterrole", resourceVersion: "testclusterrolegroup1"}, + }, + }, }, }, - roles: roleRevisionsMock(func(ns, name string) string { - return fmt.Sprintf("%s%srev", ns, name) - }), }, verify: func(t *testing.T, store *AccessStore, res string) { - // remove users + // remove groups testUserAlt := *testUser testUserAlt.Groups = []string{} - if store.CacheKey(&testUserAlt) == res { + if store.userGrantsFor(&testUserAlt).hash() == res { t.Fatal("CacheKey does not use groups for hashing") } }, @@ -94,33 +96,26 @@ func TestAccessStore_CacheKey(t *testing.T) { store: &AccessStore{ usersPolicyRules: &policyRulesMock{}, groupsPolicyRules: &policyRulesMock{ - getRBFunc: func(s string) []*rbacv1.RoleBinding { - if s == testUser.Groups[0] { - return []*rbacv1.RoleBinding{ - makeRB("testns", "testrb", testUser.Name, "testrole"), - } - } - return nil - }, - getCRBFunc: func(s string) []*rbacv1.ClusterRoleBinding { - if s == testUser.Groups[1] { - return []*rbacv1.ClusterRoleBinding{ - makeCRB("testcrb", testUser.Name, "testclusterrole"), - } - } - return nil + roleRefs: map[string]subjectGrants{ + testUser.Groups[0]: { + roleBindings: []roleRef{ + {namespace: "testns", roleName: "testrole", resourceVersion: "testrolegroup0"}, + }, + }, + testUser.Groups[0]: { + clusterRoleBindings: []roleRef{ + {roleName: "testclusterrole", resourceVersion: "testclusterrolegroup1"}, + }, + }, }, }, - roles: roleRevisionsMock(func(ns, name string) string { - return fmt.Sprintf("%s%srev", ns, name) - }), }, verify: func(t *testing.T, store *AccessStore, res string) { // swap order of groups testUserAlt := &user.DefaultInfo{Name: testUser.Name, Groups: slices.Clone(testUser.Groups)} slices.Reverse(testUserAlt.Groups) - if store.CacheKey(testUserAlt) != res { + if store.userGrantsFor(testUserAlt).hash() != res { t.Fatal("CacheKey varies depending on groups order") } }, @@ -129,23 +124,28 @@ func TestAccessStore_CacheKey(t *testing.T) { name: "role changes produce a different value", store: &AccessStore{ usersPolicyRules: &policyRulesMock{ - getRBFunc: func(s string) []*rbacv1.RoleBinding { - return []*rbacv1.RoleBinding{ - makeRB("testns", "testrb", testUser.Name, "testrole"), - } + roleRefs: map[string]subjectGrants{ + testUser.Name: { + roleBindings: []roleRef{ + {namespace: "testns", roleName: "testrole", resourceVersion: "testrolev1"}, + }, + }, }, }, groupsPolicyRules: &policyRulesMock{}, - roles: roleRevisionsMock(func(ns, name string) string { - return "rev1" - }), }, verify: func(t *testing.T, store *AccessStore, res string) { - store.roles = roleRevisionsMock(func(ns, name string) string { - return "rev2" - - }) - if store.CacheKey(testUser) == res { + // new mock returns different resource version + store.usersPolicyRules = &policyRulesMock{ + roleRefs: map[string]subjectGrants{ + testUser.Name: { + roleBindings: []roleRef{ + {namespace: "testns", roleName: "testrole", resourceVersion: "testrolev2"}, + }, + }, + }, + } + if store.userGrantsFor(testUser).hash() == res { t.Fatal("CacheKey did not change when on role change") } }, @@ -155,30 +155,26 @@ func TestAccessStore_CacheKey(t *testing.T) { store: &AccessStore{ usersPolicyRules: &policyRulesMock{}, groupsPolicyRules: &policyRulesMock{ - getRBFunc: func(s string) []*rbacv1.RoleBinding { - return []*rbacv1.RoleBinding{ - makeRB("testns", "testrb", testUser.Name, "testrole"), - } - }, - getCRBFunc: func(s string) []*rbacv1.ClusterRoleBinding { - if s == "newgroup" { - return []*rbacv1.ClusterRoleBinding{ - makeCRB("testcrb", testUser.Name, "testclusterrole"), - } - } - return nil + roleRefs: map[string]subjectGrants{ + testUser.Groups[0]: { + roleBindings: []roleRef{ + {namespace: "testns", roleName: "testrole", resourceVersion: "testrolegroup0"}, + }, + }, + "newgroup": { + clusterRoleBindings: []roleRef{ + {roleName: "testclusterrole", resourceVersion: "testclusterrolegroup1"}, + }, + }, }, }, - roles: roleRevisionsMock(func(ns, name string) string { - return fmt.Sprintf("%s%srev", ns, name) - }), }, verify: func(t *testing.T, store *AccessStore, res string) { testUserAlt := &user.DefaultInfo{ Name: testUser.Name, Groups: append(slices.Clone(testUser.Groups), "newgroup"), } - if store.CacheKey(testUserAlt) == res { + if store.userGrantsFor(testUserAlt).hash() == res { t.Fatal("CacheKey did not change when new group was added") } }, @@ -186,7 +182,7 @@ func TestAccessStore_CacheKey(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.verify(t, tt.store, tt.store.CacheKey(testUser)) + tt.verify(t, tt.store, tt.store.userGrantsFor(testUser).hash()) }) } } @@ -200,43 +196,39 @@ func TestAccessStore_AccessFor(t *testing.T) { store := &AccessStore{ concurrentAccessFor: new(singleflight.Group), usersPolicyRules: &policyRulesMock{ - getRBFunc: func(s string) []*rbacv1.RoleBinding { - return []*rbacv1.RoleBinding{ - makeRB("testns", "testrb", testUser.Name, "testrole"), - } - }, - getFunc: func(_ string) *AccessSet { - return &AccessSet{ - set: map[key]resourceAccessSet{ - {"get", corev1.Resource("ConfigMap")}: map[Access]bool{ - {Namespace: All, ResourceName: All}: true, + roleRefs: map[string]subjectGrants{ + testUser.Name: { + clusterRoleBindings: []roleRef{ + { + roleName: "testclusterrole", resourceVersion: "testclusterrolev1", + rules: []rbacv1.PolicyRule{{ + Verbs: []string{"get"}, + APIGroups: []string{corev1.GroupName}, + Resources: []string{"ConfigMap"}, + ResourceNames: []string{All}, + }}, }, }, - } + }, }, }, groupsPolicyRules: &policyRulesMock{ - getCRBFunc: func(s string) []*rbacv1.ClusterRoleBinding { - if s == "mygroup" { - return []*rbacv1.ClusterRoleBinding{ - makeCRB("testcrb", testUser.Name, "testclusterrole"), - } - } - return nil - }, - getFunc: func(_ string) *AccessSet { - return &AccessSet{ - set: map[key]resourceAccessSet{ - {"list", appsv1.Resource("Deployment")}: map[Access]bool{ - {Namespace: "testns", ResourceName: All}: true, + roleRefs: map[string]subjectGrants{ + testUser.Groups[0]: { + roleBindings: []roleRef{ + { + namespace: "testns", roleName: "testrole", resourceVersion: "testrolev1", + rules: []rbacv1.PolicyRule{{ + Verbs: []string{"list"}, + APIGroups: []string{appsv1.GroupName}, + Resources: []string{"Deployment"}, + ResourceNames: []string{All}, + }}, }, }, - } + }, }, }, - roles: roleRevisionsMock(func(ns, name string) string { - return fmt.Sprintf("%s%srev", ns, name) - }), cache: asCache, } @@ -247,12 +239,14 @@ func TestAccessStore_AccessFor(t *testing.T) { if as.ID == "" { t.Fatal("AccessSet has empty ID") } - if !as.Grants("get", corev1.Resource("ConfigMap"), "default", "cm") || + if !as.Grants("get", corev1.Resource("ConfigMap"), "anyns", "cm") || !as.Grants("list", appsv1.Resource("Deployment"), "testns", "deploy") { t.Error("AccessSet does not grant desired permissions") } - // wrong verbs - if as.Grants("delete", corev1.Resource("ConfigMap"), "default", "cm") || + // wrong ns + if as.Grants("list", appsv1.Resource("Deployment"), "default", "deploy") || + // wrong verbs + as.Grants("delete", corev1.Resource("ConfigMap"), "default", "cm") || as.Grants("get", appsv1.Resource("Deployment"), "testns", "deploy") || // wrong resource as.Grants("get", corev1.Resource("Secret"), "testns", "s") { @@ -307,23 +301,13 @@ func TestAccessStore_AccessFor_concurrent(t *testing.T) { asCache := &spyCache{accessStoreCache: cache.NewLRUExpireCache(100)} store := &AccessStore{ concurrentAccessFor: new(singleflight.Group), - roles: roleRevisionsMock(func(ns, name string) string { - return fmt.Sprintf("%s%srev", ns, name) - }), usersPolicyRules: &policyRulesMock{ - getRBFunc: func(s string) []*rbacv1.RoleBinding { - return []*rbacv1.RoleBinding{ - makeRB("testns", "testrb", testUser.Name, "testrole"), - } - }, - getFunc: func(_ string) *AccessSet { - return &AccessSet{ - set: map[key]resourceAccessSet{ - {"get", corev1.Resource("ConfigMap")}: map[Access]bool{ - {Namespace: All, ResourceName: All}: true, - }, + roleRefs: map[string]subjectGrants{ + testUser.Name: { + roleBindings: []roleRef{ + {namespace: "testns", roleName: "testrole", resourceVersion: "testrolev1"}, }, - } + }, }, }, cache: asCache, @@ -352,56 +336,3 @@ func TestAccessStore_AccessFor_concurrent(t *testing.T) { t.Errorf("Unexpected number of calls to cache.Set(): got %d, want %d", got, want) } } - -func makeRB(ns, name, user, role string) *rbacv1.RoleBinding { - return &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name}, - Subjects: []rbacv1.Subject{ - {Name: user}, - }, - RoleRef: rbacv1.RoleRef{Name: role}, - } -} - -func makeCRB(name, user, role string) *rbacv1.ClusterRoleBinding { - return &rbacv1.ClusterRoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - Subjects: []rbacv1.Subject{ - {Name: user}, - }, - RoleRef: rbacv1.RoleRef{Name: role}, - } -} - -type policyRulesMock struct { - getFunc func(string) *AccessSet - getRBFunc func(string) []*rbacv1.RoleBinding - getCRBFunc func(string) []*rbacv1.ClusterRoleBinding -} - -func (p policyRulesMock) get(s string) *AccessSet { - if p.getFunc == nil { - return nil - } - return p.getFunc(s) -} - -func (p policyRulesMock) getRoleBindings(s string) []*rbacv1.RoleBinding { - if p.getRBFunc == nil { - return nil - } - return p.getRBFunc(s) -} - -func (p policyRulesMock) getClusterRoleBindings(s string) []*rbacv1.ClusterRoleBinding { - if p.getCRBFunc == nil { - return nil - } - return p.getCRBFunc(s) -} - -type roleRevisionsMock func(ns, name string) string - -func (fn roleRevisionsMock) roleRevision(ns, name string) string { - return fn(ns, name) -} diff --git a/pkg/accesscontrol/policy_rule_index.go b/pkg/accesscontrol/policy_rule_index.go index 4f8d9ab9..eed0d48e 100644 --- a/pkg/accesscontrol/policy_rule_index.go +++ b/pkg/accesscontrol/policy_rule_index.go @@ -4,33 +4,35 @@ import ( "fmt" "sort" - v1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1" + rbacv1controllers "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/runtime/schema" ) const ( - rbacGroup = "rbac.authorization.k8s.io" + rbacGroup = rbacv1.GroupName All = "*" + + groupKind = rbacv1.GroupKind + userKind = rbacv1.UserKind + svcAccountKind = rbacv1.ServiceAccountKind ) type policyRuleIndex struct { - crCache v1.ClusterRoleCache - rCache v1.RoleCache - crbCache v1.ClusterRoleBindingCache - rbCache v1.RoleBindingCache - kind string + crCache rbacv1controllers.ClusterRoleCache + rCache rbacv1controllers.RoleCache + crbCache rbacv1controllers.ClusterRoleBindingCache + rbCache rbacv1controllers.RoleBindingCache roleIndexKey string clusterRoleIndexKey string } -func newPolicyRuleIndex(user bool, rbac v1.Interface) *policyRuleIndex { - key := "Group" +func newPolicyRuleIndex(user bool, rbac rbacv1controllers.Interface) *policyRuleIndex { + key := groupKind if user { - key = "User" + key = userKind } pi := &policyRuleIndex{ - kind: key, crCache: rbac.ClusterRole().Cache(), rCache: rbac.Role().Cache(), crbCache: rbac.ClusterRoleBinding().Cache(), @@ -39,52 +41,51 @@ func newPolicyRuleIndex(user bool, rbac v1.Interface) *policyRuleIndex { roleIndexKey: "rb" + key, } - pi.crbCache.AddIndexer(pi.clusterRoleIndexKey, pi.clusterRoleBindingBySubjectIndexer) - pi.rbCache.AddIndexer(pi.roleIndexKey, pi.roleBindingBySubject) + pi.crbCache.AddIndexer(pi.clusterRoleIndexKey, clusterRoleBindingBySubjectIndexer(key)) + pi.rbCache.AddIndexer(pi.roleIndexKey, roleBindingBySubjectIndexer(key)) return pi } -func (p *policyRuleIndex) clusterRoleBindingBySubjectIndexer(crb *rbacv1.ClusterRoleBinding) (result []string, err error) { - for _, subject := range crb.Subjects { - if subject.APIGroup == rbacGroup && subject.Kind == p.kind && crb.RoleRef.Kind == "ClusterRole" { +func clusterRoleBindingBySubjectIndexer(kind string) func(crb *rbacv1.ClusterRoleBinding) ([]string, error) { + return func(crb *rbacv1.ClusterRoleBinding) ([]string, error) { + if crb.RoleRef.Kind != "ClusterRole" { + return nil, nil + } + return indexSubjects(kind, crb.Subjects), nil + } +} + +func roleBindingBySubjectIndexer(key string) func(rb *rbacv1.RoleBinding) ([]string, error) { + return func(rb *rbacv1.RoleBinding) ([]string, error) { + return indexSubjects(key, rb.Subjects), nil + } +} + +func indexSubjects(kind string, subjects []rbacv1.Subject) []string { + var result []string + for _, subject := range subjects { + if subjectIs(kind, subject) { result = append(result, subject.Name) - } else if subject.APIGroup == "" && p.kind == "User" && subject.Kind == "ServiceAccount" && subject.Namespace != "" && crb.RoleRef.Kind == "ClusterRole" { + } else if kind == userKind && subjectIsServiceAccount(subject) { // Index is for Users and this references a service account result = append(result, fmt.Sprintf("serviceaccount:%s:%s", subject.Namespace, subject.Name)) } } - return -} - -func (p *policyRuleIndex) roleBindingBySubject(rb *rbacv1.RoleBinding) (result []string, err error) { - for _, subject := range rb.Subjects { - if subject.APIGroup == rbacGroup && subject.Kind == p.kind { - result = append(result, subject.Name) - } else if subject.APIGroup == "" && p.kind == "User" && subject.Kind == "ServiceAccount" && subject.Namespace != "" { - // Index is for Users and this references a service account - result = append(result, fmt.Sprintf("serviceaccount:%s:%s", subject.Namespace, subject.Name)) - } - } - return -} - -func (p *policyRuleIndex) get(subjectName string) *AccessSet { - result := &AccessSet{} - - for _, binding := range p.getRoleBindings(subjectName) { - p.addAccess(result, binding.Namespace, binding.RoleRef) - } - - for _, binding := range p.getClusterRoleBindings(subjectName) { - p.addAccess(result, All, binding.RoleRef) - } - return result } -func (p *policyRuleIndex) addAccess(accessSet *AccessSet, namespace string, roleRef rbacv1.RoleRef) { - for _, rule := range p.getRules(namespace, roleRef) { +func subjectIs(kind string, subject rbacv1.Subject) bool { + return subject.APIGroup == rbacGroup && subject.Kind == kind +} + +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 @@ -108,23 +109,24 @@ func (p *policyRuleIndex) addAccess(accessSet *AccessSet, namespace string, role } } -func (p *policyRuleIndex) getRules(namespace string, roleRef rbacv1.RoleRef) []rbacv1.PolicyRule { +// 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 { case "ClusterRole": role, err := p.crCache.Get(roleRef.Name) if err != nil { - return nil + return nil, "" } - return role.Rules + return role.Rules, role.ResourceVersion case "Role": role, err := p.rCache.Get(namespace, roleRef.Name) if err != nil { - return nil + return nil, "" } - return role.Rules + return role.Rules, role.ResourceVersion } - return nil + return nil, "" } func (p *policyRuleIndex) getClusterRoleBindings(subjectName string) []*rbacv1.ClusterRoleBinding { @@ -133,7 +135,7 @@ func (p *policyRuleIndex) getClusterRoleBindings(subjectName string) []*rbacv1.C return nil } sort.Slice(result, func(i, j int) bool { - return result[i].Name < result[j].Name + return result[i].UID < result[j].UID }) return result } @@ -148,3 +150,32 @@ func (p *policyRuleIndex) getRoleBindings(subjectName string) []*rbacv1.RoleBind }) return result } + +// getRoleRefs gathers rules from roles granted to a given subject through RoleBindings and ClusterRoleBindings +func (p *policyRuleIndex) getRoleRefs(subjectName string) subjectGrants { + var clusterRoleBindings []roleRef + for _, crb := range p.getClusterRoleBindings(subjectName) { + rules, resourceVersion := p.getRules(All, crb.RoleRef) + clusterRoleBindings = append(clusterRoleBindings, roleRef{ + roleName: crb.RoleRef.Name, + resourceVersion: resourceVersion, + rules: rules, + }) + } + + var roleBindings []roleRef + for _, rb := range p.getRoleBindings(subjectName) { + rules, resourceVersion := p.getRules(rb.Namespace, rb.RoleRef) + roleBindings = append(roleBindings, roleRef{ + roleName: rb.RoleRef.Name, + namespace: rb.Namespace, + resourceVersion: resourceVersion, + rules: rules, + }) + } + + return subjectGrants{ + roleBindings: roleBindings, + clusterRoleBindings: clusterRoleBindings, + } +} diff --git a/pkg/accesscontrol/policy_rule_index_test.go b/pkg/accesscontrol/policy_rule_index_test.go new file mode 100644 index 00000000..8a68dfe7 --- /dev/null +++ b/pkg/accesscontrol/policy_rule_index_test.go @@ -0,0 +1,238 @@ +package accesscontrol + +import ( + "slices" + "testing" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Test_policyRuleIndex_roleBindingBySubject(t *testing.T) { + roleRef := rbacv1.RoleRef{Kind: "Role", Name: "testrole"} + tests := []struct { + name string + kind string + rb *rbacv1.RoleBinding + want []string + }{ + { + name: "indexes users", + kind: "User", + rb: makeRB("testns", "testrb", roleRef, []rbacv1.Subject{ + { + APIGroup: rbacGroup, + Kind: "User", + Name: "myuser", + }, + }), + want: []string{"myuser"}, + }, + { + name: "indexes multiple subjects", + kind: "Group", + rb: makeRB("testns", "testrb", roleRef, []rbacv1.Subject{ + { + APIGroup: rbacGroup, + Kind: "Group", + Name: "mygroup1", + }, + { + APIGroup: rbacGroup, + Kind: "Group", + Name: "mygroup2", + }, + }), + want: []string{"mygroup1", "mygroup2"}, + }, + { + name: "indexes svcaccounts in user mode", + kind: "User", + rb: makeRB("testns", "testrb", roleRef, []rbacv1.Subject{ + { + APIGroup: "", + Kind: "ServiceAccount", + Name: "mysvcaccount", + Namespace: "testns", + }, + }), + want: []string{"serviceaccount:testns:mysvcaccount"}, + }, + { + name: "ignores svcaccounts in group mode", + kind: "Group", + rb: makeRB("testns", "testrb", roleRef, []rbacv1.Subject{ + { + APIGroup: "", + Kind: "ServiceAccount", + Name: "mysvcaccount", + Namespace: "testns", + }, + }), + want: []string{}, + }, + { + name: "ignores unknown subjects", + kind: "Group", + rb: makeRB("testns", "testrb", roleRef, []rbacv1.Subject{ + { + APIGroup: rbacGroup, + Kind: "User", + Name: "myuser", + }, + { + APIGroup: rbacGroup, + Kind: "Group", + Name: "mygroup1", + }, + { + APIGroup: "custom.api.group", + Kind: "CustomGroup", + Name: "mygroup2", + }, + }), + want: []string{"mygroup1"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + indexFunc := roleBindingBySubjectIndexer(tt.kind) + if got, err := indexFunc(tt.rb); err != nil { + t.Error(err) + } else if !slices.Equal(got, tt.want) { + t.Errorf("roleBindingBySubjectIndexer() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_policyRuleIndex_clusterRoleBindingBySubject(t *testing.T) { + roleRef := rbacv1.RoleRef{Kind: "ClusterRole", Name: "testclusterrole"} + tests := []struct { + name string + kind string + crb *rbacv1.ClusterRoleBinding + want []string + }{ + { + name: "ignores if RoleRef is a Role", + kind: "User", + crb: makeCRB("testcrb", rbacv1.RoleRef{Kind: "Role", Name: "testrole"}, []rbacv1.Subject{ + { + APIGroup: rbacGroup, + Kind: "User", + Name: "myuser", + }, + }), + want: []string{}, + }, + { + name: "indexes users", + kind: "User", + crb: makeCRB("testcrb", roleRef, []rbacv1.Subject{ + { + APIGroup: rbacGroup, + Kind: "User", + Name: "myuser", + }, + }), + want: []string{"myuser"}, + }, + { + name: "indexes multiple subjects", + kind: "Group", + crb: makeCRB("testcrb", roleRef, []rbacv1.Subject{ + { + APIGroup: rbacGroup, + Kind: "Group", + Name: "mygroup1", + }, + { + APIGroup: rbacGroup, + Kind: "Group", + Name: "mygroup2", + }, + }), + want: []string{"mygroup1", "mygroup2"}, + }, + { + name: "indexes svcaccounts in user mode", + kind: "User", + crb: makeCRB("testcrb", roleRef, []rbacv1.Subject{ + { + APIGroup: "", + Kind: "ServiceAccount", + Name: "mysvcaccount", + Namespace: "testns", + }, + }), + want: []string{"serviceaccount:testns:mysvcaccount"}, + }, + { + name: "ignores svcaccounts in group mode", + kind: "Group", + crb: makeCRB("testcrb", roleRef, []rbacv1.Subject{ + { + APIGroup: "", + Kind: "ServiceAccount", + Name: "mysvcaccount", + Namespace: "testns", + }, + }), + want: []string{}, + }, + { + name: "ignores unknown subjects", + kind: "Group", + crb: makeCRB("testcrb", roleRef, []rbacv1.Subject{ + { + APIGroup: rbacGroup, + Kind: "User", + Name: "myuser", + }, + { + APIGroup: rbacGroup, + Kind: "Group", + Name: "mygroup1", + }, + { + APIGroup: "custom.api.group", + Kind: "CustomGroup", + Name: "mygroup2", + }, + }), + want: []string{"mygroup1"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + indexFunc := clusterRoleBindingBySubjectIndexer(tt.kind) + if got, err := indexFunc(tt.crb); err != nil { + t.Error(err) + } else if !slices.Equal(got, tt.want) { + t.Errorf("clusterRoleBindingBySubjectIndexer() got = %v, want %v", got, tt.want) + } + }) + } +} + +func makeRB(namespace, name string, roleRef rbacv1.RoleRef, subjects []rbacv1.Subject) *rbacv1.RoleBinding { + return &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + RoleRef: roleRef, + Subjects: subjects, + } +} + +func makeCRB(name string, roleRef rbacv1.RoleRef, subjects []rbacv1.Subject) *rbacv1.ClusterRoleBinding { + return &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + RoleRef: roleRef, + Subjects: subjects, + } +} diff --git a/pkg/accesscontrol/role_revision_index.go b/pkg/accesscontrol/role_revision_index.go deleted file mode 100644 index 975cc508..00000000 --- a/pkg/accesscontrol/role_revision_index.go +++ /dev/null @@ -1,59 +0,0 @@ -package accesscontrol - -import ( - "context" - "sync" - - rbac "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1" - "github.com/rancher/wrangler/v3/pkg/kv" - rbacv1 "k8s.io/api/rbac/v1" -) - -type roleRevisionIndex struct { - roleRevisions sync.Map -} - -func newRoleRevision(ctx context.Context, rbac rbac.Interface) *roleRevisionIndex { - r := &roleRevisionIndex{} - rbac.Role().OnChange(ctx, "role-revision-indexer", r.onRoleChanged) - rbac.ClusterRole().OnChange(ctx, "role-revision-indexer", r.onClusterRoleChanged) - return r -} - -func (r *roleRevisionIndex) roleRevision(namespace, name string) string { - val, _ := r.roleRevisions.Load(roleKey{ - name: name, - namespace: namespace, - }) - revision, _ := val.(string) - return revision -} - -func (r *roleRevisionIndex) onClusterRoleChanged(key string, cr *rbacv1.ClusterRole) (role *rbacv1.ClusterRole, err error) { - if cr == nil { - r.roleRevisions.Delete(roleKey{ - name: key, - }) - } else { - r.roleRevisions.Store(roleKey{ - name: key, - }, cr.ResourceVersion) - } - return cr, nil -} - -func (r *roleRevisionIndex) onRoleChanged(key string, cr *rbacv1.Role) (role *rbacv1.Role, err error) { - if cr == nil { - namespace, name := kv.Split(key, "/") - r.roleRevisions.Delete(roleKey{ - name: name, - namespace: namespace, - }) - } else { - r.roleRevisions.Store(roleKey{ - name: cr.Name, - namespace: cr.Namespace, - }, cr.ResourceVersion) - } - return cr, nil -} diff --git a/pkg/accesscontrol/user_grants.go b/pkg/accesscontrol/user_grants.go new file mode 100644 index 00000000..e4f1db09 --- /dev/null +++ b/pkg/accesscontrol/user_grants.go @@ -0,0 +1,71 @@ +package accesscontrol + +import ( + "crypto/sha256" + "encoding/hex" + "hash" + + rbacv1 "k8s.io/api/rbac/v1" +) + +// userGrants is a complete snapshot of all rules granted to a user, including those through groups memberships +type userGrants struct { + user subjectGrants + groups []subjectGrants +} + +// subjectGrants defines role references granted to a given subject through RoleBindings and ClusterRoleBindings +type subjectGrants struct { + roleBindings []roleRef + clusterRoleBindings []roleRef +} + +// roleRef contains information from a Role or ClusterRole +type roleRef struct { + namespace, roleName, resourceVersion string + rules []rbacv1.PolicyRule +} + +// hash calculates a unique identifier from all the grants for a user +func (u userGrants) hash() string { + d := sha256.New() + u.user.writeTo(d) + for _, group := range u.groups { + group.writeTo(d) + } + return hex.EncodeToString(d.Sum(nil)) +} + +// writeTo appends a subject's grants information to a given hash +func (b subjectGrants) writeTo(digest hash.Hash) { + for _, rb := range b.roleBindings { + rb.writeTo(digest) + } + for _, crb := range b.clusterRoleBindings { + crb.writeTo(digest) + } +} + +// toAccessSet produces a new AccessSet from the rules in the inner roles references +func (b subjectGrants) toAccessSet() *AccessSet { + result := new(AccessSet) + + for _, binding := range b.roleBindings { + addAccess(result, binding.namespace, binding.rules) + } + + for _, binding := range b.clusterRoleBindings { + addAccess(result, All, binding.rules) + } + + return result +} + +// writeTo appends a single role information to a given hash +func (r roleRef) writeTo(digest hash.Hash) { + digest.Write([]byte(r.roleName)) + if r.namespace != "" { + digest.Write([]byte(r.namespace)) + } + digest.Write([]byte(r.resourceVersion)) +}