M21: add optional organisation identity
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""add optional external OIDC identity
|
||||
|
||||
Revision ID: a81d0ce9f662
|
||||
Revises: f43d829ab610
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "a81d0ce9f662"
|
||||
down_revision = "f43d829ab610"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("identity_provider", sa.String(80), nullable=True))
|
||||
op.add_column("users", sa.Column("external_subject", sa.String(255), nullable=True))
|
||||
op.create_unique_constraint(
|
||||
"uq_user_external_identity", "users", ["identity_provider", "external_subject"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("uq_user_external_identity", "users", type_="unique")
|
||||
op.drop_column("users", "external_subject")
|
||||
op.drop_column("users", "identity_provider")
|
||||
@@ -77,14 +77,14 @@ def list_audit_events(
|
||||
matched_ids: set[uuid.UUID] = set()
|
||||
for model in _ENTITY_MODELS.values():
|
||||
matched_ids.update(
|
||||
db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all()
|
||||
db.scalars(
|
||||
select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))
|
||||
).all()
|
||||
)
|
||||
if not matched_ids:
|
||||
if page is None:
|
||||
return []
|
||||
return AuditEventPageOut(
|
||||
items=[], page=1, page_size=page_size, total=0, total_pages=1
|
||||
)
|
||||
return AuditEventPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
|
||||
if correlation_id:
|
||||
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError # type: ignore[import-untyped]
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -17,12 +19,21 @@ from app.core.security import (
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, PasswordLoginRequest
|
||||
from app.schemas import CurrentUser, OidcStatusOut, PasswordLoginRequest
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.sessions import revoke_session
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
oauth = OAuth()
|
||||
if settings.oidc_enabled and settings.oidc_issuer_url:
|
||||
oauth.register(
|
||||
name="oidc",
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
server_metadata_url=f"{settings.oidc_issuer_url.rstrip('/')}/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid email profile"},
|
||||
)
|
||||
|
||||
|
||||
def _current_user_out(user: User) -> CurrentUser:
|
||||
@@ -36,14 +47,21 @@ def _current_user_out(user: User) -> CurrentUser:
|
||||
def _set_session(response: Response, user: User) -> None:
|
||||
token = create_session_token(
|
||||
SessionPayload(
|
||||
user_id=str(user.id), public_ref=user.public_ref, role=user.role,
|
||||
display_name=user.display_name, issued_at=int(time.time()),
|
||||
user_id=str(user.id),
|
||||
public_ref=user.public_ref,
|
||||
role=user.role,
|
||||
display_name=user.display_name,
|
||||
issued_at=int(time.time()),
|
||||
session_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name, token, httponly=True, samesite="lax",
|
||||
secure=settings.session_cookie_secure, max_age=settings.session_ttl_seconds,
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.session_cookie_secure,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,6 +96,130 @@ def bootstrap_initial_admin(db: Session) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _oidc_configured() -> bool:
|
||||
return bool(
|
||||
settings.oidc_enabled
|
||||
and settings.oidc_issuer_url
|
||||
and settings.oidc_client_id
|
||||
and settings.oidc_client_secret
|
||||
)
|
||||
|
||||
|
||||
def _oidc_redirect_uri() -> str:
|
||||
return settings.oidc_redirect_uri or (
|
||||
f"{settings.mobilityops_public_url.rstrip('/')}/api/v1/auth/oidc/callback"
|
||||
)
|
||||
|
||||
|
||||
def _allowed_oidc_email(email: str) -> bool:
|
||||
domains = {
|
||||
value.strip().casefold()
|
||||
for value in settings.oidc_allowed_email_domains.split(",")
|
||||
if value.strip()
|
||||
}
|
||||
return not domains or email.rsplit("@", 1)[-1].casefold() in domains
|
||||
|
||||
|
||||
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
|
||||
subject = str(claims.get("sub") or "").strip()
|
||||
email = str(claims.get("email") or "").strip().lower()
|
||||
if not subject or not email or claims.get("email_verified") is False:
|
||||
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
|
||||
if not _allowed_oidc_email(email):
|
||||
raise HTTPException(status_code=403, detail="Email domain is not allowed")
|
||||
|
||||
provider = settings.oidc_issuer_url.rstrip("/")
|
||||
user = db.scalar(
|
||||
select(User).where(
|
||||
User.identity_provider == provider,
|
||||
User.external_subject == subject,
|
||||
)
|
||||
)
|
||||
if user is None:
|
||||
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")
|
||||
created = user is None
|
||||
if created:
|
||||
if not settings.oidc_auto_provision:
|
||||
raise HTTPException(status_code=403, detail="OIDC user is not provisioned")
|
||||
role = settings.oidc_default_role
|
||||
if role not in {"operations_manager", "rental_employee"}:
|
||||
role = "rental_employee"
|
||||
user = User(
|
||||
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
|
||||
email=email,
|
||||
password_hash=None,
|
||||
display_name=str(claims.get("name") or email),
|
||||
role=role,
|
||||
active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
assert user is not None
|
||||
if not user.active:
|
||||
raise HTTPException(status_code=403, detail="User is inactive")
|
||||
user.identity_provider = provider
|
||||
user.external_subject = subject
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="system" if created else "user",
|
||||
actor_id=None if created else user.id,
|
||||
actor_label=settings.oidc_provider_name,
|
||||
action="oidc_user_provisioned" if created else "oidc_identity_linked",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
metadata={"provider": provider},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/oidc/status", response_model=OidcStatusOut)
|
||||
def oidc_status() -> OidcStatusOut:
|
||||
return OidcStatusOut(
|
||||
enabled=_oidc_configured(),
|
||||
provider_name=settings.oidc_provider_name if _oidc_configured() else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/oidc/login")
|
||||
async def oidc_login(request: Request) -> Response:
|
||||
if not _oidc_configured():
|
||||
raise HTTPException(status_code=404, detail="OIDC login is not configured")
|
||||
client = oauth.create_client("oidc")
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
|
||||
return await client.authorize_redirect(request, _oidc_redirect_uri())
|
||||
|
||||
|
||||
@router.get("/oidc/callback")
|
||||
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Response:
|
||||
if not _oidc_configured():
|
||||
raise HTTPException(status_code=404, detail="OIDC login is not configured")
|
||||
client = oauth.create_client("oidc")
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
raise HTTPException(status_code=401, detail="OIDC authentication failed") from exc
|
||||
user = _resolve_oidc_user(db, dict(token.get("userinfo") or {}))
|
||||
response = RedirectResponse(f"{settings.mobilityops_public_url.rstrip('/')}/dashboard")
|
||||
_set_session(response, user)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_id=user.id,
|
||||
actor_label=user.display_name,
|
||||
action="oidc_login",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
metadata={"provider": settings.oidc_issuer_url.rstrip("/")},
|
||||
)
|
||||
db.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/login", response_model=CurrentUser)
|
||||
def password_login(
|
||||
body: PasswordLoginRequest,
|
||||
|
||||
@@ -100,22 +100,24 @@ def get_dashboard(
|
||||
if b.starts_at.date() == today and b.status in ("reserved", "active"):
|
||||
today_items.append(
|
||||
TodayItem(
|
||||
kind="departure", booking_ref=b.public_ref, vehicle_ref=vehicle_ref,
|
||||
kind="departure",
|
||||
booking_ref=b.public_ref,
|
||||
vehicle_ref=vehicle_ref,
|
||||
scheduled_at=b.starts_at,
|
||||
)
|
||||
)
|
||||
if b.ends_at.date() == today and b.status in ("active", "returned"):
|
||||
today_items.append(
|
||||
TodayItem(
|
||||
kind="return", booking_ref=b.public_ref, vehicle_ref=vehicle_ref,
|
||||
kind="return",
|
||||
booking_ref=b.public_ref,
|
||||
vehicle_ref=vehicle_ref,
|
||||
scheduled_at=b.ends_at,
|
||||
)
|
||||
)
|
||||
today_items.sort(key=lambda item: item.scheduled_at)
|
||||
|
||||
recent = db.scalars(
|
||||
select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)
|
||||
).all()
|
||||
recent = db.scalars(select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)).all()
|
||||
recent_automation = [
|
||||
AutomationRunOut(
|
||||
event_id=str(r.event_id),
|
||||
|
||||
@@ -74,9 +74,7 @@ def demo_login(
|
||||
|
||||
|
||||
@router.get("/session", response_model=CurrentUser)
|
||||
def get_session(
|
||||
response: Response, user: CurrentUser = Depends(get_current_user)
|
||||
) -> CurrentUser:
|
||||
def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
# Never let the browser (or an intermediary) cache an authentication check — a stale
|
||||
# cached 200 here would keep showing a logged-out browser as authenticated.
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
@@ -42,7 +42,12 @@ def get_correlation_id(
|
||||
|
||||
|
||||
def _audit_service_request(
|
||||
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str,
|
||||
db: Session,
|
||||
*,
|
||||
client_id: str,
|
||||
tool: str,
|
||||
status_label: str,
|
||||
correlation_id: str,
|
||||
metadata: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
record_audit_event(
|
||||
|
||||
@@ -143,7 +143,10 @@ def search(
|
||||
)
|
||||
|
||||
for b in db.scalars(
|
||||
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5)
|
||||
select(Booking)
|
||||
.where(Booking.public_ref.ilike(like))
|
||||
.order_by(Booking.starts_at.desc())
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
|
||||
@@ -51,6 +51,16 @@ class Settings(BaseSettings):
|
||||
initial_admin_email: str = ""
|
||||
initial_admin_password: str = ""
|
||||
initial_admin_display_name: str = "Operations Manager"
|
||||
mobilityops_public_url: str = "http://localhost:1228"
|
||||
oidc_enabled: bool = False
|
||||
oidc_provider_name: str = "Organisatieaccount"
|
||||
oidc_issuer_url: str = ""
|
||||
oidc_client_id: str = ""
|
||||
oidc_client_secret: str = ""
|
||||
oidc_redirect_uri: str = ""
|
||||
oidc_allowed_email_domains: str = ""
|
||||
oidc_auto_provision: bool = True
|
||||
oidc_default_role: str = "rental_employee"
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
+13
-3
@@ -5,6 +5,7 @@ from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.routers import (
|
||||
audit,
|
||||
@@ -43,6 +44,15 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.app_secret,
|
||||
session_cookie="mobilityops_oidc_state",
|
||||
max_age=600,
|
||||
same_site="lax",
|
||||
https_only=settings.session_cookie_secure,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")],
|
||||
@@ -95,9 +105,7 @@ def readiness() -> JSONResponse:
|
||||
status_code=503,
|
||||
content={"status": "not_ready", "service": "mobilityops-api", "database": "down"},
|
||||
)
|
||||
return JSONResponse(
|
||||
content={"status": "ready", "service": "mobilityops-api", "database": "up"}
|
||||
)
|
||||
return JSONResponse(content={"status": "ready", "service": "mobilityops-api", "database": "up"})
|
||||
|
||||
|
||||
@app.get("/api/v1/system/status")
|
||||
@@ -107,6 +115,8 @@ def system_status() -> dict[str, object]:
|
||||
"environment": settings.mobilityops_env,
|
||||
"demo_mode": settings.mobilityops_demo_mode,
|
||||
"knowledge_provider": settings.knowledge_provider,
|
||||
"oidc_enabled": auth.oidc_status().enabled,
|
||||
"oidc_provider_name": auth.oidc_status().provider_name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy import Boolean, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
@@ -9,6 +9,9 @@ ROLES = ("operations_manager", "rental_employee")
|
||||
|
||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("identity_provider", "external_subject", name="uq_user_external_identity"),
|
||||
)
|
||||
|
||||
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True)
|
||||
@@ -16,3 +19,5 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
identity_provider: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@@ -379,6 +379,11 @@ class SearchResponse(BaseModel):
|
||||
results: list[SearchResultItem]
|
||||
|
||||
|
||||
class OidcStatusOut(BaseModel):
|
||||
enabled: bool
|
||||
provider_name: str | None = None
|
||||
|
||||
|
||||
class N8nWorkflowEvidence(BaseModel):
|
||||
name: str
|
||||
built: bool
|
||||
|
||||
@@ -39,18 +39,14 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
overlap_issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
|
||||
)
|
||||
failed_run = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
|
||||
)
|
||||
failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID))
|
||||
knowledge_health = get_knowledge_provider().health()
|
||||
|
||||
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
|
||||
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
|
||||
# languages. This service only emits stable identifiers and message codes -- never
|
||||
# display prose -- per the message_code + params architecture used across the app.
|
||||
return_ready = bool(
|
||||
booking and booking.status == "active" and booking.end_odometer_km is None
|
||||
)
|
||||
return_ready = bool(booking and booking.status == "active" and booking.end_odometer_km is None)
|
||||
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
|
||||
overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
|
||||
automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
|
||||
@@ -65,7 +61,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if return_ready
|
||||
else "bookingNotFound" if booking is None else "bookingAlreadyProcessed"
|
||||
else "bookingNotFound"
|
||||
if booking is None
|
||||
else "bookingAlreadyProcessed"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
@@ -81,7 +79,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if duplicate_ready
|
||||
else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved"
|
||||
else "duplicateIssueNotFound"
|
||||
if duplicate_issue is None
|
||||
else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
@@ -95,7 +95,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if overlap_ready
|
||||
else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved"
|
||||
else "overlapIssueNotFound"
|
||||
if overlap_issue is None
|
||||
else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
@@ -107,7 +109,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if automation_ready
|
||||
else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered"
|
||||
else "failedEventNotFound"
|
||||
if failed_run is None
|
||||
else "eventAlreadyRecovered"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
@@ -172,9 +176,7 @@ def scenario_integrity_report(db: Session) -> dict:
|
||||
overview already use, so this can never drift from what a visitor actually sees."""
|
||||
scenarios = _scenarios(db)
|
||||
not_ready = [
|
||||
{"id": s.id, "reason_code": s.blocked_reason_code}
|
||||
for s in scenarios
|
||||
if not s.ready
|
||||
{"id": s.id, "reason_code": s.blocked_reason_code} for s in scenarios if not s.ready
|
||||
]
|
||||
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
|
||||
|
||||
|
||||
@@ -141,8 +141,7 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
||||
# succeeded.
|
||||
success = False
|
||||
error = (
|
||||
"Unexpected non-JSON-object response from n8n "
|
||||
f"(status {response.status_code})"
|
||||
f"Unexpected non-JSON-object response from n8n (status {response.status_code})"
|
||||
)
|
||||
error_code = "malformedResponse"
|
||||
except httpx.HTTPError as exc:
|
||||
|
||||
@@ -107,9 +107,7 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
# procedures_sync_result), the same "the workflow's own callback is the evidence"
|
||||
# pattern the scheduled scan and error handler already use below.
|
||||
latest_procedure_sync_at = db.scalar(
|
||||
select(func.max(AuditEvent.occurred_at)).where(
|
||||
AuditEvent.action == "n8n_procedures_synced"
|
||||
)
|
||||
select(func.max(AuditEvent.occurred_at)).where(AuditEvent.action == "n8n_procedures_synced")
|
||||
)
|
||||
|
||||
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
|
||||
@@ -240,9 +238,7 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
|
||||
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
|
||||
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
|
||||
total_calls = (
|
||||
db.scalar(
|
||||
select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request")
|
||||
)
|
||||
db.scalar(select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request"))
|
||||
or 0
|
||||
)
|
||||
latest_call_row = db.execute(
|
||||
|
||||
@@ -14,25 +14,146 @@ DEFAULT_LANGUAGE = "en-GB"
|
||||
|
||||
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
|
||||
"en-GB": {
|
||||
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
||||
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then",
|
||||
"do", "does", "did", "must", "may", "can", "could", "should", "would",
|
||||
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how",
|
||||
"with", "without", "this", "that", "these", "those", "not", "no",
|
||||
"a",
|
||||
"an",
|
||||
"the",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"be",
|
||||
"been",
|
||||
"being",
|
||||
"to",
|
||||
"of",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"for",
|
||||
"and",
|
||||
"or",
|
||||
"but",
|
||||
"if",
|
||||
"then",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"must",
|
||||
"may",
|
||||
"can",
|
||||
"could",
|
||||
"should",
|
||||
"would",
|
||||
"i",
|
||||
"you",
|
||||
"it",
|
||||
"we",
|
||||
"they",
|
||||
"my",
|
||||
"your",
|
||||
"what",
|
||||
"when",
|
||||
"how",
|
||||
"with",
|
||||
"without",
|
||||
"this",
|
||||
"that",
|
||||
"these",
|
||||
"those",
|
||||
"not",
|
||||
"no",
|
||||
},
|
||||
"nl-BE": {
|
||||
"een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt",
|
||||
"van", "in", "op", "voor", "en", "of", "maar", "als", "dan",
|
||||
"moet", "mag", "kan", "kunnen", "zou", "zouden",
|
||||
"ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe",
|
||||
"met", "zonder", "dit", "dat", "deze", "die", "niet", "geen",
|
||||
"een",
|
||||
"de",
|
||||
"het",
|
||||
"is",
|
||||
"zijn",
|
||||
"was",
|
||||
"waren",
|
||||
"worden",
|
||||
"wordt",
|
||||
"van",
|
||||
"in",
|
||||
"op",
|
||||
"voor",
|
||||
"en",
|
||||
"of",
|
||||
"maar",
|
||||
"als",
|
||||
"dan",
|
||||
"moet",
|
||||
"mag",
|
||||
"kan",
|
||||
"kunnen",
|
||||
"zou",
|
||||
"zouden",
|
||||
"ik",
|
||||
"jij",
|
||||
"u",
|
||||
"we",
|
||||
"wij",
|
||||
"zij",
|
||||
"mijn",
|
||||
"jouw",
|
||||
"wat",
|
||||
"wanneer",
|
||||
"hoe",
|
||||
"met",
|
||||
"zonder",
|
||||
"dit",
|
||||
"dat",
|
||||
"deze",
|
||||
"die",
|
||||
"niet",
|
||||
"geen",
|
||||
},
|
||||
"fr-BE": {
|
||||
"un", "une", "le", "la", "les", "des", "est", "sont", "était", "être",
|
||||
"de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors",
|
||||
"doit", "peut", "peuvent", "pourrait", "devrait",
|
||||
"je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment",
|
||||
"avec", "sans", "ce", "cette", "ces", "cela", "pas", "non",
|
||||
"un",
|
||||
"une",
|
||||
"le",
|
||||
"la",
|
||||
"les",
|
||||
"des",
|
||||
"est",
|
||||
"sont",
|
||||
"était",
|
||||
"être",
|
||||
"de",
|
||||
"du",
|
||||
"en",
|
||||
"sur",
|
||||
"pour",
|
||||
"et",
|
||||
"ou",
|
||||
"mais",
|
||||
"si",
|
||||
"alors",
|
||||
"doit",
|
||||
"peut",
|
||||
"peuvent",
|
||||
"pourrait",
|
||||
"devrait",
|
||||
"je",
|
||||
"tu",
|
||||
"vous",
|
||||
"il",
|
||||
"elle",
|
||||
"nous",
|
||||
"ils",
|
||||
"mon",
|
||||
"votre",
|
||||
"quoi",
|
||||
"quand",
|
||||
"comment",
|
||||
"avec",
|
||||
"sans",
|
||||
"ce",
|
||||
"cette",
|
||||
"ces",
|
||||
"cela",
|
||||
"pas",
|
||||
"non",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -141,7 +262,7 @@ _LOW_CONFIDENCE_TEXT = {
|
||||
_LEAD_ANSWER_TEMPLATE = {
|
||||
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
|
||||
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
|
||||
"fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}',
|
||||
"fr-BE": "Selon « {title} » (v{version}), section « {heading} » : {excerpt}",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||
# Stable across runs (and across which language ships first) so a document's RAGcore
|
||||
# source_id never changes just because the sync ran on a different day or in a
|
||||
# different order -- required for RAGcore's upload idempotency to work per document.
|
||||
_SOURCE_ID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures")
|
||||
_SOURCE_ID_NAMESPACE = uuid.uuid5(
|
||||
uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures"
|
||||
)
|
||||
|
||||
|
||||
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
||||
|
||||
@@ -14,9 +14,7 @@ settings = get_settings()
|
||||
|
||||
def is_session_revoked(db: Session, token: str) -> bool:
|
||||
token_hash = session_token_hash(token)
|
||||
revoked_id = db.scalar(
|
||||
select(RevokedSession.id).where(RevokedSession.token_hash == token_hash)
|
||||
)
|
||||
revoked_id = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
|
||||
return revoked_id is not None
|
||||
|
||||
|
||||
|
||||
@@ -175,9 +175,7 @@ def evaluate_vehicle_status(
|
||||
if facts.has_active_rental and not blocking_reasons:
|
||||
if current == "rented":
|
||||
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||
return result(
|
||||
"rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False
|
||||
)
|
||||
return result("rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False)
|
||||
|
||||
if facts.has_active_rental and blocking_reasons:
|
||||
# Explicitly forbidden shortcut this evaluator must never take: an active
|
||||
@@ -190,24 +188,18 @@ def evaluate_vehicle_status(
|
||||
if facts.service_threshold_reached:
|
||||
if current == "maintenance":
|
||||
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||
return result(
|
||||
"maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False
|
||||
)
|
||||
return result("maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False)
|
||||
|
||||
if facts.has_booking_conflict:
|
||||
if current == "blocked":
|
||||
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||
return result(
|
||||
"blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False
|
||||
)
|
||||
return result("blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False)
|
||||
|
||||
# No active rental, no maintenance need, no booking conflict.
|
||||
if current in ("available", "cleaning", "blocked"):
|
||||
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||
if current == "rented":
|
||||
return result(
|
||||
"available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False
|
||||
)
|
||||
return result("available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False)
|
||||
if current == "maintenance":
|
||||
# No positive fact confirms maintenance is actually finished (no completed
|
||||
# service record is tracked here) -- clearing "maintenance" without such a
|
||||
|
||||
@@ -14,7 +14,10 @@ dependencies = [
|
||||
"sqlalchemy>=2.0,<3",
|
||||
"psycopg[binary]>=3.2,<4",
|
||||
"alembic>=1.13,<2",
|
||||
"httpx>=0.27,<1"
|
||||
"httpx>=0.27,<1",
|
||||
"httpx2>=2.10,<3",
|
||||
"authlib>=1.6,<2",
|
||||
"itsdangerous>=2.2,<3"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -13,14 +13,23 @@ annotated-types==0.8.0
|
||||
anyio==4.14.2
|
||||
# via
|
||||
# httpx
|
||||
# httpx2
|
||||
# starlette
|
||||
# watchfiles
|
||||
authlib==1.7.2
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
certifi==2026.7.22
|
||||
# via
|
||||
# httpcore
|
||||
# httpx
|
||||
cffi==2.1.1
|
||||
# via cryptography
|
||||
click==8.4.2
|
||||
# via uvicorn
|
||||
cryptography==50.0.0
|
||||
# via
|
||||
# authlib
|
||||
# joserfc
|
||||
fastapi==0.141.1
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
greenlet==3.5.4
|
||||
@@ -28,19 +37,29 @@ greenlet==3.5.4
|
||||
h11==0.16.0
|
||||
# via
|
||||
# httpcore
|
||||
# httpcore2
|
||||
# uvicorn
|
||||
httpcore==1.0.9
|
||||
# via httpx
|
||||
httpcore2==2.10.0
|
||||
# via httpx2
|
||||
httptools==0.8.0
|
||||
# via uvicorn
|
||||
httpx==0.28.1
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
httpx2==2.10.0
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
idna==3.18
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
# httpx2
|
||||
iniconfig==2.3.0
|
||||
# via pytest
|
||||
itsdangerous==2.2.0
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
joserfc==1.7.4
|
||||
# via authlib
|
||||
librt==0.13.0
|
||||
# via mypy
|
||||
mako==1.3.12
|
||||
@@ -61,6 +80,8 @@ psycopg[binary]==3.3.4
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
psycopg-binary==3.3.4
|
||||
# via psycopg
|
||||
pycparser==3.0
|
||||
# via cffi
|
||||
pydantic==2.13.4
|
||||
# via
|
||||
# fastapi
|
||||
@@ -91,11 +112,16 @@ sqlalchemy==2.0.51
|
||||
# mobilityops-api (pyproject.toml)
|
||||
starlette==1.3.1
|
||||
# via fastapi
|
||||
truststore==0.10.4
|
||||
# via
|
||||
# httpcore2
|
||||
# httpx2
|
||||
typing-extensions==4.16.0
|
||||
# via
|
||||
# alembic
|
||||
# anyio
|
||||
# fastapi
|
||||
# httpx2
|
||||
# mypy
|
||||
# psycopg
|
||||
# pydantic
|
||||
|
||||
@@ -66,9 +66,7 @@ def test_return_registered_audit_event_exposes_before_after_and_link(ops_client)
|
||||
)
|
||||
|
||||
key = "test-audit-before-after-001"
|
||||
events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "return_registered"}
|
||||
).json()
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "return_registered"}).json()
|
||||
event = next(e for e in events if e["metadata"]["idempotency_key"] == key)
|
||||
assert event["before"] == {"status": "active"}
|
||||
assert event["after"]["status"] == "returned"
|
||||
|
||||
@@ -4,6 +4,48 @@ def test_unauthenticated_dashboard_is_rejected(client):
|
||||
assert response.json()["error"]["code"] == "401"
|
||||
|
||||
|
||||
def test_oidc_status_is_disabled_without_configuration(client):
|
||||
assert client.get("/api/v1/auth/oidc/status").json() == {
|
||||
"enabled": False,
|
||||
"provider_name": None,
|
||||
}
|
||||
assert client.get("/api/v1/auth/oidc/login").status_code == 404
|
||||
|
||||
|
||||
def test_oidc_callback_auto_provisions_and_logs_in(client, monkeypatch):
|
||||
import app.api.routers.auth as auth_router
|
||||
|
||||
class FakeClient:
|
||||
async def authorize_access_token(self, _request):
|
||||
return {
|
||||
"userinfo": {
|
||||
"sub": "external-subject-1",
|
||||
"email": "oidc.user@example.test",
|
||||
"email_verified": True,
|
||||
"name": "OIDC User",
|
||||
}
|
||||
}
|
||||
|
||||
class FakeOAuth:
|
||||
def create_client(self, _name):
|
||||
return FakeClient()
|
||||
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
|
||||
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
|
||||
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
|
||||
|
||||
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"].endswith("/dashboard")
|
||||
session = client.get("/api/v1/auth/session")
|
||||
assert session.status_code == 200
|
||||
assert session.json()["display_name"] == "OIDC User"
|
||||
assert session.json()["role"] == "rental_employee"
|
||||
|
||||
|
||||
def test_demo_login_grants_access(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
assert response.status_code == 200
|
||||
@@ -83,9 +125,7 @@ def test_logout_rejects_a_cookie_even_if_the_browser_retains_it(ops_client):
|
||||
ops_client.cookies.clear()
|
||||
|
||||
# A fresh login in the same second receives a distinct signed token and remains valid.
|
||||
fresh_login = ops_client.post(
|
||||
"/api/v1/demo/login", json={"role": "operations_manager"}
|
||||
)
|
||||
fresh_login = ops_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
||||
assert fresh_login.status_code == 200
|
||||
assert ops_client.get("/api/v1/auth/session").status_code == 200
|
||||
|
||||
|
||||
@@ -65,9 +65,7 @@ def test_attention_vehicles_respects_limit(client):
|
||||
|
||||
|
||||
def test_vehicle_details_known_ref(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers()
|
||||
)
|
||||
response = client.get("/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers())
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["public_ref"] == "MO-016"
|
||||
@@ -75,9 +73,7 @@ def test_vehicle_details_known_ref(client):
|
||||
|
||||
|
||||
def test_vehicle_details_unknown_ref_is_404(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers()
|
||||
)
|
||||
response = client.get("/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers())
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
|
||||
@@ -56,9 +56,7 @@ def test_seed_demo_scenarios_present():
|
||||
assert duplicate_issue is not None
|
||||
assert duplicate_issue.rule_type == "possible_duplicate_customer"
|
||||
|
||||
failed_run = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.delivery_status == "failed")
|
||||
)
|
||||
failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.delivery_status == "failed"))
|
||||
assert failed_run is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -36,9 +36,7 @@ def _facts(**overrides) -> VehicleStatusFacts:
|
||||
|
||||
|
||||
def test_available_with_active_rental_recommends_rented():
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("available"), _facts(active_booking_refs=["BK-0001"])
|
||||
)
|
||||
result = evaluate_vehicle_status(_vehicle("available"), _facts(active_booking_refs=["BK-0001"]))
|
||||
assert result.recommended_status == "rented"
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL
|
||||
assert result.safe_to_apply is True
|
||||
@@ -83,9 +81,7 @@ def test_rented_with_no_active_booking_recommends_available():
|
||||
|
||||
|
||||
def test_service_threshold_reached_recommends_maintenance():
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("available"), _facts(service_threshold_reached=True)
|
||||
)
|
||||
result = evaluate_vehicle_status(_vehicle("available"), _facts(service_threshold_reached=True))
|
||||
assert result.recommended_status == "maintenance"
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD
|
||||
|
||||
|
||||
@@ -52,9 +52,7 @@ def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
|
||||
|
||||
|
||||
def test_retry_requires_operations_manager(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
|
||||
)
|
||||
response = employee_client.post("/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user