Spaces:
Sleeping
Sleeping
| """Accounts, sessions, and usage tiers. | |
| Local email+password accounts - the "best cheap option available now" (Deva, | |
| 2026-07-11): zero external dependencies, zero cost, real password security via | |
| stdlib scrypt. This deliberately does NOT implement email verification or | |
| self-service password reset; at lab scale a reset is an admin action. The | |
| managed-provider swap (Supabase: Google + email/password, design doc §8) | |
| replaces token creation/verification here - get_current_user() stays the only | |
| integration point the rest of the app knows about. | |
| Sessions: HMAC-signed cookie carrying {uid, email, name}. Secret from | |
| CCR_SESSION_SECRET (REQUIRED in production - random per process otherwise, | |
| which signs everyone out on restart). | |
| Anonymous usage tiers (PI decisions, 2026-07-10): | |
| * upload caps (bytes/rows), | |
| * run limit per day (signed cookie counter - a nudge toward accounts, not a | |
| security boundary; clearing cookies evades it and that is acceptable), | |
| * data removed after analysis (see retention.py). | |
| Signed-in users: caps lifted, runs persist up to a saved-run cap. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import hashlib | |
| import hmac | |
| import json | |
| import os | |
| import re | |
| import secrets | |
| from datetime import datetime, timezone | |
| from fastapi import Request | |
| COOKIE_NAME = "ccr_session" | |
| RUNS_COOKIE_NAME = "ccr_runs" | |
| _SECRET = (os.environ.get("CCR_SESSION_SECRET") or secrets.token_hex(32)).encode() | |
| # Headroom for 200 rows of long documents (200 x 8 KB transcripts ~ 1.6 MB sat | |
| # uncomfortably close to the old 2 MB). Still a cheap pre-parse shield on the | |
| # unauthenticated path: the 200-row cap below is what actually binds. | |
| ANON_MAX_BYTES_DEFAULT = 5 * 1024 * 1024 | |
| ANON_MAX_ROWS_DEFAULT = 200 # PI decision 2026-07-14 (was 500) | |
| ANON_MAX_RUNS_PER_DAY_DEFAULT = 3 | |
| USER_MAX_SAVED_RUNS_DEFAULT = 15 | |
| ANON_TTL_HOURS_DEFAULT = 0 # 0 = purge disabled (local dev); deployments set 24 | |
| _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") | |
| MIN_PASSWORD_LEN = 8 | |
| # ------------------------------------------------------------- env knobs | |
| def anon_max_bytes() -> int: | |
| return int(os.environ.get("CCR_ANON_MAX_BYTES", ANON_MAX_BYTES_DEFAULT)) | |
| def anon_max_rows() -> int: | |
| return int(os.environ.get("CCR_ANON_MAX_ROWS", ANON_MAX_ROWS_DEFAULT)) | |
| def anon_max_runs_per_day() -> int: | |
| return int(os.environ.get("CCR_ANON_MAX_RUNS_PER_DAY", ANON_MAX_RUNS_PER_DAY_DEFAULT)) | |
| def user_max_saved_runs() -> int: | |
| return int(os.environ.get("CCR_USER_MAX_SAVED_RUNS", USER_MAX_SAVED_RUNS_DEFAULT)) | |
| def anon_ttl_hours() -> int: | |
| return int(os.environ.get("CCR_ANON_TTL_HOURS", ANON_TTL_HOURS_DEFAULT)) | |
| def cookies_secure() -> bool: | |
| """Set CCR_COOKIE_SECURE=1 behind HTTPS in production.""" | |
| return os.environ.get("CCR_COOKIE_SECURE") == "1" | |
| def admin_emails() -> set[str]: | |
| """Comma-separated allowlist; env-granted, so it bootstraps the first | |
| admin and can never be locked out by DB state. See roles below for the | |
| DB-granted staff tiers (PI decision 2026-07-22).""" | |
| raw = os.environ.get("ADMIN_EMAILS", "") | |
| return {e.strip().lower() for e in raw.split(",") if e.strip()} | |
| def is_admin(email: str | None) -> bool: | |
| return bool(email) and email.strip().lower() in admin_emails() | |
| # ------------------------------------------------------------- user roles | |
| # Four tiers (PI decision 2026-07-22): pi | maintainer | lab | external. | |
| # * external - default on signup; saved-run cap applies. | |
| # * lab - lab members: unlimited saved runs. | |
| # * maintainer - lab privileges + the /admin operational surface | |
| # (user management for lab/external, resets, requeue, | |
| # verification queue, invites). | |
| # * pi - maintainer surface + escalation rights: grant/revoke | |
| # staff roles and act on staff accounts. The app is | |
| # self-governing; ADMIN_EMAILS is bootstrap + break-glass | |
| # only (seed the first PI, recover a locked-out lab). | |
| # Escalation therefore requires pi-or-env-admin (admin.py guards), so a | |
| # maintainer - or a compromised maintainer session - cannot mint staff. | |
| ROLES = ("external", "lab", "maintainer", "pi") | |
| UNLIMITED_ROLES = frozenset({"lab", "maintainer", "pi"}) | |
| STAFF_ROLES = frozenset({"maintainer", "pi"}) | |
| INVITABLE_ROLES = frozenset({"external", "lab"}) # staff is granted, never invited | |
| def normalize_role(role: str | None) -> str: | |
| """Map stored roles to the current scheme ('member' predates 'external').""" | |
| role = (role or "").strip().lower() | |
| if role == "member": | |
| return "external" | |
| return role if role in ROLES else "external" | |
| def role_unlimited(role: str | None) -> bool: | |
| """Lab members and above: no saved-run cap.""" | |
| return normalize_role(role) in UNLIMITED_ROLES | |
| def role_lab_or_above(role: str | None) -> bool: | |
| """Lab members, maintainers, and the PI - the internal tier. Gates | |
| lab-only pages (/product); same set as UNLIMITED_ROLES but a separate | |
| helper because docs access and quota policy can diverge.""" | |
| return normalize_role(role) in UNLIMITED_ROLES | |
| def role_is_staff(role: str | None) -> bool: | |
| return normalize_role(role) in STAFF_ROLES | |
| def invites_enabled() -> bool: | |
| """Invite links are ON HOLD (maintainer decision 2026-07-31): creation and | |
| redemption are disabled, existing links stop working, and the admin UI | |
| hides the section. Email-bound pre-assigned roles are the supported way | |
| to onboard people. Flip CCR_INVITES_ENABLED=1 to bring links back.""" | |
| return os.environ.get("CCR_INVITES_ENABLED", "0") == "1" | |
| # ---------------------------------------------------------- passwords | |
| def hash_password(password: str) -> str: | |
| salt = secrets.token_bytes(16) | |
| digest = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=64) | |
| return f"scrypt${salt.hex()}${digest.hex()}" | |
| def verify_password(password: str, stored: str) -> bool: | |
| try: | |
| algo, salt_hex, digest_hex = stored.split("$") | |
| if algo != "scrypt": | |
| return False | |
| digest = hashlib.scrypt( | |
| password.encode(), salt=bytes.fromhex(salt_hex), n=16384, r=8, p=1, dklen=64 | |
| ) | |
| return hmac.compare_digest(digest.hex(), digest_hex) | |
| except Exception: | |
| return False | |
| def valid_email(email: str) -> bool: | |
| return bool(_EMAIL_RE.match(email.strip().lower())) | |
| # ------------------------------------------------- signed cookie payloads | |
| def _sign(payload: bytes) -> str: | |
| return hmac.new(_SECRET, payload, hashlib.sha256).hexdigest() | |
| def sign_payload(data: dict) -> str: | |
| payload = base64.urlsafe_b64encode(json.dumps(data, separators=(",", ":")).encode()).decode() | |
| return f"{payload}.{_sign(payload.encode())}" | |
| def verify_payload(token: str | None) -> dict | None: | |
| if not token or "." not in token: | |
| return None | |
| payload, signature = token.rsplit(".", 1) | |
| if not hmac.compare_digest(signature, _sign(payload.encode())): | |
| return None | |
| try: | |
| data = json.loads(base64.urlsafe_b64decode(payload.encode()).decode()) | |
| return data if isinstance(data, dict) else None | |
| except Exception: | |
| return None | |
| # ------------------------------------------------------------- sessions | |
| def create_session_token(user_id: str, email: str, name: str) -> str: | |
| return sign_payload({"uid": user_id, "email": email, "name": name}) | |
| def get_current_user(request: Request) -> dict | None: | |
| """THE auth integration point (design doc §8). A managed provider (Supabase) | |
| replaces this body with provider-session verification; callers only ever see | |
| {"id", "email", "name", "tier"} or None.""" | |
| data = verify_payload(request.cookies.get(COOKIE_NAME)) | |
| if not data or "uid" not in data: | |
| return None | |
| return { | |
| "id": data["uid"], | |
| "email": data.get("email", ""), | |
| "name": data.get("name", ""), | |
| # placeholder only - real role lives in the users table (queried per | |
| # request in main.py/admin.py so role changes apply without re-login) | |
| "tier": "external", | |
| } | |
| # ------------------------------------------------------------ invite links | |
| # Signed, expiring tokens the PI copies into Slack; whoever registers through | |
| # one lands at the invited tier instead of external. The signature proves the | |
| # token came from us; the invites TABLE (models.Invite, id = the token's jti) | |
| # decides whether it is still live - so links can be revoked early and every | |
| # redemption is traced to the link that granted it. | |
| INVITE_TTL_DAYS_DEFAULT = 7 | |
| def invite_ttl_days() -> int: | |
| return int(os.environ.get("CCR_INVITE_TTL_DAYS", INVITE_TTL_DAYS_DEFAULT)) | |
| def create_invite_token(role: str, invited_by: str, jti: str) -> tuple[str, str]: | |
| """Returns (token, expires_at ISO date). Role must be invitable; jti is | |
| the Invite row id the token points back to.""" | |
| from datetime import timedelta | |
| role = normalize_role(role) | |
| if role not in INVITABLE_ROLES: | |
| raise ValueError(f"Only these roles can be invited: {', '.join(sorted(INVITABLE_ROLES))}.") | |
| expires = (datetime.now(timezone.utc) + timedelta(days=invite_ttl_days())).date().isoformat() | |
| return sign_payload({"invite": role, "by": invited_by, "exp": expires, "jti": jti}), expires | |
| def verify_invite_token(token: str | None) -> dict | None: | |
| """{'role', 'jti'} for a well-signed, unexpired invite token; None | |
| otherwise. Liveness (revocation) is the caller's DB check - signature | |
| and expiry alone do not make a token redeemable.""" | |
| data = verify_payload(token) | |
| if not data or "invite" not in data: | |
| return None | |
| if str(data.get("exp", "")) < datetime.now(timezone.utc).date().isoformat(): | |
| return None # expired (dates are ISO, so string compare is correct) | |
| role = normalize_role(str(data["invite"])) | |
| if role not in INVITABLE_ROLES: | |
| return None | |
| return {"role": role, "jti": str(data.get("jti", ""))} | |
| # ------------------------------------------- anonymous daily run counter | |
| def _today() -> str: | |
| return datetime.now(timezone.utc).date().isoformat() | |
| def runs_used_today(request: Request) -> int: | |
| data = verify_payload(request.cookies.get(RUNS_COOKIE_NAME)) | |
| if not data or data.get("d") != _today(): | |
| return 0 # missing, tampered, or from a previous day - counter resets | |
| try: | |
| return max(0, int(data.get("n", 0))) | |
| except (TypeError, ValueError): | |
| return 0 | |
| def run_counter_token(count: int) -> str: | |
| return sign_payload({"d": _today(), "n": int(count)}) | |