Chore: Move extsvcaccounts package to serviceaccounts (#76977)

* Chore: Move extsvcaccounts package to serviceaccounts

* Fix proxy

* Fix tests

* Fix linting
This commit is contained in:
Gabriel MABILLE
2023-10-24 11:01:04 +02:00
committed by GitHub
parent 159bb3c032
commit 3015e5921f
15 changed files with 93 additions and 91 deletions

View 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
}

View 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)
}

View 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)
})
}
}

View File

@@ -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),

View File

@@ -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)
}

View File

@@ -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"
)

View File

@@ -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)
}

View 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
}