Skip to main content

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

ThreatMitigation (primary)Mitigation (defense-in-depth)
Cross-tenant read via guessed/leaked URLPrefix-scoped object keys, signed URLs scope a single objectDB RLS by tenant_id; GCS adapter assertion; CDN cache key includes prefix
Signed URL leaked & replayedShort TTL (≤ 5 min download, 10 min upload); fingerprint blacklist on revokeAudit on every issuance; SIEM alert on > N issuances per actor per minute
Malicious upload (malware)Async virus scan in dedicated SA; quarantine bucketBlock 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 optimizerReject image/svg+xml outside theme_asset scope
PII leakage through CDNprivate data class never on CDN-fronted bucketBucket-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 proxyn/a
Authn / token replayShort-lived JWTs from iam-service; mTLS for service-to-serviceAudit logs
Authz drift / privilege escalationCentralised RBAC matrix in §3; deny-by-defaultPeriodic policy snapshots reviewed in audit
Quota exhaustion DoSPer-tenant byte + object caps, per-IP rate limitsOutbox emits warning at 80 % / 95 %
Backup / export of bytesAll bytes stay in GCS; backups bucket-versioning + ColdlineCMEK on private bucket; audited rehydration
Insider abuse (employee read)All access via service; service mTLS only; break-glass is a separate SA + 2-eyes approvalAll reads via this service produce file.access.granted audit row
Compromised KMS keyPer-environment, per-data-class CMEK; rotation 90 dErasure certificate signing key separated and rotated 30 d

2. Scope → dataClass → posture matrix

scopedataClassBucketCDNCMEKDefault retentionAllowed MIMEDefault download TTLDefault roles able to download
property_photopublic_mediamediayesoptionaldefault (90 d soft + auto-renewing while in use by a Property)image/jpeg, image/png, image/webp5 min (signed for embed by booking site or backoffice)tenant-public via CDN; backoffice users always
tenant_logopublic_mediamediayesoptionaltheme_asset (until-superseded)image/png, image/svg+xml, image/webp5 mintenant-public via CDN
theme_assetpublic_mediamediayesoptionaltheme_assetimage/*, font/woff2, text/css5 mintenant-public via CDN
invoice_pdfprivateprivatenoyesinvoice_pdf (regulated, 7 y)application/pdf60 sBillingViewer, BillingAdmin, owning guest user
receipt_scanprivateprivatenoyestax_complianceimage/jpeg, image/png, image/webp, application/pdf60 sBillingViewer, BillingAdmin
guest_id_scanprivateprivatenoyespii_id_scan (regulated, jurisdictional)image/jpeg, image/png, image/webp, application/pdf30 sFrontDesk, PropertyManager only; never CDN; never tenant-public
vendor_lock_reportprivateprivatenoyesvendor_lock_reportapplication/pdf, text/csv, application/json60 sMaintenanceAdmin, LockOps
notification_attachmentprivateprivatenoyesshort_lived_attachmentapplication/pdf, image/*60 ssender + addressee roles
miscprivate (default)privatenoyesdefaultdeclared at upload, validated against allowlist60 sscope 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.

ActionRequired role(s)Additional ABAC
POST /v1/files/uploads/initiate for scope=property_photoPropertyEditor or PropertyManagerownerScopeRefs.propertyId ∈ caller.assignedProperties
POST /v1/files/uploads/initiate for scope=guest_id_scanFrontDesk or PropertyManagerownerScopeRefs.reservationId.propertyId ∈ caller.assignedProperties and reservation status ∈ {checked_in, checking_in}
POST /v1/files/uploads/initiate for scope=tenant_logo / theme_assetTenantAdminnone
POST /v1/files/uploads/initiate for scope=invoice_pdf / receipt_scanservice-to-service (billing-service SA)n/a
POST /v1/files/{id}/download-urlvaries 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 roleIf-Match version
POST /v1/files/erasureTenantAdmin (for guest erasure) or platform ComplianceOps (tenant erasure)mandatory reason & ticketId
POST /v1/files/{id}/access-grant/revokeSecurityOps or scope owner rolenone
GET /v1/files/{id}/auditSecurityOps or scope owner roletenant scope

3.1 Scope owners (delete authority)

scopeDelete role
property_photoPropertyEditor
tenant_logo, theme_assetTenantAdmin
invoice_pdfnot deletable manually (regulated); only via retention or erasure
receipt_scanBillingAdmin (soft only; hard purge via retention/erasure)
guest_id_scannot deletable manually; only via retention or erasure
vendor_lock_reportMaintenanceAdmin
notification_attachmentsender 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 pass X-Tenant-Id (used for RLS + audit).
  • Guests on booking site: anonymous, throttled access to public_media via CDN; private data is never exposed to anonymous callers.
  • Refresh / rotate: iam-service rotates 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.com with iam.serviceAccounts.signBlob granted only to the service runtime SA. The signer SA itself has no storage.objects.* direct permissions; it only signs.
  • Scope of a single signature:
    • Method (PUT for upload, GET for download, POST for resumable session start).
    • Exact Content-Type (server-pinned at issuance for upload).
    • Exact object key.
    • Exact bucket.
    • Optional Content-MD5 pin for upload.
    • X-Goog-Expires ≤ TTL ceiling per scope.
  • Fingerprint: SHA-256 of the URL (post-signing). Stored in access_grants.signature_fingerprint and upload_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_REVOKED and emits file.access.denied.v1{reason:'revoked_signature'}.
    • For uploads, revocation aborts the corresponding upload_sessions row.
    • Note: GCS itself does not honor revocation natively; revocation is enforced by gating downloads through this service for all private scopes (see §6). For public_media we accept that a leaked URL can be replayed until natural expiry; mitigation is short TTL + audit-grade detection.
  • TTL ceilings: per scope (see §2). Max ever is 1 h, set in config/storage.yaml and asserted in code.

6. Read paths

Data classRead pathWhy
public_mediaBrowser/desktop → CDN edge → GCS bucketCacheable; signed URL still required for non-public surfaces
privateCaller → 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 serviceNo 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_scan and receipt_scan have no plaintext metadata beyond bytes and SHA-256 in this service; structured fields extracted by OCR are sent directly to tenant-service and discarded here (AI_INTEGRATION §5.4).
  • alt_text is treated as user-content; HTML-escaped on emission; never passed to a downstream interpolator.
  • owner_scope_refs.guestId is the only direct identifier of a guest stored here; it is opaque to this service and cannot be resolved without tenant-service.
  • caller_ip and caller_user_agent in access_grants are PII (lawful basis: security). Retention 365 d, then truncated to /24 for IPv4 and /56 for IPv6 and stripped UA.
  • All PII columns are flagged in the column metadata (pg_description) so dlp-scanner-job can audit.

8. Encryption at rest & in transit

  • Bucket:
    • melmastoon-private-{env} and melmastoon-archive-{env}: CMEK in melmastoon-{env}-keys / file-storage-cmek (region europe-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:

ActionAudit rowEvent
Upload initiatedyesfile.upload.initiated.v1
Upload confirmedyesfile.upload.completed.v1
Download URL issuedyes (also access_grants row)none in normal path; file.access.denied.v1 on failure
File deleted (soft)yesfile.deleted.v1
File hard-purged (retention)yesfile.retention.expired.v1
File hard-purged (erasure)yesfile.erasure.completed.v1
Signed URL revokedyesfile.access.denied.v1 (synthetic, on next attempt)
Quota warnedyesfile.bucket.quota_warning.v1
AI task invokedyes (compact: taskId only)none in this service
Cross-tenant attemptyes (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

RegimePosture
GDPRDPIA for guest_id_scan; per-guest erasure SLA 30 d; per-tenant erasure with audit certificate
PCI-DSSWe do not store card numbers; receipt scans containing PAN are forbidden by policy and flagged via DLP scan
Local data residencyAll buckets and Cloud SQL pinned to europe-west1; tenants in regulated jurisdictions can opt into me-central1 (Phase 2)
WCAGPublic 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):
    1. Page on-call.
    2. Mass-revoke all active grants for the file via POST /admin/files/{id}/access-grants/revoke-all.
    3. Roll the GCS HMAC signing identity (managed automatically by GCP).
    4. Issue a file.access.denied.v1{reason:'revoked_signature'} audit retro-record summarising affected actors.
    5. Notify tenant per data-class breach matrix in docs/07 §11.
  • A "cross-tenant access attempt" runbook handles the cross_tenant denial flood scenario.
  • A "quarantine bucket investigation" runbook handles malware findings.