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
| Path | AuthN | AuthZ |
|---|---|---|
All /bff/tenant-booking/v1/{tenantSlug}/* (Phase 1) | Anonymous; tnt_id cookie minted on bootstrap | Implicit by tenant resolution; per-tenant rate-limit |
/internal/handoff/{id}/consume | Mutual TLS + Google ID token + X-Internal-HMAC header | Caller must present bff-consumer-service SA principal |
/health/* | Open | None |
/internal/jobs/* | Cloud Scheduler OIDC | Scheduler SA only |
| Phase 2 authenticated guest flows | Authorization: Bearer <jwt> from iam-service | Subject 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 tenantSlug → tenantId exactly once in TenantContextGuard; every downstream call carries that tenantId. Cross-tenant leakage attempts are detected at:
- Resolution layer — slug → tenantId is a strict map; ambiguous slug rejected (this happens only via DB error and is logged).
- Draft access —
BookingDraft.tenantId !== request.tenantIdraisesMELMASTOON.GENERAL.CROSS_TENANT_REFERENCE. - Postgres RLS —
booking_draft_snapshotsandhandoff_arrival_loghave RLStenant_id = current_setting('app.tenant_id'). - 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.
keyRingholdscurrent+previouskeys with overlap window of 7 days for rotation safety.nonceis enforced unique by the consume use case viahandoff_arrival_log(PK onidderived 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
| Step | Window |
|---|---|
New key (v_n+1) generated in Secret Manager | T0 |
| Both consumer + tenant BFFs reload keyring | within 30 s |
Consumer mints with v_n+1 | T0 + 1 h |
Tenant continues to verify v_n and v_n+1 | T0 → T0 + 7 d |
Drop v_n from keyring | T0 + 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.assignafter a single-frame validation. returnUrl: pinned tohttps://{tenantSlug}.melmastoon.ghasi.io/booking/return/{draftId}(or matching custom domain); never a query-string-controlled path.returnbody:returnStateis opaque-to-us; we forward topayment-gateway-service.verifyReturn. We never trust client-assertedsuccess.providerReference: stored on the draft; used as part of the upstream idempotency key forconfirm.- Reservation confirm: only invoked after
verifyReturn().success === true. Failed verification logsflow.error_encountered.v1witherrorCode = MELMASTOON.PAYMENT.DECLINED(or specific provider code).
6. Cookie security
tnt_id cookie:
HttpOnlySecure(TLS-only)SameSite=Lax(Phase 1; Phase 2 may upgrade toSameSite=Strictfor sign-in flows)Path=/apiMax-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
| Secret | Storage | Rotation |
|---|---|---|
bff-tenant-booking-handoff-hmac (current + previous) | Secret Manager | 90 d, 7 d overlap |
bff-tenant-booking-pepper (PII hashing) | Secret Manager | 365 d, 30 d overlap |
bff-tenant-booking-recaptcha-secret (reused from consumer) | Secret Manager | 180 d |
| Payment provider API keys | NOT here — held by payment-gateway-service | n/a |
| Service account key | NOT used — workload identity binding to bff-tenant-sa | n/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
/availabilityfor 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
| Field | Collected at | Stored in BFF? | Storage form | Retention |
|---|---|---|---|---|
| Guest first/last name | PATCH /draft/{id} | yes (Memorystore draft only, TTL 30 min) + cold mirror as initials hash | plain in Memorystore; hash in Postgres snapshot | 30 min hot; 30 d snapshot |
| same | yes (Memorystore) + hash in snapshot | plain in Memorystore; sha256 in Postgres | same | |
| Phone | same | same | same | same |
| National ID number | same | yes (Memorystore) + hash in snapshot | plain in Memorystore; sha256 in Postgres | same |
| Special requests | same | yes (Memorystore) + truncated 200-char in snapshot | plain (Memorystore); truncated in Postgres | same |
| Billing address (Phase 2) | POST /draft/{id}/payment-intent | passed-through to gateway, NOT persisted in BFF | n/a | n/a |
| IP, UA | every request | hashed (ipHash, fingerprintHash) in session blob and outbox envelope | sha256 with pepper | same 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:
- The orchestrator hashes the email with the BFF pepper and queries
booking_draft_snapshotsfor matchingguest_email_hash. - Matching rows are reported and (on erasure) tombstoned.
- The Memorystore draft TTL means hot drafts beyond 30 min are already gone.
handoff_arrival_logrows are scoped byconsumer_session_idand TTL'd in 30 days; DSR removal earlier on request.
13. Audit log
Sensitive operations write to Cloud Logging with structured payload:
| Event | Logger |
|---|---|
| Handoff consumed | audit.handoff.consumed |
| Handoff signature invalid | audit.handoff.signature_invalid |
| Handoff replayed | audit.handoff.replayed |
| Handoff tenant mismatch | audit.handoff.tenant_mismatch |
| Payment return verification failed | audit.payment.return_invalid |
| Cross-tenant reference attempt | audit.security.cross_tenant_attempt |
| Per-tenant rate-limit reached | audit.security.rate_limited |
Structured logs export to BigQuery via Log Router for the audit lake.
14. Threat model (top 8)
| # | Threat | Mitigation |
|---|---|---|
| 1 | Handoff token forgery | HMAC + key rotation + per-key-id verifier; constant-time compare |
| 2 | Handoff replay | handoff_arrival_log PK + consumed=true; Postgres single-source of truth |
| 3 | Cross-tenant draft read | RLS + cache namespacing + tenant guard at every layer |
| 4 | Payment redirect tampering (return URL) | Pinned returnUrl; provider-side state opaque; server-side verifyReturn mandatory |
| 5 | Quote / hold scraping | Per-session + per-IP limits; reCAPTCHA on anomalies; behavioural anomaly score |
| 6 | XSS via tenant theme injection | Theme content sanitized at theme-config-service; CSP nonce on every script/style; no unsafe-inline |
| 7 | PII exfiltration through telemetry | Allow-list test on every event payload; pepper rotation; periodic synthetic PII probe |
| 8 | Cookie hijack via subdomain takeover | Cookie 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
nodeuser. - 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
complianceProfiletopricing-service; AI suggestions filtered (AI_INTEGRATION §12). - Local data-protection laws (AF, IR, etc.): per 07 §6.