Skip to content

Commit 33bf0c1

Browse files
grichacodex
andauthored
fix(auth): Carry approved superuser access in ViewerContext (#126126)
After Sentry's normal organization access checks approve superuser access to an organization the user does not belong to, Sentry adds an org-bound `superuser: {expires_at}` object to ViewerContext. Propagated elevation is always read-only, even when the browser session permits writes: callback authorization caps scopes at `SENTRY_READONLY_SCOPES`. Org members keep their existing membership-based callback permissions and ViewerContext lifetime. Sentry bounds elevation by five minutes and the remaining browser session limits. Elevated ViewerContext JWTs expire by that deadline, and re-encoding never extends it. Callbacks recheck organization binding, the deadline, current user eligibility, and customer access policy; their request-scoped user copy masks global staff/superuser flags. This uses the existing Sentry-Seer shared signing key, so Seer is trusted to preserve the claim. Browser logout or withdrawal of session org authorization does not immediately revoke an issued claim. This PR covers ViewerContext issuance and callback authorization. getsentry/seer#8606 propagates the claim through Seer; #126377 consumes it when minting and authenticating agent tokens. --------- Co-authored-by: Codex <noreply@openai.com>
1 parent 87e0c0c commit 33bf0c1

14 files changed

Lines changed: 458 additions & 45 deletions

File tree

‎src/sentry/api/authentication.py‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from sentry import features, options
2929
from sentry.auth.services.auth import AuthenticatedToken
30+
from sentry.auth.superuser import resolve_superuser_access
3031
from sentry.auth.system import SystemToken, is_internal_ip
3132
from sentry.hybridcloud.models import ApiKeyReplica, ApiTokenReplica, OrgAuthTokenReplica
3233
from sentry.hybridcloud.rpc.service import RpcAuthenticationSetupException, compare_signature
@@ -920,9 +921,9 @@ class ViewerContextAuthentication(BaseAuthentication):
920921
Used by trusted services (e.g., Seer) that echo back the viewer context
921922
originally signed by Sentry.
922923
923-
The user is resolved via user_service.get_user() (RPC-backed, cached).
924-
Sets request.auth = None so that determine_access derives permissions
925-
from the user's OrganizationMember role — identical to session auth.
924+
Identity-only contexts derive access from organization membership. An optional
925+
Sentry-signed superuser context instead produces an org-bound credential;
926+
shared access checks validate it again and cap it to read-only scopes.
926927
"""
927928

928929
def authenticate(self, request: Request) -> tuple[Any, Any] | None:
@@ -979,6 +980,38 @@ def authenticate(self, request: Request) -> tuple[Any, Any] | None:
979980
# avoid requiring browser-session SSO state on service callbacks.
980981
setattr(request, "user_from_viewer_context", True)
981982

983+
if vc.superuser is not None:
984+
org_context = (
985+
organization_service.get_organization_by_id(
986+
id=vc.organization_id,
987+
user_id=user.id,
988+
include_projects=False,
989+
include_teams=False,
990+
)
991+
if vc.organization_id is not None
992+
else None
993+
)
994+
delegated = (
995+
resolve_superuser_access(vc.superuser, user, org_context)
996+
if org_context is not None
997+
else None
998+
)
999+
if delegated is None:
1000+
raise AuthenticationFailed("Invalid superuser access")
1001+
scopes, _ = delegated
1002+
credential = AuthenticatedToken(
1003+
kind="viewer_context",
1004+
user_id=user.id,
1005+
organization_id=vc.organization_id,
1006+
scopes=sorted(scopes),
1007+
superuser=vc.superuser,
1008+
)
1009+
# Org-bound access must not enable global staff/superuser bypasses.
1010+
user = user.copy(
1011+
update={"is_staff": False, "is_superuser": False, "permissions": frozenset()}
1012+
)
1013+
return (user, credential)
1014+
9821015
# Return None for auth to match session behavior —
9831016
# determine_access will derive scopes from org membership role.
9841017
return (user, None)

‎src/sentry/auth/access.py‎

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@
1818
from sentry.auth.services.access.service import access_service
1919
from sentry.auth.services.auth import AuthenticatedToken, RpcAuthState, RpcMemberSsoState
2020
from sentry.auth.staff import is_active_staff
21-
from sentry.auth.superuser import get_superuser_scopes, is_active_superuser
21+
from sentry.auth.superuser import (
22+
Superuser,
23+
get_superuser_access_expiry,
24+
get_superuser_scopes,
25+
is_active_superuser,
26+
resolve_superuser_access,
27+
)
2228
from sentry.auth.system import is_system_auth
2329
from sentry.constants import ObjectStatus
2430
from sentry.data_secrecy.logic import should_allow_superuser_access
@@ -28,15 +34,23 @@
2834
from sentry.models.organizationmemberteam import OrganizationMemberTeam
2935
from sentry.models.project import Project
3036
from sentry.models.team import Team, TeamStatus
31-
from sentry.organizations.services.organization import RpcTeamMember, RpcUserOrganizationContext
37+
from sentry.organizations.services.organization import (
38+
RpcTeamMember,
39+
RpcUserOrganizationContext,
40+
organization_service,
41+
)
3242
from sentry.organizations.services.organization.serial import summarize_member
3343
from sentry.roles import organization_roles
3444
from sentry.roles.manager import OrganizationRole, TeamRole
3545
from sentry.seer.agent_token import is_agent_auth
3646
from sentry.sentry_apps.models.sentry_app import SentryApp
47+
from sentry.types.request import _HttpRequestWithUser
48+
from sentry.types.superuser import SuperuserAccess
3749
from sentry.users.models.user import User
3850
from sentry.users.services.user import RpcUser
51+
from sentry.users.services.user.service import user_service
3952
from sentry.utils import metrics
53+
from sentry.viewer_context import set_viewer_context_superuser
4054

4155
__all__ = (
4256
"from_user",
@@ -992,6 +1006,28 @@ def __init__(self) -> None:
9921006
)
9931007

9941008

1009+
def _propagate_superuser_access(
1010+
request: _HttpRequestWithUser,
1011+
org_context: RpcUserOrganizationContext,
1012+
su: Superuser | None,
1013+
) -> None:
1014+
# Members use their normal callback permissions and user flags; adding this
1015+
# claim would instead force read-only access and a five-minute VC lifetime.
1016+
if org_context.member is not None:
1017+
return
1018+
if request.auth is not None or su is None or not su.is_active:
1019+
return
1020+
if su.requires_org_auth(org_context.organization):
1021+
return
1022+
expires_at = get_superuser_access_expiry(su, org_context.organization.id)
1023+
if expires_at is not None:
1024+
set_viewer_context_superuser(
1025+
user_id=request.user.id,
1026+
organization_id=org_context.organization.id,
1027+
superuser=SuperuserAccess(expires_at=expires_at),
1028+
)
1029+
1030+
9951031
def from_request_org_and_scopes(
9961032
*,
9971033
request: HttpRequest,
@@ -1002,6 +1038,10 @@ def from_request_org_and_scopes(
10021038
Note that `scopes` is usually None because request.auth is not set at `get_authorization_header`
10031039
when the request is made from the frontend using cookies
10041040
"""
1041+
if isinstance(request.auth, AuthenticatedToken) and request.auth.superuser is not None:
1042+
if rpc_user_org_context is None:
1043+
return DEFAULT
1044+
return from_superuser_access(request.auth, rpc_user_org_context)
10051045
if is_agent_auth(request.auth):
10061046
if rpc_user_org_context is None:
10071047
return DEFAULT
@@ -1027,11 +1067,12 @@ def from_request_org_and_scopes(
10271067

10281068
if is_active_superuser(request):
10291069
su = getattr(request, "superuser", None)
1030-
if (
1031-
su
1032-
and su.requires_org_auth(rpc_user_org_context.organization)
1033-
and rpc_user_org_context.member is None
1034-
):
1070+
if su is None and request.auth is None:
1071+
su = Superuser(request)
1072+
requires_org_auth = su is not None and su.requires_org_auth(
1073+
rpc_user_org_context.organization
1074+
)
1075+
if requires_org_auth and rpc_user_org_context.member is None:
10351076
setattr(request, "_superuser_needs_org_auth", rpc_user_org_context.organization.slug)
10361077

10371078
member = rpc_user_org_context.member
@@ -1043,12 +1084,14 @@ def from_request_org_and_scopes(
10431084
org_member=member,
10441085
)
10451086

1046-
superuser_scopes = get_superuser_scopes(auth_state, request.user, rpc_user_org_context)
1087+
superuser_scopes = get_superuser_scopes(auth_state, rpc_user_org_context)
10471088
if scopes:
10481089
superuser_scopes = superuser_scopes.union(set(scopes))
10491090
if member and member.scopes:
10501091
superuser_scopes = superuser_scopes.union(set(member.scopes))
10511092

1093+
_propagate_superuser_access(request, rpc_user_org_context, su)
1094+
10521095
return ApiBackedOrganizationGlobalAccess(
10531096
rpc_user_organization_context=rpc_user_org_context,
10541097
auth_state=auth_state,
@@ -1114,7 +1157,9 @@ def from_user_and_rpc_user_org_context(
11141157
def from_request(
11151158
request: Request, organization: Organization | None = None, scopes: Iterable[str] | None = None
11161159
) -> Access:
1117-
if is_agent_auth(request.auth):
1160+
if isinstance(request.auth, AuthenticatedToken) and (
1161+
is_agent_auth(request.auth) or request.auth.superuser is not None
1162+
):
11181163
if organization is None:
11191164
return DEFAULT
11201165
return from_auth(request.auth, organization)
@@ -1154,7 +1199,7 @@ def from_request(
11541199
)
11551200
sso_state = auth_state.sso_state
11561201

1157-
superuser_scopes = get_superuser_scopes(auth_state, request.user, organization)
1202+
superuser_scopes = get_superuser_scopes(auth_state, organization)
11581203
if scopes:
11591204
superuser_scopes = superuser_scopes.union(set(scopes))
11601205
if member and (member_scopes := member.get_scopes()):
@@ -1285,6 +1330,11 @@ def from_rpc_member(
12851330

12861331

12871332
def from_auth(auth: AuthenticatedToken, organization: Organization) -> Access:
1333+
if isinstance(auth, AuthenticatedToken) and auth.superuser is not None:
1334+
context = organization_service.get_organization_by_id(
1335+
id=organization.id, user_id=auth.user_id
1336+
)
1337+
return from_superuser_access(auth, context) if context is not None else DEFAULT
12881338
if is_system_auth(auth):
12891339
return SystemAccess()
12901340
if is_agent_auth(auth):
@@ -1312,6 +1362,8 @@ def from_auth(auth: AuthenticatedToken, organization: Organization) -> Access:
13121362
def from_rpc_auth(
13131363
auth: AuthenticatedToken, rpc_user_org_context: RpcUserOrganizationContext
13141364
) -> Access:
1365+
if isinstance(auth, AuthenticatedToken) and auth.superuser is not None:
1366+
return from_superuser_access(auth, rpc_user_org_context)
13151367
if is_system_auth(auth):
13161368
return SystemAccess()
13171369
if is_agent_auth(auth):
@@ -1356,4 +1408,30 @@ def from_agent_auth(
13561408
return from_rpc_member(rpc_user_org_context, scopes=auth.get_scopes())
13571409

13581410

1411+
def from_superuser_access(
1412+
auth: AuthenticatedToken, rpc_user_org_context: RpcUserOrganizationContext
1413+
) -> Access:
1414+
if (
1415+
auth.superuser is None
1416+
or auth.organization_id != rpc_user_org_context.organization.id
1417+
or auth.user_id != rpc_user_org_context.user_id
1418+
):
1419+
return DEFAULT
1420+
users = user_service.get_many(filter={"user_ids": [auth.user_id]})
1421+
user = users[0] if users else None
1422+
if user is None:
1423+
return DEFAULT
1424+
delegated = resolve_superuser_access(auth.superuser, user, rpc_user_org_context)
1425+
if delegated is None:
1426+
return DEFAULT
1427+
scopes, _ = delegated
1428+
return ApiBackedOrganizationGlobalAccess(
1429+
rpc_user_organization_context=rpc_user_org_context,
1430+
auth_state=RpcAuthState(
1431+
sso_state=RpcMemberSsoState(is_valid=True, is_required=False), permissions=[]
1432+
),
1433+
scopes=scopes & set(auth.get_scopes()),
1434+
)
1435+
1436+
13591437
DEFAULT = NoAccess()

‎src/sentry/auth/services/auth/model.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from sentry.auth.scope_declaration import check_scope_declaration
1515
from sentry.hybridcloud.rpc import RpcModel
16+
from sentry.types.superuser import SuperuserAccess
1617
from sentry.users.services.user import RpcUser
1718

1819
if TYPE_CHECKING:
@@ -66,6 +67,7 @@ class AuthenticatedToken(RpcModel):
6667
organization_id: int | None = None
6768
application_id: int | None = None # only relevant for ApiToken
6869
project_id: int | None = None # only relevant for ProjectKey
70+
superuser: SuperuserAccess | None = Field(default=None, repr=False)
6971

7072
def token_has_org_access(self, organization_id: int) -> bool:
7173
return self.kind == "api_token" and self.organization_id == organization_id

‎src/sentry/auth/superuser.py‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,15 @@
3030
from sentry import options
3131
from sentry.api.exceptions import DataSecrecyError, SentryAPIException
3232
from sentry.auth.elevated_mode import ElevatedMode, InactiveReason
33-
from sentry.auth.services.auth.model import RpcAuthState
33+
from sentry.auth.services.auth.model import RpcAuthState, RpcMemberSsoState
3434
from sentry.auth.system import is_system_auth
3535
from sentry.data_secrecy.logic import should_allow_superuser_access
3636
from sentry.models.organization import Organization
3737
from sentry.organizations.services.organization import RpcUserOrganizationContext
3838
from sentry.types.request import _HttpRequestWithUser, _RequestWithUser
39+
from sentry.types.superuser import SUPERUSER_ACCESS_TTL, SuperuserAccess
3940
from sentry.users.models.user import User
41+
from sentry.users.services.user import RpcUser
4042
from sentry.utils import metrics
4143
from sentry.utils.auth import has_completed_sso
4244
from sentry.utils.settings import is_self_hosted
@@ -89,7 +91,6 @@
8991

9092
def get_superuser_scopes(
9193
auth_state: RpcAuthState,
92-
user: User,
9394
organization_context: Organization | RpcUserOrganizationContext,
9495
) -> set[str]:
9596
if not should_allow_superuser_access(organization_context):
@@ -315,7 +316,8 @@ def get_session_data(self, current_datetime: datetime | None = None) -> dict[str
315316
)
316317
return None
317318

318-
data = request.session.get(SESSION_KEY)
319+
raw_data = request.session.get(SESSION_KEY)
320+
data = dict(raw_data) if raw_data else None
319321
if not cookie_token:
320322
if data:
321323
logger.warning(
@@ -576,3 +578,39 @@ def on_response(self, response: HttpResponse) -> None:
576578
# otherwise, if the session is invalid and there's a cookie set, clear it
577579
elif not self.is_valid and request.COOKIES.get(COOKIE_NAME):
578580
response.delete_cookie(COOKIE_NAME)
581+
582+
583+
def get_superuser_access_expiry(su: Superuser, organization_id: int) -> int | None:
584+
"""Bound an approved superuser session for ViewerContext propagation."""
585+
data = su.get_session_data()
586+
if data is None:
587+
return None
588+
now = django_timezone.now()
589+
expires = min(data["exp"], data["idl"], now + SUPERUSER_ACCESS_TTL)
590+
if organization_id != su.org_id:
591+
expires = min(expires, data["exp"] - MAX_AGE + MAX_AGE_PRIVILEGED_ORG_ACCESS)
592+
if expires <= now:
593+
return None
594+
return int(expires.timestamp())
595+
596+
597+
def resolve_superuser_access(
598+
superuser: SuperuserAccess, user: RpcUser, org_context: RpcUserOrganizationContext
599+
) -> tuple[set[str], datetime] | None:
600+
"""Resolve read-only scopes after validating expiry and current user/customer policy."""
601+
if not user.is_active or not user.is_superuser or user.is_suspended:
602+
return None
603+
try:
604+
expires = datetime.fromtimestamp(superuser.expires_at, timezone.utc)
605+
now = django_timezone.now()
606+
if expires <= now or expires > now + SUPERUSER_ACCESS_TTL:
607+
return None
608+
scopes = (
609+
get_superuser_scopes(
610+
RpcAuthState(sso_state=RpcMemberSsoState(), permissions=[]), org_context
611+
)
612+
& settings.SENTRY_READONLY_SCOPES
613+
)
614+
except (TypeError, ValueError, OverflowError, DataSecrecyError):
615+
return None
616+
return scopes, expires

‎src/sentry/data_secrecy/logic.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212

1313
def should_allow_superuser_access(
14-
organization_context: Organization | RpcUserOrganizationContext,
14+
organization_context: Organization | RpcOrganization | RpcUserOrganizationContext,
1515
) -> bool:
1616
# If self hosted installation, allow superuser access
1717
if settings.SENTRY_SELF_HOSTED:

‎src/sentry/seer/signed_seer_api.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,14 @@ def _resolve_viewer_context(
133133
)
134134

135135
if has_mismatch:
136-
return replace(vc, organization_id=org_id, user_id=user_id, project_id=None, token=None)
136+
return replace(
137+
vc,
138+
organization_id=org_id,
139+
user_id=user_id,
140+
project_id=None,
141+
token=None,
142+
superuser=None,
143+
)
137144
return replace(vc, organization_id=org_id, user_id=user_id)
138145

139146

‎src/sentry/types/superuser.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from datetime import timedelta
2+
3+
from pydantic import BaseModel, StrictInt
4+
5+
SUPERUSER_ACCESS_TTL = timedelta(minutes=5)
6+
7+
8+
class SuperuserAccess(BaseModel):
9+
"""Read-only elevation for the ViewerContext's user and organization."""
10+
11+
expires_at: StrictInt
12+
13+
class Config:
14+
frozen = True

0 commit comments

Comments
 (0)