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.0on PyPI withCostBenefitResultexposed.- 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_idinjection: providers in ../../src/climate_lama/api/dependencies.py construct repos asHazardRepository(session, org_id=auth.org_id). Every query method automatically ANDsWHERE org_id = self._org_idon reads and stampsorg_idon writes. Celery tasks build the same repos fromparams["org_id"]. No test-call-site changes except building a repo with an explicitorg_id. - Cross-org FK safety:
core/compute_service.py(and the CB equivalent) validates every referencedhazard_dataset_id/exposure_dataset_id/impact_function_id/measure_idbelongs 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 witheyJ. 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
AuthContextin tests. G depends on C. H runs incrementally alongside every stream. - CORS for dev UI:
settings.cors_originsmust includehttp://localhost:5173(Vite dev server) so the login flow works against a locally-running API during development. - Logging middleware vs. auth ordering:
request_idis bound in ASGI middleware before auth runs;org_id/user_idare bound later insideget_auth_context. Logs emitted pre-auth (e.g. 401-before-auth) carryrequest_idonly — 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 carelesslogger.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)¶
- A1 (#94) Remove dead
IngestRequest.intensity_unitfrom ../../src/climate_lama/api/v1/hazards.py and tests. - A2 (#96) Align hazard ingestor
intensity_unitwith seeded impact curves. WS: re-seed Klawa–Ulbrich inm/s(embed thev98normalisation in the seeder so ingested rasters match directly). WF: document K-vs-FWI in ../../src/climate_lama/core/ingest/wildfire.py. Files touched: ../../src/climate_lama/core/impact_function_seeder.py, ingestors under ../../src/climate_lama/core/ingest/. Release-note: existingimpact_resultskeep historical curve references; Wave 1 is pre-release so no prod impact. - A3 (#98) Replace
Exposure.centroid_dataset_id+centroid_idxwithexposure_centroid_assignments(exposure_id, hazard_dataset_id, array_index) - unique
(exposure_id, hazard_dataset_id). Update ../../src/climate_lama/core/centroid_assignment.py to upsert; update ../../src/climate_lama/worker/tasks.py (compute_impact,compute_cost_benefit) to JOIN per hazard; drop UI bandaidassignCentroidsAndWaitin ../../ui/src/api/client.ts. Data migration in 0002 must backfill before dropping columns (see B1). - A4 (#88) Slim
submit_impact_calculationin ../../src/climate_lama/api/v1/compute.py; makeresolve_impact_function_idaccept IDs, not repo handles.
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. Addalembicto deps; wireenv.pyagainstsettings.database_sync_url; generate baseline from currentBase.metadata; hand-review to strip spurious diffs; wirealembic upgrade headinto ../../tests/conftest.py replacingBase.metadata.create_all(). Production deploy runsalembic upgrade headas a pre-start step; dev auto-applies. - B1 (#112)
0002_exposure_centroid_junction(lands with A3). Createexposure_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_tables—CREATE 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)withUNIQUE (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_hashis 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 asseed_for_org(org_id)) to populate builtin impact functions for the default org. - B4 (#112)
0005_add_org_id_nullable— nullableorg_id uuidFK on:exposures,hazard_datasets,hazard_events,hazard_centroids,impact_functions,measures,jobs,impact_results,cost_benefit_results. - B5 (#112)
0006_backfill_org_id—UPDATE ... SET org_id = (SELECT id FROM organizations WHERE slug='default')on each table. Idempotent. - B6 (#112)
0007_org_id_not_null_and_uniqueness— flip everyorg_idto NOT NULL; rescope uniqueness: uq_impact_functions_type_name (haz_type, name)→(org_id, haz_type, name)hazard_events.uq_hazard_events_dataset_idxandhazard_centroids.uq_hazard_centroids_dataset_arraystay (dataset implies org)- Add composite
(org_id, created_at)b-tree indexes onjobs,impact_results,cost_benefit_results,exposures,hazard_datasets. PostGIS GiST indexes on.geometrystay single-column.
Stream C — Auth layer (blocks D, G)¶
- C1 (#113) New ../../src/climate_lama/core/security.py:
- passlib bcrypt
hash_password/verify_passwordfor user passwords only. - python-jose HS256
create_access_token/decode_access_tokensigned withsettings.app_secret_key. Claims:sub=user_id, org=org_id, role, exp, iat, typ="access".decodeusesleeway=30sto tolerate API↔worker clock skew. generate_api_key()→ returns(plaintext, prefix, key_hash). Formatclk_<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 withsettings.api_key_pepper; constant-time compare viasecrets.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.pyandapi_key_repository.py(both(session, org_id)).api_key_repository.get_by_prefixis the one documented exception that operates across orgs — required for auth dispatch before the org is known.api_key_repository.touch_last_usedis a no-op whenlast_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, appliesvalidate_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}— setsrevoked_at.POST /v1/auth/users(admin only) — creates user in caller's org. Default roleanalyst. Returns initial password (admin passes it to the new user out-of-band).- OpenAPI
HTTPBearersecurity scheme wired so Swagger/docs"Try it out" sends theAuthorizationheader after login. - C5 (#115) Extend ../../src/climate_lama/api/dependencies.py:
get_bearer_token(HTTPBearer).get_auth_context— prefix-dispatchesclk_→ 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-forgettouch_last_used(debounced).require_role(min_role: Role)— dependency factory returning 403 if insufficient.- Every existing
get_*_repoprovider gainsauth: AuthContext = Depends(get_auth_context)and constructs the repo withorg_id=auth.org_id. - C6 (#113) Extend
../../src/climate_lama/config.py:
tighten
app_envtoLiteral["dev","prod"](normalize current"development"default →"dev"); addjwt_algorithm="HS256",api_key_prefix="clk_",api_key_pepper(required; startup guard raises if placeholder andapp_env="prod"). Same guard extended to the existingapp_secret_key. Extendcors_originsdefault to includehttp://localhost:5173. Keepauth_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 viagetpassif omitted),--role,--org-slug(defaults todefaultwhen exactly one org exists), optional--create-org/--org-nameto create a new org on demand (seeds its impact functions via the same helper the0004migration 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. Addseed_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
paramsdict gainsorg_id: str(UUID).compute_impact,compute_cost_benefit,ingest_hazard,assign_centroidsin ../../src/climate_lama/worker/tasks.py construct repos with it and filter every DB query. Defense-in-depth: each task assertsparams["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 anyorg_idmismatchesauth.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.1to ../../pyproject.toml. New ../../src/climate_lama/logging_config.py exposingconfigure_logging(env: str). Pipeline: structlog.contextvars.merge_contextvarsstructlog.stdlib.add_logger_name,add_log_level,PositionalArgumentsFormatterTimeStamper(fmt="iso", utc=True)StackInfoRenderer,format_exc_info- Redaction processor — recursively walks
event_dictand 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()whenapp_env=="prod", elseConsoleRenderer(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 inlocalStorage(keyclk_access_token; XSS risk documented in CLIMATE_LAMA_UI.md). On boot, hydrate viaGET /v1/auth/meonly if a token exists; on 401 clear + redirect.logout()broadcasts via thestorageevent so other tabs clear too. - G3 (#119)
LoginPage.tsx— three-field form (org slug, email, password); POST/v1/auth/login. Preserves afromlocation so post-login lands on the originally requested path. - G4 (#120)
ApiKeysPage.tsxat/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; everyfetchspreads...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 theassignCentroidsAndWaitbandaid (obsolete after A3). - G7 (#84) Vitest + Testing Library — add
vitest,@testing-library/react,@testing-library/jest-dom,jsdomto devDependencies. Newvitest.config.ts;src/test/setup.tsimports@testing-library/jest-dom. One smoke test per new screen. Wirenpm testand add to GH Actions.
Stream H — Tests (incremental, runs with every stream) — #122¶
- H1 Update ../../tests/conftest.py to run
alembic upgrade headinstead ofBase.metadata.create_allso thecitextextension 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 slugtest_<uuid4-hex-12>) — default for the bulk of suites. Avoids colliding with the migration-seededslug='default'row.admin_user / analyst_user / viewer_userscoped todefault_org.admin_token / analyst_token / viewer_token.- Role-specific
AsyncClients layered on the existingclientfixture, plusapi_key_client. - H4 Update existing test suites:
tests/test_api/*swapsclient→ role-specific client;tests/test_core/test_repositories.pyconstructs repos withorg_id=default_org.id;tests/test_worker/test_compute_impact.pyadds"org_id"to params. - H5 New suites:
tests/test_api/test_auth.py— login (success/bad-password constant-time checked manually viascripts/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¶
- ../../src/climate_lama/api/dependencies.py
— hang
get_auth_context+require_role; providers build repos withorg_id. - ../../src/climate_lama/main.py —
configure_logging()+ register logging/auth middleware. - ../../src/climate_lama/worker/celery_app.py
—
configure_logging()viaworker_process_init;task_prerun/task_postruncontext binding. - ../../src/climate_lama/config.py — extend; wire secret-rotation guard.
- ../../src/climate_lama/db/repositories/
— constructor signature: every repo now
__init__(self, session, org_id: UUID). - ../../src/climate_lama/core/impact_function_seeder.py — per-org seeding.
- ../../src/climate_lama/core/centroid_assignment.py — junction-table upsert.
- ../../tests/conftest.py — switch to Alembic-driven schema for tests.
Wave 1 reuse¶
- FastAPI
Dependspattern is already established — auth and RBAC hang off it directly. - ../../src/climate_lama/db/base.py —
every new model reuses the existing
Basewithid/created_at/updated_atmixin. - ADR-021's async/sync boundary (fresh engine per
asyncio.run()) applies unchanged to auth code in workers. structlog.stdlib.ProcessorFormatter.wrap_for_formatterpreserves all existing stdlib-logger call sites — no touch to the ~30logger.info(...)lines.- Existing
HTTPException→ envelope mapping from #87 catches all 401/403/404 cleanly.
Wave 1 verification¶
docker compose up -d postgres redis minio→ all healthy.alembic upgrade head→ 0001 baseline + 0002–0007 apply;\dtshowsorganizations,users,api_keys,exposure_centroid_assignments;\dxshowscitext. Default org exists with builtin impact functions seeded.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.uvicorn climate_lama.main:app --reload.POST /v1/auth/login {org_slug:"default", email, password}→ 200, JWT captured as$TOKEN.GET /v1/auth/mewithAuthorization: Bearer $TOKEN→ 200, role=admin.POST /v1/auth/api-keys {name:"ci"}→ 201 with plaintextclk_.... Save as$KEY. Re-GET excludes the plaintext.- Weak password (
"short") onPOST /v1/auth/users→ 422 with policy-violation details. - Using
$KEY: upload exposure CSV → ingest hazard →POST /v1/compute/impact→ 202 → poll/v1/jobs/{id}→ COMPLETED →GET /v1/results/{id}returns EAD/AAI. - 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/impactreferencing orgA's hazard → 422 (cross-org FK guard from E8). - Logs:
app_env=dev→ console-formatted;app_env=prod→ JSON lines withrequest_id,org_id,user_id; worker logs showjob_id+org_idon every task line.X-Request-IDheader present on every response. Log a request with apasswordfield in its body and confirm the value renders as"***REDACTED***"in structured output. - UI:
cd ui && npm run dev→/redirects to/login; login succeeds; dashboard loads;/settings/api-keyscreate-copy-revoke flow works;npm testpasses. - Constant-time login: run
python scripts/bench_login_timing.py— comparesPOST /v1/auth/loginresponse times with valid-user-wrong-password vs nonexistent-user; should be within a few ms. Manual verification, not asserted in CI. - 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. - Alembic round-trip: on a scratch DB,
alembic upgrade head && alembic downgrade base && alembic upgrade headcompletes cleanly. - Celery org_id guard: manually enqueue a task with
params.org_idthat disagrees with thejobs.org_idrow — 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 migrations — create_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
(open → in progress → done).
| # | 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-stoppedon all services, health checks wired to/health/live, secrets via.env.prodfile,--env-file .env.prodinvocation documented..env.example.prodlists every required variable with descriptions. Deploy runbook added todocs/DEPLOYMENT.md.
Stream E — Observability¶
Split existing #5 into two issues.
- E1 (#5)
feat(api): Prometheus metrics endpoint + FastAPI instrumentation— addprometheus-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 overlay—docker-compose.monitoring.ymlwith 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 refresh—refresh_tokens(id, org_id, user_id, token_hash varchar(64), expires_at, revoked_at, created_at)table + migration.POST /v1/auth/refreshvalidates token, issues new access token + rotated refresh token, invalidates old refresh token.POST /v1/auth/logoutrevokes the presented refresh token. Access token stays 60 min. -
B2 (new)
feat(api): login rate limiting + account lockout— addslowapitopyproject.toml; apply@limiter.limit("20/minute")per IP onPOST /v1/auth/login; return 429 withRetry-Afterheader on rate limit. Migration addsfailed_login_attempts int DEFAULT 0andlocked_until timestamptztousers; 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 middleware—Content-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-originadded via FastAPI middleware.settings.security_headers_enabled: bool = True. -
B4 (new)
feat(api): HttpOnly cookie auth path + CSRF double-submit—POST /v1/auth/loginadditionally setsSet-Cookie: access_token=<jwt>; HttpOnly; SameSite=Lax; Path=/; Secure(prod) / withoutSecure(dev).get_auth_contextchecks theaccess_tokencookie as fallback when noAuthorizationheader is present;Authorization: Bearerpath remains unchanged for API-key clients. CSRF: set readableXSRF-TOKENcookie on login; middleware checksX-XSRF-TOKENheader matches onPOST/PUT/DELETE/PATCHrequests 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_sessiondependency executesSET 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 endpoint—POST /v1/auth/api-keys/{id}/rotate(analyst+): atomically setsrevoked_aton the old key and creates a new key with the samenameandscopes; returns new plaintext once. Idempotent on already-revoked key (returns 409). -
B7 (new)
feat(api): user management — list, role-change, deactivate—GET /v1/auth/users(admin, paginated withlimit/offset);PUT /v1/auth/users/{id}(admin): acceptsroleand/oris_active; deactivation setsis_active=Falseand 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 endpoint—audit_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). Repositorylog_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 byactionandresource_type). -
B9 (new)
feat(api): invitation flow — replace initial-password return—invite_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 withis_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/acceptbody{token, new_password}: validates token, sets password, activates user, marks token used. Existingcreate_user.pyCLI 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 withis_active=False.email_verification_tokens(id, user_id, token_hash, expires_at, used_at). Sends verification email.POST /v1/auth/verify-emailbody{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 addsbilling_email varchar(255)andplan_tier varchar(32) NOT NULL DEFAULT 'free' CHECK (plan_tier IN ('free','pro','enterprise'))toorganizations.GET /v1/auth/meorg object gains these fields.PUT /v1/auth/organizations/{id}(admin): updatebilling_emailandname;plan_tieris 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— updateAuthContext.tsx: removelocalStoragereads once B4 lands (cookie is set by backend; client does not need to read it explicitly —fetchwithcredentials: 'include'sends it automatically); callPOST /v1/auth/refreshwhen tab becomes visible and the decoded token has < 5 minutes to expiry; auto-logout after 30 minutes of inactivity (trackmousemove,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 placeholder—GET /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 withContent-Disposition: attachment; filename=report_<id>.csvpdf:fpdf2one-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>.pdfNo 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.0released 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, repository—portfolios(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 reversibledowngrade().PortfolioRepository(session, org_id)withcreate,get,list,update,delete,add_exposure,remove_exposure,list_exposures. -
D2 (new)
feat(api): portfolio CRUD endpoints—POST /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}/exposuresbody{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/AAI—GET /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— moveui/contents to a newclimate-lama-uiGitHub repo; ownpackage.json,Dockerfile(multi-stage:npm run build→ nginx static),nginx.conf, GitHub Actions CI (lint +npm test+ build); update backbonedocker-compose.ymlto reference the built image. Removeui/from backbone. -
G2 (new)
feat(ui): portfolio table — sortable/filterable asset list—/portfoliosroute; 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; callsGET /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 fromGET /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 viasettings.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 |