Skip to content

Commit

Permalink
Auth: Set the default org after User login (#83918)
Browse files Browse the repository at this point in the history
* poc

* add logger, skip hook when user is not assigned to default org

* Add tests, move to hook folder

* docs

* Skip for OrgId < 1

* Address feedback

* Update docs/sources/setup-grafana/configure-grafana/_index.md

* lint

* Move the hook to org_sync.go

* Update pkg/services/authn/authnimpl/sync/org_sync.go

* Handle the case when GetUserOrgList returns error

---------

Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>
Co-authored-by: Karl Persson <kalle.persson@grafana.com>
  • Loading branch information
3 people committed Mar 12, 2024
1 parent 8c06c0d commit 63f1c30
Show file tree
Hide file tree
Showing 9 changed files with 687 additions and 6 deletions.
3 changes: 3 additions & 0 deletions conf/defaults.ini
Expand Up @@ -473,6 +473,9 @@ auto_assign_org_role = Viewer
# Require email validation before sign up completes
verify_email_enabled = false

# Redirect to default OrgId after login
login_default_org_id =

# Background text for the user field on the login page
login_hint = email or username
password_hint = password
Expand Down
3 changes: 3 additions & 0 deletions conf/sample.ini
Expand Up @@ -449,6 +449,9 @@
# Require email validation before sign up completes
;verify_email_enabled = false

# Redirect to default OrgId after login
;login_default_org_id =

# Background text for the user field on the login page
;login_hint = email or username
;password_hint = password
Expand Down
4 changes: 4 additions & 0 deletions docs/sources/setup-grafana/configure-grafana/_index.md
Expand Up @@ -820,6 +820,10 @@ The available options are `Viewer` (default), `Admin`, `Editor`, and `None`. For

Require email validation before sign up completes or when updating a user email address. Default is `false`.

### login_default_org_id

Set the default organization for users when they sign in. The default is `-1`.

### login_hint

Text used as placeholder text on login page for login/username input.
Expand Down
4 changes: 3 additions & 1 deletion pkg/services/authn/authnimpl/service.go
Expand Up @@ -147,7 +147,7 @@ func ProvideService(

// FIXME (jguer): move to User package
userSyncService := sync.ProvideUserSync(userService, userProtectionService, authInfoService, quotaService)
orgUserSyncService := sync.ProvideOrgSync(userService, orgService, accessControlService)
orgUserSyncService := sync.ProvideOrgSync(userService, orgService, accessControlService, cfg)
s.RegisterPostAuthHook(userSyncService.SyncUserHook, 10)
s.RegisterPostAuthHook(userSyncService.EnableUserHook, 20)
s.RegisterPostAuthHook(orgUserSyncService.SyncOrgRolesHook, 30)
Expand All @@ -162,6 +162,8 @@ func ProvideService(

s.RegisterPostAuthHook(rbacSync.SyncPermissionsHook, 120)

s.RegisterPostAuthHook(orgUserSyncService.SetDefaultOrgHook, 130)

return s
}

Expand Down
69 changes: 64 additions & 5 deletions pkg/services/authn/authnimpl/sync/org_sync.go
Expand Up @@ -3,6 +3,7 @@ package sync
import (
"context"
"errors"
"fmt"
"sort"

"github.com/grafana/grafana/pkg/infra/log"
Expand All @@ -11,16 +12,18 @@ import (
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)

func ProvideOrgSync(userService user.Service, orgService org.Service, accessControl accesscontrol.Service) *OrgSync {
return &OrgSync{userService, orgService, accessControl, log.New("org.sync")}
func ProvideOrgSync(userService user.Service, orgService org.Service, accessControl accesscontrol.Service, cfg *setting.Cfg) *OrgSync {
return &OrgSync{userService, orgService, accessControl, cfg, log.New("org.sync")}
}

type OrgSync struct {
userService user.Service
orgService org.Service
accessControl accesscontrol.Service
cfg *setting.Cfg

log log.Logger
}
Expand Down Expand Up @@ -72,7 +75,7 @@ func (s *OrgSync) SyncOrgRolesHook(ctx context.Context, id *authn.Identity, _ *a
// update role
cmd := &org.UpdateOrgUserCommand{OrgID: orga.OrgID, UserID: userID, Role: extRole}
if err := s.orgService.UpdateOrgUser(ctx, cmd); err != nil {
s.log.FromContext(ctx).Error("Failed to update active org user", "id", id.ID, "error", err)
ctxLogger.Error("Failed to update active org user", "id", id.ID, "error", err)
return err
}
}
Expand All @@ -90,7 +93,7 @@ func (s *OrgSync) SyncOrgRolesHook(ctx context.Context, id *authn.Identity, _ *a
cmd := &org.AddOrgUserCommand{UserID: userID, Role: orgRole, OrgID: orgId}
err := s.orgService.AddOrgUser(ctx, cmd)
if err != nil && !errors.Is(err, org.ErrOrgNotFound) {
s.log.FromContext(ctx).Error("Failed to update active org for user", "id", id.ID, "error", err)
ctxLogger.Error("Failed to update active org for user", "id", id.ID, "error", err)
return err
}
}
Expand All @@ -100,7 +103,7 @@ func (s *OrgSync) SyncOrgRolesHook(ctx context.Context, id *authn.Identity, _ *a
ctxLogger.Debug("Removing user's organization membership as part of syncing with OAuth login", "id", id.ID, "orgId", orgID)
cmd := &org.RemoveOrgUserCommand{OrgID: orgID, UserID: userID}
if err := s.orgService.RemoveOrgUser(ctx, cmd); err != nil {
s.log.FromContext(ctx).Error("Failed to remove user from org", "id", id.ID, "orgId", orgID, "error", err)
ctxLogger.Error("Failed to remove user from org", "id", id.ID, "orgId", orgID, "error", err)
if errors.Is(err, org.ErrLastOrgAdmin) {
continue
}
Expand Down Expand Up @@ -128,3 +131,59 @@ func (s *OrgSync) SyncOrgRolesHook(ctx context.Context, id *authn.Identity, _ *a

return nil
}

func (s *OrgSync) SetDefaultOrgHook(ctx context.Context, currentIdentity *authn.Identity, r *authn.Request) error {
if s.cfg.LoginDefaultOrgId < 1 || currentIdentity == nil {
return nil
}

ctxLogger := s.log.FromContext(ctx)

namespace, identifier := currentIdentity.GetNamespacedID()
if namespace != identity.NamespaceUser {
ctxLogger.Debug("Skipping default org sync, not a user", "namespace", namespace)
return nil
}

userID, err := identity.IntIdentifier(namespace, identifier)
if err != nil {
ctxLogger.Debug("Skipping default org sync, invalid ID for identity", "id", currentIdentity.ID, "namespace", namespace, "err", err)
return nil
}

hasAssignedToOrg, err := s.validateUsingOrg(ctx, userID, s.cfg.LoginDefaultOrgId)
if err != nil {
ctxLogger.Error("Skipping default org sync, failed to validate user's organizations", "id", currentIdentity.ID, "err", err)
return nil
}

if !hasAssignedToOrg {
ctxLogger.Debug("Skipping default org sync, user is not assigned to org", "id", currentIdentity.ID, "org", s.cfg.LoginDefaultOrgId)
return nil
}

cmd := user.SetUsingOrgCommand{UserID: userID, OrgID: s.cfg.LoginDefaultOrgId}
if err := s.userService.SetUsingOrg(ctx, &cmd); err != nil {
ctxLogger.Error("Failed to set default org", "id", currentIdentity.ID, "err", err)
return err
}

return nil
}

func (s *OrgSync) validateUsingOrg(ctx context.Context, userID int64, orgID int64) (bool, error) {
query := org.GetUserOrgListQuery{UserID: userID}

result, err := s.orgService.GetUserOrgList(ctx, &query)
if err != nil {
return false, fmt.Errorf("failed to get user's organizations: %w", err)
}

// validate that the org id in the list
for _, other := range result {
if other.OrgID == orgID {
return true, nil
}
}
return false, nil
}
96 changes: 96 additions & 0 deletions pkg/services/authn/authnimpl/sync/org_sync_test.go
Expand Up @@ -2,9 +2,11 @@ package sync

import (
"context"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"

"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models/roletype"
Expand All @@ -16,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
)

func TestOrgSync_SyncOrgRolesHook(t *testing.T) {
Expand Down Expand Up @@ -124,3 +127,96 @@ func TestOrgSync_SyncOrgRolesHook(t *testing.T) {
})
}
}

func TestOrgSync_SetDefaultOrgHook(t *testing.T) {
testCases := []struct {
name string
defaultOrgSetting int64
identity *authn.Identity
setupMock func(*usertest.MockService, *orgtest.FakeOrgService)

wantErr bool
}{
{
name: "should set default org",
defaultOrgSetting: 2,
identity: &authn.Identity{ID: "user:1"},
setupMock: func(userService *usertest.MockService, orgService *orgtest.FakeOrgService) {
userService.On("SetUsingOrg", mock.Anything, mock.MatchedBy(func(cmd *user.SetUsingOrgCommand) bool {
return cmd.UserID == 1 && cmd.OrgID == 2
})).Return(nil)
},
},
{
name: "should skip setting the default org when default org is not set",
defaultOrgSetting: -1,
identity: &authn.Identity{ID: "user:1"},
},
{
name: "should skip setting the default org when identity is nil",
defaultOrgSetting: -1,
identity: nil,
},
{
name: "should skip setting the default org when identity is not a user",
defaultOrgSetting: 2,
identity: &authn.Identity{ID: "service-account:1"},
},
{
name: "should skip setting the default org when user id is not valid",
defaultOrgSetting: 2,
identity: &authn.Identity{ID: "user:invalid"},
},
{
name: "should skip setting the default org when user is not allowed to use the configured default org",
defaultOrgSetting: 3,
identity: &authn.Identity{ID: "user:1"},
},
{
name: "should skip setting the default org when validateUsingOrg returns error",
defaultOrgSetting: 2,
identity: &authn.Identity{ID: "user:1"},
setupMock: func(userService *usertest.MockService, orgService *orgtest.FakeOrgService) {
orgService.ExpectedError = fmt.Errorf("error")
},
},
{
name: "should return error when the user org update was unsuccessful",
defaultOrgSetting: 2,
identity: &authn.Identity{ID: "user:1"},
setupMock: func(userService *usertest.MockService, orgService *orgtest.FakeOrgService) {
userService.On("SetUsingOrg", mock.Anything, mock.Anything).Return(fmt.Errorf("error"))
},
wantErr: true,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
cfg := setting.NewCfg()
cfg.LoginDefaultOrgId = tt.defaultOrgSetting

userService := &usertest.MockService{}
defer userService.AssertExpectations(t)

orgService := &orgtest.FakeOrgService{
ExpectedUserOrgDTO: []*org.UserOrgDTO{{OrgID: 2}},
}

if tt.setupMock != nil {
tt.setupMock(userService, orgService)
}

s := &OrgSync{
userService: userService,
orgService: orgService,
accessControl: actest.FakeService{},
log: log.NewNopLogger(),
cfg: cfg,
}

if err := s.SetDefaultOrgHook(context.Background(), tt.identity, nil); (err != nil) != tt.wantErr {
t.Errorf("OrgSync.SetDefaultOrgHook() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
1 change: 1 addition & 0 deletions pkg/services/user/user.go
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/registry"
)

//go:generate mockery --name Service --structname MockService --outpkg usertest --filename mock.go --output ./usertest/
type Service interface {
registry.ProvidesUsageStats
Create(context.Context, *CreateUserCommand) (*User, error)
Expand Down

0 comments on commit 63f1c30

Please sign in to comment.