M39: harden application and acceptance gates
This commit is contained in:
@@ -44,6 +44,13 @@ _ROUTE_TEMPLATES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _as_utc(value: datetime | None) -> datetime | None:
|
||||
"""Treat naive query datetimes as UTC so they compare safely with aware values."""
|
||||
if value is None:
|
||||
return None
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
@router.get("/export.csv")
|
||||
def export_audit_csv(
|
||||
occurred_from: datetime | None = Query(default=None),
|
||||
@@ -51,8 +58,8 @@ def export_audit_csv(
|
||||
db: Session = Depends(get_db),
|
||||
actor: CurrentUser = Depends(require_operations_manager),
|
||||
) -> Response:
|
||||
end = occurred_to or datetime.now(UTC)
|
||||
start = occurred_from or end - timedelta(days=30)
|
||||
end = _as_utc(occurred_to) or datetime.now(UTC)
|
||||
start = _as_utc(occurred_from) or end - timedelta(days=30)
|
||||
if end <= start or end - start > timedelta(days=90):
|
||||
raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days")
|
||||
events = db.scalars(
|
||||
@@ -133,7 +140,7 @@ def list_audit_events(
|
||||
action: str | None = Query(default=None),
|
||||
entity_type: str | None = Query(default=None),
|
||||
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
|
||||
correlation_id: str | None = Query(default=None),
|
||||
correlation_id: uuid.UUID | None = Query(default=None),
|
||||
occurred_from: datetime | None = Query(default=None),
|
||||
occurred_to: datetime | None = Query(default=None),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.core.config import get_settings
|
||||
from app.core.ratelimit import FailedAttemptLimiter
|
||||
from app.core.security import (
|
||||
SessionPayload,
|
||||
create_session_token,
|
||||
@@ -25,6 +26,25 @@ from app.services.sessions import revoke_session
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
_login_limiter = (
|
||||
FailedAttemptLimiter(
|
||||
max_failures=settings.login_max_failures,
|
||||
window_seconds=settings.login_failure_window_seconds,
|
||||
)
|
||||
if settings.login_max_failures > 0
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _client_key(request: Request) -> str:
|
||||
# The API sits behind the web container's reverse proxy in every documented
|
||||
# deployment; honour the first hop of X-Forwarded-For when present.
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
oauth = OAuth()
|
||||
if settings.oidc_enabled and settings.oidc_issuer_url:
|
||||
oauth.register(
|
||||
@@ -139,6 +159,11 @@ def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
if user is not None and user.external_subject not in (None, subject):
|
||||
raise HTTPException(status_code=409, detail="Email is linked to another identity")
|
||||
if user is not None and claims.get("email_verified") is not True:
|
||||
# Linking an existing local account (possibly the bootstrap admin) purely on an
|
||||
# email match requires the IdP to explicitly assert the address is verified;
|
||||
# an absent claim is treated as unverified.
|
||||
raise HTTPException(status_code=401, detail="Verified OIDC email is required")
|
||||
created = user is None
|
||||
if created:
|
||||
if not settings.oidc_auto_provision:
|
||||
@@ -223,6 +248,7 @@ async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Resp
|
||||
@router.post("/login", response_model=CurrentUser)
|
||||
def password_login(
|
||||
body: PasswordLoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> CurrentUser:
|
||||
@@ -231,9 +257,21 @@ def password_login(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Password login is unavailable in demo mode",
|
||||
)
|
||||
limiter_key = _client_key(request)
|
||||
retry_after = _login_limiter.retry_after_seconds(limiter_key) if _login_limiter else 0
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many failed login attempts. Try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == body.email.strip().lower()))
|
||||
if user is None or not user.active or not verify_password(body.password, user.password_hash):
|
||||
if _login_limiter:
|
||||
_login_limiter.record_failure(limiter_key)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if _login_limiter:
|
||||
_login_limiter.reset(limiter_key)
|
||||
_set_session(response, user)
|
||||
record_audit_event(
|
||||
db,
|
||||
|
||||
@@ -70,7 +70,9 @@ def list_bookings(
|
||||
if vehicle_ref:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||
if vehicle is None:
|
||||
return []
|
||||
if page is None:
|
||||
return []
|
||||
return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
|
||||
if starts_from:
|
||||
stmt = stmt.where(Booking.ends_at >= starts_from)
|
||||
@@ -445,8 +447,13 @@ def cancel_booking(
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
if booking.status != "reserved":
|
||||
raise HTTPException(status_code=409, detail="Only a reserved booking can be cancelled")
|
||||
if booking.status not in ("reserved", "blocked"):
|
||||
# A booking blocked at checkout (damage, technical warning, ...) has no other exit:
|
||||
# it never became active, so it can neither be returned nor completed. Cancelling
|
||||
# it (audited, with a reason) is the only way to close the file.
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Only a reserved or blocked booking can be cancelled"
|
||||
)
|
||||
customer = db.get(Customer, booking.customer_id)
|
||||
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||
if customer is None or vehicle is None:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
@@ -30,10 +31,20 @@ settings = get_settings()
|
||||
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||
|
||||
|
||||
def _local_tz() -> ZoneInfo:
|
||||
return ZoneInfo(settings.demo_timezone)
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor
|
||||
# shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting.
|
||||
return datetime.now(UTC).date()
|
||||
# Timestamps are stored in UTC but the operational day is the local (Europe/Brussels)
|
||||
# calendar day, so a 23:30Z departure belongs to tomorrow's schedule in summer.
|
||||
return datetime.now(_local_tz()).date()
|
||||
|
||||
|
||||
def _local_date(value: datetime) -> date:
|
||||
return value.astimezone(_local_tz()).date()
|
||||
|
||||
|
||||
@router.get("", response_model=DashboardOut)
|
||||
@@ -97,7 +108,7 @@ def get_dashboard(
|
||||
for b in bookings:
|
||||
vehicle = vehicles_by_id.get(b.vehicle_id)
|
||||
vehicle_ref = vehicle.public_ref if vehicle else ""
|
||||
if b.starts_at.date() == today and b.status in ("reserved", "active"):
|
||||
if _local_date(b.starts_at) == today and b.status in ("reserved", "active"):
|
||||
today_items.append(
|
||||
TodayItem(
|
||||
kind="departure",
|
||||
@@ -106,7 +117,7 @@ def get_dashboard(
|
||||
scheduled_at=b.starts_at,
|
||||
)
|
||||
)
|
||||
if b.ends_at.date() == today and b.status in ("active", "returned"):
|
||||
if _local_date(b.ends_at) == today and b.status in ("active", "returned"):
|
||||
today_items.append(
|
||||
TodayItem(
|
||||
kind="return",
|
||||
|
||||
@@ -75,6 +75,7 @@ def list_issues(
|
||||
severity: str | None = Query(default=None),
|
||||
assigned_to_ref: str | None = Query(default=None),
|
||||
overdue: bool | None = Query(default=None),
|
||||
demo_only: bool | None = Query(default=None),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -107,6 +108,10 @@ def list_issues(
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.due_at < datetime.now(UTC),
|
||||
)
|
||||
if demo_only is True:
|
||||
# Server-side so the guided demo scenarios are found on any page, not only the
|
||||
# 25 rows currently loaded in the browser.
|
||||
stmt = stmt.where(DataQualityIssue.public_ref.like("DQ-DEMO-%"))
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
issues = db.scalars(
|
||||
|
||||
@@ -111,6 +111,10 @@ def demo_reset(
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> dict:
|
||||
global _last_reset_monotonic
|
||||
if not settings.mobilityops_demo_mode:
|
||||
# Outside demo mode the reset endpoint must not exist at all: it wipes
|
||||
# operational data and replaces it with synthetic records.
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
|
||||
if not settings.demo_allow_reset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from sqlalchemy import select
|
||||
@@ -21,6 +21,7 @@ from app.schemas import (
|
||||
ProcedureListOut,
|
||||
ProcedureSyncResultIn,
|
||||
ProcedureSyncResultResult,
|
||||
ReturnCallbackIn,
|
||||
ScanResultOut,
|
||||
WorkflowErrorReportIn,
|
||||
WorkflowErrorReportResult,
|
||||
@@ -42,6 +43,14 @@ _CANONICAL_WORKFLOW_NAMES = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _require_service_token(service_token: str) -> None:
|
||||
# Constant-time comparison: a plain ``!=`` leaks how many leading bytes matched.
|
||||
if not hmac.compare_digest(
|
||||
service_token.encode("utf-8"), settings.n8n_callback_token.encode("utf-8")
|
||||
):
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
|
||||
def workflow_heartbeat(
|
||||
body: N8nHeartbeatIn,
|
||||
@@ -49,8 +58,7 @@ def workflow_heartbeat(
|
||||
db: Session = Depends(get_db),
|
||||
) -> N8nHeartbeatResult:
|
||||
"""Authenticated, idempotent execution evidence from a canonical n8n workflow."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
|
||||
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
|
||||
already_recorded = (
|
||||
@@ -87,13 +95,12 @@ def workflow_heartbeat(
|
||||
|
||||
@router.post("/return-callback")
|
||||
def return_callback(
|
||||
body: dict[str, Any],
|
||||
body: ReturnCallbackIn,
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
||||
service_token: str = Header(..., alias="X-Service-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
try:
|
||||
event_id = uuid.UUID(idempotency_key)
|
||||
@@ -124,10 +131,8 @@ def return_callback(
|
||||
actor_label="n8n",
|
||||
action="n8n_return_followup_recorded",
|
||||
entity_type="booking",
|
||||
correlation_id=uuid.UUID(body.get("correlation_id"))
|
||||
if body.get("correlation_id")
|
||||
else None,
|
||||
after={"follow_up": body.get("follow_up"), "summary": body.get("summary")},
|
||||
correlation_id=body.correlation_id,
|
||||
after={"follow_up": body.follow_up, "summary": body.summary},
|
||||
metadata={"event_id": str(event_id)},
|
||||
)
|
||||
db.commit()
|
||||
@@ -148,8 +153,7 @@ def scheduled_scan(
|
||||
safe to call repeatedly: run_scan() only ever creates an issue for a condition that
|
||||
doesn't already have one open, so a duplicate or overlapping trigger does no
|
||||
duplicate domain work -- it just reports zero new issues for anything already known."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||
return ScanResultOut(created=result.created)
|
||||
@@ -165,8 +169,7 @@ def workflow_error(
|
||||
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
|
||||
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
|
||||
error report (e.g. after a timed-out response), so this must not double-record."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
@@ -218,8 +221,7 @@ def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -
|
||||
Markdown file Fleet Ops ships, across every supported language, with a stable
|
||||
per-document id (source_id) and a content hash so the caller can detect changes
|
||||
without re-fetching content it already has."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
documents = [
|
||||
ProcedureDocumentOut(
|
||||
@@ -245,8 +247,7 @@ def procedures_sync_result(
|
||||
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
|
||||
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
|
||||
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
|
||||
Reference in New Issue
Block a user