From 70087ed1a93a96e989f11976b0369e6d700e75ee Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 15 Apr 2026 14:20:32 -0700 Subject: [PATCH] fix(api): Add project create and codeowners scopes Model project creation and ownership management as dedicated project leaf scopes. Previously, project creation and code ownership flows either relied on broader project scopes or mixed readonly access into write endpoints. Add project:create and project:codeowners, wire them through the bitfield and scope hierarchy, and move the create-project and ownership endpoints to those scopes so the public API contract is explicit. Co-Authored-By: OpenAI Codex --- src/sentry/api/bases/project.py | 13 +- src/sentry/api/permissions.py | 3 +- src/sentry/auth/access.py | 4 +- src/sentry/conf/server.py | 27 +++- .../organization_projects_experiment.py | 6 +- src/sentry/core/endpoints/team_projects.py | 8 +- .../issues/endpoints/bases/codeowners.py | 4 +- ...dd_project_create_and_codeowners_scopes.py | 145 ++++++++++++++++++ src/sentry/models/apiscopes.py | 17 +- src/sentry/models/organization.py | 2 + .../core/endpoints/test_team_projects.py | 48 ++++++ .../test_project_codeowners_details.py | 37 +++++ .../test_project_codeowners_index.py | 41 +++++ .../endpoints/test_project_ownership.py | 47 ++++++ 14 files changed, 388 insertions(+), 14 deletions(-) create mode 100644 src/sentry/migrations/1063_add_project_create_and_codeowners_scopes.py diff --git a/src/sentry/api/bases/project.py b/src/sentry/api/bases/project.py index 58d5133030fef7..ee65f2162a0141 100644 --- a/src/sentry/api/bases/project.py +++ b/src/sentry/api/bases/project.py @@ -128,13 +128,22 @@ class ProjectAlertRulePermission(ProjectPermission): class ProjectOwnershipPermission(ProjectPermission): scope_map = { - "GET": ["project:read", "project:write", "project:admin"], + "GET": ["project:codeowners", "project:read"], "POST": ["project:write", "project:admin"], - "PUT": ["project:read", "project:write", "project:admin"], + "PUT": ["project:codeowners"], "DELETE": ["project:admin"], } +class ProjectCodeOwnersPermission(ProjectPermission): + scope_map = { + "GET": ["project:codeowners", "project:read"], + "POST": ["project:codeowners"], + "PUT": ["project:codeowners"], + "DELETE": ["project:codeowners"], + } + + class ProjectEndpoint(Endpoint): permission_classes: tuple[type[BasePermission], ...] = (ProjectPermission,) diff --git a/src/sentry/api/permissions.py b/src/sentry/api/permissions.py index fec09d3e594a28..05f21454f6dab8 100644 --- a/src/sentry/api/permissions.py +++ b/src/sentry/api/permissions.py @@ -19,6 +19,7 @@ from sentry.auth.system import is_system_auth from sentry.demo_mode.utils import get_readonly_scopes, is_demo_mode_enabled, is_demo_user from sentry.hybridcloud.rpc import extract_id_from +from sentry.models.apiscopes import add_scope_hierarchy from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used from sentry.organizations.services.organization import ( RpcOrganization, @@ -120,7 +121,7 @@ def has_permission(self, request: Request, view: APIView) -> bool: assert request.method is not None allowed_scopes = set(self.scope_map.get(request.method, [])) - current_scopes = request.auth.get_scopes() + current_scopes = add_scope_hierarchy(request.auth.get_scopes()) return any(s in allowed_scopes for s in current_scopes) def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: diff --git a/src/sentry/auth/access.py b/src/sentry/auth/access.py index a93d97f77eb0f4..02d6e6d3dbdcb1 100644 --- a/src/sentry/auth/access.py +++ b/src/sentry/auth/access.py @@ -590,7 +590,9 @@ def has_any_project_scope(self, project: Project, scopes: Collection[str]) -> bo def _wrap_scopes(scopes_upper_bound: Iterable[str] | None) -> frozenset[str] | None: if scopes_upper_bound is not None: - return frozenset(scopes_upper_bound) + from sentry.models.apiscopes import add_scope_hierarchy + + return frozenset(add_scope_hierarchy(list(scopes_upper_bound))) return None diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 057cff8966274d..5999a79f401530 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1830,6 +1830,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "project:read", "project:write", "project:admin", + "project:create", + "project:codeowners", "project:releases", "project:distribution", "event:read", @@ -1867,8 +1869,21 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "team:write": {"team:read", "team:write"}, "team:admin": {"team:read", "team:write", "team:admin"}, "project:read": {"project:read"}, - "project:write": {"project:read", "project:write"}, - "project:admin": {"project:read", "project:write", "project:admin"}, + "project:write": { + "project:read", + "project:write", + "project:create", + "project:codeowners", + }, + "project:admin": { + "project:read", + "project:write", + "project:admin", + "project:create", + "project:codeowners", + }, + "project:create": {"project:create"}, + "project:codeowners": {"project:codeowners"}, "project:releases": {"project:releases"}, "project:distribution": {"project:distribution"}, "event:read": {"event:read"}, @@ -1916,7 +1931,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: ( ("project:admin", "Read, write, and admin access to projects."), ("project:write", "Read and write access to projects."), + ("project:codeowners", "Manage code owner and ownership rules."), ("project:read", "Read access to projects."), + ("project:create", "Create projects."), ), (("project:releases", "Read, write, and admin access to project releases."),), (("project:distribution", "Access to app distribution and preprod artifacts."),), @@ -1957,6 +1974,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:write", "event:admin", "project:releases", + "project:create", + "project:codeowners", "project:read", "org:read", "member:invite", @@ -1989,6 +2008,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "project:read", "project:write", "project:admin", + "project:codeowners", "project:releases", "team:read", "team:write", @@ -2014,6 +2034,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "project:read", "project:write", "project:admin", + "project:codeowners", "project:releases", "team:read", "team:write", @@ -2051,6 +2072,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "project:read", "project:write", "project:admin", + "project:codeowners", "project:releases", "event:read", "event:write", @@ -2097,6 +2119,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "org:read", "member:read", "project:read", + "project:codeowners", "project:write", "project:admin", "project:releases", diff --git a/src/sentry/core/endpoints/organization_projects_experiment.py b/src/sentry/core/endpoints/organization_projects_experiment.py index 44db7c491b67e8..798fd42d2ebe9e 100644 --- a/src/sentry/core/endpoints/organization_projects_experiment.py +++ b/src/sentry/core/endpoints/organization_projects_experiment.py @@ -47,13 +47,9 @@ def fetch_slugifed_email_username(email: str) -> str: return slugify(Address(addr_spec=email).username) -# This endpoint is intended to be available to all members of an -# organization so we include "project:read" in the POST scopes. - - class OrgProjectPermission(OrganizationPermission): scope_map = { - "POST": ["project:read", "project:write", "project:admin"], + "POST": ["project:create"], } diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index b82545bfdbb5d4..434237fc454f88 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -118,11 +118,17 @@ def validate_name(self, value: str) -> str: class TeamProjectPermission(TeamPermission): scope_map = { "GET": ["project:read", "project:write", "project:admin"], - "POST": ["project:write", "project:admin"], + "POST": ["project:create"], "PUT": ["project:write", "project:admin"], "DELETE": ["project:admin"], } + def has_object_permission(self, request: Request, view, team) -> bool: + if request.method == "POST" and request.access.has_scope("project:create"): + return request.access.has_team_access(team) + + return super().has_object_permission(request, view, team) + class AuditData(TypedDict): request: Request diff --git a/src/sentry/issues/endpoints/bases/codeowners.py b/src/sentry/issues/endpoints/bases/codeowners.py index 7caa45086ea0ae..86d1a397697756 100644 --- a/src/sentry/issues/endpoints/bases/codeowners.py +++ b/src/sentry/issues/endpoints/bases/codeowners.py @@ -1,12 +1,14 @@ from rest_framework.request import Request from sentry import features -from sentry.api.bases.project import ProjectEndpoint +from sentry.api.bases.project import ProjectCodeOwnersPermission, ProjectEndpoint from sentry.models.project import Project from sentry.utils import metrics class ProjectCodeOwnersBase(ProjectEndpoint): + permission_classes = (ProjectCodeOwnersPermission,) + def has_feature(self, request: Request, project: Project) -> bool: return bool( features.has( diff --git a/src/sentry/migrations/1063_add_project_create_and_codeowners_scopes.py b/src/sentry/migrations/1063_add_project_create_and_codeowners_scopes.py new file mode 100644 index 00000000000000..302a1134613d2c --- /dev/null +++ b/src/sentry/migrations/1063_add_project_create_and_codeowners_scopes.py @@ -0,0 +1,145 @@ +# Generated by Django 5.2.12 on 2026-04-15 00:00 + +from django.db import migrations + +import bitfield.models +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + is_post_deployment = False + + dependencies = [ + ("sentry", "1062_backfill_eventattachment_date_expires"), + ] + + operations = [ + migrations.AlterField( + model_name="apiauthorization", + name="scopes", + field=bitfield.models.BitField( + [ + "project:read", + "project:write", + "project:admin", + "project:releases", + "team:read", + "team:write", + "team:admin", + "event:read", + "event:write", + "event:admin", + "org:read", + "org:write", + "org:admin", + "member:read", + "member:write", + "member:admin", + "org:integrations", + "alerts:read", + "alerts:write", + "member:invite", + "project:distribution", + "project:create", + "project:codeowners", + ], + default=None, + ), + ), + migrations.AlterField( + model_name="apikey", + name="scopes", + field=bitfield.models.BitField( + [ + "project:read", + "project:write", + "project:admin", + "project:releases", + "team:read", + "team:write", + "team:admin", + "event:read", + "event:write", + "event:admin", + "org:read", + "org:write", + "org:admin", + "member:read", + "member:write", + "member:admin", + "org:integrations", + "alerts:read", + "alerts:write", + "member:invite", + "project:distribution", + "project:create", + "project:codeowners", + ], + default=None, + ), + ), + migrations.AlterField( + model_name="apitoken", + name="scopes", + field=bitfield.models.BitField( + [ + "project:read", + "project:write", + "project:admin", + "project:releases", + "team:read", + "team:write", + "team:admin", + "event:read", + "event:write", + "event:admin", + "org:read", + "org:write", + "org:admin", + "member:read", + "member:write", + "member:admin", + "org:integrations", + "alerts:read", + "alerts:write", + "member:invite", + "project:distribution", + "project:create", + "project:codeowners", + ], + default=None, + ), + ), + migrations.AlterField( + model_name="sentryapp", + name="scopes", + field=bitfield.models.BitField( + [ + "project:read", + "project:write", + "project:admin", + "project:releases", + "team:read", + "team:write", + "team:admin", + "event:read", + "event:write", + "event:admin", + "org:read", + "org:write", + "org:admin", + "member:read", + "member:write", + "member:admin", + "org:integrations", + "alerts:read", + "alerts:write", + "member:invite", + "project:distribution", + "project:create", + "project:codeowners", + ], + default=None, + ), + ), + ] diff --git a/src/sentry/models/apiscopes.py b/src/sentry/models/apiscopes.py index 3bd4acdf2f9133..a4c8b3952bd91f 100644 --- a/src/sentry/models/apiscopes.py +++ b/src/sentry/models/apiscopes.py @@ -21,6 +21,8 @@ def add_scope_hierarchy(curr_scopes: Sequence[str]) -> list[str]: class ApiScopes(Sequence): + # Append new scopes to the end of the overall bitfield ordering. Legacy + # tokens can still fall back to the bitfield when `scope_list` is empty. project = ( ("project:read"), ("project:write"), @@ -33,12 +35,22 @@ class ApiScopes(Sequence): event = (("event:read"), ("event:write"), ("event:admin")) - org = (("org:read"), ("org:write"), ("org:integrations"), ("org:admin")) + org = ( + ("org:read"), + ("org:write"), + ("org:integrations"), + ("org:admin"), + ) member = (("member:read"), ("member:write"), ("member:admin"), ("member:invite")) alerts = (("alerts:read"), ("alerts:write")) + appended = ( + ("project:create"), + ("project:codeowners"), + ) + def __init__(self): self.scopes = ( self.__class__.project @@ -47,6 +59,7 @@ def __init__(self): + self.__class__.org + self.__class__.member + self.__class__.alerts + + self.__class__.appended ) def __getitem__(self, value): @@ -92,6 +105,8 @@ class Meta: "alerts:write": bool, "member:invite": bool, "project:distribution": bool, + "project:create": bool, + "project:codeowners": bool, }, ) assert set(ScopesDict.__annotations__) == set(ApiScopes()) diff --git a/src/sentry/models/organization.py b/src/sentry/models/organization.py index ea93ac28fb6b78..8d32ce32cf02fe 100644 --- a/src/sentry/models/organization.py +++ b/src/sentry/models/organization.py @@ -537,6 +537,8 @@ def get_scopes(self, role: Role) -> frozenset[str]: scopes.discard("event:admin") if not self.get_option("sentry:alerts_member_write", ALERTS_MEMBER_WRITE_DEFAULT): scopes.discard("alerts:write") + if role.id == "member" and self.flags.disable_member_project_creation: + scopes.discard("project:create") return frozenset(scopes) def get_option( diff --git a/tests/sentry/core/endpoints/test_team_projects.py b/tests/sentry/core/endpoints/test_team_projects.py index 2303868a277499..7a497192fec944 100644 --- a/tests/sentry/core/endpoints/test_team_projects.py +++ b/tests/sentry/core/endpoints/test_team_projects.py @@ -1,6 +1,8 @@ from unittest import TestCase, mock from unittest.mock import MagicMock, Mock, patch +from django.urls import reverse + from sentry.constants import RESERVED_PROJECT_SLUGS from sentry.ingest import inbound_filters from sentry.models.options.project_option import ProjectOption @@ -51,6 +53,13 @@ def setUp(self) -> None: self.team = self.create_team(members=[self.user]) self.data = {"name": "foo", "slug": "bar", "platform": "python"} self.login_as(user=self.user) + self.url = reverse( + self.endpoint, + kwargs={ + "organization_id_or_slug": self.organization.slug, + "team_id_or_slug": self.team.slug, + }, + ) def test_simple(self) -> None: response = self.get_success_response( @@ -221,6 +230,45 @@ def test_disable_member_project_creation(self) -> None: platform="python", ) + def test_create_with_project_create_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:create"]) + + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content + + def test_create_with_project_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:write"]) + + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content + project = Project.objects.get(id=response.data["id"]) + assert project.name == "foo" + assert project.teams.first() == self.team + + def test_create_rejects_project_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) + + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + def test_default_inbound_filters(self) -> None: filters = ["browser-extensions", "legacy-browsers", "web-crawlers", "filtered-transaction"] python_response = self.get_success_response( diff --git a/tests/sentry/issues/endpoints/test_project_codeowners_details.py b/tests/sentry/issues/endpoints/test_project_codeowners_details.py index a8014406137645..995f1b375bb9ab 100644 --- a/tests/sentry/issues/endpoints/test_project_codeowners_details.py +++ b/tests/sentry/issues/endpoints/test_project_codeowners_details.py @@ -63,6 +63,29 @@ def test_basic_delete(self) -> None: assert response.status_code == 204 assert not ProjectCodeOwners.objects.filter(id=str(self.codeowners.id)).exists() + def test_delete_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.delete( + self.url, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 204 + assert not ProjectCodeOwners.objects.filter(id=str(self.codeowners.id)).exists() + + def test_delete_rejects_project_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.delete( + self.url, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403 + @freeze_time("2023-10-03 00:00:00") def test_basic_update(self) -> None: self.create_external_team(external_name="@getsentry/frontend", integration=self.integration) @@ -89,6 +112,20 @@ def test_basic_update(self) -> None: codeowner = ProjectCodeOwners.objects.get(id=self.codeowners.id) assert codeowner.date_updated.strftime("%Y-%m-%d %H:%M:%S") == "2023-10-03 00:00:00" + def test_update_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200 + assert response.data["id"] == str(self.codeowners.id) + def test_wrong_codeowners_id(self) -> None: self.url = reverse( "sentry-api-0-project-codeowners-details", diff --git a/tests/sentry/issues/endpoints/test_project_codeowners_index.py b/tests/sentry/issues/endpoints/test_project_codeowners_index.py index 23ce681d610cfb..8ef74d44d2277d 100644 --- a/tests/sentry/issues/endpoints/test_project_codeowners_index.py +++ b/tests/sentry/issues/endpoints/test_project_codeowners_index.py @@ -50,6 +50,47 @@ def test_without_feature_flag(self) -> None: assert resp.status_code == 403 assert resp.data == {"detail": "You do not have permission to perform this action."} + def test_get_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.get( + self.url, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200 + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_post_with_project_codeowners_token(self, get_codeowner_mock_file: MagicMock) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content + + def test_post_rejects_project_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403 + @patch( "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", return_value={"html_url": "https://github.com/test/CODEOWNERS"}, diff --git a/tests/sentry/issues/endpoints/test_project_ownership.py b/tests/sentry/issues/endpoints/test_project_ownership.py index 6263e7620ce4c8..8a999845e48992 100644 --- a/tests/sentry/issues/endpoints/test_project_ownership.py +++ b/tests/sentry/issues/endpoints/test_project_ownership.py @@ -220,6 +220,53 @@ def test_get_empty_schema(self) -> None: "schema": None, } + def test_update_with_project_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:write"]) + + response = self.client.put( + self.path, + {"raw": "*.js admin@localhost #tiger-team"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200, response.content + + def test_get_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + response = self.client.get( + self.path, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200 + + def test_update_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + response = self.client.put( + self.path, + {"raw": "*.js admin@localhost #tiger-team"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200 + assert response.data["raw"] == "*.js admin@localhost #tiger-team" + + def test_update_rejects_project_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) + + response = self.client.put( + self.path, + {"raw": "*.js admin@localhost #tiger-team"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403 + def test_get_schema_empty_raw(self) -> None: # Create ProjectOwnership... self.client.put(self.path, {"raw": "*.js admin@localhost #tiger-team"})