Skip to content

Phase 2 — Production Backend + UI Maturity

Status: Active. Wave 1 closed 2026-04-19. Wave 2a closed 2026-04-20. Wave 2b in progress (engine 0.3.0 pin bump, portfolio aggregation, UI maturity, UI repo split). See plan.md for the phase overview and status across all phases.

Goal

Turn the functionally rich but unauthenticated, single-tenant, stdlib-logged backbone into something that can credibly face real users: bearer-token auth, three-role RBAC, row-level org scoping, structured logs with request/job correlation, and a login + API-key UI. Layer on the maturity items needed before managed hosting (reports, portfolio, observability, prod Docker, UI split) as Wave 2.

Definition of done

Wave 1 — bearer-token auth (JWT + API keys) enforced on every v1 endpoint except /health* and /v1/info; three roles (admin / analyst / viewer) gate every write; every domain row carries org_id and cross-org reads return 404; logs are JSON in prod with request_id / org_id / user_id / job_id; login + API-key management flows work in the UI; Vitest suite green in CI.

Wave 2 — engine 0.3.0 released with uncertainty quantification and large-portfolio perf; PDF/xlsx reports exportable with TCFD/CSRD/ISSB S2 framing; portfolio aggregation endpoints live; Prometheus + Grafana + alerting wired; production Docker Compose (nginx, secrets, restart) green; UI split to climate-lama-ui repo; self-signup + email verification + refresh tokens + Postgres RLS + rate limiting + CSP headers + HttpOnly cookies + CSRF all shipped.

Entry conditions

  • Phase 1 closed 2026-04-17.
  • climate-lama-engine 0.2.0 on PyPI with CostBenefitResult exposed.
  • Domain-exception → HTTPException mapping centralised (#87) — auth/RBAC errors will flow through the same envelope.

All met.


Wave 1 — Auth, RBAC, Multi-Tenancy, Structlog

Wave 1 context

Phase 1 closed three months early. Multi-hazard (RF/TC/WF/WS), scenario metadata, cost-benefit, frequency curves, and insurance all shipped. climate-lama-engine 0.2.0 is on PyPI. The backbone and UI are functionally rich but unauthenticated, single-tenant, and use stdlib logging with no request tracing.

Wave 1 delivers: bearer-token auth (JWT + API keys), three-role RBAC, row-level org scoping via constructor-bound repositories, structured JSON logs with request/job correlation, and a minimal login + API-key UI. UI stays in ui/ (split to own repo is Wave 2).

Wave 1 scope

In: Alembic bootstrap, auth (JWT + HMAC-SHA256 API keys), RBAC, multi-tenancy via repo org_id, structlog with redaction, UI login + API-key management + Vitest setup (#84), password change endpoint, and cleanup backlog folded in as prerequisites (#94, #96, #98, #88).

Out (Wave 2 below): engine 0.3.0, report generation, portfolio aggregation, Prometheus/Grafana, production Docker Compose, UI split to own repo, multi-dataset-per-hazard (#97), compute→map UI lag (#99), rate-limiting, account lockout, CSP headers, HttpOnly cookies, CSRF, refresh tokens, self-signup, email verification, Postgres RLS, OIDC, idle timeout, token refresh on visibility, API-key rotation endpoint, role-change / deactivate-user endpoints, audit log table, invitation flow instead of returning initial passwords, richer organizations metadata (billing_email, plan_tier).

Wave 1 decisions

Question Decision Rationale
Bootstrap flow Migration seeds organizations row and default-org impact functions. First admin created by scripts/create_user.py CLI (which can also create additional orgs via --create-org / --org-name). Keeps secrets out of migration history; avoids a separate CLI for org creation.
Cascade on organizations delete users, api_keys CASCADE; all domain tables RESTRICT. Prevents catastrophic accidental data loss.
Refresh tokens None in Wave 1. Short-lived access tokens (60 min). UI re-prompts on 401. Acceptable pre-beta. Revisit before broader rollout.
/v1/results/{id}/geojson auth JWT required, org-scoped. No anonymous access. Consistent with every other data endpoint. Share-links are Wave 2.
Builtin impact-function seeding (per-org) seed_for_org(org_id) runs on new-org creation. Default-org seeding runs inside the 0004_seed_default_org data migration. POST /v1/impact-functions/seed endpoint retired. Every org has its own rows; queries stay uniform; no runtime surface.
Row-scoping mechanism Repo instances carry org_id at construction. No method-signature changes. Dramatically smaller diff; symmetric between API and worker paths.
Signup Admin-invite only via POST /v1/auth/users (admin-only) and CLI. No self-signup. Deferred to Wave 2 (needs email verification).
First 404 vs 403 on cross-org read Return 404. Avoids leaking existence of other orgs' resources.
Email uniqueness scope Per-org (UNIQUE (org_id, email)). Login body carries org_slug + email + password. Keeps the door open for a single user to hold identities in multiple orgs later.
API-key verification HMAC-SHA256 with pepper (api_key_pepper config); constant-time compare via secrets.compare_digest. bcrypt-per-request caps throughput at ~10 req/s/core; HMAC is µs-scale and still authenticates the secret.
Password change PUT /v1/auth/me/password ships in Wave 1 (self-service, requires current password). Avoids shipping a product where any rotation requires admin intervention.
JWT iss/aud claims Omitted in Wave 1 (single-service deployment); revisit when a second service consumes the same tokens. YAGNI for single-origin Wave 1. leeway=30s on decode for API↔worker clock skew.

Wave 1 architectural notes

  • Repo org_id injection: providers in ../../src/climate_lama/api/dependencies.py construct repos as HazardRepository(session, org_id=auth.org_id). Every query method automatically ANDs WHERE org_id = self._org_id on reads and stamps org_id on writes. Celery tasks build the same repos from params["org_id"]. No test-call-site changes except building a repo with an explicit org_id.
  • Cross-org FK safety: core/compute_service.py (and the CB equivalent) validates every referenced hazard_dataset_id / exposure_dataset_id / impact_function_id / measure_id belongs to the same org. Belt-and-braces defense before Phase 3's Postgres RLS.
  • AuthContext frozen dataclass: user_id, org_id, role, via: Literal["jwt","api_key"], api_key_id. Sourced once per request; read everywhere.
  • Prefix-dispatch auth: API keys start with clk_; JWTs start with eyJ. Dispatch by prefix — no wasted JWT decodes on API-key traffic.
  • Stream parallelism: A and F are independent. B0 (Alembic baseline) blocks all of B. B blocks C, D, E. C and F can land in parallel. E can start before C by stubbing AuthContext in tests. G depends on C. H runs incrementally alongside every stream.
  • CORS for dev UI: settings.cors_origins must include http://localhost:5173 (Vite dev server) so the login flow works against a locally-running API during development.
  • Logging middleware vs. auth ordering: request_id is bound in ASGI middleware before auth runs; org_id / user_id are bound later inside get_auth_context. Logs emitted pre-auth (e.g. 401-before-auth) carry request_id only — expected.
  • Structlog redaction: the logging pipeline includes a redactor that masks values for any key named password, current_password, new_password, token, access_token, refresh_token, api_key, api_key_secret, key_hash, authorization (case-insensitive, recursive into nested dicts). Prevents a careless logger.info(payload) from leaking plaintext credentials.

Wave 1 work breakdown — nine streams, 26 issues

Milestone: Phase 2 -- Production. Issue count: 14 pre-existing (Stream A prereqs, G7 Vitest, retrospective backlog) + 12 new filed 2026-04-18 = 26 issues covering Streams A–H plus the B0 Alembic prerequisite.

Stream A — Prereq cleanup (before Stream B)

Stream B — Schema migrations

No Alembic exists today. Schema is created via Base.metadata.create_all(). Stream B starts with B0, which introduces Alembic and a baseline revision that matches the current Base.metadata exactly. All subsequent migrations chain off 0001_baseline.

Every migration uses add-nullable → backfill → NOT NULL for safety and to force explicit default-org handling. Every migration is reversible (downgrade() implemented).

  • B0 (#111) Introduce Alembic and generate 0001_baseline. Add alembic to deps; wire env.py against settings.database_sync_url; generate baseline from current Base.metadata; hand-review to strip spurious diffs; wire alembic upgrade head into ../../tests/conftest.py replacing Base.metadata.create_all(). Production deploy runs alembic upgrade head as a pre-start step; dev auto-applies.
  • B1 (#112) 0002_exposure_centroid_junction (lands with A3). Create exposure_centroid_assignments(exposure_id, hazard_dataset_id, array_index) with unique (exposure_id, hazard_dataset_id); INSERT INTO exposure_centroid_assignments (...) SELECT id, centroid_dataset_id, centroid_idx FROM exposures WHERE centroid_dataset_id IS NOT NULL; then drop the two columns.
  • B2 (#112) 0003_create_auth_tablesCREATE EXTENSION IF NOT EXISTS citext; then:
  • organizations(id uuid PK, name varchar(255) NOT NULL, slug citext UNIQUE NOT NULL, created_at, updated_at)
  • users(id uuid PK, org_id FK organizations ON DELETE RESTRICT, email citext NOT NULL, password_hash varchar(255) NOT NULL, role varchar(32) CHECK IN ('admin','analyst','viewer'), is_active bool default true, last_login_at, created_at, updated_at) with UNIQUE (org_id, email) (per-org, per locked decision).
  • api_keys(id uuid PK, org_id FK ON DELETE CASCADE, user_id FK ON DELETE CASCADE, name varchar(128) NOT NULL, prefix varchar(12) UNIQUE NOT NULL, key_hash varchar(64) NOT NULL, scopes jsonb default '[]', last_used_at, expires_at, revoked_at, created_at, updated_at) with index (org_id, user_id). Note: key_hash is HMAC-SHA256 hex (64 chars).
  • B3 (#112) 0004_seed_default_org_and_impact_functions — idempotent insert (slug='default', name='Default Organization') and invoke the Python seeder helper (same code path as seed_for_org(org_id)) to populate builtin impact functions for the default org.
  • B4 (#112) 0005_add_org_id_nullable — nullable org_id uuid FK on: exposures, hazard_datasets, hazard_events, hazard_centroids, impact_functions, measures, jobs, impact_results, cost_benefit_results.
  • B5 (#112) 0006_backfill_org_idUPDATE ... SET org_id = (SELECT id FROM organizations WHERE slug='default') on each table. Idempotent.
  • B6 (#112) 0007_org_id_not_null_and_uniqueness — flip every org_id to NOT NULL; rescope uniqueness:
  • uq_impact_functions_type_name (haz_type, name)(org_id, haz_type, name)
  • hazard_events.uq_hazard_events_dataset_idx and hazard_centroids.uq_hazard_centroids_dataset_array stay (dataset implies org)
  • Add composite (org_id, created_at) b-tree indexes on jobs, impact_results, cost_benefit_results, exposures, hazard_datasets. PostGIS GiST indexes on .geometry stay single-column.

Stream C — Auth layer (blocks D, G)

  • C1 (#113) New ../../src/climate_lama/core/security.py:
  • passlib bcrypt hash_password / verify_password for user passwords only.
  • python-jose HS256 create_access_token / decode_access_token signed with settings.app_secret_key. Claims: sub=user_id, org=org_id, role, exp, iat, typ="access". decode uses leeway=30s to tolerate API↔worker clock skew.
  • generate_api_key() → returns (plaintext, prefix, key_hash). Format clk_<prefix8>_<secret32>. Prefix is random base32 (exclude ambiguous chars: 0/O/1/I/L). Retry on prefix UNIQUE collision up to 5 times before raising.
  • hash_api_key_secret(secret) / verify_api_key_secret(candidate, stored) — HMAC-SHA256 with settings.api_key_pepper; constant-time compare via secrets.compare_digest. Not bcrypt — bcrypt per request caps throughput at ~10 req/s/core.
  • validate_password(pw: str) — min length 12, at least one uppercase, one digit. Used as a Pydantic validator.
  • C2 (#114) New SQLAlchemy models: models/organization.py, models/user.py, models/api_key.py. Matching Pydantic schemas for every endpoint.
  • C3 (#114) New repositories under ../../src/climate_lama/db/repositories/: organization_repository.py (session-only, cross-org for admin flows), user_repository.py and api_key_repository.py (both (session, org_id)). api_key_repository.get_by_prefix is the one documented exception that operates across orgs — required for auth dispatch before the org is known. api_key_repository.touch_last_used is a no-op when last_used_at > NOW() - interval '5 minutes' to avoid write amplification.
  • C4 (#115) New ../../src/climate_lama/api/v1/auth.py:
  • POST /v1/auth/login — body {org_slug, email, password} (per-org email) → {access_token, token_type="bearer", expires_at, user}. Constant-time password check even when user not found (prevents user enumeration by timing).
  • POST /v1/auth/logout — 204 no-op (stateless JWT).
  • GET /v1/auth/me — current user + org.
  • PUT /v1/auth/me/password — self-service. Body {current_password, new_password}; verifies current, applies validate_password, updates hash; returns 204.
  • POST /v1/auth/api-keys (analyst+) — returns plaintext once.
  • GET /v1/auth/api-keys — owner sees own; admin sees all in org.
  • DELETE /v1/auth/api-keys/{id} — sets revoked_at.
  • POST /v1/auth/users (admin only) — creates user in caller's org. Default role analyst. Returns initial password (admin passes it to the new user out-of-band).
  • OpenAPI HTTPBearer security scheme wired so Swagger /docs "Try it out" sends the Authorization header after login.
  • C5 (#115) Extend ../../src/climate_lama/api/dependencies.py:
  • get_bearer_token (HTTPBearer).
  • get_auth_context — prefix-dispatches clk_ → API-key path, else JWT. API-key path: get_by_prefix (O(1)) → verify_api_key_secret (HMAC-SHA256, constant-time) → check not revoked/expired → fire-and-forget touch_last_used (debounced).
  • require_role(min_role: Role) — dependency factory returning 403 if insufficient.
  • Every existing get_*_repo provider gains auth: AuthContext = Depends(get_auth_context) and constructs the repo with org_id=auth.org_id.
  • C6 (#113) Extend ../../src/climate_lama/config.py: tighten app_env to Literal["dev","prod"] (normalize current "development" default → "dev"); add jwt_algorithm="HS256", api_key_prefix="clk_", api_key_pepper (required; startup guard raises if placeholder and app_env="prod"). Same guard extended to the existing app_secret_key. Extend cors_origins default to include http://localhost:5173. Keep auth_token_expire_minutes=60.

Stream D — RBAC (depends on C)

  • D1 Apply Depends(require_role(...)) across every v1 router:
Endpoint group viewer analyst admin
GET /health*, GET /v1/info public public public
GET of hazards / exposures / impact-functions / measures / jobs / results / results/{id}/geojson / results/{id}/freq-curve / results/{id}/cost-benefit
POST /v1/hazards/ingest, /exposures, /hazards/{id}/assign-centroids, /measures, /compute/impact, /compute/cost-benefit
POST /v1/auth/api-keys (own)
POST /v1/auth/users, DELETE of any domain resource

POST /v1/impact-functions/seed is removed (auto-seed at org creation). Enforcement is a dependency, not middleware.

  • D1 (#116) applies the table above; removes POST /v1/impact-functions/seed.
  • D2 (#115) scripts/create_user.py — CLI for seeding admin and additional users outside the API. Args: --email, --password (prompted via getpass if omitted), --role, --org-slug (defaults to default when exactly one org exists), optional --create-org / --org-name to create a new org on demand (seeds its impact functions via the same helper the 0004 migration uses). Idempotent by (org_slug, email). Duplicate emails exit non-zero with a clean message (no stack trace).

Stream E — Row scoping (depends on B; stubbed AuthContext unblocks before C)

Lands as one PR / one issue (#117) touching every repository. The sub-items below are the work-breakdown checklist inside that issue. Every repo gains an org_id: UUID constructor argument. Methods read self._org_id; every SELECT ANDs it; every INSERT stamps it. No method-signature changes on the call site.

  • E1 job_repository.py (smallest — pattern validation).
  • E2 exposure_repository.py.
  • E3 hazard_repository.py — includes the bulk centroid insert path (ADR-022).
  • E4 impact_function_repository.py. Add seed_for_org(org_id) called automatically on org creation (replaces the public seed endpoint).
  • E5 measure_repository.py.
  • E6 result_repository.py, cost_benefit_repository.py.
  • E7 Celery task params dict gains org_id: str(UUID). compute_impact, compute_cost_benefit, ingest_hazard, assign_centroids in ../../src/climate_lama/worker/tasks.py construct repos with it and filter every DB query. Defense-in-depth: each task asserts params["org_id"] == str(job_row.org_id) after loading the job row — mismatch logs an error and raises, catching tampering or mis-stamped replays.
  • E8 core/compute_service.py: pre-flight same-org check — load each referenced FK row (hazard_dataset, exposure_dataset, impact_function, measure) and 422 if any org_id mismatches auth.org_id. (Repo-level scoping catches this on read; this surfaces it as a clean 422 instead of a 404.)

Stream F — Structlog (independent — can land in parallel) — #118

  • F1 Add structlog>=24.1 to ../../pyproject.toml. New ../../src/climate_lama/logging_config.py exposing configure_logging(env: str). Pipeline:
  • structlog.contextvars.merge_contextvars
  • structlog.stdlib.add_logger_name, add_log_level, PositionalArgumentsFormatter
  • TimeStamper(fmt="iso", utc=True)
  • StackInfoRenderer, format_exc_info
  • Redaction processor — recursively walks event_dict and masks values (→ "***REDACTED***") for any key matching (case-insensitive): password, current_password, new_password, token, access_token, refresh_token, api_key, api_key_secret, key_hash, authorization. Prevents a 500-handler logging a request payload from leaking credentials.
  • JSONRenderer() when app_env=="prod", else ConsoleRenderer(colors=True)

Use structlog.stdlib.ProcessorFormatter.wrap_for_formatter (foreign_pre_chain) so the ~30 existing logging.getLogger(__name__).info(...) sites flow through unchanged. Invoke from ../../src/climate_lama/main.py lifespan startup and from ../../src/climate_lama/worker/celery_app.py via the worker_process_init signal. - F2 ASGI middleware src/climate_lama/api/middleware/logging.py — generate request_id UUIDv4 per request, bind_contextvars(request_id, method, path), set response header X-Request-ID, clear on emit. In get_auth_context, also bind_contextvars(org_id, user_id). Replace ad-hoc request_id generation in ../../src/climate_lama/main.py error handlers with structlog.contextvars.get_contextvars()["request_id"] so envelope and log IDs match. - F3 Celery @task_prerun.connect binds job_id, org_id (from params["org_id"]), task_id; @task_postrun.connect clears.

Stream G — UI Wave 1 (depends on C, D)

UI stays in ui/. Wave 1 = minimum to drive an authenticated backend.

  • G1 (#119) Add react-router-dom@6. Wrap app in <BrowserRouter>; routes: /login, / (dashboard), /settings/api-keys.
  • G2 (#119) ui/src/auth/AuthContext.tsx{ user, token, login, logout, isAuthenticated } via Context. Token in localStorage (key clk_access_token; XSS risk documented in CLIMATE_LAMA_UI.md). On boot, hydrate via GET /v1/auth/me only if a token exists; on 401 clear + redirect. logout() broadcasts via the storage event so other tabs clear too.
  • G3 (#119) LoginPage.tsx — three-field form (org slug, email, password); POST /v1/auth/login. Preserves a from location so post-login lands on the originally requested path.
  • G4 (#120) ApiKeysPage.tsx at /settings/api-keys — list + create (plaintext shown once with copy-to-clipboard) + revoke. Admins see all keys within their org grouped by user; analysts see only their own.
  • G5 (#119) <RequireAuth> wraps the dashboard and api-keys routes; <Navigate to="/login" state={{from: location}} replace /> when unauthenticated.
  • G6 (#121) ../../ui/src/api/client.ts — module-level getAuthHeader() reads current token; every fetch spreads ...getAuthHeader() into headers. Central 401 interceptor: toast "Session expired, please log in again", clear token, redirect; the in-flight request is dropped (retry-with-refresh is Wave 2). Remove the assignCentroidsAndWait bandaid (obsolete after A3).
  • G7 (#84) Vitest + Testing Library — add vitest, @testing-library/react, @testing-library/jest-dom, jsdom to devDependencies. New vitest.config.ts; src/test/setup.ts imports @testing-library/jest-dom. One smoke test per new screen. Wire npm test and add to GH Actions.

Stream H — Tests (incremental, runs with every stream) — #122

  • H1 Update ../../tests/conftest.py to run alembic upgrade head instead of Base.metadata.create_all so the citext extension is enabled and migration logic is covered. Falls through to local Postgres (port 5433 per existing fixture). Depends on B0.
  • H2 New ../../tests/factories.py with plain dict helpers: OrgFactory, UserFactory, ApiKeyFactory. No factory-boy dependency.
  • H3 New fixtures:
  • seeded_default_org (session-scoped, reuses the migration-seeded row for tests that specifically exercise the default-org path).
  • default_org (function-scoped, creates a fresh org per test with a unique slug test_<uuid4-hex-12>) — default for the bulk of suites. Avoids colliding with the migration-seeded slug='default' row.
  • admin_user / analyst_user / viewer_user scoped to default_org.
  • admin_token / analyst_token / viewer_token.
  • Role-specific AsyncClients layered on the existing client fixture, plus api_key_client.
  • H4 Update existing test suites: tests/test_api/* swaps client → role-specific client; tests/test_core/test_repositories.py constructs repos with org_id=default_org.id; tests/test_worker/test_compute_impact.py adds "org_id" to params.
  • H5 New suites:
  • tests/test_api/test_auth.py — login (success/bad-password constant-time checked manually via scripts/bench_login_timing.py, not asserted in CI), me, api-keys lifecycle, password change round-trip, 401/403 matrix.
  • tests/test_api/test_rbac.py — parametrised full-endpoint × full-role table.
  • tests/test_api/test_org_isolation.py — two orgs, verify 404 (not 403) on cross-org fetch, 422 on cross-org FK in compute, no leakage.
  • tests/test_core/test_security.py — hash/verify round-trip, JWT encode/decode, API-key generate/HMAC-hash/verify, prefix-collision retry, password-policy validator, redaction processor masking.

Wave 1 critical files

Wave 1 reuse

  • FastAPI Depends pattern is already established — auth and RBAC hang off it directly.
  • ../../src/climate_lama/db/base.py — every new model reuses the existing Base with id/created_at/updated_at mixin.
  • ADR-021's async/sync boundary (fresh engine per asyncio.run()) applies unchanged to auth code in workers.
  • structlog.stdlib.ProcessorFormatter.wrap_for_formatter preserves all existing stdlib-logger call sites — no touch to the ~30 logger.info(...) lines.
  • Existing HTTPException → envelope mapping from #87 catches all 401/403/404 cleanly.

Wave 1 verification

  1. docker compose up -d postgres redis minio → all healthy.
  2. alembic upgrade head → 0001 baseline + 0002–0007 apply; \dt shows organizations, users, api_keys, exposure_centroid_assignments; \dx shows citext. Default org exists with builtin impact functions seeded.
  3. python scripts/create_user.py --email admin@example.com --password 'StrongPass!2025' --role admin --org-slug default → exits 0; SELECT email, role FROM users; returns admin.
  4. uvicorn climate_lama.main:app --reload. POST /v1/auth/login {org_slug:"default", email, password} → 200, JWT captured as $TOKEN.
  5. GET /v1/auth/me with Authorization: Bearer $TOKEN → 200, role=admin.
  6. POST /v1/auth/api-keys {name:"ci"} → 201 with plaintext clk_.... Save as $KEY. Re-GET excludes the plaintext.
  7. Weak password ("short") on POST /v1/auth/users → 422 with policy-violation details.
  8. Using $KEY: upload exposure CSV → ingest hazard → POST /v1/compute/impact → 202 → poll /v1/jobs/{id} → COMPLETED → GET /v1/results/{id} returns EAD/AAI.
  9. Cross-org: python scripts/create_user.py --email admin-b@example.com --password '...' --role admin --org-slug orgb --create-org --org-name "Org B"; log in as B; GET /v1/hazards{data: []}; GET /v1/hazards/{orgA-hazard-id}404; POST /v1/compute/impact referencing orgA's hazard → 422 (cross-org FK guard from E8).
  10. Logs: app_env=dev → console-formatted; app_env=prod → JSON lines with request_id, org_id, user_id; worker logs show job_id + org_id on every task line. X-Request-ID header present on every response. Log a request with a password field in its body and confirm the value renders as "***REDACTED***" in structured output.
  11. UI: cd ui && npm run dev/ redirects to /login; login succeeds; dashboard loads; /settings/api-keys create-copy-revoke flow works; npm test passes.
  12. Constant-time login: run python scripts/bench_login_timing.py — compares POST /v1/auth/login response times with valid-user-wrong-password vs nonexistent-user; should be within a few ms. Manual verification, not asserted in CI.
  13. Password change round-trip: PUT /v1/auth/me/password {current_password, new_password} → 204; re-login with old password → 401; re-login with new password → 200.
  14. Alembic round-trip: on a scratch DB, alembic upgrade head && alembic downgrade base && alembic upgrade head completes cleanly.
  15. Celery org_id guard: manually enqueue a task with params.org_id that disagrees with the jobs.org_id row — task raises a clear error and logs both IDs (F3 defense-in-depth).

Wave 1 risks & mitigations

Risk Mitigation
Login brute force — no rate limiting in Wave 1. Document as Wave 2 blocker. Requires reverse proxy + slowapi or nginx limit_req. Short-term: constant-time login prevents user enumeration; account lockout after N failures is Wave 2.
XSS drains localStorage token — SPA tokens are reachable from any script. Document in CLIMATE_LAMA_UI.md. CSP headers arrive with reverse proxy in Wave 2. HttpOnly cookies + CSRF is the Wave 2 follow-up.
Test DB schema drifts from migrationscreate_all would miss citext. H1 switches tests to alembic upgrade head on startup.
API-key prefix collision — UNIQUE constraint on 8-hex prefix fails rarely. Generator retries on IntegrityError, max 5 times. Log + raise if exhausted.
Cross-org FK leak via compute request body — user supplies another org's ID. E8's explicit FK-ownership check in compute_service. Repo-level scoping on result retrieval. (Phase 3 Postgres RLS closes the gap defensively.)
Per-org impact-function seeding cost ~10 TC regional curves + 1 RF + 1 WF + 1 WS = 13 rows × N orgs. Negligible (<1 KB per org).
Intensity-unit change invalidates historical results (#96) Wave 1 is pre-release; no prod data. Release note anyway.
Centroid-junction migration drops columns (B1) Backfill INSERT INTO exposure_centroid_assignments SELECT ... runs before op.drop_column. Downgrade path restores columns and backfills in reverse.
app_secret_key / api_key_pepper accidentally shipped as placeholder Config validator raises on startup if app_env="prod" and either is still the placeholder default.
Engine adapter purity Unchanged — adapter is a pure function, receives arrays, returns arrays. No auth surface leaks into worker/models/engine_adapter.py.
API-key hash throughput (R1) — per-request verification on hot paths. HMAC-SHA256 with pepper (µs-scale) instead of bcrypt (~100ms). Constant-time compare via secrets.compare_digest. bcrypt is kept for user passwords only (login is once per session).
JWT blast radius on secret leak (R2) No kid header; rotating app_secret_key invalidates all existing tokens. 60-min expiry caps exposure. Refresh tokens + key rotation plan land in Wave 2.
Stateless logout (R3) POST /v1/auth/logout is a no-op; a stolen JWT is valid until exp. Mitigated by short expiry. Denylist + refresh-token revocation is Wave 2.
last_used_at write amplification (R4) Debounced: touch_last_used is a no-op when last_used_at > NOW() - interval '5 minutes'. Avoids one DB write per API request.
First-admin bootstrap race (R5) Two concurrent create_user.py runs on a fresh DB both attempt admin creation. UNIQUE (org_id, email) catches it; CLI prints a friendly duplicate-user message rather than a stack trace.
Credential leakage in logs (R6) Structlog pipeline includes a redaction processor that masks password, token, api_key, authorization (and variants) recursively. See Stream F architectural note.
Celery task org_id mismatch (R7) Each task asserts params["org_id"] == job_row.org_id on start. Catches tampering or mis-stamped replays before any DB query runs (E7 defense-in-depth).

Wave 1 implementation backlog

Issues in the order they should be picked up. Update Status as work progresses (openin progressdone).

# Issue Title Status
1 #100 chore(ci): add mypy static type-checking to CI open
2 #101 docs(adr): ADR for worker-side CLIMADA runtime removal open
3 #94 chore(api): remove dead intensity_unit field from IngestRequest open
4 #88 chore(api): slim compute endpoint open
5 #107 test: frequency-curve monotonicity invariant open
6 #108 test: scenario metadata round-trip open
7 #96 fix(ingest): align intensity units between ingestors and seeded impact curves open
8 #105 feat(ingest): enforce intensity_unit / impact-curve compatibility invariant open
9 #104 test: cross-hazard end-to-end smoke test (RF/TC/WF/WS) open
10 #106 test: EAD plausibility assertion per hazard on reference datasets open
11 #84 chore(ui): Vitest + Testing Library setup open
12 #118 feat(core): structlog + request_id middleware + Celery binding open
13 #113 feat(core): auth primitives — security.py, JWT, HMAC-SHA256 API keys, config open
14 #111 chore(db): introduce Alembic and generate 0001 baseline ⚠️ critical path open
15 #112 feat(db): auth tables + org-scoped migrations 0002–0007 (also closes #98) open
16 #114 feat(db,api): auth models, repositories, Pydantic schemas open
17 #115 feat(api): /v1/auth endpoints + get_auth_context + create_user.py CLI open
18 #116 feat(api): RBAC via require_role across every v1 router open
19 #117 feat(db): org-scope every repository via constructor org_id open
20 #119 feat(ui): login page + AuthContext + RequireAuth + token storage open
21 #120 feat(ui): API-keys management page (list / create / revoke) open
22 #121 feat(ui): inject bearer token into api client + 401 interceptor open
23 #122 test: auth + RBAC + org-isolation suites + migrate conftest to Alembic open

Items 1–13 have no hard inter-dependencies and can be parallelised. #111 (Alembic baseline) is the critical-path gate: #112 → #114 → #115 → #116 / #117, then UI (#119 → #120 / #121), then #122. #113 and #118 are independent and can progress alongside any stage.


Wave 2 — Maturity

Entry condition: Wave 1 green in CI; at least one integration customer has exercised the auth flow end-to-end.

Wave 2 is split into two sub-waves:

  • Wave 2a — Production-readiness: Docker Compose, observability, auth hardening (all Wave 1 deferred security items), basic report export. Completing 2a satisfies the technical entry conditions for Phase 3.
  • Wave 2b — Product features: portfolio aggregation, UI maturity, UI repo split. Engine 0.3.0 is tracked in the engine repo in parallel and is an entry condition for Wave 2b issues that depend on uncertainty quantification.

Issue sizing rule: every Wave 2 issue must be scoped for autonomous implementation — bounded files, clear acceptance criteria, no mid-task design decisions required.


Wave 2a — Production-Readiness

Wave 2a definition of done

nginx + TLS serving the API; Prometheus scraping with a Grafana dashboard live; every auth security item deferred from Wave 1 shipped and tested; basic report export endpoint returning structured JSON/CSV/PDF.

Stream F — Production Docker Compose

One issue, carries over existing #4.

  • F1 (#4) docker-compose.prod.yml — nginx reverse proxy (TLS termination, self-signed for dev / Let's Encrypt hook for prod), named volumes, restart: unless-stopped on all services, health checks wired to /health/live, secrets via .env.prod file, --env-file .env.prod invocation documented. .env.example.prod lists every required variable with descriptions. Deploy runbook added to docs/DEPLOYMENT.md.

Stream E — Observability

Split existing #5 into two issues.

  • E1 (#5) feat(api): Prometheus metrics endpoint + FastAPI instrumentation — add prometheus-fastapi-instrumentator; expose /metrics (unauthenticated scrape endpoint, excluded from RBAC); custom counters for job completions/failures by hazard type; histogram for request latency by endpoint.
  • E2 (new) docs: Grafana dashboard + alerting via docker-compose overlaydocker-compose.monitoring.yml with Prometheus + Grafana services; preconfigured datasource JSON; dashboard covering request rate, error rate, job queue depth, job duration p95; alerting rule for job failure rate > 5%.

Stream B — Auth Hardening

All security items deferred from Wave 1. Each issue is independently implementable. UI-side items (B12) depend on B4.

  • B1 (new) feat(api): refresh tokens — rotating access + 30-day refreshrefresh_tokens(id, org_id, user_id, token_hash varchar(64), expires_at, revoked_at, created_at) table + migration. POST /v1/auth/refresh validates token, issues new access token + rotated refresh token, invalidates old refresh token. POST /v1/auth/logout revokes the presented refresh token. Access token stays 60 min.

  • B2 (new) feat(api): login rate limiting + account lockout — add slowapi to pyproject.toml; apply @limiter.limit("20/minute") per IP on POST /v1/auth/login; return 429 with Retry-After header on rate limit. Migration adds failed_login_attempts int DEFAULT 0 and locked_until timestamptz to users; login handler increments on bad password, returns 423 with unlock timestamp on lockout, resets counter on success. Lockout threshold and duration in settings (login_max_attempts: int = 10, login_lockout_minutes: int = 15).

  • B3 (new) feat(api): security response headers middlewareContent-Security-Policy: default-src 'self'; script-src 'self', Strict-Transport-Security: max-age=63072000; includeSubDomains (prod only), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin added via FastAPI middleware. settings.security_headers_enabled: bool = True.

  • B4 (new) feat(api): HttpOnly cookie auth path + CSRF double-submitPOST /v1/auth/login additionally sets Set-Cookie: access_token=<jwt>; HttpOnly; SameSite=Lax; Path=/; Secure (prod) / without Secure (dev). get_auth_context checks the access_token cookie as fallback when no Authorization header is present; Authorization: Bearer path remains unchanged for API-key clients. CSRF: set readable XSRF-TOKEN cookie on login; middleware checks X-XSRF-TOKEN header matches on POST/PUT/DELETE/PATCH requests that came via the cookie auth path.

  • B5 (new) feat(db): Postgres RLS — org-isolation row-level security — enable RLS (ALTER TABLE ... ENABLE ROW LEVEL SECURITY; FORCE ROW LEVEL SECURITY) on every domain table; CREATE POLICY org_isolation ON ... USING (org_id = current_setting('app.current_org_id', true)::uuid). get_db_session dependency executes SET LOCAL app.current_org_id = '<org_id>' after acquiring the session. Superuser service account bypasses RLS for migrations. Tests add a cross-org assertion: direct SQL query without the session var returns 0 rows.

  • B6 (new) feat(api): API-key rotation endpointPOST /v1/auth/api-keys/{id}/rotate (analyst+): atomically sets revoked_at on the old key and creates a new key with the same name and scopes; returns new plaintext once. Idempotent on already-revoked key (returns 409).

  • B7 (new) feat(api): user management — list, role-change, deactivateGET /v1/auth/users (admin, paginated with limit/offset); PUT /v1/auth/users/{id} (admin): accepts role and/or is_active; deactivation sets is_active=False and revokes all active refresh tokens for that user — does not hard-delete. 404 if user not in caller's org.

  • B8 (new) feat(api): audit log table + repository + admin endpointaudit_events(id uuid PK, org_id FK, user_id FK nullable, action varchar(64), resource_type varchar(64), resource_id uuid nullable, metadata jsonb, ip_address inet, created_at). Repository log_event() called (fire-and-forget, does not block request) on: login success/fail, API key create/revoke/rotate, user create/deactivate/role-change, resource create/delete. GET /v1/audit-events (admin, paginated, filterable by action and resource_type).

  • B9 (new) feat(api): invitation flow — replace initial-password returninvite_tokens(id uuid PK, org_id FK, email citext, role varchar(32), token_hash varchar(64), expires_at, used_at). POST /v1/auth/users (admin) creates a pending user with is_active=False, generates a one-time invite token (24-hour expiry), sends invite email via SMTP helper, returns 202 (no plaintext password). POST /v1/auth/invite/accept body {token, new_password}: validates token, sets password, activates user, marks token used. Existing create_user.py CLI keeps the direct-password path for local bootstrap.

  • B10 (new) feat(api): self-signup + email verification (feature-flagged)settings.allow_self_signup: bool = False. When enabled: POST /v1/auth/signup (public) body {org_slug, email, password} creates user with is_active=False. email_verification_tokens(id, user_id, token_hash, expires_at, used_at). Sends verification email. POST /v1/auth/verify-email body {token} activates account. SMTP settings: smtp_host, smtp_port, smtp_user, smtp_password, smtp_from (all optional; startup logs warning if invitation/signup enabled without SMTP configured).

  • B11 (new) feat(db,api): org metadata — billing_email, plan_tier — migration adds billing_email varchar(255) and plan_tier varchar(32) NOT NULL DEFAULT 'free' CHECK (plan_tier IN ('free','pro','enterprise')) to organizations. GET /v1/auth/me org object gains these fields. PUT /v1/auth/organizations/{id} (admin): update billing_email and name; plan_tier is system-managed (not user-settable in Wave 2 — reserved for billing integration in Phase 3).

  • B12 (new) feat(ui): HttpOnly cookie auth + idle timeout + visibility refresh — update AuthContext.tsx: remove localStorage reads once B4 lands (cookie is set by backend; client does not need to read it explicitly — fetch with credentials: 'include' sends it automatically); call POST /v1/auth/refresh when tab becomes visible and the decoded token has < 5 minutes to expiry; auto-logout after 30 minutes of inactivity (track mousemove, keydown, pointerdown; debounce timer reset). Depends on B1 (refresh endpoint) and B4 (cookie path).

Stream C — Reports (placeholder)

Reports are an extensive product domain that will eventually support user-defined templates and a dedicated UI template editor. Wave 2a ships a useful structural placeholder only — no TCFD/CSRD framing, no templating engine. Full reporting is deferred to a future phase.

  • C1 (new) feat(api): basic report export endpoint — JSON/CSV/PDF placeholderGET /v1/results/{id}/report?format=json|csv|pdf (analyst+, org-scoped). Response shapes:
  • json: {result_id, hazard_type, scenario, time_horizon, ead, aai, currency, top_exposures: [{name, latitude, longitude, ead}], generated_at}
  • csv: tabular equivalent with Content-Disposition: attachment; filename=report_<id>.csv
  • pdf: fpdf2 one-pager — title block, run-metadata table (hazard, scenario, time horizon, run date), key-metrics table (EAD, AAI), top-10 exposures table. Content-Disposition: attachment; filename=report_<id>.pdf No external template engine. Future: user-defined templates + UI helper.

Wave 2b — Product Features

Wave 2b entry conditions

  • Wave 2a complete and green in CI.
  • climate-lama-engine 0.3.0 released on PyPI (tracked in engine repo: climate-lama-engine #10 bootstrap UQ, #11 large-portfolio perf).

Wave 2b definition of done

Portfolio aggregation endpoints live and tested; UI split to climate-lama-ui with own CI; OIDC and org switcher working; export UI wired to report endpoint;

97 (multi-dataset) and #99 (render lag) resolved.

Stream A — Engine 0.3.0 (external dependency)

Tracked entirely in the engine repo. No backbone issues. Backbone pins engine 0.3.0 in pyproject.toml once released.

Engine issue Title
climate-lama-engine #10 feat: bootstrap uncertainty quantification — confidence intervals on EAD
climate-lama-engine #11 perf: vectorize large-portfolio bottlenecks in ImpactCalc

Stream D — Portfolio Aggregation

  • D1 (new) feat(db): portfolios table — model, migration, repositoryportfolios(id uuid PK, org_id FK, name varchar(255), description text, created_at, updated_at); portfolio_exposures(portfolio_id FK, exposure_id FK, weight numeric(10,4) DEFAULT 1.0, PRIMARY KEY (portfolio_id, exposure_id)). Migration with reversible downgrade(). PortfolioRepository(session, org_id) with create, get, list, update, delete, add_exposure, remove_exposure, list_exposures.

  • D2 (new) feat(api): portfolio CRUD endpointsPOST /v1/portfolios (analyst+), GET /v1/portfolios (viewer+, paginated), GET /v1/portfolios/{id} (viewer+), PUT /v1/portfolios/{id} (analyst+), DELETE /v1/portfolios/{id} (admin), POST /v1/portfolios/{id}/exposures body {exposure_id, weight} (analyst+), DELETE /v1/portfolios/{id}/exposures/{exposure_id} (analyst+). All org-scoped; 404 on cross-org access.

  • D3 (new) feat(api): portfolio aggregation endpoint — weighted EAD/AAIGET /v1/portfolios/{id}/aggregate?scenario=...&time_horizon=... (viewer+). Loads all exposures in portfolio that have a completed impact result for the requested scenario + time horizon; computes weighted sum of EAD and AAI; returns {portfolio_id, scenario, time_horizon, total_ead, total_aai, currency, coverage_pct (exposures with results / total), by_exposure: [{exposure_id, name, ead, aai, weight}]}. Returns 422 if no results exist for any exposure.

Stream G — UI Maturity + Repo Split

  • G1 (new) chore(ui): split ui/ to climate-lama-ui standalone repo — move ui/ contents to a new climate-lama-ui GitHub repo; own package.json, Dockerfile (multi-stage: npm run build → nginx static), nginx.conf, GitHub Actions CI (lint + npm test + build); update backbone docker-compose.yml to reference the built image. Remove ui/ from backbone.

  • G2 (new) feat(ui): portfolio table — sortable/filterable asset list/portfolios route; table columns: name, exposure count, total EAD, risk score; sortable headers; filter bar (hazard type, scenario); row click → portfolio detail view; "New Portfolio" button (analyst+).

  • G3 (new) feat(ui): asset detail panel — per-hazard breakdown slide-out — click a map point → right-side slide-out panel; tabs: Overview (EAD/AAI summary), Per-Hazard (TC/RF/WF/WS bar chart), History (prior calculations list with timestamp + scenario).

  • G4 (new) feat(ui): export UI — CSV/Excel/PDF download — download dropdown on the results view; calls GET /v1/results/{id}/report?format=...; spinner during fetch; saves file via browser download; error toast on failure.

  • G5 (#97) feat(ui): multi-dataset selection per hazard + user-data upload — closes existing #97; per-hazard dataset selector dropdown (populated from GET /v1/hazards?type=...); upload flow for user-supplied raster.

  • G6 (#99) fix(ui): compute-to-map render lag (5–6 s) — closes existing #99; profile the polling → parse → render pipeline; suspected cause: GeoJSON parse on main thread. Fix: move parse to a Web Worker or use streaming.

  • G7 (new) feat(ui): OIDC login option — configurable OIDC provider URL via settings.oidc_provider_url (optional); if set, login page shows "Sign in with SSO" button; PKCE flow; fallback to form login when OIDC not configured.

  • G8 (new) feat(ui): org switcher — multi-org dropdown — header dropdown lists orgs the logged-in user belongs to (requires multi-org user concept from B9 invitation flow); selecting an org triggers re-authentication to that org slug and redirects to dashboard.

Wave 2b deferred issues (carry-over)

Issue Title Notes
#97 UI: multi-dataset selection per hazard Closed by G5
#99 UI: 5–6 s compute→map render lag Closed by G6