mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
f885c2ede9
commit
8c49e96439
@ -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,
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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)};
|
||||
`,
|
||||
});
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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[] = [];
|
||||
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user