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 05:53:18 -04:00
committed by GitHub
co-authored by alexanderzobnin alexanderzobnin@gmail.com
parent 2af5feb147
commit d85df0a560
22 changed files with 256 additions and 79 deletions
@@ -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) {
@@ -11,6 +11,7 @@ jest.mock('app/core/core', () => ({
licensedAccessControlEnabled: () => false,
hasPermission: () => true,
hasPermissionInMetadata: () => true,
hasAccessInMetadata: () => false,
},
}));
@@ -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}
@@ -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}
/>
);
};
@@ -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
@@ -37,7 +37,7 @@ export function ServiceAccountProfile({
return (
<div className={styles.section}>
<h4>Information</h4>
<h3>Information</h3>
<table className="filter-table">
<tbody>
<ServiceAccountProfileRow
@@ -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)};
`,
});
@@ -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());
}
};
}