mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
41
public/app/features/api-keys/APIKeysMigratedCard.tsx
Normal file
41
public/app/features/api-keys/APIKeysMigratedCard.tsx
Normal 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)};
|
||||
`,
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)};
|
||||
`,
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user