lock-integration-service
Bounded Context: Lock & Key (Core) · Owner: Platform / Hardware Integrations squad · Phase: 0 (TTLock + Salto SVN + Assa Abloy Vostio + Generic Wiegand encoder S0; Dormakaba Phase 3) · Storage: Cloud SQL Postgres (shared schema + RLS) + Secret Manager (vendor creds, separate KMS key) + BigQuery audit sink · Bundle: services/lock-integration-service/ · ADR: ADR-0004
lock-integration-service is the single physical-access boundary of Ghasi Melmastoon — the multi-tenant hotel SaaS whose backoffice is an Electron offline-first desktop app and whose cloud is GCP. It is the canonical implementation of the LockPort interface defined in docs/09 — Lock & Key Integration. No other service in the estate may import a vendor lock SDK; CI dependency analysis enforces the rule and an ESLint boundary plugin fails any PR that does. Vendor heterogeneity, BLE/USB hardware, mobile-key handoff, on-prem Salto connectors, signed offline issuance certificates, and the entire saga that turns a reservation.confirmed.v1 into a working credential at the door — all of it lives behind this one service.
The service is the most physically consequential surface the platform operates: a leaked vendor credential is not a digital incident, it is a hotel-room-door incident. Storage isolation, audit immutability, secret rotation, and renderer/main isolation on the Electron desktop are therefore non-negotiable and are gated by security-reviewer sign-off before each release.
Purpose
- Implement the
LockPortinterface (docs/09 §4) and route every method to the correct vendor adapter for the (tenant, property) pair. - Own the key credential lifecycle state machine:
requested → pending → active → suspended → active → revokedwith idempotent transitions and immutable audit. - Run the key-lifecycle saga that listens to
reservation.*andstaff.shift.*events on Pub/Sub and issues, updates, suspends, or revokes credentials accordingly. - Manage the lock device registry per property — vendor mapping, capabilities, online status, battery, firmware, BLE pairing tokens.
- Manage staff master keys with role-bound, time-bound, scope-bounded credentials issued at shift start and revoked at shift end.
- Provide the
CloudProxyadapter that relays cloud-initiated lock actions for properties with USB-only encoders to the Electron desktop's local adapter via an authenticated WebSocket relay. - Issue signed offline issuance certificates (Ed25519, 90-day rotation) so the Electron main process can mint provisional credentials when the cloud is unreachable, with the cloud reconciler materializing or revoking them on next sync.
- Ingest vendor delivery webhooks (TTLock callbacks, Salto Connect status, Vostio access events) with HMAC signature verification, idempotency dedupe, and dispatch to the saga.
- Maintain vendor adapter health with per-(vendor, environment) circuit breakers; trip open on >25% error or p99 > 5s; fall back to alternate kinds (
mobile_app → pin_code → manual escort). - Persist the immutable audit trail of every issue, update, suspend, revoke, and door-attempt event, anchored daily into the
audit-serviceMerkle root for tamper evidence.
Hotel-specific shape
- Vendor mix in target markets. TTLock dominates Asia, Pakistan, Iran, Afghanistan, India because of low cost (USD 30–80/lock) and BLE-friendly retrofit for guesthouses. Salto SVN is the mid-market chain default in GCC and EU. Assa Abloy Vostio is the enterprise hospitality default. Generic Wiegand/RFID encoders cover the long-tail legacy installs everywhere — the encoder lives at the front desk and is owned by the Electron main process.
- Mobile-key delivery via notification-service. We never SMTP/SMS directly; we publish
melmastoon.lock.credential.issued.v1and notification-service maps to its template (template.lock.mobile_key.invite.<locale>forkind = mobile_app,template.lock.pin_code.delivered.<locale>forkind = pin_code, etc.) and dispatches per recipient preferences. - PIN-code fallback for guests without smartphones is first-class. Guests in target markets routinely arrive with feature phones; PIN-code printed on the booking confirmation is the default delivery channel for those guests.
- Master-key scoping is critical for chain operators with hundreds of rooms across multiple properties. Housekeeping floor leads get
floor:<floor>scope; maintenance technicians getrooms:assignedscope computed from their open work orders; GMs getrooms:all + areas:all. Every staff master key is shift-bound. - Offline issuance is operationally mandatory. Many properties in target markets have intermittent ISP links. The Electron main process can issue a credential locally via the USB encoder or BLE-direct TTLock when the cloud is unreachable; the credential is marked
provisional: trueand the cloud reconciles on next sync. - Immutable per-action audit with a 7-year retention horizon is required by hospitality regulators in GCC and parts of EU under EN 14846 and local hospitality codes.
Aggregates owned
| Aggregate | Cardinality | Purpose | Identity prefix |
|---|---|---|---|
KeyCredential | 1 per (reservation, key kind) or 1 per (staff, shift) for masters | Lifecycle state, vendor ref, validity window, room scope, audit lineage | key_ |
KeyCredentialAttempt | 1 per door-access event ingested from vendor webhook | Granted/denied, reason, deviceId, timestamp; immutable, append-only | kca_ |
LockDevice | 1 per physical lock (or door reader) at a property | Vendor binding, room mapping, online status, battery, firmware, capabilities | lck_ |
VendorAdapter | 1 per (tenant, property, vendor) | Adapter selection, environment, health snapshot, last error | vad_ |
VendorCredential | 1 per (tenant, property, vendor) | Pointer into Secret Manager, never the secret bytes themselves | vcr_ |
EncoderSession | 1 per (desktop device, encoder) open session | USB/serial handle lifecycle for the on-prem encoder | enc_ |
MasterKey | 1 per (staff, scope, shift) | Role-bound, time-bound staff credential | mky_ |
KeyKindPolicy | 1 per (tenant, property) | Tenant rules: preferred kinds, fallback order, ID-verify policy, max validity window | kkp_ |
OfflineIssuance | 1 per (desktop, certificate) Ed25519 issuance certificate | Phase 2 signed cert binding the desktop to a tenant + property + allowed kinds | oki_ |
Key APIs (REST, /api/v1/key-credentials, /api/v1/lock-devices, /api/v1/master-keys, /api/v1/vendor-adapters)
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/key-credentials | Issue a credential (caller usually the saga; staff manual issue path also accepted) |
GET | /api/v1/key-credentials/:id | Read one credential (no vendorRef in response) |
GET | /api/v1/key-credentials | List + filter (by reservation, by guest, by property, by state) |
PATCH | /api/v1/key-credentials/:id | Update (rooms, validUntil, scope) |
POST | /api/v1/key-credentials/:id/revoke | Revoke with reason |
POST | /api/v1/key-credentials/:id/suspend | Suspend with reason |
POST | /api/v1/key-credentials/:id/unsuspend | Unsuspend (only valid from suspended) |
POST | /api/v1/key-credentials/:id/replace | Lost-key flow: revoke + re-issue atomically |
GET | /api/v1/key-credentials/:id/audit | Full audit trail for one credential |
POST | /api/v1/lock-devices | Register a device at a property (paired by desktop wizard) |
GET | /api/v1/lock-devices | List devices for a property |
GET | /api/v1/lock-devices/:id/health | Probe device health (online, battery, clockSkewMs) |
POST | /api/v1/master-keys | Issue a staff master key (shift-bound, scope-bounded) |
POST | /api/v1/master-keys/:id/revoke | Revoke a master at shift end |
GET | /api/v1/vendor-adapters | List configured adapters per property with health snapshot |
POST | /api/v1/vendor-adapters/:id/health-check | Force a health probe |
POST | /webhooks/v1/<vendor> | Vendor webhook intake (signature-verified, idempotent) |
POST | /api/v1/offline-issuance/certificates | Mint a new Ed25519 cert for a desktop (called by the desktop pairing wizard) |
POST | /api/v1/offline-issuance/certificates/:id/revoke | Revoke an offline issuance cert |
The vendorRef opaque identifier is never present in any API response; it is operational state owned only by the originating adapter.
Key events (produced)
| Subject | Trigger | Retention class |
|---|---|---|
melmastoon.lock.credential.requested.v1 | Saga starts on consumed reservation event | operational (90d) |
melmastoon.lock.credential.issued.v1 | Vendor confirms issuance | regulated (7y) |
melmastoon.lock.credential.failed.v1 | Saga exhausts retries | regulated (7y) |
melmastoon.lock.credential.updated.v1 | updateCredential succeeds | regulated (7y) |
melmastoon.lock.credential.revoked.v1 | Revoke confirmed (or persisted as terminal) | regulated (7y) |
melmastoon.lock.credential.suspended.v1 | Suspend confirmed | regulated (7y) |
melmastoon.lock.credential.unsuspended.v1 | Unsuspend confirmed | regulated (7y) |
melmastoon.lock.device.registered.v1 | New lock paired to a property | regulated (7y) |
melmastoon.lock.device.health_alert.v1 | Health degradation crossed threshold | operational (90d) |
melmastoon.lock.device.battery_low.v1 | Battery below configured threshold | operational (90d) |
melmastoon.lock.device.offline.v1 | Device marked offline | operational (90d) |
melmastoon.lock.device.online.v1 | Device recovered | operational (90d) |
melmastoon.lock.master_key.issued.v1 | Staff shift-start issuance | regulated (7y) |
melmastoon.lock.master_key.expired.v1 | Shift-end auto-revoke | regulated (7y) |
melmastoon.lock.encoder_session.opened.v1 | Desktop opens USB encoder handle | operational (90d) |
melmastoon.lock.encoder_session.closed.v1 | Desktop closes encoder handle | operational (90d) |
melmastoon.lock.audit.attempt.v1 | Door-access event ingested from vendor | regulated (7y), Merkle-anchored |
melmastoon.lock.vendor_webhook.received.v1 | Webhook intake (pre-dispatch) | operational (90d) |
melmastoon.lock.vendor_webhook.processed.v1 | Webhook handler completed | operational (90d) |
melmastoon.lock.vendor_adapter.health_changed.v1 | Circuit breaker state change | operational (90d) |
Key events (consumed)
| Subject | Producer | Saga step |
|---|---|---|
melmastoon.reservation.confirmed.v1 | reservation-service | issue credential against (rooms, validFrom, validUntil) |
melmastoon.reservation.cancelled.v1 | reservation-service | revoke credential (reason cancellation) |
melmastoon.reservation.checked_out.v1 | reservation-service | revoke credential (reason checkout) |
melmastoon.reservation.dates_changed.v1 | reservation-service | update credential (new validUntil, possibly rooms) |
melmastoon.reservation.no_show.v1 | reservation-service | suspend credential (reason no_show) per tenant policy |
melmastoon.reservation.fraud_flagged.v1 | reservation-service | suspend credential (reason fraud_review) |
melmastoon.staff.shift.started.v1 | staff-service | issue master key (shift-bound, scope-bounded) |
melmastoon.staff.shift.ended.v1 | staff-service | revoke master key |
melmastoon.tenant.property.deactivated.v1 | tenant-service | bulk-revoke active credentials at the property |
melmastoon.iam.user.deactivated.v1 | iam-service | revoke any active master keys held by the user |
Storage
- Cloud SQL Postgres (regional HA) shared schema with
tenant_idRLS on every table. Vendor configuration columns are encrypted at rest with a separate KMS key (projects/<project>/locations/<region>/keyRings/melmastoon-lock/cryptoKeys/lock-config) from the platform default key. - Secret Manager holds vendor API credentials under the
lock/<tenant>/<property>/<vendor>namespace; only thelock-integration-serviceCloud Run runtime identity may read. - Audit log is dual-written: Postgres for hot reads + BigQuery sink for long-term (7-year) retention and analytics.
- Desktop SQLite (encrypted, OS-keychain-referenced key) stores active credentials, master keys, and the lock device registry; conflict policy
server_authoritativeon issuance state andappend_onlyon attempts (see SYNC_CONTRACT).
Top edge cases
- Vendor cloud down. Circuit-break, attempt fallback to alternate kind via
KeyKindPolicy, fall back to local Electron encoder if available, retry on recovery. - BLE pairing fail (TTLock). Retry once, then fall back to PIN delivery; surface PagerDuty alert if BLE failure rate per property exceeds 5% over 1h.
- PIN collision (vendor refuses duplicate PIN within property). Regenerate with cryptographic RNG; max 3 attempts; then switch kind.
- Time skew on lock device > 5 min — warn; > 10 min — alert; vendor app schedules sync.
- Encoder USB disconnected. Renderer prompts staff to re-seat; offers PIN issuance instead; manual override path documented.
- Duplicate webhook delivery. Webhook inbox dedupes by
(vendor, externalEventId)unique key. - Credential issuance race for same room. Postgres advisory lock on
(property_id, room_id, valid_from_date)prevents overlapping active credentials. - Provisional credential on a reservation that was cancelled while desktop was offline. Reconciler revokes at vendor on next sync; emits canonical
revoked.v1withmetadata.wasProvisional = true.
Dependencies
| Direction | Dependency | Why |
|---|---|---|
| Upstream (consumed) | reservation-service | Drives the issue/update/revoke saga |
| Upstream (consumed) | staff-service (Phase 1+) | Master-key shift triggers |
| Upstream (consumed) | iam-service | User deactivation cascades |
| Upstream (consumed) | tenant-service | Per-tenant policies, vendor selection, property activation |
| Downstream (produced) | notification-service | Picks up lock.credential.issued.v1 and fans out mobile-key/PIN delivery |
| Downstream (produced) | audit-service | All lock.*.v1 events anchored daily into Merkle root |
| Downstream (produced) | analytics-service | Vendor cost reporting, lock attempt anomaly base data |
| Downstream (produced) | ai-orchestrator-service | Anomaly detection on attempt patterns, predictive battery replacement |
| Sibling | property-service | Source of truth for Room/Property; credential rooms[] validated against it |
| External | TTLock cloud, Salto XS4 cloud + on-prem connector, Vostio cloud, Generic Wiegand encoder via desktop | Vendor adapters |
| External | Electron desktop main process | CloudProxy adapter relay; offline issuance origin |
Cross-references
- docs/09 — Lock & Key Integration — canonical specification, the source of truth for this service
- docs/architecture/ADR-0004 — Lock Abstraction — architectural rationale
- docs/architecture/ADR-0003 — Electron Offline-First Desktop — renderer/main isolation rules
- docs/04 — Event-Driven Architecture — Pub/Sub topology, outbox/inbox semantics
- docs/07 — Security & Tenancy — secret handling, KMS, audit immutability
- docs/standards/ERROR_CODES — LOCK — canonical error code registry for this service
- Service bundle — 17 implementation-grade specs