Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/sentry/replays/endpoints/organization_replay_count.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 8 additions & 7 deletions src/sentry/replays/endpoints/organization_replay_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,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
Expand Down Expand Up @@ -144,7 +145,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
Expand All @@ -171,8 +172,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(
Expand Down Expand Up @@ -212,12 +213,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)
30 changes: 30 additions & 0 deletions src/sentry/replays/endpoints/organization_replay_endpoint.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions src/sentry/replays/endpoints/organization_replay_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@
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
from sentry.apidocs.parameters import GlobalParams
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
Expand All @@ -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,
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 4 additions & 7 deletions src/sentry/replays/endpoints/project_replay_clicks_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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)

Expand Down
24 changes: 10 additions & 14 deletions src/sentry/replays/endpoints/project_replay_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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",
Expand All @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions src/sentry/replays/endpoints/project_replay_endpoint.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 12 additions & 1 deletion src/sentry/replays/endpoints/project_replay_jobs_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading