| """Challenge-org member roles, for the organizer-broadcast gate. |
| |
| Organizers are the challenge org's ``admin`` members (participants are |
| ``contributor``/``write``). whoami doesn't expose a caller's role for OAuth |
| tokens, so the Space resolves it with its admin token via |
| ``HubClient.org_member_role_by_email`` when OAuth provides an email, falling |
| back to ``HubClient.org_member_roles`` when the targeted lookup is unavailable. |
| Results are cached here: the map changes rarely, and a broadcast is a |
| deliberate, infrequent act. Lookup failures propagate so the caller can fail |
| closed. |
| """ |
| from __future__ import annotations |
|
|
| import threading |
| import time |
| from typing import Callable |
|
|
| from app.config import Settings |
| from app.hub import HubClient |
|
|
|
|
| class OrgRoles: |
| def __init__( |
| self, |
| hub: HubClient, |
| settings: Settings, |
| clock: Callable[[], float] = time.monotonic, |
| ): |
| self._hub = hub |
| self._settings = settings |
| self._clock = clock |
| self._roles: dict[str, str] | None = None |
| self._fetched_at = float("-inf") |
| self._email_roles: dict[tuple[str, str], tuple[str | None, float]] = {} |
| self._lock = threading.Lock() |
|
|
| def _current(self) -> dict[str, str]: |
| """The cached member→role map, refreshed past the TTL. On a refresh |
| failure with no usable cache the underlying error propagates (fail |
| closed); a still-fresh cache is served without a fetch.""" |
| with self._lock: |
| now = self._clock() |
| if self._roles is None or now - self._fetched_at >= self._settings.org_roles_ttl_s: |
| self._roles = self._hub.org_member_roles(self._settings.org) |
| self._fetched_at = now |
| return self._roles |
|
|
| def _role_from_email(self, username: str, email: str) -> str | None: |
| """Targeted member lookup by OAuth email. |
| |
| Returns a role only when the email-filtered member record matches the |
| already-verified HF username. A miss is cached briefly but still lets |
| the caller fall back to the full org role map. |
| """ |
| username_l = username.lower() |
| email_l = email.strip().lower() |
| key = (username_l, email_l) |
| now = self._clock() |
| with self._lock: |
| cached = self._email_roles.get(key) |
| if cached is not None and now - cached[1] < self._settings.org_roles_ttl_s: |
| return cached[0] |
|
|
| member = self._hub.org_member_role_by_email(self._settings.org, email) |
| role = ( |
| member.role |
| if member is not None and member.user.lower() == username_l |
| else None |
| ) |
| with self._lock: |
| self._email_roles[key] = (role, now) |
| return role |
|
|
| def role_of(self, username: str, email: str | None = None) -> str | None: |
| """The caller's role in the challenge org, or None if not a member. |
| Raises if the role map can't be fetched.""" |
| email_error: Exception | None = None |
| if email: |
| try: |
| role = self._role_from_email(username, email) |
| except Exception as exc: |
| email_error = exc |
| else: |
| if role is not None: |
| return role |
| try: |
| return self._current().get(username.lower()) |
| except Exception: |
| if email_error is not None: |
| raise email_error |
| raise |
|
|