mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
ServiceAccounts: Delete ServiceAccount (#40470)
* Add extra fields to OSS types to support enterprise * WIP service accounts * Update public/app/features/api-keys/ApiKeysForm.tsx Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com> * Create a service account at the same time as the API key * Use service account credentials when accessing API with APIkey * Throw better error * Use Boolean for "create service account button" * Add GetRole to service, merge RoleDTO and Role structs This patch merges the identical OSS and Enterprise data structures, which improves the code for two reasons: 1. Makes switching between OSS and Enterprise easier 2. Reduces the chance of incompatibilities developing between the same functions in OSS and Enterprise * Start work cloning permissions onto service account * If API key is not linked to a service account, continue login as usual * Fallback to old auth if no service account linked to key * Commented * Add CloneUserToServiceAccount * Update mock.go * Put graphical bits behind a feature toggle * Start adding LinkAPIKeyToServiceAccount * Update pkg/models/user.go Co-authored-by: Eric Leijonmarck <eric.leijonmarck@gmail.com> * Update pkg/api/apikey.go Co-authored-by: Eric Leijonmarck <eric.leijonmarck@gmail.com> * Update pkg/api/apikey.go Co-authored-by: Eric Leijonmarck <eric.leijonmarck@gmail.com> * Finish LinkAPIKeyToServiceAccount * Update comment * Handle api key link error * Update pkg/services/sqlstore/apikey.go Co-authored-by: Emil Tullstedt <emil.tullstedt@grafana.com> * Feature toggle * Update pkg/services/accesscontrol/accesscontrol.go Co-authored-by: Ieva <ieva.vasiljeva@grafana.com> * Not needed (yet) * Better error messages for OSS accesscontrol * Set an invalid user id as default * ServiceAccountId should be string * Re-arrange field names * ServiceAccountId is integer * Update ossaccesscontrol.go * Linter * Remove fronend edits * Remove console log * Update ApiKeysForm.tsx * feat: add serviceaccount deletion * feat: make sure we do not accidently delete serviceaccount * feat: ServiceAccount Type * refactor: userDeletions function * refactor: serviceaccount deletions\ * refactor: error name and removed attribute for userDeletecommand * refactor:: remove serviceaccount type for now * WIP * add mocked function * Remove unnecessary db query, move to right place * Update pkg/services/accesscontrol/mock/mock.go Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com> * Update pkg/services/accesscontrol/mock/mock.go Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com> * Update pkg/services/accesscontrol/mock/mock.go Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com> * Better error messages * Better and correcter error messages * add mocked function * refactor: move function call, add error msg * add IsServiceAccount and fix table * add service accounts package * WIP * WIP * working serviceaccountsapi registration * WIP tests * test * test working * test running for service * moved the error out of the models package * fixed own review * linting errors * Update pkg/services/serviceaccounts/database/database.go Co-authored-by: Jeremy Price <Jeremy.price@grafana.com> * tests running for api * WIP * WIP * removed unused secrets background svc * removed background svc for serviceaccount infavor or wire.go * serviceaccounts manager tests * registering as backend service Co-authored-by: Jeremy Price <jeremy.price@grafana.com> Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com> Co-authored-by: Emil Tullstedt <emil.tullstedt@grafana.com> Co-authored-by: Ieva <ieva.vasiljeva@grafana.com> Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
parent
cd01384d3a
commit
4fd3dd41bc
@ -34,8 +34,9 @@ type User struct {
|
|||||||
HelpFlags1 HelpFlags1
|
HelpFlags1 HelpFlags1
|
||||||
IsDisabled bool
|
IsDisabled bool
|
||||||
|
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
OrgId int64
|
IsServiceAccount bool
|
||||||
|
OrgId int64
|
||||||
|
|
||||||
Created time.Time
|
Created time.Time
|
||||||
Updated time.Time
|
Updated time.Time
|
||||||
|
@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
||||||
"github.com/grafana/grafana/pkg/services/provisioning"
|
"github.com/grafana/grafana/pkg/services/provisioning"
|
||||||
"github.com/grafana/grafana/pkg/services/rendering"
|
"github.com/grafana/grafana/pkg/services/rendering"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
"github.com/grafana/grafana/pkg/services/updatechecker"
|
"github.com/grafana/grafana/pkg/services/updatechecker"
|
||||||
"github.com/grafana/grafana/pkg/tsdb/azuremonitor"
|
"github.com/grafana/grafana/pkg/tsdb/azuremonitor"
|
||||||
"github.com/grafana/grafana/pkg/tsdb/cloudmonitoring"
|
"github.com/grafana/grafana/pkg/tsdb/cloudmonitoring"
|
||||||
@ -50,7 +51,7 @@ func ProvideBackgroundServiceRegistry(
|
|||||||
_ *influxdb.Service, _ *loki.Service, _ *opentsdb.Service, _ *prometheus.Service, _ *tempo.Service,
|
_ *influxdb.Service, _ *loki.Service, _ *opentsdb.Service, _ *prometheus.Service, _ *tempo.Service,
|
||||||
_ *testdatasource.Service, _ *plugindashboards.Service, _ *dashboardsnapshots.Service,
|
_ *testdatasource.Service, _ *plugindashboards.Service, _ *dashboardsnapshots.Service,
|
||||||
_ *postgres.Service, _ *mysql.Service, _ *mssql.Service, _ *grafanads.Service, _ *cloudmonitoring.Service,
|
_ *postgres.Service, _ *mysql.Service, _ *mssql.Service, _ *grafanads.Service, _ *cloudmonitoring.Service,
|
||||||
_ *pluginsettings.Service, _ *alerting.AlertNotificationService,
|
_ *pluginsettings.Service, _ *alerting.AlertNotificationService, _ serviceaccounts.Service,
|
||||||
) *BackgroundServiceRegistry {
|
) *BackgroundServiceRegistry {
|
||||||
return NewBackgroundServiceRegistry(
|
return NewBackgroundServiceRegistry(
|
||||||
httpServer,
|
httpServer,
|
||||||
|
@ -54,6 +54,8 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/secrets"
|
"github.com/grafana/grafana/pkg/services/secrets"
|
||||||
secretsDatabase "github.com/grafana/grafana/pkg/services/secrets/database"
|
secretsDatabase "github.com/grafana/grafana/pkg/services/secrets/database"
|
||||||
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
|
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
|
serviceaccountsmanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager"
|
||||||
"github.com/grafana/grafana/pkg/services/shorturls"
|
"github.com/grafana/grafana/pkg/services/shorturls"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
"github.com/grafana/grafana/pkg/services/updatechecker"
|
"github.com/grafana/grafana/pkg/services/updatechecker"
|
||||||
@ -166,6 +168,8 @@ var wireBasicSet = wire.NewSet(
|
|||||||
datasources.ProvideService,
|
datasources.ProvideService,
|
||||||
pluginsettings.ProvideService,
|
pluginsettings.ProvideService,
|
||||||
alerting.ProvideService,
|
alerting.ProvideService,
|
||||||
|
serviceaccountsmanager.ProvideServiceAccountsService,
|
||||||
|
wire.Bind(new(serviceaccounts.Service), new(*serviceaccountsmanager.ServiceAccountsService)),
|
||||||
expr.ProvideService,
|
expr.ProvideService,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
53
pkg/services/serviceaccounts/api/api.go
Normal file
53
pkg/services/serviceaccounts/api/api.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
|
"github.com/grafana/grafana/pkg/middleware"
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceAccountsAPI struct {
|
||||||
|
service serviceaccounts.Service
|
||||||
|
accesscontrol accesscontrol.AccessControl
|
||||||
|
RouterRegister routing.RouteRegister
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServiceAccountsAPI(
|
||||||
|
service serviceaccounts.Service,
|
||||||
|
accesscontrol accesscontrol.AccessControl,
|
||||||
|
routerRegister routing.RouteRegister,
|
||||||
|
) *ServiceAccountsAPI {
|
||||||
|
return &ServiceAccountsAPI{
|
||||||
|
service: service,
|
||||||
|
accesscontrol: accesscontrol,
|
||||||
|
RouterRegister: routerRegister,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *ServiceAccountsAPI) RegisterAPIEndpoints(
|
||||||
|
cfg *setting.Cfg,
|
||||||
|
) {
|
||||||
|
if !cfg.FeatureToggles["service-accounts"] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth := acmiddleware.Middleware(api.accesscontrol)
|
||||||
|
api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) {
|
||||||
|
serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *ServiceAccountsAPI) DeleteServiceAccount(ctx *models.ReqContext) response.Response {
|
||||||
|
scopeID := ctx.ParamsInt64(":serviceAccountId")
|
||||||
|
err := api.service.DeleteServiceAccount(ctx.Req.Context(), ctx.OrgId, scopeID)
|
||||||
|
if err != nil {
|
||||||
|
return response.Error(http.StatusInternalServerError, "Service account deletion error", err)
|
||||||
|
}
|
||||||
|
return response.Success("service account deleted")
|
||||||
|
}
|
120
pkg/services/serviceaccounts/api/api_test.go
Normal file
120
pkg/services/serviceaccounts/api/api_test.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/macaron.v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
serviceaccountIDPath = "/api/serviceaccounts/%s"
|
||||||
|
)
|
||||||
|
|
||||||
|
// test the accesscontrol endpoints
|
||||||
|
// with permissions and without permissions
|
||||||
|
func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) {
|
||||||
|
store := sqlstore.InitTestDB(t)
|
||||||
|
svcmock := tests.ServiceAccountMock{}
|
||||||
|
|
||||||
|
var requestResponse = func(server *macaron.Macaron, httpMethod, requestpath string) *httptest.ResponseRecorder {
|
||||||
|
req, err := http.NewRequest(httpMethod, requestpath, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.ServeHTTP(recorder, req)
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
t.Run("should be able to delete serviceaccount for with permissions", func(t *testing.T) {
|
||||||
|
testcase := struct {
|
||||||
|
user tests.TestUser
|
||||||
|
acmock *accesscontrolmock.Mock
|
||||||
|
expectedCode int
|
||||||
|
}{
|
||||||
|
|
||||||
|
user: tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true},
|
||||||
|
acmock: tests.SetupMockAccesscontrol(
|
||||||
|
t,
|
||||||
|
func(c context.Context, siu *models.SignedInUser) ([]*accesscontrol.Permission, error) {
|
||||||
|
return []*accesscontrol.Permission{{Action: serviceaccounts.ActionDelete, Scope: serviceaccounts.ScopeAll}}, nil
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
expectedCode: http.StatusOK,
|
||||||
|
}
|
||||||
|
serviceAccountDeletionScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) {
|
||||||
|
createduser := tests.SetupUserServiceAccount(t, store, testcase.user)
|
||||||
|
server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock)
|
||||||
|
actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, fmt.Sprint(createduser.Id))).Code
|
||||||
|
require.Equal(t, testcase.expectedCode, actual)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should be forbidden to delete serviceaccount via accesscontrol on endpoint", func(t *testing.T) {
|
||||||
|
testcase := struct {
|
||||||
|
user tests.TestUser
|
||||||
|
acmock *accesscontrolmock.Mock
|
||||||
|
expectedCode int
|
||||||
|
}{
|
||||||
|
user: tests.TestUser{Login: "servicetest2@admin", IsServiceAccount: true},
|
||||||
|
acmock: tests.SetupMockAccesscontrol(
|
||||||
|
t,
|
||||||
|
func(c context.Context, siu *models.SignedInUser) ([]*accesscontrol.Permission, error) {
|
||||||
|
return []*accesscontrol.Permission{}, nil
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
expectedCode: http.StatusForbidden,
|
||||||
|
}
|
||||||
|
serviceAccountDeletionScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) {
|
||||||
|
createduser := tests.SetupUserServiceAccount(t, store, testcase.user)
|
||||||
|
server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock)
|
||||||
|
actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, fmt.Sprint(createduser.Id))).Code
|
||||||
|
require.Equal(t, testcase.expectedCode, actual)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceAccountDeletionScenario(t *testing.T, httpMethod string, endpoint string, user *tests.TestUser, fn func(httpmethod string, endpoint string, user *tests.TestUser)) {
|
||||||
|
t.Helper()
|
||||||
|
fn(httpMethod, endpoint, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, routerRegister routing.RouteRegister, acmock *accesscontrolmock.Mock) *macaron.Macaron {
|
||||||
|
a := NewServiceAccountsAPI(
|
||||||
|
svc,
|
||||||
|
acmock,
|
||||||
|
routerRegister,
|
||||||
|
)
|
||||||
|
a.RegisterAPIEndpoints(&setting.Cfg{FeatureToggles: map[string]bool{"service-accounts": true}})
|
||||||
|
|
||||||
|
m := macaron.New()
|
||||||
|
signedUser := &models.SignedInUser{
|
||||||
|
OrgId: 1,
|
||||||
|
OrgRole: models.ROLE_ADMIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Use(func(c *macaron.Context) {
|
||||||
|
ctx := &models.ReqContext{
|
||||||
|
Context: c,
|
||||||
|
IsSignedIn: true,
|
||||||
|
SignedInUser: signedUser,
|
||||||
|
Logger: log.New("serviceaccounts-test"),
|
||||||
|
}
|
||||||
|
c.Map(ctx)
|
||||||
|
})
|
||||||
|
a.RouterRegister.Register(m.Router)
|
||||||
|
return m
|
||||||
|
}
|
43
pkg/services/serviceaccounts/database/database.go
Normal file
43
pkg/services/serviceaccounts/database/database.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceAccountsStoreImpl struct {
|
||||||
|
sqlStore *sqlstore.SQLStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServiceAccountsStore(store *sqlstore.SQLStore) *ServiceAccountsStoreImpl {
|
||||||
|
return &ServiceAccountsStoreImpl{
|
||||||
|
sqlStore: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceAccountsStoreImpl) DeleteServiceAccount(ctx context.Context, orgID, serviceaccountID int64) error {
|
||||||
|
return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||||
|
return deleteServiceAccountInTransaction(sess, orgID, serviceaccountID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteServiceAccountInTransaction(sess *sqlstore.DBSession, orgID, serviceAccountID int64) error {
|
||||||
|
user := models.User{}
|
||||||
|
has, err := sess.Where(`org_id = ? and id = ? and is_service_account = true`, orgID, serviceAccountID).Get(&user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return serviceaccounts.ErrServiceAccountNotFound
|
||||||
|
}
|
||||||
|
for _, sql := range sqlstore.ServiceAccountDeletions() {
|
||||||
|
_, err := sess.Exec(sql, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
49
pkg/services/serviceaccounts/database/database_test.go
Normal file
49
pkg/services/serviceaccounts/database/database_test.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStore_DeleteServiceAccount(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
desc string
|
||||||
|
user tests.TestUser
|
||||||
|
expectedErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "service accounts should exist and get deleted",
|
||||||
|
user: tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true},
|
||||||
|
expectedErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "service accounts is false should not delete the user",
|
||||||
|
user: tests.TestUser{Login: "test1@admin", IsServiceAccount: false},
|
||||||
|
expectedErr: serviceaccounts.ErrServiceAccountNotFound,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.desc, func(t *testing.T) {
|
||||||
|
db, store := setupTestDatabase(t)
|
||||||
|
user := tests.SetupUserServiceAccount(t, db, c.user)
|
||||||
|
err := store.DeleteServiceAccount(context.Background(), user.OrgId, user.Id)
|
||||||
|
if c.expectedErr != nil {
|
||||||
|
require.ErrorIs(t, err, c.expectedErr)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestDatabase(t *testing.T) (*sqlstore.SQLStore, *ServiceAccountsStoreImpl) {
|
||||||
|
t.Helper()
|
||||||
|
db := sqlstore.InitTestDB(t)
|
||||||
|
return db, NewServiceAccountsStore(db)
|
||||||
|
}
|
7
pkg/services/serviceaccounts/errors.go
Normal file
7
pkg/services/serviceaccounts/errors.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package serviceaccounts
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrServiceAccountNotFound = errors.New("Service account not found")
|
||||||
|
)
|
23
pkg/services/serviceaccounts/manager/roles.go
Normal file
23
pkg/services/serviceaccounts/manager/roles.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
role = accesscontrol.RoleRegistration{
|
||||||
|
Role: accesscontrol.RoleDTO{
|
||||||
|
Version: 1,
|
||||||
|
Name: "fixed:serviceaccounts:writer",
|
||||||
|
Description: "",
|
||||||
|
Permissions: []accesscontrol.Permission{
|
||||||
|
{
|
||||||
|
Action: serviceaccounts.ActionDelete,
|
||||||
|
Scope: serviceaccounts.ScopeAll,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Grants: []string{"Admin"},
|
||||||
|
}
|
||||||
|
)
|
51
pkg/services/serviceaccounts/manager/service.go
Normal file
51
pkg/services/serviceaccounts/manager/service.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts/api"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts/database"
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ServiceAccountFeatureToggleNotFound = "FeatureToggle service-accounts not found, try adding it to your custom.ini"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceAccountsService struct {
|
||||||
|
store serviceaccounts.Store
|
||||||
|
cfg *setting.Cfg
|
||||||
|
log log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProvideServiceAccountsService(
|
||||||
|
cfg *setting.Cfg,
|
||||||
|
store *sqlstore.SQLStore,
|
||||||
|
ac accesscontrol.AccessControl,
|
||||||
|
routeRegister routing.RouteRegister,
|
||||||
|
) (*ServiceAccountsService, error) {
|
||||||
|
s := &ServiceAccountsService{
|
||||||
|
cfg: cfg,
|
||||||
|
store: database.NewServiceAccountsStore(store),
|
||||||
|
log: log.New("serviceaccounts"),
|
||||||
|
}
|
||||||
|
if err := ac.DeclareFixedRoles(role); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
serviceaccountsAPI := api.NewServiceAccountsAPI(s, ac, routeRegister)
|
||||||
|
serviceaccountsAPI.RegisterAPIEndpoints(cfg)
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error {
|
||||||
|
if !s.cfg.FeatureToggles["service-accounts"] {
|
||||||
|
s.log.Debug(ServiceAccountFeatureToggleNotFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.store.DeleteServiceAccount(ctx, orgID, serviceAccountID)
|
||||||
|
}
|
38
pkg/services/serviceaccounts/manager/service_test.go
Normal file
38
pkg/services/serviceaccounts/manager/service_test.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package manager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProvideServiceAccount_DeleteServiceAccount(t *testing.T) {
|
||||||
|
t.Run("feature toggle present, should call store function", func(t *testing.T) {
|
||||||
|
cfg := setting.NewCfg()
|
||||||
|
storeMock := &tests.ServiceAccountsStoreMock{Calls: tests.Calls{}}
|
||||||
|
cfg.FeatureToggles = map[string]bool{"service-accounts": true}
|
||||||
|
svc := ServiceAccountsService{cfg: cfg, store: storeMock}
|
||||||
|
err := svc.DeleteServiceAccount(context.Background(), 1, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, storeMock.Calls.DeleteServiceAccount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no feature toggle present, should not call store function", func(t *testing.T) {
|
||||||
|
cfg := setting.NewCfg()
|
||||||
|
svcMock := &tests.ServiceAccountsStoreMock{Calls: tests.Calls{}}
|
||||||
|
cfg.FeatureToggles = map[string]bool{"service-accounts": false}
|
||||||
|
svc := ServiceAccountsService{
|
||||||
|
cfg: cfg,
|
||||||
|
store: svcMock,
|
||||||
|
log: log.New("serviceaccounts-manager-test"),
|
||||||
|
}
|
||||||
|
err := svc.DeleteServiceAccount(context.Background(), 1, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, svcMock.Calls.DeleteServiceAccount, 0)
|
||||||
|
})
|
||||||
|
}
|
12
pkg/services/serviceaccounts/models.go
Normal file
12
pkg/services/serviceaccounts/models.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package serviceaccounts
|
||||||
|
|
||||||
|
import "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ScopeAll = "serviceaccounts:*"
|
||||||
|
ScopeID = accesscontrol.Scope("serviceaccounts", "id", accesscontrol.Parameter(":serviceaccountId"))
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionDelete = "serviceaccounts:delete"
|
||||||
|
)
|
10
pkg/services/serviceaccounts/serviceaccounts.go
Normal file
10
pkg/services/serviceaccounts/serviceaccounts.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package serviceaccounts
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
|
||||||
|
}
|
||||||
|
type Store interface {
|
||||||
|
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
|
||||||
|
}
|
62
pkg/services/serviceaccounts/tests/common.go
Normal file
62
pkg/services/serviceaccounts/tests/common.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||||
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestUser struct {
|
||||||
|
Login string
|
||||||
|
IsServiceAccount bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser TestUser) *models.User {
|
||||||
|
u1, err := sqlStore.CreateUser(context.Background(), models.CreateUserCommand{
|
||||||
|
Login: testUser.Login,
|
||||||
|
IsServiceAccount: testUser.IsServiceAccount,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return u1
|
||||||
|
}
|
||||||
|
|
||||||
|
// create mock for serviceaccountservice
|
||||||
|
type ServiceAccountMock struct{}
|
||||||
|
|
||||||
|
func (s *ServiceAccountMock) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupMockAccesscontrol(t *testing.T, userpermissionsfunc func(c context.Context, siu *models.SignedInUser) ([]*accesscontrol.Permission, error), disableAccessControl bool) *accesscontrolmock.Mock {
|
||||||
|
t.Helper()
|
||||||
|
acmock := accesscontrolmock.New()
|
||||||
|
if disableAccessControl {
|
||||||
|
acmock = acmock.WithDisabled()
|
||||||
|
}
|
||||||
|
acmock.GetUserPermissionsFunc = userpermissionsfunc
|
||||||
|
return acmock
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is a way to see
|
||||||
|
// that the Mock implements the store interface
|
||||||
|
var _ serviceaccounts.Store = new(ServiceAccountsStoreMock)
|
||||||
|
|
||||||
|
type Calls struct {
|
||||||
|
DeleteServiceAccount []interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceAccountsStoreMock struct {
|
||||||
|
Calls Calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceAccountsStoreMock) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error {
|
||||||
|
// now we can test that the mock has these calls when we call the function
|
||||||
|
s.Calls.DeleteServiceAccount = append(s.Calls.DeleteServiceAccount, []interface{}{ctx, orgID, serviceAccountID})
|
||||||
|
return nil
|
||||||
|
}
|
@ -126,6 +126,10 @@ func addUserMigrations(mg *Migrator) {
|
|||||||
mg.AddMigration("Add index user.login/user.email", NewAddIndexMigration(userV2, &Index{
|
mg.AddMigration("Add index user.login/user.email", NewAddIndexMigration(userV2, &Index{
|
||||||
Cols: []string{"login", "email"},
|
Cols: []string{"login", "email"},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mg.AddMigration("Add is_service_account column to user", NewAddColumnMigration(userV2, &Column{
|
||||||
|
Name: "is_service_account", Type: DB_Bool, Nullable: false, Default: "0",
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddMissingUserSaltAndRandsMigration struct {
|
type AddMissingUserSaltAndRandsMigration struct {
|
||||||
|
@ -225,17 +225,18 @@ func (ss *SQLStore) CreateUser(ctx context.Context, cmd models.CreateUserCommand
|
|||||||
|
|
||||||
// create user
|
// create user
|
||||||
user = &models.User{
|
user = &models.User{
|
||||||
Email: cmd.Email,
|
Email: cmd.Email,
|
||||||
Name: cmd.Name,
|
Name: cmd.Name,
|
||||||
Login: cmd.Login,
|
Login: cmd.Login,
|
||||||
Company: cmd.Company,
|
Company: cmd.Company,
|
||||||
IsAdmin: cmd.IsAdmin,
|
IsAdmin: cmd.IsAdmin,
|
||||||
IsDisabled: cmd.IsDisabled,
|
IsDisabled: cmd.IsDisabled,
|
||||||
OrgId: orgId,
|
OrgId: orgId,
|
||||||
EmailVerified: cmd.EmailVerified,
|
EmailVerified: cmd.EmailVerified,
|
||||||
Created: time.Now(),
|
Created: time.Now(),
|
||||||
Updated: time.Now(),
|
Updated: time.Now(),
|
||||||
LastSeenAt: time.Now().AddDate(-10, 0, 0),
|
LastSeenAt: time.Now().AddDate(-10, 0, 0),
|
||||||
|
IsServiceAccount: cmd.IsServiceAccount,
|
||||||
}
|
}
|
||||||
|
|
||||||
salt, err := util.GetRandomString(10)
|
salt, err := util.GetRandomString(10)
|
||||||
@ -754,7 +755,16 @@ func deleteUserInTransaction(sess *DBSession, cmd *models.DeleteUserCommand) err
|
|||||||
if !has {
|
if !has {
|
||||||
return models.ErrUserNotFound
|
return models.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
for _, sql := range userDeletions() {
|
||||||
|
_, err := sess.Exec(sql, cmd.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userDeletions() []string {
|
||||||
deletes := []string{
|
deletes := []string{
|
||||||
"DELETE FROM star WHERE user_id = ?",
|
"DELETE FROM star WHERE user_id = ?",
|
||||||
"DELETE FROM " + dialect.Quote("user") + " WHERE id = ?",
|
"DELETE FROM " + dialect.Quote("user") + " WHERE id = ?",
|
||||||
@ -766,15 +776,15 @@ func deleteUserInTransaction(sess *DBSession, cmd *models.DeleteUserCommand) err
|
|||||||
"DELETE FROM user_auth_token WHERE user_id = ?",
|
"DELETE FROM user_auth_token WHERE user_id = ?",
|
||||||
"DELETE FROM quota WHERE user_id = ?",
|
"DELETE FROM quota WHERE user_id = ?",
|
||||||
}
|
}
|
||||||
|
return deletes
|
||||||
|
}
|
||||||
|
|
||||||
for _, sql := range deletes {
|
func ServiceAccountDeletions() []string {
|
||||||
_, err := sess.Exec(sql, cmd.UserId)
|
deletes := []string{
|
||||||
if err != nil {
|
"DELETE FROM api_key WHERE service_account_id = ?",
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
deletes = append(deletes, userDeletions()...)
|
||||||
return nil
|
return deletes
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ss *SQLStore) UpdateUserPermissions(userID int64, isAdmin bool) error {
|
func (ss *SQLStore) UpdateUserPermissions(userID int64, isAdmin bool) error {
|
||||||
|
Loading…
Reference in New Issue
Block a user