Skip to content

Commit 0586ea0

Browse files
authored
fix(seer): Honor approved superuser access in agent tokens (#126377)
Agent tokens minted from an approved superuser ViewerContext can read the selected organization when the delegating user is not a member. Sentry carries the nested `superuser: {expires_at}` claim into the token and always limits elevated access to read-only, including when approved agent write grants exist. Token expiry is bounded by the original deadline. Token use rechecks current user eligibility, customer access policy, and organization binding. Org members keep membership-based minting, including approved write grants, even when browser superuser mode is active. Their tokens do not carry a superuser claim or inherit its deadline. This PR is stacked on #126126, which issues and accepts the ViewerContext claim with a maximum five-minute deadline. getsentry/seer#8606 carries it through Seer.
1 parent c142da5 commit 0586ea0

6 files changed

Lines changed: 324 additions & 7 deletions

File tree

‎src/sentry/api/authentication.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ class AgentTokenAuthentication(StandardAuthentication):
618618
The agent credential remains the authorization authority. A non-authoritative,
619619
ephemeral copy of the delegating user is returned only for compatibility with
620620
callsites that still require ``request.user``; API access is derived from the
621-
delegating member and capped by the token in ``access.from_agent_auth``."""
621+
delegating member or approved superuser context, capped by the token."""
622622

623623
token_name = b"bearer"
624624

@@ -661,7 +661,12 @@ def fail(reason: str, **extra: Any) -> NoReturn:
661661
fail("no_user_principal", org_id=auth_token.organization_id)
662662

663663
# The delegating user must still be valid even though they are not the request user.
664-
user = user_service.get_user(user_id=user_id)
664+
if auth_token.superuser is not None:
665+
# Non-members do not necessarily receive this cell's user-cache invalidations.
666+
users = user_service.get_many(filter={"user_ids": [user_id]})
667+
user = users[0] if users else None
668+
else:
669+
user = user_service.get_user(user_id=user_id)
665670
if user is None:
666671
fail("user_not_found", user_id=user_id)
667672
if not user.is_active:
@@ -677,7 +682,10 @@ def fail(reason: str, **extra: Any) -> NoReturn:
677682
)
678683
if org_context is None:
679684
fail("org_context_missing", user_id=user_id, org_id=auth_token.organization_id)
680-
if org_context.member is None:
685+
if auth_token.superuser is not None:
686+
if resolve_superuser_access(auth_token.superuser, user, org_context) is None:
687+
fail("superuser_access_invalid", user_id=user_id)
688+
elif org_context.member is None:
681689
fail("org_membership_missing", user_id=user_id, org_id=auth_token.organization_id)
682690
if not features.has(
683691
agent_token.FEATURE_FLAG,

‎src/sentry/core/endpoints/project_index.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def get(self, request: Request) -> Response:
6363
if org_context is None:
6464
queryset = queryset.none()
6565
else:
66-
agent_access = access.from_agent_auth(request.auth, org_context)
66+
agent_access = access.from_rpc_auth(request.auth, org_context)
6767
queryset = queryset.filter(organization_id=request.auth.organization_id)
6868
if not agent_access.has_global_access:
6969
queryset = queryset.filter(id__in=agent_access.accessible_project_ids)

‎src/sentry/middleware/viewer_context.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.http.request import HttpRequest
88
from django.http.response import HttpResponseBase
99

10+
from sentry.auth.services.auth import AuthenticatedToken
1011
from sentry.seer.agent_token import is_agent_auth
1112
from sentry.viewer_context import (
1213
ActorType,
@@ -103,4 +104,5 @@ def _viewer_context_from_request(request: HttpRequest) -> ViewerContext:
103104
organization_id=organization_id,
104105
actor_type=actor_type,
105106
token=auth,
107+
superuser=auth.superuser if isinstance(auth, AuthenticatedToken) else None,
106108
)

‎src/sentry/seer/agent_token.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,22 @@
1010
from dataclasses import dataclass
1111
from datetime import datetime, timedelta
1212
from enum import StrEnum
13-
from typing import TypedDict, TypeGuard
13+
from typing import NotRequired, TypedDict, TypeGuard
1414

1515
from django.conf import settings
1616
from django.contrib.auth.models import AnonymousUser
1717
from django.db import router, transaction
1818
from django.utils import timezone
1919
from jwt import PyJWTError
20+
from pydantic import ValidationError
2021

2122
from sentry.auth.services.auth import AuthenticatedToken
2223
from sentry.seer.models.agent_write_grant import (
2324
AGENT_SESSION_ID_MAX_LENGTH,
2425
DEFAULT_EXPIRATION,
2526
SeerAgentWriteGrant,
2627
)
28+
from sentry.types.superuser import SuperuserAccess
2729
from sentry.users.models.user import User
2830
from sentry.users.services.user import RpcUser
2931
from sentry.utils import jwt
@@ -76,6 +78,7 @@ class AgentTokenClaims(TypedDict):
7678
sid: str
7779
iat: int
7880
exp: int
81+
superuser: NotRequired[dict[str, object]]
7982

8083

8184
def _signing_key() -> str:
@@ -170,6 +173,7 @@ def encode_agent_token(
170173
scopes: Iterable[str],
171174
session_id: str,
172175
ttl: timedelta = DEFAULT_TOKEN_TTL,
176+
superuser: SuperuserAccess | None = None,
173177
) -> tuple[str, datetime]:
174178
"""Mint a signed agent token. Returns the JWT and its expiry. No DB write."""
175179
now = timezone.now()
@@ -186,6 +190,8 @@ def encode_agent_token(
186190
"iat": int(now.timestamp()),
187191
"exp": int(expires_at.timestamp()),
188192
}
193+
if superuser is not None:
194+
payload["superuser"] = superuser.dict()
189195
token = jwt.encode(
190196
payload,
191197
_signing_key(),
@@ -274,7 +280,7 @@ def _validate_claims(claims: Mapping[str, object]) -> AgentTokenClaims:
274280
if expires_at <= issued_at:
275281
raise jwt.DecodeError("invalid agent token lifetime")
276282

277-
return AgentTokenClaims(
283+
result = AgentTokenClaims(
278284
ver=version,
279285
aud=audience,
280286
sub=subject,
@@ -284,6 +290,12 @@ def _validate_claims(claims: Mapping[str, object]) -> AgentTokenClaims:
284290
iat=issued_at,
285291
exp=expires_at,
286292
)
293+
if "superuser" in claims:
294+
try:
295+
result["superuser"] = SuperuserAccess.parse_obj(claims["superuser"]).dict()
296+
except ValidationError as exc:
297+
raise jwt.DecodeError("invalid agent superuser access") from exc
298+
return result
287299

288300

289301
def decode_agent_token(token_str: str) -> AgentTokenClaims:
@@ -313,6 +325,7 @@ def build_authenticated_token(claims: AgentTokenClaims) -> AuthenticatedToken:
313325
scopes=claims["scopes"],
314326
user_id=principal.id,
315327
organization_id=claims["org"],
328+
superuser=SuperuserAccess.parse_obj(claims["superuser"]) if "superuser" in claims else None,
316329
)
317330

318331

‎src/sentry/seer/endpoints/organization_agent_token.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import annotations
22

33
from collections.abc import Mapping
4+
from datetime import datetime
5+
from datetime import timezone as datetime_timezone
46
from typing import Any, cast
57

8+
from django.utils import timezone
69
from drf_spectacular.utils import extend_schema
710
from rest_framework import serializers
811
from rest_framework.exceptions import PermissionDenied
@@ -25,6 +28,7 @@
2528
from sentry.models.organization import Organization
2629
from sentry.seer import agent_token
2730
from sentry.seer.endpoints.agent_request import AgentTokenRequestData, AgentTokenRequestSerializer
31+
from sentry.viewer_context import get_viewer_context
2832

2933

3034
class AgentTokenResponseSerializer(serializers.Serializer):
@@ -68,6 +72,7 @@ def post(self, request: Request, organization: Organization) -> Response:
6872
Body: ``{"sessionId": str, "requestedScopes"?: [str]}``. The token's scopes are the
6973
caller's own scopes intersected with read-only plus any approved grants for this
7074
org and session; ``requestedScopes`` can only narrow further. No token is stored.
75+
Propagated superuser elevation always remains read-only, including with write grants.
7176
"""
7277
if not features.has(agent_token.FEATURE_FLAG, organization, actor=request.user):
7378
raise ResourceDoesNotExist
@@ -100,11 +105,31 @@ def post(self, request: Request, organization: Organization) -> Response:
100105
requested_scopes=requested_scopes,
101106
)
102107

108+
viewer = get_viewer_context()
109+
superuser = (
110+
viewer.superuser
111+
if viewer is not None
112+
and viewer.user_id == user_id
113+
and viewer.organization_id == organization.id
114+
else None
115+
)
116+
ttl = agent_token.DEFAULT_TOKEN_TTL
117+
if superuser is not None:
118+
scopes = sorted(set(scopes) & agent_token.readonly_scopes())
119+
ttl = min(
120+
ttl,
121+
datetime.fromtimestamp(superuser.expires_at, datetime_timezone.utc)
122+
- timezone.now(),
123+
)
124+
if ttl.total_seconds() <= 0:
125+
raise PermissionDenied("Elevated session expired.")
103126
token, expires_at = agent_token.encode_agent_token(
104127
user_id=user_id,
105128
organization_id=organization.id,
106129
scopes=scopes,
107130
session_id=session_id,
131+
superuser=superuser,
132+
ttl=ttl,
108133
)
109134
return Response(
110135
{

0 commit comments

Comments
 (0)