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