mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Chore: Move extsvcaccounts package to serviceaccounts (#76977)
* Chore: Move extsvcaccounts package to serviceaccounts * Fix proxy * Fix tests * Fix linting
This commit is contained in:
48
pkg/services/serviceaccounts/extsvcaccounts/models.go
Normal file
48
pkg/services/serviceaccounts/extsvcaccounts/models.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package extsvcaccounts
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/models/roletype"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/util/errutil"
|
||||
)
|
||||
|
||||
const (
|
||||
kvStoreType = "extsvc-token"
|
||||
// #nosec G101 - this is not a hardcoded secret
|
||||
tokenNamePrefix = "extsvc-token"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCannotBeDeleted = errutil.BadRequest("extsvcaccounts.ErrCannotBeDeleted", errutil.WithPublicMessage("external service account cannot be deleted"))
|
||||
ErrInvalidName = errutil.BadRequest("extsvcaccounts.ErrInvalidName", errutil.WithPublicMessage("only external service account names can be prefixed with 'extsvc-'"))
|
||||
ErrCannotBeUpdated = errutil.BadRequest("extsvcaccounts.ErrCannotBeUpdated", errutil.WithPublicMessage("external service account cannot be updated"))
|
||||
ErrCannotCreateToken = errutil.BadRequest("extsvcaccounts.ErrCannotCreateToken", errutil.WithPublicMessage("cannot add external service account token"))
|
||||
|
||||
ErrCredentialsNotFound = errutil.NotFound("extsvcaccounts.credentials-not-found")
|
||||
)
|
||||
|
||||
// Credentials represents the credentials associated to an external service
|
||||
type Credentials struct {
|
||||
Secret string
|
||||
}
|
||||
|
||||
type SaveCredentialsCmd struct {
|
||||
ExtSvcSlug string
|
||||
OrgID int64
|
||||
Secret string
|
||||
}
|
||||
|
||||
type saveCmd struct {
|
||||
ExtSvcSlug string
|
||||
OrgID int64
|
||||
Permissions []ac.Permission
|
||||
SaID int64
|
||||
}
|
||||
|
||||
func newRole(r roletype.RoleType) *roletype.RoleType {
|
||||
return &r
|
||||
}
|
||||
|
||||
func newBool(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
241
pkg/services/serviceaccounts/extsvcaccounts/service.go
Normal file
241
pkg/services/serviceaccounts/extsvcaccounts/service.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package extsvcaccounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/satokengen"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||
"github.com/grafana/grafana/pkg/models/roletype"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/extsvcauth"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/kvstore"
|
||||
sa "github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||
"github.com/grafana/grafana/pkg/services/serviceaccounts/manager"
|
||||
)
|
||||
|
||||
type ExtSvcAccountsService struct {
|
||||
acSvc ac.Service
|
||||
logger log.Logger
|
||||
saSvc sa.Service
|
||||
skvStore kvstore.SecretsKVStore
|
||||
}
|
||||
|
||||
func ProvideExtSvcAccountsService(acSvc ac.Service, saSvc *manager.ServiceAccountsService, db db.DB, secretsSvc secrets.Service) *ExtSvcAccountsService {
|
||||
logger := log.New("serviceauth.extsvcaccounts")
|
||||
return &ExtSvcAccountsService{
|
||||
acSvc: acSvc,
|
||||
logger: logger,
|
||||
saSvc: saSvc,
|
||||
skvStore: kvstore.NewSQLSecretsKVStore(db, secretsSvc, logger), // Using SQL store to avoid a cyclic dependency
|
||||
}
|
||||
}
|
||||
|
||||
// RetrieveExtSvcAccount fetches an external service account by ID
|
||||
func (esa *ExtSvcAccountsService) RetrieveExtSvcAccount(ctx context.Context, orgID, saID int64) (*sa.ExtSvcAccount, error) {
|
||||
svcAcc, err := esa.saSvc.RetrieveServiceAccount(ctx, orgID, saID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sa.ExtSvcAccount{
|
||||
ID: svcAcc.Id,
|
||||
Login: svcAcc.Login,
|
||||
Name: svcAcc.Name,
|
||||
OrgID: svcAcc.OrgId,
|
||||
IsDisabled: svcAcc.IsDisabled,
|
||||
Role: roletype.RoleType(svcAcc.Role),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveExternalService creates, updates or delete a service account (and its token) with the requested permissions.
|
||||
func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) {
|
||||
if cmd == nil {
|
||||
esa.logger.Warn("Received no input")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
slug := slugify.Slugify(cmd.Name)
|
||||
|
||||
if cmd.Impersonation.Enabled {
|
||||
esa.logger.Warn("Impersonation setup skipped. It is not possible to impersonate with a service account token.", "service", slug)
|
||||
}
|
||||
|
||||
saID, err := esa.ManageExtSvcAccount(ctx, &sa.ManageExtSvcAccountCmd{
|
||||
ExtSvcSlug: slug,
|
||||
Enabled: cmd.Self.Enabled,
|
||||
OrgID: extsvcauth.TmpOrgID,
|
||||
Permissions: cmd.Self.Permissions,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// No need for a token if we don't have a service account
|
||||
if saID <= 0 {
|
||||
esa.logger.Debug("Skipping service account token creation", "service", slug)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
token, err := esa.getExtSvcAccountToken(ctx, extsvcauth.TmpOrgID, saID, slug)
|
||||
if err != nil {
|
||||
esa.logger.Error("Could not get the external svc token",
|
||||
"service", slug,
|
||||
"saID", saID,
|
||||
"error", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
return &extsvcauth.ExternalService{Name: cmd.Name, ID: slug, Secret: token}, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if cmd == nil {
|
||||
esa.logger.Warn("Received no input")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
saID, errRetrieve := esa.saSvc.RetrieveServiceAccountIdByName(ctx, cmd.OrgID, sa.ExtSvcPrefix+cmd.ExtSvcSlug)
|
||||
if errRetrieve != nil && !errors.Is(errRetrieve, sa.ErrServiceAccountNotFound) {
|
||||
return 0, errRetrieve
|
||||
}
|
||||
|
||||
if !cmd.Enabled || len(cmd.Permissions) == 0 {
|
||||
if saID > 0 {
|
||||
if err := esa.deleteExtSvcAccount(ctx, cmd.OrgID, cmd.ExtSvcSlug, saID); err != nil {
|
||||
esa.logger.Error("Error occurred while deleting service account",
|
||||
"service", cmd.ExtSvcSlug,
|
||||
"saID", saID,
|
||||
"error", err.Error())
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
esa.logger.Info("Skipping service account creation",
|
||||
"service", cmd.ExtSvcSlug,
|
||||
"enabled", cmd.Enabled,
|
||||
"permission count", len(cmd.Permissions),
|
||||
"saID", saID)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
saID, errSave := esa.saveExtSvcAccount(ctx, &saveCmd{
|
||||
ExtSvcSlug: cmd.ExtSvcSlug,
|
||||
OrgID: cmd.OrgID,
|
||||
Permissions: cmd.Permissions,
|
||||
SaID: saID,
|
||||
})
|
||||
if errSave != nil {
|
||||
esa.logger.Error("Could not save service account", "service", cmd.ExtSvcSlug, "error", errSave.Error())
|
||||
return 0, errSave
|
||||
}
|
||||
|
||||
return saID, nil
|
||||
}
|
||||
|
||||
// saveExtSvcAccount creates or updates the service account associated with an external service
|
||||
func (esa *ExtSvcAccountsService) saveExtSvcAccount(ctx context.Context, cmd *saveCmd) (int64, error) {
|
||||
if cmd.SaID <= 0 {
|
||||
// Create a service account
|
||||
esa.logger.Debug("Create service account", "service", cmd.ExtSvcSlug, "orgID", cmd.OrgID)
|
||||
sa, err := esa.saSvc.CreateServiceAccount(ctx, cmd.OrgID, &sa.CreateServiceAccountForm{
|
||||
Name: sa.ExtSvcPrefix + cmd.ExtSvcSlug,
|
||||
Role: newRole(roletype.RoleNone),
|
||||
IsDisabled: newBool(false),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cmd.SaID = sa.Id
|
||||
}
|
||||
|
||||
// update the service account's permissions
|
||||
esa.logger.Debug("Update role permissions", "service", cmd.ExtSvcSlug, "saID", cmd.SaID)
|
||||
if err := esa.acSvc.SaveExternalServiceRole(ctx, ac.SaveExternalServiceRoleCommand{
|
||||
OrgID: ac.GlobalOrgID,
|
||||
Global: true,
|
||||
ExternalServiceID: cmd.ExtSvcSlug,
|
||||
ServiceAccountID: cmd.SaID,
|
||||
Permissions: cmd.Permissions,
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return cmd.SaID, nil
|
||||
}
|
||||
|
||||
// deleteExtSvcAccount deletes a service account by ID and removes its associated role
|
||||
func (esa *ExtSvcAccountsService) deleteExtSvcAccount(ctx context.Context, orgID int64, slug string, saID int64) error {
|
||||
esa.logger.Info("Delete service account", "service", slug, "orgID", orgID, "saID", saID)
|
||||
if err := esa.saSvc.DeleteServiceAccount(ctx, orgID, saID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := esa.acSvc.DeleteExternalServiceRole(ctx, slug); err != nil {
|
||||
return err
|
||||
}
|
||||
return esa.DeleteExtSvcCredentials(ctx, orgID, slug)
|
||||
}
|
||||
|
||||
// getExtSvcAccountToken get or create the token of an External Service
|
||||
func (esa *ExtSvcAccountsService) getExtSvcAccountToken(ctx context.Context, orgID, saID int64, extSvcSlug string) (string, error) {
|
||||
// Get credentials from store
|
||||
credentials, err := esa.GetExtSvcCredentials(ctx, orgID, extSvcSlug)
|
||||
if err != nil && !errors.Is(err, ErrCredentialsNotFound) {
|
||||
return "", err
|
||||
}
|
||||
if credentials != nil {
|
||||
return credentials.Secret, nil
|
||||
}
|
||||
|
||||
// Generate token
|
||||
esa.logger.Info("Generate new service account token", "service", extSvcSlug, "orgID", orgID)
|
||||
newKeyInfo, err := satokengen.New(extSvcSlug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
esa.logger.Debug("Add service account token", "service", extSvcSlug, "orgID", orgID)
|
||||
if _, err := esa.saSvc.AddServiceAccountToken(ctx, saID, &sa.AddServiceAccountTokenCommand{
|
||||
Name: tokenNamePrefix + "-" + extSvcSlug,
|
||||
OrgId: orgID,
|
||||
Key: newKeyInfo.HashedKey,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := esa.SaveExtSvcCredentials(ctx, &SaveCredentialsCmd{
|
||||
ExtSvcSlug: extSvcSlug,
|
||||
OrgID: orgID,
|
||||
Secret: newKeyInfo.ClientSecret,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return newKeyInfo.ClientSecret, nil
|
||||
}
|
||||
|
||||
// GetExtSvcCredentials get the credentials of an External Service from an encrypted storage
|
||||
func (esa *ExtSvcAccountsService) GetExtSvcCredentials(ctx context.Context, orgID int64, extSvcSlug string) (*Credentials, error) {
|
||||
esa.logger.Debug("Get service account token from skv", "service", extSvcSlug, "orgID", orgID)
|
||||
token, ok, err := esa.skvStore.Get(ctx, orgID, extSvcSlug, kvStoreType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrCredentialsNotFound.Errorf("No credential found for in store %v", extSvcSlug)
|
||||
}
|
||||
return &Credentials{Secret: token}, nil
|
||||
}
|
||||
|
||||
// SaveExtSvcCredentials stores the credentials of an External Service in an encrypted storage
|
||||
func (esa *ExtSvcAccountsService) SaveExtSvcCredentials(ctx context.Context, cmd *SaveCredentialsCmd) error {
|
||||
esa.logger.Debug("Save service account token in skv", "service", cmd.ExtSvcSlug, "orgID", cmd.OrgID)
|
||||
return esa.skvStore.Set(ctx, cmd.OrgID, cmd.ExtSvcSlug, kvStoreType, cmd.Secret)
|
||||
}
|
||||
|
||||
// DeleteExtSvcCredentials removes the credentials of an External Service from an encrypted storage
|
||||
func (esa *ExtSvcAccountsService) DeleteExtSvcCredentials(ctx context.Context, orgID int64, extSvcSlug string) error {
|
||||
esa.logger.Debug("Delete service account token from skv", "service", extSvcSlug, "orgID", orgID)
|
||||
return esa.skvStore.Del(ctx, orgID, extSvcSlug, kvStoreType)
|
||||
}
|
||||
409
pkg/services/serviceaccounts/extsvcaccounts/service_test.go
Normal file
409
pkg/services/serviceaccounts/extsvcaccounts/service_test.go
Normal file
@@ -0,0 +1,409 @@
|
||||
package extsvcaccounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/models/roletype"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
"github.com/grafana/grafana/pkg/services/apikey"
|
||||
"github.com/grafana/grafana/pkg/services/extsvcauth"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/kvstore"
|
||||
sa "github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type TestEnv struct {
|
||||
S *ExtSvcAccountsService
|
||||
AcStore *actest.MockStore
|
||||
SaSvc *tests.MockServiceAccountService
|
||||
SkvStore *kvstore.FakeSecretsKVStore
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T) *TestEnv {
|
||||
t.Helper()
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
fmgt := featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts)
|
||||
|
||||
env := &TestEnv{
|
||||
AcStore: &actest.MockStore{},
|
||||
SaSvc: &tests.MockServiceAccountService{},
|
||||
SkvStore: kvstore.NewFakeSecretsKVStore(),
|
||||
}
|
||||
env.S = &ExtSvcAccountsService{
|
||||
acSvc: acimpl.ProvideOSSService(cfg, env.AcStore, localcache.New(0, 0), fmgt),
|
||||
logger: log.New("extsvcaccounts.test"),
|
||||
saSvc: env.SaSvc,
|
||||
skvStore: env.SkvStore,
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func TestExtSvcAccountsService_ManageExtSvcAccount(t *testing.T) {
|
||||
extSvcSlug := "grafana-test-app"
|
||||
extSvcOrgID := int64(20)
|
||||
extSvcAccID := int64(10)
|
||||
extSvcPerms := []ac.Permission{{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}}
|
||||
extSvcAccount := &sa.ServiceAccountDTO{
|
||||
Id: extSvcAccID,
|
||||
Name: extSvcSlug,
|
||||
Login: extSvcSlug,
|
||||
OrgId: extSvcOrgID,
|
||||
IsDisabled: false,
|
||||
Role: string(roletype.RoleNone),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
init func(env *TestEnv)
|
||||
cmd sa.ManageExtSvcAccountCmd
|
||||
checks func(t *testing.T, env *TestEnv)
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "should remove service account when disabled",
|
||||
init: func(env *TestEnv) {
|
||||
// A previous service account was attached to this slug
|
||||
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, mock.Anything, mock.Anything).Return(extSvcAccID, nil)
|
||||
env.SaSvc.On("DeleteServiceAccount", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
env.AcStore.On("DeleteExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
|
||||
},
|
||||
cmd: sa.ManageExtSvcAccountCmd{
|
||||
ExtSvcSlug: extSvcSlug,
|
||||
Enabled: false,
|
||||
OrgID: extSvcOrgID,
|
||||
Permissions: extSvcPerms,
|
||||
},
|
||||
checks: func(t *testing.T, env *TestEnv) {
|
||||
env.SaSvc.AssertCalled(t, "RetrieveServiceAccountIdByName", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == extSvcOrgID }),
|
||||
mock.MatchedBy(func(slug string) bool { return slug == sa.ExtSvcPrefix+extSvcSlug }))
|
||||
env.SaSvc.AssertCalled(t, "DeleteServiceAccount", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == extSvcOrgID }),
|
||||
mock.MatchedBy(func(saID int64) bool { return saID == extSvcAccID }))
|
||||
env.AcStore.AssertCalled(t, "DeleteExternalServiceRole", mock.Anything,
|
||||
mock.MatchedBy(func(slug string) bool { return slug == extSvcSlug }))
|
||||
},
|
||||
want: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should remove service account when no permission",
|
||||
init: func(env *TestEnv) {
|
||||
// A previous service account was attached to this slug
|
||||
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, mock.Anything, mock.Anything).Return(extSvcAccID, nil)
|
||||
env.SaSvc.On("DeleteServiceAccount", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
env.AcStore.On("DeleteExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
|
||||
},
|
||||
cmd: sa.ManageExtSvcAccountCmd{
|
||||
ExtSvcSlug: extSvcSlug,
|
||||
Enabled: true,
|
||||
OrgID: extSvcOrgID,
|
||||
Permissions: []ac.Permission{},
|
||||
},
|
||||
checks: func(t *testing.T, env *TestEnv) {
|
||||
env.SaSvc.AssertCalled(t, "RetrieveServiceAccountIdByName", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == extSvcOrgID }),
|
||||
mock.MatchedBy(func(slug string) bool { return slug == sa.ExtSvcPrefix+extSvcSlug }))
|
||||
env.SaSvc.AssertCalled(t, "DeleteServiceAccount", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == extSvcOrgID }),
|
||||
mock.MatchedBy(func(saID int64) bool { return saID == extSvcAccID }))
|
||||
env.AcStore.AssertCalled(t, "DeleteExternalServiceRole", mock.Anything,
|
||||
mock.MatchedBy(func(slug string) bool { return slug == extSvcSlug }))
|
||||
},
|
||||
want: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should create new service account",
|
||||
init: func(env *TestEnv) {
|
||||
// No previous service account was attached to this slug
|
||||
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(int64(0), sa.ErrServiceAccountNotFound.Errorf("mock"))
|
||||
env.SaSvc.On("CreateServiceAccount", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(extSvcAccount, nil)
|
||||
env.AcStore.On("SaveExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
|
||||
},
|
||||
cmd: sa.ManageExtSvcAccountCmd{
|
||||
ExtSvcSlug: extSvcSlug,
|
||||
Enabled: true,
|
||||
OrgID: extSvcOrgID,
|
||||
Permissions: extSvcPerms,
|
||||
},
|
||||
checks: func(t *testing.T, env *TestEnv) {
|
||||
env.SaSvc.AssertCalled(t, "RetrieveServiceAccountIdByName", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == extSvcOrgID }),
|
||||
mock.MatchedBy(func(slug string) bool { return slug == sa.ExtSvcPrefix+extSvcSlug }))
|
||||
env.SaSvc.AssertCalled(t, "CreateServiceAccount", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == extSvcOrgID }),
|
||||
mock.MatchedBy(func(cmd *sa.CreateServiceAccountForm) bool {
|
||||
return cmd.Name == sa.ExtSvcPrefix+extSvcSlug && *cmd.Role == roletype.RoleNone
|
||||
}),
|
||||
)
|
||||
env.AcStore.AssertCalled(t, "SaveExternalServiceRole", mock.Anything,
|
||||
mock.MatchedBy(func(cmd ac.SaveExternalServiceRoleCommand) bool {
|
||||
return cmd.ServiceAccountID == extSvcAccount.Id && cmd.ExternalServiceID == extSvcSlug &&
|
||||
cmd.OrgID == int64(ac.GlobalOrgID) && len(cmd.Permissions) == 1 &&
|
||||
cmd.Permissions[0] == extSvcPerms[0]
|
||||
}))
|
||||
},
|
||||
want: extSvcAccID,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should update service account",
|
||||
init: func(env *TestEnv) {
|
||||
// A previous service account was attached to this slug
|
||||
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(int64(11), nil)
|
||||
env.AcStore.On("SaveExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
|
||||
},
|
||||
cmd: sa.ManageExtSvcAccountCmd{
|
||||
ExtSvcSlug: extSvcSlug,
|
||||
Enabled: true,
|
||||
OrgID: extSvcOrgID,
|
||||
Permissions: extSvcPerms,
|
||||
},
|
||||
checks: func(t *testing.T, env *TestEnv) {
|
||||
env.SaSvc.AssertCalled(t, "RetrieveServiceAccountIdByName", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == extSvcOrgID }),
|
||||
mock.MatchedBy(func(slug string) bool { return slug == sa.ExtSvcPrefix+extSvcSlug }))
|
||||
env.AcStore.AssertCalled(t, "SaveExternalServiceRole", mock.Anything,
|
||||
mock.MatchedBy(func(cmd ac.SaveExternalServiceRoleCommand) bool {
|
||||
return cmd.ServiceAccountID == int64(11) && cmd.ExternalServiceID == extSvcSlug &&
|
||||
cmd.OrgID == int64(ac.GlobalOrgID) && len(cmd.Permissions) == 1 &&
|
||||
cmd.Permissions[0] == extSvcPerms[0]
|
||||
}))
|
||||
},
|
||||
want: 11,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
got, err := env.S.ManageExtSvcAccount(ctx, &tt.cmd)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.checks != nil {
|
||||
tt.checks(t, env)
|
||||
}
|
||||
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtSvcAccountsService_SaveExternalService(t *testing.T) {
|
||||
extSvcSlug := "grafana-test-app"
|
||||
tmpOrgID := int64(1)
|
||||
extSvcAccID := int64(10)
|
||||
extSvcPerms := []ac.Permission{{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}}
|
||||
extSvcAccount := &sa.ServiceAccountDTO{
|
||||
Id: extSvcAccID,
|
||||
Name: extSvcSlug,
|
||||
Login: extSvcSlug,
|
||||
OrgId: tmpOrgID,
|
||||
IsDisabled: false,
|
||||
Role: string(roletype.RoleNone),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
init func(env *TestEnv)
|
||||
cmd extsvcauth.ExternalServiceRegistration
|
||||
checks func(t *testing.T, env *TestEnv)
|
||||
want *extsvcauth.ExternalService
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "should remove service account when disabled",
|
||||
init: func(env *TestEnv) {
|
||||
// A previous service account was attached to this slug
|
||||
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, mock.Anything, mock.Anything).Return(extSvcAccID, nil)
|
||||
env.SaSvc.On("DeleteServiceAccount", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
env.AcStore.On("DeleteExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
|
||||
// A token was previously stored in the secret store
|
||||
_ = env.SkvStore.Set(context.Background(), tmpOrgID, extSvcSlug, kvStoreType, "ExtSvcSecretToken")
|
||||
},
|
||||
cmd: extsvcauth.ExternalServiceRegistration{
|
||||
Name: extSvcSlug,
|
||||
Self: extsvcauth.SelfCfg{
|
||||
Enabled: false,
|
||||
Permissions: extSvcPerms,
|
||||
},
|
||||
},
|
||||
checks: func(t *testing.T, env *TestEnv) {
|
||||
env.SaSvc.AssertCalled(t, "RetrieveServiceAccountIdByName", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == tmpOrgID }),
|
||||
mock.MatchedBy(func(slug string) bool { return slug == sa.ExtSvcPrefix+extSvcSlug }))
|
||||
env.SaSvc.AssertCalled(t, "DeleteServiceAccount", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == tmpOrgID }),
|
||||
mock.MatchedBy(func(saID int64) bool { return saID == extSvcAccID }))
|
||||
env.AcStore.AssertCalled(t, "DeleteExternalServiceRole", mock.Anything,
|
||||
mock.MatchedBy(func(slug string) bool { return slug == extSvcSlug }))
|
||||
_, ok, _ := env.SkvStore.Get(context.Background(), tmpOrgID, extSvcSlug, kvStoreType)
|
||||
require.False(t, ok, "secret should have been removed from store")
|
||||
},
|
||||
want: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should remove service account when no permission",
|
||||
init: func(env *TestEnv) {
|
||||
// A previous service account was attached to this slug
|
||||
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, mock.Anything, mock.Anything).Return(extSvcAccID, nil)
|
||||
env.SaSvc.On("DeleteServiceAccount", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
env.AcStore.On("DeleteExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
|
||||
},
|
||||
cmd: extsvcauth.ExternalServiceRegistration{
|
||||
Name: extSvcSlug,
|
||||
Self: extsvcauth.SelfCfg{
|
||||
Enabled: true,
|
||||
Permissions: []ac.Permission{},
|
||||
},
|
||||
},
|
||||
checks: func(t *testing.T, env *TestEnv) {
|
||||
env.SaSvc.AssertCalled(t, "RetrieveServiceAccountIdByName", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == tmpOrgID }),
|
||||
mock.MatchedBy(func(slug string) bool { return slug == sa.ExtSvcPrefix+extSvcSlug }))
|
||||
env.SaSvc.AssertCalled(t, "DeleteServiceAccount", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == tmpOrgID }),
|
||||
mock.MatchedBy(func(saID int64) bool { return saID == extSvcAccID }))
|
||||
env.AcStore.AssertCalled(t, "DeleteExternalServiceRole", mock.Anything,
|
||||
mock.MatchedBy(func(slug string) bool { return slug == extSvcSlug }))
|
||||
},
|
||||
want: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should create new service account",
|
||||
init: func(env *TestEnv) {
|
||||
// No previous service account was attached to this slug
|
||||
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(int64(0), sa.ErrServiceAccountNotFound.Errorf("mock"))
|
||||
env.SaSvc.On("CreateServiceAccount", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(extSvcAccount, nil)
|
||||
// Api Key was added without problem
|
||||
env.SaSvc.On("AddServiceAccountToken", mock.Anything, mock.Anything, mock.Anything).Return(&apikey.APIKey{}, nil)
|
||||
env.AcStore.On("SaveExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
|
||||
},
|
||||
cmd: extsvcauth.ExternalServiceRegistration{
|
||||
Name: extSvcSlug,
|
||||
Self: extsvcauth.SelfCfg{
|
||||
Enabled: true,
|
||||
Permissions: extSvcPerms,
|
||||
},
|
||||
},
|
||||
checks: func(t *testing.T, env *TestEnv) {
|
||||
env.SaSvc.AssertCalled(t, "RetrieveServiceAccountIdByName", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == tmpOrgID }),
|
||||
mock.MatchedBy(func(slug string) bool { return slug == sa.ExtSvcPrefix+extSvcSlug }))
|
||||
env.SaSvc.AssertCalled(t, "CreateServiceAccount", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == tmpOrgID }),
|
||||
mock.MatchedBy(func(cmd *sa.CreateServiceAccountForm) bool {
|
||||
return cmd.Name == sa.ExtSvcPrefix+extSvcSlug && *cmd.Role == roletype.RoleNone
|
||||
}),
|
||||
)
|
||||
env.AcStore.AssertCalled(t, "SaveExternalServiceRole", mock.Anything,
|
||||
mock.MatchedBy(func(cmd ac.SaveExternalServiceRoleCommand) bool {
|
||||
return cmd.ServiceAccountID == extSvcAccount.Id && cmd.ExternalServiceID == extSvcSlug &&
|
||||
cmd.OrgID == int64(ac.GlobalOrgID) && len(cmd.Permissions) == 1 &&
|
||||
cmd.Permissions[0] == extSvcPerms[0]
|
||||
}))
|
||||
},
|
||||
want: &extsvcauth.ExternalService{
|
||||
Name: extSvcSlug,
|
||||
ID: extSvcSlug,
|
||||
Secret: "not empty",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should update service account",
|
||||
init: func(env *TestEnv) {
|
||||
// A previous service account was attached to this slug
|
||||
env.SaSvc.On("RetrieveServiceAccountIdByName", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(int64(11), nil)
|
||||
env.AcStore.On("SaveExternalServiceRole", mock.Anything, mock.Anything).Return(nil)
|
||||
// This time we don't add a token but rely on the secret store
|
||||
_ = env.SkvStore.Set(context.Background(), tmpOrgID, extSvcSlug, kvStoreType, "ExtSvcSecretToken")
|
||||
},
|
||||
cmd: extsvcauth.ExternalServiceRegistration{
|
||||
Name: extSvcSlug,
|
||||
Self: extsvcauth.SelfCfg{
|
||||
Enabled: true,
|
||||
Permissions: extSvcPerms,
|
||||
},
|
||||
},
|
||||
checks: func(t *testing.T, env *TestEnv) {
|
||||
env.SaSvc.AssertCalled(t, "RetrieveServiceAccountIdByName", mock.Anything,
|
||||
mock.MatchedBy(func(orgID int64) bool { return orgID == tmpOrgID }),
|
||||
mock.MatchedBy(func(slug string) bool { return slug == sa.ExtSvcPrefix+extSvcSlug }))
|
||||
env.AcStore.AssertCalled(t, "SaveExternalServiceRole", mock.Anything,
|
||||
mock.MatchedBy(func(cmd ac.SaveExternalServiceRoleCommand) bool {
|
||||
return cmd.ServiceAccountID == int64(11) && cmd.ExternalServiceID == extSvcSlug &&
|
||||
cmd.OrgID == int64(ac.GlobalOrgID) && len(cmd.Permissions) == 1 &&
|
||||
cmd.Permissions[0] == extSvcPerms[0]
|
||||
}))
|
||||
},
|
||||
want: &extsvcauth.ExternalService{
|
||||
Name: extSvcSlug,
|
||||
ID: extSvcSlug,
|
||||
Secret: "not empty",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
got, err := env.S.SaveExternalService(ctx, &tt.cmd)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.checks != nil {
|
||||
tt.checks(t, env)
|
||||
}
|
||||
|
||||
// Only check that there is a secret, not it's actual value
|
||||
if tt.want != nil && len(tt.want.Secret) > 0 {
|
||||
require.NotEmpty(t, got.Secret)
|
||||
tt.want.Secret = got.Secret
|
||||
}
|
||||
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package serviceaccounts
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models/roletype"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
@@ -16,6 +17,7 @@ var (
|
||||
|
||||
const (
|
||||
ServiceAccountPrefix = "sa-"
|
||||
ExtSvcPrefix = "extsvc-"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -173,6 +175,23 @@ type Stats struct {
|
||||
ForcedExpiryEnabled bool `xorm:"-"`
|
||||
}
|
||||
|
||||
// ExtSvcAccount represents the service account associated to an external service
|
||||
type ExtSvcAccount struct {
|
||||
ID int64
|
||||
Login string
|
||||
Name string
|
||||
OrgID int64
|
||||
IsDisabled bool
|
||||
Role roletype.RoleType
|
||||
}
|
||||
|
||||
type ManageExtSvcAccountCmd struct {
|
||||
ExtSvcSlug string
|
||||
Enabled bool // disabled: the service account and its permissions will be deleted
|
||||
OrgID int64
|
||||
Permissions []accesscontrol.Permission
|
||||
}
|
||||
|
||||
// AccessEvaluator is used to protect the "Configuration > Service accounts" page access
|
||||
var AccessEvaluator = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(ActionRead),
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apikey"
|
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/extsvcaccounts"
|
||||
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||
"github.com/grafana/grafana/pkg/services/serviceaccounts/extsvcaccounts"
|
||||
"github.com/grafana/grafana/pkg/services/serviceaccounts/manager"
|
||||
)
|
||||
|
||||
@@ -101,9 +101,9 @@ func (s *ServiceAccountsProxy) UpdateServiceAccount(ctx context.Context, orgID,
|
||||
}
|
||||
|
||||
func isNameValid(name string) bool {
|
||||
return !strings.HasPrefix(name, extsvcaccounts.ExtSvcPrefix)
|
||||
return !strings.HasPrefix(name, serviceaccounts.ExtSvcPrefix)
|
||||
}
|
||||
|
||||
func isExternalServiceAccount(login string) bool {
|
||||
return strings.HasPrefix(login, serviceaccounts.ServiceAccountPrefix+extsvcaccounts.ExtSvcPrefix)
|
||||
return strings.HasPrefix(login, serviceaccounts.ServiceAccountPrefix+serviceaccounts.ExtSvcPrefix)
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apikey"
|
||||
"github.com/grafana/grafana/pkg/services/extsvcauth/extsvcaccounts"
|
||||
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||
"github.com/grafana/grafana/pkg/services/serviceaccounts/extsvcaccounts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -22,3 +22,11 @@ type Service interface {
|
||||
AddServiceAccountToken(ctx context.Context, serviceAccountID int64,
|
||||
cmd *AddServiceAccountTokenCommand) (*apikey.APIKey, error)
|
||||
}
|
||||
|
||||
//go:generate mockery --name ExtSvcAccountsService --structname MockExtSvcAccountsService --output tests --outpkg tests --filename extsvcaccmock.go
|
||||
type ExtSvcAccountsService interface {
|
||||
// ManageExtSvcAccount creates, updates or deletes the service account associated with an external service
|
||||
ManageExtSvcAccount(ctx context.Context, cmd *ManageExtSvcAccountCmd) (int64, error)
|
||||
// RetrieveExtSvcAccount fetches an external service account by ID
|
||||
RetrieveExtSvcAccount(ctx context.Context, orgID, saID int64) (*ExtSvcAccount, error)
|
||||
}
|
||||
|
||||
79
pkg/services/serviceaccounts/tests/extsvcaccmock.go
Normal file
79
pkg/services/serviceaccounts/tests/extsvcaccmock.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Code generated by mockery v2.35.2. DO NOT EDIT.
|
||||
|
||||
package tests
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
serviceaccounts "github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockExtSvcAccountsService is an autogenerated mock type for the ExtSvcAccountsService type
|
||||
type MockExtSvcAccountsService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// ManageExtSvcAccount provides a mock function with given fields: ctx, cmd
|
||||
func (_m *MockExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *serviceaccounts.ManageExtSvcAccountCmd) (int64, error) {
|
||||
ret := _m.Called(ctx, cmd)
|
||||
|
||||
var r0 int64
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *serviceaccounts.ManageExtSvcAccountCmd) (int64, error)); ok {
|
||||
return rf(ctx, cmd)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *serviceaccounts.ManageExtSvcAccountCmd) int64); ok {
|
||||
r0 = rf(ctx, cmd)
|
||||
} else {
|
||||
r0 = ret.Get(0).(int64)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *serviceaccounts.ManageExtSvcAccountCmd) error); ok {
|
||||
r1 = rf(ctx, cmd)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ret := _m.Called(ctx, orgID, saID)
|
||||
|
||||
var r0 *serviceaccounts.ExtSvcAccount
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int64, int64) (*serviceaccounts.ExtSvcAccount, error)); ok {
|
||||
return rf(ctx, orgID, saID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int64, int64) *serviceaccounts.ExtSvcAccount); ok {
|
||||
r0 = rf(ctx, orgID, saID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*serviceaccounts.ExtSvcAccount)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int64, int64) error); ok {
|
||||
r1 = rf(ctx, orgID, saID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewMockExtSvcAccountsService creates a new instance of MockExtSvcAccountsService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockExtSvcAccountsService(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockExtSvcAccountsService {
|
||||
mock := &MockExtSvcAccountsService{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
Reference in New Issue
Block a user