Client portal auth (Phase 1): per-project link + password gate #63

Merged
serversdown merged 21 commits from feat/portal-auth into dev 2026-06-16 14:59:58 -04:00
Owner

Summary

Gates each project's read-only client portal behind a secure per-project link + shared password (argon2). Operators manage access from a new "Portal access" panel on the project page (enable, generate password, copy link). Replaces the interim per-client magic-link.

  • Per-project session isolation — a session for one project can't read another's data (HTTP and the live WebSocket feed; both covered by tests).
  • Brute-force lockout (5 tries / 15 min) on the password gate.
  • New projects columns (portal_enabled, portal_password_hash, portal_link_token) + idempotent migration.
  • Retired the interim magic-link (/portal/enter, /portal/open, PORTAL_OPEN_LINKS, portal_admin.py mint-link).
  • SECRET_KEY / COOKIE_SECURE now pass through docker-compose.yml (set via .env).

Deferred by design (see docs/superpowers/specs/2026-06-15-portal-auth-design.md): operator auth for the internal app, full multi-tenancy.

Test Plan

  • pytest tests/ — 28 passing (gate, lockout, scope isolation incl. WS, operator endpoints, migration, cookie flag, retired routes)
  • Migration runs clean on the target DB (migrate_add_project_portal_auth.py)
  • Manual: enable a project's portal → set password → open the link → wrong password (lockout) → right password → see only that project's locations

Upgrade / rollout

  • New dep argon2-cffirebuild the image (won't boot without it).
  • Run python3 backend/migrate_add_project_portal_auth.py per DB.
  • Set a real SECRET_KEY (+ COOKIE_SECURE=true once on HTTPS) before the portal is internet-facing.

🤖 Generated with Claude Code

## Summary Gates each project's read-only client portal behind a **secure per-project link + shared password** (argon2). Operators manage access from a new **"Portal access"** panel on the project page (enable, generate password, copy link). Replaces the interim per-client magic-link. - Per-project session isolation — a session for one project can't read another's data (HTTP **and** the live WebSocket feed; both covered by tests). - Brute-force lockout (5 tries / 15 min) on the password gate. - New `projects` columns (`portal_enabled`, `portal_password_hash`, `portal_link_token`) + idempotent migration. - Retired the interim magic-link (`/portal/enter`, `/portal/open`, `PORTAL_OPEN_LINKS`, `portal_admin.py mint-link`). - `SECRET_KEY` / `COOKIE_SECURE` now pass through `docker-compose.yml` (set via `.env`). **Deferred by design** (see `docs/superpowers/specs/2026-06-15-portal-auth-design.md`): operator auth for the internal app, full multi-tenancy. ## Test Plan - [x] `pytest tests/` — 28 passing (gate, lockout, scope isolation incl. WS, operator endpoints, migration, cookie flag, retired routes) - [x] Migration runs clean on the target DB (`migrate_add_project_portal_auth.py`) - [ ] Manual: enable a project's portal → set password → open the link → wrong password (lockout) → right password → see only that project's locations ## Upgrade / rollout - New dep `argon2-cffi` → **rebuild the image** (won't boot without it). - Run `python3 backend/migrate_add_project_portal_auth.py` per DB. - Set a real `SECRET_KEY` (+ `COOKIE_SECURE=true` once on HTTPS) before the portal is internet-facing. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
serversdown added 21 commits 2026-06-16 14:59:43 -04:00
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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)
serversdown merged commit 98bbbcfa86 into dev 2026-06-16 14:59:58 -04:00
Sign in to join this conversation.