Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 121 additions & 1 deletion src/sentry/api/endpoints/project_app_store_connect_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
6 changes: 6 additions & 0 deletions src/sentry/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@
AppStoreConnectAppsEndpoint,
AppStoreConnectCreateCredentialsEndpoint,
AppStoreConnectCredentialsValidateEndpoint,
AppStoreConnectStatusEndpoint,
AppStoreConnectUpdateCredentialsEndpoint,
)
from .endpoints.project_avatar import ProjectAvatarEndpoint
Expand Down Expand Up @@ -2027,6 +2028,11 @@
AppStoreConnectCredentialsValidateEndpoint.as_view(),
name="sentry-api-0-project-appstoreconnect-validate",
),
url(
r"^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/appstoreconnect/status/$",
AppStoreConnectStatusEndpoint.as_view(),
name="sentry-api-0-project-appstoreconnect-status",
),
url(
r"^(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/appstoreconnect/(?P<credentials_id>[^\/]+)/$",
AppStoreConnectUpdateCredentialsEndpoint.as_view(),
Expand Down
4 changes: 3 additions & 1 deletion src/sentry/utils/appleconnect/appstore_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -238,13 +238,16 @@ function AppStoreConnect({
if (activeStep !== 0) {
return alerts;
}

if (updateAlertMessage === appStoreConnectAlertMessage.appStoreCredentialsInvalid) {
if (credentials?.status === 'invalid') {
alerts.push(
<StyledAlert type="warning" icon={<IconWarning />}>
{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.'
)}
</StyledAlert>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
message: string;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -116,3 +106,23 @@ export function getAppStoreErrorMessage(
{}
) as Record<keyof StepOneData, string>;
}

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.'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3

);
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;
}
}
}
Loading