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
| Caller | Auth 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/health | ready`) |
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:
| Action | Roles allowed | Attribute checks |
|---|---|---|
POST /availability/search | guest, anonymous_booking, staff_*, gm, owner, consumer, system | tenantId consistent with token |
GET /properties/:propertyId/calendar | staff_front_desk, gm, owner, housekeeping | propertyId in JWT property_id[] |
GET /allocations/:id | staff_*, gm, owner, system | tenant + property match |
POST /allocations (walk-in) | staff_front_desk, gm, owner | property access; device must be bound_for_offline if X-Sync-Mode: offline_arbitration |
DELETE /allocations/:id | gm, owner only | written justification mandatory; audit-emitted |
POST /blocks | staff_front_desk, gm, owner | property access |
DELETE /blocks/:id | staff_front_desk, gm, owner | property access |
POST /groups/holds | staff_front_desk, gm, owner, system (event-driven) | property access |
PUT /properties/:propertyId/policies/overbooking | gm, owner only | tenant + property; enabled=true requires non-empty alertRoutes[] (I13) |
GET /properties/:propertyId/snapshot | system (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:
- 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). - Domain-layer
TenantIdinvariant — every aggregate refuses a different tenant context (Invariant I1 in DOMAIN_MODEL §4). - Advisory-lock key includes
tenant_id— even a misrouted query cannot deadlock against another tenant (Invariant I18; DATA_MODEL §6). - 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
| Data | Class | Storage | At-rest encryption |
|---|---|---|---|
Allocation rows (room_allocations) | confidential (no PII) | inventory.room_allocations | Cloud SQL CMEK |
Inventory counters (room_type_inventory_daily) | confidential | partitioned table | Cloud SQL CMEK |
| Inventory blocks | confidential | inventory.inventory_blocks | Cloud SQL CMEK |
| Overbooking policies | confidential | inventory.overbooking_policies | Cloud SQL CMEK |
| Outbox envelopes | confidential | inventory.outbox | Cloud 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 byreservation_idto 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-servicetriggers cascading erasure; inventory drops the matchingroom_allocationsrows after a grace period (anomaly-detection retention).
8. Threat model
| Threat | Mitigation |
|---|---|
Cross-tenant read of availability via guessed propertyId | RLS + JWT/tenant guard + branded id checks |
| Cross-tenant write via misrouted Pub/Sub message | Envelope tenantId cross-check; mismatched messages dead-lettered |
Replayed reservation.confirmed.v1 from a different tenant | Inbox dedupe by event_id; envelope tenant check |
Operator account compromise issues mass DELETE /allocations | Manual release restricted to gm/owner; rate-limited (10 per 5 min); per-action audit; alert if > 5 in 1 h |
| Operator flips overbooking policy maliciously | Restricted to gm/owner; OCC version required; audit row; notification-service page to owner on change |
| Forged advisory lock collision attack from another tenant | Advisory-lock key hashes tenant_id; impossible at the DB layer |
Mass scraping of availability via /availability/search | Per-token rate limit at API gateway; per-tenant quota; ULIDs in property ids prevent enumeration |
| Outbox replay duplicates allocation events | Consumers idempotent on event_id; inventory.allocation.confirmed.v1 is idempotent on (allocationId, status) |
| Stale snapshot on desktop leads to ghost availability | Snapshot freshness banner; offline arbitration OFFLINE_GRACE_EXPIRED after 24 h |
| Policy edit race with active overbooking sale | OCC version + transactional load |
| Forged sync push of offline walk-in for another tenant | Device-bound JWT; tenant_id claim cross-checked against allocation's tenant_id; mismatch → 403 |
9. Rate limiting
| Endpoint | Per-tenant limit |
|---|---|
POST /availability/search | 200 RPS sustained, 600 RPS burst (10 s) |
POST /allocations (walk-in) | 30 RPS sustained per property |
DELETE /allocations/:id | 10 per 5 minutes per actor |
POST /blocks / DELETE /blocks/:id | 60 per minute per property |
PUT /policies/overbooking | 5 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
- Multi-tenancy model: ADR-0002
- Encryption + KMS: 07 §6
- Audit format and Merkle anchor: 07 §9
- Authorization matrix in code: APPLICATION_LOGIC §6
- Reservation security model: reservation-service SECURITY_MODEL