file-storage-service — SECURITY_MODEL
Companion: DATA_MODEL · API_CONTRACTS · EVENT_SCHEMAS · APPLICATION_LOGIC · ../../docs/07-security-compliance-tenancy.md
This service handles a wide spectrum of data — public marketing photos, theme assets, generated invoices, and sensitive guest ID scans (PII). Security model is therefore scope-driven: every scope maps to a dataClass, a bucket, retention, default permissions, signed URL TTL, and audit posture. Cross-tenant access is structurally impossible by virtue of the per-tenant key prefix and the assertion in the GCS adapter; this document specifies the rest.
1. Threat model
| Threat | Mitigation (primary) | Mitigation (defense-in-depth) |
|---|---|---|
| Cross-tenant read via guessed/leaked URL | Prefix-scoped object keys, signed URLs scope a single object | DB RLS by tenant_id; GCS adapter assertion; CDN cache key includes prefix |
| Signed URL leaked & replayed | Short TTL (≤ 5 min download, 10 min upload); fingerprint blacklist on revoke | Audit on every issuance; SIEM alert on > N issuances per actor per minute |
| Malicious upload (malware) | Async virus scan in dedicated SA; quarantine bucket | Block downloads while status='scanning' or 'quarantined' |
| Malicious upload (image bomb / zip bomb / svg-with-script) | MIME + magic-byte sniff at confirm; size cap; SVG sanitised by optimizer | Reject image/svg+xml outside theme_asset scope |
| PII leakage through CDN | private data class never on CDN-fronted bucket | Bucket-level ACL + IAM separation per data class |
| Server-side request forgery via "import from URL" | No such feature in this service; if added, allowlist + per-tenant network egress proxy | n/a |
| Authn / token replay | Short-lived JWTs from iam-service; mTLS for service-to-service | Audit logs |
| Authz drift / privilege escalation | Centralised RBAC matrix in §3; deny-by-default | Periodic policy snapshots reviewed in audit |
| Quota exhaustion DoS | Per-tenant byte + object caps, per-IP rate limits | Outbox emits warning at 80 % / 95 % |
| Backup / export of bytes | All bytes stay in GCS; backups bucket-versioning + Coldline | CMEK on private bucket; audited rehydration |
| Insider abuse (employee read) | All access via service; service mTLS only; break-glass is a separate SA + 2-eyes approval | All reads via this service produce file.access.granted audit row |
| Compromised KMS key | Per-environment, per-data-class CMEK; rotation 90 d | Erasure certificate signing key separated and rotated 30 d |
2. Scope → dataClass → posture matrix
scope | dataClass | Bucket | CDN | CMEK | Default retention | Allowed MIME | Default download TTL | Default roles able to download |
|---|---|---|---|---|---|---|---|---|
property_photo | public_media | media | yes | optional | default (90 d soft + auto-renewing while in use by a Property) | image/jpeg, image/png, image/webp | 5 min (signed for embed by booking site or backoffice) | tenant-public via CDN; backoffice users always |
tenant_logo | public_media | media | yes | optional | theme_asset (until-superseded) | image/png, image/svg+xml, image/webp | 5 min | tenant-public via CDN |
theme_asset | public_media | media | yes | optional | theme_asset | image/*, font/woff2, text/css | 5 min | tenant-public via CDN |
invoice_pdf | private | private | no | yes | invoice_pdf (regulated, 7 y) | application/pdf | 60 s | BillingViewer, BillingAdmin, owning guest user |
receipt_scan | private | private | no | yes | tax_compliance | image/jpeg, image/png, image/webp, application/pdf | 60 s | BillingViewer, BillingAdmin |
guest_id_scan | private | private | no | yes | pii_id_scan (regulated, jurisdictional) | image/jpeg, image/png, image/webp, application/pdf | 30 s | FrontDesk, PropertyManager only; never CDN; never tenant-public |
vendor_lock_report | private | private | no | yes | vendor_lock_report | application/pdf, text/csv, application/json | 60 s | MaintenanceAdmin, LockOps |
notification_attachment | private | private | no | yes | short_lived_attachment | application/pdf, image/* | 60 s | sender + addressee roles |
misc | private (default) | private | no | yes | default | declared at upload, validated against allowlist | 60 s | scope owner |
3. Authorization matrix (RBAC + ABAC)
The decision is delegated to a central PDP (the iam-service policy engine), but the service ships these rules as the contract. Roles below are platform roles defined in iam-service.
| Action | Required role(s) | Additional ABAC |
|---|---|---|
POST /v1/files/uploads/initiate for scope=property_photo | PropertyEditor or PropertyManager | ownerScopeRefs.propertyId ∈ caller.assignedProperties |
POST /v1/files/uploads/initiate for scope=guest_id_scan | FrontDesk or PropertyManager | ownerScopeRefs.reservationId.propertyId ∈ caller.assignedProperties and reservation status ∈ {checked_in, checking_in} |
POST /v1/files/uploads/initiate for scope=tenant_logo / theme_asset | TenantAdmin | none |
POST /v1/files/uploads/initiate for scope=invoice_pdf / receipt_scan | service-to-service (billing-service SA) | n/a |
POST /v1/files/{id}/download-url | varies by scope (see §2) | object's tenant_id == caller.tenant_id; ABAC if any |
DELETE /v1/files/{id} | role with delete capability for the scope (see scope owner table below) | object's tenant_id == caller.tenant_id and (scope-specific guardrails) |
PATCH /v1/files/{id} (alt_text/tags) | scope's editor role | If-Match version |
POST /v1/files/erasure | TenantAdmin (for guest erasure) or platform ComplianceOps (tenant erasure) | mandatory reason & ticketId |
POST /v1/files/{id}/access-grant/revoke | SecurityOps or scope owner role | none |
GET /v1/files/{id}/audit | SecurityOps or scope owner role | tenant scope |
3.1 Scope owners (delete authority)
| scope | Delete role |
|---|---|
property_photo | PropertyEditor |
tenant_logo, theme_asset | TenantAdmin |
invoice_pdf | not deletable manually (regulated); only via retention or erasure |
receipt_scan | BillingAdmin (soft only; hard purge via retention/erasure) |
guest_id_scan | not deletable manually; only via retention or erasure |
vendor_lock_report | MaintenanceAdmin |
notification_attachment | sender role |
Soft delete is still subject to regulated minimum retention; for regulated-class files the row moves to archived, but hard_delete_after is enforced by the sweeper, not by the actor.
4. Authentication
- External callers: short-lived JWT (RS256) issued by
iam-service. Required claims:sub,tenant_id,roles[],attrs.assignedProperties[],iat,exp,aud="file-storage-service". - Service-to-service: mTLS via Anthos Service Mesh; SPIFFE IDs validated against an allowlist in
config/service-mesh/file-storage.yaml. Each caller must additionally passX-Tenant-Id(used for RLS + audit). - Guests on booking site: anonymous, throttled access to
public_mediavia CDN;privatedata is never exposed to anonymous callers. - Refresh / rotate:
iam-servicerotates signing keys every 30 d; this service refreshes JWKS on cache miss with backoff.
X-Tenant-Id mismatch with the JWT tenant_id claim → 401 TENANT_MISMATCH and a security audit event.
5. Signed URL details
- Algorithm: GCS V4 signature (
HMAC-SHA256). - Signing identity:
file-storage-signer-sa@<project>.iam.gserviceaccount.comwithiam.serviceAccounts.signBlobgranted only to the service runtime SA. The signer SA itself has nostorage.objects.*direct permissions; it only signs. - Scope of a single signature:
- Method (
PUTfor upload,GETfor download,POSTfor resumable session start). - Exact
Content-Type(server-pinned at issuance for upload). - Exact object key.
- Exact bucket.
- Optional
Content-MD5pin for upload. X-Goog-Expires≤ TTL ceiling per scope.
- Method (
- Fingerprint: SHA-256 of the URL (post-signing). Stored in
access_grants.signature_fingerprintandupload_sessions.signed_url_fingerprint. The fingerprint, not the URL, is the revocation key. - Revocation:
- Add fingerprint to
revoked:{fingerprint}Memorystore key with TTL = remaining seconds. - On every signed-URL download path, the service checks the revocation set; on hit returns
403 SIGNATURE_REVOKEDand emitsfile.access.denied.v1{reason:'revoked_signature'}. - For uploads, revocation aborts the corresponding
upload_sessionsrow. - Note: GCS itself does not honor revocation natively; revocation is enforced by gating downloads through this service for all
privatescopes (see §6). Forpublic_mediawe accept that a leaked URL can be replayed until natural expiry; mitigation is short TTL + audit-grade detection.
- Add fingerprint to
- TTL ceilings: per scope (see §2). Max ever is 1 h, set in
config/storage.yamland asserted in code.
6. Read paths
| Data class | Read path | Why |
|---|---|---|
public_media | Browser/desktop → CDN edge → GCS bucket | Cacheable; signed URL still required for non-public surfaces |
private | Caller → file-storage-service mints signed URL → GCS bucket directly (CDN bypassed) | Allows revocation, audit, and ABAC |
| Internal worker (optimizer/scan) | Worker uses its own SA via signed URL minted by this service | No long-lived bucket grant |
Public media is also served via signed URL when embedded inside backoffice; this allows audit even for public_media and lets us revoke if necessary (without affecting CDN). For tenant-public booking-site embeds we reference the CDN URL directly without a signature, restricted to cover and gallery photos that have passed scan + AI safety.
7. Field-level encryption & PII handling
guest_id_scanandreceipt_scanhave no plaintext metadata beyond bytes and SHA-256 in this service; structured fields extracted by OCR are sent directly totenant-serviceand discarded here (AI_INTEGRATION §5.4).alt_textis treated as user-content; HTML-escaped on emission; never passed to a downstream interpolator.owner_scope_refs.guestIdis the only direct identifier of a guest stored here; it is opaque to this service and cannot be resolved withouttenant-service.caller_ipandcaller_user_agentinaccess_grantsare PII (lawful basis: security). Retention 365 d, then truncated to/24for IPv4 and/56for IPv6 and stripped UA.- All PII columns are flagged in the column metadata (
pg_description) sodlp-scanner-jobcan audit.
8. Encryption at rest & in transit
- Bucket:
melmastoon-private-{env}andmelmastoon-archive-{env}: CMEK inmelmastoon-{env}-keys / file-storage-cmek(regioneurope-west1).melmastoon-media-{env}: Google-managed key (CMEK optional and configurable per tenant on enterprise plan).
- Cloud SQL: CMEK with the same key ring; Postgres TDE.
- In transit: TLS 1.2+ everywhere; HSTS at the edge; mTLS internally.
- Key rotation:
- CMEK 90 d (auto), with grace decryption.
- Erasure certificate signing key: 30 d.
- JWT signing keys (in
iam-service): 30 d.
9. Audit
Every meaningful action produces a row in audit_events and (where appropriate) an outbox event:
| Action | Audit row | Event |
|---|---|---|
| Upload initiated | yes | file.upload.initiated.v1 |
| Upload confirmed | yes | file.upload.completed.v1 |
| Download URL issued | yes (also access_grants row) | none in normal path; file.access.denied.v1 on failure |
| File deleted (soft) | yes | file.deleted.v1 |
| File hard-purged (retention) | yes | file.retention.expired.v1 |
| File hard-purged (erasure) | yes | file.erasure.completed.v1 |
| Signed URL revoked | yes | file.access.denied.v1 (synthetic, on next attempt) |
| Quota warned | yes | file.bucket.quota_warning.v1 |
| AI task invoked | yes (compact: taskId only) | none in this service |
| Cross-tenant attempt | yes (security-tagged) | file.access.denied.v1{reason:'cross_tenant'} + SIEM alert |
Audit rows are append-only (RULE no_update_audit … DO INSTEAD NOTHING). Daily export to a write-once GCS audit bucket (Object Lock 7y for regulated class actions).
10. Headers & secure defaults
Responses set:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Cache-Control: no-store (for /uploads/initiate, /download-url, /admin/*)
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Signed URL responses include:
Cache-Control: private, max-age=30, must-revalidate (for short-TTL signed URL bodies)
CORS allowlist is configured per environment (config/cors.yaml); booking site, backoffice, and desktop dev origins only. Wildcards never used.
11. Rate limiting & quotas
- Per JWT subject + route: 60 rpm soft, 240 rpm hard, return 429 with
Retry-After. - Per tenant: bytes & objects caps (hard); see DATA_MODEL §5.8.
- Per IP: 600 rpm (anonymous CDN excluded).
- Burst: token bucket; replenished from Memorystore.
- Excess
file.access.denied.v1{reason:'cross_tenant'}events from a single subject (≥ 3 in 5 min) → automatic 30-min suspension of file APIs for that subject + paging alert.
12. Compliance posture
| Regime | Posture |
|---|---|
| GDPR | DPIA for guest_id_scan; per-guest erasure SLA 30 d; per-tenant erasure with audit certificate |
| PCI-DSS | We do not store card numbers; receipt scans containing PAN are forbidden by policy and flagged via DLP scan |
| Local data residency | All buckets and Cloud SQL pinned to europe-west1; tenants in regulated jurisdictions can opt into me-central1 (Phase 2) |
| WCAG | Public media exposed with alt_text; alt-text drafting (AI) accelerates coverage |
13. Secrets & configuration
- Secrets stored in Google Secret Manager; mounted as files; never in environment variables.
- DB credentials provided via Cloud SQL IAM auth tokens (preferred) or Secret Manager-backed username/password rotated 30 d.
- No secret is ever logged. Structured logger has a redaction allowlist enforced by a unit test that fails the build if a known PII or secret field appears in log output.
signed_url_max_lifetime_seconds, MIME allowlists, CMEK key resource names, and CDN base URL are loaded at boot and validated; any missing or out-of-range value fails the readiness probe.
14. Incident response hooks
- A specific incident class "signed URL leak suspected" has a runbook (
runbooks/signed-url-leak.md):- Page on-call.
- Mass-revoke all active grants for the file via
POST /admin/files/{id}/access-grants/revoke-all. - Roll the GCS HMAC signing identity (managed automatically by GCP).
- Issue a
file.access.denied.v1{reason:'revoked_signature'}audit retro-record summarising affected actors. - Notify tenant per data-class breach matrix in docs/07 §11.
- A "cross-tenant access attempt" runbook handles the
cross_tenantdenial flood scenario. - A "quarantine bucket investigation" runbook handles malware findings.