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"})