feat: allow sync quota groups with oauth2 auth source (#8554)

Implements synchronizing an external user's quota group with provided OAuth2 claim.

This functionality will allow system administrators to manage user's quota groups automatically.

Documentation is at forgejo/docs#1337

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8554
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: thezzisu <thezzisu@gmail.com>
Co-committed-by: thezzisu <thezzisu@gmail.com>
This commit is contained in:
thezzisu 2025-12-01 14:12:00 +01:00 committed by Gusted
parent 1000a0da3a
commit e31d67e0aa
15 changed files with 585 additions and 3 deletions

View file

@ -129,6 +129,20 @@ func oauthCLIFlags() []cli.Flag {
Name: "allow-username-change", Name: "allow-username-change",
Usage: "Allow users to change their username", 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"), GroupTeamMap: c.String("group-team-map"),
GroupTeamMapRemoval: c.Bool("group-team-map-removal"), GroupTeamMapRemoval: c.Bool("group-team-map-removal"),
AllowUsernameChange: c.Bool("allow-username-change"), 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") { if c.IsSet("group-team-map-removal") {
oAuth2Config.GroupTeamMapRemoval = c.Bool("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") { if c.IsSet("allow-username-change") {
oAuth2Config.AllowUsernameChange = c.Bool("allow-username-change") oAuth2Config.AllowUsernameChange = c.Bool("allow-username-change")

View file

@ -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", `{"org_a_team_1": {"organization-a": ["Team 1"]}, "org_a_all_teams": {"organization-a": ["Team 1", "Team 2", "Team 3"]}}`,
"--group-team-map-removal", "--group-team-map-removal",
"--allow-username-change", "--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{ source: &auth.Source{
Type: auth.OAuth2, Type: auth.OAuth2,
@ -82,6 +85,9 @@ func TestAddOauth(t *testing.T) {
AdminGroup: "admin", AdminGroup: "admin",
GroupTeamMap: `{"org_a_team_1": {"organization-a": ["Team 1"]}, "org_a_all_teams": {"organization-a": ["Team 1", "Team 2", "Team 3"]}}`, GroupTeamMap: `{"org_a_team_1": {"organization-a": ["Team 1"]}, "org_a_all_teams": {"organization-a": ["Team 1", "Team 2", "Team 3"]}}`,
GroupTeamMapRemoval: true, GroupTeamMapRemoval: true,
QuotaGroupClaimName: "quota_groups",
QuotaGroupMap: `{"oauth_group_1": ["quota_group_1"], "oauth_group_2": ["quota_group_2"]}`,
QuotaGroupMapRemoval: true,
RestrictedGroup: "restricted", RestrictedGroup: "restricted",
SkipLocalTwoFA: true, SkipLocalTwoFA: true,
AllowUsernameChange: 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://)", 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 { for n, c := range cases {
@ -658,6 +752,92 @@ func TestUpdateOauth(t *testing.T) {
}, },
errMsg: "--id flag is missing", 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 { for n, c := range cases {

View file

@ -61,7 +61,7 @@ func (g *Group) LoadRules(ctx context.Context) error {
Find(&g.Rules) 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). return db.GetEngine(ctx).
Where("kind = ? AND mapped_id = ? AND group_name = ?", KindUser, userID, g.Name). Where("kind = ? AND mapped_id = ? AND group_name = ?", KindUser, userID, g.Name).
Get(&GroupMapping{}) Get(&GroupMapping{})
@ -74,7 +74,7 @@ func (g *Group) AddUserByID(ctx context.Context, userID int64) error {
} }
defer committer.Close() defer committer.Close()
exists, err := g.isUserInGroup(ctx, userID) exists, err := g.IsUserInGroup(ctx, userID)
if err != nil { if err != nil {
return err return err
} else if exists { } else if exists {
@ -99,7 +99,7 @@ func (g *Group) RemoveUserByID(ctx context.Context, userID int64) error {
} }
defer committer.Close() defer committer.Close()
exists, err := g.isUserInGroup(ctx, userID) exists, err := g.IsUserInGroup(ctx, userID)
if err != nil { if err != nil {
return err return err
} else if !exists { } else if !exists {

View file

@ -4,6 +4,7 @@
package auth package auth
import ( import (
"forgejo.org/modules/container"
"forgejo.org/modules/json" "forgejo.org/modules/json"
"forgejo.org/modules/log" "forgejo.org/modules/log"
) )
@ -20,3 +21,25 @@ func UnmarshalGroupTeamMapping(raw string) (map[string]map[string][]string, erro
} }
return groupTeamMapping, nil 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
}

View file

@ -27,6 +27,8 @@ const (
ErrUsername = "UsernameError" ErrUsername = "UsernameError"
// ErrInvalidGroupTeamMap is returned when a group team mapping is invalid // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid
ErrInvalidGroupTeamMap = "InvalidGroupTeamMap" ErrInvalidGroupTeamMap = "InvalidGroupTeamMap"
// ErrInvalidQuotaGroupMap is returned when a quota group mapping is invalid
ErrInvalidQuotaGroupMap = "InvalidQuotaGroupMap"
// ErrEmail is returned when an email address is invalid // ErrEmail is returned when an email address is invalid
ErrEmail = "Email" ErrEmail = "Email"
) )
@ -42,6 +44,7 @@ func AddBindingRules() {
addGlobOrRegexPatternRule() addGlobOrRegexPatternRule()
addUsernamePatternRule() addUsernamePatternRule()
addValidGroupTeamMapRule() addValidGroupTeamMapRule()
addValidQuotaGroupMapRule()
addEmailBindingRules() 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() { func addEmailBindingRules() {
binding.AddRule(&binding.Rule{ binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool { IsMatch: func(rule string) bool {

View file

@ -210,5 +210,8 @@
}, },
"teams.add_all_repos.modal.header": "Add all repositories", "teams.add_all_repos.modal.header": "Add all repositories",
"teams.remove_all_repos.modal.header": "Remove 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." "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."
} }

View file

@ -191,6 +191,9 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
GroupTeamMap: form.Oauth2GroupTeamMap, GroupTeamMap: form.Oauth2GroupTeamMap,
GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval, GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval,
AllowUsernameChange: form.AllowUsernameChange, AllowUsernameChange: form.AllowUsernameChange,
QuotaGroupClaimName: form.Oauth2QuotaGroupClaimName,
QuotaGroupMap: form.Oauth2QuotaGroupMap,
QuotaGroupMapRemoval: form.Oauth2QuotaGroupMapRemoval,
} }
} }

View file

@ -1096,6 +1096,11 @@ func SignInOAuthCallback(ctx *context.Context) {
ctx.ServerError("SyncGroupsToTeams", err) ctx.ServerError("SyncGroupsToTeams", err)
return return
} }
if err := syncGroupsToQuotaGroups(ctx, source, &gothUser, u); err != nil {
ctx.ServerError("SyncGroupsToQuotaGroups", err)
return
}
} else { } else {
// no existing user is found, request attach or new account // no existing user is found, request attach or new account
showLinkingLogin(ctx, gothUser) showLinkingLogin(ctx, gothUser)
@ -1140,6 +1145,23 @@ func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *go
return nil 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] { func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] {
groupClaims, has := gothUser.RawData[source.GroupClaimName] groupClaims, has := gothUser.RawData[source.GroupClaimName]
if !has { if !has {
@ -1149,6 +1171,15 @@ func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[
return claimValueToStringSet(groupClaims) 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]) { func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) {
groups := getClaimedGroups(source, gothUser) groups := getClaimedGroups(source, gothUser)
@ -1262,8 +1293,14 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
ctx.ServerError("UnmarshalGroupTeamMapping", err) ctx.ServerError("UnmarshalGroupTeamMapping", err)
return return
} }
quotaGroupMapping, err := auth_module.UnmarshalQuotaGroupMapping(oauth2Source.QuotaGroupMap)
if err != nil {
ctx.ServerError("UnmarshalQuotaGroupMapping", err)
return
}
groups := getClaimedGroups(oauth2Source, &gothUser) groups := getClaimedGroups(oauth2Source, &gothUser)
quotaGroups := getClaimedQuotaGroups(oauth2Source, &gothUser)
// If this user is enrolled in 2FA and this source doesn't override it, // 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. // 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 // update external user information
if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil {
if !errors.Is(err, util.ErrNotExist) { 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{ if err := updateSession(ctx, nil, map[string]any{
// User needs to use 2FA, save data and redirect to 2FA page. // User needs to use 2FA, save data and redirect to 2FA page.
"twofaUid": u.ID, "twofaUid": u.ID,

View file

@ -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()
},
})
}

View file

@ -27,6 +27,9 @@ type Source struct {
AdminGroup string AdminGroup string
GroupTeamMap string GroupTeamMap string
GroupTeamMapRemoval bool GroupTeamMapRemoval bool
QuotaGroupClaimName string
QuotaGroupMap string
QuotaGroupMapRemoval bool
RestrictedGroup string RestrictedGroup string
SkipLocalTwoFA bool `json:",omitempty"` SkipLocalTwoFA bool `json:",omitempty"`
AllowUsernameChange bool AllowUsernameChange bool

View file

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

View file

@ -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 := &quota_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, &quota_model.GroupMapping{
Kind: quota_model.KindUser,
MappedID: user.ID,
GroupName: groupName,
})
unittest.AssertSuccessfulDelete(t, &quota_model.Group{Name: groupName})
}
})
}
}

View file

@ -75,6 +75,9 @@ type AuthenticationForm struct {
Oauth2RestrictedGroup string Oauth2RestrictedGroup string
Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"` Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"`
Oauth2GroupTeamMapRemoval bool Oauth2GroupTeamMapRemoval bool
Oauth2QuotaGroupClaimName string
Oauth2QuotaGroupMap string `binding:"ValidQuotaGroupMap"`
Oauth2QuotaGroupMapRemoval bool
Oauth2AttributeSSHPublicKey string Oauth2AttributeSSHPublicKey string
SkipLocalTwoFA bool SkipLocalTwoFA bool
GroupTeamMap string `binding:"ValidGroupTeamMap"` GroupTeamMap string `binding:"ValidGroupTeamMap"`

View file

@ -384,6 +384,18 @@
<label>{{ctx.Locale.Tr "admin.auths.oauth2_map_group_to_team_removal"}}</label> <label>{{ctx.Locale.Tr "admin.auths.oauth2_map_group_to_team_removal"}}</label>
<input name="oauth2_group_team_map_removal" type="checkbox" {{if $cfg.GroupTeamMapRemoval}}checked{{end}}> <input name="oauth2_group_team_map_removal" type="checkbox" {{if $cfg.GroupTeamMapRemoval}}checked{{end}}>
</div> </div>
<div class="field">
<label for="oauth2_quota_group_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_quota_group_claim_name"}}</label>
<input id="oauth2_quota_group_claim_name" name="oauth2_quota_group_claim_name" value="{{$cfg.QuotaGroupClaimName}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.auths.oauth2_quota_group_map"}}</label>
<textarea name="oauth2_quota_group_map" rows="5" placeholder='{"Developer": ["MyQuotaGroup"]}'>{{$cfg.QuotaGroupMap}}</textarea>
</div>
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "admin.auths.oauth2_quota_group_map_removal"}}</label>
<input name="oauth2_quota_group_map_removal" type="checkbox" {{if $cfg.QuotaGroupMapRemoval}}checked{{end}}>
</div>
{{end}} {{end}}
{{if .Source.IsLDAP}} {{if .Source.IsLDAP}}

View file

@ -121,4 +121,16 @@
<label>{{ctx.Locale.Tr "admin.auths.oauth2_map_group_to_team_removal"}}</label> <label>{{ctx.Locale.Tr "admin.auths.oauth2_map_group_to_team_removal"}}</label>
<input name="oauth2_group_team_map_removal" type="checkbox" {{if .oauth2_group_team_map_removal}}checked{{end}}> <input name="oauth2_group_team_map_removal" type="checkbox" {{if .oauth2_group_team_map_removal}}checked{{end}}>
</div> </div>
<div class="field">
<label for="oauth2_quota_group_claim_name">{{ctx.Locale.Tr "admin.auths.oauth2_quota_group_claim_name"}}</label>
<input id="oauth2_quota_group_claim_name" name="oauth2_quota_group_claim_name" value="{{.oauth2_quota_group_claim_name}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.auths.oauth2_quota_group_map"}}</label>
<textarea name="oauth2_quota_group_map" rows="5" placeholder='{"Developer": ["MyQuotaGroup"]}'>{{.oauth2_quota_group_map}}</textarea>
</div>
<div class="ui checkbox">
<label>{{ctx.Locale.Tr "admin.auths.oauth2_quota_group_map_removal"}}</label>
<input name="oauth2_quota_group_map_removal" type="checkbox" {{if .oauth2_quota_group_map_removal}}checked{{end}}>
</div>
</div> </div>