ccr-platform / backend /app /auth.py
devaanand's picture
feat: put invite links on hold; single brand header everywhere
6a6c7cf
Raw
History Blame Contribute Delete
10.5 kB
"""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)})