Skip to content
Merged
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
12 changes: 11 additions & 1 deletion src/sentry/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@
from sentry.analytics.events.release_set_commits import ReleaseSetCommitsLocalEvent
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.exceptions import StaffRequired, SuperuserRequired
from sentry.api.exceptions import (
INSUFFICIENT_SCOPE_ATTR,
InsufficientScope,
StaffRequired,
SuperuserRequired,
)
from sentry.apidocs.hooks import HTTP_METHOD_NAME
from sentry.auth import access
from sentry.auth.staff import has_staff_option
Expand Down Expand Up @@ -269,6 +274,11 @@ def permission_denied(self, request, message=None, code=None):
and the only permission class is SuperuserPermission. Otherwise, raises
the appropriate exception according to parent DRF function.
"""
required_scopes = getattr(request, INSUFFICIENT_SCOPE_ATTR, None)
if required_scopes:
# A token was denied for insufficient scope; surface the RFC 6750 challenge.
raise InsufficientScope(required_scopes)
Comment thread
gricha marked this conversation as resolved.

permissions = self.get_permissions()
if request.user.is_authenticated and len(permissions) == 1:
permission_cls = permissions[0]
Expand Down
24 changes: 23 additions & 1 deletion src/sentry/api/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

from collections.abc import Iterable

from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http.request import HttpRequest
from django.urls import reverse
from rest_framework import status
from rest_framework.exceptions import APIException
from rest_framework.exceptions import APIException, PermissionDenied

from sentry.models.organization import Organization
from sentry.organizations.services.organization.model import RpcOrganization
Expand All @@ -17,6 +19,26 @@ class ResourceDoesNotExist(APIException):
default_detail = "The requested resource does not exist"


class InsufficientScope(PermissionDenied):

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.

not blocking, i'm curious if we will want this for any getsentry endpoints and if this will work out of the box for those

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

i do think it works out of the box if i am to trust the agent here

"""A token-authorized request denied for lacking the required scope.

Renders as an ordinary ``403`` (DRF's default ``PermissionDenied`` body is unchanged),
but advertises the required scopes via the RFC 6750 ``insufficient_scope`` challenge.
``custom_exception_handler`` copies ``auth_header`` onto the ``WWW-Authenticate`` header.
"""

def __init__(self, required_scopes: Iterable[str]) -> None:
super().__init__()
scope = " ".join(sorted(required_scopes))
self.auth_header = f'Bearer error="insufficient_scope", scope="{scope}"'


# Set on the request by the scope check when a token is denied for insufficient scope, and
# read by Endpoint.permission_denied to raise InsufficientScope. Lets has_permission stay a
# plain bool while the view still emits the RFC 6750 challenge.
INSUFFICIENT_SCOPE_ATTR = "_insufficient_scope_required"


class SentryAPIException(APIException):
code = ""
message = ""
Expand Down
11 changes: 10 additions & 1 deletion src/sentry/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from rest_framework.request import Request

from sentry.api.exceptions import (
INSUFFICIENT_SCOPE_ATTR,
MemberDisabledOverLimit,
SsoRequired,
SuperuserRequired,
Expand Down Expand Up @@ -121,7 +122,15 @@ 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()
return any(s in allowed_scopes for s in current_scopes)
if any(s in allowed_scopes for s in current_scopes):
return True
if allowed_scopes:
# Token-authorized (request.auth is set) but under-scoped. Record the required
# scopes so permission_denied can advertise them via RFC 6750 insufficient_scope;
# has_permission itself stays a plain bool. (Skipped when no scope could satisfy
# the method, e.g. an unset scope_map entry, to avoid an empty challenge.)
setattr(request, INSUFFICIENT_SCOPE_ATTR, allowed_scopes)
return False

def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
return False
Expand Down
115 changes: 114 additions & 1 deletion tests/sentry/api/test_permissions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from rest_framework.views import APIView

from sentry.api.bases.organization import OrganizationPermission
from sentry.api.bases.project import ProjectPermission
from sentry.api.exceptions import InsufficientScope
from sentry.api.permissions import (
DemoSafePermission,
DisallowImpersonatedTokenCreation,
Expand All @@ -10,7 +13,7 @@
)
from sentry.demo_mode.utils import READONLY_SCOPES
from sentry.organizations.services.organization import organization_service
from sentry.testutils.cases import DRFPermissionTestCase
from sentry.testutils.cases import APITestCase, DRFPermissionTestCase
from sentry.testutils.helpers.options import override_options


Expand Down Expand Up @@ -104,6 +107,65 @@ def test_has_object_permission(self) -> None:
)


class InsufficientScopeTest(DRFPermissionTestCase):
"""``has_permission`` stays a plain bool; a denied under-scoped token is surfaced as an
RFC 6750 ``insufficient_scope`` challenge by ``permission_denied`` (proven end-to-end in
``InsufficientScopeResponseTest``)."""

permission = OrganizationPermission()

def setUp(self) -> None:
super().setUp()
self.user = self.create_user()
self.organization = self.create_organization(owner=self.user)

def _token_request(self, scopes, method):
token = self.create_user_auth_token(user=self.user, scope_list=list(scopes))
return self.make_request(user=self.user, auth=token, method=method)

def test_challenge_header_format(self) -> None:
# Required scopes are sorted and space-delimited per RFC 6750.
assert (
InsufficientScope(["org:write", "org:admin"]).auth_header
== 'Bearer error="insufficient_scope", scope="org:admin org:write"'
)
assert (
InsufficientScope(["org:admin"]).auth_header
== 'Bearer error="insufficient_scope", scope="org:admin"'
)

def test_under_scoped_token_is_denied(self) -> None:
# PUT requires org:write/org:admin; a read-only token holds neither.
request = self._token_request(["org:read"], "PUT")
assert self.permission.has_permission(request, APIView()) is False

def test_challenge_is_generic_across_permission_classes(self) -> None:
# The shared ScopedPermission gate denies under-scoped tokens for any permission
# class with its own scope_map -- here ProjectPermission's project scopes.
request = self._token_request(["project:read"], "PUT")
assert ProjectPermission().has_permission(request, APIView()) is False

def test_empty_scope_map_method_is_denied(self) -> None:
# A method with no scope_map entry (here PATCH) accepts no token scope; it is denied
# without recording any scopes to advertise (no empty challenge).
request = self._token_request(["org:read"], "PATCH")
assert self.permission.has_permission(request, APIView()) is False

def test_token_with_required_scope_is_allowed(self) -> None:
request = self._token_request(["org:write"], "PUT")
assert self.permission.has_permission(request, APIView())

def test_read_token_on_safe_method_is_allowed(self) -> None:
request = self._token_request(["org:read"], "GET")
assert self.permission.has_permission(request, APIView())

def test_session_request_is_allowed_at_view_level(self) -> None:
# No token: the view-level check defers to is_authenticated; scope enforcement
# happens at the object level.
request = self.make_request(user=self.user, method="PUT")
assert self.permission.has_permission(request, APIView())


class DemoSafePermissionsTest(DRFPermissionTestCase):
user_permission = DemoSafePermission()

Expand Down Expand Up @@ -223,3 +285,54 @@ def test_determine_access_no_demo_users(self) -> None:
)

assert readonly_rpc_context.member.scopes == list(self.org_member_scopes)


class InsufficientScopeResponseTest(APITestCase):
"""End-to-end: a token-scope denial reaches the client as a 403 carrying the RFC 6750
insufficient_scope WWW-Authenticate header (via custom_exception_handler)."""

endpoint = "sentry-api-0-organization-details"

def setUp(self) -> None:
super().setUp()
self.organization = self.create_organization(owner=self.user)

def _token(self, scopes):
return self.create_user_auth_token(user=self.user, scope_list=list(scopes))

def test_under_scoped_token_put_returns_insufficient_scope_header(self) -> None:
token = self._token(["org:read"])
response = self.get_error_response(
self.organization.slug,
method="put",
extra_headers={"HTTP_AUTHORIZATION": f"Bearer {token.token}"},
status_code=403,
)
assert (
response["WWW-Authenticate"]
== 'Bearer error="insufficient_scope", scope="org:admin org:write"'
)
# The body contract is unchanged: still a {"detail": ...} message, no new keys.
assert set(response.data.keys()) == {"detail"}

def test_sufficiently_scoped_token_get_has_no_challenge(self) -> None:
token = self._token(["org:read"])
response = self.get_success_response(
self.organization.slug,
extra_headers={"HTTP_AUTHORIZATION": f"Bearer {token.token}"},
)
assert "WWW-Authenticate" not in response

def test_session_denial_has_no_insufficient_scope_challenge(self) -> None:
# A session-authed member without org:write is denied at the object level, not the
# token-scope gate, so it must not carry an insufficient_scope challenge.
member = self.create_user()
self.create_member(organization=self.organization, user=member, role="member")
self.login_as(member)
response = self.get_error_response(self.organization.slug, method="put", status_code=403)
assert "insufficient_scope" not in response.get("WWW-Authenticate", "")

def test_unauthenticated_request_has_no_insufficient_scope_challenge(self) -> None:
# No credentials -> 401 authentication failure, never an insufficient_scope challenge.
response = self.get_error_response(self.organization.slug, method="put", status_code=401)
assert "insufficient_scope" not in response.get("WWW-Authenticate", "")
Loading