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
- ποΈ Architecture
- π Quick Start
- π Project Structure
- π API Endpoints
- π³ Container Services
- π Authentication
- π οΈ Development
- π§ͺ Testing
- π API Testing with Bruno
- π Documentation
- βοΈ Configuration
- π CI/CD Pipeline
- π References
π Latest Updates
Unreleased
- Event feedback:
GET /v1/userEvents,POST /v1/event/{eventId}/feedbackandGET /v1/event/{eventId}/feedbacklet consumer/PTW users list their events and submit or read feedback after a deployment. Behind theEnableEventFeedbackflag; see Crash Events. Migrationsv01.03.10-create-event-feedback.sqlandv01.03.11-index-crash-events-userid.sql.
v0.16.0 β Current
- MSS-1092: FOTA partial rollout. Migration
v01.03.07-add-firmware-rollout.sqladdsRolloutPercentage,IsTestDeviceTargeting,IsOemAvailable, andAllowBaselineOverridetoFirmwareVersions(existing versions are backfilled to 100%; new uploads default to paused/0%),IsInternalTestDevicetoDevices, and a newFirmwareTargetDevicestable for per-device targeting. Migrationv01.03.09-rename-internal-targeting-column.sqlrenames the originalIsInternalOnlycolumn toIsTestDeviceTargeting.v01.03.08-index-firmware-versions-version.sqlindexesFirmwareVersions.Version, which every FOTA check and download filters on, and dropsIdx_Id, a duplicate of the clustered primary key. - Rollout decisions live in
app.services.business.firmware_rollout: a deterministic SHA-256 bucket (device_id+versionβ 0-99, eligible whenbucket < rolloutPercentage) plus theFirmwareRolloutConfigvalue object that validates percentage range, duplicate targets, and the baseline-override rule in one place. Raising a percentage only ever adds devices; explicittargetDeviceIdsalways receive the firmware (bypassing the percentage), andisTestDeviceTargetingincludes internal test devices even during a paused or partial rollout. Other devices continue to follow the percentage bucket. - Firmware delivery is not organization-scoped. Device update checks and
downloads (
/v1/checkFirmwareUpdate,/v1/getFirmwareUpdate, and thedeviceIdpath of/v1/firmware/checkand/v1/firmware/download) offer firmware regardless of the uploader's organization, so firmware uploaded by one organization (e.g. PTW) reaches devices of every organization.FirmwareVersions.OrganizationIdrecords the uploader and only governs who may update or delete the version.isTestDeviceTargetingandisOemAvailablecan be enabled together. - SQL moved to
app.db.firmware_repository(FirmwareRepository);app.db.fota_service.FotaServiceis now a thin orchestrator over the repository and the rollout policy. The rollout policy decides what the update-check offers; the download flow serves any released version to a known device, so the device fetches exactly the version it was told about. POST /v1/uploadFirmwareaccepts the rollout configuration directly, so a release no longer needs a second call. Omitted fields default to paused (0%), matching the database default.targetDeviceIdsis comma-separated (?targetDeviceIds=ECU-1,ECU-2), and the whole configuration, including that every target exists, is validated before the binary is stored.PATCH /v1/updateFirmwarepartially updates a version's release date, description, and rollout configuration without re-uploading the binary. Every body field is optional; omitted fields keep their current value, and supplyingtargetDeviceIds(including[]) replaces the whole set.GET /v1/getFirmwareVersionsreturns the new fields andtargetDeviceIds.- The neutral firmware routes (
/v1/firmware/check,/v1/firmware/download) depend on the caller. OEM partners only see firmware flaggedIsOemAvailableand are unaffected by test-device targeting; percentage rollout and targeting never apply to them, anddeviceIdis ignored. Taichi/PTW callers may pass an optionaldeviceIdto get the full device-level rollout decision; without it they see any released firmware, regardless of rollout or test-device targeting flags. cnxn.run_in_transaction()replaces the previous context manager. It uses explicitBEGIN/COMMIT/ROLLBACK TRANSACTIONinstead of togglingConnection.autocommit, so the pooled per-thread connection can never be left in manual-commit mode, and it reuses the standard connection-reset retry. The callable it receives must perform database work only, since a dropped connection causes it to be replayed.
v0.15.0
- Deployment strategy overhaul. Adopted three long-lived environment
branches β
developmentβ Dev,stagingβ Staging,productionβ Prod β with forward-only linear promotion via pull requests. SeeDEPLOYMENT_STRATEGY.mdfor the full model (branching, hotfixes, feature flags, and diagrams). - Release-candidate versioning. Staging tags releases as
vX.Y.Z-rc.Nand Production finalizes the same core version asvX.Y.Z(Commitizenversion_scheme = "semver"). Tags are created automatically after a successful deploy β the oldbump: releasecommit trigger has been removed. - Feature flags. Added a per-environment feature-flag pattern via the
Settingssingleton and a new@require_feature("<Flag>")decorator. TheEnablePartnerEndpointsflag 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), returning404when 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, andFirmwareVersion. - 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, andPOST /v1/inflatorsfor 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/usersis restricted to mobile-app and PTW principals (@reject_partner_principal); OEM partner tokens receive403. Partner accounts are provisioned out-of-band β see "Accounts are required" below. - Device org stamping. Consumer device create/update
(
PUT /v1/device/{deviceId}) now persistsOrganizationId(FK toOrganizations). Cross-org writes follow the device takeover rule (see "Device takeover" under Organization Integration). - Events carry the caller's
UserId.POST /v1/eventsresolves the caller's account (get_me()), stores itsUserIdon the crash event, and returns404if the account is missing. The event is recorded against thedeviceIdin the request body, which must belong to the caller's organization. - Duplicate event handling. Inserting an event with an existing
eventIdnow returns a clean409 Conflict(newDuplicateEventError) instead of an opaque400or"Database query failed after retries". The DB layer re-raisesIntegrityErrorimmediately 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 with403and directed toGET /v1/firmware/check. GET /v1/firmware/checksimplified for partners. Dropped thedeviceIdparameter; the contract is nowfirmwareVersiononly (partners own many devices).- Org ownership is a foreign key.
DevicesandFirmwareVersionsnow carryOrganizationId UNIQUEIDENTIFIERreferencingOrganizations(OrganizationId)instead of a free-textOrganizationName, and the unusedDeviceTypecolumn has been dropped from both tables (migrationv01.03.06-replace-organizationname-with-organizationid.sql). ThedeviceTypequery parameter and response field are gone from all device and firmware endpoints. - Inflator
UserIdfix.POST /v1/inflatorsresolves the partner account and stampsDeviceComponents.UserId(NOT NULL + FK), fixing theCannot insert the value NULL into column 'UserId'integrity error; returns404if the partner has no account.
v0.13.1
- Fixed
createdAtoptional handling in users list schema. - Included
usersDevicesin device list responses. - Simplified SQL queries and refactored device link hydration.
v0.13.0
- MSS-1294: Added new
POST /v2/addEventendpoint 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
organizationNameanddeviceIdfields to crash Events and updated Service Bus connections. GET /v1/users(admin) now returnsorganizationNameanddeviceIdper user via the updatedUserListItemResponseschema.
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
- Clone and navigate:
git clone <repository-url>
cd airbagconnect-api
- Start the full development stack:
docker-compose up -d
- Install development dependencies:
pip install -r requirements-dev.txt
- Install pre-commit hooks:
pre-commit install
- 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.03.08)
βββ 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, updateFirmware, /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, run_in_transaction)
β β βββ comments.py # Comments DB operations
β β βββ crash_event.py
β β βββ device.py
β β βββ error.py # Error DB operations
β β βββ firmware_repository.py # FirmwareRepository: all FirmwareVersions/FirmwareTargetDevices SQL
β β βββ fota_service.py # FotaService: orchestrates repository + rollout policy + blob storage
β β βββ 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
β β βββ firmware_rollout.py # Rollout bucketing + FirmwareRolloutConfig + eligibility policy
β β βββ 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 (201 on success). 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. |
| GET | /v1/userEvents |
β | List the caller's events (newest first) with eventType, deviceId, userHasGivenFeedback and timestamp (Unix seconds). No events β 200 {"message": "No events for user"}. |
| POST | /v1/event/{eventId}/feedback |
β | Submit feedback (activity, expectedDeployment, description β€ 500 chars) or {"declined": true}. The device is taken from the event. 204 on success; 404 if the event is not the caller's or has no device; 409 if feedback already exists. |
| GET | /v1/event/{eventId}/feedback |
β | Get the caller's feedback for an event. A declined feedback returns only {"declined": true}. 404 if none. |
The event feedback endpoints are gated by the EnableEventFeedback feature flag
(default off, 404 when disabled) and reject OEM partner tokens. Feedback is
stored in EventFeedback (migration v01.03.10-create-event-feedback.sql).
Firmware Over-The-Air (FOTA)
| Method | Route | Auth | Description |
|---|---|---|---|
| POST | /v1/uploadFirmware |
β FOTA role | Upload firmware binary + metadata. Requires PTWAdminPortalRoles (e.g. SafetyPortal.Admin). Accepts the rollout configuration (rolloutPercentage, isTestDeviceTargeting, isOemAvailable, allowBaselineOverride, comma-separated targetDeviceIds); omitted fields default to paused (rolloutPercentage=0). Invalid configurations or unknown targets return 400 before anything is stored. |
| GET | /v1/getFirmwareVersions |
β PTW Admin | List all firmware versions, including rollout config and targetDeviceIds. |
| PATCH | /v1/updateFirmware |
β FOTA role | Partially update a firmware version's release date, description, rollout percentage, internal/OEM availability, baseline override, and explicit target device IDs, scoped to the caller's organization. version is a query parameter; every body field is optional and omitted fields keep their current value. |
| GET | /v1/checkFirmwareUpdate/{deviceId} |
β | Check if a newer firmware version exists for the device. Not scoped by the uploader's organization; partner tokens are rejected (403 β use /v1/firmware/check). |
| GET | /v1/firmware/check |
β Org | Check by firmwareVersion, optional deviceId. Caller must belong to an organization (403 otherwise). OEM partners: released versions flagged IsOemAvailable; test-device targeting does not restrict them and deviceId is ignored. Taichi/PTW: with deviceId, the device-level rollout decision; without it, any released version. |
| GET | /v1/firmware/download |
β Org | Binary download by firmwareVersion, optional deviceId. Same caller rules as /v1/firmware/check; with deviceId, the requested version is served if it is released and the device exists, otherwise 404. |
| GET | /v1/getFirmwareUpdate/{deviceId} |
β | Download firmware binary (application/octet-stream) with X-Version header. Serves the requested version if it is released and the device exists. |
| 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
EnablePartnerEndpointsfeature flag and return404in any environment where the flag is disabled (seeDEPLOYMENT_STRATEGY.mdΒ§ Feature flags).
Tenant isolation model:
- A partner principal is identified by issuer + appid and mapped to an active
row in the
Organizationstable (get_active_organization_by_appid). The resolved organization name is attached touser.organization_nameand itsOrganizationIdtouser.organization_idfor the request; org-scoped device and firmware queries filter on theOrganizationIdforeign key. - Accounts are required. Org-scoped writes resolve the caller's account via
get_me()and return404when it is missing. Partners cannot self-serve viaPOST /v1/users(that endpoint rejects partner tokens), so a partner account row must be provisioned out-of-band. It is matched onExternalUserId(the tokensub),IssuerName(PartnerEntraIssuer) andOrganizationName; 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 delivery is the deliberate exception: no firmware check or download is organization-scoped, so any released version (OEM-available only, for OEM partners) is offered to every organization, subject to the rollout rules. Firmware updates and deletes stay restricted to the uploading organization. Device writes follow the takeover rule below; crash events remain strictly org-scoped. - Device takeover.
PUT /v1/device/{deviceId}andPUT /v1/devices/{deviceId}may write a device owned by another organization, transferring it to the caller's organization, depending on the caller and the owning organization kind. OEM = organization with anAppId; PTW = organization of thePTWAdminPortalIssuer; Taichi = every other organization/caller. Unowned devices and devices of the caller's own organization are always writable.
| Device owned by | Taichi caller | PTW caller | OEM caller |
|---|---|---|---|
| Taichi org | β | β | β 403 |
| PTW org | β | β | β |
| OEM org | β 403 |
β | β 403 |
On takeover the device is re-stamped with the caller's OrganizationId (when
resolvable). On PUT /v1/devices/{deviceId} the previous holders'
UsersDevices links stay active; PUT /v1/device/{deviceId} keeps its
single-active-holder behaviour.
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:
targetTypeis allow-listed toUser,Event,Device,FirmwareVersion.- Replies use
parentCommentId; the parent must belong to the same target. authorIdandauthorNameare 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
TokenServiceat login; lifetime controlled byAccessTokenExpireInMinutes(default 1440 min = 1 day).
Microsoft Entra ID tokens
- Validated via
PyJWKClientagainstPTWEntraJwksUrl, RS256, expected audiencePTWEntraAudience. - Role claims prefixed
Safetyportal.are extracted asOrganizationRole. - Required for admin-only endpoints (via
@require_ptw_admin_portal_issuer).
Partner Entra ID tokens (OEM)
- Validated via
PyJWKClientagainstPartnerEntraJwksUrl, RS256, expected audiencePartnerEntraAudience. - Issuer must match
PartnerEntraIssuer. - Multiple partners are supported.
PartnerEntraAudienceaccepts a semicolon-separated list of partner app ids (same pattern asGoogleClientIds/AppleClientIds), for exampleapi://partner_one_app_id;api://partner_two_app_id. A token is treated as a partner token when its issuer matchesPartnerEntraIssuerand itsappid/audmatches any configured entry; theapi://prefix is optional in both the configuration and the token. All partners share the singlePartnerEntraIssuer(one tenant); each partner is distinguished by its appid. - Partner
appidclaim must map to an active organization (Organizations.AppId+Organizations.IsActive) for org-scoped operations, so each configured appid needs its own activeOrganizationsrow. - 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_issueridentifies partners by appid/audience and returns403before 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:
partnernames appear in auth and identity code:validate_partner_entra_id_token,is_partner_entra_principal,require_partner,reject_partner_principal.orgnames 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_principaland serve OEM partners, PTW principals and consumer mobile-app users, branching internally onis_partner_entra_principal. Do not rename these to_for_partnerβorgis intentionally the broader term.- DB helpers named
*_partner_*take anorg_nameparameter (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, PATCH /v1/updateFirmware, 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 | 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
- Navigate to
http://localhost:3000 - Login with test credentials
- Copy the generated token
Authorization Code + PKCE Flow
- Generate PKCE params at PKCE Generator
- 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 - 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.FirmwareTargetDevices |
Explicit per-device FOTA rollout targets |
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
- Install Bruno: usebruno.com
- Open Collection: In Bruno, open the
bruno/AutolivSafetyfolder - Select Environment:
dev,qa, orprod
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
- Run Login to get
access_tokenandrefresh_token - Copy
access_tokento your environment variables - When expired, use Refresh token to get a new pair
- All other requests use the stored
access_tokenautomatically
π 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
β βββ extract_partner_spec.py # Builds the OEM / Partner (B2B) spec
βββ 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 |
OEM / Partner (B2B) OpenAPI Specification
The API reference page offers two downloads: the ReDoc Download button (full
spec) and the header link Download OEM / Partner (B2B) OpenAPI specification
(api/partner-openapi.json). The partner file contains only operations whose
@oas block has x-audience: partner, plus the component schemas they
reference. To expose a new endpoint to partners, add x-audience: partner to
its @oas block in src/app/blueprint/oas/. Currently to push the latest open
api specification in API Center in
here
we need to provide the latest open api specification to Cloud IT team manually
and to update the api specification for B2B endpoints, we can download that from
here
βοΈ 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 anyfeature/*,fix/*, orbug/*branch - Pull requests to
development,staging, andproduction
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
- Checkout code
- Set Python 3.11
- Cache pre-commit hooks
- Install
requirements.txt,requirements-dev.txt, pre-commit hooks - Install Liquibase 4.23.0
- Run all pre-commit hooks (linting, formatting, security checks)
- Run unit tests with 80% coverage threshold
- Publish JUnit test results to Azure DevOps
- Publish Cobertura coverage report
- SonarCloud: prepare β analyze β publish
2. Security Scan (security_scan) β non-PR builds only, after QA
- Checkout
- Datadog Static Analysis β install CLI, run secrets detection, upload SARIF
- Datadog SBOM β generate and upload Software Bill of Materials
3. Build (build) β after QA, for development/staging/production/feature/*/fix/*/bug/*
- Apply
.funcignoreβ copy filtered sources todeploy/ - Set Python 3.11
- Install packages into
.python_packages/lib/site-packages - Create ZIP archive
- Publish artifact
4. Deploy Development (deploy_dv) β development, feature/*, fix/*, bug/*
- Deploy ZIP to the Dev Function App (Linux runtime) β any
feature/*,fix/*, orbug/*push deploys straight to Dev, unrestricted, for fast iteration - Documentation is built and published only when the source branch is
developmentitself (withmike, branch + "latest" alias, pushed toazure-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 rcand push thevX.Y.Z-rc.Ntag
6. Deploy Production (deploy_prod) β production branch (approval gated)
- Deploy ZIP to the Prod Function App
- On success: run
cz bumpto finalizevX.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 |