Skip to content

Commit 783ee2a

Browse files
fix(deploys): Fix permissions for deploy endpoint projects (#78026)
Currently, in order to link specific projects to a deploy, e.g. via the `sentry-cli deploys new` command, users must provide a token with the `project:read` scope. This is inconsistent with the `sentry-cli releases new` command, which allows users to create a new release associated with only some projects by using the `org:read` and `project:release` scopes. This PR proposes allowing specifying projects for a deploy using a token with `project:releases` scope. Fixes #78025
1 parent 98abdef commit 783ee2a

File tree

3 files changed

+75
-3
lines changed

3 files changed

+75
-3
lines changed

src/sentry/api/endpoints/release_deploys.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class DeploySerializer(serializers.Serializer):
7272
help_text="An optional date that indicates when the deploy ended. If not provided, the current time is used.",
7373
)
7474
projects = serializers.ListField(
75-
child=ProjectField(scope="project:read", id_allowed=True),
75+
child=ProjectField(scope=("project:read", "project:releases"), id_allowed=True),
7676
required=False,
7777
allow_empty=False,
7878
help_text="The optional list of project slugs to create a deploy within. If not provided, deploys are created for all of the release's projects.",

src/sentry/api/serializers/rest_framework/project.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from collections.abc import Collection
2+
13
from drf_spectacular.types import OpenApiTypes
24
from drf_spectacular.utils import extend_schema_field
35
from rest_framework import serializers
@@ -9,7 +11,14 @@
911

1012
@extend_schema_field(field=OpenApiTypes.STR)
1113
class ProjectField(serializers.Field):
12-
def __init__(self, scope="project:write", id_allowed=False, **kwags):
14+
def __init__(
15+
self, scope: str | Collection[str] = "project:write", id_allowed: bool = False, **kwags
16+
):
17+
"""
18+
The scope parameter specifies which permissions are required to access the project field.
19+
If multiple scopes are provided, the project can be accessed when the user is authenticated with
20+
any of the scopes.
21+
"""
1322
self.scope = scope
1423
self.id_allowed = id_allowed
1524
super().__init__(**kwags)
@@ -27,6 +36,8 @@ def to_internal_value(self, data):
2736
project = Project.objects.get(organization=self.context["organization"], slug=data)
2837
except Project.DoesNotExist:
2938
raise ValidationError("Invalid project")
30-
if not self.context["access"].has_project_scope(project, self.scope):
39+
40+
scopes = (self.scope,) if isinstance(self.scope, str) else self.scope
41+
if not self.context["access"].has_any_project_scope(project, scopes):
3142
raise ValidationError("Insufficient access to project")
3243
return project

tests/sentry/api/endpoints/test_release_deploys.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
from django.urls import reverse
44

5+
from sentry.models.apitoken import ApiToken
56
from sentry.models.deploy import Deploy
67
from sentry.models.environment import Environment
78
from sentry.models.release import Release
89
from sentry.models.releaseprojectenvironment import ReleaseProjectEnvironment
10+
from sentry.silo.base import SiloMode
911
from sentry.testutils.cases import APITestCase
12+
from sentry.testutils.silo import assume_test_silo_mode
1013

1114

1215
class ReleaseDeploysListTest(APITestCase):
@@ -389,3 +392,61 @@ def test_environment_validation_failure(self) -> None:
389392
)
390393
assert response.status_code == 400, response.content
391394
assert 0 == Deploy.objects.count()
395+
396+
def test_api_token_with_project_releases_scope(self):
397+
"""
398+
Test that tokens with `project:releases` scope can create deploys for only one project
399+
when the release is associated with multiple projects.
400+
"""
401+
# Create a second project
402+
project_bar = self.create_project(organization=self.org, name="bar")
403+
404+
# Create a release for both projects
405+
release = Release.objects.create(organization_id=self.org.id, version="1", total_deploys=0)
406+
release.add_project(self.project)
407+
release.add_project(project_bar)
408+
409+
# Create API token with project:releases scope
410+
user = self.create_user(is_staff=False, is_superuser=False)
411+
412+
# Add user to the organization - they need to be a member to use the API
413+
self.create_member(user=user, organization=self.org)
414+
415+
with assume_test_silo_mode(SiloMode.CONTROL):
416+
api_token = ApiToken.objects.create(user=user, scope_list=["project:releases"])
417+
418+
url = reverse(
419+
"sentry-api-0-organization-release-deploys",
420+
kwargs={
421+
"organization_id_or_slug": self.org.slug,
422+
"version": release.version,
423+
},
424+
)
425+
426+
# Create deploy for only one project (project_bar)
427+
response = self.client.post(
428+
url,
429+
data={
430+
"name": "single_project_deploy",
431+
"environment": "production",
432+
"url": "https://www.example.com",
433+
"projects": [project_bar.slug], # Only one project specified
434+
},
435+
HTTP_AUTHORIZATION=f"Bearer {api_token.token}",
436+
)
437+
438+
assert response.status_code == 201, response.content
439+
assert response.data["name"] == "single_project_deploy"
440+
assert response.data["environment"] == "production"
441+
442+
environment = Environment.objects.get(name="production", organization_id=self.org.id)
443+
444+
# Verify ReleaseProjectEnvironment was created only for project_bar
445+
assert ReleaseProjectEnvironment.objects.filter(
446+
project=project_bar, release=release, environment=environment
447+
).exists()
448+
449+
# Verify ReleaseProjectEnvironment was NOT created for self.project
450+
assert not ReleaseProjectEnvironment.objects.filter(
451+
project=self.project, release=release, environment=environment
452+
).exists()

0 commit comments

Comments
 (0)