Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions src/sentry/auth/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
"from_rpc_member",
)

# These scopes are not represented in organization member roles, but auth tokens
# still need to retain them when request access is derived from the token.
NON_ROLE_TOKEN_SCOPES = frozenset({"org:ci", "project:distribution"})


def has_role_in_organization(role: str, organization: Organization, user_id: int) -> bool:
query = OrganizationMember.objects.filter(
Expand Down Expand Up @@ -193,6 +197,18 @@ def has_any_project_scope(self, project: Project, scopes: Collection[str]) -> bo
pass


def _intersect_member_and_token_scopes(
member_scopes: Collection[str], token_scopes: Iterable[str] | None
) -> frozenset[str]:
if token_scopes is None:
return frozenset(member_scopes)

requested_scopes = frozenset(token_scopes)
return (requested_scopes & frozenset(member_scopes)) | (
requested_scopes & NON_ROLE_TOKEN_SCOPES
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NON_ROLE_TOKEN_SCOPES bypasses role restrictions for all members

Medium Severity

_intersect_member_and_token_scopes grants NON_ROLE_TOKEN_SCOPES (org:ci, project:distribution) to any member who requests them via a token, regardless of their organization role. Previously, effective scopes were always the intersection of token scopes and member role scopes, meaning a low-privilege "member" role user couldn't exceed their role's capabilities via a token. Now, any member can create a user auth token with org:ci and gain access to release creation, code mappings, and integration listing — capabilities their role doesn't grant. This broadens access for all token types across the platform, not just Vercel internal integration tokens.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e807a5f. Configure here.



@dataclass
class DbAccess(Access):
# TODO(dcramer): this is still a little gross, and ideally backend access
Expand Down Expand Up @@ -440,8 +456,9 @@ def scopes(self) -> frozenset[str]:
if self.scopes_upper_bound is None:
return frozenset(self.rpc_user_organization_context.member.scopes)

return frozenset(self.rpc_user_organization_context.member.scopes) & frozenset(
self.scopes_upper_bound
return _intersect_member_and_token_scopes(
self.rpc_user_organization_context.member.scopes,
self.scopes_upper_bound,
)

# TODO(cathy): remove this
Expand Down Expand Up @@ -1133,10 +1150,7 @@ def from_member(
is_superuser: bool = False,
is_staff: bool = False,
) -> Access:
if scopes is not None:
scope_intersection = frozenset(scopes) & member.get_scopes()
else:
scope_intersection = member.get_scopes()
scope_intersection = _intersect_member_and_token_scopes(member.get_scopes(), scopes)

if (is_superuser or is_staff) and member.user_id is not None:
# "permissions" is a bit of a misnomer -- these are all admin level permissions, and the intent is that if you
Expand Down
6 changes: 6 additions & 0 deletions src/sentry/conf/server.py
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 org:ci missing from SENTRY_TOKEN_ONLY_SCOPES causes show_auth_info regression for new Vercel installations

The PR adds org:ci to NON_ROLE_TOKEN_SCOPES in src/sentry/auth/access.py:48 but does not add it to SENTRY_TOKEN_ONLY_SCOPES in src/sentry/conf/server.py:1910-1914. The sibling scope project:distribution exists in both sets, but org:ci only exists in NON_ROLE_TOKEN_SCOPES.

This causes a regression for new Vercel installations: SentryApp.show_auth_info() (src/sentry/sentry_apps/models/sentry_app.py:206-212) subtracts SENTRY_TOKEN_ONLY_SCOPES from the app's scope list, then checks if the remainder is a subset of the user's role scopes. Since org:ci is not in any role AND not excluded via SENTRY_TOKEN_ONLY_SCOPES, the check fails, and internal integration tokens are masked in the UI. With the old Vercel scopes (project:releases, project:read, project:write), all scopes were in member roles, so show_auth_info returned True and tokens were visible.

Additionally affects SentryApp API creation validation

SentryAppParser.validate_scopes (src/sentry/sentry_apps/api/parsers/sentry_app.py:198-209) skips validation for scopes in SENTRY_TOKEN_ONLY_SCOPES. Without org:ci in that set, session-authenticated users (who don't have org:ci in their role) cannot create SentryApps with org:ci scope via the API.

(Refers to lines 1910-1914)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -1919,6 +1919,12 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
("org:write", "Read and write access to organization details."),
("org:read", "Read access to organization details."),
),
(
(
"org:ci",
"Access to CI workflows including source map uploads, release creation, and code mappings.",
),
),
(
(
"org:integrations",
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/integrations/vercel/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ def post_install(
is_internal=True,
verify_install=False,
overview=internal_integration_overview.strip(),
scopes=["project:releases", "project:read", "project:write"],
scopes=["org:ci"],
Comment thread
sentry[bot] marked this conversation as resolved.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the internal integrations can always have their perms raised by the orgs themselves if they go to the edit page. this is how they currently appear after install:

Image

We don't surface org:ci as an option here, so I think people who go to manage these new installs will see form errors if they try an edit the apps now

const matches = message.match(/Requested permission of (\w+:\w+)/);
if (matches) {
const scope = matches[1];
const resource = getResourceFromScope(scope as Scope);
// should always match but technically resource can be undefined
if (resource) {
formErrors[`${resource}--permission`] = [message];
}

If there's no issue in publicizing it, we can add org:ci to SENTRY_APP_PERMISSIONS on the frontend to avoid this

).run(user=user)
sentry_app_installation = SentryAppInstallation.objects.get(sentry_app=sentry_app)
SentryAppInstallationForProvider.objects.create(
Expand Down
3 changes: 2 additions & 1 deletion src/sentry/models/apiscopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ 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"), ("org:ci"))

member = (("member:read"), ("member:write"), ("member:admin"), ("member:invite"))

Expand Down Expand Up @@ -84,6 +84,7 @@ class Meta:
"org:read": bool,
"org:write": bool,
"org:admin": bool,
"org:ci": bool,
"member:read": bool,
"member:write": bool,
"member:admin": bool,
Expand Down
8 changes: 8 additions & 0 deletions tests/sentry/api/endpoints/test_api_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ def test_simple(self) -> None:
assert not token.refresh_token
assert token.get_scopes() == ["event:read"]

def test_ci_scope(self) -> None:
self.login_as(self.user)
url = reverse("sentry-api-0-api-tokens")
response = self.client.post(url, data={"scopes": ["org:ci"]})
assert response.status_code == 201
token = ApiToken.objects.get(user=self.user)
assert token.get_scopes() == ["org:ci"]

def test_never_cache(self) -> None:
self.login_as(self.user)
url = reverse("sentry-api-0-api-tokens")
Expand Down
68 changes: 68 additions & 0 deletions tests/sentry/api/endpoints/test_organization_releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,74 @@ def test_org_auth_token(self) -> None:
assert org_token.date_last_used is not None
assert org_token.project_last_used_id == project1.id

def test_sentry_app_installation_token_with_org_ci_scope(self) -> None:
"""
We switched to org:ci for Vercel SentryApp in PR #113394 but the
existing webhook tests didn't test for scopes specifically.
"""
user = self.create_user(is_staff=False, is_superuser=False)
org = self.create_organization()
org.flags.allow_joinleave = False
org.save()

team = self.create_team(organization=org)
project = self.create_project(teams=[team], organization=org)

url = reverse(
"sentry-api-0-organization-releases",
kwargs={"organization_id_or_slug": org.slug},
)

sentry_app = self.create_internal_integration(
name="Test Vercel Internal Integration",
organization=org,
user=user,
scopes=["org:ci"],
)
token = self.create_internal_integration_token(user=user, internal_integration=sentry_app)

with outbox_runner():
response = self.client.post(
url,
data={"version": "1.2.1", "projects": [project.slug]},
HTTP_AUTHORIZATION=f"Bearer {token.token}",
)
assert response.status_code == 201, response.content
assert Release.objects.filter(organization_id=org.id, version="1.2.1").exists()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this portion of the test doesn't seem necessary

app_no_scope = self.create_internal_integration(
name="Test No Scope Integration",
organization=org,
user=user,
scopes=[],
)
token_no_scope = self.create_internal_integration_token(
user=user, internal_integration=app_no_scope
)
response = self.client.post(
url,
data={"version": "1.2.2", "projects": [project.slug]},
HTTP_AUTHORIZATION=f"Bearer {token_no_scope.token}",
)
assert response.status_code == 403

other_org = self.create_organization()
foreign_app = self.create_internal_integration(
name="Foreign Org Integration",
organization=other_org,
user=user,
scopes=["org:ci"],
)
foreign_token = self.create_internal_integration_token(
user=user, internal_integration=foreign_app
)
response = self.client.post(
url,
data={"version": "1.2.3", "projects": [project.slug]},
HTTP_AUTHORIZATION=f"Bearer {foreign_token.token}",
)
assert response.status_code == 403

@patch("sentry.tasks.commits.fetch_commits")
def test_api_token(self, mock_fetch_commits: MagicMock) -> None:
user = self.create_user(is_staff=False, is_superuser=False)
Expand Down
20 changes: 14 additions & 6 deletions tests/sentry/core/endpoints/test_organization_cell.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from sentry.models.organization import Organization
from sentry.models.organizationmember import OrganizationMember
from sentry.testutils.cases import APITestCase
from sentry.testutils.silo import assume_test_silo_mode_of, control_silo_test, create_test_cells
from sentry.testutils.silo import control_silo_test, create_test_cells
from sentry.types.cell import Cell, get_cell_by_name, get_global_directory
from sentry.utils.security.orgauthtoken_token import generate_token, hash_token

Expand Down Expand Up @@ -136,10 +135,7 @@ def test_user_auth_token_for_owner(self) -> None:

def test_user_auth_token_for_member(self) -> None:
org_user = self.create_user()
with assume_test_silo_mode_of(OrganizationMember):
OrganizationMember.objects.create(
user_id=org_user.id, organization_id=self.org.id, role="member"
)
self.create_member(user=org_user, organization=self.org, role="member")

user_auth_token = self.create_user_auth_token(user=org_user, scope_list=["org:read"])
response = self.send_get_request_with_auth(self.org.slug, user_auth_token.token)
Expand All @@ -149,6 +145,18 @@ def test_user_auth_token_for_member(self) -> None:
assert us_locality is not None
assert response.data == {"url": us_locality.to_url(""), "name": us_locality.name}

def test_user_auth_token_for_member_with_org_ci(self) -> None:
org_user = self.create_user()
self.create_member(user=org_user, organization=self.org, role="member")

user_auth_token = self.create_user_auth_token(user=org_user, scope_list=["org:ci"])
response = self.send_get_request_with_auth(self.org.slug, user_auth_token.token)

assert response.status_code == 200
us_locality = get_global_directory().get_locality_for_cell("us")
assert us_locality is not None
assert response.data == {"url": us_locality.to_url(""), "name": us_locality.name}

def test_user_auth_token_for_non_member(self) -> None:
user_auth_token = self.create_user_auth_token(
user=self.create_user(), scope_list=["org:read"]
Expand Down
16 changes: 16 additions & 0 deletions tests/sentry/web/frontend/test_oauth_authorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,22 @@ def test_approve_flow_non_scope_set(self) -> None:
"Read, write, and admin access to organization members."
]

def test_org_ci_scope_has_its_own_permission_description(self) -> None:
self.login_as(self.user)

resp = self.client.get(
f"{self.path}?response_type=code&client_id={self.application.client_id}&scope=org:read org:ci"
)

assert resp.status_code == 200
self.assertTemplateUsed("sentry/oauth-authorize.html")
assert resp.context["application"] == self.application
assert resp.context["scopes"] == ["org:read", "org:ci"]
assert resp.context["permissions"] == [
"Read access to organization details.",
"Access to CI workflows including source map uploads, release creation, and code mappings.",
]

def test_unauthenticated_basic_auth(self) -> None:
full_path = f"{self.path}?response_type=code&client_id={self.application.client_id}"

Expand Down
Loading