Harden the API and the data layer after a security and performance audit

Security
- Every route now requires a login and refuses ids outside the caller's
  workspace with 404: plan, leave-wishes, day-overrides and vacation-days
  were fully anonymous; every other router trusted client-supplied
  workspace/child/parent ids. Scraper sessions are bound to their owner.
- Invitations can no longer be created for a foreign workspace, and a
  redeemer who is already a member (or a full workspace) is refused.
- Production refuses the default JWT secret; API docs are hidden in
  production; a failed migration aborts startup instead of serving.
- Login and register no longer reveal whether an email exists; inactive
  accounts are rejected; /auth/refresh is rate limited.
- The FK scraper writes debug artefacts only when explicitly enabled,
  logs no scraped values or page text, clears the BankID QR as soon as it
  is scanned and purges finished sessions after five minutes.
- Real FK page captures are removed from the tree (history rewrite
  follows); the parser tests use a synthetic fixture.

Performance and correctness
- Plan apply/sync run inside one locked transaction with batched writes
  instead of one commit per row, so a failure mid-batch leaves nothing
  half-applied and concurrent syncs no longer corrupt each other.
- SQLite runs in WAL mode with a busy timeout; 14 indexes cover the
  filters that were full scans; argon2 hashing runs off the event loop;
  responses are gzipped.
- Frontend: a 401 now refreshes the token once and retries, so sessions
  no longer die silently after 15 minutes; every data hook discards
  stale responses; logout clears state even when the server call fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
This commit is contained in:
2026-09-18 07:54:21 +02:00
parent ae16d71970
commit 02db5430eb
81 changed files with 3616 additions and 600 deletions
@@ -5,6 +5,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.child import Child
from app.repositories.child_repository import ChildRepository
from app.repositories.filter_validator import validate_filters
@@ -50,7 +51,7 @@ class SQLiteChildRepository(ChildRepository):
item.created_at.isoformat(),
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> Child | None:
@@ -74,7 +75,7 @@ class SQLiteChildRepository(ChildRepository):
str(id),
),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"Child {id} not found")
refreshed = await self.get_by_id(id)
@@ -83,7 +84,7 @@ class SQLiteChildRepository(ChildRepository):
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM child WHERE id = ?", (str(id),))
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"Child {id} not found")
@@ -10,6 +10,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.day_override import DayOverride
from app.repositories.day_override_repository import DayOverrideRepository
from app.repositories.filter_validator import validate_filters
@@ -52,7 +53,7 @@ class SQLiteDayOverrideRepository(DayOverrideRepository):
item.updated_at.isoformat(),
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> DayOverride | None:
@@ -78,7 +79,7 @@ class SQLiteDayOverrideRepository(DayOverrideRepository):
""",
(int(item.is_working_day), item.note, item.updated_at.isoformat(), str(id)),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"DayOverride {id} not found")
refreshed = await self.get_by_id(id)
@@ -87,7 +88,7 @@ class SQLiteDayOverrideRepository(DayOverrideRepository):
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM day_override WHERE id = ?", (str(id),))
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"DayOverride {id} not found")
@@ -9,6 +9,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.leave_wish import LeaveWish
from app.repositories.filter_validator import validate_filters
from app.repositories.leave_wish_repository import LeaveWishRepository
@@ -89,7 +90,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
item.updated_at.isoformat(),
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> LeaveWish | None:
@@ -144,7 +145,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
str(id),
),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"LeaveWish {id} not found")
refreshed = await self.get_by_id(id)
@@ -153,7 +154,7 @@ class SQLiteLeaveWishRepository(LeaveWishRepository):
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM leave_wish WHERE id = ?", (str(id),))
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"LeaveWish {id} not found")
@@ -6,6 +6,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.repositories.filter_validator import validate_filters
from app.repositories.parental_leave_repository import ParentalLeaveRepository
@@ -90,7 +91,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository):
item.updated_at.isoformat(),
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> ParentalLeaveFetch | None:
@@ -150,7 +151,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository):
str(id),
),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"ParentalLeaveFetch {id} not found")
refreshed = await self.get_by_id(id)
@@ -161,7 +162,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository):
cursor = await self._conn.execute(
"DELETE FROM parental_leave_fetch WHERE id = ?", (str(id),)
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"ParentalLeaveFetch {id} not found")
@@ -9,6 +9,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.personal_data import PersonalData
from app.repositories.filter_validator import validate_filters
from app.repositories.personal_data_repository import PersonalDataRepository
@@ -55,7 +56,7 @@ class SQLitePersonalDataRepository(PersonalDataRepository):
item.deleted_at.isoformat() if item.deleted_at else None,
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> PersonalData | None:
@@ -83,7 +84,7 @@ class SQLitePersonalDataRepository(PersonalDataRepository):
str(id),
),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"PersonalData {id} not found")
refreshed = await self.get_by_id(id)
@@ -98,13 +99,13 @@ class SQLitePersonalDataRepository(PersonalDataRepository):
"UPDATE personal_data SET deleted_at = ? WHERE uuid = ? AND deleted_at IS NULL",
(datetime.now().isoformat(), str(id)),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"PersonalData {id} not found")
async def hard_delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM personal_data WHERE uuid = ?", (str(id),))
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"PersonalData {id} not found")
@@ -5,6 +5,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.planned_leave import PlannedLeaveDay
from app.repositories.filter_validator import validate_filters
from app.repositories.planned_leave_repository import PlannedLeaveRepository
@@ -56,7 +57,7 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
item.updated_at.isoformat(),
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> PlannedLeaveDay | None:
@@ -83,7 +84,7 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
str(id),
),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"PlannedLeaveDay {id} not found")
refreshed = await self.get_by_id(id)
@@ -92,7 +93,7 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM planned_leave_day WHERE id = ?", (str(id),))
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"PlannedLeaveDay {id} not found")
@@ -177,5 +178,73 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
cursor = await self._conn.execute(
"DELETE FROM planned_leave_day WHERE wish_id = ?", (str(wish_id),)
)
await self._conn.commit()
await commit(self._conn)
return cursor.rowcount
async def create_many(self, items: list[PlannedLeaveDay]) -> None:
if not items:
return
now = datetime.now()
rows = []
for item in items:
item.created_at = item.created_at or now
item.updated_at = item.updated_at or now
rows.append(
(
str(item.id),
str(item.workspace_id),
str(item.child_id),
str(item.parent_personal_data_uuid),
item.date.isoformat(),
item.day_type,
int(item.is_dubbeldag),
str(item.wish_id) if item.wish_id is not None else None,
int(item.is_pinned),
item.created_at.isoformat(),
item.updated_at.isoformat(),
)
)
await self._conn.executemany(
"""
INSERT INTO planned_leave_day
(id, workspace_id, child_id, parent_personal_data_uuid, date,
day_type, is_dubbeldag, wish_id, is_pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
await commit(self._conn)
async def delete_many(self, ids: list[UUID]) -> int:
if not ids:
return 0
str_ids = [str(i) for i in ids]
total = 0
chunk_size = 500
for start in range(0, len(str_ids), chunk_size):
chunk = str_ids[start : start + chunk_size]
placeholders = ",".join("?" for _ in chunk)
cursor = await self._conn.execute(
f"DELETE FROM planned_leave_day WHERE id IN ({placeholders})", chunk
)
total += cursor.rowcount
await commit(self._conn)
return total
async def update_metadata_many(self, pairs: list[tuple[UUID, UUID | None, bool]]) -> None:
if not pairs:
return
now = datetime.now().isoformat()
rows = [
(str(wish_id) if wish_id is not None else None, int(is_pinned), now, str(id_))
for id_, wish_id, is_pinned in pairs
]
await self._conn.executemany(
"""
UPDATE planned_leave_day
SET wish_id = ?, is_pinned = ?, updated_at = ?
WHERE id = ?
""",
rows,
)
await commit(self._conn)
@@ -11,6 +11,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.tip_dismissal import TipDismissal
from app.repositories.filter_validator import validate_filters
from app.repositories.tip_dismissal_repository import TipDismissalRepository
@@ -46,7 +47,7 @@ class SQLiteTipDismissalRepository(TipDismissalRepository):
item.dismissed_at.isoformat(),
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> TipDismissal | None:
@@ -68,7 +69,7 @@ class SQLiteTipDismissalRepository(TipDismissalRepository):
"UPDATE tip_dismissal SET data_hash = ?, dismissed_at = ? WHERE id = ?",
(item.data_hash, item.dismissed_at.isoformat(), str(id)),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"TipDismissal {id} not found")
refreshed = await self.get_by_id(id)
@@ -77,7 +78,7 @@ class SQLiteTipDismissalRepository(TipDismissalRepository):
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM tip_dismissal WHERE id = ?", (str(id),))
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"TipDismissal {id} not found")
@@ -0,0 +1,21 @@
"""SQLite implementation of UnitOfWork.
Delegates to `app.db.connection.unit_of_work`: a locked `BEGIN IMMEDIATE`
transaction on the shared connection. See that function's docstring and the
backend-perf audit §3 for the full design.
"""
from contextlib import AbstractAsyncContextManager
import aiosqlite
from app.db.connection import unit_of_work
from app.repositories.unit_of_work import UnitOfWork
class SQLiteUnitOfWork(UnitOfWork):
def __init__(self, connection: aiosqlite.Connection) -> None:
self._conn = connection
def __call__(self) -> AbstractAsyncContextManager[None]:
return unit_of_work(self._conn)
@@ -5,6 +5,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.user import User
from app.repositories.filter_validator import validate_filters
from app.repositories.user_repository import UserRepository
@@ -23,6 +24,7 @@ class SQLiteUserRepository(UserRepository):
email=row["email"],
hashed_password=row["hashed_password"],
created_at=datetime.fromisoformat(row["created_at"]),
is_active=bool(row["is_active"]),
)
async def create(self, item: User) -> User:
@@ -30,8 +32,9 @@ class SQLiteUserRepository(UserRepository):
item.created_at = item.created_at or now
await self._conn.execute(
"""
INSERT INTO "user" (id, personal_data_uuid, email, hashed_password, created_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO "user"
(id, personal_data_uuid, email, hashed_password, created_at, is_active)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
@@ -39,9 +42,10 @@ class SQLiteUserRepository(UserRepository):
item.email,
item.hashed_password,
item.created_at.isoformat(),
int(item.is_active),
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> User | None:
@@ -56,10 +60,10 @@ class SQLiteUserRepository(UserRepository):
async def update(self, id: UUID, item: User) -> User:
cursor = await self._conn.execute(
'UPDATE "user" SET email = ?, hashed_password = ? WHERE id = ?',
(item.email, item.hashed_password, str(id)),
'UPDATE "user" SET email = ?, hashed_password = ?, is_active = ? WHERE id = ?',
(item.email, item.hashed_password, int(item.is_active), str(id)),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"User {id} not found")
refreshed = await self.get_by_id(id)
@@ -68,7 +72,7 @@ class SQLiteUserRepository(UserRepository):
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute('DELETE FROM "user" WHERE id = ?', (str(id),))
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"User {id} not found")
@@ -12,6 +12,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.vacation_days import VacationDays
from app.repositories.filter_validator import validate_filters
from app.repositories.vacation_days_repository import VacationDaysRepository
@@ -57,7 +58,7 @@ class SQLiteVacationDaysRepository(VacationDaysRepository):
item.updated_at.isoformat(),
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> VacationDays | None:
@@ -83,7 +84,7 @@ class SQLiteVacationDaysRepository(VacationDaysRepository):
""",
(item.total_days, item.saved_days_in, item.updated_at.isoformat(), str(id)),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"VacationDays {id} not found")
refreshed = await self.get_by_id(id)
@@ -92,7 +93,7 @@ class SQLiteVacationDaysRepository(VacationDaysRepository):
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM vacation_days WHERE id = ?", (str(id),))
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"VacationDays {id} not found")
@@ -5,6 +5,7 @@ from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.workspace import Workspace
from app.entities.workspace_invitation import WorkspaceInvitation
from app.repositories.filter_validator import validate_filters
@@ -42,7 +43,7 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
item.created_at.isoformat(),
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> Workspace | None:
@@ -55,7 +56,7 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
"UPDATE workspace SET name = ?, planning_mode = ? WHERE id = ?",
(item.name, item.planning_mode, str(id)),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"Workspace {id} not found")
refreshed = await self.get_by_id(id)
@@ -64,7 +65,7 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM workspace WHERE id = ?", (str(id),))
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"Workspace {id} not found")
@@ -111,7 +112,7 @@ class SQLiteWorkspaceInvitationRepository(WorkspaceInvitationRepository):
item.accepted_at.isoformat() if item.accepted_at else None,
),
)
await self._conn.commit()
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> WorkspaceInvitation | None:
@@ -141,7 +142,7 @@ class SQLiteWorkspaceInvitationRepository(WorkspaceInvitationRepository):
str(id),
),
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"WorkspaceInvitation {id} not found")
refreshed = await self.get_by_id(id)
@@ -152,7 +153,7 @@ class SQLiteWorkspaceInvitationRepository(WorkspaceInvitationRepository):
cursor = await self._conn.execute(
"DELETE FROM workspace_invitation WHERE id = ?", (str(id),)
)
await self._conn.commit()
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"WorkspaceInvitation {id} not found")