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 }