diff --git a/src/sentry/middleware/viewer_context.py b/src/sentry/middleware/viewer_context.py index 144fdd7a594042..758638282c5723 100644 --- a/src/sentry/middleware/viewer_context.py +++ b/src/sentry/middleware/viewer_context.py @@ -1,5 +1,8 @@ from __future__ import annotations +import hashlib +import hmac +import logging from collections.abc import Callable from django.conf import settings @@ -7,19 +10,123 @@ from django.http.response import HttpResponseBase from sentry import options -from sentry.viewer_context import ActorType, ViewerContext, viewer_context_scope +from sentry.viewer_context import ( + ActorType, + ViewerContext, + viewer_context_scope, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Issuer → shared-secret mapping +# +# Each internal service that propagates ViewerContext over HTTP identifies +# itself with an issuer string in the ``X-Viewer-Context-Issuer`` header. +# The middleware maps that string to a list of shared secrets (list to +# support key rotation — the first valid match wins). +# +# To add a new service: +# 1. Define a shared secret setting in conf/server.py +# 2. Add the (issuer, setting) pair to _ISSUER_SECRETS below +# 3. Have the service call inject_viewer_context_headers() with the +# matching issuer string and secret +# --------------------------------------------------------------------------- + + +def _get_issuer_secrets() -> dict[str, list[str]]: + """Build the issuer → secrets mapping from current settings. + + Called once per request (cheap dict construction) so that hot-reloaded + settings are picked up without restart. + """ + mapping: dict[str, list[str]] = {} + + if settings.RPC_SHARED_SECRET: + mapping["sentry"] = settings.RPC_SHARED_SECRET + + if settings.SEER_RPC_SHARED_SECRET: + mapping["seer"] = settings.SEER_RPC_SHARED_SECRET + + if settings.SCM_RPC_SHARED_SECRET: + mapping["scm"] = settings.SCM_RPC_SHARED_SECRET + + if settings.LAUNCHPAD_RPC_SHARED_SECRET: + mapping["launchpad"] = settings.LAUNCHPAD_RPC_SHARED_SECRET + + return mapping + + +def _verify_viewer_context_header(request: HttpRequest) -> ViewerContext | None: + """Attempt to extract a signed ViewerContext from request headers. + + Returns a ``ViewerContext`` if all three headers are present, the issuer + is recognized, and the HMAC signature is valid. Returns ``None`` + otherwise (missing headers, unknown issuer, bad signature). + + This is the receiving side of ``inject_viewer_context_headers()``. + """ + # Django normalizes headers: X-Foo-Bar → HTTP_X_FOO_BAR + raw_context = request.META.get("HTTP_X_VIEWER_CONTEXT") + signature = request.META.get("HTTP_X_VIEWER_CONTEXT_SIGNATURE") + issuer = request.META.get("HTTP_X_VIEWER_CONTEXT_ISSUER") + + if not raw_context or not signature or not issuer: + return None + + secrets = _get_issuer_secrets().get(issuer) + if not secrets: + logger.warning( + "viewer_context.unknown_issuer", + extra={"issuer": issuer}, + ) + return None + + context_bytes = raw_context.encode("utf-8") + + # Try each secret (supports key rotation) + verified = False + for secret in secrets: + expected = hmac.new( + secret.encode("utf-8"), + context_bytes, + hashlib.sha256, + ).hexdigest() + if hmac.compare_digest(expected, signature): + verified = True + break + + if not verified: + logger.warning( + "viewer_context.invalid_signature", + extra={"issuer": issuer}, + ) + return None + + return ViewerContext.deserialize(raw_context) def ViewerContextMiddleware( get_response: Callable[[HttpRequest], HttpResponseBase], ) -> Callable[[HttpRequest], HttpResponseBase]: - """Set :class:`ViewerContext` for every request after authentication. + """Set :class:`ViewerContext` for every request. + + Two sources, checked in order: - Must be placed **after** ``AuthenticationMiddleware`` so that - ``request.user`` and ``request.auth`` are already populated. + 1. **Signed header** — if the request carries ``X-Viewer-Context``, + ``X-Viewer-Context-Signature``, and ``X-Viewer-Context-Issuer`` + headers, the middleware verifies the HMAC signature against the + shared secret for that issuer. On success, the header payload + becomes the ViewerContext for the request. This path is used by + internal service-to-service calls (Seer, cross-silo RPC, etc.). + + 2. **Request auth** — falls back to deriving ViewerContext from + ``request.user`` and ``request.auth`` (populated by Django's + ``AuthenticationMiddleware``). This is the normal path for + browser/API-token requests. Gated by the ``viewer-context.enabled`` option (FLAG_NOSTORE). - Set via deploy config; requires restart to change. + Must be placed **after** ``AuthenticationMiddleware``. """ enabled = options.get("viewer-context.enabled") @@ -27,13 +134,18 @@ def ViewerContextMiddleware_impl(request: HttpRequest) -> HttpResponseBase: if not enabled: return get_response(request) - # This avoids touching user session, which means we avoid - # setting `Vary: Cookie` as a response header which will - # break HTTP caching entirely. + # Skip static assets — avoids touching user session and setting + # Vary: Cookie which breaks HTTP caching. if request.path_info.startswith(settings.ANONYMOUS_STATIC_PREFIXES): return get_response(request) - ctx = _viewer_context_from_request(request) + # Source 1: signed header from internal service + ctx = _verify_viewer_context_header(request) + + # Source 2: derive from request auth + if ctx is None: + ctx = _viewer_context_from_request(request) + with viewer_context_scope(ctx): return get_response(request) diff --git a/src/sentry/viewer_context.py b/src/sentry/viewer_context.py index b3c28788efc06d..005a92dccc1e15 100644 --- a/src/sentry/viewer_context.py +++ b/src/sentry/viewer_context.py @@ -4,12 +4,19 @@ import contextvars import dataclasses import enum -from collections.abc import Generator +import hashlib +import hmac +import logging +from collections.abc import Generator, MutableMapping from typing import TYPE_CHECKING, Any +import orjson + if TYPE_CHECKING: from sentry.auth.services.auth import AuthenticatedToken +logger = logging.getLogger(__name__) + _viewer_context_var: contextvars.ContextVar[ViewerContext | None] = contextvars.ContextVar( "viewer_context", default=None ) @@ -62,6 +69,27 @@ def serialize(self) -> dict[str, Any]: result["token"] = {"kind": self.token.kind, "scopes": list(self.token.get_scopes())} return result + @classmethod + def deserialize(cls, data: str | dict[str, Any]) -> ViewerContext: + """Reconstruct from a JSON string or dict (inverse of :meth:`serialize`). + + Unrecognized ``actor_type`` values fall back to ``UNKNOWN``. + The ``token`` field is NOT restored (it is in-process only). + """ + if isinstance(data, str): + data = orjson.loads(data) + + try: + actor_type = ActorType(data.get("actor_type", "unknown")) + except ValueError: + actor_type = ActorType.UNKNOWN + + return cls( + organization_id=data.get("organization_id"), + user_id=data.get("user_id"), + actor_type=actor_type, + ) + @contextlib.contextmanager def viewer_context_scope(ctx: ViewerContext) -> Generator[None]: @@ -80,3 +108,64 @@ def viewer_context_scope(ctx: ViewerContext) -> Generator[None]: def get_viewer_context() -> ViewerContext | None: """Return the current ``ViewerContext``, or ``None`` if not set.""" return _viewer_context_var.get() + + +# --------------------------------------------------------------------------- +# Cross-service header propagation +# +# When Sentry calls another service (or itself) over HTTP, the active +# ViewerContext must be serialized into request headers so the receiving +# side can restore it. +# +# Sending side: +# inject_viewer_context_headers(headers, secret, issuer) +# — reads the contextvar (or accepts an explicit ctx), serializes to +# JSON, HMAC-signs with *secret*, and sets three headers: +# X-Viewer-Context — JSON payload +# X-Viewer-Context-Signature — HMAC-SHA256 hex digest +# X-Viewer-Context-Issuer — identifier of the sending service +# +# Receiving side (ViewerContextMiddleware): +# Looks for the three headers. Maps the issuer to a known shared +# secret, verifies the signature, and — only on success — deserializes +# the payload and enters viewer_context_scope(). Requests without the +# headers, or with an invalid/unknown issuer, fall through to the +# normal request-based ViewerContext derivation. +# --------------------------------------------------------------------------- + +VIEWER_CONTEXT_HEADER = "X-Viewer-Context" +VIEWER_CONTEXT_SIGNATURE_HEADER = "X-Viewer-Context-Signature" +VIEWER_CONTEXT_ISSUER_HEADER = "X-Viewer-Context-Issuer" + + +def inject_viewer_context_headers( + headers: MutableMapping[str, str], + secret: str, + issuer: str, + ctx: ViewerContext | None = None, +) -> None: + """Serialize a ViewerContext into HTTP headers with an HMAC signature. + + If *ctx* is ``None``, falls back to the current contextvar value. + Sets ``X-Viewer-Context``, ``X-Viewer-Context-Signature``, and + ``X-Viewer-Context-Issuer`` on *headers*. + + No-op when there is no ViewerContext or ``secret`` is empty. + """ + if ctx is None: + ctx = get_viewer_context() + if ctx is None or not secret: + return + + try: + context_bytes = orjson.dumps(ctx.serialize()) + signature = hmac.new( + secret.encode("utf-8"), + context_bytes, + hashlib.sha256, + ).hexdigest() + headers[VIEWER_CONTEXT_HEADER] = context_bytes.decode("utf-8") + headers[VIEWER_CONTEXT_SIGNATURE_HEADER] = signature + headers[VIEWER_CONTEXT_ISSUER_HEADER] = issuer + except Exception: + logger.exception("Failed to serialize viewer context into headers.") diff --git a/tests/sentry/middleware/test_viewer_context.py b/tests/sentry/middleware/test_viewer_context.py index d73343e5bbd26c..c37076d75339f4 100644 --- a/tests/sentry/middleware/test_viewer_context.py +++ b/tests/sentry/middleware/test_viewer_context.py @@ -1,15 +1,18 @@ from __future__ import annotations +import hashlib +import hmac from unittest.mock import MagicMock +import orjson from django.contrib.auth.models import AnonymousUser -from django.test import RequestFactory +from django.test import RequestFactory, override_settings from sentry.auth.services.auth import AuthenticatedToken from sentry.middleware.viewer_context import ViewerContextMiddleware, _viewer_context_from_request from sentry.testutils.cases import TestCase from sentry.testutils.helpers.options import override_options -from sentry.viewer_context import ActorType, get_viewer_context +from sentry.viewer_context import ActorType, ViewerContext, get_viewer_context class ViewerContextFromRequestTest(TestCase): @@ -194,3 +197,182 @@ def get_response(request): assert ctx.user_id is None assert ctx.organization_id is None assert ctx.token is None + + +TEST_SECRET = "test-shared-secret" + + +def _sign(payload_bytes: bytes, secret: str = TEST_SECRET) -> str: + return hmac.new(secret.encode("utf-8"), payload_bytes, hashlib.sha256).hexdigest() + + +def _make_signed_request( + factory: RequestFactory, + ctx: ViewerContext, + issuer: str = "seer", + secret: str = TEST_SECRET, + *, + tamper_signature: str | None = None, +) -> object: + """Build a request with signed ViewerContext headers.""" + payload = orjson.dumps(ctx.serialize()) + sig = tamper_signature if tamper_signature is not None else _sign(payload, secret) + request = factory.get( + "/", + HTTP_X_VIEWER_CONTEXT=payload.decode("utf-8"), + HTTP_X_VIEWER_CONTEXT_SIGNATURE=sig, + HTTP_X_VIEWER_CONTEXT_ISSUER=issuer, + ) + request.user = AnonymousUser() + request.auth = None + return request + + +@override_settings(SEER_RPC_SHARED_SECRET=[TEST_SECRET]) +class ViewerContextSignedHeaderTest(TestCase): + def setUp(self): + super().setUp() + self.factory = RequestFactory() + + @override_options({"viewer-context.enabled": True}) + def test_valid_signed_header_sets_context(self): + captured: list = [] + + def get_response(request): + captured.append(get_viewer_context()) + return MagicMock(status_code=200) + + middleware = ViewerContextMiddleware(get_response) + ctx = ViewerContext(organization_id=42, actor_type=ActorType.INTEGRATION) + request = _make_signed_request(self.factory, ctx) + + middleware(request) + + assert len(captured) == 1 + result = captured[0] + assert result is not None + assert result.organization_id == 42 + assert result.actor_type == ActorType.INTEGRATION + assert result.user_id is None + assert result.token is None + + @override_options({"viewer-context.enabled": True}) + def test_invalid_signature_falls_back_to_request_auth(self): + captured: list = [] + + def get_response(request): + captured.append(get_viewer_context()) + return MagicMock(status_code=200) + + middleware = ViewerContextMiddleware(get_response) + ctx = ViewerContext(organization_id=42, actor_type=ActorType.INTEGRATION) + request = _make_signed_request(self.factory, ctx, tamper_signature="bad-sig") + + middleware(request) + + assert len(captured) == 1 + result = captured[0] + # Falls back to request auth — anonymous user, no org + assert result.organization_id is None + assert result.actor_type == ActorType.USER + + @override_options({"viewer-context.enabled": True}) + def test_unknown_issuer_falls_back_to_request_auth(self): + captured: list = [] + + def get_response(request): + captured.append(get_viewer_context()) + return MagicMock(status_code=200) + + middleware = ViewerContextMiddleware(get_response) + ctx = ViewerContext(organization_id=42, actor_type=ActorType.INTEGRATION) + request = _make_signed_request(self.factory, ctx, issuer="unknown-service") + + middleware(request) + + assert len(captured) == 1 + result = captured[0] + assert result.organization_id is None + assert result.actor_type == ActorType.USER + + @override_options({"viewer-context.enabled": True}) + def test_missing_headers_falls_back_to_request_auth(self): + captured: list = [] + + def get_response(request): + captured.append(get_viewer_context()) + return MagicMock(status_code=200) + + middleware = ViewerContextMiddleware(get_response) + request = self.factory.get("/") + request.user = self.user + request.auth = None + + middleware(request) + + assert len(captured) == 1 + result = captured[0] + assert result.user_id == self.user.id + assert result.actor_type == ActorType.USER + + @override_options({"viewer-context.enabled": True}) + def test_signed_header_takes_priority_over_request_auth(self): + """Even if request has an authenticated user, the signed header wins.""" + captured: list = [] + + def get_response(request): + captured.append(get_viewer_context()) + return MagicMock(status_code=200) + + middleware = ViewerContextMiddleware(get_response) + ctx = ViewerContext(organization_id=99, user_id=7, actor_type=ActorType.SYSTEM) + request = _make_signed_request(self.factory, ctx) + # Also set a real user on the request + request.user = self.user + request.auth = None + + middleware(request) + + assert len(captured) == 1 + result = captured[0] + # Header wins + assert result.organization_id == 99 + assert result.user_id == 7 + assert result.actor_type == ActorType.SYSTEM + + @override_options({"viewer-context.enabled": True}) + @override_settings(SEER_RPC_SHARED_SECRET=[TEST_SECRET, "rotated-secret"]) + def test_key_rotation_accepts_old_secret(self): + captured: list = [] + + def get_response(request): + captured.append(get_viewer_context()) + return MagicMock(status_code=200) + + middleware = ViewerContextMiddleware(get_response) + ctx = ViewerContext(organization_id=42, actor_type=ActorType.INTEGRATION) + # Sign with the rotated (second) secret + request = _make_signed_request(self.factory, ctx, secret="rotated-secret") + + middleware(request) + + assert len(captured) == 1 + result = captured[0] + assert result.organization_id == 42 + + @override_options({"viewer-context.enabled": False}) + def test_disabled_option_skips_header_check(self): + captured: list = [] + + def get_response(request): + captured.append(get_viewer_context()) + return MagicMock(status_code=200) + + middleware = ViewerContextMiddleware(get_response) + ctx = ViewerContext(organization_id=42, actor_type=ActorType.INTEGRATION) + request = _make_signed_request(self.factory, ctx) + + middleware(request) + + assert len(captured) == 1 + assert captured[0] is None