Skip to content

Commit af243b7

Browse files
committed
feat(replay): guard endpoints by granular access permissions
1 parent dc58f1e commit af243b7

18 files changed

+464
-67
lines changed

src/sentry/replays/endpoints/organization_replay_count.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from sentry.models.organization import Organization
2222
from sentry.models.project import Project
2323
from sentry.ratelimits.config import RateLimitConfig
24+
from sentry.replays.permissions import has_replay_permission
2425
from sentry.replays.usecases.replay_counts import get_replay_counts
2526
from sentry.snuba.dataset import Dataset
2627
from sentry.types.ratelimit import RateLimit, RateLimitCategory
@@ -84,6 +85,8 @@ def get(self, request: Request, organization: Organization) -> Response:
8485
"""
8586
if not features.has("organizations:session-replay", organization, actor=request.user):
8687
return Response(status=404)
88+
if not has_replay_permission(organization, request.user):
89+
return Response(status=403)
8790

8891
try:
8992
snuba_params = self.get_snuba_params(request, organization)

src/sentry/replays/endpoints/organization_replay_details.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
from sentry.api.api_owners import ApiOwner
1111
from sentry.api.api_publish_status import ApiPublishStatus
1212
from sentry.api.base import region_silo_endpoint
13-
from sentry.api.bases.organization import NoProjects, OrganizationEndpoint
13+
from sentry.api.bases.organization import NoProjects
1414
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND
1515
from sentry.apidocs.examples.replay_examples import ReplayExamples
1616
from sentry.apidocs.parameters import GlobalParams, ReplayParams
1717
from sentry.apidocs.utils import inline_sentry_response_serializer
1818
from sentry.constants import ALL_ACCESS_PROJECTS
1919
from sentry.models.organization import Organization
20+
from sentry.replays.endpoints.organization_replay_endpoint import OrganizationReplayEndpoint
2021
from sentry.replays.lib.eap import read as eap_read
2122
from sentry.replays.lib.eap.snuba_transpiler import RequestMeta, Settings
2223
from sentry.replays.post_process import ReplayDetailsResponse, process_raw_response
@@ -144,7 +145,7 @@ def query_replay_instance_eap(
144145

145146
@region_silo_endpoint
146147
@extend_schema(tags=["Replays"])
147-
class OrganizationReplayDetailsEndpoint(OrganizationEndpoint):
148+
class OrganizationReplayDetailsEndpoint(OrganizationReplayEndpoint):
148149
"""
149150
The same data as ProjectReplayDetails, except no project is required.
150151
This works as we'll query for this replay_id across all projects in the
@@ -171,8 +172,8 @@ def get(self, request: Request, organization: Organization, replay_id: str) -> R
171172
"""
172173
Return details on an individual replay.
173174
"""
174-
if not features.has("organizations:session-replay", organization, actor=request.user):
175-
return Response(status=404)
175+
if response := self.check_replay_access(request, organization):
176+
return response
176177

177178
try:
178179
filter_params = self.get_filter_params(
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from rest_framework.request import Request
2+
from rest_framework.response import Response
3+
4+
from sentry import features
5+
from sentry.api.bases.organization import OrganizationEndpoint
6+
from sentry.models.organization import Organization
7+
from sentry.replays.permissions import has_replay_permission
8+
9+
10+
class OrganizationReplayEndpoint(OrganizationEndpoint):
11+
"""
12+
Base endpoint for replay-related organizationendpoints.
13+
Provides centralized feature and permission checks for session replay access.
14+
Added to ensure that all replay endpoints are consistent and follow the same pattern
15+
for allowing granular user-based replay access control, in addition to the existing
16+
role-based access control and feature flag-based access control.
17+
"""
18+
19+
def check_replay_access(self, request: Request, organization: Organization) -> Response | None:
20+
"""
21+
Check if the session replay feature is enabled and user has replay permissions.
22+
Returns a Response object if access should be denied, None if access is granted.
23+
"""
24+
if not features.has(
25+
"organizations:session-replay", organization, actor=request.user
26+
):
27+
return Response(status=404)
28+
29+
if not has_replay_permission(organization, request.user):
30+
return Response(status=403)
31+
32+
return None

src/sentry/replays/endpoints/organization_replay_events_meta.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sentry.api.paginator import GenericOffsetPaginator
1313
from sentry.api.utils import reformat_timestamp_ms_to_isoformat
1414
from sentry.models.organization import Organization
15+
from sentry.replays.permissions import has_replay_permission
1516

1617

1718
@region_silo_endpoint
@@ -53,6 +54,9 @@ def get(self, request: Request, organization: Organization) -> Response:
5354
if not features.has("organizations:session-replay", organization, actor=request.user):
5455
return Response(status=404)
5556

57+
if not has_replay_permission(organization, request.user):
58+
return Response(status=403)
59+
5660
try:
5761
snuba_params = self.get_snuba_params(request, organization)
5862
except NoProjects:

src/sentry/replays/endpoints/organization_replay_index.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@
66
from rest_framework.request import Request
77
from rest_framework.response import Response
88

9-
from sentry import features
109
from sentry.api.api_owners import ApiOwner
1110
from sentry.api.api_publish_status import ApiPublishStatus
1211
from sentry.api.base import region_silo_endpoint
13-
from sentry.api.bases.organization import NoProjects, OrganizationEndpoint
12+
from sentry.api.bases.organization import NoProjects
1413
from sentry.api.event_search import parse_search_query
1514
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN
1615
from sentry.apidocs.examples.replay_examples import ReplayExamples
1716
from sentry.apidocs.parameters import GlobalParams
1817
from sentry.apidocs.utils import inline_sentry_response_serializer
1918
from sentry.exceptions import InvalidSearchQuery
2019
from sentry.models.organization import Organization
20+
from sentry.replays.endpoints.organization_replay_endpoint import OrganizationReplayEndpoint
2121
from sentry.replays.post_process import ReplayDetailsResponse, process_raw_response
2222
from sentry.replays.query import query_replays_collection_paginated, replay_url_parser_config
2323
from sentry.replays.usecases.errors import handled_snuba_exceptions
@@ -28,7 +28,7 @@
2828

2929
@region_silo_endpoint
3030
@extend_schema(tags=["Replays"])
31-
class OrganizationReplayIndexEndpoint(OrganizationEndpoint):
31+
class OrganizationReplayIndexEndpoint(OrganizationReplayEndpoint):
3232
owner = ApiOwner.REPLAY
3333
publish_status = {
3434
"GET": ApiPublishStatus.PUBLIC,
@@ -50,8 +50,9 @@ def get(self, request: Request, organization: Organization) -> Response:
5050
Return a list of replays belonging to an organization.
5151
"""
5252

53-
if not features.has("organizations:session-replay", organization, actor=request.user):
54-
return Response(status=404)
53+
if response := self.check_replay_access(request, organization):
54+
return response
55+
5556
try:
5657
filter_params = self.get_filter_params(request, organization)
5758
except NoProjects:

src/sentry/replays/endpoints/organization_replay_selector_index.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,10 @@
2323
)
2424
from snuba_sdk import Request as SnubaRequest
2525

26-
from sentry import features
2726
from sentry.api.api_owners import ApiOwner
2827
from sentry.api.api_publish_status import ApiPublishStatus
2928
from sentry.api.base import region_silo_endpoint
30-
from sentry.api.bases.organization import NoProjects, OrganizationEndpoint
29+
from sentry.api.bases.organization import NoProjects
3130
from sentry.api.event_search import QueryToken, parse_search_query
3231
from sentry.api.paginator import GenericOffsetPaginator
3332
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN
@@ -36,6 +35,7 @@
3635
from sentry.apidocs.utils import inline_sentry_response_serializer
3736
from sentry.exceptions import InvalidSearchQuery
3837
from sentry.models.organization import Organization
38+
from sentry.replays.endpoints.organization_replay_endpoint import OrganizationReplayEndpoint
3939
from sentry.replays.lib.new_query.conditions import IntegerScalar
4040
from sentry.replays.lib.new_query.fields import FieldProtocol, IntegerColumnField
4141
from sentry.replays.lib.new_query.parsers import parse_int
@@ -75,7 +75,7 @@ class ReplaySelectorResponse(TypedDict):
7575

7676
@region_silo_endpoint
7777
@extend_schema(tags=["Replays"])
78-
class OrganizationReplaySelectorIndexEndpoint(OrganizationEndpoint):
78+
class OrganizationReplaySelectorIndexEndpoint(OrganizationReplayEndpoint):
7979
owner = ApiOwner.REPLAY
8080
publish_status = {
8181
"GET": ApiPublishStatus.PUBLIC,
@@ -106,8 +106,9 @@ def get_replay_filter_params(self, request, organization):
106106
)
107107
def get(self, request: Request, organization: Organization) -> Response:
108108
"""Return a list of selectors for a given organization."""
109-
if not features.has("organizations:session-replay", organization, actor=request.user):
110-
return Response(status=404)
109+
if response := self.check_replay_access(request, organization):
110+
return response
111+
111112
try:
112113
filter_params = self.get_replay_filter_params(request, organization)
113114
except NoProjects:

src/sentry/replays/endpoints/project_replay_clicks_index.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,9 @@
2424
)
2525
from snuba_sdk.orderby import Direction
2626

27-
from sentry import features
2827
from sentry.api.api_owners import ApiOwner
2928
from sentry.api.api_publish_status import ApiPublishStatus
3029
from sentry.api.base import region_silo_endpoint
31-
from sentry.api.bases.project import ProjectEndpoint
3230
from sentry.api.event_search import ParenExpression, QueryToken, SearchFilter, parse_search_query
3331
from sentry.api.paginator import GenericOffsetPaginator
3432
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND
@@ -37,6 +35,7 @@
3735
from sentry.apidocs.utils import inline_sentry_response_serializer
3836
from sentry.exceptions import InvalidSearchQuery
3937
from sentry.models.project import Project
38+
from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint
4039
from sentry.replays.lib.new_query.errors import CouldNotParseValue, OperatorNotSupported
4140
from sentry.replays.lib.new_query.fields import FieldProtocol
4241
from sentry.replays.lib.query import attempt_compressed_condition
@@ -58,7 +57,7 @@ class ReplayClickResponse(TypedDict):
5857

5958
@region_silo_endpoint
6059
@extend_schema(tags=["Replays"])
61-
class ProjectReplayClicksIndexEndpoint(ProjectEndpoint):
60+
class ProjectReplayClicksIndexEndpoint(ProjectReplayEndpoint):
6261
owner = ApiOwner.REPLAY
6362
publish_status = {
6463
"GET": ApiPublishStatus.PUBLIC,
@@ -85,10 +84,8 @@ class ProjectReplayClicksIndexEndpoint(ProjectEndpoint):
8584
)
8685
def get(self, request: Request, project: Project, replay_id: str) -> Response:
8786
"""Retrieve a collection of RRWeb DOM node-ids and the timestamp they were clicked."""
88-
if not features.has(
89-
"organizations:session-replay", project.organization, actor=request.user
90-
):
91-
return Response(status=404)
87+
if response := self.check_replay_access(request, project):
88+
return response
9289

9390
filter_params = self.get_filter_params(request, project)
9491

src/sentry/replays/endpoints/project_replay_details.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
from sentry.api.api_owners import ApiOwner
99
from sentry.api.api_publish_status import ApiPublishStatus
1010
from sentry.api.base import region_silo_endpoint
11-
from sentry.api.bases.project import ProjectEndpoint, ProjectPermission
11+
from sentry.api.bases.project import ProjectPermission
1212
from sentry.apidocs.constants import RESPONSE_NO_CONTENT, RESPONSE_NOT_FOUND
1313
from sentry.apidocs.parameters import GlobalParams, ReplayParams
1414
from sentry.models.project import Project
15+
from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint
1516
from sentry.replays.post_process import process_raw_response
1617
from sentry.replays.query import query_replay_instance
1718
from sentry.replays.tasks import delete_replay
@@ -29,7 +30,7 @@ class ReplayDetailsPermission(ProjectPermission):
2930

3031
@region_silo_endpoint
3132
@extend_schema(tags=["Replays"])
32-
class ProjectReplayDetailsEndpoint(ProjectEndpoint):
33+
class ProjectReplayDetailsEndpoint(ProjectReplayEndpoint):
3334
owner = ApiOwner.REPLAY
3435
publish_status = {
3536
"DELETE": ApiPublishStatus.PUBLIC,
@@ -39,10 +40,8 @@ class ProjectReplayDetailsEndpoint(ProjectEndpoint):
3940
permission_classes = (ReplayDetailsPermission,)
4041

4142
def get(self, request: Request, project: Project, replay_id: str) -> Response:
42-
if not features.has(
43-
"organizations:session-replay", project.organization, actor=request.user
44-
):
45-
return Response(status=404)
43+
if response := self.check_replay_access(request, project):
44+
return response
4645

4746
filter_params = self.get_filter_params(request, project)
4847

@@ -87,11 +86,8 @@ def delete(self, request: Request, project: Project, replay_id: str) -> Response
8786
"""
8887
Delete a replay.
8988
"""
90-
91-
if not features.has(
92-
"organizations:session-replay", project.organization, actor=request.user
93-
):
94-
return Response(status=404)
89+
if response := self.check_replay_access(request, project):
90+
return response
9591

9692
if has_archived_segment(project.id, replay_id):
9793
return Response(status=404)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from rest_framework.request import Request
2+
from rest_framework.response import Response
3+
4+
from sentry import features
5+
from sentry.api.bases.project import ProjectEndpoint
6+
from sentry.models.project import Project
7+
from sentry.replays.permissions import has_replay_permission
8+
9+
10+
class ProjectReplayEndpoint(ProjectEndpoint):
11+
"""
12+
Base endpoint for replay-related endpoints.
13+
Provides centralized feature and permission checks for session replay access.
14+
Added to ensure that all replay endpoints are consistent and follow the same pattern
15+
for allowing granular user-based replay access control, in addition to the existing
16+
role-based access control and feature flag-based access control.
17+
"""
18+
19+
def check_replay_access(self, request: Request, project: Project) -> Response | None:
20+
"""
21+
Check if the session replay feature is enabled and user has replay permissions.
22+
Returns a Response object if access should be denied, None if access is granted.
23+
"""
24+
if not features.has(
25+
"organizations:session-replay", project.organization, actor=request.user
26+
):
27+
return Response(status=404)
28+
29+
if not has_replay_permission(project.organization, request.user):
30+
return Response(status=403)
31+
32+
return None

src/sentry/replays/endpoints/project_replay_jobs_delete.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from sentry.api.exceptions import ResourceDoesNotExist
1111
from sentry.api.paginator import OffsetPaginator
1212
from sentry.api.serializers import Serializer, serialize
13+
from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint
1314
from sentry.replays.models import ReplayDeletionJobModel
15+
from sentry.replays.permissions import has_replay_permission
1416
from sentry.replays.tasks import run_bulk_replay_delete_job
1517

1618

@@ -67,6 +69,9 @@ def get(self, request: Request, project) -> Response:
6769
"""
6870
Retrieve a collection of replay delete jobs.
6971
"""
72+
if not has_replay_permission(project.organization, request.user):
73+
return Response(status=403)
74+
7075
queryset = ReplayDeletionJobModel.objects.filter(
7176
organization_id=project.organization_id, project_id=project.id
7277
)
@@ -85,6 +90,9 @@ def post(self, request: Request, project) -> Response:
8590
"""
8691
Create a new replay deletion job.
8792
"""
93+
if not has_replay_permission(project.organization, request.user):
94+
return Response(status=403)
95+
8896
serializer = ReplayDeletionJobCreateSerializer(data=request.data)
8997
if not serializer.is_valid():
9098
return Response(serializer.errors, status=400)
@@ -124,7 +132,7 @@ def post(self, request: Request, project) -> Response:
124132

125133

126134
@region_silo_endpoint
127-
class ProjectReplayDeletionJobDetailEndpoint(ProjectEndpoint):
135+
class ProjectReplayDeletionJobDetailEndpoint(ProjectReplayEndpoint):
128136
owner = ApiOwner.REPLAY
129137
publish_status = {
130138
"GET": ApiPublishStatus.PRIVATE,
@@ -135,6 +143,9 @@ def get(self, request: Request, project, job_id: int) -> Response:
135143
"""
136144
Fetch a replay delete job instance.
137145
"""
146+
if response := self.check_replay_access(request, project):
147+
return response
148+
138149
try:
139150
job = ReplayDeletionJobModel.objects.get(
140151
id=job_id, organization_id=project.organization_id, project_id=project.id

0 commit comments

Comments
 (0)