From bc74b72847a725274420c5e1642cf985d08fae08 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:18:39 -0700 Subject: [PATCH] feat(integrations): Add signed ViewerContext header propagation Add infrastructure for propagating ViewerContext across HTTP service boundaries using signed headers. Sending side: inject_viewer_context_headers() serializes the active ViewerContext into X-Viewer-Context with an HMAC-SHA256 signature and issuer identifier. Each internal service uses its own shared secret. Receiving side: ViewerContextMiddleware checks for signed headers before falling back to request-based auth. Maps the issuer to a known shared secret, verifies the signature, and only then trusts the payload. Unknown issuers or invalid signatures fall through to normal auth. Includes ViewerContext.deserialize() classmethod for reconstructing from JSON payloads. Co-Authored-By: Claude Opus 4.6 --- src/sentry/middleware/viewer_context.py | 130 +++++++++++- src/sentry/viewer_context.py | 91 ++++++++- .../sentry/middleware/test_viewer_context.py | 186 +++++++++++++++++- 3 files changed, 395 insertions(+), 12 deletions(-) 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