From 1ea5605eaeff4b601c1aeda7fd696ba2b5c0a6d0 Mon Sep 17 00:00:00 2001 From: hwipl Date: Fri, 22 May 2026 12:38:20 +0200 Subject: [PATCH] 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 --- cmd/admin_auth_oauth.go | 17 + cmd/admin_auth_oauth_test.go | 60 +++ models/organization/team_list.go | 8 + modules/auth/common.go | 13 + modules/validation/binding.go | 20 + options/locale_next/locale_en-US.json | 2 + routers/web/admin/auths.go | 2 + routers/web/auth/linkaccount.go | 4 +- routers/web/auth/oauth.go | 42 +- .../auth/source/ldap/source_authenticate.go | 5 +- services/auth/source/ldap/source_sync.go | 5 +- services/auth/source/oauth2/source.go | 2 + .../auth/source/oauth2/source_register.go | 4 + services/auth/source/source_group_sync.go | 218 +++++++++- .../auth/source/source_group_sync_test.go | 402 ++++++++++++++++++ services/forms/auth_form.go | 2 + templates/admin/auth/edit.tmpl | 8 + templates/admin/auth/source/oauth.tmpl | 8 + 18 files changed, 803 insertions(+), 19 deletions(-) create mode 100644 services/auth/source/source_group_sync_test.go diff --git a/cmd/admin_auth_oauth.go b/cmd/admin_auth_oauth.go index f666023943..721fda4458 100644 --- a/cmd/admin_auth_oauth.go +++ b/cmd/admin_auth_oauth.go @@ -125,6 +125,15 @@ func oauthCLIFlags() []cli.Flag { Name: "group-team-map-removal", Usage: "Activate automatic team membership removal depending on groups", }, + &cli.StringFlag{ + Name: "dyn-group-maps", + Value: "", + Usage: "Dynamic mappings between groups and org teams", + }, + &cli.BoolFlag{ + Name: "dyn-group-maps-removal", + Usage: "Activate automatic team membership removal of org teams not automatically added", + }, &cli.BoolFlag{ Name: "allow-username-change", Usage: "Allow users to change their username", @@ -196,6 +205,8 @@ func parseOAuth2Config(_ context.Context, c *cli.Command) *oauth2.Source { RestrictedGroup: c.String("restricted-group"), GroupTeamMap: c.String("group-team-map"), GroupTeamMapRemoval: c.Bool("group-team-map-removal"), + DynGroupMaps: c.String("dyn-group-maps"), + DynGroupMapsRemoval: c.Bool("dyn-group-maps-removal"), AllowUsernameChange: c.Bool("allow-username-change"), QuotaGroupClaimName: c.String("quota-group-claim-name"), QuotaGroupMap: c.String("quota-group-map"), @@ -300,6 +311,12 @@ func (a *authService) updateOauth(ctx context.Context, c *cli.Command) error { if c.IsSet("group-team-map-removal") { oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal") } + if c.IsSet("dyn-group-maps") { + oAuth2Config.DynGroupMaps = c.String("dyn-group-maps") + } + if c.IsSet("dyn-group-maps-removal") { + oAuth2Config.DynGroupMapsRemoval = c.Bool("dyn-group-maps-removal") + } if c.IsSet("quota-group-claim-name") { oAuth2Config.QuotaGroupClaimName = c.String("quota-group-claim-name") } diff --git a/cmd/admin_auth_oauth_test.go b/cmd/admin_auth_oauth_test.go index 44fc28047a..37b5390a9c 100644 --- a/cmd/admin_auth_oauth_test.go +++ b/cmd/admin_auth_oauth_test.go @@ -55,6 +55,8 @@ func TestAddOauth(t *testing.T) { "--restricted-group", "restricted", "--group-team-map", `{"org_a_team_1": {"organization-a": ["Team 1"]}, "org_a_all_teams": {"organization-a": ["Team 1", "Team 2", "Team 3"]}}`, "--group-team-map-removal", + "--dyn-group-maps", `["dyn-{org}-{team}", "other-{org}-{team}"]`, + "--dyn-group-maps-removal", "--allow-username-change", "--quota-group-claim-name", "quota_groups", "--quota-group-map", `{"oauth_group_1": ["quota_group_1"], "oauth_group_2": ["quota_group_2"]}`, @@ -85,6 +87,8 @@ func TestAddOauth(t *testing.T) { AdminGroup: "admin", GroupTeamMap: `{"org_a_team_1": {"organization-a": ["Team 1"]}, "org_a_all_teams": {"organization-a": ["Team 1", "Team 2", "Team 3"]}}`, GroupTeamMapRemoval: true, + DynGroupMaps: `["dyn-{org}-{team}", "other-{org}-{team}"]`, + DynGroupMapsRemoval: true, QuotaGroupClaimName: "quota_groups", QuotaGroupMap: `{"oauth_group_1": ["quota_group_1"], "oauth_group_2": ["quota_group_2"]}`, QuotaGroupMapRemoval: true, @@ -364,6 +368,8 @@ func TestUpdateOauth(t *testing.T) { "--restricted-group", "restricted", "--group-team-map", `{"org_a_team_1": {"organization-a": ["Team 1"]}, "org_a_all_teams": {"organization-a": ["Team 1", "Team 2", "Team 3"]}}`, "--group-team-map-removal", + "--dyn-group-maps", `["dyn-{org}-{team}", "other-{org}-{team}"]`, + "--dyn-group-maps-removal", }, id: 23, existingAuthSource: &auth.Source{ @@ -394,6 +400,8 @@ func TestUpdateOauth(t *testing.T) { AdminGroup: "admin", GroupTeamMap: `{"org_a_team_1": {"organization-a": ["Team 1"]}, "org_a_all_teams": {"organization-a": ["Team 1", "Team 2", "Team 3"]}}`, GroupTeamMapRemoval: true, + DynGroupMaps: `["dyn-{org}-{team}", "other-{org}-{team}"]`, + DynGroupMapsRemoval: true, RestrictedGroup: "restricted", // `--skip-local-2fa` is currently ignored. // SkipLocalTwoFA: true, @@ -838,6 +846,58 @@ func TestUpdateOauth(t *testing.T) { }, }, }, + // case 28 + { + args: []string{ + "oauth-test", + "--id", "1", + "--dyn-group-maps", `["dyn-{org}-{team}", "other-{org}-{team}"]`, + }, + authSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + CustomURLMapping: &oauth2.CustomURLMapping{}, + DynGroupMaps: `["dyn-{org}-{team}", "other-{org}-{team}"]`, + }, + }, + }, + // case 29 + { + args: []string{ + "oauth-test", + "--id", "1", + "--dyn-group-maps-removal", + }, + authSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + CustomURLMapping: &oauth2.CustomURLMapping{}, + DynGroupMapsRemoval: true, + }, + }, + }, + // case 30 + { + args: []string{ + "oauth-test", + "--id", "23", + "--dyn-group-maps-removal=false", + }, + id: 23, + existingAuthSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + DynGroupMapsRemoval: true, + }, + }, + authSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + CustomURLMapping: &oauth2.CustomURLMapping{}, + DynGroupMapsRemoval: false, + }, + }, + }, } for n, c := range cases { diff --git a/models/organization/team_list.go b/models/organization/team_list.go index 573fd4ef96..604ab68114 100644 --- a/models/organization/team_list.go +++ b/models/organization/team_list.go @@ -107,6 +107,14 @@ func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams TeamL Find(&teams) } +// GetUserTeams returns all teams that user belongs to. +func GetUserTeams(ctx context.Context, userID int64) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_user", "team_user.team_id = team.id"). + Where("team_user.uid=?", userID). + Find(&teams) +} + // GetUserOrgTeams returns all teams that user belongs to in given organization. func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams TeamList, err error) { return teams, db.GetEngine(ctx). diff --git a/modules/auth/common.go b/modules/auth/common.go index 5b74ad2bb7..5b9c3cde5d 100644 --- a/modules/auth/common.go +++ b/modules/auth/common.go @@ -22,6 +22,19 @@ func UnmarshalGroupTeamMapping(raw string) (map[string]map[string][]string, erro return groupTeamMapping, nil } +func UnmarshalDynGroupMappings(raw string) ([]string, error) { + var dynGroupMappings []string + if raw == "" { + return dynGroupMappings, nil + } + err := json.Unmarshal([]byte(raw), &dynGroupMappings) + if err != nil { + log.Error("Failed to unmarshal dynamic group mappings: %v", err) + return nil, err + } + return dynGroupMappings, nil +} + func UnmarshalQuotaGroupMapping(raw string) (map[string]container.Set[string], error) { quotaGroupMapping := make(map[string]container.Set[string]) if raw == "" { diff --git a/modules/validation/binding.go b/modules/validation/binding.go index 23d0622de4..53142d0528 100644 --- a/modules/validation/binding.go +++ b/modules/validation/binding.go @@ -27,6 +27,8 @@ const ( ErrUsername = "UsernameError" // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid ErrInvalidGroupTeamMap = "InvalidGroupTeamMap" + // ErrInvalidDynGroupMaps is returned when dynamic group team mappings are invalid + ErrInvalidDynGroupMaps = "InvalidDynGroupMaps" // ErrInvalidQuotaGroupMap is returned when a quota group mapping is invalid ErrInvalidQuotaGroupMap = "InvalidQuotaGroupMap" // ErrEmail is returned when an email address is invalid @@ -35,6 +37,7 @@ const ( // AddBindingRules adds additional binding rules func AddBindingRules() { + addValidDynGroupMapsRule() addGitRefNameBindingRule() addValidURLListBindingRule() addValidURLBindingRule() @@ -220,6 +223,23 @@ func addValidGroupTeamMapRule() { }) } +func addValidDynGroupMapsRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "ValidDynGroupMaps" + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + _, err := auth.UnmarshalDynGroupMappings(fmt.Sprintf("%v", val)) + if err != nil { + errs.Add([]string{name}, ErrInvalidDynGroupMaps, err.Error()) + return false, errs + } + + return true, errs + }, + }) +} + func addValidQuotaGroupMapRule() { binding.AddRule(&binding.Rule{ IsMatch: func(rule string) bool { diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 69fb541718..8cadf4914c 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -773,6 +773,8 @@ "pulls.manual_merge.commit.title": "Merge commit title", "pulls.manual_merge.commit.body": "Merge commit body", "pulls.manual_merge.copy.button": "Copy merge commit message", + "admin.auths.oauth2_dyn_group_maps": "Dynamically add users to teams based on dynamic group mappings. (Optional)", + "admin.auths.oauth2_dyn_group_maps_removal": "Dynamically remove users from all teams the user is not added to based on group mappings.", "admin.auths.oauth2_quota_group_claim_name": "Claim name providing group names for this source to be used for quota management. (Optional)", "admin.auths.oauth2_quota_group_map": "Map claimed groups to quota groups. (Optional - requires claim name above)", "admin.auths.oauth2_quota_group_map_removal": "Remove users from synchronized quota groups if user does not belong to corresponding group.", diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index 891df52843..5824bb1c12 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -190,6 +190,8 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { AdminGroup: form.Oauth2AdminGroup, GroupTeamMap: form.Oauth2GroupTeamMap, GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval, + DynGroupMaps: form.Oauth2DynGroupMaps, + DynGroupMapsRemoval: form.Oauth2DynGroupMapsRemoval, AllowUsernameChange: form.AllowUsernameChange, QuotaGroupClaimName: form.Oauth2QuotaGroupClaimName, QuotaGroupMap: form.Oauth2QuotaGroupMap, diff --git a/routers/web/auth/linkaccount.go b/routers/web/auth/linkaccount.go index e212ec58b2..5da0f8bb93 100644 --- a/routers/web/auth/linkaccount.go +++ b/routers/web/auth/linkaccount.go @@ -17,7 +17,6 @@ import ( "forgejo.org/modules/util" "forgejo.org/modules/web" auth_method "forgejo.org/services/auth/method" - "forgejo.org/services/auth/source/oauth2" "forgejo.org/services/context" "forgejo.org/services/externalaccount" "forgejo.org/services/forms" @@ -274,8 +273,7 @@ func LinkAccountPostRegister(ctx *context.Context) { return } - source := authSource.Cfg.(*oauth2.Source) - if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + if err := syncGroupsToTeams(ctx, authSource, &gothUser, u); err != nil { ctx.ServerError("SyncGroupsToTeams", err) return } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 9efdc1e561..63ca0a027c 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -1156,7 +1156,7 @@ func SignInOAuthCallback(ctx *context.Context) { return } - if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + if err := syncGroupsToTeams(ctx, authSource, &gothUser, u); err != nil { ctx.ServerError("SyncGroupsToTeams", err) return } @@ -1192,16 +1192,27 @@ func claimValueToStringSet(claimValue any) container.Set[string] { return container.SetOf(groups...) } -func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error { - if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { +func syncGroupsToTeams(ctx *context.Context, authSource *auth.Source, gothUser *goth.User, u *user_model.User) error { + source := authSource.Cfg.(*oauth2.Source) + if source.GroupTeamMap != "" || source.GroupTeamMapRemoval || + source.DynGroupMaps != "" || source.DynGroupMapsRemoval { groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) if err != nil { return err } + dynGroupMappings, err := auth_module.UnmarshalDynGroupMappings(source.DynGroupMaps) + if err != nil { + return err + } + dynGroupMaps := source_service.GetDynGroupMaps(authSource.ID, dynGroupMappings) + groups := getClaimedGroups(source, gothUser) - if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + if err := source_service.SyncGroupsToTeams(ctx, + u, groups, groupTeamMapping, source.GroupTeamMapRemoval, + dynGroupMaps, source.DynGroupMapsRemoval, + ); err != nil { return err } } @@ -1363,6 +1374,13 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model return } + dynGroupMappings, err := auth_module.UnmarshalDynGroupMappings(oauth2Source.DynGroupMaps) + if err != nil { + ctx.ServerError("UnmarshalDynGroupMappings", err) + return + } + dynGroupMaps := source_service.NewDynGroupMaps(dynGroupMappings) + groups := getClaimedGroups(oauth2Source, &gothUser) quotaGroups := getClaimedQuotaGroups(oauth2Source, &gothUser) @@ -1392,8 +1410,12 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model return } - if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { - if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval || + oauth2Source.DynGroupMaps != "" || oauth2Source.DynGroupMapsRemoval { + if err := source_service.SyncGroupsToTeams(ctx, + u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval, + dynGroupMaps, oauth2Source.DynGroupMapsRemoval, + ); err != nil { ctx.ServerError("SyncGroupsToTeams", err) return } @@ -1437,8 +1459,12 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } } - if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { - if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval || + oauth2Source.DynGroupMaps != "" || oauth2Source.DynGroupMapsRemoval { + if err := source_service.SyncGroupsToTeams(ctx, + u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval, + dynGroupMaps, oauth2Source.DynGroupMapsRemoval, + ); err != nil { ctx.ServerError("SyncGroupsToTeams", err) return } diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go index a2ff10cd07..8dab76ebf6 100644 --- a/services/auth/source/ldap/source_authenticate.go +++ b/services/auth/source/ldap/source_authenticate.go @@ -110,7 +110,10 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u if err != nil { return user, err } - if err := source_service.SyncGroupsToTeams(ctx, user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + if err := source_service.SyncGroupsToTeams(ctx, + user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval, + nil, false, + ); err != nil { return user, err } } diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go index cb6172ed1d..0028f0742a 100644 --- a/services/auth/source/ldap/source_sync.go +++ b/services/auth/source/ldap/source_sync.go @@ -190,7 +190,10 @@ 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) { - if err := source_service.SyncGroupsToTeamsCached(ctx, usr, su.Groups, groupTeamMapping, source.GroupTeamMapRemoval, orgCache, teamCache); err != nil { + if err := source_service.SyncGroupsToTeamsCached(ctx, + usr, su.Groups, groupTeamMapping, source.GroupTeamMapRemoval, + nil, false, 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 f437ce15ed..b335fcc1b1 100644 --- a/services/auth/source/oauth2/source.go +++ b/services/auth/source/oauth2/source.go @@ -27,6 +27,8 @@ type Source struct { AdminGroup string GroupTeamMap string GroupTeamMapRemoval bool + DynGroupMaps string + DynGroupMapsRemoval bool QuotaGroupClaimName string QuotaGroupMap string QuotaGroupMapRemoval bool diff --git a/services/auth/source/oauth2/source_register.go b/services/auth/source/oauth2/source_register.go index 82a36acaa6..7ca1ef5a07 100644 --- a/services/auth/source/oauth2/source_register.go +++ b/services/auth/source/oauth2/source_register.go @@ -5,16 +5,20 @@ package oauth2 import ( "fmt" + + source_service "forgejo.org/services/auth/source" ) // RegisterSource causes an OAuth2 configuration to be registered func (source *Source) RegisterSource() error { + source_service.RemoveDynGroupMaps(source.authSource.ID) err := RegisterProviderWithGothic(source.authSource.Name, source) return wrapOpenIDConnectInitializeError(err, source.authSource.Name, source) } // UnregisterSource causes an OAuth2 configuration to be unregistered func (source *Source) UnregisterSource() error { + source_service.RemoveDynGroupMaps(source.authSource.ID) RemoveProviderFromGothic(source.authSource.Name) return nil } diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go index 46be6937fb..edb0ee4880 100644 --- a/services/auth/source/source_group_sync.go +++ b/services/auth/source/source_group_sync.go @@ -6,6 +6,10 @@ package source import ( "context" "fmt" + "regexp" + "slices" + "strings" + "sync" "forgejo.org/models" "forgejo.org/models/organization" @@ -22,17 +26,41 @@ const ( ) // 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 { +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, performRemoval, orgCache, teamCache) + + 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, performRemoval bool, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error { - membershipsToAdd, membershipsToRemove := resolveMappedMemberships(sourceUserGroups, sourceGroupTeamMapping) +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 performRemoval { + 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) } @@ -45,9 +73,167 @@ func SyncGroupsToTeamsCached(ctx context.Context, user *user_model.User, sourceU return nil } -func resolveMappedMemberships(sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string) (map[string][]string, map[string][]string) { +// 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}", `(?[\w-.]+)`, 1) + s = strings.Replace(s, "{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 { @@ -60,6 +246,26 @@ func resolveMappedMemberships(sourceUserGroups container.Set[string], sourceGrou } } } + + // 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 } diff --git a/services/auth/source/source_group_sync_test.go b/services/auth/source/source_group_sync_test.go new file mode 100644 index 0000000000..8ba10c3152 --- /dev/null +++ b/services/auth/source/source_group_sync_test.go @@ -0,0 +1,402 @@ +package source + +import ( + "testing" + + "forgejo.org/models/db" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/modules/container" + "forgejo.org/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNewDynGroupMaps tests NewDynGroupMaps, case insensitive. +func TestNewDynGroupMapsCaseInsensitive(t *testing.T) { + want := NewDynGroupMaps([]string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }) + got := NewDynGroupMaps([]string{ + "dyn-{org}-{team}", + "DYN-{ORG}-{TEAM}", + "DyN-{OrG}-{TeAm}", + "dYn-{oRg}-{tEaM}", + "other:{org}/{team}", + "OTHER:{ORG}/{TEAM}", + "OtHeR:{OrG}/{TeAm}", + "oThEr:{oRg}/{tEaM}", + }) + assert.Equal(t, want, got) +} + +// TestGetDynGroupMaps tests GetDynGroupMaps. +func TestGetDynGroupMaps(t *testing.T) { + defer test.MockProtect(&sourceDynGroupMaps.d)() + + // same source + want := GetDynGroupMaps(0, []string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }) + got := GetDynGroupMaps(0, []string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }) + assert.Same(t, want, got) + + // different sources + got = GetDynGroupMaps(1, []string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }) + assert.NotSame(t, want, got) +} + +// TestRemoveDynGroupMaps tests RemoveDynGroupMaps. +func TestRemoveDynGroupMaps(t *testing.T) { + defer test.MockProtect(&sourceDynGroupMaps.d)() + + // empty + assert.Nil(t, sourceDynGroupMaps.d[0]) + RemoveDynGroupMaps(0) + assert.Nil(t, sourceDynGroupMaps.d[0]) + + // with entry + GetDynGroupMaps(0, []string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }) + assert.NotNil(t, sourceDynGroupMaps.d[0]) + RemoveDynGroupMaps(0) + assert.Nil(t, sourceDynGroupMaps.d[0]) +} + +// TestResolveMappedMemberships tests resolveMappedMemberships. +func TestResolveMappedMemberships(t *testing.T) { + type test struct { + name string + srcGroups container.Set[string] + mappings map[string]map[string][]string + dynMappings *DynGroupMaps + dynRemoval bool + wantAdd map[string][]string + wantRemove map[string][]string + } + + // get from test db: + // test user with id 2 with memberships: + // - "org3": {"owners", "team1", "teamcreaterepo"}, + // - "org17": {"test_team"}, + require.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + ctx := db.DefaultContext + for _, test := range []test{ + // static, no match + { + name: "static, no match", + srcGroups: container.SetOf("does-not-matter"), + mappings: map[string]map[string][]string{ + "test-static": {"static-org": {"static-team"}}, + }, + dynMappings: nil, + dynRemoval: false, + wantAdd: map[string][]string{}, + wantRemove: map[string][]string{ + "static-org": {"static-team"}, + }, + }, + // static, match + { + name: "static, match", + srcGroups: container.SetOf("test-static"), + mappings: map[string]map[string][]string{ + "test-static": {"static-org": {"static-team"}}, + }, + dynMappings: nil, + dynRemoval: false, + wantAdd: map[string][]string{ + "static-org": {"static-team"}, + }, + wantRemove: map[string][]string{}, + }, + // static, multiple matches + { + name: "static, multiple matches", + srcGroups: container.SetOf( + "test-static", + "static2", + "other3", + ), + mappings: map[string]map[string][]string{ + "test-static": {"static1-org": {"static1-team"}}, + "static2": {"static2-org": {"static2-team"}}, + "other3": {"static3-org": {"static3-team"}}, + }, + dynMappings: nil, + dynRemoval: false, + wantAdd: map[string][]string{ + "static1-org": {"static1-team"}, + "static2-org": {"static2-team"}, + "static3-org": {"static3-team"}, + }, + wantRemove: map[string][]string{}, + }, + // static, some matches + { + name: "static, some matches", + srcGroups: container.SetOf( + "does-not-matter", + "test-static", + "other3", + "does-not-exists", + ), + mappings: map[string]map[string][]string{ + "test-static": {"static1-org": {"static1-team"}}, + "static2": {"static2-org": {"static2-team"}}, + "other3": {"static3-org": {"static3-team"}}, + }, + dynMappings: nil, + dynRemoval: false, + wantAdd: map[string][]string{ + "static1-org": {"static1-team"}, + "static3-org": {"static3-team"}, + }, + wantRemove: map[string][]string{ + "static2-org": {"static2-team"}, + }, + }, + // dynamic, no match + { + name: "dynamic, no match", + srcGroups: container.SetOf("test-notmatching"), + mappings: map[string]map[string][]string{}, + dynMappings: NewDynGroupMaps([]string{"dyn-{org}-{team}"}), + dynRemoval: false, + wantAdd: map[string][]string{}, + wantRemove: map[string][]string{}, + }, + // dynamic, match + { + name: "dynamic, match", + srcGroups: container.SetOf("dyn-dynorg-dynteam"), + mappings: map[string]map[string][]string{}, + dynMappings: NewDynGroupMaps([]string{"dyn-{org}-{team}"}), + dynRemoval: false, + wantAdd: map[string][]string{ + "dynorg": {"dynteam"}, + }, + wantRemove: map[string][]string{}, + }, + // dynamic, multiple matches + { + name: "dynamic, multiple matches", + srcGroups: container.SetOf( + "dyn-dynorg1-dynteam1", + "dyn-dynorg2-dynteam2", + "other:dynorg3/dynteam3", + "other:dynorg4/dynteam4", + ), + mappings: map[string]map[string][]string{}, + dynMappings: NewDynGroupMaps([]string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }), + dynRemoval: false, + wantAdd: map[string][]string{ + "dynorg1": {"dynteam1"}, + "dynorg2": {"dynteam2"}, + "dynorg3": {"dynteam3"}, + "dynorg4": {"dynteam4"}, + }, + wantRemove: map[string][]string{}, + }, + // dynamic, case insensitive matches + { + name: "dynamic, case insensitive matches", + srcGroups: container.SetOf( + "dyn-dynorg1-dynteam1", + "DYN-DYNORG1-DYNTEAM1", + "DyN-DyNoRg1-DyNtEaM1", + "dYn-dYnOrG1-dYnTeAm1", + "other:dynorg2/dynteam2", + "OTHER:DYNORG2/DYNTEAM2", + "OtHeR:DyNoRg2/DyNtEaM2", + "oThEr:dYnOrG2/dYnTeAm2", + ), + mappings: map[string]map[string][]string{}, + dynMappings: NewDynGroupMaps([]string{ + "dyn-{org}-{team}", + "OTHER:{ORG}/{TEAM}", + }), + dynRemoval: false, + wantAdd: map[string][]string{ + "dynorg1": {"dynteam1"}, + "dynorg2": {"dynteam2"}, + }, + wantRemove: map[string][]string{}, + }, + // dynamic, other char matches + { + name: "dynamic, other chars matches", + srcGroups: container.SetOf( + "dyn-dyn_org1-dyn_team1", + "dyn-dyn.org1-dyn.team1", + "dyn-dyn!org1-dyn!team1", // invalid char + "other:dyn_org2/dyn_team2", + "other:dyn-org2/dyn-team2", + "other:dyn.org2/dyn.team2", + "other:dyn!org2/dyn!team2", // invalid char + ), + mappings: map[string]map[string][]string{}, + dynMappings: NewDynGroupMaps([]string{ + "dyn-{org}-{team}", + "OTHER:{ORG}/{TEAM}", + }), + dynRemoval: false, + wantAdd: map[string][]string{ + "dyn_org1": {"dyn_team1"}, + "dyn.org1": {"dyn.team1"}, + "dyn_org2": {"dyn_team2"}, + "dyn-org2": {"dyn-team2"}, + "dyn.org2": {"dyn.team2"}, + }, + wantRemove: map[string][]string{}, + }, + // dynamic, some matches + { + name: "dynamic, some matches", + srcGroups: container.SetOf( + "test-notmatching", + "dyn-dynorg1-dynteam1", + "dyn-dynorg2-dynteam2", + "does-not-matter", + ), + mappings: map[string]map[string][]string{}, + dynMappings: NewDynGroupMaps([]string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }), + dynRemoval: false, + wantAdd: map[string][]string{ + "dynorg1": {"dynteam1"}, + "dynorg2": {"dynteam2"}, + }, + wantRemove: map[string][]string{}, + }, + // mixed, no match + { + name: "mixed, no match", + srcGroups: container.SetOf("does-not-matter"), + mappings: map[string]map[string][]string{ + "test-static": {"static-org": {"static-team"}}, + }, + dynMappings: NewDynGroupMaps([]string{"dyn-{org}-{team}"}), + dynRemoval: false, + wantAdd: map[string][]string{}, + wantRemove: map[string][]string{ + "static-org": {"static-team"}, + }, + }, + // mixed, some matches + { + name: "mixed, some matches", + srcGroups: container.SetOf( + "does-not-matter", + "test-static", + "dyn-dynorg1-dynteam1", + "other3", + "does-not-exists", + "dyn-dynorg2-dynteam2", + ), + mappings: map[string]map[string][]string{ + "test-static": {"static1-org": {"static1-team"}}, + "static2": {"static2-org": {"static2-team"}}, + "other3": {"static3-org": {"static3-team"}}, + }, + dynMappings: NewDynGroupMaps([]string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }), + dynRemoval: false, + wantAdd: map[string][]string{ + "dynorg1": {"dynteam1"}, + "dynorg2": {"dynteam2"}, + "static1-org": {"static1-team"}, + "static3-org": {"static3-team"}, + }, + wantRemove: map[string][]string{ + "static2-org": {"static2-team"}, + }, + }, + // dynamic, some matches, dynamic remove + { + name: "dynamic, some matches, dynamic remove", + srcGroups: container.SetOf( + "test-notmatching", + "dyn-dynorg1-dynteam1", + "dyn-dynorg2-dynteam2", + "does-not-matter", + ), + mappings: map[string]map[string][]string{}, + dynMappings: NewDynGroupMaps([]string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }), + dynRemoval: true, + wantAdd: map[string][]string{ + "dynorg1": {"dynteam1"}, + "dynorg2": {"dynteam2"}, + }, + wantRemove: map[string][]string{ + "org17": {"test_team"}, + "org3": {"owners", "team1", "teamcreaterepo"}, + }, + }, + // mixed, some matches, dynamic remove + { + name: "mixed, some matches, dynamic remove", + srcGroups: container.SetOf( + "does-not-matter", + "test-static", + "dyn-dynorg1-dynteam1", + "other3", + "does-not-exists", + "dyn-dynorg2-dynteam2", + ), + mappings: map[string]map[string][]string{ + "test-static": {"static1-org": {"static1-team"}}, + "static2": {"static2-org": {"static2-team"}}, + "other3": {"static3-org": {"static3-team"}}, + }, + dynMappings: NewDynGroupMaps([]string{ + "dyn-{org}-{team}", + "other:{org}/{team}", + }), + dynRemoval: true, + wantAdd: map[string][]string{ + "dynorg1": {"dynteam1"}, + "dynorg2": {"dynteam2"}, + "static1-org": {"static1-team"}, + "static3-org": {"static3-team"}, + }, + wantRemove: map[string][]string{ + // "static2-org": {"static2-team"} only if user added to it previously + "org17": {"test_team"}, + "org3": {"owners", "team1", "teamcreaterepo"}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + gotAdd, gotRemove := resolveMappedMemberships(ctx, user, + test.srcGroups, test.mappings, + test.dynMappings, test.dynRemoval) + + assert.Equal(t, test.wantAdd, gotAdd) + assert.Equal(t, test.wantRemove, gotRemove) + }) + } +} diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index 9d60c1561f..75f6c07e45 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -75,6 +75,8 @@ type AuthenticationForm struct { Oauth2RestrictedGroup string Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"` Oauth2GroupTeamMapRemoval bool + Oauth2DynGroupMaps string `binding:"ValidDynGroupMaps"` + Oauth2DynGroupMapsRemoval bool Oauth2QuotaGroupClaimName string Oauth2QuotaGroupMap string `binding:"ValidQuotaGroupMap"` Oauth2QuotaGroupMapRemoval bool diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index 0d4a09899f..d128340bc9 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -384,6 +384,14 @@ +
+ + +
+
+ + +
diff --git a/templates/admin/auth/source/oauth.tmpl b/templates/admin/auth/source/oauth.tmpl index df6d25d8b3..00024f97c4 100644 --- a/templates/admin/auth/source/oauth.tmpl +++ b/templates/admin/auth/source/oauth.tmpl @@ -121,6 +121,14 @@
+
+ + +
+
+ + +