feat: production-harden app and finalize merge-ready UX/theme baseline

- complete tokenized theme preset system and admin style guide visibility
- refine dashboard layout/scroll behavior and shared async/request UI states
- add user nav-visibility settings across API, migration, and frontend stores
- harden security headers/CSP handling and related test coverage
- enforce CI repository hygiene + frontend contract/theme/build quality gates
- update docs/env references and make migration smoke test head-aware
This commit is contained in:
Justin Visser 2026-02-19 15:43:20 +01:00
parent ab1d29b203
commit ae2ca8f149
66 changed files with 5655 additions and 853 deletions

View file

@ -23,6 +23,7 @@ def _serialize_settings(settings) -> dict[str, object]:
"auto_run_enabled": bool(settings.auto_run_enabled),
"run_interval_minutes": int(settings.run_interval_minutes),
"request_delay_seconds": int(settings.request_delay_seconds),
"nav_visible_pages": list(settings.nav_visible_pages or []),
}
@ -55,6 +56,11 @@ async def update_settings(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user),
):
settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
try:
parsed_interval = user_settings_service.parse_run_interval_minutes(
str(payload.run_interval_minutes)
@ -62,6 +68,11 @@ async def update_settings(
parsed_delay = user_settings_service.parse_request_delay_seconds(
str(payload.request_delay_seconds)
)
parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages(
payload.nav_visible_pages
if payload.nav_visible_pages is not None
else list(settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES)
)
except user_settings_service.UserSettingsServiceError as exc:
raise ApiException(
status_code=400,
@ -69,16 +80,13 @@ async def update_settings(
message=str(exc),
) from exc
settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
updated = await user_settings_service.update_settings(
db_session,
settings=settings,
auto_run_enabled=bool(payload.auto_run_enabled),
run_interval_minutes=parsed_interval,
request_delay_seconds=parsed_delay,
nav_visible_pages=parsed_nav_visible_pages,
)
logger.info(
"api.settings.updated",
@ -88,6 +96,7 @@ async def update_settings(
"auto_run_enabled": updated.auto_run_enabled,
"run_interval_minutes": updated.run_interval_minutes,
"request_delay_seconds": updated.request_delay_seconds,
"nav_visible_pages": updated.nav_visible_pages,
},
)
return success_payload(

View file

@ -433,6 +433,7 @@ class SettingsData(BaseModel):
auto_run_enabled: bool
run_interval_minutes: int
request_delay_seconds: int
nav_visible_pages: list[str]
model_config = ConfigDict(extra="forbid")
@ -448,6 +449,7 @@ class SettingsUpdateRequest(BaseModel):
auto_run_enabled: bool
run_interval_minutes: int
request_delay_seconds: int
nav_visible_pages: list[str] | None = None
model_config = ConfigDict(extra="forbid")

View file

@ -98,6 +98,13 @@ class UserSetting(Base):
request_delay_seconds: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("10")
)
nav_visible_pages: Mapped[list[str]] = mapped_column(
JSONB,
nullable=False,
server_default=text(
'\'["dashboard","scholars","publications","settings","style-guide","runs","users"]\'::jsonb'
),
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View file

@ -11,6 +11,32 @@ from starlette.responses import Response
from app.logging_context import set_request_id
REQUEST_ID_HEADER = "X-Request-ID"
DEFAULT_PERMISSIONS_POLICY = (
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
"geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()"
)
DEFAULT_CSP_POLICY = (
"default-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"img-src 'self' data: https:; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"connect-src 'self'; "
"object-src 'none'"
)
DEFAULT_CSP_DOCS_POLICY = (
"default-src 'self'; "
"img-src 'self' data: https:; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"connect-src 'self' https:; "
"object-src 'none'; "
"frame-ancestors 'none'"
)
logger = logging.getLogger(__name__)
@ -80,6 +106,100 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
return any(path.startswith(prefix) for prefix in self._skip_paths)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
def __init__(
self,
app,
*,
enabled: bool = True,
x_content_type_options: str = "nosniff",
x_frame_options: str = "DENY",
referrer_policy: str = "strict-origin-when-cross-origin",
permissions_policy: str = DEFAULT_PERMISSIONS_POLICY,
cross_origin_opener_policy: str = "same-origin",
cross_origin_resource_policy: str = "same-origin",
content_security_policy_enabled: bool = True,
content_security_policy: str = DEFAULT_CSP_POLICY,
content_security_policy_docs: str = DEFAULT_CSP_DOCS_POLICY,
content_security_policy_report_only: bool = False,
strict_transport_security_enabled: bool = False,
strict_transport_security_max_age: int = 31_536_000,
strict_transport_security_include_subdomains: bool = True,
strict_transport_security_preload: bool = False,
) -> None:
super().__init__(app)
self._enabled = enabled
self._x_content_type_options = x_content_type_options.strip()
self._x_frame_options = x_frame_options.strip()
self._referrer_policy = referrer_policy.strip()
self._permissions_policy = permissions_policy.strip()
self._cross_origin_opener_policy = cross_origin_opener_policy.strip()
self._cross_origin_resource_policy = cross_origin_resource_policy.strip()
self._csp_enabled = content_security_policy_enabled
self._csp_policy = content_security_policy.strip()
self._csp_docs_policy = content_security_policy_docs.strip()
self._csp_report_only = content_security_policy_report_only
self._hsts_enabled = strict_transport_security_enabled
self._hsts_max_age = max(0, strict_transport_security_max_age)
self._hsts_include_subdomains = strict_transport_security_include_subdomains
self._hsts_preload = strict_transport_security_preload
async def dispatch(self, request: Request, call_next) -> Response:
response = await call_next(request)
if not self._enabled:
return response
if self._x_content_type_options:
response.headers.setdefault("X-Content-Type-Options", self._x_content_type_options)
if self._x_frame_options:
response.headers.setdefault("X-Frame-Options", self._x_frame_options)
if self._referrer_policy:
response.headers.setdefault("Referrer-Policy", self._referrer_policy)
if self._permissions_policy:
response.headers.setdefault("Permissions-Policy", self._permissions_policy)
if self._cross_origin_opener_policy:
response.headers.setdefault(
"Cross-Origin-Opener-Policy",
self._cross_origin_opener_policy,
)
if self._cross_origin_resource_policy:
response.headers.setdefault(
"Cross-Origin-Resource-Policy",
self._cross_origin_resource_policy,
)
csp_policy = self._csp_policy_for_path(request.url.path)
if self._csp_enabled and csp_policy:
csp_header = (
"Content-Security-Policy-Report-Only"
if self._csp_report_only
else "Content-Security-Policy"
)
response.headers.setdefault(csp_header, csp_policy)
hsts = self._strict_transport_security_value()
if hsts:
response.headers.setdefault("Strict-Transport-Security", hsts)
return response
def _csp_policy_for_path(self, path: str) -> str:
if path.startswith("/docs") or path.startswith("/redoc"):
return self._csp_docs_policy or self._csp_policy
return self._csp_policy
def _strict_transport_security_value(self) -> str:
if not self._hsts_enabled:
return ""
directives = [f"max-age={self._hsts_max_age}"]
if self._hsts_include_subdomains:
directives.append("includeSubDomains")
if self._hsts_preload:
directives.append("preload")
return "; ".join(directives)
def parse_skip_paths(raw_value: str) -> tuple[str, ...]:
parts = [part.strip() for part in raw_value.split(",")]
return tuple(part for part in parts if part)

View file

@ -13,7 +13,11 @@ from app.api.router import router as api_router
from app.api.runtime_deps import get_ingestion_service, get_scholar_source
from app.db.session import check_database
from app.db.session import close_engine
from app.http.middleware import RequestLoggingMiddleware, parse_skip_paths
from app.http.middleware import (
RequestLoggingMiddleware,
SecurityHeadersMiddleware,
parse_skip_paths,
)
from app.logging_config import configure_logging, parse_redact_fields
from app.security.csrf import CSRFMiddleware
from app.services.scheduler import SchedulerService
@ -63,6 +67,26 @@ app.add_middleware(
log_requests=settings.log_requests,
skip_paths=parse_skip_paths(settings.log_request_skip_paths),
)
app.add_middleware(
SecurityHeadersMiddleware,
enabled=settings.security_headers_enabled,
x_content_type_options=settings.security_x_content_type_options,
x_frame_options=settings.security_x_frame_options,
referrer_policy=settings.security_referrer_policy,
permissions_policy=settings.security_permissions_policy,
cross_origin_opener_policy=settings.security_cross_origin_opener_policy,
cross_origin_resource_policy=settings.security_cross_origin_resource_policy,
content_security_policy_enabled=settings.security_csp_enabled,
content_security_policy=settings.security_csp_policy,
content_security_policy_docs=settings.security_csp_docs_policy,
content_security_policy_report_only=settings.security_csp_report_only,
strict_transport_security_enabled=settings.security_strict_transport_security_enabled,
strict_transport_security_max_age=settings.security_strict_transport_security_max_age,
strict_transport_security_include_subdomains=(
settings.security_strict_transport_security_include_subdomains
),
strict_transport_security_preload=settings.security_strict_transport_security_preload,
)
app.include_router(api_router)

View file

@ -10,13 +10,38 @@ class UserSettingsServiceError(ValueError):
"""Raised for expected settings-validation failures."""
NAV_PAGE_DASHBOARD = "dashboard"
NAV_PAGE_SCHOLARS = "scholars"
NAV_PAGE_PUBLICATIONS = "publications"
NAV_PAGE_SETTINGS = "settings"
NAV_PAGE_STYLE_GUIDE = "style-guide"
NAV_PAGE_RUNS = "runs"
NAV_PAGE_USERS = "users"
ALLOWED_NAV_PAGES = (
NAV_PAGE_DASHBOARD,
NAV_PAGE_SCHOLARS,
NAV_PAGE_PUBLICATIONS,
NAV_PAGE_SETTINGS,
NAV_PAGE_STYLE_GUIDE,
NAV_PAGE_RUNS,
NAV_PAGE_USERS,
)
REQUIRED_NAV_PAGES = (
NAV_PAGE_DASHBOARD,
NAV_PAGE_SCHOLARS,
NAV_PAGE_SETTINGS,
)
DEFAULT_NAV_VISIBLE_PAGES = list(ALLOWED_NAV_PAGES)
def parse_run_interval_minutes(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise UserSettingsServiceError("Run interval must be a whole number.") from exc
raise UserSettingsServiceError("Check interval must be a whole number.") from exc
if parsed < 15:
raise UserSettingsServiceError("Run interval must be at least 15 minutes.")
raise UserSettingsServiceError("Check interval must be at least 15 minutes.")
return parsed
@ -25,11 +50,41 @@ def parse_request_delay_seconds(value: str) -> int:
parsed = int(value)
except ValueError as exc:
raise UserSettingsServiceError("Request delay must be a whole number.") from exc
if parsed < 1:
raise UserSettingsServiceError("Request delay must be at least 1 second.")
if parsed < 2:
raise UserSettingsServiceError("Request delay must be at least 2 seconds.")
return parsed
def parse_nav_visible_pages(value: object) -> list[str]:
if not isinstance(value, list):
raise UserSettingsServiceError("Navigation visibility must be a list of page ids.")
deduped: list[str] = []
seen: set[str] = set()
for raw_page in value:
if not isinstance(raw_page, str):
raise UserSettingsServiceError("Navigation visibility entries must be strings.")
page_id = raw_page.strip()
if page_id not in ALLOWED_NAV_PAGES:
raise UserSettingsServiceError(f"Unsupported navigation page id: {page_id}")
if page_id in seen:
continue
seen.add(page_id)
deduped.append(page_id)
missing_required = [page for page in REQUIRED_NAV_PAGES if page not in seen]
if missing_required:
raise UserSettingsServiceError(
"Dashboard, Scholars, and Settings must remain visible."
)
return deduped
async def get_or_create_settings(
db_session: AsyncSession,
*,
@ -56,11 +111,12 @@ async def update_settings(
auto_run_enabled: bool,
run_interval_minutes: int,
request_delay_seconds: int,
nav_visible_pages: list[str],
) -> UserSetting:
settings.auto_run_enabled = auto_run_enabled
settings.run_interval_minutes = run_interval_minutes
settings.request_delay_seconds = request_delay_seconds
settings.nav_visible_pages = nav_visible_pages
await db_session.commit()
await db_session.refresh(settings)
return settings

View file

@ -1,6 +1,33 @@
from dataclasses import dataclass
import os
DEFAULT_SECURITY_PERMISSIONS_POLICY = (
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
"geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()"
)
DEFAULT_SECURITY_CSP_POLICY = (
"default-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"img-src 'self' data: https:; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"connect-src 'self'; "
"object-src 'none'"
)
DEFAULT_SECURITY_CSP_DOCS_POLICY = (
"default-src 'self'; "
"img-src 'self' data: https:; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; "
"connect-src 'self' https:; "
"object-src 'none'; "
"frame-ancestors 'none'"
)
def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
@ -39,6 +66,42 @@ class Settings:
)
session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-insecure-session-key")
session_cookie_secure: bool = _env_bool("SESSION_COOKIE_SECURE", False)
security_headers_enabled: bool = _env_bool("SECURITY_HEADERS_ENABLED", True)
security_x_content_type_options: str = _env_str("SECURITY_X_CONTENT_TYPE_OPTIONS", "nosniff")
security_x_frame_options: str = _env_str("SECURITY_X_FRAME_OPTIONS", "DENY")
security_referrer_policy: str = _env_str("SECURITY_REFERRER_POLICY", "strict-origin-when-cross-origin")
security_permissions_policy: str = _env_str(
"SECURITY_PERMISSIONS_POLICY",
DEFAULT_SECURITY_PERMISSIONS_POLICY,
)
security_cross_origin_opener_policy: str = _env_str(
"SECURITY_CROSS_ORIGIN_OPENER_POLICY",
"same-origin",
)
security_cross_origin_resource_policy: str = _env_str(
"SECURITY_CROSS_ORIGIN_RESOURCE_POLICY",
"same-origin",
)
security_csp_enabled: bool = _env_bool("SECURITY_CSP_ENABLED", True)
security_csp_policy: str = _env_str("SECURITY_CSP_POLICY", DEFAULT_SECURITY_CSP_POLICY)
security_csp_docs_policy: str = _env_str("SECURITY_CSP_DOCS_POLICY", DEFAULT_SECURITY_CSP_DOCS_POLICY)
security_csp_report_only: bool = _env_bool("SECURITY_CSP_REPORT_ONLY", False)
security_strict_transport_security_enabled: bool = _env_bool(
"SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED",
False,
)
security_strict_transport_security_max_age: int = _env_int(
"SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE",
31_536_000,
)
security_strict_transport_security_include_subdomains: bool = _env_bool(
"SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS",
True,
)
security_strict_transport_security_preload: bool = _env_bool(
"SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD",
False,
)
login_rate_limit_attempts: int = _env_int("LOGIN_RATE_LIMIT_ATTEMPTS", 5)
login_rate_limit_window_seconds: int = _env_int(
"LOGIN_RATE_LIMIT_WINDOW_SECONDS",
@ -116,11 +179,11 @@ class Settings:
)
scholar_name_search_min_interval_seconds: float = _env_float(
"SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS",
3.0,
8.0,
)
scholar_name_search_interval_jitter_seconds: float = _env_float(
"SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS",
1.0,
2.0,
)
scholar_name_search_cooldown_block_threshold: int = _env_int(
"SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD",