diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 1ad5e7d42e8552..35b0216d35b55e 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -29,7 +29,7 @@ prevent: 0002_alter_integration_id_not_null releases: 0004_cleanup_failed_safe_deletes -replays: 0006_add_bulk_delete_job +replays: 0007_organizationmember_replay_access sentry: 1012_add_event_id_to_open_period diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index 6be0c32f1eca40..52b34576bbe9a6 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -81,6 +81,7 @@ from sentry.models.team import Team, TeamStatus from sentry.organizations.absolute_url import generate_organization_url from sentry.organizations.services.organization import RpcOrganizationSummary +from sentry.replays.models import OrganizationMemberReplayAccess from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -563,13 +564,51 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp autoEnableCodeReview: bool autoOpenPrs: bool defaultCodeReviewTriggers: list[str] + hasGranularReplayPermissions: bool + replayAccessMembers: list[int] class DetailedOrganizationSerializer(OrganizationSerializer): def get_attrs( self, item_list: Sequence[Organization], user: User | RpcUser | AnonymousUser, **kwargs: Any ) -> MutableMapping[Organization, MutableMapping[str, Any]]: - return super().get_attrs(item_list, user) + attrs = super().get_attrs(item_list, user) + + replay_permissions = {} + has_feature = features.batch_has_for_organizations( + "organizations:granular-replay-permissions", item_list + ) + if has_feature and any(has_feature.values()): + replay_permissions = { + opt.organization_id: opt.value + for opt in OrganizationOption.objects.filter( + organization__in=item_list, key="sentry:granular-replay-permissions" + ) + } + + # Only process replay access data if replay_permissions is enabled for at least one org + enabled_org_ids = [org_id for org_id, enabled in replay_permissions.items() if enabled] + replay_access_by_org: dict[int, list[int]] = {} + if enabled_org_ids: + for org_id, user_id in OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization__in=enabled_org_ids + ).values_list("organizationmember__organization_id", "organizationmember__user_id"): + if user_id is not None: + replay_access_by_org.setdefault(org_id, []).append(user_id) + + for item in item_list: + attrs[item]["replay_permissions_enabled"] = replay_permissions.get(item.id, False) + attrs[item]["replay_access_members"] = ( + replay_access_by_org.get(item.id, []) + if replay_permissions.get(item.id, False) + else [] + ) + else: + for item in item_list: + attrs[item]["replay_permissions_enabled"] = False + attrs[item]["replay_access_members"] = [] + + return attrs def serialize( # type: ignore[override] self, @@ -745,8 +784,14 @@ def serialize( # type: ignore[override] team__organization=obj ).count(), "isDynamicallySampled": is_dynamically_sampled, + "hasGranularReplayPermissions": False, + "replayAccessMembers": [], } + if features.has("organizations:granular-replay-permissions", obj): + context["hasGranularReplayPermissions"] = bool(attrs.get("replay_permissions_enabled")) + context["replayAccessMembers"] = attrs.get("replay_access_members", []) + if has_custom_dynamic_sampling(obj, actor=user): context["targetSampleRate"] = float( obj.get_option("sentry:target_sample_rate", TARGET_SAMPLE_RATE_DEFAULT) @@ -796,6 +841,8 @@ def serialize( # type: ignore[override] "streamlineOnly", "ingestThroughTrustedRelaysOnly", "enabledConsolePlatforms", + "hasGranularReplayPermissions", + "replayAccessMembers", ] ) class DetailedOrganizationSerializerWithProjectsAndTeamsResponse( diff --git a/src/sentry/backup/comparators.py b/src/sentry/backup/comparators.py index 5636fbdf08c46c..323ada5a8136d2 100644 --- a/src/sentry/backup/comparators.py +++ b/src/sentry/backup/comparators.py @@ -982,6 +982,9 @@ def get_default_comparators() -> dict[str, list[JSONScrubbingComparator]]: DateUpdatedComparator("date_updated", "date_added") ], "monitors.monitor": [UUID4Comparator("guid")], + "replays.organizationmemberreplayaccess": [ + DateUpdatedComparator("date_updated", "date_added") + ], }, ) diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py index e9541d8f22bbc3..d3701ea447d03e 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -11,6 +11,7 @@ from django.utils import timezone as django_timezone from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_serializer from rest_framework import serializers, status +from rest_framework.exceptions import NotFound, PermissionDenied from sentry_sdk import capture_exception from bitfield.types import BitHandler @@ -94,6 +95,7 @@ from sentry.models.options.organization_option import OrganizationOption from sentry.models.options.project_option import ProjectOption from sentry.models.organization import Organization, OrganizationStatus +from sentry.models.organizationmember import OrganizationMember from sentry.models.project import Project from sentry.organizations.services.organization import organization_service from sentry.organizations.services.organization.model import ( @@ -102,6 +104,7 @@ RpcOrganizationDeleteState, ) from sentry.relay.datascrubbing import validate_pii_config_update, validate_pii_selectors +from sentry.replays.models import OrganizationMemberReplayAccess from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.services.organization.provisioning import ( OrganizationSlugCollisionException, @@ -369,6 +372,13 @@ class OrganizationSerializer(BaseOrganizationSerializer): ingestThroughTrustedRelaysOnly = serializers.ChoiceField( choices=[("enabled", "enabled"), ("disabled", "disabled")], required=False ) + hasGranularReplayPermissions = serializers.BooleanField(required=False) + replayAccessMembers = serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True, + help_text="List of user IDs that have access to replay data. Only modifiable by owners and managers.", + ) def _has_sso_enabled(self): org = self.context["organization"] @@ -475,6 +485,26 @@ def validate_samplingMode(self, value): return value + def validate_hasGranularReplayPermissions(self, value): + self._validate_granular_replay_permissions() + return value + + def validate_replayAccessMembers(self, value): + self._validate_granular_replay_permissions() + return value + + def _validate_granular_replay_permissions(self): + organization = self.context["organization"] + request = self.context["request"] + + if not features.has("organizations:granular-replay-permissions", organization): + raise NotFound("This feature is not enabled for your organization.") + + if not request.access.has_scope("org:admin"): + raise PermissionDenied( + "You do not have permission to modify granular replay permissions." + ) + def validate(self, attrs): attrs = super().validate(attrs) if attrs.get("avatarType") == "upload": @@ -589,6 +619,74 @@ def save(self, **kwargs): if trusted_relay_info is not None: self.save_trusted_relays(trusted_relay_info, changed_data, org) + if "hasGranularReplayPermissions" in data: + option_key = "sentry:granular-replay-permissions" + new_value = data["hasGranularReplayPermissions"] + option_inst, created = OrganizationOption.objects.get_or_create( + organization=org, key=option_key, defaults={"value": new_value} + ) + if not created and option_inst.value != new_value: + old_val = option_inst.value + option_inst.value = new_value + option_inst.save() + changed_data["hasGranularReplayPermissions"] = f"from {old_val} to {new_value}" + elif created: + changed_data["hasGranularReplayPermissions"] = f"to {new_value}" + + if "replayAccessMembers" in data: + user_ids = data["replayAccessMembers"] + if user_ids is None: + user_ids = [] + + current_user_ids = set( + OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization=org + ).values_list("organizationmember__user_id", flat=True) + ) + new_user_ids = set(user_ids) + + to_add = new_user_ids - current_user_ids + to_remove = current_user_ids - new_user_ids + + if to_add: + user_to_member = dict( + OrganizationMember.objects.filter( + organization=org, user_id__in=to_add + ).values_list("user_id", "id") + ) + invalid_user_ids = to_add - set(user_to_member.keys()) + if invalid_user_ids: + raise serializers.ValidationError( + { + "replayAccessMembers": f"Invalid user IDs (not members of this organization): {sorted(invalid_user_ids)}" + } + ) + + OrganizationMemberReplayAccess.objects.bulk_create( + [ + OrganizationMemberReplayAccess( + organizationmember_id=user_to_member[user_id] + ) + for user_id in to_add + ], + ignore_conflicts=True, + ) + + if to_remove: + OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization=org, organizationmember__user_id__in=to_remove + ).delete() + + if to_add or to_remove: + changes = [] + if to_add: + changes.append(f"added {len(to_add)} user(s)") + if to_remove: + changes.append(f"removed {len(to_remove)} user(s)") + changed_data["replayAccessMembers"] = ( + f"{' and '.join(changes)} (total: {len(new_user_ids)} user(s) with access)" + ) + if "openMembership" in data: org.flags.allow_joinleave = data["openMembership"] if "allowSharedIssues" in data: @@ -809,6 +907,16 @@ class OrganizationDetailsPutSerializer(serializers.Serializer): help_text="The role required to download debug information files, ProGuard mappings and source maps.", required=False, ) + hasGranularReplayPermissions = serializers.BooleanField( + help_text="Specify `true` to enable granular replay permissions, allowing per-member access control for replay data.", + required=False, + ) + replayAccessMembers = serializers.ListField( + child=serializers.IntegerField(), + help_text="A list of user IDs who have permission to access replay data. Requires the hasGranularReplayPermissions flag to be true to be enforced.", + required=False, + allow_null=True, + ) # avatar avatarType = serializers.ChoiceField( diff --git a/src/sentry/replays/endpoints/organization_replay_count.py b/src/sentry/replays/endpoints/organization_replay_count.py index 54c64fdd89aacf..cda70e170226f9 100644 --- a/src/sentry/replays/endpoints/organization_replay_count.py +++ b/src/sentry/replays/endpoints/organization_replay_count.py @@ -21,6 +21,7 @@ from sentry.models.organization import Organization from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig +from sentry.replays.permissions import has_replay_permission from sentry.replays.usecases.replay_counts import get_replay_counts from sentry.snuba.dataset import Dataset from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -84,6 +85,8 @@ def get(self, request: Request, organization: Organization) -> Response: """ if not features.has("organizations:session-replay", organization, actor=request.user): return Response(status=404) + if not has_replay_permission(organization, request.user): + return Response(status=403) try: snuba_params = self.get_snuba_params(request, organization) diff --git a/src/sentry/replays/endpoints/organization_replay_details.py b/src/sentry/replays/endpoints/organization_replay_details.py index f5109d5caf15d5..ec615987d0acbf 100644 --- a/src/sentry/replays/endpoints/organization_replay_details.py +++ b/src/sentry/replays/endpoints/organization_replay_details.py @@ -20,13 +20,14 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.organization import NoProjects, OrganizationEndpoint +from sentry.api.bases.organization import NoProjects from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.parameters import GlobalParams, ReplayParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ALL_ACCESS_PROJECTS from sentry.models.organization import Organization +from sentry.replays.endpoints.organization_replay_endpoint import OrganizationReplayEndpoint from sentry.replays.lib.eap import read as eap_read from sentry.replays.lib.eap.snuba_transpiler import RequestMeta, Settings from sentry.replays.post_process import ReplayDetailsResponse, process_raw_response @@ -216,7 +217,7 @@ def query_replay_instance_eap( @region_silo_endpoint @extend_schema(tags=["Replays"]) -class OrganizationReplayDetailsEndpoint(OrganizationEndpoint): +class OrganizationReplayDetailsEndpoint(OrganizationReplayEndpoint): """ The same data as ProjectReplayDetails, except no project is required. This works as we'll query for this replay_id across all projects in the @@ -243,8 +244,8 @@ def get(self, request: Request, organization: Organization, replay_id: str) -> R """ Return details on an individual replay. """ - if not features.has("organizations:session-replay", organization, actor=request.user): - return Response(status=404) + if response := self.check_replay_access(request, organization): + return response try: filter_params = self.get_filter_params( @@ -294,12 +295,12 @@ def get(self, request: Request, organization: Organization, replay_id: str) -> R request_user_id=request.user.id, ) - response = process_raw_response( + replay_data = process_raw_response( snuba_response, fields=request.query_params.getlist("field"), ) - if len(response) == 0: + if len(replay_data) == 0: return Response(status=404) else: - return Response({"data": response[0]}, status=200) + return Response({"data": replay_data[0]}, status=200) diff --git a/src/sentry/replays/endpoints/organization_replay_endpoint.py b/src/sentry/replays/endpoints/organization_replay_endpoint.py new file mode 100644 index 00000000000000..498c516d977a8e --- /dev/null +++ b/src/sentry/replays/endpoints/organization_replay_endpoint.py @@ -0,0 +1,30 @@ +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import features +from sentry.api.bases.organization import OrganizationEndpoint +from sentry.models.organization import Organization +from sentry.replays.permissions import has_replay_permission + + +class OrganizationReplayEndpoint(OrganizationEndpoint): + """ + Base endpoint for replay-related organizationendpoints. + Provides centralized feature and permission checks for session replay access. + Added to ensure that all replay endpoints are consistent and follow the same pattern + for allowing granular user-based replay access control, in addition to the existing + role-based access control and feature flag-based access control. + """ + + def check_replay_access(self, request: Request, organization: Organization) -> Response | None: + """ + Check if the session replay feature is enabled and user has replay permissions. + Returns a Response object if access should be denied, None if access is granted. + """ + if not features.has("organizations:session-replay", organization, actor=request.user): + return Response(status=404) + + if not has_replay_permission(organization, request.user): + return Response(status=403) + + return None diff --git a/src/sentry/replays/endpoints/organization_replay_events_meta.py b/src/sentry/replays/endpoints/organization_replay_events_meta.py index 8a7fcc4a223af6..db3a8ef74ce6dd 100644 --- a/src/sentry/replays/endpoints/organization_replay_events_meta.py +++ b/src/sentry/replays/endpoints/organization_replay_events_meta.py @@ -12,6 +12,7 @@ from sentry.api.paginator import GenericOffsetPaginator from sentry.api.utils import reformat_timestamp_ms_to_isoformat from sentry.models.organization import Organization +from sentry.replays.permissions import has_replay_permission @region_silo_endpoint @@ -53,6 +54,9 @@ def get(self, request: Request, organization: Organization) -> Response: if not features.has("organizations:session-replay", organization, actor=request.user): return Response(status=404) + if not has_replay_permission(organization, request.user): + return Response(status=403) + try: snuba_params = self.get_snuba_params(request, organization) except NoProjects: diff --git a/src/sentry/replays/endpoints/organization_replay_index.py b/src/sentry/replays/endpoints/organization_replay_index.py index 5430938f8b7cd3..1a2356c2a326a0 100644 --- a/src/sentry/replays/endpoints/organization_replay_index.py +++ b/src/sentry/replays/endpoints/organization_replay_index.py @@ -6,11 +6,10 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.organization import NoProjects, OrganizationEndpoint +from sentry.api.bases.organization import NoProjects from sentry.api.event_search import parse_search_query from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN from sentry.apidocs.examples.replay_examples import ReplayExamples @@ -18,6 +17,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.exceptions import InvalidSearchQuery from sentry.models.organization import Organization +from sentry.replays.endpoints.organization_replay_endpoint import OrganizationReplayEndpoint from sentry.replays.post_process import ReplayDetailsResponse, process_raw_response from sentry.replays.query import query_replays_collection_paginated, replay_url_parser_config from sentry.replays.usecases.errors import handled_snuba_exceptions @@ -28,7 +28,7 @@ @region_silo_endpoint @extend_schema(tags=["Replays"]) -class OrganizationReplayIndexEndpoint(OrganizationEndpoint): +class OrganizationReplayIndexEndpoint(OrganizationReplayEndpoint): owner = ApiOwner.REPLAY publish_status = { "GET": ApiPublishStatus.PUBLIC, @@ -50,8 +50,9 @@ def get(self, request: Request, organization: Organization) -> Response: Return a list of replays belonging to an organization. """ - if not features.has("organizations:session-replay", organization, actor=request.user): - return Response(status=404) + if response := self.check_replay_access(request, organization): + return response + try: filter_params = self.get_filter_params(request, organization) except NoProjects: diff --git a/src/sentry/replays/endpoints/organization_replay_selector_index.py b/src/sentry/replays/endpoints/organization_replay_selector_index.py index f24bb97eb6921f..92307ce65b08ae 100644 --- a/src/sentry/replays/endpoints/organization_replay_selector_index.py +++ b/src/sentry/replays/endpoints/organization_replay_selector_index.py @@ -23,11 +23,10 @@ ) from snuba_sdk import Request as SnubaRequest -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.organization import NoProjects, OrganizationEndpoint +from sentry.api.bases.organization import NoProjects from sentry.api.event_search import QueryToken, parse_search_query from sentry.api.paginator import GenericOffsetPaginator from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN @@ -36,6 +35,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.exceptions import InvalidSearchQuery from sentry.models.organization import Organization +from sentry.replays.endpoints.organization_replay_endpoint import OrganizationReplayEndpoint from sentry.replays.lib.new_query.conditions import IntegerScalar from sentry.replays.lib.new_query.fields import FieldProtocol, IntegerColumnField from sentry.replays.lib.new_query.parsers import parse_int @@ -75,7 +75,7 @@ class ReplaySelectorResponse(TypedDict): @region_silo_endpoint @extend_schema(tags=["Replays"]) -class OrganizationReplaySelectorIndexEndpoint(OrganizationEndpoint): +class OrganizationReplaySelectorIndexEndpoint(OrganizationReplayEndpoint): owner = ApiOwner.REPLAY publish_status = { "GET": ApiPublishStatus.PUBLIC, @@ -106,8 +106,9 @@ def get_replay_filter_params(self, request, organization): ) def get(self, request: Request, organization: Organization) -> Response: """Return a list of selectors for a given organization.""" - if not features.has("organizations:session-replay", organization, actor=request.user): - return Response(status=404) + if response := self.check_replay_access(request, organization): + return response + try: filter_params = self.get_replay_filter_params(request, organization) except NoProjects: diff --git a/src/sentry/replays/endpoints/project_replay_clicks_index.py b/src/sentry/replays/endpoints/project_replay_clicks_index.py index 78e42a0bdac095..119e009fda4b52 100644 --- a/src/sentry/replays/endpoints/project_replay_clicks_index.py +++ b/src/sentry/replays/endpoints/project_replay_clicks_index.py @@ -24,11 +24,9 @@ ) from snuba_sdk.orderby import Direction -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.project import ProjectEndpoint from sentry.api.event_search import ParenExpression, QueryToken, SearchFilter, parse_search_query from sentry.api.paginator import GenericOffsetPaginator from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND @@ -37,6 +35,7 @@ from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.exceptions import InvalidSearchQuery from sentry.models.project import Project +from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.lib.new_query.errors import CouldNotParseValue, OperatorNotSupported from sentry.replays.lib.new_query.fields import FieldProtocol from sentry.replays.lib.query import attempt_compressed_condition @@ -58,7 +57,7 @@ class ReplayClickResponse(TypedDict): @region_silo_endpoint @extend_schema(tags=["Replays"]) -class ProjectReplayClicksIndexEndpoint(ProjectEndpoint): +class ProjectReplayClicksIndexEndpoint(ProjectReplayEndpoint): owner = ApiOwner.REPLAY publish_status = { "GET": ApiPublishStatus.PUBLIC, @@ -85,10 +84,8 @@ class ProjectReplayClicksIndexEndpoint(ProjectEndpoint): ) def get(self, request: Request, project: Project, replay_id: str) -> Response: """Retrieve a collection of RRWeb DOM node-ids and the timestamp they were clicked.""" - if not features.has( - "organizations:session-replay", project.organization, actor=request.user - ): - return Response(status=404) + if response := self.check_replay_access(request, project): + return response filter_params = self.get_filter_params(request, project) diff --git a/src/sentry/replays/endpoints/project_replay_details.py b/src/sentry/replays/endpoints/project_replay_details.py index 87ca10c29cf2e3..dd9576bafaad2b 100644 --- a/src/sentry/replays/endpoints/project_replay_details.py +++ b/src/sentry/replays/endpoints/project_replay_details.py @@ -8,10 +8,11 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.project import ProjectEndpoint, ProjectPermission +from sentry.api.bases.project import ProjectPermission from sentry.apidocs.constants import RESPONSE_NO_CONTENT, RESPONSE_NOT_FOUND from sentry.apidocs.parameters import GlobalParams, ReplayParams from sentry.models.project import Project +from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.post_process import process_raw_response from sentry.replays.query import query_replay_instance from sentry.replays.tasks import delete_replay @@ -29,7 +30,7 @@ class ReplayDetailsPermission(ProjectPermission): @region_silo_endpoint @extend_schema(tags=["Replays"]) -class ProjectReplayDetailsEndpoint(ProjectEndpoint): +class ProjectReplayDetailsEndpoint(ProjectReplayEndpoint): owner = ApiOwner.REPLAY publish_status = { "DELETE": ApiPublishStatus.PUBLIC, @@ -39,10 +40,8 @@ class ProjectReplayDetailsEndpoint(ProjectEndpoint): permission_classes = (ReplayDetailsPermission,) def get(self, request: Request, project: Project, replay_id: str) -> Response: - if not features.has( - "organizations:session-replay", project.organization, actor=request.user - ): - return Response(status=404) + if response := self.check_replay_access(request, project): + return response filter_params = self.get_filter_params(request, project) @@ -60,15 +59,15 @@ def get(self, request: Request, project: Project, replay_id: str) -> Response: request_user_id=request.user.id, ) - response = process_raw_response( + replay_data = process_raw_response( snuba_response, fields=request.query_params.getlist("field"), ) - if len(response) == 0: + if len(replay_data) == 0: return Response(status=404) else: - return Response({"data": response[0]}, status=200) + return Response({"data": replay_data[0]}, status=200) @extend_schema( operation_id="Delete a Replay Instance", @@ -87,11 +86,8 @@ def delete(self, request: Request, project: Project, replay_id: str) -> Response """ Delete a replay. """ - - if not features.has( - "organizations:session-replay", project.organization, actor=request.user - ): - return Response(status=404) + if response := self.check_replay_access(request, project): + return response if has_archived_segment(project.id, replay_id): return Response(status=404) diff --git a/src/sentry/replays/endpoints/project_replay_endpoint.py b/src/sentry/replays/endpoints/project_replay_endpoint.py new file mode 100644 index 00000000000000..ef968c2b56cc47 --- /dev/null +++ b/src/sentry/replays/endpoints/project_replay_endpoint.py @@ -0,0 +1,32 @@ +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import features +from sentry.api.bases.project import ProjectEndpoint +from sentry.models.project import Project +from sentry.replays.permissions import has_replay_permission + + +class ProjectReplayEndpoint(ProjectEndpoint): + """ + Base endpoint for replay-related endpoints. + Provides centralized feature and permission checks for session replay access. + Added to ensure that all replay endpoints are consistent and follow the same pattern + for allowing granular user-based replay access control, in addition to the existing + role-based access control and feature flag-based access control. + """ + + def check_replay_access(self, request: Request, project: Project) -> Response | None: + """ + Check if the session replay feature is enabled and user has replay permissions. + Returns a Response object if access should be denied, None if access is granted. + """ + if not features.has( + "organizations:session-replay", project.organization, actor=request.user + ): + return Response(status=404) + + if not has_replay_permission(project.organization, request.user): + return Response(status=403) + + return None diff --git a/src/sentry/replays/endpoints/project_replay_jobs_delete.py b/src/sentry/replays/endpoints/project_replay_jobs_delete.py index e3d73ec93b066b..52a922e44c6f74 100644 --- a/src/sentry/replays/endpoints/project_replay_jobs_delete.py +++ b/src/sentry/replays/endpoints/project_replay_jobs_delete.py @@ -10,7 +10,9 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import Serializer, serialize +from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.models import ReplayDeletionJobModel +from sentry.replays.permissions import has_replay_permission from sentry.replays.tasks import run_bulk_replay_delete_job @@ -67,6 +69,9 @@ def get(self, request: Request, project) -> Response: """ Retrieve a collection of replay delete jobs. """ + if not has_replay_permission(project.organization, request.user): + return Response(status=403) + queryset = ReplayDeletionJobModel.objects.filter( organization_id=project.organization_id, project_id=project.id ) @@ -85,6 +90,9 @@ def post(self, request: Request, project) -> Response: """ Create a new replay deletion job. """ + if not has_replay_permission(project.organization, request.user): + return Response(status=403) + serializer = ReplayDeletionJobCreateSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=400) @@ -124,7 +132,7 @@ def post(self, request: Request, project) -> Response: @region_silo_endpoint -class ProjectReplayDeletionJobDetailEndpoint(ProjectEndpoint): +class ProjectReplayDeletionJobDetailEndpoint(ProjectReplayEndpoint): owner = ApiOwner.REPLAY publish_status = { "GET": ApiPublishStatus.PRIVATE, @@ -135,6 +143,9 @@ def get(self, request: Request, project, job_id: int) -> Response: """ Fetch a replay delete job instance. """ + if response := self.check_replay_access(request, project): + return response + try: job = ReplayDeletionJobModel.objects.get( id=job_id, organization_id=project.organization_id, project_id=project.id diff --git a/src/sentry/replays/endpoints/project_replay_recording_segment_details.py b/src/sentry/replays/endpoints/project_replay_recording_segment_details.py index 95b7bf37bebd28..ee7c62067c3940 100644 --- a/src/sentry/replays/endpoints/project_replay_recording_segment_details.py +++ b/src/sentry/replays/endpoints/project_replay_recording_segment_details.py @@ -7,22 +7,21 @@ from drf_spectacular.utils import extend_schema from rest_framework.request import Request -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.project import ProjectEndpoint from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.parameters import GlobalParams, ReplayParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.lib.storage import RecordingSegmentStorageMeta, make_recording_filename from sentry.replays.usecases.reader import download_segment, fetch_segment_metadata @region_silo_endpoint @extend_schema(tags=["Replays"]) -class ProjectReplayRecordingSegmentDetailsEndpoint(ProjectEndpoint): +class ProjectReplayRecordingSegmentDetailsEndpoint(ProjectReplayEndpoint): owner = ApiOwner.REPLAY publish_status = { "GET": ApiPublishStatus.PUBLIC, @@ -48,10 +47,8 @@ class ProjectReplayRecordingSegmentDetailsEndpoint(ProjectEndpoint): ) def get(self, request: Request, project, replay_id, segment_id) -> HttpResponseBase: """Return a replay recording segment.""" - if not features.has( - "organizations:session-replay", project.organization, actor=request.user - ): - return self.respond(status=404) + if response := self.check_replay_access(request, project): + return response segment = fetch_segment_metadata(project.id, replay_id, int(segment_id)) if not segment: diff --git a/src/sentry/replays/endpoints/project_replay_recording_segment_index.py b/src/sentry/replays/endpoints/project_replay_recording_segment_index.py index 0f53dcd75ecbd8..d14b0e43ffbec2 100644 --- a/src/sentry/replays/endpoints/project_replay_recording_segment_index.py +++ b/src/sentry/replays/endpoints/project_replay_recording_segment_index.py @@ -6,23 +6,22 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.project import ProjectEndpoint from sentry.api.paginator import GenericOffsetPaginator from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, ReplayParams, VisibilityParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.lib.storage import storage from sentry.replays.usecases.reader import download_segments, fetch_segments_metadata @region_silo_endpoint @extend_schema(tags=["Replays"]) -class ProjectReplayRecordingSegmentIndexEndpoint(ProjectEndpoint): +class ProjectReplayRecordingSegmentIndexEndpoint(ProjectReplayEndpoint): owner = ApiOwner.REPLAY publish_status = { "GET": ApiPublishStatus.PUBLIC, @@ -53,10 +52,8 @@ def __init__(self, **options) -> None: ) def get(self, request: Request, project, replay_id: str) -> Response: """Return a collection of replay recording segments.""" - if not features.has( - "organizations:session-replay", project.organization, actor=request.user - ): - return self.respond(status=404) + if response := self.check_replay_access(request, project): + return response return self.paginate( request=request, diff --git a/src/sentry/replays/endpoints/project_replay_summary.py b/src/sentry/replays/endpoints/project_replay_summary.py index 542da6aed0784f..d074ca6b5f28e5 100644 --- a/src/sentry/replays/endpoints/project_replay_summary.py +++ b/src/sentry/replays/endpoints/project_replay_summary.py @@ -12,9 +12,10 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.project import ProjectEndpoint, ProjectPermission +from sentry.api.bases.project import ProjectPermission from sentry.api.utils import default_start_end_dates from sentry.models.project import Project +from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.lib.seer_api import seer_summarization_connection_pool from sentry.replays.lib.storage import storage from sentry.replays.post_process import process_raw_response @@ -44,7 +45,7 @@ class ReplaySummaryPermission(ProjectPermission): @region_silo_endpoint @extend_schema(tags=["Replays"]) -class ProjectReplaySummaryEndpoint(ProjectEndpoint): +class ProjectReplaySummaryEndpoint(ProjectReplayEndpoint): owner = ApiOwner.REPLAY publish_status = { "GET": ApiPublishStatus.EXPERIMENTAL, @@ -127,6 +128,8 @@ def get(self, request: Request, project: Project, replay_id: str) -> Response: {"sample_rate": self.sample_rate_get} if self.sample_rate_get else None ), ): + if response := self.check_replay_access(request, project): + return response if not self.has_replay_summary_access(project, request): return self.respond( @@ -154,6 +157,9 @@ def post(self, request: Request, project: Project, replay_id: str) -> Response: {"sample_rate": self.sample_rate_post} if self.sample_rate_post else None ), ): + if response := self.check_replay_access(request, project): + return response + if not self.has_replay_summary_access(project, request): return self.respond( {"detail": "Replay summaries are not available for this organization."}, diff --git a/src/sentry/replays/endpoints/project_replay_video_details.py b/src/sentry/replays/endpoints/project_replay_video_details.py index 3dcf4d6e66e01e..3743b9e51c3c11 100644 --- a/src/sentry/replays/endpoints/project_replay_video_details.py +++ b/src/sentry/replays/endpoints/project_replay_video_details.py @@ -8,15 +8,14 @@ from drf_spectacular.utils import extend_schema from rest_framework.request import Request -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.project import ProjectEndpoint from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.parameters import GlobalParams, ReplayParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.lib.http import ( MalformedRangeHeader, UnsatisfiableRange, @@ -32,7 +31,7 @@ @region_silo_endpoint @extend_schema(tags=["Replays"]) -class ProjectReplayVideoDetailsEndpoint(ProjectEndpoint): +class ProjectReplayVideoDetailsEndpoint(ProjectReplayEndpoint): owner = ApiOwner.REPLAY publish_status = { "GET": ApiPublishStatus.EXPERIMENTAL, @@ -56,10 +55,8 @@ class ProjectReplayVideoDetailsEndpoint(ProjectEndpoint): ) def get(self, request: Request, project, replay_id, segment_id) -> HttpResponseBase: """Return a replay video.""" - if not features.has( - "organizations:session-replay", project.organization, actor=request.user - ): - return self.respond(status=404) + if response := self.check_replay_access(request, project): + return response segment = fetch_segment_metadata(project.id, replay_id, int(segment_id)) if not segment: @@ -70,16 +67,20 @@ def get(self, request: Request, project, replay_id, segment_id) -> HttpResponseB return self.respond({"detail": "Replay recording segment not found."}, status=404) if range_header := request.headers.get("Range"): - response = handle_range_response(range_header, video) + video_response = handle_range_response(range_header, video) else: video_io = BytesIO(video) iterator = iter(lambda: video_io.read(4096), b"") - response = StreamingHttpResponse(iterator, content_type="application/octet-stream") - response["Content-Length"] = len(video) - - response["Accept-Ranges"] = "bytes" - response["Content-Disposition"] = f'attachment; filename="{make_video_filename(segment)}"' - return response + video_response = StreamingHttpResponse( + iterator, content_type="application/octet-stream" + ) + video_response["Content-Length"] = len(video) + + video_response["Accept-Ranges"] = "bytes" + video_response["Content-Disposition"] = ( + f'attachment; filename="{make_video_filename(segment)}"' + ) + return video_response def handle_range_response(range_header: str, video: bytes) -> HttpResponseBase: diff --git a/src/sentry/replays/endpoints/project_replay_viewed_by.py b/src/sentry/replays/endpoints/project_replay_viewed_by.py index 84180d2c454f73..d9a9c371bfd7cd 100644 --- a/src/sentry/replays/endpoints/project_replay_viewed_by.py +++ b/src/sentry/replays/endpoints/project_replay_viewed_by.py @@ -6,16 +6,16 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint -from sentry.api.bases.project import ProjectEndpoint, ProjectEventPermission +from sentry.api.bases.project import ProjectEventPermission from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.parameters import GlobalParams, ReplayParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.project import Project +from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.query import query_replay_viewed_by_ids from sentry.replays.usecases.events import publish_replay_event, viewed_event from sentry.replays.usecases.query import execute_query, make_full_aggregation_query @@ -33,7 +33,7 @@ class ReplayViewedByResponse(TypedDict): @region_silo_endpoint @extend_schema(tags=["Replays"]) -class ProjectReplayViewedByEndpoint(ProjectEndpoint): +class ProjectReplayViewedByEndpoint(ProjectReplayEndpoint): owner = ApiOwner.REPLAY publish_status = {"GET": ApiPublishStatus.PUBLIC, "POST": ApiPublishStatus.PRIVATE} permission_classes = (ProjectEventPermission,) @@ -55,10 +55,8 @@ class ProjectReplayViewedByEndpoint(ProjectEndpoint): ) def get(self, request: Request, project: Project, replay_id: str) -> Response: """Return a list of users who have viewed a replay.""" - if not features.has( - "organizations:session-replay", project.organization, actor=request.user - ): - return Response(status=404) + if response := self.check_replay_access(request, project): + return response try: uuid.UUID(replay_id) @@ -98,10 +96,8 @@ def post(self, request: Request, project: Project, replay_id: str) -> Response: if not request.user.is_authenticated: return Response(status=400) - if not features.has( - "organizations:session-replay", project.organization, actor=request.user - ): - return Response(status=404) + if response := self.check_replay_access(request, project): + return response try: replay_id = str(uuid.UUID(replay_id)) diff --git a/src/sentry/replays/migrations/0007_organizationmember_replay_access.py b/src/sentry/replays/migrations/0007_organizationmember_replay_access.py new file mode 100644 index 00000000000000..95c5813d13bdb5 --- /dev/null +++ b/src/sentry/replays/migrations/0007_organizationmember_replay_access.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.8 on 2025-12-02 12:31 + +import django.db.models.deletion +from django.db import migrations, models + +import sentry.db.models.fields.bounded +import sentry.db.models.fields.foreignkey +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("replays", "0006_add_bulk_delete_job"), + ("sentry", "1011_update_oc_integration_cascade_to_null"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationMemberReplayAccess", + fields=[ + ( + "id", + sentry.db.models.fields.bounded.BoundedBigAutoField( + primary_key=True, serialize=False + ), + ), + ("date_updated", models.DateTimeField(auto_now=True)), + ("date_added", models.DateTimeField(auto_now_add=True)), + ( + "organizationmember", + sentry.db.models.fields.foreignkey.FlexibleForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="replay_access", + to="sentry.organizationmember", + unique=True, + ), + ), + ], + options={ + "db_table": "sentry_organizationmemberreplayaccess", + }, + ), + ] diff --git a/src/sentry/replays/models.py b/src/sentry/replays/models.py index b1fb841b71ae79..a9eebe85508cab 100644 --- a/src/sentry/replays/models.py +++ b/src/sentry/replays/models.py @@ -8,6 +8,7 @@ from sentry.db.models import ( BoundedBigIntegerField, DefaultFieldsModel, + FlexibleForeignKey, Model, region_silo_model, sane_repr, @@ -78,3 +79,28 @@ def delete(self, *args, **kwargs): rv = super().delete(*args, **kwargs) return rv + + +@region_silo_model +class OrganizationMemberReplayAccess(DefaultFieldsModel): + """ + Tracks which organization members have permission to access replay data. + + When no records exist for an organization, all members have access (default). + When records exist, only members with a record can access replays. + """ + + __relocation_scope__ = RelocationScope.Organization + + organizationmember = FlexibleForeignKey( + "sentry.OrganizationMember", + on_delete=models.CASCADE, + related_name="replay_access", + unique=True, + ) + + class Meta: + app_label = "replays" + db_table = "sentry_organizationmemberreplayaccess" + + __repr__ = sane_repr("organizationmember_id") diff --git a/src/sentry/replays/permissions.py b/src/sentry/replays/permissions.py new file mode 100644 index 00000000000000..58c30c4e2ae2f9 --- /dev/null +++ b/src/sentry/replays/permissions.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.contrib.auth.models import AnonymousUser + +from sentry import features +from sentry.models.options.organization_option import OrganizationOption +from sentry.models.organizationmember import OrganizationMember +from sentry.replays.models import OrganizationMemberReplayAccess + +if TYPE_CHECKING: + from sentry.models.organization import Organization + from sentry.users.models.user import User + + +def has_replay_permission(organization: Organization, user: User | AnonymousUser | None) -> bool: + """ + Determine whether a user has permission to access replay data for a given organization. + + Rules: + - User must be authenticated and an active org member. + - If the 'organizations:granular-replay-permissions' feature flag is OFF, all users have access. + - If the 'sentry:granular-replay-permissions' org option is not set or falsy, all org members have access. + - If no allowlist records exist for the organization but the feature flag is on, no one has access. + - If allowlist records exist, only users explicitly present in the OrganizationMemberReplayAccess allowlist have access. + - Returns True if allowed, False otherwise. + """ + if not features.has("organizations:granular-replay-permissions", organization): + return True + + if user is None or not user.is_authenticated: + return False + + try: + member = OrganizationMember.objects.get(organization=organization, user_id=user.id) + except OrganizationMember.DoesNotExist: + return False + + # if the feature to gate replays by organization option is disabled, return True to allow access to all members + org_option = OrganizationOption.objects.filter( + organization=organization, key="sentry:granular-replay-permissions" + ).first() + if not org_option or not org_option.value: + return True + + allowlist_exists = OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization=organization + ).exists() + + if not allowlist_exists: + return False + + has_access = OrganizationMemberReplayAccess.objects.filter(organizationmember=member).exists() + + return has_access diff --git a/src/sentry/testutils/helpers/backups.py b/src/sentry/testutils/helpers/backups.py index 9e3993638a436b..113ae113bb779e 100644 --- a/src/sentry/testutils/helpers/backups.py +++ b/src/sentry/testutils/helpers/backups.py @@ -103,6 +103,7 @@ from sentry.models.savedsearch import SavedSearch, Visibility from sentry.models.search_common import SearchType from sentry.monitors.models import Monitor, ScheduleType +from sentry.replays.models import OrganizationMemberReplayAccess from sentry.sentry_apps.logic import SentryAppUpdater from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.services.nodestore.django.models import Node @@ -472,6 +473,9 @@ def create_exhaustive_organization( organization=org, key="sentry:scrape_javascript", value=True ) + owner_member = OrganizationMember.objects.get(organization=org, user_id=owner_id) + OrganizationMemberReplayAccess.objects.create(organizationmember=owner_member) + # Team team = self.create_team(name=f"test_team_in_{slug}", organization=org) self.create_team_membership(user=owner, team=team) diff --git a/tests/sentry/api/serializers/test_organization.py b/tests/sentry/api/serializers/test_organization.py index 16ea1fcb20b95e..437c9cd03b8fa1 100644 --- a/tests/sentry/api/serializers/test_organization.py +++ b/tests/sentry/api/serializers/test_organization.py @@ -23,6 +23,7 @@ OrganizationOnboardingTask, ) from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment +from sentry.replays.models import OrganizationMemberReplayAccess from sentry.testutils.cases import TestCase from sentry.testutils.skips import requires_snuba @@ -162,6 +163,93 @@ def test_detailed(self) -> None: assert isinstance(result["teamRoleList"], list) assert result["requiresSso"] == acc.requires_sso + def test_granular_replay_permissions_disabled_without_feature(self) -> None: + user = self.create_user() + organization = self.create_organization(owner=user) + acc = access.from_user(user, organization) + + serializer = DetailedOrganizationSerializer() + result = serialize(organization, user, serializer, access=acc) + + assert result["hasGranularReplayPermissions"] is False + assert result["replayAccessMembers"] == [] + + def test_granular_replay_permissions_flag_with_feature(self) -> None: + user = self.create_user() + organization = self.create_organization(owner=user) + acc = access.from_user(user, organization) + + with self.feature("organizations:granular-replay-permissions"): + serializer = DetailedOrganizationSerializer() + result = serialize(organization, user, serializer, access=acc) + assert result["hasGranularReplayPermissions"] is False + assert result["replayAccessMembers"] == [] + + OrganizationOption.objects.set_value( + organization=organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + result = serialize(organization, user, serializer, access=acc) + assert result["hasGranularReplayPermissions"] is True + assert result["replayAccessMembers"] == [] + + def test_replay_access_members_serialized(self) -> None: + user = self.create_user() + organization = self.create_organization(owner=user) + member1 = self.create_member( + organization=organization, user=self.create_user(), role="member" + ) + member2 = self.create_member( + organization=organization, user=self.create_user(), role="member" + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member1) + OrganizationMemberReplayAccess.objects.create(organizationmember=member2) + acc = access.from_user(user, organization) + + with self.feature("organizations:granular-replay-permissions"): + serializer = DetailedOrganizationSerializer() + result = serialize(organization, user, serializer, access=acc) + assert set(result["replayAccessMembers"]) == set() + + def test_replay_access_members_serialized_with_option_enabled(self) -> None: + user = self.create_user() + organization = self.create_organization(owner=user) + member1 = self.create_member( + organization=organization, user=self.create_user(), role="member" + ) + member2 = self.create_member( + organization=organization, user=self.create_user(), role="member" + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member1) + OrganizationMemberReplayAccess.objects.create(organizationmember=member2) + acc = access.from_user(user, organization) + + with self.feature("organizations:granular-replay-permissions"): + # Set the org option to enable granular replay permissions + OrganizationOption.objects.set_value( + organization=organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + serializer = DetailedOrganizationSerializer() + result = serialize(organization, user, serializer, access=acc) + assert result["hasGranularReplayPermissions"] is True + assert set(result["replayAccessMembers"]) == {member1.user_id, member2.user_id} + + def test_replay_access_members_empty_when_none_set(self) -> None: + user = self.create_user() + organization = self.create_organization(owner=user) + acc = access.from_user(user, organization) + + with self.feature("organizations:granular-replay-permissions"): + serializer = DetailedOrganizationSerializer() + result = serialize(organization, user, serializer, access=acc) + + assert result["replayAccessMembers"] == [] + class DetailedOrganizationSerializerWithProjectsAndTeamsTest(TestCase): def test_detailed_org_projs_teams(self) -> None: diff --git a/tests/sentry/core/endpoints/test_organization_details.py b/tests/sentry/core/endpoints/test_organization_details.py index e23d969b61bff6..76a3965eb9e28b 100644 --- a/tests/sentry/core/endpoints/test_organization_details.py +++ b/tests/sentry/core/endpoints/test_organization_details.py @@ -32,6 +32,7 @@ from sentry.models.organization import Organization, OrganizationStatus from sentry.models.organizationmapping import OrganizationMapping from sentry.models.organizationslugreservation import OrganizationSlugReservation +from sentry.replays.models import OrganizationMemberReplayAccess from sentry.signals import project_created from sentry.silo.safety import unguarded_write from sentry.snuba.metrics import TransactionMRI @@ -1476,6 +1477,261 @@ def test_enable_seer_coding_can_be_enabled(self) -> None: assert self.organization.get_option("sentry:enable_seer_coding") is True + @with_feature("organizations:granular-replay-permissions") + def test_granular_replay_permissions_flag_set(self) -> None: + with assume_test_silo_mode_of(AuditLogEntry): + AuditLogEntry.objects.filter(organization_id=self.organization.id).delete() + + data = {"hasGranularReplayPermissions": True} + with outbox_runner(): + self.get_success_response(self.organization.slug, **data) + + option_value = OrganizationOption.objects.get( + organization=self.organization, key="sentry:granular-replay-permissions" + ) + assert option_value.value is True + + with assume_test_silo_mode_of(AuditLogEntry): + log = AuditLogEntry.objects.get(organization_id=self.organization.id) + assert "to True" in log.data["hasGranularReplayPermissions"] + + @with_feature("organizations:granular-replay-permissions") + def test_granular_replay_permissions_flag_unset(self) -> None: + self.organization.update_option("sentry:granular-replay-permissions", True) + with assume_test_silo_mode_of(AuditLogEntry): + AuditLogEntry.objects.filter(organization_id=self.organization.id).delete() + + data = {"hasGranularReplayPermissions": False} + with outbox_runner(): + self.get_success_response(self.organization.slug, **data) + + option_value = OrganizationOption.objects.get( + organization=self.organization, key="sentry:granular-replay-permissions" + ) + assert option_value.value is False + + with assume_test_silo_mode_of(AuditLogEntry): + log = AuditLogEntry.objects.get(organization_id=self.organization.id) + + assert "to False" in log.data["hasGranularReplayPermissions"] + + @with_feature("organizations:granular-replay-permissions") + def test_granular_replay_permissions_no_spurious_audit_log(self) -> None: + self.organization.update_option("sentry:granular-replay-permissions", True) + with assume_test_silo_mode_of(AuditLogEntry): + AuditLogEntry.objects.filter(organization_id=self.organization.id).delete() + + data = {"hasGranularReplayPermissions": True} + with outbox_runner(): + self.get_success_response(self.organization.slug, **data) + + with assume_test_silo_mode_of(AuditLogEntry): + audit_logs = AuditLogEntry.objects.filter(organization_id=self.organization.id) + assert audit_logs.count() == 0 + + @with_feature("organizations:granular-replay-permissions") + def test_granular_replay_permissions_change_logs_old_value(self) -> None: + self.organization.update_option("sentry:granular-replay-permissions", False) + with assume_test_silo_mode_of(AuditLogEntry): + AuditLogEntry.objects.filter(organization_id=self.organization.id).delete() + + data = {"hasGranularReplayPermissions": True} + with outbox_runner(): + self.get_success_response(self.organization.slug, **data) + + option_value = OrganizationOption.objects.get( + organization=self.organization, key="sentry:granular-replay-permissions" + ) + assert option_value.value is True + + with assume_test_silo_mode_of(AuditLogEntry): + log = AuditLogEntry.objects.get(organization_id=self.organization.id) + assert log.data["hasGranularReplayPermissions"] == "from False to True" + + def test_granular_replay_permissions_flag_requires_feature(self) -> None: + data = {"hasGranularReplayPermissions": True} + self.get_error_response(self.organization.slug, **data, status_code=404) + + @with_feature("organizations:granular-replay-permissions") + def test_granular_replay_permissions_flag_requires_admin_scope(self) -> None: + member_user = self.create_user() + self.create_member( + organization=self.organization, user=member_user, role="member", teams=[] + ) + self.login_as(member_user) + + data = {"hasGranularReplayPermissions": True} + response = self.get_error_response(self.organization.slug, **data, status_code=403) + assert response.status_code == 403 + + @with_feature("organizations:granular-replay-permissions") + def test_replay_access_members_add(self) -> None: + member1 = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + member2 = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + with assume_test_silo_mode_of(AuditLogEntry): + AuditLogEntry.objects.filter(organization_id=self.organization.id).delete() + + data = {"replayAccessMembers": [member1.user_id, member2.user_id]} + with outbox_runner(): + self.get_success_response(self.organization.slug, **data) + + access_members = list( + OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization=self.organization + ).values_list("organizationmember_id", flat=True) + ) + assert set(access_members) == {member1.id, member2.id} + + with assume_test_silo_mode_of(AuditLogEntry): + log = AuditLogEntry.objects.get(organization_id=self.organization.id) + assert "added 2 user(s)" in log.data["replayAccessMembers"] + assert "total: 2 user(s)" in log.data["replayAccessMembers"] + + @with_feature("organizations:granular-replay-permissions") + def test_replay_access_members_remove(self) -> None: + member1 = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + member2 = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member1) + OrganizationMemberReplayAccess.objects.create(organizationmember=member2) + with assume_test_silo_mode_of(AuditLogEntry): + AuditLogEntry.objects.filter(organization_id=self.organization.id).delete() + + data = {"replayAccessMembers": [member1.user_id]} + with outbox_runner(): + self.get_success_response(self.organization.slug, **data) + + access_members = list( + OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization=self.organization + ).values_list("organizationmember_id", flat=True) + ) + assert access_members == [member1.id] + + with assume_test_silo_mode_of(AuditLogEntry): + log = AuditLogEntry.objects.get(organization_id=self.organization.id) + assert "removed 1 user(s)" in log.data["replayAccessMembers"] + assert "total: 1 user(s)" in log.data["replayAccessMembers"] + + @with_feature("organizations:granular-replay-permissions") + def test_replay_access_members_add_and_remove(self) -> None: + member1 = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + member2 = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + member3 = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member1) + with assume_test_silo_mode_of(AuditLogEntry): + AuditLogEntry.objects.filter(organization_id=self.organization.id).delete() + + data = {"replayAccessMembers": [member2.user_id, member3.user_id]} + with outbox_runner(): + self.get_success_response(self.organization.slug, **data) + + access_members = set( + OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization=self.organization + ).values_list("organizationmember_id", flat=True) + ) + assert access_members == {member2.id, member3.id} + + with assume_test_silo_mode_of(AuditLogEntry): + log = AuditLogEntry.objects.get(organization_id=self.organization.id) + assert "added 2 user(s)" in log.data["replayAccessMembers"] + assert "removed 1 user(s)" in log.data["replayAccessMembers"] + assert "total: 2 user(s)" in log.data["replayAccessMembers"] + + @with_feature("organizations:granular-replay-permissions") + def test_replay_access_members_clear_all(self) -> None: + member1 = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member1) + with assume_test_silo_mode_of(AuditLogEntry): + AuditLogEntry.objects.filter(organization_id=self.organization.id).delete() + + data: dict[str, Any] = {"replayAccessMembers": []} + with outbox_runner(): + self.get_success_response(self.organization.slug, **data) + + access_count = OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization=self.organization + ).count() + assert access_count == 0 + + with assume_test_silo_mode_of(AuditLogEntry): + log = AuditLogEntry.objects.get(organization_id=self.organization.id) + assert "removed 1 user(s)" in log.data["replayAccessMembers"] + assert "total: 0 user(s)" in log.data["replayAccessMembers"] + + def test_replay_access_members_requires_feature(self) -> None: + member1 = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + data = {"replayAccessMembers": [member1.user_id]} + self.get_error_response(self.organization.slug, **data, status_code=404) + + @with_feature("organizations:granular-replay-permissions") + def test_replay_access_members_requires_admin_scope(self) -> None: + member_user = self.create_user() + self.create_member( + organization=self.organization, user=member_user, role="member", teams=[] + ) + self.login_as(member_user) + + other_member = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + data = {"replayAccessMembers": [other_member.user_id]} + self.get_error_response(self.organization.slug, **data, status_code=403) + + @with_feature("organizations:granular-replay-permissions") + def test_replay_access_members_invalid_user_ids(self) -> None: + nonexistent_id = 999999999 + data = {"replayAccessMembers": [nonexistent_id]} + response = self.get_error_response(self.organization.slug, **data, status_code=400) + assert "replayAccessMembers" in response.data + assert str(nonexistent_id) in response.data["replayAccessMembers"] + + @with_feature("organizations:granular-replay-permissions") + def test_replay_access_members_from_other_organization(self) -> None: + other_org = self.create_organization(owner=self.create_user()) + other_org_member = self.create_member( + organization=other_org, user=self.create_user(), role="member" + ) + data = {"replayAccessMembers": [other_org_member.user_id]} + response = self.get_error_response(self.organization.slug, **data, status_code=400) + assert "replayAccessMembers" in response.data + assert str(other_org_member.user_id) in response.data["replayAccessMembers"] + + @with_feature("organizations:granular-replay-permissions") + def test_replay_access_members_mixed_valid_and_invalid(self) -> None: + valid_member = self.create_member( + organization=self.organization, user=self.create_user(), role="member" + ) + nonexistent_id = 999999999 + data = {"replayAccessMembers": [valid_member.user_id, nonexistent_id]} + response = self.get_error_response(self.organization.slug, **data, status_code=400) + assert "replayAccessMembers" in response.data + assert str(nonexistent_id) in response.data["replayAccessMembers"] + assert str(valid_member.user_id) not in response.data["replayAccessMembers"] + + access_count = OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization=self.organization + ).count() + assert access_count == 0 + class OrganizationDeleteTest(OrganizationDetailsTestBase): method = "delete" diff --git a/tests/sentry/replays/endpoints/test_project_replay_jobs_delete.py b/tests/sentry/replays/endpoints/test_project_replay_jobs_delete.py index 9d1aab93d603a2..84ef71b826f8c2 100644 --- a/tests/sentry/replays/endpoints/test_project_replay_jobs_delete.py +++ b/tests/sentry/replays/endpoints/test_project_replay_jobs_delete.py @@ -5,7 +5,8 @@ from sentry.hybridcloud.outbox.category import OutboxScope from sentry.models.apitoken import ApiToken from sentry.models.auditlogentry import AuditLogEntry -from sentry.replays.models import ReplayDeletionJobModel +from sentry.models.options.organization_option import OrganizationOption +from sentry.replays.models import OrganizationMemberReplayAccess, ReplayDeletionJobModel from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.silo import assume_test_silo_mode, region_silo_test @@ -342,6 +343,133 @@ def test_permission_granted_with_project_admin(self) -> None: ) assert response.status_code == 201 + def test_granular_permissions_without_replay_access(self) -> None: + """Test that users without replay access cannot access endpoints even with project:write""" + with self.feature("organizations:granular-replay-permissions"): + # Enable granular permissions org option + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + # Create another user with replay access + user_with_access = self.create_user() + member_with_access = self.create_member( + user=user_with_access, organization=self.organization, role="admin" + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member_with_access) + + # Login as user without replay access (but has project:write via admin role) + user_without_access = self.create_user() + self.create_member( + user=user_without_access, organization=self.organization, role="admin" + ) + self.login_as(user=user_without_access) + + # GET should return 403 + self.get_error_response(self.organization.slug, self.project.slug, status_code=403) + + # POST should return 403 + data = { + "data": { + "rangeStart": "2023-01-01T00:00:00Z", + "rangeEnd": "2023-01-02T00:00:00Z", + "environments": ["production"], + "query": "test query", + } + } + self.get_error_response( + self.organization.slug, self.project.slug, method="post", **data, status_code=403 + ) + + def test_granular_permissions_with_replay_access(self) -> None: + """Test that users with replay access can access endpoints with project:write""" + with self.feature("organizations:granular-replay-permissions"): + # Enable granular permissions org option + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + # Create user with replay access (admin role gives project:write) + user_with_access = self.create_user() + member_with_access = self.create_member( + user=user_with_access, organization=self.organization, role="admin" + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member_with_access) + self.login_as(user=user_with_access) + + # GET should succeed + response = self.get_success_response(self.organization.slug, self.project.slug) + assert response.data == {"data": []} + + # POST should succeed + data = { + "data": { + "rangeStart": "2023-01-01T00:00:00Z", + "rangeEnd": "2023-01-02T00:00:00Z", + "environments": ["production"], + "query": "test query", + } + } + with patch("sentry.replays.tasks.run_bulk_replay_delete_job.delay"): + response = self.get_success_response( + self.organization.slug, + self.project.slug, + method="post", + **data, + status_code=201, + ) + assert "data" in response.data + + def test_granular_permissions_feature_disabled_allows_all(self) -> None: + """Test that when feature flag is disabled, all users with project:write can access""" + # Enable org option but disable feature flag + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + # Create user with replay access + user_with_access = self.create_user() + member_with_access = self.create_member( + user=user_with_access, organization=self.organization, role="admin" + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member_with_access) + + # Login as user without replay access (admin role gives project:write) + user_without_access = self.create_user() + self.create_member(user=user_without_access, organization=self.organization, role="admin") + self.login_as(user=user_without_access) + + # GET should succeed (feature flag is OFF) + response = self.get_success_response(self.organization.slug, self.project.slug) + assert response.data == {"data": []} + + def test_granular_permissions_org_option_disabled_allows_all(self) -> None: + """Test that when org option is disabled, all users with project:write can access""" + with self.feature("organizations:granular-replay-permissions"): + # Create user with replay access + user_with_access = self.create_user() + member_with_access = self.create_member( + user=user_with_access, organization=self.organization, role="admin" + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member_with_access) + + # Login as user without replay access (org option is NOT enabled, admin role gives project:write) + user_without_access = self.create_user() + self.create_member( + user=user_without_access, organization=self.organization, role="admin" + ) + self.login_as(user=user_without_access) + + # GET should succeed (org option is OFF) + response = self.get_success_response(self.organization.slug, self.project.slug) + assert response.data == {"data": []} + @patch("sentry.replays.tasks.run_bulk_replay_delete_job.delay") def test_post_has_seer_data(self, mock_task: MagicMock) -> None: """Test POST with summaries enabled schedules task with has_seer_data=True.""" @@ -375,7 +503,8 @@ def setUp(self) -> None: super().setUp() self.login_as(self.user) self.organization = self.create_organization(owner=self.user) - self.project = self.create_project(organization=self.organization) + self.team = self.create_team(organization=self.organization) + self.project = self.create_project(organization=self.organization, teams=[self.team]) self.other_project = self.create_project() # Different organization def test_get_success(self) -> None: @@ -390,19 +519,20 @@ def test_get_success(self) -> None: status="in-progress", ) - response = self.get_success_response(self.organization.slug, self.project.slug, job.id) - - assert "data" in response.data - job_data = response.data["data"] - assert job_data["id"] == job.id - assert job_data["status"] == "in-progress" - assert job_data["environments"] == ["prod", "staging"] - assert job_data["query"] == "test query" - assert job_data["countDeleted"] == 0 # Default offset value - assert "dateCreated" in job_data - assert "dateUpdated" in job_data - assert "rangeStart" in job_data - assert "rangeEnd" in job_data + with self.feature("organizations:session-replay"): + response = self.get_success_response(self.organization.slug, self.project.slug, job.id) + + assert "data" in response.data + job_data = response.data["data"] + assert job_data["id"] == job.id + assert job_data["status"] == "in-progress" + assert job_data["environments"] == ["prod", "staging"] + assert job_data["query"] == "test query" + assert job_data["countDeleted"] == 0 # Default offset value + assert "dateCreated" in job_data + assert "dateUpdated" in job_data + assert "rangeStart" in job_data + assert "rangeEnd" in job_data def test_get_count_deleted_reflects_offset(self) -> None: """Test that countDeleted field correctly reflects the offset value""" @@ -417,12 +547,13 @@ def test_get_count_deleted_reflects_offset(self) -> None: offset=123, # Set specific offset value ) - response = self.get_success_response(self.organization.slug, self.project.slug, job.id) + with self.feature("organizations:session-replay"): + response = self.get_success_response(self.organization.slug, self.project.slug, job.id) - assert "data" in response.data - job_data = response.data["data"] - assert job_data["id"] == job.id - assert job_data["countDeleted"] == 123 + assert "data" in response.data + job_data = response.data["data"] + assert job_data["id"] == job.id + assert job_data["countDeleted"] == 123 def test_get_nonexistent_job(self) -> None: """Test GET for non-existent job returns 404""" @@ -523,13 +654,14 @@ def test_permission_granted_with_project_write(self) -> None: token = ApiToken.objects.create(user=self.user, scope_list=["project:write"]) # GET should succeed - response = self.client.get( - f"/api/0/projects/{self.organization.slug}/{self.project.slug}/replays/jobs/delete/{job.id}/", - HTTP_AUTHORIZATION=f"Bearer {token.token}", - format="json", - ) - assert response.status_code == 200 - assert response.data["data"]["id"] == job.id + with self.feature("organizations:session-replay"): + response = self.client.get( + f"/api/0/projects/{self.organization.slug}/{self.project.slug}/replays/jobs/delete/{job.id}/", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + format="json", + ) + assert response.status_code == 200 + assert response.data["data"]["id"] == job.id def test_permission_granted_with_project_admin(self) -> None: """Test that users with project:admin permissions can access endpoint""" @@ -548,10 +680,178 @@ def test_permission_granted_with_project_admin(self) -> None: token = ApiToken.objects.create(user=self.user, scope_list=["project:admin"]) # GET should succeed - response = self.client.get( - f"/api/0/projects/{self.organization.slug}/{self.project.slug}/replays/jobs/delete/{job.id}/", - HTTP_AUTHORIZATION=f"Bearer {token.token}", - format="json", + with self.feature("organizations:session-replay"): + response = self.client.get( + f"/api/0/projects/{self.organization.slug}/{self.project.slug}/replays/jobs/delete/{job.id}/", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + format="json", + ) + assert response.status_code == 200 + assert response.data["data"]["id"] == job.id + + def test_granular_permissions_without_replay_access(self) -> None: + """Test that users without replay access cannot access endpoint even with project:write""" + job = ReplayDeletionJobModel.objects.create( + project_id=self.project.id, + organization_id=self.organization.id, + range_start=datetime.datetime(2023, 1, 1, tzinfo=datetime.UTC), + range_end=datetime.datetime(2023, 1, 2, tzinfo=datetime.UTC), + query="test query", + environments=[], + status="pending", ) - assert response.status_code == 200 - assert response.data["data"]["id"] == job.id + + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + # Enable granular permissions org option + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + # Create another user with replay access + user_with_access = self.create_user() + member_with_access = self.create_member( + user=user_with_access, + organization=self.organization, + role="admin", + teams=[self.team], + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member_with_access) + + # Login as user without replay access (but has project:write via admin role) + user_without_access = self.create_user() + self.create_member( + user=user_without_access, + organization=self.organization, + role="admin", + teams=[self.team], + ) + self.login_as(user=user_without_access) + + # GET should return 403 + self.get_error_response( + self.organization.slug, self.project.slug, job.id, status_code=403 + ) + + def test_granular_permissions_with_replay_access(self) -> None: + """Test that users with replay access can access endpoint with project:write""" + job = ReplayDeletionJobModel.objects.create( + project_id=self.project.id, + organization_id=self.organization.id, + range_start=datetime.datetime(2023, 1, 1, tzinfo=datetime.UTC), + range_end=datetime.datetime(2023, 1, 2, tzinfo=datetime.UTC), + query="test query", + environments=[], + status="pending", + ) + + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + # Enable granular permissions org option + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + # Create user with replay access (admin role gives project:write) + user_with_access = self.create_user() + member_with_access = self.create_member( + user=user_with_access, + organization=self.organization, + role="admin", + teams=[self.team], + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member_with_access) + self.login_as(user=user_with_access) + + # GET should succeed + response = self.get_success_response(self.organization.slug, self.project.slug, job.id) + assert response.data["data"]["id"] == job.id + + def test_granular_permissions_feature_disabled_allows_all(self) -> None: + """Test that when feature flag is disabled, all users with project:write can access""" + job = ReplayDeletionJobModel.objects.create( + project_id=self.project.id, + organization_id=self.organization.id, + range_start=datetime.datetime(2023, 1, 1, tzinfo=datetime.UTC), + range_end=datetime.datetime(2023, 1, 2, tzinfo=datetime.UTC), + query="test query", + environments=[], + status="pending", + ) + + # Enable session-replay and org option but disable granular-replay-permissions feature flag + with self.feature("organizations:session-replay"): + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + # Create user with replay access + user_with_access = self.create_user() + member_with_access = self.create_member( + user=user_with_access, + organization=self.organization, + role="admin", + teams=[self.team], + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member_with_access) + + # Login as user without replay access (admin role gives project:write) + user_without_access = self.create_user() + self.create_member( + user=user_without_access, + organization=self.organization, + role="admin", + teams=[self.team], + ) + self.login_as(user=user_without_access) + + # GET should succeed (feature flag is OFF) + response = self.get_success_response(self.organization.slug, self.project.slug, job.id) + assert response.data["data"]["id"] == job.id + + def test_granular_permissions_org_option_disabled_allows_all(self) -> None: + """Test that when org option is disabled, all users with project:write can access""" + job = ReplayDeletionJobModel.objects.create( + project_id=self.project.id, + organization_id=self.organization.id, + range_start=datetime.datetime(2023, 1, 1, tzinfo=datetime.UTC), + range_end=datetime.datetime(2023, 1, 2, tzinfo=datetime.UTC), + query="test query", + environments=[], + status="pending", + ) + + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + # Create user with replay access + user_with_access = self.create_user() + member_with_access = self.create_member( + user=user_with_access, + organization=self.organization, + role="admin", + teams=[self.team], + ) + OrganizationMemberReplayAccess.objects.create(organizationmember=member_with_access) + + # Login as user without replay access (org option is NOT enabled, admin role gives project:write) + user_without_access = self.create_user() + self.create_member( + user=user_without_access, + organization=self.organization, + role="admin", + teams=[self.team], + ) + self.login_as(user=user_without_access) + + # GET should succeed (org option is OFF) + response = self.get_success_response(self.organization.slug, self.project.slug, job.id) + assert response.data["data"]["id"] == job.id diff --git a/tests/sentry/replays/endpoints/test_project_replay_summary.py b/tests/sentry/replays/endpoints/test_project_replay_summary.py index b2e1eadaf0f931..1a58013cfdd75b 100644 --- a/tests/sentry/replays/endpoints/test_project_replay_summary.py +++ b/tests/sentry/replays/endpoints/test_project_replay_summary.py @@ -84,7 +84,10 @@ def test_feature_flag_disabled(self) -> None: response = ( self.client.get(self.url) if method == "GET" else self.client.post(self.url) ) - assert response.status_code == 403, (replay, replay_ai, method) + # When session-replay is disabled, endpoint returns 404 + # When session-replay is enabled but replay-ai-summaries is disabled, returns 403 + expected_status = 404 if not replay else 403 + assert response.status_code == expected_status, (replay, replay_ai, method) def test_no_seer_access(self) -> None: self.mock_has_seer_access.return_value = False diff --git a/tests/sentry/replays/endpoints/test_replay_granular_permissions.py b/tests/sentry/replays/endpoints/test_replay_granular_permissions.py new file mode 100644 index 00000000000000..ea4ca5f4347c24 --- /dev/null +++ b/tests/sentry/replays/endpoints/test_replay_granular_permissions.py @@ -0,0 +1,198 @@ +from sentry.models.options.organization_option import OrganizationOption +from sentry.replays.models import OrganizationMemberReplayAccess +from sentry.testutils.cases import APITestCase +from sentry.testutils.silo import region_silo_test + + +@region_silo_test +class TestReplayGranularPermissions(APITestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + self.user_with_access = self.create_user() + self.user_without_access = self.create_user() + + self.member_with_access = self.create_member( + organization=self.organization, user=self.user_with_access + ) + self.member_without_access = self.create_member( + organization=self.organization, user=self.user_without_access + ) + + def _enable_granular_permissions(self) -> None: + """Enable the organization option for granular replay permissions""" + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + def test_organization_replay_index_with_permission(self) -> None: + """User with replay permission can access org replay index""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + self.login_as(self.user_with_access) + url = f"/api/0/organizations/{self.organization.slug}/replays/" + response = self.client.get(url) + assert response.status_code == 200 + + def test_organization_replay_index_without_permission(self) -> None: + """User without replay permission cannot access org replay index""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + self.login_as(self.user_without_access) + url = f"/api/0/organizations/{self.organization.slug}/replays/" + response = self.client.get(url) + assert response.status_code == 403 + + def test_organization_replay_details_with_permission(self) -> None: + """User with replay permission can access org replay details (gets 404 for non-existent replay, not 403)""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + self.login_as(self.user_with_access) + url = f"/api/0/organizations/{self.organization.slug}/replays/123e4567-e89b-12d3-a456-426614174000/" + response = self.client.get(url) + # Should get 404 for non-existent replay, NOT 403 Forbidden (which would indicate permission denial) + assert response.status_code == 404 + + def test_organization_replay_details_without_permission(self) -> None: + """User without replay permission cannot access org replay details""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + self.login_as(self.user_without_access) + url = f"/api/0/organizations/{self.organization.slug}/replays/123e4567-e89b-12d3-a456-426614174000/" + response = self.client.get(url) + assert response.status_code == 403 + + def test_organization_replay_count_without_permission(self) -> None: + """User without replay permission cannot access org replay count""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + self.login_as(self.user_without_access) + url = f"/api/0/organizations/{self.organization.slug}/replay-count/" + response = self.client.get(url, {"query": "issue.id:1"}) + assert response.status_code == 403 + + def test_project_replay_details_without_permission(self) -> None: + """User without replay permission cannot access project replay details""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + self.login_as(self.user_without_access) + url = f"/api/0/projects/{self.organization.slug}/{self.project.slug}/replays/123e4567-e89b-12d3-a456-426614174000/" + response = self.client.get(url) + assert response.status_code == 403 + + def test_empty_allowlist_denies_all_users(self) -> None: + """When allowlist is empty and org option is enabled, no org members have access""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + self._enable_granular_permissions() + self.login_as(self.user_without_access) + url = f"/api/0/organizations/{self.organization.slug}/replays/" + response = self.client.get(url) + assert response.status_code == 403 + + def test_org_option_disabled_allows_all_users(self) -> None: + """When org option is disabled, all org members have access even with allowlist""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + self.login_as(self.user_without_access) + url = f"/api/0/organizations/{self.organization.slug}/replays/" + response = self.client.get(url) + assert response.status_code == 200 + + def test_feature_flag_disabled_allows_all_users(self) -> None: + """When feature flag is disabled, all org members have access""" + with self.feature("organizations:session-replay"): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + self.login_as(self.user_without_access) + url = f"/api/0/organizations/{self.organization.slug}/replays/" + response = self.client.get(url) + assert response.status_code == 200 + + def test_removing_last_user_from_allowlist_keeps_access_denied(self) -> None: + """When the last user is removed from allowlist, access remains denied (empty allowlist = no access)""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + self._enable_granular_permissions() + access_record = OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + + self.login_as(self.user_without_access) + url = f"/api/0/organizations/{self.organization.slug}/replays/" + response = self.client.get(url) + assert response.status_code == 403 + + access_record.delete() + + assert not OrganizationMemberReplayAccess.objects.filter( + organizationmember__organization=self.organization + ).exists() + + response = self.client.get(url) + assert response.status_code == 403 + + def test_disabling_org_option_reopens_access(self) -> None: + """When org option is disabled, all org members regain access""" + with self.feature( + ["organizations:session-replay", "organizations:granular-replay-permissions"] + ): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + + self.login_as(self.user_without_access) + url = f"/api/0/organizations/{self.organization.slug}/replays/" + response = self.client.get(url) + assert response.status_code == 403 + + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=False, + ) + + response = self.client.get(url) + assert response.status_code == 200 diff --git a/tests/sentry/replays/test_permissions.py b/tests/sentry/replays/test_permissions.py new file mode 100644 index 00000000000000..f65a834213e200 --- /dev/null +++ b/tests/sentry/replays/test_permissions.py @@ -0,0 +1,96 @@ +from sentry.models.options.organization_option import OrganizationOption +from sentry.replays.models import OrganizationMemberReplayAccess +from sentry.replays.permissions import has_replay_permission +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import region_silo_test + + +@region_silo_test +class TestReplayPermissions(TestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization() + self.user1 = self.create_user() + self.user2 = self.create_user() + self.user3 = self.create_user() + self.member1 = self.create_member(organization=self.organization, user=self.user1) + self.member2 = self.create_member(organization=self.organization, user=self.user2) + self.member3 = self.create_member(organization=self.organization, user=self.user3) + + def _enable_granular_permissions(self) -> None: + """Enable the organization option for granular replay permissions""" + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=True, + ) + + def test_feature_flag_disabled_returns_true(self) -> None: + """When feature flag is disabled, all members should have access""" + self._enable_granular_permissions() + assert has_replay_permission(self.organization, self.user1) is True + + def test_org_option_disabled_returns_true(self) -> None: + """When org option is disabled, all members should have access even with allowlist""" + with self.feature("organizations:granular-replay-permissions"): + OrganizationMemberReplayAccess.objects.create(organizationmember=self.member1) + assert has_replay_permission(self.organization, self.user2) is True + + def test_empty_allowlist_returns_false(self) -> None: + """When allowlist is empty access control is active, no one should have access""" + with self.feature("organizations:granular-replay-permissions"): + self._enable_granular_permissions() + assert has_replay_permission(self.organization, self.user1) is False + assert has_replay_permission(self.organization, self.user2) is False + + def test_member_in_allowlist_returns_true(self) -> None: + """When member is in allowlist, they should have access""" + with self.feature("organizations:granular-replay-permissions"): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create(organizationmember=self.member1) + assert has_replay_permission(self.organization, self.user1) is True + + def test_member_not_in_allowlist_returns_false(self) -> None: + """When member is not in allowlist and allowlist exists, they should not have access""" + with self.feature("organizations:granular-replay-permissions"): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create(organizationmember=self.member1) + assert has_replay_permission(self.organization, self.user2) is False + + def test_multiple_members_in_allowlist(self) -> None: + """Test multiple members in allowlist""" + with self.feature("organizations:granular-replay-permissions"): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create(organizationmember=self.member1) + OrganizationMemberReplayAccess.objects.create(organizationmember=self.member2) + + assert has_replay_permission(self.organization, self.user1) is True + assert has_replay_permission(self.organization, self.user2) is True + assert has_replay_permission(self.organization, self.user3) is False + + def test_non_member_returns_false(self) -> None: + """Non-members should not have access""" + non_member_user = self.create_user() + with self.feature("organizations:granular-replay-permissions"): + self._enable_granular_permissions() + assert has_replay_permission(self.organization, non_member_user) is False + + def test_unauthenticated_user_returns_false(self) -> None: + """Unauthenticated users should not have access""" + with self.feature("organizations:granular-replay-permissions"): + self._enable_granular_permissions() + assert has_replay_permission(self.organization, None) is False + + def test_disabling_org_option_reopens_access(self) -> None: + """When org option is disabled after being enabled, access is restored""" + with self.feature("organizations:granular-replay-permissions"): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create(organizationmember=self.member1) + assert has_replay_permission(self.organization, self.user2) is False + + OrganizationOption.objects.set_value( + organization=self.organization, + key="sentry:granular-replay-permissions", + value=False, + ) + assert has_replay_permission(self.organization, self.user2) is True