From b354c93348693e18d8405f80b04175c236e65cb9 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:30:43 -0700 Subject: [PATCH] feat(api): Advertise required scopes on token-scope 403s (RFC 6750) When a token-authorized request is denied because its scopes do not cover the endpoint's required scopes, return an RFC 6750 insufficient_scope challenge in the WWW-Authenticate header instead of a bare 403, so callers learn which scope they lack. has_permission stays a plain bool: the shared token-scope gate records the required scopes on the request, and Endpoint.permission_denied (which already raises for superuser/staff) raises InsufficientScope from them. The required scopes come from the endpoint's own scope_map, so every token-scoped endpoint benefits with no per-class edits. The response body is unchanged and the behavior is a no-op for non-token auth and for 401s, so this is non-breaking. Sentry's custom_exception_handler already forwards an exception's auth_header onto WWW-Authenticate, so the transport is reused as-is. Co-Authored-By: Claude Opus 4.8 --- src/sentry/api/base.py | 12 ++- src/sentry/api/exceptions.py | 24 +++++- src/sentry/api/permissions.py | 11 ++- tests/sentry/api/test_permissions.py | 115 ++++++++++++++++++++++++++- 4 files changed, 158 insertions(+), 4 deletions(-) diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index 57ec8d636925..f9c3f604435a 100644 --- a/src/sentry/api/base.py +++ b/src/sentry/api/base.py @@ -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 @@ -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) + permissions = self.get_permissions() if request.user.is_authenticated and len(permissions) == 1: permission_cls = permissions[0] diff --git a/src/sentry/api/exceptions.py b/src/sentry/api/exceptions.py index bb870a52e636..535fecc03183 100644 --- a/src/sentry/api/exceptions.py +++ b/src/sentry/api/exceptions.py @@ -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 @@ -17,6 +19,26 @@ class ResourceDoesNotExist(APIException): default_detail = "The requested resource does not exist" +class InsufficientScope(PermissionDenied): + """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 = "" diff --git a/src/sentry/api/permissions.py b/src/sentry/api/permissions.py index e48f3425999c..48bc4fb4ea30 100644 --- a/src/sentry/api/permissions.py +++ b/src/sentry/api/permissions.py @@ -8,6 +8,7 @@ from rest_framework.request import Request from sentry.api.exceptions import ( + INSUFFICIENT_SCOPE_ATTR, MemberDisabledOverLimit, SsoRequired, SuperuserRequired, @@ -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 diff --git a/tests/sentry/api/test_permissions.py b/tests/sentry/api/test_permissions.py index 1d2286204b35..6409deebb02c 100644 --- a/tests/sentry/api/test_permissions.py +++ b/tests/sentry/api/test_permissions.py @@ -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, @@ -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 @@ -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() @@ -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", "")