mirror of https://github.com/grafana/grafana
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>pull/41617/head
parent
cd01384d3a
commit
4fd3dd41bc
@ -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") |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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) |
||||
} |
@ -0,0 +1,7 @@ |
||||
package serviceaccounts |
||||
|
||||
import "errors" |
||||
|
||||
var ( |
||||
ErrServiceAccountNotFound = errors.New("Service account not found") |
||||
) |
@ -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"}, |
||||
} |
||||
) |
@ -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) |
||||
} |
@ -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) |
||||
}) |
||||
} |
@ -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" |
||||
) |
@ -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 |
||||
} |
@ -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 |
||||
} |
Loading…
Reference in new issue