Skip to main content

Security

:::info Source Sourced from services/authoring-service/09-SECURITY_MODEL.md in the documentation repo. :::

Companion: 13 Security & Compliance · 04 API Contracts


1. Threat Model

ThreatSurfaceMitigation
Cross-tenant draft leakREST API, events, syncMulti-layer tenant isolation (§3)
Prompt injection via block contentAI generate flowsPre-moderation + input wrapping at ai-gateway
AI PII exfiltrationAI flowsPII redaction before gateway + provider no-train flag
SCORM zip RCESCORM importSandboxed extraction + manifest validator + signed origin allowlist
Unauthorized publishPublish endpointExplicit publish permission + approver != author invariant
Malicious embed blockEmbed block runtimeWhitelisted providers + iframe sandbox attributes
Session hijackAll endpointsShort JWT TTL + rotating refresh + device binding
Outbox poisoningEvent pipelineSchema validation + signed envelopes + replay idempotency
Yjs update corruptionCollab WSSigned update vectors + server-side validation
Insider draft exfiltrationAll readsPer-request audit log + compliance officer read trail
Draft enumerationGET /draftsULIDs (unpredictable) + tenant filter + authz on each row
IDOR on blocksPATCH/DELETE blockABAC policy: block.tenantId == ctx.tenantId AND user in draft collaborators
Mass data scrapingList endpointsRate limits + pagination caps + anomaly detection

2. Authentication

  • Inbound clients: JWT Bearer (Ed25519 signed, KMS-backed kid)
  • Service-to-service: mTLS + internal service tokens
  • WebSocket collab: JWT in subprotocol header (not query string to avoid log leak)

2.1 JWT Validation

Every request validates:

  • Signature via JWKS from identity-service
  • exp not in the past
  • iss == https://auth.ghasi.io
  • aud includes authoring-service
  • kid matches a non-revoked key
  • tid (tenant) matches X-Tenant-Id header
  • dpop proof if present (M6+)

Failure → 401 with WWW-Authenticate header.

3. Authorization

3.1 Roles (coarse)

RolePermissions
platform_adminRead all drafts (audit); no writes
compliance_officerRead all drafts for audit; redaction controls
org_owner, org_adminManage provider settings (in provider tenants)
provider_adminCreate/delete drafts, add collaborators, configure prompts
authorCreate/edit/submit drafts; use AI co-author
reviewerRead drafts in in_review; approve/reject
publisherPublish approved drafts (M3+; before that, author+reviewer combined)
learner, individualNo access

3.2 ABAC Predicates

Resource:ActionPredicate
draft:createuser.role IN {author, provider_admin} AND user.tenantId == resource.tenantId
draft:readresource.tenantId == user.tenantId AND (user IN resource.collaborators OR user.role IN {provider_admin, compliance_officer})
draft:updateresource.tenantId == user.tenantId AND user IN resource.collaborators AND resource.state == 'editing'
draft:submit_reviewresource.createdBy == user.id OR user IN resource.collaborators with role=editor
draft:approveuser.role IN {reviewer, provider_admin} AND user != resource.lastSubmittedBy
draft:publishuser.role IN {publisher, provider_admin} AND resource.state == 'approved'
draft:deleteuser == resource.createdBy OR user.role == provider_admin AND resource.state == 'editing'
block:createSame as draft:update
ai:generateSame as draft:update + tenant AI budget check
collab:joinSame as draft:read
scorm:importSame as draft:update

3.3 Decision Flow

Request arrives

[AuthN Guard] validates JWT → RequestContext populated

[Controller] declares required permission (decorator: @RequireAuth('draft:update'))

[PolicyEngine] evaluates:
1. Does role grant permission? (coarse)
2. Do ABAC predicates pass? (fine)
3. Is tenant scoped? (rls check)

[Log] decisionId recorded; linked to audit chain

Controller handler executes

3.4 UI Hint Endpoint

POST /api/v1/authz/check
{ "checks": [{"resource":"draft:publish","resourceId":"drf_01H..."}] }

=> { "results": [{"resource":"draft:publish","allowed":false,"reason":"state_not_approved"}] }

4. Multi-Tenant Isolation

LayerMechanism
Edge (CDN)Per-tenant path prefixes or domain
API GatewayX-Tenant-Id header must equal JWT tid; otherwise reject
ControllerUse cases take RequestContext with tenantId; never accept from body
DomainAggregate construction validates tenant consistency (INV-1)
Postgres RLSEvery authoring table has rls_*_tenant policy
Postgres sessionPgBouncer proxy-init sets app.tenant_id per request
Events (NATS)tenantId in envelope; consumer filters on it
Cache (Redis)Key prefix tenants/{tid}/authoring/...
S3SCORM uploads under tenants/{tid}/scorm/...; signed URLs per-caller
SearchTenant filter on every OpenSearch query
Vector indexTenant filter on every pgvector k-NN
Logstenant_id tag on every structured log line
Metricstenant_id label on every metric

4.1 Tenant Leak Prevention Tests

Every CI build runs the two-tenant simulator suite:

  1. Provision two tenants (A, B) with distinct users
  2. For each endpoint and event topic, attempt cross-access
  3. Assert 100% refusal with correct error codes
  4. Assert no cross-tenant data in logs, metrics, caches

5. Input Validation

5.1 Zod Schemas on Every Endpoint

All request bodies validated via Zod. Schema violations return 422 with field-level errors.

5.2 Block Content Validation

  • Markdown: sanitized via rehype-sanitize with strict whitelist
  • Image alt: length 1-500 chars, no HTML
  • Embed URLs: whitelist of providers (YouTube, Vimeo, Loom, CodePen, specific tenant-approved)
  • Embed sandbox attributes: non-empty whitelist (allow-scripts allow-same-origin by default; loosened only by provider_admin)
  • Interaction config: JSON Schema validation per interaction type

5.3 SCORM Import Validation

  • Max zip size: 500 MB
  • Extract to ephemeral sandbox (separate container, no network)
  • Validate imsmanifest.xml against SCORM 1.2/2004 XSD
  • Reject manifests with external entity references (XXE prevention)
  • Scan for zip-slip attacks (path traversal in entry names)
  • Scan for zip bombs (entry size ratio check)
  • Remove any <script> tags from embedded HTML (scripts are not supported in SCORM import v1)

6. Output Encoding

  • JSON responses: explicit charset=utf-8 and X-Content-Type-Options: nosniff
  • HTML in previews: served from sandboxed subdomain (preview-{draftId}.ghasi.io)
  • Runtime player: CSP with nonce, frame-ancestors 'self'
  • Markdown → HTML rendering: DOMPurify in the player

7. Secret Management

  • No secrets in code; all via env vars loaded from Vault/KMS at boot
  • AI gateway tokens: rotated weekly, stored in Secret Manager
  • Database passwords: rotated monthly via automated Vault policy
  • Signing keys: KMS-backed; never present on pod filesystem

Required env vars:

DATABASE_URL postgres://authoring:***@pg:5432/authoring
NATS_URL nats://nats:4222
NATS_CREDS_PATH /vault/secrets/nats.creds
AI_GATEWAY_URL https://ai-gateway.internal
AI_GATEWAY_TOKEN (injected from Vault)
IDENTITY_JWKS_URL https://auth.ghasi.io/.well-known/jwks.json
OTEL_EXPORTER_OTLP_ENDPOINT http://otel-collector:4318
S3_SCORM_BUCKET ghasi-authoring-scorm-{region}
REDIS_URL redis://redis:6379

8. CSRF & CORS

  • Cookie-based session not used; JWT Bearer in Authorization header
  • CORS: allowlist of tenant domains (https://{tenant-slug}.ghasi.io, plus trusted first-party)
  • Access-Control-Allow-Credentials: false (no cookies)
  • Preflight cache 600s

9. Rate Limiting

(See 04-API_CONTRACTS.md §4.) Enforced at API Gateway using Redis token bucket. Service-side secondary enforcement for AI endpoints.

10. WebSocket Security

  • JWT validated on upgrade; rejected if expired mid-session (re-auth required)
  • Per-connection rate limits: 100 messages/sec
  • Server-side Yjs update validation: every incoming update parsed and re-serialized
  • Large update vectors (> 1 MB) rejected
  • Idle timeout: 5 minutes

11. SSE Security

  • Each SSE connection tied to the JWT; revoked refresh → connection closed
  • Heartbeat every 15s (server → client)
  • Client disconnection detected → server-side job continues; results attached on reconnect

12. Audit Logging

Every mutation writes an audit entry via AuditLogger (port → audit-service, async via event). Fields:

  • tenantId, userId, decisionId
  • action, resourceType, resourceId
  • timestamp, ip, userAgent
  • delta: before/after hash

Retention: 7 years (regulated class).

13. Headers (Response)

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'none'; script-src 'self' 'nonce-{R}'; connect-src 'self' https://*.ghasi.io; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none';
Permissions-Policy: camera=(), microphone=(), geolocation=()

14. Dependency Security

  • Lockfiles committed (pnpm-lock.yaml)
  • Snyk / npm audit in CI; blocks on HIGH/CRITICAL
  • SBOM generated on every release (SPDX format)
  • Provenance attestations (SLSA level 3)
  • Distroless base image for runtime
  • No curl | bash anywhere; all tool installs pinned

15. Compliance Hooks

RegulationHook
GDPR Art. 17 (Erasure)gdpr.subject_request.received.v1 handler anonymizes/deletes user references in drafts
GDPR Art. 15 (Access)/internal/compliance/user-data?userId={id} returns all draft-related data for a user
GDPR Art. 20 (Portability)Same endpoint with ?format=json exports user's authored drafts
SOC 2 CC6.1Access controls audited via decisionId chain
FERPA (if applicable)Student data never stored in authoring — pure authoring context
COPPA (if applicable)Tenant config flag prevents AI flows for tenants serving under-13 audiences

16. Incident Response

EventAutomated ResponseHuman Response
DLQ non-empty > 5 minPagerDuty alertOn-call runbook
Cross-tenant attempt detectedBlock request, alert SecOpsSecOps investigation within 15 min
Publish saga timeout rate > 5%Alert platform teamInvestigate downstream services
AI moderation block rate > 20%Alert content-safety teamReview prompts and inputs
Unauthorized admin access attemptBlock + alertSecOps runbook

17. Data Classification

DataClassificationHandling
Draft titles, contentBusiness sensitiveEncrypted at rest (Postgres TDE); RLS enforced
AI provenanceAudit-relevantRetained 7 years; regulated class
Collaborator listsInternalStandard
Uploaded media refsBusiness sensitiveSigned URLs only
SCORM source zipsBusiness sensitivePer-tenant S3 prefix; encrypted
Block versionsAudit-relevantRetained 2 years, then archived