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​