SECURITY_MODEL — bff-backoffice-service
Sibling: API_CONTRACTS · APPLICATION_LOGIC · SYNC_CONTRACT · DEPLOYMENT_TOPOLOGY
Cross-cutting: 07 Security/Compliance/Tenancy · ADR-0003 Electron offline-first desktop
1. Posture
This BFF is the cloud-side counterpart to a device-installed line-of-business application that holds physical-world authority (issue room keys, post folio charges, change room status, decide AI suggestions). The threat model concentrates on:
- Device theft / token theft — DPoP (proof-of-possession) on every request.
- Insider abuse — full audit envelope on every mutation; lock revocations gated by MFA step-up; activity ledger 7-y retained.
- Cross-tenant access — JWT subject + Postgres RLS + Memorystore namespacing.
- Sync-protocol abuse — handshake mints time-boxed signed token; cursor advance audited.
- Schema drift / replay — Zod parsers, idempotency keys, audit log.
It is not the source of truth for authentication or authorization (that's iam-service); it enforces device-bound posture, MFA gates, audit, and rate limits.
2. AuthN / AuthZ
| Path | AuthN | AuthZ |
|---|---|---|
/auth/refresh | Refresh token + DPoP | Device-bound; iam-service decision |
/auth/sign-out | Bearer + DPoP | Self-only |
/auth/mfa/step-up | Bearer + DPoP | Operator self |
/dashboard, /today, etc. | Bearer + DPoP | Operator scope on requested propertyId |
/locks/*/issue-key | Bearer + DPoP | Property-scoped + role-gated |
/locks/*/revoke-key | Bearer + DPoP + valid MfaAttestation | Property-scoped + role-gated |
/reservations/*/check-in etc. | Bearer + DPoP | Reservation-service final say; BFF passes role context |
/folios/*/charges | Bearer + DPoP; large amounts gated | Property-scoped |
/sync/handshake | Bearer + DPoP | Device-bound to JWT |
/sse/stream | Bearer + DPoP | Per-device single connection |
/health/* | Open | n/a |
/internal/jobs/* | Cloud Scheduler OIDC | Scheduler SA only |
All cross-tenant boundary checks happen at:
- JWT subject claim (operator + tenant resolved from
iam-service) - Property scope in JWT or resolved from
tenant-service.operatorRole - Postgres RLS (
tenant_idpredicate on every owned table) - Memorystore key namespacing (
{tenantId}:prefix)
tenant-isolation.spec.ts proves cross-tenant attempts fail at every layer.
3. Device-bound JWT (DPoP)
Per RFC 9449.
3.1 Token shape
{
"iss": "https://iam.melmastoon.ghasi.io",
"aud": "bff-backoffice-service",
"sub": "opr_01H8Y...",
"tnt": "tnt_01H8Y...",
"rol": ["front_desk_supervisor"],
"psc": ["prop_01H8Y..."],
"cnf": { "jkt": "<base64url SHA-256 of device public key>", "kid": "dev_01H8Y..." },
"iat": 1714032862,
"exp": 1714033762,
"jti": "tk_..."
}
15 min TTL.
3.2 DPoP proof
Per request, the desktop signs a single-use DPoP JWT with the device private key. BFF verifies:
- Signature against the public key embedded in the proof header.
- SHA-256(public key) matches
cnf.jktof the access token. htmmatches the request method.htumatches the request URL (without query string).iatwithin ±60 s of server time.jtinot seen in the last 5 min (Memorystore single-use cache).ath(access token hash) matches SHA-256 of the access token (when present).
Failure → MELMASTOON.IAM.DPOP_INVALID.
3.3 Refresh
Refresh token is a long-lived (30 d) opaque token bound to the same device key (proof-of-possession again on /auth/refresh). Stolen refresh token without device key is unusable.
4. MFA step-up
Sensitive scopes:
| Scope | Required factor |
|---|---|
lock_revoke | Operator's primary MFA (TOTP / WebAuthn / SMS) |
large_folio_adjust | Same; threshold per tenant config |
refund | Same |
force_checkout | Same |
device_unenroll | WebAuthn preferred; TOTP acceptable |
Flow:
desktop ── POST /auth/mfa/step-up { scope, factor, code } ──► BFF
│ │── proxy iam-service.attestStepUp
│◄────── { attestationToken, expiresAt: now+5min, scope } ──│
│
├── perform sensitive action with mfaAttestationToken header
▼
BFF ── consume(mfaAttestationToken) ── single-use, 5 min TTL
│── proceed with sensitive call (e.g., lock revoke)
│── audit log includes mfa_attestation_id
Replay → MELMASTOON.BFF.BACKOFFICE.MFA_INVALID_OR_USED.
5. Tenant + property boundary
Every request resolves:
tenantIdfrom JWTtnt.operatorIdfrom JWTsub.propertyScopefrom JWTpsc(cached) ortenant-service.operatorRolelookup.requestedPropertyIdfrom header / query.- Reject with
MELMASTOON.BFF.BACKOFFICE.PROPERTY_OUT_OF_SCOPEifrequestedPropertyId ∉ propertyScope. - Set
app.tenant_idon Postgres connection for RLS. - Cache keys include
tenantId.
6. Audit envelope
Every mutation proxy and lock-action proxy carries:
"_audit": {
"tenantId": "tnt_...",
"operatorId": "opr_...",
"deviceId": "dev_...",
"sessionId": "bos_...",
"requestId": "req_...",
"traceId": "00-...",
"appVersion": "1.4.2",
"appPlatform": "win32",
"mfaAttestationId": null,
"emittedAt": "..."
}
Plus X-Audit-* request headers carrying the same values to upstream services that prefer headers.
7. Activity ledger
bff_backoffice.operator_activity rows are immutable; cannot be deleted via API. Retention 90 d hot, 7 y cold (BigQuery export). Used for:
- Investigations (incident replay).
- Insider abuse detection (anomalous action volume; off-hours mutations).
- Compliance audits (operator-by-operator activity reports).
- AI training feedback loops (operator override patterns).
8. Lock-action proxy controls
Lock issue/revoke is the most physically consequential action. Controls:
- Operator must have
front_deskorfront_desk_supervisorrole (or per-tenant equivalent). - Operator's property scope must include the target property.
- Revocation requires fresh MFA attestation.
- Per-device rate limit (30 lock actions / hour); abuse triggers
MELMASTOON.GENERAL.RATE_LIMITED. - Audit row written before vendor call; outcome updated after.
- Failures escalate via
melmastoon.alert.raised.v1withcategory=lock_failed. - 7-y retention on
lock_action_proxy_audit; never deletable.
9. CSP + Electron renderer hardening
Per ADR-0003 §3, the Electron renderer is sandboxed (nodeIntegration: false, contextIsolation: true, sandbox: true). The renderer reaches the BFF only through the contextBridge in main process; the BFF never serves any HTML to the Electron renderer.
For the debug build (web dev tools), the BFF returns:
Content-Security-Policy:
default-src 'none';
connect-src 'self' https://api.melmastoon.ghasi.io https://stage-backoffice.melmastoon.ghasi.io;
frame-ancestors 'none';
base-uri 'none';
object-src 'none';
upgrade-insecure-requests;
10. CORS
| Allowed origin | Environment |
|---|---|
app://melmastoon-backoffice | Electron production |
http://localhost:5173 | Dev renderer |
https://stage-backoffice-debug.melmastoon.ghasi.io | Debug stage |
All others 403. Mutating endpoints additionally require Origin header to be on allow-list.
11. Secrets
| Secret | Storage | Rotation |
|---|---|---|
bff-backoffice-pepper (PII hashing) | Secret Manager | 365 d, 30 d overlap |
bff-backoffice-sse-signing-key (SSE event signature optional) | Secret Manager | 180 d |
| Service account key | NOT used; workload identity to bff-backoffice-sa | n/a |
| iam-service JWKS endpoint | Public (per JWT spec) | n/a |
DPoP keys live on the device via keytar (OS keychain); never in this BFF.
12. Rate limiting
| Scope | Limit |
|---|---|
| Per device per endpoint family | 60 / min reads, 30 / min mutations |
/devices/*/heartbeat | 1 / min |
/auth/refresh | 5 / 15 min / device |
/auth/mfa/step-up | 10 / hour / device |
/locks/* | 30 / hour / device |
/sse/stream | 1 active connection / device |
/sync/handshake | 12 / hour / device |
429 carries Retry-After. Cloud Armor adds IP-level rules atop these for DDoS resilience.
13. PII inventory
| Field | Stored? | Form | Retention |
|---|---|---|---|
| Operator name | No (BFF) | n/a | n/a |
| Operator email | No | n/a | n/a |
| Guest name in API responses | yes (transient; not persisted) | resolved on dashboard render | 0 (response only) |
| Notes (alert ack, AI decision) | yes (truncated) | 200-char truncation | 365 d / 7 y |
| IP, UA | hashed | sha256 with pepper | session-bound |
| Device fingerprint | hashed | sha256 with pepper | session-bound |
telemetry-pii.spec.ts asserts no plain PII appears in any published event. Audit lake rows reference only resource ids (no PII).
14. Threat model (top 10)
| # | Threat | Mitigation |
|---|---|---|
| 1 | Stolen access token replay | DPoP single-use; cnf.jkt thumbprint check |
| 2 | Stolen refresh token | DPoP on refresh; device-bound |
| 3 | Compromised device key | OS keychain isolation; per-device revocation; alert on key-material-not-attested |
| 4 | Cross-tenant cache key collision | Tenant-scoped keys; tenant-isolation.spec.ts |
| 5 | Insider lock-revoke abuse | MFA gate; full audit; activity-ledger anomaly detector |
| 6 | Insider folio adjustment fraud | MFA gate above threshold; audit; nightly reconciliation |
| 7 | Schema drift from upstream | Zod parsers; SCHEMA_DRIFT alert; per-PR contract tests |
| 8 | SSE connection abuse (long-poll DDoS) | Per-device 1-conn cap; idle-timeout; Cloud Armor rules |
| 9 | Idempotency key reuse with different bodies | request-hash check; PRECONDITION_FAILED |
| 10 | Trace / log PII exfiltration | log filter + nightly synthetic probe; audit lake export uses ids only |
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.
16. Compliance
- PCI: out of scope (no PAN here).
- GDPR: in scope; operator PII via
tenant-service; we hold hashed identifiers only. - Sharia compliance respected via
complianceProfilepropagation. - Audit retention 7 y for: lock actions, AI decisions, alert acks; satisfies most regulators.
- BAA / DPA: tenant-by-tenant; this BFF is on the in-scope list for tenant agreements.
17. Penetration test
Annual pen test by external firm. Specific surfaces tested:
- DPoP replay
- MFA bypass
- Cross-tenant via header injection
- SSE flood
- Lock-revoke without MFA
- Idempotency key bypass
- Refresh token theft simulation
Findings tracked in services/bff-backoffice-service/_pentest/<year>.md.