mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-28 07:02:26 -04:00
feat: add dynamic group mappings for OIDC (#11656)
Currently, Forgejo supports configuring static group team mappings for
an OIDC authentication source that map OIDC groups to Forgejo
organizations and teams. For example, the following mapping
```json
{"Developer": {"MyForgejoOrganization": ["MyForgejoTeam1", "MyForgejoTeam2"]}}
```
automatically adds a user in the OIDC group `Developer` to the teams
`MyForgejoTeam1` and `MyForgejoTeam2` in organization
`MyForgejoOrganization`.
In order to support more dynamic mappings and to avoid having to update
the mappings for new organizations and teams, add an additional
configuration option that supports mappings with placeholders like in
the following example:
```json
["group-{org}-{team}", "other:{org}/{team}"]
```
In this example, the mappings add a user in OIDC groups
`group-org1-team1`, `group-org2-team2`, and `other:org3/team3` to team
`team1` in organization `org1`, team `team2` in organization `org2`, and
to team `team3` in organization `org3`.
Additionally, this adds a configuration option to dynamically remove
users from organization teams. If enabled, a user is removed from all
teams that are not added via a static or dynamic mapping. Thus, users
are only in teams that are added via such a mapping and no other teams.
Docs: forgejo/docs!1950
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11656
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
parent
7054075be5
commit
1ea5605eae
18 changed files with 803 additions and 19 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ type Source struct {
|
|||
AdminGroup string
|
||||
GroupTeamMap string
|
||||
GroupTeamMapRemoval bool
|
||||
DynGroupMaps string
|
||||
DynGroupMapsRemoval bool
|
||||
QuotaGroupClaimName string
|
||||
QuotaGroupMap string
|
||||
QuotaGroupMapRemoval bool
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}", `(?<org>[\w-.]+)`, 1)
|
||||
s = strings.Replace(s, "{team}", `(?<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
|
||||
}
|
||||
|
||||
|
|
|
|||
402
services/auth/source/source_group_sync_test.go
Normal file
402
services/auth/source/source_group_sync_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -384,6 +384,14 @@
|
|||
<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>{{ctx.Locale.Tr "admin.auths.oauth2_dyn_group_maps"}}</label>
|
||||
<textarea name="oauth2_dyn_group_maps" rows="5" placeholder='["group-{org}-{team}", "other-{org}-{team}"]'>{{$cfg.DynGroupMaps}}</textarea>
|
||||
</div>
|
||||
<div class="ui checkbox">
|
||||
<label>{{ctx.Locale.Tr "admin.auths.oauth2_dyn_group_maps_removal"}}</label>
|
||||
<input name="oauth2_dyn_group_maps_removal" type="checkbox" {{if $cfg.DynGroupMapsRemoval}}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}}">
|
||||
|
|
|
|||
|
|
@ -121,6 +121,14 @@
|
|||
<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>{{ctx.Locale.Tr "admin.auths.oauth2_dyn_group_maps"}}</label>
|
||||
<textarea name="oauth2_dyn_group_maps" rows="5" placeholder='["group-{org}-{team}", "other-{org}-{team}"]'>{{.oauth2_dyn_group_maps}}</textarea>
|
||||
</div>
|
||||
<div class="ui checkbox">
|
||||
<label>{{ctx.Locale.Tr "admin.auths.oauth2_dyn_group_maps_removal"}}</label>
|
||||
<input name="oauth2_dyn_group_maps_removal" type="checkbox" {{if .oauth2_dyn_group_maps_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}}">
|
||||
|
|
|
|||
Loading…
Reference in a new issue