Skip to content

Commit 5efb05c

Browse files
vaindpriscilawebdevloewenheim
authored
feat(symbols): Add platform-restricted builtin symbol sources with org access control (#102013)
## Summary Adds support for **platform-restricted builtin symbol sources** with organization-level access control. This enables symbol sources to be restricted to specific platforms (e.g., console platforms like Nintendo Switch) and only accessible to organizations with the appropriate console platform access enabled. **Companion PR**: [getsentry/getsentry#18867](getsentry/getsentry#18867) - Defines the Nintendo symbol source configuration (credentials, bucket settings, etc.) ## Changes ### Platform Filtering for Builtin Sources API - **[builtin_symbol_sources.py](src/sentry/api/endpoints/builtin_symbol_sources.py)**: Add platform-based filtering - Filter sources by `platform` query parameter - Check organization's `enabledConsolePlatforms` access for restricted sources - Backward compatible (works without platform parameter) ### Default Symbol Sources Enhancement - **[default_symbol_sources.py](src/sentry/api/helpers/default_symbol_sources.py)**: Enhanced helper for platform-specific defaults - Added `nintendo-switch` platform mapping to `nintendo` source - Check platform restrictions and org access before adding sources - Organization auto-fetched from project if not provided ### Shared Utility - **[console_platforms.py](src/sentry/utils/console_platforms.py)**: New utility module - `organization_has_console_platform_access()` - checks if org has access to a console platform - Used by both builtin sources endpoint and tempest utils ### Schema Update - **[sources.py](src/sentry/lang/native/sources.py)**: Add `platforms` field to source schema ### Refactoring - **[tempest/utils.py](src/sentry/tempest/utils.py)**: Refactored to use shared `organization_has_console_platform_access` utility - **[team_projects.py](src/sentry/core/endpoints/team_projects.py)**: Pass organization to `set_default_symbol_sources()` ## How It Works ### Platform Restrictions Sources can include a `platforms` key to restrict visibility: ```python "nintendo": { "type": "s3", "platforms": ["nintendo-switch"], # Only visible to nintendo-switch projects # ... other config (defined in getsentry) } ``` **Two-layer filtering:** 1. **Platform match**: Request's platform must match source's `platforms` list 2. **Org access**: Organization must have platform in `enabledConsolePlatforms` ### Default Symbol Sources ```python DEFAULT_SYMBOL_SOURCES = { "electron": ["ios", "microsoft", "electron"], "unity": ["ios", "microsoft", "android", "nuget", "unity", "nvidia", "ubuntu"], "unreal": ["ios", "microsoft", "android", "nvidia", "ubuntu"], "godot": ["ios", "microsoft", "android", "nuget", "nvidia", "ubuntu"], "nintendo-switch": ["nintendo"], } ``` ## Test Plan - [x] Unit tests pass - [x] Pre-commit hooks pass - [x] Mypy type checking passes - [x] Verify Nintendo Switch project in enabled org sees the source - [x] Verify Nintendo Switch project in non-enabled org does NOT see the source - [x] Verify non-Nintendo projects never see the source - [x] Verify public sources still visible to all platforms - [x] Verify API works without platform parameter (backward compatibility) --------- Co-authored-by: Priscila Oliveira <[email protected]> Co-authored-by: Sebastian Zivota <[email protected]>
1 parent e195c19 commit 5efb05c

File tree

9 files changed

+361
-18
lines changed

9 files changed

+361
-18
lines changed

src/sentry/api/endpoints/builtin_symbol_sources.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import cast
2+
13
from django.conf import settings
24
from rest_framework.request import Request
35
from rest_framework.response import Response
@@ -6,6 +8,8 @@
68
from sentry.api.api_publish_status import ApiPublishStatus
79
from sentry.api.base import Endpoint, region_silo_endpoint
810
from sentry.api.serializers import serialize
11+
from sentry.models.organization import Organization
12+
from sentry.utils.console_platforms import organization_has_console_platform_access
913

1014

1115
def normalize_symbol_source(key, source):
@@ -26,10 +30,36 @@ class BuiltinSymbolSourcesEndpoint(Endpoint):
2630
permission_classes = ()
2731

2832
def get(self, request: Request, **kwargs) -> Response:
29-
sources = [
30-
normalize_symbol_source(key, source)
31-
for key, source in settings.SENTRY_BUILTIN_SOURCES.items()
32-
]
33+
platform = request.GET.get("platform")
34+
35+
# Get organization if organization context is available
36+
organization = None
37+
organization_id_or_slug = kwargs.get("organization_id_or_slug")
38+
if organization_id_or_slug:
39+
try:
40+
if str(organization_id_or_slug).isdecimal():
41+
organization = Organization.objects.get_from_cache(id=organization_id_or_slug)
42+
else:
43+
organization = Organization.objects.get_from_cache(slug=organization_id_or_slug)
44+
except Organization.DoesNotExist:
45+
pass
46+
47+
sources = []
48+
for key, source in settings.SENTRY_BUILTIN_SOURCES.items():
49+
source_platforms: list[str] | None = cast("list[str] | None", source.get("platforms"))
50+
51+
# If source has platform restrictions, check if current platform matches
52+
if source_platforms is not None:
53+
if not platform or platform not in source_platforms:
54+
continue
55+
56+
# Platform matches - now check if organization has access to this console platform
57+
if not organization or not organization_has_console_platform_access(
58+
organization, platform
59+
):
60+
continue
61+
62+
sources.append(normalize_symbol_source(key, source))
3363

3464
sources.sort(key=lambda s: s["name"])
3565
return Response(serialize(sources))
Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
from typing import cast
2+
3+
from django.conf import settings
4+
5+
from sentry.constants import ENABLED_CONSOLE_PLATFORMS_DEFAULT
6+
from sentry.models.organization import Organization
17
from sentry.models.project import Project
28
from sentry.projects.services.project import RpcProject
39

@@ -7,11 +13,69 @@
713
"unity": ["ios", "microsoft", "android", "nuget", "unity", "nvidia", "ubuntu"],
814
"unreal": ["ios", "microsoft", "android", "nvidia", "ubuntu"],
915
"godot": ["ios", "microsoft", "android", "nuget", "nvidia", "ubuntu"],
16+
"nintendo-switch": ["nintendo"],
1017
}
1118

1219

13-
def set_default_symbol_sources(project: Project | RpcProject) -> None:
14-
if project.platform and project.platform in DEFAULT_SYMBOL_SOURCES:
15-
project.update_option(
16-
"sentry:builtin_symbol_sources", DEFAULT_SYMBOL_SOURCES[project.platform]
17-
)
20+
def set_default_symbol_sources(
21+
project: Project | RpcProject, organization: Organization | None = None
22+
) -> None:
23+
"""
24+
Sets default symbol sources for a project based on its platform.
25+
26+
For sources with platform restrictions (e.g., console platforms), this function checks
27+
if the organization has access to the required platform before adding the source.
28+
29+
Args:
30+
project: The project to configure symbol sources for
31+
organization: Optional organization (fetched from project if not provided)
32+
"""
33+
if not project.platform or project.platform not in DEFAULT_SYMBOL_SOURCES:
34+
return
35+
36+
# Get organization from project if not provided
37+
if organization is None:
38+
if isinstance(project, Project):
39+
organization = project.organization
40+
else:
41+
# For RpcProject, fetch organization by ID
42+
try:
43+
organization = Organization.objects.get_from_cache(id=project.organization_id)
44+
except Organization.DoesNotExist:
45+
# If organization doesn't exist, cannot set defaults
46+
return
47+
48+
# Get default sources for this platform
49+
source_keys = DEFAULT_SYMBOL_SOURCES[project.platform]
50+
51+
# Get enabled console platforms once (optimization to avoid repeated DB calls)
52+
enabled_console_platforms = organization.get_option(
53+
"sentry:enabled_console_platforms", ENABLED_CONSOLE_PLATFORMS_DEFAULT
54+
)
55+
56+
# Filter sources based on platform restrictions and organization access
57+
enabled_sources = []
58+
for source_key in source_keys:
59+
source_config = settings.SENTRY_BUILTIN_SOURCES.get(source_key)
60+
61+
# If source exists in config, check for platform restrictions
62+
if source_config:
63+
required_platforms: list[str] | None = cast(
64+
"list[str] | None", source_config.get("platforms")
65+
)
66+
if required_platforms:
67+
# Source is platform-restricted - check if org has access
68+
# Only add source if org has access to at least one of the required platforms
69+
has_access = any(
70+
platform in enabled_console_platforms for platform in required_platforms
71+
)
72+
if not has_access:
73+
continue
74+
75+
# Include the source (either it passed platform check or doesn't exist in config)
76+
# Non-existent sources will be filtered out at runtime in sources.py
77+
enabled_sources.append(source_key)
78+
79+
# Always update the option for recognized platforms, even if empty
80+
# This ensures platform-specific defaults override epoch defaults
81+
project.update_option("sentry:builtin_symbol_sources", enabled_sources)

src/sentry/core/endpoints/team_projects.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def apply_default_project_settings(organization: Organization, project: Project)
4747

4848
set_default_disabled_detectors(project)
4949

50-
set_default_symbol_sources(project)
50+
set_default_symbol_sources(project, organization)
5151

5252
# Create project option to turn on ML similarity feature for new EA projects
5353
if project_is_seer_eligible(project):

src/sentry/lang/native/sources.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"filters": FILTERS_SCHEMA,
8787
"is_public": {"type": "boolean"},
8888
"has_index": {"type": "boolean"},
89+
"platforms": {"type": "array", "items": {"type": "string"}},
8990
}
9091

9192
APP_STORE_CONNECT_SCHEMA = {

src/sentry/projectoptions/defaults.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@
3333
epoch_defaults={1: "4.x", 2: "5.x", 7: "6.x", 8: "7.x", 13: "8.x", 14: "9.x", 15: "10.x"},
3434
)
3535

36-
# Default symbol sources. The ios source does not exist by default and
37-
# will be skipped later. The microsoft source exists by default and is
38-
# unlikely to be disabled.
36+
# Default symbol sources. The ios source does not exist by default and
37+
# will be skipped later. The microsoft source exists by default and is
38+
# unlikely to be disabled. Platform-specific sources may be added via
39+
# set_default_symbol_sources() when a project is created.
3940
register(
4041
key="sentry:builtin_symbol_sources",
4142
epoch_defaults={

src/sentry/tempest/utils.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
from sentry.models.organization import Organization
2+
from sentry.utils.console_platforms import organization_has_console_platform_access
23

34

45
def has_tempest_access(organization: Organization | None) -> bool:
5-
66
if not organization:
77
return False
88

9-
enabled_platforms = organization.get_option("sentry:enabled_console_platforms", [])
10-
has_playstation_access = "playstation" in enabled_platforms
11-
12-
return has_playstation_access
9+
return organization_has_console_platform_access(organization, "playstation")
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from sentry.constants import ENABLED_CONSOLE_PLATFORMS_DEFAULT
2+
from sentry.models.organization import Organization
3+
4+
5+
def organization_has_console_platform_access(organization: Organization, platform: str) -> bool:
6+
"""
7+
Check if an organization has access to a specific console platform.
8+
9+
Args:
10+
organization: The organization to check
11+
platform: The console platform (e.g., 'nintendo-switch', 'playstation', 'xbox')
12+
13+
Returns:
14+
True if the organization has access to the console platform, False otherwise
15+
"""
16+
enabled_console_platforms = organization.get_option(
17+
"sentry:enabled_console_platforms", ENABLED_CONSOLE_PLATFORMS_DEFAULT
18+
)
19+
return platform in enabled_console_platforms

tests/sentry/api/endpoints/test_builtin_symbol_sources.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
1+
from django.test import override_settings
2+
13
from sentry.testutils.cases import APITestCase
24

5+
SENTRY_BUILTIN_SOURCES_PLATFORM_TEST = {
6+
"public-source-1": {
7+
"id": "sentry:public-1",
8+
"name": "Public Source 1",
9+
"type": "http",
10+
"url": "https://example.com/symbols/",
11+
},
12+
"public-source-2": {
13+
"id": "sentry:public-2",
14+
"name": "Public Source 2",
15+
"type": "http",
16+
"url": "https://example.com/symbols2/",
17+
},
18+
"nintendo": {
19+
"id": "sentry:nintendo",
20+
"name": "Nintendo SDK",
21+
"type": "s3",
22+
"bucket": "nintendo-symbols",
23+
"region": "us-east-1",
24+
"access_key": "test-key",
25+
"secret_key": "test-secret",
26+
"layout": {"type": "native"},
27+
"platforms": ["nintendo-switch"],
28+
},
29+
}
30+
331

432
class BuiltinSymbolSourcesNoSlugTest(APITestCase):
533
endpoint = "sentry-api-0-builtin-symbol-sources"
@@ -39,3 +67,77 @@ def test_with_slug(self) -> None:
3967
assert "id" in body[0]
4068
assert "name" in body[0]
4169
assert "hidden" in body[0]
70+
71+
72+
class BuiltinSymbolSourcesPlatformFilteringTest(APITestCase):
73+
endpoint = "sentry-api-0-organization-builtin-symbol-sources"
74+
75+
def setUp(self) -> None:
76+
super().setUp()
77+
self.organization = self.create_organization(owner=self.user)
78+
self.login_as(user=self.user)
79+
80+
@override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_PLATFORM_TEST)
81+
def test_platform_filtering_nintendo_switch_with_access(self) -> None:
82+
"""Nintendo Switch platform should see nintendo source only if org has access"""
83+
# Enable nintendo-switch for this organization
84+
self.organization.update_option("sentry:enabled_console_platforms", ["nintendo-switch"])
85+
86+
resp = self.get_response(self.organization.slug, qs_params={"platform": "nintendo-switch"})
87+
assert resp.status_code == 200
88+
89+
body = resp.data
90+
source_keys = [source["sentry_key"] for source in body]
91+
92+
# Nintendo Switch with access should see nintendo
93+
assert "nintendo" in source_keys
94+
# Should also see public sources (no platform restriction)
95+
assert "public-source-1" in source_keys
96+
assert "public-source-2" in source_keys
97+
98+
@override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_PLATFORM_TEST)
99+
def test_platform_filtering_nintendo_switch_without_access(self) -> None:
100+
"""Nintendo Switch platform should NOT see nintendo if org lacks access"""
101+
# Organization does not have nintendo-switch enabled (default is empty list)
102+
103+
resp = self.get_response(self.organization.slug, qs_params={"platform": "nintendo-switch"})
104+
assert resp.status_code == 200
105+
106+
body = resp.data
107+
source_keys = [source["sentry_key"] for source in body]
108+
109+
# Should NOT see nintendo without console platform access
110+
assert "nintendo" not in source_keys
111+
# Should still see public sources
112+
assert "public-source-1" in source_keys
113+
assert "public-source-2" in source_keys
114+
115+
@override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_PLATFORM_TEST)
116+
def test_platform_filtering_unity(self) -> None:
117+
"""Unity platform should NOT see nintendo source"""
118+
resp = self.get_response(self.organization.slug, qs_params={"platform": "unity"})
119+
assert resp.status_code == 200
120+
121+
body = resp.data
122+
source_keys = [source["sentry_key"] for source in body]
123+
124+
# Unity should see public sources (no platform restriction)
125+
assert "public-source-1" in source_keys
126+
assert "public-source-2" in source_keys
127+
# Unity should NOT see nintendo (restricted to nintendo-switch)
128+
assert "nintendo" not in source_keys
129+
130+
@override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_PLATFORM_TEST)
131+
def test_no_platform_parameter(self) -> None:
132+
"""Without platform parameter, should see public sources but not platform-restricted ones"""
133+
resp = self.get_response(self.organization.slug)
134+
assert resp.status_code == 200
135+
136+
body = resp.data
137+
source_keys = [source["sentry_key"] for source in body]
138+
139+
# Should see public sources (no platform restriction)
140+
assert "public-source-1" in source_keys
141+
assert "public-source-2" in source_keys
142+
# Should NOT see platform-restricted source when no platform is provided
143+
assert "nintendo" not in source_keys

0 commit comments

Comments
 (0)