API Keys: Add revocation for SATs (#53896)

* add apikey is_revoked field

* add token store tests

* Apply suggestions from code review

* remove unused fields
This commit is contained in:
Jo 2022-08-18 16:54:39 +02:00 committed by GitHub
parent 8b18530cb8
commit 4a9137ac40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 235 additions and 105 deletions

View File

@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/sqlstore/db" "github.com/grafana/grafana/pkg/services/sqlstore/db"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/pkg/errors"
"xorm.io/xorm" "xorm.io/xorm"
) )
@ -111,6 +112,7 @@ func (ss *sqlStore) AddAPIKey(ctx context.Context, cmd *apikey.AddCommand) error
return apikey.ErrInvalidExpiration return apikey.ErrInvalidExpiration
} }
isRevoked := false
t := apikey.APIKey{ t := apikey.APIKey{
OrgId: cmd.OrgId, OrgId: cmd.OrgId,
Name: cmd.Name, Name: cmd.Name,
@ -119,12 +121,14 @@ func (ss *sqlStore) AddAPIKey(ctx context.Context, cmd *apikey.AddCommand) error
Created: updated, Created: updated,
Updated: updated, Updated: updated,
Expires: expires, Expires: expires,
ServiceAccountId: nil, ServiceAccountId: cmd.ServiceAccountID,
IsRevoked: &isRevoked,
} }
if _, err := sess.Insert(&t); err != nil { if _, err := sess.Insert(&t); err != nil {
return err return errors.Wrap(err, "failed to insert token")
} }
cmd.Result = &t cmd.Result = &t
return nil return nil
}) })

View File

@ -26,18 +26,21 @@ type APIKey struct {
LastUsedAt *time.Time `xorm:"last_used_at"` LastUsedAt *time.Time `xorm:"last_used_at"`
Expires *int64 Expires *int64
ServiceAccountId *int64 ServiceAccountId *int64
IsRevoked *bool `xorm:"is_revoked"`
} }
func (k APIKey) TableName() string { return "api_key" } func (k APIKey) TableName() string { return "api_key" }
// swagger:model // swagger:model
type AddCommand struct { type AddCommand struct {
Name string `json:"name" binding:"Required"` Name string `json:"name" binding:"Required"`
Role org.RoleType `json:"role" binding:"Required"` Role org.RoleType `json:"role" binding:"Required"`
OrgId int64 `json:"-"` OrgId int64 `json:"-"`
Key string `json:"-"` Key string `json:"-"`
SecondsToLive int64 `json:"secondsToLive"` SecondsToLive int64 `json:"secondsToLive"`
Result *APIKey `json:"-"` ServiceAccountID *int64 `json:"-"`
Result *APIKey `json:"-"`
} }
type DeleteCommand struct { type DeleteCommand struct {

View File

@ -281,6 +281,12 @@ func (h *ContextHandler) initContextWithAPIKey(reqContext *models.ReqContext) bo
return true return true
} }
if apikey.IsRevoked != nil && *apikey.IsRevoked {
reqContext.JsonApiErr(http.StatusUnauthorized, "Revoked token", nil)
return true
}
// update api_key last used date // update api_key last used date
if err := h.apiKeyService.UpdateAPIKeyLastUsedDate(reqContext.Req.Context(), apikey.Id); err != nil { if err := h.apiKeyService.UpdateAPIKeyLastUsedDate(reqContext.Req.Context(), apikey.Id); err != nil {
reqContext.JsonApiErr(http.StatusInternalServerError, InvalidAPIKey, errKey) reqContext.JsonApiErr(http.StatusInternalServerError, InvalidAPIKey, errKey)

View File

@ -83,7 +83,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints() {
// swagger:route POST /serviceaccounts service_accounts createServiceAccount // swagger:route POST /serviceaccounts service_accounts createServiceAccount
// //
// Create service account // # 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): // 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:*` // action: `serviceaccounts:write` scope: `serviceaccounts:*`
@ -134,7 +134,7 @@ func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) respon
// swagger:route GET /serviceaccounts/{serviceAccountId} service_accounts retrieveServiceAccount // swagger:route GET /serviceaccounts/{serviceAccountId} service_accounts retrieveServiceAccount
// //
// Get single serviceaccount by Id // # 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): // 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) // action: `serviceaccounts:read` scope: `serviceaccounts:id:1` (single service account)
@ -167,7 +167,10 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re
serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccount.Name) serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccount.Name)
serviceAccount.AccessControl = metadata[saIDString] serviceAccount.AccessControl = metadata[saIDString]
tokens, err := api.store.ListTokens(ctx.Req.Context(), serviceAccount.OrgId, serviceAccount.Id) tokens, err := api.store.ListTokens(ctx.Req.Context(), &serviceaccounts.GetSATokensQuery{
OrgID: &serviceAccount.OrgId,
ServiceAccountID: &serviceAccount.Id,
})
if err != nil { if err != nil {
api.log.Warn("Failed to list tokens for service account", "serviceAccount", serviceAccount.Id) api.log.Warn("Failed to list tokens for service account", "serviceAccount", serviceAccount.Id)
} }
@ -178,7 +181,7 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re
// swagger:route PATCH /serviceaccounts/{serviceAccountId} service_accounts updateServiceAccount // swagger:route PATCH /serviceaccounts/{serviceAccountId} service_accounts updateServiceAccount
// //
// Update service account // # 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): // 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) // action: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)
@ -247,7 +250,7 @@ func (api *ServiceAccountsAPI) validateRole(r *org.RoleType, orgRole *org.RoleTy
// swagger:route DELETE /serviceaccounts/{serviceAccountId} service_accounts deleteServiceAccount // swagger:route DELETE /serviceaccounts/{serviceAccountId} service_accounts deleteServiceAccount
// //
// Delete service account // # 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): // 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) // action: `serviceaccounts:delete` scope: `serviceaccounts:id:1` (single service account)
@ -272,7 +275,7 @@ func (api *ServiceAccountsAPI) DeleteServiceAccount(ctx *models.ReqContext) resp
// swagger:route GET /serviceaccounts/search service_accounts searchOrgServiceAccountsWithPaging // swagger:route GET /serviceaccounts/search service_accounts searchOrgServiceAccountsWithPaging
// //
// Search service accounts with paging // # 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): // 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:*` // action: `serviceaccounts:read` scope: `serviceaccounts:*`
@ -316,7 +319,9 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *models.ReqC
saIDs[saIDString] = true saIDs[saIDString] = true
metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true}) metadata := api.getAccessControlMetadata(c, map[string]bool{saIDString: true})
sa.AccessControl = metadata[strconv.FormatInt(sa.Id, 10)] sa.AccessControl = metadata[strconv.FormatInt(sa.Id, 10)]
tokens, err := api.store.ListTokens(ctx, sa.OrgId, sa.Id) tokens, err := api.store.ListTokens(ctx, &serviceaccounts.GetSATokensQuery{
OrgID: &sa.OrgId, ServiceAccountID: &sa.Id,
})
if err != nil { if err != nil {
api.log.Warn("Failed to list tokens for service account", "serviceAccount", sa.Id) api.log.Warn("Failed to list tokens for service account", "serviceAccount", sa.Id)
} }

View File

@ -37,6 +37,8 @@ type TokenDTO struct {
SecondsUntilExpiration *float64 `json:"secondsUntilExpiration"` SecondsUntilExpiration *float64 `json:"secondsUntilExpiration"`
// example: false // example: false
HasExpired bool `json:"hasExpired"` HasExpired bool `json:"hasExpired"`
// example: false
IsRevoked *bool `json:"isRevoked"`
} }
func hasExpired(expiration *int64) bool { func hasExpired(expiration *int64) bool {
@ -51,7 +53,7 @@ const sevenDaysAhead = 7 * 24 * time.Hour
// swagger:route GET /serviceaccounts/{serviceAccountId}/tokens service_accounts listTokens // swagger:route GET /serviceaccounts/{serviceAccountId}/tokens service_accounts listTokens
// //
// Get service account tokens // # Get service account tokens
// //
// Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation): // 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: `global:serviceaccounts:id:1` (single service account) // action: `serviceaccounts:read` scope: `global:serviceaccounts:id:1` (single service account)
@ -70,15 +72,21 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo
return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err) return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err)
} }
saTokens, err := api.store.ListTokens(ctx.Req.Context(), ctx.OrgID, saID) saTokens, err := api.store.ListTokens(ctx.Req.Context(), &serviceaccounts.GetSATokensQuery{
OrgID: &ctx.OrgID,
ServiceAccountID: &saID,
})
if err != nil { if err != nil {
return response.Error(http.StatusInternalServerError, "Internal server error", err) return response.Error(http.StatusInternalServerError, "Internal server error", err)
} }
result := make([]*TokenDTO, len(saTokens)) result := make([]TokenDTO, len(saTokens))
for i, t := range saTokens { for i, t := range saTokens {
var expiration *time.Time = nil var (
var secondsUntilExpiration float64 = 0 token = t // pin pointer
expiration *time.Time = nil
secondsUntilExpiration float64 = 0
)
isExpired := hasExpired(t.Expires) isExpired := hasExpired(t.Expires)
if t.Expires != nil { if t.Expires != nil {
@ -89,14 +97,15 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo
} }
} }
result[i] = &TokenDTO{ result[i] = TokenDTO{
Id: t.Id, Id: token.Id,
Name: t.Name, Name: token.Name,
Created: &t.Created, Created: &token.Created,
Expiration: expiration, Expiration: expiration,
SecondsUntilExpiration: &secondsUntilExpiration, SecondsUntilExpiration: &secondsUntilExpiration,
HasExpired: isExpired, HasExpired: isExpired,
LastUsedAt: t.LastUsedAt, LastUsedAt: token.LastUsedAt,
IsRevoked: token.IsRevoked,
} }
} }
@ -105,7 +114,7 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo
// swagger:route POST /serviceaccounts/{serviceAccountId}/tokens service_accounts createToken // swagger:route POST /serviceaccounts/{serviceAccountId}/tokens service_accounts createToken
// //
// CreateNewToken adds a token to a service account // # CreateNewToken adds a token to a 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): // 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) // action: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)
@ -179,7 +188,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon
// swagger:route DELETE /serviceaccounts/{serviceAccountId}/tokens/{tokenId} service_accounts deleteToken // swagger:route DELETE /serviceaccounts/{serviceAccountId}/tokens/{tokenId} service_accounts deleteToken
// //
// DeleteToken deletes service account tokens // # DeleteToken deletes service account tokens
// //
// Required permissions (See note in the [introduction](https://grafana.com/docs/grafana/latest/developers/http_api/serviceaccount/#service-account-api) for an explanation): // 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) // action: `serviceaccounts:write` scope: `serviceaccounts:id:1` (single service account)

View File

@ -259,10 +259,10 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) {
type saStoreMockTokens struct { type saStoreMockTokens struct {
serviceaccounts.Store serviceaccounts.Store
saAPIKeys []*apikey.APIKey saAPIKeys []apikey.APIKey
} }
func (s *saStoreMockTokens) ListTokens(ctx context.Context, orgID, saID int64) ([]*apikey.APIKey, error) { func (s *saStoreMockTokens) ListTokens(ctx context.Context, query *serviceaccounts.GetSATokensQuery) ([]apikey.APIKey, error) {
return s.saAPIKeys, nil return s.saAPIKeys, nil
} }
@ -273,7 +273,7 @@ func TestServiceAccountsAPI_ListTokens(t *testing.T) {
type testCreateSAToken struct { type testCreateSAToken struct {
desc string desc string
tokens []*apikey.APIKey tokens []apikey.APIKey
expectedHasExpired bool expectedHasExpired bool
expectedResponseBodyField string expectedResponseBodyField string
expectedCode int expectedCode int
@ -287,7 +287,7 @@ func TestServiceAccountsAPI_ListTokens(t *testing.T) {
testCases := []testCreateSAToken{ testCases := []testCreateSAToken{
{ {
desc: "should be able to list serviceaccount with no expiration date", desc: "should be able to list serviceaccount with no expiration date",
tokens: []*apikey.APIKey{{ tokens: []apikey.APIKey{{
Id: 1, Id: 1,
OrgId: 1, OrgId: 1,
ServiceAccountId: &saId, ServiceAccountId: &saId,
@ -307,7 +307,7 @@ func TestServiceAccountsAPI_ListTokens(t *testing.T) {
}, },
{ {
desc: "should be able to list serviceaccount with secondsUntilExpiration", desc: "should be able to list serviceaccount with secondsUntilExpiration",
tokens: []*apikey.APIKey{{ tokens: []apikey.APIKey{{
Id: 1, Id: 1,
OrgId: 1, OrgId: 1,
ServiceAccountId: &saId, ServiceAccountId: &saId,
@ -327,7 +327,7 @@ func TestServiceAccountsAPI_ListTokens(t *testing.T) {
}, },
{ {
desc: "should be able to list serviceaccount with expired token", desc: "should be able to list serviceaccount with expired token",
tokens: []*apikey.APIKey{{ tokens: []apikey.APIKey{{
Id: 1, Id: 1,
OrgId: 1, OrgId: 1,
ServiceAccountId: &saId, ServiceAccountId: &saId,

View File

@ -483,7 +483,10 @@ func (s *ServiceAccountsStoreImpl) RevertApiKey(ctx context.Context, saId int64,
return ErrServiceAccountAndTokenMismatch return ErrServiceAccountAndTokenMismatch
} }
tokens, err := s.ListTokens(ctx, key.OrgId, *key.ServiceAccountId) tokens, err := s.ListTokens(ctx, &serviceaccounts.GetSATokensQuery{
OrgID: &key.OrgId,
ServiceAccountID: key.ServiceAccountId,
})
if err != nil { if err != nil {
return fmt.Errorf("cannot revert token: %w", err) return fmt.Errorf("cannot revert token: %w", err)
} }

View File

@ -185,7 +185,10 @@ func TestStore_MigrateApiKeys(t *testing.T) {
saMigrated := serviceAccounts.ServiceAccounts[0] saMigrated := serviceAccounts.ServiceAccounts[0]
require.Equal(t, string(key.Role), saMigrated.Role) require.Equal(t, string(key.Role), saMigrated.Role)
tokens, err := store.ListTokens(context.Background(), key.OrgId, saMigrated.Id) tokens, err := store.ListTokens(context.Background(), &serviceaccounts.GetSATokensQuery{
OrgID: &key.OrgId,
ServiceAccountID: &saMigrated.Id,
})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, tokens, 1) require.Len(t, tokens, 1)
} }
@ -264,7 +267,10 @@ func TestStore_MigrateAllApiKeys(t *testing.T) {
saMigrated := serviceAccounts.ServiceAccounts[0] saMigrated := serviceAccounts.ServiceAccounts[0]
require.Equal(t, string(c.keys[0].Role), saMigrated.Role) require.Equal(t, string(c.keys[0].Role), saMigrated.Role)
tokens, err := store.ListTokens(context.Background(), c.orgId, saMigrated.Id) tokens, err := store.ListTokens(context.Background(), &serviceaccounts.GetSATokensQuery{
OrgID: &c.orgId,
ServiceAccountID: &saMigrated.Id,
})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, tokens, 1) require.Len(t, tokens, 1)
} }

View File

@ -3,15 +3,13 @@ package database
import ( import (
"context" "context"
"sync" "sync"
"time"
"github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
) )
const ( const (
ExporterName = "grafana" ExporterName = "grafana"
metricsCollectionInterval = time.Minute * 30
) )
var ( var (
@ -46,25 +44,6 @@ func InitMetrics() {
}) })
} }
func (s *ServiceAccountsStoreImpl) RunMetricsCollection(ctx context.Context) error {
if _, err := s.GetUsageMetrics(ctx); err != nil {
s.log.Warn("Failed to get usage metrics", "error", err.Error())
}
updateStatsTicker := time.NewTicker(metricsCollectionInterval)
defer updateStatsTicker.Stop()
for {
select {
case <-updateStatsTicker.C:
if _, err := s.GetUsageMetrics(ctx); err != nil {
s.log.Warn("Failed to get usage metrics", "error", err.Error())
}
case <-ctx.Done():
return ctx.Err()
}
}
}
func (s *ServiceAccountsStoreImpl) GetUsageMetrics(ctx context.Context) (map[string]interface{}, error) { func (s *ServiceAccountsStoreImpl) GetUsageMetrics(ctx context.Context) (map[string]interface{}, error) {
stats := map[string]interface{}{} stats := map[string]interface{}{}

View File

@ -2,27 +2,37 @@ package database
import ( import (
"context" "context"
"time"
"github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore"
"xorm.io/xorm" "github.com/pkg/errors"
) )
func (s *ServiceAccountsStoreImpl) ListTokens(ctx context.Context, orgId int64, serviceAccountId int64) ([]*apikey.APIKey, error) { const maxRetrievedTokens = 300
result := make([]*apikey.APIKey, 0)
err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
var sess *xorm.Session
func (s *ServiceAccountsStoreImpl) ListTokens(
ctx context.Context, query *serviceaccounts.GetSATokensQuery,
) ([]apikey.APIKey, error) {
result := make([]apikey.APIKey, 0)
err := s.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
quotedUser := s.sqlStore.Dialect.Quote("user") quotedUser := s.sqlStore.Dialect.Quote("user")
sess = dbSession. sess := dbSession.Limit(maxRetrievedTokens, 0).Where("api_key.service_account_id IS NOT NULL")
Join("inner", quotedUser, quotedUser+".id = api_key.service_account_id").
Where(quotedUser+".org_id=? AND "+quotedUser+".id=?", orgId, serviceAccountId). if query.OrgID != nil {
sess = sess.Where(quotedUser+".org_id=?", *query.OrgID)
sess = sess.Where("api_key.org_id=?", *query.OrgID)
}
if query.ServiceAccountID != nil {
sess = sess.Where("api_key.service_account_id=?", *query.ServiceAccountID)
}
sess = sess.Join("inner", quotedUser, quotedUser+".id = api_key.service_account_id").
Asc("api_key.name") Asc("api_key.name")
return sess.Find(&result) return errors.Wrapf(sess.Find(&result), "list token error")
}) })
return result, err return result, err
} }
@ -33,37 +43,27 @@ func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, s
return err return err
} }
key := apikey.APIKey{OrgId: cmd.OrgId, Name: cmd.Name} addKeyCmd := &apikey.AddCommand{
exists, _ := sess.Get(&key)
if exists {
return ErrDuplicateToken
}
updated := time.Now()
var expires *int64 = nil
if cmd.SecondsToLive > 0 {
v := updated.Add(time.Second * time.Duration(cmd.SecondsToLive)).Unix()
expires = &v
} else if cmd.SecondsToLive < 0 {
return ErrInvalidTokenExpiration
}
token := apikey.APIKey{
OrgId: cmd.OrgId,
Name: cmd.Name, Name: cmd.Name,
Role: org.RoleViewer, Role: org.RoleViewer,
OrgId: cmd.OrgId,
Key: cmd.Key, Key: cmd.Key,
Created: updated, SecondsToLive: cmd.SecondsToLive,
Updated: updated, ServiceAccountID: &serviceAccountId,
Expires: expires,
LastUsedAt: nil,
ServiceAccountId: &serviceAccountId,
} }
if _, err := sess.Insert(&token); err != nil { if err := s.apiKeyService.AddAPIKey(ctx, addKeyCmd); err != nil {
switch {
case errors.Is(err, apikey.ErrDuplicate):
return ErrDuplicateToken
case errors.Is(err, apikey.ErrInvalidExpiration):
return ErrInvalidTokenExpiration
}
return err return err
} }
cmd.Result = &token
cmd.Result = addKeyCmd.Result
return nil return nil
}) })
} }
@ -85,6 +85,23 @@ func (s *ServiceAccountsStoreImpl) DeleteServiceAccountToken(ctx context.Context
}) })
} }
func (s *ServiceAccountsStoreImpl) RevokeServiceAccountToken(ctx context.Context, orgId, serviceAccountId, tokenId int64) error {
rawSQL := "UPDATE api_key SET is_revoked = ? WHERE id=? and org_id=? and service_account_id=?"
return s.sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
result, err := sess.Exec(rawSQL, s.sqlStore.Dialect.BooleanStr(true), tokenId, orgId, serviceAccountId)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if affected == 0 {
return ErrServiceAccountTokenNotFound
}
return err
})
}
// assignApiKeyToServiceAccount sets the API key service account ID // assignApiKeyToServiceAccount sets the API key service account ID
func (s *ServiceAccountsStoreImpl) assignApiKeyToServiceAccount(sess *sqlstore.DBSession, apiKeyId int64, serviceAccountId int64) error { func (s *ServiceAccountsStoreImpl) assignApiKeyToServiceAccount(sess *sqlstore.DBSession, apiKeyId int64, serviceAccountId int64) error {
key := apikey.APIKey{Id: apiKeyId} key := apikey.APIKey{Id: apiKeyId}

View File

@ -48,7 +48,10 @@ func TestStore_AddServiceAccountToken(t *testing.T) {
require.Equal(t, t.Name(), newKey.Name) require.Equal(t, t.Name(), newKey.Name)
// Verify against DB // Verify against DB
keys, errT := store.ListTokens(context.Background(), user.OrgID, user.ID) keys, errT := store.ListTokens(context.Background(), &serviceaccounts.GetSATokensQuery{
OrgID: &user.OrgID,
ServiceAccountID: &user.ID,
})
require.NoError(t, errT) require.NoError(t, errT)
@ -57,6 +60,8 @@ func TestStore_AddServiceAccountToken(t *testing.T) {
if k.Name == keyName { if k.Name == keyName {
found = true found = true
require.Equal(t, key.HashedKey, newKey.Key) require.Equal(t, key.HashedKey, newKey.Key)
require.False(t, *k.IsRevoked)
if tc.secondsToLive == 0 { if tc.secondsToLive == 0 {
require.Nil(t, k.Expires) require.Nil(t, k.Expires)
} else { } else {
@ -91,6 +96,48 @@ func TestStore_AddServiceAccountToken_WrongServiceAccount(t *testing.T) {
require.Error(t, err, "It should not be possible to add token to non-existing service account") require.Error(t, err, "It should not be possible to add token to non-existing service account")
} }
func TestStore_RevokeServiceAccountToken(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: &apikey.APIKey{},
}
err = store.AddServiceAccountToken(context.Background(), sa.ID, &cmd)
require.NoError(t, err)
newKey := cmd.Result
// Revoke SAT
err = store.RevokeServiceAccountToken(context.Background(), sa.OrgID, sa.ID, newKey.Id)
require.NoError(t, err)
// Verify against DB
keys, errT := store.ListTokens(context.Background(), &serviceaccounts.GetSATokensQuery{
OrgID: &sa.OrgID,
ServiceAccountID: &sa.ID,
})
require.NoError(t, errT)
for _, k := range keys {
if k.Name == keyName {
require.True(t, *k.IsRevoked)
return
}
}
require.Fail(t, "Key not found")
}
func TestStore_DeleteServiceAccountToken(t *testing.T) { func TestStore_DeleteServiceAccountToken(t *testing.T) {
userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true} userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true}
db, store := setupTestDatabase(t) db, store := setupTestDatabase(t)
@ -124,7 +171,10 @@ func TestStore_DeleteServiceAccountToken(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Verify against DB // Verify against DB
keys, errT := store.ListTokens(context.Background(), sa.OrgID, sa.ID) keys, errT := store.ListTokens(context.Background(), &serviceaccounts.GetSATokensQuery{
OrgID: &sa.OrgID,
ServiceAccountID: &sa.ID,
})
require.NoError(t, errT) require.NoError(t, errT)
for _, k := range keys { for _, k := range keys {

View File

@ -2,6 +2,8 @@ package manager
import ( import (
"context" "context"
"fmt"
"time"
"github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
@ -13,9 +15,14 @@ import (
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
const (
metricsCollectionInterval = time.Minute * 30
)
type ServiceAccountsService struct { type ServiceAccountsService struct {
store serviceaccounts.Store store serviceaccounts.Store
log log.Logger log log.Logger
backgroundLog log.Logger
} }
func ProvideServiceAccountsService( func ProvideServiceAccountsService(
@ -28,8 +35,9 @@ func ProvideServiceAccountsService(
) (*ServiceAccountsService, error) { ) (*ServiceAccountsService, error) {
database.InitMetrics() database.InitMetrics()
s := &ServiceAccountsService{ s := &ServiceAccountsService{
store: serviceAccountsStore, store: serviceAccountsStore,
log: log.New("serviceaccounts"), log: log.New("serviceaccounts"),
backgroundLog: log.New("serviceaccounts.background"),
} }
if err := RegisterRoles(ac); err != nil { if err := RegisterRoles(ac); err != nil {
@ -45,8 +53,33 @@ func ProvideServiceAccountsService(
} }
func (sa *ServiceAccountsService) Run(ctx context.Context) error { func (sa *ServiceAccountsService) Run(ctx context.Context) error {
sa.log.Debug("Started Service Account Metrics collection service") sa.backgroundLog.Debug("service initialized")
return sa.store.RunMetricsCollection(ctx)
if _, err := sa.store.GetUsageMetrics(ctx); err != nil {
sa.log.Warn("Failed to get usage metrics", "error", err.Error())
}
updateStatsTicker := time.NewTicker(metricsCollectionInterval)
defer updateStatsTicker.Stop()
for {
select {
case <-ctx.Done():
if err := ctx.Err(); err != nil {
return fmt.Errorf("context error in service account background service: %w", ctx.Err())
}
sa.backgroundLog.Debug("stopped service account background service")
return nil
case <-updateStatsTicker.C:
sa.backgroundLog.Debug("updating usage metrics")
if _, err := sa.store.GetUsageMetrics(ctx); err != nil {
sa.backgroundLog.Warn("Failed to get usage metrics", "error", err.Error())
}
}
}
} }
func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, orgID int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, orgID int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {

View File

@ -64,6 +64,11 @@ type ServiceAccountDTO struct {
AccessControl map[string]bool `json:"accessControl,omitempty"` AccessControl map[string]bool `json:"accessControl,omitempty"`
} }
type GetSATokensQuery struct {
OrgID *int64 // optional filtering by org ID
ServiceAccountID *int64 // optional filtering by service account ID
}
type AddServiceAccountTokenCommand struct { type AddServiceAccountTokenCommand struct {
Name string `json:"name" binding:"Required"` Name string `json:"name" binding:"Required"`
OrgId int64 `json:"-"` OrgId int64 `json:"-"`

View File

@ -28,9 +28,9 @@ type Store interface {
MigrateApiKeysToServiceAccounts(ctx context.Context, orgID int64) error MigrateApiKeysToServiceAccounts(ctx context.Context, orgID int64) error
MigrateApiKey(ctx context.Context, orgID int64, keyId int64) error MigrateApiKey(ctx context.Context, orgID int64, keyId int64) error
RevertApiKey(ctx context.Context, saId int64, keyId int64) error RevertApiKey(ctx context.Context, saId int64, keyId int64) error
ListTokens(ctx context.Context, orgID int64, serviceAccount int64) ([]*apikey.APIKey, error) ListTokens(ctx context.Context, query *GetSATokensQuery) ([]apikey.APIKey, error)
DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error
RevokeServiceAccountToken(ctx context.Context, orgId, serviceAccountId, tokenId int64) error
AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *AddServiceAccountTokenCommand) error AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *AddServiceAccountTokenCommand) error
GetUsageMetrics(ctx context.Context) (map[string]interface{}, error) GetUsageMetrics(ctx context.Context) (map[string]interface{}, error)
RunMetricsCollection(ctx context.Context) error
} }

View File

@ -183,8 +183,8 @@ func (s *ServiceAccountsStoreMock) RevertApiKey(ctx context.Context, saId int64,
return nil return nil
} }
func (s *ServiceAccountsStoreMock) ListTokens(ctx context.Context, orgID int64, serviceAccount int64) ([]*apikey.APIKey, error) { func (s *ServiceAccountsStoreMock) ListTokens(ctx context.Context, query *serviceaccounts.GetSATokensQuery) ([]apikey.APIKey, error) {
s.Calls.ListTokens = append(s.Calls.ListTokens, []interface{}{ctx, orgID, serviceAccount}) s.Calls.ListTokens = append(s.Calls.ListTokens, []interface{}{ctx, query.OrgID, query.ServiceAccountID})
return nil, nil return nil, nil
} }

View File

@ -95,4 +95,9 @@ func addApiKeyMigrations(mg *Migrator) {
mg.AddMigration("Add last_used_at to api_key table", NewAddColumnMigration(apiKeyV2, &Column{ mg.AddMigration("Add last_used_at to api_key table", NewAddColumnMigration(apiKeyV2, &Column{
Name: "last_used_at", Type: DB_DateTime, Nullable: true, Name: "last_used_at", Type: DB_DateTime, Nullable: true,
})) }))
// is_revoked indicates whether key is revoked or not. Revoked keys should be kept in the table, but invalid.
mg.AddMigration("Add is_revoked column to api_key table", NewAddColumnMigration(apiKeyV2, &Column{
Name: "is_revoked", Type: DB_Bool, Nullable: true, Default: "0",
}))
} }

View File

@ -25,18 +25,22 @@ export const ServiceAccountTokensTable = ({ tokens, timeZone, tokenActionsDisabl
<th>Created</th> <th>Created</th>
<th>Last used at</th> <th>Last used at</th>
<th /> <th />
<th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tokens.map((key) => { {tokens.map((key) => {
return ( return (
<tr key={key.id} className={styles.tableRow(key.hasExpired)}> <tr key={key.id} className={styles.tableRow(key.hasExpired || key.isRevoked)}>
<td>{key.name}</td> <td>{key.name}</td>
<td> <td>
<TokenExpiration timeZone={timeZone} token={key} /> <TokenExpiration timeZone={timeZone} token={key} />
</td> </td>
<td>{formatDate(timeZone, key.created)}</td> <td>{formatDate(timeZone, key.created)}</td>
<td>{formatLastUsedAtDate(timeZone, key.lastUsedAt)}</td> <td>{formatLastUsedAtDate(timeZone, key.lastUsedAt)}</td>
<td className="width-1 text-center">
{key.isRevoked && <span className="label label-tag label-tag--gray">Revoked</span>}
</td>
<td> <td>
<DeleteButton <DeleteButton
aria-label={`Delete service account token ${key.name}`} aria-label={`Delete service account token ${key.name}`}

View File

@ -10,6 +10,7 @@ export interface ApiKey extends WithAccessControlMetadata {
expiration?: string; expiration?: string;
secondsUntilExpiration?: number; secondsUntilExpiration?: number;
hasExpired?: boolean; hasExpired?: boolean;
isRevoked?: boolean;
created?: string; created?: string;
lastUsedAt?: string; lastUsedAt?: string;
} }