Files
grafana/pkg/services/serviceaccounts/api/api.go
T
Eric Leijonmarck 9d6ab92e39 Service accounts: Remove Add API keys buttons and remove one state of migrating for API keys tab (#63411)
* add: hide apikeys tab on start

* make use of store method

* added hiding of apikeys tab for new org creation

* missing err check

* removed unused files

* implemennted fake to make tests run

* move check for globalHideApikeys from org to admin

* refactor to remove the fake

* removed unused method calls for interface

* Update pkg/services/serviceaccounts/manager/service.go

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>

* Update pkg/services/serviceaccounts/manager/service.go

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>

* remove the checkglobal method

* removed duplicate global set const

* add count of apikeys for performance

* remove apikeys adding in UI

* added back deleted file

* added comment on component

* changed wording and copy for hiding and migrating service accounts

* refactor: remove migrationstatus in front/backend

This removes the migrationstatus state from the UI in favor of only
looking at the number of API keys to determine what to show to the user.
This simplifies the logic and makes less calls to the backend with each
page load. This was called both on the API keys page and the Service
accounts page.

- removes the state of migrationstatus from the UI
- removes the backend call
- removes the backend endpoint for migrationstatus

* Update pkg/services/apikey/apikeyimpl/xorm_store.go

Co-authored-by: Karl Persson <kalle.persson@grafana.com>

* changes the contet to also be primary

* change id of version for footer component

---------

Co-authored-by: Alexander Zobnin <alexanderzobnin@gmail.com>
Co-authored-by: Karl Persson <kalle.persson@grafana.com>
2023-03-01 15:34:53 +00:00

506 lines
20 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"strconv"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apikey"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/database"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
type ServiceAccountsAPI struct {
cfg *setting.Cfg
service service
accesscontrol accesscontrol.AccessControl
accesscontrolService accesscontrol.Service
RouterRegister routing.RouteRegister
log log.Logger
permissionService accesscontrol.ServiceAccountPermissionsService
}
// Service implements the API exposed methods for service accounts.
type service interface {
CreateServiceAccount(ctx context.Context, orgID int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error)
RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*serviceaccounts.ServiceAccountProfileDTO, error)
UpdateServiceAccount(ctx context.Context, orgID, serviceAccountID int64,
saForm *serviceaccounts.UpdateServiceAccountForm) (*serviceaccounts.ServiceAccountProfileDTO, error)
SearchOrgServiceAccounts(ctx context.Context, query *serviceaccounts.SearchOrgServiceAccountsQuery) (*serviceaccounts.SearchOrgServiceAccountsResult, error)
ListTokens(ctx context.Context, query *serviceaccounts.GetSATokensQuery) ([]apikey.APIKey, error)
DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) 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, saId int64, keyId int64) error
// Service account tokens
AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *serviceaccounts.AddServiceAccountTokenCommand) (*apikey.APIKey, error)
DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error
}
func NewServiceAccountsAPI(
cfg *setting.Cfg,
service service,
accesscontrol accesscontrol.AccessControl,
accesscontrolService accesscontrol.Service,
routerRegister routing.RouteRegister,
permissionService accesscontrol.ServiceAccountPermissionsService,
) *ServiceAccountsAPI {
return &ServiceAccountsAPI{
cfg: cfg,
service: service,
accesscontrol: accesscontrol,
accesscontrolService: accesscontrolService,
RouterRegister: routerRegister,
log: log.New("serviceaccounts.api"),
permissionService: permissionService,
}
}
func (api *ServiceAccountsAPI) RegisterAPIEndpoints() {
auth := accesscontrol.Middleware(api.accesscontrol)
api.RouterRegister.Group("/api/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) {
serviceAccountsRoute.Get("/search", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionRead)), routing.Wrap(api.SearchOrgServiceAccountsWithPaging))
serviceAccountsRoute.Post("/", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.CreateServiceAccount))
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))
serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount))
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.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("/:serviceAccountId/revert/:keyId", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.RevertApiKey))
})
}
// swagger:route POST /serviceaccounts service_accounts createServiceAccount
//
// # Create service account
//
// Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):
// action: `serviceaccounts:write` scope: `serviceaccounts:*`
//
// Requires basic authentication and that the authenticated user is a Grafana Admin.
//
// Responses:
// 201: createServiceAccountResponse
// 400: badRequestError
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (api *ServiceAccountsAPI) CreateServiceAccount(c *contextmodel.ReqContext) response.Response {
cmd := serviceaccounts.CreateServiceAccountForm{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "Bad request data", err)
}
if err := api.validateRole(cmd.Role, &c.OrgRole); err != nil {
switch {
case errors.Is(err, serviceaccounts.ErrServiceAccountInvalidRole):
return response.Error(http.StatusBadRequest, err.Error(), err)
case errors.Is(err, serviceaccounts.ErrServiceAccountRolePrivilegeDenied):
return response.Error(http.StatusForbidden, err.Error(), err)
default:
return response.Error(http.StatusInternalServerError, "failed to create service account", err)
}
}
serviceAccount, err := api.service.CreateServiceAccount(c.Req.Context(), c.OrgID, &cmd)
switch {
case errors.Is(err, database.ErrServiceAccountAlreadyExists):
return response.Error(http.StatusBadRequest, "Failed to create service account", err)
case err != nil:
return response.Error(http.StatusInternalServerError, "Failed to create service account", err)
}
if !api.accesscontrol.IsDisabled() {
if c.SignedInUser.IsRealUser() {
if _, err := api.permissionService.SetUserPermission(c.Req.Context(), c.OrgID, accesscontrol.User{ID: c.SignedInUser.UserID}, strconv.FormatInt(serviceAccount.Id, 10), "Admin"); err != nil {
return response.Error(http.StatusInternalServerError, "Failed to set permissions for service account creator", err)
}
}
// Clear permission cache for the user who's created the service account, so that new permissions are fetched for their next call
// Required for cases when caller wants to immediately interact with the newly created object
api.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
}
return response.JSON(http.StatusCreated, serviceAccount)
}
// swagger:route GET /serviceaccounts/{serviceAccountId} service_accounts retrieveServiceAccount
//
// # Get single serviceaccount by Id
//
// Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):
// action: `serviceaccounts:read` scope: `serviceaccounts:id:1` (single service account)
//
// Responses:
// 200: retrieveServiceAccountResponse
// 400: badRequestError
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *contextmodel.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)
}
serviceAccount, err := api.service.RetrieveServiceAccount(ctx.Req.Context(), ctx.OrgID, scopeID)
if err != nil {
switch {
case errors.Is(err, serviceaccounts.ErrServiceAccountNotFound):
return response.Error(http.StatusNotFound, "Failed to retrieve service account", err)
default:
return response.Error(http.StatusInternalServerError, "Failed to retrieve service account", err)
}
}
saIDString := strconv.FormatInt(serviceAccount.Id, 10)
metadata := api.getAccessControlMetadata(ctx, map[string]bool{saIDString: true})
serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccount.Name)
serviceAccount.AccessControl = metadata[saIDString]
tokens, err := api.service.ListTokens(ctx.Req.Context(), &serviceaccounts.GetSATokensQuery{
OrgID: &serviceAccount.OrgId,
ServiceAccountID: &serviceAccount.Id,
})
if err != nil {
api.log.Warn("Failed to list tokens for service account", "serviceAccount", serviceAccount.Id)
}
serviceAccount.Tokens = int64(len(tokens))
return response.JSON(http.StatusOK, serviceAccount)
}
// swagger:route PATCH /serviceaccounts/{serviceAccountId} service_accounts updateServiceAccount
//
// # Update service account
//
// Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):
// action: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)
//
// Responses:
// 200: updateServiceAccountResponse
// 400: badRequestError
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (api *ServiceAccountsAPI) UpdateServiceAccount(c *contextmodel.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)
}
cmd := serviceaccounts.UpdateServiceAccountForm{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "Bad request data", err)
}
if err := api.validateRole(cmd.Role, &c.OrgRole); err != nil {
switch {
case errors.Is(err, serviceaccounts.ErrServiceAccountInvalidRole):
return response.Error(http.StatusBadRequest, err.Error(), err)
case errors.Is(err, serviceaccounts.ErrServiceAccountRolePrivilegeDenied):
return response.Error(http.StatusForbidden, err.Error(), err)
default:
return response.Error(http.StatusInternalServerError, "failed to update service account", err)
}
}
resp, err := api.service.UpdateServiceAccount(c.Req.Context(), c.OrgID, scopeID, &cmd)
if err != nil {
switch {
case errors.Is(err, serviceaccounts.ErrServiceAccountNotFound):
return response.Error(http.StatusNotFound, "Failed to retrieve service account", err)
default:
return response.Error(http.StatusInternalServerError, "Failed update service account", err)
}
}
saIDString := strconv.FormatInt(resp.Id, 10)
metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true})
resp.AvatarUrl = dtos.GetGravatarUrlWithDefault("", resp.Name)
resp.AccessControl = metadata[saIDString]
return response.JSON(http.StatusOK, util.DynMap{
"message": "Service account updated",
"id": resp.Id,
"name": resp.Name,
"serviceaccount": resp,
})
}
func (api *ServiceAccountsAPI) validateRole(r *org.RoleType, orgRole *org.RoleType) error {
if r != nil && !r.IsValid() {
return serviceaccounts.ErrServiceAccountInvalidRole
}
if r != nil && !orgRole.Includes(*r) {
return serviceaccounts.ErrServiceAccountRolePrivilegeDenied
}
return nil
}
// swagger:route DELETE /serviceaccounts/{serviceAccountId} service_accounts deleteServiceAccount
//
// # Delete service account
//
// Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):
// action: `serviceaccounts:delete` scope: `serviceaccounts:id:1` (single service account)
//
// Responses:
// 200: okResponse
// 400: badRequestError
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (api *ServiceAccountsAPI) DeleteServiceAccount(ctx *contextmodel.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")
}
// swagger:route GET /serviceaccounts/search service_accounts searchOrgServiceAccountsWithPaging
//
// # Search service accounts with paging
//
// Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation):
// action: `serviceaccounts:read` scope: `serviceaccounts:*`
//
// Responses:
// 200: searchOrgServiceAccountsWithPagingResponse
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmodel.ReqContext) response.Response {
ctx := c.Req.Context()
perPage := c.QueryInt("perpage")
if perPage <= 0 {
perPage = 1000
}
page := c.QueryInt("page")
if page < 1 {
page = 1
}
// its okay that it fails, it is only filtering that might be weird, but to safe quard against any weird incoming query param
onlyWithExpiredTokens := c.QueryBool("expiredTokens")
onlyDisabled := c.QueryBool("disabled")
filter := serviceaccounts.FilterIncludeAll
if onlyWithExpiredTokens {
filter = serviceaccounts.FilterOnlyExpiredTokens
}
if onlyDisabled {
filter = serviceaccounts.FilterOnlyDisabled
}
q := serviceaccounts.SearchOrgServiceAccountsQuery{
OrgID: c.OrgID,
Query: c.Query("query"),
Page: page,
Limit: perPage,
Filter: filter,
SignedInUser: c.SignedInUser,
}
serviceAccountSearch, err := api.service.SearchOrgServiceAccounts(ctx, &q)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to get service accounts for current organization", err)
}
saIDs := map[string]bool{}
for i := range serviceAccountSearch.ServiceAccounts {
sa := serviceAccountSearch.ServiceAccounts[i]
sa.AvatarUrl = dtos.GetGravatarUrlWithDefault("", sa.Name)
saIDString := strconv.FormatInt(sa.Id, 10)
saIDs[saIDString] = true
metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true})
sa.AccessControl = metadata[strconv.FormatInt(sa.Id, 10)]
tokens, err := api.service.ListTokens(ctx, &serviceaccounts.GetSATokensQuery{
OrgID: &sa.OrgId, ServiceAccountID: &sa.Id,
})
if err != nil {
api.log.Warn("Failed to list tokens for service account", "serviceAccount", sa.Id)
}
sa.Tokens = int64(len(tokens))
}
return response.JSON(http.StatusOK, serviceAccountSearch)
}
// POST /api/serviceaccounts/hideapikeys
func (api *ServiceAccountsAPI) HideApiKeysTab(ctx *contextmodel.ReqContext) response.Response {
if err := api.service.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 *contextmodel.ReqContext) response.Response {
if err := api.service.MigrateApiKeysToServiceAccounts(ctx.Req.Context(), ctx.OrgID); err != nil {
return response.Error(http.StatusInternalServerError, "Internal server error", err)
}
return response.Success("API keys migrated to service accounts")
}
// POST /api/serviceaccounts/migrate/:keyId
func (api *ServiceAccountsAPI) ConvertToServiceAccount(ctx *contextmodel.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.service.MigrateApiKey(ctx.Req.Context(), ctx.OrgID, keyId); err != nil {
return response.Error(http.StatusInternalServerError, "Error converting API key", err)
}
return response.Success("Service accounts migrated")
}
// POST /api/serviceaccounts/revert/:keyId
func (api *ServiceAccountsAPI) RevertApiKey(ctx *contextmodel.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)
}
serviceAccountId, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "service account ID is invalid", err)
}
if err := api.service.RevertApiKey(ctx.Req.Context(), serviceAccountId, keyId); err != nil {
return response.Error(http.StatusInternalServerError, "error reverting to API key", err)
}
return response.Success("reverted service account to API key")
}
func (api *ServiceAccountsAPI) getAccessControlMetadata(c *contextmodel.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)
}
// swagger:parameters searchOrgServiceAccountsWithPaging
type SearchOrgServiceAccountsWithPagingParams struct {
// in:query
// required:false
Disabled bool `jsson:"disabled"`
// in:query
// required:false
ExpiredTokens bool `json:"expiredTokens"`
// It will return results where the query value is contained in one of the name.
// Query values with spaces need to be URL encoded.
// in:query
// required:false
Query string `json:"query"`
// The default value is 1000.
// in:query
// required:false
PerPage int `json:"perpage"`
// The default value is 1.
// in:query
// required:false
Page int `json:"page"`
}
// swagger:parameters createServiceAccount
type CreateServiceAccountParams struct {
//in:body
Body serviceaccounts.CreateServiceAccountForm
}
// swagger:parameters retrieveServiceAccount
type RetrieveServiceAccountParams struct {
// in:path
ServiceAccountId int64 `json:"serviceAccountId"`
}
// swagger:parameters updateServiceAccount
type UpdateServiceAccountParams struct {
// in:path
ServiceAccountId int64 `json:"serviceAccountId"`
// in:body
Body serviceaccounts.UpdateServiceAccountForm
}
// swagger:parameters deleteServiceAccount
type DeleteServiceAccountParams struct {
// in:path
ServiceAccountId int64 `json:"serviceAccountId"`
}
// swagger:response searchOrgServiceAccountsWithPagingResponse
type SearchOrgServiceAccountsWithPagingResponse struct {
// in:body
Body *serviceaccounts.SearchOrgServiceAccountsResult
}
// swagger:response createServiceAccountResponse
type CreateServiceAccountResponse struct {
// in:body
Body *serviceaccounts.ServiceAccountDTO
}
// swagger:response retrieveServiceAccountResponse
type RetrieveServiceAccountResponse struct {
// in:body
Body *serviceaccounts.ServiceAccountDTO
}
// swagger:response updateServiceAccountResponse
type UpdateServiceAccountResponse struct {
// in:body
Body struct {
Message string `json:"message"`
ID int64 `json:"id"`
Name string `json:"name"`
ServiceAccount *serviceaccounts.ServiceAccountProfileDTO `json:"serviceaccount"`
}
}