Plugin: Remove external service on plugin removal (#77712)

* Plugin: Remove external service on plugin removal

* Add feature flag check in the service registration service

* Initialize map

* Add HasExternalService as suggested

* Commit suggestion

Co-authored-by: linoman <2051016+linoman@users.noreply.github.com>

* Nit on test.

Co-authored-by: linoman <2051016+linoman@users.noreply.github.com>


---------

Co-authored-by: linoman <2051016+linoman@users.noreply.github.com>
This commit is contained in:
Gabriel MABILLE 2023-11-13 13:18:13 +01:00 committed by GitHub
parent 7169bfdb30
commit 20a2840046
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 431 additions and 30 deletions

View File

@ -13,5 +13,7 @@ type ExternalService struct {
} }
type ExternalServiceRegistry interface { type ExternalServiceRegistry interface {
RegisterExternalService(ctx context.Context, name string, pType plugindef.Type, svc *plugindef.ExternalServiceRegistration) (*ExternalService, error) HasExternalService(ctx context.Context, pluginID string) bool
RegisterExternalService(ctx context.Context, pluginID string, pType plugindef.Type, svc *plugindef.ExternalServiceRegistration) (*ExternalService, error)
RemoveExternalService(ctx context.Context, pluginID string) error
} }

View File

@ -437,10 +437,18 @@ type FakeAuthService struct {
Result *auth.ExternalService Result *auth.ExternalService
} }
func (f *FakeAuthService) RegisterExternalService(ctx context.Context, name string, pType plugindef.Type, svc *plugindef.ExternalServiceRegistration) (*auth.ExternalService, error) { func (f *FakeAuthService) HasExternalService(ctx context.Context, pluginID string) bool {
return f.Result != nil
}
func (f *FakeAuthService) RegisterExternalService(ctx context.Context, pluginID string, pType plugindef.Type, svc *plugindef.ExternalServiceRegistration) (*auth.ExternalService, error) {
return f.Result, nil return f.Result, nil
} }
func (f *FakeAuthService) RemoveExternalService(ctx context.Context, pluginID string) error {
return nil
}
type FakeDiscoverer struct { type FakeDiscoverer struct {
DiscoverFunc func(ctx context.Context, src plugins.PluginSource) ([]*plugins.FoundBundle, error) DiscoverFunc func(ctx context.Context, src plugins.PluginSource) ([]*plugins.FoundBundle, error)
} }

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/auth"
"github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/manager/loader"
@ -24,16 +25,18 @@ type PluginInstaller struct {
pluginRegistry registry.Service pluginRegistry registry.Service
pluginLoader loader.Service pluginLoader loader.Service
log log.Logger log log.Logger
serviceRegistry auth.ExternalServiceRegistry
} }
func ProvideInstaller(cfg *config.Cfg, pluginRegistry registry.Service, pluginLoader loader.Service, func ProvideInstaller(cfg *config.Cfg, pluginRegistry registry.Service, pluginLoader loader.Service,
pluginRepo repo.Service) *PluginInstaller { pluginRepo repo.Service, serviceRegistry auth.ExternalServiceRegistry) *PluginInstaller {
return New(pluginRegistry, pluginLoader, pluginRepo, return New(pluginRegistry, pluginLoader, pluginRepo,
storage.FileSystem(log.NewPrettyLogger("installer.fs"), cfg.PluginsPath), storage.SimpleDirNameGeneratorFunc) storage.FileSystem(log.NewPrettyLogger("installer.fs"), cfg.PluginsPath), storage.SimpleDirNameGeneratorFunc, serviceRegistry)
} }
func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginRepo repo.Service, func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginRepo repo.Service,
pluginStorage storage.ZipExtractor, pluginStorageDirFunc storage.DirNameGeneratorFunc) *PluginInstaller { pluginStorage storage.ZipExtractor, pluginStorageDirFunc storage.DirNameGeneratorFunc,
serviceRegistry auth.ExternalServiceRegistry) *PluginInstaller {
return &PluginInstaller{ return &PluginInstaller{
pluginLoader: pluginLoader, pluginLoader: pluginLoader,
pluginRegistry: pluginRegistry, pluginRegistry: pluginRegistry,
@ -41,6 +44,7 @@ func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginRep
pluginStorage: pluginStorage, pluginStorage: pluginStorage,
pluginStorageDirFunc: pluginStorageDirFunc, pluginStorageDirFunc: pluginStorageDirFunc,
log: log.New("plugin.installer"), log: log.New("plugin.installer"),
serviceRegistry: serviceRegistry,
} }
} }
@ -156,6 +160,9 @@ func (m *PluginInstaller) Remove(ctx context.Context, pluginID string) error {
} }
} }
if m.serviceRegistry.HasExternalService(ctx, pluginID) {
return m.serviceRegistry.RemoveExternalService(ctx, pluginID)
}
return nil return nil
} }

View File

@ -65,7 +65,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
}, },
} }
inst := New(fakes.NewFakePluginRegistry(), loader, pluginRepo, fs, storage.SimpleDirNameGeneratorFunc) inst := New(fakes.NewFakePluginRegistry(), loader, pluginRepo, fs, storage.SimpleDirNameGeneratorFunc, &fakes.FakeAuthService{})
err := inst.Add(context.Background(), pluginID, v1, testCompatOpts()) err := inst.Add(context.Background(), pluginID, v1, testCompatOpts())
require.NoError(t, err) require.NoError(t, err)
@ -171,7 +171,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
}, },
} }
pm := New(reg, &fakes.FakeLoader{}, &fakes.FakePluginRepo{}, &fakes.FakePluginStorage{}, storage.SimpleDirNameGeneratorFunc) pm := New(reg, &fakes.FakeLoader{}, &fakes.FakePluginRepo{}, &fakes.FakePluginStorage{}, storage.SimpleDirNameGeneratorFunc, &fakes.FakeAuthService{})
err := pm.Add(context.Background(), p.ID, "3.2.0", testCompatOpts()) err := pm.Add(context.Background(), p.ID, "3.2.0", testCompatOpts())
require.ErrorIs(t, err, plugins.ErrInstallCorePlugin) require.ErrorIs(t, err, plugins.ErrInstallCorePlugin)

View File

@ -17,6 +17,12 @@ const (
type AuthProvider string type AuthProvider string
type ExternalServiceRegistry interface { type ExternalServiceRegistry interface {
// HasExternalService returns whether an external service has been saved with that name.
HasExternalService(ctx context.Context, name string) bool
// RemoveExternalService removes an external service and its associated resources from the database (ex: service account, token).
RemoveExternalService(ctx context.Context, name string) error
// SaveExternalService creates or updates an external service in the database. Based on the requested auth provider, // SaveExternalService creates or updates an external service in the database. Based on the requested auth provider,
// it generates client_id, secrets and any additional provider specificities (ex: rsa keys). It also ensures that the // it generates client_id, secrets and any additional provider specificities (ex: rsa keys). It also ensures that the
// associated service account has the correct permissions. // associated service account has the correct permissions.

View File

@ -33,6 +33,8 @@ type OAuth2Server interface {
// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and // GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and
// SignedInUser from the associated service account. // SignedInUser from the associated service account.
GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error) GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error)
// RemoveExternalService removes an external service and its associated resources from the store.
RemoveExternalService(ctx context.Context, name string) error
// HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization // HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization
// grant (ex: client_credentials, jwtbearer). // grant (ex: client_credentials, jwtbearer).
@ -45,6 +47,7 @@ type OAuth2Server interface {
//go:generate mockery --name Store --structname MockStore --outpkg oastest --filename store_mock.go --output ./oastest/ //go:generate mockery --name Store --structname MockStore --outpkg oastest --filename store_mock.go --output ./oastest/
type Store interface { type Store interface {
DeleteExternalService(ctx context.Context, id string) error
GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error) GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error)
GetExternalServiceByName(ctx context.Context, name string) (*OAuthExternalService, error) GetExternalServiceByName(ctx context.Context, name string) (*OAuthExternalService, error)
GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error)

View File

@ -183,6 +183,33 @@ func (s *OAuth2ServiceImpl) setClientUser(ctx context.Context, client *oauthserv
return nil return nil
} }
func (s *OAuth2ServiceImpl) RemoveExternalService(ctx context.Context, name string) error {
s.logger.Info("Remove external service", "service", name)
client, err := s.sqlstore.GetExternalServiceByName(ctx, name)
if err != nil {
if errors.Is(err, oauthserver.ErrClientNotFound) {
s.logger.Debug("No external service linked to this name", "name", name)
return nil
}
s.logger.Error("Error fetching external service", "name", name, "error", err.Error())
return err
}
// Since we will delete the service, clear cache entry
s.cache.Delete(client.ClientID)
// Delete the OAuth client info in store
if err := s.sqlstore.DeleteExternalService(ctx, client.ClientID); err != nil {
s.logger.Error("Error deleting external service", "name", name, "error", err.Error())
return err
}
s.logger.Debug("Deleted external service", "name", name, "client_id", client.ClientID)
// Remove the associated service account
return s.saService.RemoveExtSvcAccount(ctx, oauthserver.TmpOrgID, slugify.Slugify(name))
}
// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and // SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and
// it ensures that the associated service account has the correct permissions. // it ensures that the associated service account has the correct permissions.
// Database consistency is not guaranteed, consider changing this in the future. // Database consistency is not guaranteed, consider changing this in the future.
@ -412,14 +439,13 @@ func (s *OAuth2ServiceImpl) handlePluginStateChanged(ctx context.Context, event
s.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled) s.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled)
// Retrieve client associated to the plugin // Retrieve client associated to the plugin
slug := slugify.Slugify(event.PluginId) client, err := s.sqlstore.GetExternalServiceByName(ctx, event.PluginId)
client, err := s.sqlstore.GetExternalServiceByName(ctx, slug)
if err != nil { if err != nil {
if errors.Is(err, oauthserver.ErrClientNotFound) { if errors.Is(err, oauthserver.ErrClientNotFound) {
s.logger.Debug("No external service linked to this plugin", "pluginId", event.PluginId) s.logger.Debug("No external service linked to this plugin", "pluginId", event.PluginId)
return nil return nil
} }
s.logger.Error("Error fetching service", "pluginId", event.PluginId, "error", err) s.logger.Error("Error fetching service", "pluginId", event.PluginId, "error", err.Error())
return err return err
} }

View File

@ -408,6 +408,51 @@ func assertArrayInMap[K comparable, V string](t *testing.T, m1 map[K][]V, m2 map
} }
} }
func TestOAuth2ServiceImpl_RemoveExternalService(t *testing.T) {
const serviceName = "my-ext-service"
const clientID = "RANDOMID"
dummyClient := &oauthserver.OAuthExternalService{
Name: serviceName,
ClientID: clientID,
ServiceAccountID: 1,
}
testCases := []struct {
name string
init func(*TestEnv)
}{
{
name: "should do nothing on not found",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, serviceName).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName))
},
},
{
name: "should remove the external service and its associated service account",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, serviceName).Return(dummyClient, nil)
env.OAuthStore.On("DeleteExternalService", mock.Anything, clientID).Return(nil)
env.SAService.On("RemoveExtSvcAccount", mock.Anything, oauthserver.TmpOrgID, serviceName).Return(nil)
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
if tt.init != nil {
tt.init(env)
}
err := env.S.RemoveExternalService(context.Background(), serviceName)
require.NoError(t, err)
env.OAuthStore.AssertExpectations(t)
env.SAService.AssertExpectations(t)
})
}
}
func TestTestOAuth2ServiceImpl_handleKeyOptions(t *testing.T) { func TestTestOAuth2ServiceImpl_handleKeyOptions(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string

View File

@ -25,6 +25,10 @@ func (s *FakeService) GetExternalService(ctx context.Context, id string) (*oauth
return s.ExpectedClient, s.ExpectedErr return s.ExpectedClient, s.ExpectedErr
} }
func (s *FakeService) RemoveExternalService(ctx context.Context, name string) error {
return s.ExpectedErr
}
func (s *FakeService) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {} func (s *FakeService) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {}
func (s *FakeService) HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) {} func (s *FakeService) HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) {}

View File

@ -16,6 +16,20 @@ type MockStore struct {
mock.Mock mock.Mock
} }
// DeleteExternalService provides a mock function with given fields: ctx, id
func (_m *MockStore) DeleteExternalService(ctx context.Context, id string) error {
ret := _m.Called(ctx, id)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetExternalService provides a mock function with given fields: ctx, id // GetExternalService provides a mock function with given fields: ctx, id
func (_m *MockStore) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) { func (_m *MockStore) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)

View File

@ -126,6 +126,7 @@ func (s *store) GetExternalService(ctx context.Context, id string) (*oauthserver
return err return err
} }
if !found { if !found {
res = nil
return oauthserver.ErrClientNotFoundFn(id) return oauthserver.ErrClientNotFoundFn(id)
} }
@ -223,3 +224,18 @@ func (s *store) UpdateExternalServiceGrantTypes(ctx context.Context, clientID, g
return err return err
}) })
} }
func (s *store) DeleteExternalService(ctx context.Context, id string) error {
if id == "" {
return oauthserver.ErrClientRequiredID
}
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
if _, err := sess.Exec(`DELETE FROM oauth_client WHERE client_id = ?`, id); err != nil {
return err
}
_, err := sess.Exec(`DELETE FROM oauth_impersonate_permission WHERE client_id = ?`, id)
return err
})
}

View File

@ -2,6 +2,7 @@ package store
import ( import (
"context" "context"
"errors"
"testing" "testing"
"github.com/go-jose/go-jose/v3" "github.com/go-jose/go-jose/v3"
@ -352,6 +353,88 @@ vuO8AU0bVoUmYMKhozkcCYHudkeS08hEjQIDAQAB
} }
} }
func TestStore_RemoveExternalService(t *testing.T) {
ctx := context.Background()
client1 := oauthserver.OAuthExternalService{
Name: "my-external-service",
ClientID: "ClientID",
ImpersonatePermissions: []accesscontrol.Permission{},
}
client2 := oauthserver.OAuthExternalService{
Name: "my-external-service-2",
ClientID: "ClientID2",
ImpersonatePermissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
},
}
// Init store
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
require.NoError(t, s.SaveExternalService(context.Background(), &client1))
require.NoError(t, s.SaveExternalService(context.Background(), &client2))
// Check presence of clients in store
getState := func(t *testing.T) map[string]bool {
client, err := s.GetExternalService(ctx, "ClientID")
if err != nil && !errors.Is(err, oauthserver.ErrClientNotFound) {
require.Fail(t, "error fetching client")
}
client2, err := s.GetExternalService(ctx, "ClientID2")
if err != nil && !errors.Is(err, oauthserver.ErrClientNotFound) {
require.Fail(t, "error fetching client")
}
return map[string]bool{
"ClientID": client != nil,
"ClientID2": client2 != nil,
}
}
tests := []struct {
name string
id string
state map[string]bool
wantErr bool
}{
{
name: "no id provided",
state: map[string]bool{"ClientID": true, "ClientID2": true},
wantErr: true,
},
{
name: "not found",
id: "ClientID3",
state: map[string]bool{"ClientID": true, "ClientID2": true},
wantErr: false,
},
{
name: "remove client 2",
id: "ClientID2",
state: map[string]bool{"ClientID": true, "ClientID2": false},
wantErr: false,
},
{
name: "remove client 1",
id: "ClientID",
state: map[string]bool{"ClientID": false, "ClientID2": false},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := s.DeleteExternalService(ctx, tt.id)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.EqualValues(t, tt.state, getState(t))
})
}
}
func compareClientToStored(t *testing.T, s *store, wanted *oauthserver.OAuthExternalService) { func compareClientToStored(t *testing.T, s *store, wanted *oauthserver.OAuthExternalService) {
ctx := context.Background() ctx := context.Background()
stored, err := s.GetExternalService(ctx, wanted.ClientID) stored, err := s.GetExternalService(ctx, wanted.ClientID)

View File

@ -2,8 +2,10 @@ package registry
import ( import (
"context" "context"
"sync"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/services/extsvcauth" "github.com/grafana/grafana/pkg/services/extsvcauth"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver" "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
@ -17,14 +19,53 @@ type Registry struct {
logger log.Logger logger log.Logger
oauthServer oauthserver.OAuth2Server oauthServer oauthserver.OAuth2Server
saSvc *extsvcaccounts.ExtSvcAccountsService saSvc *extsvcaccounts.ExtSvcAccountsService
extSvcProviders map[string]extsvcauth.AuthProvider
lock sync.Mutex
} }
func ProvideExtSvcRegistry(oauthServer oauthserver.OAuth2Server, saSvc *extsvcaccounts.ExtSvcAccountsService, features featuremgmt.FeatureToggles) *Registry { func ProvideExtSvcRegistry(oauthServer oauthserver.OAuth2Server, saSvc *extsvcaccounts.ExtSvcAccountsService, features featuremgmt.FeatureToggles) *Registry {
return &Registry{ return &Registry{
features: features, extSvcProviders: map[string]extsvcauth.AuthProvider{},
logger: log.New("extsvcauth.registry"), features: features,
oauthServer: oauthServer, lock: sync.Mutex{},
saSvc: saSvc, logger: log.New("extsvcauth.registry"),
oauthServer: oauthServer,
saSvc: saSvc,
}
}
// HasExternalService returns whether an external service has been saved with that name.
func (r *Registry) HasExternalService(ctx context.Context, name string) bool {
_, ok := r.extSvcProviders[slugify.Slugify(name)]
return ok
}
// RemoveExternalService removes an external service and its associated resources from the database (ex: service account, token).
func (r *Registry) RemoveExternalService(ctx context.Context, name string) error {
provider, ok := r.extSvcProviders[slugify.Slugify(name)]
if !ok {
r.logger.Debug("external service not found", "service", name)
return nil
}
switch provider {
case extsvcauth.ServiceAccounts:
if !r.features.IsEnabled(featuremgmt.FlagExternalServiceAccounts) {
r.logger.Debug("Skipping External Service removal, flag disabled", "service", name, "flag", featuremgmt.FlagExternalServiceAccounts)
return nil
}
r.logger.Debug("Routing External Service removal to the External Service Account service", "service", name)
return r.saSvc.RemoveExternalService(ctx, name)
case extsvcauth.OAuth2Server:
if !r.features.IsEnabled(featuremgmt.FlagExternalServiceAuth) {
r.logger.Debug("Skipping External Service removal, flag disabled", "service", name, "flag", featuremgmt.FlagExternalServiceAccounts)
return nil
}
r.logger.Debug("Routing External Service removal to the OAuth2Server", "service", name)
return r.oauthServer.RemoveExternalService(ctx, name)
default:
return extsvcauth.ErrUnknownProvider.Errorf("unknow provider '%v'", provider)
} }
} }
@ -32,17 +73,22 @@ func ProvideExtSvcRegistry(oauthServer oauthserver.OAuth2Server, saSvc *extsvcac
// it generates client_id, secrets and any additional provider specificities (ex: rsa keys). It also ensures that the // it generates client_id, secrets and any additional provider specificities (ex: rsa keys). It also ensures that the
// associated service account has the correct permissions. // associated service account has the correct permissions.
func (r *Registry) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) { func (r *Registry) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) {
// Record provider in case of removal
r.lock.Lock()
r.extSvcProviders[slugify.Slugify(cmd.Name)] = cmd.AuthProvider
r.lock.Unlock()
switch cmd.AuthProvider { switch cmd.AuthProvider {
case extsvcauth.ServiceAccounts: case extsvcauth.ServiceAccounts:
if !r.features.IsEnabled(featuremgmt.FlagExternalServiceAccounts) { if !r.features.IsEnabled(featuremgmt.FlagExternalServiceAccounts) {
r.logger.Warn("Skipping external service authentication, flag disabled", "service", cmd.Name, "flag", featuremgmt.FlagExternalServiceAccounts) r.logger.Warn("Skipping External Service authentication, flag disabled", "service", cmd.Name, "flag", featuremgmt.FlagExternalServiceAccounts)
return nil, nil return nil, nil
} }
r.logger.Debug("Routing the External Service registration to the External Service Account service", "service", cmd.Name) r.logger.Debug("Routing the External Service registration to the External Service Account service", "service", cmd.Name)
return r.saSvc.SaveExternalService(ctx, cmd) return r.saSvc.SaveExternalService(ctx, cmd)
case extsvcauth.OAuth2Server: case extsvcauth.OAuth2Server:
if !r.features.IsEnabled(featuremgmt.FlagExternalServiceAuth) { if !r.features.IsEnabled(featuremgmt.FlagExternalServiceAuth) {
r.logger.Warn("Skipping external service authentication, flag disabled", "service", cmd.Name, "flag", featuremgmt.FlagExternalServiceAuth) r.logger.Warn("Skipping External Service authentication, flag disabled", "service", cmd.Name, "flag", featuremgmt.FlagExternalServiceAuth)
return nil, nil return nil, nil
} }
r.logger.Debug("Routing the External Service registration to the OAuth2Server", "service", cmd.Name) r.logger.Debug("Routing the External Service registration to the OAuth2Server", "service", cmd.Name)

View File

@ -39,8 +39,7 @@ func newExternalServiceRegistration(cfg *config.Cfg, serviceRegistry auth.Extern
// Register registers the external service with the external service registry, if the feature is enabled. // Register registers the external service with the external service registry, if the feature is enabled.
func (r *ExternalServiceRegistration) Register(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) { func (r *ExternalServiceRegistration) Register(ctx context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
if p.ExternalServiceRegistration != nil && if p.ExternalServiceRegistration != nil {
(r.cfg.Features.IsEnabled(featuremgmt.FlagExternalServiceAuth) || r.cfg.Features.IsEnabled(featuremgmt.FlagExternalServiceAccounts)) {
s, err := r.externalServiceRegistry.RegisterExternalService(ctx, p.ID, plugindef.Type(p.Type), p.ExternalServiceRegistration) s, err := r.externalServiceRegistry.RegisterExternalService(ctx, p.ID, plugindef.Type(p.Type), p.ExternalServiceRegistration)
if err != nil { if err != nil {
r.log.Error("Could not register an external service. Initialization skipped", "pluginId", p.ID, "error", err) r.log.Error("Could not register an external service. Initialization skipped", "pluginId", p.ID, "error", err)

View File

@ -5,32 +5,53 @@ import (
"errors" "errors"
"github.com/grafana/grafana/pkg/plugins/auth" "github.com/grafana/grafana/pkg/plugins/auth"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/plugindef" "github.com/grafana/grafana/pkg/plugins/plugindef"
"github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/extsvcauth" "github.com/grafana/grafana/pkg/services/extsvcauth"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
) )
type Service struct { type Service struct {
reg extsvcauth.ExternalServiceRegistry featureEnabled bool
settingsSvc pluginsettings.Service log log.Logger
reg extsvcauth.ExternalServiceRegistry
settingsSvc pluginsettings.Service
} }
func ProvideService(reg extsvcauth.ExternalServiceRegistry, settingsSvc pluginsettings.Service) *Service { func ProvideService(cfg *config.Cfg, reg extsvcauth.ExternalServiceRegistry, settingsSvc pluginsettings.Service) *Service {
s := &Service{ s := &Service{
reg: reg, featureEnabled: cfg.Features.IsEnabled(featuremgmt.FlagExternalServiceAuth) || cfg.Features.IsEnabled(featuremgmt.FlagExternalServiceAccounts),
settingsSvc: settingsSvc, log: log.New("plugins.external.registration"),
reg: reg,
settingsSvc: settingsSvc,
} }
return s return s
} }
func (s *Service) HasExternalService(ctx context.Context, pluginID string) bool {
if !s.featureEnabled {
s.log.Debug("Skipping HasExternalService call. The feature is behind a feature toggle and needs to be enabled.")
return false
}
return s.reg.HasExternalService(ctx, pluginID)
}
// RegisterExternalService is a simplified wrapper around SaveExternalService for the plugin use case. // RegisterExternalService is a simplified wrapper around SaveExternalService for the plugin use case.
func (s *Service) RegisterExternalService(ctx context.Context, svcName string, pType plugindef.Type, svc *plugindef.ExternalServiceRegistration) (*auth.ExternalService, error) { func (s *Service) RegisterExternalService(ctx context.Context, pluginID string, pType plugindef.Type, svc *plugindef.ExternalServiceRegistration) (*auth.ExternalService, error) {
if !s.featureEnabled {
s.log.Warn("Skipping External Service Registration. The feature is behind a feature toggle and needs to be enabled.")
return nil, nil
}
// Datasource plugins can only be enabled // Datasource plugins can only be enabled
enabled := true enabled := true
// App plugins can be disabled // App plugins can be disabled
if pType == plugindef.TypeApp { if pType == plugindef.TypeApp {
settings, err := s.settingsSvc.GetPluginSettingByPluginID(ctx, &pluginsettings.GetByPluginIDArgs{PluginID: svcName}) settings, err := s.settingsSvc.GetPluginSettingByPluginID(ctx, &pluginsettings.GetByPluginIDArgs{PluginID: pluginID})
if err != nil && !errors.Is(err, pluginsettings.ErrPluginSettingNotFound) { if err != nil && !errors.Is(err, pluginsettings.ErrPluginSettingNotFound) {
return nil, err return nil, err
} }
@ -56,7 +77,7 @@ func (s *Service) RegisterExternalService(ctx context.Context, svcName string, p
} }
registration := &extsvcauth.ExternalServiceRegistration{ registration := &extsvcauth.ExternalServiceRegistration{
Name: svcName, Name: pluginID,
Impersonation: impersonation, Impersonation: impersonation,
Self: self, Self: self,
} }
@ -98,3 +119,13 @@ func toAccessControlPermissions(ps []plugindef.Permission) []accesscontrol.Permi
} }
return res return res
} }
// RemoveExternalService removes the external service account associated to a plugin
func (s *Service) RemoveExternalService(ctx context.Context, pluginID string) error {
if !s.featureEnabled {
s.log.Debug("Skipping External Service Removal. The feature is behind a feature toggle and needs to be enabled.")
return nil
}
return s.reg.RemoveExternalService(ctx, pluginID)
}

View File

@ -126,6 +126,37 @@ func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *
return &extsvcauth.ExternalService{Name: cmd.Name, ID: slug, Secret: token}, nil return &extsvcauth.ExternalService{Name: cmd.Name, ID: slug, Secret: token}, nil
} }
func (esa *ExtSvcAccountsService) RemoveExternalService(ctx context.Context, name string) error {
// This is double proofing, we should never reach here anyway the flags have already been checked.
if !esa.features.IsEnabled(featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(featuremgmt.FlagExternalServiceAuth) {
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
return nil
}
return esa.RemoveExtSvcAccount(ctx, extsvcauth.TmpOrgID, slugify.Slugify(name))
}
func (esa *ExtSvcAccountsService) RemoveExtSvcAccount(ctx context.Context, orgID int64, extSvcSlug string) error {
saID, errRetrieve := esa.saSvc.RetrieveServiceAccountIdByName(ctx, orgID, sa.ExtSvcPrefix+extSvcSlug)
if errRetrieve != nil && !errors.Is(errRetrieve, sa.ErrServiceAccountNotFound) {
return errRetrieve
}
if saID <= 0 {
esa.logger.Debug("No external service account associated with this service", "service", extSvcSlug, "orgID", orgID)
return nil
}
if err := esa.deleteExtSvcAccount(ctx, orgID, extSvcSlug, saID); err != nil {
esa.logger.Error("Error occurred while deleting service account",
"service", extSvcSlug,
"saID", saID,
"error", err.Error())
return err
}
esa.logger.Info("Deleted external service account", "service", extSvcSlug, "orgID", orgID)
return nil
}
// ManageExtSvcAccount creates, updates or deletes the service account associated with an external service // ManageExtSvcAccount creates, updates or deletes the service account associated with an external service
func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *sa.ManageExtSvcAccountCmd) (int64, error) { func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *sa.ManageExtSvcAccountCmd) (int64, error) {
// This is double proofing, we should never reach here anyway the flags have already been checked. // This is double proofing, we should never reach here anyway the flags have already been checked.
@ -153,7 +184,6 @@ func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *
"error", err.Error()) "error", err.Error())
return 0, err return 0, err
} }
esa.metrics.deletedCount.Inc()
} }
esa.logger.Info("Skipping service account creation, no permission", esa.logger.Info("Skipping service account creation, no permission",
"service", cmd.ExtSvcSlug, "service", cmd.ExtSvcSlug,
@ -173,8 +203,6 @@ func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *
esa.logger.Error("Could not save service account", "service", cmd.ExtSvcSlug, "error", errSave.Error()) esa.logger.Error("Could not save service account", "service", cmd.ExtSvcSlug, "error", errSave.Error())
return 0, errSave return 0, errSave
} }
esa.metrics.savedCount.Inc()
return saID, nil return saID, nil
} }
@ -212,6 +240,8 @@ func (esa *ExtSvcAccountsService) saveExtSvcAccount(ctx context.Context, cmd *sa
return 0, err return 0, err
} }
esa.metrics.savedCount.Inc()
return cmd.SaID, nil return cmd.SaID, nil
} }
@ -224,7 +254,11 @@ func (esa *ExtSvcAccountsService) deleteExtSvcAccount(ctx context.Context, orgID
if err := esa.acSvc.DeleteExternalServiceRole(ctx, slug); err != nil { if err := esa.acSvc.DeleteExternalServiceRole(ctx, slug); err != nil {
return err return err
} }
return esa.DeleteExtSvcCredentials(ctx, orgID, slug) if err := esa.DeleteExtSvcCredentials(ctx, orgID, slug); err != nil {
return err
}
esa.metrics.deletedCount.Inc()
return nil
} }
// getExtSvcAccountToken get or create the token of an External Service // getExtSvcAccountToken get or create the token of an External Service

View File

@ -380,3 +380,64 @@ func TestExtSvcAccountsService_SaveExternalService(t *testing.T) {
}) })
} }
} }
func TestExtSvcAccountsService_RemoveExtSvcAccount(t *testing.T) {
extSvcSlug := "grafana-test-app"
tmpOrgID := int64(1)
extSvcAccID := int64(10)
tests := []struct {
name string
init func(env *TestEnv)
slug string
checks func(t *testing.T, env *TestEnv)
want *extsvcauth.ExternalService
}{
{
name: "should not fail if the service account does not exist",
init: func(env *TestEnv) {
// No previous service account was attached to this slug
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, tmpOrgID, sa.ExtSvcPrefix+extSvcSlug).
Return(int64(0), sa.ErrServiceAccountNotFound.Errorf("not found"))
},
slug: extSvcSlug,
want: nil,
},
{
name: "should remove service account",
init: func(env *TestEnv) {
// A previous service account was attached to this slug
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, tmpOrgID, sa.ExtSvcPrefix+extSvcSlug).
Return(extSvcAccID, nil)
env.SaSvc.On("DeleteServiceAccount", mock.Anything, tmpOrgID, extSvcAccID).Return(nil)
env.AcStore.On("DeleteExternalServiceRole", mock.Anything, extSvcSlug).Return(nil)
// A token was previously stored in the secret store
_ = env.SkvStore.Set(context.Background(), tmpOrgID, extSvcSlug, kvStoreType, "ExtSvcSecretToken")
},
slug: extSvcSlug,
checks: func(t *testing.T, env *TestEnv) {
_, ok, _ := env.SkvStore.Get(context.Background(), tmpOrgID, extSvcSlug, kvStoreType)
require.False(t, ok, "secret should have been removed from store")
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
env := setupTestEnv(t)
if tt.init != nil {
tt.init(env)
}
err := env.S.RemoveExtSvcAccount(ctx, tmpOrgID, tt.slug)
require.NoError(t, err)
if tt.checks != nil {
tt.checks(t, env)
}
env.SaSvc.AssertExpectations(t)
env.AcStore.AssertExpectations(t)
})
}
}

View File

@ -41,6 +41,8 @@ type ExtSvcAccountsService interface {
EnableExtSvcAccount(ctx context.Context, cmd *EnableExtSvcAccountCmd) error EnableExtSvcAccount(ctx context.Context, cmd *EnableExtSvcAccountCmd) error
// ManageExtSvcAccount creates, updates or deletes the service account associated with an external service // ManageExtSvcAccount creates, updates or deletes the service account associated with an external service
ManageExtSvcAccount(ctx context.Context, cmd *ManageExtSvcAccountCmd) (int64, error) ManageExtSvcAccount(ctx context.Context, cmd *ManageExtSvcAccountCmd) (int64, error)
// RemoveExtSvcAccount removes the external service account associated with an external service
RemoveExtSvcAccount(ctx context.Context, orgID int64, extSvcSlug string) error
// RetrieveExtSvcAccount fetches an external service account by ID // RetrieveExtSvcAccount fetches an external service account by ID
RetrieveExtSvcAccount(ctx context.Context, orgID, saID int64) (*ExtSvcAccount, error) RetrieveExtSvcAccount(ctx context.Context, orgID, saID int64) (*ExtSvcAccount, error)
} }

View File

@ -52,6 +52,20 @@ func (_m *MockExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cm
return r0, r1 return r0, r1
} }
// RemoveExtSvcAccount provides a mock function with given fields: ctx, orgID, extSvcSlug
func (_m *MockExtSvcAccountsService) RemoveExtSvcAccount(ctx context.Context, orgID int64, extSvcSlug string) error {
ret := _m.Called(ctx, orgID, extSvcSlug)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64, string) error); ok {
r0 = rf(ctx, orgID, extSvcSlug)
} else {
r0 = ret.Error(0)
}
return r0
}
// RetrieveExtSvcAccount provides a mock function with given fields: ctx, orgID, saID // RetrieveExtSvcAccount provides a mock function with given fields: ctx, orgID, saID
func (_m *MockExtSvcAccountsService) RetrieveExtSvcAccount(ctx context.Context, orgID int64, saID int64) (*serviceaccounts.ExtSvcAccount, error) { func (_m *MockExtSvcAccountsService) RetrieveExtSvcAccount(ctx context.Context, orgID int64, saID int64) (*serviceaccounts.ExtSvcAccount, error) {
ret := _m.Called(ctx, orgID, saID) ret := _m.Called(ctx, orgID, saID)