Skip to main content

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 LockPort interface (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 → revoked with idempotent transitions and immutable audit.
  • Run the key-lifecycle saga that listens to reservation.* and staff.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 CloudProxy adapter 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-service Merkle 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.v1 and notification-service maps to its template (template.lock.mobile_key.invite.<locale> for kind = mobile_app, template.lock.pin_code.delivered.<locale> for kind = 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 get rooms:assigned scope computed from their open work orders; GMs get rooms: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: true and 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

AggregateCardinalityPurposeIdentity prefix
KeyCredential1 per (reservation, key kind) or 1 per (staff, shift) for mastersLifecycle state, vendor ref, validity window, room scope, audit lineagekey_
KeyCredentialAttempt1 per door-access event ingested from vendor webhookGranted/denied, reason, deviceId, timestamp; immutable, append-onlykca_
LockDevice1 per physical lock (or door reader) at a propertyVendor binding, room mapping, online status, battery, firmware, capabilitieslck_
VendorAdapter1 per (tenant, property, vendor)Adapter selection, environment, health snapshot, last errorvad_
VendorCredential1 per (tenant, property, vendor)Pointer into Secret Manager, never the secret bytes themselvesvcr_
EncoderSession1 per (desktop device, encoder) open sessionUSB/serial handle lifecycle for the on-prem encoderenc_
MasterKey1 per (staff, scope, shift)Role-bound, time-bound staff credentialmky_
KeyKindPolicy1 per (tenant, property)Tenant rules: preferred kinds, fallback order, ID-verify policy, max validity windowkkp_
OfflineIssuance1 per (desktop, certificate) Ed25519 issuance certificatePhase 2 signed cert binding the desktop to a tenant + property + allowed kindsoki_

Key APIs (REST, /api/v1/key-credentials, /api/v1/lock-devices, /api/v1/master-keys, /api/v1/vendor-adapters)

MethodPathPurpose
POST/api/v1/key-credentialsIssue a credential (caller usually the saga; staff manual issue path also accepted)
GET/api/v1/key-credentials/:idRead one credential (no vendorRef in response)
GET/api/v1/key-credentialsList + filter (by reservation, by guest, by property, by state)
PATCH/api/v1/key-credentials/:idUpdate (rooms, validUntil, scope)
POST/api/v1/key-credentials/:id/revokeRevoke with reason
POST/api/v1/key-credentials/:id/suspendSuspend with reason
POST/api/v1/key-credentials/:id/unsuspendUnsuspend (only valid from suspended)
POST/api/v1/key-credentials/:id/replaceLost-key flow: revoke + re-issue atomically
GET/api/v1/key-credentials/:id/auditFull audit trail for one credential
POST/api/v1/lock-devicesRegister a device at a property (paired by desktop wizard)
GET/api/v1/lock-devicesList devices for a property
GET/api/v1/lock-devices/:id/healthProbe device health (online, battery, clockSkewMs)
POST/api/v1/master-keysIssue a staff master key (shift-bound, scope-bounded)
POST/api/v1/master-keys/:id/revokeRevoke a master at shift end
GET/api/v1/vendor-adaptersList configured adapters per property with health snapshot
POST/api/v1/vendor-adapters/:id/health-checkForce a health probe
POST/webhooks/v1/<vendor>Vendor webhook intake (signature-verified, idempotent)
POST/api/v1/offline-issuance/certificatesMint a new Ed25519 cert for a desktop (called by the desktop pairing wizard)
POST/api/v1/offline-issuance/certificates/:id/revokeRevoke 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)

SubjectTriggerRetention class
melmastoon.lock.credential.requested.v1Saga starts on consumed reservation eventoperational (90d)
melmastoon.lock.credential.issued.v1Vendor confirms issuanceregulated (7y)
melmastoon.lock.credential.failed.v1Saga exhausts retriesregulated (7y)
melmastoon.lock.credential.updated.v1updateCredential succeedsregulated (7y)
melmastoon.lock.credential.revoked.v1Revoke confirmed (or persisted as terminal)regulated (7y)
melmastoon.lock.credential.suspended.v1Suspend confirmedregulated (7y)
melmastoon.lock.credential.unsuspended.v1Unsuspend confirmedregulated (7y)
melmastoon.lock.device.registered.v1New lock paired to a propertyregulated (7y)
melmastoon.lock.device.health_alert.v1Health degradation crossed thresholdoperational (90d)
melmastoon.lock.device.battery_low.v1Battery below configured thresholdoperational (90d)
melmastoon.lock.device.offline.v1Device marked offlineoperational (90d)
melmastoon.lock.device.online.v1Device recoveredoperational (90d)
melmastoon.lock.master_key.issued.v1Staff shift-start issuanceregulated (7y)
melmastoon.lock.master_key.expired.v1Shift-end auto-revokeregulated (7y)
melmastoon.lock.encoder_session.opened.v1Desktop opens USB encoder handleoperational (90d)
melmastoon.lock.encoder_session.closed.v1Desktop closes encoder handleoperational (90d)
melmastoon.lock.audit.attempt.v1Door-access event ingested from vendorregulated (7y), Merkle-anchored
melmastoon.lock.vendor_webhook.received.v1Webhook intake (pre-dispatch)operational (90d)
melmastoon.lock.vendor_webhook.processed.v1Webhook handler completedoperational (90d)
melmastoon.lock.vendor_adapter.health_changed.v1Circuit breaker state changeoperational (90d)

Key events (consumed)

SubjectProducerSaga step
melmastoon.reservation.confirmed.v1reservation-serviceissue credential against (rooms, validFrom, validUntil)
melmastoon.reservation.cancelled.v1reservation-servicerevoke credential (reason cancellation)
melmastoon.reservation.checked_out.v1reservation-servicerevoke credential (reason checkout)
melmastoon.reservation.dates_changed.v1reservation-serviceupdate credential (new validUntil, possibly rooms)
melmastoon.reservation.no_show.v1reservation-servicesuspend credential (reason no_show) per tenant policy
melmastoon.reservation.fraud_flagged.v1reservation-servicesuspend credential (reason fraud_review)
melmastoon.staff.shift.started.v1staff-serviceissue master key (shift-bound, scope-bounded)
melmastoon.staff.shift.ended.v1staff-servicerevoke master key
melmastoon.tenant.property.deactivated.v1tenant-servicebulk-revoke active credentials at the property
melmastoon.iam.user.deactivated.v1iam-servicerevoke any active master keys held by the user

Storage

  • Cloud SQL Postgres (regional HA) shared schema with tenant_id RLS 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 the lock-integration-service Cloud 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_authoritative on issuance state and append_only on 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.v1 with metadata.wasProvisional = true.

Dependencies

DirectionDependencyWhy
Upstream (consumed)reservation-serviceDrives the issue/update/revoke saga
Upstream (consumed)staff-service (Phase 1+)Master-key shift triggers
Upstream (consumed)iam-serviceUser deactivation cascades
Upstream (consumed)tenant-servicePer-tenant policies, vendor selection, property activation
Downstream (produced)notification-servicePicks up lock.credential.issued.v1 and fans out mobile-key/PIN delivery
Downstream (produced)audit-serviceAll lock.*.v1 events anchored daily into Merkle root
Downstream (produced)analytics-serviceVendor cost reporting, lock attempt anomaly base data
Downstream (produced)ai-orchestrator-serviceAnomaly detection on attempt patterns, predictive battery replacement
Siblingproperty-serviceSource of truth for Room/Property; credential rooms[] validated against it
ExternalTTLock cloud, Salto XS4 cloud + on-prem connector, Vostio cloud, Generic Wiegand encoder via desktopVendor adapters
ExternalElectron desktop main processCloudProxy adapter relay; offline issuance origin

Cross-references