M8: add operational authentication mode

This commit is contained in:
NuklearRabbit
2026-08-10 02:06:50 +02:00
parent 4cdf667dc1
commit 0bfcf71ff7
20 changed files with 345 additions and 24 deletions
+6
View File
@@ -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
@@ -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")
+12 -3
View File
@@ -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(
+128
View File
@@ -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"}
+4
View File
@@ -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:
+3
View File
@@ -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
+29
View File
@@ -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
+6
View File
@@ -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)
+2
View File
@@ -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)
+5
View File
@@ -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
+36
View File
@@ -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
+15
View File
@@ -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
+7
View File
@@ -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;
+5 -5
View File
@@ -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() {
<span className="environment-dot" />
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
</div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
{demoMode && user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
<ApiErrorNotice error={resetError} />
{!resetConfirming ? (
@@ -313,8 +313,8 @@ export function Layout() {
)}
</div>
<div className="topbar-meta">
<DemoGuideTrigger />
<DemoBadge />
{demoMode && <DemoGuideTrigger />}
{demoMode && <DemoBadge />}
{user && (
<details className="operator-menu">
<summary className="operator">
@@ -350,7 +350,7 @@ export function Layout() {
</button>
</nav>
<DemoGuide />
{demoMode && <DemoGuide />}
</div>
);
}
+23 -6
View File
@@ -1,11 +1,13 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { api, onUnauthorized } from "../api/client";
import type { CurrentUser, Role } from "../api/types";
import type { CurrentUser, Role, SystemStatus } from "../api/types";
interface AuthState {
user: CurrentUser | null;
loading: boolean;
demoMode: boolean;
loginAs: (role: Role) => Promise<void>;
loginWithPassword: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
@@ -32,11 +34,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// confirmed (or rejected) the session cookie.
const [user, setUser] = useState<CurrentUser | null>(() => readCachedUser());
const [loading, setLoading] = useState(true);
const [demoMode, setDemoMode] = useState(true);
useEffect(() => {
let cancelled = false;
api
.get<CurrentUser>("/api/v1/demo/session")
api.get<SystemStatus>("/api/v1/system/status")
.then((system) => setDemoMode(system.demo_mode))
.catch(() => setDemoMode(true))
.finally(() => api
.get<CurrentUser>("/api/v1/auth/session")
.then((confirmed) => {
if (cancelled) return;
setUser(confirmed);
@@ -49,7 +55,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
})
.finally(() => {
if (!cancelled) setLoading(false);
});
}));
return () => {
cancelled = true;
};
@@ -75,11 +81,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, []);
const loginWithPassword = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
const loggedIn = await api.post<CurrentUser>("/api/v1/auth/login", { email, password });
setUser(loggedIn);
cacheUser(loggedIn);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
setUser(null);
cacheUser(null);
try {
await api.post("/api/v1/demo/logout");
await api.post("/api/v1/auth/logout");
} catch {
// Best effort: the cookie is cleared server-side when it works, and the client has
// already dropped its own state either way.
@@ -87,7 +104,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
return (
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>{children}</AuthContext.Provider>
<AuthContext.Provider value={{ user, loading, demoMode, loginAs, loginWithPassword, logout }}>{children}</AuthContext.Provider>
);
}
+5 -1
View File
@@ -19,5 +19,9 @@
"roleOperationsManager": "Operations manager",
"roleRentalEmployee": "Rental employee",
"switchRole": "Switch role",
"logout": "Log out"
"logout": "Log out",
"emailLabel": "Email address",
"passwordLabel": "Password",
"signIn": "Sign in",
"passwordLoginFailed": "Sign-in failed. Check your credentials."
}
+5 -1
View File
@@ -19,5 +19,9 @@
"roleOperationsManager": "Responsable des opérations",
"roleRentalEmployee": "Collaborateur de location",
"switchRole": "Changer de rôle",
"logout": "Déconnexion"
"logout": "Déconnexion",
"emailLabel": "Adresse e-mail",
"passwordLabel": "Mot de passe",
"signIn": "Se connecter",
"passwordLoginFailed": "Connexion impossible. Vérifiez vos identifiants."
}
+5 -1
View File
@@ -19,5 +19,9 @@
"roleOperationsManager": "Operationsmanager",
"roleRentalEmployee": "Verhuurmedewerker",
"switchRole": "Wissel van rol",
"logout": "Uitloggen"
"logout": "Uitloggen",
"emailLabel": "E-mailadres",
"passwordLabel": "Wachtwoord",
"signIn": "Aanmelden",
"passwordLoginFailed": "Aanmelden mislukt. Controleer je gegevens."
}
+3 -3
View File
@@ -46,7 +46,7 @@ export function Dashboard() {
const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]);
const { formatTime, formatShortDate, formatNumber } = useLocaleFormat();
const greetingPeriod = useGreetingPeriod();
const { user } = useAuth();
const { user, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
const [searchParams, setSearchParams] = useSearchParams();
@@ -114,7 +114,7 @@ export function Dashboard() {
<div className="page dashboard-page">
<PageHeader eyebrow={t("eyebrow")} title={greetingTitle} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
<section className="demo-start-panel" aria-label={t("demoStart.title")}>
{demoMode && <section className="demo-start-panel" aria-label={t("demoStart.title")}>
<div>
<Icon name="spark" />
<div>
@@ -132,7 +132,7 @@ export function Dashboard() {
)}
<Link className="button button-primary" to="/scenarios">{t("demoStart.viewScenarios")} <Icon name="chevron" /></Link>
</div>
</section>
</section>}
<section className="readiness-band" aria-labelledby="readiness-heading">
<div className="readiness-label">
+21 -4
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
@@ -10,10 +10,12 @@ import { PRODUCT_NAME } from "../product";
export function Login() {
const { t } = useTranslation(["auth", "common"]);
const { loginAs, loading } = useAuth();
const { loginAs, loginWithPassword, loading, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function handleLogin(role: Role, guided = false) {
setError(null);
@@ -25,6 +27,17 @@ export function Login() {
}
}
async function handlePasswordLogin(event: FormEvent) {
event.preventDefault();
setError(null);
try {
await loginWithPassword(email, password);
navigate("/dashboard");
} catch {
setError(t("passwordLoginFailed"));
}
}
const orgName = manifest?.organization_name ?? t("common:orgName");
const description = t("defaultDescription", { productName: PRODUCT_NAME });
@@ -61,7 +74,7 @@ export function Login() {
<p className="login-intro">{t("accessIntro")}</p>
{error && <p className="error" role="alert">{error}</p>}
<button
{demoMode ? <><button
type="button"
className="button button-primary login-guide-cta"
disabled={loading}
@@ -82,7 +95,11 @@ export function Login() {
<span><strong>{t("exploreAsRentalEmployee")}</strong><small>{t("exploreAsRentalEmployeeDetail")}</small></span>
<Icon name="chevron" />
</button>
</div>
</div></> : <form className="login-options" onSubmit={handlePasswordLogin}>
<label>{t("emailLabel")}<input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} required /></label>
<label>{t("passwordLabel")}<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} required /></label>
<button type="submit" className="button button-primary" disabled={loading}>{t("signIn")}</button>
</form>}
<div className="access-note"><Icon name="shield" /><p><strong>{t("safeByDesignTitle")}</strong><span>{t("safeByDesignDetail")}</span></p></div>
</div>
</section>