SECURITY_MODEL — billing-service
Conforms to 07 Security, Compliance & Tenancy and ADR-0002 Multi-Tenancy. The billing service holds the most regulated state in the platform and the highest impact-of-leak surface (financial ledgers, invoice PDFs, cash drawer history). PCI scope is targeted at SAQ A by routing every cardholder data interaction through
payment-gateway-service's tokenized iframe / SDK; we never receive PAN, never log it, and never persist it.
1. PCI DSS posture
- Target: PCI DSS v4.0 SAQ A.
- How: All card capture happens inside the processor-hosted iframe / SDK rendered in our frontends.
billing-serviceonly ever sees:- the processor-issued
paymentIdtoken, - the tenant-stored
paymentMethodToken(an opaque processor reference for subscription billing — seepayment-gateway-service), - the captured amount and currency.
- the processor-issued
- What we explicitly forbid:
- storing or logging PAN, CVV, full track data, or expiry beyond
MM/YY-on-token-metadata supplied by the processor; - creating any column named
pan*,cvv*,cardholder*,card_number*(CI-gated); - returning processor raw responses to clients.
- storing or logging PAN, CVV, full track data, or expiry beyond
- Annual SAQ A review is owned by Finance + Security; the evidence pack is generated from the audit-service tail of
billing.*.v1and thepayment-gateway-serviceevidence pack.
2. Tenant isolation (defense in depth)
Per ADR-0002 §2, guest folio data lives in tenant_<uuid>_billing schemas. Layers of enforcement:
| Layer | Mechanism |
|---|---|
| 1. Connection routing | PgBouncer pool per tenant; search_path is set per logical connection from the request's X-Tenant-Id claim |
| 2. Postgres role | service-account role billing_app has USAGE on every per-tenant schema but cannot read across schemas via LIKE patterns; per-schema GRANT lists are explicit |
| 3. RLS inside each schema | every table has ENABLE ROW LEVEL SECURITY with policy tenant_id = current_setting('app.tenant_id', true) |
| 4. Application guard | every command and event handler asserts tenantId == ctx.tenantId; mismatch raises MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE |
| 5. CI lint | a custom rule rejects raw db.execute("SELECT … FROM tenant_…") strings outside of the TenantSchemaResolver |
Subscription data lives in the central billing_central schema. Cross-tenant queries there are gated by platform.* scopes only — no user-tenant token can reach those rows.
3. AuthN
- All requests carry an OAuth2 access token issued by
iam-service(per 07 §3). - Tokens carry:
sub(actorId),tenantId,propertyIds[],scopes[],auth_strength(pwd|mfa|step_up),iat,exp. - Token validation: JWKS rotation every 30 days; cached for 10 minutes with proactive refresh.
- Service-to-service calls (e.g.,
payment-gateway-service.refund) use Workload Identity Federation; mTLS within the GCP VPC.
4. AuthZ (RBAC + scopes)
| Scope | Granted to |
|---|---|
billing.folio.read | front_desk, cashier, housekeeping_supervisor (read-only on own property) |
billing.folio.write | front_desk, cashier |
billing.folio.reopen | supervisor, gm, tenant_admin (step-up required) |
billing.invoice.read | front_desk, cashier, accountant |
billing.invoice.send | front_desk, accountant |
billing.credit_note.write | supervisor, accountant (step-up required) |
billing.cash_drawer.operate | cashier, front_desk |
billing.cash_drawer.close | cashier (closing) and distinct `cashier |
billing.cash_drawer.acknowledge_discrepancy | supervisor, gm (step-up) |
billing.ai.review | supervisor, gm |
subscription.read (own tenant) | tenant_admin |
platform.subscription.read | platform finance_ops |
platform.subscription.write | platform finance_ops (step-up) |
Role-to-scope mapping is owned by iam-service; this service consumes scopes and never assumes role names.
5. Step-up authentication (cryptographic)
Step-up authentication is required for high-impact actions. The flow:
- Caller invokes step-up endpoint at
iam-servicewith the requested scope and TOTP / WebAuthn assertion. iam-servicereturns a signedstepUpToken(JWS) bound to:actorId,scope,nonce,aud=billing-service,exp(≤ 5 min),assertionHash.- The caller passes
stepUpTokenin the request body for the gated endpoint. billing-serviceverifies the JWS signature againstiam-service's key; verifiesaud,exp,scope, and thatactorId == ctx.actorId; consumes the token (single-use; nonce stored in Redis 5 min TTL).- The verified
stepUpTokenIdandassertionHashare recorded on the action's audit row (e.g.,cash_drawer_sessions.step_up_token_id).
This is how the cash drawer two-staff sign-off is cryptographically enforced:
closingActorperformsinitiate-close(regular auth).coSignerperformsclosewith their own step-up token (differentsub, scopebilling.cash_drawer.close).- Server checks
coSigner.sub != closingActor.suband that both belong to the property'scashier|supervisorrole group. - The session row stores both
closed_by,co_signer, andstep_up_token_id. melmastoon.billing.cash_drawer.closed.v1carriesstepUpEvidence: { stepUpTokenId, method }for downstream audit.
A replay of the same stepUpToken is rejected on the second use (nonce consumed). Offline attempts are blocked at the connectivity check; the token format itself is server-issued so the desktop cannot forge one.
6. Encryption
| At rest | Mechanism |
|---|---|
| Cloud SQL Postgres | CMEK (Cloud KMS) per region; per-tenant column-level not used (schema isolation is the protection) |
| GCS invoice PDFs | CMEK; per-tenant bucket; signed URLs short-TTL (5 min) |
| Secret Manager (DB creds, JWS keys, hashing keys) | Google-managed envelope keys |
| Electron local DB (SQLCipher) | AES-256, key derived from hardware-bound secret per 07 §6.3 |
| In transit | Mechanism |
|---|---|
| Public ingress | TLS 1.3 only; HSTS max-age=31536000; includeSubDomains; preload |
| Service-to-service | mTLS inside VPC; SPIFFE identities |
| Pub/Sub | TLS to brokers; per-topic IAM |
| Cloud SQL | TLS required; client cert |
7. Logging & PII redaction
- Structured JSON logs to Cloud Logging with fields:
traceId,spanId,tenantId,actorId,route,latencyMs,outcome. - No money values (we log
amountClass: 'small|medium|large'instead). - No customer PII (names, emails, phones).
- Charge descriptions are logged as
kindonly — the localized strings can carry sensitive context (e.g., guest meal preferences) and are skipped. - Stack traces are redacted to drop any payload bodies;
pino-noirredaction is configured forreq.body.amountMicro,req.body.customer.*,res.body.*.amountMicro, and a deny-list of header names. - Secrets in env are blocked by
pino-noirpaths; CI lint refuses anyconsole.log-style in production code.
8. Audit trail
Every state transition emits an event captured by audit-service:
- folio open / charge / payment / refund / close / reopen,
- invoice generated / sent / voided,
- credit note generated,
- cash drawer opened / closed / discrepancy / acknowledged,
- subscription state transitions,
- AI signal review resolutions,
- supervisor overrides (with
reason).
Audit retention: 7 years for financial events. The audit topic is the long-term tail; the per-service _outbox is pruned at 30 days.
9. Secrets management
| Secret | Where | Rotation |
|---|---|---|
| Postgres connection (per-tenant pool) | Secret Manager → Workload Identity at boot | 90 d |
| JWS verification key (iam) | JWKS endpoint, cached 10 min | 30 d |
| Per-tenant actor-hash HMAC key (AI inputs) | Secret Manager (one per tenant) | 90 d |
| Pub/Sub publisher creds | Workload Identity | n/a |
| GCS signing key for PDFs | Workload Identity | 90 d |
10. Threat model (top items)
| Threat | Mitigation |
|---|---|
| Cross-tenant data leak via misrouted connection | Schema-per-tenant + RLS + application guard + CI lint (4 layers) |
| Cardholder data accidentally enters our store | Architectural: PAN never leaves the processor iframe; CI guard rejects PAN-shaped column names; runtime DLP scan in audit-service flags rows whose payloads match Luhn-passing 13-19 digit strings |
| Two-staff sign-off circumvented | Cryptographic step-up token + coSigner != closingActor check + audit row + cash_drawer.discrepancy_found.v1 analytics |
| Insider posts a fake refund to drain a folio | RLS + billing.credit_note.write + step-up + AI fraud signal + nightly reconciliation against payment-gateway-service records |
| Invoice number sequence tampering | DB unique constraint + monotonic per-tenant per-jurisdiction sequence stored server-side; PDF embeds a hash of the invoice payload signed by the service's per-tenant key |
| Cash variance under-reported by collusion | Two-staff sign-off + AI cash pattern detector + GM nightly summary + per-actor variance trend |
| Subscription dunning bypassed | Dunning state machine in domain layer + suspended state propagated to tenant-service (which gates platform access) |
| Replay of cash-session close on reconnect | Stable cds_ ULID + idempotency-key + nonce-bound step-up token consumed on first use |
| Outbox tampering | Outbox writes are inside the same DB transaction as state writes; an attacker who can write the outbox can write the state, and the audit chain records both |
| PDF tampering after issue | PDF rendered server-side; URL signed; PDF metadata embeds invoiceNumber + sha256(payload); verifier endpoint at /api/v1/invoices/:id/verify?hash=… |
11. Per-tenant data deletion (DSAR / offboarding)
On tenant.deleted.v1:
- The per-tenant schema is renamed
tenant_<uuid>_billing__archived_<yyyymmdd>and made read-only. - After 90 days the schema is dropped (Cloud SQL DDL); the tenant's GCS invoice bucket is deleted.
- Subscription rows in
billing_central.subscriptions,subscription_invoices, andusage_recordsfor the tenant are kept for 7 years for our regulatory record (we are the platform; this is our own bookkeeping). PII is scrubbed fromcustomerJSON in subscription invoices on Day 90. payment-gateway-serviceindependently revokes the tenant'spaymentMethodTokenper 07 §11.
12. Cross-references
- Platform security & compliance: 07.
- Multi-tenancy model: ADR-0002.
- Cash workflows: 10 Payments §7.
- Sync & local data security: SYNC_CONTRACT §10.
- AI provenance & PII rules: AI_INTEGRATION §6,§8.