Security
:::info Source
Sourced from services/delivery-service/SECURITY_MODEL.md in the documentation repo.
:::
Companion: 13 Security, Compliance & Tenancy · DOMAIN_MODEL · AI_INTEGRATION
1. Threat Model (Service-Specific)
| Threat | Surface | Mitigation |
|---|---|---|
| Unauthorized session access | REST API | JWT verification, ownership check on every session operation |
| Cross-tenant session leak | REST API, event consumers | RLS on all tables, tenant check in use cases, two-tenant simulator in CI |
| Offline bundle tamper | Device-side storage | JWS license envelope verification, AES-GCM content encryption, checksum validation on mount |
| License replay | License envelope | Envelope includes device-bound nonce + expiry; one-time-use licenses for individual grants |
| Tutor prompt injection | Tutor turn endpoint | ai-gateway prompt-injection classifier; scoped system prompt |
| Tutor PII exfiltration | Tutor turn endpoint | PII redaction before cloud inference; no-train flag; local inference preferred for sensitive tenants |
| AI curriculum drift | Tutor responses | Output classifier flags off-topic; rate limit on per-session turns |
| Session hijack via stolen token | REST API | Short JWT TTL (15 min); device binding; IP anomaly detection |
| DoS via navigation spam | PATCH /navigate | 120 requests/minute/session rate limit; optimistic concurrency prevents queue buildup |
| SSE connection exhaustion | Tutor stream | Connection limits per user (5 concurrent); auto-close on inactivity |
| Bundle download replay | mount-offline endpoint | Idempotency-Key required; mount is unique per (device, bundle) |
2. Authentication
All endpoints require a valid JWT issued by identity-service:
- Header:
Authorization: Bearer <jwt> - Algorithm: EdDSA Ed25519
- Expiry: 15 minutes (refresh handled by client)
- Required claims:
sub(userId),tid(tenantId),did(deviceId),scope
Public endpoints: none. All delivery endpoints require authentication.
3. Authorization
3.1 Required Permissions
| Endpoint | Permission |
|---|---|
POST /play-sessions | delivery.play_session:create |
PATCH /{id}/navigate | delivery.play_session:navigate (owner only via ABAC) |
POST /{id}/pause | delivery.play_session:manage (owner only) |
POST /{id}/complete | delivery.play_session:manage (owner only) |
POST /{id}/abandon | delivery.play_session:manage (owner only) |
GET /{id}/state | delivery.play_session:read (owner or instructor) |
POST /{id}/tutor/turn | delivery.tutor:use (owner only) |
POST /{id}/mount-offline | delivery.offline:mount (owner only) |
POST /{id}/unmount-offline | delivery.offline:unmount (owner only) |
3.2 ABAC Predicates
resource.tenant_id == ctx.tenant_id
resource.user_id == ctx.user.id // owner-only ops
resource.enrollment.user_id == ctx.user.id // for navigate/complete
Instructors with instructor.sessions:read scope may view sessions for learners in their assignment groups — enforced by additional predicate:
ctx.user.role in [instructor, org_manager]
AND resource.enrollment.assignment.owner_id == ctx.user.id
4. Multi-Tenant Isolation
Every table has tenant_id and RLS policy. The application layer also:
- Sets
app.tenant_idfrom JWT on every request (via PgBouncer hook). - Rejects any request where
X-Tenant-Idheader does not match JWTtid. - Validates aggregate tenant membership at construction (domain invariant).
- Runs a "two-tenant simulator" integration test in CI that verifies all endpoints refuse cross-tenant access.
5. Offline Bundle Security
5.1 License Envelope
License Envelope = JWS(signed by tenant key, content = {
tenantId, userId, deviceId, bundleId, courseVersionId,
issuedAt, expiresAt, nonce
})
On mount, delivery-service:
- Verifies JWS signature against tenant public key.
- Confirms
deviceIdin envelope matches JWTdid. - Confirms
expiresAt > now(). - Confirms
tenantId,userIdmatch JWT. - Confirms
bundleIdcorresponds to a known bundle. - Validates bundle checksum against content-service's published checksum.
5.2 Bundle Encryption
Bundle content is encrypted with AES-GCM using a key derived from (tenantKey, devicePubKey, bundleId). Delivery does not handle decryption; the client runtime does. Delivery only verifies integrity.
5.3 Tamper Detection
If a client reports tampered content (via content.bundle.tamper_detected.v1), delivery-service:
- Force-unmounts all affected OfflineMounts.
- Pauses any active sessions using those mounts.
- Forwards tamper report to content-service for broader investigation.
- Alerts security team (PagerDuty P2).
6. Input Validation
All request bodies validated via class-validator (NestJS DTOs):
| Field | Validation |
|---|---|
enrollmentId | ULID format, exists, user-owned |
courseVersionId | ULID format, known version |
deviceId | ULID format, matches JWT did |
cursor.moduleId | Exists in manifest |
cursor.lessonId | Exists in manifest, part of moduleId |
prompt (tutor) | Max 2000 chars, non-empty, UTF-8 valid |
licenseEnvelope | Valid JWS format, signature verifies |
bundleChecksum | SHA-256 format |
7. Rate Limiting
Per API_CONTRACTS §4. Implementation uses Redis counters with token-bucket algorithm:
Key: delivery:ratelimit:{endpoint}:{scope}
Value: { tokens: int, lastRefill: timestamp }
TTL: 2x window
Rate-limit exceeded returns 429 Too Many Requests with Retry-After header.
8. Secrets Management
| Secret | Storage | Rotation |
|---|---|---|
| JWT verification key (JWKS) | KMS via identity-service JWKS endpoint | Auto-rotated by identity |
| Database password | AWS Secrets Manager / Vault | 90 days |
| Redis password | AWS Secrets Manager / Vault | 90 days |
| NATS credentials | AWS Secrets Manager / Vault | 90 days |
| AI Gateway token | AWS Secrets Manager / Vault | 30 days |
| Tenant signing keys (for envelope) | KMS, per-tenant | 180 days |
All secrets loaded at startup; never logged, never returned in API responses.
9. Audit Logging
The following operations produce audit events sent to the audit sink:
| Operation | Event |
|---|---|
| Session start | audit.delivery.session_started |
| Offline mount | audit.delivery.offline_mounted (includes license hash) |
| Offline unmount with tamper | audit.delivery.tamper_response |
| Cross-tenant attempt | audit.security.cross_tenant_rejected |
| Unauthorized access | audit.security.unauthorized_access |
Audit events include actor.id, tenant_id, trace_id, source IP, user agent, and action-specific metadata.
10. Compliance
| Regulation | Delivery-Specific Requirements |
|---|---|
| GDPR | Participate in subject deletion via gdpr.subject_request.received.v1; delete sessions and tutor turns for subject |
| COPPA | Verify tenant COPPA flag before tutor turns for minors; redact names in prompts |
| FERPA | Educational institution tenants; strict audit logging for session access by non-owners |
| SOC 2 | Audit trail for all session lifecycle operations |
| HIPAA | Not applicable (no PHI in delivery context) |
11. Security Testing
| Test | Frequency |
|---|---|
| SAST (Semgrep) | Every PR |
| DAST (OWASP ZAP) | Nightly against staging |
| Dependency scan (Snyk / npm audit) | Every PR + daily |
| Pen test | Annual + after major releases |
| Tenant isolation test | Every PR (CI) |
| Offline bundle tamper test | Weekly |
| JWT fuzzing | Monthly |
| License envelope fuzzing | Monthly |
12. Incident Response
| Severity | Condition | Response |
|---|---|---|
| P1 | Cross-tenant data leak confirmed | Immediate service halt, legal/compliance notification, incident bridge |
| P2 | Tamper detection spike | Investigate bundle, rotate tenant keys, alert content team |
| P3 | AI tutor prompt injection succeeding | Update classifier, review affected turns, notify ai-gateway team |
| P3 | Rate-limit bypass | Patch, deploy, monitor |
All P1/P2 incidents require post-mortem with action items tracked in platform backlog.