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",
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
|
||||||
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
|
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"`
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue