diff --git a/.env.example b/.env.example index 4681d67..da9212a 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,12 @@ DEMO_ORGANIZATION_NAME=Northstar Mobility DEMO_TIMEZONE=Europe/Brussels DEMO_ALLOW_RESET=true +# Operational mode: set MOBILITYOPS_DEMO_MODE=false and provide the first manager. +# Keep these values in a secret store or an untracked production .env file. +INITIAL_ADMIN_EMAIL= +INITIAL_ADMIN_PASSWORD= +INITIAL_ADMIN_DISPLAY_NAME=Operations Manager + # n8n N8N_BASE_URL=http://n8n:5678 N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return diff --git a/backend/alembic/versions/b7c7b536df85_operational_user_credentials.py b/backend/alembic/versions/b7c7b536df85_operational_user_credentials.py new file mode 100644 index 0000000..594c205 --- /dev/null +++ b/backend/alembic/versions/b7c7b536df85_operational_user_credentials.py @@ -0,0 +1,25 @@ +"""operational user credentials + +Revision ID: b7c7b536df85 +Revises: 799d8800e241 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "b7c7b536df85" +down_revision = "799d8800e241" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("email", sa.String(length=320), nullable=True)) + op.add_column("users", sa.Column("password_hash", sa.String(length=512), nullable=True)) + op.create_unique_constraint("uq_users_email", "users", ["email"]) + + +def downgrade() -> None: + op.drop_constraint("uq_users_email", "users", type_="unique") + op.drop_column("users", "password_hash") + op.drop_column("users", "email") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 096b501..f049304 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -8,6 +8,7 @@ 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 settings = get_settings() @@ -22,13 +23,21 @@ def get_db() -> Generator[Session, None, None]: db.close() -def get_current_user(request: Request) -> CurrentUser: +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 if payload is None or payload.role not in _VALID_ROLES: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") - role: Role = payload.role # type: ignore[assignment] - return CurrentUser(public_ref=payload.public_ref, display_name=payload.display_name, role=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( diff --git a/backend/app/api/routers/auth.py b/backend/app/api/routers/auth.py new file mode 100644 index 0000000..053dbd6 --- /dev/null +++ b/backend/app/api/routers/auth.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import time +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from sqlalchemy import select +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.security import ( + SessionPayload, + create_session_token, + hash_password, + read_session_token, + verify_password, +) +from app.models.user import User +from app.schemas import CurrentUser, PasswordLoginRequest +from app.services.audit import record_audit_event + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) +settings = get_settings() + + +def _current_user_out(user: User) -> CurrentUser: + return CurrentUser( + public_ref=user.public_ref, + display_name=user.display_name, + role=user.role, # type: ignore[arg-type] + ) + + +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()), + ) + ) + response.set_cookie( + settings.session_cookie_name, token, httponly=True, samesite="lax", + secure=settings.session_cookie_secure, max_age=settings.session_ttl_seconds, + ) + + +def bootstrap_initial_admin(db: Session) -> None: + """Create or rotate the explicitly configured first manager in operational mode.""" + if ( + settings.mobilityops_demo_mode + or not settings.initial_admin_email + or not settings.initial_admin_password + ): + return + email = settings.initial_admin_email.strip().lower() + user = db.scalar(select(User).where(User.email == email)) + if user is None: + user = User( + public_ref="USR-ADMIN", + email=email, + password_hash=hash_password(settings.initial_admin_password), + display_name=settings.initial_admin_display_name, + role="operations_manager", + active=True, + ) + db.add(user) + record_audit_event( + db, + actor_type="system", + actor_label="bootstrap", + action="operational_admin_created", + entity_type="user", + entity_id=user.id, + ) + db.commit() + + +@router.post("/login", response_model=CurrentUser) +def password_login( + body: PasswordLoginRequest, + response: Response, + db: Session = Depends(get_db), +) -> CurrentUser: + if settings.mobilityops_demo_mode: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Password login is unavailable in demo mode", + ) + 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): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + _set_session(response, user) + record_audit_event( + db, + actor_type="user", + actor_id=user.id, + actor_label=user.display_name, + action="password_login", + entity_type="user", + entity_id=user.id, + ) + db.commit() + return _current_user_out(user) + + +@router.get("/session", response_model=CurrentUser) +def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + response.headers["Cache-Control"] = "no-store" + return user + + +@router.post("/logout") +def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict: + token = request.cookies.get(settings.session_cookie_name) + payload = read_session_token(token) if token else None + if payload is not None: + record_audit_event( + db, + actor_type="user", + actor_id=uuid.UUID(payload.user_id), + actor_label=payload.display_name, + action="logout", + entity_type="user", + ) + db.commit() + response.delete_cookie(settings.session_cookie_name) + return {"status": "logged_out"} diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index fd06a68..73957d4 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -22,6 +22,8 @@ settings = get_settings() @router.get("/manifest", response_model=DemoManifestOut) def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut: + if not settings.mobilityops_demo_mode: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled") # Deliberately unauthenticated: the demo-entry screen and the permanent demo badge # both need this before any session exists. Nothing here is sensitive — it's the same # honest "what is this demo" summary a logged-in user would see. @@ -32,6 +34,8 @@ def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut: def demo_login( body: DemoLoginRequest, response: Response, db: Session = Depends(get_db) ) -> CurrentUser: + if not settings.mobilityops_demo_mode: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled") public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP" user = db.scalar(select(User).where(User.public_ref == public_ref)) if user is None: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 70afce0..7e5dbc5 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -48,6 +48,9 @@ class Settings(BaseSettings): demo_organization_name: str = "Northstar Mobility" demo_timezone: str = "Europe/Brussels" demo_allow_reset: bool = True + initial_admin_email: str = "" + initial_admin_password: str = "" + initial_admin_display_name: str = "Operations Manager" @lru_cache diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 46ac28d..15b36a6 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -4,6 +4,7 @@ import base64 import hashlib import hmac import json +import os import time from dataclasses import dataclass @@ -50,3 +51,31 @@ def read_session_token(token: str) -> SessionPayload | None: if time.time() - payload.issued_at > settings.session_ttl_seconds: return None return payload + + +def hash_password(password: str) -> str: + salt = os.urandom(16) + derived = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32) + encoded_salt = base64.urlsafe_b64encode(salt).decode() + encoded_hash = base64.urlsafe_b64encode(derived).decode() + return f"scrypt$16384$8$1${encoded_salt}${encoded_hash}" + + +def verify_password(password: str, encoded: str | None) -> bool: + if not encoded: + return False + try: + algorithm, n, r, p, salt, expected = encoded.split("$") + if algorithm != "scrypt": + return False + derived = hashlib.scrypt( + password.encode(), + salt=base64.urlsafe_b64decode(salt.encode()), + n=int(n), + r=int(r), + p=int(p), + dklen=32, + ) + return hmac.compare_digest(derived, base64.urlsafe_b64decode(expected.encode())) + except (ValueError, TypeError): + return False diff --git a/backend/app/main.py b/backend/app/main.py index a44449e..daadf8d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from fastapi.responses import JSONResponse from app.api.routers import ( audit, + auth, bookings, dashboard, data_quality, @@ -19,7 +20,9 @@ from app.api.routers import ( vehicles, workflows, ) +from app.api.routers.auth import bootstrap_initial_admin from app.core.config import PRODUCT_NAME, get_settings +from app.core.db import SessionLocal from app.core.errors import AppError, error_body from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher @@ -28,6 +31,8 @@ settings = get_settings() @asynccontextmanager async def lifespan(_app: FastAPI): + with SessionLocal() as db: + bootstrap_initial_admin(db) start_background_dispatcher() yield stop_background_dispatcher() @@ -81,6 +86,7 @@ def system_status() -> dict[str, object]: app.include_router(demo.router) +app.include_router(auth.router) app.include_router(dashboard.router) app.include_router(vehicles.router) app.include_router(bookings.router) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 9506532..beffd0a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -11,6 +11,8 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "users" public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True) + password_hash: Mapped[str | None] = mapped_column(String(512), nullable=True) 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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 78dda18..55d3e61 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -12,6 +12,11 @@ class DemoLoginRequest(BaseModel): role: Role +class PasswordLoginRequest(BaseModel): + email: str = Field(min_length=3, max_length=320) + password: str = Field(min_length=8, max_length=256) + + class CurrentUser(BaseModel): public_ref: str display_name: str diff --git a/backend/tests/test_operational_auth.py b/backend/tests/test_operational_auth.py new file mode 100644 index 0000000..8d54283 --- /dev/null +++ b/backend/tests/test_operational_auth.py @@ -0,0 +1,36 @@ +from app.api.routers import auth as auth_router +from app.core.db import SessionLocal +from app.core.security import hash_password +from app.models.user import User + + +def test_password_login_is_available_only_outside_demo_mode(ops_client, monkeypatch): + user = User( + public_ref="USR-REAL", + email="manager@example.test", + password_hash=hash_password("correct-horse-battery-staple"), + display_name="Real Manager", + role="operations_manager", + active=True, + ) + with SessionLocal() as db: + db.add(user) + db.commit() + monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False) + + response = ops_client.post( + "/api/v1/auth/login", + json={"email": "manager@example.test", "password": "correct-horse-battery-staple"}, + ) + assert response.status_code == 200 + assert response.json()["public_ref"] == "USR-REAL" + assert ops_client.get("/api/v1/auth/session").json()["display_name"] == "Real Manager" + + +def test_password_login_rejects_invalid_credentials(ops_client, monkeypatch): + monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False) + response = ops_client.post( + "/api/v1/auth/login", + json={"email": "unknown@example.test", "password": "correct-horse-battery-staple"}, + ) + assert response.status_code == 401 diff --git a/docs/17-runbook.md b/docs/17-runbook.md index 771e452..349f747 100644 --- a/docs/17-runbook.md +++ b/docs/17-runbook.md @@ -30,6 +30,21 @@ docker compose run --rm api pytest -q # all tests pass docker compose run --rm api ruff check . # clean ``` +## Operational mode (non-demo login) + +Keep the current demonstration environment on `MOBILITYOPS_DEMO_MODE=true`. For an +operational deployment, set `MOBILITYOPS_DEMO_MODE=false`, `DEMO_ALLOW_RESET=false` and +provide `INITIAL_ADMIN_EMAIL`, `INITIAL_ADMIN_PASSWORD` (at least 8 characters) and an +optional `INITIAL_ADMIN_DISPLAY_NAME` in the deployment's untracked secret environment. +On startup the API creates the first active Operations Manager only when no user with that +email exists. The sign-in page then accepts email/password instead of exposing demo roles; +demo reset, the guided tour and the synthetic-data badge are hidden. + +Use a long unique `APP_SECRET`, set `SESSION_COOKIE_SECURE=true` once the public endpoint +uses HTTPS, and keep `INITIAL_ADMIN_PASSWORD` out of Git and logs. Existing sessions are +revalidated against the current user record on every request, so deactivating an account +invalidates its next request. + ## n8n automation (one-time per environment) The n8n image used here (n8nio/n8n:latest, 2.x) requires an owner account before any diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index ae76242..05e468f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -6,6 +6,13 @@ export interface CurrentUser { role: Role; } +export interface SystemStatus { + service: string; + environment: string; + demo_mode: boolean; + knowledge_provider: string; +} + export interface Vehicle { public_ref: string; make: string; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 82d5f54..3bba590 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -75,7 +75,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [ export function Layout() { const { t } = useTranslation(["navigation", "common", "auth"]); - const { user, logout } = useAuth(); + const { user, logout, demoMode } = useAuth(); const { manifest } = useDemoManifest(); const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide(); const navigate = useNavigate(); @@ -231,7 +231,7 @@ export function Layout() {
{t("accessIntro")}
{error &&{error}
} -{t("safeByDesignTitle")}{t("safeByDesignDetail")}