Skip to content

Commit 136c628

Browse files
svenseebergmelegiul
andcommitted
Add LDAP group sync to Teams, fixes #1395
* 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 <[email protected]> Co-authored-by: Sven Seeberg <[email protected]>
1 parent 91162bb commit 136c628

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+5874
-38
lines changed

cmd/admin_auth_ldap.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,14 @@ var (
8989
Name: "public-ssh-key-attribute",
9090
Usage: "The attribute of the user’s LDAP record containing the user’s public ssh key.",
9191
},
92+
cli.StringFlag{
93+
Name: "team-group-map",
94+
Usage: "Map of LDAP groups to teams.",
95+
},
96+
cli.StringFlag{
97+
Name: "team-group-map-force",
98+
Usage: "Force synchronization of mapped LDAP groups to teams.",
99+
},
92100
}
93101

94102
ldapBindDnCLIFlags = append(commonLdapCLIFlags,
@@ -245,6 +253,12 @@ func parseLdapConfig(c *cli.Context, config *models.LDAPConfig) error {
245253
if c.IsSet("allow-deactivate-all") {
246254
config.Source.AllowDeactivateAll = c.Bool("allow-deactivate-all")
247255
}
256+
if c.IsSet("team-group-map") {
257+
config.Source.TeamGroupMap = c.String("team-group-map")
258+
}
259+
if c.IsSet("team-group-map-removal") {
260+
config.Source.TeamGroupMapRemoval = c.Bool("team-group-map-removal")
261+
}
248262
return nil
249263
}
250264

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ require (
106106
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
107107
github.com/stretchr/testify v1.7.0
108108
github.com/syndtr/goleveldb v1.0.0
109+
github.com/thoas/go-funk v0.8.0
109110
github.com/tstranex/u2f v1.0.0
110111
github.com/ulikunitz/xz v0.5.10 // indirect
111112
github.com/unknwon/com v1.0.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,8 @@ github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s
10181018
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
10191019
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
10201020
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
1021+
github.com/thoas/go-funk v0.8.0 h1:JP9tKSvnpFVclYgDM0Is7FD9M4fhPvqA0s0BsXmzSRQ=
1022+
github.com/thoas/go-funk v0.8.0/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
10211023
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
10221024
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
10231025
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=

models/login_source.go

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,46 @@ func composeFullName(firstname, surname, username string) string {
491491
}
492492
}
493493

494+
// remove membership to organizations/teams if user is not member of corresponding LDAP group
495+
// e.g. lets assume user is member of LDAP group "x", but LDAP group team map contains LDAP groups "x" and "y"
496+
// then users membership gets removed for all organizations/teams mapped by LDAP group "y"
497+
func removeMappedMemberships(user *User, ldapTeamRemove map[string][]string) {
498+
for orgName, teamNames := range ldapTeamRemove {
499+
org, err := GetOrgByName(orgName)
500+
if err != nil {
501+
// organization must be created before LDAP group sync
502+
log.Debug("LDAP group sync: Could not find organisation %s: %v", orgName, err)
503+
continue
504+
}
505+
for _, teamName := range teamNames {
506+
team, err := org.GetTeam(teamName)
507+
if err != nil {
508+
// team must must be created before LDAP group sync
509+
log.Debug("LDAP group sync: Could not find team %s: %v", teamName, err)
510+
continue
511+
}
512+
if isMember, err := IsTeamMember(org.ID, team.ID, user.ID); isMember && err == nil {
513+
log.Trace("LDAP group sync: removing user [%s] from team [%s]", user.Name, org.Name)
514+
}
515+
err = team.RemoveMember(user.ID)
516+
if err != nil {
517+
log.Error("LDAP group sync: Could not remove user from team: %v", err)
518+
}
519+
}
520+
if remainingTeams, err := GetUserOrgTeams(org.ID, user.ID); err == nil && len(remainingTeams) == 0 {
521+
if isMember, err := IsOrganizationMember(org.ID, user.ID); isMember && err == nil {
522+
log.Trace("LDAP group sync: removing user [%s] from organization [%s]", user.Name, org.Name)
523+
}
524+
err = org.RemoveMember(user.ID)
525+
if err != nil {
526+
log.Error("LDAP group sync: Could not remove user from organization: %v", err)
527+
}
528+
} else if err != nil {
529+
log.Error("LDAP group sync: Could not find users [id: %d] teams for given organization [%s]", user.ID, org.Name)
530+
}
531+
}
532+
}
533+
494534
// LoginViaLDAP queries if login/password is valid against the LDAP directory pool,
495535
// and create a local user if success when enabled.
496536
func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*User, error) {
@@ -537,7 +577,9 @@ func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*Use
537577
if isAttributeSSHPublicKeySet && synchronizeLdapSSHPublicKeys(user, source, sr.SSHPublicKey) {
538578
return user, RewriteAllPublicKeys()
539579
}
540-
580+
if source.LDAP().TeamGroupMapEnabled || source.LDAP().TeamGroupMapRemoval {
581+
SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, source)
582+
}
541583
return user, nil
542584
}
543585

@@ -568,10 +610,50 @@ func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*Use
568610
if err == nil && isAttributeSSHPublicKeySet && addLdapSSHPublicKeys(user, source, sr.SSHPublicKey) {
569611
err = RewriteAllPublicKeys()
570612
}
571-
613+
if source.LDAP().TeamGroupMapEnabled || source.LDAP().TeamGroupMapRemoval {
614+
SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, source)
615+
}
572616
return user, err
573617
}
574618

619+
// SyncLdapGroupsToTeams maps LDAP groups to organization and team memberships
620+
func SyncLdapGroupsToTeams(user *User, ldapTeamAdd map[string][]string, ldapTeamRemove map[string][]string, source *LoginSource) {
621+
if source.LDAP().TeamGroupMapRemoval {
622+
// when the user is not a member of configs LDAP group, remove mapped organizations/teams memberships
623+
removeMappedMemberships(user, ldapTeamRemove)
624+
}
625+
for orgName, teamNames := range ldapTeamAdd {
626+
org, err := GetOrgByName(orgName)
627+
if err != nil {
628+
// organization must be created before LDAP group sync
629+
log.Debug("LDAP group sync: Could not find organisation %s: %v", orgName, err)
630+
continue
631+
}
632+
if isMember, err := IsOrganizationMember(org.ID, user.ID); !isMember && err == nil {
633+
log.Trace("LDAP group sync: adding user [%s] to organization [%s]", user.Name, org.Name)
634+
}
635+
err = org.AddMember(user.ID)
636+
if err != nil {
637+
log.Error("LDAP group sync: Could not add user to organization: %v", err)
638+
}
639+
for _, teamName := range teamNames {
640+
team, err := org.GetTeam(teamName)
641+
if err != nil {
642+
// team must be created before LDAP group sync
643+
log.Debug("LDAP group sync: Could not find team %s: %v", teamName, err)
644+
continue
645+
}
646+
if isMember, err := IsTeamMember(org.ID, team.ID, user.ID); !isMember && err == nil {
647+
log.Trace("LDAP group sync: adding user [%s] to team [%s]", user.Name, org.Name)
648+
}
649+
err = team.AddMember(user.ID)
650+
if err != nil {
651+
log.Error("LDAP group sync: Could not add user to team: %v", err)
652+
}
653+
}
654+
}
655+
}
656+
575657
// _________ __________________________
576658
// / _____/ / \__ ___/\______ \
577659
// \_____ \ / \ / \| | | ___/

models/user.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2014,6 +2014,10 @@ func SyncExternalUsers(ctx context.Context, updateExisting bool) error {
20142014
}
20152015
}
20162016
}
2017+
// Synchronize LDAP groups with organization and team memberships
2018+
if s.LDAP().TeamGroupMapEnabled || s.LDAP().TeamGroupMapRemoval {
2019+
SyncLdapGroupsToTeams(usr, su.LdapTeamAdd, su.LdapTeamRemove, s)
2020+
}
20172021
}
20182022

20192023
// Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed

modules/auth/ldap/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,11 @@ share the following fields:
121121
* Group Attribute for User (optional)
122122
* Which group LDAP attribute contains an array above user attribute names.
123123
* Example: memberUid
124+
125+
* Team group map (optional)
126+
* Automatically add users to Organization teams, depending on LDAP group memberships.
127+
* Note: this function only adds users to teams, it never removes users.
128+
* Example: {"cn=MyGroup,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2", ...], ...}, ...}
129+
130+
* Team group map removal (optional)
131+
* If set to true, users will be removed from teams if they are not members of the corresponding group.

modules/auth/ldap/ldap.go

Lines changed: 117 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ package ldap
99

1010
import (
1111
"crypto/tls"
12+
"encoding/json"
1213
"fmt"
1314
"strings"
1415

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

1718
"github.com/go-ldap/ldap/v3"
19+
"github.com/thoas/go-funk"
1820
)
1921

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

6166
// SearchResult : user data
6267
type SearchResult struct {
63-
Username string // Username
64-
Name string // Name
65-
Surname string // Surname
66-
Mail string // E-mail address
67-
SSHPublicKey []string // SSH Public Key
68-
IsAdmin bool // if user is administrator
69-
IsRestricted bool // if user is restricted
68+
Username string // Username
69+
Name string // Name
70+
Surname string // Surname
71+
Mail string // E-mail address
72+
SSHPublicKey []string // SSH Public Key
73+
IsAdmin bool // if user is administrator
74+
IsRestricted bool // if user is restricted
75+
LdapTeamAdd map[string][]string // organizations teams to add
76+
LdapTeamRemove map[string][]string // organizations teams to remove
7077
}
7178

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

240+
// List all group memberships of a user
241+
func (ls *Source) listLdapGroupMemberships(l *ldap.Conn, uid string) []string {
242+
var ldapGroups []string
243+
var groupFilter = fmt.Sprintf("(%s=%s)", ls.GroupMemberUID, uid)
244+
result, err := l.Search(ldap.NewSearchRequest(
245+
ls.GroupDN,
246+
ldap.ScopeWholeSubtree,
247+
ldap.NeverDerefAliases,
248+
0,
249+
0,
250+
false,
251+
groupFilter,
252+
[]string{},
253+
nil,
254+
))
255+
if err != nil {
256+
log.Error("Failed group search using filter[%s]: %v", groupFilter, err)
257+
return ldapGroups
258+
}
259+
260+
for _, entry := range result.Entries {
261+
if entry.DN == "" {
262+
log.Error("LDAP search was successful, but found no DN!")
263+
continue
264+
}
265+
ldapGroups = append(ldapGroups, entry.DN)
266+
}
267+
268+
return ldapGroups
269+
}
270+
271+
// parse LDAP groups and return map of ldap groups to organizations teams
272+
func (ls *Source) mapLdapGroupsToTeams() map[string]map[string][]string {
273+
ldapGroupsToTeams := make(map[string]map[string][]string)
274+
err := json.Unmarshal([]byte(ls.TeamGroupMap), &ldapGroupsToTeams)
275+
if err != nil {
276+
log.Debug("Failed to unmarshall LDAP teams map: %v", err)
277+
return nil
278+
}
279+
return ldapGroupsToTeams
280+
}
281+
282+
func (ls *Source) getMappedTeams(l *ldap.Conn, uid string) (map[string][]string, map[string][]string) {
283+
teamsToAdd := map[string][]string{}
284+
teamsToRemove := map[string][]string{}
285+
// get all LDAP group memberships for user
286+
usersLdapGroups := ls.listLdapGroupMemberships(l, uid)
287+
// unmarshall LDAP group team map from configs
288+
ldapGroupsToTeams := ls.mapLdapGroupsToTeams()
289+
// select all LDAP groups from settings
290+
allLdapGroups := funk.Keys(ldapGroupsToTeams).([]string)
291+
// contains LDAP config groups, which the user is a member of
292+
usersLdapGroupsToAdd := funk.IntersectString(allLdapGroups, usersLdapGroups)
293+
// contains LDAP config groups, which the user is not a member of
294+
usersLdapGroupToRemove, _ := funk.DifferenceString(allLdapGroups, usersLdapGroups)
295+
for _, groupToAdd := range usersLdapGroupsToAdd {
296+
for k, v := range ldapGroupsToTeams[groupToAdd] {
297+
teamsToAdd[k] = v
298+
}
299+
}
300+
for _, groupToRemove := range usersLdapGroupToRemove {
301+
for k, v := range ldapGroupsToTeams[groupToRemove] {
302+
teamsToRemove[k] = v
303+
}
304+
}
305+
return teamsToAdd, teamsToRemove
306+
}
307+
233308
// SearchEntry : search an LDAP source if an entry (name, passwd) is valid and in the specific filter
234309
func (ls *Source) SearchEntry(name, passwd string, directBind bool) *SearchResult {
235310
// See https://tools.ietf.org/search/rfc4513#section-5.1.2
@@ -341,6 +416,9 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) *SearchResul
341416
surname := sr.Entries[0].GetAttributeValue(ls.AttributeSurname)
342417
mail := sr.Entries[0].GetAttributeValue(ls.AttributeMail)
343418
uid := sr.Entries[0].GetAttributeValue(ls.UserUID)
419+
if ls.UserUID == "dn" || ls.UserUID == "DN" {
420+
uid = sr.Entries[0].DN
421+
}
344422

345423
// Check group membership
346424
if ls.GroupsEnabled {
@@ -402,14 +480,22 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) *SearchResul
402480
}
403481
}
404482

483+
teamsToAdd := make(map[string][]string)
484+
teamsToRemove := make(map[string][]string)
485+
if ls.TeamGroupMapEnabled || ls.TeamGroupMapRemoval {
486+
teamsToAdd, teamsToRemove = ls.getMappedTeams(l, uid)
487+
}
488+
405489
return &SearchResult{
406-
Username: username,
407-
Name: firstname,
408-
Surname: surname,
409-
Mail: mail,
410-
SSHPublicKey: sshPublicKey,
411-
IsAdmin: isAdmin,
412-
IsRestricted: isRestricted,
490+
Username: username,
491+
Name: firstname,
492+
Surname: surname,
493+
Mail: mail,
494+
SSHPublicKey: sshPublicKey,
495+
IsAdmin: isAdmin,
496+
IsRestricted: isRestricted,
497+
LdapTeamAdd: teamsToAdd,
498+
LdapTeamRemove: teamsToRemove,
413499
}
414500
}
415501

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

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

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

469555
for i, v := range sr.Entries {
556+
teamsToAdd := make(map[string][]string)
557+
teamsToRemove := make(map[string][]string)
558+
if ls.TeamGroupMapEnabled || ls.TeamGroupMapRemoval {
559+
userAttributeListedInGroup := v.GetAttributeValue(ls.UserUID)
560+
if ls.UserUID == "dn" || ls.UserUID == "DN" {
561+
userAttributeListedInGroup = v.DN
562+
}
563+
teamsToAdd, teamsToRemove = ls.getMappedTeams(l, userAttributeListedInGroup)
564+
}
470565
result[i] = &SearchResult{
471-
Username: v.GetAttributeValue(ls.AttributeUsername),
472-
Name: v.GetAttributeValue(ls.AttributeName),
473-
Surname: v.GetAttributeValue(ls.AttributeSurname),
474-
Mail: v.GetAttributeValue(ls.AttributeMail),
475-
IsAdmin: checkAdmin(l, ls, v.DN),
566+
Username: v.GetAttributeValue(ls.AttributeUsername),
567+
Name: v.GetAttributeValue(ls.AttributeName),
568+
Surname: v.GetAttributeValue(ls.AttributeSurname),
569+
Mail: v.GetAttributeValue(ls.AttributeMail),
570+
IsAdmin: checkAdmin(l, ls, v.DN),
571+
LdapTeamAdd: teamsToAdd,
572+
LdapTeamRemove: teamsToRemove,
476573
}
477574
if !result[i].IsAdmin {
478575
result[i].IsRestricted = checkRestricted(l, ls, v.DN)

options/locale/locale_en-US.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2405,6 +2405,9 @@ auths.group_search_base = Group Search Base DN
24052405
auths.valid_groups_filter = Valid Groups Filter
24062406
auths.group_attribute_list_users = Group Attribute Containing List Of Users
24072407
auths.user_attribute_in_group = User Attribute Listed In Group
2408+
auths.team_group_map = Map LDAP groups to Organization teams
2409+
auths.team_group_map_removal = Remove users from synchronized teams if user does not belong to corresponding LDAP group
2410+
auths.team_group_map_enabled = Enable mapping LDAP groups to gitea organizations teams
24082411
auths.ms_ad_sa = MS AD Search Attributes
24092412
auths.smtp_auth = SMTP Authentication Type
24102413
auths.smtphost = SMTP Host

routers/web/admin/auths.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ func parseLDAPConfig(form forms.AuthenticationForm) *models.LDAPConfig {
145145
AdminFilter: form.AdminFilter,
146146
RestrictedFilter: form.RestrictedFilter,
147147
AllowDeactivateAll: form.AllowDeactivateAll,
148+
TeamGroupMap: form.TeamGroupMap,
149+
TeamGroupMapRemoval: form.TeamGroupMapRemoval,
150+
TeamGroupMapEnabled: form.TeamGroupMapEnabled,
148151
Enabled: true,
149152
},
150153
}

0 commit comments

Comments
 (0)