Skip to content

Autoliv Safety REST API

A Python-based Azure Functions REST API for the Autoliv Safety Platform, providing secure endpoints for safety-critical automotive systems.

Current version: 0.15.0 Β· Python: 3.11 Β· Runtime: Azure Functions (Python)

πŸ“‹ Table of Contents


πŸ†• Latest Updates

v0.15.0 β€” Current

  • Deployment strategy overhaul. Adopted three long-lived environment branches β€” development β†’ Dev, staging β†’ Staging, production β†’ Prod β€” with forward-only linear promotion via pull requests. See DEPLOYMENT_STRATEGY.md for the full model (branching, hotfixes, feature flags, and diagrams).
  • Release-candidate versioning. Staging tags releases as vX.Y.Z-rc.N and Production finalizes the same core version as vX.Y.Z (Commitizen version_scheme = "semver"). Tags are created automatically after a successful deploy β€” the old bump: release commit trigger has been removed.
  • Feature flags. Added a per-environment feature-flag pattern via the Settings singleton and a new @require_feature("<Flag>") decorator. The EnablePartnerEndpoints flag gates the organization/partner endpoints (PUT /v1/devices/{deviceId}, POST /v1/events, POST /v1/events/{eventId}/data, GET /v1/firmware/check, GET /v1/firmware/download, POST /v1/inflators), returning 404 when disabled.

v0.14.0

  • Restructured src/app/ into organized subfolders: core/, middleware/, services/business/, services/infrastructure/.
  • Security dependency upgrades: Werkzeug 3.1.6, cryptography 46.0.6, azure-identity 1.16.1, PyJWT 2.12.0, requests 2.32.3, urllib3 2.5.0.
  • Fixed XSS vulnerability in id_token/index.html.
  • MSS-1465: Added Portal Comments β€” database table and full CRUD APIs for attaching comments to Users, Events, Devices, and FirmwareVersion.
  • Added organization-scoped integration on neutral routes (no /partner/* prefix) using issuer-dispatched authorization via @require_authenticated_principal.
  • Added new neutral org endpoints: PUT /v1/devices/{deviceId}, POST /v1/events, POST /v1/events/{eventId}/data, GET /v1/firmware/check, GET /v1/firmware/download, and POST /v1/inflators for strict org-scoped contracts.

OEM Partner Integration β€” Tenant Isolation Hardening

A set of behavior-safe fixes to support OEM partner (e.g. Thule) onboarding alongside existing Autoliv Safety and PTW Entra users. Existing consumer/Autoliv/PTW flows are unchanged.

  • Account-scoped org resolution on user creation. POST /v1/users now carries @require_authenticated_principal, so a partner Entra principal's account is stamped with the organization resolved from the Organizations table (via get_active_organization_by_appid) instead of defaulting to Autoliv.
  • Device org stamping. Consumer device create/update (PUT /v1/device/{deviceId}) now persists OrganizationName, and cross-org writes are blocked org-vs-org (a device owned by org A can no longer be modified by a user from org B).
  • Events carry the partner UserId. POST /v1/events resolves the partner account (get_me()), stores its UserId on the crash event, and returns 404 if the partner has no account.
  • Duplicate event handling. Inserting an event with an existing eventId now returns a clean 409 Conflict (new DuplicateEventError) instead of an opaque 400 or "Database query failed after retries". The DB layer re-raises IntegrityError immediately rather than retrying/masking it.
  • Firmware check org isolation. The Autoliv/consumer GET /v1/checkFirmwareUpdate/{deviceId} query is now organization-scoped (NULL-org rows treated as legacy/global), preventing cross-org firmware from being offered. Partner tokens are rejected here with 403 and directed to GET /v1/firmware/check.
  • GET /v1/firmware/check simplified for partners. Dropped the deviceId parameter; the contract is now firmwareVersion + deviceType only (partners own many devices).
  • Inflator UserId fix. POST /v1/inflators resolves the partner account and stamps DeviceComponents.UserId (NOT NULL + FK), fixing the Cannot insert the value NULL into column 'UserId' integrity error; returns 404 if the partner has no account.

v0.13.1

  • Fixed createdAt optional handling in users list schema.
  • Included usersDevices in device list responses.
  • Simplified SQL queries and refactored device link hydration.

v0.13.0

  • MSS-1294: Added new POST /v2/addEvent endpoint for crash event notifications with improved grace period status response.
  • MSS-764: List Devices in User information.
  • MSS-763: List Users in device information.

v0.12.4

  • MSS-1147: Added organizationName and deviceId fields to crash Events and updated Service Bus connections.
  • GET /v1/users (admin) now returns organizationName and deviceId per user via the updated UserListItemResponse schema.

How Releases Work

Releases follow the environment-branch promotion model (see DEPLOYMENT_STRATEGY.md). Code is promoted by pull request: development β†’ staging β†’ production. Merging to staging deploys to Staging and tags a release candidate (vX.Y.Z-rc.N); merging to production deploys to Prod and finalizes the tag (vX.Y.Z). Tags are created automatically after a successful deploy β€” the old bump: release trigger is no longer used. See CI/CD Pipeline for the full flow.


πŸ—οΈ Architecture

System Architecture Diagram

``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ CLIENT APPLICATIONS β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ Web App β”‚ Mobile App β”‚ IoT Devices β”‚ Admin Dashboard β”‚ β”‚ (React/JS) β”‚ (Kotlin) β”‚ (Embedded) β”‚ (PTW Admin Portal) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ AUTHENTICATION LAYER β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ FusionAuth β”‚ Auth UI Helper β”‚ External OAuth β”‚ Token Validation β”‚ β”‚ :9011 β”‚ :3000 β”‚ (Google/Apple) β”‚ (JWT RS256) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ API GATEWAY LAYER β”‚ β”‚ Azure Functions (:7071) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ ─ β”‚ Auth β”‚ Users β”‚ Device β”‚ Events β”‚ FOTA β”‚ UserSettings β”‚ β”‚ /v1/auth β”‚ /v1/usersβ”‚ /v1/deviβ”‚ /v1/add β”‚ /v1/Firmβ”‚ /v1/userEmergency β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ BUSINESS LOGIC LAYER β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ AuthService β”‚ Notification β”‚ TokenService β”‚ BlobStorage / DB Layer β”‚ β”‚ (Google/Apple)β”‚ Service + SNS β”‚ (Key Vault) β”‚ (pyodbc β†’ MSSQL) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ DATA LAYER β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ SQL Server β”‚ Azure Blob β”‚ Azure Service β”‚ Azure Key Vault β”‚ β”‚ :1433 β”‚ Storage β”‚ Bus (AMQP) β”‚ (JWT signing key) β”‚ β”‚ dbo.Users β”‚ fota-updates/ β”‚ Notification β”‚ β”‚ β”‚ dbo.Devices β”‚ crash-logs/ β”‚ Queue β”‚ β”‚ β”‚ dbo.CrashEventsβ”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Authentication Flow:
Client β†’ FusionAuth β†’ Google/Apple OIDC β†’ Autoliv JWT + Refresh Token

API Request Flow:
Client β†’ Azure Functions β†’ @validate_request β†’ Blueprint β†’ DB/Storage β†’ Response

Crash Event Flow:
IoT Device β†’ POST /v1/addEvent β†’ Service Bus (grace period) β†’ SNS SMS to contacts

FOTA Update Flow:
Admin β†’ POST /v1/uploadFirmware (PTW role required) β†’ Blob Storage β†’ Device polls

```

Technology Stack

Component Technology Version / Details
Runtime Azure Functions (Python) Python 3.11, Functions v4
API Framework azure-functions blueprints 1.21.3
Database Microsoft SQL Server MSSQL 2019+, pyodbc 5.1.0
ORM / Query Raw T-SQL via pyodbc + PLY parser Custom SQL query builder
Request Models Pydantic v2 + pydantic-settings β‰₯2.10.0, strict mode
Auth (login) Google / Apple OIDC + PyJWT PyJWT 2.12.0, RS256 via Azure Key Vault
Auth (Entra) Microsoft Entra ID JWKS validation for admin endpoints
Blob Storage Azure Blob Storage azure-storage-blob 12.14.1
Messaging Azure Service Bus azure-servicebus 7.14.1, AMQP persistent
SMS AWS SNS boto3 1.37.22
Key Vault Azure Key Vault (Keys) azure-keyvault-keys β‰₯4.7.0
Geocoding Azure Maps Search azure-maps-search 2.0.0b2
Localization pycountry 24.6.1
Type checking pyright (strict) Enforced via pre-commit
Documentation MkDocs + ReDoc + OpenAPI 3.0 Versioned via mike

πŸš€ Quick Start

Prerequisites

  • Python 3.11+
  • Docker & Docker Compose
  • Azure Functions Core Tools 4.x

Local Development Setup

  1. Clone and navigate:

bash git clone <repository-url> cd airbagconnect-api

  1. Start the full development stack:

bash docker-compose up -d

  1. Install development dependencies:

bash pip install -r requirements-dev.txt

  1. Install pre-commit hooks:

bash pre-commit install

  1. Populate the database (development data):

bash liquibase update --labels="dev"

Service URLs

Service URL Description
API http://localhost:7071 Azure Functions API endpoints
Auth Service http://localhost:9011 Local FusionAuth (OpenID Connect)
Auth UI http://localhost:3000 Token generation helper
Database localhost:1433 SQL Server (MSSQL)
Blob Storage localhost:10000 Azurite blob emulator
Documentation http://localhost:8000 MkDocs / ReDoc API docs

πŸ“ Project Structure

airbagconnect-api/ β”œβ”€β”€ .azure-pipelines/ β”‚ └── azure-pipelines.yml # CI/CD pipeline definition β”œβ”€β”€ .pre-commit-config.yaml # Pre-commit hook configuration β”œβ”€β”€ .vscode/ # VS Code settings, launch, tasks β”œβ”€β”€ azurite/ # Azure Storage emulator setup β”‚ β”œβ”€β”€ Dockerfile β”‚ └── create_container.py β”œβ”€β”€ bruno/ β”‚ └── AutolivSafety/ # Bruno API collection (Auth, Device, Event, FOTA, User, UserSettings) β”œβ”€β”€ claude_docs/ # Internal architecture docs (CONVENTIONS, DECISIONS, PROJECT_CONTEXT, REPO_MAP, RUNBOOK) β”œβ”€β”€ docs/ β”‚ β”œβ”€β”€ oas/ # OpenAPI JSON fragments + schema extractor β”‚ β”œβ”€β”€ redoc/ # ReDoc HTML template β”‚ └── mkdocs/ # MkDocs static site source β”œβ”€β”€ id_token/ # HTML tool for fetching Google/Apple ID tokens locally β”œβ”€β”€ liquibase/ β”‚ β”œβ”€β”€ changelog-root.yml β”‚ β”œβ”€β”€ changesets/ # XML changesets (v01.01.xx – v01.02.xx) β”‚ └── migrations/ # SQL scripts (v01.01.00 – v01.02.18) β”œβ”€β”€ mssql/ # SQL Server Docker image & init scripts β”œβ”€β”€ pyproject.toml # Tooling config (pylint, pyright, pytest, sqlfluff, commitizen) β”œβ”€β”€ requirements-dev.txt # Dev Python dependencies β”œβ”€β”€ docker-compose.yaml # Full local development stack β”œβ”€β”€ tox.ini β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ function_app.py # Azure Functions entrypoint β€” registers all blueprints β”‚ β”œβ”€β”€ host.json # Azure Functions host config β”‚ β”œβ”€β”€ local.settings.json # Local env vars (not committed) β”‚ β”œβ”€β”€ requirements.txt # Production Python dependencies β”‚ β”œβ”€β”€ Dockerfile # Production container image β”‚ └── app/ β”‚ β”œβ”€β”€ __init__.py # Startup: Service Bus warmup, atexit shutdown hook β”‚ β”œβ”€β”€ core/ β”‚ β”‚ β”œβ”€β”€ settings.py # Pydantic Settings β€” reads from env / local.settings.json β”‚ β”‚ β”œβ”€β”€ typing.py # Type protocols (AzureFunction, Dataclass) β”‚ β”‚ β”œβ”€β”€ exceptions.py # AuthenticationException β”‚ β”‚ β”œβ”€β”€ utils.py # Utility functions (field_names, as_uuid) β”‚ β”‚ β”œβ”€β”€ logging_config.py # Application-level logging configuration β”‚ β”‚ β”œβ”€β”€ log_error_message.py # Error message constants β”‚ β”‚ └── localized_message_store.py # Multilingual SMS templates β”‚ β”œβ”€β”€ middleware/ β”‚ β”‚ β”œβ”€β”€ auth.py # JWT validation (Autoliv_Safety + Entra ID), UserContext β”‚ β”‚ └── decorators.py # @validate_request, @handle_exceptions, @require_ptw_admin_portal_issuer, @require_fota_upload_permission β”‚ β”œβ”€β”€ blueprint/ β”‚ β”‚ β”œβ”€β”€ auth.py # /v1/auth/login, /v1/auth/refresh β”‚ β”‚ β”œβ”€β”€ comments.py # /v1/portal/comments CRUD β”‚ β”‚ β”œβ”€β”€ crash_event.py # /v1/addEvent, /v2/addEvent, abort, status, all events, blob upload, SAS, test SMS β”‚ β”‚ β”œβ”€β”€ device.py # /v1/device/{id}, /v1/devices/{id}, /v1/inflator, /v1/inflators β”‚ β”‚ β”œβ”€β”€ fota_service.py # /v1/uploadFirmware, versions, check/get, delete, /v1/firmware/check, /v1/firmware/download β”‚ β”‚ β”œβ”€β”€ servicebus_processor.py # Timer warmup + Service Bus queue trigger β”‚ β”‚ β”œβ”€β”€ users.py # /v1/users, /v1/users/me β”‚ β”‚ β”œβ”€β”€ usersettings.py # /v1/userEmergencyContacts, /v1/emergencyContacts/{ids} β”‚ β”‚ └── utils.py # Shared blueprint helpers (JSON response builders) β”‚ β”œβ”€β”€ db/ β”‚ β”‚ β”œβ”€β”€ cnxn.py # DB connection helpers (fetch_one, fetch_object, execute, execute_scalar) β”‚ β”‚ β”œβ”€β”€ comments.py # Comments DB operations β”‚ β”‚ β”œβ”€β”€ crash_event.py β”‚ β”‚ β”œβ”€β”€ device.py β”‚ β”‚ β”œβ”€β”€ error.py # Error DB operations β”‚ β”‚ β”œβ”€β”€ fota_service.py β”‚ β”‚ β”œβ”€β”€ notification.py β”‚ β”‚ β”œβ”€β”€ organization.py β”‚ β”‚ β”œβ”€β”€ permission.py β”‚ β”‚ β”œβ”€β”€ refresh_token.py β”‚ β”‚ β”œβ”€β”€ tables.py # Table/view name constants β”‚ β”‚ β”œβ”€β”€ users.py β”‚ β”‚ β”œβ”€β”€ users_devices.py # UsersDevices many-to-many DB operations β”‚ β”‚ β”œβ”€β”€ usersettings.py β”‚ β”‚ └── sql/ # PLY-based SQL query builder β”‚ β”œβ”€β”€ schema/ β”‚ β”‚ β”œβ”€β”€ auth.py # LoginRequest, TokenResponse, LoginResponse, RefreshTokenRequest β”‚ β”‚ β”œβ”€β”€ comments.py # CommentCreateRequest, CommentUpdateRequest, CommentListResponse β”‚ β”‚ β”œβ”€β”€ common.py β”‚ β”‚ β”œβ”€β”€ crash_event.py # CrashNotificationRequest, EventType, GeoLocation, NotificationStatusType β”‚ β”‚ β”œβ”€β”€ device.py # DeviceUpdateRequest, DeviceResponse, InflatorValidationResponse β”‚ β”‚ β”œβ”€β”€ fota.py # FotaUploadRequest/Response, FirmwareVersionResponse, FotaDeleteRequest β”‚ β”‚ └── users.py # UserRequest, UserResponse, UserListItemResponse, UserEmergencyContactRequest/Response β”‚ └── services/ β”‚ β”œβ”€β”€ business/ β”‚ β”‚ β”œβ”€β”€ auth_service.py # Google/Apple ID token validation, UserContextFactory β”‚ β”‚ β”œβ”€β”€ notification_handler.py # SMS dispatch via AWS SNS β”‚ β”‚ β”œβ”€β”€ notification_service.py # Grace period scheduling, abort, process_notification β”‚ β”‚ └── token_service.py # JWT minting/verification (Azure Key Vault RS256) β”‚ └── infrastructure/ β”‚ β”œβ”€β”€ servicebus.py # ServiceBusClientSingleton (AMQP connection pool + warm-keep) β”‚ └── storage.py # BlobStorage (upload, SAS token generation) └── tests/ β”œβ”€β”€ data.py # Shared test fixtures β”œβ”€β”€ unit/ β”‚ β”œβ”€β”€ blueprint/ # Per-blueprint unit tests (auth, crash_event, device, fota, users, usersettings, servicebus_processor) β”‚ β”œβ”€β”€ db/ # Per-db-module unit tests β”‚ β”œβ”€β”€ service_tests/ # Auth service, notification, token service, refresh token β”‚ β”œβ”€β”€ test_auth.py β”‚ β”œβ”€β”€ test_decorators.py β”‚ β”œβ”€β”€ test_servicebus.py β”‚ └── test_storage.py └── integration/ β”œβ”€β”€ api/ # Typed API wrappers per domain β”œβ”€β”€ conftest.py └── test_service_*.py # Full API tests: auth, crash_event, device, fota, user, usersettings


🌐 API Endpoints

All endpoints require a Bearer JWT in Authorization except where noted. Every request is validated by the @validate_request decorator which extracts a typed UserContext.

Authentication

Method Route Auth Description
POST /v1/auth/login ❌ Login with Google or Apple ID token. Returns access_token + refresh_token.
POST /v1/auth/refresh ❌ Single-use refresh token rotation. Returns a new token pair.

Users

Method Route Auth Description
GET /v1/users/me βœ… Get current user's profile.
DELETE /v1/users/me βœ… Anonymise account β€” clears PII, deletes emergency contacts & refresh tokens.
POST /v1/users βœ… Create / upsert a user profile tied to the JWT identity. Partner principals are stamped with their resolved organization.
GET /v1/users βœ… PTW Admin List all users with organizationName and deviceId. Admin issuer required.

Emergency Contacts (UserSettings)

Method Route Auth Description
GET /v1/userEmergencyContacts βœ… Get current user's ICE contacts (max 5).
POST /v1/userEmergencyContacts βœ… Add a new ICE contact (validates phone, language, limit, duplicates).
DELETE /v1/emergencyContacts/{contactIds} βœ… Delete contacts by comma-separated UUIDs.

Device

Method Route Auth Description
GET /v1/device/{deviceId} βœ… Get device info for the authenticated user.
PUT /v1/device/{deviceId} βœ… Upsert device record and assign to current user.
PUT /v1/devices/{deviceId} βœ… Principal-neutral device upsert (user or org principal).
GET /v1/inflator/{inflatorId} βœ… Validate inflator ID and check if already in use.
POST /v1/inflator βœ… Associate an inflator with the current authenticated user device.
POST /v1/inflators βœ… Org Strict org-scoped inflator association (requires inflatorId + deviceId; partner must have an account).
GET /v1/devices βœ… PTW Admin List all devices. Admin issuer required.

Crash Events

Method Route Auth Description
POST /v1/addEvent βœ… Report a crash event. DEPLOY events are queued to Service Bus with a grace period; others are stored only.
POST /v2/addEvent βœ… V2 crash event with improved grace period status response (Started, Did Not Start, Failed).
POST /v1/events βœ… Org Create organization-scoped crash event for owned device (no grace period scheduling). Stamps partner UserId; partner must have an account. Duplicate eventId β†’ 409.
POST /v1/events/{eventId}/data βœ… Org Upload binary EDR data for organization-scoped event.
POST /v1/abortNotification/{eventId} βœ… Cancel a notification still within the grace period.
GET /v1/notificationStatus/{eventId} βœ… Get the notification status of a specific event.
GET /v1/getAllEvents βœ… PTW Admin Retrieve all crash events. Admin issuer required.
POST /v1/addEventInformation/{eventId}/data βœ… Upload binary EDR data (application/octet-stream) to Azure Blob Storage.
POST /v1/getBlobSasToken βœ… Generate a 1-hour SAS URL to download a blob by path.
POST /v1/testSms βœ… Send a test SMS directly via AWS SNS.

Firmware Over-The-Air (FOTA)

Method Route Auth Description
POST /v1/uploadFirmware βœ… FOTA role Upload firmware binary + metadata. Requires PTWAdminPortalRoles (e.g. SafetyPortal.Admin).
GET /v1/getFirmwareVersions βœ… PTW Admin List all firmware versions.
GET /v1/checkFirmwareUpdate/{deviceId} βœ… Check if a newer firmware version exists for the device. Organization-scoped; partner tokens are rejected (403 β†’ use /v1/firmware/check).
GET /v1/firmware/check βœ… Org Org-scoped check with firmwareVersion and deviceType (CompactVest/BagPack). No deviceId.
GET /v1/firmware/download βœ… Org Strict org-scoped binary download with firmwareVersion + deviceType (no deviceId).
GET /v1/getFirmwareUpdate/{deviceId} βœ… Download firmware binary (application/octet-stream) with X-Version header.
DELETE /v1/deleteFirmware βœ… FOTA role Delete a firmware version. Returns 409 if referenced by other versions or active devices.

Organization Integration (B2B)

Organization integrations use the neutral routes above with principal authorization determined by token issuer (@require_authenticated_principal) rather than /v1/partner/* route prefixes.

These org endpoints are gated by the EnablePartnerEndpoints feature flag and return 404 in any environment where the flag is disabled (see DEPLOYMENT_STRATEGY.md Β§ Feature flags).

Tenant isolation model:

  • A partner principal is identified by issuer + appid and mapped to an active row in the Organizations table (get_active_organization_by_appid). The resolved organization name is attached to user.organization_name for the request.
  • Accounts are required. A partner must create an account (POST /v1/users) before adding/managing devices, events, or inflators. Org-scoped writes resolve the partner account via get_me() and return 404 when it is missing.
  • Org stamping. Devices (OrganizationName), crash events (UserId + OrganizationName), and inflators (DeviceComponents.UserId) are stamped with the requester's organization/account so resources stay scoped to their owning tenant.
  • Cross-org protection. A resource owned by one organization cannot be read or modified by a principal from another organization (returns 403). Firmware lookups are organization-scoped; rows with a NULL organization are treated as legacy/global and remain visible to all.

Portal Comments

These endpoints are for PTW Admin Portal users only and require @require_ptw_admin_portal_issuer.

Method Route Auth Description
POST /v1/portal/comments PTW Admin Create a comment or reply on a User, Event, Device, or FirmwareVersion.
GET /v1/portal/comments/{targetType}/{targetId} PTW Admin List active comments for a target as a flat list ordered by createdAt.
PATCH /v1/portal/comments/{commentId} PTW Admin Update comment body and set updatedAt.
DELETE /v1/portal/comments/{commentId} PTW Admin Soft delete a comment by setting deletedAt.

Create request body:

json { "targetType": "Device", "targetId": "ABC123", "parentCommentId": null, "body": "Internal note" }

Notes:

  • targetType is allow-listed to User, Event, Device, FirmwareVersion.
  • Replies use parentCommentId; the parent must belong to the same target.
  • authorId and authorName are taken from the signed-in token, not from the request body.

Service Bus Triggers (background, non-HTTP)

Trigger Type Schedule / Queue Description
Timer Every 4 min (0 */4 * * * *), on startup servicebus-warmup β€” keeps the AMQP link alive. No message sent.
Service Bus %NotificationQueueName% servicebus-notification-processor β€” processes deferred crash notifications after grace period, dispatches SNS SMS.

🐳 Container Services

docker-compose.yaml defines six services:

Service Image / Build Port(s) Description Depends On
app build: src 7071 β†’ 80 Azure Functions Python API mssql, azurite, auth
mssql build: mssql 1433 β†’ 1433 Microsoft SQL Server β€”
azurite build: azurite 10000–10002 Azure Storage emulator (Blob/Queue/Table) β€”
auth FusionAuth 1.45.1 9011 β†’ 9011 Local OpenID Connect provider auth-db (healthy)
auth-db postgres:12.9 (internal) PostgreSQL backing store for FusionAuth β€”
docs build: docs/ 8000 β†’ 8000 MkDocs documentation server (replicas: 0 by default) β€”

Named volumes: mssql-data, azurite-data, auth-data, auth-db-data.

Service Management

```bash

Start all services

docker-compose up -d

Start specific service

docker-compose up -d mssql

View logs

docker-compose logs -f app

Rebuild after code changes

docker-compose build app && docker-compose up -d app ```


πŸ” Authentication

How It Works

Every HTTP request goes through @validate_request (in src/app/middleware/decorators.py), which reads the Authorization header and calls extract_user() in src/app/middleware/auth.py. The issuer (iss claim) determines the validation path:

Autoliv Safety tokens (issued by this API)

  • Signed with an RSA key managed in Azure Key Vault (JwtKeyName).
  • Verified by calling the Key Vault REST API (/keys/{name}/verify) β€” the private key never leaves Key Vault.
  • Claims: sub, iss, organization_role, global_role, organization_name, exp (checked manually).
  • Minted by TokenService at login; lifetime controlled by AccessTokenExpireInMinutes (default 1440 min = 1 day).

Microsoft Entra ID tokens

  • Validated via PyJWKClient against PTWEntraJwksUrl, RS256, expected audience PTWEntraAudience.
  • Role claims prefixed Safetyportal. are extracted as OrganizationRole.
  • Required for admin-only endpoints (via @require_ptw_admin_portal_issuer).

Partner Entra ID tokens (OEM)

  • Validated via PyJWKClient against PartnerEntraJwksUrl, RS256, expected audience PartnerEntraAudience.
  • Issuer must match PartnerEntraIssuer.
  • Multiple partners are supported. PartnerEntraAudience accepts a semicolon-separated list of partner app ids (same pattern as GoogleClientIds / AppleClientIds), for example api://partner_one_app_id;api://partner_two_app_id. A token is treated as a partner token when its issuer matches PartnerEntraIssuer and its appid/aud matches any configured entry; the api:// prefix is optional in both the configuration and the token. All partners share the single PartnerEntraIssuer (one tenant); each partner is distinguished by its appid.
  • Partner appid claim must map to an active organization (Organizations.AppId + Organizations.IsActive) for org-scoped operations, so each configured appid needs its own active Organizations row.
  • Rejected from admin-only endpoints even in a shared tenant: because a partner token can carry the same issuer as the PTW Admin Portal, @require_ptw_admin_portal_issuer identifies partners by appid/audience and returns 403 before the issuer check.

Login Flow

``` POST /v1/auth/login Body: { "id_token": "...", "provider": "google" | "apple" }

  1. AuthService validates the Google/Apple ID token against their JWKS endpoints
  2. Existing refresh tokens for this user are invalidated
  3. TokenService mints a new access token (RS256, Key Vault) + refresh token
  4. Refresh token hash is stored in dbo.RefreshTokens
  5. Returns: access_token, refresh_token, expires_in, organization_name ```

Token Refresh

Refresh tokens are single-use. On POST /v1/auth/refresh, the used token is invalidated immediately and a new pair is returned.

  • Refresh token lifetime: RefreshTokenExpireInDays (default 180 days)
  • Expired token cleanup: every TokenCleanupIntervalHours (default 24h)

Terminology: partner vs org

These two words are not synonyms. The distinction is deliberate and is applied consistently across the codebase:

Term Means Answers
partner The caller's identity β€” an OEM principal authenticated by a partner Entra token Who is calling?
org The data scope β€” the Organizations.OrganizationName value that rows are stamped with Which data does this touch?

Practical consequences:

  • partner names appear in auth and identity code: validate_partner_entra_id_token, is_partner_entra_principal, require_partner, reject_partner_principal.
  • org names appear on endpoints and queries that are scoped to an organization but are not partner-only. Endpoints suffixed _for_org (e.g. create_event_for_org, get_firmware_update_for_org) use @require_authenticated_principal and serve both OEM partners and consumer (Autoliv Safety / PTW) principals, branching internally on is_partner_entra_principal. Do not rename these to _for_partner β€” org is intentionally the broader term.
  • DB helpers named *_partner_* take an org_name parameter (e.g. get_device_for_partner(device_id, org_name)). The name describes which caller the query serves; the parameter is the literal column value being filtered on.

Use is_partner_entra_principal (in app.middleware.auth) as the single canonical partner predicate. For resolving a partner's organization, prefer the guarded @require_partner decorator; lookup_partner_org_name is an unguarded DB lookup that performs no authorization checks.

Authorization Decorators

Decorator Enforces Used On
@require_ptw_admin_portal_issuer user.issuer == PTWAdminPortalIssuer and principal is not an OEM partner (partners share the issuer in a shared tenant and are rejected by appid/audience) GET /v1/users, GET /v1/devices, GET /v1/getAllEvents, GET /v1/getFirmwareVersions, /v1/portal/comments*
@require_fota_upload_permission Issuer match + one of PTWAdminPortalRoles (comma-separated) POST /v1/uploadFirmware, DELETE /v1/deleteFirmware
@require_authenticated_principal Authenticated user principal or partner appid→active org POST /v1/users and org-enabled neutral endpoints (/v1/devices/{id}, /v1/events*, /v1/firmware/check, /v1/firmware/download, /v1/inflators)
@require_feature("<Flag>") Named boolean flag on Settings is enabled (else 404) Organization/partner endpoints via EnablePartnerEndpoints

Development Authentication

Test Accounts

Account Email Role
Admin admin@auth.com Administrator
User user@auth.com Standard User

Passwords are configured via environment variables in docker-compose.yaml.

Quick Token Generation (Development Only)

Option 1: Password Grant via FusionAuth

bash curl --request POST \ --url http://localhost:9011/oauth2/token \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data grant_type=password \ --data client_id=[CLIENT_ID] \ --data client_secret=[CLIENT_SECRET] \ --data username=user@auth.com \ --data password=[PASSWORD] \ --data 'scope=openid offline_access'

Option 2: Auth UI Helper

  1. Navigate to http://localhost:3000
  2. Login with test credentials
  3. Copy the generated token

Authorization Code + PKCE Flow

  1. Generate PKCE params at PKCE Generator
  2. Initiate: http://localhost:9011/oauth2/authorize? client_id=[CLIENT_ID]& scope=openid%20offline_access& response_type=code& redirect_uri=http%3A%2F%2Flocalhost%2Foauth-redirect.html& code_challenge=[CODE_CHALLENGE]& code_challenge_method=S256
  3. Exchange: bash curl --request POST \ --url http://localhost:9011/oauth2/token \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data grant_type=authorization_code \ --data redirect_uri=http://localhost/oauth-redirect.html \ --data client_id=[CLIENT_ID] \ --data code=[CODE] \ --data code_verifier=[CODE_VERIFIER]

πŸ› οΈ Development

Prerequisites

Tool Version Purpose
Python 3.11+ Runtime environment
Docker Latest Container orchestration
Azure Functions Core Tools 4.x Local Functions runtime
Pre-commit β‰₯3.2.2 Code quality hooks
Liquibase 4.23.0 Database migrations

Development Setup

```bash

Install dev dependencies

pip install -r requirements-dev.txt

Install all hooks (including pre-push unit tests)

pre-commit install

Install only commit-stage hooks (skip pre-push tests)

pre-commit install -t pre-commit ```

Code Quality β€” Pre-commit Hooks

All hooks run on every git commit. The unit_test hook runs on git push.

Hook Tool / Source Purpose
trailing-whitespace pre-commit-hooks Remove trailing whitespace
end-of-file-fixer pre-commit-hooks Ensure files end with newline
check-yaml pre-commit-hooks Validate YAML syntax
check-json pre-commit-hooks Validate JSON syntax
check-toml pre-commit-hooks Validate TOML syntax
check-added-large-files pre-commit-hooks Block large file commits
autoflake PyCQA/autoflake 2.3.1 Remove unused imports/variables
isort pycqa/isort 7.0.0 Sort imports (black profile)
black psf/black 25.12.0 Format Python code
prettier mirrors-prettier 4.0.0Ξ± Format YAML and Markdown
pretty-format-toml macisamuele 2.15.0 Auto-format TOML
shfmt-docker scop/pre-commit-shfmt Shell script formatter (-s)
sqlfluff-lint sqlfluff 4.0.0Ξ± Lint SQL (T-SQL dialect)
sqlfluff-fix sqlfluff Auto-fix SQL lint issues
pylint local (system Python) Python linter
pyright local (system Python) Strict type checking
bandit PyCQA/bandit 1.9.2 Security scanner (excludes tests/)
pydoclint jsh9/pydoclint 0.8.3 Docstring linter (Google style)
unit_test (pre-push) local run_tests.sh Run unit tests with 70% coverage threshold

Commands:

```bash

Run all hooks manually

pre-commit run -a

Run on specific files

pre-commit run --files file1.py file2.py

Skip hooks (emergency only)

git commit --no-verify ```

Database Development

Liquibase Installation

```bash

Windows

choco install liquibase

macOS

brew install liquibase

Linux (Ubuntu/Debian)

sudo apt-get install liquibase

Or: https://www.liquibase.org/download

```

Configuration: liquibase.properties (in project root, already configured) Changesets: liquibase/changesets/ (XML, naming: v01.02.XX-description.xml) SQL scripts: liquibase/migrations/ (naming: v01.02.XX-description.sql)

Common Liquibase Commands

Command Purpose
liquibase update Apply all pending migrations
liquibase update --labels="dev" Apply dev-only seed data
liquibase status See pending changesets
liquibase rollback-count 1 Undo last changeset
liquibase release-locks Clear stuck migration lock
liquibase clear-checksums Reset checksum mismatches
liquibase validate Check changelog syntax

Database Tables

All tables are in schema dbo:

Table / View Purpose
dbo.Users User accounts (linked to external identity)
dbo.UserAuth User authentication details
dbo.UsersView Denormalized user view
dbo.Devices IoT device records
dbo.UsersDevices Many-to-many user ↔ device join table
dbo.DeviceComponents Inflator/component records per device
dbo.CrashEvents Crash event records with notification state
dbo.EventNotificationDetails Per-contact notification dispatch records
dbo.FirmwareVersions FOTA firmware versions + blob paths
dbo.Comments Internal PTW Portal comments for list items
dbo.Organizations Organization/tenant records
dbo.Issuers Trusted JWT issuers
dbo.RefreshTokens Hashed single-use refresh tokens
dbo.UserEmergencyContacts ICE contacts per user (max 5)
dbo.UsersView Denormalized user view (joined Users data)

Running SQL Locally

DBeaver (Recommended)

Step Detail
Install dbeaver.io
Host localhost:1433
Credentials Username SA, password from docker-compose.yaml
Database safeliv_dev

WSL2 users: See WSL2 Database Connection if DBeaver fails to connect from Windows.

sqlcmd (lightweight)

bash sqlcmd -S localhost,1433 -U SA -P [PASSWORD] -d safeliv_dev

Useful queries:

```sql -- List all tables SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE';

-- Check migration history SELECT * FROM DATABASECHANGELOG ORDER BY DATEEXECUTED DESC; ```

WSL2 Database Connection

Option 1: Windows Firewall rule (simplest)

```powershell

Run as Administrator in Windows PowerShell

New-NetFirewallRule -DisplayName "WSL2 SQL Server" -Direction Inbound -LocalPort 1433 -Protocol TCP -Action Allow ```

Then connect DBeaver to 127.0.0.1:1433, username SA.

Option 2: Port proxy

powershell wsl hostname -I # note the WSL2 IP netsh interface portproxy add v4tov4 listenport=1433 listenaddress=127.0.0.1 connectport=1433 connectaddress=<WSL_IP>

Option 3: sqlcmd inside WSL2 (always works)

bash sudo apt-get install mssql-tools unixodbc-dev /opt/mssql-tools/bin/sqlcmd -S localhost,1433 -U SA -P '[PASSWORD]' -d safeliv_dev


πŸ§ͺ Testing

Test Architecture

Type Location Isolation Database
Unit Tests tests/unit/ Mocked DB, storage, auth None required
Integration Tests tests/integration/ Real DB, real services Docker stack

Coverage threshold: 70% at pre-push, 80% enforced in CI.

Setup

Ensure src/local.settings.json has a valid DatabaseConnection:

json { "Values": { "DatabaseConnection": "Driver={ODBC Driver 17 for SQL Server};Database=safeliv_dev;Server=localhost,1433;UID=SA;PWD=[PASSWORD];Encrypt=yes;TrustServerCertificate=yes;" } }

ODBC Driver 17+ required: Installation guide

Running Tests

```bash

Show help

./run_tests.sh -h

All tests

./run_tests.sh

Unit tests only

./run_tests.sh --unit

Integration tests only

./run_tests.sh --integration

Unit tests, skip DB spin-up (faster, reuses existing DB)

./run_tests.sh --unit --existing-db

Manual

pytest tests/unit/ pytest tests/integration/ pytest tests/unit/blueprint/test_v1_user.py::test_get_me ```

Test Structure

tests/ β”œβ”€β”€ data.py # Shared test fixtures β”œβ”€β”€ unit/ β”‚ β”œβ”€β”€ blueprint/ # Tests per HTTP blueprint (auth, comments, crash_event v1+v2, device, fota, users, usersettings, servicebus_processor) β”‚ β”œβ”€β”€ db/ # Tests per DB module (cnxn, crash_event, device, fota, notification, user, usersettings) β”‚ β”œβ”€β”€ service_tests/ # AuthService, NotificationService, TokenService, RefreshToken β”‚ β”œβ”€β”€ test_auth.py # extract_user(), validate_autoliv_safety_token(), validate_entra_id_token() β”‚ β”œβ”€β”€ test_decorators.py # All decorators β”‚ β”œβ”€β”€ test_servicebus.py # ServiceBusClientSingleton β”‚ β”œβ”€β”€ test_storage.py # BlobStorage β”‚ └── test_utils.py # Utility function tests └── integration/ β”œβ”€β”€ api/ # Typed API wrappers per domain β”œβ”€β”€ conftest.py └── test_service_{auth,crash_event,device,fota_service,user,usersettings}.py


πŸ”Œ API Testing with Bruno

This project includes a Bruno API collection at bruno/AutolivSafety/. Bruno is a fast, Git-friendly open-source API client.

Getting Started

  1. Install Bruno: usebruno.com
  2. Open Collection: In Bruno, open the bruno/AutolivSafety folder
  3. Select Environment: dev, qa, or prod

Available Environments

Environment Purpose
dev Local development
qa Pre-production testing
prod Live system

Bruno Environment Variables

Variable Description Example
host API base URL https://meuw-safety-dv-fn01.azurewebsites.net
v1 API version path /api/v1
code Azure Functions auth code (from Azure portal)

Collection Structure

Module Endpoints
Auth Login (Google/Apple), Refresh token
Device Get/update device info, add inflator, get inflator validation
Event Add event, get all events, abort notification, get notification status, SAS token
FotaService Get versions, upload, check update, get update, delete version
User Create user, get me, get all users, delete me
UserSettings Get/add/delete emergency contacts

Authentication Flow in Bruno

  1. Run Login to get access_token and refresh_token
  2. Copy access_token to your environment variables
  3. When expired, use Refresh token to get a new pair
  4. All other requests use the stored access_token automatically

πŸ“– Documentation

Building Documentation

```bash

Build docs once

docker compose run --rm docs ./docs/generate_docs.sh

Build and serve with live reload

docker compose run --rm -p 8000:8000 docs ./docs/generate_docs.sh --serve

open http://localhost:8000 ```

Documentation Structure

docs/ β”œβ”€β”€ oas/ β”‚ β”œβ”€β”€ base.json # Base OpenAPI 3.0 template β”‚ β”œβ”€β”€ base-schema.json # Schema definitions β”‚ └── extract_schema.py # Schema extraction tool β”œβ”€β”€ mkdocs/ # MkDocs static site source └── redoc/ └── template.hbs # ReDoc HTML template

Documentation System

Component Technology Purpose
API Docs OpenAPI 3.0 + ReDoc Interactive API docs
Dev Guides MkDocs Static developer documentation
Versioning mike Multi-version doc deployments

βš™οΈ Configuration

Configuration Files

File Purpose Committed
src/local.settings.json Local dev env vars ❌ No
docker-compose.yaml Container config βœ… Yes (test secrets only)
liquibase.properties DB migration config βœ… Yes

Environment Variables (from src/app/settings.py)

All values read from OS environment first, then from src/local.settings.json["Values"].

Variable Default Description
AzureWebJobsStorage required Azure Functions internal storage connection string
AzureWebJobsFeatureFlags required Set to EnableWorkerIndexing to enable blueprint support
FUNCTIONS_WORKER_RUNTIME required Must be "python"
PYTHON_THREADPOOL_THREAD_COUNT β€” Thread pool size (set to "10" in local settings)
DatabaseConnection required ODBC connection string for MSSQL
DatabaseName required SQL database name (e.g. safeliv_dev)
BlobStorageConnection required Azure Blob Storage connection string
BlobStorageContainerFota required Container for firmware files (e.g. fota-updates)
BlobStorageContainerEvents required Container for crash EDR blobs (e.g. crash-logs)
BlobStorageTokenExpirySeconds 21600 SAS token expiry in seconds (6 hours)
AuthOpenIDSettings required Semicolon-delimited OpenID configs: Issuer=...;ConfigUrl=...;ClientId=... (multiple: ;;)
AuthJWTCacheTTL 0 Seconds to cache OpenID config per issuer (0 = no cache)
PTWAdminPortalIssuer "" Required issuer for admin-only endpoints (Entra ID URL)
PTWAdminPortalRoles "" Comma-separated roles required for FOTA upload/delete
GoogleClientIds "" Semicolon-separated valid Google OAuth client IDs
AppleClientIds "" Semicolon-separated valid Apple client IDs
KeyVaultUrl "" Azure Key Vault URL for JWT signing key
JwtKeyName "JwtSigningKey" Key name in Key Vault for RS256 signing
PTWEntraJwksUrl "" Microsoft Entra ID JWKS endpoint URL
PTWEntraAudience "" Expected audience for Entra ID tokens
PartnerEntraJwksUrl "" Partner Entra ID JWKS endpoint URL
PartnerEntraAudience "" Semicolon-separated partner app ids (api:// prefix optional); supports multiple partners
PartnerEntraIssuer "" Required issuer for partner Entra ID tokens (shared by all partners)
AccessTokenExpireInMinutes 1440 Access token lifetime (1 day)
RefreshTokenExpireInDays 180 Refresh token lifetime
EnablePartnerEndpoints False Feature flag β€” enables the organization/partner endpoints (404 when off)
TokenCleanupIntervalHours 24 Expired token cleanup frequency
ServiceBusConnectionString required Azure Service Bus connection string (AMQP)
NotificationQueueName required Service Bus queue for crash notifications
GracePeriodDuration 30 Seconds before a DEPLOY event notification is dispatched
MAX_CONTACTS_ALLOWED 5 Maximum emergency contacts per user
AwsAccessKeyId "" AWS credentials for SNS SMS
AwsSecretAccessKey "" AWS credentials for SNS SMS
AwsRegion "" AWS region for SNS
AwsSnsSenderId "ALVSAFETY" SMS sender ID
APPLICATIONINSIGHTS_CONNECTION_STRING β€” Azure Application Insights connection string
LogLevel "INFO" Root log level (TRACE/DEBUG/INFO/WARNING/ERROR)
Debug False Return error body in 500 responses instead of bare 400s
DevServer β€” Optional dev server identifier (used internally)

Full local.settings.json Template

json { "IsEncrypted": false, "Values": { "AzureWebJobsStorage": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=[KEY];BlobEndpoint=http://azurite:10000/devstoreaccount1;", "AzureWebJobsFeatureFlags": "EnableWorkerIndexing", "FUNCTIONS_WORKER_RUNTIME": "python", "PYTHON_THREADPOOL_THREAD_COUNT": "10", "DatabaseConnection": "Driver={ODBC Driver 17 for SQL Server};Database=safeliv_dev;Server=mssql,1433;UID=SA;PWD=[PASSWORD];Encrypt=yes;TrustServerCertificate=yes;", "DatabaseName": "safeliv_dev", "BlobStorageConnection": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=[KEY];BlobEndpoint=http://azurite:10000/devstoreaccount1;", "BlobStorageContainerFota": "fota-updates", "BlobStorageContainerEvents": "crash-logs", "AuthOpenIDSettings": "Issuer=[ISSUER];ConfigUrl=[OPENID_CONFIG_URL];ClientId=[CLIENT_ID]", "PTWAdminPortalIssuer": "[ENTRA_ISSUER_URL]", "PTWAdminPortalRoles": "SafetyPortal.Admin,SafetyPortal.Contributor", "GoogleClientIds": "[GOOGLE_CLIENT_ID]", "AppleClientIds": "[APPLE_CLIENT_ID]", "KeyVaultUrl": "https://[VAULT_NAME].vault.azure.net/", "JwtKeyName": "JwtSigningKey", "PTWEntraJwksUrl": "https://login.microsoftonline.com/[TENANT_ID]/discovery/v2.0/keys", "PTWEntraAudience": "[ENTRA_APP_CLIENT_ID]", "ServiceBusConnectionString": "Endpoint=sb://[NAMESPACE].servicebus.windows.net/;SharedAccessKeyName=...;SharedAccessKey=...", "NotificationQueueName": "[QUEUE_NAME]", "GracePeriodDuration": "30", "AwsAccessKeyId": "[AWS_KEY]", "AwsSecretAccessKey": "[AWS_SECRET]", "AwsRegion": "eu-north-1", "APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=[KEY]", "LogLevel": "DEBUG" } }

Database Connection String

Driver={ODBC Driver 17 for SQL Server}; Database=[DATABASE_NAME]; Server=[SERVER],[PORT]; UID=[USERNAME]; PWD=[PASSWORD]; Encrypt=yes; TrustServerCertificate=yes; Connection Timeout=60; LoginTimeout=60;

Security Considerations

Type Level Notes
Development secrets Low Shared in docker-compose (test only)
Production secrets High Use Azure Key Vault
JWT signing key High Stored in Key Vault, never exported
API keys / access keys High Never commit to repository
Connection strings Medium Mask passwords in logs

πŸ”„ CI/CD Pipeline

The pipeline runs on Azure Pipelines and is defined in .azure-pipelines/azure-pipelines.yml.

Triggers:

  • Commits to development, staging, production, and any feature/*, fix/*, or bug/* branch
  • Pull requests to development, staging, and production

Pipeline Variables

Variable Value / Purpose
pythonVersion 3.11
nodeVersion 24.x β€” for documentation tooling
testCoverage 80 β€” minimum required test coverage (%)
functionArtifactName Azure Function artifact name
functionArtifactArchiveName ZIP file name including the build ID
azureServiceConnection Service connection for development environment
azureFunctionName Development function app name
deploymentEnvironment Development deployment environment name

Pipeline Stages

1. Quality Assurance (qa) β€” always runs

  1. Checkout code
  2. Set Python 3.11
  3. Cache pre-commit hooks
  4. Install requirements.txt, requirements-dev.txt, pre-commit hooks
  5. Install Liquibase 4.23.0
  6. Run all pre-commit hooks (linting, formatting, security checks)
  7. Run unit tests with 80% coverage threshold
  8. Publish JUnit test results to Azure DevOps
  9. Publish Cobertura coverage report
  10. SonarCloud: prepare β†’ analyze β†’ publish

2. Security Scan (security_scan) β€” non-PR builds only, after QA

  1. Checkout
  2. Datadog Static Analysis β€” install CLI, run secrets detection, upload SARIF
  3. Datadog SBOM β€” generate and upload Software Bill of Materials

3. Build (build) β€” after QA, for development/staging/production/feature/*/fix/*/bug/*

  1. Apply .funcignore β€” copy filtered sources to deploy/
  2. Set Python 3.11
  3. Install packages into .python_packages/lib/site-packages
  4. Create ZIP archive
  5. Publish artifact

4. Deploy Development (deploy_dv) β€” development, feature/*, fix/*, bug/*

  • Deploy ZIP to the Dev Function App (Linux runtime) β€” any feature/*, fix/*, or bug/* push deploys straight to Dev, unrestricted, for fast iteration
  • Documentation is built and published only when the source branch is development itself (with mike, branch + "latest" alias, pushed to azure-docs, synced to docs-hub via PR) β€” feature/fix/bug deploys skip docs entirely

5. Deploy Staging (deploy_staging) β€” staging branch

  • Deploy ZIP to the Staging Function App (the existing QA resources)
  • On success: run cz bump --prerelease rc and push the vX.Y.Z-rc.N tag

6. Deploy Production (deploy_prod) β€” production branch (approval gated)

  • Deploy ZIP to the Prod Function App
  • On success: run cz bump to finalize vX.Y.Z, push the tag, and publish versioned docs (mike) + docs-hub sync

How to Trigger a Release

Releases happen by promoting through the environment branches (normally via pull request with the qa gate and required reviews):

```bash

Promote development β†’ Staging (deploys to Staging, tags vX.Y.Z-rc.N)

git checkout staging git merge --no-ff development git push origin staging

Promote staging β†’ Production (deploys to Prod, finalizes tag vX.Y.Z)

git checkout production git merge --no-ff staging git push origin production ```

The pipeline runs cz bump --prerelease rc on staging and cz bump on production automatically. You no longer commit bump: release.

How Commitizen Works

Reads conventional commits since the last tag to determine bump type:

Commit Type Bump Example
feat: MINOR 1.0.0 β†’ 1.1.0
fix: PATCH 1.0.0 β†’ 1.0.1
BREAKING CHANGE: MAJOR 1.0.0 β†’ 2.0.0
docs:, chore: None β€”

Configuration in pyproject.toml:

toml [tool.commitizen] name = "cz_conventional_commits" version = "0.15.0" tag_format = "v$version" version_scheme = "semver" update_changelog_on_bump = true

cz bump analyzes git history to determine the bump, updates pyproject.toml + CHANGELOG.md, and creates an annotated tag. Staging uses cz bump --prerelease rc (vX.Y.Z-rc.N); Production runs cz bump to finalize vX.Y.Z.

Datadog Security Configuration

Variable Where to set
DD_API_KEY Azure DevOps pipeline secret variable
DD_APP_KEY Azure DevOps pipeline secret variable
DD_SITE Set directly in pipeline definition

Key Pipeline Features

Feature Description
Caching Pre-commit hooks cached between runs
Parallel stages Security scan runs independently of deployment pipeline
Environment protection Staging and Production environments have approval gates
Versioned documentation mike manages multi-version doc deployments
Automated releases Commitizen handles versioning and changelog generation
Security scanning Datadog SBOM + secrets scanning
Quality gates 80% test coverage + SonarCloud analysis required

πŸ”— References