Service Accounts: Managed permissions for service accounts (#51818)

* backend changes

* frontend changes

* linting

* nit

* import order

* allow SA creator to access the SA page

* fix merge

* tests

* fix frontend tests

Co-authored-by: alexanderzobnin alexanderzobnin@gmail.com
This commit is contained in:
Ieva 2022-07-08 10:53:18 +01:00 committed by GitHub
parent 2af5feb147
commit d85df0a560
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 256 additions and 79 deletions

View File

@ -450,7 +450,10 @@ var teamsEditAccessEvaluator = ac.EvalAll(
var apiKeyAccessEvaluator = ac.EvalPermission(ac.ActionAPIKeyRead)
// serviceAccountAccessEvaluator is used to protect the "Configuration > Service accounts" page access
var serviceAccountAccessEvaluator = ac.EvalPermission(serviceaccounts.ActionRead)
var serviceAccountAccessEvaluator = ac.EvalAny(
ac.EvalPermission(serviceaccounts.ActionRead),
ac.EvalPermission(serviceaccounts.ActionCreate),
)
// Metadata helpers
// getAccessControlMetadata returns the accesscontrol metadata associated with a given resource

View File

@ -94,6 +94,7 @@ import (
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
secretsMigrator "github.com/grafana/grafana/pkg/services/secrets/migrator"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/database"
serviceaccountsmanager "github.com/grafana/grafana/pkg/services/serviceaccounts/manager"
"github.com/grafana/grafana/pkg/services/shorturls"
"github.com/grafana/grafana/pkg/services/sqlstore"
@ -238,6 +239,10 @@ var wireBasicSet = wire.NewSet(
pluginSettings.ProvideService,
wire.Bind(new(pluginsettings.Service), new(*pluginSettings.Service)),
alerting.ProvideService,
database.ProvideServiceAccountsStore,
wire.Bind(new(serviceaccounts.Store), new(*database.ServiceAccountsStoreImpl)),
ossaccesscontrol.ProvideServiceAccountPermissions,
wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)),
serviceaccountsmanager.ProvideServiceAccountsService,
wire.Bind(new(serviceaccounts.Service), new(*serviceaccountsmanager.ServiceAccountsService)),
expr.ProvideService,

View File

@ -62,6 +62,10 @@ type DatasourcePermissionsService interface {
PermissionsService
}
type ServiceAccountPermissionsService interface {
PermissionsService
}
type PermissionsService interface {
// GetPermissions returns all permissions for given resourceID
GetPermissions(ctx context.Context, user *models.SignedInUser, resourceID string) ([]ResourcePermission, error)

View File

@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
@ -260,3 +261,46 @@ func (e DatasourcePermissionsService) SetPermissions(ctx context.Context, orgID
func (e DatasourcePermissionsService) MapActions(permission accesscontrol.ResourcePermission) string {
return ""
}
type ServiceAccountPermissionsService struct {
*resourcepermissions.Service
}
func ProvideServiceAccountPermissions(
cfg *setting.Cfg, router routing.RouteRegister, sql *sqlstore.SQLStore,
ac accesscontrol.AccessControl, store resourcepermissions.Store,
license models.Licensing, serviceAccountStore serviceaccounts.Store,
) (*ServiceAccountPermissionsService, error) {
options := resourcepermissions.Options{
Resource: "serviceaccounts",
ResourceAttribute: "id",
ResourceValidator: func(ctx context.Context, orgID int64, resourceID string) error {
id, err := strconv.ParseInt(resourceID, 10, 64)
if err != nil {
return err
}
_, err = serviceAccountStore.RetrieveServiceAccount(ctx, orgID, id)
return err
},
Assignments: resourcepermissions.Assignments{
Users: true,
Teams: false,
BuiltInRoles: false,
ServiceAccounts: false,
},
PermissionsToActions: map[string][]string{
"View": {serviceaccounts.ActionRead},
"Edit": {serviceaccounts.ActionRead, serviceaccounts.ActionWrite, serviceaccounts.ActionDelete},
"Admin": {serviceaccounts.ActionRead, serviceaccounts.ActionWrite, serviceaccounts.ActionDelete, serviceaccounts.ActionPermissionsRead, serviceaccounts.ActionPermissionsWrite},
},
ReaderRoleName: "Service account permission reader",
WriterRoleName: "Service account permission writer",
RoleGroup: "Service accounts",
}
srv, err := resourcepermissions.New(options, cfg, router, license, ac, store, sql)
if err != nil {
return nil, err
}
return &ServiceAccountPermissionsService{srv}, nil
}

View File

@ -20,12 +20,13 @@ import (
)
type ServiceAccountsAPI struct {
cfg *setting.Cfg
service serviceaccounts.Service
accesscontrol accesscontrol.AccessControl
RouterRegister routing.RouteRegister
store serviceaccounts.Store
log log.Logger
cfg *setting.Cfg
service serviceaccounts.Service
accesscontrol accesscontrol.AccessControl
RouterRegister routing.RouteRegister
store serviceaccounts.Store
log log.Logger
permissionService accesscontrol.ServiceAccountPermissionsService
}
func NewServiceAccountsAPI(
@ -34,14 +35,16 @@ func NewServiceAccountsAPI(
accesscontrol accesscontrol.AccessControl,
routerRegister routing.RouteRegister,
store serviceaccounts.Store,
permissionService accesscontrol.ServiceAccountPermissionsService,
) *ServiceAccountsAPI {
return &ServiceAccountsAPI{
cfg: cfg,
service: service,
accesscontrol: accesscontrol,
RouterRegister: routerRegister,
store: store,
log: log.New("serviceaccounts.api"),
cfg: cfg,
service: service,
accesscontrol: accesscontrol,
RouterRegister: routerRegister,
store: store,
log: log.New("serviceaccounts.api"),
permissionService: permissionService,
}
}
@ -103,6 +106,14 @@ func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) respon
return response.Error(http.StatusInternalServerError, "Failed to create service account", err)
}
if !api.accesscontrol.IsDisabled() {
if c.SignedInUser.IsRealUser() {
if _, err := api.permissionService.SetUserPermission(c.Req.Context(), c.OrgId, accesscontrol.User{ID: c.SignedInUser.UserId}, strconv.FormatInt(serviceAccount.Id, 10), "Admin"); err != nil {
return response.Error(http.StatusInternalServerError, "Failed to set permissions for service account creator", err)
}
}
}
return response.JSON(http.StatusCreated, serviceAccount)
}

View File

@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/grafana/grafana/pkg/api/routing"
@ -15,8 +16,11 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
acDatabase "github.com/grafana/grafana/pkg/services/accesscontrol/database"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/database"
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
@ -35,7 +39,7 @@ var (
func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
saStore := database.ProvideServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
autoAssignOrg := store.Cfg.AutoAssignOrg
@ -150,7 +154,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
serviceAccountRequestScenario(t, http.MethodPost, serviceAccountPath, testUser, func(httpmethod string, endpoint string, user *tests.TestUser) {
server, _ := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore)
server, api := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store, saStore)
marshalled, err := json.Marshal(tc.body)
require.NoError(t, err)
@ -166,9 +170,25 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) {
require.Equal(t, tc.expectedCode, actualCode, actualBody)
if actualCode == http.StatusCreated {
assert.NotEmpty(t, actualBody["id"])
assert.Equal(t, tc.body["name"], actualBody["name"].(string))
assert.Equal(t, tc.wantID, actualBody["login"].(string))
sa := serviceaccounts.ServiceAccountDTO{}
err = json.Unmarshal(actual.Body.Bytes(), &sa)
require.NoError(t, err)
assert.NotZero(t, sa.Id)
assert.Equal(t, tc.body["name"], sa.Name)
assert.Equal(t, tc.wantID, sa.Login)
tempUser := &models.SignedInUser{
OrgId: 1,
Permissions: map[int64]map[string][]string{
1: {
serviceaccounts.ActionRead: []string{serviceaccounts.ScopeAll},
},
},
}
perms, err := api.permissionService.GetPermissions(context.Background(), tempUser, strconv.FormatInt(sa.Id, 10))
assert.NoError(t, err)
assert.Equal(t, 1, len(perms), "should have added managed permissions for SA creator")
assert.Equal(t, int64(1), perms[0].ID)
assert.Equal(t, int64(1), perms[0].UserId)
} else if actualCode == http.StatusBadRequest {
assert.Contains(t, tc.wantError, actualBody["error"].(string))
}
@ -182,7 +202,7 @@ func TestServiceAccountsAPI_CreateServiceAccount(t *testing.T) {
func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
saStore := database.ProvideServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
var requestResponse = func(server *web.Mux, httpMethod, requestpath string) *httptest.ResponseRecorder {
@ -251,7 +271,11 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock,
routerRegister routing.RouteRegister,
acmock *accesscontrolmock.Mock,
sqlStore *sqlstore.SQLStore, saStore serviceaccounts.Store) (*web.Mux, *ServiceAccountsAPI) {
a := NewServiceAccountsAPI(setting.NewCfg(), svc, acmock, routerRegister, saStore)
cfg := setting.NewCfg()
saPermissionService, err := ossaccesscontrol.ProvideServiceAccountPermissions(cfg, routing.NewRouteRegister(), sqlStore, acmock, acDatabase.ProvideService(sqlStore), &licensing.OSSLicensingService{}, saStore)
require.NoError(t, err)
a := NewServiceAccountsAPI(cfg, svc, acmock, routerRegister, saStore, saPermissionService)
a.RegisterAPIEndpoints()
a.cfg.ApiKeyMaxSecondsToLive = -1 // disable api key expiration
@ -259,6 +283,7 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock,
m := web.New()
signedUser := &models.SignedInUser{
OrgId: 1,
UserId: 1,
OrgRole: models.ROLE_VIEWER,
}
@ -278,7 +303,7 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock,
func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
saStore := database.ProvideServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
type testRetrieveSATestCase struct {
desc string
@ -369,7 +394,7 @@ func newString(s string) *string {
func TestServiceAccountsAPI_UpdateServiceAccount(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
saStore := database.ProvideServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
type testUpdateSATestCase struct {
desc string

View File

@ -52,7 +52,7 @@ func createTokenforSA(t *testing.T, store serviceaccounts.Store, keyName string,
func TestServiceAccountsAPI_CreateToken(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
saStore := database.NewServiceAccountsStore(store, kvStore)
saStore := database.ProvideServiceAccountsStore(store, kvStore)
svcmock := tests.ServiceAccountMock{}
sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true})
@ -169,7 +169,7 @@ func TestServiceAccountsAPI_DeleteToken(t *testing.T) {
store := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(store)
svcMock := &tests.ServiceAccountMock{}
saStore := database.NewServiceAccountsStore(store, kvStore)
saStore := database.ProvideServiceAccountsStore(store, kvStore)
sa := tests.SetupUserServiceAccount(t, store, tests.TestUser{Login: "sa", IsServiceAccount: true})
type testCreateSAToken struct {

View File

@ -23,7 +23,7 @@ type ServiceAccountsStoreImpl struct {
log log.Logger
}
func NewServiceAccountsStore(store *sqlstore.SQLStore, kvStore kvstore.KVStore) *ServiceAccountsStoreImpl {
func ProvideServiceAccountsStore(store *sqlstore.SQLStore, kvStore kvstore.KVStore) *ServiceAccountsStoreImpl {
return &ServiceAccountsStoreImpl{
sqlStore: store,
kvStore: kvStore,

View File

@ -105,7 +105,7 @@ func setupTestDatabase(t *testing.T) (*sqlstore.SQLStore, *ServiceAccountsStoreI
t.Helper()
db := sqlstore.InitTestDB(t)
kvStore := kvstore.ProvideService(db)
return db, NewServiceAccountsStore(db, kvStore)
return db, ProvideServiceAccountsStore(db, kvStore)
}
func TestStore_RetrieveServiceAccount(t *testing.T) {

View File

@ -23,11 +23,26 @@ func RegisterRoles(ac accesscontrol.AccessControl) error {
Grants: []string{string(models.ROLE_ADMIN)},
}
saCreator := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:serviceaccounts:creator",
DisplayName: "Service accounts creator",
Description: "Create service accounts.",
Group: "Service accounts",
Permissions: []accesscontrol.Permission{
{
Action: serviceaccounts.ActionCreate,
},
},
},
Grants: []string{string(models.ROLE_ADMIN)},
}
saWriter := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:serviceaccounts:writer",
DisplayName: "Service accounts writer",
Description: "Create, delete, read, or query service accounts.",
Description: "Create, delete and read service accounts, manage service account permissions.",
Group: "Service accounts",
Permissions: accesscontrol.ConcatPermissions(saReader.Role.Permissions, []accesscontrol.Permission{
{
@ -41,12 +56,20 @@ func RegisterRoles(ac accesscontrol.AccessControl) error {
Action: serviceaccounts.ActionDelete,
Scope: serviceaccounts.ScopeAll,
},
{
Action: serviceaccounts.ActionPermissionsRead,
Scope: serviceaccounts.ScopeAll,
},
{
Action: serviceaccounts.ActionPermissionsWrite,
Scope: serviceaccounts.ScopeAll,
},
}),
},
Grants: []string{string(models.ROLE_ADMIN)},
}
if err := ac.DeclareFixedRoles(saReader, saWriter); err != nil {
if err := ac.DeclareFixedRoles(saReader, saCreator, saWriter); err != nil {
return err
}

View File

@ -4,14 +4,12 @@ import (
"context"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/api"
"github.com/grafana/grafana/pkg/services/serviceaccounts/database"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
@ -22,15 +20,15 @@ type ServiceAccountsService struct {
func ProvideServiceAccountsService(
cfg *setting.Cfg,
store *sqlstore.SQLStore,
kvStore kvstore.KVStore,
ac accesscontrol.AccessControl,
routeRegister routing.RouteRegister,
usageStats usagestats.Service,
serviceAccountsStore serviceaccounts.Store,
permissionService accesscontrol.ServiceAccountPermissionsService,
) (*ServiceAccountsService, error) {
database.InitMetrics()
s := &ServiceAccountsService{
store: database.NewServiceAccountsStore(store, kvStore),
store: serviceAccountsStore,
log: log.New("serviceaccounts"),
}
@ -40,7 +38,7 @@ func ProvideServiceAccountsService(
usageStats.RegisterMetricsFunc(s.store.GetUsageMetrics)
serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, routeRegister, s.store)
serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, routeRegister, s.store, permissionService)
serviceaccountsAPI.RegisterAPIEndpoints()
return s, nil

View File

@ -13,10 +13,12 @@ var (
)
const (
ActionRead = "serviceaccounts:read"
ActionWrite = "serviceaccounts:write"
ActionCreate = "serviceaccounts:create"
ActionDelete = "serviceaccounts:delete"
ActionRead = "serviceaccounts:read"
ActionWrite = "serviceaccounts:write"
ActionCreate = "serviceaccounts:create"
ActionDelete = "serviceaccounts:delete"
ActionPermissionsRead = "serviceaccounts.permissions:read"
ActionPermissionsWrite = "serviceaccounts.permissions:write"
)
type ServiceAccount struct {

View File

@ -13,7 +13,11 @@ import { OrgRolePicker } from '../admin/OrgRolePicker';
export interface Props {}
const createServiceAccount = async (sa: ServiceAccountDTO) => getBackendSrv().post('/api/serviceaccounts/', sa);
const createServiceAccount = async (sa: ServiceAccountDTO) => {
const result = await getBackendSrv().post('/api/serviceaccounts/', sa);
await contextSrv.fetchUserPermissions();
return result;
};
const updateServiceAccount = async (id: number, sa: ServiceAccountDTO) =>
getBackendSrv().patch(`/api/serviceaccounts/${id}`, sa);
@ -44,7 +48,10 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
setRoleOptions(options);
}
if (contextSrv.hasPermission(AccessControlAction.ActionBuiltinRolesList)) {
if (
contextSrv.accessControlBuiltInRoleAssignmentEnabled() &&
contextSrv.hasPermission(AccessControlAction.ActionBuiltinRolesList)
) {
const builtInRoles = await fetchBuiltinRoles(currentOrgId);
setBuiltinRoles(builtInRoles);
}
@ -75,7 +82,11 @@ export const ServiceAccountCreatePage = ({}: Props): JSX.Element => {
tokens: response.tokens,
};
await updateServiceAccount(response.id, data);
if (contextSrv.licensedAccessControlEnabled()) {
if (
contextSrv.licensedAccessControlEnabled() &&
contextSrv.hasPermission(AccessControlAction.ActionUserRolesAdd) &&
contextSrv.hasPermission(AccessControlAction.ActionUserRolesRemove)
) {
await updateUserRoles(pendingRoles, newAccount.id, newAccount.orgId);
}
} catch (e) {

View File

@ -11,6 +11,7 @@ jest.mock('app/core/core', () => ({
licensedAccessControlEnabled: () => false,
hasPermission: () => true,
hasPermissionInMetadata: () => true,
hasAccessInMetadata: () => false,
},
}));

View File

@ -9,6 +9,7 @@ import { contextSrv } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { AccessControlAction, ApiKey, Role, ServiceAccountDTO, StoreState } from 'app/types';
import { ServiceAccountPermissions } from './ServiceAccountPermissions';
import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal';
import { ServiceAccountProfile } from './components/ServiceAccountProfile';
import { ServiceAccountTokensTable } from './components/ServiceAccountTokensTable';
@ -79,6 +80,11 @@ export const ServiceAccountPageUnconnected = ({
!contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) || serviceAccount.isDisabled;
const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite);
const canReadPermissions = contextSrv.hasAccessInMetadata(
AccessControlAction.ServiceAccountsPermissionsRead,
serviceAccount!,
false
);
useEffect(() => {
loadServiceAccount(serviceAccountId);
@ -186,7 +192,7 @@ export const ServiceAccountPageUnconnected = ({
/>
)}
<div className={styles.tokensListHeader}>
<h4>Tokens</h4>
<h3>Tokens</h3>
<Button onClick={() => setIsTokenModalOpen(true)} disabled={tokenActionsDisabled}>
Add service account token
</Button>
@ -199,6 +205,7 @@ export const ServiceAccountPageUnconnected = ({
tokenActionsDisabled={tokenActionsDisabled}
/>
)}
{canReadPermissions && <ServiceAccountPermissions serviceAccount={serviceAccount} />}
</div>
<ConfirmModal
isOpen={isDeleteModalOpen}

View File

@ -0,0 +1,28 @@
import React from 'react';
import { Permissions } from 'app/core/components/AccessControl';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, ServiceAccountDTO } from '../../types';
type ServiceAccountPermissionsProps = {
serviceAccount: ServiceAccountDTO;
};
export const ServiceAccountPermissions = (props: ServiceAccountPermissionsProps) => {
const canSetPermissions = contextSrv.hasPermissionInMetadata(
AccessControlAction.ServiceAccountsPermissionsWrite,
props.serviceAccount
);
return (
<Permissions
title="Permissions"
addPermissionTitle="Add permission"
buttonLabel="Add permission"
resource="serviceaccounts"
resourceId={props.serviceAccount.id}
canSetPermissions={canSetPermissions}
/>
);
};

View File

@ -230,23 +230,22 @@ export const ServiceAccountsListPageUnconnected = ({
</>
)}
<>
<div className={cx(styles.table, 'admin-list-table')}>
<table className="filter-table filter-table--hover">
<thead>
<tr>
<th></th>
<th>Account</th>
<th>ID</th>
<th>Roles</th>
<th>Tokens</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{!isLoading &&
serviceAccounts.length !== 0 &&
serviceAccounts.map((serviceAccount: ServiceAccountDTO) => (
{!isLoading && serviceAccounts.length !== 0 && (
<>
<div className={cx(styles.table, 'admin-list-table')}>
<table className="filter-table filter-table--hover">
<thead>
<tr>
<th></th>
<th>Account</th>
<th>ID</th>
<th>Roles</th>
<th>Tokens</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{serviceAccounts.map((serviceAccount: ServiceAccountDTO) => (
<ServiceAccountListItem
serviceAccount={serviceAccount}
key={serviceAccount.id}
@ -259,10 +258,11 @@ export const ServiceAccountsListPageUnconnected = ({
onAddTokenClick={onTokenAdd}
/>
))}
</tbody>
</table>
</div>
</>
</tbody>
</table>
</div>
</>
)}
{currentServiceAccount && (
<>
<ConfirmModal

View File

@ -37,7 +37,7 @@ export function ServiceAccountProfile({
return (
<div className={styles.section}>
<h4>Information</h4>
<h3>Information</h3>
<table className="filter-table">
<tbody>
<ServiceAccountProfileRow

View File

@ -1,4 +1,4 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import React from 'react';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
@ -17,7 +17,7 @@ export const ServiceAccountTokensTable = ({ tokens, timeZone, tokenActionsDisabl
const styles = getStyles(theme);
return (
<table className="filter-table">
<table className={cx(styles.section, 'filter-table')}>
<thead>
<tr>
<th>Name</th>
@ -124,4 +124,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
neverExpire: css`
color: ${theme.colors.text.secondary};
`,
section: css`
margin-bottom: ${theme.spacing(4)};
`,
});

View File

@ -47,8 +47,10 @@ export function fetchACOptions(): ThunkResult<void> {
export function getApiKeysMigrationStatus(): ThunkResult<void> {
return async (dispatch) => {
const result = await getBackendSrv().get('/api/serviceaccounts/migrationstatus');
dispatch(apiKeysMigrationStatusLoaded(!!result?.migrated));
if (contextSrv.hasPermission(AccessControlAction.ServiceAccountsRead)) {
const result = await getBackendSrv().get('/api/serviceaccounts/migrationstatus');
dispatch(apiKeysMigrationStatusLoaded(!!result?.migrated));
}
};
}
@ -61,20 +63,22 @@ export function fetchServiceAccounts(
): ThunkResult<void> {
return async (dispatch, getState) => {
try {
if (withLoadingIndicator) {
dispatch(serviceAccountsFetchBegin());
if (contextSrv.hasPermission(AccessControlAction.ServiceAccountsRead)) {
if (withLoadingIndicator) {
dispatch(serviceAccountsFetchBegin());
}
const { perPage, page, query, serviceAccountStateFilter } = getState().serviceAccounts;
const result = await getBackendSrv().get(
`/api/serviceaccounts/search?perpage=${perPage}&page=${page}&query=${query}${getStateFilter(
serviceAccountStateFilter
)}&accesscontrol=true`
);
dispatch(serviceAccountsFetched(result));
}
const { perPage, page, query, serviceAccountStateFilter } = getState().serviceAccounts;
const result = await getBackendSrv().get(
`/api/serviceaccounts/search?perpage=${perPage}&page=${page}&query=${query}${getStateFilter(
serviceAccountStateFilter
)}&accesscontrol=true`
);
dispatch(serviceAccountsFetched(result));
} catch (error) {
console.error(error);
} finally {
serviceAccountsFetchEnd();
dispatch(serviceAccountsFetchEnd());
}
};
}

View File

@ -210,7 +210,11 @@ export function getAppRoutes(): RouteDescriptor[] {
},
{
path: '/org/serviceaccounts',
roles: () => contextSrv.evaluatePermission(() => ['Admin'], [AccessControlAction.ServiceAccountsRead]),
roles: () =>
contextSrv.evaluatePermission(
() => ['Admin'],
[AccessControlAction.ServiceAccountsRead, AccessControlAction.ServiceAccountsCreate]
),
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "ServiceAccountsPage" */ 'app/features/serviceaccounts/ServiceAccountsListPage')

View File

@ -26,6 +26,8 @@ export enum AccessControlAction {
ServiceAccountsCreate = 'serviceaccounts:create',
ServiceAccountsWrite = 'serviceaccounts:write',
ServiceAccountsDelete = 'serviceaccounts:delete',
ServiceAccountsPermissionsRead = 'serviceaccounts.permissions:read',
ServiceAccountsPermissionsWrite = 'serviceaccounts.permissions:write',
OrgsRead = 'orgs:read',
OrgsPreferencesRead = 'orgs.preferences:read',
@ -64,6 +66,8 @@ export enum AccessControlAction {
ActionTeamsRolesAdd = 'teams.roles:add',
ActionTeamsRolesRemove = 'teams.roles:remove',
ActionUserRolesList = 'users.roles:read',
ActionUserRolesAdd = 'users.roles:add',
ActionUserRolesRemove = 'users.roles:remove',
DashboardsRead = 'dashboards:read',
DashboardsWrite = 'dashboards:write',