Skip to content

Commit 50ae1e1

Browse files
svenseebergmelegiul
andcommitted
Add LDAP group sync to Teams, fixes #1395
* Add setting for a JSON that maps LDAP groups to Org Teams. * 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 6554835 commit 50ae1e1

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

+5851
-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
@@ -105,6 +105,7 @@ require (
105105
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
106106
github.com/stretchr/testify v1.7.0
107107
github.com/syndtr/goleveldb v1.0.0
108+
github.com/thoas/go-funk v0.8.0
108109
github.com/tstranex/u2f v1.0.0
109110
github.com/ulikunitz/xz v0.5.10 // indirect
110111
github.com/unknwon/com v1.0.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,8 @@ github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s
10111011
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
10121012
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
10131013
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
1014+
github.com/thoas/go-funk v0.8.0 h1:JP9tKSvnpFVclYgDM0Is7FD9M4fhPvqA0s0BsXmzSRQ=
1015+
github.com/thoas/go-funk v0.8.0/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q=
10141016
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
10151017
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
10161018
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=

models/login_source.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,36 @@ 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+
err = team.RemoveMember(user.ID)
513+
if err != nil {
514+
log.Error("LDAP group sync: Could not remove user from team: %v", err)
515+
}
516+
}
517+
err = org.RemoveMember(user.ID)
518+
if err != nil {
519+
log.Error("LDAP group sync: Could not remove user from organization: %v", err)
520+
}
521+
}
522+
}
523+
494524
// LoginViaLDAP queries if login/password is valid against the LDAP directory pool,
495525
// and create a local user if success when enabled.
496526
func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*User, error) {
@@ -537,7 +567,9 @@ func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*Use
537567
if isAttributeSSHPublicKeySet && synchronizeLdapSSHPublicKeys(user, source, sr.SSHPublicKey) {
538568
return user, RewriteAllPublicKeys()
539569
}
540-
570+
if source.LDAP().TeamGroupMapEnabled || source.LDAP().TeamGroupMapRemoval {
571+
SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, source)
572+
}
541573
return user, nil
542574
}
543575

@@ -568,10 +600,44 @@ func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*Use
568600
if err == nil && isAttributeSSHPublicKeySet && addLdapSSHPublicKeys(user, source, sr.SSHPublicKey) {
569601
err = RewriteAllPublicKeys()
570602
}
571-
603+
if source.LDAP().TeamGroupMapEnabled || source.LDAP().TeamGroupMapRemoval {
604+
SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, source)
605+
}
572606
return user, err
573607
}
574608

609+
// SyncLdapGroupsToTeams maps LDAP groups to organization and team memberships
610+
func SyncLdapGroupsToTeams(user *User, ldapTeamAdd map[string][]string, ldapTeamRemove map[string][]string, source *LoginSource) {
611+
if source.LDAP().TeamGroupMapRemoval {
612+
// when the user is not a member of configs LDAP group, remove mapped organizations/teams memberships
613+
removeMappedMemberships(user, ldapTeamRemove)
614+
}
615+
for orgName, teamNames := range ldapTeamAdd {
616+
org, err := GetOrgByName(orgName)
617+
if err != nil {
618+
// organization must be created before LDAP group sync
619+
log.Debug("LDAP group sync: Could not find organisation %s: %v", orgName, err)
620+
continue
621+
}
622+
err = org.AddMember(user.ID)
623+
if err != nil {
624+
log.Error("LDAP group sync: Could not add user to organization: %v", err)
625+
}
626+
for _, teamName := range teamNames {
627+
team, err := org.GetTeam(teamName)
628+
if err != nil {
629+
// team must be created before LDAP group sync
630+
log.Debug("LDAP group sync: Could not find team %s: %v", teamName, err)
631+
continue
632+
}
633+
err = team.AddMember(user.ID)
634+
if err != nil {
635+
log.Error("LDAP group sync: Could not add user to team: %v", err)
636+
}
637+
}
638+
}
639+
}
640+
575641
// _________ __________________________
576642
// / _____/ / \__ ___/\______ \
577643
// \_____ \ / \ / \| | | ___/

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: 110 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 || len(result.Entries) < 1 {
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
@@ -402,14 +477,22 @@ func (ls *Source) SearchEntry(name, passwd string, directBind bool) *SearchResul
402477
}
403478
}
404479

480+
teamsToAdd := make(map[string][]string)
481+
teamsToRemove := make(map[string][]string)
482+
if ls.TeamGroupMapEnabled || ls.TeamGroupMapRemoval {
483+
teamsToAdd, teamsToRemove = ls.getMappedTeams(l, uid)
484+
}
485+
405486
return &SearchResult{
406-
Username: username,
407-
Name: firstname,
408-
Surname: surname,
409-
Mail: mail,
410-
SSHPublicKey: sshPublicKey,
411-
IsAdmin: isAdmin,
412-
IsRestricted: isRestricted,
487+
Username: username,
488+
Name: firstname,
489+
Surname: surname,
490+
Mail: mail,
491+
SSHPublicKey: sshPublicKey,
492+
IsAdmin: isAdmin,
493+
IsRestricted: isRestricted,
494+
LdapTeamAdd: teamsToAdd,
495+
LdapTeamRemove: teamsToRemove,
413496
}
414497
}
415498

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

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

446-
attribs := []string{ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail}
529+
attribs := []string{ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail, ls.UserUID}
447530
if isAttributeSSHPublicKeySet {
448531
attribs = append(attribs, ls.AttributeSSHPublicKey)
449532
}
@@ -467,12 +550,19 @@ func (ls *Source) SearchEntries() ([]*SearchResult, error) {
467550
result := make([]*SearchResult, len(sr.Entries))
468551

469552
for i, v := range sr.Entries {
553+
teamsToAdd := make(map[string][]string)
554+
teamsToRemove := make(map[string][]string)
555+
if ls.TeamGroupMapEnabled || ls.TeamGroupMapRemoval {
556+
teamsToAdd, teamsToRemove = ls.getMappedTeams(l, v.GetAttributeValue(ls.UserUID))
557+
}
470558
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),
559+
Username: v.GetAttributeValue(ls.AttributeUsername),
560+
Name: v.GetAttributeValue(ls.AttributeName),
561+
Surname: v.GetAttributeValue(ls.AttributeSurname),
562+
Mail: v.GetAttributeValue(ls.AttributeMail),
563+
IsAdmin: checkAdmin(l, ls, v.DN),
564+
LdapTeamAdd: teamsToAdd,
565+
LdapTeamRemove: teamsToRemove,
476566
}
477567
if !result[i].IsAdmin {
478568
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
@@ -2403,6 +2403,9 @@ auths.group_search_base = Group Search Base DN
24032403
auths.valid_groups_filter = Valid Groups Filter
24042404
auths.group_attribute_list_users = Group Attribute Containing List Of Users
24052405
auths.user_attribute_in_group = User Attribute Listed In Group
2406+
auths.team_group_map = Map LDAP groups to Organization teams
2407+
auths.team_group_map_removal = Remove users from synchronized teams if user does not belong to corresponding LDAP group
2408+
auths.team_group_map_enabled = Enable mapping LDAP groups to gitea organizations teams
24062409
auths.ms_ad_sa = MS AD Search Attributes
24072410
auths.smtp_auth = SMTP Authentication Type
24082411
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
}

services/forms/auth_form.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ type AuthenticationForm struct {
6767
SSPIStripDomainNames bool
6868
SSPISeparatorReplacement string `binding:"AlphaDashDot;MaxSize(5)"`
6969
SSPIDefaultLanguage string
70+
TeamGroupMap string
71+
TeamGroupMapRemoval bool
72+
TeamGroupMapEnabled bool
7073
}
7174

7275
// Validate validates fields

0 commit comments

Comments
 (0)