Merge pull request #1612 from vi7/multiple-user-to-group-mapping

connector/ldap: add multiple user to group mapping
This commit is contained in:
Joel Speed 2020-02-02 11:09:05 +00:00 committed by GitHub
commit 30cd592801
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 371 additions and 63 deletions

View file

@ -123,11 +123,12 @@ connectors:
# Optional filter to apply when searching the directory. # Optional filter to apply when searching the directory.
filter: "(objectClass=group)" filter: "(objectClass=group)"
# Following two fields are used to match a user to a group. It adds an additional # Following list contains field pairs that are used to match a user to a group. It adds an additional
# requirement to the filter that an attribute in the group must match the user's # requirement to the filter that an attribute in the group must match the user's
# attribute value. # attribute value.
userAttr: uid userMatchers:
groupAttr: member - userAttr: uid
groupAttr: member
# Represents group name. # Represents group name.
nameAttr: name nameAttr: name
@ -215,8 +216,9 @@ groupSearch:
# The group search needs to match the "uid" attribute on # The group search needs to match the "uid" attribute on
# the user with the "memberUid" attribute on the group. # the user with the "memberUid" attribute on the group.
userAttr: uid userMatchers:
groupAttr: memberUid - userAttr: uid
groupAttr: memberUid
# Unique name of the group. # Unique name of the group.
nameAttr: cn nameAttr: cn
@ -242,8 +244,27 @@ groupSearch:
# Optional filter to apply when searching the directory. # Optional filter to apply when searching the directory.
filter: "(objectClass=group)" filter: "(objectClass=group)"
userAttr: DN # Use "DN" here not "uid" userMatchers:
groupAttr: member - userAttr: DN # Use "DN" here not "uid"
groupAttr: member
nameAttr: name
```
There are cases when different types (objectClass) of groups use different attributes to keep a list of members. Below is an example of group query for such case:
```yaml
groupSearch:
baseDN: cn=groups,cn=compat,dc=example,dc=com
# Optional filter to search for different group types
filter: "(|(objectClass=posixGroup)(objectClass=group))"
# Use multiple user matchers so Dex will know which attribute names should be used to search for group members
userMatchers:
- userAttr: uid
groupAttr: memberUid
- userAttr: DN
groupAttr: member
nameAttr: name nameAttr: name
``` ```
@ -275,8 +296,9 @@ connectors:
# Would translate to the query "(&(objectClass=group)(member=<user uid>))". # Would translate to the query "(&(objectClass=group)(member=<user uid>))".
baseDN: cn=groups,dc=freeipa,dc=example,dc=com baseDN: cn=groups,dc=freeipa,dc=example,dc=com
filter: "(objectClass=group)" filter: "(objectClass=group)"
userAttr: uid userMatchers:
groupAttr: member - userAttr: uid
groupAttr: member
nameAttr: name nameAttr: name
``` ```
@ -315,8 +337,9 @@ connectors:
groupSearch: groupSearch:
baseDN: cn=Users,dc=example,dc=com baseDN: cn=Users,dc=example,dc=com
filter: "(objectClass=group)" filter: "(objectClass=group)"
userAttr: DN userMatchers:
groupAttr: member - userAttr: DN
groupAttr: member
nameAttr: cn nameAttr: cn
``` ```

View file

@ -41,16 +41,26 @@ import (
// nameAttr: name // nameAttr: name
// preferredUsernameAttr: uid // preferredUsernameAttr: uid
// groupSearch: // groupSearch:
// # Would translate to the query "(&(objectClass=group)(member=<user uid>))" // # Would translate to the separate query per user matcher pair and aggregate results into a single group list:
// # "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(memberUid=<user uid>))"
// # "(&(|(objectClass=posixGroup)(objectClass=groupOfNames))(member=<user DN>))"
// baseDN: cn=groups,dc=example,dc=com // baseDN: cn=groups,dc=example,dc=com
// filter: "(objectClass=group)" // filter: "(|(objectClass=posixGroup)(objectClass=groupOfNames))"
// userAttr: uid // userMatchers:
// # Use if full DN is needed and not available as any other attribute // - userAttr: uid
// # Will only work if "DN" attribute does not exist in the record // groupAttr: memberUid
// # userAttr: DN // # Use if full DN is needed and not available as any other attribute
// groupAttr: member // # Will only work if "DN" attribute does not exist in the record:
// - userAttr: DN
// groupAttr: member
// nameAttr: name // nameAttr: name
// //
type UserMatcher struct {
UserAttr string `json:"userAttr"`
GroupAttr string `json:"groupAttr"`
}
type Config struct { type Config struct {
// The host and optional port of the LDAP server. If port isn't supplied, it will be // The host and optional port of the LDAP server. If port isn't supplied, it will be
// guessed based on the TLS configuration. 389 or 636. // guessed based on the TLS configuration. 389 or 636.
@ -124,16 +134,22 @@ type Config struct {
Scope string `json:"scope"` // Defaults to "sub" Scope string `json:"scope"` // Defaults to "sub"
// These two fields are use to match a user to a group. // DEPRECATED config options. Those are left for backward compatibility.
// See "UserMatchers" below for the current group to user matching implementation
// TODO: should be eventually removed from the code
UserAttr string `json:"userAttr"`
GroupAttr string `json:"groupAttr"`
// Array of the field pairs used to match a user to a group.
// See the "UserMatcher" struct for the exact field names
// //
// It adds an additional requirement to the filter that an attribute in the group // Each pair adds an additional requirement to the filter that an attribute in the group
// match the user's attribute value. For example that the "members" attribute of // match the user's attribute value. For example that the "members" attribute of
// a group matches the "uid" of the user. The exact filter being added is: // a group matches the "uid" of the user. The exact filter being added is:
// //
// (<groupAttr>=<userAttr value>) // (userMatchers[n].<groupAttr>=userMatchers[n].<userAttr value>)
// //
UserAttr string `json:"userAttr"` UserMatchers []UserMatcher `json:"userMatchers"`
GroupAttr string `json:"groupAttr"`
// The attribute of the group that represents its name. // The attribute of the group that represents its name.
NameAttr string `json:"nameAttr"` NameAttr string `json:"nameAttr"`
@ -165,6 +181,23 @@ func parseScope(s string) (int, bool) {
return 0, false return 0, false
} }
// Build a list of group attr name to user attr value matchers.
// Function exists here to allow backward compatibility between old and new
// group to user matching implementations.
// See "Config.GroupSearch.UserMatchers" comments for the details
func (c *ldapConnector) userMatchers() []UserMatcher {
if len(c.GroupSearch.UserMatchers) > 0 && c.GroupSearch.UserMatchers[0].UserAttr != "" {
return c.GroupSearch.UserMatchers[:]
} else {
return []UserMatcher{
{
UserAttr: c.GroupSearch.UserAttr,
GroupAttr: c.GroupSearch.GroupAttr,
},
}
}
}
// Open returns an authentication strategy using LDAP. // Open returns an authentication strategy using LDAP.
func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
conn, err := c.OpenConnector(logger) conn, err := c.OpenConnector(logger)
@ -378,11 +411,14 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E
Attributes: []string{ Attributes: []string{
c.UserSearch.IDAttr, c.UserSearch.IDAttr,
c.UserSearch.EmailAttr, c.UserSearch.EmailAttr,
c.GroupSearch.UserAttr,
// TODO(ericchiang): what if this contains duplicate values? // TODO(ericchiang): what if this contains duplicate values?
}, },
} }
for _, matcher := range c.userMatchers() {
req.Attributes = append(req.Attributes, matcher.UserAttr)
}
if c.UserSearch.NameAttr != "" { if c.UserSearch.NameAttr != "" {
req.Attributes = append(req.Attributes, c.UserSearch.NameAttr) req.Attributes = append(req.Attributes, c.UserSearch.NameAttr)
} }
@ -536,36 +572,38 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
} }
var groups []*ldap.Entry var groups []*ldap.Entry
for _, attr := range getAttrs(user, c.GroupSearch.UserAttr) { for _, matcher := range c.userMatchers() {
filter := fmt.Sprintf("(%s=%s)", c.GroupSearch.GroupAttr, ldap.EscapeFilter(attr)) for _, attr := range getAttrs(user, matcher.UserAttr) {
if c.GroupSearch.Filter != "" { filter := fmt.Sprintf("(%s=%s)", matcher.GroupAttr, ldap.EscapeFilter(attr))
filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter) if c.GroupSearch.Filter != "" {
} filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter)
}
req := &ldap.SearchRequest{
BaseDN: c.GroupSearch.BaseDN, req := &ldap.SearchRequest{
Filter: filter, BaseDN: c.GroupSearch.BaseDN,
Scope: c.groupSearchScope, Filter: filter,
Attributes: []string{c.GroupSearch.NameAttr}, Scope: c.groupSearchScope,
} Attributes: []string{c.GroupSearch.NameAttr},
}
gotGroups := false
if err := c.do(ctx, func(conn *ldap.Conn) error { gotGroups := false
c.logger.Infof("performing ldap search %s %s %s", if err := c.do(ctx, func(conn *ldap.Conn) error {
req.BaseDN, scopeString(req.Scope), req.Filter) c.logger.Infof("performing ldap search %s %s %s",
resp, err := conn.Search(req) req.BaseDN, scopeString(req.Scope), req.Filter)
if err != nil { resp, err := conn.Search(req)
return fmt.Errorf("ldap: search failed: %v", err) if err != nil {
return fmt.Errorf("ldap: search failed: %v", err)
}
gotGroups = len(resp.Entries) != 0
groups = append(groups, resp.Entries...)
return nil
}); err != nil {
return nil, err
}
if !gotGroups {
// TODO(ericchiang): Is this going to spam the logs?
c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter)
} }
gotGroups = len(resp.Entries) != 0
groups = append(groups, resp.Entries...)
return nil
}); err != nil {
return nil, err
}
if !gotGroups {
// TODO(ericchiang): Is this going to spam the logs?
c.logger.Errorf("ldap: groups search with filter %q returned no groups", filter)
} }
} }

View file

@ -307,8 +307,12 @@ member: cn=jane,ou=People,dc=example,dc=org
c.UserSearch.IDAttr = "DN" c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn" c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org" c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
c.GroupSearch.UserAttr = "DN" c.GroupSearch.UserMatchers = []UserMatcher{
c.GroupSearch.GroupAttr = "member" {
UserAttr: "DN",
GroupAttr: "member",
},
}
c.GroupSearch.NameAttr = "cn" c.GroupSearch.NameAttr = "cn"
tests := []subtest{ tests := []subtest{
@ -400,8 +404,12 @@ gidNumber: 1002
c.UserSearch.IDAttr = "DN" c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn" c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org" c.GroupSearch.BaseDN = "ou=Groups,dc=example,dc=org"
c.GroupSearch.UserAttr = "departmentNumber" c.GroupSearch.UserMatchers = []UserMatcher{
c.GroupSearch.GroupAttr = "gidNumber" {
UserAttr: "departmentNumber",
GroupAttr: "gidNumber",
},
}
c.GroupSearch.NameAttr = "cn" c.GroupSearch.NameAttr = "cn"
tests := []subtest{ tests := []subtest{
{ {
@ -485,6 +493,243 @@ cn: admins
member: cn=john,ou=People,dc=example,dc=org member: cn=john,ou=People,dc=example,dc=org
member: cn=jane,ou=People,dc=example,dc=org member: cn=jane,ou=People,dc=example,dc=org
dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org
objectClass: groupOfNames
cn: developers
member: cn=jane,ou=People,dc=example,dc=org
`
c := &Config{}
c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "dc=example,dc=org"
c.GroupSearch.UserMatchers = []UserMatcher{
{
UserAttr: "DN",
GroupAttr: "member",
},
}
c.GroupSearch.NameAttr = "cn"
c.GroupSearch.Filter = "(ou:dn:=Seattle)" // ignore other groups
tests := []subtest{
{
name: "validpassword",
username: "jane",
password: "foo",
groups: true,
want: connector.Identity{
UserID: "cn=jane,ou=People,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
Groups: []string{"admins", "developers"},
},
},
{
name: "validpassword2",
username: "john",
password: "bar",
groups: true,
want: connector.Identity{
UserID: "cn=john,ou=People,dc=example,dc=org",
Username: "john",
Email: "johndoe@example.com",
EmailVerified: true,
Groups: []string{"admins"},
},
},
}
runTests(t, schema, connectLDAP, c, tests)
}
func TestGroupToUserMatchers(t *testing.T) {
schema := `
dn: ou=People,dc=example,dc=org
objectClass: organizationalUnit
ou: People
dn: cn=jane,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: jane
uid: janedoe
mail: janedoe@example.com
userpassword: foo
dn: cn=john,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: john
uid: johndoe
mail: johndoe@example.com
userpassword: bar
# Group definitions.
dn: ou=Seattle,dc=example,dc=org
objectClass: organizationalUnit
ou: Seattle
dn: ou=Portland,dc=example,dc=org
objectClass: organizationalUnit
ou: Portland
dn: ou=Groups,ou=Seattle,dc=example,dc=org
objectClass: organizationalUnit
ou: Groups
dn: ou=UnixGroups,ou=Seattle,dc=example,dc=org
objectClass: organizationalUnit
ou: UnixGroups
dn: ou=Groups,ou=Portland,dc=example,dc=org
objectClass: organizationalUnit
ou: Groups
dn: ou=UnixGroups,ou=Portland,dc=example,dc=org
objectClass: organizationalUnit
ou: UnixGroups
dn: cn=qa,ou=Groups,ou=Portland,dc=example,dc=org
objectClass: groupOfNames
cn: qa
member: cn=john,ou=People,dc=example,dc=org
dn: cn=logger,ou=UnixGroups,ou=Portland,dc=example,dc=org
objectClass: posixGroup
gidNumber: 1000
cn: logger
memberUid: johndoe
dn: cn=admins,ou=Groups,ou=Seattle,dc=example,dc=org
objectClass: groupOfNames
cn: admins
member: cn=john,ou=People,dc=example,dc=org
member: cn=jane,ou=People,dc=example,dc=org
dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org
objectClass: groupOfNames
cn: developers
member: cn=jane,ou=People,dc=example,dc=org
dn: cn=frontend,ou=UnixGroups,ou=Seattle,dc=example,dc=org
objectClass: posixGroup
gidNumber: 1001
cn: frontend
memberUid: janedoe
`
c := &Config{}
c.UserSearch.BaseDN = "ou=People,dc=example,dc=org"
c.UserSearch.NameAttr = "cn"
c.UserSearch.EmailAttr = "mail"
c.UserSearch.IDAttr = "DN"
c.UserSearch.Username = "cn"
c.GroupSearch.BaseDN = "dc=example,dc=org"
c.GroupSearch.UserMatchers = []UserMatcher{
{
UserAttr: "DN",
GroupAttr: "member",
},
{
UserAttr: "uid",
GroupAttr: "memberUid",
},
}
c.GroupSearch.NameAttr = "cn"
c.GroupSearch.Filter = "(|(objectClass=posixGroup)(objectClass=groupOfNames))" // search all group types
tests := []subtest{
{
name: "validpassword",
username: "jane",
password: "foo",
groups: true,
want: connector.Identity{
UserID: "cn=jane,ou=People,dc=example,dc=org",
Username: "jane",
Email: "janedoe@example.com",
EmailVerified: true,
Groups: []string{"admins", "developers", "frontend"},
},
},
{
name: "validpassword2",
username: "john",
password: "bar",
groups: true,
want: connector.Identity{
UserID: "cn=john,ou=People,dc=example,dc=org",
Username: "john",
Email: "johndoe@example.com",
EmailVerified: true,
Groups: []string{"qa", "admins", "logger"},
},
},
}
runTests(t, schema, connectLDAP, c, tests)
}
// Test deprecated group to user matching implementation
// which was left for backward compatibility.
// See "Config.GroupSearch.UserMatchers" comments for the details
func TestDeprecatedGroupToUserMatcher(t *testing.T) {
schema := `
dn: ou=People,dc=example,dc=org
objectClass: organizationalUnit
ou: People
dn: cn=jane,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: jane
mail: janedoe@example.com
userpassword: foo
dn: cn=john,ou=People,dc=example,dc=org
objectClass: person
objectClass: inetOrgPerson
sn: doe
cn: john
mail: johndoe@example.com
userpassword: bar
# Group definitions.
dn: ou=Seattle,dc=example,dc=org
objectClass: organizationalUnit
ou: Seattle
dn: ou=Portland,dc=example,dc=org
objectClass: organizationalUnit
ou: Portland
dn: ou=Groups,ou=Seattle,dc=example,dc=org
objectClass: organizationalUnit
ou: Groups
dn: ou=Groups,ou=Portland,dc=example,dc=org
objectClass: organizationalUnit
ou: Groups
dn: cn=qa,ou=Groups,ou=Portland,dc=example,dc=org
objectClass: groupOfNames
cn: qa
member: cn=john,ou=People,dc=example,dc=org
dn: cn=admins,ou=Groups,ou=Seattle,dc=example,dc=org
objectClass: groupOfNames
cn: admins
member: cn=john,ou=People,dc=example,dc=org
member: cn=jane,ou=People,dc=example,dc=org
dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org dn: cn=developers,ou=Groups,ou=Seattle,dc=example,dc=org
objectClass: groupOfNames objectClass: groupOfNames
cn: developers cn: developers

View file

@ -41,10 +41,11 @@ connectors:
baseDN: cn=Users,dc=example,dc=com baseDN: cn=Users,dc=example,dc=com
filter: "(objectClass=group)" filter: "(objectClass=group)"
userMatchers:
# A user is a member of a group when their DN matches # A user is a member of a group when their DN matches
# the value of a "member" attribute on the group entity. # the value of a "member" attribute on the group entity.
userAttr: DN - userAttr: DN
groupAttr: member groupAttr: member
# The group name should be the "cn" value. # The group name should be the "cn" value.
nameAttr: cn nameAttr: cn

View file

@ -37,10 +37,11 @@ connectors:
baseDN: ou=Groups,dc=example,dc=org baseDN: ou=Groups,dc=example,dc=org
filter: "(objectClass=groupOfNames)" filter: "(objectClass=groupOfNames)"
# A user is a member of a group when their DN matches userMatchers:
# the value of a "member" attribute on the group entity. # A user is a member of a group when their DN matches
userAttr: DN # the value of a "member" attribute on the group entity.
groupAttr: member - userAttr: DN
groupAttr: member
# The group name should be the "cn" value. # The group name should be the "cn" value.
nameAttr: cn nameAttr: cn