Coverage for app/backend/src/couchers/middleware/interceptors.py: 87%

290 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 00:57 +0000

1import logging 

2from collections.abc import Callable, Mapping 

3from dataclasses import dataclass, field 

4from datetime import datetime 

5from os import getpid 

6from threading import get_ident 

7from time import perf_counter_ns 

8from traceback import format_exception 

9from typing import Any, NoReturn, cast 

10from zoneinfo import ZoneInfo 

11 

12import grpc 

13import sentry_sdk 

14from google.protobuf.descriptor_pool import DescriptorPool 

15from google.protobuf.message import Message 

16from opentelemetry import trace 

17from sqlalchemy import Function, literal_column, select, update 

18from sqlalchemy.dialects.postgresql import insert as pg_insert 

19from sqlalchemy.orm import undefer 

20from sqlalchemy.sql import func 

21 

22from couchers.config import config 

23from couchers.constants import ( 

24 CALL_CANCELLED_ERROR_MESSAGE, 

25 COOKIES_AND_AUTH_HEADER_ERROR_MESSAGE, 

26 NONEXISTENT_API_CALL_ERROR_MESSAGE, 

27 PERMISSION_DENIED_ERROR_MESSAGE, 

28 RATE_LIMIT_ERROR_MESSAGE, 

29 UNAUTHORIZED_ERROR_MESSAGE, 

30 UNKNOWN_ERROR_MESSAGE, 

31) 

32from couchers.context import CouchersContext, make_interactive_context, make_media_context 

33from couchers.db import session_scope 

34from couchers.i18n import LocalizationContext 

35from couchers.metrics import ( 

36 observe_api_call, 

37 observe_in_servicer_duration_histogram, 

38 observe_in_servicer_perf_histograms, 

39 observe_in_servicer_pool_wait_histogram, 

40 observe_in_servicer_serde_histogram, 

41 observe_in_servicer_setup_errors_counter, 

42 observe_in_servicer_setup_histogram, 

43) 

44from couchers.middleware.descriptor_pool import get_descriptor_pool 

45from couchers.middleware.errors import CallRejectedError 

46from couchers.middleware.perf import PerfResult, read_perf, start_perf 

47from couchers.middleware.proto_annotations import find_auth_level 

48from couchers.middleware.ratelimit import should_rate_limit 

49from couchers.middleware.sanitize import sanitized_bytes 

50from couchers.models import APICall, ClientPlatform, User, UserActivity, UserSession 

51from couchers.proto import annotations_pb2 

52from couchers.proto.annotations_pb2 import AuthLevel 

53from couchers.utils import ( 

54 create_lang_cookie, 

55 create_session_cookies, 

56 generate_sofa_cookie, 

57 parse_api_key, 

58 parse_session_cookie, 

59 parse_sofa_cookie, 

60 parse_ui_lang_cookie, 

61 parse_user_id_cookie, 

62) 

63 

64logger = logging.getLogger(__name__) 

65 

66# the prometheus label shared by calls to methods with no servicer registered, whose name is whatever the caller sent 

67NONEXISTENT_METHOD_LABEL = "<nonexistent>" 

68 

69 

70@dataclass(frozen=True, slots=True, kw_only=True) 

71class UserAuthInfo: 

72 """Information about an authenticated user session.""" 

73 

74 user_id: int 

75 is_jailed: bool 

76 is_editor: bool 

77 is_superuser: bool 

78 token_expiry: datetime 

79 ui_language_preference: str | None 

80 timezone: str | None 

81 token: str = field(repr=False) 

82 is_api_key: bool 

83 

84 

85@dataclass(frozen=True, slots=True, kw_only=True) 

86class CouchersHeaders: 

87 # the user id cookie: client-supplied and unauthenticated, only good for spotting a desynced cookie 

88 user_id_str: str | None 

89 token: str | None = field(repr=False) 

90 sofa: str | None 

91 # which mechanism the token came in on, not whether it authenticated: a bad key still reads True here, while 

92 # the context's is_api_key is False whenever there's no session at all 

93 is_api_key: bool 

94 ip_address: str | None 

95 user_agent: str | None 

96 client_platform: ClientPlatform | None 

97 ui_lang: str | None 

98 

99 

100def _binned_now() -> Function[Any]: 

101 return func.date_bin( 

102 literal_column("interval '1 hour'"), 

103 func.now(), 

104 literal_column("'2000-01-01'::timestamptz"), 

105 ) 

106 

107 

108def _try_get_and_update_user_details( 

109 token: str | None, 

110 is_api_key: bool, 

111 ip_address: str | None, 

112 user_agent: str | None, 

113 sofa: str | None, 

114 client_platform: ClientPlatform | None, 

115) -> UserAuthInfo | None: 

116 """ 

117 Tries to get session and user info corresponding to this token. 

118 

119 Also updates the user's last active time, token last active time, and increments API call count. 

120 

121 Returns UserAuthInfo if a valid session is found, None otherwise. 

122 """ 

123 if not token: 

124 return None 

125 

126 with session_scope() as session: 

127 result = session.execute( 

128 select(User, UserSession, User.is_jailed) 

129 .select_from(UserSession) 

130 .join(User, User.id == UserSession.user_id) 

131 .where(User.is_visible) 

132 .where(UserSession.token == token) 

133 .where(UserSession.is_valid) 

134 .where(UserSession.is_api_key == is_api_key) 

135 # User.timezone is deferred and read below for every authenticated call, so load it here rather 

136 # than paying a second round trip for its ST_Contains against timezone_areas 

137 .options(undefer(User.timezone)) 

138 ).one_or_none() 

139 

140 if not result: 

141 return None 

142 

143 user, user_session, is_jailed = result._tuple() 

144 

145 # update user last active time if it's been a while; a non-matching UPDATE takes no row lock, so this 

146 # costs nothing on the calls that don't move it 

147 touch_user = ( 

148 update(User) 

149 .where(User.id == user.id) 

150 .where(User.last_active < func.now() - literal_column("interval '5 minutes'")) 

151 .values(last_active=func.now()) 

152 .cte("touch_user") 

153 ) 

154 

155 # let's update the token 

156 touch_session = ( 

157 update(UserSession) 

158 .where(UserSession.token == token) 

159 .values(last_seen=func.now(), api_calls=UserSession.api_calls + 1) 

160 .cte("touch_session") 

161 ) 

162 

163 # upsert so concurrent requests for the same activity tuple don't race to insert and violate the index 

164 insert_stmt = pg_insert(UserActivity).values( 

165 user_id=user.id, 

166 period=_binned_now(), 

167 ip_address=ip_address, 

168 user_agent=user_agent, 

169 sofa=sofa, 

170 client_platform=client_platform, 

171 api_calls=1, 

172 ) 

173 # one statement, so the sessions and user_activity row locks that every concurrent call from the same 

174 # session queues on are held for a single round trip. postgres leaves the order it applies the CTEs in 

175 # undefined, but every caller runs this same statement, so they all take those locks the same way round 

176 session.execute( 

177 insert_stmt.on_conflict_do_update( 

178 index_elements=[ 

179 UserActivity.user_id, 

180 UserActivity.period, 

181 UserActivity.ip_address, 

182 UserActivity.user_agent, 

183 UserActivity.sofa, 

184 ], 

185 set_={ 

186 "api_calls": UserActivity.api_calls + 1, 

187 "client_platform": func.coalesce( 

188 insert_stmt.excluded.client_platform, UserActivity.client_platform 

189 ), 

190 }, 

191 ).add_cte(touch_user, touch_session) 

192 ) 

193 

194 # build before committing to avoid expire_on_commit reloading these attributes 

195 auth_info = UserAuthInfo( 

196 user_id=user.id, 

197 is_jailed=is_jailed, 

198 is_editor=user.is_editor, 

199 is_superuser=user.is_superuser, 

200 token_expiry=user_session.expiry, 

201 ui_language_preference=user.ui_language_preference, 

202 timezone=user.timezone, 

203 token=token, 

204 is_api_key=is_api_key, 

205 ) 

206 

207 session.commit() 

208 

209 return auth_info 

210 

211 

212def abort_handler[T, R]( 

213 message: str, 

214 status_code: grpc.StatusCode, 

215) -> grpc.RpcMethodHandler[T, R]: 

216 def f(request: Any, context: CouchersContext) -> NoReturn: 

217 context.abort(status_code, message) 

218 

219 return grpc.unary_unary_rpc_method_handler(f) 

220 

221 

222def unauthenticated_handler[T, R]( 

223 message: str = UNAUTHORIZED_ERROR_MESSAGE, 

224 status_code: grpc.StatusCode = grpc.StatusCode.UNAUTHENTICATED, 

225) -> grpc.RpcMethodHandler[T, R]: 

226 return abort_handler(message, status_code) 

227 

228 

229def _log_call( 

230 *, 

231 method: str, 

232 status_code: str | None, 

233 user_id: int | None, 

234 is_api_key: bool, 

235 sofa: str | None, 

236 headers: CouchersHeaders | None, 

237 start: int, 

238 perf: PerfResult | None, 

239 request: Message | None = None, 

240 response: Message | None = None, 

241 exception: Exception | None = None, 

242 nonexistent_method: bool = False, 

243) -> None: 

244 """Record a finished call: one api_calls row, plus the per-call Prometheus observations.""" 

245 duration = (perf_counter_ns() - start) / 1e6 # ms 

246 metric_method = NONEXISTENT_METHOD_LABEL if nonexistent_method else method 

247 

248 req_bytes = sanitized_bytes(request) 

249 res_bytes = sanitized_bytes(response) 

250 response_truncated = False 

251 truncate_res_bytes_length = 16 * 1024 # 16 kB 

252 if res_bytes and len(res_bytes) > truncate_res_bytes_length: 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

253 res_bytes = res_bytes[:truncate_res_bytes_length] 

254 response_truncated = True 

255 

256 traceback = "".join(format_exception(type(exception), exception, exception.__traceback__)) if exception else None 

257 

258 with session_scope() as session: 

259 session.add( 

260 APICall( 

261 is_api_key=is_api_key, 

262 method=method, 

263 status_code=status_code, 

264 duration=duration, 

265 user_id=user_id, 

266 request=req_bytes, 

267 response=res_bytes, 

268 response_truncated=response_truncated, 

269 traceback=traceback, 

270 db_query_count=perf.db_query_count if perf else None, 

271 db_write_query_count=perf.db_write_query_count if perf else None, 

272 db_time_ms=perf.db_time_ms if perf else None, 

273 cpu_ms=perf.cpu_ms if perf else None, 

274 client_platform=headers.client_platform if headers else None, 

275 ip_address=headers.ip_address if headers else None, 

276 user_agent=headers.user_agent if headers else None, 

277 sofa=sofa, 

278 ) 

279 ) 

280 

281 observe_in_servicer_duration_histogram( 

282 metric_method, user_id, status_code or "", type(exception).__name__ if exception else "", duration / 1000 

283 ) 

284 observe_api_call(metric_method, headers.client_platform if headers else None) 

285 logger.debug(f"{user_id=}, {method=}, {duration=} ms") 

286 

287 

288def _log_rejected_call( 

289 *, 

290 method: str, 

291 code: grpc.StatusCode, 

292 start: int, 

293 handler_call_details: grpc.HandlerCallDetails, 

294 user_id: int | None = None, 

295 exception: Exception | None = None, 

296 nonexistent_method: bool = False, 

297) -> None: 

298 """Log a call rejected during auth/setup.""" 

299 try: 

300 headers: CouchersHeaders | None = parse_headers(dict(handler_call_details.invocation_metadata)) 

301 except BadHeaders: 

302 headers = None 

303 

304 perf = read_perf() 

305 _log_call( 

306 method=method, 

307 status_code=code.name, 

308 user_id=user_id, 

309 is_api_key=headers.is_api_key if headers else False, 

310 sofa=headers.sofa if headers else None, 

311 headers=headers, 

312 start=start, 

313 perf=perf, 

314 exception=exception, 

315 nonexistent_method=nonexistent_method, 

316 ) 

317 observe_in_servicer_setup_histogram(NONEXISTENT_METHOD_LABEL if nonexistent_method else method, perf) 

318 

319 

320def _rejected_call_handler[T, R]( 

321 *, 

322 method: str, 

323 message: str, 

324 code: grpc.StatusCode, 

325 start: int, 

326 handler_call_details: grpc.HandlerCallDetails, 

327) -> grpc.RpcMethodHandler[T, R]: 

328 """Terminate a call that has no handler to run, logging it from the pool thread rather than the serving one.""" 

329 

330 def f(request: Any, context: grpc.ServicerContext) -> NoReturn: 

331 start_perf() 

332 _log_rejected_call( 

333 method=method, 

334 code=code, 

335 start=start, 

336 handler_call_details=handler_call_details, 

337 nonexistent_method=True, 

338 ) 

339 context.abort(code, message) 

340 

341 return grpc.unary_unary_rpc_method_handler(f) 

342 

343 

344type Cont[T, R] = Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler[T, R] | None] 

345 

346 

347@dataclass(frozen=True, slots=True, kw_only=True) 

348class AdmittedCall: 

349 """What a call that's cleared to run carries into the handler body.""" 

350 

351 headers: CouchersHeaders 

352 auth_info: UserAuthInfo | None 

353 sofa: str 

354 new_sofa_cookie: str | None 

355 localization: LocalizationContext 

356 

357 

358@dataclass(frozen=True, slots=True, kw_only=True) 

359class RejectedCall: 

360 """What a call that didn't clear setup gets terminated and logged with.""" 

361 

362 code: grpc.StatusCode 

363 message: str 

364 user_id: int | None 

365 # set when setup broke rather than turned the call away, so the caller can report it 

366 exception: Exception | None 

367 

368 

369def admit_call(pool: DescriptorPool, handler_call_details: grpc.HandlerCallDetails) -> AdmittedCall | RejectedCall: 

370 """ 

371 Pre-RPC setup handling. 

372 

373 Never raises: a call that doesn't make it through comes back as a RejectedCall carrying whatever setup had 

374 resolved before it stopped, so the caller can log the call it never ran. 

375 """ 

376 auth_info = None 

377 try: 

378 headers = parse_headers(dict(handler_call_details.invocation_metadata)) 

379 

380 # if this is not present in prod, it's a Big Bug in config 

381 assert config.DEV or headers.ip_address is not None 

382 

383 auth_level = find_auth_level(pool, handler_call_details.method) 

384 

385 auth_info = _try_get_and_update_user_details( 

386 headers.token, 

387 headers.is_api_key, 

388 headers.ip_address, 

389 headers.user_agent, 

390 headers.sofa, 

391 headers.client_platform, 

392 ) 

393 

394 check_permissions(auth_info, auth_level) 

395 

396 if should_rate_limit(handler_call_details.method, headers, auth_info): 

397 raise CallRejectedError(RATE_LIMIT_ERROR_MESSAGE, grpc.StatusCode.RESOURCE_EXHAUSTED) 

398 

399 if headers.sofa: 

400 sofa = headers.sofa 

401 new_sofa_cookie = None 

402 else: 

403 sofa, new_sofa_cookie = generate_sofa_cookie() 

404 

405 loc_context = LocalizationContext( 

406 locale=(auth_info.ui_language_preference if auth_info else headers.ui_lang) or "", 

407 timezone=ZoneInfo((auth_info and auth_info.timezone) or "Etc/UTC"), 

408 ) 

409 except BadHeaders: 

410 return RejectedCall( 

411 code=grpc.StatusCode.UNAUTHENTICATED, 

412 message=COOKIES_AND_AUTH_HEADER_ERROR_MESSAGE, 

413 user_id=None, 

414 exception=None, 

415 ) 

416 except CallRejectedError as e: 

417 return RejectedCall( 

418 code=e.code, message=e.msg, user_id=auth_info.user_id if auth_info else None, exception=None 

419 ) 

420 except Exception as e: 

421 return RejectedCall( 

422 code=grpc.StatusCode.INTERNAL, 

423 message=UNKNOWN_ERROR_MESSAGE, 

424 user_id=auth_info.user_id if auth_info else None, 

425 exception=e, 

426 ) 

427 

428 return AdmittedCall( 

429 headers=headers, 

430 auth_info=auth_info, 

431 sofa=sofa, 

432 new_sofa_cookie=new_sofa_cookie, 

433 localization=loc_context, 

434 ) 

435 

436 

437class CouchersMiddlewareInterceptor(grpc.ServerInterceptor): 

438 """ 

439 1. Does auth: extracts a session token from a cookie, and authenticates a user with that. 

440 

441 Sets context.user_id and context.token if authenticated, otherwise 

442 terminates the call with an UNAUTHENTICATED error code. 

443 

444 2. Makes sure cookies are in sync. 

445 

446 3. Injects a session to get a database transaction. 

447 

448 4. Measures and logs the time it takes to service each incoming call. 

449 

450 All of that happens in the returned handler, on a thread pool thread. gRPC runs intercept_service inline on the 

451 server's single completion-queue thread while holding the server-wide lock, and only submits to the pool once 

452 the interceptor chain has returned a handler, so blocking in the interceptor body (the auth query above all) 

453 serializes call dispatch for the entire worker process. 

454 """ 

455 

456 def __init__(self) -> None: 

457 self._pool = get_descriptor_pool() 

458 

459 def intercept_service[T = Message, R = Message]( 

460 self, 

461 continuation: Cont[T, R], 

462 handler_call_details: grpc.HandlerCallDetails, 

463 ) -> grpc.RpcMethodHandler[T, R]: 

464 start = perf_counter_ns() 

465 

466 method = handler_call_details.method 

467 

468 # only the handler lookup happens here, the rest waits for the handler thread; see the class docstring 

469 handler = continuation(handler_call_details) 

470 if not handler or not (prev_function := handler.unary_unary): 

471 return _rejected_call_handler( 

472 method=method, 

473 message=NONEXISTENT_API_CALL_ERROR_MESSAGE, 

474 code=grpc.StatusCode.UNIMPLEMENTED, 

475 start=start, 

476 handler_call_details=handler_call_details, 

477 ) 

478 

479 def function_without_couchers_stuff(req: Message, grpc_context: grpc.ServicerContext) -> Message | None: 

480 # accounting for the auth/setup phase; the handler re-arms its own below 

481 start_perf() 

482 

483 call = admit_call(self._pool, handler_call_details) 

484 

485 if isinstance(call, RejectedCall): 

486 # anything unexpected goes to Sentry before the row below: if the DB is what's broken, that fails too 

487 if call.exception: 

488 observe_in_servicer_setup_errors_counter(method, type(call.exception).__name__) 

489 sentry_sdk.set_tag("context", "servicer_setup") 

490 sentry_sdk.set_tag("method", method) 

491 sentry_sdk.capture_exception(call.exception) 

492 _log_rejected_call( 

493 method=method, 

494 code=call.code, 

495 start=start, 

496 handler_call_details=handler_call_details, 

497 user_id=call.user_id, 

498 exception=call.exception, 

499 ) 

500 grpc_context.abort(call.code, call.message) 

501 

502 observe_in_servicer_setup_histogram(method, read_perf()) 

503 

504 headers = call.headers 

505 auth_info = call.auth_info 

506 sofa = call.sofa 

507 loc_context = call.localization 

508 

509 couchers_context = make_interactive_context( 

510 grpc_context=grpc_context, 

511 user_id=auth_info.user_id if auth_info else None, 

512 is_api_key=auth_info.is_api_key if auth_info else False, 

513 token=auth_info.token if auth_info else None, 

514 localization=loc_context, 

515 sofa=sofa, 

516 ) 

517 

518 with session_scope() as session: 

519 # force the checkout now so its wait is timed here rather than hiding in the handler's first query 

520 pool_wait_start = perf_counter_ns() 

521 session.connection() 

522 observe_in_servicer_pool_wait_histogram(method, (perf_counter_ns() - pool_wait_start) / 1e9) 

523 start_perf() 

524 

525 res: Message | None = None 

526 exception: Exception | None = None 

527 try: 

528 _res = prev_function(req, couchers_context, session) # type: ignore[call-arg, arg-type] 

529 # flush so pending ORM writes execute (and are counted) before we snapshot; a handler that only 

530 # session.add(...)s and returns would otherwise flush at commit, after read_perf() 

531 session.flush() 

532 res = cast(Message, _res) 

533 except Exception as e: 

534 exception = e 

535 

536 perf = read_perf() 

537 

538 if exception and couchers_context._grpc_context: 

539 context_code = couchers_context._grpc_context.code() # type: ignore[attr-defined] 

540 code = getattr(context_code, "name", None) 

541 else: 

542 code = None 

543 

544 _log_call( 

545 method=method, 

546 status_code=code, 

547 user_id=couchers_context._user_id, 

548 is_api_key=cast(bool, couchers_context._is_api_key), 

549 sofa=sofa, 

550 headers=headers, 

551 start=start, 

552 perf=perf, 

553 request=req, 

554 response=res, 

555 exception=exception, 

556 ) 

557 observe_in_servicer_perf_histograms(method, perf) 

558 

559 if exception: 

560 if not code: 

561 sentry_sdk.set_tag("context", "servicer") 

562 sentry_sdk.set_tag("method", method) 

563 sentry_sdk.set_tag("user_agent", headers.user_agent) 

564 sentry_sdk.set_tag("ui_lang", loc_context.preferred_locale) 

565 sentry_sdk.set_user( 

566 { 

567 "id": couchers_context._user_id, 

568 "ip_address": headers.ip_address, 

569 "sofa": sofa[:12], 

570 } 

571 ) 

572 sentry_sdk.capture_exception(exception) 

573 

574 raise exception 

575 

576 if auth_info and not auth_info.is_api_key: 

577 # check the two cookies are in sync & that language preference cookie is correct 

578 if headers.user_id_str != str(auth_info.user_id): 578 ↛ 582line 578 didn't jump to line 582 because the condition on line 578 was always true

579 couchers_context.set_cookies( 

580 create_session_cookies(auth_info.token, auth_info.user_id, auth_info.token_expiry) 

581 ) 

582 if auth_info.ui_language_preference and auth_info.ui_language_preference != headers.ui_lang: 

583 couchers_context.set_cookies(create_lang_cookie(auth_info.ui_language_preference)) 

584 

585 if call.new_sofa_cookie: 

586 couchers_context.set_cookies([call.new_sofa_cookie]) 

587 

588 if not grpc_context.is_active(): 588 ↛ 589line 588 didn't jump to line 589 because the condition on line 588 was never true

589 grpc_context.abort(grpc.StatusCode.INTERNAL, CALL_CANCELLED_ERROR_MESSAGE) 

590 

591 couchers_context._send_cookies() 

592 

593 return res 

594 

595 def timed_serde[A, B](fn: Callable[[A], B], direction: str) -> Callable[[A], B]: 

596 def wrapped(arg: A) -> B: 

597 t0 = perf_counter_ns() 

598 result = fn(arg) 

599 observe_in_servicer_serde_histogram(method, direction, (perf_counter_ns() - t0) / 1e9) 

600 return result 

601 

602 return wrapped 

603 

604 # always set for our generated-proto methods, but grpc types them as optional 

605 assert handler.request_deserializer is not None and handler.response_serializer is not None 

606 return grpc.unary_unary_rpc_method_handler( 

607 function_without_couchers_stuff, 

608 request_deserializer=timed_serde(handler.request_deserializer, "deserialize"), 

609 response_serializer=timed_serde(handler.response_serializer, "serialize"), 

610 ) 

611 

612 

613def parse_headers(headers: Mapping[str, str | bytes]) -> CouchersHeaders: 

614 if "cookie" in headers and "authorization" in headers: 

615 # for security reasons, only one of "cookie" or "authorization" can be present 

616 raise BadHeaders("Both cookies and authorization are present in headers") 

617 elif "cookie" in headers: 

618 # the session token is passed in cookies, i.e., in the `cookie` header 

619 token, is_api_key = parse_session_cookie(headers), False 

620 elif "authorization" in headers: 

621 # the session token is passed in the `authorization` header 

622 token, is_api_key = parse_api_key(headers), True 

623 else: 

624 # no session found 

625 token, is_api_key = None, False 

626 

627 ip_address = headers.get("x-couchers-real-ip") 

628 user_agent = headers.get("user-agent") 

629 

630 # the client (web app or native app) declares its platform via this header 

631 client_platform_raw = headers.get("x-couchers-client-platform") 

632 client_platform = ( 

633 ClientPlatform[client_platform_raw] 

634 if isinstance(client_platform_raw, str) and client_platform_raw in ClientPlatform.__members__ 

635 else None 

636 ) 

637 

638 ui_lang = parse_ui_lang_cookie(headers) 

639 user_id_str = parse_user_id_cookie(headers) 

640 sofa = parse_sofa_cookie(headers) 

641 

642 return CouchersHeaders( 

643 user_id_str=user_id_str, 

644 token=token, 

645 sofa=sofa, 

646 is_api_key=is_api_key, 

647 ip_address=ip_address if isinstance(ip_address, str) else None, 

648 user_agent=user_agent if isinstance(user_agent, str) else None, 

649 client_platform=client_platform, 

650 ui_lang=ui_lang, 

651 ) 

652 

653 

654class BadHeaders(Exception): 

655 pass 

656 

657 

658def check_permissions(auth_info: UserAuthInfo | None, auth_level: AuthLevel.ValueType) -> None: 

659 if not auth_info: 

660 # if this isn't an open service, fail 

661 if auth_level != annotations_pb2.AUTH_LEVEL_OPEN: 

662 raise CallRejectedError(UNAUTHORIZED_ERROR_MESSAGE, grpc.StatusCode.UNAUTHENTICATED) 

663 else: 

664 # a valid user session was found - check permissions 

665 if auth_level == annotations_pb2.AUTH_LEVEL_ADMIN and not auth_info.is_superuser: 

666 raise CallRejectedError(PERMISSION_DENIED_ERROR_MESSAGE, grpc.StatusCode.PERMISSION_DENIED) 

667 

668 if auth_level == annotations_pb2.AUTH_LEVEL_EDITOR and not auth_info.is_editor: 

669 raise CallRejectedError(PERMISSION_DENIED_ERROR_MESSAGE, grpc.StatusCode.PERMISSION_DENIED) 

670 

671 # if the user is jailed and this isn't an open or jailed service, fail 

672 if auth_info.is_jailed and auth_level not in [ 

673 annotations_pb2.AUTH_LEVEL_OPEN, 

674 annotations_pb2.AUTH_LEVEL_JAILED, 

675 ]: 

676 raise CallRejectedError(PERMISSION_DENIED_ERROR_MESSAGE, grpc.StatusCode.UNAUTHENTICATED) 

677 

678 

679class MediaInterceptor(grpc.ServerInterceptor): 

680 """ 

681 Extracts an "Authorization: Bearer <hex>" header and calls the 

682 is_authorized function. Terminates the call with an HTTP error 

683 code if not authorized. 

684 

685 Also adds a session to called APIs. 

686 """ 

687 

688 def __init__(self, is_authorized: Callable[[str], bool]): 

689 self._is_authorized = is_authorized 

690 

691 def intercept_service[T, R]( 

692 self, 

693 continuation: Cont[T, R], 

694 handler_call_details: grpc.HandlerCallDetails, 

695 ) -> grpc.RpcMethodHandler[T, R]: 

696 handler = continuation(handler_call_details) 

697 if not handler: 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true

698 raise RuntimeError("No handler") 

699 

700 prev_func = handler.unary_unary 

701 if not prev_func: 701 ↛ 702line 701 didn't jump to line 702 because the condition on line 701 was never true

702 raise RuntimeError(f"No prev_function, {handler}") 

703 

704 metadata = dict(handler_call_details.invocation_metadata) 

705 

706 token = parse_api_key(metadata) 

707 

708 if not token or not self._is_authorized(token): 708 ↛ 709line 708 didn't jump to line 709 because the condition on line 708 was never true

709 return unauthenticated_handler() 

710 

711 def function_without_session(request: T, grpc_context: grpc.ServicerContext) -> R: 

712 with session_scope() as session: 

713 return prev_func(request, make_media_context(grpc_context), session) # type: ignore[call-arg, arg-type] 

714 

715 return grpc.unary_unary_rpc_method_handler( 

716 function_without_session, 

717 request_deserializer=handler.request_deserializer, 

718 response_serializer=handler.response_serializer, 

719 ) 

720 

721 

722class OTelInterceptor(grpc.ServerInterceptor): 

723 """ 

724 OpenTelemetry tracing 

725 """ 

726 

727 def __init__(self) -> None: 

728 self.tracer = trace.get_tracer(__name__) 

729 

730 def intercept_service[T, R]( 

731 self, 

732 continuation: Cont[T, R], 

733 handler_call_details: grpc.HandlerCallDetails, 

734 ) -> grpc.RpcMethodHandler[T, R]: 

735 handler = continuation(handler_call_details) 

736 if not handler: 

737 raise RuntimeError("No handler") 

738 

739 prev_func = handler.unary_unary 

740 if not prev_func: 

741 raise RuntimeError(f"No prev_function, {handler}") 

742 

743 method = handler_call_details.method 

744 

745 def tracing_function(request: T, context: grpc.ServicerContext) -> R: 

746 # method is of the form "/org.couchers.api.core.API/GetUser" 

747 _, service_name, method_name = method.split("/") 

748 

749 headers = dict(handler_call_details.invocation_metadata) 

750 

751 with self.tracer.start_as_current_span("handler") as rollspan: 

752 rollspan.set_attribute("rpc.method_full", method) 

753 rollspan.set_attribute("rpc.service", service_name) 

754 rollspan.set_attribute("rpc.method", method_name) 

755 

756 rollspan.set_attribute("rpc.thread", get_ident()) 

757 rollspan.set_attribute("rpc.pid", getpid()) 

758 

759 res = prev_func(request, context) 

760 

761 rollspan.set_attribute("web.user_agent", headers.get("user-agent") or "") 

762 rollspan.set_attribute("web.ip_address", headers.get("x-couchers-real-ip") or "") 

763 

764 return res 

765 

766 return grpc.unary_unary_rpc_method_handler( 

767 tracing_function, 

768 request_deserializer=handler.request_deserializer, 

769 response_serializer=handler.response_serializer, 

770 ) 

771 

772 

773class ErrorSanitizationInterceptor(grpc.ServerInterceptor): 

774 """ 

775 If the call resulted in a non-gRPC error, this strips away the error details. 

776 

777 It's important to put this first, so that it does not interfere with other interceptors. 

778 """ 

779 

780 def intercept_service[T, R]( 

781 self, 

782 continuation: Cont[T, R], 

783 handler_call_details: grpc.HandlerCallDetails, 

784 ) -> grpc.RpcMethodHandler[T, R]: 

785 handler = continuation(handler_call_details) 

786 if not handler: 786 ↛ 787line 786 didn't jump to line 787 because the condition on line 786 was never true

787 raise RuntimeError("No handler") 

788 

789 prev_func = handler.unary_unary 

790 if not prev_func: 790 ↛ 791line 790 didn't jump to line 791 because the condition on line 790 was never true

791 raise RuntimeError(f"No prev_function, {handler}") 

792 

793 def sanitizing_function(req: T, context: grpc.ServicerContext) -> R: 

794 try: 

795 res = prev_func(req, context) 

796 except Exception as e: 

797 code = context.code() # type: ignore[attr-defined] 

798 # the code is one of the RPC error codes if this was failed through abort(), otherwise it's None 

799 if not code: 

800 logger.exception(e) 

801 logger.info("Probably an unknown error! Sanitizing...") 

802 context.abort(grpc.StatusCode.INTERNAL, UNKNOWN_ERROR_MESSAGE) 

803 else: 

804 logger.warning(f"RPC error: {code} in method {handler_call_details.method}") 

805 raise e 

806 return res 

807 

808 return grpc.unary_unary_rpc_method_handler( 

809 sanitizing_function, 

810 request_deserializer=handler.request_deserializer, 

811 response_serializer=handler.response_serializer, 

812 )