forgejo/services/auth/source/source_group_sync.go
hwipl 1ea5605eae feat: add dynamic group mappings for OIDC (#11656)
Currently, Forgejo supports configuring static group team mappings for
an OIDC authentication source that map OIDC groups to Forgejo
organizations and teams. For example, the following mapping

```json
{"Developer": {"MyForgejoOrganization": ["MyForgejoTeam1", "MyForgejoTeam2"]}}
```

automatically adds a user in the OIDC group `Developer` to the teams
`MyForgejoTeam1` and `MyForgejoTeam2` in organization
`MyForgejoOrganization`.

In order to support more dynamic mappings and to avoid having to update
the mappings for new organizations and teams, add an additional
configuration option that supports mappings with placeholders like in
the following example:

```json
["group-{org}-{team}", "other:{org}/{team}"]
```

In this example, the mappings add a user in OIDC groups
`group-org1-team1`, `group-org2-team2`, and `other:org3/team3` to team
`team1` in organization `org1`, team `team2` in organization `org2`, and
to team `team3` in organization `org3`.

Additionally, this adds a configuration option to dynamically remove
users from organization teams. If enabled, a user is removed from all
teams that are not added via a static or dynamic mapping. Thus, users
are only in teams that are added via such a mapping and no other teams.

Docs: forgejo/docs!1950

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11656
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
2026-05-22 12:38:20 +02:00

322 lines
8.8 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package source
import (
"context"
"fmt"
"regexp"
"slices"
"strings"
"sync"
"forgejo.org/models"
"forgejo.org/models/organization"
user_model "forgejo.org/models/user"
"forgejo.org/modules/container"
"forgejo.org/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,
sourceGroupTeamRemoval bool,
dynGroupMaps *DynGroupMaps,
dynGroupMapsRemoval bool,
) error {
orgCache := make(map[string]*organization.Organization)
teamCache := make(map[string]*organization.Team)
return SyncGroupsToTeamsCached(ctx, user,
sourceUserGroups, sourceGroupTeamMapping, sourceGroupTeamRemoval,
dynGroupMaps, dynGroupMapsRemoval,
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,
sourceGroupTeamRemoval bool,
dynGroupMaps *DynGroupMaps,
dynGroupMapsRemoval bool,
orgCache map[string]*organization.Organization,
teamCache map[string]*organization.Team,
) error {
membershipsToAdd, membershipsToRemove := resolveMappedMemberships(
ctx, user,
sourceUserGroups, sourceGroupTeamMapping,
dynGroupMaps, dynGroupMapsRemoval)
if sourceGroupTeamRemoval || dynGroupMapsRemoval {
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
}
// DynGroupMaps are dynamic group to organization team mappings.
type DynGroupMaps struct {
regexes []*regexp.Regexp
}
// Find checks whether group matches a dynamic group to organization team
// mapping and returns the name of the organization and of the team.
func (d *DynGroupMaps) Find(group string) (string, string) {
if d == nil {
return "", ""
}
group = strings.ToLower(group)
for _, r := range d.regexes {
// check if group matches regex
match := r.FindStringSubmatch(group)
if match == nil {
continue
}
// match, try to get org and team
org := ""
team := ""
for i, name := range r.SubexpNames() {
switch name {
case "org":
org = match[i]
case "team":
team = match[i]
}
}
return org, team
}
return "", ""
}
// Empty returns whether the dynamic group to organization team mappings
// are empty.
func (d *DynGroupMaps) Empty() bool {
return d == nil || len(d.regexes) == 0
}
// NewDynGroupMaps returns new dynamic group to organzation team mappings.
func NewDynGroupMaps(list []string) *DynGroupMaps {
d := &DynGroupMaps{
regexes: []*regexp.Regexp{},
}
for _, s := range list {
// replace placeholders with regex
s = strings.ToLower(s)
s = strings.Replace(s, "{org}", `(?<org>[\w-.]+)`, 1)
s = strings.Replace(s, "{team}", `(?<team>[\w-.]+)`, 1)
s = fmt.Sprintf("^%s$", s)
// skip duplicates
if slices.ContainsFunc(d.regexes, func(r *regexp.Regexp) bool {
return r.String() == s
}) {
continue
}
// create regex
r, err := regexp.Compile(s)
if err != nil {
log.Error("group sync: could not compile regex: %v", err)
continue
}
d.regexes = append(d.regexes, r)
}
return d
}
// sourceDynGroupMaps contains the dynamic group to organization team mappings
// for the authentication sources.
var sourceDynGroupMaps struct {
sync.Mutex
d map[int64]*DynGroupMaps
}
// GetDynGroupMaps returns the dynamic group to organization team mappings of
// the authentication source identified by its source ID. If the mappings do
// not exist yet, they are created using the entries in list.
func GetDynGroupMaps(sourceID int64, list []string) *DynGroupMaps {
sourceDynGroupMaps.Lock()
defer sourceDynGroupMaps.Unlock()
if sourceDynGroupMaps.d == nil {
sourceDynGroupMaps.d = make(map[int64]*DynGroupMaps)
}
if sourceDynGroupMaps.d[sourceID] == nil {
sourceDynGroupMaps.d[sourceID] = NewDynGroupMaps(list)
}
return sourceDynGroupMaps.d[sourceID]
}
// RemoveDynGroupMaps removes the dynamic group to organization team mappings
// of the authentication source identified by its source ID.
func RemoveDynGroupMaps(sourceID int64) {
sourceDynGroupMaps.Lock()
defer sourceDynGroupMaps.Unlock()
if sourceDynGroupMaps.d == nil {
return
}
sourceDynGroupMaps.d[sourceID] = nil
}
// getMembershipsToRemoveNotAdded returns memberships to remove.
// It returns all current memberships of the user that are not added based on
// the group team mappings in membershipsToAdd as memberships to remove.
func getMembershipsToRemoveNotAdded(
ctx context.Context,
user *user_model.User,
membershipsToAdd map[string][]string,
) map[string][]string {
membershipsToRemove := map[string][]string{}
// get user's organizations
orgs, err := organization.GetUserOrgsList(ctx, user)
if err != nil {
log.Warn("group sync: could not get organizations: %v", err)
return membershipsToRemove
}
// get user's teams
teams, err := organization.GetUserTeams(ctx, user.ID)
if err != nil {
log.Warn("group sync: could not get teams: %v", err)
return membershipsToRemove
}
// check memberships
for _, org := range orgs {
for _, team := range teams {
if team.OrgID != org.ID {
continue
}
// remove membership if it's not added via group team mapping
if !slices.Contains(membershipsToAdd[org.Name], team.LowerName) {
membershipsToRemove[org.Name] = append(membershipsToRemove[org.Name], team.LowerName)
}
}
}
return membershipsToRemove
}
func resolveMappedMemberships(
ctx context.Context,
user *user_model.User,
sourceUserGroups container.Set[string],
sourceGroupTeamMapping map[string]map[string][]string,
dynGroupMaps *DynGroupMaps,
dynGroupMapsRemoval bool,
) (map[string][]string, map[string][]string) {
membershipsToAdd := map[string][]string{}
membershipsToRemove := map[string][]string{}
// static mappings
for group, memberships := range sourceGroupTeamMapping {
isUserInGroup := sourceUserGroups.Contains(group)
if isUserInGroup {
for org, teams := range memberships {
membershipsToAdd[org] = append(membershipsToAdd[org], teams...)
}
} else {
for org, teams := range memberships {
membershipsToRemove[org] = append(membershipsToRemove[org], teams...)
}
}
}
// dynamic mappings
if !dynGroupMaps.Empty() {
for group := range sourceUserGroups {
org, team := dynGroupMaps.Find(group)
if org == "" || team == "" {
// no matching mapping found or invalid mapping
continue
}
if !slices.Contains(membershipsToAdd[org], team) {
membershipsToAdd[org] = append(membershipsToAdd[org], team)
}
}
}
// dynamic removal
if dynGroupMapsRemoval {
membershipsToRemove = getMembershipsToRemoveNotAdded(ctx, user, membershipsToAdd)
}
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(ctx, 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(ctx, team, user.ID); err != nil {
log.Error("group sync: Could not remove user from team: %v", err)
return err
}
}
}
}
return nil
}