Separate API key store from SA token store (#45862)

* ServiceAccounts: Fix token-apikey cross deletion

* ServiceAccounts: separate API key store and service account token store

* ServiceAccounts: hide service account tokens from API Keys page

* ServiceAccounts: uppercase statement

* ServiceAccounts: fix and add new tests for SAT store

* ServiceAccounts: remove service account ID from add API key

* ServiceAccounts: clear up errors
This commit is contained in:
J Guerreiro 2022-02-28 10:30:45 +00:00 committed by GitHub
parent 15d681b823
commit 5cb03d6e62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 272 additions and 81 deletions

View File

@ -80,7 +80,6 @@ func (hs *HTTPServer) AddAPIKey(c *models.ReqContext) response.Response {
}
}
cmd.ServiceAccountId = nil // Security: API keys can't be added to SAs through this endpoint since we do not implement access checks here
cmd.OrgId = c.OrgId
newKeyInfo, err := apikeygen.New(cmd.OrgId, cmd.Name)

View File

@ -27,13 +27,12 @@ type ApiKey struct {
// ---------------------
// COMMANDS
type AddApiKeyCommand struct {
Name string `json:"name" binding:"Required"`
Role RoleType `json:"role" binding:"Required"`
OrgId int64 `json:"-"`
Key string `json:"-"`
SecondsToLive int64 `json:"secondsToLive"`
ServiceAccountId *int64 `json:"-"`
Result *ApiKey `json:"-"`
Name string `json:"name" binding:"Required"`
Role RoleType `json:"role" binding:"Required"`
OrgId int64 `json:"-"`
Key string `json:"-"`
SecondsToLive int64 `json:"secondsToLive"`
Result *ApiKey `json:"-"`
}
type DeleteApiKeyCommand struct {

View File

@ -1,7 +1,6 @@
package api
import (
"context"
"errors"
"net/http"
"strconv"
@ -20,19 +19,12 @@ import (
"github.com/grafana/grafana/pkg/web"
)
type APIKeyStore interface {
AddAPIKey(ctx context.Context, cmd *models.AddApiKeyCommand) error
GetApiKeyById(ctx context.Context, query *models.GetApiKeyByIdQuery) error
DeleteApiKey(ctx context.Context, cmd *models.DeleteApiKeyCommand) error
}
type ServiceAccountsAPI struct {
cfg *setting.Cfg
service serviceaccounts.Service
accesscontrol accesscontrol.AccessControl
RouterRegister routing.RouteRegister
store serviceaccounts.Store
apiKeyStore APIKeyStore
log log.Logger
}
@ -47,7 +39,6 @@ func NewServiceAccountsAPI(
accesscontrol accesscontrol.AccessControl,
routerRegister routing.RouteRegister,
store serviceaccounts.Store,
apiKeyStore APIKeyStore,
) *ServiceAccountsAPI {
return &ServiceAccountsAPI{
cfg: cfg,
@ -55,7 +46,6 @@ func NewServiceAccountsAPI(
accesscontrol: accesscontrol,
RouterRegister: routerRegister,
store: store,
apiKeyStore: apiKeyStore,
log: log.New("serviceaccounts.api"),
}
}

View File

@ -102,7 +102,7 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock,
routerRegister routing.RouteRegister,
acmock *accesscontrolmock.Mock,
sqlStore *sqlstore.SQLStore, saStore serviceaccounts.Store) *web.Mux {
a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, saStore, sqlStore)
a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, saStore)
a.RegisterAPIEndpoints(featuremgmt.WithFeatures(featuremgmt.FlagServiceAccounts))
a.cfg.ApiKeyMaxSecondsToLive = -1 // disable api key expiration

View File

@ -97,7 +97,6 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon
}
// Force affected service account to be the one referenced in the URL
cmd.ServiceAccountId = &saID
cmd.OrgId = c.OrgId
if !cmd.Role.IsValid() {
@ -120,7 +119,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon
cmd.Key = newKeyInfo.HashedKey
if err := api.apiKeyStore.AddAPIKey(c.Req.Context(), &cmd); err != nil {
if err := api.store.AddServiceAccountToken(c.Req.Context(), saID, &cmd); err != nil {
if errors.Is(err, models.ErrInvalidApiKeyExpiration) {
return response.Error(http.StatusBadRequest, err.Error(), nil)
}
@ -143,7 +142,7 @@ func (api *ServiceAccountsAPI) CreateToken(c *models.ReqContext) response.Respon
func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Response {
saID, err := strconv.ParseInt(web.Params(c.Req)[":serviceAccountId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err)
return response.Error(http.StatusBadRequest, "Service Account ID is invalid", err)
}
// confirm service account exists
@ -158,29 +157,10 @@ func (api *ServiceAccountsAPI) DeleteToken(c *models.ReqContext) response.Respon
tokenID, err := strconv.ParseInt(web.Params(c.Req)[":tokenId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err)
return response.Error(http.StatusBadRequest, "Token ID is invalid", err)
}
// confirm API key belongs to service account. TODO: refactor get & delete to single call
cmdGet := &models.GetApiKeyByIdQuery{ApiKeyId: tokenID}
if err = api.apiKeyStore.GetApiKeyById(c.Req.Context(), cmdGet); err != nil {
status := http.StatusNotFound
if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) {
status = http.StatusInternalServerError
} else {
err = models.ErrApiKeyNotFound
}
return response.Error(status, failedToDeleteMsg, err)
}
// verify service account ID matches the URL
if *cmdGet.Result.ServiceAccountId != saID {
return response.Error(http.StatusNotFound, failedToDeleteMsg, err)
}
cmdDel := &models.DeleteApiKeyCommand{Id: tokenID, OrgId: c.OrgId}
if err = api.apiKeyStore.DeleteApiKey(c.Req.Context(), cmdDel); err != nil {
if err = api.store.DeleteServiceAccountToken(c.Req.Context(), c.OrgId, saID, tokenID); err != nil {
status := http.StatusNotFound
if err != nil && !errors.Is(err, models.ErrApiKeyNotFound) {
status = http.StatusInternalServerError

View File

@ -12,7 +12,6 @@ import (
"time"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@ -31,19 +30,20 @@ const (
serviceaccountIDTokensDetailPath = "/api/serviceaccounts/%v/tokens/%v" // #nosec G101
)
func createTokenforSA(t *testing.T, keyName string, orgID int64, saID int64, secondsToLive int64) *models.ApiKey {
func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string, orgID int64, saID int64, secondsToLive int64) *models.ApiKey {
key, err := apikeygen.New(orgID, keyName)
require.NoError(t, err)
cmd := models.AddApiKeyCommand{
Name: keyName,
Role: "Viewer",
OrgId: orgID,
Key: key.HashedKey,
SecondsToLive: secondsToLive,
ServiceAccountId: &saID,
Result: &models.ApiKey{},
Name: keyName,
Role: "Viewer",
OrgId: orgID,
Key: key.HashedKey,
SecondsToLive: secondsToLive,
Result: &models.ApiKey{},
}
err = bus.Dispatch(context.Background(), &cmd)
err = store.AddServiceAccountToken(context.Background(), saID, &cmd)
require.NoError(t, err)
return cmd.Result
}
@ -156,7 +156,8 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) {
func TestServiceAccountsAPI_DeleteToken(t *testing.T) {
store := sqlstore.InitTestDB(t)
svcmock := tests.ServiceAccountMock{}
svcMock := &tests.ServiceAccountMock{}
saStore := database.NewServiceAccountsStore(store)
sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true})
type testCreateSAToken struct {
@ -216,11 +217,11 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
token := createTokenforSA(t, tc.keyName, sa.OrgId, sa.Id, 1)
token := createTokenforSA(t, saStore, tc.keyName, sa.OrgId, sa.Id, 1)
endpoint := fmt.Sprintf(serviceaccountIDTokensDetailPath, sa.Id, token.Id)
bodyString := ""
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.MethodDelete, endpoint, strings.NewReader(bodyString))
actualCode := actual.Code

View File

@ -0,0 +1,41 @@
package database
import (
"fmt"
"github.com/grafana/grafana/pkg/models"
)
type ErrMisingSAToken struct {
}
func (e *ErrMisingSAToken) Error() string {
return "service account token not found"
}
func (e *ErrMisingSAToken) Unwrap() error {
return models.ErrApiKeyNotFound
}
type ErrInvalidExpirationSAToken struct {
}
func (e *ErrInvalidExpirationSAToken) Error() string {
return "service account token not found"
}
func (e *ErrInvalidExpirationSAToken) Unwrap() error {
return models.ErrInvalidApiKeyExpiration
}
type ErrDuplicateSAToken struct {
name string
}
func (e *ErrDuplicateSAToken) Error() string {
return fmt.Sprintf("service account token %s already exists", e.name)
}
func (e *ErrDuplicateSAToken) Unwrap() error {
return models.ErrDuplicateApiKey
}

View File

@ -0,0 +1,63 @@
package database
import (
"context"
"time"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, saID int64, cmd *models.AddApiKeyCommand) error {
return s.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
key := models.ApiKey{OrgId: cmd.OrgId, Name: cmd.Name}
exists, _ := sess.Get(&key)
if exists {
return &ErrDuplicateSAToken{cmd.Name}
}
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 &ErrInvalidExpirationSAToken{}
}
t := models.ApiKey{
OrgId: cmd.OrgId,
Name: cmd.Name,
Role: cmd.Role,
Key: cmd.Key,
Created: updated,
Updated: updated,
Expires: expires,
ServiceAccountId: &saID,
}
if _, err := sess.Insert(&t); err != nil {
return err
}
cmd.Result = &t
return nil
})
}
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)
if err != nil {
return err
}
n, err := result.RowsAffected()
if err != nil {
return err
} else if n == 0 {
return &ErrMisingSAToken{}
}
return nil
})
}

View File

@ -0,0 +1,115 @@
package database
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/components/apikeygen"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
"github.com/stretchr/testify/require"
)
func TestStore_AddServiceAccountToken(t *testing.T) {
userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true}
db, store := setupTestDatabase(t)
user := tests.SetupUserServiceAccount(t, db, userToCreate)
type testCasesAdd struct {
secondsToLive int64
desc string
}
testCases := []testCasesAdd{{-10, "invalid"}, {0, "no expiry"}, {10, "valid"}}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
keyName := t.Name()
key, err := apikeygen.New(user.OrgId, keyName)
require.NoError(t, err)
cmd := models.AddApiKeyCommand{
Name: keyName,
Role: "Viewer",
OrgId: user.OrgId,
Key: key.HashedKey,
SecondsToLive: tc.secondsToLive,
Result: &models.ApiKey{},
}
err = store.AddServiceAccountToken(context.Background(), user.Id, &cmd)
if tc.secondsToLive < 0 {
require.Error(t, err)
return
}
require.NoError(t, err)
newKey := cmd.Result
require.Equal(t, t.Name(), newKey.Name)
// Verify against DB
keys, errT := store.ListTokens(context.Background(), user.OrgId, user.Id)
require.NoError(t, errT)
found := false
for _, k := range keys {
if k.Name == keyName {
found = true
require.Equal(t, key.HashedKey, newKey.Key)
if tc.secondsToLive == 0 {
require.Nil(t, k.Expires)
} else {
require.NotNil(t, k.Expires)
}
}
}
require.True(t, found, "Key not found")
})
}
}
func TestStore_DeleteServiceAccountToken(t *testing.T) {
userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true}
db, store := setupTestDatabase(t)
user := tests.SetupUserServiceAccount(t, db, userToCreate)
keyName := t.Name()
key, err := apikeygen.New(user.OrgId, keyName)
require.NoError(t, err)
cmd := models.AddApiKeyCommand{
Name: keyName,
Role: "Viewer",
OrgId: user.OrgId,
Key: key.HashedKey,
SecondsToLive: 0,
Result: &models.ApiKey{},
}
err = store.AddServiceAccountToken(context.Background(), user.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)
require.Error(t, err)
// Delete key from wrong org
err = store.DeleteServiceAccountToken(context.Background(), user.OrgId+2, user.Id, newKey.Id)
require.Error(t, err)
err = store.DeleteServiceAccountToken(context.Background(), user.OrgId, user.Id, newKey.Id)
require.NoError(t, err)
// Verify against DB
keys, errT := store.ListTokens(context.Background(), user.OrgId, user.Id)
require.NoError(t, errT)
for _, k := range keys {
if k.Name == keyName {
require.Fail(t, "Key not deleted")
}
}
}

View File

@ -43,7 +43,7 @@ func ProvideServiceAccountsService(
}
}
serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, routeRegister, s.store, store)
serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, routeRegister, s.store)
serviceaccountsAPI.RegisterAPIEndpoints(features)
return s, nil

View File

@ -21,4 +21,6 @@ type Store interface {
UpgradeServiceAccounts(ctx context.Context) error
ConvertToServiceAccounts(ctx context.Context, keys []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 *models.AddApiKeyCommand) error
}

View File

@ -67,14 +67,16 @@ func SetupMockAccesscontrol(t *testing.T,
var _ serviceaccounts.Store = new(ServiceAccountsStoreMock)
type Calls struct {
CreateServiceAccount []interface{}
ListServiceAccounts []interface{}
RetrieveServiceAccount []interface{}
DeleteServiceAccount []interface{}
UpgradeServiceAccounts []interface{}
ConvertServiceAccounts []interface{}
ListTokens []interface{}
UpdateServiceAccount []interface{}
CreateServiceAccount []interface{}
ListServiceAccounts []interface{}
RetrieveServiceAccount []interface{}
DeleteServiceAccount []interface{}
UpgradeServiceAccounts []interface{}
ConvertServiceAccounts []interface{}
ListTokens []interface{}
DeleteServiceAccountToken []interface{}
UpdateServiceAccount []interface{}
AddServiceAccountToken []interface{}
}
type ServiceAccountsStoreMock struct {
@ -124,3 +126,13 @@ func (s *ServiceAccountsStoreMock) UpdateServiceAccount(ctx context.Context,
return nil, nil
}
func (s *ServiceAccountsStoreMock) DeleteServiceAccountToken(ctx context.Context, orgID, serviceAccountID, tokenID int64) error {
s.Calls.DeleteServiceAccountToken = append(s.Calls.DeleteServiceAccountToken, []interface{}{ctx, orgID, serviceAccountID, tokenID})
return nil
}
func (s *ServiceAccountsStoreMock) AddServiceAccountToken(ctx context.Context, serviceAccountID int64, cmd *models.AddApiKeyCommand) error {
s.Calls.AddServiceAccountToken = append(s.Calls.AddServiceAccountToken, []interface{}{ctx, cmd})
return nil
}

View File

@ -34,6 +34,8 @@ func (ss *SQLStore) GetAPIKeys(ctx context.Context, query *models.GetApiKeysQuer
Asc("name")
}
sess = sess.Where("service_account_id IS NULL")
query.Result = make([]*models.ApiKey, 0)
return sess.Find(&query.Result)
})
@ -61,7 +63,7 @@ func (ss *SQLStore) DeleteApiKey(ctx context.Context, cmd *models.DeleteApiKeyCo
}
func deleteAPIKey(sess *DBSession, id, orgID int64) error {
rawSQL := "DELETE FROM api_key WHERE id=? and org_id=?"
rawSQL := "DELETE FROM api_key WHERE id=? and org_id=? and service_account_id IS NULL"
result, err := sess.Exec(rawSQL, id, orgID)
if err != nil {
return err
@ -101,7 +103,7 @@ func (ss *SQLStore) AddAPIKey(ctx context.Context, cmd *models.AddApiKeyCommand)
Created: updated,
Updated: updated,
Expires: expires,
ServiceAccountId: cmd.ServiceAccountId,
ServiceAccountId: nil,
}
if _, err := sess.Insert(&t); err != nil {

View File

@ -34,20 +34,7 @@ func TestApiKeyDataAccess(t *testing.T) {
})
t.Run("Add non expiring key", func(t *testing.T) {
cmd := models.AddApiKeyCommand{OrgId: 1, Name: "non-expiring", Key: "asd1", SecondsToLive: 0, ServiceAccountId: nil}
err := ss.AddAPIKey(context.Background(), &cmd)
assert.Nil(t, err)
query := models.GetApiKeyByNameQuery{KeyName: "non-expiring", OrgId: 1}
err = ss.GetApiKeyByName(context.Background(), &query)
assert.Nil(t, err)
assert.Nil(t, query.Result.Expires)
})
t.Run("Add key for service account", func(t *testing.T) {
var one int64 = 1
cmd := models.AddApiKeyCommand{OrgId: 1, Name: "non-expiring-SA", Key: "sa1-key", ServiceAccountId: &one}
cmd := models.AddApiKeyCommand{OrgId: 1, Name: "non-expiring", Key: "asd1", SecondsToLive: 0}
err := ss.AddAPIKey(context.Background(), &cmd)
assert.Nil(t, err)