Skip to main content

SECURITY_MODEL — bff-tenant-booking-service

Sibling: API_CONTRACTS · APPLICATION_LOGIC · DEPLOYMENT_TOPOLOGY · FAILURE_MODES

Cross-cutting: 07 Security/Compliance/Tenancy · Standards · ERROR_CODES

1. Posture

The booking BFF straddles three security boundaries: the anonymous public, the payment provider trust circle, and the tenant brand boundary. It must protect: tenant funds, guest PII collected at booking-time, the integrity of the booking funnel against injection or replay, and the mint-and-consume contract with bff-consumer-service.

It does not directly authorize money capture (lives in payment-gateway-service), does not own credentials or session keys for tenants (lives in iam-service), does not sign locks (lives in lock-integration-service).

2. AuthN / AuthZ

PathAuthNAuthZ
All /bff/tenant-booking/v1/{tenantSlug}/* (Phase 1)Anonymous; tnt_id cookie minted on bootstrapImplicit by tenant resolution; per-tenant rate-limit
/internal/handoff/{id}/consumeMutual TLS + Google ID token + X-Internal-HMAC headerCaller must present bff-consumer-service SA principal
/health/*OpenNone
/internal/jobs/*Cloud Scheduler OIDCScheduler SA only
Phase 2 authenticated guest flowsAuthorization: Bearer <jwt> from iam-serviceSubject must own session OR be tenant guest with matching email

No API surface accepts a tenant staff JWT (those land at bff-backoffice-service). Cross-surface tokens raise MELMASTOON.BFF.SURFACE_MISMATCH.

3. Tenant boundary

Every request resolves tenantSlugtenantId exactly once in TenantContextGuard; every downstream call carries that tenantId. Cross-tenant leakage attempts are detected at:

  1. Resolution layer — slug → tenantId is a strict map; ambiguous slug rejected (this happens only via DB error and is logged).
  2. Draft accessBookingDraft.tenantId !== request.tenantId raises MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE.
  3. Postgres RLSbooking_draft_snapshots and handoff_arrival_log have RLS tenant_id = current_setting('app.tenant_id').
  4. Cache key namespacing — every Memorystore key includes tenantId; collisions impossible by construction.

Test gate: tenant-isolation.spec.ts proves cross-tenant access fails at every layer (controller, application, infrastructure).

4. Handoff signature scheme (inbound from bff-consumer-service)

4.1 Token format

hf_v1.<base64url(canonical-payload-json)>.<base64url(hmac-sha256-signature)>

Canonical payload JSON: stable key order (alphabetical), no whitespace, charset UTF-8. version, keyId, nonce, consumerSessionId, tenantId, propertyId, checkIn, checkOut, occupancy, currency, locale, campaign?, mintedAt, expiresAt.

4.2 Verification

class HandoffVerifier {
verify(token: string): VerifiedHandoff {
const { keyId, payloadBytes, signatureBytes } = parseToken(token);
const key = this.keyRing.lookup(keyId);
if (!key) throw new HandoffSignatureInvalidError('unknown_key_id');
if (!constantTimeEqual(hmacSha256(key, payloadBytes), signatureBytes)) {
throw new HandoffSignatureInvalidError('mac_mismatch');
}
const payload = parseCanonical(payloadBytes);
if (payload.version !== 1) throw new HandoffSignatureInvalidError('version_mismatch');
if (now() > new Date(payload.expiresAt).getTime()) throw new HandoffExpiredError();
return { payload, keyId, signatureFingerprint: sha256Hex(signatureBytes) };
}
}
  • Constant-time MAC compare; reject all timing leaks.
  • keyRing holds current + previous keys with overlap window of 7 days for rotation safety.
  • nonce is enforced unique by the consume use case via handoff_arrival_log (PK on id derived from canonical payload + signing key id).

4.3 Replay protection

POST /handoff/consume writes a handoff_arrival_log row with consumed=true. A second consume returns MELMASTOON.BFF.TENANT.HANDOFF_REPLAYED. The Postgres unique constraint on the id is the source of truth.

4.4 Key rotation

StepWindow
New key (v_n+1) generated in Secret ManagerT0
Both consumer + tenant BFFs reload keyringwithin 30 s
Consumer mints with v_n+1T0 + 1 h
Tenant continues to verify v_n and v_n+1T0 → T0 + 7 d
Drop v_n from keyringT0 + 7 d

Rotation drill quarterly.

5. Payment redirect security

The redirect dance is the highest-risk surface:

  • Outbound URL: BFF stores the provider redirect URL on the draft only; never displays it raw to the user; the SPA does window.location.assign after a single-frame validation.
  • returnUrl: pinned to https://{tenantSlug}.melmastoon.ghasi.io/booking/return/{draftId} (or matching custom domain); never a query-string-controlled path.
  • return body: returnState is opaque-to-us; we forward to payment-gateway-service.verifyReturn. We never trust client-asserted success.
  • providerReference: stored on the draft; used as part of the upstream idempotency key for confirm.
  • Reservation confirm: only invoked after verifyReturn().success === true. Failed verification logs flow.error_encountered.v1 with errorCode = MELMASTOON.PAYMENT.DECLINED (or specific provider code).

tnt_id cookie:

  • HttpOnly
  • Secure (TLS-only)
  • SameSite=Lax (Phase 1; Phase 2 may upgrade to SameSite=Strict for sign-in flows)
  • Path=/api
  • Max-Age=2592000 (30 days)
  • Domain: scoped to subdomain (kabul-grand-hotel.melmastoon.ghasi.io) or custom domain; never wildcarded across tenants.

7. CSP

Per response, BFF injects:

Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-<nonce>' https://cdn.melmastoon.ghasi.io;
style-src 'self' 'nonce-<nonce>' https://cdn.melmastoon.ghasi.io;
img-src 'self' data: https://cdn.melmastoon.ghasi.io;
font-src 'self' https://cdn.melmastoon.ghasi.io;
connect-src 'self' https://api.melmastoon.ghasi.io;
frame-src https://*.adyen.com https://*.paypal.com https://*.<approved-provider>;
form-action 'self' https://*.adyen.com https://*.paypal.com;
frame-ancestors 'none';
base-uri 'self';
object-src 'none';
upgrade-insecure-requests;
report-uri https://csp.melmastoon.ghasi.io/report;

Per-tenant frame-src and form-action allow-lists are computed from tenant.config.paymentMethods[].providers[].

8. CORS

Strict per-environment allow-list (see API_CONTRACTS §17). Custom domains added per tenant via Terraform-managed list. Requests from non-allow-listed origins return 403 + MELMASTOON.BFF.SURFACE_MISMATCH with no body.

9. Secrets

SecretStorageRotation
bff-tenant-booking-handoff-hmac (current + previous)Secret Manager90 d, 7 d overlap
bff-tenant-booking-pepper (PII hashing)Secret Manager365 d, 30 d overlap
bff-tenant-booking-recaptcha-secret (reused from consumer)Secret Manager180 d
Payment provider API keysNOT here — held by payment-gateway-servicen/a
Service account keyNOT used — workload identity binding to bff-tenant-san/a

Mounted as files via Cloud Run Secret Manager integration. Never in env vars.

10. Rate limiting (defense layer)

Cloud Armor rules + application-layer limits (API_CONTRACTS §15). Bot mitigation falls back to reCAPTCHA Enterprise challenge (same provider as consumer BFF).

For the booking surface, the threat model emphasizes:

  • Inventory probing — repeated /availability for cancellation arbitrage; per-tenant per-IP limits + behavioural anomalies.
  • Quote-spam — per-session 30 quotes/min cap; quote-spam triggers MELMASTOON.GENERAL.RATE_LIMITED.
  • Hold-spam — 20 holds/hour/session; suspicious sessions get reCAPTCHA challenge before next hold.
  • Payment-intent abuse — 6 intents/hour/session; PSP fraud rules layered on top.

11. PII inventory

FieldCollected atStored in BFF?Storage formRetention
Guest first/last namePATCH /draft/{id}yes (Memorystore draft only, TTL 30 min) + cold mirror as initials hashplain in Memorystore; hash in Postgres snapshot30 min hot; 30 d snapshot
Emailsameyes (Memorystore) + hash in snapshotplain in Memorystore; sha256 in Postgressame
Phonesamesamesamesame
National ID numbersameyes (Memorystore) + hash in snapshotplain in Memorystore; sha256 in Postgressame
Special requestssameyes (Memorystore) + truncated 200-char in snapshotplain (Memorystore); truncated in Postgressame
Billing address (Phase 2)POST /draft/{id}/payment-intentpassed-through to gateway, NOT persisted in BFFn/an/a
IP, UAevery requesthashed (ipHash, fingerprintHash) in session blob and outbox envelopesha256 with peppersame as session

PII test (telemetry-pii.spec.ts) asserts no plain PII reaches Pub/Sub. PII export is supported via per-tenant operator endpoint in tenant-service (this BFF is not the source of truth).

12. GDPR + DSR

When tenant-service receives a DSR for a guest by email:

  1. The orchestrator hashes the email with the BFF pepper and queries booking_draft_snapshots for matching guest_email_hash.
  2. Matching rows are reported and (on erasure) tombstoned.
  3. The Memorystore draft TTL means hot drafts beyond 30 min are already gone.
  4. handoff_arrival_log rows are scoped by consumer_session_id and TTL'd in 30 days; DSR removal earlier on request.

13. Audit log

Sensitive operations write to Cloud Logging with structured payload:

EventLogger
Handoff consumedaudit.handoff.consumed
Handoff signature invalidaudit.handoff.signature_invalid
Handoff replayedaudit.handoff.replayed
Handoff tenant mismatchaudit.handoff.tenant_mismatch
Payment return verification failedaudit.payment.return_invalid
Cross-tenant reference attemptaudit.security.cross_tenant_attempt
Per-tenant rate-limit reachedaudit.security.rate_limited

Structured logs export to BigQuery via Log Router for the audit lake.

14. Threat model (top 8)

#ThreatMitigation
1Handoff token forgeryHMAC + key rotation + per-key-id verifier; constant-time compare
2Handoff replayhandoff_arrival_log PK + consumed=true; Postgres single-source of truth
3Cross-tenant draft readRLS + cache namespacing + tenant guard at every layer
4Payment redirect tampering (return URL)Pinned returnUrl; provider-side state opaque; server-side verifyReturn mandatory
5Quote / hold scrapingPer-session + per-IP limits; reCAPTCHA on anomalies; behavioural anomaly score
6XSS via tenant theme injectionTheme content sanitized at theme-config-service; CSP nonce on every script/style; no unsafe-inline
7PII exfiltration through telemetryAllow-list test on every event payload; pepper rotation; periodic synthetic PII probe
8Cookie hijack via subdomain takeoverCookie scoped to subdomain only; custom-domain TLS via GCLB; DNS CAA records prevent unauthorized cert issuance

15. Container hardening

  • Distroless Node 20 base image.
  • Non-root node user.
  • Read-only root filesystem.
  • No shell.
  • Trivy scan in CI; high/critical CVE blocks promotion.
  • Cosign signing with Fulcio identity; binary authorization in prod cluster verifies signature.

16. Compliance

  • PCI: out of scope; no PAN ever touches this BFF.
  • GDPR: in scope (EU users); see §12.
  • Sharia compliance: respected via passthrough complianceProfile to pricing-service; AI suggestions filtered (AI_INTEGRATION §12).
  • Local data-protection laws (AF, IR, etc.): per 07 §6.