From 6af876361b1f1464a05235435c0967c2ac34701a 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:25:49 +0000 Subject: [PATCH] feat(service-accounts): Require service_account:write for service account writes Create, update and delete now require service_account:write instead of org:admin. Manager and Owner hold the new scope. API tokens and keys that manage service accounts must request service_account:write; org:admin no longer grants these writes. Reads stay on org:read. Co-Authored-By: Shaun Kaasten --- .../organization_service_accounts.py | 6 +-- .../test_organization_service_accounts.py | 47 ++++++++++++++++--- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/sentry/service_accounts/endpoints/organization_service_accounts.py b/src/sentry/service_accounts/endpoints/organization_service_accounts.py index 499a9e0375f3..04f1dababb5a 100644 --- a/src/sentry/service_accounts/endpoints/organization_service_accounts.py +++ b/src/sentry/service_accounts/endpoints/organization_service_accounts.py @@ -232,9 +232,9 @@ class ServiceAccountUpdateSerializer(ServiceAccountCreateSerializer): class ServiceAccountPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:admin"], - "PUT": ["org:admin"], - "DELETE": ["org:admin"], + "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 7c871e746ae5..fa81023cc34c 100644 --- a/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py +++ b/tests/sentry/service_accounts/endpoints/test_organization_service_accounts.py @@ -198,7 +198,7 @@ def test_create_same_name_in_other_org(self) -> None: status_code=201, ) - def test_create_requires_org_admin(self) -> None: + def test_create_requires_service_account_write(self) -> None: member = self.create_user() self.create_member(organization=self.organization, user=member, role="member") self.login_as(member) @@ -214,9 +214,40 @@ def test_create_requires_org_admin(self) -> None: assert not ServiceAccount.objects.exists() + def test_manager_can_create(self) -> None: + manager = self.create_user() + self.create_member(organization=self.organization, user=manager, role="manager") + self.login_as(manager) + + with self.feature(FEATURE_FLAG): + self.get_success_response( + self.organization.slug, + method="post", + name="nightly-triage", + scopes=["event:read"], + status_code=201, + ) + + def test_create_rejects_org_admin_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:admin"]) + url = reverse(self.endpoint, args=[self.organization.slug]) + + with self.feature(FEATURE_FLAG): + response = self.client.post( + url, + data={"name": "nightly-triage", "scopes": ["org:read"]}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403 + assert not ServiceAccount.objects.exists() + def test_create_rejects_scopes_caller_lacks(self) -> None: with assume_test_silo_mode(SiloMode.CONTROL): - token = self.create_user_auth_token(user=self.user, scope_list=["org:admin"]) + token = self.create_user_auth_token( + user=self.user, scope_list=["service_account:write"] + ) url = reverse(self.endpoint, args=[self.organization.slug]) with self.feature(FEATURE_FLAG): @@ -287,7 +318,7 @@ def test_create_rejects_all_projects_with_projects(self) -> None: def test_create_all_projects_requires_global_role(self) -> None: api_key = self.create_api_key( - organization=self.organization, scope_list=["org:admin", "event:read"] + organization=self.organization, scope_list=["service_account:write", "event:read"] ) url = reverse(self.endpoint, args=[self.organization.slug]) @@ -516,7 +547,9 @@ def test_update_all_projects_requires_global_role(self) -> None: service_account = self.create_service_account( organization=self.organization, name="seer-explorer", all_projects=True ) - api_key = self.create_api_key(organization=self.organization, scope_list=["org:admin"]) + api_key = self.create_api_key( + organization=self.organization, scope_list=["service_account:write"] + ) def put(service_account_id: int, data: dict[str, Any]) -> Any: return self.client.put( @@ -570,7 +603,7 @@ def test_update_duplicate_name(self) -> None: assert self.service_account.name == "seer-autofix" def test_update_rejects_scopes_caller_lacks(self) -> None: - token = self.create_user_auth_token(user=self.user, scope_list=["org:admin"]) + token = self.create_user_auth_token(user=self.user, scope_list=["service_account:write"]) url = reverse(self.endpoint, args=[self.organization.slug, self.service_account.id]) with self.feature(FEATURE_FLAG): @@ -622,7 +655,7 @@ def convert_args_then_delete( event=audit_log.get_event_id("SERVICE_ACCOUNT_EDIT") ).exists() - def test_update_requires_org_admin(self) -> None: + def test_update_requires_service_account_write(self) -> None: member = self.create_user() self.create_member(organization=self.organization, user=member, role="member") self.login_as(member) @@ -684,7 +717,7 @@ def test_delete(self) -> None: assert entry.target_object == service_account_id assert entry.data["name"] == "seer-autofix" - def test_delete_requires_org_admin(self) -> None: + def test_delete_requires_service_account_write(self) -> None: member = self.create_user() self.create_member(organization=self.organization, user=member, role="member") self.login_as(member)