Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
52 KiB
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-<project.id>, 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 atdata/seismo_fleet.db.backend/models.py:Project(tableprojects,idis caller-suppliedstr(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 withmax_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);PortalAuthErrorhandler; operator routes/projects/{id}/portal-preview,/projects/{id}/portal-link(create/list/revoke);project_detail_pagepassesportal_open_linkstoprojects/detail.html.templates/portal/base.html: blockstitle/head/content/scripts; shows client chip + Sign-out whenclientcontext var is truthy; globalsesc(),cssVar().templates/portal/access_required.html: lock-card splash branching onreason.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, idempotentPRAGMA table_info+ALTER TABLE ADD COLUMNmigration 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:
"""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:
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:
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
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
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:
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:
"""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
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(theProjectclass, afterclient_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
Projectmodel
In backend/models.py, inside class Project, immediately after the client_id line, add:
# --- 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:
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.pypattern)
Create backend/migrate_add_project_portal_auth.py:
#!/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
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:
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:
# --- Phase-1 per-project password gate -------------------------------------------
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
# 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
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}; importForm, the new helpers,verify_password) -
Test:
tests/test_portal_gate.py -
Step 1: Create the password prompt template
Create templates/portal/password.html:
{% extends "portal/base.html" %}
{% block title %}{{ project_name }}{% endblock %}
{% block content %}
<div class="max-w-md mx-auto mt-20 text-center reveal">
<div class="panel inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6">
<svg class="w-7 h-7 text-[var(--text-dim)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<h1 class="text-2xl font-bold tracking-tight mb-1">{{ project_name }}</h1>
<p class="text-[var(--text-dim)] text-sm mb-6">Enter the password to view this monitoring portal.</p>
{% if error %}
<p class="text-[var(--lvl-bad)] text-sm mb-4">{{ error }}</p>
{% endif %}
<form method="post" action="/portal/p/{{ link_token }}" class="panel p-5 text-left">
<label class="block text-xs text-[var(--text-dim)] mb-1" for="password">Password</label>
<input id="password" name="password" type="password" autofocus required
class="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--panel-b)] text-[var(--text)] mb-4">
<button type="submit"
class="w-full px-4 py-2 rounded-lg bg-seismo-orange text-white font-medium hover:opacity-90">
View portal
</button>
</form>
</div>
{% endblock %}
- Step 2: Write the failing route tests
Create tests/test_portal_gate.py:
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:
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, Form
and extend the backend.portal_auth import to add the new helpers:
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):
@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
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:
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
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:
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):
@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
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 <!-- Client portal actions for this project --> block (lines 21-41) with:
<!-- Client portal access for this project -->
<div class="shrink-0 flex items-center gap-2">
<button type="button" onclick="openPortalAccess()"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-slate-600 bg-slate-700/40 text-gray-200 hover:bg-slate-700 transition-colors"
title="Manage this project's client portal access">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
Portal access
</button>
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-seismo-orange/40 bg-seismo-orange/10 text-seismo-orange hover:bg-seismo-orange/20 transition-colors"
title="Preview this project's client portal in a new tab">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
Preview
</a>
</div>
- Step 2: Replace the share modal + JS (lines 2101-2219)
Replace the entire <!-- Share client portal link modal --> block and its <script> (lines 2101-2219, ending just before {% endblock %}) with:
<!-- Portal access modal -->
<div id="portal-access-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick="if(event.target===this)closePortalAccess()">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg p-6">
<div class="flex items-center justify-between mb-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal access</h3>
<button onclick="closePortalAccess()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Send the client the link <em>and</em> the password. Read-only. Disabling rotates the link.
</p>
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Portal enabled</span>
<button id="pa-toggle" onclick="togglePortalEnabled()"
class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600">…</button>
</div>
<div id="pa-details" class="hidden space-y-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Portal link</label>
<div class="flex gap-2">
<input id="pa-link" readonly class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyField('pa-link', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Password</label>
<div class="flex gap-2">
<input id="pa-pass" readonly placeholder="•••••••• (set one below)"
class="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-gray-50 dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyField('pa-pass', this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div>
<button onclick="regeneratePassword()" class="mt-2 text-sm text-seismo-orange hover:text-seismo-navy font-medium">↻ Generate new password</button>
<p class="text-xs text-gray-400 mt-1">Shown once — copy it now. Regenerating invalidates the old one.</p>
</div>
</div>
</div>
</div>
<script>
const PA_PROJECT_ID = "{{ project_id }}";
function openPortalAccess() { document.getElementById('portal-access-modal').classList.remove('hidden'); loadPortalAccess(); }
function closePortalAccess() { document.getElementById('portal-access-modal').classList.add('hidden'); }
function copyField(id, btn) {
const inp = document.getElementById(id); inp.select();
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); };
if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
else { document.execCommand('copy'); done(); }
}
async function loadPortalAccess() {
const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access`)).json();
renderPortalAccess(j);
}
function renderPortalAccess(j) {
const toggle = document.getElementById('pa-toggle');
const details = document.getElementById('pa-details');
toggle.textContent = j.enabled ? 'On — click to disable' : 'Off — click to enable';
toggle.className = 'px-3 py-1.5 text-sm rounded-lg border ' +
(j.enabled ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-slate-300 dark:border-slate-600');
details.classList.toggle('hidden', !j.enabled);
if (j.enabled && j.link_url) document.getElementById('pa-link').value = j.link_url;
}
async function togglePortalEnabled() {
const on = document.getElementById('pa-toggle').textContent.startsWith('On');
const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access/${on ? 'disable' : 'enable'}`, { method: 'POST' })).json();
if (on) renderPortalAccess({ enabled: false, link_url: null });
else renderPortalAccess(j);
}
async function regeneratePassword() {
const j = await (await fetch(`/projects/${PA_PROJECT_ID}/portal-access/password`, { method: 'POST' })).json();
if (j.password) { const f = document.getElementById('pa-pass'); f.value = j.password; f.placeholder = ''; }
}
</script>
{% endblock %}
- Step 3: Verify manually in the browser
docker compose restart terra-view
Then open http://localhost:1001/projects/<a-real-project-id>, 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
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-linkcreate/list/revoke; repoint/projects/{id}/portal-previewtomint_portal_session; drop theportal_open_linkstemplate arg) -
Modify:
backend/portal_auth.py(removePORTAL_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:
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}frombackend/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:
@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), changefrom backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKStofrom backend.portal_auth import PortalAuthError. -
Step 5: Remove
PORTAL_OPEN_LINKSfrombackend/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
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
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(addCOOKIE_SECURE) -
Modify:
backend/routers/portal.py+backend/main.py(passsecure=COOKIE_SECUREat 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:
# 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:
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_SECUREat everyset_cookiesite
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:
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
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:
### 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
docker exec terra-view-terra-view-1 python -m pytest tests/ -v
Expected: all green.
- Step 4: Commit
git add CHANGELOG.md docs/CLIENT_PORTAL.md
git commit -m "docs: changelog + portal-auth Phase 1 notes"
Rollout (after merge to dev)
- Run the migration on each DB:
docker exec <container> python3 backend/migrate_add_project_portal_auth.py(dev:terra-view-terra-view-1; prod:terra-view-web-app-1). Rebuild images first soargon2-cffiis present. - Set a real
SECRET_KEYin prod (already required); setCOOKIE_SECURE=trueonce behind TLS. - 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.