Skip to content

Commit 832ce40

Browse files
authored
Add LDAP group sync to Teams, fixes #1395 (#16299)
* Add setting for a JSON that maps LDAP groups to Org Teams. * Add log when removing or adding team members. * Sync is being run on login and periodically. * Existing group filter settings are reused. * Adding and removing team members. * Sync not existing LDAP group. * Login with broken group map JSON.
1 parent 26718a7 commit 832ce40

File tree

14 files changed

+423
-65
lines changed

14 files changed

+423
-65
lines changed

cmd/admin_auth_ldap.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,6 @@ func parseLdapConfig(c *cli.Context, config *ldap.Source) error {
260260
if c.IsSet("skip-local-2fa") {
261261
config.SkipLocalTwoFA = c.Bool("skip-local-2fa")
262262
}
263-
264263
return nil
265264
}
266265

integrations/auth_ldap_test.go

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import (
1111
"strings"
1212
"testing"
1313

14+
"code.gitea.io/gitea/models"
15+
"code.gitea.io/gitea/models/unittest"
16+
user_model "code.gitea.io/gitea/models/user"
1417
"code.gitea.io/gitea/services/auth"
1518

1619
"github.com/stretchr/testify/assert"
@@ -97,7 +100,13 @@ func getLDAPServerHost() string {
97100
return host
98101
}
99102

100-
func addAuthSourceLDAP(t *testing.T, sshKeyAttribute string) {
103+
func addAuthSourceLDAP(t *testing.T, sshKeyAttribute string, groupMapParams ...string) {
104+
groupTeamMapRemoval := "off"
105+
groupTeamMap := ""
106+
if len(groupMapParams) == 2 {
107+
groupTeamMapRemoval = groupMapParams[0]
108+
groupTeamMap = groupMapParams[1]
109+
}
101110
session := loginUser(t, "user1")
102111
csrf := GetCSRF(t, session, "/admin/auths/new")
103112
req := NewRequestWithValues(t, "POST", "/admin/auths/new", map[string]string{
@@ -119,6 +128,12 @@ func addAuthSourceLDAP(t *testing.T, sshKeyAttribute string) {
119128
"attribute_ssh_public_key": sshKeyAttribute,
120129
"is_sync_enabled": "on",
121130
"is_active": "on",
131+
"groups_enabled": "on",
132+
"group_dn": "ou=people,dc=planetexpress,dc=com",
133+
"group_member_uid": "member",
134+
"group_team_map": groupTeamMap,
135+
"group_team_map_removal": groupTeamMapRemoval,
136+
"user_uid": "DN",
122137
})
123138
session.MakeRequest(t, req, http.StatusFound)
124139
}
@@ -294,3 +309,105 @@ func TestLDAPUserSSHKeySync(t *testing.T) {
294309
assert.ElementsMatch(t, u.SSHKeys, syncedKeys, "Unequal number of keys synchronized for user: %s", u.UserName)
295310
}
296311
}
312+
313+
func TestLDAPGroupTeamSyncAddMember(t *testing.T) {
314+
if skipLDAPTests() {
315+
t.Skip()
316+
return
317+
}
318+
defer prepareTestEnv(t)()
319+
addAuthSourceLDAP(t, "", "on", `{"cn=ship_crew,ou=people,dc=planetexpress,dc=com":{"org26": ["team11"]},"cn=admin_staff,ou=people,dc=planetexpress,dc=com": {"non-existent": ["non-existent"]}}`)
320+
org, err := models.GetOrgByName("org26")
321+
assert.NoError(t, err)
322+
team, err := models.GetTeam(org.ID, "team11")
323+
assert.NoError(t, err)
324+
auth.SyncExternalUsers(context.Background(), true)
325+
for _, gitLDAPUser := range gitLDAPUsers {
326+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
327+
Name: gitLDAPUser.UserName,
328+
}).(*user_model.User)
329+
usersOrgs, err := models.FindOrgs(models.FindOrgOptions{
330+
UserID: user.ID,
331+
IncludePrivate: true,
332+
})
333+
assert.NoError(t, err)
334+
allOrgTeams, err := models.GetUserOrgTeams(org.ID, user.ID)
335+
assert.NoError(t, err)
336+
if user.Name == "fry" || user.Name == "leela" || user.Name == "bender" {
337+
// assert members of LDAP group "cn=ship_crew" are added to mapped teams
338+
assert.Equal(t, len(usersOrgs), 1, "User [%s] should be member of one organization", user.Name)
339+
assert.Equal(t, usersOrgs[0].Name, "org26", "Membership should be added to the right organization")
340+
isMember, err := models.IsTeamMember(usersOrgs[0].ID, team.ID, user.ID)
341+
assert.NoError(t, err)
342+
assert.True(t, isMember, "Membership should be added to the right team")
343+
err = team.RemoveMember(user.ID)
344+
assert.NoError(t, err)
345+
err = usersOrgs[0].RemoveMember(user.ID)
346+
assert.NoError(t, err)
347+
} else {
348+
// assert members of LDAP group "cn=admin_staff" keep initial team membership since mapped team does not exist
349+
assert.Empty(t, usersOrgs, "User should be member of no organization")
350+
isMember, err := models.IsTeamMember(org.ID, team.ID, user.ID)
351+
assert.NoError(t, err)
352+
assert.False(t, isMember, "User should no be added to this team")
353+
assert.Empty(t, allOrgTeams, "User should not be added to any team")
354+
}
355+
}
356+
}
357+
358+
func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) {
359+
if skipLDAPTests() {
360+
t.Skip()
361+
return
362+
}
363+
defer prepareTestEnv(t)()
364+
addAuthSourceLDAP(t, "", "on", `{"cn=dispatch,ou=people,dc=planetexpress,dc=com": {"org26": ["team11"]}}`)
365+
org, err := models.GetOrgByName("org26")
366+
assert.NoError(t, err)
367+
team, err := models.GetTeam(org.ID, "team11")
368+
assert.NoError(t, err)
369+
loginUserWithPassword(t, gitLDAPUsers[0].UserName, gitLDAPUsers[0].Password)
370+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
371+
Name: gitLDAPUsers[0].UserName,
372+
}).(*user_model.User)
373+
err = org.AddMember(user.ID)
374+
assert.NoError(t, err)
375+
err = team.AddMember(user.ID)
376+
assert.NoError(t, err)
377+
isMember, err := models.IsOrganizationMember(org.ID, user.ID)
378+
assert.NoError(t, err)
379+
assert.True(t, isMember, "User should be member of this organization")
380+
isMember, err = models.IsTeamMember(org.ID, team.ID, user.ID)
381+
assert.NoError(t, err)
382+
assert.True(t, isMember, "User should be member of this team")
383+
// assert team member "professor" gets removed from org26 team11
384+
loginUserWithPassword(t, gitLDAPUsers[0].UserName, gitLDAPUsers[0].Password)
385+
isMember, err = models.IsOrganizationMember(org.ID, user.ID)
386+
assert.NoError(t, err)
387+
assert.False(t, isMember, "User membership should have been removed from organization")
388+
isMember, err = models.IsTeamMember(org.ID, team.ID, user.ID)
389+
assert.NoError(t, err)
390+
assert.False(t, isMember, "User membership should have been removed from team")
391+
}
392+
393+
// Login should work even if Team Group Map contains a broken JSON
394+
func TestBrokenLDAPMapUserSignin(t *testing.T) {
395+
if skipLDAPTests() {
396+
t.Skip()
397+
return
398+
}
399+
defer prepareTestEnv(t)()
400+
addAuthSourceLDAP(t, "", "on", `{"NOT_A_VALID_JSON"["MISSING_DOUBLE_POINT"]}`)
401+
402+
u := gitLDAPUsers[0]
403+
404+
session := loginUserWithPassword(t, u.UserName, u.Password)
405+
req := NewRequest(t, "GET", "/user/settings")
406+
resp := session.MakeRequest(t, req, http.StatusOK)
407+
408+
htmlDoc := NewHTMLParser(t, resp.Body)
409+
410+
assert.Equal(t, u.UserName, htmlDoc.GetInputValueByName("name"))
411+
assert.Equal(t, u.FullName, htmlDoc.GetInputValueByName("full_name"))
412+
assert.Equal(t, u.Email, htmlDoc.Find(`label[for="email"]`).Siblings().First().Text())
413+
}

options/locale/locale_en-US.ini

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2581,11 +2581,13 @@ auths.filter = User Filter
25812581
auths.admin_filter = Admin Filter
25822582
auths.restricted_filter = Restricted Filter
25832583
auths.restricted_filter_helper = Leave empty to not set any users as restricted. Use an asterisk ('*') to set all users that do not match Admin Filter as restricted.
2584-
auths.verify_group_membership = Verify group membership in LDAP
2584+
auths.verify_group_membership = Verify group membership in LDAP (leave the filter empty to skip)
25852585
auths.group_search_base = Group Search Base DN
2586-
auths.valid_groups_filter = Valid Groups Filter
25872586
auths.group_attribute_list_users = Group Attribute Containing List Of Users
25882587
auths.user_attribute_in_group = User Attribute Listed In Group
2588+
auths.map_group_to_team = Map LDAP groups to Organization teams (leave the field empty to skip)
2589+
auths.map_group_to_team_removal = Remove users from synchronized teams if user does not belong to corresponding LDAP group
2590+
auths.enable_ldap_groups = Enable LDAP groups
25892591
auths.ms_ad_sa = MS AD Search Attributes
25902592
auths.smtp_auth = SMTP Authentication Type
25912593
auths.smtphost = SMTP Host

routers/web/admin/auths.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source {
145145
GroupDN: form.GroupDN,
146146
GroupFilter: form.GroupFilter,
147147
GroupMemberUID: form.GroupMemberUID,
148+
GroupTeamMap: form.GroupTeamMap,
149+
GroupTeamMapRemoval: form.GroupTeamMapRemoval,
148150
UserUID: form.UserUID,
149151
AdminFilter: form.AdminFilter,
150152
RestrictedFilter: form.RestrictedFilter,

services/auth/source/ldap/README.md

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

services/auth/source/ldap/source.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ type Source struct {
5252
GroupDN string // Group Search Base
5353
GroupFilter string // Group Name Filter
5454
GroupMemberUID string // Group Attribute containing array of UserUID
55+
GroupTeamMap string // Map LDAP groups to teams
56+
GroupTeamMapRemoval bool // Remove user from teams which are synchronized and user is not a member of the corresponding LDAP group
5557
UserUID string // User Attribute listed in Group
5658
SkipLocalTwoFA bool `json:",omitempty"` // Skip Local 2fa for users authenticated with this source
5759

services/auth/source/ldap/source_authenticate.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"strings"
1010

11+
"code.gitea.io/gitea/models"
1112
asymkey_model "code.gitea.io/gitea/models/asymkey"
1213
"code.gitea.io/gitea/models/auth"
1314
"code.gitea.io/gitea/models/db"
@@ -59,10 +60,14 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str
5960
}
6061

6162
if user != nil {
63+
if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
64+
orgCache := make(map[string]*models.Organization)
65+
teamCache := make(map[string]*models.Team)
66+
source.SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, orgCache, teamCache)
67+
}
6268
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(user, source.authSource, sr.SSHPublicKey) {
6369
return user, asymkey_model.RewriteAllPublicKeys()
6470
}
65-
6671
return user, nil
6772
}
6873

@@ -98,10 +103,14 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str
98103
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(user, source.authSource, sr.SSHPublicKey) {
99104
err = asymkey_model.RewriteAllPublicKeys()
100105
}
101-
102106
if err == nil && len(source.AttributeAvatar) > 0 {
103107
_ = user_service.UploadAvatar(user, sr.Avatar)
104108
}
109+
if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
110+
orgCache := make(map[string]*models.Organization)
111+
teamCache := make(map[string]*models.Team)
112+
source.SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, orgCache, teamCache)
113+
}
105114

106115
return user, err
107116
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package ldap
6+
7+
import (
8+
"code.gitea.io/gitea/models"
9+
user_model "code.gitea.io/gitea/models/user"
10+
"code.gitea.io/gitea/modules/log"
11+
)
12+
13+
// SyncLdapGroupsToTeams maps LDAP groups to organization and team memberships
14+
func (source *Source) SyncLdapGroupsToTeams(user *user_model.User, ldapTeamAdd, ldapTeamRemove map[string][]string, orgCache map[string]*models.Organization, teamCache map[string]*models.Team) {
15+
var err error
16+
if source.GroupsEnabled && source.GroupTeamMapRemoval {
17+
// when the user is not a member of configs LDAP group, remove mapped organizations/teams memberships
18+
removeMappedMemberships(user, ldapTeamRemove, orgCache, teamCache)
19+
}
20+
for orgName, teamNames := range ldapTeamAdd {
21+
org, ok := orgCache[orgName]
22+
if !ok {
23+
org, err = models.GetOrgByName(orgName)
24+
if err != nil {
25+
// organization must be created before LDAP group sync
26+
log.Warn("LDAP group sync: Could not find organisation %s: %v", orgName, err)
27+
continue
28+
}
29+
orgCache[orgName] = org
30+
}
31+
if isMember, err := models.IsOrganizationMember(org.ID, user.ID); !isMember && err == nil {
32+
log.Trace("LDAP group sync: adding user [%s] to organization [%s]", user.Name, org.Name)
33+
err = org.AddMember(user.ID)
34+
if err != nil {
35+
log.Error("LDAP group sync: Could not add user to organization: %v", err)
36+
continue
37+
}
38+
}
39+
for _, teamName := range teamNames {
40+
team, ok := teamCache[orgName+teamName]
41+
if !ok {
42+
team, err = org.GetTeam(teamName)
43+
if err != nil {
44+
// team must be created before LDAP group sync
45+
log.Warn("LDAP group sync: Could not find team %s: %v", teamName, err)
46+
continue
47+
}
48+
teamCache[orgName+teamName] = team
49+
}
50+
if isMember, err := models.IsTeamMember(org.ID, team.ID, user.ID); !isMember && err == nil {
51+
log.Trace("LDAP group sync: adding user [%s] to team [%s]", user.Name, org.Name)
52+
} else {
53+
continue
54+
}
55+
err := team.AddMember(user.ID)
56+
if err != nil {
57+
log.Error("LDAP group sync: Could not add user to team: %v", err)
58+
}
59+
}
60+
}
61+
}
62+
63+
// remove membership to organizations/teams if user is not member of corresponding LDAP group
64+
// e.g. lets assume user is member of LDAP group "x", but LDAP group team map contains LDAP groups "x" and "y"
65+
// then users membership gets removed for all organizations/teams mapped by LDAP group "y"
66+
func removeMappedMemberships(user *user_model.User, ldapTeamRemove map[string][]string, orgCache map[string]*models.Organization, teamCache map[string]*models.Team) {
67+
var err error
68+
for orgName, teamNames := range ldapTeamRemove {
69+
org, ok := orgCache[orgName]
70+
if !ok {
71+
org, err = models.GetOrgByName(orgName)
72+
if err != nil {
73+
// organization must be created before LDAP group sync
74+
log.Warn("LDAP group sync: Could not find organisation %s: %v", orgName, err)
75+
continue
76+
}
77+
orgCache[orgName] = org
78+
}
79+
for _, teamName := range teamNames {
80+
team, ok := teamCache[orgName+teamName]
81+
if !ok {
82+
team, err = org.GetTeam(teamName)
83+
if err != nil {
84+
// team must must be created before LDAP group sync
85+
log.Warn("LDAP group sync: Could not find team %s: %v", teamName, err)
86+
continue
87+
}
88+
}
89+
if isMember, err := models.IsTeamMember(org.ID, team.ID, user.ID); isMember && err == nil {
90+
log.Trace("LDAP group sync: removing user [%s] from team [%s]", user.Name, org.Name)
91+
} else {
92+
continue
93+
}
94+
err = team.RemoveMember(user.ID)
95+
if err != nil {
96+
log.Error("LDAP group sync: Could not remove user from team: %v", err)
97+
}
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)