mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Access control: Pass access control metadata for api keys (#48445)
* Move ApiKeyDTO to dtos package * Add access control filter to api keys * pass user in GetApiKeysQuery * Add api key metadata to DTO * Remove scope all requirement from get api keys endpoint * Handle api key access control metadata in frondend
This commit is contained in:
parent
e9a2a06651
commit
6c6137f45a
@ -278,7 +278,7 @@ func (hs *HTTPServer) registerRoutes() {
|
|||||||
// auth api keys
|
// auth api keys
|
||||||
apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) {
|
apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) {
|
||||||
apikeyIDScope := ac.Scope("apikeys", "id", ac.Parameter(":id"))
|
apikeyIDScope := ac.Scope("apikeys", "id", ac.Parameter(":id"))
|
||||||
keysRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyRead, ac.ScopeAPIKeysAll)), routing.Wrap(hs.GetAPIKeys))
|
keysRoute.Get("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyRead)), routing.Wrap(hs.GetAPIKeys))
|
||||||
keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota("api_key"), routing.Wrap(hs.AddAPIKey))
|
keysRoute.Post("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyCreate)), quota("api_key"), routing.Wrap(hs.AddAPIKey))
|
||||||
keysRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyDelete, apikeyIDScope)), routing.Wrap(hs.DeleteAPIKey))
|
keysRoute.Delete("/:id", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionAPIKeyDelete, apikeyIDScope)), routing.Wrap(hs.DeleteAPIKey))
|
||||||
})
|
})
|
||||||
|
@ -15,20 +15,22 @@ import (
|
|||||||
|
|
||||||
// GetAPIKeys returns a list of API keys
|
// GetAPIKeys returns a list of API keys
|
||||||
func (hs *HTTPServer) GetAPIKeys(c *models.ReqContext) response.Response {
|
func (hs *HTTPServer) GetAPIKeys(c *models.ReqContext) response.Response {
|
||||||
query := models.GetApiKeysQuery{OrgId: c.OrgId, IncludeExpired: c.QueryBool("includeExpired")}
|
query := models.GetApiKeysQuery{OrgId: c.OrgId, User: c.SignedInUser, IncludeExpired: c.QueryBool("includeExpired")}
|
||||||
|
|
||||||
if err := hs.SQLStore.GetAPIKeys(c.Req.Context(), &query); err != nil {
|
if err := hs.SQLStore.GetAPIKeys(c.Req.Context(), &query); err != nil {
|
||||||
return response.Error(500, "Failed to list api keys", err)
|
return response.Error(500, "Failed to list api keys", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]*models.ApiKeyDTO, len(query.Result))
|
ids := map[string]bool{}
|
||||||
|
result := make([]*dtos.ApiKeyDTO, len(query.Result))
|
||||||
for i, t := range query.Result {
|
for i, t := range query.Result {
|
||||||
|
ids[strconv.FormatInt(t.Id, 10)] = true
|
||||||
var expiration *time.Time = nil
|
var expiration *time.Time = nil
|
||||||
if t.Expires != nil {
|
if t.Expires != nil {
|
||||||
v := time.Unix(*t.Expires, 0)
|
v := time.Unix(*t.Expires, 0)
|
||||||
expiration = &v
|
expiration = &v
|
||||||
}
|
}
|
||||||
result[i] = &models.ApiKeyDTO{
|
result[i] = &dtos.ApiKeyDTO{
|
||||||
Id: t.Id,
|
Id: t.Id,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Role: t.Role,
|
Role: t.Role,
|
||||||
@ -36,6 +38,13 @@ func (hs *HTTPServer) GetAPIKeys(c *models.ReqContext) response.Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metadata := hs.getMultiAccessControlMetadata(c, c.OrgId, "apikeys:id", ids)
|
||||||
|
if len(metadata) > 0 {
|
||||||
|
for _, key := range result {
|
||||||
|
key.AccessControl = metadata[strconv.FormatInt(key.Id, 10)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return response.JSON(http.StatusOK, result)
|
return response.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +70,7 @@ type DeleteAPIkeyParams struct {
|
|||||||
type GetAPIkeyResponse struct {
|
type GetAPIkeyResponse struct {
|
||||||
// The response message
|
// The response message
|
||||||
// in: body
|
// in: body
|
||||||
Body []*models.ApiKeyDTO `json:"body"`
|
Body []*dtos.ApiKeyDTO `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// swagger:response postAPIkeyResponse
|
// swagger:response postAPIkeyResponse
|
||||||
|
@ -1,7 +1,22 @@
|
|||||||
package dtos
|
package dtos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
)
|
||||||
|
|
||||||
type NewApiKeyResult struct {
|
type NewApiKeyResult struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiKeyDTO struct {
|
||||||
|
Id int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role models.RoleType `json:"role"`
|
||||||
|
Expiration *time.Time `json:"expiration,omitempty"`
|
||||||
|
AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"`
|
||||||
|
}
|
||||||
|
@ -47,6 +47,7 @@ type DeleteApiKeyCommand struct {
|
|||||||
type GetApiKeysQuery struct {
|
type GetApiKeysQuery struct {
|
||||||
OrgId int64
|
OrgId int64
|
||||||
IncludeExpired bool
|
IncludeExpired bool
|
||||||
|
User *SignedInUser
|
||||||
Result []*ApiKey
|
Result []*ApiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,13 +61,3 @@ type GetApiKeyByIdQuery struct {
|
|||||||
ApiKeyId int64
|
ApiKeyId int64
|
||||||
Result *ApiKey
|
Result *ApiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// DTO & Projections
|
|
||||||
|
|
||||||
type ApiKeyDTO struct {
|
|
||||||
Id int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Role RoleType `json:"role"`
|
|
||||||
Expiration *time.Time `json:"expiration,omitempty"`
|
|
||||||
}
|
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var sqlIDAcceptList = map[string]struct{}{
|
var sqlIDAcceptList = map[string]struct{}{
|
||||||
|
"id": {},
|
||||||
"org_user.user_id": {},
|
"org_user.user_id": {},
|
||||||
"role.id": {},
|
"role.id": {},
|
||||||
"t.id": {},
|
"t.id": {},
|
||||||
|
@ -7,6 +7,8 @@ import (
|
|||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetAPIKeys queries the database based
|
// GetAPIKeys queries the database based
|
||||||
@ -27,6 +29,14 @@ func (ss *SQLStore) GetAPIKeys(ctx context.Context, query *models.GetApiKeysQuer
|
|||||||
|
|
||||||
sess = sess.Where("service_account_id IS NULL")
|
sess = sess.Where("service_account_id IS NULL")
|
||||||
|
|
||||||
|
if ss.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAccesscontrol) {
|
||||||
|
filter, err := accesscontrol.Filter(query.User, "id", "apikeys:id:", accesscontrol.ActionAPIKeyRead)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sess.And(filter.Where, filter.Args...)
|
||||||
|
}
|
||||||
|
|
||||||
query.Result = make([]*models.ApiKey, 0)
|
query.Result = make([]*models.ApiKey, 0)
|
||||||
return sess.Find(&query.Result)
|
return sess.Find(&query.Result)
|
||||||
})
|
})
|
||||||
|
@ -5,10 +5,14 @@ package sqlstore
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -149,3 +153,60 @@ func TestApiKeyErrors(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type getApiKeysTestCase struct {
|
||||||
|
desc string
|
||||||
|
user *models.SignedInUser
|
||||||
|
expectedNumKeys int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLStore_GetAPIKeys(t *testing.T) {
|
||||||
|
tests := []getApiKeysTestCase{
|
||||||
|
{
|
||||||
|
desc: "expect all keys for wildcard scope",
|
||||||
|
user: &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{
|
||||||
|
1: {"apikeys:read": {"apikeys:*"}},
|
||||||
|
}},
|
||||||
|
expectedNumKeys: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "expect only api keys that user have scopes for",
|
||||||
|
user: &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{
|
||||||
|
1: {"apikeys:read": {"apikeys:id:1", "apikeys:id:3"}},
|
||||||
|
}},
|
||||||
|
expectedNumKeys: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "expect no keys when user have no scopes",
|
||||||
|
user: &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{
|
||||||
|
1: {"apikeys:read": {}},
|
||||||
|
}},
|
||||||
|
expectedNumKeys: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.desc, func(t *testing.T) {
|
||||||
|
store := InitTestDB(t, InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagAccesscontrol}})
|
||||||
|
seedApiKeys(t, store, 10)
|
||||||
|
|
||||||
|
query := &models.GetApiKeysQuery{OrgId: 1, User: tt.user}
|
||||||
|
err := store.GetAPIKeys(context.Background(), query)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, query.Result, tt.expectedNumKeys)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedApiKeys(t *testing.T, store *SQLStore, num int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for i := 0; i < num; i++ {
|
||||||
|
err := store.AddAPIKey(context.Background(), &models.AddApiKeyCommand{
|
||||||
|
Name: fmt.Sprintf("key:%d", i),
|
||||||
|
Key: fmt.Sprintf("key:%d", i),
|
||||||
|
OrgId: 1,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -13,6 +13,15 @@ import { ApiKeysPageUnconnected, Props } from './ApiKeysPage';
|
|||||||
import { getMultipleMockKeys } from './__mocks__/apiKeysMock';
|
import { getMultipleMockKeys } from './__mocks__/apiKeysMock';
|
||||||
import { setSearchQuery } from './state/reducers';
|
import { setSearchQuery } from './state/reducers';
|
||||||
|
|
||||||
|
jest.mock('app/core/core', () => {
|
||||||
|
return {
|
||||||
|
contextSrv: {
|
||||||
|
hasPermission: () => true,
|
||||||
|
hasPermissionInMetadata: () => true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const setup = (propOverrides: Partial<Props>) => {
|
const setup = (propOverrides: Partial<Props>) => {
|
||||||
const loadApiKeysMock = jest.fn();
|
const loadApiKeysMock = jest.fn();
|
||||||
const deleteApiKeyMock = jest.fn();
|
const deleteApiKeyMock = jest.fn();
|
||||||
@ -40,9 +49,7 @@ const setup = (propOverrides: Partial<Props>) => {
|
|||||||
includeExpired: false,
|
includeExpired: false,
|
||||||
includeExpiredDisabled: false,
|
includeExpiredDisabled: false,
|
||||||
toggleIncludeExpired: toggleIncludeExpiredMock,
|
toggleIncludeExpired: toggleIncludeExpiredMock,
|
||||||
canRead: true,
|
|
||||||
canCreate: true,
|
canCreate: true,
|
||||||
canDelete: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(props, propOverrides);
|
Object.assign(props, propOverrides);
|
||||||
|
@ -24,9 +24,7 @@ import { setSearchQuery } from './state/reducers';
|
|||||||
import { getApiKeys, getApiKeysCount, getIncludeExpired, getIncludeExpiredDisabled } from './state/selectors';
|
import { getApiKeys, getApiKeysCount, getIncludeExpired, getIncludeExpiredDisabled } from './state/selectors';
|
||||||
|
|
||||||
function mapStateToProps(state: StoreState) {
|
function mapStateToProps(state: StoreState) {
|
||||||
const canRead = contextSrv.hasAccess(AccessControlAction.ActionAPIKeysRead, true);
|
|
||||||
const canCreate = contextSrv.hasAccess(AccessControlAction.ActionAPIKeysCreate, true);
|
const canCreate = contextSrv.hasAccess(AccessControlAction.ActionAPIKeysCreate, true);
|
||||||
const canDelete = contextSrv.hasAccess(AccessControlAction.ActionAPIKeysDelete, true);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
navModel: getNavModel(state.navIndex, 'apikeys'),
|
navModel: getNavModel(state.navIndex, 'apikeys'),
|
||||||
@ -37,9 +35,7 @@ function mapStateToProps(state: StoreState) {
|
|||||||
timeZone: getTimeZone(state.user),
|
timeZone: getTimeZone(state.user),
|
||||||
includeExpired: getIncludeExpired(state.apiKeys),
|
includeExpired: getIncludeExpired(state.apiKeys),
|
||||||
includeExpiredDisabled: getIncludeExpiredDisabled(state.apiKeys),
|
includeExpiredDisabled: getIncludeExpiredDisabled(state.apiKeys),
|
||||||
canRead: canRead,
|
|
||||||
canCreate: canCreate,
|
canCreate: canCreate,
|
||||||
canDelete: canDelete,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,9 +126,7 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
|
|||||||
timeZone,
|
timeZone,
|
||||||
includeExpired,
|
includeExpired,
|
||||||
includeExpiredDisabled,
|
includeExpiredDisabled,
|
||||||
canRead,
|
|
||||||
canCreate,
|
canCreate,
|
||||||
canDelete,
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (!hasFetched) {
|
if (!hasFetched) {
|
||||||
@ -181,13 +175,7 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
|
|||||||
<InlineField disabled={includeExpiredDisabled} label="Include expired keys">
|
<InlineField disabled={includeExpiredDisabled} label="Include expired keys">
|
||||||
<InlineSwitch id="showExpired" value={includeExpired} onChange={this.onIncludeExpiredChange} />
|
<InlineSwitch id="showExpired" value={includeExpired} onChange={this.onIncludeExpiredChange} />
|
||||||
</InlineField>
|
</InlineField>
|
||||||
<ApiKeysTable
|
<ApiKeysTable apiKeys={apiKeys} timeZone={timeZone} onDelete={this.onDeleteApiKey} />
|
||||||
apiKeys={apiKeys}
|
|
||||||
timeZone={timeZone}
|
|
||||||
onDelete={this.onDeleteApiKey}
|
|
||||||
canRead={canRead}
|
|
||||||
canDelete={canDelete}
|
|
||||||
/>
|
|
||||||
</VerticalGroup>
|
</VerticalGroup>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
@ -3,6 +3,8 @@ import React, { FC } from 'react';
|
|||||||
|
|
||||||
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
|
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
|
||||||
import { DeleteButton, Icon, IconName, Tooltip, useTheme2 } from '@grafana/ui';
|
import { DeleteButton, Icon, IconName, Tooltip, useTheme2 } from '@grafana/ui';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
|
import { AccessControlAction } from 'app/types';
|
||||||
|
|
||||||
import { ApiKey } from '../../types';
|
import { ApiKey } from '../../types';
|
||||||
|
|
||||||
@ -10,11 +12,9 @@ interface Props {
|
|||||||
apiKeys: ApiKey[];
|
apiKeys: ApiKey[];
|
||||||
timeZone: TimeZone;
|
timeZone: TimeZone;
|
||||||
onDelete: (apiKey: ApiKey) => void;
|
onDelete: (apiKey: ApiKey) => void;
|
||||||
canRead: boolean;
|
|
||||||
canDelete: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete, canRead, canDelete }) => {
|
export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete }) => {
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete, canRead,
|
|||||||
<th style={{ width: '34px' }} />
|
<th style={{ width: '34px' }} />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
{canRead && apiKeys.length > 0 ? (
|
{apiKeys.length > 0 ? (
|
||||||
<tbody>
|
<tbody>
|
||||||
{apiKeys.map((key) => {
|
{apiKeys.map((key) => {
|
||||||
const isExpired = Boolean(key.expiration && Date.now() > new Date(key.expiration).getTime());
|
const isExpired = Boolean(key.expiration && Date.now() > new Date(key.expiration).getTime());
|
||||||
@ -51,7 +51,7 @@ export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete, canRead,
|
|||||||
aria-label="Delete API key"
|
aria-label="Delete API key"
|
||||||
size="sm"
|
size="sm"
|
||||||
onConfirm={() => onDelete(key)}
|
onConfirm={() => onDelete(key)}
|
||||||
disabled={!canDelete}
|
disabled={!contextSrv.hasPermissionInMetadata(AccessControlAction.ActionAPIKeysDelete, key)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -16,8 +16,8 @@ export function loadApiKeys(): ThunkResult<void> {
|
|||||||
return async (dispatch) => {
|
return async (dispatch) => {
|
||||||
dispatch(isFetching());
|
dispatch(isFetching());
|
||||||
const [keys, keysIncludingExpired] = await Promise.all([
|
const [keys, keysIncludingExpired] = await Promise.all([
|
||||||
getBackendSrv().get('/api/auth/keys?includeExpired=false'),
|
getBackendSrv().get('/api/auth/keys?includeExpired=false&accesscontrol=true'),
|
||||||
getBackendSrv().get('/api/auth/keys?includeExpired=true'),
|
getBackendSrv().get('/api/auth/keys?includeExpired=true&accesscontrol=true'),
|
||||||
]);
|
]);
|
||||||
dispatch(apiKeysLoaded({ keys, keysIncludingExpired }));
|
dispatch(apiKeysLoaded({ keys, keysIncludingExpired }));
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { OrgRole } from './acl';
|
import { WithAccessControlMetadata } from '@grafana/data';
|
||||||
|
|
||||||
export interface ApiKey {
|
import { OrgRole } from './acl';
|
||||||
|
|
||||||
|
export interface ApiKey extends WithAccessControlMetadata {
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
role: OrgRole;
|
role: OrgRole;
|
||||||
|
Loading…
Reference in New Issue
Block a user