From 6c6f0987dc04c942c33a2a90ffbe7075c0e2e5eb Mon Sep 17 00:00:00 2001 From: Taran Pelkey Date: Wed, 10 Jul 2024 14:41:49 -0400 Subject: [PATCH] Add groups to policy entities (#20052) * Add groups to policy entities * update comment --------- Co-authored-by: Harshavardhana --- cmd/iam-store.go | 68 +++++++++++++--------- cmd/iam.go | 57 ++++++++++++++++++- cmd/sts-handlers_test.go | 82 +++++++++++++++++++++++++++ internal/config/identity/ldap/ldap.go | 48 ++++++++++++++++ 4 files changed, 225 insertions(+), 30 deletions(-) diff --git a/cmd/iam-store.go b/cmd/iam-store.go index 729904165..f1f7fab1e 100644 --- a/cmd/iam-store.go +++ b/cmd/iam-store.go @@ -2088,50 +2088,64 @@ func (store *IAMStoreSys) GetAllSTSUserMappings(userPredicate func(string) bool) return stsMap, nil } -// Assumes store is locked by caller. If users is empty, returns all user mappings. -func (store *IAMStoreSys) listUserPolicyMappings(cache *iamCache, users []string, +// Assumes store is locked by caller. If userMap is empty, returns all user mappings. +func (store *IAMStoreSys) listUserPolicyMappings(cache *iamCache, userMap map[string]set.StringSet, userPredicate func(string) bool, ) []madmin.UserPolicyEntities { + stsMap := xsync.NewMapOf[string, MappedPolicy]() + resMap := make(map[string]madmin.UserPolicyEntities, len(userMap)) + + for user, groupSet := range userMap { + // Attempt to load parent user mapping for STS accounts + store.loadMappedPolicy(context.TODO(), user, stsUser, false, stsMap) + blankEntities := madmin.UserPolicyEntities{User: user} + if !groupSet.IsEmpty() { + blankEntities.MemberOfMappings = store.listGroupPolicyMappings(cache, groupSet, nil) + } + resMap[user] = blankEntities + } + var r []madmin.UserPolicyEntities - usersSet := set.CreateStringSet(users...) cache.iamUserPolicyMap.Range(func(user string, mappedPolicy MappedPolicy) bool { if userPredicate != nil && !userPredicate(user) { return true } - if !usersSet.IsEmpty() && !usersSet.Contains(user) { - return true + entitiesWithMemberOf, ok := resMap[user] + if !ok { + if len(userMap) > 0 { + return true + } + entitiesWithMemberOf = madmin.UserPolicyEntities{User: user} } ps := mappedPolicy.toSlice() sort.Strings(ps) - r = append(r, madmin.UserPolicyEntities{ - User: user, - Policies: ps, - }) + entitiesWithMemberOf.Policies = ps + resMap[user] = entitiesWithMemberOf return true }) - stsMap := xsync.NewMapOf[string, MappedPolicy]() - for _, user := range users { - // Attempt to load parent user mapping for STS accounts - store.loadMappedPolicy(context.TODO(), user, stsUser, false, stsMap) - } - stsMap.Range(func(user string, mappedPolicy MappedPolicy) bool { if userPredicate != nil && !userPredicate(user) { return true } + entitiesWithMemberOf := resMap[user] + ps := mappedPolicy.toSlice() sort.Strings(ps) - r = append(r, madmin.UserPolicyEntities{ - User: user, - Policies: ps, - }) + entitiesWithMemberOf.Policies = ps + resMap[user] = entitiesWithMemberOf return true }) + for _, v := range resMap { + if v.Policies != nil || v.MemberOfMappings != nil { + r = append(r, v) + } + } + sort.Slice(r, func(i, j int) bool { return r[i].User < r[j].User }) @@ -2140,11 +2154,11 @@ func (store *IAMStoreSys) listUserPolicyMappings(cache *iamCache, users []string } // Assumes store is locked by caller. If groups is empty, returns all group mappings. -func (store *IAMStoreSys) listGroupPolicyMappings(cache *iamCache, groups []string, +func (store *IAMStoreSys) listGroupPolicyMappings(cache *iamCache, groupsSet set.StringSet, groupPredicate func(string) bool, ) []madmin.GroupPolicyEntities { var r []madmin.GroupPolicyEntities - groupsSet := set.CreateStringSet(groups...) + cache.iamGroupPolicyMap.Range(func(group string, mappedPolicy MappedPolicy) bool { if groupPredicate != nil && !groupPredicate(group) { return true @@ -2171,11 +2185,9 @@ func (store *IAMStoreSys) listGroupPolicyMappings(cache *iamCache, groups []stri } // Assumes store is locked by caller. If policies is empty, returns all policy mappings. -func (store *IAMStoreSys) listPolicyMappings(cache *iamCache, policies []string, +func (store *IAMStoreSys) listPolicyMappings(cache *iamCache, queryPolSet set.StringSet, userPredicate, groupPredicate func(string) bool, ) []madmin.PolicyEntities { - queryPolSet := set.CreateStringSet(policies...) - policyToUsersMap := make(map[string]set.StringSet) cache.iamUserPolicyMap.Range(func(user string, mappedPolicy MappedPolicy) bool { if userPredicate != nil && !userPredicate(user) { @@ -2305,7 +2317,7 @@ func (store *IAMStoreSys) listPolicyMappings(cache *iamCache, policies []string, } // ListPolicyMappings - return users/groups mapped to policies. -func (store *IAMStoreSys) ListPolicyMappings(q madmin.PolicyEntitiesQuery, +func (store *IAMStoreSys) ListPolicyMappings(q cleanEntitiesQuery, userPredicate, groupPredicate func(string) bool, ) madmin.PolicyEntitiesResult { cache := store.rlock() @@ -2313,7 +2325,7 @@ func (store *IAMStoreSys) ListPolicyMappings(q madmin.PolicyEntitiesQuery, var result madmin.PolicyEntitiesResult - isAllPoliciesQuery := len(q.Users) == 0 && len(q.Groups) == 0 && len(q.Policy) == 0 + isAllPoliciesQuery := len(q.Users) == 0 && len(q.Groups) == 0 && len(q.Policies) == 0 if len(q.Users) > 0 { result.UserMappings = store.listUserPolicyMappings(cache, q.Users, userPredicate) @@ -2321,8 +2333,8 @@ func (store *IAMStoreSys) ListPolicyMappings(q madmin.PolicyEntitiesQuery, if len(q.Groups) > 0 { result.GroupMappings = store.listGroupPolicyMappings(cache, q.Groups, groupPredicate) } - if len(q.Policy) > 0 || isAllPoliciesQuery { - result.PolicyMappings = store.listPolicyMappings(cache, q.Policy, userPredicate, groupPredicate) + if len(q.Policies) > 0 || isAllPoliciesQuery { + result.PolicyMappings = store.listPolicyMappings(cache, q.Policies, userPredicate, groupPredicate) } return result } diff --git a/cmd/iam.go b/cmd/iam.go index e88020b54..899488c4e 100644 --- a/cmd/iam.go +++ b/cmd/iam.go @@ -807,6 +807,57 @@ func (sys *IAMSys) ListLDAPUsers(ctx context.Context) (map[string]madmin.UserInf } } +type cleanEntitiesQuery struct { + Users map[string]set.StringSet + Groups set.StringSet + Policies set.StringSet +} + +// createCleanEntitiesQuery - maps users to their groups and normalizes user or group DNs if ldap. +func (sys *IAMSys) createCleanEntitiesQuery(q madmin.PolicyEntitiesQuery, ldap bool) cleanEntitiesQuery { + cleanQ := cleanEntitiesQuery{ + Users: make(map[string]set.StringSet), + Groups: set.CreateStringSet(q.Groups...), + Policies: set.CreateStringSet(q.Policy...), + } + + if ldap { + // Validate and normalize users, then fetch and normalize their groups + // Also include unvalidated users for backward compatibility. + for _, user := range q.Users { + lookupRes, actualGroups, _ := sys.LDAPConfig.GetValidatedDNWithGroups(user) + if lookupRes != nil { + groupSet := set.CreateStringSet(actualGroups...) + + // duplicates can be overwritten, fetched groups should be identical. + cleanQ.Users[lookupRes.NormDN] = groupSet + } + // Search for non-normalized DN as well for backward compatibility. + if _, ok := cleanQ.Users[user]; !ok { + cleanQ.Users[user] = nil + } + } + + // Validate and normalize groups. + for _, group := range q.Groups { + lookupRes, underDN, _ := sys.LDAPConfig.GetValidatedGroupDN(nil, group) + if lookupRes != nil && !underDN { + cleanQ.Groups.Add(lookupRes.NormDN) + } + } + } else { + for _, user := range q.Users { + info, err := sys.store.GetUserInfo(user) + var groupSet set.StringSet + if err == nil { + groupSet = set.CreateStringSet(info.MemberOf...) + } + cleanQ.Users[user] = groupSet + } + } + return cleanQ +} + // QueryLDAPPolicyEntities - queries policy associations for LDAP users/groups/policies. func (sys *IAMSys) QueryLDAPPolicyEntities(ctx context.Context, q madmin.PolicyEntitiesQuery) (*madmin.PolicyEntitiesResult, error) { if !sys.Initialized() { @@ -819,7 +870,8 @@ func (sys *IAMSys) QueryLDAPPolicyEntities(ctx context.Context, q madmin.PolicyE select { case <-sys.configLoaded: - pe := sys.store.ListPolicyMappings(q, sys.LDAPConfig.IsLDAPUserDN, sys.LDAPConfig.IsLDAPGroupDN) + cleanQuery := sys.createCleanEntitiesQuery(q, true) + pe := sys.store.ListPolicyMappings(cleanQuery, sys.LDAPConfig.IsLDAPUserDN, sys.LDAPConfig.IsLDAPGroupDN) pe.Timestamp = UTCNow() return &pe, nil case <-ctx.Done(): @@ -893,6 +945,7 @@ func (sys *IAMSys) QueryPolicyEntities(ctx context.Context, q madmin.PolicyEntit select { case <-sys.configLoaded: + cleanQuery := sys.createCleanEntitiesQuery(q, false) var userPredicate, groupPredicate func(string) bool if sys.LDAPConfig.Enabled() { userPredicate = func(s string) bool { @@ -902,7 +955,7 @@ func (sys *IAMSys) QueryPolicyEntities(ctx context.Context, q madmin.PolicyEntit return !sys.LDAPConfig.IsLDAPGroupDN(s) } } - pe := sys.store.ListPolicyMappings(q, userPredicate, groupPredicate) + pe := sys.store.ListPolicyMappings(cleanQuery, userPredicate, groupPredicate) pe.Timestamp = UTCNow() return &pe, nil case <-ctx.Done(): diff --git a/cmd/sts-handlers_test.go b/cmd/sts-handlers_test.go index b56671d47..5889a324e 100644 --- a/cmd/sts-handlers_test.go +++ b/cmd/sts-handlers_test.go @@ -733,6 +733,7 @@ func TestIAMWithLDAPServerSuite(t *testing.T) { suite.SetUpSuite(c) suite.SetUpLDAP(c, ldapServer) suite.TestLDAPSTS(c) + suite.TestLDAPPolicyEntitiesLookup(c) suite.TestLDAPUnicodeVariations(c) suite.TestLDAPSTSServiceAccounts(c) suite.TestLDAPSTSServiceAccountsWithUsername(c) @@ -764,6 +765,7 @@ func TestIAMWithLDAPNonNormalizedBaseDNConfigServerSuite(t *testing.T) { suite.SetUpSuite(c) suite.SetUpLDAPWithNonNormalizedBaseDN(c, ldapServer) suite.TestLDAPSTS(c) + suite.TestLDAPPolicyEntitiesLookup(c) suite.TestLDAPUnicodeVariations(c) suite.TestLDAPSTSServiceAccounts(c) suite.TestLDAPSTSServiceAccountsWithUsername(c) @@ -2096,6 +2098,86 @@ func (s *TestSuiteIAM) TestLDAPAttributesLookup(c *check) { } } +func (s *TestSuiteIAM) TestLDAPPolicyEntitiesLookup(c *check) { + ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout) + defer cancel() + + groupDN := "cn=projectb,ou=groups,ou=swengg,dc=min,dc=io" + groupPolicy := "readwrite" + groupReq := madmin.PolicyAssociationReq{ + Policies: []string{groupPolicy}, + Group: groupDN, + } + _, err := s.adm.AttachPolicyLDAP(ctx, groupReq) + if err != nil { + c.Fatalf("Unable to attach group policy: %v", err) + } + type caseTemplate struct { + inDN string + expectedOutDN string + expectedGroupDN string + expectedGroupPolicy string + } + cases := []caseTemplate{ + { + inDN: "uid=dillon,ou=people,ou=swengg,dc=min,dc=io", + expectedOutDN: "uid=dillon,ou=people,ou=swengg,dc=min,dc=io", + expectedGroupDN: groupDN, + expectedGroupPolicy: groupPolicy, + }, + } + + policy := "readonly" + for _, testCase := range cases { + userReq := madmin.PolicyAssociationReq{ + Policies: []string{policy}, + User: testCase.inDN, + } + _, err := s.adm.AttachPolicyLDAP(ctx, userReq) + if err != nil { + c.Fatalf("Unable to attach policy: %v", err) + } + + entities, err := s.adm.GetLDAPPolicyEntities(ctx, madmin.PolicyEntitiesQuery{ + Users: []string{testCase.inDN}, + Policy: []string{policy}, + }) + if err != nil { + c.Fatalf("Unable to fetch policy entities: %v", err) + } + + // switch statement to check all the conditions + switch { + case len(entities.UserMappings) != 1: + c.Fatalf("Expected to find exactly one user mapping") + case entities.UserMappings[0].User != testCase.expectedOutDN: + c.Fatalf("Expected user DN `%s`, found `%s`", testCase.expectedOutDN, entities.UserMappings[0].User) + case len(entities.UserMappings[0].Policies) != 1: + c.Fatalf("Expected exactly one policy attached to user") + case entities.UserMappings[0].Policies[0] != policy: + c.Fatalf("Expected attached policy `%s`, found `%s`", policy, entities.UserMappings[0].Policies[0]) + case len(entities.UserMappings[0].MemberOfMappings) != 1: + c.Fatalf("Expected exactly one group attached to user") + case entities.UserMappings[0].MemberOfMappings[0].Group != testCase.expectedGroupDN: + c.Fatalf("Expected attached group `%s`, found `%s`", testCase.expectedGroupDN, entities.UserMappings[0].MemberOfMappings[0].Group) + case len(entities.UserMappings[0].MemberOfMappings[0].Policies) != 1: + c.Fatalf("Expected exactly one policy attached to group") + case entities.UserMappings[0].MemberOfMappings[0].Policies[0] != testCase.expectedGroupPolicy: + c.Fatalf("Expected attached policy `%s`, found `%s`", testCase.expectedGroupPolicy, entities.UserMappings[0].MemberOfMappings[0].Policies[0]) + } + + _, err = s.adm.DetachPolicyLDAP(ctx, userReq) + if err != nil { + c.Fatalf("Unable to detach policy: %v", err) + } + } + + _, err = s.adm.DetachPolicyLDAP(ctx, groupReq) + if err != nil { + c.Fatalf("Unable to detach group policy: %v", err) + } +} + func (s *TestSuiteIAM) TestOpenIDSTS(c *check) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/internal/config/identity/ldap/ldap.go b/internal/config/identity/ldap/ldap.go index eaf8d4a06..a6f71a748 100644 --- a/internal/config/identity/ldap/ldap.go +++ b/internal/config/identity/ldap/ldap.go @@ -179,6 +179,54 @@ func (l *Config) GetValidatedDNUnderBaseDN(conn *ldap.Conn, dn string, baseDNLis return searchRes, false, nil } +// GetValidatedDNWithGroups - Gets validated DN from given DN or short username +// and returns the DN and the groups the user is a member of. +// +// If username is required in group search but a DN is passed, no groups are +// returned. +func (l *Config) GetValidatedDNWithGroups(username string) (*xldap.DNSearchResult, []string, error) { + conn, err := l.LDAP.Connect() + if err != nil { + return nil, nil, err + } + defer conn.Close() + + // Bind to the lookup user account + if err = l.LDAP.LookupBind(conn); err != nil { + return nil, nil, err + } + + var lookupRes *xldap.DNSearchResult + shortUsername := "" + // Check if the passed in username is a valid DN. + if !l.ParsesAsDN(username) { + // We consider it as a login username and attempt to check it exists in + // the directory. + lookupRes, err = l.LDAP.LookupUsername(conn, username) + if err != nil { + if strings.Contains(err.Error(), "User DN not found for") { + return nil, nil, nil + } + return nil, nil, fmt.Errorf("Unable to find user DN: %w", err) + } + shortUsername = username + } else { + // Since the username parses as a valid DN, check that it exists and is + // under a configured base DN in the LDAP directory. + var isUnderBaseDN bool + lookupRes, isUnderBaseDN, err = l.GetValidatedUserDN(conn, username) + if err == nil && !isUnderBaseDN { + return nil, nil, fmt.Errorf("Unable to find user DN: %w", err) + } + } + + groups, err := l.LDAP.SearchForUserGroups(conn, shortUsername, lookupRes.ActualDN) + if err != nil { + return nil, nil, err + } + return lookupRes, groups, nil +} + // Bind - binds to ldap, searches LDAP and returns the distinguished name of the // user and the list of groups. func (l *Config) Bind(username, password string) (*xldap.DNSearchResult, []string, error) {