A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
445 lines
12 KiB
Go
445 lines
12 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ExportFormat represents the export format
|
|
type ExportFormat string
|
|
|
|
const (
|
|
FormatCSV ExportFormat = "csv"
|
|
FormatJSON ExportFormat = "json"
|
|
)
|
|
|
|
// Exporter exports audit data in various formats
|
|
type Exporter struct {
|
|
store *Store
|
|
}
|
|
|
|
// NewExporter creates a new exporter
|
|
func NewExporter(store *Store) *Exporter {
|
|
return &Exporter{store: store}
|
|
}
|
|
|
|
// ExportOptions defines export options
|
|
type ExportOptions struct {
|
|
TenantID uuid.UUID
|
|
NamespaceID *uuid.UUID
|
|
UserID *uuid.UUID
|
|
StartDate time.Time
|
|
EndDate time.Time
|
|
Format ExportFormat
|
|
IncludePII bool // If false, redact PII fields
|
|
}
|
|
|
|
// ExportLLMAudit exports LLM audit entries
|
|
func (e *Exporter) ExportLLMAudit(ctx context.Context, w io.Writer, opts *ExportOptions) error {
|
|
filter := &LLMAuditFilter{
|
|
TenantID: opts.TenantID,
|
|
NamespaceID: opts.NamespaceID,
|
|
UserID: opts.UserID,
|
|
StartDate: &opts.StartDate,
|
|
EndDate: &opts.EndDate,
|
|
Limit: 0, // No limit for export
|
|
}
|
|
|
|
entries, _, err := e.store.QueryLLMAuditEntries(ctx, filter)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query audit entries: %w", err)
|
|
}
|
|
|
|
switch opts.Format {
|
|
case FormatCSV:
|
|
return e.exportLLMAuditCSV(w, entries, opts.IncludePII)
|
|
case FormatJSON:
|
|
return e.exportLLMAuditJSON(w, entries, opts.IncludePII)
|
|
default:
|
|
return fmt.Errorf("unsupported format: %s", opts.Format)
|
|
}
|
|
}
|
|
|
|
// ExportGeneralAudit exports general audit entries
|
|
func (e *Exporter) ExportGeneralAudit(ctx context.Context, w io.Writer, opts *ExportOptions) error {
|
|
filter := &GeneralAuditFilter{
|
|
TenantID: opts.TenantID,
|
|
NamespaceID: opts.NamespaceID,
|
|
UserID: opts.UserID,
|
|
StartDate: &opts.StartDate,
|
|
EndDate: &opts.EndDate,
|
|
Limit: 0,
|
|
}
|
|
|
|
entries, _, err := e.store.QueryGeneralAuditEntries(ctx, filter)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to query audit entries: %w", err)
|
|
}
|
|
|
|
switch opts.Format {
|
|
case FormatCSV:
|
|
return e.exportGeneralAuditCSV(w, entries, opts.IncludePII)
|
|
case FormatJSON:
|
|
return e.exportGeneralAuditJSON(w, entries, opts.IncludePII)
|
|
default:
|
|
return fmt.Errorf("unsupported format: %s", opts.Format)
|
|
}
|
|
}
|
|
|
|
func (e *Exporter) exportLLMAuditCSV(w io.Writer, entries []*LLMAuditEntry, includePII bool) error {
|
|
writer := csv.NewWriter(w)
|
|
defer writer.Flush()
|
|
|
|
// Write header
|
|
header := []string{
|
|
"ID", "Tenant ID", "Namespace ID", "User ID", "Session ID",
|
|
"Operation", "Model", "Provider", "Prompt Hash", "Prompt Length", "Response Length",
|
|
"Tokens Used", "Duration (ms)", "PII Detected", "PII Types", "PII Redacted",
|
|
"Policy ID", "Policy Violations", "Data Categories", "Error", "Created At",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write entries
|
|
for _, entry := range entries {
|
|
namespaceID := ""
|
|
if entry.NamespaceID != nil {
|
|
namespaceID = entry.NamespaceID.String()
|
|
}
|
|
|
|
policyID := ""
|
|
if entry.PolicyID != nil {
|
|
policyID = entry.PolicyID.String()
|
|
}
|
|
|
|
userID := entry.UserID.String()
|
|
sessionID := entry.SessionID
|
|
if !includePII {
|
|
userID = "[REDACTED]"
|
|
sessionID = "[REDACTED]"
|
|
}
|
|
|
|
row := []string{
|
|
entry.ID.String(),
|
|
entry.TenantID.String(),
|
|
namespaceID,
|
|
userID,
|
|
sessionID,
|
|
entry.Operation,
|
|
entry.ModelUsed,
|
|
entry.Provider,
|
|
entry.PromptHash,
|
|
fmt.Sprintf("%d", entry.PromptLength),
|
|
fmt.Sprintf("%d", entry.ResponseLength),
|
|
fmt.Sprintf("%d", entry.TokensUsed),
|
|
fmt.Sprintf("%d", entry.DurationMS),
|
|
fmt.Sprintf("%t", entry.PIIDetected),
|
|
strings.Join(entry.PIITypesDetected, ";"),
|
|
fmt.Sprintf("%t", entry.PIIRedacted),
|
|
policyID,
|
|
strings.Join(entry.PolicyViolations, ";"),
|
|
strings.Join(entry.DataCategoriesAccessed, ";"),
|
|
entry.ErrorMessage,
|
|
entry.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
|
|
if err := writer.Write(row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Exporter) exportLLMAuditJSON(w io.Writer, entries []*LLMAuditEntry, includePII bool) error {
|
|
// Redact PII if needed
|
|
if !includePII {
|
|
for _, entry := range entries {
|
|
entry.UserID = uuid.Nil
|
|
entry.SessionID = "[REDACTED]"
|
|
entry.RequestMetadata = nil
|
|
}
|
|
}
|
|
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(map[string]any{
|
|
"type": "llm_audit_export",
|
|
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
|
"count": len(entries),
|
|
"entries": entries,
|
|
})
|
|
}
|
|
|
|
func (e *Exporter) exportGeneralAuditCSV(w io.Writer, entries []*GeneralAuditEntry, includePII bool) error {
|
|
writer := csv.NewWriter(w)
|
|
defer writer.Flush()
|
|
|
|
// Write header
|
|
header := []string{
|
|
"ID", "Tenant ID", "Namespace ID", "User ID",
|
|
"Action", "Resource Type", "Resource ID",
|
|
"IP Address", "User Agent", "Reason", "Created At",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write entries
|
|
for _, entry := range entries {
|
|
namespaceID := ""
|
|
if entry.NamespaceID != nil {
|
|
namespaceID = entry.NamespaceID.String()
|
|
}
|
|
|
|
resourceID := ""
|
|
if entry.ResourceID != nil {
|
|
resourceID = entry.ResourceID.String()
|
|
}
|
|
|
|
userID := entry.UserID.String()
|
|
ipAddress := entry.IPAddress
|
|
userAgent := entry.UserAgent
|
|
if !includePII {
|
|
userID = "[REDACTED]"
|
|
ipAddress = "[REDACTED]"
|
|
userAgent = "[REDACTED]"
|
|
}
|
|
|
|
row := []string{
|
|
entry.ID.String(),
|
|
entry.TenantID.String(),
|
|
namespaceID,
|
|
userID,
|
|
entry.Action,
|
|
entry.ResourceType,
|
|
resourceID,
|
|
ipAddress,
|
|
userAgent,
|
|
entry.Reason,
|
|
entry.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
|
|
if err := writer.Write(row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Exporter) exportGeneralAuditJSON(w io.Writer, entries []*GeneralAuditEntry, includePII bool) error {
|
|
// Redact PII if needed
|
|
if !includePII {
|
|
for _, entry := range entries {
|
|
entry.UserID = uuid.Nil
|
|
entry.IPAddress = "[REDACTED]"
|
|
entry.UserAgent = "[REDACTED]"
|
|
}
|
|
}
|
|
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(map[string]any{
|
|
"type": "general_audit_export",
|
|
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
|
"count": len(entries),
|
|
"entries": entries,
|
|
})
|
|
}
|
|
|
|
// ExportUsageStats exports LLM usage statistics
|
|
func (e *Exporter) ExportUsageStats(ctx context.Context, w io.Writer, tenantID uuid.UUID, startDate, endDate time.Time, format ExportFormat) error {
|
|
stats, err := e.store.GetLLMUsageStats(ctx, tenantID, startDate, endDate)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get usage stats: %w", err)
|
|
}
|
|
|
|
report := map[string]any{
|
|
"type": "llm_usage_report",
|
|
"tenant_id": tenantID.String(),
|
|
"period_start": startDate.Format(time.RFC3339),
|
|
"period_end": endDate.Format(time.RFC3339),
|
|
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
|
"total_requests": stats.TotalRequests,
|
|
"total_tokens": stats.TotalTokens,
|
|
"total_duration_ms": stats.TotalDurationMS,
|
|
"requests_with_pii": stats.RequestsWithPII,
|
|
"policy_violations": stats.PolicyViolations,
|
|
"models_used": stats.ModelsUsed,
|
|
}
|
|
|
|
switch format {
|
|
case FormatJSON:
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(report)
|
|
case FormatCSV:
|
|
writer := csv.NewWriter(w)
|
|
defer writer.Flush()
|
|
|
|
// Write summary as CSV
|
|
if err := writer.Write([]string{"Metric", "Value"}); err != nil {
|
|
return err
|
|
}
|
|
|
|
rows := [][]string{
|
|
{"Total Requests", fmt.Sprintf("%d", stats.TotalRequests)},
|
|
{"Total Tokens", fmt.Sprintf("%d", stats.TotalTokens)},
|
|
{"Total Duration (ms)", fmt.Sprintf("%d", stats.TotalDurationMS)},
|
|
{"Requests with PII", fmt.Sprintf("%d", stats.RequestsWithPII)},
|
|
{"Policy Violations", fmt.Sprintf("%d", stats.PolicyViolations)},
|
|
}
|
|
|
|
for model, count := range stats.ModelsUsed {
|
|
rows = append(rows, []string{fmt.Sprintf("Model: %s", model), fmt.Sprintf("%d", count)})
|
|
}
|
|
|
|
for _, row := range rows {
|
|
if err := writer.Write(row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported format: %s", format)
|
|
}
|
|
}
|
|
|
|
// ComplianceReport generates a compliance report
|
|
type ComplianceReport struct {
|
|
TenantID uuid.UUID `json:"tenant_id"`
|
|
PeriodStart time.Time `json:"period_start"`
|
|
PeriodEnd time.Time `json:"period_end"`
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
TotalLLMRequests int `json:"total_llm_requests"`
|
|
TotalTokensUsed int `json:"total_tokens_used"`
|
|
PIIIncidents int `json:"pii_incidents"`
|
|
PIIRedactionRate float64 `json:"pii_redaction_rate"`
|
|
PolicyViolations int `json:"policy_violations"`
|
|
ModelsUsed map[string]int `json:"models_used"`
|
|
TopUsers []UserActivity `json:"top_users,omitempty"`
|
|
NamespaceBreakdown []NamespaceStats `json:"namespace_breakdown,omitempty"`
|
|
}
|
|
|
|
// UserActivity represents user activity in the report
|
|
type UserActivity struct {
|
|
UserID uuid.UUID `json:"user_id"`
|
|
RequestCount int `json:"request_count"`
|
|
TokensUsed int `json:"tokens_used"`
|
|
PIIIncidents int `json:"pii_incidents"`
|
|
}
|
|
|
|
// NamespaceStats represents namespace statistics
|
|
type NamespaceStats struct {
|
|
NamespaceID uuid.UUID `json:"namespace_id"`
|
|
NamespaceName string `json:"namespace_name,omitempty"`
|
|
RequestCount int `json:"request_count"`
|
|
TokensUsed int `json:"tokens_used"`
|
|
}
|
|
|
|
// GenerateComplianceReport generates a detailed compliance report
|
|
func (e *Exporter) GenerateComplianceReport(ctx context.Context, tenantID uuid.UUID, startDate, endDate time.Time) (*ComplianceReport, error) {
|
|
// Get basic stats
|
|
stats, err := e.store.GetLLMUsageStats(ctx, tenantID, startDate, endDate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Calculate PII redaction rate
|
|
var redactionRate float64
|
|
if stats.RequestsWithPII > 0 {
|
|
// Query entries with PII to check redaction
|
|
filter := &LLMAuditFilter{
|
|
TenantID: tenantID,
|
|
PIIDetected: boolPtr(true),
|
|
StartDate: &startDate,
|
|
EndDate: &endDate,
|
|
}
|
|
entries, _, err := e.store.QueryLLMAuditEntries(ctx, filter)
|
|
if err == nil {
|
|
redactedCount := 0
|
|
for _, e := range entries {
|
|
if e.PIIRedacted {
|
|
redactedCount++
|
|
}
|
|
}
|
|
redactionRate = float64(redactedCount) / float64(len(entries)) * 100
|
|
}
|
|
}
|
|
|
|
report := &ComplianceReport{
|
|
TenantID: tenantID,
|
|
PeriodStart: startDate,
|
|
PeriodEnd: endDate,
|
|
GeneratedAt: time.Now().UTC(),
|
|
TotalLLMRequests: stats.TotalRequests,
|
|
TotalTokensUsed: stats.TotalTokens,
|
|
PIIIncidents: stats.RequestsWithPII,
|
|
PIIRedactionRate: redactionRate,
|
|
PolicyViolations: stats.PolicyViolations,
|
|
ModelsUsed: stats.ModelsUsed,
|
|
}
|
|
|
|
return report, nil
|
|
}
|
|
|
|
// ExportComplianceReport exports a compliance report
|
|
func (e *Exporter) ExportComplianceReport(ctx context.Context, w io.Writer, tenantID uuid.UUID, startDate, endDate time.Time, format ExportFormat) error {
|
|
report, err := e.GenerateComplianceReport(ctx, tenantID, startDate, endDate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch format {
|
|
case FormatJSON:
|
|
encoder := json.NewEncoder(w)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(report)
|
|
case FormatCSV:
|
|
writer := csv.NewWriter(w)
|
|
defer writer.Flush()
|
|
|
|
// Write header
|
|
if err := writer.Write([]string{"Metric", "Value"}); err != nil {
|
|
return err
|
|
}
|
|
|
|
rows := [][]string{
|
|
{"Report Type", "AI Compliance Report"},
|
|
{"Tenant ID", report.TenantID.String()},
|
|
{"Period Start", report.PeriodStart.Format(time.RFC3339)},
|
|
{"Period End", report.PeriodEnd.Format(time.RFC3339)},
|
|
{"Generated At", report.GeneratedAt.Format(time.RFC3339)},
|
|
{"Total LLM Requests", fmt.Sprintf("%d", report.TotalLLMRequests)},
|
|
{"Total Tokens Used", fmt.Sprintf("%d", report.TotalTokensUsed)},
|
|
{"PII Incidents", fmt.Sprintf("%d", report.PIIIncidents)},
|
|
{"PII Redaction Rate", fmt.Sprintf("%.2f%%", report.PIIRedactionRate)},
|
|
{"Policy Violations", fmt.Sprintf("%d", report.PolicyViolations)},
|
|
}
|
|
|
|
for _, row := range rows {
|
|
if err := writer.Write(row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unsupported format: %s", format)
|
|
}
|
|
}
|
|
|
|
func boolPtr(b bool) *bool {
|
|
return &b
|
|
}
|