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 is restricted to mobile-app and PTW principals (@reject_partner_principal); OEM partner tokens receive 403. Partner accounts are provisioned out-of-band β€” see "Accounts are required" below.
  • Device org stamping. Consumer device create/update (PUT /v1/device/{deviceId}) now persists OrganizationId (FK to Organizations), 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 caller's UserId. POST /v1/events resolves the caller's account (get_me()), stores its UserId on the crash event, and returns 404 if the account is missing. The event is recorded against the deviceId in the request body, which must belong to the caller's organization.
  • 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 only (partners own many devices).
  • Org ownership is a foreign key. Devices and FirmwareVersions now carry OrganizationId UNIQUEIDENTIFIER referencing Organizations(OrganizationId) instead of a free-text OrganizationName, and the unused DeviceType column has been dropped from both tables (migration v01.03.06-replace-organizationname-with-organizationid.sql). The deviceType query parameter and response field are gone from all device and firmware endpoints.
  • 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:
git clone <repository-url>
cd airbagconnect-api
  1. Start the full development stack:
docker-compose up -d
  1. Install development dependencies:
pip install -r requirements-dev.txt
  1. Install pre-commit hooks:
pre-commit install
  1. Populate the database (development data):
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} βœ… Org-scoped device upsert. Unlike /v1/device/{deviceId}, the account's other devices stay active.
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 org-scoped crash event against the deviceId in the body. Consumer/PTW callers also schedule emergency SMS for DEPLOY. 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 Check by firmwareVersion. No deviceId. Not org-scoped.
GET /v1/firmware/download βœ… Org Binary download by firmwareVersion (no deviceId). Not org-scoped β€” any released version is downloadable.
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. Every authenticated principal β€” consumer mobile app, PTW, and OEM partner β€” may call them, and each one is scoped to its own organization.

One behaviour for every principal. The org routes do not branch on token type. POST /v1/events always records the event against the deviceId in the request body after an org ownership check, and PUT /v1/devices/{deviceId} always uses the org upsert, which leaves the account's other devices active (unlike PUT /v1/device/{deviceId}, which keeps a single active device). The only principal-dependent behaviour is emergency SMS: consumer and PTW callers schedule the grace-period notification for DEPLOY events and receive the gracePeriod* fields in the response; OEM partners never do.

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 and its OrganizationId to user.organization_id for the request; org-scoped device and firmware queries filter on the OrganizationId foreign key.
  • Accounts are required. Org-scoped writes resolve the caller's account via get_me() and return 404 when it is missing. Partners cannot self-serve via POST /v1/users (that endpoint rejects partner tokens), so a partner account row must be provisioned out-of-band. It is matched on ExternalUserId (the token sub), IssuerName (PartnerEntraIssuer) and OrganizationName; all three must match the partner's token for the org endpoints to work.
  • 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 is the deliberate exception: neither GET /v1/firmware/check nor GET /v1/firmware/download is organization-scoped, so any released version is discoverable and downloadable by version string. Devices and crash events remain strictly org-scoped.

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:

{
  "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

# 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 OEM partners, PTW principals and consumer mobile-app users, 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 Any authenticated principal; OEM partners additionally resolve appid→active org and are scoped to it Org-enabled neutral endpoints (/v1/devices/{id}, /v1/events*, /v1/firmware/check, /v1/firmware/download, /v1/inflators)
@reject_partner_principal Principal is not an OEM partner Mobile/PTW endpoints incl. POST /v1/users, GET /v1/inflator/{inflatorId}
@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

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:
    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

# 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:

# 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

# 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)

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

Useful queries:

-- 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)

# 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

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)

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:

{
  "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

# 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

# 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
AwsSnsChinaShortCode "" Registered SNS short code used for +86 destinations (China rejects alphanumeric sender IDs)
AwsSmsPoolId "" AWS End User Messaging phone pool ID. When set, SMS goes through the pool, which picks the origination identity per destination country, and the three SNS settings above are unused
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

{
  "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": "ap-southeast-1",
    "AwsSnsChinaShortCode": "[CHINA_SHORT_CODE]",
    "AwsSmsPoolId": "[PHONE_POOL_ID]",
    "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):

# 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:

[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