Skip to content

Add LDAP group sync to Teams, fixes #1395 #16299

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 33 commits into from
Feb 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
136c628
Add LDAP group sync to Teams, fixes #1395
svenseeberg May 30, 2021
673df99
Add tests to LDAP group sync
melegiul Jul 2, 2021
3a032cc
Replace funk package by custom utility
melegiul Aug 26, 2021
6ef0722
Merge branch 'main' into feature/ldap-group-sync
melegiul Aug 30, 2021
8339cf9
Clean up test database - revert initial
melegiul Aug 31, 2021
76bb588
Skip adding team/org members when already member
melegiul Sep 2, 2021
edd19e2
Rename generic get keys from map function
melegiul Sep 2, 2021
ed0bab6
Merge branch 'main' into feature/ldap-group-sync
melegiul Sep 7, 2021
eda55b6
Merge branch 'main' into feature/ldap-group-sync
melegiul Sep 30, 2021
5f6f092
Merge branch 'main' into feature/ldap-group-sync
melegiul Oct 27, 2021
ba93eb0
Improve non-idiomatic go code
melegiul Oct 27, 2021
4d864b8
Add cache for teams and orgs
melegiul Nov 2, 2021
564b59f
Merge branch 'main' into feature/ldap-group-sync
melegiul Nov 11, 2021
1849924
Fix cli command flag and checkbox listener
melegiul Nov 11, 2021
8865932
Merge branch 'main' into feature/ldap-group-sync
melegiul Dec 14, 2021
6d21c2b
Set log level to warning for missing orgs/teams
melegiul Dec 14, 2021
f8d7a39
Remove redundant check remaining team memberships
melegiul Dec 14, 2021
c03bcb7
Fix integration tests
melegiul Dec 14, 2021
675d64d
Disable group mapping checkbox on LDAP removal
melegiul Dec 14, 2021
7f6d010
Merge branch 'main' into feature/ldap-group-sync
melegiul Jan 19, 2022
9798db1
Merge branch 'main' into feature/ldap-group-sync
Jan 22, 2022
a75516d
Run make fmt
Jan 22, 2022
0d402cc
use kebap case for CSS classes
svenseeberg Jan 22, 2022
de1fd67
Merge branch 'main' into feature/ldap-group-sync
wxiaoguang Feb 8, 2022
6ef197e
refactor
wxiaoguang Feb 8, 2022
9563483
Merge pull request #4 from wxiaoguang/feature/ldap-group-sync
svenseeberg Feb 10, 2022
c965872
Merge branch 'main' into feature/ldap-group-sync
wxiaoguang Feb 10, 2022
d01e377
fix lint
wxiaoguang Feb 10, 2022
82d0cb3
try to fix unit test
wxiaoguang Feb 10, 2022
dac97ff
Merge branch 'main' into feature/ldap-group-sync
6543 Feb 10, 2022
8f0b40a
fix unit test
wxiaoguang Feb 11, 2022
25880d3
Merge branch 'main' into feature/ldap-group-sync
wxiaoguang Feb 11, 2022
f65f28f
Merge branch 'main' into feature/ldap-group-sync
wxiaoguang Feb 11, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion cmd/admin_auth_ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ func parseLdapConfig(c *cli.Context, config *ldap.Source) error {
if c.IsSet("skip-local-2fa") {
config.SkipLocalTwoFA = c.Bool("skip-local-2fa")
}

return nil
}

Expand Down
119 changes: 118 additions & 1 deletion integrations/auth_ldap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (
"strings"
"testing"

"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/services/auth"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -97,7 +100,13 @@ func getLDAPServerHost() string {
return host
}

func addAuthSourceLDAP(t *testing.T, sshKeyAttribute string) {
func addAuthSourceLDAP(t *testing.T, sshKeyAttribute string, groupMapParams ...string) {
groupTeamMapRemoval := "off"
groupTeamMap := ""
if len(groupMapParams) == 2 {
groupTeamMapRemoval = groupMapParams[0]
groupTeamMap = groupMapParams[1]
}
session := loginUser(t, "user1")
csrf := GetCSRF(t, session, "/admin/auths/new")
req := NewRequestWithValues(t, "POST", "/admin/auths/new", map[string]string{
Expand All @@ -119,6 +128,12 @@ func addAuthSourceLDAP(t *testing.T, sshKeyAttribute string) {
"attribute_ssh_public_key": sshKeyAttribute,
"is_sync_enabled": "on",
"is_active": "on",
"groups_enabled": "on",
"group_dn": "ou=people,dc=planetexpress,dc=com",
"group_member_uid": "member",
"group_team_map": groupTeamMap,
"group_team_map_removal": groupTeamMapRemoval,
"user_uid": "DN",
})
session.MakeRequest(t, req, http.StatusFound)
}
Expand Down Expand Up @@ -294,3 +309,105 @@ func TestLDAPUserSSHKeySync(t *testing.T) {
assert.ElementsMatch(t, u.SSHKeys, syncedKeys, "Unequal number of keys synchronized for user: %s", u.UserName)
}
}

func TestLDAPGroupTeamSyncAddMember(t *testing.T) {
if skipLDAPTests() {
t.Skip()
return
}
defer prepareTestEnv(t)()
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"]}}`)
org, err := models.GetOrgByName("org26")
assert.NoError(t, err)
team, err := models.GetTeam(org.ID, "team11")
assert.NoError(t, err)
auth.SyncExternalUsers(context.Background(), true)
for _, gitLDAPUser := range gitLDAPUsers {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
Name: gitLDAPUser.UserName,
}).(*user_model.User)
usersOrgs, err := models.FindOrgs(models.FindOrgOptions{
UserID: user.ID,
IncludePrivate: true,
})
assert.NoError(t, err)
allOrgTeams, err := models.GetUserOrgTeams(org.ID, user.ID)
assert.NoError(t, err)
if user.Name == "fry" || user.Name == "leela" || user.Name == "bender" {
// assert members of LDAP group "cn=ship_crew" are added to mapped teams
assert.Equal(t, len(usersOrgs), 1, "User [%s] should be member of one organization", user.Name)
assert.Equal(t, usersOrgs[0].Name, "org26", "Membership should be added to the right organization")
isMember, err := models.IsTeamMember(usersOrgs[0].ID, team.ID, user.ID)
assert.NoError(t, err)
assert.True(t, isMember, "Membership should be added to the right team")
err = team.RemoveMember(user.ID)
assert.NoError(t, err)
err = usersOrgs[0].RemoveMember(user.ID)
assert.NoError(t, err)
} else {
// assert members of LDAP group "cn=admin_staff" keep initial team membership since mapped team does not exist
assert.Empty(t, usersOrgs, "User should be member of no organization")
isMember, err := models.IsTeamMember(org.ID, team.ID, user.ID)
assert.NoError(t, err)
assert.False(t, isMember, "User should no be added to this team")
assert.Empty(t, allOrgTeams, "User should not be added to any team")
}
}
}

func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) {
if skipLDAPTests() {
t.Skip()
return
}
defer prepareTestEnv(t)()
addAuthSourceLDAP(t, "", "on", `{"cn=dispatch,ou=people,dc=planetexpress,dc=com": {"org26": ["team11"]}}`)
org, err := models.GetOrgByName("org26")
assert.NoError(t, err)
team, err := models.GetTeam(org.ID, "team11")
assert.NoError(t, err)
loginUserWithPassword(t, gitLDAPUsers[0].UserName, gitLDAPUsers[0].Password)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
Name: gitLDAPUsers[0].UserName,
}).(*user_model.User)
err = org.AddMember(user.ID)
assert.NoError(t, err)
err = team.AddMember(user.ID)
assert.NoError(t, err)
isMember, err := models.IsOrganizationMember(org.ID, user.ID)
assert.NoError(t, err)
assert.True(t, isMember, "User should be member of this organization")
isMember, err = models.IsTeamMember(org.ID, team.ID, user.ID)
assert.NoError(t, err)
assert.True(t, isMember, "User should be member of this team")
// assert team member "professor" gets removed from org26 team11
loginUserWithPassword(t, gitLDAPUsers[0].UserName, gitLDAPUsers[0].Password)
isMember, err = models.IsOrganizationMember(org.ID, user.ID)
assert.NoError(t, err)
assert.False(t, isMember, "User membership should have been removed from organization")
isMember, err = models.IsTeamMember(org.ID, team.ID, user.ID)
assert.NoError(t, err)
assert.False(t, isMember, "User membership should have been removed from team")
}

// Login should work even if Team Group Map contains a broken JSON
func TestBrokenLDAPMapUserSignin(t *testing.T) {
if skipLDAPTests() {
t.Skip()
return
}
defer prepareTestEnv(t)()
addAuthSourceLDAP(t, "", "on", `{"NOT_A_VALID_JSON"["MISSING_DOUBLE_POINT"]}`)

u := gitLDAPUsers[0]

session := loginUserWithPassword(t, u.UserName, u.Password)
req := NewRequest(t, "GET", "/user/settings")
resp := session.MakeRequest(t, req, http.StatusOK)

htmlDoc := NewHTMLParser(t, resp.Body)

assert.Equal(t, u.UserName, htmlDoc.GetInputValueByName("name"))
assert.Equal(t, u.FullName, htmlDoc.GetInputValueByName("full_name"))
assert.Equal(t, u.Email, htmlDoc.Find(`label[for="email"]`).Siblings().First().Text())
}
6 changes: 4 additions & 2 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2581,11 +2581,13 @@ auths.filter = User Filter
auths.admin_filter = Admin Filter
auths.restricted_filter = Restricted Filter
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.
auths.verify_group_membership = Verify group membership in LDAP
auths.verify_group_membership = Verify group membership in LDAP (leave the filter empty to skip)
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.map_group_to_team = Map LDAP groups to Organization teams (leave the field empty to skip)
auths.map_group_to_team_removal = Remove users from synchronized teams if user does not belong to corresponding LDAP group
auths.enable_ldap_groups = Enable LDAP groups
auths.ms_ad_sa = MS AD Search Attributes
auths.smtp_auth = SMTP Authentication Type
auths.smtphost = SMTP Host
Expand Down
2 changes: 2 additions & 0 deletions routers/web/admin/auths.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source {
GroupDN: form.GroupDN,
GroupFilter: form.GroupFilter,
GroupMemberUID: form.GroupMemberUID,
GroupTeamMap: form.GroupTeamMap,
GroupTeamMapRemoval: form.GroupTeamMapRemoval,
UserUID: form.UserUID,
AdminFilter: form.AdminFilter,
RestrictedFilter: form.RestrictedFilter,
Expand Down
8 changes: 8 additions & 0 deletions services/auth/source/ldap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,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.
2 changes: 2 additions & 0 deletions services/auth/source/ldap/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ type Source struct {
GroupDN string // Group Search Base
GroupFilter string // Group Name Filter
GroupMemberUID string // Group Attribute containing array of UserUID
GroupTeamMap string // Map LDAP groups to teams
GroupTeamMapRemoval bool // Remove user from teams which are synchronized and user is not a member of the corresponding LDAP group
UserUID string // User Attribute listed in Group
SkipLocalTwoFA bool `json:",omitempty"` // Skip Local 2fa for users authenticated with this source

Expand Down
13 changes: 11 additions & 2 deletions services/auth/source/ldap/source_authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"strings"

"code.gitea.io/gitea/models"
asymkey_model "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
Expand Down Expand Up @@ -59,10 +60,14 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str
}

if user != nil {
if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
orgCache := make(map[string]*models.Organization)
teamCache := make(map[string]*models.Team)
source.SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, orgCache, teamCache)
}
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(user, source.authSource, sr.SSHPublicKey) {
return user, asymkey_model.RewriteAllPublicKeys()
}

return user, nil
}

Expand Down Expand Up @@ -98,10 +103,14 @@ func (source *Source) Authenticate(user *user_model.User, userName, password str
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(user, source.authSource, sr.SSHPublicKey) {
err = asymkey_model.RewriteAllPublicKeys()
}

if err == nil && len(source.AttributeAvatar) > 0 {
_ = user_service.UploadAvatar(user, sr.Avatar)
}
if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
orgCache := make(map[string]*models.Organization)
teamCache := make(map[string]*models.Team)
source.SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, orgCache, teamCache)
}

return user, err
}
Expand Down
100 changes: 100 additions & 0 deletions services/auth/source/ldap/source_group_sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package ldap

import (
"code.gitea.io/gitea/models"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
)

// SyncLdapGroupsToTeams maps LDAP groups to organization and team memberships
func (source *Source) SyncLdapGroupsToTeams(user *user_model.User, ldapTeamAdd, ldapTeamRemove map[string][]string, orgCache map[string]*models.Organization, teamCache map[string]*models.Team) {
var err error
if source.GroupsEnabled && source.GroupTeamMapRemoval {
// when the user is not a member of configs LDAP group, remove mapped organizations/teams memberships
removeMappedMemberships(user, ldapTeamRemove, orgCache, teamCache)
}
for orgName, teamNames := range ldapTeamAdd {
org, ok := orgCache[orgName]
if !ok {
org, err = models.GetOrgByName(orgName)
if err != nil {
// organization must be created before LDAP group sync
log.Warn("LDAP group sync: Could not find organisation %s: %v", orgName, err)
continue
}
orgCache[orgName] = org
}
if isMember, err := models.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)
continue
}
}
for _, teamName := range teamNames {
team, ok := teamCache[orgName+teamName]
if !ok {
team, err = org.GetTeam(teamName)
if err != nil {
// team must be created before LDAP group sync
log.Warn("LDAP group sync: Could not find team %s: %v", teamName, err)
continue
}
teamCache[orgName+teamName] = team
}
if isMember, err := models.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)
} else {
continue
}
err := team.AddMember(user.ID)
if err != nil {
log.Error("LDAP group sync: Could not add user to team: %v", err)
}
}
}
}

// 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_model.User, ldapTeamRemove map[string][]string, orgCache map[string]*models.Organization, teamCache map[string]*models.Team) {
var err error
for orgName, teamNames := range ldapTeamRemove {
org, ok := orgCache[orgName]
if !ok {
org, err = models.GetOrgByName(orgName)
if err != nil {
// organization must be created before LDAP group sync
log.Warn("LDAP group sync: Could not find organisation %s: %v", orgName, err)
continue
}
orgCache[orgName] = org
}
for _, teamName := range teamNames {
team, ok := teamCache[orgName+teamName]
if !ok {
team, err = org.GetTeam(teamName)
if err != nil {
// team must must be created before LDAP group sync
log.Warn("LDAP group sync: Could not find team %s: %v", teamName, err)
continue
}
}
if isMember, err := models.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)
} else {
continue
}
err = team.RemoveMember(user.ID)
if err != nil {
log.Error("LDAP group sync: Could not remove user from team: %v", err)
}
}
}
}
Loading