Close the residual gaps the 2026-09-18 audit left open
Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.
Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
child_id as an unchecked query param, and get_overview called
get_days_left_at_age_4 without workspace_id, so the ownership guard
never ran. Any member could read another workspace's child projection.
Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
failure's raw exception text and the FK maintenance banner's scraped
text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
lowercasing existing rows (aborts naming colliding rows rather than
failing opaquely on the UNIQUE constraint).
Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
consumers. setBusy(true) was unconditional while the clear was guarded
by isCurrent, so a superseded call stranded its flag forever — under
StrictMode this hung every in-app navigation behind the PageSkeleton.
isCurrent decides which response's data wins, never whether a call
releases the flag it raised.
Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
GET /workspace/:id from every cold load.
CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).
Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.
Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"""Lowercase existing "user".email values.
|
||||
|
||||
Email lookup/storage is case-sensitive (`SQLiteUserRepository.get_by_email`
|
||||
runs a bare `WHERE email = ?`), so `Foo@x.se` and `foo@x.se` used to register
|
||||
as two distinct accounts. The API boundary now lowercases incoming email on
|
||||
every register/login request (`app.models.auth.RegisterRequest`/
|
||||
`LoginRequest`, `normalize_email` field validators), so all NEW rows are
|
||||
already lowercase; this migration normalizes rows written before that fix.
|
||||
|
||||
If two existing rows would collapse to the same lowercased address (a
|
||||
genuine pre-existing duplicate-account situation), blindly lowercasing both
|
||||
would hit the `email` column's UNIQUE constraint and abort with an opaque
|
||||
integrity error on whichever row happens to update second. Instead,
|
||||
`upgrade()` scans for any such collision FIRST and raises a clear,
|
||||
actionable `RuntimeError` naming the colliding rows before writing anything,
|
||||
so an operator can merge or rename one of each colliding pair by hand and
|
||||
re-run the migration, rather than facing a half-applied state.
|
||||
|
||||
`downgrade()` cannot restore the original casing (it was never recorded) —
|
||||
it is a deliberate, documented no-op. Lowercasing is a one-way normalization.
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-09-20
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "009"
|
||||
down_revision: str | None = "008"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def _collect_rows(conn: Any) -> list[Any]:
|
||||
from sqlalchemy import text
|
||||
|
||||
return conn.execute(text('SELECT id, email FROM "user"')).fetchall()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
conn = op.get_bind()
|
||||
rows = _collect_rows(conn)
|
||||
|
||||
by_lower: dict[str, list[tuple[str, str]]] = {}
|
||||
for row in rows:
|
||||
by_lower.setdefault(row.email.lower(), []).append((str(row.id), row.email))
|
||||
|
||||
collisions = {lower: entries for lower, entries in by_lower.items() if len(entries) > 1}
|
||||
if collisions:
|
||||
detail = "; ".join(
|
||||
f"{lower!r} <- {[f'{id_} ({email})' for id_, email in entries]}"
|
||||
for lower, entries in collisions.items()
|
||||
)
|
||||
raise RuntimeError(
|
||||
'Cannot lowercase "user".email: the following addresses would collide '
|
||||
f"under case-insensitive comparison and must be resolved by hand first "
|
||||
f"(merge or rename one account in each pair, then re-run this migration): "
|
||||
f"{detail}"
|
||||
)
|
||||
|
||||
for lower, entries in by_lower.items():
|
||||
(id_, email) = entries[0]
|
||||
if email != lower:
|
||||
conn.execute(
|
||||
text('UPDATE "user" SET email = :email WHERE id = :id'),
|
||||
{"email": lower, "id": id_},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""No-op: the original casing was never recorded, so it cannot be restored."""
|
||||
Reference in New Issue
Block a user