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:
parent
ab1d29b203
commit
ae2ca8f149
66 changed files with 5655 additions and 853 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
26
app/main.py
26
app/main.py
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue