ServiceAccounts: API keys migration (#50002)

* ServiceAccounts: able to get upgrade status

* Banner with API keys migration info

* Show API keys migration info on Service accounts page

* Migrate individual API keys

* Use transaction for key migration

* Migrate all api keys to service accounts

* Hide api keys after migration

* Migrate API keys separately for each org

* Revert API key

* Revert key API method

* Rename migration actions and reducers

* Fix linter errors

* Tests for migrating single API key

* Tests for migrating all api keys

* More tests

* Fix reverting tokens

* API: rename convert to migrate

* Add api route descriptions to methods

* rearrange methods in api.go

* Refactor: rename and move some methods

* Prevent assigning tokens to non-existing service accounts

* Refactor: ID TO Id

* Refactor: fix error message

* Delete service account if migration failed

* Fix linter errors
This commit is contained in:
Alexander Zobnin
2022-06-15 15:59:40 +03:00
committed by GitHub
parent b47ec36d0d
commit f82264c2b1
31 changed files with 961 additions and 318 deletions

View File

@@ -0,0 +1,41 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LinkButton, useStyles2 } from '@grafana/ui';
interface Props {
onHideApiKeys: () => void;
}
export const APIKeysMigratedCard = ({ onHideApiKeys }: Props): JSX.Element => {
const styles = useStyles2(getStyles);
return (
<Alert title="API keys were migrated to Service accounts. This tab is deprecated." severity="info">
<div className={styles.text}>
We have upgraded your API keys into more powerful Service accounts and tokens. All your keys are safe and
working - you will find them inside respective service accounts. Keys are now called tokens.
</div>
<div className={styles.actionRow}>
<LinkButton className={styles.actionButton} href="org/serviceaccounts" onClick={onHideApiKeys}>
Go to service accounts tab and never show API keys tab again
</LinkButton>
<a href="org/serviceaccounts">Go to service accounts tab</a>
</div>
</Alert>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
text: css`
margin-bottom: ${theme.spacing(2)};
`,
actionRow: css`
display: flex;
align-items: center;
`,
actionButton: css`
margin-right: ${theme.spacing(2)};
`,
});

View File

@@ -25,9 +25,13 @@ jest.mock('app/core/core', () => {
const setup = (propOverrides: Partial<Props>) => {
const loadApiKeysMock = jest.fn();
const deleteApiKeyMock = jest.fn();
const migrateApiKeyMock = jest.fn();
const addApiKeyMock = jest.fn();
const migrateAllMock = jest.fn();
const toggleIncludeExpiredMock = jest.fn();
const setSearchQueryMock = mockToolkitActionCreator(setSearchQuery);
const getApiKeysMigrationStatusMock = jest.fn();
const hideApiKeysMock = jest.fn();
const props: Props = {
navModel: {
main: {
@@ -44,12 +48,17 @@ const setup = (propOverrides: Partial<Props>) => {
deleteApiKey: deleteApiKeyMock,
setSearchQuery: setSearchQueryMock,
addApiKey: addApiKeyMock,
getApiKeysMigrationStatus: getApiKeysMigrationStatusMock,
migrateApiKey: migrateApiKeyMock,
migrateAll: migrateAllMock,
hideApiKeys: hideApiKeysMock,
apiKeysCount: 0,
timeZone: 'utc',
includeExpired: false,
includeExpiredDisabled: false,
toggleIncludeExpired: toggleIncludeExpiredMock,
canCreate: true,
apiKeysMigrated: false,
};
Object.assign(props, propOverrides);

View File

@@ -14,12 +14,23 @@ import { getTimeZone } from 'app/features/profile/state/selectors';
import { AccessControlAction, ApiKey, NewApiKey, StoreState } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events';
import { APIKeysMigratedCard } from './APIKeysMigratedCard';
import { ApiKeysActionBar } from './ApiKeysActionBar';
import { ApiKeysAddedModal } from './ApiKeysAddedModal';
import { ApiKeysController } from './ApiKeysController';
import { ApiKeysForm } from './ApiKeysForm';
import { ApiKeysTable } from './ApiKeysTable';
import { addApiKey, deleteApiKey, loadApiKeys, toggleIncludeExpired } from './state/actions';
import { MigrateToServiceAccountsCard } from './MigrateToServiceAccountsCard';
import {
addApiKey,
deleteApiKey,
migrateApiKey,
migrateAll,
loadApiKeys,
toggleIncludeExpired,
getApiKeysMigrationStatus,
hideApiKeys,
} from './state/actions';
import { setSearchQuery } from './state/reducers';
import { getApiKeys, getApiKeysCount, getIncludeExpired, getIncludeExpiredDisabled } from './state/selectors';
@@ -36,15 +47,20 @@ function mapStateToProps(state: StoreState) {
includeExpired: getIncludeExpired(state.apiKeys),
includeExpiredDisabled: getIncludeExpiredDisabled(state.apiKeys),
canCreate: canCreate,
apiKeysMigrated: state.apiKeys.apiKeysMigrated,
};
}
const mapDispatchToProps = {
loadApiKeys,
deleteApiKey,
migrateApiKey,
migrateAll,
setSearchQuery,
toggleIncludeExpired,
addApiKey,
getApiKeysMigrationStatus,
hideApiKeys,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
@@ -64,6 +80,7 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
componentDidMount() {
this.fetchApiKeys();
this.props.getApiKeysMigrationStatus();
}
async fetchApiKeys() {
@@ -74,6 +91,14 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
this.props.deleteApiKey(key.id!);
};
onMigrateAll = () => {
this.props.migrateAll();
};
onMigrateApiKey = (key: ApiKey) => {
this.props.migrateApiKey(key.id!);
};
onSearchQueryChange = (value: string) => {
this.props.setSearchQuery(value);
};
@@ -116,6 +141,11 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
}
};
onHideApiKeys = async () => {
await this.props.hideApiKeys();
window.location.reload();
};
render() {
const {
hasFetched,
@@ -127,6 +157,7 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
includeExpired,
includeExpiredDisabled,
canCreate,
apiKeysMigrated,
} = this.props;
if (!hasFetched) {
@@ -142,18 +173,17 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
<Page.Contents isLoading={false}>
<ApiKeysController>
{({ isAdding, toggleIsAdding }) => {
const showCTA = !isAdding && apiKeysCount === 0;
const showCTA = !isAdding && apiKeysCount === 0 && !apiKeysMigrated;
const showTable = apiKeysCount > 0;
return (
<>
{/* TODO: enable when API keys to service accounts migration is ready
{config.featureToggles.serviceAccounts && (
<Alert title="Switch from API keys to Service accounts" severity="info">
Service accounts give you more control. API keys will be automatically migrated into tokens inside
respective service accounts. The current API keys will still work, but will be called tokens and
you will find them in the detail view of a respective service account.
</Alert>
)} */}
{/* TODO: remove feature flag check before GA */}
{config.featureToggles.serviceAccounts && !apiKeysMigrated && (
<MigrateToServiceAccountsCard onMigrate={this.onMigrateAll} />
)}
{config.featureToggles.serviceAccounts && apiKeysMigrated && (
<APIKeysMigratedCard onHideApiKeys={this.onHideApiKeys} />
)}
{showCTA ? (
<EmptyListCTA
title="You haven't added any API keys yet."
@@ -183,7 +213,12 @@ export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
<InlineField disabled={includeExpiredDisabled} label="Include expired keys">
<InlineSwitch id="showExpired" value={includeExpired} onChange={this.onIncludeExpiredChange} />
</InlineField>
<ApiKeysTable apiKeys={apiKeys} timeZone={timeZone} onDelete={this.onDeleteApiKey} />
<ApiKeysTable
apiKeys={apiKeys}
timeZone={timeZone}
onMigrate={this.onMigrateApiKey}
onDelete={this.onDeleteApiKey}
/>
</VerticalGroup>
) : null}
</>

View File

@@ -2,7 +2,8 @@ import { css } from '@emotion/css';
import React, { FC } from 'react';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
import { DeleteButton, Icon, IconName, Tooltip, useTheme2 } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { Button, DeleteButton, HorizontalGroup, Icon, IconName, Tooltip, useTheme2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
@@ -12,9 +13,10 @@ interface Props {
apiKeys: ApiKey[];
timeZone: TimeZone;
onDelete: (apiKey: ApiKey) => void;
onMigrate: (apiKey: ApiKey) => void;
}
export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete }) => {
export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete, onMigrate }) => {
const theme = useTheme2();
const styles = getStyles(theme);
@@ -47,12 +49,19 @@ export const ApiKeysTable: FC<Props> = ({ apiKeys, timeZone, onDelete }) => {
)}
</td>
<td>
<DeleteButton
aria-label="Delete API key"
size="sm"
onConfirm={() => onDelete(key)}
disabled={!contextSrv.hasPermissionInMetadata(AccessControlAction.ActionAPIKeysDelete, key)}
/>
<HorizontalGroup justify="flex-end">
{config.featureToggles.serviceAccounts && (
<Button size="sm" onClick={() => onMigrate(key)}>
Migrate
</Button>
)}
<DeleteButton
aria-label="Delete API key"
size="sm"
onConfirm={() => onDelete(key)}
disabled={!contextSrv.hasPermissionInMetadata(AccessControlAction.ActionAPIKeysDelete, key)}
/>
</HorizontalGroup>
</td>
</tr>
);

View File

@@ -0,0 +1,45 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, useStyles2 } from '@grafana/ui';
interface Props {
onMigrate: () => void;
disabled?: boolean;
}
export const MigrateToServiceAccountsCard = ({ onMigrate, disabled }: Props): JSX.Element => {
const styles = useStyles2(getStyles);
return (
<Alert title="Switch from API keys to Service accounts" severity="info">
<div className={styles.text}>
Service accounts give you more control. API keys will be automatically migrated into tokens inside respective
service accounts. The current API keys will still work, but will be called tokens and you will find them in the
detail view of a respective service account.
</div>
<div className={styles.actionRow}>
{!disabled && (
<Button className={styles.actionButton} onClick={onMigrate}>
Migrate now
</Button>
)}
<span>Read more about Service accounts and how to turn them on</span>
</div>
</Alert>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
text: css`
margin-bottom: ${theme.spacing(2)};
`,
actionRow: css`
display: flex;
align-items: center;
`,
actionButton: css`
margin-right: ${theme.spacing(2)};
`,
});

View File

@@ -1,7 +1,14 @@
import { getBackendSrv } from 'app/core/services/backend_srv';
import { config } from '@grafana/runtime';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { ApiKey, ThunkResult } from 'app/types';
import { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery } from './reducers';
import {
apiKeysLoaded,
includeExpiredToggled,
isFetching,
apiKeysMigrationStatusLoaded,
setSearchQuery,
} from './reducers';
export function addApiKey(apiKey: ApiKey, openModal: (key: string) => void): ThunkResult<void> {
return async (dispatch) => {
@@ -31,6 +38,43 @@ export function deleteApiKey(id: number): ThunkResult<void> {
};
}
export function migrateApiKey(id: number): ThunkResult<void> {
return async (dispatch) => {
try {
await getBackendSrv().post(`/api/serviceaccounts/migrate/${id}`);
} finally {
dispatch(loadApiKeys());
}
};
}
export function migrateAll(): ThunkResult<void> {
return async (dispatch) => {
try {
await getBackendSrv().post('/api/serviceaccounts/migrate');
} finally {
dispatch(getApiKeysMigrationStatus());
dispatch(loadApiKeys());
}
};
}
export function getApiKeysMigrationStatus(): ThunkResult<void> {
return async (dispatch) => {
// TODO: remove when service account enabled by default (or use another way to detect if it's enabled)
if (config.featureToggles.serviceAccounts) {
const result = await getBackendSrv().get('/api/serviceaccounts/migrationstatus');
dispatch(apiKeysMigrationStatusLoaded(!!result?.migrated));
}
};
}
export function hideApiKeys(): ThunkResult<void> {
return async (dispatch) => {
await getBackendSrv().post('/api/serviceaccounts/hideApiKeys');
};
}
export function toggleIncludeExpired(): ThunkResult<void> {
return (dispatch) => {
dispatch(includeExpiredToggled());

View File

@@ -8,6 +8,7 @@ export const initialApiKeysState: ApiKeysState = {
keys: [],
keysIncludingExpired: [],
searchQuery: '',
apiKeysMigrated: false,
};
const apiKeysSlice = createSlice({
@@ -22,6 +23,9 @@ const apiKeysSlice = createSlice({
: state.includeExpired;
return { ...state, hasFetched: true, keys, keysIncludingExpired, includeExpired };
},
apiKeysMigrationStatusLoaded: (state, action): ApiKeysState => {
return { ...state, apiKeysMigrated: action.payload };
},
setSearchQuery: (state, action): ApiKeysState => {
return { ...state, searchQuery: action.payload };
},
@@ -34,7 +38,8 @@ const apiKeysSlice = createSlice({
},
});
export const { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery } = apiKeysSlice.actions;
export const { apiKeysLoaded, includeExpiredToggled, isFetching, setSearchQuery, apiKeysMigrationStatusLoaded } =
apiKeysSlice.actions;
export const apiKeysReducer = apiKeysSlice.reducer;

View File

@@ -16,6 +16,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const keyCount = getApiKeysCount(mockState);
expect(keyCount).toBe(5);
@@ -28,6 +29,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const keyCount = getApiKeysCount(mockState);
expect(keyCount).toBe(8);
@@ -43,6 +45,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys).toEqual(mockKeys);
@@ -55,6 +58,7 @@ describe('API Keys selectors', () => {
searchQuery: '5',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys.length).toEqual(1);
@@ -69,6 +73,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys).toEqual(mockKeysIncludingExpired);
@@ -81,6 +86,7 @@ describe('API Keys selectors', () => {
searchQuery: '5',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const keys = getApiKeys(mockState);
expect(keys.length).toEqual(1);
@@ -96,6 +102,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const includeExpired = getIncludeExpired(mockState);
expect(includeExpired).toBe(true);
@@ -108,6 +115,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const includeExpired = getIncludeExpired(mockState);
expect(includeExpired).toBe(false);
@@ -122,6 +130,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: true,
apiKeysMigrated: false,
};
const includeExpiredDisabled = getIncludeExpiredDisabled(mockState);
expect(includeExpiredDisabled).toBe(true);
@@ -134,6 +143,7 @@ describe('API Keys selectors', () => {
searchQuery: '',
hasFetched: true,
includeExpired: false,
apiKeysMigrated: false,
};
const includeExpiredDisabled = getIncludeExpired(mockState);
expect(includeExpiredDisabled).toBe(false);