diff options
author | KN4CK3R <admin@oldschoolhack.me> | 2023-02-08 07:44:42 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-02-08 14:44:42 +0800 |
commit | e8186f1c0f194ce3f63bed9a564002b80c0859c9 (patch) | |
tree | 75ffc50f54af2ef441ecf60448531b9e0ed64490 /services | |
parent | 2c6cc0b8c982b3d49a5b208f75e15b2269584312 (diff) | |
download | gitea-e8186f1c0f194ce3f63bed9a564002b80c0859c9.tar.gz gitea-e8186f1c0f194ce3f63bed9a564002b80c0859c9.zip |
Map OIDC groups to Orgs/Teams (#21441)
Fixes #19555
Test-Instructions:
https://github.com/go-gitea/gitea/pull/21441#issuecomment-1419438000
This PR implements the mapping of user groups provided by OIDC providers
to orgs teams in Gitea. The main part is a refactoring of the existing
LDAP code to make it usable from different providers.
Refactorings:
- Moved the router auth code from module to service because of import
cycles
- Changed some model methods to take a `Context` parameter
- Moved the mapping code from LDAP to a common location
I've tested it with Keycloak but other providers should work too. The
JSON mapping format is the same as for LDAP.
![grafik](https://user-images.githubusercontent.com/1666336/195634392-3fc540fc-b229-4649-99ac-91ae8e19df2d.png)
---------
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Diffstat (limited to 'services')
-rw-r--r-- | services/auth/middleware.go | 60 | ||||
-rw-r--r-- | services/auth/source/ldap/source_authenticate.go | 96 | ||||
-rw-r--r-- | services/auth/source/ldap/source_group_sync.go | 94 | ||||
-rw-r--r-- | services/auth/source/ldap/source_search.go | 136 | ||||
-rw-r--r-- | services/auth/source/ldap/source_sync.go | 11 | ||||
-rw-r--r-- | services/auth/source/oauth2/source.go | 23 | ||||
-rw-r--r-- | services/auth/source/source_group_sync.go | 116 | ||||
-rw-r--r-- | services/forms/auth_form.go | 4 |
8 files changed, 294 insertions, 246 deletions
diff --git a/services/auth/middleware.go b/services/auth/middleware.go new file mode 100644 index 0000000000..cccaab2998 --- /dev/null +++ b/services/auth/middleware.go @@ -0,0 +1,60 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/web/middleware" +) + +// Auth is a middleware to authenticate a web user +func Auth(authMethod Method) func(*context.Context) { + return func(ctx *context.Context) { + if err := authShared(ctx, authMethod); err != nil { + log.Error("Failed to verify user: %v", err) + ctx.Error(http.StatusUnauthorized, "Verify") + return + } + if ctx.Doer == nil { + // ensure the session uid is deleted + _ = ctx.Session.Delete("uid") + } + } +} + +// APIAuth is a middleware to authenticate an api user +func APIAuth(authMethod Method) func(*context.APIContext) { + return func(ctx *context.APIContext) { + if err := authShared(ctx.Context, authMethod); err != nil { + ctx.Error(http.StatusUnauthorized, "APIAuth", err) + } + } +} + +func authShared(ctx *context.Context, authMethod Method) error { + var err error + ctx.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session) + if err != nil { + return err + } + if ctx.Doer != nil { + if ctx.Locale.Language() != ctx.Doer.Language { + ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req) + } + ctx.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == BasicMethodName + ctx.IsSigned = true + ctx.Data["IsSigned"] = ctx.IsSigned + ctx.Data["SignedUser"] = ctx.Doer + ctx.Data["SignedUserID"] = ctx.Doer.ID + ctx.Data["SignedUserName"] = ctx.Doer.Name + ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin + } else { + ctx.Data["SignedUserID"] = int64(0) + ctx.Data["SignedUserName"] = "" + } + return nil +} diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go index 321cf5540d..fba8da7934 100644 --- a/services/auth/source/ldap/source_authenticate.go +++ b/services/auth/source/ldap/source_authenticate.go @@ -10,9 +10,10 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" + auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/util" + source_service "code.gitea.io/gitea/services/auth/source" "code.gitea.io/gitea/services/mailer" user_service "code.gitea.io/gitea/services/user" ) @@ -64,61 +65,66 @@ 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]*organization.Organization) - teamCache := make(map[string]*organization.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() + if err := asymkey_model.RewriteAllPublicKeys(); err != nil { + return user, err + } + } + } else { + // Fallback. + if len(sr.Username) == 0 { + sr.Username = userName } - return user, nil - } - - // Fallback. - if len(sr.Username) == 0 { - sr.Username = userName - } - if len(sr.Mail) == 0 { - sr.Mail = fmt.Sprintf("%s@localhost", sr.Username) - } + if len(sr.Mail) == 0 { + sr.Mail = fmt.Sprintf("%s@localhost", sr.Username) + } - user = &user_model.User{ - LowerName: strings.ToLower(sr.Username), - Name: sr.Username, - FullName: composeFullName(sr.Name, sr.Surname, sr.Username), - Email: sr.Mail, - LoginType: source.authSource.Type, - LoginSource: source.authSource.ID, - LoginName: userName, - IsAdmin: sr.IsAdmin, - } - overwriteDefault := &user_model.CreateUserOverwriteOptions{ - IsRestricted: util.OptionalBoolOf(sr.IsRestricted), - IsActive: util.OptionalBoolTrue, - } + user = &user_model.User{ + LowerName: strings.ToLower(sr.Username), + Name: sr.Username, + FullName: composeFullName(sr.Name, sr.Surname, sr.Username), + Email: sr.Mail, + LoginType: source.authSource.Type, + LoginSource: source.authSource.ID, + LoginName: userName, + IsAdmin: sr.IsAdmin, + } + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsRestricted: util.OptionalBoolOf(sr.IsRestricted), + IsActive: util.OptionalBoolTrue, + } - err := user_model.CreateUser(user, overwriteDefault) - if err != nil { - return user, err - } + err := user_model.CreateUser(user, overwriteDefault) + if err != nil { + return user, err + } - mailer.SendRegisterNotifyMail(user) + mailer.SendRegisterNotifyMail(user) - 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 isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(user, source.authSource, sr.SSHPublicKey) { + if err := asymkey_model.RewriteAllPublicKeys(); err != nil { + return user, err + } + } + if len(source.AttributeAvatar) > 0 { + if err := user_service.UploadAvatar(user, sr.Avatar); err != nil { + return user, err + } + } } + if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) { - orgCache := make(map[string]*organization.Organization) - teamCache := make(map[string]*organization.Team) - source.SyncLdapGroupsToTeams(user, sr.LdapTeamAdd, sr.LdapTeamRemove, orgCache, teamCache) + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return user, err + } + if err := source_service.SyncGroupsToTeams(db.DefaultContext, user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + return user, err + } } - return user, err + return user, nil } // IsSkipLocalTwoFA returns if this source should skip local 2fa for password authentication diff --git a/services/auth/source/ldap/source_group_sync.go b/services/auth/source/ldap/source_group_sync.go deleted file mode 100644 index 95a6084922..0000000000 --- a/services/auth/source/ldap/source_group_sync.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package ldap - -import ( - "code.gitea.io/gitea/models" - "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/organization" - 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]*organization.Organization, teamCache map[string]*organization.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 = organization.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 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 := organization.IsTeamMember(db.DefaultContext, 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 := models.AddTeamMember(team, 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]*organization.Organization, teamCache map[string]*organization.Team) { - var err error - for orgName, teamNames := range ldapTeamRemove { - org, ok := orgCache[orgName] - if !ok { - org, err = organization.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 := organization.IsTeamMember(db.DefaultContext, 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 = models.RemoveTeamMember(team, user.ID) - if err != nil { - log.Error("LDAP group sync: Could not remove user from team: %v", err) - } - } - } -} diff --git a/services/auth/source/ldap/source_search.go b/services/auth/source/ldap/source_search.go index 16f13029f9..5a2d25b0c4 100644 --- a/services/auth/source/ldap/source_search.go +++ b/services/auth/source/ldap/source_search.go @@ -11,26 +11,24 @@ import ( "strconv" "strings" - "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/util" "github.com/go-ldap/ldap/v3" ) // SearchResult : user data type SearchResult struct { - Username string // Username - Name string // Name - Surname string // Surname - Mail string // E-mail address - SSHPublicKey []string // SSH Public Key - IsAdmin bool // if user is administrator - IsRestricted bool // if user is restricted - LowerName string // LowerName - Avatar []byte - LdapTeamAdd map[string][]string // organizations teams to add - LdapTeamRemove map[string][]string // organizations teams to remove + Username string // Username + Name string // Name + Surname string // Surname + Mail string // E-mail address + SSHPublicKey []string // SSH Public Key + IsAdmin bool // if user is administrator + IsRestricted bool // if user is restricted + LowerName string // LowerName + Avatar []byte + Groups container.Set[string] } func (source *Source) sanitizedUserQuery(username string) (string, bool) { @@ -196,9 +194,8 @@ func checkRestricted(l *ldap.Conn, ls *Source, userDN string) bool { } // List all group memberships of a user -func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGroupFilter bool) []string { - var ldapGroups []string - var searchFilter string +func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGroupFilter bool) container.Set[string] { + ldapGroups := make(container.Set[string]) groupFilter, ok := source.sanitizedGroupFilter(source.GroupFilter) if !ok { @@ -210,12 +207,12 @@ func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGr return ldapGroups } + var searchFilter string if applyGroupFilter { searchFilter = fmt.Sprintf("(&(%s)(%s=%s))", groupFilter, source.GroupMemberUID, ldap.EscapeFilter(uid)) } else { searchFilter = fmt.Sprintf("(%s=%s)", source.GroupMemberUID, ldap.EscapeFilter(uid)) } - result, err := l.Search(ldap.NewSearchRequest( groupDN, ldap.ScopeWholeSubtree, @@ -237,44 +234,12 @@ func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGr log.Error("LDAP search was successful, but found no DN!") continue } - ldapGroups = append(ldapGroups, entry.DN) + ldapGroups.Add(entry.DN) } return ldapGroups } -// parse LDAP groups and return map of ldap groups to organizations teams -func (source *Source) mapLdapGroupsToTeams() map[string]map[string][]string { - ldapGroupsToTeams := make(map[string]map[string][]string) - err := json.Unmarshal([]byte(source.GroupTeamMap), &ldapGroupsToTeams) - if err != nil { - log.Error("Failed to unmarshall LDAP teams map: %v", err) - return ldapGroupsToTeams - } - return ldapGroupsToTeams -} - -// getMappedMemberships : returns the organizations and teams to modify the users membership -func (source *Source) getMappedMemberships(usersLdapGroups []string, uid string) (map[string][]string, map[string][]string) { - // unmarshall LDAP group team map from configs - ldapGroupsToTeams := source.mapLdapGroupsToTeams() - membershipsToAdd := map[string][]string{} - membershipsToRemove := map[string][]string{} - for group, memberships := range ldapGroupsToTeams { - isUserInGroup := util.SliceContainsString(usersLdapGroups, group) - if isUserInGroup { - for org, teams := range memberships { - membershipsToAdd[org] = teams - } - } else if !isUserInGroup { - for org, teams := range memberships { - membershipsToRemove[org] = teams - } - } - } - return membershipsToAdd, membershipsToRemove -} - func (source *Source) getUserAttributeListedInGroup(entry *ldap.Entry) string { if strings.ToLower(source.UserUID) == "dn" { return entry.DN @@ -399,23 +364,6 @@ func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchR surname := sr.Entries[0].GetAttributeValue(source.AttributeSurname) mail := sr.Entries[0].GetAttributeValue(source.AttributeMail) - teamsToAdd := make(map[string][]string) - teamsToRemove := make(map[string][]string) - - // Check group membership - if source.GroupsEnabled { - userAttributeListedInGroup := source.getUserAttributeListedInGroup(sr.Entries[0]) - usersLdapGroups := source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) - - if source.GroupFilter != "" && len(usersLdapGroups) == 0 { - return nil - } - - if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { - teamsToAdd, teamsToRemove = source.getMappedMemberships(usersLdapGroups, userAttributeListedInGroup) - } - } - if isAttributeSSHPublicKeySet { sshPublicKey = sr.Entries[0].GetAttributeValues(source.AttributeSSHPublicKey) } @@ -431,6 +379,17 @@ func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchR Avatar = sr.Entries[0].GetRawAttributeValue(source.AttributeAvatar) } + // Check group membership + var usersLdapGroups container.Set[string] + if source.GroupsEnabled { + userAttributeListedInGroup := source.getUserAttributeListedInGroup(sr.Entries[0]) + usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) + + if source.GroupFilter != "" && len(usersLdapGroups) == 0 { + return nil + } + } + if !directBind && source.AttributesInBind { // binds user (checking password) after looking-up attributes in BindDN context err = bindUser(l, userDN, passwd) @@ -440,17 +399,16 @@ func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchR } return &SearchResult{ - LowerName: strings.ToLower(username), - Username: username, - Name: firstname, - Surname: surname, - Mail: mail, - SSHPublicKey: sshPublicKey, - IsAdmin: isAdmin, - IsRestricted: isRestricted, - Avatar: Avatar, - LdapTeamAdd: teamsToAdd, - LdapTeamRemove: teamsToRemove, + LowerName: strings.ToLower(username), + Username: username, + Name: firstname, + Surname: surname, + Mail: mail, + SSHPublicKey: sshPublicKey, + IsAdmin: isAdmin, + IsRestricted: isRestricted, + Avatar: Avatar, + Groups: usersLdapGroups, } } @@ -512,33 +470,29 @@ func (source *Source) SearchEntries() ([]*SearchResult, error) { result := make([]*SearchResult, 0, len(sr.Entries)) for _, v := range sr.Entries { - teamsToAdd := make(map[string][]string) - teamsToRemove := make(map[string][]string) - + var usersLdapGroups container.Set[string] if source.GroupsEnabled { userAttributeListedInGroup := source.getUserAttributeListedInGroup(v) if source.GroupFilter != "" { - usersLdapGroups := source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) + usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true) if len(usersLdapGroups) == 0 { continue } } if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { - usersLdapGroups := source.listLdapGroupMemberships(l, userAttributeListedInGroup, false) - teamsToAdd, teamsToRemove = source.getMappedMemberships(usersLdapGroups, userAttributeListedInGroup) + usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, false) } } user := &SearchResult{ - Username: v.GetAttributeValue(source.AttributeUsername), - Name: v.GetAttributeValue(source.AttributeName), - Surname: v.GetAttributeValue(source.AttributeSurname), - Mail: v.GetAttributeValue(source.AttributeMail), - IsAdmin: checkAdmin(l, source, v.DN), - LdapTeamAdd: teamsToAdd, - LdapTeamRemove: teamsToRemove, + Username: v.GetAttributeValue(source.AttributeUsername), + Name: v.GetAttributeValue(source.AttributeName), + Surname: v.GetAttributeValue(source.AttributeSurname), + Mail: v.GetAttributeValue(source.AttributeMail), + IsAdmin: checkAdmin(l, source, v.DN), + Groups: usersLdapGroups, } if !user.IsAdmin { diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go index 73e8309aca..4571ff6540 100644 --- a/services/auth/source/ldap/source_sync.go +++ b/services/auth/source/ldap/source_sync.go @@ -13,8 +13,10 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" + auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" + source_service "code.gitea.io/gitea/services/auth/source" user_service "code.gitea.io/gitea/services/user" ) @@ -65,6 +67,11 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { orgCache := make(map[string]*organization.Organization) teamCache := make(map[string]*organization.Team) + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return err + } + for _, su := range sr { select { case <-ctx.Done(): @@ -173,7 +180,9 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { } // Synchronize LDAP groups with organization and team memberships if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) { - source.SyncLdapGroupsToTeams(usr, su.LdapTeamAdd, su.LdapTeamRemove, orgCache, teamCache) + if err := source_service.SyncGroupsToTeamsCached(ctx, usr, su.Groups, groupTeamMapping, source.GroupTeamMapRemoval, orgCache, teamCache); err != nil { + log.Error("SyncGroupsToTeamsCached: %v", err) + } } } diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go index 0abebc04ec..675005e55a 100644 --- a/services/auth/source/oauth2/source.go +++ b/services/auth/source/oauth2/source.go @@ -8,13 +8,6 @@ import ( "code.gitea.io/gitea/modules/json" ) -// ________ _____ __ .__ ________ -// \_____ \ / _ \ __ ___/ |_| |__ \_____ \ -// / | \ / /_\ \| | \ __\ | \ / ____/ -// / | \/ | \ | /| | | Y \/ \ -// \_______ /\____|__ /____/ |__| |___| /\_______ \ -// \/ \/ \/ \/ - // Source holds configuration for the OAuth2 login source. type Source struct { Provider string @@ -24,13 +17,15 @@ type Source struct { CustomURLMapping *CustomURLMapping IconURL string - Scopes []string - RequiredClaimName string - RequiredClaimValue string - GroupClaimName string - AdminGroup string - RestrictedGroup string - SkipLocalTwoFA bool `json:",omitempty"` + Scopes []string + RequiredClaimName string + RequiredClaimValue string + GroupClaimName string + AdminGroup string + GroupTeamMap string + GroupTeamMapRemoval bool + RestrictedGroup string + SkipLocalTwoFA bool `json:",omitempty"` // reference to the authSource authSource *auth.Source diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go new file mode 100644 index 0000000000..20b6095345 --- /dev/null +++ b/services/auth/source/source_group_sync.go @@ -0,0 +1,116 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package source + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" +) + +type syncType int + +const ( + syncAdd syncType = iota + syncRemove +) + +// SyncGroupsToTeams maps authentication source groups to organization and team memberships +func SyncGroupsToTeams(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool) error { + orgCache := make(map[string]*organization.Organization) + teamCache := make(map[string]*organization.Team) + return SyncGroupsToTeamsCached(ctx, user, sourceUserGroups, sourceGroupTeamMapping, performRemoval, orgCache, teamCache) +} + +// SyncGroupsToTeamsCached maps authentication source groups to organization and team memberships +func SyncGroupsToTeamsCached(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error { + membershipsToAdd, membershipsToRemove := resolveMappedMemberships(sourceUserGroups, sourceGroupTeamMapping) + + if performRemoval { + if err := syncGroupsToTeamsCached(ctx, user, membershipsToRemove, syncRemove, orgCache, teamCache); err != nil { + return fmt.Errorf("could not sync[remove] user groups: %w", err) + } + } + + if err := syncGroupsToTeamsCached(ctx, user, membershipsToAdd, syncAdd, orgCache, teamCache); err != nil { + return fmt.Errorf("could not sync[add] user groups: %w", err) + } + + return nil +} + +func resolveMappedMemberships(sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string) (map[string][]string, map[string][]string) { + membershipsToAdd := map[string][]string{} + membershipsToRemove := map[string][]string{} + for group, memberships := range sourceGroupTeamMapping { + isUserInGroup := sourceUserGroups.Contains(group) + if isUserInGroup { + for org, teams := range memberships { + membershipsToAdd[org] = teams + } + } else { + for org, teams := range memberships { + membershipsToRemove[org] = teams + } + } + } + return membershipsToAdd, membershipsToRemove +} + +func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeamMap map[string][]string, action syncType, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error { + for orgName, teamNames := range orgTeamMap { + var err error + org, ok := orgCache[orgName] + if !ok { + org, err = organization.GetOrgByName(ctx, orgName) + if err != nil { + if organization.IsErrOrgNotExist(err) { + // organization must be created before group sync + log.Warn("group sync: Could not find organisation %s: %v", orgName, err) + continue + } + return err + } + orgCache[orgName] = org + } + for _, teamName := range teamNames { + team, ok := teamCache[orgName+teamName] + if !ok { + team, err = org.GetTeam(ctx, teamName) + if err != nil { + if organization.IsErrTeamNotExist(err) { + // team must be created before group sync + log.Warn("group sync: Could not find team %s: %v", teamName, err) + continue + } + return err + } + teamCache[orgName+teamName] = team + } + + isMember, err := organization.IsTeamMember(ctx, org.ID, team.ID, user.ID) + if err != nil { + return err + } + + if action == syncAdd && !isMember { + if err := models.AddTeamMember(team, user.ID); err != nil { + log.Error("group sync: Could not add user to team: %v", err) + return err + } + } else if action == syncRemove && isMember { + if err := models.RemoveTeamMember(team, user.ID); err != nil { + log.Error("group sync: Could not remove user from team: %v", err) + return err + } + } + } + } + return nil +} diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index 0cede07f95..5625aa1e2e 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -72,13 +72,15 @@ type AuthenticationForm struct { Oauth2GroupClaimName string Oauth2AdminGroup string Oauth2RestrictedGroup string + Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"` + Oauth2GroupTeamMapRemoval bool SkipLocalTwoFA bool SSPIAutoCreateUsers bool SSPIAutoActivateUsers bool SSPIStripDomainNames bool SSPISeparatorReplacement string `binding:"AlphaDashDot;MaxSize(5)"` SSPIDefaultLanguage string - GroupTeamMap string + GroupTeamMap string `binding:"ValidGroupTeamMap"` GroupTeamMapRemoval bool } |