diff --git a/cmd/admin_auth_oauth.go b/cmd/admin_auth_oauth.go index 0fd8650f37..f666023943 100644 --- a/cmd/admin_auth_oauth.go +++ b/cmd/admin_auth_oauth.go @@ -129,6 +129,20 @@ func oauthCLIFlags() []cli.Flag { Name: "allow-username-change", Usage: "Allow users to change their username", }, + &cli.StringFlag{ + Name: "quota-group-claim-name", + Value: "", + Usage: "Claim name providing quota group names for this source", + }, + &cli.StringFlag{ + Name: "quota-group-map", + Value: "", + Usage: "JSON mapping between groups and quota groups", + }, + &cli.BoolFlag{ + Name: "quota-group-map-removal", + Usage: "Activate automatic quota group removal depending on groups", + }, } } @@ -183,6 +197,9 @@ func parseOAuth2Config(_ context.Context, c *cli.Command) *oauth2.Source { GroupTeamMap: c.String("group-team-map"), GroupTeamMapRemoval: c.Bool("group-team-map-removal"), AllowUsernameChange: c.Bool("allow-username-change"), + QuotaGroupClaimName: c.String("quota-group-claim-name"), + QuotaGroupMap: c.String("quota-group-map"), + QuotaGroupMapRemoval: c.Bool("quota-group-map-removal"), } } @@ -283,6 +300,15 @@ 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("quota-group-claim-name") { + oAuth2Config.QuotaGroupClaimName = c.String("quota-group-claim-name") + } + if c.IsSet("quota-group-map") { + oAuth2Config.QuotaGroupMap = c.String("quota-group-map") + } + if c.IsSet("quota-group-map-removal") { + oAuth2Config.QuotaGroupMapRemoval = c.Bool("quota-group-map-removal") + } if c.IsSet("allow-username-change") { oAuth2Config.AllowUsernameChange = c.Bool("allow-username-change") diff --git a/cmd/admin_auth_oauth_test.go b/cmd/admin_auth_oauth_test.go index 3430ad1f56..44fc28047a 100644 --- a/cmd/admin_auth_oauth_test.go +++ b/cmd/admin_auth_oauth_test.go @@ -56,6 +56,9 @@ func TestAddOauth(t *testing.T) { "--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", "--allow-username-change", + "--quota-group-claim-name", "quota_groups", + "--quota-group-map", `{"oauth_group_1": ["quota_group_1"], "oauth_group_2": ["quota_group_2"]}`, + "--quota-group-map-removal", }, source: &auth.Source{ Type: auth.OAuth2, @@ -82,6 +85,9 @@ 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, + QuotaGroupClaimName: "quota_groups", + QuotaGroupMap: `{"oauth_group_1": ["quota_group_1"], "oauth_group_2": ["quota_group_2"]}`, + QuotaGroupMapRemoval: true, RestrictedGroup: "restricted", SkipLocalTwoFA: true, AllowUsernameChange: true, @@ -189,6 +195,94 @@ func TestAddOauth(t *testing.T) { }, errMsg: "invalid Auto Discovery URL: example.com (this must be a valid URL starting with http:// or https://)", }, + // case 7 + { + args: []string{ + "oauth-test", + "--name", "oauth2 source with quota group claim name", + "--provider", "openidConnect", + "--auto-discover-url", "https://example.com/.well-known/openid-configuration", + "--quota-group-claim-name", "quota_groups", + }, + source: &auth.Source{ + Type: auth.OAuth2, + Name: "oauth2 source with quota group claim name", + IsActive: true, + Cfg: &oauth2.Source{ + Provider: "openidConnect", + OpenIDConnectAutoDiscoveryURL: "https://example.com/.well-known/openid-configuration", + Scopes: []string{}, + QuotaGroupClaimName: "quota_groups", + }, + }, + }, + // case 8 + { + args: []string{ + "oauth-test", + "--name", "oauth2 source with quota group map", + "--provider", "openidConnect", + "--auto-discover-url", "https://example.com/.well-known/openid-configuration", + "--quota-group-map", `{"oauth_group_1": ["quota_group_1"], "oauth_group_2": ["quota_group_2"]}`, + }, + source: &auth.Source{ + Type: auth.OAuth2, + Name: "oauth2 source with quota group map", + IsActive: true, + Cfg: &oauth2.Source{ + Provider: "openidConnect", + OpenIDConnectAutoDiscoveryURL: "https://example.com/.well-known/openid-configuration", + Scopes: []string{}, + QuotaGroupMap: `{"oauth_group_1": ["quota_group_1"], "oauth_group_2": ["quota_group_2"]}`, + }, + }, + }, + // case 9 + { + args: []string{ + "oauth-test", + "--name", "oauth2 source with quota group map removal", + "--provider", "openidConnect", + "--auto-discover-url", "https://example.com/.well-known/openid-configuration", + "--quota-group-map-removal", + }, + source: &auth.Source{ + Type: auth.OAuth2, + Name: "oauth2 source with quota group map removal", + IsActive: true, + Cfg: &oauth2.Source{ + Provider: "openidConnect", + OpenIDConnectAutoDiscoveryURL: "https://example.com/.well-known/openid-configuration", + Scopes: []string{}, + QuotaGroupMapRemoval: true, + }, + }, + }, + // case 10 + { + args: []string{ + "oauth-test", + "--name", "oauth2 source with all quota group fields", + "--provider", "openidConnect", + "--auto-discover-url", "https://example.com/.well-known/openid-configuration", + "--quota-group-claim-name", "quota_groups", + "--quota-group-map", `{"developers": ["dev_quota"], "admins": ["admin_quota"]}`, + "--quota-group-map-removal", + }, + source: &auth.Source{ + Type: auth.OAuth2, + Name: "oauth2 source with all quota group fields", + IsActive: true, + Cfg: &oauth2.Source{ + Provider: "openidConnect", + OpenIDConnectAutoDiscoveryURL: "https://example.com/.well-known/openid-configuration", + Scopes: []string{}, + QuotaGroupClaimName: "quota_groups", + QuotaGroupMap: `{"developers": ["dev_quota"], "admins": ["admin_quota"]}`, + QuotaGroupMapRemoval: true, + }, + }, + }, } for n, c := range cases { @@ -658,6 +752,92 @@ func TestUpdateOauth(t *testing.T) { }, errMsg: "--id flag is missing", }, + // case 23 + { + args: []string{ + "oauth-test", + "--id", "1", + "--quota-group-claim-name", "quota_groups", + }, + authSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + CustomURLMapping: &oauth2.CustomURLMapping{}, + QuotaGroupClaimName: "quota_groups", + }, + }, + }, + // case 24 + { + args: []string{ + "oauth-test", + "--id", "1", + "--quota-group-map", `{"oauth_group_1": ["quota_group_1"], "oauth_group_2": ["quota_group_2"]}`, + }, + authSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + CustomURLMapping: &oauth2.CustomURLMapping{}, + QuotaGroupMap: `{"oauth_group_1": ["quota_group_1"], "oauth_group_2": ["quota_group_2"]}`, + }, + }, + }, + // case 25 + { + args: []string{ + "oauth-test", + "--id", "1", + "--quota-group-map-removal", + }, + authSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + CustomURLMapping: &oauth2.CustomURLMapping{}, + QuotaGroupMapRemoval: true, + }, + }, + }, + // case 26 + { + args: []string{ + "oauth-test", + "--id", "24", + "--quota-group-map-removal=false", + }, + id: 24, + existingAuthSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + QuotaGroupMapRemoval: true, + }, + }, + authSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + CustomURLMapping: &oauth2.CustomURLMapping{}, + QuotaGroupMapRemoval: false, + }, + }, + }, + // case 27 + { + args: []string{ + "oauth-test", + "--id", "1", + "--quota-group-claim-name", "quota_groups", + "--quota-group-map", `{"developers": ["dev_quota"], "admins": ["admin_quota"]}`, + "--quota-group-map-removal", + }, + authSource: &auth.Source{ + Type: auth.OAuth2, + Cfg: &oauth2.Source{ + CustomURLMapping: &oauth2.CustomURLMapping{}, + QuotaGroupClaimName: "quota_groups", + QuotaGroupMap: `{"developers": ["dev_quota"], "admins": ["admin_quota"]}`, + QuotaGroupMapRemoval: true, + }, + }, + }, } for n, c := range cases { diff --git a/models/quota/group.go b/models/quota/group.go index a4ec8d0e14..6207f4cfb2 100644 --- a/models/quota/group.go +++ b/models/quota/group.go @@ -61,7 +61,7 @@ func (g *Group) LoadRules(ctx context.Context) error { Find(&g.Rules) } -func (g *Group) isUserInGroup(ctx context.Context, userID int64) (bool, error) { +func (g *Group) IsUserInGroup(ctx context.Context, userID int64) (bool, error) { return db.GetEngine(ctx). Where("kind = ? AND mapped_id = ? AND group_name = ?", KindUser, userID, g.Name). Get(&GroupMapping{}) @@ -74,7 +74,7 @@ func (g *Group) AddUserByID(ctx context.Context, userID int64) error { } defer committer.Close() - exists, err := g.isUserInGroup(ctx, userID) + exists, err := g.IsUserInGroup(ctx, userID) if err != nil { return err } else if exists { @@ -99,7 +99,7 @@ func (g *Group) RemoveUserByID(ctx context.Context, userID int64) error { } defer committer.Close() - exists, err := g.isUserInGroup(ctx, userID) + exists, err := g.IsUserInGroup(ctx, userID) if err != nil { return err } else if !exists { diff --git a/modules/auth/common.go b/modules/auth/common.go index 0f36fd942f..5b74ad2bb7 100644 --- a/modules/auth/common.go +++ b/modules/auth/common.go @@ -4,6 +4,7 @@ package auth import ( + "forgejo.org/modules/container" "forgejo.org/modules/json" "forgejo.org/modules/log" ) @@ -20,3 +21,25 @@ func UnmarshalGroupTeamMapping(raw string) (map[string]map[string][]string, erro } return groupTeamMapping, nil } + +func UnmarshalQuotaGroupMapping(raw string) (map[string]container.Set[string], error) { + quotaGroupMapping := make(map[string]container.Set[string]) + if raw == "" { + return quotaGroupMapping, nil + } + + rawMapping := make(map[string][]string) + err := json.Unmarshal([]byte(raw), &rawMapping) + if err != nil { + log.Error("Failed to unmarshal group quota group mapping: %v", err) + return nil, err + } + + for key, values := range rawMapping { + set := make(container.Set[string]) + set.AddMultiple(values...) + quotaGroupMapping[key] = set + } + + return quotaGroupMapping, nil +} diff --git a/modules/validation/binding.go b/modules/validation/binding.go index f4f82278bd..463e7e8f7a 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" + // ErrInvalidQuotaGroupMap is returned when a quota group mapping is invalid + ErrInvalidQuotaGroupMap = "InvalidQuotaGroupMap" // ErrEmail is returned when an email address is invalid ErrEmail = "Email" ) @@ -42,6 +44,7 @@ func AddBindingRules() { addGlobOrRegexPatternRule() addUsernamePatternRule() addValidGroupTeamMapRule() + addValidQuotaGroupMapRule() addEmailBindingRules() } @@ -217,6 +220,23 @@ func addValidGroupTeamMapRule() { }) } +func addValidQuotaGroupMapRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "ValidQuotaGroupMap" + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + _, err := auth.UnmarshalQuotaGroupMapping(fmt.Sprintf("%v", val)) + if err != nil { + errs.Add([]string{name}, ErrInvalidQuotaGroupMap, err.Error()) + return false, errs + } + + return true, errs + }, + }) +} + func addEmailBindingRules() { 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 806290d3b3..de1110436e 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -210,5 +210,8 @@ }, "teams.add_all_repos.modal.header": "Add all repositories", "teams.remove_all_repos.modal.header": "Remove all repositories", + "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.", "meta.last_line": "Thank you for translating Forgejo! This line isn't seen by the users but it serves other purposes in the translation management. You can place a fun fact in the translation instead of translating it." } diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index c352b6ad1a..27a241f508 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -191,6 +191,9 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { GroupTeamMap: form.Oauth2GroupTeamMap, GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval, AllowUsernameChange: form.AllowUsernameChange, + QuotaGroupClaimName: form.Oauth2QuotaGroupClaimName, + QuotaGroupMap: form.Oauth2QuotaGroupMap, + QuotaGroupMapRemoval: form.Oauth2QuotaGroupMapRemoval, } } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 7fbc8b8d55..f44a102a49 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -1096,6 +1096,11 @@ func SignInOAuthCallback(ctx *context.Context) { ctx.ServerError("SyncGroupsToTeams", err) return } + + if err := syncGroupsToQuotaGroups(ctx, source, &gothUser, u); err != nil { + ctx.ServerError("SyncGroupsToQuotaGroups", err) + return + } } else { // no existing user is found, request attach or new account showLinkingLogin(ctx, gothUser) @@ -1140,6 +1145,23 @@ func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *go return nil } +func syncGroupsToQuotaGroups(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error { + if source.QuotaGroupMap != "" || source.QuotaGroupMapRemoval { + quotaGroupMapping, err := auth_module.UnmarshalQuotaGroupMapping(source.QuotaGroupMap) + if err != nil { + return err + } + + groups := getClaimedQuotaGroups(source, gothUser) + + if err := source_service.SyncGroupsToQuotaGroups(ctx, u, groups, quotaGroupMapping, source.QuotaGroupMapRemoval); err != nil { + return err + } + } + + return nil +} + func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] { groupClaims, has := gothUser.RawData[source.GroupClaimName] if !has { @@ -1149,6 +1171,15 @@ func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[ return claimValueToStringSet(groupClaims) } +func getClaimedQuotaGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] { + groupClaims, has := gothUser.RawData[source.QuotaGroupClaimName] + if !has { + return nil + } + + return claimValueToStringSet(groupClaims) +} + func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) { groups := getClaimedGroups(source, gothUser) @@ -1262,8 +1293,14 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model ctx.ServerError("UnmarshalGroupTeamMapping", err) return } + quotaGroupMapping, err := auth_module.UnmarshalQuotaGroupMapping(oauth2Source.QuotaGroupMap) + if err != nil { + ctx.ServerError("UnmarshalQuotaGroupMapping", err) + return + } groups := getClaimedGroups(oauth2Source, &gothUser) + quotaGroups := getClaimedQuotaGroups(oauth2Source, &gothUser) // If this user is enrolled in 2FA and this source doesn't override it, // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. @@ -1291,6 +1328,13 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } } + if oauth2Source.QuotaGroupMap != "" || oauth2Source.QuotaGroupMapRemoval { + if err := source_service.SyncGroupsToQuotaGroups(ctx, u, quotaGroups, quotaGroupMapping, oauth2Source.QuotaGroupMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToQuotaGroups", err) + return + } + } + // update external user information if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { if !errors.Is(err, util.ErrNotExist) { @@ -1329,6 +1373,13 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } } + if oauth2Source.QuotaGroupMap != "" || oauth2Source.QuotaGroupMapRemoval { + if err := source_service.SyncGroupsToQuotaGroups(ctx, u, quotaGroups, quotaGroupMapping, oauth2Source.QuotaGroupMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToQuotaGroups", err) + return + } + } + if err := updateSession(ctx, nil, map[string]any{ // User needs to use 2FA, save data and redirect to 2FA page. "twofaUid": u.ID, diff --git a/services/auth/source/main_test.go b/services/auth/source/main_test.go new file mode 100644 index 0000000000..4f3205b939 --- /dev/null +++ b/services/auth/source/main_test.go @@ -0,0 +1,21 @@ +// Copyright 2025 The Forgejo Contributors. All rights reserved. +// SPDX-License-Identifier: MIT + +package source + +import ( + "testing" + + "forgejo.org/models/unittest" + "forgejo.org/modules/setting" + "forgejo.org/services/webhook" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + SetUp: func() error { + setting.LoadQueueSettings() + return webhook.Init() + }, + }) +} diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go index a3126cf353..f437ce15ed 100644 --- a/services/auth/source/oauth2/source.go +++ b/services/auth/source/oauth2/source.go @@ -27,6 +27,9 @@ type Source struct { AdminGroup string GroupTeamMap string GroupTeamMapRemoval bool + QuotaGroupClaimName string + QuotaGroupMap string + QuotaGroupMapRemoval bool RestrictedGroup string SkipLocalTwoFA bool `json:",omitempty"` AllowUsernameChange bool diff --git a/services/auth/source/source_quota_group_sync.go b/services/auth/source/source_quota_group_sync.go new file mode 100644 index 0000000000..98e16aca74 --- /dev/null +++ b/services/auth/source/source_quota_group_sync.go @@ -0,0 +1,77 @@ +// Copyright 2025 The Forgejo Contributors. All rights reserved. +// SPDX-License-Identifier: MIT + +package source + +import ( + "context" + + "forgejo.org/models/quota" + user_model "forgejo.org/models/user" + "forgejo.org/modules/container" + "forgejo.org/modules/log" +) + +func SyncGroupsToQuotaGroups(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupQuotaGroupMapping map[string]container.Set[string], performRemoval bool) error { + qgroupCache := make(map[string]*quota.Group) + qgroupsToAdd, qgroupsToRemove := resolveMappedQuotaGroups(sourceUserGroups, sourceGroupQuotaGroupMapping) + if performRemoval { + if err := syncGroupsToQuotaGroupsCached(ctx, user, qgroupsToRemove, syncRemove, qgroupCache); err != nil { + return err + } + } + return syncGroupsToQuotaGroupsCached(ctx, user, qgroupsToAdd, syncAdd, qgroupCache) +} + +func resolveMappedQuotaGroups(sourceUserGroups container.Set[string], sourceGroupQuotaGroupMapping map[string]container.Set[string]) (container.Set[string], container.Set[string]) { + qgroupsToAdd := make(container.Set[string]) + qgroupsToRemove := make(container.Set[string]) + for group, qgroups := range sourceGroupQuotaGroupMapping { + isUserInGroup := sourceUserGroups.Contains(group) + if isUserInGroup { + for qgroup := range qgroups { + qgroupsToAdd[qgroup] = struct{}{} + } + } else { + for qgroup := range qgroups { + qgroupsToRemove[qgroup] = struct{}{} + } + } + } + return qgroupsToAdd, qgroupsToRemove +} + +func syncGroupsToQuotaGroupsCached(ctx context.Context, user *user_model.User, qgroups container.Set[string], action syncType, qgroupCache map[string]*quota.Group) error { + for qgroupName := range qgroups { + var err error + qgroup, ok := qgroupCache[qgroupName] + if !ok { + qgroup, err = quota.GetGroupByName(ctx, qgroupName) + if err != nil { + return err + } + if qgroup == nil { + log.Warn("quota group sync: Could not find quota group %s: %v", qgroupName, err) + continue + } + qgroupCache[qgroup.Name] = qgroup + } + isMember, err := qgroup.IsUserInGroup(ctx, user.ID) + if err != nil { + return err + } + + if action == syncAdd && !isMember { + if err := qgroup.AddUserByID(ctx, user.ID); err != nil { + log.Error("quota group sync: Could not add user to quota group: %v", err) + return err + } + } else if action == syncRemove && isMember { + if err := qgroup.RemoveUserByID(ctx, user.ID); err != nil { + log.Error("quota group sync: Could not remove user from quota group: %v", err) + return err + } + } + } + return nil +} diff --git a/services/auth/source/source_quota_group_sync_test.go b/services/auth/source/source_quota_group_sync_test.go new file mode 100644 index 0000000000..06da0b6267 --- /dev/null +++ b/services/auth/source/source_quota_group_sync_test.go @@ -0,0 +1,148 @@ +// Copyright 2025 The Forgejo Contributors. All rights reserved. +// SPDX-License-Identifier: MIT + +package source + +import ( + "testing" + + "forgejo.org/models/db" + quota_model "forgejo.org/models/quota" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/modules/container" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSyncGroupsToQuotaGroupsCached(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + ctx := db.DefaultContext + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + tests := []struct { + name string + qgroups container.Set[string] + action syncType + setupGroups []string + setupMembers map[string]bool + expectedAdds []string + expectedRemoves []string + expectedError bool + }{ + { + name: "syncAdd - user not in group should be added", + qgroups: container.SetOf("test-group-1"), + action: syncAdd, + setupGroups: []string{"test-group-1"}, + setupMembers: map[string]bool{ + "test-group-1": false, + }, + expectedAdds: []string{"test-group-1"}, + }, + { + name: "syncAdd - user already in group should not be added again", + qgroups: container.SetOf("test-group-2"), + action: syncAdd, + setupGroups: []string{"test-group-2"}, + setupMembers: map[string]bool{ + "test-group-2": true, + }, + }, + { + name: "syncRemove - user in group should be removed", + qgroups: container.SetOf("test-group-3"), + action: syncRemove, + setupGroups: []string{"test-group-3"}, + setupMembers: map[string]bool{ + "test-group-3": true, + }, + expectedRemoves: []string{"test-group-3"}, + }, + { + name: "syncRemove - user not in group should not cause error", + qgroups: container.SetOf("test-group-4"), + action: syncRemove, + setupGroups: []string{"test-group-4"}, + setupMembers: map[string]bool{ + "test-group-4": false, + }, + }, + { + name: "multiple groups - mixed operations", + qgroups: container.SetOf("test-group-5", "test-group-6"), + action: syncAdd, + setupGroups: []string{"test-group-5", "test-group-6"}, + setupMembers: map[string]bool{ + "test-group-5": false, + "test-group-6": true, + }, + expectedAdds: []string{"test-group-5"}, + }, + { + name: "nonexistent group should log warning and continue", + qgroups: container.SetOf("nonexistent-group"), + action: syncAdd, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + qgroupCache := make(map[string]*quota_model.Group) + + for _, groupName := range tt.setupGroups { + group := "a_model.Group{Name: groupName} + _, err := db.GetEngine(ctx).Insert(group) + require.NoError(t, err, "Failed to setup test group") + + if isMember, exists := tt.setupMembers[groupName]; exists && isMember { + err = group.AddUserByID(ctx, user.ID) + require.NoError(t, err, "Failed to setup initial membership") + } + } + + err := syncGroupsToQuotaGroupsCached(ctx, user, tt.qgroups, tt.action, qgroupCache) + + if tt.expectedError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + for _, groupName := range tt.expectedAdds { + group := qgroupCache[groupName] + if group == nil { + group, err = quota_model.GetGroupByName(ctx, groupName) + require.NoError(t, err) + } + isMember, err := group.IsUserInGroup(ctx, user.ID) + require.NoError(t, err) + assert.True(t, isMember, "User should be added to group %s", groupName) + } + + for _, groupName := range tt.expectedRemoves { + group := qgroupCache[groupName] + if group == nil { + group, err = quota_model.GetGroupByName(ctx, groupName) + require.NoError(t, err) + } + isMember, err := group.IsUserInGroup(ctx, user.ID) + require.NoError(t, err) + assert.False(t, isMember, "User should be removed from group %s", groupName) + } + + for _, groupName := range tt.setupGroups { + unittest.AssertSuccessfulDelete(t, "a_model.GroupMapping{ + Kind: quota_model.KindUser, + MappedID: user.ID, + GroupName: groupName, + }) + unittest.AssertSuccessfulDelete(t, "a_model.Group{Name: groupName}) + } + }) + } +} diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index b89e87f749..59c5f5b80f 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -75,6 +75,9 @@ type AuthenticationForm struct { Oauth2RestrictedGroup string Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"` Oauth2GroupTeamMapRemoval bool + Oauth2QuotaGroupClaimName string + Oauth2QuotaGroupMap string `binding:"ValidQuotaGroupMap"` + Oauth2QuotaGroupMapRemoval bool Oauth2AttributeSSHPublicKey string SkipLocalTwoFA bool GroupTeamMap string `binding:"ValidGroupTeamMap"` diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index 16a93d8727..0d4a09899f 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -384,6 +384,18 @@ +