530 lines
16 KiB
Go

package main
import (
"context"
"net/http"
"strings"
"time"
)
const (
dataSubjectRequestTypeCorrection = "correction"
dataSubjectRequestTypeRestriction = "restriction"
dataSubjectRequestTypeErasure = "erasure"
dataSubjectRequestStatusPending = "pending"
dataSubjectRequestStatusCompleted = "completed"
)
func (a *app) educationalRecordsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if !hasAnyRole(claims, "admin", "instructor", "platform_owner", "platform_admin") {
writeError(w, http.StatusForbidden, "insufficient role for educational records")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) educationalComplianceReport(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
filter := Doc{}
if enforceTenant {
filter["tenantId"] = tenantID
}
users, err := a.list("users", filter, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load users for compliance report")
return
}
enrollments, err := a.list("enrollments", filter, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load enrollments for compliance report")
return
}
auditEvents, err := a.list("audit_events", filter, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load audit events for compliance report")
return
}
writeJSON(w, http.StatusOK, Doc{
"scope": "GDPR + FERPA-style educational records controls",
"tenantId": tenantID,
"tenantScoped": enforceTenant,
"classification": Doc{
"identityData": Doc{
"collection": "users",
"fields": []string{"id", "email", "name", "username", "roles", "tenantId"},
"count": len(users),
},
"educationalRecords": Doc{
"collection": "enrollments",
"fields": []string{"userId", "courseId", "status", "progress", "score", "completedAt"},
"count": len(enrollments),
},
"operationalAudit": Doc{
"collection": "audit_events",
"fields": []string{"actor", "action", "resource", "result", "timestamp", "metadata"},
"count": len(auditEvents),
},
},
"governance": Doc{
"purposeLimitation": "Educational records are used for training delivery, progress tracking, certification, and support.",
"minimumNecessary": "Endpoints should only return fields required for authorized operational use.",
"retentionPolicy": Doc{
"educationalRecordRetentionDays": a.cfg.EducationalRecordRetentionDays,
},
},
})
}
func (a *app) applyEducationalRetentionPolicy(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
now := time.Now().UTC()
cutoff := now.Add(-time.Duration(a.cfg.EducationalRecordRetentionDays) * 24 * time.Hour).UnixMilli()
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
enrollmentCollection, err := a.resolveCollection("enrollments")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve enrollments collection")
return
}
requestCollection, err := a.resolveCollection("data_subject_requests")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve data subject requests collection")
return
}
enrollmentResult, err := enrollmentCollection.UpdateMany(ctx, Doc{
"updatedAt": Doc{"$lt": cutoff},
"status": Doc{"$in": List{"completed", "failed"}},
"archivedAt": Doc{"$exists": false},
}, Doc{
"$set": Doc{
"archivedAt": now.UnixMilli(),
"archivedReason": "educational_retention_policy",
"dataMinimized": true,
},
"$unset": Doc{
"score": "",
"progress": "",
"timeSpentMin": "",
},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to apply educational retention policy")
return
}
requestResult, err := requestCollection.UpdateMany(ctx, Doc{
"updatedAt": Doc{"$lt": cutoff},
"status": dataSubjectRequestStatusCompleted,
"archivedAt": Doc{"$exists": false},
}, Doc{"$set": Doc{
"archivedAt": now.UnixMilli(),
"archivedReason": "educational_retention_policy",
}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to apply data subject request retention policy")
return
}
writeJSON(w, http.StatusOK, Doc{
"enrollmentsArchived": enrollmentResult.ModifiedCount,
"dataSubjectRequestsArchived": requestResult.ModifiedCount,
"cutoff": cutoff,
})
}
func (a *app) exportMyDataSubjectPackage(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
userID := strings.TrimSpace(claims.Sub)
if userID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
filter := Doc{"userId": userID}
if enforceTenant {
filter["tenantId"] = tenantID
}
users, err := a.list("users", Doc{"id": userID}, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to export user profile")
return
}
enrollments, err := a.list("enrollments", filter, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to export educational records")
return
}
consentReceipts, err := a.list("consent_receipts", filter, Sort{{Key: "decidedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to export consent receipts")
return
}
requests, err := a.list("data_subject_requests", filter, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to export data subject requests")
return
}
a.recordAuditEvent(r.Context(), claims, "compliance.data_subject.export", "data_subject", auditResultSuccess, map[string]any{
"tenantScoped": enforceTenant,
"recordTypes": []string{"users", "enrollments", "consent_receipts", "data_subject_requests"},
})
writeJSON(w, http.StatusOK, Doc{
"subject": userID,
"scope": "GDPR + FERPA-style educational records controls",
"exportedAt": time.Now().UTC().Format(time.RFC3339),
"data": Doc{
"identity": users,
"educationalRecords": enrollments,
"consentReceipts": consentReceipts,
"requests": requests,
},
})
}
func (a *app) submitMyDataCorrectionRequest(w http.ResponseWriter, r *http.Request) {
a.submitMyDataSubjectRequest(w, r, dataSubjectRequestTypeCorrection)
}
func (a *app) submitMyDataRestrictionRequest(w http.ResponseWriter, r *http.Request) {
a.submitMyDataSubjectRequest(w, r, dataSubjectRequestTypeRestriction)
}
func (a *app) submitMyDataSubjectRequest(w http.ResponseWriter, r *http.Request, requestType string) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid request payload")
return
}
subjectID := strings.TrimSpace(claims.Sub)
if subjectID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
tenantID, _ := entitlementTenantScopeFromRequest(r)
requestDoc := Doc{
"userId": subjectID,
"tenantId": tenantID,
"type": requestType,
"status": dataSubjectRequestStatusPending,
"requestedAt": time.Now().UTC().UnixMilli(),
"payload": sanitizeDataSubjectPayload(payload),
}
created, err := a.create("data_subject_requests", requestDoc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to record data subject request")
return
}
a.recordAuditEvent(r.Context(), claims, "compliance.data_subject."+requestType, "data_subject", auditResultSuccess, map[string]any{
"requestId": created["id"],
})
writeJSON(w, http.StatusAccepted, Doc{
"request": created,
})
}
func (a *app) eraseMyDataSubjectRecord(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload struct {
Mode string `json:"mode"`
Reason string `json:"reason"`
}
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid request payload")
return
}
subjectID := strings.TrimSpace(claims.Sub)
if subjectID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
mode := strings.TrimSpace(strings.ToLower(payload.Mode))
if mode == "" {
mode = "anonymize"
}
if mode != "anonymize" {
writeError(w, http.StatusBadRequest, "mode must be anonymize")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
now := time.Now().UTC().UnixMilli()
userPatch := Doc{
"email": "deleted+" + subjectID + "@example.invalid",
"name": "Deleted User",
"username": "deleted-" + subjectID,
"preferred_username": "deleted-" + subjectID,
"status": "inactive",
"gdprErasedAt": now,
}
user, err := a.patch("users", subjectID, userPatch)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to anonymize user identity")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
enrollmentCollection, err := a.resolveCollection("enrollments")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve enrollments collection")
return
}
enrollmentFilter := Doc{"userId": subjectID}
if enforceTenant {
enrollmentFilter["tenantId"] = tenantID
}
enrollmentResult, err := enrollmentCollection.UpdateMany(ctx, enrollmentFilter, Doc{
"$set": Doc{
"userId": "erased:" + subjectID,
"erasedUserId": subjectID,
"gdprErasedAt": now,
"updatedAt": now,
"archivedReason": "data_subject_erasure",
},
"$unset": Doc{
"score": "",
"progress": "",
},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to anonymize educational records")
return
}
requestDoc := Doc{
"userId": subjectID,
"tenantId": tenantID,
"type": dataSubjectRequestTypeErasure,
"status": dataSubjectRequestStatusCompleted,
"requestedAt": now,
"completedAt": now,
"mode": mode,
"reason": strings.TrimSpace(payload.Reason),
}
_, _ = a.create("data_subject_requests", requestDoc)
a.recordAuditEvent(r.Context(), claims, "compliance.data_subject.erase", "data_subject", auditResultSuccess, map[string]any{
"mode": mode,
"tenantScoped": enforceTenant,
"enrollmentsAnonymized": enrollmentResult.ModifiedCount,
})
writeJSON(w, http.StatusOK, Doc{
"status": "completed",
"userId": user["id"],
"enrollmentsAnonymized": enrollmentResult.ModifiedCount,
"completedAt": now,
})
}
func (a *app) getMyConsentReceipt(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
userID := strings.TrimSpace(claims.Sub)
if userID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
filter := Doc{"userId": userID, "revokedAt": Doc{"$exists": false}}
if enforceTenant {
filter["tenantId"] = tenantID
}
receipts, err := a.list("consent_receipts", filter, Sort{{Key: "decidedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load consent receipt")
return
}
if len(receipts) == 0 {
writeJSON(w, http.StatusOK, Doc{"receipt": nil})
return
}
writeJSON(w, http.StatusOK, Doc{"receipt": receipts[0]})
}
func (a *app) upsertMyConsentReceipt(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload struct {
PolicyVersion string `json:"policyVersion"`
PolicyURL string `json:"policyUrl"`
Analytics bool `json:"analytics"`
Marketing bool `json:"marketing"`
Preferences bool `json:"preferences"`
}
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid consent payload")
return
}
userID := strings.TrimSpace(claims.Sub)
if userID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
policyVersion := strings.TrimSpace(payload.PolicyVersion)
if policyVersion == "" {
writeError(w, http.StatusBadRequest, "policyVersion is required")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
now := time.Now().UTC().UnixMilli()
filter := Doc{"userId": userID, "revokedAt": Doc{"$exists": false}}
if enforceTenant {
filter["tenantId"] = tenantID
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
collection, err := a.resolveCollection("consent_receipts")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve consent receipts collection")
return
}
_, _ = collection.UpdateMany(ctx, filter, Doc{"$set": Doc{
"revokedAt": now,
"revokedReason": "superseded",
"updatedAt": now,
}})
doc := Doc{
"userId": userID,
"tenantId": tenantID,
"policyVersion": policyVersion,
"policyUrl": strings.TrimSpace(payload.PolicyURL),
"categories": Doc{
"necessary": true,
"analytics": payload.Analytics,
"marketing": payload.Marketing,
"preferences": payload.Preferences,
},
"decidedAt": now,
}
created, err := a.create("consent_receipts", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to store consent receipt")
return
}
a.recordAuditEvent(r.Context(), claims, "consent.receipt.upsert", "consent_receipts", auditResultSuccess, map[string]any{
"policyVersion": policyVersion,
})
writeJSON(w, http.StatusOK, Doc{"receipt": created})
}
func claimsFromRequest(r *http.Request) (*accessClaims, bool) {
if r == nil {
return nil, false
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil {
return nil, false
}
return claims, true
}
func sanitizeDataSubjectPayload(payload map[string]any) Doc {
if payload == nil {
return Doc{}
}
out := Doc{}
if reason, ok := payload["reason"].(string); ok {
out["reason"] = strings.TrimSpace(reason)
}
if fields, ok := payload["fields"].(map[string]any); ok {
allowed := Doc{}
for key, raw := range fields {
value, isString := raw.(string)
if !isString {
continue
}
switch strings.TrimSpace(key) {
case "name", "displayName", "email":
allowed[key] = strings.TrimSpace(value)
}
}
if len(allowed) > 0 {
out["fields"] = allowed
}
}
return out
}