# 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.