23 Commits

Author SHA1 Message Date
serversdown 766f64f35f refactor: final-review cleanup
- delete dead magic-link helpers (resolve_token, ensure_project_client,
  mint_link_token, provision_preview_session) + now-unused datetime import
- key brute-force lockout on link_token alone (IP term only enabled a
  source-IP-rotation bypass; behind the proxy all clients share one IP)
- drop unused PORTAL_BASE_URL from the retired CLI
- add WebSocket ownership tests (unauth + cross-project both close 1008)
2026-06-16 00:28:23 +00:00
serversdown da128f6173 docs: changelog + portal-auth Phase 1 notes 2026-06-16 00:19:33 +00:00
serversdown 20f62a5c0a feat: env-driven Secure flag on portal session cookie
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:16:54 +00:00
serversdown 01180d5725 fix: retire portal_admin mint-link (dead /portal/enter URL); refresh docstrings; assert revoke route gone 2026-06-16 00:15:09 +00:00
serversdown f0a13ea2ff refactor: retire interim magic-link/open-link in favor of password gate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:06:02 +00:00
serversdown 0394f4b0c8 fix: error handling + robust state in Portal access panel JS (per review) 2026-06-16 00:02:33 +00:00
serversdown eb91441904 feat: operator Portal access panel (enable + password + link)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:59:41 +00:00
serversdown 25a4a28433 feat: operator portal-access endpoints (enable/password/disable/state)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:55:10 +00:00
serversdown b8e4718318 fix: link project to its portal client (project.client_id) so the portal isn't empty
Caught by adversarial review of the scope test: portal_client_for_project minted a
dedicated client but never set project.client_id, so the client-scoped routes found
no projects — every location 404'd, including the client's own (empty portal). Now
links the project + adds a positive-case test.
2026-06-15 23:53:19 +00:00
serversdown c3eb900b7e test: portal session is isolated to its own project (404 on others)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:48:08 +00:00
serversdown c74dada8b3 fix: treat enabled-but-passwordless portal as inactive (no dead form / self-lockout) 2026-06-15 23:46:14 +00:00
serversdown d75f405857 feat: per-project portal password gate (/portal/p/{token}) + lockout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:41:37 +00:00
serversdown 446d8704f9 refactor: hoist Project import to top; drop unused test import 2026-06-15 23:39:14 +00:00
serversdown c04830a0ad feat: per-project portal session mint + link-token resolve + lockout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:35:48 +00:00
serversdown b11e1a554f feat: add per-project portal gate columns + migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:32:41 +00:00
serversdown ad6de946b5 refactor: simplify verify_password except clause; drop unused import 2026-06-15 23:31:14 +00:00
serversdown d44625374d feat: argon2 password hashing helpers for the portal 2026-06-15 23:29:26 +00:00
serversdown 33069a070d test: tidy conftest fixtures per review (drop dead try/finally, scope override cleanup, rm unused import) 2026-06-15 23:28:16 +00:00
serversdown ec5d986ac5 test: stand up pytest harness + add argon2-cffi
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:23:41 +00:00
serversdown 0888da32b4 docs: portal-auth Phase 1 implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:11:33 +00:00
serversdown 485e3f165b docs: portal-auth design spec (Phase 1 password gate; operator-auth + multi-tenant deferred)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:27:40 +00:00
serversdown 5f02a0bc21 Merge client portal into dev
Reviewed-on: #61
2026-06-11 23:21:52 -04:00
serversdown 182e224f3c Merge pull request 'Feat: add SLM live monitoring improvements' (#60) from feat/slm-live-monitor into dev
Reviewed-on: #60

## [Unreleased]

SLM live monitoring — fan-out feed + cache-first reads.  Targets **0.14.0**.  The throughline: the NL-43 allows exactly **one** TCP connection at a time, so every page that opened its own device stream (or sent its own `Measure?`/DOD on load) was competing for that single connection — a second viewer saw nothing, and dashboard loads stole polling resolution from the live feed.  This release moves Terra-View entirely onto SLMM's shared, cached monitoring: one DOD poll loop per device, fanned out to all viewers; dashboards read SLMM's cache (a DB read on SLMM's side) instead of touching the device; and the live panels populate instantly from cache on open, upgrading to the live WS only on demand.  Paired with the SLMM-side work (adaptive poll rate, unreachable backoff, device-offline alert) on SLMM branch `dev`.

### Added

- **Fan-out `/monitor` feed consumption.**  The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream.  Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone.  A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`.
- **L1/L10 percentile lines + cards.**  Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10.  Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can).  Missing/`-.-` values leave a gap rather than dropping the line to 0.
- **Live-chart backfill on open.**  Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second.
- **Live Measurements panel auto-populates from cache.**  Opening the dashboard panel fills the KPI cards from cached `/status` and backfills the chart from `/history` — pure cache reads, no device hit.  Shows a measuring badge (● Measuring / ■ Stopped) and a freshness stamp ("as of 3:48 PM (10s ago)", amber + "cached" when stale).  Re-polls the cache every 15s while open; **Start Live Stream** upgrades to the live WS and no longer wipes the backfilled trail (chart point cap raised 60 → 600).
- **Refresh buttons** — one per device-list row, one in the panel header.  On-demand, user-initiated single device read via `GET /api/slmm/{unit}/live` (which also refreshes SLMM's cache), with a spinner + success/error toast, then reloads the device list.
- **Per-unit live-monitoring (keepalive) toggle on `/admin/slmm`** — turns a device's server-side keepalive feed on/off (`POST /monitor/start|stop`), so alerting can keep a device's feed running with no browser attached.

### Changed

- **Dashboard device list + command center read SLMM's cache, not the device.**  `slm_dashboard.py`'s `get_slm_units` pulls each unit's cached status from SLMM's `/roster` (one call, a SLMM DB read) for the badge + freshness; the command-center `get_live_view` reads cached `/status` instead of sending `Measure?` + a fresh DOD on every load.  This stops dashboard loads from stealing the device's single connection from the live monitor.  The elapsed-measurement timer still works because `measurement_start_time` is now included in the cached `/status` response.
- **Device-list freshness reflects real monitoring.**  The "Last check" line now uses SLMM's cached `last_seen` (which the monitor advances on every successful poll) via `unit.cache_last_seen`, instead of the `slm_last_check` roster field the monitor never updates.  The status badge also treats `Measure` as Measuring, matching the panel and SLMM's cache.
- **Status badge relocated** to the card's bottom meta row (next to "Last check"), off the top-right corner where it collided with the chart/gear/refresh action icons.

### Fixed

- **Deploy/bench threw `can't access property "dispatchEvent", e is null`.**  `toggleSLMDeployed()` and the save-config path called `htmx.trigger('#slm-list', 'load')` guarded only by `typeof htmx !== 'undefined'`; no page has a `#slm-list`, so htmx resolved null and called `null.dispatchEvent(...)`.  The deploy POST had already succeeded, so the operator saw both the green success **and** a red error.  Both call sites now guard on the element existing (`slm_settings_modal.html`).
- **Monitor WS proxy leaked `CancelledError` / "task exception never retrieved"** on stream stop — the cleanup awaited pending tasks but only caught `Exception`, missing `CancelledError` (a `BaseException`).
- **"No recent check-in" shown even on an actively-monitored device** — the row read the stale `slm_last_check` roster field instead of SLMM's live cache (see Changed).
- **L1/L10 KPI cards populated but the chart drew no L1/L10 lines** — the card chart only had Lp + Leq datasets.

### Upgrade Notes

Requires the **matching SLMM build (branch `dev`)** — Terra-View now depends on SLMM's fan-out `/monitor` feed, `/history` trail, `/status` carrying `ln1`/`ln2` + `measurement_start_time`, cached `/roster` status, and the `monitor_enabled` keepalive flag.

```bash
# SLMM (branch dev) — REBUILD + MIGRATE (or you'll get `no such column: nl43_status.ln1` 500s)
cd /home/serversdown/slmm && docker compose build slmm && docker compose up -d slmm
docker exec terra-view-slmm-1 python3 migrate_add_ln_percentiles.py
docker exec terra-view-slmm-1 python3 migrate_add_monitor_enabled.py

# Terra-View — NO migration; templates are baked into the image, so rebuild (don't just restart)
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
```

The two builds must ship **together**.  Note the `docker-compose.yml` container was renamed for clarity (now `terra-view-terra-view-1`) — adjust any `docker exec` scripts that referenced the old name.

---
2026-06-10 16:33:25 -04:00
25 changed files with 2281 additions and 283 deletions
+8
View File
@@ -105,6 +105,14 @@ SLMM feed; every route resolves the client through one swappable
--- ---
### 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).
- Per-project session isolation (a session for one project can't read another's data); brute-force lockout (5 tries / 15 min) on the password gate.
- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links and the `portal_admin.py mint-link` command.
- **Upgrade:** run `python3 backend/migrate_add_project_portal_auth.py` per DB. Set `COOKIE_SECURE=true` once served over HTTPS.
---
## [0.13.3] - 2026-06-05 ## [0.13.3] - 2026-06-05
Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work. Calibration sync from SFM events. Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored. Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit. Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.
+26
View File
@@ -0,0 +1,26 @@
"""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
_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 Exception: # argon2 raises on mismatch/garbage; treat all as "no match"
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)
+55 -57
View File
@@ -69,7 +69,7 @@ from backend.templates_config import templates
# Client-portal auth: an unauthenticated portal request renders the access page # Client-portal auth: an unauthenticated portal request renders the access page
# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every # (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every
# portal route can simply Depends(get_current_client). # portal route can simply Depends(get_current_client).
from backend.portal_auth import PortalAuthError, PORTAL_OPEN_LINKS from backend.portal_auth import PortalAuthError
@app.exception_handler(PortalAuthError) @app.exception_handler(PortalAuthError)
async def portal_auth_handler(request: Request, exc: PortalAuthError): async def portal_auth_handler(request: Request, exc: PortalAuthError):
@@ -410,81 +410,79 @@ async def project_detail_page(request: Request, project_id: str):
return templates.TemplateResponse("projects/detail.html", { return templates.TemplateResponse("projects/detail.html", {
"request": request, "request": request,
"project_id": project_id, "project_id": project_id,
"portal_open_links": PORTAL_OPEN_LINKS,
}) })
@app.get("/projects/{project_id}/portal-preview") @app.get("/projects/{project_id}/portal-preview")
async def project_portal_preview(project_id: str, db: Session = Depends(get_db)): async def project_portal_preview(project_id: str, db: Session = Depends(get_db)):
"""Operator testing shortcut: log into the client portal scoped to this project """Operator testing shortcut: open this project's client portal (no CLI)."""
(auto-provisioning a client/link if needed), no CLI. Lives under /projects (not
/portal), so a public proxy that exposes only /portal/* won't expose this."""
from backend.models import Project from backend.models import Project
from backend.portal_auth import ( from backend.portal_auth import mint_portal_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE
provision_preview_session, make_session_cookie, COOKIE_NAME, COOKIE_MAX_AGE,
)
project = db.query(Project).filter_by(id=project_id).first() project = db.query(Project).filter_by(id=project_id).first()
if not project: if not project:
return JSONResponse(status_code=404, content={"detail": "Project not found"}) return JSONResponse(status_code=404, content={"detail": "Project not found"})
token_id = provision_preview_session(project, db) token_id = mint_portal_session(project, db)
resp = RedirectResponse(url="/portal", status_code=303) resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id), resp.set_cookie(COOKIE_NAME, make_session_cookie(token_id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax") max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE)
return resp return resp
@app.post("/projects/{project_id}/portal-link") @app.get("/projects/{project_id}/portal-access")
async def project_portal_link_create(project_id: str, request: Request, db: Session = Depends(get_db)): async def project_portal_access_state(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Mint a fresh shareable client link for this project's client. Returns the """Current portal-access state for the operator panel."""
full /portal/enter/<token> URL (shown once). Operator-only (internal app)."""
from backend.models import Project from backend.models import Project
from backend.portal_auth import ensure_project_client, mint_link_token p = db.query(Project).filter_by(id=project_id).first()
project = db.query(Project).filter_by(id=project_id).first() if not p:
if not project:
return JSONResponse(status_code=404, content={"detail": "Project not found"}) return JSONResponse(status_code=404, content={"detail": "Project not found"})
client = ensure_project_client(project, db) link_url = (str(request.base_url).rstrip("/") + f"/portal/p/{p.portal_link_token}") \
raw = mint_link_token(client, db, label="shared link") if (p.portal_enabled and p.portal_link_token) else None
url = str(request.base_url).rstrip("/") + f"/portal/enter/{raw}" return {"enabled": bool(p.portal_enabled), "has_password": bool(p.portal_password_hash),
return {"url": url, "client_name": client.name} "link_url": link_url}
@app.get("/projects/{project_id}/portal-links") @app.post("/projects/{project_id}/portal-access/enable")
async def project_portal_links_list(project_id: str, db: Session = Depends(get_db)): async def project_portal_access_enable(project_id: str, request: Request, db: Session = Depends(get_db)):
"""List active (non-revoked) shareable links for this project's client.""" """Turn the portal on; mint a link token if one doesn't exist yet."""
from backend.models import Project, ClientAccessToken, Client import secrets
project = db.query(Project).filter_by(id=project_id).first() from backend.models import Project
if not project or not project.client_id: p = db.query(Project).filter_by(id=project_id).first()
return {"client_name": None, "links": []} if not p:
client = db.query(Client).filter_by(id=project.client_id).first() return JSONResponse(status_code=404, content={"detail": "Project not found"})
toks = (db.query(ClientAccessToken) if not p.portal_link_token:
.filter_by(client_id=project.client_id, revoked_at=None) p.portal_link_token = secrets.token_urlsafe(24)
.order_by(ClientAccessToken.created_at.desc()).all()) p.portal_enabled = True
return {
"client_name": client.name if client else None,
"links": [{
"id": t.id, "label": t.label,
"created_at": t.created_at.isoformat() if t.created_at else None,
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
} for t in toks],
}
@app.post("/projects/{project_id}/portal-link/{token_id}/revoke")
async def project_portal_link_revoke(project_id: str, token_id: str, db: Session = Depends(get_db)):
"""Revoke one shareable link (scoped to this project's client). Kills the link
and any live session minted from it on the next request."""
from datetime import datetime as _dt
from backend.models import Project, ClientAccessToken
project = db.query(Project).filter_by(id=project_id).first()
if not project or not project.client_id:
return JSONResponse(status_code=404, content={"detail": "Not found"})
tok = db.query(ClientAccessToken).filter_by(id=token_id, client_id=project.client_id).first()
if not tok:
return JSONResponse(status_code=404, content={"detail": "Link not found"})
if not tok.revoked_at:
tok.revoked_at = _dt.utcnow()
db.commit() db.commit()
return {"ok": True} 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}
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse) @app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
@@ -0,0 +1,61 @@
#!/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()
+4
View File
@@ -193,6 +193,10 @@ class Project(Base):
# Project metadata # Project metadata
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick") client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display) client_id = Column(String, nullable=True, index=True) # FK -> clients.id; authoritative portal link (client_name kept for display)
# --- 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
site_address = Column(String, nullable=True) site_address = Column(String, nullable=True)
site_coordinates = Column(String, nullable=True) # "lat,lon" site_coordinates = Column(String, nullable=True) # "lat,lon"
start_date = Column(Date, nullable=True) start_date = Column(Date, nullable=True)
+14 -22
View File
@@ -12,22 +12,22 @@ only its hash is stored.
python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23 python3 backend/portal_admin.py link-project --slug myler --project-number 2567-23
python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall" python3 backend/portal_admin.py link-project --slug myler --project-name "RKM Hall"
# mint a magic access link (FULL URL PRINTED ONCE — copy it now) # mint-link is RETIRED — per-client magic URLs (/portal/enter) no longer exist.
python3 backend/portal_admin.py mint-link --slug myler [--label "Dave's link"] # Client access is now per-PROJECT + password: open the project's page in
# Terra-View → "Portal access" to enable it, generate a password, and copy
# the /portal/p/<token> link. (create-client / link-project / list / revoke
# still operate on the underlying Client/token rows.)
# list clients, their projects, and active links # list clients, their projects, and active links
python3 backend/portal_admin.py list python3 backend/portal_admin.py list
# revoke a link (stops the link AND any live session it minted) # revoke a link (stops the link AND any live session it minted)
python3 backend/portal_admin.py revoke --token-id <TID> python3 backend/portal_admin.py revoke --token-id <TID>
The printed URL base comes from PORTAL_BASE_URL (default http://localhost:8001).
""" """
import os import os
import sys import sys
import uuid import uuid
import secrets
import argparse import argparse
from datetime import datetime from datetime import datetime
@@ -37,9 +37,6 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.database import SessionLocal from backend.database import SessionLocal
from backend.models import Client, ClientAccessToken, Project from backend.models import Client, ClientAccessToken, Project
from backend.portal_auth import hash_token
PORTAL_BASE_URL = os.getenv("PORTAL_BASE_URL", "http://localhost:8001").rstrip("/")
def _get_client(db, slug): def _get_client(db, slug):
@@ -87,20 +84,15 @@ def link_project(args):
def mint_link(args): def mint_link(args):
db = SessionLocal() # Retired: the per-client magic URL (/portal/enter/...) was removed when the
try: # portal moved to per-project + password access. Minting a token here would
c = _get_client(db, args.slug) # only produce a dead link.
raw = secrets.token_urlsafe(32) sys.exit(
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=c.id, "mint-link is retired: per-client magic URLs (/portal/enter/...) no longer exist.\n"
token_hash=hash_token(raw), label=args.label) "Client access is now per-project + password. In Terra-View, open the project's page →\n"
db.add(tok) "'Portal access' to enable the portal, generate a password, and copy the /portal/p/<token>\n"
db.commit() "link to send the client."
print(f"✓ Minted access link for '{c.name}'" )
f"{f' ({args.label})' if args.label else ''} — token id {tok.id}")
print("\n COPY THIS NOW (shown only once):\n")
print(f" {PORTAL_BASE_URL}/portal/enter/{raw}\n")
finally:
db.close()
def revoke(args): def revoke(args):
+64 -54
View File
@@ -21,13 +21,12 @@ import base64
import hashlib import hashlib
import logging import logging
import secrets import secrets
from datetime import datetime
from fastapi import Request, Depends from fastapi import Request, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import Client, ClientAccessToken from backend.models import Client, ClientAccessToken, Project
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,15 +38,9 @@ if SECRET_KEY == "dev-insecure-change-me":
COOKIE_NAME = "portal_session" COOKIE_NAME = "portal_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at
# Plain, no-token portal links (/portal/open/{project_id}). These are an # the Synology reverse proxy). Default false so plain-HTTP dev still works.
# UNAUTHENTICATED, proxy-reachable session-minting path (and a linked project's COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
# open link grants the *whole* client's scope), so they default OFF and must be
# explicitly enabled — set PORTAL_OPEN_LINKS=true only in a dev/prototype env.
PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "false").lower() in ("1", "true", "yes")
if PORTAL_OPEN_LINKS:
logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. "
"Keep this OFF in any internet-facing / production deployment.")
class PortalAuthError(Exception): class PortalAuthError(Exception):
@@ -120,64 +113,81 @@ def get_current_client(request: Request, db: Session = Depends(get_db)) -> Clien
return client return client
def resolve_token(raw_token: str, db: Session): # --- Phase-1 per-project password gate -------------------------------------------
"""Validate a raw magic-URL token. Returns (ClientAccessToken, Client) on # A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
success, or (None, None). Also stamps last_used_at.""" # owning exactly that project. The project is linked to it via project.client_id so
tok = db.query(ClientAccessToken).filter_by( # the existing client-scoped routes (which resolve projects by Project.client_id ==
token_hash=hash_token(raw_token), revoked_at=None # client.id) surface exactly this one project for the portal session — per-project
).first() # isolation with no route changes. (Phase 1 repurposes project.client_id for this; a
if not tok: # real per-client model is the deferred multi-tenant work.)
return None, None
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
if not client:
return None, None
tok.last_used_at = datetime.utcnow()
db.commit()
return tok, client
def ensure_project_client(project, db) -> Client: def portal_client_for_project(project, db) -> Client:
"""Find or create the Client for a project. Reuses the project's linked client """Get-or-create the dedicated 1:1 portal client for a project, and link the
if it has one; otherwise creates/uses a per-project 'preview-<id>' client and project to it so the client-scoped routes resolve exactly this project."""
sets project.client_id (only when unset, so it never clobbers a real link).""" slug = f"portal-{project.id}"
client = None
if project.client_id:
client = db.query(Client).filter_by(id=project.client_id, active=True).first()
if client is None:
slug = f"preview-{project.id}" # full id — an 8-char prefix can collide across projects
client = db.query(Client).filter_by(slug=slug).first() client = db.query(Client).filter_by(slug=slug).first()
if client is None: if client is None:
client = Client(id=str(uuid.uuid4()), client = Client(id=str(uuid.uuid4()),
name=(project.client_name or project.name or "Preview"), name=(project.client_name or project.name or "Client"),
slug=slug, active=True) slug=slug, active=True)
db.add(client) db.add(client)
db.flush() db.flush()
if not project.client_id: if project.client_id != client.id:
project.client_id = client.id project.client_id = client.id # without this, the client owns no projects
db.flush()
return client return client
def mint_link_token(client, db, label=None) -> str: def mint_portal_session(project, db) -> str:
"""Mint a fresh access token for a client and return the RAW secret (caller """Ensure the project's portal client + an access token exist; return the token
builds the /portal/enter/<raw> URL and shows it once). Only the hash is stored.""" id to seal into a session cookie. Reuses an existing token to avoid clutter."""
raw = secrets.token_urlsafe(32) client = portal_client_for_project(project, db)
db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
token_hash=hash_token(raw), label=label))
db.commit()
return raw
def provision_preview_session(project, db) -> str:
"""Operator preview shortcut: ensure a Client + access token exist for a project
and return a token id to seal into a session cookie (no shared link). Reuses an
existing token so repeat previews don't accumulate clutter; the raw secret is
discarded (preview rides the cookie)."""
client = ensure_project_client(project, db)
tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first() tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
if tok is None: if tok is None:
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id, tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
token_hash=hash_token(secrets.token_urlsafe(32)), token_hash=hash_token(secrets.token_urlsafe(32)),
label="preview") label="portal")
db.add(tok) db.add(tok)
db.commit() db.commit()
return tok.id 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, keyed per link_token (the password is shared per
# project, so per-IP granularity buys nothing and an IP term only lets an attacker
# reset the budget by rotating source IPs). Resets on restart; adequate for a
# read-only surface behind the UniFi edge. Single-worker dev; 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)
+64 -47
View File
@@ -1,9 +1,10 @@
""" """
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md). Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed A client opens a per-project secure link (/portal/p/{link_token}), enters the
session cookie, then sees their locations (overview) and per-location read-only shared password, and gets a signed session cookie scoped to that project; they
live data sourced from SLMM's cache. Every data route re-checks ownership. then see that project's locations (overview) and per-location read-only live
data sourced from SLMM's cache. Every data route re-checks ownership.
""" """
import os import os
@@ -14,7 +15,7 @@ from datetime import datetime
import httpx import httpx
import websockets import websockets
from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket from fastapi import APIRouter, Request, Depends, HTTPException, WebSocket, Form
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -23,10 +24,12 @@ from backend.database import get_db, SessionLocal
from backend.models import Client, MonitoringLocation, Project, UnitAssignment from backend.models import Client, MonitoringLocation, Project, UnitAssignment
from backend.templates_config import templates from backend.templates_config import templates
from backend.portal_auth import ( from backend.portal_auth import (
get_current_client, client_from_cookie, make_session_cookie, resolve_token, get_current_client, client_from_cookie, make_session_cookie,
provision_preview_session, PORTAL_OPEN_LINKS, COOKIE_NAME, COOKIE_MAX_AGE, COOKIE_SECURE,
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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/portal", tags=["portal"]) router = APIRouter(prefix="/portal", tags=["portal"])
@@ -91,46 +94,6 @@ def _client_locations(client: Client, db: Session) -> list:
} for loc in locs] } for loc in locs]
@router.get("/enter/{token}")
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
tok, client = resolve_token(token, db)
if not client:
return templates.TemplateResponse(
"portal/access_required.html",
{"request": request, "reason": "invalid"},
status_code=403,
)
resp = RedirectResponse(url="/portal", status_code=303)
resp.set_cookie(
COOKIE_NAME, make_session_cookie(tok.id),
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
)
logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}")
return resp
@router.get("/open/{project_id}")
def portal_open(project_id: str, request: Request, db: Session = Depends(get_db)):
"""Dev-only plain shareable link: open a project's client portal with no token
(gated by PORTAL_OPEN_LINKS). Lets anyone with the URL view it for feedback —
sets the session cookie and lands on /portal. Lives under /portal so it works
through a reverse proxy that exposes only /portal/*."""
if not PORTAL_OPEN_LINKS:
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "required"},
status_code=404)
project = db.query(Project).filter_by(id=project_id).first()
if not project:
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "invalid"},
status_code=404)
token_id = provision_preview_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
@router.get("/logout") @router.get("/logout")
def portal_logout(): def portal_logout():
@@ -147,6 +110,60 @@ def portal_access(request: Request):
) )
@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 or not project.portal_password_hash:
# unknown token, disabled portal, or enabled-but-no-password-set — all look
# identical to a client (no existence/config leak, no self-lockout on a
# passwordless 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 or not project.portal_password_hash:
# unknown token, disabled portal, or enabled-but-no-password-set — all look
# identical to a client (no existence/config leak, no self-lockout on a
# passwordless project).
return templates.TemplateResponse(
"portal/access_required.html", {"request": request, "reason": "invalid"},
status_code=404)
# Shared per-project password → lock per token. (Keying on IP too only enabled a
# bypass via source-IP rotation, and behind the reverse proxy every client shares
# one IP anyway.)
lock_key = link_token
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 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", secure=COOKIE_SECURE)
logger.info(f"[PORTAL] password ok for project {project.id[:8]} → session opened")
return resp
@router.get("") @router.get("")
def portal_home(request: Request, client: Client = Depends(get_current_client), def portal_home(request: Request, client: Client = Depends(get_current_client),
db: Session = Depends(get_db)): db: Session = Depends(get_db)):
+6
View File
@@ -2,6 +2,12 @@
**Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x **Status:** in development (`feat/client-portal`) · **Targets:** 0.14.x
> **Update (Phase-1 auth landed):** the interim magic-link gate described below is
> **retired** — client access is now a per-project secure link + shared password
> (argon2). See the design at `docs/superpowers/specs/2026-06-15-portal-auth-design.md`
> and the build plan at `docs/superpowers/plans/2026-06-15-portal-auth.md`. The
> operator manages access from each project's **Portal access** panel.
A client-facing, **read-only**, **scoped** view into a client's own monitoring A client-facing, **read-only**, **scoped** view into a client's own monitoring
data. The first internet-facing-with-real-clients surface in the system. Built data. The first internet-facing-with-real-clients surface in the system. Built
*inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM *inside* the Terra-View app (new `/portal/*` namespace), reusing the cached SLMM
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,237 @@
# Portal Authentication — Design & Build Plan
**Status:** in development (`feat/portal-auth`) · **Targets:** 0.14.x · **Date:** 2026-06-15
Supersedes the interim shareable magic-link described in
[CLIENT_PORTAL.md](../../CLIENT_PORTAL.md) with a real password gate.
## Goal
Give a client a **secure link + password** that opens a **read-only dashboard**
live data plus access to historical data — for the machines commissioned on
**their project**. Nothing else: no device control, no editing, no internal pages.
This is the first real, internet-facing, client-credentialed surface in the
system.
## Scope
**Phase 1 (this spec — build now):** per-project, password-gated, read-only portal.
**Deferred (designed, not built — captured below so nothing is lost):**
- **Operator auth** — logins + roles for the *internal* app (you / parents).
Full design in [Deferred A](#deferred-a--operator-auth-designed-not-built).
- **Full multi-tenancy** — per-client rollups, per-project separation within a
client, individual client user accounts, and extending the portal to all
client-relevant data. [Deferred B](#deferred-b--full-multi-tenancy).
## Principles (the portal's standing charter)
1. **Read-only.** A client can look, never touch.
2. **Strictly scoped, server-side.** Never trust a project / location / unit id
from the request — always re-resolve ownership.
3. **Cache-first.** Portal live data comes from SLMM's cache (the same cached
reads the internal dashboard uses). A client can never make us hit the device.
4. **The gate is a swappable seam.** Everything routes through the scoping layer
the portal already has; auth is the thin thing in front of it.
## The model
- **Tenant unit = the project.** Each project is its own portal: one link, one
password, showing that project's commissioned machines.
- **Shared credential — "company / project-manager wide."** No individual client
accounts. Because access is read-only, one shared password per project is an
acceptable trade. (Per-person accounts are a Deferred-B item.)
- **The link identifies the project; the password authorizes.** A password alone
can't say *which* project — so the link carries an unguessable, revocable
per-project token, and the password is the shared secret gating it.
## Architecture
Two layers, two subdomains (hosting target: office Synology NAS behind a UniFi
UXG Max; own domain `terra-mechanics.com`).
```
Internet
UniFi UXG Max ── Layer 1 (IT pro): firewall, IPS/IDS, GeoIP allow-list,
│ kill-switch rule, 443 only
Synology NAS ── DSM reverse proxy + Let's Encrypt wildcard TLS
├─ terra-view.terra-mechanics.com → internal app (operator auth = Deferred A)
└─ portal.terra-mechanics.com → LOCKED to /portal/* only, password gate
```
The portal subdomain is **restricted to `/portal/*` at the reverse proxy** — a
client on `portal.` physically cannot reach `/roster`, `/admin/*`, etc., even by
guessing URLs. This path-lock is a load-bearing control for as long as the
internal app remains unauthenticated (until Deferred A lands).
## Data model
Add three columns to **`Project`**:
| Column | Type | Purpose |
|---|---|---|
| `portal_enabled` | bool, default `false` | Is the portal open for this project. |
| `portal_password_hash` | text, nullable | argon2id hash of the shared password. Never plaintext. |
| `portal_link_token` | text, unique, nullable | Unguessable token in the secure link; identifies the project without exposing its raw id, and is revocable (regenerate → old link dies). |
**Reused unchanged:** the `Client → Project → MonitoringLocation →
UnitAssignment → unit` scoping chain and the existing read-only scoped data
routes (`resolve_client_location` + live / history / events).
**Migration:** `migrate_add_project_portal_auth.py` — an `ALTER TABLE` adding the
three columns to the existing (non-empty) `projects` table. Same pattern as
`migrate_add_client_portal.py`; `create_all` won't add columns to an existing
table.
## Auth flow
1. **Operator enables + shares.** On the project page, the operator turns the
portal on; the system generates a strong password + a `portal_link_token`; the
operator copies **link + password** to send the client.
2. **Client opens the link** `portal.terra-mechanics.com/portal/p/{link_token}`
the project is resolved from the token → a **password prompt** renders.
3. **Client submits the password** → argon2-verified against
`portal_password_hash`. On success, a **signed session cookie scoped to that
project** is set (HMAC via the existing `SECRET_KEY` cookie machinery), and
they are redirected to the project dashboard.
4. **Subsequent requests** re-validate the cookie (signature + project still
`portal_enabled` + within cookie max-age) and serve the existing read-only
scoped data.
5. **Logout** clears the cookie. **Revoke** = disable the portal or regenerate the
token / password, which kills outstanding links and any session minted from
them on the next request.
**Lockout:** track failed attempts (per token + IP); after 5 failures refuse for
a 15-minute cooldown. Combined with the UniFi GeoIP/IPS edge, that's solid for a
read-only surface.
**Shared cookie machinery:** lift the portal's cookie sign/verify out of
`portal_auth.py` into a small shared `backend/auth_cookies.py` — one signer, so
the future operator auth (Deferred A) reuses it instead of copy-pasting crypto.
### Relationship to the existing portal code
The portal today is *client-scoped* (a `ClientAccessToken` magic-link → a cookie
covering all of a client's projects, with a `/portal` overview). Phase 1 makes the
entry point *project-scoped*:
- The **`/portal/p/{link_token}` + password** flow becomes the way in; the
interim client magic-link (`/portal/enter/{token}`, `/portal/open/*`,
`PORTAL_OPEN_LINKS`) is **retired** in its favor.
- The existing read-only views (`/portal/location/{id}`, live / history / events)
and the scoping helper are **reused as-is**, just resolved against the project in
the session cookie instead of the client.
- `Client` / `ClientAccessToken` rows are **left in place** (no destructive
migration) — they become the substrate for the Deferred-B per-client rollup.
## Operator "Portal access" panel
On the project detail page (internal app), a panel that:
- Toggles `portal_enabled`.
- **Regenerate password** → shows a freshly generated strong password **once** for
the operator to copy.
- **Copy link** → the `/portal/p/{token}` URL.
- **Revoke** → regenerate the token (old link dies) and/or disable the portal.
This is an operator action. Until operator auth lands (Deferred A), it sits behind
the same posture as the rest of the internal app — see Security notes.
## Error handling
- **Bad password** → generic "incorrect password" + increment fail count.
- **Unknown / disabled / revoked token** → generic "this portal link is no longer
active" page (no project-existence leak).
- **Locked out** → "too many attempts, try again in 15 minutes."
- **Expired / invalid cookie** → back to the password prompt.
- **Portal disabled after a session started** → next request bounced to the prompt.
## Rollout
1. Implement on `feat/portal-auth` → review → merge to `dev`.
2. **Migration** `migrate_add_project_portal_auth.py` on each DB (dev + prod), same
drill as the client-portal migration.
3. **`SECRET_KEY`** must be a real value in prod (already required for the existing
portal cookie; the password gate reuses it).
4. **Hosting:** DSM reverse proxy routes `portal.` → app, locked to `/portal/*`;
Let's Encrypt wildcard TLS; cookies `Secure` once on TLS. UXG Max GeoIP + IPS +
kill-switch handled by the IT pro.
5. Enable a real project's portal, set a password, and test the full
link → password → dashboard flow over HTTPS before sending a client.
## Testing
- **Unit:** argon2 hash/verify; token resolution (valid / unknown / disabled);
lockout counter; cookie sign/verify + scope check; "disabled mid-session" bounce.
- **Scoping:** a session for project A cannot read project B's locations / history
/ events (404, no existence leak).
- **Manual smoke:** enable → copy link + password → open in a fresh browser →
wrong password (lockout) → right password → see live + history → logout.
---
## Deferred A — Operator auth (designed, not built)
Logins + roles for the **internal** app (`terra-view.` subdomain). Closes the
"internal app is wide open" hole. Full design, ready to lift into its own spec:
- **Two layers:** UniFi UXG Max edge (IT-pro owned — firewall, IPS, GeoIP,
kill-switch, 443-only) + in-app auth (built by us). Internet-exposed with login
(no VPN — deliberately, to spare non-technical family members).
- **`OperatorUser` model:** `id, email (unique, lowercased), display_name,
password_hash (argon2id), role, active, created_at, last_login_at,
sessions_valid_from, failed_login_count, locked_until` (+ later `totp_secret`,
`totp_enabled`).
- **Role ladder:** `superadmin > admin > operator`.
- `superadmin` = you — everything + account management (create/disable users,
reset passwords, assign roles).
- `admin` = your parents (company owners) + you — full run of the app, no
operational restrictions.
- `operator` = **future** restricted tier for hires; the ladder accepts it with
no route changes.
- The only thing gated above plain `admin` in v1 is account management
(`superadmin`).
- **Sessions:** stateless signed cookie reusing `auth_cookies.py` + `SECRET_KEY`
(distinct cookie name from the portal). `sessions_valid_from` gives "log out
everywhere" / revoke-on-password-change with no session table.
- **Authorization:** one **deny-by-default middleware** gates the whole internal
app (exempt: `/login`, `/logout`, `/health`, `/static/*`, `/portal/*`);
`require_role("admin"|"superadmin")` guards specific routes. New routes are
protected automatically.
- **Lockout:** 5 fails → 15-min cooldown (doubling).
- **2FA:** deferred; TOTP later, admin/superadmin account first.
- **Safe rollout (no self-lockout):** ship behind a feature flag
`OPERATOR_AUTH_ENABLED` (default **off** = app behaves as today) → seed the first
`superadmin` via a small CLI (`backend/operator_admin.py`, modeled on
`portal_admin.py`) → log in while still open → flip the flag on → create
parents' accounts. Flag back off = instant escape hatch; break-glass =
re-run seed / `reset-password` CLI in the container.
- **`OperatorUser` is a brand-new table** → `create_all` builds it on startup; only
the seed step is required.
## Deferred B — Full multi-tenancy
- Per-client **rollup**: one login spanning all of a client's projects.
- Per-project **separation within a client** (true tenant isolation).
- **Individual client user accounts** (per-person, optional roles) replacing the
shared per-project password.
- Extend the portal to **all client-relevant data types** (beyond sound:
vibration, reports, etc.) — the long-term goal of "everything we can show a
client."
- All additive on the existing scoping seam — no teardown.
## Security notes
- Auth-gated from day one (even the shared password) — never wide-open like the
internal app currently is.
- Scoping enforced server-side; client-supplied ids always re-checked.
- Passwords argon2-hashed; link tokens unguessable + revocable; raw password shown
once.
- `SECRET_KEY` a real secret in prod; cookies `HttpOnly` + `SameSite=Lax` +
`Secure` (once on TLS).
- **Known risk:** the operator "Portal access" panel — and the whole internal app —
is unauthenticated until Deferred A. Mitigated for now by the `/portal/*`
path-lock on the public subdomain plus keeping the internal app off the public
internet. Tracked in the hardening backlog (CLIENT_PORTAL.md).
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest==8.3.3
+1
View File
@@ -10,3 +10,4 @@ httpx==0.25.2
openpyxl==3.1.2 openpyxl==3.1.2
rapidfuzz==3.10.1 rapidfuzz==3.10.1
schedule==1.2.2 schedule==1.2.2
argon2-cffi==23.1.0
+26
View File
@@ -0,0 +1,26 @@
{% 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 %}
+67 -95
View File
@@ -18,16 +18,16 @@
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span> <span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
</nav> </nav>
<!-- Client portal actions for this project --> <!-- Client portal access for this project -->
<div class="shrink-0 flex items-center gap-2"> <div class="shrink-0 flex items-center gap-2">
<button type="button" onclick="openShareModal()" <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" 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="Get a shareable link to this project's client portal"> title="Manage this project's client portal access">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 11-5.656-5.656l1.5-1.5"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 115.656 5.656l-1.5 1.5"></path> 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> </svg>
Copy client link Portal access
</button> </button>
<a href="/projects/{{ project_id }}/portal-preview" target="_blank" rel="noopener" <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" 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"
@@ -36,7 +36,7 @@
<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="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> <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> </svg>
View client portal Preview
</a> </a>
</div> </div>
</div> </div>
@@ -2098,123 +2098,95 @@ document.addEventListener('DOMContentLoaded', function() {
</script> </script>
<!-- Share client portal link modal --> <!-- Portal access modal -->
<div id="share-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" <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)closeShareModal()"> 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="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"> <div class="flex items-center justify-between mb-1">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal link</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Client portal access</h3>
<button onclick="closeShareModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"> <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> <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> </button>
</div> </div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4"> <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
Anyone with a link can view this project's client portal (read-only). Links are revocable. Send the client the link <em>and</em> the password. Read-only. Disabling rotates the link.
</p> </p>
{% if portal_open_links %} <div class="flex items-center justify-between mb-4">
<!-- Dev quick link: plain, no-token URL anyone can open (PORTAL_OPEN_LINKS on) --> <span class="text-sm font-medium text-gray-700 dark:text-gray-200">Portal enabled</span>
<div class="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50"> <button id="pa-toggle" onclick="togglePortalEnabled()"
<label class="block text-xs font-medium text-amber-700 dark:text-amber-300 mb-1">Quick share link (dev — anyone can open, no login)</label> class="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-600">…</button>
<div class="flex gap-2">
<input id="open-url" readonly
class="flex-1 px-3 py-2 text-sm rounded-lg border border-amber-300 dark:border-amber-700 bg-white dark:bg-slate-900 text-gray-800 dark:text-gray-200" />
<button onclick="copyOpenUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button>
</div> </div>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">For feedback during development. Disable <code>PORTAL_OPEN_LINKS</code> before real clients.</p>
</div>
{% endif %}
<div id="share-new" class="hidden mb-4"> <div id="pa-details" class="hidden space-y-4">
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New link &mdash; copy it now</label> <div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Portal link</label>
<div class="flex gap-2"> <div class="flex gap-2">
<input id="share-new-url" readonly <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" /> 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="copyShareUrl(this)" class="px-3 py-2 text-sm rounded-lg bg-seismo-orange text-white hover:bg-orange-600">Copy</button> <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>
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Active links</span>
<button onclick="generateShareLink()" class="text-sm text-seismo-orange hover:text-seismo-navy font-medium">+ Generate new link</button>
</div>
<div id="share-list" class="space-y-2 max-h-56 overflow-y-auto"></div>
</div> </div>
</div> </div>
<script> <script>
const SHARE_PROJECT_ID = "{{ project_id }}"; const PA_PROJECT_ID = "{{ project_id }}";
function openShareModal() { let paEnabled = false;
document.getElementById('share-modal').classList.remove('hidden'); function paToast(msg) { if (window.showToast) showToast(msg, 'error'); else alert(msg); }
document.getElementById('share-new').classList.add('hidden'); function openPortalAccess() { document.getElementById('portal-access-modal').classList.remove('hidden'); loadPortalAccess(); }
const ou = document.getElementById('open-url'); // only present when PORTAL_OPEN_LINKS on function closePortalAccess() { document.getElementById('portal-access-modal').classList.add('hidden'); }
if (ou) ou.value = `${location.origin}/portal/open/${SHARE_PROJECT_ID}`;
loadShareLinks();
}
function closeShareModal() { document.getElementById('share-modal').classList.add('hidden'); }
function copyOpenUrl(btn) { function copyField(id, btn) {
const inp = document.getElementById('open-url'); const inp = document.getElementById(id); inp.select();
inp.select();
const done = () => { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy', 1500); }; 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(); }); if (navigator.clipboard) navigator.clipboard.writeText(inp.value).then(done).catch(() => { document.execCommand('copy'); done(); });
else { document.execCommand('copy'); done(); } else { document.execCommand('copy'); done(); }
} }
async function loadShareLinks() { async function loadPortalAccess() {
const list = document.getElementById('share-list');
list.innerHTML = '<div class="text-sm text-gray-400">Loading…</div>';
try { try {
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-links`)).json(); const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access`);
if (!j.links || !j.links.length) { if (!r.ok) throw new Error('load failed');
list.innerHTML = '<div class="text-sm text-gray-400">No links yet — generate one above.</div>'; renderPortalAccess(await r.json());
return; } catch (e) { paToast('Could not load portal access.'); }
} }
list.innerHTML = ''; function renderPortalAccess(j) {
for (const l of j.links) { paEnabled = !!j.enabled;
const last = l.last_used_at ? ('last used ' + new Date(l.last_used_at + 'Z').toLocaleString()) : 'never used'; const toggle = document.getElementById('pa-toggle');
const row = document.createElement('div'); const details = document.getElementById('pa-details');
row.className = 'flex items-center justify-between gap-2 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700'; toggle.textContent = paEnabled ? 'On — click to disable' : 'Off — click to enable';
row.innerHTML = `<div class="text-sm min-w-0"> toggle.className = 'px-3 py-1.5 text-sm rounded-lg border ' +
<div class="text-gray-800 dark:text-gray-200 truncate">${l.label || 'Link'}</div> (paEnabled ? 'border-green-500 text-green-600 dark:text-green-400' : 'border-slate-300 dark:border-slate-600');
<div class="text-xs text-gray-400">${last}</div></div>`; details.classList.toggle('hidden', !paEnabled);
const btn = document.createElement('button'); document.getElementById('pa-link').value = (paEnabled && j.link_url) ? j.link_url : '';
btn.className = 'shrink-0 text-xs text-red-600 hover:text-red-700';
btn.textContent = 'Revoke';
btn.onclick = () => revokeShareLink(l.id);
row.appendChild(btn);
list.appendChild(row);
} }
} catch (e) { async function togglePortalEnabled() {
list.innerHTML = '<div class="text-sm text-red-500">Failed to load links.</div>'; const action = paEnabled ? 'disable' : 'enable';
}
}
async function generateShareLink() {
try { try {
const j = await (await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link`, { method: 'POST' })).json(); const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/${action}`, { method: 'POST' });
if (j.url) { if (!r.ok) throw new Error('toggle failed');
document.getElementById('share-new').classList.remove('hidden'); const j = await r.json();
document.getElementById('share-new-url').value = j.url; renderPortalAccess(action === 'disable' ? { enabled: false, link_url: null } : j);
loadShareLinks(); } catch (e) { paToast(`Could not ${action} the portal.`); }
} }
} catch (e) { async function regeneratePassword() {
if (window.showToast) showToast('Failed to generate link', 'error'); try {
} const r = await fetch(`/projects/${PA_PROJECT_ID}/portal-access/password`, { method: 'POST' });
} if (!r.ok) throw new Error('password failed');
const j = await r.json();
function copyShareUrl(btn) { if (j.password) { const f = document.getElementById('pa-pass'); f.value = j.password; f.placeholder = ''; }
const inp = document.getElementById('share-new-url'); } catch (e) { paToast('Could not generate a password.'); }
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 revokeShareLink(id) {
if (!confirm('Revoke this link? Anyone using it will be signed out on their next action.')) return;
try { await fetch(`/projects/${SHARE_PROJECT_ID}/portal-link/${id}/revoke`, { method: 'POST' }); loadShareLinks(); }
catch (e) { if (window.showToast) showToast('Failed to revoke', 'error'); }
} }
</script> </script>
{% endblock %} {% endblock %}
View File
+64
View File
@@ -0,0 +1,64 @@
"""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 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():
yield db_session
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.pop(get_db, None)
@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
+23
View File
@@ -0,0 +1,23 @@
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
+16
View File
@@ -0,0 +1,16 @@
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()
+40
View File
@@ -0,0 +1,40 @@
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}
+46
View File
@@ -0,0 +1,46 @@
import time
from tests.conftest import make_project
from backend import portal_auth as pa
from backend.models import Client, ClientAccessToken
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}"
assert db_session.query(Client).filter_by(slug=f"portal-{p.id}").count() == 1
# the project must be linked to its portal client, or client-scoped routes find nothing
assert p.client_id == c1.id
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
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
+60
View File
@@ -0,0 +1,60 @@
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()
def test_enabled_without_password_is_not_accessible(client, db_session):
# enabled portal but no password set yet (operator enabled before generating one)
# must NOT show a usable form — looks like an invalid link, no self-lockout.
make_project(db_session, portal_enabled=True, portal_link_token="tok-nopw")
r = client.get("/portal/p/tok-nopw")
assert r.status_code == 404
assert "isn't valid" in r.text.lower()
# and a POST can't succeed or set a cookie either
r2 = client.post("/portal/p/tok-nopw", data={"password": "anything"}, follow_redirects=False)
assert r2.status_code == 404
assert "portal_session=" not in r2.headers.get("set-cookie", "")
+29
View File
@@ -0,0 +1,29 @@
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")
+81
View File
@@ -0,0 +1,81 @@
import uuid
from datetime import datetime
import pytest
from sqlalchemy.orm import sessionmaker
from starlette.testclient import WebSocketDisconnect
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(),
sort_order=0)
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
def test_session_can_open_its_own_location(client, db_session):
# Positive case: proves the negative test's 404 is real scoping, not a blanket
# "client owns nothing" failure — an A session CAN open A's own location.
a = make_project(db_session, portal_enabled=True, portal_link_token="ta2",
portal_password_hash=hash_password("pw"))
a_loc = _sound_location(db_session, a)
r = client.post("/portal/p/ta2", data={"password": "pw"}, follow_redirects=False)
assert r.status_code == 303
r2 = client.get(f"/portal/location/{a_loc.id}")
assert r2.status_code == 200
def test_ws_stream_rejects_unauthenticated(client, db_session):
# The live-feed WebSocket must refuse a connection with no session cookie (1008).
a = make_project(db_session, portal_enabled=True, portal_link_token="tw1",
portal_password_hash=hash_password("pw"))
a_loc = _sound_location(db_session, a)
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws:
ws.receive_text()
assert exc.value.code == 1008
def test_ws_stream_rejects_cross_project(client, db_session, monkeypatch):
# The WebSocket enforces the SAME per-project ownership as the HTTP routes: a
# B-session opening A's stream is closed 1008 (ownership) before any device feed.
# The handler uses SessionLocal() directly (not the get_db override), so point it
# at the test DB engine so this genuinely exercises the ownership check (not a
# vacuous "client not found").
import backend.routers.portal as portal_router
monkeypatch.setattr(portal_router, "SessionLocal",
sessionmaker(bind=db_session.get_bind()))
a = make_project(db_session, portal_enabled=True, portal_link_token="tw2",
portal_password_hash=hash_password("pw"))
a_loc = _sound_location(db_session, a)
make_project(db_session, portal_enabled=True, portal_link_token="tw3",
portal_password_hash=hash_password("pw"))
# Log in as project B, then aim the stream at project A's location.
assert client.post("/portal/p/tw3", data={"password": "pw"},
follow_redirects=False).status_code == 303
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(f"/portal/api/location/{a_loc.id}/stream") as ws:
ws.receive_text()
assert exc.value.code == 1008
+20
View File
@@ -0,0 +1,20 @@
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
assert client.post(f"/projects/{p.id}/portal-link/sometoken/revoke").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", "")