From 0888da32b410a515cde2c61145c02aad5e68915f Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 15 Jun 2026 21:11:33 +0000 Subject: [PATCH] docs: portal-auth Phase 1 implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-15-portal-auth.md | 1259 +++++++++++++++++ 1 file changed, 1259 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-portal-auth.md diff --git a/docs/superpowers/plans/2026-06-15-portal-auth.md b/docs/superpowers/plans/2026-06-15-portal-auth.md new file mode 100644 index 0000000..b7efd7e --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-portal-auth.md @@ -0,0 +1,1259 @@ +# Portal Authentication (Phase 1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Gate each project's read-only client portal behind a secure per-project link + shared password. + +**Architecture:** Add `portal_enabled` / `portal_password_hash` / `portal_link_token` to `Project`. A new `/portal/p/{link_token}` page resolves the project from the (unguessable) token and prompts for the password; on success it mints the *existing* signed session cookie — pinned to a **dedicated 1:1 portal client** for that project — so every existing client-scoped read-only route (overview, location, live/history/events) works unchanged and is automatically scoped to that one project. The interim magic-link / open-link entry points are retired. + +**Why reuse the client cookie instead of re-keying routes to "project":** the existing scoped-data layer (`get_current_client` → `_client_project_ids` → `resolve_client_location`) is battle-tested in prod. Giving each portal-enabled project its own dedicated `Client` (slug `portal-`, owning exactly that one project) yields per-project isolation with **zero route surgery**. `Project.client_id` is left untouched as substrate for the deferred per-client rollup. + +**Tech Stack:** FastAPI, SQLAlchemy 2 + SQLite (raw-`sqlite3` migrations, no Alembic), Jinja2, argon2-cffi (new), pytest + Starlette TestClient (new — no test harness exists yet). Docker: deps are baked into the image, so new deps require a rebuild. + +**Key existing references (verbatim from codebase):** +- `backend/database.py`: `Base`, `SessionLocal`, `get_db` (generator dependency). SQLite at `data/seismo_fleet.db`. +- `backend/models.py`: `Project` (table `projects`, `id` is caller-supplied `str(uuid.uuid4())`, no FK constraints/relationships), `Client` (slug unique), `ClientAccessToken`. +- `backend/portal_auth.py`: `SECRET_KEY` (env, default `"dev-insecure-change-me"`), `COOKIE_NAME="portal_session"`, `COOKIE_MAX_AGE=60*60*24*30`, `make_session_cookie(token_id)` → `{"tid","iat"}` signed cookie, `client_from_cookie`, `get_current_client`, `ensure_project_client`, `provision_preview_session`, `mint_link_token`, `PortalAuthError`, `PORTAL_OPEN_LINKS`. +- `backend/routers/portal.py`: `APIRouter(prefix="/portal")`. Routes `/portal/enter/{token}`, `/portal/open/{project_id}`, `/portal/logout`, `/portal/access`, `/portal`, `/portal/location/{id}`, `/portal/api/location/{id}/{live,history,events,thresholds}`, WS `/stream`. Cookie set with `max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax"`. Scope helpers `_client_project_ids`, `resolve_client_location`, `active_unit_for_location`, `_client_locations`. +- `backend/main.py`: `app.include_router(portal.router)`; `PortalAuthError` handler; operator routes `/projects/{id}/portal-preview`, `/projects/{id}/portal-link` (create/list/revoke); `project_detail_page` passes `portal_open_links` to `projects/detail.html`. +- `templates/portal/base.html`: blocks `title`/`head`/`content`/`scripts`; shows client chip + Sign-out when `client` context var is truthy; globals `esc()`, `cssVar()`. `templates/portal/access_required.html`: lock-card splash branching on `reason`. +- `templates/projects/detail.html`: breadcrumb buttons (lines 21-41) + share modal & JS (lines 2101-2219) — the operator portal-sharing UI to replace. +- `backend/migrate_add_client_portal.py`: the raw-`sqlite3`, idempotent `PRAGMA table_info` + `ALTER TABLE ADD COLUMN` migration pattern. + +--- + +## Task 1: Stand up the test harness + add dependencies + +**Files:** +- Modify: `requirements.txt` +- Create: `requirements-dev.txt` +- Create: `tests/__init__.py` (empty) +- Create: `tests/conftest.py` +- Create: `tests/test_harness.py` (sanity test, deleted at end of task) + +- [ ] **Step 1: Add the runtime dependency (argon2)** + +Append to `requirements.txt`: +``` +argon2-cffi==23.1.0 +``` + +- [ ] **Step 2: Create the dev/test dependency file** + +Create `requirements-dev.txt`: +``` +-r requirements.txt +pytest==8.3.3 +``` + +- [ ] **Step 3: Create the test package + fixtures** + +Create `tests/__init__.py` (empty file). + +Create `tests/conftest.py`: +```python +"""Test harness: a throwaway SQLite DB per test, get_db overridden, a TestClient +that does NOT run lifespan startup (so schedulers/SLMM polling stay off).""" +import os +import uuid +import pytest +from datetime import datetime +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from starlette.testclient import TestClient + +from backend.database import Base, get_db +import backend.models as models # noqa: F401 (ensure all tables are registered on Base) + + +@pytest.fixture() +def db_session(tmp_path): + db_file = tmp_path / "test.db" + engine = create_engine(f"sqlite:///{db_file}", connect_args={"check_same_thread": False}) + Base.metadata.create_all(bind=engine) + TestingSession = sessionmaker(autocommit=False, autoflush=False, bind=engine) + sess = TestingSession() + try: + yield sess + finally: + sess.close() + engine.dispose() + + +@pytest.fixture() +def client(db_session): + from backend.main import app # imported lazily so module side effects are contained + def _override(): + try: + yield db_session + finally: + pass + app.dependency_overrides[get_db] = _override + # No `with` → lifespan/startup events do not run (no scheduler/SLMM threads). + c = TestClient(app) + yield c + app.dependency_overrides.clear() + + +@pytest.fixture(autouse=True) +def _reset_portal_lockout(): + """Portal lockout state is a module-global dict; clear it between tests so + one test's failed attempts can't lock out another.""" + try: + import backend.portal_auth as _pa + if hasattr(_pa, "_failures"): + _pa._failures.clear() + except Exception: + pass + yield + + +def make_project(db_session, name=None, **kwargs): + """Insert and return a Project with a unique name.""" + p = models.Project( + id=str(uuid.uuid4()), + name=name or f"Proj {uuid.uuid4().hex[:8]}", + status="active", + created_at=datetime.utcnow(), + **kwargs, + ) + db_session.add(p) + db_session.commit() + return p +``` + +- [ ] **Step 4: Add a sanity test that proves the harness boots** + +Create `tests/test_harness.py`: +```python +def test_app_boots_and_serves(client): + r = client.get("/portal/access") + assert r.status_code == 200 + + +def test_make_project_persists(db_session): + from tests.conftest import make_project + p = make_project(db_session, name="Harness Co") + assert p.id and p.name == "Harness Co" +``` + +- [ ] **Step 5: Bind-mount the source into the dev container (one-time, so code is live)** + +The dev container bakes source at build time (only `data-dev` is bind-mounted), so it won't pick up code edits — which would force a rebuild per TDD change. Fix it once by mounting the source. In `docker-compose.override.yml`, under the `terra-view` service `volumes:`, add a source mount **above** the existing data mount so the data mount still wins for `/app/data`: + +```yaml + volumes: + - .:/app + - ./data-dev:/app/data +``` +(Local dev-env convenience — does not need to be committed with the feature.) + +- [ ] **Step 6: Rebuild (bakes argon2), recreate with the mount, install pytest, run the sanity test** + +```bash +cd /home/serversdown/terra-view +docker compose build terra-view && docker compose up -d terra-view +docker exec terra-view-terra-view-1 pip install -r requirements-dev.txt +docker exec terra-view-terra-view-1 python -m pytest tests/test_harness.py -v +``` +Expected: both tests PASS. With the source mounted, later tasks need **no rebuild** — `docker exec … pytest` sees your edits live. (`pip install` of pytest is ephemeral; re-run it only if you recreate the container.) + +- [ ] **Step 7: Remove the throwaway sanity test and commit** + +```bash +rm tests/test_harness.py +git add requirements.txt requirements-dev.txt tests/__init__.py tests/conftest.py +git commit -m "test: stand up pytest harness + add argon2-cffi" +``` + +--- + +## Task 2: Password hashing helpers + +**Files:** +- Create: `backend/auth_passwords.py` +- Test: `tests/test_auth_passwords.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_auth_passwords.py`: +```python +import pytest +from backend.auth_passwords import hash_password, verify_password, generate_password + + +def test_hash_is_not_plaintext_and_verifies(): + h = hash_password("hunter2") + assert h != "hunter2" + assert h.startswith("$argon2") + assert verify_password("hunter2", h) is True + + +def test_verify_rejects_wrong_password(): + h = hash_password("hunter2") + assert verify_password("nope", h) is False + + +def test_verify_is_safe_on_garbage_hash(): + assert verify_password("anything", "not-a-real-hash") is False + + +def test_generated_password_is_strong_and_unique(): + a, b = generate_password(), generate_password() + assert a != b + assert len(a) >= 12 +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_auth_passwords.py -v` +Expected: FAIL (`ModuleNotFoundError: backend.auth_passwords`). + +- [ ] **Step 3: Implement the helpers** + +Create `backend/auth_passwords.py`: +```python +"""Password hashing for the client portal — argon2id via argon2-cffi. + +Kept separate from portal_auth (cookie signing) so the future operator auth can +reuse the same hasher. Never store or log raw passwords.""" +import secrets +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError + +_ph = PasswordHasher() + + +def hash_password(raw: str) -> str: + """Return an argon2id hash string for a raw password.""" + return _ph.hash(raw) + + +def verify_password(raw: str, hashed: str) -> bool: + """True iff raw matches the stored hash. Never raises.""" + try: + return _ph.verify(hashed, raw) + except (VerifyMismatchError, VerificationError, InvalidHashError, Exception): + return False + + +def generate_password(n_bytes: int = 12) -> str: + """A strong, URL-safe shareable password (~16 chars for n_bytes=12).""" + return secrets.token_urlsafe(n_bytes) +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_auth_passwords.py -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add backend/auth_passwords.py tests/test_auth_passwords.py +git commit -m "feat: argon2 password hashing helpers for the portal" +``` + +--- + +## Task 3: Project portal columns + migration + +**Files:** +- Modify: `backend/models.py` (the `Project` class, after `client_id` ~`models.py:195`) +- Create: `backend/migrate_add_project_portal_auth.py` +- Test: `tests/test_portal_migration.py` + +- [ ] **Step 1: Add the three columns to the `Project` model** + +In `backend/models.py`, inside `class Project`, immediately after the `client_id` line, add: +```python + # --- Client portal (Phase 1: per-project link + password gate) --- + portal_enabled = Column(Boolean, default=False) # is the portal open for this project + portal_password_hash = Column(String, nullable=True) # argon2 hash of the shared password + portal_link_token = Column(String, nullable=True, unique=True, index=True) # unguessable token in the secure link +``` + +- [ ] **Step 2: Write the failing migration test** + +Create `tests/test_portal_migration.py`: +```python +import sqlite3 +import importlib + + +def _columns(db_file): + conn = sqlite3.connect(db_file) + cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")} + conn.close() + return cols + + +def test_migration_adds_columns_and_is_idempotent(tmp_path, monkeypatch): + db_file = tmp_path / "seismo_fleet.db" + conn = sqlite3.connect(db_file) + conn.execute("CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT)") + conn.commit() + conn.close() + + monkeypatch.chdir(tmp_path) # migration resolves data/ relative to cwd + (tmp_path / "data").mkdir() + (tmp_path / "data" / "seismo_fleet.db").write_bytes(db_file.read_bytes()) + + mod = importlib.import_module("backend.migrate_add_project_portal_auth") + mod.migrate() + cols = _columns(tmp_path / "data" / "seismo_fleet.db") + assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= cols + + mod.migrate() # second run must not raise + assert {"portal_enabled", "portal_password_hash", "portal_link_token"} <= _columns(tmp_path / "data" / "seismo_fleet.db") +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_migration.py -v` +Expected: FAIL (`ModuleNotFoundError: backend.migrate_add_project_portal_auth`). + +- [ ] **Step 4: Write the migration (copy the `migrate_add_client_portal.py` pattern)** + +Create `backend/migrate_add_project_portal_auth.py`: +```python +#!/usr/bin/env python3 +""" +Database migration: Project portal auth (Phase 1). + +Adds the per-project portal gate columns to `projects`: + - portal_enabled (BOOLEAN, default 0) + - portal_password_hash (TEXT, nullable) + - portal_link_token (TEXT, nullable) [+ unique index] + +Idempotent. Run once per existing DB: + docker exec terra-view-terra-view-1 python3 backend/migrate_add_project_portal_auth.py +""" +import sqlite3 +from pathlib import Path + +_COLUMNS = { + "portal_enabled": "BOOLEAN DEFAULT 0", + "portal_password_hash": "TEXT", + "portal_link_token": "TEXT", +} + + +def migrate(): + possible_paths = [Path("data/seismo_fleet.db"), Path("data/sfm.db"), Path("data/seismo.db")] + db_path = next((p for p in possible_paths if p.exists()), None) + if db_path is None: + print(f"Database not found in any of: {[str(p) for p in possible_paths]}") + print("A fresh DB created via models.py will include these columns automatically.") + return + + print(f"Using database: {db_path}") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(projects)") + existing = {row[1] for row in cursor.fetchall()} + + for col, ddl in _COLUMNS.items(): + if col in existing: + print(f"○ Column already exists: projects.{col}") + continue + try: + cursor.execute(f"ALTER TABLE projects ADD COLUMN {col} {ddl}") + print(f"✓ Added column: projects.{col} ({ddl})") + except sqlite3.OperationalError as e: + print(f"✗ Failed to add projects.{col}: {e}") + + # Unique index on the link token (separate from ADD COLUMN; idempotent via IF NOT EXISTS). + try: + cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_projects_portal_link_token " + "ON projects (portal_link_token)") + print("✓ Ensured unique index: ix_projects_portal_link_token") + except sqlite3.OperationalError as e: + print(f"✗ Failed to create index: {e}") + + conn.commit() + conn.close() + print("\n✓ Project portal-auth migration complete.") + + +if __name__ == "__main__": + migrate() +``` + +- [ ] **Step 5: Run to verify pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_migration.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/models.py backend/migrate_add_project_portal_auth.py tests/test_portal_migration.py +git commit -m "feat: add per-project portal gate columns + migration" +``` + +--- + +## Task 4: Portal session-mint + link-token + lockout helpers + +**Files:** +- Modify: `backend/portal_auth.py` (append new helpers + a lockout store) +- Test: `tests/test_portal_auth_helpers.py` + +These build on the existing `ensure_project_client`-style pattern but pin a **dedicated 1:1 portal client** to the project, and add link-token resolution + an in-memory lockout. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_portal_auth_helpers.py`: +```python +import time +from tests.conftest import make_project +from backend import portal_auth as pa +from backend.models import Client, ClientAccessToken +from backend.auth_passwords import hash_password + + +def test_portal_client_for_project_is_1to1_and_idempotent(db_session): + p = make_project(db_session) + c1 = pa.portal_client_for_project(p, db_session) + c2 = pa.portal_client_for_project(p, db_session) + assert isinstance(c1, Client) and c1.id == c2.id + assert c1.slug == f"portal-{p.id}" + # exactly one client row for this project + assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1 + + +def test_mint_portal_session_returns_usable_token_id(db_session): + p = make_project(db_session) + tid = pa.mint_portal_session(p, db_session) + tok = db_session.query(ClientAccessToken).filter_by(id=tid, revoked_at=None).first() + assert tok is not None + # the cookie built from it resolves back to this project's portal client + cookie = pa.make_session_cookie(tid) + client = pa.client_from_cookie(cookie, db_session) + assert client is not None and client.slug == f"portal-{p.id}" + + +def test_resolve_project_by_link_token(db_session): + p = make_project(db_session, portal_enabled=True, portal_link_token="tok-abc") + assert pa.resolve_project_by_link_token("tok-abc", db_session).id == p.id + assert pa.resolve_project_by_link_token("nope", db_session) is None + + +def test_resolve_project_ignores_disabled_portal(db_session): + make_project(db_session, portal_enabled=False, portal_link_token="tok-off") + assert pa.resolve_project_by_link_token("tok-off", db_session) is None + + +def test_lockout_after_max_attempts(): + pa.clear_failures("k1") + assert pa.is_locked("k1") is False + for _ in range(pa.MAX_ATTEMPTS): + pa.register_failure("k1") + assert pa.is_locked("k1") is True + pa.clear_failures("k1") + assert pa.is_locked("k1") is False +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_auth_helpers.py -v` +Expected: FAIL (`AttributeError: module 'backend.portal_auth' has no attribute 'portal_client_for_project'`). + +- [ ] **Step 3: Append the helpers to `backend/portal_auth.py`** + +Add at the end of `backend/portal_auth.py`: +```python +# --- Phase-1 per-project password gate ------------------------------------------- +# A portal-enabled project gets its OWN dedicated client (slug "portal-") +# owning exactly that project, so the existing client-scoped routes are automatically +# per-project. Project.client_id is left untouched (deferred per-client rollup). + +from backend.models import Project # local import; Project not needed above + + +def portal_client_for_project(project, db) -> Client: + """Get-or-create the dedicated 1:1 portal client for a project.""" + slug = f"portal-{project.id}" + client = db.query(Client).filter_by(slug=slug).first() + if client is None: + client = Client(id=str(uuid.uuid4()), + name=(project.client_name or project.name or "Client"), + slug=slug, active=True) + db.add(client) + db.flush() + return client + + +def mint_portal_session(project, db) -> str: + """Ensure the project's portal client + an access token exist; return the token + id to seal into a session cookie. Reuses an existing token to avoid clutter.""" + client = portal_client_for_project(project, db) + tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first() + if tok is None: + tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, + token_hash=hash_token(secrets.token_urlsafe(32)), + label="portal") + db.add(tok) + db.commit() + return tok.id + + +def resolve_project_by_link_token(link_token: str, db): + """Return the portal-enabled Project for a link token, or None.""" + if not link_token: + return None + return db.query(Project).filter_by( + portal_link_token=link_token, portal_enabled=True).first() + + +# In-memory brute-force lockout (per link_token+IP). Resets on restart; adequate for +# a read-only surface behind the UniFi edge. Single-worker dev; note multi-worker +# would need a shared store. +MAX_ATTEMPTS = 5 +LOCK_SECONDS = 15 * 60 +_failures: dict = {} # key -> (count, first_failure_epoch) + + +def is_locked(key: str) -> bool: + rec = _failures.get(key) + if not rec: + return False + count, first = rec + if count < MAX_ATTEMPTS: + return False + if (time.time() - first) > LOCK_SECONDS: + _failures.pop(key, None) # window expired + return False + return True + + +def register_failure(key: str) -> None: + count, first = _failures.get(key, (0, time.time())) + _failures[key] = (count + 1, first) + + +def clear_failures(key: str) -> None: + _failures.pop(key, None) +``` + +Note: `hash_token`, `secrets`, `uuid`, `time`, `Client`, `ClientAccessToken` are already imported at the top of `portal_auth.py`. + +- [ ] **Step 4: Run to verify pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_auth_helpers.py -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add backend/portal_auth.py tests/test_portal_auth_helpers.py +git commit -m "feat: per-project portal session mint + link-token resolve + lockout" +``` + +--- + +## Task 5: The password-gate page + the password prompt template + +**Files:** +- Create: `templates/portal/password.html` +- Modify: `backend/routers/portal.py` (add GET + POST `/portal/p/{link_token}`; import `Form`, the new helpers, `verify_password`) +- Test: `tests/test_portal_gate.py` + +- [ ] **Step 1: Create the password prompt template** + +Create `templates/portal/password.html`: +```jinja +{% extends "portal/base.html" %} +{% block title %}{{ project_name }}{% endblock %} +{% block content %} +
+
+ + + +
+

{{ project_name }}

+

Enter the password to view this monitoring portal.

+ {% if error %} +

{{ error }}

+ {% endif %} +
+ + + +
+
+{% endblock %} +``` + +- [ ] **Step 2: Write the failing route tests** + +Create `tests/test_portal_gate.py`: +```python +from tests.conftest import make_project +from backend import portal_auth as pa +from backend.auth_passwords import hash_password + + +def _enabled_project(db_session, token="tok-1", password="secretpw"): + return make_project(db_session, portal_enabled=True, portal_link_token=token, + portal_password_hash=hash_password(password)) + + +def test_get_prompt_renders_for_valid_token(client, db_session): + _enabled_project(db_session) + r = client.get("/portal/p/tok-1") + assert r.status_code == 200 + assert "password" in r.text.lower() + + +def test_get_unknown_token_shows_generic_page(client, db_session): + r = client.get("/portal/p/does-not-exist") + assert r.status_code in (403, 404) + assert "password" not in r.text.lower() or "isn't valid" in r.text.lower() + + +def test_wrong_password_is_rejected(client, db_session): + _enabled_project(db_session, password="rightpw") + r = client.post("/portal/p/tok-1", data={"password": "wrongpw"}, follow_redirects=False) + assert r.status_code == 200 # re-renders the form, no cookie + assert "portal_session" not in r.headers.get("set-cookie", "") + + +def test_correct_password_sets_cookie_and_redirects(client, db_session): + _enabled_project(db_session, password="rightpw") + r = client.post("/portal/p/tok-1", data={"password": "rightpw"}, follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/portal" + assert "portal_session=" in r.headers.get("set-cookie", "") + + +def test_lockout_after_five_wrong(client, db_session): + _enabled_project(db_session, token="tok-lock", password="rightpw") + for _ in range(5): + client.post("/portal/p/tok-lock", data={"password": "x"}, follow_redirects=False) + # 6th attempt — even the CORRECT password is refused while locked + r = client.post("/portal/p/tok-lock", data={"password": "rightpw"}, follow_redirects=False) + assert r.status_code == 200 + assert "portal_session=" not in r.headers.get("set-cookie", "") + assert "too many" in r.text.lower() +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_gate.py -v` +Expected: FAIL (404 — routes not defined). + +- [ ] **Step 4: Add the routes to `backend/routers/portal.py`** + +Update the imports block in `backend/routers/portal.py`: +```python +from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, Form +``` +and extend the `backend.portal_auth` import to add the new helpers: +```python +from backend.portal_auth import ( + get_current_client, client_from_cookie, make_session_cookie, resolve_token, + provision_preview_session, PORTAL_OPEN_LINKS, + COOKIE_NAME, COOKIE_MAX_AGE, + resolve_project_by_link_token, mint_portal_session, + is_locked, register_failure, clear_failures, +) +from backend.auth_passwords import verify_password +``` + +Add these two routes (place them just after the existing `/portal/access` handler): +```python +@router.get("/p/{link_token}") +def portal_password_prompt(link_token: str, request: Request, db: Session = Depends(get_db)): + """Secure per-project link: resolve the project from the token, prompt for the + shared password. Generic page if the token is unknown/disabled (no leak).""" + project = resolve_project_by_link_token(link_token, db) + if not project: + return templates.TemplateResponse( + "portal/access_required.html", {"request": request, "reason": "invalid"}, + status_code=404) + return templates.TemplateResponse("portal/password.html", { + "request": request, "link_token": link_token, + "project_name": project.name, "error": None}) + + +@router.post("/p/{link_token}") +def portal_password_submit(link_token: str, request: Request, + password: str = Form(...), db: Session = Depends(get_db)): + """Verify the shared password; on success mint a project-scoped session cookie.""" + project = resolve_project_by_link_token(link_token, db) + if not project: + return templates.TemplateResponse( + "portal/access_required.html", {"request": request, "reason": "invalid"}, + status_code=404) + + lock_key = f"{link_token}:{request.client.host if request.client else '?'}" + if is_locked(lock_key): + return templates.TemplateResponse("portal/password.html", { + "request": request, "link_token": link_token, "project_name": project.name, + "error": "Too many attempts. Try again in 15 minutes."}, status_code=200) + + if not project.portal_password_hash or not verify_password(password, project.portal_password_hash): + register_failure(lock_key) + return templates.TemplateResponse("portal/password.html", { + "request": request, "link_token": link_token, "project_name": project.name, + "error": "Incorrect password."}, status_code=200) + + clear_failures(lock_key) + token_id = mint_portal_session(project, db) + resp = RedirectResponse(url="/portal", status_code=303) + resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax") + logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened") + return resp +``` + +- [ ] **Step 5: Run to verify pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_gate.py -v` +Expected: PASS (5 tests). (If a prior test left lock state, restart the container or run the file in isolation — lockout state is module-global by design.) + +- [ ] **Step 6: Commit** + +```bash +git add templates/portal/password.html backend/routers/portal.py tests/test_portal_gate.py +git commit -m "feat: per-project portal password gate (/portal/p/{token}) + lockout" +``` + +--- + +## Task 6: Per-project scope isolation test + +**Files:** +- Test: `tests/test_portal_scope.py` + +Proves a session minted for project A cannot read project B's location. + +- [ ] **Step 1: Write the test** + +Create `tests/test_portal_scope.py`: +```python +import uuid +from datetime import datetime +from tests.conftest import make_project +from backend import portal_auth as pa +from backend.auth_passwords import hash_password +from backend.models import MonitoringLocation + + +def _sound_location(db_session, project): + loc = MonitoringLocation( + id=str(uuid.uuid4()), project_id=project.id, name="Site", + location_type="sound", created_at=datetime.utcnow()) + db_session.add(loc) + db_session.commit() + return loc + + +def test_session_for_A_cannot_open_B_location(client, db_session): + a = make_project(db_session, portal_enabled=True, portal_link_token="ta", + portal_password_hash=hash_password("pw")) + b = make_project(db_session) + b_loc = _sound_location(db_session, b) + + # Establish an A session + r = client.post("/portal/p/ta", data={"password": "pw"}, follow_redirects=False) + assert r.status_code == 303 + + # Try to open B's location page → 404 (not 403), no leak + r2 = client.get(f"/portal/location/{b_loc.id}") + assert r2.status_code == 404 +``` + +Note: confirm `MonitoringLocation`'s required columns by checking `backend/models.py`; add any `nullable=False` fields the constructor needs (e.g. `sort_order` defaults). If the model requires extra non-null fields, set them in `_sound_location`. + +- [ ] **Step 2: Run to verify pass (behavior already implemented by `resolve_client_location`)** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_scope.py -v` +Expected: PASS. (If it fails on a missing non-null column, fix `_sound_location` per the note, not the app.) + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_portal_scope.py +git commit -m "test: portal session is isolated to its own project (404 on others)" +``` + +--- + +## Task 7: Operator "Portal access" endpoints + +**Files:** +- Modify: `backend/main.py` (add 4 routes near the existing `/projects/{id}/portal-*` routes ~`main.py:417-487`) +- Test: `tests/test_portal_access_admin.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_portal_access_admin.py`: +```python +from tests.conftest import make_project +from backend.models import Project + + +def test_enable_creates_link_token_and_reports_state(client, db_session): + p = make_project(db_session) + r = client.post(f"/projects/{p.id}/portal-access/enable") + assert r.status_code == 200 + body = r.json() + assert body["enabled"] is True + assert body["link_url"].endswith(f"/portal/p/{db_session.get(Project, p.id).portal_link_token}") + + +def test_set_password_returns_raw_once_and_stores_hash(client, db_session): + p = make_project(db_session) + client.post(f"/projects/{p.id}/portal-access/enable") + r = client.post(f"/projects/{p.id}/portal-access/password") + assert r.status_code == 200 + raw = r.json()["password"] + assert len(raw) >= 12 + fresh = db_session.get(Project, p.id) + assert fresh.portal_password_hash and fresh.portal_password_hash != raw + + +def test_disable_turns_off_and_rotates_token(client, db_session): + p = make_project(db_session) + client.post(f"/projects/{p.id}/portal-access/enable") + old = db_session.get(Project, p.id).portal_link_token + r = client.post(f"/projects/{p.id}/portal-access/disable") + assert r.status_code == 200 + fresh = db_session.get(Project, p.id) + assert fresh.portal_enabled is False + assert fresh.portal_link_token != old + + +def test_get_state(client, db_session): + p = make_project(db_session) + r = client.get(f"/projects/{p.id}/portal-access") + assert r.status_code == 200 + assert r.json() == {"enabled": False, "has_password": False, "link_url": None} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_access_admin.py -v` +Expected: FAIL (404). + +- [ ] **Step 3: Implement the four routes in `backend/main.py`** + +Add after the existing `/projects/{project_id}/portal-link/{token_id}/revoke` route (~`main.py:487`): +```python +@app.get("/projects/{project_id}/portal-access") +async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)): + """Current portal-access state for the operator panel.""" + from backend.models import Project + p = db.query(Project).filter_by(id=project_id).first() + if not p: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + link_url = (str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}") \ + if (p.portal_enabled and p.portal_link_token) else None + return {"enabled": bool(p.portal_enabled), "has_password": bool(p.portal_password_hash), + "link_url": link_url} + + +@app.post("/projects/{project_id}/portal-access/enable") +async def project_portal_access_enable(project_id: str, request: Request, db: Session = Depends(get_db)): + """Turn the portal on; mint a link token if one doesn't exist yet.""" + import secrets + from backend.models import Project + p = db.query(Project).filter_by(id=project_id).first() + if not p: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + if not p.portal_link_token: + p.portal_link_token = secrets.token_urlsafe(24) + p.portal_enabled = True + db.commit() + link_url = str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}" + return {"enabled": True, "has_password": bool(p.portal_password_hash), "link_url": link_url} + + +@app.post("/projects/{project_id}/portal-access/password") +async def project_portal_access_password(project_id: str, db: Session = Depends(get_db)): + """Generate a fresh strong password, store its hash, return the raw once.""" + from backend.models import Project + from backend.auth_passwords import hash_password, generate_password + p = db.query(Project).filter_by(id=project_id).first() + if not p: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + raw = generate_password() + p.portal_password_hash = hash_password(raw) + db.commit() + return {"password": raw} + + +@app.post("/projects/{project_id}/portal-access/disable") +async def project_portal_access_disable(project_id: str, db: Session = Depends(get_db)): + """Turn the portal off and rotate the link token (kills the old link).""" + import secrets + from backend.models import Project + p = db.query(Project).filter_by(id=project_id).first() + if not p: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + p.portal_enabled = False + p.portal_link_token = secrets.token_urlsafe(24) # rotate so the old link 404s + db.commit() + return {"enabled": False} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_portal_access_admin.py -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add backend/main.py tests/test_portal_access_admin.py +git commit -m "feat: operator portal-access endpoints (enable/password/disable/state)" +``` + +--- + +## Task 8: Operator "Portal access" panel (replace the share modal) + +**Files:** +- Modify: `templates/projects/detail.html` (replace breadcrumb buttons lines 21-41 and the share modal + JS lines 2101-2219) + +This is a UI task — verify manually in the browser. + +- [ ] **Step 1: Replace the breadcrumb buttons (lines 21-41)** + +Replace the whole `` block (lines 21-41) with: +```html + +
+ + + + + + + Preview + +
+``` + +- [ ] **Step 2: Replace the share modal + JS (lines 2101-2219)** + +Replace the entire `` block and its ` +{% endblock %} +``` + +- [ ] **Step 3: Verify manually in the browser** + +```bash +docker compose restart terra-view +``` +Then open `http://localhost:1001/projects/`, click **Portal access**: +- Toggle On → a link appears. +- Generate new password → a password appears. +- Copy buttons work. +- Open the link in a private window → password prompt → enter the generated password → lands on the project's read-only portal. + +- [ ] **Step 4: Commit** + +```bash +git add templates/projects/detail.html +git commit -m "feat: operator Portal access panel (enable + password + link)" +``` + +--- + +## Task 9: Retire the interim magic-link / open-link entry points + +**Files:** +- Modify: `backend/routers/portal.py` (remove `/portal/enter/{token}`, `/portal/open/{project_id}`) +- Modify: `backend/main.py` (remove `/projects/{id}/portal-link` create/list/revoke; repoint `/projects/{id}/portal-preview` to `mint_portal_session`; drop the `portal_open_links` template arg) +- Modify: `backend/portal_auth.py` (remove `PORTAL_OPEN_LINKS` + its warning) +- Test: `tests/test_retired_routes.py` + +- [ ] **Step 1: Write the failing test (routes should be gone / preview still works)** + +Create `tests/test_retired_routes.py`: +```python +from tests.conftest import make_project + + +def test_enter_and_open_are_gone(client, db_session): + assert client.get("/portal/enter/anything", follow_redirects=False).status_code == 404 + assert client.get("/portal/open/anything", follow_redirects=False).status_code == 404 + + +def test_portal_link_endpoints_are_gone(client, db_session): + p = make_project(db_session) + assert client.post(f"/projects/{p.id}/portal-link").status_code == 404 + assert client.get(f"/projects/{p.id}/portal-links").status_code == 404 + + +def test_preview_still_mints_a_session(client, db_session): + p = make_project(db_session) + r = client.get(f"/projects/{p.id}/portal-preview", follow_redirects=False) + assert r.status_code == 303 + assert "portal_session=" in r.headers.get("set-cookie", "") +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_retired_routes.py -v` +Expected: FAIL (the old routes still answer 200/303). + +- [ ] **Step 3: Remove `/portal/enter/{token}` and `/portal/open/{project_id}` from `backend/routers/portal.py`** + +Delete both route functions (`portal_enter` at `portal.py:94-110` and `portal_open` at `portal.py:113-132`). From the `backend.portal_auth` import line in `portal.py`, remove the now-unused `resolve_token`, `provision_preview_session`, `PORTAL_OPEN_LINKS` names (keep `make_session_cookie`, `mint_portal_session`, etc.). + +- [ ] **Step 4: Update `backend/main.py`** + +- Delete the three routes `project_portal_link_create`, `project_portal_links_list`, `project_portal_link_revoke` (`main.py:436-487`). +- In `project_portal_preview` (`main.py:417-433`), change the body to use the new mint helper: +```python +@app.get("/projects/{project_id}/portal-preview") +async def project_portal_preview(project_id: str, db: Session = Depends(get_db)): + """Operator testing shortcut: open this project's client portal (no CLI).""" + from backend.models import Project + from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE + project = db.query(Project).filter_by(id=project_id).first() + if not project: + return JSONResponse(status_code=404, content={"detail": "Project not found"}) + token_id = mint_portal_session(project, db) + resp = RedirectResponse(url="/portal", status_code=303) + resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax") + return resp +``` +- In `project_detail_page` (`main.py:407-414`), remove the `"portal_open_links": PORTAL_OPEN_LINKS,` context line. +- In the imports (`main.py:72`), change `from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS` to `from backend.portal_auth import PortalAuthError`. + +- [ ] **Step 5: Remove `PORTAL_OPEN_LINKS` from `backend/portal_auth.py`** + +Delete the `PORTAL_OPEN_LINKS = ...` assignment and its `if PORTAL_OPEN_LINKS: logger.warning(...)` block (`portal_auth.py:43-50`). + +- [ ] **Step 6: Run to verify pass + full suite green** + +```bash +docker exec terra-view-terra-view-1 python -m pytest tests/ -v +``` +Expected: all tests PASS (including `test_retired_routes.py`). Fix any import errors surfaced by the removals. + +- [ ] **Step 7: Commit** + +```bash +git add backend/routers/portal.py backend/main.py backend/portal_auth.py tests/test_retired_routes.py +git commit -m "refactor: retire interim magic-link/open-link in favor of password gate" +``` + +--- + +## Task 10: Harden the session cookie (Secure flag, env-driven) + +**Files:** +- Modify: `backend/portal_auth.py` (add `COOKIE_SECURE`) +- Modify: `backend/routers/portal.py` + `backend/main.py` (pass `secure=COOKIE_SECURE` at the cookie set sites) +- Test: `tests/test_cookie_secure.py` + +- [ ] **Step 1: Add the flag to `backend/portal_auth.py`** + +Near `COOKIE_NAME`/`COOKIE_MAX_AGE`, add: +```python +# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at +# the Synology reverse proxy). Default false so plain-HTTP dev still works. +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes") +``` + +- [ ] **Step 2: Write the failing test** + +Create `tests/test_cookie_secure.py`: +```python +import importlib +from tests.conftest import make_project +from backend.auth_passwords import hash_password + + +def test_cookie_secure_flag_is_applied(monkeypatch, client, db_session): + import backend.portal_auth as pa + monkeypatch.setattr(pa, "COOKIE_SECURE", True, raising=False) + # also patch the name imported into the router module + import backend.routers.portal as pr + monkeypatch.setattr(pr, "COOKIE_SECURE", True, raising=False) + + make_project(db_session, portal_enabled=True, portal_link_token="ts", + portal_password_hash=hash_password("pw")) + r = client.post("/portal/p/ts", data={"password": "pw"}, follow_redirects=False) + assert "secure" in r.headers.get("set-cookie", "").lower() +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_cookie_secure.py -v` +Expected: FAIL (cookie has no `Secure`). + +- [ ] **Step 4: Apply `secure=COOKIE_SECURE` at every `set_cookie` site** + +In `backend/routers/portal.py`: add `COOKIE_SECURE` to the `backend.portal_auth` import, and in the `portal_password_submit` `set_cookie` call add `secure=COOKIE_SECURE`: +```python + resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), + max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", + secure=COOKIE_SECURE) +``` +In `backend/main.py` `project_portal_preview`, import `COOKIE_SECURE` alongside the others and add `secure=COOKIE_SECURE` to its `set_cookie` call the same way. + +- [ ] **Step 5: Run to verify pass** + +Run: `docker exec terra-view-terra-view-1 python -m pytest tests/test_cookie_secure.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/portal_auth.py backend/routers/portal.py backend/main.py tests/test_cookie_secure.py +git commit -m "feat: env-driven Secure flag on portal session cookie" +``` + +--- + +## Task 11: Docs, changelog, and rollout notes + +**Files:** +- Modify: `CHANGELOG.md` (`[Unreleased]`) +- Modify: `docs/CLIENT_PORTAL.md` (note Phase-1 password gate supersedes the magic-link) + +- [ ] **Step 1: Add a changelog entry** + +Under `[Unreleased]` in `CHANGELOG.md`, add: +```markdown +### Portal authentication (Phase 1) +- Each project's client portal is now gated by a **secure per-project link + shared password** (argon2-hashed). Operators manage it from the project page's **Portal access** panel (enable, generate password, copy link). +- Brute-force lockout (5 tries / 15 min) on the password gate. +- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links. +- **Upgrade:** run `python3 backend/migrate_add_project_portal_auth.py` per DB. Set `COOKIE_SECURE=true` once served over HTTPS. +``` + +- [ ] **Step 2: Note the supersession in `docs/CLIENT_PORTAL.md`** + +Add a short line at the top of `docs/CLIENT_PORTAL.md` pointing to the new spec/plan and noting the magic-link is retired in favor of the per-project password gate (see `docs/superpowers/specs/2026-06-15-portal-auth-design.md`). + +- [ ] **Step 3: Run the full suite one last time** + +```bash +docker exec terra-view-terra-view-1 python -m pytest tests/ -v +``` +Expected: all green. + +- [ ] **Step 4: Commit** + +```bash +git add CHANGELOG.md docs/CLIENT_PORTAL.md +git commit -m "docs: changelog + portal-auth Phase 1 notes" +``` + +--- + +## Rollout (after merge to dev) + +1. Run the migration on each DB: `docker exec python3 backend/migrate_add_project_portal_auth.py` (dev: `terra-view-terra-view-1`; prod: `terra-view-web-app-1`). Rebuild images first so `argon2-cffi` is present. +2. Set a real `SECRET_KEY` in prod (already required); set `COOKIE_SECURE=true` once behind TLS. +3. Enable a project's portal, set a password, and walk the link→password→dashboard flow over HTTPS before sending a client. + +## Deferred (separate specs later) + +- **Operator auth** (roles, UniFi edge, staged flip) — see `docs/superpowers/specs/2026-06-15-portal-auth-design.md` §Deferred A. +- **Full multi-tenancy** (per-client rollup, individual client accounts) — §Deferred B.