Skip to content

Commit 9f6c21a

Browse files
committed
feat(replay): add model to allow per-user access control for replays
1 parent 240036d commit 9f6c21a

File tree

6 files changed

+213
-0
lines changed

6 files changed

+213
-0
lines changed

src/sentry/api/serializers/models/organization.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
from sentry.models.options.project_option import ProjectOption
7474
from sentry.models.organization import Organization, OrganizationStatus
7575
from sentry.models.organizationaccessrequest import OrganizationAccessRequest
76+
from sentry.models.organizationmemberreplayaccess import OrganizationMemberReplayAccess
7677
from sentry.models.organizationonboardingtask import OrganizationOnboardingTask
7778
from sentry.models.project import Project
7879
from sentry.models.team import Team, TeamStatus
@@ -557,6 +558,7 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp
557558
enableSeerEnhancedAlerts: bool
558559
enableSeerCoding: bool
559560
autoOpenPrs: bool
561+
replayAccessMembers: list[int]
560562

561563

562564
class DetailedOrganizationSerializer(OrganizationSerializer):
@@ -726,6 +728,26 @@ def serialize( # type: ignore[override]
726728
"isDynamicallySampled": is_dynamically_sampled,
727729
}
728730

731+
if features.has("organizations:granular-replay-permissions", obj):
732+
# The organization option can be missing, False, or null.
733+
# Only set hasGranularReplayPermissions to True if the option exists and is truthy.
734+
permissions_enabled = (
735+
OrganizationOption.objects.filter(
736+
organization=obj, key="sentry:granular-replay-permissions"
737+
)
738+
.values_list("value", flat=True)
739+
.first()
740+
)
741+
context["hasGranularReplayPermissions"] = bool(permissions_enabled)
742+
context["replayAccessMembers"] = list(
743+
OrganizationMemberReplayAccess.objects.filter(organization=obj).values_list(
744+
"organizationmember_id", flat=True
745+
)
746+
)
747+
else:
748+
context["hasGranularReplayPermissions"] = False
749+
context["replayAccessMembers"] = []
750+
729751
if has_custom_dynamic_sampling(obj, actor=user):
730752
context["targetSampleRate"] = float(
731753
obj.get_option("sentry:target_sample_rate", TARGET_SAMPLE_RATE_DEFAULT)

src/sentry/core/endpoints/organization_details.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
from sentry.models.options.organization_option import OrganizationOption
9292
from sentry.models.options.project_option import ProjectOption
9393
from sentry.models.organization import Organization, OrganizationStatus
94+
from sentry.models.organizationmemberreplayaccess import OrganizationMemberReplayAccess
9495
from sentry.models.project import Project
9596
from sentry.organizations.services.organization import organization_service
9697
from sentry.organizations.services.organization.model import (
@@ -338,6 +339,12 @@ class OrganizationSerializer(BaseOrganizationSerializer):
338339
ingestThroughTrustedRelaysOnly = serializers.ChoiceField(
339340
choices=[("enabled", "enabled"), ("disabled", "disabled")], required=False
340341
)
342+
replayAccessMembers = serializers.ListField(
343+
child=serializers.IntegerField(),
344+
required=False,
345+
allow_null=True,
346+
help_text="List of organization member IDs that have access to replay data. Only modifiable by owners and managers.",
347+
)
341348

342349
def _has_sso_enabled(self):
343350
org = self.context["organization"]
@@ -558,6 +565,73 @@ def save(self, **kwargs):
558565
if trusted_relay_info is not None:
559566
self.save_trusted_relays(trusted_relay_info, changed_data, org)
560567

568+
if "hasGranularReplayPermissions" in data or "replayAccessMembers" in data:
569+
if not features.has("organizations:granular-replay-permissions", org):
570+
raise serializers.ValidationError(
571+
{
572+
"hasGranularReplayPermissions": "This feature is not enabled for your organization."
573+
}
574+
)
575+
576+
if not self.context["request"].access.has_scope("org:admin"):
577+
raise serializers.ValidationError(
578+
{
579+
"hasGranularReplayPermissions": "You do not have permission to modify granular replay permissions."
580+
}
581+
)
582+
583+
if "hasGranularReplayPermissions" in data:
584+
option_key = "sentry:granular-replay-permissions"
585+
new_value = data["hasGranularReplayPermissions"]
586+
587+
option_inst, created = OrganizationOption.objects.update_or_create(
588+
organization=org, key=option_key, defaults={"value": new_value}
589+
)
590+
591+
if new_value or created:
592+
changed_data["hasGranularReplayPermissions"] = f"to {new_value}"
593+
594+
if "replayAccessMembers" in data:
595+
member_ids = data["replayAccessMembers"]
596+
597+
if member_ids is None:
598+
member_ids = []
599+
600+
current_member_ids = set(
601+
OrganizationMemberReplayAccess.objects.filter(organization=org).values_list(
602+
"organizationmember_id", flat=True
603+
)
604+
)
605+
new_member_ids = set(member_ids)
606+
607+
to_add = new_member_ids - current_member_ids
608+
to_remove = current_member_ids - new_member_ids
609+
610+
if to_add:
611+
OrganizationMemberReplayAccess.objects.bulk_create(
612+
[
613+
OrganizationMemberReplayAccess(
614+
organization=org, organizationmember_id=member_id
615+
)
616+
for member_id in to_add
617+
]
618+
)
619+
620+
if to_remove:
621+
OrganizationMemberReplayAccess.objects.filter(
622+
organization=org, organizationmember_id__in=to_remove
623+
).delete()
624+
625+
if to_add or to_remove:
626+
changes = []
627+
if to_add:
628+
changes.append(f"added {len(to_add)} member(s)")
629+
if to_remove:
630+
changes.append(f"removed {len(to_remove)} member(s)")
631+
changed_data["replayAccessMembers"] = (
632+
f"{' and '.join(changes)} (total: {len(new_member_ids)} member(s) with access)"
633+
)
634+
561635
if "openMembership" in data:
562636
org.flags.allow_joinleave = data["openMembership"]
563637
if "allowSharedIssues" in data:
@@ -775,6 +849,16 @@ class OrganizationDetailsPutSerializer(serializers.Serializer):
775849
help_text="The role required to download debug information files, ProGuard mappings and source maps.",
776850
required=False,
777851
)
852+
hasGranularReplayPermissions = serializers.BooleanField(
853+
help_text="Specify `true` to enable granular replay permissions, allowing per-member access control for replay data.",
854+
required=False,
855+
)
856+
replayAccessMembers = serializers.ListField(
857+
child=serializers.IntegerField(),
858+
help_text="A list of organization member IDs who have permission to access replay data. When empty, all members have access. Requires the granular-replay-permissions feature flag.",
859+
required=False,
860+
allow_null=True,
861+
)
778862

779863
# avatar
780864
avatarType = serializers.ChoiceField(
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Generated by Django 5.2.8 on 2025-12-02 12:31
2+
3+
import django.db.models.deletion
4+
import django.utils.timezone
5+
from django.db import migrations, models
6+
7+
import sentry.db.models.fields.bounded
8+
import sentry.db.models.fields.foreignkey
9+
from sentry.new_migrations.migrations import CheckedMigration
10+
11+
12+
class Migration(CheckedMigration):
13+
# This flag is used to mark that a migration shouldn't be automatically run in production.
14+
# This should only be used for operations where it's safe to run the migration after your
15+
# code has deployed. So this should not be used for most operations that alter the schema
16+
# of a table.
17+
# Here are some things that make sense to mark as post deployment:
18+
# - Large data migrations. Typically we want these to be run manually so that they can be
19+
# monitored and not block the deploy for a long period of time while they run.
20+
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
21+
# run this outside deployments so that we don't block them. Note that while adding an index
22+
# is a schema change, it's completely safe to run the operation after the code has deployed.
23+
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
24+
25+
is_post_deployment = False
26+
27+
dependencies = [
28+
("sentry", "1011_update_oc_integration_cascade_to_null"),
29+
]
30+
31+
operations = [
32+
migrations.CreateModel(
33+
name="OrganizationMemberReplayAccess",
34+
fields=[
35+
(
36+
"id",
37+
sentry.db.models.fields.bounded.BoundedBigAutoField(
38+
primary_key=True, serialize=False
39+
),
40+
),
41+
("date_added", models.DateTimeField(default=django.utils.timezone.now)),
42+
(
43+
"organization",
44+
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
45+
on_delete=django.db.models.deletion.CASCADE,
46+
related_name="replay_access_set",
47+
to="sentry.organization",
48+
),
49+
),
50+
(
51+
"organizationmember",
52+
sentry.db.models.fields.foreignkey.FlexibleForeignKey(
53+
on_delete=django.db.models.deletion.CASCADE,
54+
related_name="replay_access",
55+
to="sentry.organizationmember",
56+
),
57+
),
58+
],
59+
options={
60+
"db_table": "sentry_organizationmemberreplayaccess",
61+
"unique_together": {("organization", "organizationmember")},
62+
},
63+
),
64+
]

src/sentry/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
from .organizationmember import * # NOQA
7575
from .organizationmemberinvite import * # NOQA
7676
from .organizationmembermapping import * # NOQA
77+
from .organizationmemberreplayaccess import * # NOQA
7778
from .organizationmemberteam import * # NOQA
7879
from .organizationmemberteamreplica import * # NOQA
7980
from .organizationonboardingtask import * # NOQA
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from django.db import models
4+
from django.utils import timezone
5+
6+
from sentry.backup.scopes import RelocationScope
7+
from sentry.db.models import FlexibleForeignKey, Model, region_silo_model, sane_repr
8+
9+
10+
@region_silo_model
11+
class OrganizationMemberReplayAccess(Model):
12+
"""
13+
Tracks which organization members have permission to access replay data.
14+
15+
When no records exist for an organization, all members have access (default).
16+
When records exist, only members with a record can access replays.
17+
"""
18+
19+
__relocation_scope__ = RelocationScope.Organization
20+
21+
organization = FlexibleForeignKey("sentry.Organization", related_name="replay_access_set")
22+
organizationmember = FlexibleForeignKey(
23+
"sentry.OrganizationMember",
24+
on_delete=models.CASCADE,
25+
related_name="replay_access",
26+
)
27+
date_added = models.DateTimeField(default=timezone.now)
28+
29+
class Meta:
30+
app_label = "sentry"
31+
db_table = "sentry_organizationmemberreplayaccess"
32+
unique_together = (("organization", "organizationmember"),)
33+
34+
__repr__ = sane_repr("organization_id", "organizationmember_id")

src/sentry/testutils/helpers/backups.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,14 @@ def create_exhaustive_organization(
472472
organization=org, key="sentry:scrape_javascript", value=True
473473
)
474474

475+
# OrganizationMemberReplayAccess - add for the owner member
476+
from sentry.models.organizationmemberreplayaccess import OrganizationMemberReplayAccess
477+
478+
owner_member = OrganizationMember.objects.get(organization=org, user_id=owner_id)
479+
OrganizationMemberReplayAccess.objects.create(
480+
organization=org, organizationmember=owner_member
481+
)
482+
475483
# Team
476484
team = self.create_team(name=f"test_team_in_{slug}", organization=org)
477485
self.create_team_membership(user=owner, team=team)

0 commit comments

Comments
 (0)