fix: Restore all files lost during destructive rebase

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>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,444 @@
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
}

View File

@@ -0,0 +1,472 @@
package audit
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store provides database operations for audit logs
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new audit store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// LLMAuditEntry represents an LLM audit log entry
type LLMAuditEntry struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
NamespaceID *uuid.UUID `json:"namespace_id,omitempty" db:"namespace_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
SessionID string `json:"session_id,omitempty" db:"session_id"`
Operation string `json:"operation" db:"operation"`
ModelUsed string `json:"model_used" db:"model_used"`
Provider string `json:"provider" db:"provider"`
PromptHash string `json:"prompt_hash" db:"prompt_hash"`
PromptLength int `json:"prompt_length" db:"prompt_length"`
ResponseLength int `json:"response_length,omitempty" db:"response_length"`
TokensUsed int `json:"tokens_used" db:"tokens_used"`
DurationMS int `json:"duration_ms" db:"duration_ms"`
PIIDetected bool `json:"pii_detected" db:"pii_detected"`
PIITypesDetected []string `json:"pii_types_detected,omitempty" db:"pii_types_detected"`
PIIRedacted bool `json:"pii_redacted" db:"pii_redacted"`
PolicyID *uuid.UUID `json:"policy_id,omitempty" db:"policy_id"`
PolicyViolations []string `json:"policy_violations,omitempty" db:"policy_violations"`
DataCategoriesAccessed []string `json:"data_categories_accessed,omitempty" db:"data_categories_accessed"`
ErrorMessage string `json:"error_message,omitempty" db:"error_message"`
RequestMetadata map[string]any `json:"request_metadata,omitempty" db:"request_metadata"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// GeneralAuditEntry represents a general audit trail entry
type GeneralAuditEntry struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
NamespaceID *uuid.UUID `json:"namespace_id,omitempty" db:"namespace_id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Action string `json:"action" db:"action"`
ResourceType string `json:"resource_type" db:"resource_type"`
ResourceID *uuid.UUID `json:"resource_id,omitempty" db:"resource_id"`
OldValues map[string]any `json:"old_values,omitempty" db:"old_values"`
NewValues map[string]any `json:"new_values,omitempty" db:"new_values"`
IPAddress string `json:"ip_address,omitempty" db:"ip_address"`
UserAgent string `json:"user_agent,omitempty" db:"user_agent"`
Reason string `json:"reason,omitempty" db:"reason"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// CreateLLMAuditEntry creates a new LLM audit log entry
func (s *Store) CreateLLMAuditEntry(ctx context.Context, entry *LLMAuditEntry) error {
if entry.ID == uuid.Nil {
entry.ID = uuid.New()
}
if entry.CreatedAt.IsZero() {
entry.CreatedAt = time.Now().UTC()
}
metadataJSON, _ := json.Marshal(entry.RequestMetadata)
_, err := s.pool.Exec(ctx, `
INSERT INTO compliance_llm_audit_log (
id, tenant_id, namespace_id, user_id, session_id,
operation, model_used, provider, prompt_hash, prompt_length, response_length,
tokens_used, duration_ms, pii_detected, pii_types_detected, pii_redacted,
policy_id, policy_violations, data_categories_accessed, error_message,
request_metadata, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
`,
entry.ID, entry.TenantID, entry.NamespaceID, entry.UserID, entry.SessionID,
entry.Operation, entry.ModelUsed, entry.Provider, entry.PromptHash, entry.PromptLength, entry.ResponseLength,
entry.TokensUsed, entry.DurationMS, entry.PIIDetected, entry.PIITypesDetected, entry.PIIRedacted,
entry.PolicyID, entry.PolicyViolations, entry.DataCategoriesAccessed, entry.ErrorMessage,
metadataJSON, entry.CreatedAt,
)
return err
}
// CreateGeneralAuditEntry creates a new general audit entry
func (s *Store) CreateGeneralAuditEntry(ctx context.Context, entry *GeneralAuditEntry) error {
if entry.ID == uuid.Nil {
entry.ID = uuid.New()
}
if entry.CreatedAt.IsZero() {
entry.CreatedAt = time.Now().UTC()
}
oldValuesJSON, _ := json.Marshal(entry.OldValues)
newValuesJSON, _ := json.Marshal(entry.NewValues)
_, err := s.pool.Exec(ctx, `
INSERT INTO compliance_audit_trail (
id, tenant_id, namespace_id, user_id, action, resource_type, resource_id,
old_values, new_values, ip_address, user_agent, reason, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
`,
entry.ID, entry.TenantID, entry.NamespaceID, entry.UserID,
entry.Action, entry.ResourceType, entry.ResourceID,
oldValuesJSON, newValuesJSON, entry.IPAddress, entry.UserAgent,
entry.Reason, entry.CreatedAt,
)
return err
}
// LLMAuditFilter defines filters for LLM audit queries
type LLMAuditFilter struct {
TenantID uuid.UUID
NamespaceID *uuid.UUID
UserID *uuid.UUID
Operation string
Model string
PIIDetected *bool
HasViolations *bool
StartDate *time.Time
EndDate *time.Time
Limit int
Offset int
}
// QueryLLMAuditEntries queries LLM audit entries with filters
func (s *Store) QueryLLMAuditEntries(ctx context.Context, filter *LLMAuditFilter) ([]*LLMAuditEntry, int, error) {
query := `
SELECT id, tenant_id, namespace_id, user_id, session_id,
operation, model_used, provider, prompt_hash, prompt_length, response_length,
tokens_used, duration_ms, pii_detected, pii_types_detected, pii_redacted,
policy_id, policy_violations, data_categories_accessed, error_message,
request_metadata, created_at
FROM compliance_llm_audit_log
WHERE tenant_id = $1
`
countQuery := `SELECT COUNT(*) FROM compliance_llm_audit_log WHERE tenant_id = $1`
args := []any{filter.TenantID}
argIndex := 2
if filter.NamespaceID != nil {
query += fmt.Sprintf(" AND namespace_id = $%d", argIndex)
countQuery += fmt.Sprintf(" AND namespace_id = $%d", argIndex)
args = append(args, *filter.NamespaceID)
argIndex++
}
if filter.UserID != nil {
query += fmt.Sprintf(" AND user_id = $%d", argIndex)
countQuery += fmt.Sprintf(" AND user_id = $%d", argIndex)
args = append(args, *filter.UserID)
argIndex++
}
if filter.Operation != "" {
query += fmt.Sprintf(" AND operation = $%d", argIndex)
countQuery += fmt.Sprintf(" AND operation = $%d", argIndex)
args = append(args, filter.Operation)
argIndex++
}
if filter.Model != "" {
query += fmt.Sprintf(" AND model_used = $%d", argIndex)
countQuery += fmt.Sprintf(" AND model_used = $%d", argIndex)
args = append(args, filter.Model)
argIndex++
}
if filter.PIIDetected != nil {
query += fmt.Sprintf(" AND pii_detected = $%d", argIndex)
countQuery += fmt.Sprintf(" AND pii_detected = $%d", argIndex)
args = append(args, *filter.PIIDetected)
argIndex++
}
if filter.HasViolations != nil && *filter.HasViolations {
query += " AND array_length(policy_violations, 1) > 0"
countQuery += " AND array_length(policy_violations, 1) > 0"
}
if filter.StartDate != nil {
query += fmt.Sprintf(" AND created_at >= $%d", argIndex)
countQuery += fmt.Sprintf(" AND created_at >= $%d", argIndex)
args = append(args, *filter.StartDate)
argIndex++
}
if filter.EndDate != nil {
query += fmt.Sprintf(" AND created_at <= $%d", argIndex)
countQuery += fmt.Sprintf(" AND created_at <= $%d", argIndex)
args = append(args, *filter.EndDate)
argIndex++
}
// Get total count
var totalCount int
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&totalCount); err != nil {
return nil, 0, err
}
// Add ordering and pagination
query += " ORDER BY created_at DESC"
if filter.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIndex)
args = append(args, filter.Limit)
argIndex++
}
if filter.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIndex)
args = append(args, filter.Offset)
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var entries []*LLMAuditEntry
for rows.Next() {
var entry LLMAuditEntry
var metadataJSON []byte
err := rows.Scan(
&entry.ID, &entry.TenantID, &entry.NamespaceID, &entry.UserID, &entry.SessionID,
&entry.Operation, &entry.ModelUsed, &entry.Provider, &entry.PromptHash, &entry.PromptLength, &entry.ResponseLength,
&entry.TokensUsed, &entry.DurationMS, &entry.PIIDetected, &entry.PIITypesDetected, &entry.PIIRedacted,
&entry.PolicyID, &entry.PolicyViolations, &entry.DataCategoriesAccessed, &entry.ErrorMessage,
&metadataJSON, &entry.CreatedAt,
)
if err != nil {
continue
}
if metadataJSON != nil {
json.Unmarshal(metadataJSON, &entry.RequestMetadata)
}
entries = append(entries, &entry)
}
return entries, totalCount, nil
}
// GeneralAuditFilter defines filters for general audit queries
type GeneralAuditFilter struct {
TenantID uuid.UUID
NamespaceID *uuid.UUID
UserID *uuid.UUID
Action string
ResourceType string
ResourceID *uuid.UUID
StartDate *time.Time
EndDate *time.Time
Limit int
Offset int
}
// QueryGeneralAuditEntries queries general audit entries with filters
func (s *Store) QueryGeneralAuditEntries(ctx context.Context, filter *GeneralAuditFilter) ([]*GeneralAuditEntry, int, error) {
query := `
SELECT id, tenant_id, namespace_id, user_id, action, resource_type, resource_id,
old_values, new_values, ip_address, user_agent, reason, created_at
FROM compliance_audit_trail
WHERE tenant_id = $1
`
countQuery := `SELECT COUNT(*) FROM compliance_audit_trail WHERE tenant_id = $1`
args := []any{filter.TenantID}
argIndex := 2
if filter.NamespaceID != nil {
query += fmt.Sprintf(" AND namespace_id = $%d", argIndex)
countQuery += fmt.Sprintf(" AND namespace_id = $%d", argIndex)
args = append(args, *filter.NamespaceID)
argIndex++
}
if filter.UserID != nil {
query += fmt.Sprintf(" AND user_id = $%d", argIndex)
countQuery += fmt.Sprintf(" AND user_id = $%d", argIndex)
args = append(args, *filter.UserID)
argIndex++
}
if filter.Action != "" {
query += fmt.Sprintf(" AND action = $%d", argIndex)
countQuery += fmt.Sprintf(" AND action = $%d", argIndex)
args = append(args, filter.Action)
argIndex++
}
if filter.ResourceType != "" {
query += fmt.Sprintf(" AND resource_type = $%d", argIndex)
countQuery += fmt.Sprintf(" AND resource_type = $%d", argIndex)
args = append(args, filter.ResourceType)
argIndex++
}
if filter.ResourceID != nil {
query += fmt.Sprintf(" AND resource_id = $%d", argIndex)
countQuery += fmt.Sprintf(" AND resource_id = $%d", argIndex)
args = append(args, *filter.ResourceID)
argIndex++
}
if filter.StartDate != nil {
query += fmt.Sprintf(" AND created_at >= $%d", argIndex)
countQuery += fmt.Sprintf(" AND created_at >= $%d", argIndex)
args = append(args, *filter.StartDate)
argIndex++
}
if filter.EndDate != nil {
query += fmt.Sprintf(" AND created_at <= $%d", argIndex)
countQuery += fmt.Sprintf(" AND created_at <= $%d", argIndex)
args = append(args, *filter.EndDate)
argIndex++
}
// Get total count
var totalCount int
if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&totalCount); err != nil {
return nil, 0, err
}
// Add ordering and pagination
query += " ORDER BY created_at DESC"
if filter.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIndex)
args = append(args, filter.Limit)
argIndex++
}
if filter.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIndex)
args = append(args, filter.Offset)
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var entries []*GeneralAuditEntry
for rows.Next() {
var entry GeneralAuditEntry
var oldValuesJSON, newValuesJSON []byte
err := rows.Scan(
&entry.ID, &entry.TenantID, &entry.NamespaceID, &entry.UserID,
&entry.Action, &entry.ResourceType, &entry.ResourceID,
&oldValuesJSON, &newValuesJSON, &entry.IPAddress, &entry.UserAgent,
&entry.Reason, &entry.CreatedAt,
)
if err != nil {
continue
}
if oldValuesJSON != nil {
json.Unmarshal(oldValuesJSON, &entry.OldValues)
}
if newValuesJSON != nil {
json.Unmarshal(newValuesJSON, &entry.NewValues)
}
entries = append(entries, &entry)
}
return entries, totalCount, nil
}
// GetLLMUsageStats retrieves aggregated LLM usage statistics
func (s *Store) GetLLMUsageStats(ctx context.Context, tenantID uuid.UUID, startDate, endDate time.Time) (*LLMUsageStats, error) {
var stats LLMUsageStats
err := s.pool.QueryRow(ctx, `
SELECT
COUNT(*) as total_requests,
COALESCE(SUM(tokens_used), 0) as total_tokens,
COALESCE(SUM(duration_ms), 0) as total_duration_ms,
COUNT(*) FILTER (WHERE pii_detected = TRUE) as requests_with_pii,
COUNT(*) FILTER (WHERE array_length(policy_violations, 1) > 0) as policy_violations
FROM compliance_llm_audit_log
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
`, tenantID, startDate, endDate).Scan(
&stats.TotalRequests,
&stats.TotalTokens,
&stats.TotalDurationMS,
&stats.RequestsWithPII,
&stats.PolicyViolations,
)
if err != nil {
return nil, err
}
// Get model usage breakdown
rows, err := s.pool.Query(ctx, `
SELECT model_used, COUNT(*) as count
FROM compliance_llm_audit_log
WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3
GROUP BY model_used
`, tenantID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
stats.ModelsUsed = make(map[string]int)
for rows.Next() {
var model string
var count int
if err := rows.Scan(&model, &count); err == nil {
stats.ModelsUsed[model] = count
}
}
return &stats, nil
}
// LLMUsageStats represents aggregated LLM usage statistics
type LLMUsageStats struct {
TotalRequests int `json:"total_requests"`
TotalTokens int `json:"total_tokens"`
TotalDurationMS int64 `json:"total_duration_ms"`
RequestsWithPII int `json:"requests_with_pii"`
PolicyViolations int `json:"policy_violations"`
ModelsUsed map[string]int `json:"models_used"`
}
// CleanupOldEntries removes audit entries older than the retention period
func (s *Store) CleanupOldEntries(ctx context.Context, retentionDays int) (int, int, error) {
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
// Cleanup LLM audit log
llmResult, err := s.pool.Exec(ctx, `
DELETE FROM compliance_llm_audit_log WHERE created_at < $1
`, cutoff)
if err != nil {
return 0, 0, err
}
// Cleanup general audit trail
generalResult, err := s.pool.Exec(ctx, `
DELETE FROM compliance_audit_trail WHERE created_at < $1
`, cutoff)
if err != nil {
return int(llmResult.RowsAffected()), 0, err
}
return int(llmResult.RowsAffected()), int(generalResult.RowsAffected()), nil
}

View File

@@ -0,0 +1,337 @@
package audit
import (
"context"
"time"
"github.com/google/uuid"
)
// TrailBuilder helps construct structured audit entries
type TrailBuilder struct {
store *Store
}
// NewTrailBuilder creates a new trail builder
func NewTrailBuilder(store *Store) *TrailBuilder {
return &TrailBuilder{store: store}
}
// LLMEntryBuilder builds LLM audit entries
type LLMEntryBuilder struct {
entry *LLMAuditEntry
store *Store
}
// NewLLMEntry creates a new LLM audit entry builder
func (tb *TrailBuilder) NewLLMEntry() *LLMEntryBuilder {
return &LLMEntryBuilder{
entry: &LLMAuditEntry{
ID: uuid.New(),
PIITypesDetected: []string{},
PolicyViolations: []string{},
DataCategoriesAccessed: []string{},
RequestMetadata: make(map[string]any),
CreatedAt: time.Now().UTC(),
},
store: tb.store,
}
}
// WithTenant sets the tenant ID
func (b *LLMEntryBuilder) WithTenant(tenantID uuid.UUID) *LLMEntryBuilder {
b.entry.TenantID = tenantID
return b
}
// WithNamespace sets the namespace ID
func (b *LLMEntryBuilder) WithNamespace(namespaceID uuid.UUID) *LLMEntryBuilder {
b.entry.NamespaceID = &namespaceID
return b
}
// WithUser sets the user ID
func (b *LLMEntryBuilder) WithUser(userID uuid.UUID) *LLMEntryBuilder {
b.entry.UserID = userID
return b
}
// WithSession sets the session ID
func (b *LLMEntryBuilder) WithSession(sessionID string) *LLMEntryBuilder {
b.entry.SessionID = sessionID
return b
}
// WithOperation sets the operation type
func (b *LLMEntryBuilder) WithOperation(operation string) *LLMEntryBuilder {
b.entry.Operation = operation
return b
}
// WithModel sets the model used
func (b *LLMEntryBuilder) WithModel(model, provider string) *LLMEntryBuilder {
b.entry.ModelUsed = model
b.entry.Provider = provider
return b
}
// WithPrompt sets prompt-related fields
func (b *LLMEntryBuilder) WithPrompt(hash string, length int) *LLMEntryBuilder {
b.entry.PromptHash = hash
b.entry.PromptLength = length
return b
}
// WithResponse sets response-related fields
func (b *LLMEntryBuilder) WithResponse(length int) *LLMEntryBuilder {
b.entry.ResponseLength = length
return b
}
// WithUsage sets token usage and duration
func (b *LLMEntryBuilder) WithUsage(tokens int, durationMS int) *LLMEntryBuilder {
b.entry.TokensUsed = tokens
b.entry.DurationMS = durationMS
return b
}
// WithPII sets PII detection fields
func (b *LLMEntryBuilder) WithPII(detected bool, types []string, redacted bool) *LLMEntryBuilder {
b.entry.PIIDetected = detected
b.entry.PIITypesDetected = types
b.entry.PIIRedacted = redacted
return b
}
// WithPolicy sets policy-related fields
func (b *LLMEntryBuilder) WithPolicy(policyID *uuid.UUID, violations []string) *LLMEntryBuilder {
b.entry.PolicyID = policyID
b.entry.PolicyViolations = violations
return b
}
// WithDataCategories sets accessed data categories
func (b *LLMEntryBuilder) WithDataCategories(categories []string) *LLMEntryBuilder {
b.entry.DataCategoriesAccessed = categories
return b
}
// WithError sets error message
func (b *LLMEntryBuilder) WithError(errMsg string) *LLMEntryBuilder {
b.entry.ErrorMessage = errMsg
return b
}
// WithMetadata sets request metadata
func (b *LLMEntryBuilder) WithMetadata(metadata map[string]any) *LLMEntryBuilder {
b.entry.RequestMetadata = metadata
return b
}
// AddMetadata adds a key-value pair to metadata
func (b *LLMEntryBuilder) AddMetadata(key string, value any) *LLMEntryBuilder {
if b.entry.RequestMetadata == nil {
b.entry.RequestMetadata = make(map[string]any)
}
b.entry.RequestMetadata[key] = value
return b
}
// Build returns the built entry
func (b *LLMEntryBuilder) Build() *LLMAuditEntry {
return b.entry
}
// Save persists the entry to the database
func (b *LLMEntryBuilder) Save(ctx context.Context) error {
return b.store.CreateLLMAuditEntry(ctx, b.entry)
}
// GeneralEntryBuilder builds general audit entries
type GeneralEntryBuilder struct {
entry *GeneralAuditEntry
store *Store
}
// NewGeneralEntry creates a new general audit entry builder
func (tb *TrailBuilder) NewGeneralEntry() *GeneralEntryBuilder {
return &GeneralEntryBuilder{
entry: &GeneralAuditEntry{
ID: uuid.New(),
OldValues: make(map[string]any),
NewValues: make(map[string]any),
CreatedAt: time.Now().UTC(),
},
store: tb.store,
}
}
// WithTenant sets the tenant ID
func (b *GeneralEntryBuilder) WithTenant(tenantID uuid.UUID) *GeneralEntryBuilder {
b.entry.TenantID = tenantID
return b
}
// WithNamespace sets the namespace ID
func (b *GeneralEntryBuilder) WithNamespace(namespaceID uuid.UUID) *GeneralEntryBuilder {
b.entry.NamespaceID = &namespaceID
return b
}
// WithUser sets the user ID
func (b *GeneralEntryBuilder) WithUser(userID uuid.UUID) *GeneralEntryBuilder {
b.entry.UserID = userID
return b
}
// WithAction sets the action
func (b *GeneralEntryBuilder) WithAction(action string) *GeneralEntryBuilder {
b.entry.Action = action
return b
}
// WithResource sets the resource type and ID
func (b *GeneralEntryBuilder) WithResource(resourceType string, resourceID *uuid.UUID) *GeneralEntryBuilder {
b.entry.ResourceType = resourceType
b.entry.ResourceID = resourceID
return b
}
// WithOldValues sets the old values
func (b *GeneralEntryBuilder) WithOldValues(values map[string]any) *GeneralEntryBuilder {
b.entry.OldValues = values
return b
}
// WithNewValues sets the new values
func (b *GeneralEntryBuilder) WithNewValues(values map[string]any) *GeneralEntryBuilder {
b.entry.NewValues = values
return b
}
// WithClient sets client information
func (b *GeneralEntryBuilder) WithClient(ipAddress, userAgent string) *GeneralEntryBuilder {
b.entry.IPAddress = ipAddress
b.entry.UserAgent = userAgent
return b
}
// WithReason sets the reason for the action
func (b *GeneralEntryBuilder) WithReason(reason string) *GeneralEntryBuilder {
b.entry.Reason = reason
return b
}
// Build returns the built entry
func (b *GeneralEntryBuilder) Build() *GeneralAuditEntry {
return b.entry
}
// Save persists the entry to the database
func (b *GeneralEntryBuilder) Save(ctx context.Context) error {
return b.store.CreateGeneralAuditEntry(ctx, b.entry)
}
// Common audit action types
const (
ActionCreate = "create"
ActionUpdate = "update"
ActionDelete = "delete"
ActionRead = "read"
ActionExport = "export"
ActionGrant = "grant"
ActionRevoke = "revoke"
ActionLogin = "login"
ActionLogout = "logout"
ActionFailed = "failed"
)
// Common resource types
const (
ResourceTenant = "tenant"
ResourceNamespace = "namespace"
ResourceRole = "role"
ResourceUserRole = "user_role"
ResourcePolicy = "llm_policy"
ResourceAPIKey = "api_key"
ResourceEvidence = "evidence"
ResourceControl = "control"
)
// Convenience methods for common operations
// LogRoleAssignment creates an audit entry for role assignment
func (tb *TrailBuilder) LogRoleAssignment(ctx context.Context, tenantID, userID, targetUserID, roleID uuid.UUID, grantedBy uuid.UUID, ipAddress, userAgent string) error {
return tb.NewGeneralEntry().
WithTenant(tenantID).
WithUser(grantedBy).
WithAction(ActionGrant).
WithResource(ResourceUserRole, &roleID).
WithNewValues(map[string]any{
"target_user_id": targetUserID.String(),
"role_id": roleID.String(),
}).
WithClient(ipAddress, userAgent).
Save(ctx)
}
// LogRoleRevocation creates an audit entry for role revocation
func (tb *TrailBuilder) LogRoleRevocation(ctx context.Context, tenantID, userID, targetUserID, roleID uuid.UUID, revokedBy uuid.UUID, reason, ipAddress, userAgent string) error {
return tb.NewGeneralEntry().
WithTenant(tenantID).
WithUser(revokedBy).
WithAction(ActionRevoke).
WithResource(ResourceUserRole, &roleID).
WithOldValues(map[string]any{
"target_user_id": targetUserID.String(),
"role_id": roleID.String(),
}).
WithReason(reason).
WithClient(ipAddress, userAgent).
Save(ctx)
}
// LogPolicyChange creates an audit entry for LLM policy changes
func (tb *TrailBuilder) LogPolicyChange(ctx context.Context, tenantID, userID, policyID uuid.UUID, action string, oldValues, newValues map[string]any, ipAddress, userAgent string) error {
return tb.NewGeneralEntry().
WithTenant(tenantID).
WithUser(userID).
WithAction(action).
WithResource(ResourcePolicy, &policyID).
WithOldValues(oldValues).
WithNewValues(newValues).
WithClient(ipAddress, userAgent).
Save(ctx)
}
// LogNamespaceAccess creates an audit entry for namespace access
func (tb *TrailBuilder) LogNamespaceAccess(ctx context.Context, tenantID, userID, namespaceID uuid.UUID, action string, ipAddress, userAgent string) error {
return tb.NewGeneralEntry().
WithTenant(tenantID).
WithUser(userID).
WithNamespace(namespaceID).
WithAction(action).
WithResource(ResourceNamespace, &namespaceID).
WithClient(ipAddress, userAgent).
Save(ctx)
}
// LogDataExport creates an audit entry for data export
func (tb *TrailBuilder) LogDataExport(ctx context.Context, tenantID, userID uuid.UUID, namespaceID *uuid.UUID, resourceType, format string, recordCount int, ipAddress, userAgent string) error {
builder := tb.NewGeneralEntry().
WithTenant(tenantID).
WithUser(userID).
WithAction(ActionExport).
WithResource(resourceType, nil).
WithNewValues(map[string]any{
"format": format,
"record_count": recordCount,
}).
WithClient(ipAddress, userAgent)
if namespaceID != nil {
builder.WithNamespace(*namespaceID)
}
return builder.Save(ctx)
}