diff --git a/pkg/services/pluginsintegration/clientmiddleware/forwarded_percona_token_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/forwarded_percona_token_middleware.go index 0d96930b66c5e..d40a783f89075 100644 --- a/pkg/services/pluginsintegration/clientmiddleware/forwarded_percona_token_middleware.go +++ b/pkg/services/pluginsintegration/clientmiddleware/forwarded_percona_token_middleware.go @@ -39,9 +39,11 @@ func (m *PerconaForwarderHTTPClientMiddleware) applyHeaders(ctx context.Context, return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { reqCtx := contexthandler.FromContext(ctx) - xProxyFilter := reqCtx.Req.Header.Get("X-Proxy-Filter") - if xProxyFilter != "" { - req.Header.Set("X-Proxy-Filter", xProxyFilter) + if reqCtx != nil && reqCtx.Req != nil && reqCtx.Req.Header != nil { + xProxyFilter := reqCtx.Req.Header.Get("X-Proxy-Filter") + if xProxyFilter != "" { + req.Header.Set("X-Proxy-Filter", xProxyFilter) + } } return next.RoundTrip(req) diff --git a/pkg/services/serviceaccounts/database/store.go b/pkg/services/serviceaccounts/database/store.go index ff8105401dc9a..a96bd4bec1994 100644 --- a/pkg/services/serviceaccounts/database/store.go +++ b/pkg/services/serviceaccounts/database/store.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/nameutil" ) type ServiceAccountsStoreImpl struct { @@ -58,8 +59,8 @@ func generateLogin(prefix string, orgId int64, name string) string { // CreateServiceAccount creates service account func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, orgId int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { - name := saForm.Name - login := generateLogin(serviceaccounts.ServiceAccountPrefix, orgId, saForm.Name) + name := nameutil.SanitizeSAName(saForm.Name) + login := nameutil.SanitizeSAName(generateLogin(serviceaccounts.ServiceAccountPrefix, orgId, saForm.Name)) isDisabled := false role := org.RoleViewer if saForm.IsDisabled != nil { @@ -481,8 +482,8 @@ func (s *ServiceAccountsStoreImpl) MigrateApiKey(ctx context.Context, orgId int6 func (s *ServiceAccountsStoreImpl) CreateServiceAccountFromApikey(ctx context.Context, key *apikey.APIKey) error { prefix := "sa-autogen" cmd := user.CreateUserCommand{ - Login: generateLogin(prefix, key.OrgID, key.Name), - Name: fmt.Sprintf("%v-%v", prefix, key.Name), + Login: nameutil.SanitizeSAName(generateLogin(prefix, key.OrgID, key.Name)), + Name: nameutil.SanitizeSAName(fmt.Sprintf("%v-%v", prefix, key.Name)), OrgID: key.OrgID, DefaultOrgRole: string(key.Role), IsServiceAccount: true, diff --git a/pkg/services/serviceaccounts/database/token_store.go b/pkg/services/serviceaccounts/database/token_store.go index 173f7a27177d1..1bc6346eacdd2 100644 --- a/pkg/services/serviceaccounts/database/token_store.go +++ b/pkg/services/serviceaccounts/database/token_store.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/apikey" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/util/nameutil" ) const maxRetrievedTokens = 300 @@ -50,7 +51,7 @@ func (s *ServiceAccountsStoreImpl) AddServiceAccountToken(ctx context.Context, s } addKeyCmd := &apikey.AddCommand{ - Name: cmd.Name, + Name: nameutil.SanitizeSAName(cmd.Name), Role: org.RoleViewer, OrgID: cmd.OrgId, Key: cmd.Key, diff --git a/pkg/util/nameutil/serviceaccount.go b/pkg/util/nameutil/serviceaccount.go new file mode 100644 index 0000000000000..e2d6b6ae601a3 --- /dev/null +++ b/pkg/util/nameutil/serviceaccount.go @@ -0,0 +1,23 @@ +package nameutil + +import ( + "crypto/md5" + "fmt" +) + +// SanitizeSAName is used for sanitize name and it's length for service accounts. +// Max length of service account name is 190 chars (limit in Grafana Postgres DB). +// However, prefix added by grafana is counted too. Prefix is sa-{orgID}-. +// Bare minimum is 5 chars reserved (orgID is <10, like sa-1-) and could be more depends +// on orgID number. Let's reserve 10 chars. It will cover almost one million orgIDs. +// Sanitizing, ensure its length by hashing postfix when length is exceeded. +// MD5 is used because it has fixed length 32 chars. +// +// Be aware that the same method is implemented in the PMM repo, and all changes should be reflected there as well! +func SanitizeSAName(name string) string { + if len(name) <= 180 { + return name + } + + return fmt.Sprintf("%s%x", name[:148], md5.Sum([]byte(name[148:]))) //nolint:gosec +} diff --git a/pkg/util/nameutil/serviceaccount_test.go b/pkg/util/nameutil/serviceaccount_test.go new file mode 100644 index 0000000000000..b24714719ef6d --- /dev/null +++ b/pkg/util/nameutil/serviceaccount_test.go @@ -0,0 +1,32 @@ +package nameutil + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSanitizeSAName(t *testing.T) { + // max possible length without hashing + len180, err := generateRandomString(180) + require.NoError(t, err) + require.Equal(t, len180, SanitizeSAName(len180)) + + // too long length - postfix hashed + len200, err := generateRandomString(200) + require.NoError(t, err) + len200sanitized := SanitizeSAName(len200) + require.Equal(t, fmt.Sprintf("%s%s", len200[:148], len200sanitized[148:]), len200sanitized) +} + +func generateRandomString(length int) (string, error) { + buffer := make([]byte, length) + _, err := rand.Read(buffer) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(buffer)[:length], nil +} diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index 7adf963eed4ac..9f2395b586ab3 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -118,15 +118,27 @@ export class LoginCtrl extends PureComponent { }; toGrafana = () => { + // @PERCONA + // trigger apikey migration on login + const constructUrl = (url: string) => { + const forceApiKeyMigration = 'force-apikey-migration=true'; + + if (url.includes('?')) { + return url + '&' + forceApiKeyMigration; + } else { + return url + '?' + forceApiKeyMigration; + } + }; + // Use window.location.href to force page reload if (this.result?.redirectUrl) { if (config.appSubUrl !== '' && !this.result.redirectUrl.startsWith(config.appSubUrl)) { - window.location.assign(config.appSubUrl + this.result.redirectUrl); + window.location.assign(constructUrl(config.appSubUrl + this.result.redirectUrl)); } else { - window.location.assign(this.result.redirectUrl); + window.location.assign(constructUrl(this.result.redirectUrl)); } } else { - window.location.assign(config.appSubUrl + '/'); + window.location.assign(constructUrl(config.appSubUrl + '/')); } }; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx index 6431a7a59ecb4..8869cc267c827 100644 --- a/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaBootstrapper.tsx @@ -19,6 +19,7 @@ import { useAppDispatch } from 'app/store/store'; import { useSelector } from 'app/types'; import { Telemetry } from '../../../ui-events/components/Telemetry'; +import { useMigrator } from '../../core/hooks/migrator'; import usePerconaTour from '../../core/hooks/tour'; import { checkUpdatesAction } from '../../core/reducers/updates'; import { logger } from '../../helpers/logger'; @@ -27,6 +28,7 @@ import { isPmmAdmin } from '../../helpers/permissions'; import { Messages } from './PerconaBootstrapper.messages'; import { getStyles } from './PerconaBootstrapper.styles'; import { PerconaBootstrapperProps } from './PerconaBootstrapper.types'; +import PerconaMigrator from './PerconaMigrator'; import PerconaNavigation from './PerconaNavigation/PerconaNavigation'; import PerconaTourBootstrapper from './PerconaTour'; import PerconaUpdateVersion from './PerconaUpdateVersion/PerconaUpdateVersion'; @@ -42,6 +44,7 @@ export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => { const { user } = config.bootData; const { isSignedIn } = user; const theme = useTheme2(); + const { migrationSummaryVisible } = useMigrator(); const dismissModal = () => { setModalIsOpen(false); @@ -114,7 +117,9 @@ export const PerconaBootstrapper = ({ onReady }: PerconaBootstrapperProps) => { {isSignedIn && } - {updateAvailable && showUpdateModal && !isLoadingUpdates ? ( + {isSignedIn && isPmmAdmin(user) && migrationSummaryVisible ? ( + + ) : updateAvailable && showUpdateModal && !isLoadingUpdates ? ( ) : ( isSignedIn && diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/PerconaMigrator.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/PerconaMigrator.tsx new file mode 100644 index 0000000000000..08cc390a37482 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/PerconaMigrator.tsx @@ -0,0 +1,19 @@ +import React, { FC } from 'react'; + +import { useMigrator } from 'app/percona/shared/core/hooks/migrator'; +import { useSelector } from 'app/types'; + +import MigrationSummary from './components/MigrationSummary'; + +const PerconaMigrator: FC = () => { + const migrationResult = useSelector((state) => state.apiKeys.migrationResult); + const { migrationSummaryVisible, dismissSummary } = useMigrator(); + + if (!migrationResult || !migrationSummaryVisible) { + return; + } + + return ; +}; + +export default PerconaMigrator; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.messages.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.messages.ts new file mode 100644 index 0000000000000..e0c8eee362238 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.messages.ts @@ -0,0 +1,5 @@ +export const Messages = { + id: (id: number) => `API Key ID ${id}`, + name: 'Name: ', + error: 'Error: ', +}; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.tsx new file mode 100644 index 0000000000000..5c096ac39f125 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.tsx @@ -0,0 +1,35 @@ +import React, { FC } from 'react'; + +import { Text } from '@grafana/ui'; + +import { Messages } from './FailedMigrationRow.messages'; +import { FailedMigrationRowProps } from './FailedMigrationRow.types'; +import { parseDetail } from './FailedMigrationRow.utils'; + +const FailedMigrationRow: FC = ({ id, details }) => { + const { name, error } = parseDetail(details); + + return ( +
  • + {Messages.id(id)} +
      + {!!name && ( +
    • + + {Messages.name} + {name} + +
    • + )} +
    • + + {Messages.error} + {error} + +
    • +
    +
  • + ); +}; + +export default FailedMigrationRow; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.types.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.types.ts new file mode 100644 index 0000000000000..3855a4a010679 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.types.ts @@ -0,0 +1,4 @@ +export interface FailedMigrationRowProps { + id: number; + details: string; +} diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.utils.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.utils.ts new file mode 100644 index 0000000000000..b902c22c5d473 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/FailedMigrationRow.utils.ts @@ -0,0 +1,17 @@ +import { capitalizeText } from 'app/percona/shared/helpers/capitalizeText'; + +export const parseDetail = (detail: string) => { + if (!detail.includes('- Error: ')) { + return { + name: '', + error: detail, + }; + } + + const [name, error] = detail.split('- Error: '); + + return { + name: name.replace('API key name: ', ''), + error: capitalizeText(error), + }; +}; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.constants.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.constants.ts new file mode 100644 index 0000000000000..158894dc3de31 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.constants.ts @@ -0,0 +1,6 @@ +// todo: use a shortened link +export const SERVICE_ACCOUNTS_DOCS_LINK = + 'https://docs.percona.com/percona-monitoring-and-management/3/api/authentication.html?h=service+accounts'; + +// todo: use a shortened link +export const CONTACT_SUPPORT_LINK = 'https://www.percona.com/about/contact'; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.messages.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.messages.ts new file mode 100644 index 0000000000000..055732fd31514 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.messages.ts @@ -0,0 +1,12 @@ +export const Messages = { + title: 'API migration status', + description: + 'Migration of API keys complete. The following keys could not be converted to service accounts but remain fully functional:', + failed: (failed: number, total: number) => `Failed conversions (${failed} of ${total} total)`, + needHelp: 'Need help? See our ', + documentation: 'documentation', + or: ' or ', + contactSupport: 'contact support', + dot: '.', + close: 'Close', +}; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.styles.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.styles.ts new file mode 100644 index 0000000000000..e24c6fd858151 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.styles.ts @@ -0,0 +1,31 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme2) => ({ + modal: css` + width: auto; + min-width: 750px; + max-width: 75vw; + `, + migrationSummary: css` + padding: ${theme.spacing(2)}; + padding-top: 0; + `, + list: css` + margin-top: ${theme.spacing(0.5)}; + margin-bottom: ${theme.spacing(2)}; + + ol, + ul { + margin-left: ${theme.spacing(2)}; + + li > * { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + `, +}); diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.tsx b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.tsx new file mode 100644 index 0000000000000..b0cb1d5c73ec9 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/components/MigrationSummary.tsx @@ -0,0 +1,51 @@ +import React, { FC } from 'react'; + +import { Button, Modal, TextLink, useStyles2 } from '@grafana/ui'; +import { MigrationSummaryProps } from 'app/features/api-keys/ApiKeysPage'; + +import FailedMigrationRow from './FailedMigrationRow'; +import { CONTACT_SUPPORT_LINK, SERVICE_ACCOUNTS_DOCS_LINK } from './MigrationSummary.constants'; +import { Messages } from './MigrationSummary.messages'; +import { getStyles } from './MigrationSummary.styles'; + +const MigrationSummary: FC = ({ visible, data, onDismiss }) => { + const styles = useStyles2(getStyles); + + return ( + +

    {Messages.description}

    + {Messages.failed(data.failed, data.total)} +
    +
      + {data.failedApikeyIDs.map((id, idx) => ( + + ))} +
    +
    +

    + {Messages.needHelp} + + {Messages.documentation} + + {Messages.or} + + {Messages.contactSupport} + + {Messages.dot} +

    + + + +
    + ); +}; + +export default MigrationSummary; diff --git a/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/index.ts b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/index.ts new file mode 100644 index 0000000000000..e56e803db8a71 --- /dev/null +++ b/public/app/percona/shared/components/PerconaBootstrapper/PerconaMigrator/index.ts @@ -0,0 +1,3 @@ +import PerconaMigrator from './PerconaMigrator'; + +export default PerconaMigrator; diff --git a/public/app/percona/shared/core/hooks/migrator.ts b/public/app/percona/shared/core/hooks/migrator.ts new file mode 100644 index 0000000000000..8f2979fac3fd0 --- /dev/null +++ b/public/app/percona/shared/core/hooks/migrator.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router'; + +import { AppEvents } from '@grafana/data'; +import { appEvents } from 'app/core/core'; +import { migrateAll } from 'app/features/api-keys/state/actions'; +import { useAppDispatch } from 'app/store/store'; +import { useSelector } from 'app/types'; + +import { snoozeApiKeyMigrationSummary } from '../reducers/user/user'; +import { getPerconaUser } from '../selectors'; + +export const useMigrator = () => { + const migrationResult = useSelector((state) => state.apiKeys.migrationResult); + const { snoozedApiKeysMigration } = useSelector(getPerconaUser); + const dispatch = useAppDispatch(); + const location = useLocation(); + const migrationSummaryVisible = !snoozedApiKeysMigration && migrationResult && migrationResult.failed > 0; + + useEffect(() => { + if (!location.search.includes('force-apikey-migration=true')) { + return; + } + + dispatch(migrateAll()); + }, [location.search, dispatch]); + + useEffect(() => { + if (migrationResult && migrationResult.total > 0 && migrationResult.failed === 0) { + // give some time for the app to load + setTimeout(() => { + appEvents.emit(AppEvents.alertSuccess, ['All api keys successfully migrated']); + }, 1000); + } + }, [migrationResult]); + + const dismissSummary = () => { + dispatch(snoozeApiKeyMigrationSummary(true)); + }; + + return { migrationSummaryVisible, dismissSummary }; +}; diff --git a/public/app/percona/shared/core/reducers/user/user.ts b/public/app/percona/shared/core/reducers/user/user.ts index d8391e0f948fe..022a179881857 100644 --- a/public/app/percona/shared/core/reducers/user/user.ts +++ b/public/app/percona/shared/core/reducers/user/user.ts @@ -9,6 +9,7 @@ import { toUserDetailsModel } from './user.utils'; export const initialUserState: PerconaUserState = { userId: 0, + snoozedApiKeysMigration: true, productTourCompleted: true, alertingTourCompleted: true, isAuthorized: false, @@ -86,6 +87,16 @@ export const setAlertingTourCompleted = createAsyncThunk( } ); +export const snoozeApiKeyMigrationSummary = createAsyncThunk( + 'percona/snoozeApiKeyMigrationSummary', + async (dismiss: boolean, thunkAPI): Promise => { + const res = await UserService.setApiKeyMigrationSnoozed(dismiss); + const details = toUserDetailsModel(res); + thunkAPI.dispatch(setUserDetails(details)); + return details; + } +); + export const { setAuthorized, setIsPlatformUser, setUserDetails } = perconaUserSlice.actions; export default perconaUserSlice.reducer; diff --git a/public/app/percona/shared/core/reducers/user/user.types.ts b/public/app/percona/shared/core/reducers/user/user.types.ts index c8a950d900c5a..ed9a3fe1e6545 100644 --- a/public/app/percona/shared/core/reducers/user/user.types.ts +++ b/public/app/percona/shared/core/reducers/user/user.types.ts @@ -1,5 +1,6 @@ export interface UserDetails { userId: number; + snoozedApiKeysMigration: boolean; productTourCompleted: boolean; alertingTourCompleted: boolean; snoozedPmmVersion?: string; diff --git a/public/app/percona/shared/core/reducers/user/user.utils.ts b/public/app/percona/shared/core/reducers/user/user.utils.ts index 5e972077d1bd8..d539c4a993246 100644 --- a/public/app/percona/shared/core/reducers/user/user.utils.ts +++ b/public/app/percona/shared/core/reducers/user/user.utils.ts @@ -6,5 +6,6 @@ export const toUserDetailsModel = (res: UserDetailsResponse): UserDetails => ({ userId: res.user_id, productTourCompleted: !!res.product_tour_completed, alertingTourCompleted: !!res.alerting_tour_completed, + snoozedApiKeysMigration: !!res.snoozed_api_keys_migration, snoozedPmmVersion: res.snoozed_pmm_version, }); diff --git a/public/app/percona/shared/services/user/User.service.ts b/public/app/percona/shared/services/user/User.service.ts index eeefbe6be117c..030c54a462c50 100644 --- a/public/app/percona/shared/services/user/User.service.ts +++ b/public/app/percona/shared/services/user/User.service.ts @@ -26,6 +26,10 @@ export const UserService = { const payload: UserDetailsPutPayload = { snoozed_pmm_version: version }; return await api.put('/v1/users/me', payload); }, + async setApiKeyMigrationSnoozed(shouldSnooze: boolean): Promise { + const payload: UserDetailsPutPayload = { snoozed_api_keys_migration: shouldSnooze }; + return await api.put('/v1/users/me', payload); + }, async getUsersList(): Promise { return await api.get('/v1/users'); }, diff --git a/public/app/percona/shared/services/user/User.types.ts b/public/app/percona/shared/services/user/User.types.ts index a31cadb6fc239..e143824d3b319 100644 --- a/public/app/percona/shared/services/user/User.types.ts +++ b/public/app/percona/shared/services/user/User.types.ts @@ -6,6 +6,7 @@ export interface UserDetailsResponse { user_id: number; product_tour_completed?: boolean; alerting_tour_completed?: boolean; + snoozed_api_keys_migration?: boolean; snoozed_pmm_version?: string; } @@ -13,6 +14,7 @@ export interface UserDetailsPutPayload { product_tour_completed?: boolean; alerting_tour_completed?: boolean; snoozed_pmm_version?: string; + snoozed_api_keys_migration?: boolean; } export interface UserListItemResponse {