housekeeping-service — SECURITY_MODEL
Tenant isolation by Postgres RLS; AuthN by
iam-serviceJWTs; AuthZ by role-based policy at the API edge plus invariant-based checks in the domain. Pub/Sub push subscriptions verified via OIDC. Aligned withdocs/07-security-compliance-tenancy.md.
This document is the binding security spec for housekeeping-service. Any deviation requires an ADR.
1. Identity & authentication
| Caller | AuthN | Notes |
|---|---|---|
| Electron desktop user (supervisor, housekeeper, property manager, owner) | Bearer JWT signed by iam-service | Carries tenant_id, staff_id, role[], properties[], iat, exp (15 min). |
| Tenant booking surface (mid-stay request) | Bearer JWT (guest-scoped, downgraded) | Limited to POST /tasks with kind=mid_stay_clean for the guest's own active reservation. |
| Pub/Sub push | OIDC token issued to service account housekeeping-events@<project>.iam.gserviceaccount.com | Verified against Google JWKS; audience must match the push URL. |
| Internal cron callers (Cloud Run Jobs) | Workload identity via service account housekeeping-cron@<project>.iam.gserviceaccount.com | Calls /internal/cron/* over the internal load balancer only. |
JWT validation at the gateway and re-validated in this service (defence-in-depth). Clock skew tolerance ±60 s. Replay protection via jti cache in Redis (5 min).
2. Tenant isolation
- Every table has a
tenant_idcolumn. RLS policies (seeDATA_MODEL.md) enforceUSING (tenant_id = current_tenant_id())andWITH CHECK (...)on every read and write. - The application sets
app.tenant_idper connection at the start of every request via a PostgresSET LOCAL app.tenant_id = $1inside theUnitOfWork.begin(). - Cross-tenant leak is blocked at three layers: gateway
X-Tenant-Id↔ JWT match, applicationTenantContextpropagation, and Postgres RLS. Removing any one still leaves the others; thetests/integration/tenant-isolation.spec.tssuite asserts each layer.
3. Authorization matrix (role × action)
Roles, in increasing power: housekeeper < housekeeping_supervisor < property_manager < owner < tenant_admin. Optional flags: inspector (additive). The booking surface uses a guest pseudo-role.
| Endpoint / action | housekeeper | supervisor | prop_mgr | owner | tenant_admin | guest |
|---|---|---|---|---|---|---|
GET /board | own property | yes | yes | yes | yes | – |
GET /tasks/:id | own assigned + own property | yes | yes | yes | yes | – |
POST /tasks (turnover, deep_clean, …) | – | yes | yes | yes | yes | – |
POST /tasks (mid_stay_clean for own active reservation) | – | yes | yes | yes | yes | yes (via BFF) |
POST /tasks/:id/assign | self only (claim flow) | yes | yes | yes | yes | – |
| `POST /tasks/:id/start | pause | resume` | own task | yes | yes | yes |
POST /tasks/:id/complete | own task | yes | yes | yes | yes | – |
POST /tasks/:id/fail | own task | yes | yes | yes | yes | – |
POST /tasks/:id/escalate | own task | yes | yes | yes | yes | – |
POST /tasks/:id/maintenance-required | own task | yes | yes | yes | yes | – |
PATCH /tasks/:id/priority | – | yes | yes | yes | yes | – |
POST /rooms/:id/status (manual flip) | – | reason required | yes | yes | yes | – |
POST /rooms/:id/block | – | yes | yes | yes | yes | – |
POST /checklists | – | – | yes | yes | yes | – |
POST /lost-and-found | yes | yes | yes | yes | yes | – |
| `POST /lost-and-found/:id/match | dispose` | – | yes | yes | yes | yes |
| `POST /linen/*/issue | return` | own task | yes | yes | yes | yes |
POST /tasks/:id/inspections | inspector flag required | inspector flag required | yes | yes | yes | – |
GET /stats/turnover | – | yes | yes | yes | yes | – |
Enforcement: @Roles() decorator at the controller (presentation), plus invariant checks in domain (e.g., aggregate refuses an assign to a staff member outside the tenant's staff set).
4. Object-level access
Beyond role checks:
- A
housekeepercan only act on tasks whereassignee_staff_id = JWT.staff_idOR they hold a self-claim from the open queue. - A
housekeeping_supervisoris scoped toproperties[]from the JWT. - A
property_managerand above are scoped to their assigned properties. - A
tenant_admincan act on all properties of the tenant.
Object-level checks are run in the use case (not only the controller), so event-driven and sync-driven calls are equally enforced.
5. Data classification
| Class | Examples in this service | Treatment |
|---|---|---|
| Public | Aggregate counts, room numbers (without status) | Cacheable |
| Internal | Tasks, statuses, checklists | RLS, JWT-scoped |
| Sensitive | Lost-and-found descriptions, photos | Photos via signed URLs only; descriptions logged with redaction |
| PII (limited) | Claimant name & phone for matched lost items | Logged with redaction; access audited; retention per tenant policy |
| PCI | None | Not handled here |
| PHI | None | Not handled here |
Photos are stored by media-service in GCS with object lifecycle (90 days warm, archive after) and are referenced here only by mediaId. Signed URLs minted on demand with 5-minute TTLs.
6. Encryption
- At rest: Cloud SQL CMEK; backups CMEK; SQLite on Electron desktop encrypted via OS keychain (DPAPI on Windows, Keychain on macOS, libsecret on Linux).
- In transit: TLS 1.3 everywhere (HTTPS, Cloud SQL, Pub/Sub). Internal-only endpoints additionally require mTLS via the internal load balancer.
- Field-level: none required (no card data, no health data).
7. Secrets
All in Google Secret Manager, mounted as files at runtime:
- DB connection (host, user, password)
- JWT public key set (rotation: weekly)
- Pub/Sub OIDC audience and SA email
- Routing port API token (for orchestrator)
Service code uses @melmastoon/shared-secrets to load and watch for rotation. No secrets in env vars longer-term, no secrets in logs (enforced by redactor.middleware.ts).
8. Logging redaction
Allowlist-based: only the field set in log-redactor.allowlist.ts is permitted out. Lost-and-found description, claimant name/phone, and notes are masked unless the log level is DEBUG and LOG_REDACT=off is set in non-production environments.
9. Pub/Sub trust
- Push subscriptions hit
https://housekeeping.<env>.melmastoon.com/internal/events/<topic-short>over HTTPS. - We verify the OIDC token: signature against Google JWKS,
aud == push URL,email == housekeeping-events@<project>.iam.gserviceaccount.com. - Replay protection via inbox
(topic, message_id). - DLQ
melmastoon.dlq.housekeeping; on-call rotates ownership monthly.
10. Audit & compliance
- Every state-changing use case writes to
audit_events(local) and emits the corresponding event (whichaudit-servicearchives off-platform). - Manual room-status overrides require
reasonand are tagged in audit withcause="manual_override". - Quarterly DPIA review owned by Operations + Compliance; documented in
docs/07-security-compliance-tenancy.md.
11. Threat model (top 10, condensed)
| # | Threat | Control |
|---|---|---|
| T1 | Cross-tenant read/write | RLS + JWT match + tests |
| T2 | Spoofed Pub/Sub push | OIDC verification |
| T3 | Replay of mutating REST | Idempotency-Key, jti cache |
| T4 | Privilege escalation via direct DB | DB user has no BYPASSRLS; admin actions audited |
| T5 | Lost-and-found PII leak | Allowlist redaction; signed URLs for photos |
| T6 | Routing-suggestion misuse (auto-apply) | HITL gate default supervisor_approval; per-suggestion audit |
| T7 | Sync conflict abuse (older write wins) | Per-field policy + version check + audit on conflict |
| T8 | DoS via large checklists / linen ops | Payload size caps; rate limits |
| T9 | Stale JWT after revoke | Short TTL (15 min); revocation list checked at gateway |
| T10 | Local SQLite theft on desktop | OS-keychain encryption; auto-lock on screen-lock |
12. Cross-link
- Platform security & tenancy:
docs/07-security-compliance-tenancy.md. - DDL & RLS:
DATA_MODEL.md. - Failure & DLQ playbook:
FAILURE_MODES.md. - Sync conflict policy:
SYNC_CONTRACT.md.