SECURITY_MODEL — reservation-service
Sibling: APPLICATION_LOGIC · DATA_MODEL · API_CONTRACTS · OBSERVABILITY
Strategic anchors: 07 Security, Compliance & Tenancy · ADR-0002 Multi-Tenancy · 09 Lock & Key · 10 Payments
reservation-service holds guest PII, partial document references, channel attribution, and is the saga orchestrator for booking. It is in scope for tenant isolation, GDPR/regional privacy regulation, audit-traceability, and AML-style anomaly review. It is out of PCI scope (no card data) and out of lock-vendor secret scope (no vendor SDKs imported).
1. Identity & authentication
| Caller | Auth mechanism |
|---|---|
bff-tenant-booking-service (guest funnel) | Service-to-service OIDC token from iam-service with aud=reservation-service and tenant_id claim; or anonymous-booking-session JWT for unauthenticated guests (max scope: reservation:create:own) |
bff-backoffice-service (front-desk) | Staff JWT with tenant_id, property_id array, roles[], device_id (Electron device-bound token); short-lived (15 min) refreshable |
Pub/Sub push handlers (/internal/events/*) | GCP service-account JWT with aud=reservation-service; signature verified by Cloud Run platform; principal must be pubsub-pusher@<project>.iam.gserviceaccount.com |
Cloud Scheduler (/internal/jobs/expire-holds) | Service-account JWT; principal must be scheduler@<project>.iam.gserviceaccount.com |
| Internal health (`/internal/health | ready`) |
X-Tenant-Id is cross-checked against the JWT's tenant_id claim on every request; mismatch returns 403 MELMASTOON.TENANT.MISMATCH. Anonymous booking sessions carry tenant_id inside the session token signed by iam-service; the bff cannot fabricate it.
2. Authorization (RBAC + ABAC)
The full matrix is reproduced in APPLICATION_LOGIC §6. Highlights:
| Action | Roles allowed | Attribute checks |
|---|---|---|
| Quote / Hold | guest, anonymous_booking, staff_front_desk, gm, owner | tenantId match; staff also need propertyId access |
| Confirm (event-driven) | system (Pub/Sub principal) | event signed by payment-gateway-service IAM principal |
| Cancel own reservation | guest | reservation.primaryGuest.guestId == jwt.guest_id |
| Cancel any reservation | staff_front_desk, gm, owner | propertyId access |
| Modify (any sub-type) | staff_front_desk, gm, owner | propertyId access; manual_state_override requires gm or owner |
| Check-in / Check-out / Walk-in | staff_front_desk, gm, owner | propertyId access; device must be bound_for_offline for offline use |
policyOverride on cancel | gm, owner only | written justification mandatory; logs to audit-service |
| Merge / Split | gm, owner | same property; gm cannot merge across properties |
Read by guest (/by-guest/:guestId) | staff_front_desk, gm, owner, finance; guest (self) | tenantId match |
Authorization decisions are evaluated in the application layer guard (AuthorizationDecider) before the use case runs; denials produce an audit-service event.
3. Tenant isolation (defense in depth)
Four independent layers, every one capable of stopping a cross-tenant breach:
- JWT/X-Tenant-Id consistency — controller guard rejects mismatch (403).
- Postgres RLS — every table sets
app.tenant_idviaSET LOCAL; policies named<table>_tenant_isolation(DATA_MODEL §3) bind every row. - Domain-layer
TenantIdinvariant — every aggregate constructor refuses missing or mismatched tenant context (Invariant I1 in DOMAIN_MODEL §4). - Outbox tenant tagging — every outbox row carries
tenant_id; the relay refuses to publish events whose envelope tenant does not match the row.
The mandatory test/integration/tenant-isolation.spec.ts test exercises all four layers including direct ID guesses across tenants, RLS bypass attempts, and event publishing under a swapped session.
4. Data classification
| Data | Class | Storage | At-rest encryption |
|---|---|---|---|
| Guest email, phone | PII (high) | reservation.guests.email_enc, phone_e164_enc | AES-256-GCM via tenant-scoped DEK from KMS; HMAC-SHA256 hash for indexed lookup |
| Guest names | PII (medium) | reservation.guests.full_name_* | Cloud SQL CMEK; not field-level (transliteration, sort, search require plaintext) |
| Document last4 + issuer | PII (medium) | reservation.guests.document_* | Cloud SQL CMEK |
| Reservation totals, payment method | confidential | reservation.reservations.* | Cloud SQL CMEK |
payment_intent_ids, key_credential_ids | references only | reservation.reservations.* | Cloud SQL CMEK; no vendor secrets |
| Modification audit | confidential | reservation.reservation_modifications | Cloud SQL CMEK; immutable |
| Outbox envelopes | confidential | reservation.outbox | Cloud SQL CMEK; PII tokenized at sink |
4.1 Field-level encryption keys
- One DEK per tenant for
guests.email_encandphone_e164_enc. - DEKs are wrapped under a tenant-scoped CMK in Cloud KMS (
projects/<p>/locations/<r>/keyRings/melmastoon-tenants/cryptoKeys/tnt_<id>). - Key rotation policy: yearly automatic; on rotation, new writes use the new DEK; reads transparently decrypt with prior DEK versions.
4.2 Hash-for-search
email_hash = HMAC-SHA256(tenantSalt, lower(trim(email))).phone_e164_hash = HMAC-SHA256(tenantSalt, normalizeE164(phone)).tenantSaltis per-tenant in Secret Manager and rotated every 24 months (with rolling re-hash background job).
4.3 The hold-expiry sweeper
The hold-expiry worker runs as a separate Cloud Run job under role reservation_holds_sweeper which has BYPASSRLS only for reservation.reservation_holds. Every other table (including reservations itself) is accessed via per-row SET app.tenant_id. The sweeper transitions only the local reservation_holds row and emits hold_expired.v1; the consequent state change to reservations happens via the inbox handler running under the tenant role.
5. Secret handling
- No payment processor or lock vendor SDK is imported. Compliance is enforced by an ESLint dependency-graph rule in CI.
- Secrets used: Postgres credentials (Cloud SQL Proxy via Workload Identity), Pub/Sub publisher credentials (Workload Identity), Cloud KMS access, Secret Manager access for
tenantSalt. All bound via Workload Identity; no service-account JSON keys in any deploy artifact.
6. Audit trail
Every state-changing action emits to audit-service via the canonical event:
{
subjectKind: 'reservation',
subjectId: 'rsv_…',
tenantId: 'tnt_…',
action: 'reservation.confirmed' | 'reservation.cancelled' | 'reservation.modified.<sub>'
| 'reservation.checked_in' | 'reservation.checked_out' | 'reservation.no_show'
| 'reservation.policy_override' | 'reservation.merged' | 'reservation.split',
actor: ActorRef,
before?: object,
after?: object,
policyAppliedRef?: string,
reason?: string,
occurredAt: ISODate,
}
Audit events are part of the daily Merkle anchor in audit-service (07 §9). The manual_state_override modification type is always audit-flagged.
7. Privacy & regulatory
- GDPR / equivalent:
Guest.erased_at(DSR right-to-erasure); PII columns zeroed; reservations remain for revenue ledger; modifications kept for regulated retention. The erasure job emitsmelmastoon.guest.erased.v1consumed bynotification-service,analytics-service,search-aggregation-serviceto purge their projections. - Data residency: Iran-tenant data (
tenant.region == 'gcp-asia-south1' or designated) is constrained to in-region Postgres replica and in-region Pub/Sub topics; cross-region replica is opt-in per tenant. - Children data:
additional_guests.is_child_without_id == trueflag triggers stricter export controls (no inclusion in analytics extracts). - No PCI: card data never reaches us; the
paymentIntentIdswe store are tokens.
8. AI safety surfaces
ai-orchestrator-service enforces:
- DLP redaction of inputs leaving our service.
- Per-tenant budgets (see AI_INTEGRATION §4).
- HITL gating for
auto_blockanomaly verdicts and any AI verdict that would advance state irreversibly.
We never persist a free-form AI response without normalizing it through a typed schema; outputs failing schema validation are dropped and logged.
9. Threat model (summary)
| Threat | Mitigation |
|---|---|
Cross-tenant data read via guessed rsv_ id | RLS + JWT/tenant guard + branded id repository checks |
| Replay of payment-captured event from another tenant | Pub/Sub subscription is tenant-agnostic; envelope's tenantId cross-checked against the loaded reservation's tenant; mismatched messages dead-lettered |
| Operator account compromise | Short-lived JWTs + device binding + per-action audit + GM/owner approval for irreversible overrides |
Mass scraping via /by-guest enumeration | Per-token rate limits enforced by API gateway; query path requires guestId (not enumerable) or hashed contact |
| Forged state transitions from desktop offline queue | Server-side state machine re-runs every command on push; OCC + RLS prevent unauthorized writes |
| Inflated/forged reservation total tampering | Totals are pricing-service-derived and re-validated server-side at confirm; modifications produce payment deltas with audit |
| Lock vendor compromise | We hold no vendor secrets; lock-integration-service is a separate trust boundary |