mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-10 01:05:14 +00:00
Implement remote user login source and promotion to regular user
A remote user (UserTypeRemoteUser) is a placeholder that can be promoted to a regular user (UserTypeIndividual). It represents users that exist somewhere else. Although the UserTypeRemoteUser already exists in Forgejo, it is neither used or documented. A new login type / source (Remote) is introduced and set to be the login type of remote users. Type UserTypeRemoteUser LogingType Remote The association between a remote user and its counterpart in another environment (for instance another forge) is via the OAuth2 login source: LoginName set to the unique identifier relative to the login source LoginSource set to the identifier of the remote source For instance when migrating from GitLab.com, a user can be created as if it was authenticated using GitLab.com as an OAuth2 authentication source. When a user authenticates to Forejo from the same authentication source and the identifier match, the remote user is promoted to a regular user. For instance if 43 is the ID of the GitLab.com OAuth2 login source, 88 is the ID of the Remote loging source, and 48323 is the identifier of the foo user: Type UserTypeRemoteUser LogingType Remote LoginName 48323 LoginSource 88 Email (empty) Name foo Will be promoted to the following when the user foo authenticates to the Forgejo instance using GitLab.com as an OAuth2 provider. All users with a LoginType of Remote and a LoginName of 48323 are examined. If the LoginSource has a provider name that matches the provider name of GitLab.com (usually just "gitlab"), it is a match and can be promoted. The email is obtained via the OAuth2 provider and the user set to: Type UserTypeIndividual LogingType OAuth2 LoginName 48323 LoginSource 43 Email foo@example.com Name foo Note: the Remote login source is an indirection to the actual login source, i.e. the provider string my be set to a login source that does not exist yet.
This commit is contained in:
parent
7624b78544
commit
7cabc5670d
|
@ -33,6 +33,7 @@ const (
|
||||||
DLDAP // 5
|
DLDAP // 5
|
||||||
OAuth2 // 6
|
OAuth2 // 6
|
||||||
SSPI // 7
|
SSPI // 7
|
||||||
|
Remote // 8
|
||||||
)
|
)
|
||||||
|
|
||||||
// String returns the string name of the LoginType
|
// String returns the string name of the LoginType
|
||||||
|
@ -53,6 +54,7 @@ var Names = map[Type]string{
|
||||||
PAM: "PAM",
|
PAM: "PAM",
|
||||||
OAuth2: "OAuth2",
|
OAuth2: "OAuth2",
|
||||||
SSPI: "SPNEGO with SSPI",
|
SSPI: "SPNEGO with SSPI",
|
||||||
|
Remote: "Remote",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config represents login config as far as the db is concerned
|
// Config represents login config as far as the db is concerned
|
||||||
|
@ -181,6 +183,10 @@ func (source *Source) IsSSPI() bool {
|
||||||
return source.Type == SSPI
|
return source.Type == SSPI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (source *Source) IsRemote() bool {
|
||||||
|
return source.Type == Remote
|
||||||
|
}
|
||||||
|
|
||||||
// HasTLS returns true of this source supports TLS.
|
// HasTLS returns true of this source supports TLS.
|
||||||
func (source *Source) HasTLS() bool {
|
func (source *Source) HasTLS() bool {
|
||||||
hasTLSer, ok := source.Cfg.(HasTLSer)
|
hasTLSer, ok := source.Cfg.(HasTLSer)
|
||||||
|
|
36
models/user/fixtures/user.yml
Normal file
36
models/user/fixtures/user.yml
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
-
|
||||||
|
id: 1041
|
||||||
|
lower_name: remote01
|
||||||
|
name: remote01
|
||||||
|
full_name: Remote01
|
||||||
|
email: remote01@example.com
|
||||||
|
keep_email_private: false
|
||||||
|
email_notifications_preference: onmention
|
||||||
|
passwd: ZogKvWdyEx:password
|
||||||
|
passwd_hash_algo: dummy
|
||||||
|
must_change_password: false
|
||||||
|
login_source: 1001
|
||||||
|
login_name: 123
|
||||||
|
type: 5
|
||||||
|
salt: ZogKvWdyEx
|
||||||
|
max_repo_creation: -1
|
||||||
|
is_active: true
|
||||||
|
is_admin: false
|
||||||
|
is_restricted: false
|
||||||
|
allow_git_hook: false
|
||||||
|
allow_import_local: false
|
||||||
|
allow_create_organization: true
|
||||||
|
prohibit_login: true
|
||||||
|
avatar: avatarremote01
|
||||||
|
avatar_email: avatarremote01@example.com
|
||||||
|
use_custom_avatar: false
|
||||||
|
num_followers: 0
|
||||||
|
num_following: 0
|
||||||
|
num_stars: 0
|
||||||
|
num_repos: 0
|
||||||
|
num_teams: 0
|
||||||
|
num_members: 0
|
||||||
|
visibility: 0
|
||||||
|
repo_admin_change_team_access: false
|
||||||
|
theme: ""
|
||||||
|
keep_activity_private: false
|
|
@ -45,7 +45,11 @@ type SearchUserOptions struct {
|
||||||
|
|
||||||
func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session {
|
func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session {
|
||||||
var cond builder.Cond
|
var cond builder.Cond
|
||||||
|
if opts.Type == UserTypeIndividual {
|
||||||
|
cond = builder.In("type", UserTypeIndividual, UserTypeRemoteUser)
|
||||||
|
} else {
|
||||||
cond = builder.Eq{"type": opts.Type}
|
cond = builder.Eq{"type": opts.Type}
|
||||||
|
}
|
||||||
if opts.IncludeReserved {
|
if opts.IncludeReserved {
|
||||||
if opts.Type == UserTypeIndividual {
|
if opts.Type == UserTypeIndividual {
|
||||||
cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or(
|
cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or(
|
||||||
|
|
|
@ -216,7 +216,7 @@ func (u *User) GetEmail() string {
|
||||||
// GetAllUsers returns a slice of all individual users found in DB.
|
// GetAllUsers returns a slice of all individual users found in DB.
|
||||||
func GetAllUsers(ctx context.Context) ([]*User, error) {
|
func GetAllUsers(ctx context.Context) ([]*User, error) {
|
||||||
users := make([]*User, 0)
|
users := make([]*User, 0)
|
||||||
return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).Find(&users)
|
return users, db.GetEngine(ctx).OrderBy("id").In("type", UserTypeIndividual, UserTypeRemoteUser).Find(&users)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllAdmins returns a slice of all adminusers found in DB.
|
// GetAllAdmins returns a slice of all adminusers found in DB.
|
||||||
|
@ -416,6 +416,10 @@ func (u *User) IsBot() bool {
|
||||||
return u.Type == UserTypeBot
|
return u.Type == UserTypeBot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) IsRemote() bool {
|
||||||
|
return u.Type == UserTypeRemoteUser
|
||||||
|
}
|
||||||
|
|
||||||
// DisplayName returns full name if it's not empty,
|
// DisplayName returns full name if it's not empty,
|
||||||
// returns username otherwise.
|
// returns username otherwise.
|
||||||
func (u *User) DisplayName() string {
|
func (u *User) DisplayName() string {
|
||||||
|
@ -918,7 +922,8 @@ func GetUserByName(ctx context.Context, name string) (*User, error) {
|
||||||
if len(name) == 0 {
|
if len(name) == 0 {
|
||||||
return nil, ErrUserNotExist{Name: name}
|
return nil, ErrUserNotExist{Name: name}
|
||||||
}
|
}
|
||||||
u := &User{LowerName: strings.ToLower(name), Type: UserTypeIndividual}
|
// adding Type: UserTypeIndividual is a noop because it is zero and discarded
|
||||||
|
u := &User{LowerName: strings.ToLower(name)}
|
||||||
has, err := db.GetEngine(ctx).Get(u)
|
has, err := db.GetEngine(ctx).Get(u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/structs"
|
"code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
@ -33,6 +34,35 @@ func TestOAuth2Application_LoadUser(t *testing.T) {
|
||||||
assert.NotNil(t, user)
|
assert.NotNil(t, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetUserByName(t *testing.T) {
|
||||||
|
defer tests.AddFixtures("models/user/fixtures/")()
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
{
|
||||||
|
_, err := user_model.GetUserByName(db.DefaultContext, "")
|
||||||
|
assert.True(t, user_model.IsErrUserNotExist(err), err)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
_, err := user_model.GetUserByName(db.DefaultContext, "UNKNOWN")
|
||||||
|
assert.True(t, user_model.IsErrUserNotExist(err), err)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
user, err := user_model.GetUserByName(db.DefaultContext, "USER2")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, user.Name, "user2")
|
||||||
|
}
|
||||||
|
{
|
||||||
|
user, err := user_model.GetUserByName(db.DefaultContext, "org3")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, user.Name, "org3")
|
||||||
|
}
|
||||||
|
{
|
||||||
|
user, err := user_model.GetUserByName(db.DefaultContext, "remote01")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, user.Name, "remote01")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetUserEmailsByNames(t *testing.T) {
|
func TestGetUserEmailsByNames(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
@ -61,7 +91,24 @@ func TestCanCreateOrganization(t *testing.T) {
|
||||||
assert.False(t, user.CanCreateOrganization())
|
assert.False(t, user.CanCreateOrganization())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetAllUsers(t *testing.T) {
|
||||||
|
defer tests.AddFixtures("models/user/fixtures/")()
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
users, err := user_model.GetAllUsers(db.DefaultContext)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
found := make(map[user_model.UserType]bool, 0)
|
||||||
|
for _, user := range users {
|
||||||
|
found[user.Type] = true
|
||||||
|
}
|
||||||
|
assert.True(t, found[user_model.UserTypeIndividual], users)
|
||||||
|
assert.True(t, found[user_model.UserTypeRemoteUser], users)
|
||||||
|
assert.False(t, found[user_model.UserTypeOrganization], users)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearchUsers(t *testing.T) {
|
func TestSearchUsers(t *testing.T) {
|
||||||
|
defer tests.AddFixtures("models/user/fixtures/")()
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
testSuccess := func(opts *user_model.SearchUserOptions, expectedUserOrOrgIDs []int64) {
|
testSuccess := func(opts *user_model.SearchUserOptions, expectedUserOrOrgIDs []int64) {
|
||||||
users, _, err := user_model.SearchUsers(db.DefaultContext, opts)
|
users, _, err := user_model.SearchUsers(db.DefaultContext, opts)
|
||||||
|
@ -102,13 +149,13 @@ func TestSearchUsers(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}},
|
testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}},
|
||||||
[]int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40})
|
[]int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40, 1041})
|
||||||
|
|
||||||
testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(false)},
|
testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(false)},
|
||||||
[]int64{9})
|
[]int64{9})
|
||||||
|
|
||||||
testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(true)},
|
testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(true)},
|
||||||
[]int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40})
|
[]int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40, 1041})
|
||||||
|
|
||||||
testUserSuccess(&user_model.SearchUserOptions{Keyword: "user1", OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(true)},
|
testUserSuccess(&user_model.SearchUserOptions{Keyword: "user1", OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(true)},
|
||||||
[]int64{1, 10, 11, 12, 13, 14, 15, 16, 18})
|
[]int64{1, 10, 11, 12, 13, 14, 15, 16, 18})
|
||||||
|
@ -124,7 +171,7 @@ func TestSearchUsers(t *testing.T) {
|
||||||
[]int64{29})
|
[]int64{29})
|
||||||
|
|
||||||
testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsProhibitLogin: optional.Some(true)},
|
testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsProhibitLogin: optional.Some(true)},
|
||||||
[]int64{37})
|
[]int64{1041, 37})
|
||||||
|
|
||||||
testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsTwoFactorEnabled: optional.Some(true)},
|
testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsTwoFactorEnabled: optional.Some(true)},
|
||||||
[]int64{24})
|
[]int64{24})
|
||||||
|
|
|
@ -35,6 +35,7 @@ import (
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
"code.gitea.io/gitea/services/externalaccount"
|
"code.gitea.io/gitea/services/externalaccount"
|
||||||
"code.gitea.io/gitea/services/forms"
|
"code.gitea.io/gitea/services/forms"
|
||||||
|
remote_service "code.gitea.io/gitea/services/remote"
|
||||||
user_service "code.gitea.io/gitea/services/user"
|
user_service "code.gitea.io/gitea/services/user"
|
||||||
|
|
||||||
"gitea.com/go-chi/binding"
|
"gitea.com/go-chi/binding"
|
||||||
|
@ -1202,9 +1203,21 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
ctx.Redirect(setting.AppSubURL + "/user/two_factor")
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
|
|
||||||
// login the user
|
|
||||||
func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
|
func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
|
||||||
|
gothUser, err := oAuth2FetchUser(ctx, authSource, request, response)
|
||||||
|
if err != nil {
|
||||||
|
return nil, goth.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil {
|
||||||
|
return nil, goth.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser)
|
||||||
|
return u, gothUser, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) {
|
||||||
oauth2Source := authSource.Cfg.(*oauth2.Source)
|
oauth2Source := authSource.Cfg.(*oauth2.Source)
|
||||||
|
|
||||||
// Make sure that the response is not an error response.
|
// Make sure that the response is not an error response.
|
||||||
|
@ -1216,10 +1229,10 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
|
||||||
// Delete the goth session
|
// Delete the goth session
|
||||||
err := gothic.Logout(response, request)
|
err := gothic.Logout(response, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, goth.User{}, err
|
return goth.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, goth.User{}, errCallback{
|
return goth.User{}, errCallback{
|
||||||
Code: errorName,
|
Code: errorName,
|
||||||
Description: errorDescription,
|
Description: errorDescription,
|
||||||
}
|
}
|
||||||
|
@ -1232,24 +1245,28 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
|
||||||
log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
|
log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
|
||||||
err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
|
err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
|
||||||
}
|
}
|
||||||
return nil, goth.User{}, err
|
return goth.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if oauth2Source.RequiredClaimName != "" {
|
if oauth2Source.RequiredClaimName != "" {
|
||||||
claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
|
claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
|
||||||
if !has {
|
if !has {
|
||||||
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
||||||
}
|
}
|
||||||
|
|
||||||
if oauth2Source.RequiredClaimValue != "" {
|
if oauth2Source.RequiredClaimValue != "" {
|
||||||
groups := claimValueToStringSet(claimInterface)
|
groups := claimValueToStringSet(claimInterface)
|
||||||
|
|
||||||
if !groups.Contains(oauth2Source.RequiredClaimValue) {
|
if !groups.Contains(oauth2Source.RequiredClaimValue) {
|
||||||
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return gothUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) {
|
||||||
user := &user_model.User{
|
user := &user_model.User{
|
||||||
LoginName: gothUser.UserID,
|
LoginName: gothUser.UserID,
|
||||||
LoginType: auth.OAuth2,
|
LoginType: auth.OAuth2,
|
||||||
|
@ -1258,27 +1275,28 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
|
||||||
|
|
||||||
hasUser, err := user_model.GetUser(ctx, user)
|
hasUser, err := user_model.GetUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, goth.User{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasUser {
|
if hasUser {
|
||||||
return user, gothUser, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType)
|
||||||
|
|
||||||
// search in external linked users
|
// search in external linked users
|
||||||
externalLoginUser := &user_model.ExternalLoginUser{
|
externalLoginUser := &user_model.ExternalLoginUser{
|
||||||
ExternalID: gothUser.UserID,
|
ExternalID: gothUser.UserID,
|
||||||
LoginSourceID: authSource.ID,
|
LoginSourceID: authSource.ID,
|
||||||
}
|
}
|
||||||
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser)
|
hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, goth.User{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if hasUser {
|
if hasUser {
|
||||||
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID)
|
user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
|
||||||
return user, gothUser, err
|
return user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// no user found to login
|
// no user found to login
|
||||||
return nil, gothUser, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
33
services/auth/source/remote/source.go
Normal file
33
services/auth/source/remote/source.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
// Copyright Earl Warren <contact@earl-warren.org>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package remote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
URL string
|
||||||
|
MatchingSource string
|
||||||
|
|
||||||
|
// reference to the authSource
|
||||||
|
authSource *auth.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *Source) FromDB(bs []byte) error {
|
||||||
|
return json.UnmarshalHandleDoubleEncode(bs, &source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *Source) ToDB() ([]byte, error) {
|
||||||
|
return json.Marshal(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *Source) SetAuthSource(authSource *auth.Source) {
|
||||||
|
source.authSource = authSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
auth.RegisterTypeConfig(auth.Remote, &Source{})
|
||||||
|
}
|
133
services/remote/promote.go
Normal file
133
services/remote/promote.go
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
// Copyright Earl Warren <contact@earl-warren.org>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package remote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/services/auth/source/oauth2"
|
||||||
|
remote_source "code.gitea.io/gitea/services/auth/source/remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Reason int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReasonNoMatch Reason = iota
|
||||||
|
ReasonNotAuth2
|
||||||
|
ReasonBadAuth2
|
||||||
|
ReasonLoginNameNotExists
|
||||||
|
ReasonNotRemote
|
||||||
|
ReasonEmailIsSet
|
||||||
|
ReasonNoSource
|
||||||
|
ReasonSourceWrongType
|
||||||
|
ReasonCanPromote
|
||||||
|
ReasonPromoted
|
||||||
|
ReasonUpdateFail
|
||||||
|
ReasonErrorLoginName
|
||||||
|
ReasonErrorGetSource
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewReason(level log.Level, reason Reason, message string, args ...any) Reason {
|
||||||
|
log.Log(1, level, message, args...)
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUsersByLoginName(ctx context.Context, name string) ([]*user_model.User, error) {
|
||||||
|
if len(name) == 0 {
|
||||||
|
return nil, user_model.ErrUserNotExist{Name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]*user_model.User, 0, 5)
|
||||||
|
|
||||||
|
return users, db.GetEngine(ctx).
|
||||||
|
Table("user").
|
||||||
|
Where("login_name = ? AND login_type = ? AND type = ?", name, auth_model.Remote, user_model.UserTypeRemoteUser).
|
||||||
|
Find(&users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The remote user has:
|
||||||
|
//
|
||||||
|
// Type UserTypeRemoteUser
|
||||||
|
// LogingType Remote
|
||||||
|
// LoginName set to the unique identifier of the originating authentication source
|
||||||
|
// LoginSource set to the Remote source that can be matched against an OAuth2 source
|
||||||
|
//
|
||||||
|
// If the source from which an authentification happens is OAuth2, an existing
|
||||||
|
// remote user will be promoted to an OAuth2 user provided:
|
||||||
|
//
|
||||||
|
// user.LoginName is the same as goth.UserID (argument loginName)
|
||||||
|
// user.LoginSource has a MatchingSource equals to the name of the OAuth2 provider
|
||||||
|
//
|
||||||
|
// Once promoted, the user will be logged in without further interaction from the
|
||||||
|
// user and will own all repositories, issues, etc. associated with it.
|
||||||
|
func MaybePromoteRemoteUser(ctx context.Context, source *auth_model.Source, loginName, email string) (promoted bool, reason Reason, err error) {
|
||||||
|
user, reason, err := getRemoteUserToPromote(ctx, source, loginName, email)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
return false, reason, err
|
||||||
|
}
|
||||||
|
promote := &user_model.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Type: user_model.UserTypeIndividual,
|
||||||
|
Email: email,
|
||||||
|
LoginSource: source.ID,
|
||||||
|
LoginType: source.Type,
|
||||||
|
}
|
||||||
|
reason = NewReason(log.DEBUG, ReasonPromoted, "promote user %v: LoginName %v => %v, LoginSource %v => %v, LoginType %v => %v, Email %v => %v", user.ID, user.LoginName, promote.LoginName, user.LoginSource, promote.LoginSource, user.LoginType, promote.LoginType, user.Email, promote.Email)
|
||||||
|
if err := user_model.UpdateUserCols(ctx, promote, "type", "email", "login_source", "login_type"); err != nil {
|
||||||
|
return false, ReasonUpdateFail, err
|
||||||
|
}
|
||||||
|
return true, reason, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRemoteUserToPromote(ctx context.Context, source *auth_model.Source, loginName, email string) (*user_model.User, Reason, error) {
|
||||||
|
if !source.IsOAuth2() {
|
||||||
|
return nil, NewReason(log.DEBUG, ReasonNotAuth2, "source %v is not OAuth2", source), nil
|
||||||
|
}
|
||||||
|
oauth2Source, ok := source.Cfg.(*oauth2.Source)
|
||||||
|
if !ok {
|
||||||
|
return nil, NewReason(log.ERROR, ReasonBadAuth2, "source claims to be OAuth2 but is not"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := getUsersByLoginName(ctx, loginName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewReason(log.ERROR, ReasonErrorLoginName, "getUserByLoginName('%s') %v", loginName, err), err
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, NewReason(log.ERROR, ReasonLoginNameNotExists, "no user with LoginType UserTypeRemoteUser and LoginName '%s'", loginName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := ReasonNoSource
|
||||||
|
for _, u := range users {
|
||||||
|
userSource, err := auth_model.GetSourceByID(ctx, u.LoginSource)
|
||||||
|
if err != nil {
|
||||||
|
if auth_model.IsErrSourceNotExist(err) {
|
||||||
|
reason = NewReason(log.DEBUG, ReasonNoSource, "source id = %v for user %v not found %v", u.LoginSource, u.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, NewReason(log.ERROR, ReasonErrorGetSource, "GetSourceByID('%s') %v", u.LoginSource, err), err
|
||||||
|
}
|
||||||
|
if u.Email != "" {
|
||||||
|
reason = NewReason(log.DEBUG, ReasonEmailIsSet, "the user email is already set to '%s'", u.Email)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remoteSource, ok := userSource.Cfg.(*remote_source.Source)
|
||||||
|
if !ok {
|
||||||
|
reason = NewReason(log.DEBUG, ReasonSourceWrongType, "expected a remote source but got %T %v", userSource, userSource)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if oauth2Source.Provider != remoteSource.MatchingSource {
|
||||||
|
reason = NewReason(log.DEBUG, ReasonNoMatch, "skip OAuth2 source %s because it is different from %s which is the expected match for the remote source %s", oauth2Source.Provider, remoteSource.MatchingSource, remoteSource.URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, ReasonCanPromote, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, reason, nil
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
"code.gitea.io/gitea/routers"
|
"code.gitea.io/gitea/routers"
|
||||||
|
"code.gitea.io/gitea/services/auth/source/remote"
|
||||||
gitea_context "code.gitea.io/gitea/services/context"
|
gitea_context "code.gitea.io/gitea/services/context"
|
||||||
repo_service "code.gitea.io/gitea/services/repository"
|
repo_service "code.gitea.io/gitea/services/repository"
|
||||||
files_service "code.gitea.io/gitea/services/repository/files"
|
files_service "code.gitea.io/gitea/services/repository/files"
|
||||||
|
@ -53,7 +54,8 @@ import (
|
||||||
gouuid "github.com/google/uuid"
|
gouuid "github.com/google/uuid"
|
||||||
"github.com/markbates/goth"
|
"github.com/markbates/goth"
|
||||||
"github.com/markbates/goth/gothic"
|
"github.com/markbates/goth/gothic"
|
||||||
goth_gitlab "github.com/markbates/goth/providers/gitlab"
|
goth_gitlab "github.com/markbates/goth/providers/github"
|
||||||
|
goth_github "github.com/markbates/goth/providers/gitlab"
|
||||||
"github.com/santhosh-tekuri/jsonschema/v5"
|
"github.com/santhosh-tekuri/jsonschema/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
@ -338,6 +340,36 @@ func authSourcePayloadGitLabCustom(name string) map[string]string {
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authSourcePayloadGitHub(name string) map[string]string {
|
||||||
|
payload := authSourcePayloadOAuth2(name)
|
||||||
|
payload["oauth2_provider"] = "github"
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func authSourcePayloadGitHubCustom(name string) map[string]string {
|
||||||
|
payload := authSourcePayloadGitHub(name)
|
||||||
|
payload["oauth2_use_custom_url"] = "on"
|
||||||
|
payload["oauth2_auth_url"] = goth_github.AuthURL
|
||||||
|
payload["oauth2_token_url"] = goth_github.TokenURL
|
||||||
|
payload["oauth2_profile_url"] = goth_github.ProfileURL
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRemoteAuthSource(t *testing.T, name, url, matchingSource string) *auth.Source {
|
||||||
|
assert.NoError(t, auth.CreateSource(context.Background(), &auth.Source{
|
||||||
|
Type: auth.Remote,
|
||||||
|
Name: name,
|
||||||
|
IsActive: true,
|
||||||
|
Cfg: &remote.Source{
|
||||||
|
URL: url,
|
||||||
|
MatchingSource: matchingSource,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
source, err := auth.GetSourceByName(context.Background(), name)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
func createUser(ctx context.Context, t testing.TB, user *user_model.User) func() {
|
func createUser(ctx context.Context, t testing.TB, user *user_model.User) func() {
|
||||||
user.MustChangePassword = false
|
user.MustChangePassword = false
|
||||||
user.LowerName = strings.ToLower(user.Name)
|
user.LowerName = strings.ToLower(user.Name)
|
||||||
|
|
205
tests/integration/remote_test.go
Normal file
205
tests/integration/remote_test.go
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
// Copyright Earl Warren <contact@earl-warren.org>
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/test"
|
||||||
|
remote_service "code.gitea.io/gitea/services/remote"
|
||||||
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/markbates/goth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRemote_MaybePromoteUserSuccess(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
//
|
||||||
|
// OAuth2 authentication source GitLab
|
||||||
|
//
|
||||||
|
gitlabName := "gitlab"
|
||||||
|
_ = addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
|
||||||
|
//
|
||||||
|
// Remote authentication source matching the GitLab authentication source
|
||||||
|
//
|
||||||
|
remoteName := "remote"
|
||||||
|
remote := createRemoteAuthSource(t, remoteName, "http://mygitlab.eu", gitlabName)
|
||||||
|
|
||||||
|
//
|
||||||
|
// Create a user as if it had previously been created by the remote
|
||||||
|
// authentication source.
|
||||||
|
//
|
||||||
|
gitlabUserID := "5678"
|
||||||
|
gitlabEmail := "gitlabuser@example.com"
|
||||||
|
userBeforeSignIn := &user_model.User{
|
||||||
|
Name: "gitlabuser",
|
||||||
|
Type: user_model.UserTypeRemoteUser,
|
||||||
|
LoginType: auth_model.Remote,
|
||||||
|
LoginSource: remote.ID,
|
||||||
|
LoginName: gitlabUserID,
|
||||||
|
}
|
||||||
|
defer createUser(context.Background(), t, userBeforeSignIn)()
|
||||||
|
|
||||||
|
//
|
||||||
|
// A request for user information sent to Goth will return a
|
||||||
|
// goth.User exactly matching the user created above.
|
||||||
|
//
|
||||||
|
defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
|
||||||
|
return goth.User{
|
||||||
|
Provider: gitlabName,
|
||||||
|
UserID: gitlabUserID,
|
||||||
|
Email: gitlabEmail,
|
||||||
|
}, nil
|
||||||
|
})()
|
||||||
|
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
|
||||||
|
resp := MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
assert.Equal(t, "/", test.RedirectURL(resp))
|
||||||
|
userAfterSignIn := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userBeforeSignIn.ID})
|
||||||
|
|
||||||
|
// both are about the same user
|
||||||
|
assert.Equal(t, userAfterSignIn.ID, userBeforeSignIn.ID)
|
||||||
|
// the login time was updated, proof the login succeeded
|
||||||
|
assert.Greater(t, userAfterSignIn.LastLoginUnix, userBeforeSignIn.LastLoginUnix)
|
||||||
|
// the login type was promoted from Remote to OAuth2
|
||||||
|
assert.Equal(t, userBeforeSignIn.LoginType, auth_model.Remote)
|
||||||
|
assert.Equal(t, userAfterSignIn.LoginType, auth_model.OAuth2)
|
||||||
|
// the OAuth2 email was used to set the missing user email
|
||||||
|
assert.Equal(t, userBeforeSignIn.Email, "")
|
||||||
|
assert.Equal(t, userAfterSignIn.Email, gitlabEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemote_MaybePromoteUserFail(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
//
|
||||||
|
// OAuth2 authentication source GitLab
|
||||||
|
//
|
||||||
|
gitlabName := "gitlab"
|
||||||
|
gitlabSource := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
|
||||||
|
//
|
||||||
|
// Remote authentication source matching the GitLab authentication source
|
||||||
|
//
|
||||||
|
remoteName := "remote"
|
||||||
|
remoteSource := createRemoteAuthSource(t, remoteName, "http://mygitlab.eu", gitlabName)
|
||||||
|
|
||||||
|
{
|
||||||
|
promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, &auth_model.Source{}, "", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, promoted)
|
||||||
|
assert.Equal(t, remote_service.ReasonNotAuth2, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
remoteSource.Type = auth_model.OAuth2
|
||||||
|
promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, remoteSource, "", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, promoted)
|
||||||
|
assert.Equal(t, remote_service.ReasonBadAuth2, reason)
|
||||||
|
remoteSource.Type = auth_model.Remote
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, "unknownloginname", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, promoted)
|
||||||
|
assert.Equal(t, remote_service.ReasonLoginNameNotExists, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
remoteUserID := "844"
|
||||||
|
remoteUser := &user_model.User{
|
||||||
|
Name: "withmailuser",
|
||||||
|
Type: user_model.UserTypeRemoteUser,
|
||||||
|
LoginType: auth_model.Remote,
|
||||||
|
LoginSource: remoteSource.ID,
|
||||||
|
LoginName: remoteUserID,
|
||||||
|
Email: "some@example.com",
|
||||||
|
}
|
||||||
|
defer createUser(context.Background(), t, remoteUser)()
|
||||||
|
promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, promoted)
|
||||||
|
assert.Equal(t, remote_service.ReasonEmailIsSet, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
remoteUserID := "7464"
|
||||||
|
nonexistentloginsource := int64(4344)
|
||||||
|
remoteUser := &user_model.User{
|
||||||
|
Name: "badsourceuser",
|
||||||
|
Type: user_model.UserTypeRemoteUser,
|
||||||
|
LoginType: auth_model.Remote,
|
||||||
|
LoginSource: nonexistentloginsource,
|
||||||
|
LoginName: remoteUserID,
|
||||||
|
}
|
||||||
|
defer createUser(context.Background(), t, remoteUser)()
|
||||||
|
promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, promoted)
|
||||||
|
assert.Equal(t, remote_service.ReasonNoSource, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
remoteUserID := "33335678"
|
||||||
|
remoteUser := &user_model.User{
|
||||||
|
Name: "badremoteuser",
|
||||||
|
Type: user_model.UserTypeRemoteUser,
|
||||||
|
LoginType: auth_model.Remote,
|
||||||
|
LoginSource: gitlabSource.ID,
|
||||||
|
LoginName: remoteUserID,
|
||||||
|
}
|
||||||
|
defer createUser(context.Background(), t, remoteUser)()
|
||||||
|
promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, promoted)
|
||||||
|
assert.Equal(t, remote_service.ReasonSourceWrongType, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
unrelatedName := "unrelated"
|
||||||
|
unrelatedSource := addAuthSource(t, authSourcePayloadGitHubCustom(unrelatedName))
|
||||||
|
assert.NotNil(t, unrelatedSource)
|
||||||
|
|
||||||
|
remoteUserID := "488484"
|
||||||
|
remoteEmail := "4848484@example.com"
|
||||||
|
remoteUser := &user_model.User{
|
||||||
|
Name: "unrelateduser",
|
||||||
|
Type: user_model.UserTypeRemoteUser,
|
||||||
|
LoginType: auth_model.Remote,
|
||||||
|
LoginSource: remoteSource.ID,
|
||||||
|
LoginName: remoteUserID,
|
||||||
|
}
|
||||||
|
defer createUser(context.Background(), t, remoteUser)()
|
||||||
|
promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, unrelatedSource, remoteUserID, remoteEmail)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, promoted)
|
||||||
|
assert.Equal(t, remote_service.ReasonNoMatch, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
remoteUserID := "5678"
|
||||||
|
remoteEmail := "gitlabuser@example.com"
|
||||||
|
remoteUser := &user_model.User{
|
||||||
|
Name: "remoteuser",
|
||||||
|
Type: user_model.UserTypeRemoteUser,
|
||||||
|
LoginType: auth_model.Remote,
|
||||||
|
LoginSource: remoteSource.ID,
|
||||||
|
LoginName: remoteUserID,
|
||||||
|
}
|
||||||
|
defer createUser(context.Background(), t, remoteUser)()
|
||||||
|
promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, remoteEmail)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, promoted)
|
||||||
|
assert.Equal(t, remote_service.ReasonPromoted, reason)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue