Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 121 additions & 9 deletions src/sentry/middleware/viewer_context.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,151 @@
from __future__ import annotations

import hashlib
import hmac
import logging
from collections.abc import Callable

from django.conf import settings
from django.http.request import HttpRequest
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")

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)

Expand Down
91 changes: 90 additions & 1 deletion src/sentry/viewer_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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]:
Expand All @@ -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.")
Loading
Loading