ServiceAccounts: API keys migration (#50002)

* ServiceAccounts: able to get upgrade status

* Banner with API keys migration info

* Show API keys migration info on Service accounts page

* Migrate individual API keys

* Use transaction for key migration

* Migrate all api keys to service accounts

* Hide api keys after migration

* Migrate API keys separately for each org

* Revert API key

* Revert key API method

* Rename migration actions and reducers

* Fix linter errors

* Tests for migrating single API key

* Tests for migrating all api keys

* More tests

* Fix reverting tokens

* API: rename convert to migrate

* Add api route descriptions to methods

* rearrange methods in api.go

* Refactor: rename and move some methods

* Prevent assigning tokens to non-existing service accounts

* Refactor: ID TO Id

* Refactor: fix error message

* Delete service account if migration failed

* Fix linter errors
This commit is contained in:
Alexander Zobnin 2022-06-15 15:59:40 +03:00 committed by GitHub
parent b47ec36d0d
commit f82264c2b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 961 additions and 318 deletions

View File

@ -24,6 +24,7 @@ import (
httpstatic "github.com/grafana/grafana/pkg/api/static"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/framework/coremodel"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
@ -162,6 +163,7 @@ type HTTPServer struct {
dashboardVersionService dashver.Service
starService star.Service
CoremodelRegistry *coremodel.Registry
kvStore kvstore.KVStore
}
type ServerOptions struct {
@ -195,7 +197,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
avatarCacheServer *avatar.AvatarCacheServer, preferenceService pref.Service, entityEventsService store.EntityEventsService,
teamsPermissionsService accesscontrol.TeamPermissionsService, folderPermissionsService accesscontrol.FolderPermissionsService,
dashboardPermissionsService accesscontrol.DashboardPermissionsService, dashboardVersionService dashver.Service,
starService star.Service, coremodelRegistry *coremodel.Registry, csrfService csrf.Service,
starService star.Service, coremodelRegistry *coremodel.Registry, csrfService csrf.Service, kvStore kvstore.KVStore,
) (*HTTPServer, error) {
web.Env = cfg.Env
m := web.New()
@ -277,6 +279,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
dashboardVersionService: dashboardVersionService,
starService: starService,
CoremodelRegistry: coremodelRegistry,
kvStore: kvStore,
}
if hs.Listener != nil {
hs.log.Debug("Using provided listener")

View File

@ -307,7 +307,8 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
})
}
if hasAccess(ac.ReqOrgAdmin, apiKeyAccessEvaluator) {
apiKeysHidden, _, _ := hs.kvStore.Get(c.Req.Context(), c.OrgId, "serviceaccounts", "hideApiKeys")
if hasAccess(ac.ReqOrgAdmin, apiKeyAccessEvaluator) && apiKeysHidden != "1" {
configNodes = append(configNodes, &dtos.NavLink{
Text: "API keys",
Id: "apikeys",

View File

@ -62,21 +62,25 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints(
serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.RetrieveServiceAccount))
serviceAccountsRoute.Patch("/:serviceAccountId", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.updateServiceAccount))
accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.UpdateServiceAccount))
serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount))
// TODO:
// for 9.0 please reenable this with issue https://github.com/grafana/grafana-enterprise/issues/2969
// serviceAccountsRoute.Post("/upgradeall", auth(middleware.ReqOrgAdmin,
// accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.UpgradeServiceAccounts))
// serviceAccountsRoute.Post("/convert/:keyId", auth(middleware.ReqOrgAdmin,
// accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.ConvertToServiceAccount))
serviceAccountsRoute.Get("/:serviceAccountId/tokens", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.ListTokens))
serviceAccountsRoute.Post("/:serviceAccountId/tokens", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.CreateToken))
serviceAccountsRoute.Delete("/:serviceAccountId/tokens/:tokenId", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionWrite, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteToken))
serviceAccountsRoute.Get("/migrationstatus", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionRead)), routing.Wrap(api.GetAPIKeysMigrationStatus))
serviceAccountsRoute.Post("/hideApiKeys", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.HideApiKeysTab))
serviceAccountsRoute.Post("/migrate", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.MigrateApiKeysToServiceAccounts))
serviceAccountsRoute.Post("/migrate/:keyId", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.ConvertToServiceAccount))
serviceAccountsRoute.Post("/revert/:keyId", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionDelete)), routing.Wrap(api.RevertApiKey))
})
}
@ -101,55 +105,7 @@ func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) respon
return response.JSON(http.StatusCreated, serviceAccount)
}
func (api *ServiceAccountsAPI) DeleteServiceAccount(ctx *models.ReqContext) response.Response {
scopeID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err)
}
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")
}
func (api *ServiceAccountsAPI) UpgradeServiceAccounts(ctx *models.ReqContext) response.Response {
if err := api.store.UpgradeServiceAccounts(ctx.Req.Context()); err == nil {
return response.Success("Service accounts upgraded")
} else {
return response.Error(http.StatusInternalServerError, "Internal server error", err)
}
}
func (api *ServiceAccountsAPI) ConvertToServiceAccount(ctx *models.ReqContext) response.Response {
keyId, err := strconv.ParseInt(web.Params(ctx.Req)[":keyId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "Key ID is invalid", err)
}
if err := api.store.ConvertToServiceAccounts(ctx.Req.Context(), []int64{keyId}); err == nil {
return response.Success("Service accounts converted")
} else {
return response.Error(500, "Internal server error", err)
}
}
func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, saIDs map[string]bool) map[string]accesscontrol.Metadata {
if api.accesscontrol.IsDisabled() || !c.QueryBool("accesscontrol") {
return map[string]accesscontrol.Metadata{}
}
if c.SignedInUser.Permissions == nil {
return map[string]accesscontrol.Metadata{}
}
permissions, ok := c.SignedInUser.Permissions[c.OrgId]
if !ok {
return map[string]accesscontrol.Metadata{}
}
return accesscontrol.GetResourcesMetadata(c.Req.Context(), permissions, "serviceaccounts:id:", saIDs)
}
// GET /api/serviceaccounts/:serviceAccountId
func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) response.Response {
scopeID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64)
if err != nil {
@ -180,7 +136,8 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re
return response.JSON(http.StatusOK, serviceAccount)
}
func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) response.Response {
// PATCH /api/serviceaccounts/:serviceAccountId
func (api *ServiceAccountsAPI) UpdateServiceAccount(c *models.ReqContext) response.Response {
scopeID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err)
@ -221,6 +178,19 @@ func (api *ServiceAccountsAPI) updateServiceAccount(c *models.ReqContext) respon
})
}
// DELETE /api/serviceaccounts/:serviceAccountId
func (api *ServiceAccountsAPI) DeleteServiceAccount(ctx *models.ReqContext) response.Response {
scopeID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "Service account ID is invalid", err)
}
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")
}
// SearchOrgServiceAccountsWithPaging is an HTTP handler to search for org users with paging.
// GET /api/serviceaccounts/search
func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *models.ReqContext) response.Response {
@ -266,3 +236,71 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *models.ReqC
return response.JSON(http.StatusOK, serviceAccountSearch)
}
// GET /api/serviceaccounts/migrationstatus
func (api *ServiceAccountsAPI) GetAPIKeysMigrationStatus(ctx *models.ReqContext) response.Response {
upgradeStatus, err := api.store.GetAPIKeysMigrationStatus(ctx.Req.Context(), ctx.OrgId)
if err != nil {
return response.Error(http.StatusInternalServerError, "Internal server error", err)
}
return response.JSON(http.StatusOK, upgradeStatus)
}
// POST /api/serviceaccounts/hideapikeys
func (api *ServiceAccountsAPI) HideApiKeysTab(ctx *models.ReqContext) response.Response {
if err := api.store.HideApiKeysTab(ctx.Req.Context(), ctx.OrgId); err != nil {
return response.Error(http.StatusInternalServerError, "Internal server error", err)
}
return response.Success("API keys hidden")
}
// POST /api/serviceaccounts/migrate
func (api *ServiceAccountsAPI) MigrateApiKeysToServiceAccounts(ctx *models.ReqContext) response.Response {
if err := api.store.MigrateApiKeysToServiceAccounts(ctx.Req.Context(), ctx.OrgId); err == nil {
return response.Success("API keys migrated to service accounts")
} else {
return response.Error(http.StatusInternalServerError, "Internal server error", err)
}
}
// POST /api/serviceaccounts/migrate/:keyId
func (api *ServiceAccountsAPI) ConvertToServiceAccount(ctx *models.ReqContext) response.Response {
keyId, err := strconv.ParseInt(web.Params(ctx.Req)[":keyId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "Key ID is invalid", err)
}
if err := api.store.MigrateApiKey(ctx.Req.Context(), ctx.OrgId, keyId); err == nil {
return response.Success("Service accounts converted")
} else {
return response.Error(http.StatusInternalServerError, "Error converting API key", err)
}
}
// POST /api/serviceaccounts/revert/:keyId
func (api *ServiceAccountsAPI) RevertApiKey(ctx *models.ReqContext) response.Response {
keyId, err := strconv.ParseInt(web.Params(ctx.Req)[":keyId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "Key ID is invalid", err)
}
if err := api.store.RevertApiKey(ctx.Req.Context(), keyId); err != nil {
return response.Error(http.StatusInternalServerError, "Error reverting API key", err)
}
return response.Success("API key reverted")
}
func (api *ServiceAccountsAPI) getAccessControlMetadata(c *models.ReqContext, saIDs map[string]bool) map[string]accesscontrol.Metadata {
if api.accesscontrol.IsDisabled() || !c.QueryBool("accesscontrol") {
return map[string]accesscontrol.Metadata{}
}
if c.SignedInUser.Permissions == nil {
return map[string]accesscontrol.Metadata{}
}
permissions, ok := c.SignedInUser.Permissions[c.OrgId]
if !ok {
return map[string]accesscontrol.Metadata{}
}
return accesscontrol.GetResourcesMetadata(c.Req.Context(), permissions, "serviceaccounts:id:", saIDs)
}

View File

@ -11,6 +11,7 @@ import (
"testing"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@ -34,6 +35,8 @@ var (
func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
autoAssignOrg := store.Cfg.AutoAssignOrg
@ -121,7 +124,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
serviceAccountRequestScenario(t, http.MethodPost, serviceAccountPath, testUser, func(httpmethod string, endpoint string, user *tests.TestUser) {
server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store))
server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore)
marshalled, err := json.Marshal(tc.body)
require.NoError(t, err)
@ -152,6 +155,8 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) {
// with permissions and without permissions
func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
var requestResponse = func(server *web.Mux, httpMethod, requestpath string) *httptest.ResponseRecorder {
@ -180,7 +185,7 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) {
}
serviceAccountRequestScenario(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, store, database.NewServiceAccountsStore(store))
server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, saStore)
actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, fmt.Sprint(createduser.Id))).Code
require.Equal(t, testcase.expectedCode, actual)
})
@ -204,7 +209,7 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) {
}
serviceAccountRequestScenario(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, store, database.NewServiceAccountsStore(store))
server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store, saStore)
actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, createduser.Id)).Code
require.Equal(t, testcase.expectedCode, actual)
})
@ -246,6 +251,8 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock,
func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
type testRetrieveSATestCase struct {
desc string
@ -310,7 +317,7 @@ func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) {
createdUser := tests.SetupUserServiceAccount(t, store, *tc.user)
scopeID = int(createdUser.Id)
}
server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store))
server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore)
actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, scopeID))
@ -335,6 +342,8 @@ func newString(s string) *string {
func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
type testUpdateSATestCase struct {
desc string
@ -428,7 +437,7 @@ func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
server, saAPI := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store))
server, saAPI := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore)
scopeID := tc.Id
if tc.user != nil {
createdUser := tests.SetupUserServiceAccount(t, store, *tc.user)

View File

@ -38,6 +38,7 @@ func hasExpired(expiration *int64) bool {
const sevenDaysAhead = 7 * 24 * time.Hour
// GET /api/serviceaccounts/:serviceAccountId/tokens
func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Response {
saID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64)
if err != nil {
@ -76,6 +77,7 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo
}
// CreateNewToken adds an API key to a service account
// POST /api/serviceaccounts/:serviceAccountId/tokens
func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Response {
saID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64)
if err != nil {
@ -136,6 +138,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon
}
// DeleteToken deletes service account tokens
// DELETE /api/serviceaccounts/:serviceAccountId/tokens/:tokenId
func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Response {
saID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64)
if err != nil {

View File

@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/components/apikeygen"
apikeygenprefix "github.com/grafana/grafana/pkg/components/apikeygenprefixed"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
@ -50,6 +51,8 @@ func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string,
func TestServiceAccountsAPI_CreateToken(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true})
@ -130,7 +133,7 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) {
bodyString = string(b)
}
server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, database.NewServiceAccountsStore(store))
server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore)
actual := requestResponse(server, http.MethodPost, endpoint, strings.NewReader(bodyString))
actualCode := actual.Code
@ -164,8 +167,9 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) {
func TestServiceAccountsAPI_DeleteToken(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
svcMock := &tests.ServiceAccountMock{}
saStore := database.NewServiceAccountsStore(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true})
type testCreateSAToken struct {

View File

@ -8,31 +8,35 @@ import (
"strings"
"time"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/sqlstore"
"xorm.io/xorm"
)
type ServiceAccountsStoreImpl struct {
sqlStore *sqlstore.SQLStore
kvStore kvstore.KVStore
log log.Logger
}
func NewServiceAccountsStore(store *sqlstore.SQLStore) *ServiceAccountsStoreImpl {
func NewServiceAccountsStore(store *sqlstore.SQLStore, kvStore kvstore.KVStore) *ServiceAccountsStoreImpl {
return &ServiceAccountsStoreImpl{
sqlStore: store,
kvStore: kvStore,
log: log.New("serviceaccounts.store"),
}
}
func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, orgID int64, name string) (saDTO *serviceaccounts.ServiceAccountDTO, err error) {
// CreateServiceAccount creates service account
func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, orgId int64, name string) (saDTO *serviceaccounts.ServiceAccountDTO, err error) {
generatedLogin := "sa-" + strings.ToLower(name)
generatedLogin = strings.ReplaceAll(generatedLogin, " ", "-")
cmd := models.CreateUserCommand{
Login: generatedLogin,
OrgId: orgID,
OrgId: orgId,
Name: name,
IsServiceAccount: true,
}
@ -53,6 +57,64 @@ func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, org
Tokens: 0,
}, nil
}
// UpdateServiceAccount updates service account
func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context,
orgId, serviceAccountId int64,
saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error) {
updatedUser := &serviceaccounts.ServiceAccountProfileDTO{}
err := s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
var err error
updatedUser, err = s.RetrieveServiceAccount(ctx, orgId, serviceAccountId)
if err != nil {
return err
}
if saForm.Name == nil && saForm.Role == nil && saForm.IsDisabled == nil {
return nil
}
updateTime := time.Now()
if saForm.Role != nil {
var orgUser models.OrgUser
orgUser.Role = *saForm.Role
orgUser.Updated = updateTime
if _, err := sess.Where("org_id = ? AND user_id = ?", orgId, serviceAccountId).Update(&orgUser); err != nil {
return err
}
updatedUser.Role = string(*saForm.Role)
}
if saForm.Name != nil || saForm.IsDisabled != nil {
user := models.User{
Updated: updateTime,
}
if saForm.IsDisabled != nil {
user.IsDisabled = *saForm.IsDisabled
updatedUser.IsDisabled = *saForm.IsDisabled
sess.UseBool("is_disabled")
}
if saForm.Name != nil {
user.Name = *saForm.Name
updatedUser.Name = *saForm.Name
}
if _, err := sess.ID(serviceAccountId).Update(&user); err != nil {
return err
}
}
return nil
})
return updatedUser, err
}
func ServiceAccountDeletions() []string {
deletes := []string{
"DELETE FROM api_key WHERE service_account_id = ?",
@ -61,110 +123,34 @@ func ServiceAccountDeletions() []string {
return deletes
}
func (s *ServiceAccountsStoreImpl) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error {
// DeleteServiceAccount deletes service account and all associated tokens
func (s *ServiceAccountsStoreImpl) DeleteServiceAccount(ctx context.Context, orgId, serviceAccountId int64) error {
return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
user := models.User{}
has, err := sess.Where(`org_id = ? and id = ? and is_service_account = ?`,
orgID, serviceAccountID, s.sqlStore.Dialect.BooleanStr(true)).Get(&user)
return s.deleteServiceAccount(sess, orgId, serviceAccountId)
})
}
func (s *ServiceAccountsStoreImpl) deleteServiceAccount(sess *sqlstore.DBSession, orgId, serviceAccountId int64) error {
user := models.User{}
has, err := sess.Where(`org_id = ? and id = ? and is_service_account = ?`,
orgId, serviceAccountId, s.sqlStore.Dialect.BooleanStr(true)).Get(&user)
if err != nil {
return err
}
if !has {
return serviceaccounts.ErrServiceAccountNotFound
}
for _, sql := range ServiceAccountDeletions() {
_, err := sess.Exec(sql, user.Id)
if err != nil {
return err
}
if !has {
return serviceaccounts.ErrServiceAccountNotFound
}
for _, sql := range ServiceAccountDeletions() {
_, err := sess.Exec(sql, user.Id)
if err != nil {
return err
}
}
return nil
})
}
func (s *ServiceAccountsStoreImpl) UpgradeServiceAccounts(ctx context.Context) error {
basicKeys := s.sqlStore.GetAllOrgsAPIKeys(ctx)
if len(basicKeys) > 0 {
s.log.Info("Launching background thread to upgrade API keys to service accounts", "numberKeys", len(basicKeys))
go func() {
for _, key := range basicKeys {
err := s.CreateServiceAccountFromApikey(ctx, key)
if err != nil {
s.log.Error("migating to service accounts failed with error", err)
}
}
}()
}
return nil
}
func (s *ServiceAccountsStoreImpl) ConvertToServiceAccounts(ctx context.Context, keys []int64) error {
basicKeys := s.sqlStore.GetAllOrgsAPIKeys(ctx)
if len(basicKeys) == 0 {
return nil
}
if len(basicKeys) != len(keys) {
return fmt.Errorf("one of the keys already has a serviceaccount")
}
for _, key := range basicKeys {
if !contains(keys, key.Id) {
s.log.Error("convert service accounts stopped for keyId %d as it is not part of the query to convert or already has a service account", key.Id)
continue
}
err := s.CreateServiceAccountFromApikey(ctx, key)
if err != nil {
s.log.Error("converting to service accounts failed with error", err)
}
}
return nil
}
func (s *ServiceAccountsStoreImpl) CreateServiceAccountFromApikey(ctx context.Context, key *models.ApiKey) error {
prefix := "sa-autogen-"
cmd := models.CreateUserCommand{
Login: fmt.Sprintf("%v-%v-%v", prefix, key.OrgId, key.Name),
Name: prefix + key.Name,
OrgId: key.OrgId,
DefaultOrgRole: string(key.Role),
IsServiceAccount: true,
}
newSA, errCreateSA := s.sqlStore.CreateUser(ctx, cmd)
if errCreateSA != nil {
return fmt.Errorf("failed to create service account: %w", errCreateSA)
}
if errUpdateKey := s.assignApiKeyToServiceAccount(ctx, key.Id, newSA.Id); errUpdateKey != nil {
return fmt.Errorf(
"failed to attach new service account to API key for keyId: %d and newServiceAccountId: %d with error: %w",
key.Id, newSA.Id, errUpdateKey,
)
}
s.log.Debug("Updated basic api key", "keyId", key.Id, "newServiceAccountId", newSA.Id)
return nil
}
//nolint:gosimple
func (s *ServiceAccountsStoreImpl) ListTokens(ctx context.Context, orgID int64, serviceAccountID int64) ([]*models.ApiKey, error) {
result := make([]*models.ApiKey, 0)
err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
var sess *xorm.Session
quotedUser := s.sqlStore.Dialect.Quote("user")
sess = dbSession.
Join("inner", quotedUser, quotedUser+".id = api_key.service_account_id").
Where(quotedUser+".org_id=? AND "+quotedUser+".id=?", orgID, serviceAccountID).
Asc("api_key.name")
return sess.Find(&result)
})
return result, err
}
// RetrieveServiceAccountByID returns a service account by its ID
func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*serviceaccounts.ServiceAccountProfileDTO, error) {
// RetrieveServiceAccount returns a service account by its ID
func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, orgId, serviceAccountId int64) (*serviceaccounts.ServiceAccountProfileDTO, error) {
serviceAccount := &serviceaccounts.ServiceAccountProfileDTO{}
err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
@ -176,10 +162,10 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o
whereParams := make([]interface{}, 0)
whereConditions = append(whereConditions, "org_user.org_id = ?")
whereParams = append(whereParams, orgID)
whereParams = append(whereParams, orgId)
whereConditions = append(whereConditions, "org_user.user_id = ?")
whereParams = append(whereParams, serviceAccountID)
whereParams = append(whereParams, serviceAccountId)
whereConditions = append(whereConditions,
fmt.Sprintf("%s.is_service_account = %s",
@ -212,7 +198,7 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o
return serviceAccount, err
}
func (s *ServiceAccountsStoreImpl) RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error) {
func (s *ServiceAccountsStoreImpl) RetrieveServiceAccountIdByName(ctx context.Context, orgId int64, name string) (int64, error) {
serviceAccount := &struct {
Id int64
}{}
@ -229,7 +215,7 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccountIdByName(ctx context.Co
s.sqlStore.Dialect.Quote("user"),
s.sqlStore.Dialect.BooleanStr(true)),
}
whereParams := []interface{}{name, orgID}
whereParams := []interface{}{name, orgId}
sess.Where(strings.Join(whereConditions, " AND "), whereParams...)
@ -253,64 +239,8 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccountIdByName(ctx context.Co
return serviceAccount.Id, nil
}
func (s *ServiceAccountsStoreImpl) UpdateServiceAccount(ctx context.Context,
orgID, serviceAccountID int64,
saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error) {
updatedUser := &serviceaccounts.ServiceAccountProfileDTO{}
err := s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
var err error
updatedUser, err = s.RetrieveServiceAccount(ctx, orgID, serviceAccountID)
if err != nil {
return err
}
if saForm.Name == nil && saForm.Role == nil && saForm.IsDisabled == nil {
return nil
}
updateTime := time.Now()
if saForm.Role != nil {
var orgUser models.OrgUser
orgUser.Role = *saForm.Role
orgUser.Updated = updateTime
if _, err := sess.Where("org_id = ? AND user_id = ?", orgID, serviceAccountID).Update(&orgUser); err != nil {
return err
}
updatedUser.Role = string(*saForm.Role)
}
if saForm.Name != nil || saForm.IsDisabled != nil {
user := models.User{
Updated: updateTime,
}
if saForm.IsDisabled != nil {
user.IsDisabled = *saForm.IsDisabled
updatedUser.IsDisabled = *saForm.IsDisabled
sess.UseBool("is_disabled")
}
if saForm.Name != nil {
user.Name = *saForm.Name
updatedUser.Name = *saForm.Name
}
if _, err := sess.ID(serviceAccountID).Update(&user); err != nil {
return err
}
}
return nil
})
return updatedUser, err
}
func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(
ctx context.Context, orgID int64, query string, filter serviceaccounts.ServiceAccountFilter, page int, limit int,
ctx context.Context, orgId int64, query string, filter serviceaccounts.ServiceAccountFilter, page int, limit int,
signedInUser *models.SignedInUser,
) (*serviceaccounts.SearchServiceAccountsResult, error) {
searchResult := &serviceaccounts.SearchServiceAccountsResult{
@ -328,7 +258,7 @@ func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(
whereParams := make([]interface{}, 0)
whereConditions = append(whereConditions, "org_user.org_id = ?")
whereParams = append(whereParams, orgID)
whereParams = append(whereParams, orgId)
whereConditions = append(whereConditions,
fmt.Sprintf("%s.is_service_account = %s",
@ -415,11 +345,134 @@ func (s *ServiceAccountsStoreImpl) SearchOrgServiceAccounts(
return searchResult, nil
}
func contains(s []int64, e int64) bool {
for _, a := range s {
if a == e {
return true
func (s *ServiceAccountsStoreImpl) GetAPIKeysMigrationStatus(ctx context.Context, orgId int64) (status *serviceaccounts.APIKeysMigrationStatus, err error) {
migrationStatus, exists, err := s.kvStore.Get(ctx, orgId, "serviceaccounts", "migrationStatus")
if err != nil {
return nil, err
}
if exists && migrationStatus == "1" {
return &serviceaccounts.APIKeysMigrationStatus{
Migrated: true,
}, nil
} else {
return &serviceaccounts.APIKeysMigrationStatus{
Migrated: false,
}, nil
}
}
func (s *ServiceAccountsStoreImpl) HideApiKeysTab(ctx context.Context, orgId int64) error {
if err := s.kvStore.Set(ctx, orgId, "serviceaccounts", "hideApiKeys", "1"); err != nil {
s.log.Error("Failed to hide API keys tab", err)
}
return nil
}
func (s *ServiceAccountsStoreImpl) MigrateApiKeysToServiceAccounts(ctx context.Context, orgId int64) error {
basicKeys := s.sqlStore.GetAllAPIKeys(ctx, orgId)
if len(basicKeys) > 0 {
for _, key := range basicKeys {
err := s.CreateServiceAccountFromApikey(ctx, key)
if err != nil {
s.log.Error("migating to service accounts failed with error", err)
return err
}
s.log.Debug("API key converted to service account token", "keyId", key.Id)
}
}
return false
if err := s.kvStore.Set(ctx, orgId, "serviceaccounts", "migrationStatus", "1"); err != nil {
s.log.Error("Failed to write API keys migration status", err)
}
return nil
}
func (s *ServiceAccountsStoreImpl) MigrateApiKey(ctx context.Context, orgId int64, keyId int64) error {
basicKeys := s.sqlStore.GetAllAPIKeys(ctx, orgId)
if len(basicKeys) == 0 {
return fmt.Errorf("no API keys to convert found")
}
for _, key := range basicKeys {
if keyId == key.Id {
err := s.CreateServiceAccountFromApikey(ctx, key)
if err != nil {
s.log.Error("converting to service account failed with error", "keyId", keyId, "error", err)
return err
}
}
}
return nil
}
func (s *ServiceAccountsStoreImpl) CreateServiceAccountFromApikey(ctx context.Context, key *models.ApiKey) error {
prefix := "sa-autogen"
cmd := models.CreateUserCommand{
Login: fmt.Sprintf("%v-%v-%v", prefix, key.OrgId, key.Name),
Name: fmt.Sprintf("%v-%v", prefix, key.Name),
OrgId: key.OrgId,
DefaultOrgRole: string(key.Role),
IsServiceAccount: true,
}
return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
newSA, errCreateSA := s.sqlStore.CreateUser(ctx, cmd)
if errCreateSA != nil {
return fmt.Errorf("failed to create service account: %w", errCreateSA)
}
if err := s.assignApiKeyToServiceAccount(sess, key.Id, newSA.Id); err != nil {
if err := s.sqlStore.DeleteUser(ctx, &models.DeleteUserCommand{UserId: newSA.Id}); err != nil {
s.log.Error("Error deleting service account", "error", err)
}
return fmt.Errorf("failed to migrate API key to service account token: %w", err)
}
return nil
})
}
// RevertApiKey converts service account token to old API key
func (s *ServiceAccountsStoreImpl) RevertApiKey(ctx context.Context, keyId int64) error {
query := models.GetApiKeyByIdQuery{ApiKeyId: keyId}
if err := s.sqlStore.GetApiKeyById(ctx, &query); err != nil {
return err
}
key := query.Result
if key.ServiceAccountId == nil {
// TODO: better error message
return fmt.Errorf("API key is not linked to service account")
}
tokens, err := s.ListTokens(ctx, key.OrgId, *key.ServiceAccountId)
if err != nil {
return fmt.Errorf("cannot revert API key: %w", err)
}
if len(tokens) > 1 {
return fmt.Errorf("cannot revert API key: service account contains more than one token")
}
err = s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
user := models.User{}
has, err := sess.Where(`org_id = ? and id = ? and is_service_account = ?`,
key.OrgId, *key.ServiceAccountId, s.sqlStore.Dialect.BooleanStr(true)).Get(&user)
if err != nil {
return err
}
if !has {
return serviceaccounts.ErrServiceAccountNotFound
}
// Detach API key from service account
if err := s.detachApiKeyFromServiceAccount(sess, key.Id); err != nil {
return err
}
// Delete service account
if err := s.deleteServiceAccount(sess, key.OrgId, *key.ServiceAccountId); err != nil {
return err
}
return nil
})
if err != nil {
return fmt.Errorf("cannot revert API key: %w", err)
}
return nil
}

View File

@ -4,6 +4,8 @@ import (
"context"
"testing"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
"github.com/grafana/grafana/pkg/services/sqlstore"
@ -70,7 +72,8 @@ func TestStore_DeleteServiceAccount(t *testing.T) {
func setupTestDatabase(t *testing.T) (*sqlstore.SQLStore, *ServiceAccountsStoreImpl) {
t.Helper()
db := sqlstore.InitTestDB(t)
return db, NewServiceAccountsStore(db)
kvStore := kvstore.ProvideService(db)
return db, NewServiceAccountsStore(db, kvStore)
}
func TestStore_RetrieveServiceAccount(t *testing.T) {
@ -106,3 +109,174 @@ func TestStore_RetrieveServiceAccount(t *testing.T) {
})
}
}
func TestStore_MigrateApiKeys(t *testing.T) {
cases := []struct {
desc string
key tests.TestApiKey
expectedErr error
}{
{
desc: "api key should be migrated to service account token",
key: tests.TestApiKey{Name: "Test1", Role: models.ROLE_EDITOR, OrgId: 1},
expectedErr: nil,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
db, store := setupTestDatabase(t)
store.sqlStore.Cfg.AutoAssignOrg = true
store.sqlStore.Cfg.AutoAssignOrgId = 1
store.sqlStore.Cfg.AutoAssignOrgRole = "Viewer"
err := store.sqlStore.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "main"})
require.NoError(t, err)
key := tests.SetupApiKey(t, db, c.key)
err = store.MigrateApiKey(context.Background(), key.OrgId, key.Id)
if c.expectedErr != nil {
require.ErrorIs(t, err, c.expectedErr)
} else {
require.NoError(t, err)
serviceAccounts, err := store.SearchOrgServiceAccounts(context.Background(), key.OrgId, "", "all", 1, 50, &models.SignedInUser{UserId: 1, OrgId: 1, Permissions: map[int64]map[string][]string{
key.OrgId: {
"serviceaccounts:read": {"serviceaccounts:id:*"},
},
}})
require.NoError(t, err)
require.Equal(t, int64(1), serviceAccounts.TotalCount)
saMigrated := serviceAccounts.ServiceAccounts[0]
require.Equal(t, string(key.Role), saMigrated.Role)
tokens, err := store.ListTokens(context.Background(), key.OrgId, saMigrated.Id)
require.NoError(t, err)
require.Len(t, tokens, 1)
}
})
}
}
func TestStore_MigrateAllApiKeys(t *testing.T) {
cases := []struct {
desc string
keys []tests.TestApiKey
orgId int64
expectedServiceAccouts int64
expectedErr error
}{
{
desc: "api keys should be migrated to service account tokens within provided org",
keys: []tests.TestApiKey{
{Name: "test1", Role: models.ROLE_EDITOR, Key: "secret1", OrgId: 1},
{Name: "test2", Role: models.ROLE_EDITOR, Key: "secret2", OrgId: 1},
{Name: "test3", Role: models.ROLE_EDITOR, Key: "secret3", OrgId: 2},
},
orgId: 1,
expectedServiceAccouts: 2,
expectedErr: nil,
},
{
desc: "api keys from another orgs shouldn't be migrated",
keys: []tests.TestApiKey{
{Name: "test1", Role: models.ROLE_EDITOR, Key: "secret1", OrgId: 2},
{Name: "test2", Role: models.ROLE_EDITOR, Key: "secret2", OrgId: 2},
},
orgId: 1,
expectedServiceAccouts: 0,
expectedErr: nil,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
db, store := setupTestDatabase(t)
store.sqlStore.Cfg.AutoAssignOrg = true
store.sqlStore.Cfg.AutoAssignOrgId = 1
store.sqlStore.Cfg.AutoAssignOrgRole = "Viewer"
err := store.sqlStore.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "main"})
require.NoError(t, err)
for _, key := range c.keys {
tests.SetupApiKey(t, db, key)
}
err = store.MigrateApiKeysToServiceAccounts(context.Background(), c.orgId)
if c.expectedErr != nil {
require.ErrorIs(t, err, c.expectedErr)
} else {
require.NoError(t, err)
serviceAccounts, err := store.SearchOrgServiceAccounts(context.Background(), c.orgId, "", "all", 1, 50, &models.SignedInUser{UserId: 101, OrgId: c.orgId, Permissions: map[int64]map[string][]string{
c.orgId: {
"serviceaccounts:read": {"serviceaccounts:id:*"},
},
}})
require.NoError(t, err)
require.Equal(t, c.expectedServiceAccouts, serviceAccounts.TotalCount)
if c.expectedServiceAccouts > 0 {
saMigrated := serviceAccounts.ServiceAccounts[0]
require.Equal(t, string(c.keys[0].Role), saMigrated.Role)
tokens, err := store.ListTokens(context.Background(), c.orgId, saMigrated.Id)
require.NoError(t, err)
require.Len(t, tokens, 1)
}
}
})
}
}
func TestStore_RevertApiKey(t *testing.T) {
cases := []struct {
desc string
key tests.TestApiKey
expectedErr error
}{
{
desc: "service account token should be reverted to api key",
key: tests.TestApiKey{Name: "Test1", Role: models.ROLE_EDITOR, OrgId: 1},
expectedErr: nil,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
db, store := setupTestDatabase(t)
store.sqlStore.Cfg.AutoAssignOrg = true
store.sqlStore.Cfg.AutoAssignOrgId = 1
store.sqlStore.Cfg.AutoAssignOrgRole = "Viewer"
err := store.sqlStore.CreateOrg(context.Background(), &models.CreateOrgCommand{Name: "main"})
require.NoError(t, err)
key := tests.SetupApiKey(t, db, c.key)
err = store.MigrateApiKey(context.Background(), key.OrgId, key.Id)
require.NoError(t, err)
err = store.RevertApiKey(context.Background(), key.Id)
if c.expectedErr != nil {
require.ErrorIs(t, err, c.expectedErr)
} else {
require.NoError(t, err)
serviceAccounts, err := store.SearchOrgServiceAccounts(context.Background(), key.OrgId, "", "all", 1, 50, &models.SignedInUser{UserId: 1, OrgId: 1, Permissions: map[int64]map[string][]string{
key.OrgId: {
"serviceaccounts:read": {"serviceaccounts:id:*"},
},
}})
require.NoError(t, err)
// Service account should be deleted
require.Equal(t, int64(0), serviceAccounts.TotalCount)
apiKeys := store.sqlStore.GetAllAPIKeys(context.Background(), 1)
require.Len(t, apiKeys, 1)
apiKey := apiKeys[0]
require.Equal(t, c.key.Name, apiKey.Name)
require.Equal(t, c.key.OrgId, apiKey.OrgId)
require.Equal(t, c.key.Role, apiKey.Role)
require.Equal(t, key.Key, apiKey.Key)
// Api key should not be linked to service account
require.Nil(t, apiKey.ServiceAccountId)
}
})
}
}

View File

@ -7,10 +7,31 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/sqlstore"
"xorm.io/xorm"
)
func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, saID int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) error {
func (s *ServiceAccountsStoreImpl) ListTokens(ctx context.Context, orgId int64, serviceAccountId int64) ([]*models.ApiKey, error) {
result := make([]*models.ApiKey, 0)
err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
var sess *xorm.Session
quotedUser := s.sqlStore.Dialect.Quote("user")
sess = dbSession.
Join("inner", quotedUser, quotedUser+".id = api_key.service_account_id").
Where(quotedUser+".org_id=? AND "+quotedUser+".id=?", orgId, serviceAccountId).
Asc("api_key.name")
return sess.Find(&result)
})
return result, err
}
func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, serviceAccountId int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) error {
return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
if _, err := s.RetrieveServiceAccount(ctx, cmd.OrgId, serviceAccountId); err != nil {
return err
}
key := models.ApiKey{OrgId: cmd.OrgId, Name: cmd.Name}
exists, _ := sess.Get(&key)
if exists {
@ -26,7 +47,7 @@ func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, s
return &ErrInvalidExpirationSAToken{}
}
t := models.ApiKey{
token := models.ApiKey{
OrgId: cmd.OrgId,
Name: cmd.Name,
Role: models.ROLE_VIEWER,
@ -34,22 +55,22 @@ func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, s
Created: updated,
Updated: updated,
Expires: expires,
ServiceAccountId: &saID,
ServiceAccountId: &serviceAccountId,
}
if _, err := sess.Insert(&t); err != nil {
if _, err := sess.Insert(&token); err != nil {
return err
}
cmd.Result = &t
cmd.Result = &token
return nil
})
}
func (s *ServiceAccountsStoreImpl) DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error {
func (s *ServiceAccountsStoreImpl) DeleteServiceAccountToken(ctx context.Context, orgId, serviceAccountId, tokenId int64) error {
rawSQL := "DELETE FROM api_key WHERE id=? and org_id=? and service_account_id=?"
return s.sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
result, err := sess.Exec(rawSQL, tokenID, orgID, serviceAccountID)
result, err := sess.Exec(rawSQL, tokenId, orgId, serviceAccountId)
if err != nil {
return err
}
@ -64,25 +85,45 @@ func (s *ServiceAccountsStoreImpl) DeleteServiceAccountToken(ctx context.Context
}
// assignApiKeyToServiceAccount sets the API key service account ID
func (s *ServiceAccountsStoreImpl) assignApiKeyToServiceAccount(ctx context.Context, apikeyId int64, saccountId int64) error {
return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
key := models.ApiKey{Id: apikeyId}
exists, err := sess.Get(&key)
if err != nil {
s.log.Warn("API key not loaded", "err", err)
return err
}
if !exists {
s.log.Warn("API key not found", "err", err)
return models.ErrApiKeyNotFound
}
key.ServiceAccountId = &saccountId
func (s *ServiceAccountsStoreImpl) assignApiKeyToServiceAccount(sess *sqlstore.DBSession, apiKeyId int64, serviceAccountId int64) error {
key := models.ApiKey{Id: apiKeyId}
exists, err := sess.Get(&key)
if err != nil {
s.log.Warn("API key not loaded", "err", err)
return err
}
if !exists {
s.log.Warn("API key not found", "err", err)
return models.ErrApiKeyNotFound
}
key.ServiceAccountId = &serviceAccountId
if _, err := sess.ID(key.Id).Update(&key); err != nil {
s.log.Warn("Could not update api key", "err", err)
return err
}
if _, err := sess.ID(key.Id).Update(&key); err != nil {
s.log.Warn("Could not update api key", "err", err)
return err
}
return nil
})
return nil
}
// detachApiKeyFromServiceAccount converts service account token to old API key
func (s *ServiceAccountsStoreImpl) detachApiKeyFromServiceAccount(sess *sqlstore.DBSession, apiKeyId int64) error {
key := models.ApiKey{Id: apiKeyId}
exists, err := sess.Get(&key)
if err != nil {
s.log.Warn("Cannot get API key", "err", err)
return err
}
if !exists {
s.log.Warn("API key not found", "err", err)
return models.ErrApiKeyNotFound
}
key.ServiceAccountId = nil
if _, err := sess.ID(key.Id).AllCols().Update(&key); err != nil {
s.log.Error("Could not update api key", "err", err)
return err
}
return nil
}

View File

@ -70,40 +70,61 @@ func TestStore_AddServiceAccountToken(t *testing.T) {
}
}
func TestStore_DeleteServiceAccountToken(t *testing.T) {
userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true}
func TestStore_AddServiceAccountToken_WrongServiceAccount(t *testing.T) {
saToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true}
db, store := setupTestDatabase(t)
user := tests.SetupUserServiceAccount(t, db, userToCreate)
sa := tests.SetupUserServiceAccount(t, db, saToCreate)
keyName := t.Name()
key, err := apikeygen.New(user.OrgId, keyName)
key, err := apikeygen.New(sa.OrgId, keyName)
require.NoError(t, err)
cmd := serviceaccounts.AddServiceAccountTokenCommand{
Name: keyName,
OrgId: user.OrgId,
OrgId: sa.OrgId,
Key: key.HashedKey,
SecondsToLive: 0,
Result: &models.ApiKey{},
}
err = store.AddServiceAccountToken(context.Background(), user.Id, &cmd)
err = store.AddServiceAccountToken(context.Background(), sa.Id+1, &cmd)
require.Error(t, err, "It should not be possible to add token to non-existing service account")
}
func TestStore_DeleteServiceAccountToken(t *testing.T) {
userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true}
db, store := setupTestDatabase(t)
sa := tests.SetupUserServiceAccount(t, db, userToCreate)
keyName := t.Name()
key, err := apikeygen.New(sa.OrgId, keyName)
require.NoError(t, err)
cmd := serviceaccounts.AddServiceAccountTokenCommand{
Name: keyName,
OrgId: sa.OrgId,
Key: key.HashedKey,
SecondsToLive: 0,
Result: &models.ApiKey{},
}
err = store.AddServiceAccountToken(context.Background(), sa.Id, &cmd)
require.NoError(t, err)
newKey := cmd.Result
// Delete key from wrong service account
err = store.DeleteServiceAccountToken(context.Background(), user.OrgId, user.Id+2, newKey.Id)
err = store.DeleteServiceAccountToken(context.Background(), sa.OrgId, sa.Id+2, newKey.Id)
require.Error(t, err)
// Delete key from wrong org
err = store.DeleteServiceAccountToken(context.Background(), user.OrgId+2, user.Id, newKey.Id)
err = store.DeleteServiceAccountToken(context.Background(), sa.OrgId+2, sa.Id, newKey.Id)
require.Error(t, err)
err = store.DeleteServiceAccountToken(context.Background(), user.OrgId, user.Id, newKey.Id)
err = store.DeleteServiceAccountToken(context.Background(), sa.OrgId, sa.Id, newKey.Id)
require.NoError(t, err)
// Verify against DB
keys, errT := store.ListTokens(context.Background(), user.OrgId, user.Id)
keys, errT := store.ListTokens(context.Background(), sa.OrgId, sa.Id)
require.NoError(t, errT)
for _, k := range keys {

View File

@ -4,6 +4,7 @@ import (
"context"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@ -29,13 +30,14 @@ func ProvideServiceAccountsService(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
store *sqlstore.SQLStore,
kvStore kvstore.KVStore,
ac accesscontrol.AccessControl,
routeRegister routing.RouteRegister,
usageStats usagestats.Service,
) (*ServiceAccountsService, error) {
s := &ServiceAccountsService{
features: features,
store: database.NewServiceAccountsStore(store),
store: database.NewServiceAccountsStore(store, kvStore),
log: log.New("serviceaccounts"),
}

View File

@ -73,6 +73,10 @@ type ServiceAccountProfileDTO struct {
type ServiceAccountFilter string // used for filtering
type APIKeysMigrationStatus struct {
Migrated bool `json:"migrated"`
}
const (
FilterOnlyExpiredTokens ServiceAccountFilter = "expiredTokens"
FilterOnlyDisabled ServiceAccountFilter = "disabled"

View File

@ -22,8 +22,11 @@ type Store interface {
RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*ServiceAccountProfileDTO, error)
RetrieveServiceAccountIdByName(ctx context.Context, orgID int64, name string) (int64, error)
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error
UpgradeServiceAccounts(ctx context.Context) error
ConvertToServiceAccounts(ctx context.Context, keys []int64) error
GetAPIKeysMigrationStatus(ctx context.Context, orgID int64) (*APIKeysMigrationStatus, error)
HideApiKeysTab(ctx context.Context, orgID int64) error
MigrateApiKeysToServiceAccounts(ctx context.Context, orgID int64) error
MigrateApiKey(ctx context.Context, orgID int64, keyId int64) error
RevertApiKey(ctx context.Context, keyId int64) error
ListTokens(ctx context.Context, orgID int64, serviceAccount int64) ([]*models.ApiKey, error)
DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error
AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *AddServiceAccountTokenCommand) error

View File

@ -19,6 +19,13 @@ type TestUser struct {
IsServiceAccount bool
}
type TestApiKey struct {
Name string
Role models.RoleType
OrgId int64
Key string
}
func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser TestUser) *models.User {
role := string(models.ROLE_VIEWER)
if testUser.Role != "" {
@ -35,6 +42,28 @@ func SetupUserServiceAccount(t *testing.T, sqlStore *sqlstore.SQLStore, testUser
return u1
}
func SetupApiKey(t *testing.T, sqlStore *sqlstore.SQLStore, testKey TestApiKey) *models.ApiKey {
role := models.ROLE_VIEWER
if testKey.Role != "" {
role = testKey.Role
}
addKeyCmd := &models.AddApiKeyCommand{
Name: testKey.Name,
Role: role,
OrgId: testKey.OrgId,
}
if testKey.Key != "" {
addKeyCmd.Key = testKey.Key
} else {
addKeyCmd.Key = "secret"
}
err := sqlStore.AddAPIKey(context.Background(), addKeyCmd)
require.NoError(t, err)
return addKeyCmd.Result
}
// create mock for serviceaccountservice
type ServiceAccountMock struct{}
@ -72,17 +101,20 @@ var _ serviceaccounts.Store = new(ServiceAccountsStoreMock)
var _ serviceaccounts.Service = new(ServiceAccountMock)
type Calls struct {
CreateServiceAccount []interface{}
RetrieveServiceAccount []interface{}
DeleteServiceAccount []interface{}
UpgradeServiceAccounts []interface{}
ConvertServiceAccounts []interface{}
ListTokens []interface{}
DeleteServiceAccountToken []interface{}
UpdateServiceAccount []interface{}
AddServiceAccountToken []interface{}
SearchOrgServiceAccounts []interface{}
RetrieveServiceAccountIdByName []interface{}
CreateServiceAccount []interface{}
RetrieveServiceAccount []interface{}
DeleteServiceAccount []interface{}
GetAPIKeysMigrationStatus []interface{}
HideApiKeysTab []interface{}
MigrateApiKeysToServiceAccounts []interface{}
MigrateApiKey []interface{}
RevertApiKey []interface{}
ListTokens []interface{}
DeleteServiceAccountToken []interface{}
UpdateServiceAccount []interface{}
AddServiceAccountToken []interface{}
SearchOrgServiceAccounts []interface{}
RetrieveServiceAccountIdByName []interface{}
}
type ServiceAccountsStoreMock struct {
@ -106,13 +138,28 @@ func (s *ServiceAccountsStoreMock) DeleteServiceAccount(ctx context.Context, org
return nil
}
func (s *ServiceAccountsStoreMock) UpgradeServiceAccounts(ctx context.Context) error {
s.Calls.UpgradeServiceAccounts = append(s.Calls.UpgradeServiceAccounts, []interface{}{ctx})
func (s *ServiceAccountsStoreMock) HideApiKeysTab(ctx context.Context, orgID int64) error {
s.Calls.HideApiKeysTab = append(s.Calls.HideApiKeysTab, []interface{}{ctx})
return nil
}
func (s *ServiceAccountsStoreMock) ConvertToServiceAccounts(ctx context.Context, keys []int64) error {
s.Calls.ConvertServiceAccounts = append(s.Calls.ConvertServiceAccounts, []interface{}{ctx})
func (s *ServiceAccountsStoreMock) GetAPIKeysMigrationStatus(ctx context.Context, orgID int64) (*serviceaccounts.APIKeysMigrationStatus, error) {
s.Calls.GetAPIKeysMigrationStatus = append(s.Calls.GetAPIKeysMigrationStatus, []interface{}{ctx})
return nil, nil
}
func (s *ServiceAccountsStoreMock) MigrateApiKeysToServiceAccounts(ctx context.Context, orgID int64) error {
s.Calls.MigrateApiKeysToServiceAccounts = append(s.Calls.MigrateApiKeysToServiceAccounts, []interface{}{ctx})
return nil
}
func (s *ServiceAccountsStoreMock) MigrateApiKey(ctx context.Context, orgID int64, keyId int64) error {
s.Calls.MigrateApiKey = append(s.Calls.MigrateApiKey, []interface{}{ctx})
return nil
}
func (s *ServiceAccountsStoreMock) RevertApiKey(ctx context.Context, keyId int64) error {
s.Calls.RevertApiKey = append(s.Calls.RevertApiKey, []interface{}{ctx})
return nil
}

View File

@ -42,12 +42,15 @@ func (ss *SQLStore) GetAPIKeys(ctx context.Context, query *models.GetApiKeysQuer
})
}
// GetAllOrgsAPIKeys queries the database for valid non SA APIKeys across all orgs
func (ss *SQLStore) GetAllOrgsAPIKeys(ctx context.Context) []*models.ApiKey {
// GetAllAPIKeys queries the database for valid non SA APIKeys across all orgs
func (ss *SQLStore) GetAllAPIKeys(ctx context.Context, orgID int64) []*models.ApiKey {
result := make([]*models.ApiKey, 0)
err := ss.WithDbSession(ctx, func(dbSession *DBSession) error {
sess := dbSession. //CHECK how many API keys do our clients have? Can we load them all?
Where("(expires IS NULL OR expires >= ?) AND service_account_id IS NULL", timeNow().Unix()).Asc("name")
sess := dbSession.
Where("(expires IS NULL OR expires >= ?) AND service_account_id IS NULL", timeNow().Unix()).Asc("name")
if orgID != -1 {
sess = sess.Where("org_id=?", orgID)
}
return sess.Find(&result)
})
if err != nil {

View File

@ -537,7 +537,7 @@ func (m *SQLStoreMock) GetAPIKeys(ctx context.Context, query *models.GetApiKeysQ
return m.ExpectedError
}
func (m *SQLStoreMock) GetAllOrgsAPIKeys(ctx context.Context) []*models.ApiKey {
func (m *SQLStoreMock) GetAllAPIKeys(ctx context.Context, orgID int64) []*models.ApiKey {
return nil
}

View File

@ -111,7 +111,7 @@ type Store interface {
SetAlertNotificationStateToPendingCommand(ctx context.Context, cmd *models.SetAlertNotificationStateToPendingCommand) error
GetOrCreateAlertNotificationState(ctx context.Context, cmd *models.GetOrCreateNotificationStateQuery) error
GetAPIKeys(ctx context.Context, query *models.GetApiKeysQuery) error
GetAllOrgsAPIKeys(ctx context.Context) []*models.ApiKey
GetAllAPIKeys(ctx context.Context, orgID int64) []*models.ApiKey
DeleteApiKey(ctx context.Context, cmd *models.DeleteApiKeyCommand) error
AddAPIKey(ctx context.Context, cmd *models.AddApiKeyCommand) error
GetApiKeyById(ctx context.Context, query *models.GetApiKeyByIdQuery) error

View File

@ -0,0 +1,41 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LinkButton, useStyles2 } from '@grafana/ui';
interface Props {
onHideApiKeys: () => void;
}
export const APIKeysMigratedCard = ({ onHideApiKeys }: Props): JSX.Element => {
const styles = useStyles2(getStyles);
return (
<Alert title="API keys were migrated to Service accounts. This tab is deprecated." severity="info">
<div className={styles.text}>
We have upgraded your API keys into more powerful Service accounts and tokens. All your keys are safe and
working - you will find them inside respective service accounts. Keys are now called tokens.
</div>
<div className={styles.actionRow}>
<LinkButton className={styles.actionButton} href="org/serviceaccounts" onClick={onHideApiKeys}>
Go to service accounts tab and never show API keys tab again
</LinkButton>
<a href="org/serviceaccounts">Go to service accounts tab</a>
</div>
</Alert>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
text: css`
margin-bottom: ${theme.spacing(2)};
`,
actionRow: css`
display: flex;
align-items: center;
`,
actionButton: css`
margin-right: ${theme.spacing(2)};
`,
});

View File

@ -25,9 +25,13 @@ jest.mock('app/core/core', () => {
const setup = (propOverrides: Partial<Props>) => {
const loadApiKeysMock = jest.fn();
const deleteApiKeyMock = jest.fn();
const migrateApiKeyMock = jest.fn();
const addApiKeyMock = jest.fn();
const migrateAllMock = jest.fn();
const toggleIncludeExpiredMock = jest.fn();
const setSearchQueryMock = mockToolkitActionCreator(setSearchQuery);
const getApiKeysMigrationStatusMock = jest.fn();
const hideApiKeysMock = jest.fn();
const props: Props = {
navModel: {
main: {
@ -44,12 +48,17 @@ const setup = (propOverrides: Partial<Props>) => {
deleteApiKey: deleteApiKeyMock,
setSearchQuery: setSearchQueryMock,
addApiKey: addApiKeyMock,
getApiKeysMigrationStatus: getApiKeysMigrationStatusMock,
migrateApiKey: migrateApiKeyMock,
migrateAll: migrateAllMock,
hideApiKeys: hideApiKeysMock,
apiKeysCount: 0,
timeZone: 'utc',
includeExpired: false,
includeExpiredDisabled: false,
toggleIncludeExpired: toggleIncludeExpiredMock,
canCreate: true,
apiKeysMigrated: false,
};
Object.assign(props, propOverrides);

View File

@ -14,12 +14,23 @@ import { getTimeZone } from 'app/features/profile/state/selectors';
import { AccessControlAction, ApiKey, NewApiKey, StoreState } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events';
import { APIKeysMigratedCard } from './APIKeysMigratedCard';
import { ApiKeysActionBar } from './ApiKeysActionBar';
import { ApiKeysAddedModal } from './ApiKeysAddedModal';
import { ApiKeysController } from './ApiKeysController';
import { ApiKeysForm } from './ApiKeysForm';
import { ApiKeysTable } from './ApiKeysTable';
import { addApiKey, deleteApiKey, loadApiKeys, toggleIncludeExpired } from './state/actions';
import { MigrateToServiceAccountsCard } from './MigrateToServiceAccountsCard';
import {
addApiKey,
deleteApiKey,
migrateApiKey,
migrateAll,
loadApiKeys,
toggleIncludeExpired,
getApiKeysMigrationStatus,
hideApiKeys,
} from './state/actions';
import { setSearchQuery } from './state/reducers';
import { getApiKeys, getApiKeysCount, getIncludeExpired, getIncludeExpiredDisabled } from './state/selectors';
@ -36,15 +47,20 @@ function mapStateToProps(state: StoreState) {
includeExpired: getIncludeExpired(state.apiKeys),
includeExpiredDisabled: getIncludeExpiredDisabled(state.apiKeys),
canCreate: canCreate,
apiKeysMigrated: state.apiKeys.apiKeysMigrated,
};
}
const mapDispatchToProps = {
loadApiKeys,
deleteApiKey,
migrateApiKey,
migrateAll,
setSearchQuery,
toggleIncludeExpired,
addApiKey,
getApiKeysMigrationStatus,
hideApiKeys,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -64,6 +80,7 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
componentDidMount() {
this.fetchApiKeys();
this.props.getApiKeysMigrationStatus();
}
async fetchApiKeys() {
@ -74,6 +91,14 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
this.props.deleteApiKey(key.id!);
};
onMigrateAll = () => {
this.props.migrateAll();
};
onMigrateApiKey = (key: ApiKey) => {
this.props.migrateApiKey(key.id!);
};
onSearchQueryChange = (value: string) => {
this.props.setSearchQuery(value);
};
@ -116,6 +141,11 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
}
};
onHideApiKeys = async () => {
await this.props.hideApiKeys();
window.location.reload();
};
render() {
const {
hasFetched,
@ -127,6 +157,7 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
includeExpired,
includeExpiredDisabled,
canCreate,
apiKeysMigrated,
} = this.props;
if (!hasFetched) {
@ -142,18 +173,17 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
<Page.Contents isLoading={false}>
<ApiKeysController>
{({ isAdding, toggleIsAdding }) => {
const showCTA = !isAdding && apiKeysCount === 0;
const showCTA = !isAdding && apiKeysCount === 0 && !apiKeysMigrated;
const showTable = apiKeysCount > 0;
return (
<>
{/* TODO: enable when API keys to service accounts migration is ready
{config.featureToggles.serviceAccounts && (
<Alert title="Switch from API keys to Service accounts" severity="info">
Service accounts give you more control. API keys will be automatically migrated into tokens inside
respective service accounts. The current API keys will still work, but will be called tokens and
you will find them in the detail view of a respective service account.
</Alert>
)} */}
{/* TODO: remove feature flag check before GA */}
{config.featureToggles.serviceAccounts && !apiKeysMigrated && (
<MigrateToServiceAccountsCard onMigrate={this.onMigrateAll} />
)}
{config.featureToggles.serviceAccounts && apiKeysMigrated && (
<APIKeysMigratedCard onHideApiKeys={this.onHideApiKeys} />
)}
{showCTA ? (
<EmptyListCTA
title="You haven't added any API keys yet."
@ -183,7 +213,12 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
<InlineField disabled={includeExpiredDisabled} label="Include expired keys">
<InlineSwitch id="showExpired" value={includeExpired} onChange={this.onIncludeExpiredChange} />
</InlineField>
<ApiKeysTable apiKeys={apiKeys} timeZone={timeZone} onDelete={this.onDeleteApiKey} />
<ApiKeysTable
apiKeys={apiKeys}
timeZone={timeZone}
onMigrate={this.onMigrateApiKey}
onDelete={this.onDeleteApiKey}
/>
</VerticalGroup>
) : null}
</>

View File

@ -2,7 +2,8 @@ import { css } from '@emotion/css';
import React, { FC } from 'react';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
import { DeleteButton, Icon, IconName, Tooltip, useTheme2 } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { Button, DeleteButton, HorizontalGroup, Icon, IconName, Tooltip, useTheme2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
@ -12,9 +13,10 @@ interface Props {
apiKeys: ApiKey[];
timeZone: TimeZone;
onDelete: (apiKey: ApiKey) => void;
onMigrate: (apiKey: ApiKey) => void;
}
export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete }) => {
export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete, onMigrate }) => {
const theme = useTheme2();
const styles = getStyles(theme);
@ -47,12 +49,19 @@ export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete }) => {
)}
</td>
<td>
<DeleteButton
aria-label="Delete API key"
size="sm"
onConfirm={() => onDelete(key)}
disabled={!contextSrv.hasPermissionInMetadata(AccessControlAction.ActionAPIKeysDelete, key)}
/>
<HorizontalGroup justify="flex-end">
{config.featureToggles.serviceAccounts && (
<Button size="sm" onClick={() => onMigrate(key)}>
Migrate
</Button>
)}
<DeleteButton
aria-label="Delete API key"
size="sm"
onConfirm={() => onDelete(key)}
disabled={!contextSrv.hasPermissionInMetadata(AccessControlAction.ActionAPIKeysDelete, key)}
/>
</HorizontalGroup>
</td>
</tr>
);

View File

@ -0,0 +1,45 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, useStyles2 } from '@grafana/ui';
interface Props {
onMigrate: () => void;
disabled?: boolean;
}
export const MigrateToServiceAccountsCard = ({ onMigrate, disabled }: Props): JSX.Element => {
const styles = useStyles2(getStyles);
return (
<Alert title="Switch from API keys to Service accounts" severity="info">
<div className={styles.text}>
Service accounts give you more control. API keys will be automatically migrated into tokens inside respective
service accounts. The current API keys will still work, but will be called tokens and you will find them in the
detail view of a respective service account.
</div>
<div className={styles.actionRow}>
{!disabled && (
<Button className={styles.actionButton} onClick={onMigrate}>
Migrate now
</Button>
)}
<span>Read more about Service accounts and how to turn them on</span>
</div>
</Alert>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
text: css`
margin-bottom: ${theme.spacing(2)};
`,
actionRow: css`
display: flex;
align-items: center;
`,
actionButton: css`
margin-right: ${theme.spacing(2)};
`,
});

View File

@ -1,7 +1,14 @@
import { getBackendSrv } from 'app/core/services/backend_srv';
import { config } from '@grafana/runtime';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { ApiKey, ThunkResult } from 'app/types';
import { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery } from './reducers';
import {
apiKeysLoaded,
includeExpiredToggled,
isFetching,
apiKeysMigrationStatusLoaded,
setSearchQuery,
} from './reducers';
export function addApiKey(apiKey: ApiKey, openModal: (key: string) => void): ThunkResult<void> {
return async (dispatch) => {
@ -31,6 +38,43 @@ export function deleteApiKey(id: number): ThunkResult<void> {
};
}
export function migrateApiKey(id: number): ThunkResult<void> {
return async (dispatch) => {
try {
await getBackendSrv().post(`/api/serviceaccounts/migrate/${id}`);
} finally {
dispatch(loadApiKeys());
}
};
}
export function migrateAll(): ThunkResult<void> {
return async (dispatch) => {
try {
await getBackendSrv().post('/api/serviceaccounts/migrate');
} finally {
dispatch(getApiKeysMigrationStatus());
dispatch(loadApiKeys());
}
};
}
export function getApiKeysMigrationStatus(): ThunkResult<void> {
return async (dispatch) => {
// TODO: remove when service account enabled by default (or use another way to detect if it's enabled)
if (config.featureToggles.serviceAccounts) {
const result = await getBackendSrv().get('/api/serviceaccounts/migrationstatus');
dispatch(apiKeysMigrationStatusLoaded(!!result?.migrated));
}
};
}
export function hideApiKeys(): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().post('/api/serviceaccounts/hideApiKeys');
};
}
export function toggleIncludeExpired(): ThunkResult<void> {
return (dispatch) => {
dispatch(includeExpiredToggled());

View File

@ -8,6 +8,7 @@ export const initialApiKeysState: ApiKeysState = {
keys: [],
keysIncludingExpired: [],
searchQuery: '',
apiKeysMigrated: false,
};
const apiKeysSlice = createSlice({
@ -22,6 +23,9 @@ const apiKeysSlice = createSlice({
: state.includeExpired;
return { ...state, hasFetched: true, keys, keysIncludingExpired, includeExpired };
},
apiKeysMigrationStatusLoaded: (state, action): ApiKeysState => {
return { ...state, apiKeysMigrated: action.payload };
},
setSearchQuery: (state, action): ApiKeysState => {
return { ...state, searchQuery: action.payload };
},
@ -34,7 +38,8 @@ const apiKeysSlice = createSlice({
},
});
export const { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery } = apiKeysSlice.actions;
export const { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery, apiKeysMigrationStatusLoaded } =
apiKeysSlice.actions;
export const apiKeysReducer = apiKeysSlice.reducer;

View File

@ -16,6 +16,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const keyCount = getApiKeysCount(mockState);
expect(keyCount).toBe(5);
@ -28,6 +29,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const keyCount = getApiKeysCount(mockState);
expect(keyCount).toBe(8);
@ -43,6 +45,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys).toEqual(mockKeys);
@ -55,6 +58,7 @@ describe('API Keys selectors', () => {
searchQuery: '5',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys.length).toEqual(1);
@ -69,6 +73,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys).toEqual(mockKeysIncludingExpired);
@ -81,6 +86,7 @@ describe('API Keys selectors', () => {
searchQuery: '5',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys.length).toEqual(1);
@ -96,6 +102,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const includeExpired = getIncludeExpired(mockState);
expect(includeExpired).toBe(true);
@ -108,6 +115,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const includeExpired = getIncludeExpired(mockState);
expect(includeExpired).toBe(false);
@ -122,6 +130,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const includeExpiredDisabled = getIncludeExpiredDisabled(mockState);
expect(includeExpiredDisabled).toBe(true);
@ -134,6 +143,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const includeExpiredDisabled = getIncludeExpired(mockState);
expect(includeExpiredDisabled).toBe(false);

View File

@ -22,6 +22,7 @@ const setup = (propOverrides: Partial<Props>) => {
const updateServiceAccountMock = jest.fn();
const changeStateFilterMock = jest.fn();
const createServiceAccountTokenMock = jest.fn();
const getApiKeysMigrationStatusMock = jest.fn();
const props: Props = {
navModel: {
main: {
@ -41,6 +42,7 @@ const setup = (propOverrides: Partial<Props>) => {
showPaging: false,
totalPages: 1,
serviceAccounts: [],
apiKeysMigrated: false,
changeQuery: changeQueryMock,
fetchACOptions: fetchACOptionsMock,
fetchServiceAccounts: fetchServiceAccountsMock,
@ -48,6 +50,7 @@ const setup = (propOverrides: Partial<Props>) => {
updateServiceAccount: updateServiceAccountMock,
changeStateFilter: changeStateFilterMock,
createServiceAccountToken: createServiceAccountTokenMock,
getApiKeysMigrationStatus: getApiKeysMigrationStatusMock,
};
Object.assign(props, propOverrides);

View File

@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { GrafanaTheme2, OrgRole } from '@grafana/data';
import { ConfirmModal, FilterInput, Icon, LinkButton, RadioButtonGroup, Tooltip, useStyles2 } from '@grafana/ui';
import { Alert, ConfirmModal, FilterInput, Icon, LinkButton, RadioButtonGroup, Tooltip, useStyles2 } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import Page from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
@ -22,6 +22,7 @@ import {
updateServiceAccount,
changeStateFilter,
createServiceAccountToken,
getApiKeysMigrationStatus,
} from './state/actions';
interface OwnProps {}
@ -43,6 +44,7 @@ const mapDispatchToProps = {
updateServiceAccount,
changeStateFilter,
createServiceAccountToken,
getApiKeysMigrationStatus,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -55,6 +57,7 @@ export const ServiceAccountsListPageUnconnected = ({
builtInRoles,
query,
serviceAccountStateFilter,
apiKeysMigrated,
changeQuery,
fetchACOptions,
fetchServiceAccounts,
@ -62,6 +65,7 @@ export const ServiceAccountsListPageUnconnected = ({
updateServiceAccount,
changeStateFilter,
createServiceAccountToken,
getApiKeysMigrationStatus,
}: Props): JSX.Element => {
const styles = useStyles2(getStyles);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
@ -72,10 +76,11 @@ export const ServiceAccountsListPageUnconnected = ({
useEffect(() => {
fetchServiceAccounts({ withLoadingIndicator: true });
getApiKeysMigrationStatus();
if (contextSrv.licensedAccessControlEnabled()) {
fetchACOptions();
}
}, [fetchACOptions, fetchServiceAccounts]);
}, [fetchACOptions, fetchServiceAccounts, getApiKeysMigrationStatus]);
const noServiceAccountsCreated =
serviceAccounts.length === 0 && serviceAccountStateFilter === ServiceAccountStateFilter.All && !query;
@ -151,9 +156,21 @@ export const ServiceAccountsListPageUnconnected = ({
setCurrentServiceAccount(null);
};
const onMigrationInfoClose = () => {
// TODO: dismiss banner permanently
};
return (
<Page navModel={navModel}>
<Page.Contents>
{apiKeysMigrated && (
<Alert
title="API keys migrated to Service accounts. Your keys are now called tokens and live inside respective service
accounts. Learn more."
severity="success"
onRemove={onMigrationInfoClose}
></Alert>
)}
<div className={styles.pageHeader}>
<h2>Service accounts</h2>
<div className={styles.apiKeyInfoLabel}>

View File

@ -15,6 +15,7 @@ import {
serviceAccountsFetchBegin,
serviceAccountsFetched,
serviceAccountsFetchEnd,
apiKeysMigrationStatusLoaded,
stateFilterChanged,
} from './reducers';
@ -41,6 +42,13 @@ export function fetchACOptions(): ThunkResult<void> {
};
}
export function getApiKeysMigrationStatus(): ThunkResult<void> {
return async (dispatch) => {
const result = await getBackendSrv().get('/api/serviceaccounts/migrationstatus');
dispatch(apiKeysMigrationStatusLoaded(!!result?.migrated));
};
}
interface FetchServiceAccountsParams {
withLoadingIndicator: boolean;
}

View File

@ -51,6 +51,7 @@ export const initialStateList: ServiceAccountsState = {
totalPages: 1,
showPaging: false,
serviceAccountStateFilter: ServiceAccountStateFilter.All,
apiKeysMigrated: false,
};
interface ServiceAccountsFetched {
@ -89,6 +90,9 @@ const serviceAccountsSlice = createSlice({
builtInRolesLoaded: (state, action: PayloadAction<Record<string, Role[]>>): ServiceAccountsState => {
return { ...state, builtInRoles: action.payload };
},
apiKeysMigrationStatusLoaded: (state, action): ServiceAccountsState => {
return { ...state, apiKeysMigrated: action.payload };
},
queryChanged: (state, action: PayloadAction<string>) => {
return {
...state,
@ -114,6 +118,7 @@ export const {
serviceAccountsFetched,
acOptionsLoaded,
builtInRolesLoaded,
apiKeysMigrationStatusLoaded,
pageChanged,
stateFilterChanged,
queryChanged,

View File

@ -25,4 +25,5 @@ export interface ApiKeysState {
keysIncludingExpired: ApiKey[];
searchQuery: string;
hasFetched: boolean;
apiKeysMigrated: boolean;
}

View File

@ -66,6 +66,7 @@ export interface ServiceAccountsState {
isLoading: boolean;
roleOptions: Role[];
builtInRoles: Record<string, Role[]>;
apiKeysMigrated: boolean;
// search / filtering
query: string;
@ -75,3 +76,7 @@ export interface ServiceAccountsState {
showPaging: boolean;
serviceAccountStateFilter: ServiceAccountStateFilter;
}
export interface ServiceAccountsUpgradeStatus {
upgraded: boolean;
}