530 lines
16 KiB
Go
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
|
|
}
|