diff --git a/src/sentry/api/endpoints/project_app_store_connect_credentials.py b/src/sentry/api/endpoints/project_app_store_connect_credentials.py index 4a6b495a6c5808..3bd22b63f18841 100644 --- a/src/sentry/api/endpoints/project_app_store_connect_credentials.py +++ b/src/sentry/api/endpoints/project_app_store_connect_credentials.py @@ -39,7 +39,11 @@ from sentry import features from sentry.api.bases.project import ProjectEndpoint, ProjectPermission, StrictProjectPermission -from sentry.api.exceptions import AppConnectAuthenticationError, AppConnectMultipleSourcesError +from sentry.api.exceptions import ( + AppConnectAuthenticationError, + AppConnectForbiddenError, + AppConnectMultipleSourcesError, +) from sentry.api.fields.secret import SecretField, validate_secret from sentry.lang.native import appconnect from sentry.lang.native.symbolicator import redact_source_secrets, secret_fields @@ -411,3 +415,119 @@ def get(self, request: Request, project: Project, credentials_id: str) -> Respon }, status=200, ) + + +class AppStoreConnectStatusEndpoint(ProjectEndpoint): # type: ignore + """Returns a summary of the project's App Store Connect configuration + and builds. + + ``GET projects/{org_slug}/{proj_slug}/appstoreconnect/status/`` + + Response: + ```json + { + "abc123": { + "credentials": { status: "valid" } | { status: "invalid", code: "app-connect-forbidden-error" }, + "pendingDownloads": 123, + "latestBuildVersion: "9.8.7" | null, + "latestBuildNumber": "987000" | null, + "lastCheckedBuilds": "YYYY-MM-DDTHH:MM:SS.SSSSSSZ" | null + }, + "...": { + "credentials": ..., + "pendingDownloads": ..., + "latestBuildVersion: ..., + "latestBuildNumber": ..., + "lastCheckedBuilds": ... + }, + ... + } + ``` + + * ``pendingDownloads`` is the number of pending build dSYM downloads. + + * ``latestBuildVersion`` and ``latestBuildNumber`` together form a unique identifier for + the latest build recognized by Sentry. + + * ``lastCheckedBuilds`` is when sentry last checked for new builds, regardless + of whether there were any or no builds in App Store Connect at the time. + """ + + permission_classes = [ProjectPermission] + + def get(self, request: Request, project: Project) -> Response: + config_ids = appconnect.AppStoreConnectConfig.all_config_ids(project) + statuses = {} + for config_id in config_ids: + try: + symbol_source_cfg = appconnect.AppStoreConnectConfig.from_project_config( + project, config_id + ) + except KeyError: + continue + + credentials = appstore_connect.AppConnectCredentials( + key_id=symbol_source_cfg.appconnectKey, + key=symbol_source_cfg.appconnectPrivateKey, + issuer_id=symbol_source_cfg.appconnectIssuer, + ) + + session = requests.Session() + + try: + apps = appstore_connect.get_apps(session, credentials) + except appstore_connect.UnauthorizedError: + asc_credentials = { + "status": "invalid", + "code": AppConnectAuthenticationError.code, + } + except appstore_connect.ForbiddenError: + asc_credentials = {"status": "invalid", "code": AppConnectForbiddenError.code} + else: + if apps: + asc_credentials = {"status": "valid"} + else: + asc_credentials = { + "status": "invalid", + "code": AppConnectAuthenticationError.code, + } + + # TODO: is it possible to set up two configs pointing to the same app? + pending_downloads = AppConnectBuild.objects.filter( + project=project, app_id=symbol_source_cfg.appId, fetched=False + ).count() + + latest_build = ( + AppConnectBuild.objects.filter( + project=project, bundle_id=symbol_source_cfg.bundleId + ) + .order_by("-uploaded_to_appstore") + .first() + ) + if latest_build is None: + latestBuildVersion = None + latestBuildNumber = None + else: + latestBuildVersion = latest_build.bundle_short_version + latestBuildNumber = latest_build.bundle_version + + try: + check_entry = LatestAppConnectBuildsCheck.objects.get( + project=project, source_id=symbol_source_cfg.id + ) + # If the source was only just created then it's possible that sentry hasn't checked for any + # new builds for it yet. + except LatestAppConnectBuildsCheck.DoesNotExist: + last_checked_builds = None + else: + last_checked_builds = check_entry.last_checked + + statuses[config_id] = { + "credentials": asc_credentials, + "pendingDownloads": pending_downloads, + "latestBuildVersion": latestBuildVersion, + "latestBuildNumber": latestBuildNumber, + "lastCheckedBuilds": last_checked_builds, + } + + return Response(statuses, status=200) diff --git a/src/sentry/api/exceptions.py b/src/sentry/api/exceptions.py index 7b43fef82f4f57..d118bfe2b819e8 100644 --- a/src/sentry/api/exceptions.py +++ b/src/sentry/api/exceptions.py @@ -97,6 +97,12 @@ class TwoFactorRequired(SentryAPIException): message = "Organization requires two-factor authentication to be enabled" +class AppConnectForbiddenError(SentryAPIException): + status_code = status.HTTP_403_FORBIDDEN + code = "app-connect-forbidden-error" + message = "App connect Forbidden error" + + class AppConnectAuthenticationError(SentryAPIException): status_code = status.HTTP_401_UNAUTHORIZED code = "app-connect-authentication-error" diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 383afc8e393f15..a37ec19be0f10a 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -282,6 +282,7 @@ AppStoreConnectAppsEndpoint, AppStoreConnectCreateCredentialsEndpoint, AppStoreConnectCredentialsValidateEndpoint, + AppStoreConnectStatusEndpoint, AppStoreConnectUpdateCredentialsEndpoint, ) from .endpoints.project_avatar import ProjectAvatarEndpoint @@ -2027,6 +2028,11 @@ AppStoreConnectCredentialsValidateEndpoint.as_view(), name="sentry-api-0-project-appstoreconnect-validate", ), + url( + r"^(?P[^\/]+)/(?P[^\/]+)/appstoreconnect/status/$", + AppStoreConnectStatusEndpoint.as_view(), + name="sentry-api-0-project-appstoreconnect-status", + ), url( r"^(?P[^\/]+)/(?P[^\/]+)/appstoreconnect/(?P[^\/]+)/$", AppStoreConnectUpdateCredentialsEndpoint.as_view(), diff --git a/src/sentry/utils/appleconnect/appstore_connect.py b/src/sentry/utils/appleconnect/appstore_connect.py index 9c8b5d3850c922..9e4d0f3a1d13c5 100644 --- a/src/sentry/utils/appleconnect/appstore_connect.py +++ b/src/sentry/utils/appleconnect/appstore_connect.py @@ -36,7 +36,7 @@ class UnauthorizedError(RequestError): class ForbiddenError(RequestError): - """The App Store Connect session does not have access to the requested dSYM.""" + """Forbidden: authentication token does not have sufficient permissions.""" pass @@ -154,6 +154,8 @@ def _get_appstore_json( if response.status_code == HTTPStatus.UNAUTHORIZED: raise UnauthorizedError(full_url) + elif response.status_code == HTTPStatus.FORBIDDEN: + raise ForbiddenError(full_url) else: raise RequestError(full_url) try: diff --git a/static/app/components/globalAppStoreConnectUpdateAlert/updateAlert.tsx b/static/app/components/globalAppStoreConnectUpdateAlert/updateAlert.tsx index ac01f8dd7934a2..19945eb687d83b 100644 --- a/static/app/components/globalAppStoreConnectUpdateAlert/updateAlert.tsx +++ b/static/app/components/globalAppStoreConnectUpdateAlert/updateAlert.tsx @@ -11,7 +11,7 @@ import {IconClose, IconRefresh} from 'app/icons'; import {t} from 'app/locale'; import space from 'app/styles/space'; import {Organization, Project} from 'app/types'; -import {AppStoreConnectValidationData} from 'app/types/debugFiles'; +import {AppStoreConnectStatusData} from 'app/types/debugFiles'; import {promptIsDismissed} from 'app/utils/promptIsDismissed'; import withApi from 'app/utils/withApi'; @@ -69,14 +69,14 @@ function UpdateAlert({api, Wrapper, isCompact, project, organization, className} } function renderMessage( - appStoreConnectValidationData: AppStoreConnectValidationData, + appStoreConnectStatusData: AppStoreConnectStatusData, projectSettingsLink: string ) { - if (!appStoreConnectValidationData.updateAlertMessage) { + if (!appStoreConnectStatusData.updateAlertMessage) { return null; } - const {updateAlertMessage} = appStoreConnectValidationData; + const {updateAlertMessage} = appStoreConnectStatusData; return (
diff --git a/static/app/components/modals/debugFileCustomRepository/appStoreConnect/index.tsx b/static/app/components/modals/debugFileCustomRepository/appStoreConnect/index.tsx index 178c73de7d9aaf..7e8df29fe539dc 100644 --- a/static/app/components/modals/debugFileCustomRepository/appStoreConnect/index.tsx +++ b/static/app/components/modals/debugFileCustomRepository/appStoreConnect/index.tsx @@ -9,7 +9,6 @@ import Button from 'app/components/button'; import ButtonBar from 'app/components/buttonBar'; import LoadingIndicator from 'app/components/loadingIndicator'; import {AppStoreConnectContextProps} from 'app/components/projects/appStoreConnectContext'; -import {appStoreConnectAlertMessage} from 'app/components/projects/appStoreConnectContext/utils'; import {IconWarning} from 'app/icons'; import {t, tct} from 'app/locale'; import space from 'app/styles/space'; @@ -57,7 +56,7 @@ function AppStoreConnect({ onSubmit, appStoreConnectContext, }: Props) { - const {updateAlertMessage} = appStoreConnectContext ?? {}; + const {credentials} = appStoreConnectContext ?? {}; const [isLoading, setIsLoading] = useState(false); const [activeStep, setActiveStep] = useState(0); @@ -114,6 +113,7 @@ function AppStoreConnect({ const appStoreConnnectError = getAppStoreErrorMessage(error); if (typeof appStoreConnnectError === 'string') { // app-connect-authentication-error + // app-connect-forbidden-error addErrorMessage(appStoreConnnectError); return; } @@ -238,13 +238,16 @@ function AppStoreConnect({ if (activeStep !== 0) { return alerts; } - - if (updateAlertMessage === appStoreConnectAlertMessage.appStoreCredentialsInvalid) { + if (credentials?.status === 'invalid') { alerts.push( }> - {t( - 'Your App Store Connect credentials are invalid. To reconnect, update your credentials.' - )} + {credentials.code === 'app-connect-forbidden-error' + ? t( + 'Your App Store Connect credentials have insufficient permissions. To reconnect, update your credentials.' + ) + : t( + 'Your App Store Connect credentials are invalid. To reconnect, update your credentials.' + )} ); } diff --git a/static/app/components/modals/debugFileCustomRepository/appStoreConnect/utils.tsx b/static/app/components/modals/debugFileCustomRepository/appStoreConnect/utils.tsx index 1c6d439e56e276..d7c68e4e701175 100644 --- a/static/app/components/modals/debugFileCustomRepository/appStoreConnect/utils.tsx +++ b/static/app/components/modals/debugFileCustomRepository/appStoreConnect/utils.tsx @@ -28,13 +28,17 @@ const fieldErrorMessageMapping = { }, }; -type ErrorCodeDetailed = +export type ErrorCodeDetailed = | 'app-connect-authentication-error' + | 'app-connect-forbidden-error' | 'app-connect-multiple-sources-error'; +export type ValidationErrorDetailed = { + code: ErrorCodeDetailed; +}; + type ResponseJSONDetailed = { - detail: { - code: ErrorCodeDetailed; + detail: ValidationErrorDetailed & { extra: Record; message: string; }; @@ -64,21 +68,7 @@ export function getAppStoreErrorMessage( ?.detail; if (detailedErrorResponse) { - switch (detailedErrorResponse.code) { - case 'app-connect-authentication-error': - return t( - 'We could not establish a connection with App Store Connect. Please check the entered App Store Connect credentials' - ); - case 'app-connect-multiple-sources-error': - return t( - 'Only one Apple App Store Connect application is allowed in this project' - ); - default: { - // this shall not happen - Sentry.captureException(new Error('Unknown app store connect error')); - return unexpectedErrorMessage; - } - } + return getAppStoreValidationErrorMessage(detailedErrorResponse); } const errorResponse = error.responseJSON as undefined | ResponseJSON; @@ -116,3 +106,23 @@ export function getAppStoreErrorMessage( {} ) as Record; } + +export function getAppStoreValidationErrorMessage( + error: ValidationErrorDetailed +): string { + switch (error.code) { + case 'app-connect-authentication-error': + return t( + 'Credentials are invalid or missing. Check the entered App Store Connect credentials are correct.' + ); + case 'app-connect-forbidden-error': + return t('The supplied API key does not have sufficient permissions.'); + case 'app-connect-multiple-sources-error': + return t('Only one Apple App Store Connect application is allowed in this project'); + default: { + // this shall not happen + Sentry.captureException(new Error('Unknown app store connect error')); + return unexpectedErrorMessage; + } + } +} diff --git a/static/app/components/projects/appStoreConnectContext/index.tsx b/static/app/components/projects/appStoreConnectContext/index.tsx index 83180572292b51..66f9614a16112c 100644 --- a/static/app/components/projects/appStoreConnectContext/index.tsx +++ b/static/app/components/projects/appStoreConnectContext/index.tsx @@ -1,10 +1,10 @@ import {createContext, useEffect, useState} from 'react'; import {Organization, Project} from 'app/types'; -import {AppStoreConnectValidationData} from 'app/types/debugFiles'; +import {AppStoreConnectStatusData} from 'app/types/debugFiles'; import useApi from 'app/utils/useApi'; -export type AppStoreConnectContextProps = AppStoreConnectValidationData | undefined; +export type AppStoreConnectContextProps = AppStoreConnectStatusData | undefined; const AppStoreConnectContext = createContext(undefined); @@ -20,7 +20,7 @@ const Provider = ({children, project, organization}: ProviderProps) => { const api = useApi(); const [projectDetails, setProjectDetails] = useState(); - const [appStoreConnectValidationData, setAppStoreConnectValidationData] = + const [appStoreConnectStatusData, setAppStoreConnectStatusData] = useState(undefined); const orgSlug = organization.slug; @@ -30,7 +30,7 @@ const Provider = ({children, project, organization}: ProviderProps) => { }, [project]); useEffect(() => { - fetchAppStoreConnectValidationData(); + fetchAppStoreConnectStatusData(); }, [projectDetails]); async function fetchProjectDetails() { @@ -57,7 +57,7 @@ const Provider = ({children, project, organization}: ProviderProps) => { )?.id; } - async function fetchAppStoreConnectValidationData() { + async function fetchAppStoreConnectStatusData() { if (!projectDetails) { return; } @@ -71,13 +71,18 @@ const Provider = ({children, project, organization}: ProviderProps) => { } try { - const response = await api.requestPromise( - `/projects/${orgSlug}/${projectDetails.slug}/appstoreconnect/validate/${appStoreConnectSymbolSourceId}/` + const response: Map = await api.requestPromise( + `/projects/${orgSlug}/${projectDetails.slug}/appstoreconnect/status/` ); - setAppStoreConnectValidationData({ - id: appStoreConnectSymbolSourceId, - ...response, - }); + + const sourceStatus: Omit | undefined = + response[appStoreConnectSymbolSourceId]; + if (sourceStatus) { + setAppStoreConnectStatusData({ + ...sourceStatus, + id: appStoreConnectSymbolSourceId, + }); + } } catch { // do nothing } @@ -86,11 +91,11 @@ const Provider = ({children, project, organization}: ProviderProps) => { return ( ( +const AppStoreConnectContext = createContext( undefined ); @@ -19,12 +19,12 @@ type ProviderProps = { const Provider = withApi( withProject(({api, children, project, orgSlug}: ProviderProps) => { - const [appStoreConnectValidationData, setAppStoreConnectValidationData] = useState< - AppStoreConnectValidationData | undefined + const [appStoreConnectStatusData, setAppStoreConnectStatusData] = useState< + AppStoreConnectStatusData | undefined >(); useEffect(() => { - fetchAppStoreConnectValidationData(); + fetchAppStoreConnectStatusData(); }, [project]); function getAppStoreConnectSymbolSourceId() { @@ -33,7 +33,7 @@ const Provider = withApi( )?.id; } - async function fetchAppStoreConnectValidationData() { + async function fetchAppStoreConnectStatusData() { const appStoreConnectSymbolSourceId = getAppStoreConnectSymbolSourceId(); if (!appStoreConnectSymbolSourceId) { @@ -41,20 +41,25 @@ const Provider = withApi( } try { - const response = await api.requestPromise( - `/projects/${orgSlug}/${project.slug}/appstoreconnect/validate/${appStoreConnectSymbolSourceId}/` + const response: Map = await api.requestPromise( + `/projects/${orgSlug}/${project.slug}/appstoreconnect/status/` ); - setAppStoreConnectValidationData({ - id: appStoreConnectSymbolSourceId, - ...response, - }); + + const sourceStatus: Omit | undefined = + response[appStoreConnectSymbolSourceId]; + if (sourceStatus) { + setAppStoreConnectStatusData({ + ...sourceStatus, + id: appStoreConnectSymbolSourceId, + }); + } } catch { // do nothing } } return ( - + {children} ); diff --git a/static/app/views/settings/projectDebugFiles/externalSources/customRepositories/details.tsx b/static/app/views/settings/projectDebugFiles/externalSources/customRepositories/details.tsx index 52bfc0cffa9e93..fa08eb133239fd 100644 --- a/static/app/views/settings/projectDebugFiles/externalSources/customRepositories/details.tsx +++ b/static/app/views/settings/projectDebugFiles/externalSources/customRepositories/details.tsx @@ -4,10 +4,10 @@ import DateTime from 'app/components/dateTime'; import NotAvailable from 'app/components/notAvailable'; import {t, tct} from 'app/locale'; import space from 'app/styles/space'; -import {AppStoreConnectValidationData} from 'app/types/debugFiles'; +import {AppStoreConnectStatusData} from 'app/types/debugFiles'; type Props = { - details?: AppStoreConnectValidationData; + details?: AppStoreConnectStatusData; }; function Details({details}: Props) { diff --git a/static/app/views/settings/projectDebugFiles/externalSources/customRepositories/status.tsx b/static/app/views/settings/projectDebugFiles/externalSources/customRepositories/status.tsx index d16778db991fb8..240ddccfed054c 100644 --- a/static/app/views/settings/projectDebugFiles/externalSources/customRepositories/status.tsx +++ b/static/app/views/settings/projectDebugFiles/externalSources/customRepositories/status.tsx @@ -9,11 +9,11 @@ import {IconRefresh} from 'app/icons/iconRefresh'; import {IconWarning} from 'app/icons/iconWarning'; import {t, tn} from 'app/locale'; import space from 'app/styles/space'; -import {AppStoreConnectValidationData} from 'app/types/debugFiles'; +import {AppStoreConnectStatusData} from 'app/types/debugFiles'; type Props = { onEditRepository: () => void; - details?: AppStoreConnectValidationData; + details?: AppStoreConnectStatusData; }; function Status({details, onEditRepository}: Props) { @@ -23,9 +23,9 @@ function Status({details, onEditRepository}: Props) { return ; } - const {pendingDownloads, appstoreCredentialsValid, lastCheckedBuilds} = details ?? {}; + const {pendingDownloads, credentials, lastCheckedBuilds} = details ?? {}; - if (appstoreCredentialsValid === false) { + if (credentials?.status === 'invalid') { return (