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 @@
+
+ + +
+
+ + +