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 14 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
15 changes: 15 additions & 0 deletions cmd/admin_auth_ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ var (
Name: "avatar-attribute",
Usage: "The attribute of the user’s LDAP record containing the user’s avatar.",
},
cli.StringFlag{
Name: "team-group-map",
Usage: "Map of LDAP groups to teams.",
},
cli.BoolFlag{
Name: "team-group-map-removal",
Usage: "Force synchronization of mapped LDAP groups to teams.",
},
}

ldapBindDnCLIFlags = append(commonLdapCLIFlags,
Expand Down Expand Up @@ -260,6 +268,13 @@ func parseLdapConfig(c *cli.Context, config *ldap.Source) error {
if c.IsSet("skip-local-2fa") {
config.SkipLocalTwoFA = c.Bool("skip-local-2fa")
}
if c.IsSet("team-group-map") {
config.TeamGroupMapEnabled = c.Bool("team-group-map")
config.TeamGroupMap = c.String("team-group-map")
}
if c.IsSet("team-group-map-removal") {
config.TeamGroupMapRemoval = c.Bool("team-group-map-removal")
}

return nil
}
Expand Down
117 changes: 116 additions & 1 deletion integrations/auth_ldap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"strings"
"testing"

"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/services/auth"

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

func addAuthSourceLDAP(t *testing.T, sshKeyAttribute string) {
func addAuthSourceLDAP(t *testing.T, sshKeyAttribute string, groupMapParams ...string) {
teamGroupMapEnabled := "off"
teamGroupMapRemoval := "off"
teamGroupMap := ""
if len(groupMapParams) == 3 {
teamGroupMapEnabled = groupMapParams[0]
teamGroupMapRemoval = groupMapParams[1]
teamGroupMap = groupMapParams[2]
}
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 +129,12 @@ func addAuthSourceLDAP(t *testing.T, sshKeyAttribute string) {
"attribute_ssh_public_key": sshKeyAttribute,
"is_sync_enabled": "on",
"is_active": "on",
"team_group_map_enabled": teamGroupMapEnabled,
"team_group_map_removal": teamGroupMapRemoval,
"group_dn": "ou=people,dc=planetexpress,dc=com",
"group_member_uid": "member",
"user_uid": "DN",
"team_group_map": teamGroupMap,
})
session.MakeRequest(t, req, http.StatusFound)
}
Expand Down Expand Up @@ -294,3 +310,102 @@ 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", "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 := db.AssertExistsAndLoadBean(t, &models.User{
Name: gitLDAPUser.UserName,
}).(*models.User)
usersOrgs, err := models.GetOrgsByUserID(user.ID, 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", "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 := db.AssertExistsAndLoadBean(t, &models.User{
Name: gitLDAPUsers[0].UserName,
}).(*models.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", "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())
}
3 changes: 3 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2472,6 +2472,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 @@ -146,6 +146,9 @@ func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source {
AdminFilter: form.AdminFilter,
RestrictedFilter: form.RestrictedFilter,
AllowDeactivateAll: form.AllowDeactivateAll,
TeamGroupMap: form.TeamGroupMap,
TeamGroupMapRemoval: form.TeamGroupMapRemoval,
TeamGroupMapEnabled: form.TeamGroupMapEnabled,
Enabled: true,
SkipLocalTwoFA: form.SkipLocalTwoFA,
}
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.
3 changes: 3 additions & 0 deletions services/auth/source/ldap/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ type Source struct {
GroupMemberUID string // Group Attribute containing array of UserUID
UserUID string // User Attribute listed in Group
SkipLocalTwoFA bool `json:",omitempty"` // Skip Local 2fa for users authenticated with this source
TeamGroupMap string // Map LDAP groups to teams
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One slight fly in the ointment here is that the login_source will be read and loaded from the source quite a lot - it's infact in the DB as a JSON where it gets unmarshaled on load.

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

// reference to the loginSource
loginSource *login.Source
Expand Down
12 changes: 10 additions & 2 deletions services/auth/source/ldap/source_authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,14 @@ func (source *Source) Authenticate(user *models.User, userName, password string)
}

if user != nil {
if source.TeamGroupMapEnabled || source.TeamGroupMapRemoval {
orgCache := make(map[string]*models.User)
teamCache := make(map[string]*models.Team)
source.SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, orgCache, teamCache)
}
if isAttributeSSHPublicKeySet && models.SynchronizePublicKeys(user, source.loginSource, sr.SSHPublicKey) {
return user, models.RewriteAllPublicKeys()
}

return user, nil
}

Expand Down Expand Up @@ -95,10 +99,14 @@ func (source *Source) Authenticate(user *models.User, userName, password string)
if isAttributeSSHPublicKeySet && models.AddPublicKeysBySource(user, source.loginSource, sr.SSHPublicKey) {
err = models.RewriteAllPublicKeys()
}

if err == nil && len(source.AttributeAvatar) > 0 {
_ = user.UploadAvatar(sr.Avatar)
}
if source.TeamGroupMapEnabled || source.TeamGroupMapRemoval {
orgCache := make(map[string]*models.User)
teamCache := make(map[string]*models.Team)
source.SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, orgCache, teamCache)
}

return user, err
}
Expand Down
113 changes: 113 additions & 0 deletions services/auth/source/ldap/source_group_sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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"
"code.gitea.io/gitea/modules/log"
)

// SyncLdapGroupsToTeams maps LDAP groups to organization and team memberships
func (source *Source) SyncLdapGroupsToTeams(user *models.User, ldapTeamAdd map[string][]string, ldapTeamRemove map[string][]string, orgCache map[string]*models.User, teamCache map[string]*models.Team) {
var err error
if source.TeamGroupMapRemoval {
// 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.Debug("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.Debug("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 *models.User, ldapTeamRemove map[string][]string, orgCache map[string]*models.User, 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.Debug("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.Debug("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)
}
}
if remainingTeams, err := models.GetUserOrgTeams(org.ID, user.ID); err == nil && len(remainingTeams) == 0 {
// only remove organization membership when no team memberships are left for this organization
if isMember, err := models.IsOrganizationMember(org.ID, user.ID); isMember && err == nil {
log.Trace("LDAP group sync: removing user [%s] from organization [%s]", user.Name, org.Name)
} else {
continue
}
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)
}
}
}
Loading