mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
8b18530cb8
commit
4a9137ac40
@ -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
|
||||||
})
|
})
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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{}{}
|
||||||
|
|
||||||
|
@ -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}
|
||||||
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
@ -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:"-"`
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
@ -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}`}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user