File size: 10,545 Bytes
4b09d2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
776393c
 
 
 
4ce78b4
4b09d2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b633038
bfb6467
 
 
b633038
 
 
 
 
 
 
 
bfb6467
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78fa678
 
 
 
 
 
 
bfb6467
 
 
 
6a6c7cf
 
 
 
 
 
 
 
4b09d2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bfb6467
 
 
4b09d2d
 
 
bfb6467
 
5ad96fc
 
 
 
bfb6467
 
 
 
 
 
 
5ad96fc
 
 
bfb6467
 
 
 
 
 
5ad96fc
bfb6467
 
5ad96fc
 
 
 
bfb6467
 
 
 
 
 
5ad96fc
 
 
bfb6467
 
4b09d2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""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)})