Skip to content

Conversation

@shellmayr
Copy link
Member

@shellmayr shellmayr commented Dec 5, 2025

This PR adds elements to our data model to enable granular permissions for replay.

  • Two pieces of data are added:

    • an OrganizationOption sentry:granular-replay-permissions that specifies whether or not the granular access control should be enabled and
    • A new model OrganizationMemberReplayAccess to track which organization members have access to replay data if the granular permission controls are enabled
  • Updated serializers (DetailedOrganizationSerializerResponse, OrganizationDetailsPutSerializer, and OrganizationSerializer) to expose and allow modification of the replayAccessMembers field and the hasGranularReplayPermissions flag

  • Modified organization API endpoints to handle reading and updating granular replay permissions, including validation for feature flag and admin scope, and logic to add or remove member access.

  • More information on the plan & product impact can be found here Research granular permissions for viewing Replays

A comment on the implementation approach, for the record: I thought about making this a more generic permissions model, so we can use it for other use-cases, but since this is currently only scoped to Replay, I want to do what we need and not optimize for any later use-case. If this is to be extended to other data types / product features, this should be easily migrate-able to a more generic model that contains relationships between Organization, OrganizationMember and the permission type.

Closes TET-1565
Closes TET-1566

@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Dec 5, 2025
@linear
Copy link

linear bot commented Dec 5, 2025

@github-actions
Copy link
Contributor

github-actions bot commented Dec 5, 2025

This PR has a migration; here is the generated SQL for src/sentry/migrations/1012_organizationmember_replay_access.py

for 1012_organizationmember_replay_access in sentry

--
-- Create model OrganizationMemberReplayAccess
--
CREATE TABLE "sentry_organizationmemberreplayaccess" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "date_added" timestamp with time zone NOT NULL, "organization_id" bigint NOT NULL, "organizationmember_id" bigint NOT NULL);
CREATE UNIQUE INDEX CONCURRENTLY "sentry_organizationmembe_organization_id_organiza_9700a74d_uniq" ON "sentry_organizationmemberreplayaccess" ("organization_id", "organizationmember_id");
ALTER TABLE "sentry_organizationmemberreplayaccess" ADD CONSTRAINT "sentry_organizationmembe_organization_id_organiza_9700a74d_uniq" UNIQUE USING INDEX "sentry_organizationmembe_organization_id_organiza_9700a74d_uniq";
ALTER TABLE "sentry_organizationmemberreplayaccess" ADD CONSTRAINT "sentry_organizationm_organization_id_99e648de_fk_sentry_or" FOREIGN KEY ("organization_id") REFERENCES "sentry_organization" ("id") DEFERRABLE INITIALLY DEFERRED NOT VALID;
ALTER TABLE "sentry_organizationmemberreplayaccess" VALIDATE CONSTRAINT "sentry_organizationm_organization_id_99e648de_fk_sentry_or";
ALTER TABLE "sentry_organizationmemberreplayaccess" ADD CONSTRAINT "sentry_organizationm_organizationmember_i_2283bdfc_fk_sentry_or" FOREIGN KEY ("organizationmember_id") REFERENCES "sentry_organizationmember" ("id") DEFERRABLE INITIALLY DEFERRED NOT VALID;
ALTER TABLE "sentry_organizationmemberreplayaccess" VALIDATE CONSTRAINT "sentry_organizationm_organizationmember_i_2283bdfc_fk_sentry_or";
CREATE INDEX CONCURRENTLY "sentry_organizationmemberreplayaccess_organization_id_99e648de" ON "sentry_organizationmemberreplayaccess" ("organization_id");
CREATE INDEX CONCURRENTLY "sentry_organizationmemberr_organizationmember_id_2283bdfc" ON "sentry_organizationmemberreplayaccess" ("organizationmember_id");

@codecov
Copy link

codecov bot commented Dec 5, 2025

Codecov Report

❌ Patch coverage is 81.94444% with 13 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/sentry/core/endpoints/organization_details.py 71.73% 13 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #104446      +/-   ##
===========================================
- Coverage   80.52%    80.51%   -0.01%     
===========================================
  Files        9330      9331       +1     
  Lines      400670    400832     +162     
  Branches    25692     25692              
===========================================
+ Hits       322634    322742     +108     
- Misses      77570     77624      +54     
  Partials      466       466              

child=serializers.IntegerField(),
required=False,
allow_null=True,
help_text="List of organization member IDs that have access to replay data. Only modifiable by owners and managers.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of our permission systems are based on team membership and not individual members. Should this also use the team model?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is a purposely new permission model (not team-based, not role-based) - we've had discussions about this and a record in these two linear issues (one, two). Do you have any concerns with this, apart from the fact that we're straying from the usual model?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only concern was that we're diverging from the typical permissions model. It sounds like you're doing that on purpose though.

Comment on lines +866 to +871
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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If replayAccessMembers is sent in a request without hasGranularReplayPermissions will those changes be applied or silently dropped?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API request should go through and apply the changes nonetheless (e.g. to apply a list of privileged users first, and then switch to enforcing access), even when the flag is not turned on.

Copy link
Member

@wedamija wedamija left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to make sure that this definitely needs to be per user, and not per team. Usually throughout sentry we assign permissions per team, and it's pretty rare that we give individual permissions to folks.

I think that ideally, we'd add a new permission scope, and teams that have a row here are given the scope.

Alternatively, if you do need this at the user level, I'm thinking that this table might be overkill. You could just add another boolean to

_OrganizationMemberFlags = TypedDict(
"_OrganizationMemberFlags",
{
"sso:linked": bool,
"sso:invalid": bool,
"member-limit:restricted": bool,
"idp:provisioned": bool,
"idp:role-restricted": bool,
"partnership:restricted": bool,
},
)
to control whether the specific OrganizationMember is allowed to access replays.

@shellmayr shellmayr force-pushed the shellmayr/feat/add-model-for-granular-replay-access branch from 6938277 to fdc6279 Compare December 9, 2025 13:01
@github-actions
Copy link
Contributor

github-actions bot commented Dec 9, 2025

This PR has a migration; here is the generated SQL for src/sentry/replays/migrations/0007_organizationmember_replay_access.py

for 0007_organizationmember_replay_access in replays

--
-- Create model OrganizationMemberReplayAccess
--
CREATE TABLE "sentry_organizationmemberreplayaccess" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "date_updated" timestamp with time zone NOT NULL, "date_added" timestamp with time zone NOT NULL, "organizationmember_id" bigint NOT NULL UNIQUE);
ALTER TABLE "sentry_organizationmemberreplayaccess" ADD CONSTRAINT "sentry_organizationm_organizationmember_i_2283bdfc_fk_sentry_or" FOREIGN KEY ("organizationmember_id") REFERENCES "sentry_organizationmember" ("id") DEFERRABLE INITIALLY DEFERRED NOT VALID;
ALTER TABLE "sentry_organizationmemberreplayaccess" VALIDATE CONSTRAINT "sentry_organizationm_organizationmember_i_2283bdfc_fk_sentry_or";

@shellmayr
Copy link
Member Author

@wedamija regarding the question of team vs user scope - we've had discussions about this and a record in these two linear issues (one, two) and this is the permission model that has been requested from the product side.

new_user_ids = set(user_ids)

to_add = new_user_ids - current_user_ids
to_remove = current_user_ids - new_user_ids
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: None values cause inconsistent read/write behavior in replay access

The serializer filters out None values from replayAccessMembers when reading, but the save() method does not filter them from current_user_ids. If an OrganizationMember linked to a replay access record has user_id=None (e.g., after membership changes), the API returns a list without that entry. When the client sends the same list back, to_remove will contain None, but the SQL IN (NULL) clause won't match any rows, causing the delete to silently fail. The audit log will incorrectly report users were removed when they weren't, and orphan records will remain.

Additional Locations (1)

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants