Skip to content

Commit e8186f1

Browse files
KN4CK3Rlunny
andauthored
Map OIDC groups to Orgs/Teams (#21441)
Fixes #19555 Test-Instructions: #21441 (comment) This PR implements the mapping of user groups provided by OIDC providers to orgs teams in Gitea. The main part is a refactoring of the existing LDAP code to make it usable from different providers. Refactorings: - Moved the router auth code from module to service because of import cycles - Changed some model methods to take a `Context` parameter - Moved the mapping code from LDAP to a common location I've tested it with Keycloak but other providers should work too. The JSON mapping format is the same as for LDAP. ![grafik](https://user-images.githubusercontent.com/1666336/195634392-3fc540fc-b229-4649-99ac-91ae8e19df2d.png) --------- Co-authored-by: Lunny Xiao <[email protected]>
1 parent 2c6cc0b commit e8186f1

File tree

34 files changed

+500
-423
lines changed

34 files changed

+500
-423
lines changed

cmd/admin.go

+17
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,15 @@ var (
372372
Value: "",
373373
Usage: "Group Claim value for restricted users",
374374
},
375+
cli.StringFlag{
376+
Name: "group-team-map",
377+
Value: "",
378+
Usage: "JSON mapping between groups and org teams",
379+
},
380+
cli.BoolFlag{
381+
Name: "group-team-map-removal",
382+
Usage: "Activate automatic team membership removal depending on groups",
383+
},
375384
}
376385

377386
microcmdAuthUpdateOauth = cli.Command{
@@ -853,6 +862,8 @@ func parseOAuth2Config(c *cli.Context) *oauth2.Source {
853862
GroupClaimName: c.String("group-claim-name"),
854863
AdminGroup: c.String("admin-group"),
855864
RestrictedGroup: c.String("restricted-group"),
865+
GroupTeamMap: c.String("group-team-map"),
866+
GroupTeamMapRemoval: c.Bool("group-team-map-removal"),
856867
}
857868
}
858869

@@ -935,6 +946,12 @@ func runUpdateOauth(c *cli.Context) error {
935946
if c.IsSet("restricted-group") {
936947
oAuth2Config.RestrictedGroup = c.String("restricted-group")
937948
}
949+
if c.IsSet("group-team-map") {
950+
oAuth2Config.GroupTeamMap = c.String("group-team-map")
951+
}
952+
if c.IsSet("group-team-map-removal") {
953+
oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal")
954+
}
938955

939956
// update custom URL mapping
940957
customURLMapping := &oauth2.CustomURLMapping{}

docs/content/doc/usage/command-line.en-us.md

+2
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ Admin operations:
137137
- `--group-claim-name`: Claim name providing group names for this source. (Optional)
138138
- `--admin-group`: Group Claim value for administrator users. (Optional)
139139
- `--restricted-group`: Group Claim value for restricted users. (Optional)
140+
- `--group-team-map`: JSON mapping between groups and org teams. (Optional)
141+
- `--group-team-map-removal`: Activate automatic team membership removal depending on groups. (Optional)
140142
- Examples:
141143
- `gitea admin auth add-oauth --name external-github --provider github --key OBTAIN_FROM_SOURCE --secret OBTAIN_FROM_SOURCE`
142144
- `update-oauth`:

models/organization/org.go

+6-14
Original file line numberDiff line numberDiff line change
@@ -110,22 +110,14 @@ func (org *Organization) CanCreateOrgRepo(uid int64) (bool, error) {
110110
return CanCreateOrgRepo(db.DefaultContext, org.ID, uid)
111111
}
112112

113-
func (org *Organization) getTeam(ctx context.Context, name string) (*Team, error) {
114-
return GetTeam(ctx, org.ID, name)
115-
}
116-
117113
// GetTeam returns named team of organization.
118-
func (org *Organization) GetTeam(name string) (*Team, error) {
119-
return org.getTeam(db.DefaultContext, name)
120-
}
121-
122-
func (org *Organization) getOwnerTeam(ctx context.Context) (*Team, error) {
123-
return org.getTeam(ctx, OwnerTeamName)
114+
func (org *Organization) GetTeam(ctx context.Context, name string) (*Team, error) {
115+
return GetTeam(ctx, org.ID, name)
124116
}
125117

126118
// GetOwnerTeam returns owner team of organization.
127-
func (org *Organization) GetOwnerTeam() (*Team, error) {
128-
return org.getOwnerTeam(db.DefaultContext)
119+
func (org *Organization) GetOwnerTeam(ctx context.Context) (*Team, error) {
120+
return org.GetTeam(ctx, OwnerTeamName)
129121
}
130122

131123
// FindOrgTeams returns all teams of a given organization
@@ -342,15 +334,15 @@ func CreateOrganization(org *Organization, owner *user_model.User) (err error) {
342334
}
343335

344336
// GetOrgByName returns organization by given name.
345-
func GetOrgByName(name string) (*Organization, error) {
337+
func GetOrgByName(ctx context.Context, name string) (*Organization, error) {
346338
if len(name) == 0 {
347339
return nil, ErrOrgNotExist{0, name}
348340
}
349341
u := &Organization{
350342
LowerName: strings.ToLower(name),
351343
Type: user_model.UserTypeOrganization,
352344
}
353-
has, err := db.GetEngine(db.DefaultContext).Get(u)
345+
has, err := db.GetEngine(ctx).Get(u)
354346
if err != nil {
355347
return nil, err
356348
} else if !has {

models/organization/org_test.go

+8-8
Original file line numberDiff line numberDiff line change
@@ -61,28 +61,28 @@ func TestUser_IsOrgMember(t *testing.T) {
6161
func TestUser_GetTeam(t *testing.T) {
6262
assert.NoError(t, unittest.PrepareTestDatabase())
6363
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
64-
team, err := org.GetTeam("team1")
64+
team, err := org.GetTeam(db.DefaultContext, "team1")
6565
assert.NoError(t, err)
6666
assert.Equal(t, org.ID, team.OrgID)
6767
assert.Equal(t, "team1", team.LowerName)
6868

69-
_, err = org.GetTeam("does not exist")
69+
_, err = org.GetTeam(db.DefaultContext, "does not exist")
7070
assert.True(t, organization.IsErrTeamNotExist(err))
7171

7272
nonOrg := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 2})
73-
_, err = nonOrg.GetTeam("team")
73+
_, err = nonOrg.GetTeam(db.DefaultContext, "team")
7474
assert.True(t, organization.IsErrTeamNotExist(err))
7575
}
7676

7777
func TestUser_GetOwnerTeam(t *testing.T) {
7878
assert.NoError(t, unittest.PrepareTestDatabase())
7979
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
80-
team, err := org.GetOwnerTeam()
80+
team, err := org.GetOwnerTeam(db.DefaultContext)
8181
assert.NoError(t, err)
8282
assert.Equal(t, org.ID, team.OrgID)
8383

8484
nonOrg := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 2})
85-
_, err = nonOrg.GetOwnerTeam()
85+
_, err = nonOrg.GetOwnerTeam(db.DefaultContext)
8686
assert.True(t, organization.IsErrTeamNotExist(err))
8787
}
8888

@@ -115,15 +115,15 @@ func TestUser_GetMembers(t *testing.T) {
115115
func TestGetOrgByName(t *testing.T) {
116116
assert.NoError(t, unittest.PrepareTestDatabase())
117117

118-
org, err := organization.GetOrgByName("user3")
118+
org, err := organization.GetOrgByName(db.DefaultContext, "user3")
119119
assert.NoError(t, err)
120120
assert.EqualValues(t, 3, org.ID)
121121
assert.Equal(t, "user3", org.Name)
122122

123-
_, err = organization.GetOrgByName("user2") // user2 is an individual
123+
_, err = organization.GetOrgByName(db.DefaultContext, "user2") // user2 is an individual
124124
assert.True(t, organization.IsErrOrgNotExist(err))
125125

126-
_, err = organization.GetOrgByName("") // corner case
126+
_, err = organization.GetOrgByName(db.DefaultContext, "") // corner case
127127
assert.True(t, organization.IsErrOrgNotExist(err))
128128
}
129129

modules/auth/common.go

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package auth
5+
6+
import (
7+
"code.gitea.io/gitea/modules/json"
8+
"code.gitea.io/gitea/modules/log"
9+
)
10+
11+
func UnmarshalGroupTeamMapping(raw string) (map[string]map[string][]string, error) {
12+
groupTeamMapping := make(map[string]map[string][]string)
13+
if raw == "" {
14+
return groupTeamMapping, nil
15+
}
16+
err := json.Unmarshal([]byte(raw), &groupTeamMapping)
17+
if err != nil {
18+
log.Error("Failed to unmarshal group team mapping: %v", err)
19+
return nil, err
20+
}
21+
return groupTeamMapping, nil
22+
}

modules/context/api.go

-30
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import (
1919
"code.gitea.io/gitea/modules/log"
2020
"code.gitea.io/gitea/modules/setting"
2121
"code.gitea.io/gitea/modules/web/middleware"
22-
auth_service "code.gitea.io/gitea/services/auth"
2322
)
2423

2524
// APIContext is a specific context for API service
@@ -215,35 +214,6 @@ func (ctx *APIContext) CheckForOTP() {
215214
}
216215
}
217216

218-
// APIAuth converts auth_service.Auth as a middleware
219-
func APIAuth(authMethod auth_service.Method) func(*APIContext) {
220-
return func(ctx *APIContext) {
221-
// Get user from session if logged in.
222-
var err error
223-
ctx.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session)
224-
if err != nil {
225-
ctx.Error(http.StatusUnauthorized, "APIAuth", err)
226-
return
227-
}
228-
229-
if ctx.Doer != nil {
230-
if ctx.Locale.Language() != ctx.Doer.Language {
231-
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
232-
}
233-
ctx.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == auth_service.BasicMethodName
234-
ctx.IsSigned = true
235-
ctx.Data["IsSigned"] = ctx.IsSigned
236-
ctx.Data["SignedUser"] = ctx.Doer
237-
ctx.Data["SignedUserID"] = ctx.Doer.ID
238-
ctx.Data["SignedUserName"] = ctx.Doer.Name
239-
ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin
240-
} else {
241-
ctx.Data["SignedUserID"] = int64(0)
242-
ctx.Data["SignedUserName"] = ""
243-
}
244-
}
245-
}
246-
247217
// APIContexter returns apicontext as middleware
248218
func APIContexter() func(http.Handler) http.Handler {
249219
return func(next http.Handler) http.Handler {

modules/context/context.go

-32
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import (
3636
"code.gitea.io/gitea/modules/typesniffer"
3737
"code.gitea.io/gitea/modules/util"
3838
"code.gitea.io/gitea/modules/web/middleware"
39-
"code.gitea.io/gitea/services/auth"
4039

4140
"gitea.com/go-chi/cache"
4241
"gitea.com/go-chi/session"
@@ -659,37 +658,6 @@ func getCsrfOpts() CsrfOptions {
659658
}
660659
}
661660

662-
// Auth converts auth.Auth as a middleware
663-
func Auth(authMethod auth.Method) func(*Context) {
664-
return func(ctx *Context) {
665-
var err error
666-
ctx.Doer, err = authMethod.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session)
667-
if err != nil {
668-
log.Error("Failed to verify user %v: %v", ctx.Req.RemoteAddr, err)
669-
ctx.Error(http.StatusUnauthorized, "Verify")
670-
return
671-
}
672-
if ctx.Doer != nil {
673-
if ctx.Locale.Language() != ctx.Doer.Language {
674-
ctx.Locale = middleware.Locale(ctx.Resp, ctx.Req)
675-
}
676-
ctx.IsBasicAuth = ctx.Data["AuthedMethod"].(string) == auth.BasicMethodName
677-
ctx.IsSigned = true
678-
ctx.Data["IsSigned"] = ctx.IsSigned
679-
ctx.Data["SignedUser"] = ctx.Doer
680-
ctx.Data["SignedUserID"] = ctx.Doer.ID
681-
ctx.Data["SignedUserName"] = ctx.Doer.Name
682-
ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin
683-
} else {
684-
ctx.Data["SignedUserID"] = int64(0)
685-
ctx.Data["SignedUserName"] = ""
686-
687-
// ensure the session uid is deleted
688-
_ = ctx.Session.Delete("uid")
689-
}
690-
}
691-
}
692-
693661
// Contexter initializes a classic context for a request.
694662
func Contexter(ctx context.Context) func(next http.Handler) http.Handler {
695663
_, rnd := templates.HTMLRenderer(ctx)

modules/context/org.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) {
8080
orgName := ctx.Params(":org")
8181

8282
var err error
83-
ctx.Org.Organization, err = organization.GetOrgByName(orgName)
83+
ctx.Org.Organization, err = organization.GetOrgByName(ctx, orgName)
8484
if err != nil {
8585
if organization.IsErrOrgNotExist(err) {
8686
redirectUserID, err := user_model.LookupUserRedirect(orgName)

modules/repository/create_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) {
4949
assert.NoError(t, organization.CreateOrganization(org, user), "CreateOrganization")
5050

5151
// Check Owner team.
52-
ownerTeam, err := org.GetOwnerTeam()
52+
ownerTeam, err := org.GetOwnerTeam(db.DefaultContext)
5353
assert.NoError(t, err, "GetOwnerTeam")
5454
assert.True(t, ownerTeam.IncludesAllRepositories, "Owner team includes all repositories")
5555

@@ -63,7 +63,7 @@ func TestIncludesAllRepositoriesTeams(t *testing.T) {
6363
}
6464
}
6565
// Get fresh copy of Owner team after creating repos.
66-
ownerTeam, err = org.GetOwnerTeam()
66+
ownerTeam, err = org.GetOwnerTeam(db.DefaultContext)
6767
assert.NoError(t, err, "GetOwnerTeam")
6868

6969
// Create teams and check repositories.

modules/repository/repo.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User,
5757
repoPath := repo_model.RepoPath(u.Name, opts.RepoName)
5858

5959
if u.IsOrganization() {
60-
t, err := organization.OrgFromUser(u).GetOwnerTeam()
60+
t, err := organization.OrgFromUser(u).GetOwnerTeam(ctx)
6161
if err != nil {
6262
return nil, err
6363
}

modules/validation/binding.go

+21-3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"regexp"
99
"strings"
1010

11+
"code.gitea.io/gitea/modules/auth"
1112
"code.gitea.io/gitea/modules/git"
1213

1314
"gitea.com/go-chi/binding"
@@ -17,15 +18,14 @@ import (
1718
const (
1819
// ErrGitRefName is git reference name error
1920
ErrGitRefName = "GitRefNameError"
20-
2121
// ErrGlobPattern is returned when glob pattern is invalid
2222
ErrGlobPattern = "GlobPattern"
23-
2423
// ErrRegexPattern is returned when a regex pattern is invalid
2524
ErrRegexPattern = "RegexPattern"
26-
2725
// ErrUsername is username error
2826
ErrUsername = "UsernameError"
27+
// ErrInvalidGroupTeamMap is returned when a group team mapping is invalid
28+
ErrInvalidGroupTeamMap = "InvalidGroupTeamMap"
2929
)
3030

3131
// AddBindingRules adds additional binding rules
@@ -37,6 +37,7 @@ func AddBindingRules() {
3737
addRegexPatternRule()
3838
addGlobOrRegexPatternRule()
3939
addUsernamePatternRule()
40+
addValidGroupTeamMapRule()
4041
}
4142

4243
func addGitRefNameBindingRule() {
@@ -167,6 +168,23 @@ func addUsernamePatternRule() {
167168
})
168169
}
169170

171+
func addValidGroupTeamMapRule() {
172+
binding.AddRule(&binding.Rule{
173+
IsMatch: func(rule string) bool {
174+
return strings.HasPrefix(rule, "ValidGroupTeamMap")
175+
},
176+
IsValid: func(errs binding.Errors, name string, val interface{}) (bool, binding.Errors) {
177+
_, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val))
178+
if err != nil {
179+
errs.Add([]string{name}, ErrInvalidGroupTeamMap, err.Error())
180+
return false, errs
181+
}
182+
183+
return true, errs
184+
},
185+
})
186+
}
187+
170188
func portOnly(hostport string) string {
171189
colon := strings.IndexByte(hostport, ':')
172190
if colon == -1 {

modules/web/middleware/binding.go

+2
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ func Validate(errs binding.Errors, data map[string]interface{}, f Form, l transl
136136
data["ErrorMsg"] = trName + l.Tr("form.regex_pattern_error", errs[0].Message)
137137
case validation.ErrUsername:
138138
data["ErrorMsg"] = trName + l.Tr("form.username_error")
139+
case validation.ErrInvalidGroupTeamMap:
140+
data["ErrorMsg"] = trName + l.Tr("form.invalid_group_team_map_error", errs[0].Message)
139141
default:
140142
msg := errs[0].Classification
141143
if msg != "" && errs[0].Message != "" {

options/locale/locale_en-US.ini

+3
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ include_error = ` must contain substring '%s'.`
477477
glob_pattern_error = ` glob pattern is invalid: %s.`
478478
regex_pattern_error = ` regex pattern is invalid: %s.`
479479
username_error = ` can only contain alphanumeric chars ('0-9','a-z','A-Z'), dash ('-'), underscore ('_') and dot ('.'). It cannot begin or end with non-alphanumeric chars, and consecutive non-alphanumeric chars are also forbidden.`
480+
invalid_group_team_map_error = ` mapping is invalid: %s`
480481
unknown_error = Unknown error:
481482
captcha_incorrect = The CAPTCHA code is incorrect.
482483
password_not_match = The passwords do not match.
@@ -2758,6 +2759,8 @@ auths.oauth2_required_claim_value_helper = Set this value to restrict login from
27582759
auths.oauth2_group_claim_name = Claim name providing group names for this source. (Optional)
27592760
auths.oauth2_admin_group = Group Claim value for administrator users. (Optional - requires claim name above)
27602761
auths.oauth2_restricted_group = Group Claim value for restricted users. (Optional - requires claim name above)
2762+
auths.oauth2_map_group_to_team = Map claimed groups to Organization teams. (Optional - requires claim name above)
2763+
auths.oauth2_map_group_to_team_removal = Remove users from synchronized teams if user does not belong to corresponding group.
27612764
auths.enable_auto_register = Enable Auto Registration
27622765
auths.sspi_auto_create_users = Automatically create users
27632766
auths.sspi_auto_create_users_helper = Allow SSPI auth method to automatically create new accounts for users that login for the first time

routers/api/v1/api.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
507507

508508
var err error
509509
if assignOrg {
510-
ctx.Org.Organization, err = organization.GetOrgByName(ctx.Params(":org"))
510+
ctx.Org.Organization, err = organization.GetOrgByName(ctx, ctx.Params(":org"))
511511
if err != nil {
512512
if organization.IsErrOrgNotExist(err) {
513513
redirectUserID, err := user_model.LookupUserRedirect(ctx.Params(":org"))
@@ -687,7 +687,7 @@ func Routes(ctx gocontext.Context) *web.Route {
687687
}
688688

689689
// Get user from session if logged in.
690-
m.Use(context.APIAuth(group))
690+
m.Use(auth.APIAuth(group))
691691

692692
m.Use(context.ToggleAPI(&context.ToggleOptions{
693693
SignInRequired: setting.Service.RequireSignInView,

0 commit comments

Comments
 (0)