diff --git a/.env.example b/.env.example index da9212a..8094e63 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,17 @@ TZ=Europe/Brussels # otherwise browsers will silently drop the cookie and no one can log in. SESSION_COOKIE_SECURE=false +# Optional OpenID Connect login. Public demo role buttons remain available when enabled. +OIDC_ENABLED=false +OIDC_PROVIDER_NAME=Organisatieaccount +OIDC_ISSUER_URL= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI= +OIDC_ALLOWED_EMAIL_DOMAINS= +OIDC_AUTO_PROVISION=true +OIDC_DEFAULT_ROLE=rental_employee + # Demo presentation (fictional org identity, badge/manifest, reset safety valve). # DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent # of role -- a safety valve for any environment where the dataset must not be rebuildable. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 1bc835e..0922150 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2503,3 +2503,20 @@ evidence yet." `/mnt/user/appdata/mobilityops/backups/postgres/mobilityops-20260810T111913Z.dump`. - Exact next action: none for the locked PoC. Routine operation, monitoring and any scope expansion require a separate approved milestone. + +## M21 — optional organisation identity alongside the public demo (2026-08-10) + +- Added standards-based OpenID Connect login while preserving both public demo roles and + the guided demo. Provider discovery, authorization-code exchange, state/nonce checks + and ID-token validation are delegated to Authlib's OIDC client. +- External identities bind uniquely to issuer plus subject. A verified email is required; + deployments can enforce an email-domain allowlist and disable auto-provisioning. New + users receive the least-privileged rental role and every provision/link/login is + audited. Deactivated users remain blocked by the canonical user record. +- Added the nullable external-identity migration `a81d0ce9f662`, configuration contract, + trilingual login action and runbook. OIDC secrets stay deployment-only. +- Added Starlette's supported `httpx2` test transport, removing the prior suite-wide + deprecation warning rather than suppressing it. +- Evidence: focused authentication **13 passed with zero warnings**; frontend production + build passed; ruff clean. Exact next action: implement structured request logging, + correlation, metrics, dashboards and alerts. diff --git a/backend/alembic/versions/a81d0ce9f662_oidc_identity.py b/backend/alembic/versions/a81d0ce9f662_oidc_identity.py new file mode 100644 index 0000000..441cff1 --- /dev/null +++ b/backend/alembic/versions/a81d0ce9f662_oidc_identity.py @@ -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") diff --git a/backend/app/api/routers/audit.py b/backend/app/api/routers/audit.py index da76092..dcdae8d 100644 --- a/backend/app/api/routers/audit.py +++ b/backend/app/api/routers/audit.py @@ -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) diff --git a/backend/app/api/routers/auth.py b/backend/app/api/routers/auth.py index f241dcb..24c3455 100644 --- a/backend/app/api/routers/auth.py +++ b/backend/app/api/routers/auth.py @@ -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, diff --git a/backend/app/api/routers/dashboard.py b/backend/app/api/routers/dashboard.py index c69c578..e9b58f6 100644 --- a/backend/app/api/routers/dashboard.py +++ b/backend/app/api/routers/dashboard.py @@ -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), diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index d22bb11..ff271ed 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -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" diff --git a/backend/app/api/routers/mcp_integrations.py b/backend/app/api/routers/mcp_integrations.py index 8713c1a..1680bf6 100644 --- a/backend/app/api/routers/mcp_integrations.py +++ b/backend/app/api/routers/mcp_integrations.py @@ -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( diff --git a/backend/app/api/routers/search.py b/backend/app/api/routers/search.py index f8aaa52..dd1df89 100644 --- a/backend/app/api/routers/search.py +++ b/backend/app/api/routers/search.py @@ -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( diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7e5dbc5..541eb0c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 36537b7..e293208 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, } diff --git a/backend/app/models/mixins.py b/backend/app/models/mixins.py index 3a319c8..7372cc8 100644 --- a/backend/app/models/mixins.py +++ b/backend/app/models/mixins.py @@ -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: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index beffd0a..bed5840 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 72df330..1836413 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py index 905cf5c..a63771a 100644 --- a/backend/app/services/demo_manifest.py +++ b/backend/app/services/demo_manifest.py @@ -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..*) 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} diff --git a/backend/app/services/dispatcher.py b/backend/app/services/dispatcher.py index 7b30c60..a3f25e7 100644 --- a/backend/app/services/dispatcher.py +++ b/backend/app/services/dispatcher.py @@ -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: diff --git a/backend/app/services/integration_status.py b/backend/app/services/integration_status.py index 2fa9136..82b4847 100644 --- a/backend/app/services/integration_status.py +++ b/backend/app/services/integration_status.py @@ -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( diff --git a/backend/app/services/knowledge/demo.py b/backend/app/services/knowledge/demo.py index 04312e0..e922be8 100644 --- a/backend/app/services/knowledge/demo.py +++ b/backend/app/services/knowledge/demo.py @@ -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}", } diff --git a/backend/app/services/knowledge/procedures.py b/backend/app/services/knowledge/procedures.py index a4a995e..8c6b10d 100644 --- a/backend/app/services/knowledge/procedures.py +++ b/backend/app/services/knowledge/procedures.py @@ -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]: diff --git a/backend/app/services/sessions.py b/backend/app/services/sessions.py index 002e21e..7cf4e2c 100644 --- a/backend/app/services/sessions.py +++ b/backend/app/services/sessions.py @@ -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 diff --git a/backend/app/services/vehicle_status.py b/backend/app/services/vehicle_status.py index 8eba07e..2897c19 100644 --- a/backend/app/services/vehicle_status.py +++ b/backend/app/services/vehicle_status.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4179ff3..8c64fbc 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/requirements.lock b/backend/requirements.lock index 3d91d84..3115a47 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -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 diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 72bdc7a..53ba24f 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -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" diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 807573f..ddb5b98 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_mcp_integrations.py b/backend/tests/test_mcp_integrations.py index 4b19a8a..6429e58 100644 --- a/backend/tests/test_mcp_integrations.py +++ b/backend/tests/test_mcp_integrations.py @@ -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 diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index 7fe8511..7574bc2 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -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() diff --git a/backend/tests/test_vehicle_status.py b/backend/tests/test_vehicle_status.py index b983dff..ab4ca85 100644 --- a/backend/tests/test_vehicle_status.py +++ b/backend/tests/test_vehicle_status.py @@ -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 diff --git a/backend/tests/test_workflows.py b/backend/tests/test_workflows.py index aa7ed4f..ad6542d 100644 --- a/backend/tests/test_workflows.py +++ b/backend/tests/test_workflows.py @@ -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 diff --git a/compose.yaml b/compose.yaml index 5930e80..f341aa0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -41,6 +41,17 @@ services: DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility} DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels} DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true} + MOBILITYOPS_PUBLIC_URL: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + OIDC_ENABLED: ${OIDC_ENABLED:-false} + OIDC_PROVIDER_NAME: ${OIDC_PROVIDER_NAME:-Organisatieaccount} + OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} + OIDC_ALLOWED_EMAIL_DOMAINS: ${OIDC_ALLOWED_EMAIL_DOMAINS:-} + OIDC_AUTO_PROVISION: ${OIDC_AUTO_PROVISION:-true} + OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-rental_employee} ports: - "8128:8000" depends_on: diff --git a/docs/17-runbook.md b/docs/17-runbook.md index 3dcf116..7e33e82 100644 --- a/docs/17-runbook.md +++ b/docs/17-runbook.md @@ -50,6 +50,22 @@ uses HTTPS, and keep `INITIAL_ADMIN_PASSWORD` out of Git and logs. Existing sess revalidated against the current user record on every request, so deactivating an account invalidates its next request. +### Optional organisation login (OIDC) + +OpenID Connect can coexist with the public demo. Set `OIDC_ENABLED=true`, issuer URL, +client ID and client secret; register +`/api/v1/auth/oidc/callback` at the identity provider. The login +screen then adds an organisation-login action without removing either public demo role. +State and nonce validation use a short-lived signed HttpOnly cookie. Identity binding is +unique on issuer plus `sub`; only a verified email is accepted. Optionally restrict +domains with `OIDC_ALLOWED_EMAIL_DOMAINS`. New identities receive the least-privileged +`rental_employee` role by default and can subsequently be promoted by an Operations +Manager. Set `OIDC_AUTO_PROVISION=false` when every account must be pre-created. + +OIDC must use HTTPS outside a trusted local network. Set `SESSION_COOKIE_SECURE=true` and +keep `OIDC_CLIENT_SECRET` in the deployment secret store. Disabling OIDC immediately +removes the organisation-login action but does not affect public demo access. + ## n8n automation (one-time per environment) The optional bundled fallback image (`n8nio/n8n:2.33.7`) requires an owner account before any diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index fdda448..c8f9751 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -11,6 +11,8 @@ export interface SystemStatus { environment: string; demo_mode: boolean; knowledge_provider: string; + oidc_enabled: boolean; + oidc_provider_name: string | null; } export interface Vehicle { diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index a3d07d1..9268a1e 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -6,6 +6,8 @@ interface AuthState { user: CurrentUser | null; loading: boolean; demoMode: boolean; + oidcEnabled: boolean; + oidcProviderName: string | null; loginAs: (role: Role) => Promise; loginWithPassword: (email: string, password: string) => Promise; logout: () => Promise; @@ -35,11 +37,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(() => readCachedUser()); const [loading, setLoading] = useState(true); const [demoMode, setDemoMode] = useState(true); + const [oidcEnabled, setOidcEnabled] = useState(false); + const [oidcProviderName, setOidcProviderName] = useState(null); useEffect(() => { let cancelled = false; api.get("/api/v1/system/status") - .then((system) => setDemoMode(system.demo_mode)) + .then((system) => { + setDemoMode(system.demo_mode); + setOidcEnabled(system.oidc_enabled); + setOidcProviderName(system.oidc_provider_name); + }) .catch(() => setDemoMode(true)) .finally(() => api .get("/api/v1/auth/session") @@ -119,7 +127,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); return ( - {children} + {children} ); } diff --git a/frontend/src/i18n/locales/en-GB/auth.json b/frontend/src/i18n/locales/en-GB/auth.json index 1ce1632..a877562 100644 --- a/frontend/src/i18n/locales/en-GB/auth.json +++ b/frontend/src/i18n/locales/en-GB/auth.json @@ -23,5 +23,7 @@ "emailLabel": "Email address", "passwordLabel": "Password", "signIn": "Sign in", - "passwordLoginFailed": "Sign-in failed. Check your credentials." + "passwordLoginFailed": "Sign-in failed. Check your credentials.", + "or": "or", + "signInWithOrganization": "Sign in with {{provider}}" } diff --git a/frontend/src/i18n/locales/fr-BE/auth.json b/frontend/src/i18n/locales/fr-BE/auth.json index ac09d14..7cf23e0 100644 --- a/frontend/src/i18n/locales/fr-BE/auth.json +++ b/frontend/src/i18n/locales/fr-BE/auth.json @@ -23,5 +23,7 @@ "emailLabel": "Adresse e-mail", "passwordLabel": "Mot de passe", "signIn": "Se connecter", - "passwordLoginFailed": "Connexion impossible. Vérifiez vos identifiants." + "passwordLoginFailed": "Connexion impossible. Vérifiez vos identifiants.", + "or": "ou", + "signInWithOrganization": "Se connecter avec {{provider}}" } diff --git a/frontend/src/i18n/locales/nl-BE/auth.json b/frontend/src/i18n/locales/nl-BE/auth.json index f968804..2046f5a 100644 --- a/frontend/src/i18n/locales/nl-BE/auth.json +++ b/frontend/src/i18n/locales/nl-BE/auth.json @@ -23,5 +23,7 @@ "emailLabel": "E-mailadres", "passwordLabel": "Wachtwoord", "signIn": "Aanmelden", - "passwordLoginFailed": "Aanmelden mislukt. Controleer je gegevens." + "passwordLoginFailed": "Aanmelden mislukt. Controleer je gegevens.", + "or": "of", + "signInWithOrganization": "Aanmelden met {{provider}}" } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 9a6c9d8..5f684f6 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -10,7 +10,7 @@ import { PRODUCT_NAME } from "../product"; export function Login() { const { t } = useTranslation(["auth", "common"]); - const { loginAs, loginWithPassword, loading, demoMode } = useAuth(); + const { loginAs, loginWithPassword, loading, demoMode, oidcEnabled, oidcProviderName } = useAuth(); const { manifest } = useDemoManifest(); const navigate = useNavigate(); const [error, setError] = useState(null); @@ -100,6 +100,13 @@ export function Login() { } + {oidcEnabled && <> +
{t("or")}
+ + + {t("signInWithOrganization", { provider: oidcProviderName })} + + }

{t("safeByDesignTitle")}{t("safeByDesignDetail")}

diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3c3539f..67d179f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -113,6 +113,10 @@ a:hover { color: var(--teal); } .sidebar-language { display: none; padding: 0 20px 14px; } .login-panel-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .login-panel-top .page-eyebrow { margin: 0; } +.login-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; color: var(--muted); font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; } +.login-divider::before, .login-divider::after { content: ""; height: 1px; flex: 1; background: var(--line); } +.login-oidc { width: 100%; min-height: 44px; justify-content: center; gap: 8px; background: white; border: 1px solid var(--line-strong); } +.login-oidc svg { width: 17px; } .demo-badge { position: relative; } .demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; } .demo-badge-trigger:hover { background: #dfe8ef; }