ServiceAccounts: Add token view for Service Accounts (#45013)

* fix SA creation scope

* add writer action to SA fixed role

* ServiceAccounts: Add token table to SA detail page

* ServiceAccounts: Allow deletion of tokens from token table

* refactor service account page

* avoid using store for delete
This commit is contained in:
J Guerreiro 2022-02-08 11:35:15 +00:00 committed by GitHub
parent f885c2ede9
commit 8c49e96439
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 168 additions and 49 deletions

View File

@ -65,7 +65,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints(
serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount))
serviceAccountsRoute.Get("/upgrade", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.UpgradeServiceAccounts))
serviceAccountsRoute.Post("/convert/:keyId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.ConvertToServiceAccount))
serviceAccountsRoute.Post("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.CreateServiceAccount))
serviceAccountsRoute.Post("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate)), routing.Wrap(api.CreateServiceAccount))
serviceAccountsRoute.Get("/:serviceAccountId/tokens", auth(middleware.ReqOrgAdmin,
accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.ListTokens))
serviceAccountsRoute.Post("/:serviceAccountId/tokens", auth(middleware.ReqOrgAdmin,

View File

@ -18,6 +18,10 @@ func RegisterRoles(ac accesscontrol.AccessControl) error {
Action: serviceaccounts.ActionRead,
Scope: serviceaccounts.ScopeAll,
},
{
Action: serviceaccounts.ActionWrite,
Scope: serviceaccounts.ScopeAll,
},
{
Action: serviceaccounts.ActionCreate,
},

View File

@ -1,67 +1,89 @@
import React, { PureComponent } from 'react';
import React, { useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { getNavModel } from 'app/core/selectors/navModel';
import Page from 'app/core/components/Page/Page';
import { ServiceAccountProfile } from './ServiceAccountProfile';
import { StoreState, ServiceAccountDTO } from 'app/types';
import { StoreState, ServiceAccountDTO, ApiKey } from 'app/types';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { loadServiceAccount } from './state/actions';
import { deleteServiceAccountToken, loadServiceAccount, loadServiceAccountTokens } from './state/actions';
import { ServiceAccountTokensTable } from './ServiceAccountTokensTable';
import { getTimeZone, NavModel } from '@grafana/data';
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {
navModel: NavModel;
serviceAccount?: ServiceAccountDTO;
tokens: ApiKey[];
isLoading: boolean;
}
export class ServiceAccountPage extends PureComponent<Props> {
async componentDidMount() {
const { match } = this.props;
this.props.loadServiceAccount(parseInt(match.params.id, 10));
}
render() {
const { navModel, serviceAccount, isLoading } = this.props;
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
{serviceAccount && (
<>
<ServiceAccountProfile
serviceaccount={serviceAccount}
onServiceAccountDelete={() => {
console.log(`not implemented`);
}}
onServiceAccountUpdate={() => {
console.log(`not implemented`);
}}
onServiceAccountDisable={() => {
console.log(`not implemented`);
}}
onServiceAccountEnable={() => {
console.log(`not implemented`);
}}
/>
</>
)}
</Page.Contents>
</Page>
);
}
}
function mapStateToProps(state: StoreState) {
return {
navModel: getNavModel(state.navIndex, 'serviceaccounts'),
serviceAccount: state.serviceAccountProfile.serviceAccount,
tokens: state.serviceAccountProfile.tokens,
isLoading: state.serviceAccountProfile.isLoading,
timezone: getTimeZone(state.user),
};
}
const mapDispatchToProps = {
loadServiceAccount,
loadServiceAccountTokens,
deleteServiceAccountToken,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = OwnProps & ConnectedProps<typeof connector>;
export default connector(ServiceAccountPage);
const ServiceAccountPageUnconnected = ({
navModel,
match,
serviceAccount,
tokens,
timezone,
isLoading,
loadServiceAccount,
loadServiceAccountTokens,
deleteServiceAccountToken,
}: Props) => {
useEffect(() => {
const serviceAccountId = parseInt(match.params.id, 10);
loadServiceAccount(serviceAccountId);
loadServiceAccountTokens(serviceAccountId);
}, [match, loadServiceAccount, loadServiceAccountTokens]);
const onDeleteServiceAccountToken = (key: ApiKey) => {
deleteServiceAccountToken(parseInt(match.params.id, 10), key.id!);
};
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
{serviceAccount && (
<>
<ServiceAccountProfile
serviceaccount={serviceAccount}
onServiceAccountDelete={() => {
console.log(`not implemented`);
}}
onServiceAccountUpdate={() => {
console.log(`not implemented`);
}}
onServiceAccountDisable={() => {
console.log(`not implemented`);
}}
onServiceAccountEnable={() => {
console.log(`not implemented`);
}}
/>
</>
)}
<h3 className="page-heading">Tokens</h3>
{tokens && (
<ServiceAccountTokensTable tokens={tokens} timeZone={timezone} onDelete={onDeleteServiceAccountToken} />
)}
</Page.Contents>
</Page>
);
};
export const ServiceAccountPage = connector(ServiceAccountPageUnconnected);

View File

@ -57,7 +57,7 @@ export function ServiceAccountProfile({
return (
<>
<h3 className="page-heading">Service account information</h3>
<h3 className="page-heading">Information</h3>
<a href="org/serviceaccounts">
<Button variant="link" icon="backward" />
</a>

View File

@ -0,0 +1,70 @@
import React, { FC } from 'react';
import { DeleteButton, Icon, Tooltip, useTheme2 } from '@grafana/ui';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
import { ApiKey } from '../../types';
import { css } from '@emotion/css';
interface Props {
tokens: ApiKey[];
timeZone: TimeZone;
onDelete: (token: ApiKey) => void;
}
export const ServiceAccountTokensTable: FC<Props> = ({ tokens, timeZone, onDelete }) => {
const theme = useTheme2();
const styles = getStyles(theme);
return (
<>
<table className="filter-table">
<thead>
<tr>
<th>Name</th>
<th>Expires</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{tokens.map((key) => {
const isExpired = !!(key.expiration && Date.now() > new Date(key.expiration).getTime());
return (
<tr key={key.id} className={styles.tableRow(isExpired)}>
<td>{key.name}</td>
<td>
{formatDate(timeZone, key.expiration)}
{isExpired && (
<span className={styles.tooltipContainer}>
<Tooltip content="This API key has expired.">
<Icon name="exclamation-triangle" />
</Tooltip>
</span>
)}
</td>
<td>
<DeleteButton aria-label="Delete API key" size="sm" onConfirm={() => onDelete(key)} />
</td>
</tr>
);
})}
</tbody>
</table>
</>
);
};
function formatDate(timeZone: TimeZone, expiration?: string): string {
if (!expiration) {
return 'No expiration date';
}
return dateTimeFormat(expiration, { timeZone });
}
const getStyles = (theme: GrafanaTheme2) => ({
tableRow: (isExpired: boolean) => css`
color: ${isExpired ? theme.colors.text.secondary : theme.colors.text.primary};
`,
tooltipContainer: css`
margin-left: ${theme.spacing(1)};
`,
});

View File

@ -1,7 +1,7 @@
import { ThunkResult } from '../../../types';
import { getBackendSrv } from '@grafana/runtime';
import { ServiceAccountDTO } from 'app/types';
import { serviceAccountLoaded, serviceAccountsLoaded } from './reducers';
import { serviceAccountLoaded, serviceAccountsLoaded, serviceAccountTokensLoaded } from './reducers';
const BASE_URL = `/api/serviceaccounts`;
@ -16,6 +16,24 @@ export function loadServiceAccount(id: number): ThunkResult<void> {
};
}
export function deleteServiceAccountToken(saID: number, id: number): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().delete(`${BASE_URL}/${saID}/tokens/${id}`);
dispatch(loadServiceAccountTokens(saID));
};
}
export function loadServiceAccountTokens(saID: number): ThunkResult<void> {
return async (dispatch) => {
try {
const response = await getBackendSrv().get(`${BASE_URL}/${saID}/tokens`);
dispatch(serviceAccountTokensLoaded(response));
} catch (error) {
console.error(error);
}
};
}
export function loadServiceAccounts(): ThunkResult<void> {
return async (dispatch) => {
try {

View File

@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types';
import { ApiKey, ServiceAccountDTO, ServiceAccountProfileState, ServiceAccountsState } from 'app/types';
export const initialState: ServiceAccountsState = {
serviceAccounts: [] as ServiceAccountDTO[],
@ -12,6 +12,7 @@ export const initialState: ServiceAccountsState = {
export const initialStateProfile: ServiceAccountProfileState = {
serviceAccount: {} as ServiceAccountDTO,
isLoading: true,
tokens: [] as ApiKey[],
};
export const serviceAccountProfileSlice = createSlice({
@ -21,6 +22,9 @@ export const serviceAccountProfileSlice = createSlice({
serviceAccountLoaded: (state, action: PayloadAction<ServiceAccountDTO>): ServiceAccountProfileState => {
return { ...state, serviceAccount: action.payload, isLoading: false };
},
serviceAccountTokensLoaded: (state, action: PayloadAction<ApiKey[]>): ServiceAccountProfileState => {
return { ...state, tokens: action.payload, isLoading: false };
},
},
});
@ -44,7 +48,7 @@ const serviceAccountsSlice = createSlice({
export const { setServiceAccountsSearchQuery, setServiceAccountsSearchPage, serviceAccountsLoaded } =
serviceAccountsSlice.actions;
export const { serviceAccountLoaded } = serviceAccountProfileSlice.actions;
export const { serviceAccountLoaded, serviceAccountTokensLoaded } = serviceAccountProfileSlice.actions;
export const serviceAccountProfileReducer = serviceAccountProfileSlice.reducer;
export const serviceAccountsReducer = serviceAccountsSlice.reducer;

View File

@ -12,7 +12,7 @@ import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/
import { contextSrv } from 'app/core/services/context_srv';
import { getLiveRoutes } from 'app/features/live/pages/routes';
import { getAlertingRoutes } from 'app/features/alerting/routes';
import ServiceAccountPage from 'app/features/serviceaccounts/ServiceAccountPage';
import { ServiceAccountPage } from 'app/features/serviceaccounts/ServiceAccountPage';
export const extraRoutes: RouteDescriptor[] = [];

View File

@ -1,5 +1,5 @@
import { WithAccessControlMetadata } from '@grafana/data';
import { OrgRole } from '.';
import { ApiKey, OrgRole } from '.';
export interface OrgServiceAccount {
serviceAccountId: number;
@ -39,6 +39,7 @@ export interface ServiceAccountDTO extends WithAccessControlMetadata {
export interface ServiceAccountProfileState {
serviceAccount: ServiceAccountDTO;
isLoading: boolean;
tokens: ApiKey[];
}
export interface ServiceAccountsState {