diff --git a/pyproject.toml b/pyproject.toml index ec25af5232b3..42ca04151f46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,7 @@ dependencies = [ "sentry-options>=1.2.15", "sentry-protos>=0.91.0", "sentry-redis-tools>=0.5.0", + "sentry-viewercontext>=0.1.0", "sentry-relay>=0.10.1", "sentry-scm>=1.9.0", "sentry-sdk[http2]>=2.72.0", diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index 2a5f7e64a14a..1ca2f54e4b81 100644 --- a/src/sentry/api/authentication.py +++ b/src/sentry/api/authentication.py @@ -49,6 +49,7 @@ from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.token_exchange.util import GrantTypes from sentry.silo.base import SiloLimit, SiloMode +from sentry.types.superuser import from_viewer_context_superuser from sentry.users.models.user import User from sentry.users.services.user import RpcUser from sentry.users.services.user.service import user_service @@ -984,7 +985,7 @@ def authenticate(self, request: Request) -> tuple[Any, Any] | None: vc = viewer_context_from_header(header) user_id = get_acting_user_id(vc) - superuser = vc.actor.superuser if vc is not None else None + superuser = from_viewer_context_superuser(vc.actor.superuser) if vc is not None else None if vc is None or user_id is None: # TODO(jstanley): Temporary logging for debugging non-public prod 401s diff --git a/src/sentry/auth/access.py b/src/sentry/auth/access.py index 4faead387339..c1bd776e50d5 100644 --- a/src/sentry/auth/access.py +++ b/src/sentry/auth/access.py @@ -45,12 +45,11 @@ from sentry.seer.agent_token import is_agent_auth from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.types.request import _HttpRequestWithUser -from sentry.types.superuser import SuperuserAccess from sentry.users.models.user import User from sentry.users.services.user import RpcUser from sentry.users.services.user.service import user_service from sentry.utils import metrics -from sentry.viewer_context import set_viewer_context_superuser +from sentry.viewer_context import SuperuserAccess, set_viewer_context_superuser __all__ = ( "from_user", diff --git a/src/sentry/middleware/viewer_context.py b/src/sentry/middleware/viewer_context.py index 3aaf8890c133..91f430ff5491 100644 --- a/src/sentry/middleware/viewer_context.py +++ b/src/sentry/middleware/viewer_context.py @@ -9,6 +9,7 @@ from sentry.auth.services.auth import AuthenticatedToken from sentry.seer.agent_token import is_agent_auth +from sentry.types.superuser import to_viewer_context_superuser from sentry.utils.auth import is_user_from_viewer_context from sentry.viewer_context import ( Actor, @@ -98,7 +99,11 @@ def _viewer_context_from_request(request: HttpRequest) -> ViewerContext: # An agent token is a non-user actor: the request user is anonymous, so read the # delegating user it acts on behalf of from the credential. - superuser = auth.superuser if isinstance(auth, AuthenticatedToken) else None + superuser = ( + to_viewer_context_superuser(auth.superuser) + if isinstance(auth, AuthenticatedToken) + else None + ) if auth is not None and is_agent_auth(auth): actor = Actor(type=ActorType.AGENT, delegated_user_id=auth.user_id, superuser=superuser) else: diff --git a/src/sentry/seer/endpoints/organization_agent_token.py b/src/sentry/seer/endpoints/organization_agent_token.py index eddfc3c567a3..ee5157480e8a 100644 --- a/src/sentry/seer/endpoints/organization_agent_token.py +++ b/src/sentry/seer/endpoints/organization_agent_token.py @@ -28,6 +28,7 @@ from sentry.models.organization import Organization from sentry.seer import agent_token from sentry.seer.endpoints.agent_request import AgentTokenRequestData, AgentTokenRequestSerializer +from sentry.types.superuser import from_viewer_context_superuser from sentry.viewer_context import get_acting_user_id, get_viewer_context @@ -107,7 +108,7 @@ def post(self, request: Request, organization: Organization) -> Response: viewer = get_viewer_context() superuser = ( - viewer.actor.superuser + from_viewer_context_superuser(viewer.actor.superuser) if viewer is not None and get_acting_user_id(viewer) == user_id and viewer.organization_id == organization.id diff --git a/src/sentry/types/superuser.py b/src/sentry/types/superuser.py index 9beb17c1adac..b76f615b93e7 100644 --- a/src/sentry/types/superuser.py +++ b/src/sentry/types/superuser.py @@ -1,6 +1,7 @@ from datetime import timedelta from pydantic import BaseModel, StrictInt +from sentry_viewercontext import SuperuserAccess as ViewerContextSuperuserAccess SUPERUSER_ACCESS_TTL = timedelta(minutes=5) @@ -12,3 +13,21 @@ class SuperuserAccess(BaseModel): class Config: frozen = True + + +def from_viewer_context_superuser( + superuser: ViewerContextSuperuserAccess | None, +) -> SuperuserAccess | None: + """Convert the shared library's elevation into the Pydantic model RPC and agent tokens use.""" + if superuser is None: + return None + return SuperuserAccess(expires_at=superuser.expires_at) + + +def to_viewer_context_superuser( + superuser: SuperuserAccess | None, +) -> ViewerContextSuperuserAccess | None: + """Convert Pydantic elevation into the shared library's type for a ViewerContext.""" + if superuser is None: + return None + return ViewerContextSuperuserAccess(expires_at=superuser.expires_at) diff --git a/src/sentry/viewer_context.py b/src/sentry/viewer_context.py index b11668d92cbc..edf5d030086c 100644 --- a/src/sentry/viewer_context.py +++ b/src/sentry/viewer_context.py @@ -1,22 +1,39 @@ from __future__ import annotations -import contextlib -import contextvars import dataclasses -import enum -import hashlib import logging -import time -from collections.abc import Generator, Mapping +from collections.abc import Mapping from random import random -from typing import Any, NamedTuple +from typing import Any -import jwt as pyjwt import sentry_sdk from django.conf import settings +from sentry_viewercontext import ( + Actor, + ActorId, + ActorType, + SuperuserAccess, + ViewerContext, + decode_jwt, + encode_jwt, + get_viewer_context, + is_jwt, + key_id, + viewer_context_scope, + viewer_context_var, +) from sentry.silo.base import SiloMode -from sentry.types.superuser import SUPERUSER_ACCESS_TTL, SuperuserAccess + +__all__ = [ + "Actor", + "ActorId", + "ActorType", + "SuperuserAccess", + "ViewerContext", + "get_viewer_context", + "viewer_context_scope", +] logger = logging.getLogger(__name__) @@ -24,9 +41,7 @@ # "caller passed None" from "caller didn't pass ctx". _OBSERVE_USE_CONTEXTVAR: Any = object() -_viewer_context_var: contextvars.ContextVar[ViewerContext | None] = contextvars.ContextVar( - "viewer_context", default=None -) +_viewer_context_var = viewer_context_var """ ViewerContext is an object propagated across codebase (and crossing service boundary) to deliver @@ -39,106 +54,6 @@ """ -class ActorType(enum.StrEnum): - USER = "user" - SERVICE_ACCOUNT = "service_account" - SYSTEM = "system" - INTEGRATION = "integration" - AGENT = "agent" - UNKNOWN = "unknown" - - -class ActorId(NamedTuple): - type: ActorType - id: int | None - - -@dataclasses.dataclass(frozen=True) -class Actor: - """Typed caller identity, separate from any human delegating its work.""" - - type: ActorType - actor_id: int | None = None - delegated_user_id: int | None = None - superuser: SuperuserAccess | None = dataclasses.field(default=None, repr=False) - - @property - def id(self) -> ActorId: - return ActorId(self.type, self.actor_id) - - def serialize(self) -> dict[str, Any]: - result: dict[str, Any] = {"type": self.type.value} - if self.actor_id is not None: - result["id"] = self.actor_id - if self.delegated_user_id is not None: - result["delegated_user_id"] = self.delegated_user_id - if self.superuser is not None: - result["superuser"] = self.superuser.dict() - return result - - @classmethod - def deserialize(cls, data: Any) -> Actor: - if not isinstance(data, dict) or "type" not in data: - raise ValueError("Actor must be an object with a type") - superuser = data.get("superuser") - return cls( - type=ActorType(data["type"]), - actor_id=data.get("id"), - delegated_user_id=data.get("delegated_user_id"), - superuser=SuperuserAccess.parse_obj(superuser) if superuser is not None else None, - ) - - -@dataclasses.dataclass(frozen=True) -class ViewerContext: - """Caller identity and organization/project scope for the current unit of work.""" - - actor: Actor - organization_id: int | None = None - project_id: int | None = None - - def serialize(self) -> dict[str, Any]: - result: dict[str, Any] = {"version": 2, "actor": self.actor.serialize()} - if self.organization_id is not None: - result["organization_id"] = self.organization_id - if self.project_id is not None: - result["project_id"] = self.project_id - return result - - @classmethod - def deserialize(cls, data: dict[str, Any]) -> ViewerContext: - if not isinstance(data, dict): - raise ValueError("Viewer context must be an object") - if type(data.get("version")) is not int or data["version"] != 2: - raise ValueError("Expected a V2 viewer context") - if {"user_id", "actor_type", "superuser", "token"}.intersection(data): - raise ValueError("V2 contexts cannot carry V1 identity or credentials") - return cls( - actor=Actor.deserialize(data.get("actor")), - organization_id=data.get("organization_id"), - project_id=data.get("project_id"), - ) - - -@contextlib.contextmanager -def viewer_context_scope(ctx: ViewerContext) -> Generator[None]: - """Enter a viewer context for the duration of a unit of work. - - Always use this instead of raw ``_viewer_context_var.set()`` — - it guarantees cleanup via ``reset(token)`` even on exceptions. - """ - tok = _viewer_context_var.set(ctx) - try: - yield - finally: - _viewer_context_var.reset(tok) - - -def get_viewer_context() -> ViewerContext | None: - """Return the current ``ViewerContext``, or ``None`` if not set.""" - return _viewer_context_var.get() - - def get_acting_user_id(ctx: ViewerContext | None) -> int | None: """Return the human user *ctx* acts as, or ``None``. @@ -271,19 +186,6 @@ def set_viewer_context_project(project_id: int) -> None: # JWT encoding / decoding for cross-service propagation # --------------------------------------------------------------------------- -_JWT_STANDARD_CLAIMS = frozenset({"iat", "exp", "iss", "aud", "nbf", "jti", "sub"}) -# JWT header field identifying which key was used for signing (RFC 7515 §4.1.4). -_JWT_KEY_ID_HEADER = "kid" - - -def _key_id(key: str) -> str: - """Short stable identifier for a key (first 8 hex chars of SHA-256). - - Embedded as ``kid`` in the JWT header so the receiver can look up the - correct verification key without trying every key it knows about. - """ - return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8] - def _get_signing_key(key: str | None = None) -> str: """Return the key to use for JWT signing. @@ -312,7 +214,7 @@ def _get_verification_keys() -> dict[str, str]: seer_secret = getattr(settings, "SEER_API_SHARED_SECRET", "") if seer_secret: - keys[_key_id(seer_secret)] = seer_secret + keys[key_id(seer_secret)] = seer_secret return keys @@ -324,34 +226,13 @@ def encode_viewer_context( ttl: int | None = None, ) -> str: """Encode a :class:`ViewerContext` as a signed HS256 JWT.""" - secret = _get_signing_key(key) - if ttl is None: ttl = getattr(settings, "VIEWER_CONTEXT_JWT_TTL", 900) - - now = time.time() - expires_at = now + ttl - superuser = viewer_context.actor.superuser - if superuser is not None: - expires_at = min( - expires_at, - now + SUPERUSER_ACCESS_TTL.total_seconds(), - superuser.expires_at, - ) - payload: dict[str, Any] = { - **viewer_context.serialize(), - "iat": now, - "exp": expires_at, - "iss": "sentry", - } if viewer_context.organization_id is not None and _organization_is_early_adopter( viewer_context.organization_id ): - payload["organization_is_early_adopter"] = True - - return pyjwt.encode( - payload, secret, algorithm="HS256", headers={_JWT_KEY_ID_HEADER: _key_id(secret)} - ) + viewer_context = dataclasses.replace(viewer_context, organization_is_early_adopter=True) + return encode_jwt(viewer_context, key=_get_signing_key(key), ttl=ttl) def _organization_is_early_adopter(organization_id: int) -> bool: @@ -381,31 +262,13 @@ def decode_viewer_context( ) -> ViewerContext: """Decode and verify an HS256 JWT into a :class:`ViewerContext`. - When *key* is provided it is used directly. Otherwise all keys - from ``_get_verification_keys()`` are tried, kid-matched key first. + When *key* is provided it is used directly. Otherwise the key matching the + token's ``kid`` among ``_get_verification_keys()`` is used. """ - if key is not None: - secret = key - else: - keys_by_kid = _get_verification_keys() - if not keys_by_kid: - raise ValueError("No verification keys available.") - - kid = pyjwt.get_unverified_header(token).get(_JWT_KEY_ID_HEADER) - secret = keys_by_kid.get(kid, "") if kid else "" - if not secret: - raise pyjwt.exceptions.InvalidKeyError(f"No verification key matches kid={kid!r}") - - claims = pyjwt.decode( - token, - secret, - algorithms=["HS256"], - options={"require": ["iat", "exp", "iss"]}, - issuer="sentry", - leeway=leeway, - ) - vc_data = {ck: cv for ck, cv in claims.items() if ck not in _JWT_STANDARD_CLAIMS} - return ViewerContext.deserialize(vc_data) + keys = [key] if key is not None else list(_get_verification_keys().values()) + if not keys: + raise ValueError("No verification keys available.") + return decode_jwt(token, keys=keys, leeway=leeway) def viewer_context_from_header( @@ -423,13 +286,4 @@ def viewer_context_from_header( def is_jwt_viewer_context(header_value: str) -> bool: - """Check whether the header value is a JWT by attempting to read its header. - - Uses PyJWT's own parser — raises ``DecodeError`` on anything that - isn't a valid JWT structure (raw JSON, empty string, etc.). - """ - try: - pyjwt.get_unverified_header(header_value) - return True - except pyjwt.exceptions.DecodeError: - return False + return is_jwt(header_value) diff --git a/tests/sentry/seer/endpoints/test_organization_agent_token.py b/tests/sentry/seer/endpoints/test_organization_agent_token.py index bd3a75972183..1feeb80b8098 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_token.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -48,11 +48,11 @@ from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.silo import assume_test_silo_mode from sentry.testutils.skips import requires_snuba -from sentry.types.superuser import SuperuserAccess from sentry.utils import json from sentry.viewer_context import ( Actor, ActorType, + SuperuserAccess, ViewerContext, encode_viewer_context, get_viewer_context, @@ -139,7 +139,7 @@ def test_chat_to_mint_to_read_without_membership(self): assert issue_response.data["id"] == str(group.id) claims = agent_token.decode_agent_token(minted.data["token"]) assert set(claims["scopes"]) <= agent_token.readonly_scopes() - assert claims["superuser"] == grant.dict() + assert claims["superuser"] == {"expires_at": grant.expires_at} assert claims["exp"] <= grant.expires_at def test_identity_alone_does_not_delegate_superuser(self): @@ -196,7 +196,7 @@ def test_elevated_member_keeps_approved_agent_writes(self): def test_invalid_expiry_cannot_mint(self): self._chat_authorization() with self.feature(FLAG): - assert self._mint(SuperuserAccess.construct(expires_at=True)).status_code == 401 + assert self._mint(SuperuserAccess(expires_at=True)).status_code == 401 def test_expired_elevation_cannot_mint_or_be_used(self): grant = self._chat_authorization() diff --git a/tests/sentry/seer/endpoints/test_superuser_viewer_context.py b/tests/sentry/seer/endpoints/test_superuser_viewer_context.py index 5e2cba668c2b..31d1a977839e 100644 --- a/tests/sentry/seer/endpoints/test_superuser_viewer_context.py +++ b/tests/sentry/seer/endpoints/test_superuser_viewer_context.py @@ -9,10 +9,10 @@ from sentry.seer.signed_seer_api import _resolve_viewer_context from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.datetime import freeze_time -from sentry.types.superuser import SuperuserAccess from sentry.viewer_context import ( Actor, ActorType, + SuperuserAccess, ViewerContext, encode_viewer_context, ) @@ -108,7 +108,7 @@ def _assert_read_only_elevation(self, permissions, browser_write_status) -> None assert resolved.actor.superuser is not None assert type(resolved.actor.superuser.expires_at) is int assert 0 < resolved.actor.superuser.expires_at - timezone.now().timestamp() <= 300 - assert resolved.actor.superuser.dict() == { + assert resolved.actor.serialize()["superuser"] == { "expires_at": resolved.actor.superuser.expires_at } diff --git a/tests/sentry/seer/test_signed_seer_api.py b/tests/sentry/seer/test_signed_seer_api.py index c7cb3cde9f67..bc663e930b8d 100644 --- a/tests/sentry/seer/test_signed_seer_api.py +++ b/tests/sentry/seer/test_signed_seer_api.py @@ -11,10 +11,10 @@ make_delete_grouping_records_by_project_request, make_signed_seer_api_request, ) -from sentry.types.superuser import SuperuserAccess from sentry.viewer_context import ( Actor, ActorType, + SuperuserAccess, ViewerContext, viewer_context_scope, ) diff --git a/tests/sentry/test_viewer_context.py b/tests/sentry/test_viewer_context.py index 165e41c565ad..3ea5c5098a73 100644 --- a/tests/sentry/test_viewer_context.py +++ b/tests/sentry/test_viewer_context.py @@ -7,10 +7,10 @@ import pytest -from sentry.types.superuser import SuperuserAccess from sentry.viewer_context import ( Actor, ActorType, + SuperuserAccess, ViewerContext, get_acting_user_id, get_viewer_context, diff --git a/tests/sentry/test_viewer_context_jwt.py b/tests/sentry/test_viewer_context_jwt.py index b778d43f779b..72604147a568 100644 --- a/tests/sentry/test_viewer_context_jwt.py +++ b/tests/sentry/test_viewer_context_jwt.py @@ -7,16 +7,17 @@ import pytest from django.test import override_settings from django.utils import timezone +from sentry_viewercontext import key_id from sentry.silo.base import SiloMode from sentry.testutils.cases import TestCase from sentry.testutils.helpers.datetime import freeze_time from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode -from sentry.types.superuser import SuperuserAccess from sentry.viewer_context import ( Actor, ActorType, + SuperuserAccess, ViewerContext, decode_viewer_context, encode_viewer_context, @@ -196,13 +197,12 @@ def test_expired_token_rejected(self): "exp": time.time() - 60, "iss": "sentry", } - from sentry.viewer_context import _key_id expired_token = pyjwt.encode( payload, "test-secret-key", algorithm="HS256", - headers={"kid": _key_id("test-secret-key")}, + headers={"kid": key_id("test-secret-key")}, ) with pytest.raises(pyjwt.exceptions.ExpiredSignatureError): @@ -212,7 +212,7 @@ def test_wrong_key_rejected(self): vc = ViewerContext(actor=Actor(type=ActorType.UNKNOWN), organization_id=1) token = encode_viewer_context(vc, key="correct-key") - with pytest.raises(pyjwt.exceptions.InvalidSignatureError): + with pytest.raises(pyjwt.exceptions.InvalidTokenError, match="No verification key"): decode_viewer_context(token, key="wrong-key") def test_leeway_accepts_nearly_expired(self): @@ -224,7 +224,9 @@ def test_leeway_accepts_nearly_expired(self): "exp": time.time() - 3, # expired 3 seconds ago "iss": "sentry", } - token = pyjwt.encode(payload, "test-key", algorithm="HS256") + token = pyjwt.encode( + payload, "test-key", algorithm="HS256", headers={"kid": key_id("test-key")} + ) # Should succeed with default leeway of 5 seconds result = decode_viewer_context(token, key="test-key") diff --git a/tests/sentry/types/test_superuser.py b/tests/sentry/types/test_superuser.py new file mode 100644 index 000000000000..36bd70a6ab60 --- /dev/null +++ b/tests/sentry/types/test_superuser.py @@ -0,0 +1,17 @@ +from sentry.types.superuser import ( + SuperuserAccess, + from_viewer_context_superuser, + to_viewer_context_superuser, +) +from sentry.viewer_context import SuperuserAccess as ViewerContextSuperuserAccess + + +def test_viewer_context_superuser_roundtrip() -> None: + converted = to_viewer_context_superuser(SuperuserAccess(expires_at=123)) + assert converted == ViewerContextSuperuserAccess(expires_at=123) + assert from_viewer_context_superuser(converted) == SuperuserAccess(expires_at=123) + + +def test_viewer_context_superuser_none() -> None: + assert to_viewer_context_superuser(None) is None + assert from_viewer_context_superuser(None) is None diff --git a/uv.lock b/uv.lock index 8b6f74589027..ce7963397331 100644 --- a/uv.lock +++ b/uv.lock @@ -2269,6 +2269,7 @@ dependencies = [ { name = "sentry-scm" }, { name = "sentry-sdk", extra = ["http2"] }, { name = "sentry-usage-accountant" }, + { name = "sentry-viewercontext" }, { name = "simplejson" }, { name = "slack-sdk" }, { name = "snuba-sdk" }, @@ -2441,6 +2442,7 @@ requires-dist = [ { name = "sentry-scm", specifier = ">=1.9.0" }, { name = "sentry-sdk", extras = ["http2"], specifier = ">=2.72.0" }, { name = "sentry-usage-accountant", specifier = ">=0.0.10" }, + { name = "sentry-viewercontext", specifier = ">=0.1.0" }, { name = "simplejson", specifier = ">=3.17.6" }, { name = "slack-sdk", specifier = ">=3.40.1" }, { name = "snuba-sdk", specifier = ">=3.0.43" }, @@ -2690,6 +2692,17 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/sentry_usage_accountant-0.0.10-py3-none-any.whl", hash = "sha256:8317a2117b694c26b429150134287dcdc03c9e5b0711888ddd87bf75147b0cca" }, ] +[[package]] +name = "sentry-viewercontext" +version = "0.1.0" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +dependencies = [ + { name = "pyjwt" }, +] +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_viewercontext-0.1.0-py3-none-any.whl", hash = "sha256:388161ccf93af22ed0c3bd6e48ac4875b05d096e6839edd68befe47ec8b15132" }, +] + [[package]] name = "setproctitle" version = "1.3.7"