Skip to content

Conversation

@michelletran-sentry
Copy link
Contributor

@michelletran-sentry michelletran-sentry commented Feb 5, 2026

The main purpose of this PR is to allow our SaaS instance to manage elevated privileges in a secondary IdP system. It is done by specific team names (i.e. snty-staff and snty-superuser) created on the default org.

When members are added/removed from "staff" or "superuser" teams in the default SaaS organization, automatically grant or revoke the corresponding is_staff/is_superuser flags.

Key changes:

  • Grant is_staff when adding to "staff" team, is_superuser to "superuser" team
  • Revoke only the matching privilege when removing from privileged teams
  • Security-first two-pass architecture: revoke before delete, grant after add
  • Handle transaction constraints (RPC calls outside transactions)
  • Add error handling and logging for privilege update failures
  • Add comprehensive test coverage (8 new tests)

@github-actions github-actions bot added the Scope: Backend Automatically applied to PRs that change backend components label Feb 5, 2026
@michelletran-sentry michelletran-sentry force-pushed the feat/scim-groups-privilege-management branch from 5817726 to 53727cd Compare February 6, 2026 01:12
@michelletran-sentry michelletran-sentry force-pushed the feat/scim-groups-privilege-management branch from 53727cd to 94b13e2 Compare February 6, 2026 03:32
@michelletran-sentry michelletran-sentry force-pushed the feat/scim-groups-privilege-management branch from 94b13e2 to ae266ed Compare February 10, 2026 02:05
@michelletran-sentry michelletran-sentry force-pushed the feat/scim-groups-privilege-management branch from ae266ed to f2b5d5a Compare February 10, 2026 02:10
return (
settings.SENTRY_MODE == SentryMode.SAAS
and team.organization.id == settings.SENTRY_DEFAULT_ORGANIZATION_ID
and team.slug in ("snty-staff", "snty-superuser")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Jeffreyhung Are these team names OK?

Copy link
Member

Choose a reason for hiding this comment

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

LGTM!

Copy link
Member

@Jeffreyhung Jeffreyhung Feb 10, 2026

Choose a reason for hiding this comment

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

can we break superuser down to read and write? or is this just prefix and we can append read write at the end?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah good point about superuser read and write! Hmm... So users who are superuser.write would be a subset of users who are superuser.read. How would you like to model this in the Idp? We could either have 2 distinct groups, which permissions read and write separately (i.e. User Y would need to be both in read and write groups), or we can have 2 distinct groups that are mutually exclusive (i.e. User Y who has superuser write, will be given both read and write permissions, but will only be in the write group in Okta). @Jeffreyhung What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

I think this will make things easier to manage

we can have 2 distinct groups that are mutually exclusive (i.e. User Y who has superuser write, will be given both read and write permissions, but will only be in the write group in Okta

)
members_to_revoke.append((member, team))
except OrganizationMember.DoesNotExist:
pass
Copy link
Contributor

Choose a reason for hiding this comment

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

REMOVE pre-scan revokes privileges without verifying team membership

High Severity

The REMOVE pre-scan only checks that the OrganizationMember exists in the organization, but never verifies the member is actually in the team before scheduling privilege revocation. In contrast, the REPLACE pre-scan correctly queries OrganizationMemberTeam.objects.filter(team_id=team.id) to only collect actual team members. Additionally, _remove_members_operation itself correctly handles the "not in team" case by catching OrganizationMemberTeam.DoesNotExist and returning — but by then, the privilege has already been irrevocably revoked via the RPC call. This means a SCIM REMOVE operation targeting any org member (even one never in snty-staff or snty-superuser) would incorrectly strip their is_staff or is_superuser flag.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm... this might be OK because it lets us remove the flags from users who might not be added to the team yet.

"member_id": member.id,
},
)
failed_grants.append(member.id)
Copy link
Contributor

Choose a reason for hiding this comment

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

Grant phase doesn't verify final team membership state

Low Severity

members_to_grant_privileges accumulates members from all ADD and REPLACE operations across the entire PATCH, but the post-transaction grant phase never re-verifies that those members are still in the team after all operations complete. If a later operation (e.g., REPLACE or REMOVE) undoes an earlier ADD within the same PATCH, the member ends up with is_staff or is_superuser granted despite no longer being in the privileged team. The grant phase at line 588 iterates all accumulated members without checking OrganizationMemberTeam existence.

Additional Locations (1)

Fix in Cursor Fix in Web

michelletran-sentry and others added 2 commits February 10, 2026 12:47
When members are added/removed from "staff" or "superuser" teams in the
default SaaS organization, automatically grant or revoke the corresponding
is_staff/is_superuser flags.

Key changes:
- Grant is_staff when adding to "staff" team, is_superuser to "superuser" team
- Revoke only the matching privilege when removing from privileged teams
- Security-first two-pass architecture: revoke before delete, grant after add
- Handle transaction constraints (RPC calls outside transactions)
- Add error handling and logging for privilege update failures
- Add comprehensive test coverage (8 new tests)
When a snty-staff or snty-superuser team is deleted, automatically revoke
the corresponding privileges from all team members.

Key changes:
- Revoke is_staff when deleting snty-staff team
- Revoke is_superuser when deleting snty-superuser team
- Fail with 500 error if privilege revocation fails during deletion
- Add 4 new tests for team deletion privilege management

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

def _remove_members_operation(self, request: Request, member_id, team):
added_members.append(member)

return added_members
Copy link
Contributor

Choose a reason for hiding this comment

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

ADD retry after grant failure silently skips privilege

Medium Severity

If _grant_privilege fails (RPC error) and returns a 500, the team membership transaction has already committed. When the SCIM provider retries the same ADD operation, _add_members_operation sees the member already exists in the team (line 411) and continues, returning an empty added_members list. The grant loop has nothing to process, and the endpoint returns 204 success — but the privilege was never granted. The operation is not idempotent for privileged teams, leaving a member in the team without the corresponding is_staff/is_superuser flag.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

@michelletran-sentry michelletran-sentry Feb 10, 2026

Choose a reason for hiding this comment

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

If this happens, then we can just remove then re-add the user. There's going to be inconsistencies here because it's over 2 different silos and there's no real "transaction" we can enforce on this.

Add RPC service methods to manage UserPermission records across silos:
- `add_permission(user_id, permission)` - Creates a permission for a user
- `remove_permission(user_id, permission)` - Removes a permission from a user

These methods enable region silo endpoints to manage control silo
UserPermission records through proper RPC calls instead of direct
database access.

Both methods use proper transaction handling with
`router.db_for_write(UserPermission)` and return boolean values
indicating whether the operation created/removed a record.

Co-Authored-By: Claude <noreply@anthropic.com>
Rename snty-superuser to snty-superuser-read and add new
snty-superuser-write team for granular superuser privilege management:

- **snty-superuser-read**: Grants `is_superuser` flag only
- **snty-superuser-write**: Grants `is_superuser` flag + "superuser.write" permission

Changes:
- Update `_should_manage_privileges()` to recognize three privileged teams
- Update `_grant_privilege()` to use `user_service.add_permission()` RPC
  for managing UserPermission records across silos
- Update `_revoke_privileges()` to use `user_service.remove_permission()` RPC
- Fix tests to wrap UserPermission access with `assume_test_silo_mode(SiloMode.CONTROL)`
  since UserPermission is a control silo model

This enables proper cross-silo permission management where region silo
SCIM endpoints can manage control silo UserPermission records through
RPC instead of direct database access.

Tests: 34 passing

Co-Authored-By: Claude <noreply@anthropic.com>
Comment on lines +613 to +623
"scim.privilege_grant_failed",
extra={
"organization_id": team.organization.id,
"team_id": team.id,
"member_id": member.id,
},
)
return Response(
{"detail": "Failed to grant user privileges"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: A failure during privilege granting, after team membership is committed, leads to a permanent inconsistent state because idempotent retries will not re-attempt the grant.
Severity: HIGH

Suggested Fix

To ensure atomicity, the privilege granting logic should be moved inside the main database transaction. Alternatively, implement a compensation mechanism, such as rolling back the team membership if the privilege grant fails, or create a background job to reconcile users who are in this inconsistent state.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/core/endpoints/scim/teams.py#L608-L623

Potential issue: The SCIM team update logic separates team membership changes from
privilege granting. Team membership is saved in a database transaction, and then
privilege-granting RPC calls are made afterward. If a privilege grant fails, the user is
left in an inconsistent state: they are a member of the team but lack the necessary
privileges. Because the operation is idempotent, subsequent retries by the SCIM client
will detect that the user is already on the team and will not re-attempt the failed
privilege grant, making the inconsistent state permanent.

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.

2 participants