Files
MobilityOps/backend/app/api/deps.py
T
NuklearRabbit 24dcb3494c
MobilityOps acceptance / backend (push) Failing after 47s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped
M41: harden trust boundaries and delivery
2026-08-21 17:06:59 +02:00

95 lines
3.5 KiB
Python

from __future__ import annotations
import hmac
import re
from collections.abc import Generator
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.db import SessionLocal
from app.core.security import SessionPayload, read_session_token
from app.models.user import User
from app.schemas import CurrentUser, Role
from app.services.sessions import is_session_revoked
settings = get_settings()
_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined]
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
token = request.cookies.get(settings.session_cookie_name)
payload: SessionPayload | None = read_session_token(token) if token else None
revoked = token is not None and is_session_revoked(db, token)
if payload is None or payload.role not in _VALID_ROLES or revoked:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
# Demo reset deliberately rebuilds the deterministic users table. Retaining the
# signed demo session until the reset endpoint clears its cookie keeps existing demo
# workflows stable; operational sessions are always checked against the live record.
if settings.mobilityops_demo_mode:
demo_role: Role = payload.role # type: ignore[assignment]
return CurrentUser(
public_ref=payload.public_ref,
display_name=payload.display_name,
role=demo_role,
)
user = db.get(User, payload.user_id)
if (
user is None
or not user.active
or user.public_ref != payload.public_ref
or user.role not in _VALID_ROLES
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
role: Role = user.role # type: ignore[assignment]
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=role)
def require_operations_manager(
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if user.role != "operations_manager":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Operations Manager role required"
)
return user
@dataclass(frozen=True)
class McpClientContext:
reported_client_id: str
tenant: str
_MCP_CLIENT_ID = re.compile(
r"^itworx-mcp-hub:(?:readiness|mobilityops:[A-Za-z0-9][A-Za-z0-9._:-]{0,127})$"
)
def require_mcp_service_token(
x_service_token: str = Header(..., alias="X-Service-Token"),
x_client_id: str = Header(..., alias="X-Client-Id", min_length=1, max_length=180),
x_tenant_id: str | None = Header(default=None, alias="X-Tenant-Id", max_length=120),
) -> McpClientContext:
if not hmac.compare_digest(x_service_token, settings.mcp_hub_service_token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service token"
)
if not _MCP_CLIENT_ID.fullmatch(x_client_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Untrusted MCP client identity"
)
if x_tenant_id is not None and x_tenant_id != settings.ragcore_tenant:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant mismatch")
return McpClientContext(reported_client_id=x_client_id, tenant=settings.ragcore_tenant)