maintenance-service · SECURITY_MODEL
Aligned with
docs/07-security-compliance-tenancy.md. Defence in depth: identity at the edge, RBAC+ABAC at the API, RLS at the DB, encryption-at-rest with KMS CMEK, encryption-in-transit everywhere, audit-grade event trail.
1. Identity & authentication
| Caller | Mechanism | Notes |
|---|---|---|
BFF (bff-backoffice-service) | Service JWT signed by iam-service, OIDC-style claims | Validated at Kong + re-validated in NestJS guard |
| Other services (server-to-server) | Workload Identity (GCP) → short-lived OIDC tokens → mTLS | No long-lived service accounts |
| Pub/Sub push | OIDC token issued by Pub/Sub, audience = our service URL | Verified via google-auth-library |
| Cron (Cloud Scheduler) | OIDC token, audience = our service URL | Same path as Pub/Sub |
| Desktop sync | User JWT + device JWT (device-bound) | sync-service issues ephemeral upstream token to us |
| GitHub Actions (CI deploy) | Workload Identity Federation, no static keys |
JWT claims required for user calls:
{
"sub": "usr_01HXY...",
"tenant": "tnt_01HXP...",
"props": ["prop_01HXP...", "prop_01HXQ..."],
"roles": ["staff_supervisor","gm"],
"device": "dev_01HXY...",
"iat": 1745330400,
"exp": 1745334000
}
2. Authorization (RBAC + ABAC)
Roles enforced at the controller layer via NestJS guard @RequireRoles([...]). Property-level ABAC checks props array vs. the request's resolved propertyId.
| Resource | Action | Required role(s) | ABAC |
|---|---|---|---|
WorkOrder | create | staff (any) | propertyId ∈ jwt.props |
WorkOrder | assign | staff_supervisor, gm, owner | property scope |
WorkOrder | start, block, resume, resolve | assignee themselves OR staff_supervisor+ | property scope |
WorkOrder | verify | gm, owner | property scope |
WorkOrder | cancel | staff_supervisor+ | property scope |
WorkOrder | escalate | staff_supervisor+ or system | property scope |
WorkOrder | record vendor invoice | staff_supervisor+, accounting | property scope |
PreventiveSchedule | create/update/delete/trigger | gm, owner | property scope |
Asset | create/update/delete | staff_supervisor+ | property scope |
Vendor | create/update/delete | gm, owner, accounting | tenant scope |
Part | create/update | staff_supervisor+ | property scope |
Authorization failures emit MELMASTOON.IAM.AUTHZ_DENIED (HTTP 403) and log actor.userId, route, subjectId, requiredRoles, actorRoles.
3. Multi-tenant isolation
Three layers:
- Edge: Kong validates JWT and asserts
X-Tenant-Idmatches the JWT'stenantclaim. Any mismatch → 403 before reaching the service. - Application: NestJS request-scoped middleware sets
request.tenantId. All repositories taketenantIdas a required parameter; passing a different one than the request's is a programmer error caught in code review and a runtime assertion. - Database: Postgres RLS on every tenant-scoped table. Connection wrapper executes
SET LOCAL app.tenant_id = $1at the start of every transaction. RLS policy:tenant_id = current_setting('app.tenant_id').
A failure of any layer must not leak data — tested by tenant-isolation.spec.ts (mandatory in CI).
4. Data classification
| Field | Class | Storage | Notes |
|---|---|---|---|
WorkOrder.title, description | Internal | Postgres plaintext | Indexed for full-text search; no PII expected, but the AI redactor scrubs guest names from outbound LLM calls |
WorkOrder.vendorAcknowledgement.note | Internal | Postgres plaintext | |
Vendor.phoneE164, whatsappE164 | PII (medium) | Postgres plaintext (operational need) | Field-level audit on read by non-staff_supervisor |
Vendor.email, addressFreeText | PII (medium) | Postgres plaintext | Same |
vendor_invoice_file_ref | Confidential | GCS bucket with KMS CMEK | Signed URL with 5 min TTL when served |
cost_*_amount_micro, vendor_invoice_* | Financial / Regulated | Postgres plaintext + retained 7 yrs | RLS still enforced; analytics-only consumers receive aggregates |
aiProvenance | Audit | Postgres jsonb | Includes model id, score; no raw inputs |
roomId, propertyId, assetId | Identifiers (non-PII) | plaintext |
5. Encryption
- In transit: TLS 1.3 mandatory; mTLS for service-to-service via Workload Identity.
- At rest: Cloud SQL CMEK with KMS key
projects/<melmastoon>/locations/europe-west1/keyRings/data/cryptoKeys/maintenance-db. Annual rotation. GCS bucket for vendor invoices: same KMS keyring. - Field-level: none required in v1 (no PII stored beyond contact details that must remain queryable). If we add guest contact in v2, we'll use the platform
field-encryptionlibrary with HMAC-SHA256 deterministic encryption for searchable fields.
6. Secrets
| Secret | Source | Usage |
|---|---|---|
| Cloud SQL password | Secret Manager secrets/maintenance-db-password | Mounted as env var via Cloud Run secret-volume |
| Pub/Sub publisher SA | Workload Identity | No static credential file |
| KMS key access | IAM binding on the runtime SA | |
| AI orchestrator client token | Issued per-request via Workload Identity | |
| Notification client token | Issued per-request via Workload Identity |
No secret is checked into git. CI uses Workload Identity Federation.
7. Audit trail
Every state-changing call produces:
- A domain event (carried via outbox to Pub/Sub).
- An entry in
audit-servicetopicmelmastoon.audit.maintenance.v1withactor.userId,actor.deviceId,route,subjectId,before/after(cardinality-limited diff),correlationId,ipAddress,userAgent.
Auditor queries are served from BigQuery (melmastoon_audit_v1.maintenance_*).
8. Vendor invoice files (PII-adjacent)
- Uploaded directly to GCS by the BFF using a signed PUT URL we generate (5 min TTL, exact content-type & size constraints).
- We never receive the file body in our service; we only persist the GCS URI.
- Bucket policy: object lifecycle = 7 years; uniform bucket-level access; CMEK; access logging on.
9. Privacy / regulatory
- GDPR (EU tenants): vendor PII is processed under "legitimate interest". Erasure requests trigger
Vendor.active=false+ field redaction; FK-linked WOs keep an opaque tombstone. - Local laws (Afghanistan/Pakistan operating regions): we keep all data in
europe-west1by default; tenant-pinned residency is supported via tenant settings. - Children data: maintenance has no child PII surface.
- Data residency:
tenant.dataResidency = 'eu-west' | 'asia-south'. Cross-region replication respects this.
10. AI safety surfaces
- All inputs to the AI orchestrator are PII-redacted by default.
- HITL on any capability that drives a state mutation (severity, vendor outbound).
- Per-tenant budget controls (cost cap), with fail-soft on exhaustion.
- Provenance persisted; replay supported.
- Outputs are validated against constrained schemas (e.g.,
WorkOrderSeverityis an enum; bad values discarded silently).
11. Threat model summary
| Threat | Mitigation |
|---|---|
| Cross-tenant data leak via app bug | RLS at DB layer (defence in depth); tenant-isolation.spec.ts in CI |
| Cross-tenant data leak via cache key collision | Cache keys include tenantId; lint rule no-unscoped-cache-key |
| Forged Pub/Sub push | OIDC token verification with audience match; private VPC ingress |
| Replay of state transitions (e.g., resolve twice) | OCC version + idempotency keys + inbox dedupe |
| Vendor-impersonation outbound notification | Vendor record is server-side; outbound channel/identifier comes from DB, not request body |
| Long-lived service account key abuse | Workload Identity, no static keys |
| Unauthorised file access (vendor invoice) | Signed URL with 5 min TTL, auth-checked at issue, KMS CMEK |
| AI prompt injection via guest complaint text | PII redaction + constrained-output models + HITL on consequential capabilities |
| OCC bypass via direct DB write | Only the service has write access; CI lints against ad-hoc DB writes; DB role lacks BYPASSRLS |
| Audit gap on bulk operations | All bulk writes go through use cases; no direct repository access from controllers |
12. Incident readiness
- Runbooks linked from
docs/standards/ERROR_CODES.mdper code. - Tenant-scoped data export (for regulator subpoena) is supported via
MIGRATION_PLAN.md§off-boarding flow. - Tenant-scoped erasure:
Vendorredacted,Partdeactivated, WOs anonymised onassignee_user_idandvendor_ack_recorded_byafter 7-year regulated retention expires.