mirror of
https://github.com/grafana/grafana.git
synced 2025-01-10 08:03:58 -06:00
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:
parent
b47ec36d0d
commit
f82264c2b1
@ -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")
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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"),
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
41
public/app/features/api-keys/APIKeysMigratedCard.tsx
Normal file
41
public/app/features/api-keys/APIKeysMigratedCard.tsx
Normal 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)};
|
||||
`,
|
||||
});
|
@ -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);
|
||||
|
@ -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}
|
||||
</>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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)};
|
||||
`,
|
||||
});
|
@ -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());
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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}>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -25,4 +25,5 @@ export interface ApiKeysState {
|
||||
keysIncludingExpired: ApiKey[];
|
||||
searchQuery: string;
|
||||
hasFetched: boolean;
|
||||
apiKeysMigrated: boolean;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user