From f72e95308ae7bcee244c1958d1a5faa93e3a941c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:41:42 +0000 Subject: [PATCH 1/2] feat(service-accounts): Require service_account:read for service account reads List and detail now require service_account:read instead of org:read. Manager and Owner hold the new scope, so members can no longer list service accounts. The scope is explicit: org:read and service_account:write do not grant reads, so tokens that read and write need both service account scopes. Co-Authored-By: Shaun Kaasten --- .../organization_service_accounts.py | 2 +- .../test_organization_service_accounts.py | 25 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/sentry/service_accounts/endpoints/organization_service_accounts.py b/src/sentry/service_accounts/endpoints/organization_service_accounts.py index 04f1dababb5a..1906c54323a2 100644 --- a/src/sentry/service_accounts/endpoints/organization_service_accounts.py +++ b/src/sentry/service_accounts/endpoints/organization_service_accounts.py @@ -231,7 +231,7 @@ class ServiceAccountUpdateSerializer(ServiceAccountCreateSerializer): class ServiceAccountPermission(OrganizationPermission): scope_map = { - "GET": ["org:read", "org:write", "org:admin"], + "GET": ["service_account:read"], "POST": ["service_account:write"], "PUT": ["service_account:write"], "DELETE": ["service_account:write"], diff --git a/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py b/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py index fa81023cc34c..269fde6ef08d 100644 --- a/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py +++ b/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py @@ -99,17 +99,40 @@ def test_list_paginates(self) -> None: ) assert [row["id"] for row in response.data] == [str(second.id)] - def test_member_can_list(self) -> None: + def test_list_requires_service_account_read(self) -> None: self.create_service_account(organization=self.organization) member = self.create_user() self.create_member(organization=self.organization, user=member, role="member") self.login_as(member) + with self.feature(FEATURE_FLAG): + self.get_error_response(self.organization.slug, status_code=403) + + def test_manager_can_list(self) -> None: + self.create_service_account(organization=self.organization) + manager = self.create_user() + self.create_member(organization=self.organization, user=manager, role="manager") + self.login_as(manager) + with self.feature(FEATURE_FLAG): response = self.get_success_response(self.organization.slug) assert len(response.data) == 1 + def test_list_with_token_scopes(self) -> None: + self.create_service_account(organization=self.organization) + url = reverse(self.endpoint, args=[self.organization.slug]) + + def list_with(scope_list: list[str]) -> Any: + token = self.create_user_auth_token(user=self.user, scope_list=scope_list) + return self.client.get(url, HTTP_AUTHORIZATION=f"Bearer {token.token}") + + with self.feature(FEATURE_FLAG): + assert list_with(["service_account:read"]).status_code == 200 + # Neither org:read nor the explicit write scope grants reads. + assert list_with(["org:read"]).status_code == 403 + assert list_with(["service_account:write"]).status_code == 403 + def test_create(self) -> None: with assume_test_silo_mode(SiloMode.CELL): project = self.create_project(organization=self.organization) From 04a802a55883b7981aa1aab7178c48ff3f51d1be Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:28:02 +0000 Subject: [PATCH 2/2] feat(service-accounts): Grant service_account:read to Member and Admin Members should be able to list service accounts, for example to pick one for a Seer automation. Member and Admin now hold service_account:read, so moving reads off org:read keeps their access. Writes stay limited to Manager and Owner. Co-Authored-By: Shaun Kaasten --- src/sentry/conf/server.py | 4 ++++ tests/sentry/conf/test_scopes.py | 5 +++++ .../endpoints/test_organization_service_accounts.py | 11 +---------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index bc8ccde3a6f0..b33afaf69dfe 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2174,6 +2174,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "project:releases", "project:read", "org:read", + "service_account:read", "member:invite", "member:read", "team:read", @@ -2201,6 +2202,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:write", "event:admin", "org:read", + "service_account:read", "member:read", "member:invite", "project:read", @@ -2354,6 +2356,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "project:releases", "project:read", "org:read", + "service_account:read", "member:invite", "member:read", "team:read", @@ -2385,6 +2388,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:write", "event:admin", "org:read", + "service_account:read", "member:read", "member:invite", "project:read", diff --git a/tests/sentry/conf/test_scopes.py b/tests/sentry/conf/test_scopes.py index ac3d115e07e8..1606a5c7044b 100644 --- a/tests/sentry/conf/test_scopes.py +++ b/tests/sentry/conf/test_scopes.py @@ -65,6 +65,11 @@ def test_service_account_scopes_granted_to_manager_and_owner(self) -> None: granted = {role["id"] for role in roles if SERVICE_ACCOUNT_SCOPES <= role["scopes"]} assert granted == {"manager", "owner"} + def test_service_account_read_granted_to_org_readers(self) -> None: + for roles in (settings.SENTRY_ROLES, settings.SENTRY_GRANULAR_ROLES): + granted = {role["id"] for role in roles if "service_account:read" in role["scopes"]} + assert granted == {"member", "admin", "manager", "owner"} + def test_service_account_scopes_not_grantable_to_non_humans(self) -> None: assert SERVICE_ACCOUNT_SCOPES <= SENTRY_SCOPES assert SERVICE_ACCOUNT_SCOPES.isdisjoint(GRANTABLE_SCOPES) diff --git a/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py b/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py index 269fde6ef08d..454910cb4d5e 100644 --- a/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py +++ b/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py @@ -99,21 +99,12 @@ def test_list_paginates(self) -> None: ) assert [row["id"] for row in response.data] == [str(second.id)] - def test_list_requires_service_account_read(self) -> None: + def test_member_can_list(self) -> None: self.create_service_account(organization=self.organization) member = self.create_user() self.create_member(organization=self.organization, user=member, role="member") self.login_as(member) - with self.feature(FEATURE_FLAG): - self.get_error_response(self.organization.slug, status_code=403) - - def test_manager_can_list(self) -> None: - self.create_service_account(organization=self.organization) - manager = self.create_user() - self.create_member(organization=self.organization, user=manager, role="manager") - self.login_as(manager) - with self.feature(FEATURE_FLAG): response = self.get_success_response(self.organization.slug)