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>forgejo
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