mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-12-07 14:09:47 +00:00
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:
parent
1000a0da3a
commit
e31d67e0aa
15 changed files with 585 additions and 3 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
21
services/auth/source/main_test.go
Normal file
21
services/auth/source/main_test.go
Normal 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()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
77
services/auth/source/source_quota_group_sync.go
Normal file
77
services/auth/source/source_quota_group_sync.go
Normal 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
|
||||
}
|
||||
148
services/auth/source/source_quota_group_sync_test.go
Normal file
148
services/auth/source/source_quota_group_sync_test.go
Normal 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 := "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})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -384,6 +384,18 @@
|
|||
<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}}>
|
||||
</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}}
|
||||
|
||||
{{if .Source.IsLDAP}}
|
||||
|
|
|
|||
|
|
@ -121,4 +121,16 @@
|
|||
<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}}>
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue