import math import random from dataclasses import dataclass from typing import Literal from uuid import UUID from django.conf import settings from django.core.cache import cache from django.db import connection from django.http import HttpRequest from ninja.errors import AuthenticationError, HttpError, ValidationError from apps.organizations_ext.tasks import check_organization_throttle from glitchtip.api.exceptions import ThrottleException from sentry.utils.auth import parse_auth_header from .constants import EVENT_BLOCK_CACHE_KEY @dataclass class OrganizationInfo: id: int is_accepting_events: bool event_throttle_rate: int scrub_ip_addresses: bool @dataclass class ProjectAuthInfo: id: int scrub_ip_addresses: bool event_throttle_rate: int organization_id: int organization: OrganizationInfo @property def should_scrub_ip_addresses(self): """Organization overrides project setting""" return self.scrub_ip_addresses or self.organization.scrub_ip_addresses class EventAuthHttpRequest(HttpRequest): """Django HttpRequest that is known to be authenticated by a project DSN""" auth: ProjectAuthInfo def auth_from_request(request: HttpRequest): """ Get DSN (sentry_key) from request header Accept both sentry or glitchtip prefix Do not read request body when possible. This may result in uncompression which is slow. """ for k in request.GET.keys(): if k in ["sentry_key", "glitchtip_key"]: return request.GET[k] if auth_header := request.META.get( "HTTP_X_SENTRY_AUTH", request.META.get("HTTP_AUTHORIZATION") ): result = parse_auth_header(auth_header) return result.get("sentry_key", result.get("glitchtip_key")) raise AuthenticationError("Unable to find authentication information") # One letter codes to save cache memory and map to various event rejection type exceptions REJECTION_MAP: dict[Literal["v", "t"], Exception] = { "v": AuthenticationError([{"message": "Invalid DSN"}]), "t": ThrottleException(), } REJECTION_WAIT = 30 def serialize_throttle(org_throttle: int, project_throttle: int) -> str: """ Format example "t:30:0" means throttle with 30% org throttle and 0% (disabled) project throttle """ return f"t:{org_throttle}:{project_throttle}" def deserialize_throttle(input: str) -> None | tuple[int, int]: """Return (org_throttle, project_throttle) as integer %""" if input == "t": return 0, 0 if input.startswith("t:"): parts = input.split(":", 2) if len(parts) == 3: return int(parts[1]), int(parts[2]) return None def is_accepting_events(throttle_rate: int) -> bool: """Consider throttle to determine if event are being accepted""" if throttle_rate == 0: return True return random.randint(0, 100) > throttle_rate def calculate_retry_after(throttle: int): """Calculates Retry-After using a power function.""" return math.ceil(0.02 * throttle**2.3) def get_project(request: HttpRequest) -> ProjectAuthInfo | None: """ Return the valid and accepting events project based on a request. Throttle unwanted requests using cache to mitigate repeat attempts """ if not request.resolver_match: raise ValidationError([{"message": "Invalid project ID"}]) project_id: int = request.resolver_match.captured_kwargs.get("project_id") try: sentry_key = UUID(auth_from_request(request)) except ValueError as err: raise ValidationError( [{"message": "dsn key badly formed hexadecimal UUID string"}] ) from err # block cache check should be right before database call block_cache_key = EVENT_BLOCK_CACHE_KEY + str(project_id) if block_value := cache.get(block_cache_key): if block_value.startswith("t"): if throttle := deserialize_throttle(block_value): org_throttle, project_throttle = throttle if not is_accepting_events(org_throttle) or not is_accepting_events( project_throttle ): raise ThrottleException(calculate_retry_after(max(throttle))) else: # Repeat the original message until cache expires raise REJECTION_MAP[block_value] # May someday be async https://code.djangoproject.com/ticket/35629 with connection.cursor() as cursor: cursor.callproc( "get_project_auth_info", [ project_id, sentry_key, ], ) row = cursor.fetchone() if not row: cache.set(block_cache_key, "v", REJECTION_WAIT) raise REJECTION_MAP["v"] project = ProjectAuthInfo( id=row[0], scrub_ip_addresses=row[1], event_throttle_rate=row[2], organization_id=row[3], organization=OrganizationInfo( id=row[0], is_accepting_events=row[4], event_throttle_rate=row[5], scrub_ip_addresses=row[6], ), ) if ( not project.organization.is_accepting_events or project.organization.event_throttle_rate == 100 or project.event_throttle_rate == 100 ): cache.set(block_cache_key, "t", REJECTION_WAIT) raise ThrottleException(600) if project.organization.event_throttle_rate or project.event_throttle_rate: cache.set( block_cache_key, serialize_throttle( project.organization.event_throttle_rate, project.event_throttle_rate, ), REJECTION_WAIT, ) if not is_accepting_events( project.organization.event_throttle_rate ) or not is_accepting_events(project.event_throttle_rate): raise ThrottleException( calculate_retry_after( max( project.organization.event_throttle_rate, project.event_throttle_rate, ) ) ) # Check throttle needs every 1 out of X requests if ( settings.BILLING_ENABLED and random.random() < 1 / settings.GLITCHTIP_THROTTLE_CHECK_INTERVAL ): check_organization_throttle.delay(project.organization_id) return project # Check throttle needs every 1 out of X requests if ( settings.BILLING_ENABLED and random.random() < 1 / settings.GLITCHTIP_THROTTLE_CHECK_INTERVAL ): check_organization_throttle.delay(project.organization_id) return project def event_auth(request: HttpRequest) -> ProjectAuthInfo | None: """ Event Ingest authentication means validating the DSN (sentry_key). Throttling is also handled here. It does not include user authentication. """ if settings.MAINTENANCE_EVENT_FREEZE: raise HttpError( 503, "Events are not currently being accepted due to maintenance." ) return get_project(request)