diff --git a/static/app/constants/index.tsx b/static/app/constants/index.tsx index ffaa89dcf4c600..1a9afad6e881eb 100644 --- a/static/app/constants/index.tsx +++ b/static/app/constants/index.tsx @@ -43,6 +43,7 @@ export const API_ACCESS_SCOPES = [ 'org:read', 'org:write', 'project:admin', + 'project:distribution', 'project:read', 'project:releases', 'project:write', @@ -68,6 +69,7 @@ export const ALLOWED_SCOPES = [ 'org:superuser', // not an assignable API access scope 'org:write', 'project:admin', + 'project:distribution', 'project:read', 'project:releases', 'project:write', @@ -119,7 +121,7 @@ type PermissionChoice = { scopes: Scope[]; }; -type PermissionObj = { +export type PermissionObj = { choices: { 'no-access': PermissionChoice; admin?: PermissionChoice; @@ -165,6 +167,14 @@ export const SENTRY_APP_PERMISSIONS: PermissionObj[] = [ admin: {label: 'Admin', scopes: ['project:releases']}, }, }, + { + resource: 'Distribution', + help: 'Pre-release app distribution for trusted testers.', + choices: { + 'no-access': {label: 'No Access', scopes: []}, + read: {label: 'Read', scopes: ['project:distribution']}, + }, + }, { resource: 'Event', label: 'Issue & Event', diff --git a/static/app/types/integrations.tsx b/static/app/types/integrations.tsx index 40512ae045529f..0f47431aa62efa 100644 --- a/static/app/types/integrations.tsx +++ b/static/app/types/integrations.tsx @@ -24,6 +24,7 @@ export type Permissions = { Release: PermissionValue; Team: PermissionValue; Alerts?: PermissionValue; + Distribution?: PermissionValue; }; export type PermissionResource = keyof Permissions; diff --git a/static/app/utils/consolidatedScopes.tsx b/static/app/utils/consolidatedScopes.tsx index 3115ea22ed0609..5670a69be0cddc 100644 --- a/static/app/utils/consolidatedScopes.tsx +++ b/static/app/utils/consolidatedScopes.tsx @@ -15,6 +15,7 @@ const HUMAN_RESOURCE_NAMES = { project: 'Project', team: 'Team', release: 'Release', + distribution: 'Distribution', event: 'Event', org: 'Organization', member: 'Member', @@ -25,6 +26,7 @@ const DEFAULT_RESOURCE_PERMISSIONS: Permissions = { Project: 'no-access', Team: 'no-access', Release: 'no-access', + Distribution: 'no-access', Event: 'no-access', Organization: 'no-access', Member: 'no-access', @@ -32,6 +34,7 @@ const DEFAULT_RESOURCE_PERMISSIONS: Permissions = { }; const PROJECT_RELEASES = 'project:releases'; +const PROJECT_DISTRIBUTION = 'project:distribution'; const ORG_INTEGRATIONS = 'org:integrations'; type PermissionLevelResources = { @@ -95,7 +98,17 @@ function toResourcePermissions(scopes: string[]): Permissions { // row for Releases. if (scopes.includes(PROJECT_RELEASES)) { permissions.Release = 'admin'; - filteredScopes = scopes.filter((scope: string) => scope !== PROJECT_RELEASES); // remove project:releases + filteredScopes = filteredScopes.filter((scope: string) => scope !== PROJECT_RELEASES); // remove project:releases + } + + // The scope for distribution is `project:distribution`, but instead of displaying + // it as a permission of Project, we want to separate it out into its own + // row for Distribution. + if (scopes.includes(PROJECT_DISTRIBUTION)) { + permissions.Distribution = 'read'; + filteredScopes = filteredScopes.filter( + (scope: string) => scope !== PROJECT_DISTRIBUTION + ); // remove project:distribution } // We have a special case with the org:integrations scope. This scope is diff --git a/static/app/views/settings/account/apiNewToken.tsx b/static/app/views/settings/account/apiNewToken.tsx index fbb5a6d9c0ccb9..57c2dba91bf2fe 100644 --- a/static/app/views/settings/account/apiNewToken.tsx +++ b/static/app/views/settings/account/apiNewToken.tsx @@ -8,11 +8,13 @@ import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelHeader from 'sentry/components/panels/panelHeader'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; +import {SENTRY_APP_PERMISSIONS} from 'sentry/constants'; import {t, tct} from 'sentry/locale'; import type {Permissions} from 'sentry/types/integrations'; import type {NewInternalAppApiToken} from 'sentry/types/user'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; +import useOrganization from 'sentry/utils/useOrganization'; import {displayNewToken} from 'sentry/views/settings/components/newTokenHandler'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import TextBlock from 'sentry/views/settings/components/text/textBlock'; @@ -29,11 +31,20 @@ export default function ApiNewToken() { Release: 'no-access', Organization: 'no-access', Alerts: 'no-access', + Distribution: 'no-access', }); const navigate = useNavigate(); + const organization = useOrganization({allowNull: true}); const [hasNewToken, setHasnewToken] = useState(false); const [preview, setPreview] = useState(''); + const hasPreprodFeature = + organization?.features.includes('organizations:preprod-build-distribution') ?? false; + + const displayedPermissions = SENTRY_APP_PERMISSIONS.filter( + o => o.resource !== 'Distribution' || hasPreprodFeature + ); + const getPreview = () => { let previewString = ''; for (const k in permissions) { @@ -107,6 +118,7 @@ export default function ApiNewToken() { setPermissions(p); setPreview(getPreview()); }} + displayedPermissions={displayedPermissions} /> void; permissions: Permissions; + /** + * Optional list of permissions to display in the selection. + * Defaults to SENTRY_APP_PERMISSIONS if not provided. + * Useful for limiting permissions available to personal tokens vs integration tokens. + */ + displayedPermissions?: PermissionObj[]; }; type State = { @@ -132,10 +138,11 @@ export default class PermissionSelection extends Component { render() { const {permissions} = this.state; + const {displayedPermissions = SENTRY_APP_PERMISSIONS} = this.props; return ( - {SENTRY_APP_PERMISSIONS.map(config => { + {displayedPermissions.map(config => { const options = Object.entries(config.choices).map(([value, {label}]) => ({ value, label,