Skip to main content

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-service only ever sees:
    • the processor-issued paymentId token,
    • the tenant-stored paymentMethodToken (an opaque processor reference for subscription billing — see payment-gateway-service),
    • the captured amount and currency.
  • 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.
  • Annual SAQ A review is owned by Finance + Security; the evidence pack is generated from the audit-service tail of billing.*.v1 and the payment-gateway-service evidence pack.

2. Tenant isolation (defense in depth)

Per ADR-0002 §2, guest folio data lives in tenant_<uuid>_billing schemas. Layers of enforcement:

LayerMechanism
1. Connection routingPgBouncer pool per tenant; search_path is set per logical connection from the request's X-Tenant-Id claim
2. Postgres roleservice-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 schemaevery table has ENABLE ROW LEVEL SECURITY with policy tenant_id = current_setting('app.tenant_id', true)
4. Application guardevery command and event handler asserts tenantId == ctx.tenantId; mismatch raises MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE
5. CI linta 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)

ScopeGranted to
billing.folio.readfront_desk, cashier, housekeeping_supervisor (read-only on own property)
billing.folio.writefront_desk, cashier
billing.folio.reopensupervisor, gm, tenant_admin (step-up required)
billing.invoice.readfront_desk, cashier, accountant
billing.invoice.sendfront_desk, accountant
billing.credit_note.writesupervisor, accountant (step-up required)
billing.cash_drawer.operatecashier, front_desk
billing.cash_drawer.closecashier (closing) and distinct `cashier
billing.cash_drawer.acknowledge_discrepancysupervisor, gm (step-up)
billing.ai.reviewsupervisor, gm
subscription.read (own tenant)tenant_admin
platform.subscription.readplatform finance_ops
platform.subscription.writeplatform 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:

  1. Caller invokes step-up endpoint at iam-service with the requested scope and TOTP / WebAuthn assertion.
  2. iam-service returns a signed stepUpToken (JWS) bound to: actorId, scope, nonce, aud=billing-service, exp (≤ 5 min), assertionHash.
  3. The caller passes stepUpToken in the request body for the gated endpoint.
  4. billing-service verifies the JWS signature against iam-service's key; verifies aud, exp, scope, and that actorId == ctx.actorId; consumes the token (single-use; nonce stored in Redis 5 min TTL).
  5. The verified stepUpTokenId and assertionHash are 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:

  • closingActor performs initiate-close (regular auth).
  • coSigner performs close with their own step-up token (different sub, scope billing.cash_drawer.close).
  • Server checks coSigner.sub != closingActor.sub and that both belong to the property's cashier|supervisor role group.
  • The session row stores both closed_by, co_signer, and step_up_token_id.
  • melmastoon.billing.cash_drawer.closed.v1 carries stepUpEvidence: { 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 restMechanism
Cloud SQL PostgresCMEK (Cloud KMS) per region; per-tenant column-level not used (schema isolation is the protection)
GCS invoice PDFsCMEK; 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 transitMechanism
Public ingressTLS 1.3 only; HSTS max-age=31536000; includeSubDomains; preload
Service-to-servicemTLS inside VPC; SPIFFE identities
Pub/SubTLS to brokers; per-topic IAM
Cloud SQLTLS 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 kind only — 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-noir redaction is configured for req.body.amountMicro, req.body.customer.*, res.body.*.amountMicro, and a deny-list of header names.
  • Secrets in env are blocked by pino-noir paths; CI lint refuses any console.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

SecretWhereRotation
Postgres connection (per-tenant pool)Secret Manager → Workload Identity at boot90 d
JWS verification key (iam)JWKS endpoint, cached 10 min30 d
Per-tenant actor-hash HMAC key (AI inputs)Secret Manager (one per tenant)90 d
Pub/Sub publisher credsWorkload Identityn/a
GCS signing key for PDFsWorkload Identity90 d

10. Threat model (top items)

ThreatMitigation
Cross-tenant data leak via misrouted connectionSchema-per-tenant + RLS + application guard + CI lint (4 layers)
Cardholder data accidentally enters our storeArchitectural: 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 circumventedCryptographic step-up token + coSigner != closingActor check + audit row + cash_drawer.discrepancy_found.v1 analytics
Insider posts a fake refund to drain a folioRLS + billing.credit_note.write + step-up + AI fraud signal + nightly reconciliation against payment-gateway-service records
Invoice number sequence tamperingDB 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 collusionTwo-staff sign-off + AI cash pattern detector + GM nightly summary + per-actor variance trend
Subscription dunning bypassedDunning state machine in domain layer + suspended state propagated to tenant-service (which gates platform access)
Replay of cash-session close on reconnectStable cds_ ULID + idempotency-key + nonce-bound step-up token consumed on first use
Outbox tamperingOutbox 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 issuePDF 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:

  1. The per-tenant schema is renamed tenant_<uuid>_billing__archived_<yyyymmdd> and made read-only.
  2. After 90 days the schema is dropped (Cloud SQL DDL); the tenant's GCS invoice bucket is deleted.
  3. Subscription rows in billing_central.subscriptions, subscription_invoices, and usage_records for the tenant are kept for 7 years for our regulatory record (we are the platform; this is our own bookkeeping). PII is scrubbed from customer JSON in subscription invoices on Day 90.
  4. payment-gateway-service independently revokes the tenant's paymentMethodToken per 07 §11.

12. Cross-references