SECURITY_MODEL — theme-config-service
Sibling: API_CONTRACTS · DATA_MODEL · AI_INTEGRATION
Platform anchors:
docs/07-security-compliance-tenancy.md·docs/architecture/ADR-0002-multi-tenancy-model.md
This document specifies the threat model, authentication, authorization, tenancy isolation, secret handling, asset / preview link hardening, and compliance posture for theme-config-service.
1. Threat model snapshot
| Threat | Vector | Severity | Primary mitigations |
|---|---|---|---|
| Cross-tenant theme leakage | RLS misconfig, app bypassing app.tenant_id | Critical | RLS-on by default, tenant_id enforced on every aggregate, repository tests assert isolation |
| Stored XSS in tenant-branded site | Malicious markup in ContentBlock.body (HTML or markdown) | Critical | dompurify allow-list at write + at render in BFFs; CSP header on the booking flow origin |
| Unauthorized publish | Author with read-only role triggers publish | High | RBAC theme:publish; OCC; idempotency; audit log |
| Preview link sprayed publicly | Preview URL leaked / brute-forced | High | 256-bit secret; SHA-256 storage; expiry; rate-limited; revocable |
| Asset URL pointing off-platform | Author injects external URL bypassing file-storage-service | Medium | Asset URL allow-list (only *.melmastoon.app + signed CDN URLs accepted) |
| Bundle tampering at the CDN edge | Compromised GCS object replaced | Critical | Bundles immutable; SHA-256 stored on the publication row; CDN bundle is verified by the BFF on first read after invalidation |
| Spoofed event from external producer | Unsigned Pub/Sub message forged | High | Pub/Sub publisher IAM scoped per service; envelope producer field cross-checked against the topic's IAM publisher |
| AI prompt injection from a tenant-controlled string | Tenant injects "ignore previous instructions" into a brand keyword | Medium | Orchestrator-side instruction-hierarchy enforcement; HITL gates; output schema enforced; brand keywords sanitised to ≤ 64 char alphanumeric+space |
| OCC race causing lost-update during publish | Two simultaneous publishes | Medium | OCC + partial unique index theme_publications_active_uq; MELMASTOON.THEME.PUBLISH_CONFLICT |
| Privilege escalation via property scope | Tenant_admin in chain Phase 2 edits sibling property's theme | High | Property-scoped JWT claim enforced at the gateway and re-verified in the use case |
2. Authentication
| Caller | Mechanism | Identity carrier |
|---|---|---|
| Backoffice console (human) | OIDC → IAM session → Bearer JWT | sub = user ULID, roles[], tenantId, propertyScope[] |
| Booking flow / BFF (server-to-server) | Workload identity → mTLS to gateway → service JWT | sub = service ULID, tenantId = caller's tenant context |
notification-service (read email theme) | Workload identity → mTLS internal endpoint | sub = service; bypasses gateway |
| Edge worker (preview / public bundle) | Anonymous; preview tokens validated | n/a |
| Desktop sync gateway | Device JWT bound to deviceId + tenantId + propertyId | sub = device ULID |
| Pub/Sub subscribers | Pub/Sub IAM | n/a (we are the subscriber) |
JWTs are issued by iam-service; we do not verify signatures locally beyond the gateway's verification (defense-in-depth optional verify in dev mode only). The gateway strips and re-signs claims into a service-internal JWT before forwarding.
3. Authorization (RBAC)
Role catalogue used by this service (subset of platform roles):
| Role | Granted permissions |
|---|---|
tenant_admin | All theme permissions including theme:publish, theme:approve_ai, theme:rollback, theme:delete_locale |
brand_owner | theme:read, theme:author, theme:publish, theme:approve_ai, theme:rollback |
theme_author | theme:read, theme:author (drafts only; cannot publish or approve AI) |
support_admin (platform) | theme:read (cross-tenant, audited; never write) |
cs_agent | theme:read only on the active publication (no draft visibility) |
device | theme:read:active_published only via desktop-sync endpoints |
Permission matrix per endpoint group:
| Endpoint | Required permission |
|---|---|
GET /themes, GET /themes/:id, GET /theme-versions/... (drafts) | theme:read |
POST /themes, PATCH /themes/:id, POST /themes/:id/locales, DELETE /themes/:id/locales/:locale | theme:author |
POST /themes/:id/versions, PATCH /theme-versions/:id | theme:author |
POST /theme-versions/:id/preview, DELETE /theme-versions/:id/preview/:tokenId | theme:author |
POST /theme-versions/:id/publish | theme:publish |
POST /themes/:id/rollback | theme:publish |
| AI request endpoints | theme:author |
| AI apply endpoints | theme:approve_ai AND not the original author |
GET /public/themes/:id/published.json | none (public) |
GET /public/preview/:token | preview-token validation |
GET /internal/email-theme/:id | service-only mTLS, role service:notification-service |
Property-scoped enforcement: when theme.scope === 'property', the actor's propertyScope[] must include theme.propertyId; otherwise 403 MELMASTOON.IAM.SCOPE_MISMATCH.
Authoring vs approval separation of duties: ApplyAi*UseCase rejects actor.id === suggestion.createdBy.
4. Tenancy isolation
Per ADR-0002:
- Single-database, RLS-enforced isolation. All tenant-scoped tables have
ENABLE ROW LEVEL SECURITY+tenant_isolationpolicy. - Application sets
app.tenant_idGUC on every checked-out connection in aBEGIN; SET LOCAL ...pair before any tenant-scoped query. - Repositories assert
ctx.tenantIdmatchestenant_idreturned by every read; mismatches throwTenantBoundaryViolatedError(ops-paged). - Migrations and platform reads use a separate
theme_config_migrator/theme_config_rorole; the latter still has RLS on (analytics queriesSET app.tenant_idper partition). - The test harness includes
cross_tenant_isolation.spec.tsthat setsapp.tenant_idto tenant A then attempts to read tenant B rows; must return zero rows.
5. Asset & URL handling
- All
MediaRef.urlvalues must match^https://(storage\\.googleapis\\.com|cdn\\.melmastoon\\.app|<tenant-cdn-allowlist>)/.+$. Anything else →MELMASTOON.THEME.ASSET_URL_NOT_ALLOWED. MediaRef.contentHash(SHA-256) must be supplied for non-public assets; absence is allowed for public CDN URLs but logged.AssetIntegrityClient.validateManyis called at publish time and on the daily scanner; broken URLs do not block publish only when the actor presentstheme:publish_allow_broken_assets(admin override; rare).- The published bundle JSON contains absolute HTTPS URLs only; relative URLs are rejected at validation.
6. Preview links
The preview link is the highest-risk surface in this service because it grants anonymous read of an unpublished theme version (which can include drafty AI-generated content).
| Property | Value |
|---|---|
| Secret length | 32 bytes (256 bits) from crypto.randomBytes |
| Encoding in URL | base64url(tokenId + ':' + secret) (token id is exposed; secret never persisted) |
| Storage | preview_tokens.token_hash = sha256_hex(secret) only |
| Comparison | constant-time (crypto.timingSafeEqual) |
| TTL | author-chosen 1–168 h (default 24 h); hard cap 168 h |
| Revocation | DELETE endpoint or automatic on publish/draft-edit |
| Rate limit | 60 rpm per tokenHash; 600 rpm per tenant; brute-force prevented |
| Audit | last_accessed_at, access_count updated; desktop.theme.preview.accessed.v1 emitted with referrer header (best-effort) |
| Headers on response | Cache-Control: private, no-store, X-Robots-Tag: noindex, no Set-Cookie |
| Watermark | Preview bundle response carries meta.preview = true; the booking flow renders a top banner "PREVIEW — not yet published" |
Even with the secret, the preview token gives read access only to one specific draft version, never to other versions, other themes, or other tenants.
7. CDN bundle integrity
- Bundles are uploaded to GCS with
gsutil_cors-restricted, signed URLs are not used (the bundle is public). Object generation is captured;Cache-Control: public, max-age=300, s-maxage=86400, stale-while-revalidate=60. - The publication row stores
bundle_sha256. The BFF, on first cache-miss after a CDN invalidation, fetches the bundle, recomputes SHA-256, and refuses to serve if mismatched (raisestheme.bundle.integrity_violation.v1+ ops page). - GCS bucket has Object Versioning + retention policy 30 days to protect against accidental overwrite.
8. Secrets & key management
| Secret | Storage | Rotation |
|---|---|---|
| Cloud SQL connection (IAM auth) | Workload Identity, no static secret | n/a |
| Pub/Sub publish IAM | Workload Identity | n/a |
| GCS bucket access | Workload Identity | n/a |
| Memorystore AUTH | Secret Manager → mounted env at boot | 90 days |
| AI orchestrator mTLS | Workload Identity certs from Workload Identity Federation | rotated daily by GCP |
| Preview token secrets | never stored at rest | per-token (created → user-shared) |
| HMAC for desktop pairing JWT verification | Secret Manager | 90 days |
The service has no static API keys. All secrets that exist are GCP-managed and short-lived.
9. Input validation & safety
- All
MarkupEntryformat: 'html'andformat: 'markdown'content is sanitised throughdompurifyconfigured with theMELMASTOON_THEME_SAFE_HTMLallow-list (no<script>, no<iframe>except YouTube/Vimeo, no inline event handlers, nojavascript:URLs, attribute allow-list per tag). ContentBlock.meta.layoutand similar enums are validated against fixed allow-lists.LocalePack.entriesICU strings parsed by@formatjs/icu-messageformat-parser; invalid syntax →400 LOCALE_PLACEHOLDER_MISMATCHwith field-level violation.MediaRef.urlvalidated against the asset URL allow-list (§5).NavigationItem.targetvalidated against a route allow-list whenkind === 'route';https://only whenkind === 'external';tel:andmailto:allowed forkind === 'tel'|'email'.- All numeric inputs (e.g.
ttlHours,ordinal, spacing scale) validated with min/max bounds.
10. Logging, audit, and PII
- Application logs are JSON, structured, redacted: no full request bodies, no full prompts, no token secrets.
- Audit events flow to
audit-serviceviatheme.audit.*events; immutable, 7-year retention. - PII handling: this service generally does not process PII. The exceptions are:
actor.id,actor.ip,actor.userAgenton commands → audit-only, never indexed bytenant_id.- Author/approver names appear in HITL surfaces; not stored, fetched on demand from
iam-service.
- DSAR requests routed to the platform DSAR pipeline; we contribute audit log entries scoped to the user.
11. Compliance posture
| Requirement | Status |
|---|---|
| GDPR data minimisation | Enforced at AI boundary (§AI_INTEGRATION §4) |
| GDPR right to erasure | Tenant deletion cascades to soft-delete + 30-day hard purge; AI logs purged on schedule |
| WCAG 2.1 AA | Hard-enforced on token contrast at publish |
| OWASP ASVS L2 | Mapped in services/theme-config-service/security/ASVS_MAPPING.md |
| ISO 27001 controls | Inherited from platform; service-specific controls listed in services/theme-config-service/security/SOC2_CONTROLS.md |
| Pen-test cadence | Quarterly authoring surface; annual full scope |
| Dependency scanning | Snyk + GitHub Advanced Security in CI; high-severity blocks merge |
| Container scanning | Trivy on every image build; CIS Docker baseline |
| Secret scanning | Pre-commit + GitHub secret scanning |
12. Incident response runbooks (pointers)
| Incident | Runbook |
|---|---|
| Cross-tenant data exposure suspected | runbooks/sev1-cross-tenant.md |
| Stored XSS in tenant content | runbooks/sev1-stored-xss.md |
| Bundle integrity violation | runbooks/sev2-bundle-integrity.md |
| Preview link suspected leaked | runbooks/sev3-preview-leak.md (revoke all tokens for the version + rotate) |
| AI prompt injection observed | runbooks/sev3-ai-prompt-injection.md |
| RLS bypass observed | runbooks/sev1-rls-bypass.md |
Runbooks live in the monorepo and are exercised quarterly.
13. References
- Platform tenancy:
docs/architecture/ADR-0002-multi-tenancy-model.md - Platform compliance:
docs/07-security-compliance-tenancy.md - AI safety:
AI_INTEGRATION,docs/08-ai-architecture.md - API surface:
API_CONTRACTS