Skip to content

fix(replays): Don't match pending invites for replay access with no user - #129540

Merged
gricha merged 1 commit into
masterfrom
fix/none-lookup-replayperm
Nov 4, 2026
Merged

gricha merged 1 commit into
masterfrom
fix/none-lookup-replayperm

Conversation

@gricha

@gricha gricha commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

With granular replay permissions on, API keys got the replay access of a pending invite, or a 500.

get_org_member looks the caller up with user_id=request.user.id. For an API key that's None, which matched pending invites (member rows with no user). It runs on every replay endpoint once sentry:granular-replay-permissions is on:

  • With several pending invites, .get() raises MultipleObjectsReturned, so every replay request from an API key 500s.
  • With one pending invite that is on the allowlist, the key is granted access. This happens without anyone adding an invite: when an org starts requiring 2FA, non-compliant members are turned back into pending invites (remove_user() nulls user_id) and keep their allowlist row.

The lookup now goes through owned_by_acting_user, which matches nothing without a user, so a caller with no user is treated as not a member, as it already is when there are no pending invites. Org auth tokens and Sentry apps are handled earlier and are unaffected, as are users.

Found by the request.user.id None-lookup mypy check in #129532.

Evidence: the two new tests fail on master:

E   assert 200 == 403
FAILED ...::TestReplayGranularPermissions::test_api_key_does_not_get_a_pending_invites_access
E   assert 500 == 403
    sentry.models.organizationmember.OrganizationMember.MultipleObjectsReturned: get() returned more than one OrganizationMember -- it returned 2!
FAILED ...::TestReplayGranularPermissions::test_api_key_with_several_pending_invites_denied
2 failed, 24 deselected

With the fix: 2 passed, and test_replay_granular_permissions.py + test_project_replay_jobs_delete.py: 53 passed.

Production check (Sentry's own telemetry, last 90 days; spans are sampled, counts below are sampled spans):

  • replays/ and replay-count/: about 126k sampled requests. Across all replay routes, requests without a user: no successful responses and no 500s; 1 sampled 403 on replays/, the rest 401.
  • No OrganizationMember.MultipleObjectsReturned errors on any replay route.
  • Nothing seen relying on the current behavior. Telemetry doesn't show which orgs have granular permissions on, and sampling (about 1 in 400 on replays/) could miss rare API-key use.

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Nov 2, 2026
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from bb1ea48 to f510d1a Compare November 2, 2026 21:25
@gricha
gricha changed the base branch from master to fix/none-lookup-memutils November 2, 2026 21:30
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from f510d1a to ed8030b Compare November 2, 2026 21:31
@gricha
gricha added this pull request to stack #129570 November 2, 2026 21:33
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from ed8030b to 6a9b0a1 Compare November 2, 2026 21:44
@gricha
gricha removed this pull request from stack #129570 November 2, 2026 21:45
@gricha
gricha added this pull request to stack #129572 November 2, 2026 21:45
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from 6a9b0a1 to a8a72b2 Compare November 2, 2026 22:08
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from a8a72b2 to b526ca8 Compare November 2, 2026 22:16
@sentry

sentry Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Sentry Snapshot Testing

Name Added Removed Changed Renamed Unchanged Skipped Status
sentry-frontend
sentry-frontend
0 0 0 0 780 0 ✅ Unchanged

⚙️ sentry-frontend Snapshot Settings

@gricha
gricha force-pushed the fix/none-lookup-replayperm branch 2 times, most recently from dccc8bc to 4863db0 Compare November 2, 2026 23:08
@gricha
gricha marked this pull request as ready for review November 2, 2026 23:11
@gricha
gricha requested review from azulus and skaasten November 2, 2026 23:11
@gricha
gricha requested review from a team as code owners November 2, 2026 23:11
@gricha
gricha requested a review from cvxluo November 2, 2026 23:15
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch 3 times, most recently from 2aa09ee to 2a01137 Compare November 2, 2026 23:42
@gricha
gricha requested review from a team as code owners November 3, 2026 19:59
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from e6b41f7 to 90b1ac9 Compare November 3, 2026 20:00
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from 90b1ac9 to 6de4675 Compare November 3, 2026 20:09
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from 6de4675 to 1793b6e Compare November 3, 2026 20:51
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from 1793b6e to 464e2d8 Compare November 3, 2026 21:10
Base automatically changed from fix/none-lookup-memutils to master November 3, 2026 23:19
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from 464e2d8 to 79d8b20 Compare November 3, 2026 23:19
get_org_member looked the caller up by user_id=request.user.id. For an API key
that's None, which matched pending invites: a pending invite on the replay
allowlist granted the key access, and several pending invites raised
MultipleObjectsReturned.
@gricha
gricha force-pushed the fix/none-lookup-replayperm branch from 79d8b20 to 0507444 Compare November 4, 2026 00:44
@gricha
gricha merged commit e5ef3a1 into master Nov 4, 2026
81 of 83 checks passed
@gricha
gricha deleted the fix/none-lookup-replayperm branch November 4, 2026 01:15
gricha added a commit that referenced this pull request Nov 4, 2026
Adds `lookupMayMatchNullRows` to the decision-model linter's security
suite.

An exact ORM lookup whose value can be None, like
`filter(user_id=request.user.id)` for an API key, compiles to `user_id
IS NULL` and matches rows with no user: pending invites, deleted users'
exports, token-created rows. That caused #129441, #129442 and #129443,
and the two fixes below this PR (#129538, #129540). Flake8 rule S032
only catches a literal `acting_user_id(...)` keyword, so it missed all
of them.

The rule runs on changed files only, at `warn`. Its report says how to
fix it: decide whether None means no rows (check for None first, or
`owned_by_acting_user()` for the caller's own rows), no filter, or NULL
rows (pass None or `__isnull=True`).

An earlier version of this PR was a mypy plugin. On top of the
request.user stack it found 147 such lookups in 74 files; that list is
the cleanup worklist, handled in follow-up PRs instead of an allowlist
here.

Checked locally with this config: the full security suite in diff mode,
as CI runs it, on 12 diffs. Seven add one of this week's bugs and five
add lookups that are fine. The threshold is 0.7.

| Diff adds | P(TRUE) | Flagged |
|---|---|---|
| Data export lookup by `request.user.id` (#129442) | 0.81 | yes |
| `is_member` projects by `request.user.id` (#129443) | 0.83 | yes |
| Member roles by `request.user.id` (#129538) | 0.80 | yes |
| Replay access by `request.user.id` (#129540) | 0.79 | yes |
| Team unsubscribe by `member.user_id` (#129600) | 0.72 | yes |
| Dashboard edit check (#129441) | 0.51 | no |
| Sentry app token rescope (#129602) | 0.10 | no |
| An `__isnull=False` guard | 0.27 | no |
| An `application_id is None` guard | 0.14 | no |
| `dist_id=dist.id if dist else None` | 0.13 | no |
| Global user options (`project_id=None`) | 0.46 | no |
| Derived `pipeline_hash` (commented as meant) | 0.35 | no |

The two misses: #129441's None comes through an untyped parameter and a
`==`, and #129602 depends on `SentryApp.application` being nullable,
which the diff doesn't show. A diff that only deletes a guard isn't
checked either, since the linter asks about added lines. An earlier,
more abstract wording flagged none of the seven.



**Reviewer cases (re-measured Nov 2, changeset mode, full `security`
suite):**

| Case | Expected | P(TRUE) |
|---|---|---|
| `UserEmail.objects.filter(user_id=authenticator.user_id)`, NOT NULL
column | no warning | 0.19 |
| `filter(project_id=project_id)` where None means the global user
option | no warning | 0.49 |
|
`organization_service.check_membership_by_id(user_id=request.user.id)`,
lookup behind a service call | warning | 0.22 (missed) |

The two false-positive risks stay under the 0.7 threshold. A rewording
that limits the rule to nullable columns, exempts "None means global"
and mentions service calls dropped two real bugs below it (#129540 to
0.63, #129600 to 0.55) and still missed the service call. A lighter one
kept 5 of 8 but left #129540 and #129600 at 0.72 and 0.70. So the
wording stays; lookups behind service calls are a known gap.
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.

3 participants