authentication.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. import math
  2. import random
  3. from dataclasses import dataclass
  4. from typing import Literal
  5. from uuid import UUID
  6. from django.conf import settings
  7. from django.core.cache import cache
  8. from django.db import connection
  9. from django.http import HttpRequest
  10. from ninja.errors import AuthenticationError, HttpError, ValidationError
  11. from apps.organizations_ext.tasks import check_organization_throttle
  12. from glitchtip.api.exceptions import ThrottleException
  13. from sentry.utils.auth import parse_auth_header
  14. from .constants import EVENT_BLOCK_CACHE_KEY
  15. @dataclass
  16. class OrganizationInfo:
  17. id: int
  18. is_accepting_events: bool
  19. event_throttle_rate: int
  20. scrub_ip_addresses: bool
  21. @dataclass
  22. class ProjectAuthInfo:
  23. id: int
  24. scrub_ip_addresses: bool
  25. event_throttle_rate: int
  26. organization_id: int
  27. organization: OrganizationInfo
  28. @property
  29. def should_scrub_ip_addresses(self):
  30. """Organization overrides project setting"""
  31. return self.scrub_ip_addresses or self.organization.scrub_ip_addresses
  32. class EventAuthHttpRequest(HttpRequest):
  33. """Django HttpRequest that is known to be authenticated by a project DSN"""
  34. auth: ProjectAuthInfo
  35. def auth_from_request(request: HttpRequest):
  36. """
  37. Get DSN (sentry_key) from request header
  38. Accept both sentry or glitchtip prefix
  39. Do not read request body when possible. This may result in uncompression which is slow.
  40. """
  41. for k in request.GET.keys():
  42. if k in ["sentry_key", "glitchtip_key"]:
  43. return request.GET[k]
  44. if auth_header := request.META.get(
  45. "HTTP_X_SENTRY_AUTH", request.META.get("HTTP_AUTHORIZATION")
  46. ):
  47. result = parse_auth_header(auth_header)
  48. return result.get("sentry_key", result.get("glitchtip_key"))
  49. raise AuthenticationError("Unable to find authentication information")
  50. # One letter codes to save cache memory and map to various event rejection type exceptions
  51. REJECTION_MAP: dict[Literal["v", "t"], Exception] = {
  52. "v": AuthenticationError([{"message": "Invalid DSN"}]),
  53. "t": ThrottleException(),
  54. }
  55. REJECTION_WAIT = 30
  56. def serialize_throttle(org_throttle: int, project_throttle: int) -> str:
  57. """
  58. Format example "t:30:0" means throttle with 30% org throttle and 0% (disabled)
  59. project throttle
  60. """
  61. return f"t:{org_throttle}:{project_throttle}"
  62. def deserialize_throttle(input: str) -> None | tuple[int, int]:
  63. """Return (org_throttle, project_throttle) as integer %"""
  64. if input == "t":
  65. return 0, 0
  66. if input.startswith("t:"):
  67. parts = input.split(":", 2)
  68. if len(parts) == 3:
  69. return int(parts[1]), int(parts[2])
  70. return None
  71. def is_accepting_events(throttle_rate: int) -> bool:
  72. """Consider throttle to determine if event are being accepted"""
  73. if throttle_rate == 0:
  74. return True
  75. return random.randint(0, 100) > throttle_rate
  76. def calculate_retry_after(throttle: int):
  77. """Calculates Retry-After using a power function."""
  78. return math.ceil(0.02 * throttle**2.3)
  79. def get_project(request: HttpRequest) -> ProjectAuthInfo | None:
  80. """
  81. Return the valid and accepting events project based on a request.
  82. Throttle unwanted requests using cache to mitigate repeat attempts
  83. """
  84. if not request.resolver_match:
  85. raise ValidationError([{"message": "Invalid project ID"}])
  86. project_id: int = request.resolver_match.captured_kwargs.get("project_id")
  87. try:
  88. sentry_key = UUID(auth_from_request(request))
  89. except ValueError as err:
  90. raise ValidationError(
  91. [{"message": "dsn key badly formed hexadecimal UUID string"}]
  92. ) from err
  93. # block cache check should be right before database call
  94. block_cache_key = EVENT_BLOCK_CACHE_KEY + str(project_id)
  95. if block_value := cache.get(block_cache_key):
  96. if block_value.startswith("t"):
  97. if throttle := deserialize_throttle(block_value):
  98. org_throttle, project_throttle = throttle
  99. if not is_accepting_events(org_throttle) or not is_accepting_events(
  100. project_throttle
  101. ):
  102. raise ThrottleException(calculate_retry_after(max(throttle)))
  103. else:
  104. # Repeat the original message until cache expires
  105. raise REJECTION_MAP[block_value]
  106. # May someday be async https://code.djangoproject.com/ticket/35629
  107. with connection.cursor() as cursor:
  108. cursor.callproc(
  109. "get_project_auth_info",
  110. [
  111. project_id,
  112. sentry_key,
  113. ],
  114. )
  115. row = cursor.fetchone()
  116. if not row:
  117. cache.set(block_cache_key, "v", REJECTION_WAIT)
  118. raise REJECTION_MAP["v"]
  119. project = ProjectAuthInfo(
  120. id=row[0],
  121. scrub_ip_addresses=row[1],
  122. event_throttle_rate=row[2],
  123. organization_id=row[3],
  124. organization=OrganizationInfo(
  125. id=row[0],
  126. is_accepting_events=row[4],
  127. event_throttle_rate=row[5],
  128. scrub_ip_addresses=row[6],
  129. ),
  130. )
  131. if (
  132. not project.organization.is_accepting_events
  133. or project.organization.event_throttle_rate == 100
  134. or project.event_throttle_rate == 100
  135. ):
  136. cache.set(block_cache_key, "t", REJECTION_WAIT)
  137. raise ThrottleException(600)
  138. if project.organization.event_throttle_rate or project.event_throttle_rate:
  139. cache.set(
  140. block_cache_key,
  141. serialize_throttle(
  142. project.organization.event_throttle_rate,
  143. project.event_throttle_rate,
  144. ),
  145. REJECTION_WAIT,
  146. )
  147. if not is_accepting_events(
  148. project.organization.event_throttle_rate
  149. ) or not is_accepting_events(project.event_throttle_rate):
  150. raise ThrottleException(
  151. calculate_retry_after(
  152. max(
  153. project.organization.event_throttle_rate,
  154. project.event_throttle_rate,
  155. )
  156. )
  157. )
  158. # Check throttle needs every 1 out of X requests
  159. if (
  160. settings.BILLING_ENABLED
  161. and random.random() < 1 / settings.GLITCHTIP_THROTTLE_CHECK_INTERVAL
  162. ):
  163. check_organization_throttle.delay(project.organization_id)
  164. return project
  165. # Check throttle needs every 1 out of X requests
  166. if (
  167. settings.BILLING_ENABLED
  168. and random.random() < 1 / settings.GLITCHTIP_THROTTLE_CHECK_INTERVAL
  169. ):
  170. check_organization_throttle.delay(project.organization_id)
  171. return project
  172. def event_auth(request: HttpRequest) -> ProjectAuthInfo | None:
  173. """
  174. Event Ingest authentication means validating the DSN (sentry_key).
  175. Throttling is also handled here.
  176. It does not include user authentication.
  177. """
  178. if settings.MAINTENANCE_EVENT_FREEZE:
  179. raise HttpError(
  180. 503, "Events are not currently being accepted due to maintenance."
  181. )
  182. return get_project(request)