Auth: SSO settings foundations (#77724)

* inital changes, db migration

* changes

* Implement basic GetAll, Delete

* Add first batch of tests

* Add more tests

* Add service tests for GetForProvider, List

* Update http_server.go + wire.go

* Lint + update fixed role

* Update CODEOWNERS

* Change API init

* Change roles, rename

* Review with @kalleep

* Revert a mistakenly changed part

* Updates based on @dmihai 's feedback

---------

Co-authored-by: Karl Persson <kalle.persson@grafana.com>
This commit is contained in:
Misi
2023-11-08 10:50:01 +01:00
committed by GitHub
co-authored by Karl Persson
parent 8a46dc39d0
commit 5285e9503b
24 changed files with 1436 additions and 3 deletions
+1
View File
@@ -127,6 +127,7 @@
/pkg/services/secrets/ @grafana/backend-platform
/pkg/services/shorturls/ @grafana/backend-platform
/pkg/services/sqlstore/ @grafana/backend-platform
/pkg/services/ssosettings/ @grafana/identity-access-team
/pkg/services/star/ @grafana/backend-platform
/pkg/services/stats/ @grafana/backend-platform
/pkg/services/tag/ @grafana/backend-platform
@@ -176,3 +176,4 @@ The following toggles require explicitly setting Grafana's [app mode]({{< relref
| `idForwarding` | Generate signed id token for identity that can be forwarded to plugins and external services |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `panelTitleSearchInV1` | Enable searching for dashboards using panel title in search v1 |
| `ssoSettingsApi` | Enables the SSO settings API |
@@ -160,4 +160,5 @@ export interface FeatureToggles {
dashboardSceneForViewers?: boolean;
panelFilterVariable?: boolean;
pdfTables?: boolean;
ssoSettingsApi?: boolean;
}
@@ -36,6 +36,7 @@ import (
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
samanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/services/store"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/store/sanitizer"
@@ -63,7 +64,7 @@ func ProvideBackgroundServiceRegistry(
_ serviceaccounts.Service, _ *guardian.Provider,
_ *plugindashboardsservice.DashboardUpdater, _ *sanitizer.Provider,
_ *grpcserver.HealthService, _ entity.EntityStoreServer, _ *grpcserver.ReflectionService, _ *ldapapi.Service,
_ *apiregistry.Service, _ auth.IDService, _ *teamapi.TeamAPI,
_ *apiregistry.Service, _ auth.IDService, _ *teamapi.TeamAPI, _ ssosettings.Service,
) *BackgroundServiceRegistry {
return NewBackgroundServiceRegistry(
httpServer,
+4
View File
@@ -130,6 +130,8 @@ import (
"github.com/grafana/grafana/pkg/services/signingkeys"
"github.com/grafana/grafana/pkg/services/signingkeys/signingkeysimpl"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoSettingsImpl "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingsimpl"
starApi "github.com/grafana/grafana/pkg/services/star/api"
"github.com/grafana/grafana/pkg/services/star/starimpl"
"github.com/grafana/grafana/pkg/services/stats/statsimpl"
@@ -377,6 +379,8 @@ var wireBasicSet = wire.NewSet(
loggermw.Provide,
signingkeysimpl.ProvideEmbeddedSigningKeysService,
wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)),
ssoSettingsImpl.ProvideService,
wire.Bind(new(ssosettings.Service), new(*ssoSettingsImpl.SSOSettingsService)),
idimpl.ProvideService,
wire.Bind(new(auth.IDService), new(*idimpl.Service)),
grafanaapiserver.WireSet,
+2 -2
View File
@@ -202,11 +202,11 @@ var (
Permissions: []Permission{
{
Action: ActionSettingsRead,
Scope: ScopeSettingsSAML,
Scope: ScopeSettingsAuth,
},
{
Action: ActionSettingsWrite,
Scope: ScopeSettingsSAML,
Scope: ScopeSettingsAuth,
},
},
}
+8
View File
@@ -1032,6 +1032,14 @@ var (
FrontendOnly: false,
Owner: grafanaSharingSquad,
},
{
Name: "ssoSettingsApi",
Description: "Enables the SSO settings API",
RequiresDevMode: true,
Stage: FeatureStageExperimental,
FrontendOnly: false,
Owner: identityAccessTeam,
},
}
)
+1
View File
@@ -141,3 +141,4 @@ extractFieldsNameDeduplication,experimental,@grafana/grafana-bi-squad,false,fals
dashboardSceneForViewers,experimental,@grafana/dashboards-squad,false,false,false,true
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,false,true
pdfTables,privatePreview,@grafana/sharing-squad,false,false,false,false
ssoSettingsApi,experimental,@grafana/identity-access-team,true,false,false,false
1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
141 dashboardSceneForViewers experimental @grafana/dashboards-squad false false false true
142 panelFilterVariable experimental @grafana/dashboards-squad false false false true
143 pdfTables privatePreview @grafana/sharing-squad false false false false
144 ssoSettingsApi experimental @grafana/identity-access-team true false false false
+4
View File
@@ -574,4 +574,8 @@ const (
// FlagPdfTables
// Enables generating table data as PDF in reporting
FlagPdfTables = "pdfTables"
// FlagSsoSettingsApi
// Enables the SSO settings API
FlagSsoSettingsApi = "ssoSettingsApi"
)
@@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/anonservice"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/oauthserver"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/signingkeys"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ssosettings"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert"
. "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
@@ -106,6 +107,8 @@ func (*OSSMigrations) AddMigration(mg *Migrator) {
ualert.CreatedFoldersMigration(mg)
dashboardFolderMigrations.AddDashboardFolderMigrations(mg)
ssosettings.AddMigration(mg)
}
func addStarMigrations(mg *Migrator) {
@@ -0,0 +1,19 @@
package ssosettings
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func AddMigration(mg *migrator.Migrator) {
var ssoSettingV1 = migrator.Table{
Name: "sso_setting",
Columns: []*migrator.Column{
{Name: "id", Type: migrator.DB_NVarchar, Length: 40, IsPrimaryKey: true}, // Store uuidv4
{Name: "provider", Type: migrator.DB_NVarchar, Length: 255, Nullable: false},
{Name: "settings", Type: migrator.DB_Text, Nullable: false},
{Name: "created", Type: migrator.DB_DateTime, Nullable: false},
{Name: "updated", Type: migrator.DB_DateTime, Nullable: false},
{Name: "is_deleted", Type: migrator.DB_Bool, Nullable: false, Default: "0"},
},
}
mg.AddMigration("create sso_setting table", migrator.NewAddTableMigration(ssoSettingV1))
}
+114
View File
@@ -0,0 +1,114 @@
package api
import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/log"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/web"
)
type Api struct {
Log log.Logger
RouteRegister routing.RouteRegister
AccessControl ac.AccessControl
Features *featuremgmt.FeatureManager
SSOSettingsService ssosettings.Service
}
func ProvideApi(
ssoSettingsSvc ssosettings.Service,
routeRegister routing.RouteRegister,
ac ac.AccessControl,
) *Api {
api := &Api{
SSOSettingsService: ssoSettingsSvc,
RouteRegister: routeRegister,
AccessControl: ac,
Log: log.New("ssosettings.api"),
}
return api
}
// RegisterAPIEndpoints Registers Endpoints on Grafana Router
func (api *Api) RegisterAPIEndpoints() {
api.RouteRegister.Group("/api/v1/sso-settings", func(router routing.RouteRegister) {
auth := ac.Middleware(api.AccessControl)
scopeKey := ac.Parameter(":key")
settingsScope := ac.Scope("settings", "auth."+scopeKey, "*")
reqWriteAccess := auth(ac.EvalAny(
ac.EvalPermission(ac.ActionSettingsWrite, ac.ScopeSettingsAuth),
ac.EvalPermission(ac.ActionSettingsWrite, settingsScope)))
router.Get("/", auth(ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsAuth)), routing.Wrap(api.listAllProvidersSettings))
router.Get("/:key", auth(ac.EvalPermission(ac.ActionSettingsRead, settingsScope)), routing.Wrap(api.getProviderSettings))
router.Put("/:key", reqWriteAccess, routing.Wrap(api.updateProviderSettings))
router.Delete("/:key", reqWriteAccess, routing.Wrap(api.removeProviderSettings))
})
}
func (api *Api) listAllProvidersSettings(c *contextmodel.ReqContext) response.Response {
providers, err := api.SSOSettingsService.List(c.Req.Context(), c.SignedInUser)
if err != nil {
return response.Error(500, "Failed to get providers", err)
}
return response.JSON(200, providers)
}
func (api *Api) getProviderSettings(c *contextmodel.ReqContext) response.Response {
key, ok := web.Params(c.Req)[":key"]
if !ok {
return response.Error(400, "Missing key", nil)
}
settings, err := api.SSOSettingsService.GetForProvider(c.Req.Context(), key)
if err != nil {
return response.Error(404, "The provider was not found", err)
}
return response.JSON(200, settings)
}
func (api *Api) updateProviderSettings(c *contextmodel.ReqContext) response.Response {
key, ok := web.Params(c.Req)[":key"]
if !ok {
return response.Error(400, "Missing key", nil)
}
var newSettings models.SSOSetting
if err := web.Bind(c.Req, &newSettings); err != nil {
return response.Error(400, "Failed to parse request body", err)
}
err := api.SSOSettingsService.Upsert(c.Req.Context(), key, newSettings.Settings)
// TODO: first check whether the error is referring to validation errors
// other error
if err != nil {
return response.Error(500, "Failed to update provider settings", err)
}
return response.JSON(204, nil)
}
func (api *Api) removeProviderSettings(c *contextmodel.ReqContext) response.Response {
key, ok := web.Params(c.Req)[":key"]
if !ok {
return response.Error(400, "Missing key", nil)
}
err := api.SSOSettingsService.Delete(c.Req.Context(), key)
if err != nil {
return response.Error(500, "Failed to delete provider settings", err)
}
return response.JSON(204, nil)
}
+3
View File
@@ -0,0 +1,3 @@
package api
// TODO: add tests when you implement the final version of the API endpoint
@@ -0,0 +1,126 @@
package database
import (
"context"
"time"
"github.com/google/uuid"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/services/ssosettings/models"
)
type SSOSettingsStore struct {
sqlStore db.DB
log log.Logger
}
func ProvideStore(sqlStore db.DB) *SSOSettingsStore {
return &SSOSettingsStore{
sqlStore: sqlStore,
log: log.New("ssosettings.store"),
}
}
var _ ssosettings.Store = (*SSOSettingsStore)(nil)
func (s *SSOSettingsStore) Get(ctx context.Context, provider string) (*models.SSOSetting, error) {
result := models.SSOSetting{Provider: provider}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
var err error
sess.Table("sso_setting")
found, err := sess.Where("is_deleted = ?", s.sqlStore.GetDialect().BooleanStr(false)).Get(&result)
if err != nil {
return err
}
if !found {
return ssosettings.ErrNotFound
}
return nil
})
if err != nil {
return nil, err
}
return &result, nil
}
func (s *SSOSettingsStore) List(ctx context.Context) ([]*models.SSOSetting, error) {
result := make([]*models.SSOSetting, 0)
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
sess.Table("sso_setting")
err := sess.Where("is_deleted = ?", s.sqlStore.GetDialect().BooleanStr(false)).Find(&result)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *SSOSettingsStore) Upsert(ctx context.Context, provider string, data map[string]interface{}) error {
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
var err error
found, err := sess.Where("provider = ? AND is_deleted = ?", provider, s.sqlStore.GetDialect().BooleanStr(false)).Exist(&models.SSOSetting{})
if err != nil {
return err
}
if found {
_, err = sess.Where("provider = ? AND is_deleted = ?", provider, s.sqlStore.GetDialect().BooleanStr(false)).Update(&models.SSOSetting{
Settings: data,
Updated: time.Now().UTC(),
})
} else {
_, err = sess.Insert(&models.SSOSetting{
ID: uuid.New().String(),
Provider: provider,
Settings: data,
Created: time.Now().UTC(),
Updated: time.Now().UTC(),
})
}
return err
})
return err
}
func (s *SSOSettingsStore) Patch(ctx context.Context, provider string, data map[string]interface{}) error {
panic("not implemented") // TODO: Implement
}
func (s *SSOSettingsStore) Delete(ctx context.Context, provider string) error {
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
existing := new(models.SSOSetting)
found, err := sess.Where("provider = ? AND is_deleted = ?", provider, s.sqlStore.GetDialect().BooleanStr(false)).Get(existing)
if err != nil {
return err
}
if !found {
return nil // nothing to delete
}
existing.Updated = time.Now().UTC()
existing.IsDeleted = true
_, err = sess.ID(existing.ID).MustCols("updated", "is_deleted").Update(existing)
return err
})
return err
}
@@ -0,0 +1,215 @@
package database
import (
"context"
"testing"
"golang.org/x/exp/maps"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/stretchr/testify/require"
)
func TestIntegrationGetSSOSettings(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
var sqlStore *sqlstore.SQLStore
var ssoSettingsStore *SSOSettingsStore
setup := func() {
sqlStore = db.InitTestDB(t)
ssoSettingsStore = ProvideStore(sqlStore)
err := insertSSOSetting(ssoSettingsStore, "azuread", nil)
require.NoError(t, err)
}
t.Run("returns existing SSO settings", func(t *testing.T) {
setup()
expected := &models.SSOSetting{
Provider: "azuread",
Settings: map[string]interface{}{
"enabled": true,
},
}
actual, err := ssoSettingsStore.Get(context.Background(), "azuread")
require.NoError(t, err)
require.True(t, maps.Equal(expected.Settings, actual.Settings))
})
t.Run("returns not found if the SSO setting is missing for the specified provider", func(t *testing.T) {
setup()
_, err := ssoSettingsStore.Get(context.Background(), "okta")
require.ErrorAs(t, err, &ssosettings.ErrNotFound)
})
t.Run("returns not found if the SSO setting is soft deleted for the specified provider", func(t *testing.T) {
setup()
err := ssoSettingsStore.Delete(context.Background(), "azuread")
require.NoError(t, err)
_, err = ssoSettingsStore.Get(context.Background(), "azuread")
require.ErrorAs(t, err, &ssosettings.ErrNotFound)
})
}
func TestIntegrationUpsertSSOSettings(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
var sqlStore *sqlstore.SQLStore
var ssoSettingsStore *SSOSettingsStore
setup := func() {
sqlStore = db.InitTestDB(t)
ssoSettingsStore = ProvideStore(sqlStore)
}
t.Run("insert a new SSO setting successfully", func(t *testing.T) {
setup()
expected := &models.SSOSetting{
Provider: "azuread",
Settings: map[string]interface{}{
"enabled": true,
},
}
err := ssoSettingsStore.Upsert(context.Background(), "azuread", map[string]interface{}{
"enabled": true,
})
require.NoError(t, err)
actual, err := ssoSettingsStore.Get(context.Background(), "azuread")
require.NoError(t, err)
require.True(t, maps.Equal(expected.Settings, actual.Settings))
})
t.Run("replaces an existing SSO setting for the specified provider", func(t *testing.T) {
setup()
err := ssoSettingsStore.Upsert(context.Background(), "azuread", map[string]interface{}{
"enabled": true,
})
require.NoError(t, err)
err = ssoSettingsStore.Upsert(context.Background(), "azuread", map[string]interface{}{
"enabled": false,
})
require.NoError(t, err)
actual, err := ssoSettingsStore.Get(context.Background(), "azuread")
require.NoError(t, err)
list, err := ssoSettingsStore.List(context.Background())
require.NoError(t, err)
require.Equal(t, 1, len(list))
require.Equal(t, false, actual.Settings["enabled"])
})
}
func TestIntegrationListSSOSettings(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
var sqlStore *sqlstore.SQLStore
var ssoSettingsStore *SSOSettingsStore
setup := func() {
sqlStore = db.InitTestDB(t)
ssoSettingsStore = ProvideStore(sqlStore)
err := insertSSOSetting(ssoSettingsStore, "azuread", map[string]interface{}{
"enabled": true,
})
require.NoError(t, err)
err = insertSSOSetting(ssoSettingsStore, "okta", map[string]interface{}{
"enabled": false,
})
require.NoError(t, err)
}
t.Run("returns every SSO settings successfully", func(t *testing.T) {
setup()
list, err := ssoSettingsStore.List(context.Background())
require.NoError(t, err)
require.Equal(t, 2, len(list))
})
}
func TestIntegrationDeleteSSOSettings(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
var sqlStore *sqlstore.SQLStore
var ssoSettingsStore *SSOSettingsStore
setup := func() {
sqlStore = db.InitTestDB(t)
ssoSettingsStore = ProvideStore(sqlStore)
}
t.Run("soft deletes the settings successfully", func(t *testing.T) {
setup()
err := insertSSOSetting(ssoSettingsStore, "azuread", map[string]interface{}{
"enabled": true,
})
require.NoError(t, err)
err = ssoSettingsStore.Delete(context.Background(), "azuread")
require.NoError(t, err)
var count int64
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
count, err = sess.Table("sso_setting").Where("is_deleted = ?", sqlStore.GetDialect().BooleanStr(true)).Count()
return err
})
require.NoError(t, err)
require.Equal(t, int64(1), count)
})
t.Run("return without error if the integration was not found", func(t *testing.T) {
setup()
err := ssoSettingsStore.Delete(context.Background(), "azuread")
require.NoError(t, err)
var count int64
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
count, err = sess.Table("sso_setting").Where("is_deleted = ?", sqlStore.GetDialect().BooleanStr(true)).Count()
return err
})
require.NoError(t, err)
require.Equal(t, int64(0), count)
})
}
func insertSSOSetting(ssoSettingsStore ssosettings.Store, provider string, settings map[string]interface{}) error {
if settings == nil {
settings = map[string]interface{}{
"enabled": true,
}
}
return ssoSettingsStore.Upsert(context.Background(), provider, settings)
}
+7
View File
@@ -0,0 +1,7 @@
package ssosettings
import "errors"
var (
ErrNotFound = errors.New("not found")
)
+45
View File
@@ -0,0 +1,45 @@
package models
import (
"encoding/json"
"fmt"
"time"
)
type SettingsSource int
const (
DB = iota
System
)
func (s SettingsSource) MarshalJSON() ([]byte, error) {
switch s {
case DB:
return json.Marshal("database")
case System:
return json.Marshal("system")
default:
return nil, fmt.Errorf("unknown source: %d", s)
}
}
type SSOSetting struct {
ID string `xorm:"id pk" json:"-"`
Provider string `xorm:"provider" json:"provider"`
Settings map[string]interface{} `xorm:"settings" json:"settings"`
Created time.Time `xorm:"created" json:"-"`
Updated time.Time `xorm:"updated" json:"-"`
IsDeleted bool `xorm:"is_deleted" json:"-"`
Source SettingsSource `xorm:"-" json:"source"`
}
// TableName returns the table name (needed for Xorm)
func (s SSOSetting) TableName() string {
return "sso_setting"
}
type SSOSettingsResponse struct {
Settings map[string]interface{} `json:"settings"`
Provider string `json:"type"`
}
+58
View File
@@ -0,0 +1,58 @@
package ssosettings
import (
"context"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ssosettings/models"
)
var (
// ConfigurableOAuthProviders is a list of OAuth providers that can be configured from the API
// TODO: make it configurable
ConfigurableOAuthProviders = []string{"github", "gitlab", "google", "generic_oauth", "azuread", "okta"}
AllOAuthProviders = []string{"github", "gitlab", "google", "generic_oauth", "grafana_com", "azuread", "okta"}
)
// Service is a SSO settings service
type Service interface {
// List returns all SSO settings from DB and config files
List(ctx context.Context, requester identity.Requester) ([]*models.SSOSetting, error)
// GetForProvider returns the SSO settings for a given provider (DB or config file)
GetForProvider(ctx context.Context, provider string) (*models.SSOSetting, error)
// Upsert creates or updates the SSO settings for a given provider
Upsert(ctx context.Context, provider string, data map[string]interface{}) error
// Delete deletes the SSO settings for a given provider (soft delete)
Delete(ctx context.Context, provider string) error
// Patch updates the specified SSO settings (key-value pairs) for a given provider
Patch(ctx context.Context, provider string, data map[string]interface{}) error
// RegisterReloadable registers a reloadable provider
RegisterReloadable(ctx context.Context, provider string, reloadable Reloadable)
// Reload implements ssosettings.Reloadable interface
Reload(ctx context.Context, provider string)
}
// Reloadable is an interface that can be implemented by a provider to allow it to be reloaded
type Reloadable interface {
Reload(ctx context.Context) error
}
// FallbackStrategy is an interface that can be implemented to allow a provider to load settings from a different source
// than the database. This is useful for providers that are not configured in the database, but instead are configured
// using the config file and/or environment variables. Used mostly for backwards compatibility.
type FallbackStrategy interface {
IsMatch(provider string) bool
ParseConfigFromSystem(ctx context.Context) (map[string]interface{}, error)
}
// Store is a SSO settings store
//
//go:generate mockery --name Store --structname MockStore --outpkg ssosettingstests --filename store_mock.go --output ./ssosettingstests/
type Store interface {
Get(ctx context.Context, provider string) (*models.SSOSetting, error)
List(ctx context.Context) ([]*models.SSOSetting, error)
Upsert(ctx context.Context, provider string, data map[string]interface{}) error
Patch(ctx context.Context, provider string, data map[string]interface{}) error
Delete(ctx context.Context, provider string) error
}
@@ -0,0 +1,178 @@
package ssosettingsimpl
import (
"context"
"errors"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/services/ssosettings/api"
"github.com/grafana/grafana/pkg/services/ssosettings/database"
"github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/strategies"
"github.com/grafana/grafana/pkg/setting"
)
var _ ssosettings.Service = (*SSOSettingsService)(nil)
type SSOSettingsService struct {
log log.Logger
cfg *setting.Cfg
store ssosettings.Store
ac ac.AccessControl
fbStrategies []ssosettings.FallbackStrategy
}
func ProvideService(cfg *setting.Cfg, sqlStore db.DB, ac ac.AccessControl,
routeRegister routing.RouteRegister, features *featuremgmt.FeatureManager) *SSOSettingsService {
strategies := []ssosettings.FallbackStrategy{
strategies.NewOAuthStrategy(cfg),
// register other strategies here, for example SAML
}
store := database.ProvideStore(sqlStore)
svc := &SSOSettingsService{
log: log.New("ssosettings.service"),
cfg: cfg,
store: store,
ac: ac,
fbStrategies: strategies,
}
if features.IsEnabled(featuremgmt.FlagSsoSettingsApi) {
ssoSettingsApi := api.ProvideApi(svc, routeRegister, ac)
ssoSettingsApi.RegisterAPIEndpoints()
}
return svc
}
var _ ssosettings.Service = (*SSOSettingsService)(nil)
func (s *SSOSettingsService) GetForProvider(ctx context.Context, provider string) (*models.SSOSetting, error) {
dto, err := s.store.Get(ctx, provider)
if errors.Is(err, ssosettings.ErrNotFound) {
setting, err := s.loadSettingsUsingFallbackStrategy(ctx, provider)
if err != nil {
return nil, err
}
return setting, nil
}
if err != nil {
return nil, err
}
dto.Source = models.DB
return dto, nil
}
func (s *SSOSettingsService) List(ctx context.Context, requester identity.Requester) ([]*models.SSOSetting, error) {
result := make([]*models.SSOSetting, 0, len(ssosettings.AllOAuthProviders))
storedSettings, err := s.store.List(ctx)
if err != nil {
return nil, err
}
for _, provider := range ssosettings.AllOAuthProviders {
ev := ac.EvalPermission(ac.ActionSettingsRead, ac.Scope("settings", "auth."+provider, "*"))
hasAccess, err := s.ac.Evaluate(ctx, requester, ev)
if err != nil {
return nil, err
}
if !hasAccess {
continue
}
settings := getSettingsByProvider(provider, storedSettings)
if len(settings) == 0 {
// If there is no data in the DB then we need to load the settings using the fallback strategy
setting, err := s.loadSettingsUsingFallbackStrategy(ctx, provider)
if err != nil {
return nil, err
}
settings = append(settings, setting)
}
result = append(result, settings...)
}
return result, nil
}
func (s *SSOSettingsService) Upsert(ctx context.Context, provider string, data map[string]interface{}) error {
// TODO: validation (configurable provider? Contains the required fields? etc)
err := s.store.Upsert(ctx, provider, data)
if err != nil {
return err
}
return nil
}
func (s *SSOSettingsService) Patch(ctx context.Context, provider string, data map[string]interface{}) error {
panic("not implemented") // TODO: Implement
}
func (s *SSOSettingsService) Delete(ctx context.Context, provider string) error {
return s.store.Delete(ctx, provider)
}
func (s *SSOSettingsService) Reload(ctx context.Context, provider string) {
panic("not implemented") // TODO: Implement
}
func (s *SSOSettingsService) RegisterReloadable(ctx context.Context, provider string, reloadable ssosettings.Reloadable) {
panic("not implemented") // TODO: Implement
}
func (s *SSOSettingsService) RegisterFallbackStrategy(providerRegex string, strategy ssosettings.FallbackStrategy) {
s.fbStrategies = append(s.fbStrategies, strategy)
}
func (s *SSOSettingsService) loadSettingsUsingFallbackStrategy(ctx context.Context, provider string) (*models.SSOSetting, error) {
loadStrategy, ok := s.getFallBackstrategyFor(provider)
if !ok {
return nil, errors.New("no fallback strategy found for provider: " + provider)
}
settingsFromSystem, err := loadStrategy.ParseConfigFromSystem(ctx)
if err != nil {
return nil, err
}
return &models.SSOSetting{
Provider: provider,
Source: models.System,
Settings: settingsFromSystem,
}, nil
}
func getSettingsByProvider(provider string, settings []*models.SSOSetting) []*models.SSOSetting {
result := make([]*models.SSOSetting, 0)
for _, setting := range settings {
if setting.Provider == provider {
result = append(result, setting)
}
}
return result
}
func (s *SSOSettingsService) getFallBackstrategyFor(provider string) (ssosettings.FallbackStrategy, bool) {
for _, strategy := range s.fbStrategies {
if strategy.IsMatch(provider) {
return strategy, true
}
}
return nil, false
}
@@ -0,0 +1,387 @@
package ssosettingsimpl
import (
"context"
"fmt"
"testing"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
func TestSSOSettingsService_GetForProvider(t *testing.T) {
testCases := []struct {
name string
setup func(env testEnv)
want *models.SSOSetting
wantErr bool
}{
{
name: "should return successfully",
setup: func(env testEnv) {
env.store.ExpectedSSOSetting = &models.SSOSetting{
Provider: "github",
Settings: map[string]interface{}{
"enabled": true,
},
Source: models.DB,
}
},
want: &models.SSOSetting{
Provider: "github",
Settings: map[string]interface{}{
"enabled": true,
},
},
wantErr: false,
},
{
name: "should return error if store returns an error different than not found",
setup: func(env testEnv) { env.store.ExpectedError = fmt.Errorf("error") },
want: nil,
wantErr: true,
},
{
name: "should fallback to strategy if store returns not found",
setup: func(env testEnv) {
env.store.ExpectedError = ssosettings.ErrNotFound
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfig = map[string]interface{}{
"enabled": true,
}
},
want: &models.SSOSetting{
Provider: "github",
Settings: map[string]interface{}{
"enabled": true,
},
Source: models.System,
},
wantErr: false,
},
{
name: "should return error if the fallback strategy was not found",
setup: func(env testEnv) {
env.store.ExpectedError = ssosettings.ErrNotFound
env.fallbackStrategy.ExpectedIsMatch = false
},
want: nil,
wantErr: true,
},
{
name: "should return error if fallback strategy returns error",
setup: func(env testEnv) {
env.store.ExpectedError = ssosettings.ErrNotFound
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedError = fmt.Errorf("error")
},
want: nil,
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
env := setupTestEnv(t)
if tc.setup != nil {
tc.setup(env)
}
actual, err := env.service.GetForProvider(context.Background(), "github")
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.want, actual)
})
}
}
func TestSSOSettingsService_List(t *testing.T) {
defaultIdentity := &user.SignedInUser{
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
accesscontrol.ActionSettingsRead: {accesscontrol.ScopeSettingsAll},
},
},
}
scopedIdentity := &user.SignedInUser{
UserID: 1,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
accesscontrol.ActionSettingsRead: []string{
accesscontrol.Scope("settings", "auth.azuread", "*"),
accesscontrol.Scope("settings", "auth.github", "*"),
},
},
},
}
testCases := []struct {
name string
setup func(env testEnv)
identity identity.Requester
want []*models.SSOSetting
wantErr bool
}{
{
name: "should return successfully",
setup: func(env testEnv) {
env.store.ExpectedSSOSettings = []*models.SSOSetting{
{
Provider: "github",
Settings: map[string]interface{}{
"enabled": true,
},
Source: models.DB,
},
{
Provider: "okta",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.DB,
},
}
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfig = map[string]interface{}{
"enabled": false,
}
},
identity: defaultIdentity,
want: []*models.SSOSetting{
{
Provider: "github",
Settings: map[string]interface{}{
"enabled": true,
},
Source: models.DB,
},
{
Provider: "okta",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.DB,
},
{
Provider: "gitlab",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "generic_oauth",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "google",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "azuread",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "grafana_com",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
},
wantErr: false,
},
{
name: "should return the settings that the user has access to",
setup: func(env testEnv) {
env.store.ExpectedSSOSettings = []*models.SSOSetting{
{
Provider: "github",
Settings: map[string]interface{}{
"enabled": true,
},
Source: models.DB,
},
{
Provider: "okta",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.DB,
},
}
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfig = map[string]interface{}{
"enabled": false,
}
},
identity: scopedIdentity,
want: []*models.SSOSetting{
{
Provider: "github",
Settings: map[string]interface{}{
"enabled": true,
},
Source: models.DB,
},
{
Provider: "azuread",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
},
wantErr: false,
},
{
name: "should return error if store returns an error",
setup: func(env testEnv) { env.store.ExpectedError = fmt.Errorf("error") },
identity: defaultIdentity,
want: nil,
wantErr: true,
},
{
name: "should use the fallback strategy if store returns empty list",
setup: func(env testEnv) {
env.store.ExpectedSSOSettings = []*models.SSOSetting{}
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfig = map[string]interface{}{
"enabled": false,
}
},
identity: defaultIdentity,
want: []*models.SSOSetting{
{
Provider: "github",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "okta",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "gitlab",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "generic_oauth",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "google",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "azuread",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
{
Provider: "grafana_com",
Settings: map[string]interface{}{
"enabled": false,
},
Source: models.System,
},
},
wantErr: false,
},
{
name: "should return error if any of the fallback strategies was not found",
setup: func(env testEnv) {
env.store.ExpectedSSOSettings = []*models.SSOSetting{}
env.fallbackStrategy.ExpectedIsMatch = false
},
identity: defaultIdentity,
want: nil,
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
env := setupTestEnv(t)
if tc.setup != nil {
tc.setup(env)
}
actual, err := env.service.List(context.Background(), tc.identity)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.ElementsMatch(t, tc.want, actual)
})
}
}
func setupTestEnv(t *testing.T) testEnv {
store := ssosettingstests.NewFakeStore()
fallbackStrategy := ssosettingstests.NewFakeFallbackStrategy()
accessControl := acimpl.ProvideAccessControl(setting.NewCfg())
svc := &SSOSettingsService{
log: log.NewNopLogger(),
store: store,
ac: accessControl,
fbStrategies: []ssosettings.FallbackStrategy{fallbackStrategy},
}
return testEnv{
service: svc,
store: store,
ac: accessControl,
fallbackStrategy: fallbackStrategy,
}
}
type testEnv struct {
service *SSOSettingsService
store *ssosettingstests.FakeStore
ac accesscontrol.AccessControl
fallbackStrategy *ssosettingstests.FakeFallbackStrategy
}
@@ -0,0 +1,22 @@
package ssosettingstests
import context "context"
type FakeFallbackStrategy struct {
ExpectedIsMatch bool
ExpectedConfig map[string]interface{}
ExpectedError error
}
func NewFakeFallbackStrategy() *FakeFallbackStrategy {
return &FakeFallbackStrategy{}
}
func (f *FakeFallbackStrategy) IsMatch(provider string) bool {
return f.ExpectedIsMatch
}
func (f *FakeFallbackStrategy) ParseConfigFromSystem(ctx context.Context) (map[string]interface{}, error) {
return f.ExpectedConfig, f.ExpectedError
}
@@ -0,0 +1,40 @@
package ssosettingstests
import (
context "context"
"github.com/grafana/grafana/pkg/services/ssosettings"
models "github.com/grafana/grafana/pkg/services/ssosettings/models"
)
var _ ssosettings.Store = (*FakeStore)(nil)
type FakeStore struct {
ExpectedSSOSetting *models.SSOSetting
ExpectedSSOSettings []*models.SSOSetting
ExpectedError error
}
func NewFakeStore() *FakeStore {
return &FakeStore{}
}
func (f *FakeStore) Get(ctx context.Context, provider string) (*models.SSOSetting, error) {
return f.ExpectedSSOSetting, f.ExpectedError
}
func (f *FakeStore) List(ctx context.Context) ([]*models.SSOSetting, error) {
return f.ExpectedSSOSettings, f.ExpectedError
}
func (f *FakeStore) Upsert(ctx context.Context, provider string, data map[string]interface{}) error {
return f.ExpectedError
}
func (f *FakeStore) Patch(ctx context.Context, provider string, data map[string]interface{}) error {
return f.ExpectedError
}
func (f *FakeStore) Delete(ctx context.Context, provider string) error {
return f.ExpectedError
}
@@ -0,0 +1,124 @@
// Code generated by mockery v2.27.1. DO NOT EDIT.
package ssosettingstests
import (
context "context"
models "github.com/grafana/grafana/pkg/services/ssosettings/models"
mock "github.com/stretchr/testify/mock"
)
// MockStore is an autogenerated mock type for the Store type
type MockStore struct {
mock.Mock
}
// Delete provides a mock function with given fields: ctx, provider
func (_m *MockStore) Delete(ctx context.Context, provider string) error {
ret := _m.Called(ctx, provider)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(ctx, provider)
} else {
r0 = ret.Error(0)
}
return r0
}
// Get provides a mock function with given fields: ctx, provider
func (_m *MockStore) Get(ctx context.Context, provider string) (*models.SSOSetting, error) {
ret := _m.Called(ctx, provider)
var r0 *models.SSOSetting
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*models.SSOSetting, error)); ok {
return rf(ctx, provider)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *models.SSOSetting); ok {
r0 = rf(ctx, provider)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.SSOSetting)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, provider)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAll provides a mock function with given fields: ctx
func (_m *MockStore) GetAll(ctx context.Context) ([]*models.SSOSetting, error) {
ret := _m.Called(ctx)
var r0 []*models.SSOSetting
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]*models.SSOSetting, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []*models.SSOSetting); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.SSOSetting)
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Patch provides a mock function with given fields: ctx, provider, data
func (_m *MockStore) Patch(ctx context.Context, provider string, data map[string]interface{}) error {
ret := _m.Called(ctx, provider, data)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, map[string]interface{}) error); ok {
r0 = rf(ctx, provider, data)
} else {
r0 = ret.Error(0)
}
return r0
}
// Upsert provides a mock function with given fields: ctx, provider, data
func (_m *MockStore) Upsert(ctx context.Context, provider string, data map[string]interface{}) error {
ret := _m.Called(ctx, provider, data)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, map[string]interface{}) error); ok {
r0 = rf(ctx, provider, data)
} else {
r0 = ret.Error(0)
}
return r0
}
type mockConstructorTestingTNewMockStore interface {
mock.TestingT
Cleanup(func())
}
// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockStore(t mockConstructorTestingTNewMockStore) *MockStore {
mock := &MockStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -0,0 +1,71 @@
package strategies
import (
"context"
"regexp"
"strings"
"github.com/grafana/grafana/pkg/services/ssosettings"
"github.com/grafana/grafana/pkg/setting"
)
type OAuthStrategy struct {
provider string
cfg *setting.Cfg
supportedProvidersRegex *regexp.Regexp
}
var _ ssosettings.FallbackStrategy = (*OAuthStrategy)(nil)
func NewOAuthStrategy(cfg *setting.Cfg) *OAuthStrategy {
compiledRegex := regexp.MustCompile(`^` + strings.Join(ssosettings.AllOAuthProviders, "|") + `$`)
return &OAuthStrategy{
cfg: cfg,
supportedProvidersRegex: compiledRegex,
}
}
func (s *OAuthStrategy) IsMatch(provider string) bool {
return s.supportedProvidersRegex.MatchString(provider)
}
func (s *OAuthStrategy) ParseConfigFromSystem(_ context.Context) (map[string]interface{}, error) {
section := s.cfg.SectionWithEnvOverrides("auth." + s.provider)
result := map[string]interface{}{
"client_id": section.Key("client_id").Value(),
"client_secret": section.Key("client_secret").Value(),
"scopes": section.Key("scopes").Value(),
"auth_url": section.Key("auth_url").Value(),
"token_url": section.Key("token_url").Value(),
"api_url": section.Key("api_url").Value(),
"teams_url": section.Key("teams_url").Value(),
"enabled": section.Key("enabled").MustBool(false),
"email_attribute_name": section.Key("email_attribute_name").Value(),
"email_attribute_path": section.Key("email_attribute_path").Value(),
"role_attribute_path": section.Key("role_attribute_path").Value(),
"role_attribute_strict": section.Key("role_attribute_strict").MustBool(false),
"groups_attribute_path": section.Key("groups_attribute_path").Value(),
"team_ids_attribute_path": section.Key("team_ids_attribute_path").Value(),
"allowed_domains": section.Key("allowed_domains").Value(),
"hosted_domain": section.Key("hosted_domain").Value(),
"allow_sign_up": section.Key("allow_sign_up").MustBool(true),
"name": section.Key("name").MustString("default name"), // TODO: change this default value
"icon": section.Key("icon").Value(),
"tls_client_cert": section.Key("tls_client_cert").Value(),
"tls_client_key": section.Key("tls_client_key").Value(),
"tls_client_ca": section.Key("tls_client_ca").Value(),
"tls_skip_verify_insecure": section.Key("tls_skip_verify_insecure").MustBool(false),
"use_pkce": section.Key("use_pkce").MustBool(true),
"use_refresh_token": section.Key("use_refresh_token").MustBool(false),
"allow_assign_grafana_admin": section.Key("allow_assign_grafana_admin").MustBool(false),
"auto_login": section.Key("auto_login").MustBool(false),
"allowed_groups": section.Key("allowed_groups").Value(),
}
// when empty_scopes parameter exists and is true, overwrite scope with empty value
if section.Key("empty_scopes").MustBool(false) {
result["scopes"] = []string{}
}
return result, nil
}