123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220 |
- 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)
|