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:
2026-09-21 06:16:23 +02:00
parent fc90cf9a5f
commit 88197060de
44 changed files with 1401 additions and 218 deletions
+15 -1
View File
@@ -1,6 +1,6 @@
"""Pydantic schemas for auth endpoints."""
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, EmailStr, Field, field_validator
class RegisterRequest(BaseModel):
@@ -13,11 +13,25 @@ class RegisterRequest(BaseModel):
# if one exists) instead of creating a new one.
invite_token: str | None = None
@field_validator("email")
@classmethod
def normalize_email(cls, v: str) -> str:
"""Lowercase at the API boundary — storage/lookup is case-sensitive
(`WHERE email = ?`), so Foo@x.se and foo@x.se must resolve to one
account rather than silently becoming two."""
return v.lower()
class LoginRequest(BaseModel):
email: EmailStr
password: str
@field_validator("email")
@classmethod
def normalize_email(cls, v: str) -> str:
"""Lowercase at the API boundary — see `RegisterRequest.normalize_email`."""
return v.lower()
class TokenResponse(BaseModel):
access_token: str
+4
View File
@@ -44,7 +44,9 @@ async def get_overview(
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> WorkspaceOverviewResponse:
await scope.check_child(child_id)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
@@ -65,7 +67,9 @@ async def get_projection(
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> WorkspaceOverviewResponse:
await scope.check_child(child_id)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
+11
View File
@@ -10,6 +10,7 @@ from app.dependencies import (
ChildRepositoryDep,
CurrentUserDep,
PersonalDataRepositoryDep,
WorkspaceRepositoryDep,
)
from app.rate_limit import limiter
from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse
@@ -105,6 +106,11 @@ class _UserContext(BaseModel):
workspace_id: str
parent_name: str
children: list[_ChildContext]
# Mirrors `WorkspaceResponse.take_pl_on_red_days` — included here so the
# frontend's cold-load `refreshContext()` can build its `workspace`
# state from this response alone, without a follow-up `GET
# /workspace/:id` (see AGENTS.md H12 step 1).
take_pl_on_red_days: bool
@router.get("/context", response_model=_UserContext)
@@ -112,12 +118,16 @@ async def get_context(
current_user: CurrentUserDep,
pd_repo: PersonalDataRepositoryDep,
child_repo: ChildRepositoryDep,
ws_repo: WorkspaceRepositoryDep,
) -> _UserContext:
"""Return workspace + children for the current user. Used by the dev harness."""
pd = await pd_repo.get_by_id(current_user.personal_data_uuid)
if pd is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "No parent profile found")
children = await child_repo.list_by_workspace(pd.workspace_id)
workspace = await ws_repo.get_by_id(pd.workspace_id)
if workspace is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "No workspace found")
return _UserContext(
personal_data_uuid=str(pd.uuid),
workspace_id=str(pd.workspace_id),
@@ -125,6 +135,7 @@ async def get_context(
children=[
_ChildContext(id=str(c.id), name=c.name, birth_date=str(c.birth_date)) for c in children
],
take_pl_on_red_days=workspace.take_pl_on_red_days,
)
+35 -27
View File
@@ -306,7 +306,7 @@ class FKScraperService(FetchDataService):
# Schedule TTL watchdog
asyncio.create_task(_ttl_watchdog(session_id))
logger.info("scraper session started [id=%s]", session_id)
logger.debug("scraper session started [id=%s]", session_id)
return session_id
async def poll_session(self, session_id: str, owner_user_id: UUID) -> dict:
@@ -345,7 +345,7 @@ class FKScraperService(FetchDataService):
state.qr_data = None
if state._task and not state._task.done():
state._task.cancel()
logger.info("scraper session cancelled [id=%s]", session_id)
logger.debug("scraper session cancelled [id=%s]", session_id)
# ---------------------------------------------------------------------------
@@ -390,7 +390,7 @@ async def _ttl_watchdog(session_id: str) -> None:
state.qr_data = None
if state._task and not state._task.done():
state._task.cancel()
logger.info("scraper session timed out [id=%s]", session_id)
logger.debug("scraper session timed out [id=%s]", session_id)
elif state.status in ("done", "error", "cancelled"):
_sessions.pop(session_id, None)
@@ -451,13 +451,13 @@ async def _save_debug_artifacts(page, session_id: str, console_messages: list[st
screenshot_path = prefix.with_suffix(".png")
await page.screenshot(path=str(screenshot_path), full_page=True)
logger.info(
logger.debug(
"scraper debug artifacts saved [id=%s] dir=%s",
session_id,
out.resolve(),
)
except Exception as artifact_exc:
logger.warning("failed to save debug artifacts [id=%s]: %s", session_id, artifact_exc)
logger.warning("failed to save debug artifacts: %s", artifact_exc)
async def _run_session(state: _SessionState) -> None:
@@ -509,9 +509,9 @@ async def _do_login(page, state: _SessionState) -> None:
# Check for maintenance message before proceeding
maintenance_msg = await _check_maintenance(page)
if maintenance_msg:
error_msg = f"FK is under maintenance: {maintenance_msg}"
_set_status(state, "error", error_msg)
logger.info("[%s] FK maintenance detected; aborting session", state.session_id)
_set_status(state, "error", "FK is currently under maintenance. Please try again later.")
logger.debug("[%s] FK maintenance banner text: %s", state.session_id, maintenance_msg)
logger.info("FK maintenance detected; aborting session")
return
# Accept cookie consent banner if present (only appears once per browser profile)
@@ -533,7 +533,7 @@ async def _do_login(page, state: _SessionState) -> None:
if is_disabled:
error_msg = "FK login temporarily unavailable (BankID button disabled). Please try again later."
_set_status(state, "error", error_msg)
logger.info("[%s] BankID button is disabled; aborting session", state.session_id)
logger.info("BankID button is disabled; aborting session")
return
except Exception:
pass # proceed anyway; may not be disabled
@@ -627,7 +627,9 @@ async def _do_extract(page, state: _SessionState) -> None:
if family is not None:
state.partner_deduction = _deduce_partner(data, family)
except Exception as exc:
logger.info("[%s] family-panel deduction skipped: %s", state.session_id, type(exc).__name__)
logger.debug(
"[%s] family-panel deduction skipped: %s", state.session_id, type(exc).__name__
)
state.family = None
state.partner_deduction = None
@@ -645,7 +647,7 @@ async def _do_extract(page, state: _SessionState) -> None:
state.partner_deduction, transferable_days_left=transferable.partner_total
)
except Exception as exc:
logger.info(
logger.debug(
"[%s] transferable-days parse skipped: %s", state.session_id, type(exc).__name__
)
@@ -656,14 +658,20 @@ async def _do_extract(page, state: _SessionState) -> None:
_set_status(
state,
"error",
f"Scrape succeeded but failed to persist data: {type(exc).__name__}: {exc}",
"Scrape succeeded but failed to persist the data. Please try again.",
)
logger.error(
"persist error [id=%s]: %s: %s",
state.session_id,
type(exc).__name__,
exc,
exc_info=False,
)
logger.error("persist error [id=%s]: %s", state.session_id, exc, exc_info=False)
return
_set_status(state, "done")
state.result = data
logger.info("scraper session done [id=%s]", state.session_id)
logger.info("scraper session done")
async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFetch:
@@ -746,19 +754,19 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
Defensive by design: the real FK DOM for this panel is unconfirmed (see
`_SEL.FAMILY*`'s comment), so any missing element or unexpected text is
treated as "no family data available" — logged at INFO and returned as
treated as "no family data available" — logged at DEBUG and returned as
None — never raised. The caller's own `data` (this parent's snapshot)
must succeed regardless of what happens here.
"""
try:
family_panel = page.locator(_SEL.FAMILY)
if await family_panel.count() == 0:
logger.info("[%s] family panel not present; skipping deduction", state.session_id)
logger.debug("[%s] family panel not present; skipping deduction", state.session_id)
return None
dagar_kvar_loc = page.locator(_SEL.FAMILY_DAGAR_KVAR)
if await dagar_kvar_loc.count() == 0:
logger.info(
logger.debug(
"[%s] family panel dagar-kvar element not found; skipping deduction",
state.session_id,
)
@@ -768,7 +776,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
discrete_loc = page.locator(_SEL.FAMILY_DISCRETE)
if await discrete_loc.count() == 0:
logger.info(
logger.debug(
"[%s] family panel discrete element not found; skipping deduction",
state.session_id,
)
@@ -776,7 +784,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
discrete_text = await discrete_loc.first.inner_text(timeout=2_000)
total_match = re.search(r"av\s+(\d+)\s+dagar", discrete_text)
if not total_match:
logger.info(
logger.debug(
"[%s] could not parse 'av N dagar' from family panel text; skipping deduction",
state.session_id,
)
@@ -806,7 +814,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
lagsta_niva_left=lagsta_niva_left,
)
except Exception as exc:
logger.info(
logger.debug(
"[%s] family panel parsing failed (%s); skipping deduction",
state.session_id,
type(exc).__name__,
@@ -894,7 +902,7 @@ async def _extract_transferable_days(
Fully defensive by design, mirroring `_extract_family_summary`: any
missing element, unmatched child, or unexpected text is "no transferable
data available" — logged at INFO and returned as None — never raised.
data available" — logged at DEBUG and returned as None — never raised.
The caller's own `data` (already validated) must be unaffected either way.
"""
try:
@@ -924,7 +932,7 @@ async def _extract_transferable_days(
sections = page.locator(_SEL.MIN_FP_BARN_SEKTION)
section_count = await sections.count()
if section_count == 0:
logger.info(
logger.debug(
"[%s] no barn-sektion found on min-foraldrapenning page; skipping "
"transferable-days parse",
state.session_id,
@@ -935,7 +943,7 @@ async def _extract_transferable_days(
chosen = sections.first
else:
if not child_name or not child_name.strip():
logger.info(
logger.debug(
"[%s] multiple children on min-foraldrapenning page and no "
"child_name to match; skipping transferable-days parse",
state.session_id,
@@ -953,7 +961,7 @@ async def _extract_transferable_days(
chosen = section
break
if chosen is None:
logger.info(
logger.debug(
"[%s] no barn-sektion matched child_name; skipping transferable-days parse",
state.session_id,
)
@@ -961,7 +969,7 @@ async def _extract_transferable_days(
panel = chosen.locator(_SEL.PANEL_FORA_OVER_DAGAR)
if await panel.count() == 0:
logger.info(
logger.debug(
"[%s] chosen barn-sektion has no transfer panel; skipping transferable-days parse",
state.session_id,
)
@@ -1014,7 +1022,7 @@ async def _extract_transferable_days(
partner_sjuk, partner_lagsta = sjuk, lagsta
if mine_sjuk is None or mine_lagsta is None:
logger.info(
logger.debug(
"[%s] 'Du kan föra över' group missing or incomplete; skipping "
"transferable-days parse",
state.session_id,
@@ -1028,7 +1036,7 @@ async def _extract_transferable_days(
partner_lagsta_niva=partner_lagsta,
)
except Exception as exc:
logger.info(
logger.debug(
"[%s] transferable-days panel parsing failed (%s); skipping",
state.session_id,
type(exc).__name__,
+1 -1
View File
@@ -552,7 +552,7 @@ class AnalyticsService:
projections = []
for pd in members:
proj = await self.get_days_left_at_age_4(pd.uuid, child_id)
proj = await self.get_days_left_at_age_4(pd.uuid, child_id, workspace_id=workspace_id)
projections.append(proj)
combined_used = sum(p.days_used + p.planned_days_before_b4 for p in projections)