STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
directory.py
Go to the documentation of this file.
1"""Who a design can be shared with: the team roster.
2
3Sharing needs a list of people, and the design tools have never had one -- they
4only ever knew the single email in the request header. The roster here is the
5**union** of two sources, and it needs both:
6
7* **The auth service** (``AUTH_USERS_URL`` -> ``auth/main.py``'s ``/users``),
8 which records everyone who has signed in. It is the only source of real
9 display names; nothing on this side of the wire knows them.
10* **The shared userdata volume** -- everyone who already has designs. Auth only
11 learns about a person when they log in and sessions last 30 days, so for the
12 first month after this ships the auth roster is missing most of the team. In
13 dev there is no auth service at all. The volume scan covers both cases, which
14 is what keeps ``./dev.sh`` working with nothing in front of it.
15
16Auth being unset, down, or slow degrades this to the volume scan. It must never
17break listing or editing designs.
18
19**On making this app an authenticated-request forwarder.** The outbound call
20carries exactly one header -- the caller's own session cookie, which Caddy has
21already handed us -- and its URL comes from ``AUTH_USERS_URL`` in the
22environment, never from the request. A caller can therefore influence *whether*
23we ask auth, never *whom* we ask, which is what keeps this from being an SSRF
24hole. Keep it that way: no request-supplied URLs, no header pass-through.
25"""
26
27from __future__ import annotations
28
29import json
30import os
31import threading
32import time
33import urllib.error
34import urllib.request
35
36from fastapi import Request
37
38from stardesign.userdata import UserData, slug_user
39
40#: How long one auth-roster fetch is reused. Short enough that a new teammate
41#: appears promptly, long enough that listing designs is not a network call.
42TTL_SECONDS = float(os.environ.get("AUTH_USERS_TTL", "60"))
43
44#: Deliberately short: a slow auth service must not stall a design list.
45TIMEOUT_SECONDS = 3.0
46
47_lock = threading.Lock()
48# None means "never fetched", which is NOT the same as "fetched a long time ago".
49# A sentinel timestamp of 0.0 looked stale on a long-running machine and *fresh*
50# on a freshly booted one -- `time.monotonic()` counts from boot, so on a new
51# host `now - 0.0` is a handful of seconds, inside the TTL. The result was an
52# empty roster served for the first TTL after every restart, without auth ever
53# being asked. Local machines never saw it; a fresh CI container did.
54_cache: tuple[float, list[dict]] | None = None
55
56
57def _fetch_auth(cookie: str) -> list[dict]:
58 """The auth service's roster, or ``[]`` if unset, unreachable, or garbage."""
59 base = os.environ.get("AUTH_USERS_URL", "").strip().rstrip("/")
60 if not base:
61 return []
62 req = urllib.request.Request(f"{base}/users", method="GET")
63 # AUTH_USERS_URL is a public hostname, so this call goes out through
64 # Cloudflare, which blocks urllib's default `Python-urllib/x.y` signature
65 # with a 403 (error 1010) before the request ever reaches auth. That 403 is
66 # an HTTPError -- a URLError subclass -- so the handler below swallows it and
67 # the roster silently degrades to the volume scan: every share picker in
68 # every design tool then offers only the people who already have designs.
69 # Any ordinary User-Agent gets through; there is no need to look like a
70 # browser.
71 req.add_header("User-Agent", "stardesign/1.0")
72 if cookie:
73 req.add_header("Cookie", cookie)
74 try:
75 with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS) as resp:
76 data = json.loads(resp.read().decode("utf-8"))
77 except (urllib.error.URLError, OSError, ValueError, TimeoutError):
78 return []
79 if not isinstance(data, list):
80 return []
81 return [
82 {"email": str(row["email"]), "name": str(row.get("name") or "")}
83 for row in data
84 if isinstance(row, dict) and row.get("email")
85 ]
86
87
88def _auth_roster(request: Request) -> list[dict]:
89 """:func:`_fetch_auth` behind a process-wide TTL cache.
90
91 Cached across callers, not per-cookie: every session that reaches this app
92 has already been verified by Caddy and would get the same answer. A failed
93 fetch caches ``[]`` for the same TTL, so an auth outage costs one attempt a
94 minute rather than one per request.
95 """
96 now = time.monotonic()
97 with _lock:
98 cached = _cache
99 if cached is not None and now - cached[0] < TTL_SECONDS:
100 return cached[1]
101 fetched = _fetch_auth(request.headers.get("cookie", ""))
102 with _lock:
103 globals()["_cache"] = (time.monotonic(), fetched)
104 return fetched
105
106
107def roster(request: Request, ud: UserData) -> list[dict]:
108 """Everyone a design can be shared with: ``[{email, name}]``, sorted.
109
110 Keyed on the path slug, so the same person arriving from both sources
111 collapses to one row -- and auth's spelling of the address wins, since the
112 volume only ever holds the slug.
113 """
114 people: dict[str, dict] = {
115 user: {"email": user, "name": ""} for user in ud.all_users()
116 }
117 for row in _auth_roster(request):
118 slug = slug_user(row["email"])
119 entry = people.setdefault(slug, {"email": row["email"], "name": ""})
120 entry["email"] = row["email"]
121 entry["name"] = row["name"] or entry["name"]
122 return sorted(people.values(), key=lambda u: (u["name"].lower() or u["email"], u["email"]))
123
124
125def display_names(request: Request, ud: UserData) -> dict[str, str]:
126 """``slug -> display name`` for labelling design owners; ``""`` when unknown."""
127 return {slug_user(u["email"]): u["name"] for u in roster(request, ud)}
list[dict] _auth_roster(Request request)
Definition directory.py:88
list[dict] _fetch_auth(str cookie)
Definition directory.py:57
dict[str, str] display_names(Request request, UserData ud)
Definition directory.py:125
list[dict] roster(Request request, UserData ud)
Definition directory.py:107
static void decode(const disk_t *d)