mirror of https://github.com/go-gitea/gitea
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>pull/22798/head^2
parent
2c6cc0b8c9
commit
e8186f1c0f
@ -0,0 +1,22 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth |
||||||
|
|
||||||
|
import ( |
||||||
|
"code.gitea.io/gitea/modules/json" |
||||||
|
"code.gitea.io/gitea/modules/log" |
||||||
|
) |
||||||
|
|
||||||
|
func UnmarshalGroupTeamMapping(raw string) (map[string]map[string][]string, error) { |
||||||
|
groupTeamMapping := make(map[string]map[string][]string) |
||||||
|
if raw == "" { |
||||||
|
return groupTeamMapping, nil |
||||||
|
} |
||||||
|
err := json.Unmarshal([]byte(raw), &groupTeamMapping) |
||||||
|
if err != nil { |
||||||
|
log.Error("Failed to unmarshal group team mapping: %v", err) |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return groupTeamMapping, nil |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
@ -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 |
||||||
|
} |
Loading…
Reference in new issue