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 58d5133030fe..ee65f2162a01 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 fec09d3e594a..05f21454f6da 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 a93d97f77eb0..02d6e6d3dbdc 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 057cff896627..5999a79f4015 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 44db7c491b67..798fd42d2ebe 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 b82545bfdbb5..434237fc454f 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 7caa45086ea0..86d1a3976977 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 000000000000..302a1134613d --- /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 3bd4acdf2f91..a4c8b3952bd9 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 ea93ac28fb6b..8d32ce32cf02 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 2303868a2774..7a497192fec9 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 a80144061376..995f1b375bb9 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 23ce681d610c..8ef74d44d227 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 6263e7620ce4..8a999845e489 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"})