Skip to content

Commit

Permalink
Add LDAP group sync to Teams, fixes go-gitea#1395
Browse files Browse the repository at this point in the history
* Add setting for a JSON that maps LDAP groups
  to Org Teams.
* Add log trace when removing or adding team members.
* Sync is being run on login and periodically.
* Existing group filter settings are reused.

Co-authored-by: Giuliano Mele <mele@integreat-app.de>
Co-authored-by: Sven Seeberg <mail@sven-seeberg.de>
  • Loading branch information
svenseeberg and melegiul committed Jul 15, 2021
1 parent 91162bb commit 136c628
Show file tree
Hide file tree
Showing 50 changed files with 5,874 additions and 38 deletions.
14 changes: 14 additions & 0 deletions cmd/admin_auth_ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ var (
Name: "public-ssh-key-attribute",
Usage: "The attribute of the user’s LDAP record containing the user’s public ssh key.",
},
cli.StringFlag{
Name: "team-group-map",
Usage: "Map of LDAP groups to teams.",
},
cli.StringFlag{
Name: "team-group-map-force",
Usage: "Force synchronization of mapped LDAP groups to teams.",
},
}

ldapBindDnCLIFlags = append(commonLdapCLIFlags,
Expand Down Expand Up @@ -245,6 +253,12 @@ func parseLdapConfig(c *cli.Context, config *models.LDAPConfig) error {
if c.IsSet("allow-deactivate-all") {
config.Source.AllowDeactivateAll = c.Bool("allow-deactivate-all")
}
if c.IsSet("team-group-map") {
config.Source.TeamGroupMap = c.String("team-group-map")
}
if c.IsSet("team-group-map-removal") {
config.Source.TeamGroupMapRemoval = c.Bool("team-group-map-removal")
}
return nil
}

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/testify v1.7.0
github.com/syndtr/goleveldb v1.0.0
github.com/thoas/go-funk v0.8.0
github.com/tstranex/u2f v1.0.0
github.com/ulikunitz/xz v0.5.10 // indirect
github.com/unknwon/com v1.0.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,8 @@ github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/thoas/go-funk v0.8.0 h1:JP9tKSvnpFVclYgDM0Is7FD9M4fhPvqA0s0BsXmzSRQ=
github.com/thoas/go-funk v0.8.0/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
Expand Down
86 changes: 84 additions & 2 deletions models/login_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,46 @@ func composeFullName(firstname, surname, username string) string {
}
}

// remove membership to organizations/teams if user is not member of corresponding LDAP group
// e.g. lets assume user is member of LDAP group "x", but LDAP group team map contains LDAP groups "x" and "y"
// then users membership gets removed for all organizations/teams mapped by LDAP group "y"
func removeMappedMemberships(user *User, ldapTeamRemove map[string][]string) {
for orgName, teamNames := range ldapTeamRemove {
org, err := GetOrgByName(orgName)
if err != nil {
// organization must be created before LDAP group sync
log.Debug("LDAP group sync: Could not find organisation %s: %v", orgName, err)
continue
}
for _, teamName := range teamNames {
team, err := org.GetTeam(teamName)
if err != nil {
// team must must be created before LDAP group sync
log.Debug("LDAP group sync: Could not find team %s: %v", teamName, err)
continue
}
if isMember, err := IsTeamMember(org.ID, team.ID, user.ID); isMember && err == nil {
log.Trace("LDAP group sync: removing user [%s] from team [%s]", user.Name, org.Name)
}
err = team.RemoveMember(user.ID)
if err != nil {
log.Error("LDAP group sync: Could not remove user from team: %v", err)
}
}
if remainingTeams, err := GetUserOrgTeams(org.ID, user.ID); err == nil && len(remainingTeams) == 0 {
if isMember, err := IsOrganizationMember(org.ID, user.ID); isMember && err == nil {
log.Trace("LDAP group sync: removing user [%s] from organization [%s]", user.Name, org.Name)
}
err = org.RemoveMember(user.ID)
if err != nil {
log.Error("LDAP group sync: Could not remove user from organization: %v", err)
}
} else if err != nil {
log.Error("LDAP group sync: Could not find users [id: %d] teams for given organization [%s]", user.ID, org.Name)
}
}
}

// LoginViaLDAP queries if login/password is valid against the LDAP directory pool,
// and create a local user if success when enabled.
func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*User, error) {
Expand Down Expand Up @@ -537,7 +577,9 @@ func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*Use
if isAttributeSSHPublicKeySet && synchronizeLdapSSHPublicKeys(user, source, sr.SSHPublicKey) {
return user, RewriteAllPublicKeys()
}

if source.LDAP().TeamGroupMapEnabled || source.LDAP().TeamGroupMapRemoval {
SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, source)
}
return user, nil
}

Expand Down Expand Up @@ -568,10 +610,50 @@ func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*Use
if err == nil && isAttributeSSHPublicKeySet && addLdapSSHPublicKeys(user, source, sr.SSHPublicKey) {
err = RewriteAllPublicKeys()
}

if source.LDAP().TeamGroupMapEnabled || source.LDAP().TeamGroupMapRemoval {
SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, source)
}
return user, err
}

// SyncLdapGroupsToTeams maps LDAP groups to organization and team memberships
func SyncLdapGroupsToTeams(user *User, ldapTeamAdd map[string][]string, ldapTeamRemove map[string][]string, source *LoginSource) {
if source.LDAP().TeamGroupMapRemoval {
// when the user is not a member of configs LDAP group, remove mapped organizations/teams memberships
removeMappedMemberships(user, ldapTeamRemove)
}
for orgName, teamNames := range ldapTeamAdd {
org, err := GetOrgByName(orgName)
if err != nil {
// organization must be created before LDAP group sync
log.Debug("LDAP group sync: Could not find organisation %s: %v", orgName, err)
continue
}
if isMember, err := IsOrganizationMember(org.ID, user.ID); !isMember && err == nil {
log.Trace("LDAP group sync: adding user [%s] to organization [%s]", user.Name, org.Name)
}
err = org.AddMember(user.ID)
if err != nil {
log.Error("LDAP group sync: Could not add user to organization: %v", err)
}
for _, teamName := range teamNames {
team, err := org.GetTeam(teamName)
if err != nil {
// team must be created before LDAP group sync
log.Debug("LDAP group sync: Could not find team %s: %v", teamName, err)
continue
}
if isMember, err := IsTeamMember(org.ID, team.ID, user.ID); !isMember && err == nil {
log.Trace("LDAP group sync: adding user [%s] to team [%s]", user.Name, org.Name)
}
err = team.AddMember(user.ID)
if err != nil {
log.Error("LDAP group sync: Could not add user to team: %v", err)
}
}
}
}

// _________ __________________________
// / _____/ / \__ ___/\______ \
// \_____ \ / \ / \| | | ___/
Expand Down
4 changes: 4 additions & 0 deletions models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2014,6 +2014,10 @@ func SyncExternalUsers(ctx context.Context, updateExisting bool) error {
}
}
}
// Synchronize LDAP groups with organization and team memberships
if s.LDAP().TeamGroupMapEnabled || s.LDAP().TeamGroupMapRemoval {
SyncLdapGroupsToTeams(usr, su.LdapTeamAdd, su.LdapTeamRemove, s)
}
}

// Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed
Expand Down
8 changes: 8 additions & 0 deletions modules/auth/ldap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,11 @@ share the following fields:
* Group Attribute for User (optional)
* Which group LDAP attribute contains an array above user attribute names.
* Example: memberUid

* Team group map (optional)
* Automatically add users to Organization teams, depending on LDAP group memberships.
* Note: this function only adds users to teams, it never removes users.
* Example: {"cn=MyGroup,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2", ...], ...}, ...}

* Team group map removal (optional)
* If set to true, users will be removed from teams if they are not members of the corresponding group.
137 changes: 117 additions & 20 deletions modules/auth/ldap/ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ package ldap

import (
"crypto/tls"
"encoding/json"
"fmt"
"strings"

"code.gitea.io/gitea/modules/log"

"github.com/go-ldap/ldap/v3"
"github.com/thoas/go-funk"
)

// SecurityProtocol protocol type
Expand Down Expand Up @@ -56,17 +58,22 @@ type Source struct {
GroupFilter string // Group Name Filter
GroupMemberUID string // Group Attribute containing array of UserUID
UserUID string // User Attribute listed in Group
TeamGroupMap string // Map LDAP groups to teams
TeamGroupMapRemoval bool // Remove user from teams which are synchronized and user is not a member of the corresponding LDAP group
TeamGroupMapEnabled bool // if LDAP groups mapping to gitea organizations teams is enabled
}

// SearchResult : user data
type SearchResult struct {
Username string // Username
Name string // Name
Surname string // Surname
Mail string // E-mail address
SSHPublicKey []string // SSH Public Key
IsAdmin bool // if user is administrator
IsRestricted bool // if user is restricted
Username string // Username
Name string // Name
Surname string // Surname
Mail string // E-mail address
SSHPublicKey []string // SSH Public Key
IsAdmin bool // if user is administrator
IsRestricted bool // if user is restricted
LdapTeamAdd map[string][]string // organizations teams to add
LdapTeamRemove map[string][]string // organizations teams to remove
}

func (ls *Source) sanitizedUserQuery(username string) (string, bool) {
Expand Down Expand Up @@ -230,6 +237,74 @@ func checkRestricted(l *ldap.Conn, ls *Source, userDN string) bool {
return false
}

// List all group memberships of a user
func (ls *Source) listLdapGroupMemberships(l *ldap.Conn, uid string) []string {
var ldapGroups []string
var groupFilter = fmt.Sprintf("(%s=%s)", ls.GroupMemberUID, uid)
result, err := l.Search(ldap.NewSearchRequest(
ls.GroupDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0,
0,
false,
groupFilter,
[]string{},
nil,
))
if err != nil {
log.Error("Failed group search using filter[%s]: %v", groupFilter, err)
return ldapGroups
}

for _, entry := range result.Entries {
if entry.DN == "" {
log.Error("LDAP search was successful, but found no DN!")
continue
}
ldapGroups = append(ldapGroups, entry.DN)
}

return ldapGroups
}

// parse LDAP groups and return map of ldap groups to organizations teams
func (ls *Source) mapLdapGroupsToTeams() map[string]map[string][]string {
ldapGroupsToTeams := make(map[string]map[string][]string)
err := json.Unmarshal([]byte(ls.TeamGroupMap), &ldapGroupsToTeams)
if err != nil {
log.Debug("Failed to unmarshall LDAP teams map: %v", err)
return nil
}
return ldapGroupsToTeams
}

func (ls *Source) getMappedTeams(l *ldap.Conn, uid string) (map[string][]string, map[string][]string) {
teamsToAdd := map[string][]string{}
teamsToRemove := map[string][]string{}
// get all LDAP group memberships for user
usersLdapGroups := ls.listLdapGroupMemberships(l, uid)
// unmarshall LDAP group team map from configs
ldapGroupsToTeams := ls.mapLdapGroupsToTeams()
// select all LDAP groups from settings
allLdapGroups := funk.Keys(ldapGroupsToTeams).([]string)
// contains LDAP config groups, which the user is a member of
usersLdapGroupsToAdd := funk.IntersectString(allLdapGroups, usersLdapGroups)
// contains LDAP config groups, which the user is not a member of
usersLdapGroupToRemove, _ := funk.DifferenceString(allLdapGroups, usersLdapGroups)
for _, groupToAdd := range usersLdapGroupsToAdd {
for k, v := range ldapGroupsToTeams[groupToAdd] {
teamsToAdd[k] = v
}
}
for _, groupToRemove := range usersLdapGroupToRemove {
for k, v := range ldapGroupsToTeams[groupToRemove] {
teamsToRemove[k] = v
}
}
return teamsToAdd, teamsToRemove
}

// SearchEntry : search an LDAP source if an entry (name, passwd) is valid and in the specific filter
func (ls *Source) SearchEntry(name, passwd string, directBind bool) *SearchResult {
// See https://tools.ietf.org/search/rfc4513#section-5.1.2
Expand Down Expand Up @@ -341,6 +416,9 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) *SearchResul
surname := sr.Entries[0].GetAttributeValue(ls.AttributeSurname)
mail := sr.Entries[0].GetAttributeValue(ls.AttributeMail)
uid := sr.Entries[0].GetAttributeValue(ls.UserUID)
if ls.UserUID == "dn" || ls.UserUID == "DN" {
uid = sr.Entries[0].DN
}

// Check group membership
if ls.GroupsEnabled {
Expand Down Expand Up @@ -402,14 +480,22 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) *SearchResul
}
}

teamsToAdd := make(map[string][]string)
teamsToRemove := make(map[string][]string)
if ls.TeamGroupMapEnabled || ls.TeamGroupMapRemoval {
teamsToAdd, teamsToRemove = ls.getMappedTeams(l, uid)
}

return &SearchResult{
Username: username,
Name: firstname,
Surname: surname,
Mail: mail,
SSHPublicKey: sshPublicKey,
IsAdmin: isAdmin,
IsRestricted: isRestricted,
Username: username,
Name: firstname,
Surname: surname,
Mail: mail,
SSHPublicKey: sshPublicKey,
IsAdmin: isAdmin,
IsRestricted: isRestricted,
LdapTeamAdd: teamsToAdd,
LdapTeamRemove: teamsToRemove,
}
}

Expand Down Expand Up @@ -443,7 +529,7 @@ func (ls *Source) SearchEntries() ([]*SearchResult, error) {

var isAttributeSSHPublicKeySet = len(strings.TrimSpace(ls.AttributeSSHPublicKey)) > 0

attribs := []string{ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail}
attribs := []string{ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail, ls.UserUID}
if isAttributeSSHPublicKeySet {
attribs = append(attribs, ls.AttributeSSHPublicKey)
}
Expand All @@ -467,12 +553,23 @@ func (ls *Source) SearchEntries() ([]*SearchResult, error) {
result := make([]*SearchResult, len(sr.Entries))

for i, v := range sr.Entries {
teamsToAdd := make(map[string][]string)
teamsToRemove := make(map[string][]string)
if ls.TeamGroupMapEnabled || ls.TeamGroupMapRemoval {
userAttributeListedInGroup := v.GetAttributeValue(ls.UserUID)
if ls.UserUID == "dn" || ls.UserUID == "DN" {
userAttributeListedInGroup = v.DN
}
teamsToAdd, teamsToRemove = ls.getMappedTeams(l, userAttributeListedInGroup)
}
result[i] = &SearchResult{
Username: v.GetAttributeValue(ls.AttributeUsername),
Name: v.GetAttributeValue(ls.AttributeName),
Surname: v.GetAttributeValue(ls.AttributeSurname),
Mail: v.GetAttributeValue(ls.AttributeMail),
IsAdmin: checkAdmin(l, ls, v.DN),
Username: v.GetAttributeValue(ls.AttributeUsername),
Name: v.GetAttributeValue(ls.AttributeName),
Surname: v.GetAttributeValue(ls.AttributeSurname),
Mail: v.GetAttributeValue(ls.AttributeMail),
IsAdmin: checkAdmin(l, ls, v.DN),
LdapTeamAdd: teamsToAdd,
LdapTeamRemove: teamsToRemove,
}
if !result[i].IsAdmin {
result[i].IsRestricted = checkRestricted(l, ls, v.DN)
Expand Down
3 changes: 3 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2405,6 +2405,9 @@ auths.group_search_base = Group Search Base DN
auths.valid_groups_filter = Valid Groups Filter
auths.group_attribute_list_users = Group Attribute Containing List Of Users
auths.user_attribute_in_group = User Attribute Listed In Group
auths.team_group_map = Map LDAP groups to Organization teams
auths.team_group_map_removal = Remove users from synchronized teams if user does not belong to corresponding LDAP group
auths.team_group_map_enabled = Enable mapping LDAP groups to gitea organizations teams
auths.ms_ad_sa = MS AD Search Attributes
auths.smtp_auth = SMTP Authentication Type
auths.smtphost = SMTP Host
Expand Down
3 changes: 3 additions & 0 deletions routers/web/admin/auths.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ func parseLDAPConfig(form forms.AuthenticationForm) *models.LDAPConfig {
AdminFilter: form.AdminFilter,
RestrictedFilter: form.RestrictedFilter,
AllowDeactivateAll: form.AllowDeactivateAll,
TeamGroupMap: form.TeamGroupMap,
TeamGroupMapRemoval: form.TeamGroupMapRemoval,
TeamGroupMapEnabled: form.TeamGroupMapEnabled,
Enabled: true,
},
}
Expand Down
Loading

0 comments on commit 136c628

Please sign in to comment.