Skip to main content

SECURITY_MODEL — inventory-service

Sibling: APPLICATION_LOGIC · DATA_MODEL · API_CONTRACTS · OBSERVABILITY

Strategic anchors: 07 Security, Compliance & Tenancy · ADR-0002 Multi-Tenancy

inventory-service is high-leverage but holds no PII and no payment data. Its security posture is shaped by three concerns: (a) strict tenant isolation (a leak across tenants would let one hotel see or take another hotel's rooms); (b) integrity of the allocation ledger (overbooking is an operational and reputational disaster); (c) resistance to operator misuse (manual release and overbooking-policy edits must be audited and bounded). Out of PCI scope, out of lock-vendor secret scope, out of GDPR PII scope (inventory rows reference reservation ids only — the guest data lives in reservation-service).


1. Identity & authentication

CallerAuth mechanism
bff-tenant-booking-service (funnel availability)Service-to-service OIDC token from iam-service; aud=inventory-service; tenant_id claim required
bff-backoffice-service (calendar grid, blocks, manual release, walk-in)Staff JWT with tenant_id, property_id[], roles[], device_id; short-lived (15 min)
bff-consumer-service (meta search)Anonymous consumer JWT scoped to read-only availability.search; no tenant write
search-aggregation-service (cache rebuild)Service-account JWT; aud=inventory-service; principal search-agg@<project>.iam.gserviceaccount.com
reservation-service events (Pub/Sub push)GCP service-account JWT verified by Cloud Run; principal must be pubsub-pusher@<project>.iam.gserviceaccount.com and event source claim must equal reservation-service
property-service events (Pub/Sub push)same; source claim property-service
sync-service (desktop snapshot pull)Device-bound JWT issued via iam-service device-binding flow; carries tenant_id and property_id; tied to attested device id
Cloud Scheduler (sweeper, extender, reconciliation)Service-account JWT; principal must be scheduler@<project>.iam.gserviceaccount.com
Internal health (`/internal/healthready`)

X-Tenant-Id is cross-checked against the JWT's tenant_id on every request; mismatch returns 403 MELMASTOON.TENANT.MISMATCH. Pub/Sub push handlers also verify the envelope's tenantId matches the loaded aggregate's tenant_id — mismatched messages are dead-lettered.


2. Authorization (RBAC + ABAC)

The matrix below distills APPLICATION_LOGIC §6:

ActionRoles allowedAttribute checks
POST /availability/searchguest, anonymous_booking, staff_*, gm, owner, consumer, systemtenantId consistent with token
GET /properties/:propertyId/calendarstaff_front_desk, gm, owner, housekeepingpropertyId in JWT property_id[]
GET /allocations/:idstaff_*, gm, owner, systemtenant + property match
POST /allocations (walk-in)staff_front_desk, gm, ownerproperty access; device must be bound_for_offline if X-Sync-Mode: offline_arbitration
DELETE /allocations/:idgm, owner onlywritten justification mandatory; audit-emitted
POST /blocksstaff_front_desk, gm, ownerproperty access
DELETE /blocks/:idstaff_front_desk, gm, ownerproperty access
POST /groups/holdsstaff_front_desk, gm, owner, system (event-driven)property access
PUT /properties/:propertyId/policies/overbookinggm, owner onlytenant + property; enabled=true requires non-empty alertRoutes[] (I13)
GET /properties/:propertyId/snapshotsystem (sync-service principal)tenant + property; device-bound
All /internal/events/*system (Pub/Sub pusher)source-service claim must match expected publisher
All /internal/jobs/*system (Cloud Scheduler)scheduler service-account principal

Authorization is decided in the application layer (AuthorizationDecider) before the use case runs; denials emit an audit-service event with actor, action, subject, decision='deny', reason.


3. Tenant isolation (defense in depth)

Five independent layers, each capable of stopping a cross-tenant breach:

  1. JWT/X-Tenant-Id consistency — controller guard rejects mismatch (403).
  2. Postgres RLS — every table sets app.tenant_id via SET LOCAL; policies named <table>_tenant_isolation (DATA_MODEL §3).
  3. Domain-layer TenantId invariant — every aggregate refuses a different tenant context (Invariant I1 in DOMAIN_MODEL §4).
  4. Advisory-lock key includes tenant_id — even a misrouted query cannot deadlock against another tenant (Invariant I18; DATA_MODEL §6).
  5. 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 exercises all five layers, including direct ID guesses across tenants, RLS bypass attempts, advisory-lock cross-tenant collision attempts, and event publishing under a swapped session.


4. Data classification

DataClassStorageAt-rest encryption
Allocation rows (room_allocations)confidential (no PII)inventory.room_allocationsCloud SQL CMEK
Inventory counters (room_type_inventory_daily)confidentialpartitioned tableCloud SQL CMEK
Inventory blocksconfidentialinventory.inventory_blocksCloud SQL CMEK
Overbooking policiesconfidentialinventory.overbooking_policiesCloud SQL CMEK
Outbox envelopesconfidentialinventory.outboxCloud SQL CMEK

No field-level encryption is required because no field is PII. Reservation references (reservation_id, reservation_item_id) are opaque ULIDs that are useless without joining to reservation-service, which has its own encryption posture.


5. Secret handling

Secrets used: Cloud SQL credentials (Workload Identity), Pub/Sub publisher credentials (Workload Identity), KMS access for table CMEK. No payment processor, lock vendor, or third-party SDK credentials. CI dependency-graph guard (ESLint rule no-vendor-sdk) rejects any import from stripe, paypal, ttlock, salto, etc., for inventory-service.


6. Audit trail

Every state-changing action emits to audit-service:

{
subjectKind: 'inventory_allocation' | 'inventory_block' | 'overbooking_policy',
subjectId: 'inv_…' | 'blk_…' | 'obp_…',
tenantId: 'tnt_…',
action: 'allocation.committed' | 'allocation.released'
| 'allocation.released.manual' | 'block.created' | 'block.released'
| 'overbooking_policy.updated' | 'walk_in.allocated' | 'reaccommodation.required',
actor: ActorRef, // staff_id or system principal
before?: object,
after?: object,
reason?: string, // mandatory for manual releases & policy edits
occurredAt: ISODate
}

Audit events participate in the daily Merkle anchor in audit-service (07 §9).

DELETE /allocations/:id and PUT /policies/overbooking are always audit-flagged with severity='operator_override'.


7. Privacy & regulatory

  • No PII: nothing in this service identifies a guest.
  • Data residency: same per-tenant rules as the rest of the platform — Iran-tenant rows live in the in-region Postgres replica only; cross-region Pub/Sub topics are not used for tenants pinned to a single region.
  • Subject access requests (SAR): when a guest submits a SAR via reservation-service, that service may join to inventory by reservation_id to expose room-stay metadata; the SAR exporter is the authority. Inventory itself does not respond to SAR directly.
  • Right to erasure: when a tenant offboards, reservation-service triggers cascading erasure; inventory drops the matching room_allocations rows after a grace period (anomaly-detection retention).

8. Threat model

ThreatMitigation
Cross-tenant read of availability via guessed propertyIdRLS + JWT/tenant guard + branded id checks
Cross-tenant write via misrouted Pub/Sub messageEnvelope tenantId cross-check; mismatched messages dead-lettered
Replayed reservation.confirmed.v1 from a different tenantInbox dedupe by event_id; envelope tenant check
Operator account compromise issues mass DELETE /allocationsManual release restricted to gm/owner; rate-limited (10 per 5 min); per-action audit; alert if > 5 in 1 h
Operator flips overbooking policy maliciouslyRestricted to gm/owner; OCC version required; audit row; notification-service page to owner on change
Forged advisory lock collision attack from another tenantAdvisory-lock key hashes tenant_id; impossible at the DB layer
Mass scraping of availability via /availability/searchPer-token rate limit at API gateway; per-tenant quota; ULIDs in property ids prevent enumeration
Outbox replay duplicates allocation eventsConsumers idempotent on event_id; inventory.allocation.confirmed.v1 is idempotent on (allocationId, status)
Stale snapshot on desktop leads to ghost availabilitySnapshot freshness banner; offline arbitration OFFLINE_GRACE_EXPIRED after 24 h
Policy edit race with active overbooking saleOCC version + transactional load
Forged sync push of offline walk-in for another tenantDevice-bound JWT; tenant_id claim cross-checked against allocation's tenant_id; mismatch → 403

9. Rate limiting

EndpointPer-tenant limit
POST /availability/search200 RPS sustained, 600 RPS burst (10 s)
POST /allocations (walk-in)30 RPS sustained per property
DELETE /allocations/:id10 per 5 minutes per actor
POST /blocks / DELETE /blocks/:id60 per minute per property
PUT /policies/overbooking5 per hour per tenant

Enforced at API gateway (Kong) with token-bucket; service returns 429 MELMASTOON.API.RATE_LIMITED if the gateway is bypassed.


10. Cross-references