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:
Karl Persson 2022-04-29 15:30:24 +02:00 committed by GitHub
parent e9a2a06651
commit 6c6137f45a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 123 additions and 39 deletions

View File

@ -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))
}) })

View File

@ -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)
} }

View File

@ -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

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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": {},

View File

@ -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)
}) })

View File

@ -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)
}
}

View File

@ -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);

View File

@ -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}
</> </>

View File

@ -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>

View File

@ -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 }));
}; };

View File

@ -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;