M21: add optional organisation identity

This commit is contained in:
NuklearRabbit
2026-08-10 15:35:25 +02:00
parent 509cb95110
commit c3f1cfc699
38 changed files with 563 additions and 110 deletions
+4 -4
View File
@@ -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)
+147 -5
View File
@@ -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,
+7 -5
View File
@@ -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),
+1 -3
View File
@@ -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"
+6 -1
View File
@@ -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(
+4 -1
View File
@@ -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(
+10
View File
@@ -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
View File
@@ -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,
}
+1 -3
View File
@@ -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:
+6 -1
View File
@@ -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)
+5
View File
@@ -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
+15 -13
View File
@@ -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}
+1 -2
View File
@@ -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:
+2 -6
View File
@@ -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(
+137 -16
View File
@@ -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}",
}
+3 -1
View File
@@ -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]:
+1 -3
View File
@@ -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
+4 -12
View File
@@ -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