Skip to content

feat(api): Advertise required scopes on token-scope 403s (RFC 6750)#118612

Merged
gricha merged 1 commit into
masterfrom
greg/insufficient-scope-errors
Jun 29, 2026
Merged

feat(api): Advertise required scopes on token-scope 403s (RFC 6750)#118612
gricha merged 1 commit into
masterfrom
greg/insufficient-scope-errors

Conversation

@gricha

@gricha gricha commented Jun 26, 2026

Copy link
Copy Markdown
Member

Summary

When a token-authorized request is denied for lacking the required scope, Sentry returns a
bare 403 {"detail": "You do not have permission to perform this action."} — it never says
which scope was needed. This adopts the OAuth 2.0 standard answer (RFC 6750
insufficient_scope) so callers know what they're missing:

WWW-Authenticate: Bearer error="insufficient_scope", scope="org:admin org:write"

The required scopes come from the endpoint's own scope_map, surfaced from the single
shared token-scope gate (ScopedPermission.has_permission), so every token-scoped
endpoint gets it with no per-class edits.

Why it's safe / non-breaking

  • The 403 body is unchanged — the scope info rides only in the WWW-Authenticate
    header, so clients parsing {"detail": ...} are unaffected.
  • No-op for non-token auth (session/superuser/staff) and for 401 authentication failures.
  • The transport already existed: custom_exception_handler copies an exception's
    auth_header onto WWW-Authenticate (it even cited RFC 6750).
  • Discloses only the endpoint's required scopes (already public in open-source scope_map
    and the API docs). The denial fires at the view level before the org/object is loaded,
    so it leaks no resource existence; it never enumerates the caller's held scopes.

Non-goals

  • Object-level scope denials (has_object_permission) are not enriched yet — deliberate,
    to keep the blast radius small. The view-level gate covers the motivating cases.

Tests

tests/sentry/api/test_permissions.py: unit (the raise + exact header at the permission
boundary) and end-to-end (header reaches the HTTP response; body unchanged; no header
on 401, on session denial, or on success).

Spec: openspec/changes/add-insufficient-scope-errors/.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Jun 26, 2026
@gricha gricha force-pushed the greg/insufficient-scope-errors branch 3 times, most recently from 27a0106 to 0599669 Compare June 29, 2026 15:14
@gricha gricha marked this pull request as ready for review June 29, 2026 15:15
@gricha gricha requested review from a team as code owners June 29, 2026 15:15
Comment thread src/sentry/api/permissions.py Outdated
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

@gricha gricha force-pushed the greg/insufficient-scope-errors branch from 0599669 to 3f7ce77 Compare June 29, 2026 18:05
Comment thread tests/sentry/api/bases/test_project.py Outdated
Comment on lines +38 to +40
return perm.has_permission(drf_request, APIView()) and perm.has_object_permission(
drf_request, APIView(), obj
)

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.

Having a has_* method raise when you're denied feels a bit clunky. Usually has* methods will capture any exceptions from the checks they do and return a bool.

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.

corrected, thanks for the call out!

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 <noreply@anthropic.com>
@gricha gricha force-pushed the greg/insufficient-scope-errors branch from 3f7ce77 to b354c93 Compare June 29, 2026 19:50
Comment thread src/sentry/api/base.py
@gricha gricha merged commit 68c8a61 into master Jun 29, 2026
87 checks passed
@gricha gricha deleted the greg/insufficient-scope-errors branch June 29, 2026 21:32
shayna-ch pushed a commit that referenced this pull request Jun 30, 2026
…118612)

## Summary

When a token-authorized request is denied for lacking the required
scope, Sentry returns a
bare `403 {"detail": "You do not have permission to perform this
action."}` — it never says
*which* scope was needed. This adopts the OAuth 2.0 standard answer (RFC
6750
`insufficient_scope`) so callers know what they're missing:

```
WWW-Authenticate: Bearer error="insufficient_scope", scope="org:admin org:write"
```

The required scopes come from the endpoint's own `scope_map`, surfaced
from the single
shared token-scope gate (`ScopedPermission.has_permission`), so
**every** token-scoped
endpoint gets it with no per-class edits.

## Why it's safe / non-breaking

- The `403` **body is unchanged** — the scope info rides only in the
`WWW-Authenticate`
  header, so clients parsing `{"detail": ...}` are unaffected.
- No-op for non-token auth (session/superuser/staff) and for `401`
authentication failures.
- The transport already existed: `custom_exception_handler` copies an
exception's
  `auth_header` onto `WWW-Authenticate` (it even cited RFC 6750).
- Discloses only the endpoint's required scopes (already public in
open-source `scope_map`
and the API docs). The denial fires at the view level *before* the
org/object is loaded,
so it leaks no resource existence; it never enumerates the caller's held
scopes.

## Non-goals

- Object-level scope denials (`has_object_permission`) are not enriched
yet — deliberate,
to keep the blast radius small. The view-level gate covers the
motivating cases.

## Tests

`tests/sentry/api/test_permissions.py`: unit (the raise + exact header
at the permission
boundary) and end-to-end (header reaches the HTTP response; body
unchanged; **no** header
on `401`, on session denial, or on success).

Spec: `openspec/changes/add-insufficient-scope-errors/`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants