diff --git a/src/sentry/api/endpoints/builtin_symbol_sources.py b/src/sentry/api/endpoints/builtin_symbol_sources.py index af34b014c4632f..43d965c370aead 100644 --- a/src/sentry/api/endpoints/builtin_symbol_sources.py +++ b/src/sentry/api/endpoints/builtin_symbol_sources.py @@ -1,3 +1,5 @@ +from typing import cast + from django.conf import settings from rest_framework.request import Request from rest_framework.response import Response @@ -6,6 +8,8 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import Endpoint, region_silo_endpoint from sentry.api.serializers import serialize +from sentry.models.organization import Organization +from sentry.utils.console_platforms import organization_has_console_platform_access def normalize_symbol_source(key, source): @@ -26,10 +30,36 @@ class BuiltinSymbolSourcesEndpoint(Endpoint): permission_classes = () def get(self, request: Request, **kwargs) -> Response: - sources = [ - normalize_symbol_source(key, source) - for key, source in settings.SENTRY_BUILTIN_SOURCES.items() - ] + platform = request.GET.get("platform") + + # Get organization if organization context is available + organization = None + organization_id_or_slug = kwargs.get("organization_id_or_slug") + if organization_id_or_slug: + try: + if str(organization_id_or_slug).isdecimal(): + organization = Organization.objects.get_from_cache(id=organization_id_or_slug) + else: + organization = Organization.objects.get_from_cache(slug=organization_id_or_slug) + except Organization.DoesNotExist: + pass + + sources = [] + for key, source in settings.SENTRY_BUILTIN_SOURCES.items(): + source_platforms: list[str] | None = cast("list[str] | None", source.get("platforms")) + + # If source has platform restrictions, check if current platform matches + if source_platforms is not None: + if not platform or platform not in source_platforms: + continue + + # Platform matches - now check if organization has access to this console platform + if not organization or not organization_has_console_platform_access( + organization, platform + ): + continue + + sources.append(normalize_symbol_source(key, source)) sources.sort(key=lambda s: s["name"]) return Response(serialize(sources)) diff --git a/src/sentry/api/helpers/default_symbol_sources.py b/src/sentry/api/helpers/default_symbol_sources.py index a0adf06a64083a..870332378cc81b 100644 --- a/src/sentry/api/helpers/default_symbol_sources.py +++ b/src/sentry/api/helpers/default_symbol_sources.py @@ -1,3 +1,9 @@ +from typing import cast + +from django.conf import settings + +from sentry.constants import ENABLED_CONSOLE_PLATFORMS_DEFAULT +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.projects.services.project import RpcProject @@ -7,11 +13,69 @@ "unity": ["ios", "microsoft", "android", "nuget", "unity", "nvidia", "ubuntu"], "unreal": ["ios", "microsoft", "android", "nvidia", "ubuntu"], "godot": ["ios", "microsoft", "android", "nuget", "nvidia", "ubuntu"], + "nintendo-switch": ["nintendo"], } -def set_default_symbol_sources(project: Project | RpcProject) -> None: - if project.platform and project.platform in DEFAULT_SYMBOL_SOURCES: - project.update_option( - "sentry:builtin_symbol_sources", DEFAULT_SYMBOL_SOURCES[project.platform] - ) +def set_default_symbol_sources( + project: Project | RpcProject, organization: Organization | None = None +) -> None: + """ + Sets default symbol sources for a project based on its platform. + + For sources with platform restrictions (e.g., console platforms), this function checks + if the organization has access to the required platform before adding the source. + + Args: + project: The project to configure symbol sources for + organization: Optional organization (fetched from project if not provided) + """ + if not project.platform or project.platform not in DEFAULT_SYMBOL_SOURCES: + return + + # Get organization from project if not provided + if organization is None: + if isinstance(project, Project): + organization = project.organization + else: + # For RpcProject, fetch organization by ID + try: + organization = Organization.objects.get_from_cache(id=project.organization_id) + except Organization.DoesNotExist: + # If organization doesn't exist, cannot set defaults + return + + # Get default sources for this platform + source_keys = DEFAULT_SYMBOL_SOURCES[project.platform] + + # Get enabled console platforms once (optimization to avoid repeated DB calls) + enabled_console_platforms = organization.get_option( + "sentry:enabled_console_platforms", ENABLED_CONSOLE_PLATFORMS_DEFAULT + ) + + # Filter sources based on platform restrictions and organization access + enabled_sources = [] + for source_key in source_keys: + source_config = settings.SENTRY_BUILTIN_SOURCES.get(source_key) + + # If source exists in config, check for platform restrictions + if source_config: + required_platforms: list[str] | None = cast( + "list[str] | None", source_config.get("platforms") + ) + if required_platforms: + # Source is platform-restricted - check if org has access + # Only add source if org has access to at least one of the required platforms + has_access = any( + platform in enabled_console_platforms for platform in required_platforms + ) + if not has_access: + continue + + # Include the source (either it passed platform check or doesn't exist in config) + # Non-existent sources will be filtered out at runtime in sources.py + enabled_sources.append(source_key) + + # Always update the option for recognized platforms, even if empty + # This ensures platform-specific defaults override epoch defaults + project.update_option("sentry:builtin_symbol_sources", enabled_sources) diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index 43bbca677eea54..1888c56c7493e8 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -47,7 +47,7 @@ def apply_default_project_settings(organization: Organization, project: Project) set_default_disabled_detectors(project) - set_default_symbol_sources(project) + set_default_symbol_sources(project, organization) # Create project option to turn on ML similarity feature for new EA projects if project_is_seer_eligible(project): diff --git a/src/sentry/lang/native/sources.py b/src/sentry/lang/native/sources.py index b97eee02cf5dcb..fa9dfdbdb4b849 100644 --- a/src/sentry/lang/native/sources.py +++ b/src/sentry/lang/native/sources.py @@ -86,6 +86,7 @@ "filters": FILTERS_SCHEMA, "is_public": {"type": "boolean"}, "has_index": {"type": "boolean"}, + "platforms": {"type": "array", "items": {"type": "string"}}, } APP_STORE_CONNECT_SCHEMA = { diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py index bb31547cc41adc..0d18df58cc6639 100644 --- a/src/sentry/projectoptions/defaults.py +++ b/src/sentry/projectoptions/defaults.py @@ -33,9 +33,10 @@ epoch_defaults={1: "4.x", 2: "5.x", 7: "6.x", 8: "7.x", 13: "8.x", 14: "9.x", 15: "10.x"}, ) -# Default symbol sources. The ios source does not exist by default and -# will be skipped later. The microsoft source exists by default and is -# unlikely to be disabled. +# Default symbol sources. The ios source does not exist by default and +# will be skipped later. The microsoft source exists by default and is +# unlikely to be disabled. Platform-specific sources may be added via +# set_default_symbol_sources() when a project is created. register( key="sentry:builtin_symbol_sources", epoch_defaults={ diff --git a/src/sentry/tempest/utils.py b/src/sentry/tempest/utils.py index 24f7cc3ff0d168..53d2caedb88133 100644 --- a/src/sentry/tempest/utils.py +++ b/src/sentry/tempest/utils.py @@ -1,12 +1,9 @@ from sentry.models.organization import Organization +from sentry.utils.console_platforms import organization_has_console_platform_access def has_tempest_access(organization: Organization | None) -> bool: - if not organization: return False - enabled_platforms = organization.get_option("sentry:enabled_console_platforms", []) - has_playstation_access = "playstation" in enabled_platforms - - return has_playstation_access + return organization_has_console_platform_access(organization, "playstation") diff --git a/src/sentry/utils/console_platforms.py b/src/sentry/utils/console_platforms.py new file mode 100644 index 00000000000000..96544e116beb42 --- /dev/null +++ b/src/sentry/utils/console_platforms.py @@ -0,0 +1,19 @@ +from sentry.constants import ENABLED_CONSOLE_PLATFORMS_DEFAULT +from sentry.models.organization import Organization + + +def organization_has_console_platform_access(organization: Organization, platform: str) -> bool: + """ + Check if an organization has access to a specific console platform. + + Args: + organization: The organization to check + platform: The console platform (e.g., 'nintendo-switch', 'playstation', 'xbox') + + Returns: + True if the organization has access to the console platform, False otherwise + """ + enabled_console_platforms = organization.get_option( + "sentry:enabled_console_platforms", ENABLED_CONSOLE_PLATFORMS_DEFAULT + ) + return platform in enabled_console_platforms diff --git a/tests/sentry/api/endpoints/test_builtin_symbol_sources.py b/tests/sentry/api/endpoints/test_builtin_symbol_sources.py index 1ebc9a83658062..326f4e08af5159 100644 --- a/tests/sentry/api/endpoints/test_builtin_symbol_sources.py +++ b/tests/sentry/api/endpoints/test_builtin_symbol_sources.py @@ -1,5 +1,33 @@ +from django.test import override_settings + from sentry.testutils.cases import APITestCase +SENTRY_BUILTIN_SOURCES_PLATFORM_TEST = { + "public-source-1": { + "id": "sentry:public-1", + "name": "Public Source 1", + "type": "http", + "url": "https://example.com/symbols/", + }, + "public-source-2": { + "id": "sentry:public-2", + "name": "Public Source 2", + "type": "http", + "url": "https://example.com/symbols2/", + }, + "nintendo": { + "id": "sentry:nintendo", + "name": "Nintendo SDK", + "type": "s3", + "bucket": "nintendo-symbols", + "region": "us-east-1", + "access_key": "test-key", + "secret_key": "test-secret", + "layout": {"type": "native"}, + "platforms": ["nintendo-switch"], + }, +} + class BuiltinSymbolSourcesNoSlugTest(APITestCase): endpoint = "sentry-api-0-builtin-symbol-sources" @@ -39,3 +67,77 @@ def test_with_slug(self) -> None: assert "id" in body[0] assert "name" in body[0] assert "hidden" in body[0] + + +class BuiltinSymbolSourcesPlatformFilteringTest(APITestCase): + endpoint = "sentry-api-0-organization-builtin-symbol-sources" + + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization(owner=self.user) + self.login_as(user=self.user) + + @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_PLATFORM_TEST) + def test_platform_filtering_nintendo_switch_with_access(self) -> None: + """Nintendo Switch platform should see nintendo source only if org has access""" + # Enable nintendo-switch for this organization + self.organization.update_option("sentry:enabled_console_platforms", ["nintendo-switch"]) + + resp = self.get_response(self.organization.slug, qs_params={"platform": "nintendo-switch"}) + assert resp.status_code == 200 + + body = resp.data + source_keys = [source["sentry_key"] for source in body] + + # Nintendo Switch with access should see nintendo + assert "nintendo" in source_keys + # Should also see public sources (no platform restriction) + assert "public-source-1" in source_keys + assert "public-source-2" in source_keys + + @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_PLATFORM_TEST) + def test_platform_filtering_nintendo_switch_without_access(self) -> None: + """Nintendo Switch platform should NOT see nintendo if org lacks access""" + # Organization does not have nintendo-switch enabled (default is empty list) + + resp = self.get_response(self.organization.slug, qs_params={"platform": "nintendo-switch"}) + assert resp.status_code == 200 + + body = resp.data + source_keys = [source["sentry_key"] for source in body] + + # Should NOT see nintendo without console platform access + assert "nintendo" not in source_keys + # Should still see public sources + assert "public-source-1" in source_keys + assert "public-source-2" in source_keys + + @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_PLATFORM_TEST) + def test_platform_filtering_unity(self) -> None: + """Unity platform should NOT see nintendo source""" + resp = self.get_response(self.organization.slug, qs_params={"platform": "unity"}) + assert resp.status_code == 200 + + body = resp.data + source_keys = [source["sentry_key"] for source in body] + + # Unity should see public sources (no platform restriction) + assert "public-source-1" in source_keys + assert "public-source-2" in source_keys + # Unity should NOT see nintendo (restricted to nintendo-switch) + assert "nintendo" not in source_keys + + @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_PLATFORM_TEST) + def test_no_platform_parameter(self) -> None: + """Without platform parameter, should see public sources but not platform-restricted ones""" + resp = self.get_response(self.organization.slug) + assert resp.status_code == 200 + + body = resp.data + source_keys = [source["sentry_key"] for source in body] + + # Should see public sources (no platform restriction) + assert "public-source-1" in source_keys + assert "public-source-2" in source_keys + # Should NOT see platform-restricted source when no platform is provided + assert "nintendo" not in source_keys diff --git a/tests/sentry/api/helpers/test_default_symbol_sources.py b/tests/sentry/api/helpers/test_default_symbol_sources.py new file mode 100644 index 00000000000000..4d486ad46fccdb --- /dev/null +++ b/tests/sentry/api/helpers/test_default_symbol_sources.py @@ -0,0 +1,129 @@ +from django.test import override_settings + +from sentry.api.helpers.default_symbol_sources import set_default_symbol_sources +from sentry.testutils.cases import TestCase + +# Mock SENTRY_BUILTIN_SOURCES with a platform-restricted source for testing +SENTRY_BUILTIN_SOURCES_TEST = { + "nintendo": { + "id": "sentry:nintendo", + "name": "Nintendo SDK", + "type": "s3", + "bucket": "nintendo-symbols", + "region": "us-east-1", + "access_key": "test-key", + "secret_key": "test-secret", + "layout": {"type": "native"}, + "platforms": ["nintendo-switch"], + }, +} + + +class SetDefaultSymbolSourcesTest(TestCase): + def setUp(self): + super().setUp() + self.organization = self.create_organization(owner=self.user) + + def test_no_platform(self): + """Projects without a platform should keep their epoch defaults""" + project = self.create_project(organization=self.organization, platform=None) + # Capture epoch defaults before calling set_default_symbol_sources + epoch_defaults = project.get_option("sentry:builtin_symbol_sources") + + set_default_symbol_sources(project, self.organization) + + # Should not change the defaults for projects without a platform + sources = project.get_option("sentry:builtin_symbol_sources") + assert sources == epoch_defaults + + def test_unknown_platform(self): + """Projects with unknown platforms should keep their epoch defaults""" + project = self.create_project(organization=self.organization, platform="unknown-platform") + # Capture epoch defaults before calling set_default_symbol_sources + epoch_defaults = project.get_option("sentry:builtin_symbol_sources") + + set_default_symbol_sources(project, self.organization) + + # Should not change the defaults for projects with unknown platforms + sources = project.get_option("sentry:builtin_symbol_sources") + assert sources == epoch_defaults + + def test_electron_platform(self): + """Electron projects should get the correct default sources""" + project = self.create_project(organization=self.organization, platform="electron") + set_default_symbol_sources(project, self.organization) + + sources = project.get_option("sentry:builtin_symbol_sources") + assert sources is not None + assert "ios" in sources + assert "microsoft" in sources + assert "electron" in sources + + def test_unity_platform(self): + """Unity projects should get the correct default sources""" + project = self.create_project(organization=self.organization, platform="unity") + set_default_symbol_sources(project, self.organization) + + sources = project.get_option("sentry:builtin_symbol_sources") + assert sources is not None + assert "ios" in sources + assert "microsoft" in sources + assert "android" in sources + assert "nuget" in sources + assert "unity" in sources + assert "nvidia" in sources + assert "ubuntu" in sources + + def test_organization_auto_fetch_from_project(self): + """Function should auto-fetch organization from project if not provided""" + project = self.create_project(organization=self.organization, platform="electron") + # Don't pass organization parameter + set_default_symbol_sources(project) + + sources = project.get_option("sentry:builtin_symbol_sources") + assert sources is not None + assert "electron" in sources + + +class PlatformRestrictedSymbolSourcesTest(TestCase): + """Tests for platform-restricted symbol sources (e.g., console platforms)""" + + def setUp(self): + super().setUp() + self.organization = self.create_organization(owner=self.user) + + @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_TEST) + def test_nintendo_switch_with_org_access(self): + """Nintendo Switch project should get nintendo source if org has access""" + # Grant org access to nintendo-switch console platform + self.organization.update_option("sentry:enabled_console_platforms", ["nintendo-switch"]) + + project = self.create_project(organization=self.organization, platform="nintendo-switch") + set_default_symbol_sources(project, self.organization) + + sources = project.get_option("sentry:builtin_symbol_sources") + assert sources is not None + assert "nintendo" in sources + + @override_settings(SENTRY_BUILTIN_SOURCES=SENTRY_BUILTIN_SOURCES_TEST) + def test_nintendo_switch_without_org_access(self): + """Nintendo Switch project should NOT get nintendo source if org lacks access""" + # Org has no enabled console platforms (default is empty list) + project = self.create_project(organization=self.organization, platform="nintendo-switch") + set_default_symbol_sources(project, self.organization) + + sources = project.get_option("sentry:builtin_symbol_sources") + # Should be empty since no sources are available (nintendo is restricted) + assert sources == [] + + def test_unity_not_affected_by_console_restrictions(self): + """Unity projects should get sources regardless of console platform access""" + # Org has no enabled console platforms + project = self.create_project(organization=self.organization, platform="unity") + set_default_symbol_sources(project, self.organization) + + sources = project.get_option("sentry:builtin_symbol_sources") + assert sources is not None + # Unity sources have no platform restrictions, so they should all be added + assert "unity" in sources + assert "microsoft" in sources