feat: Add Academy, Whistleblower, Incidents, Vendor, DSB, SSO, Reporting, Multi-Tenant and Industry backends

Go handlers, models, stores and migrations for all SDK modules.
Updates developer portal navigation and BYOEH page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-13 21:11:27 +01:00
parent 364d2c69ff
commit 504dd3591b
40 changed files with 13105 additions and 7 deletions

View File

@@ -0,0 +1,226 @@
package academy
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// CourseCategory represents the category of a compliance course
type CourseCategory string
const (
CourseCategoryDSGVOBasics CourseCategory = "dsgvo_basics"
CourseCategoryITSecurity CourseCategory = "it_security"
CourseCategoryAILiteracy CourseCategory = "ai_literacy"
CourseCategoryWhistleblowerProtection CourseCategory = "whistleblower_protection"
CourseCategoryCustom CourseCategory = "custom"
)
// EnrollmentStatus represents the status of an enrollment
type EnrollmentStatus string
const (
EnrollmentStatusNotStarted EnrollmentStatus = "not_started"
EnrollmentStatusInProgress EnrollmentStatus = "in_progress"
EnrollmentStatusCompleted EnrollmentStatus = "completed"
EnrollmentStatusExpired EnrollmentStatus = "expired"
)
// LessonType represents the type of a lesson
type LessonType string
const (
LessonTypeVideo LessonType = "video"
LessonTypeText LessonType = "text"
LessonTypeQuiz LessonType = "quiz"
LessonTypeInteractive LessonType = "interactive"
)
// ============================================================================
// Main Entities
// ============================================================================
// Course represents a compliance training course
type Course struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Category CourseCategory `json:"category"`
DurationMinutes int `json:"duration_minutes"`
RequiredForRoles []string `json:"required_for_roles"` // JSONB in DB
Lessons []Lesson `json:"lessons,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Lesson represents a single lesson within a course
type Lesson struct {
ID uuid.UUID `json:"id"`
CourseID uuid.UUID `json:"course_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
LessonType LessonType `json:"lesson_type"`
ContentURL string `json:"content_url,omitempty"`
DurationMinutes int `json:"duration_minutes"`
OrderIndex int `json:"order_index"`
QuizQuestions []QuizQuestion `json:"quiz_questions,omitempty"` // JSONB in DB
}
// QuizQuestion represents a single quiz question embedded in a lesson
type QuizQuestion struct {
Question string `json:"question"`
Options []string `json:"options"`
CorrectIndex int `json:"correct_index"`
Explanation string `json:"explanation"`
}
// Enrollment represents a user's enrollment in a course
type Enrollment struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
CourseID uuid.UUID `json:"course_id"`
UserID uuid.UUID `json:"user_id"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
Status EnrollmentStatus `json:"status"`
ProgressPercent int `json:"progress_percent"`
CurrentLessonIndex int `json:"current_lesson_index"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Certificate represents a completion certificate for an enrollment
type Certificate struct {
ID uuid.UUID `json:"id"`
EnrollmentID uuid.UUID `json:"enrollment_id"`
UserName string `json:"user_name"`
CourseTitle string `json:"course_title"`
IssuedAt time.Time `json:"issued_at"`
ValidUntil *time.Time `json:"valid_until,omitempty"`
PDFURL string `json:"pdf_url,omitempty"`
}
// AcademyStatistics contains aggregated academy metrics
type AcademyStatistics struct {
TotalCourses int `json:"total_courses"`
TotalEnrollments int `json:"total_enrollments"`
CompletionRate float64 `json:"completion_rate"` // 0-100
OverdueCount int `json:"overdue_count"`
AvgCompletionDays float64 `json:"avg_completion_days"`
}
// ============================================================================
// Filter Types
// ============================================================================
// CourseFilters defines filters for listing courses
type CourseFilters struct {
Category CourseCategory
IsActive *bool
Search string
Limit int
Offset int
}
// EnrollmentFilters defines filters for listing enrollments
type EnrollmentFilters struct {
CourseID *uuid.UUID
UserID *uuid.UUID
Status EnrollmentStatus
Limit int
Offset int
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// CreateCourseRequest is the API request for creating a course
type CreateCourseRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
Category CourseCategory `json:"category" binding:"required"`
DurationMinutes int `json:"duration_minutes"`
RequiredForRoles []string `json:"required_for_roles,omitempty"`
Lessons []CreateLessonRequest `json:"lessons,omitempty"`
}
// CreateLessonRequest is the API request for creating a lesson
type CreateLessonRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
LessonType LessonType `json:"lesson_type" binding:"required"`
ContentURL string `json:"content_url,omitempty"`
DurationMinutes int `json:"duration_minutes"`
OrderIndex int `json:"order_index"`
QuizQuestions []QuizQuestion `json:"quiz_questions,omitempty"`
}
// UpdateCourseRequest is the API request for updating a course
type UpdateCourseRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Category *CourseCategory `json:"category,omitempty"`
DurationMinutes *int `json:"duration_minutes,omitempty"`
RequiredForRoles []string `json:"required_for_roles,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
// EnrollUserRequest is the API request for enrolling a user in a course
type EnrollUserRequest struct {
CourseID uuid.UUID `json:"course_id" binding:"required"`
UserID uuid.UUID `json:"user_id" binding:"required"`
UserName string `json:"user_name" binding:"required"`
UserEmail string `json:"user_email" binding:"required"`
Deadline *time.Time `json:"deadline,omitempty"`
}
// UpdateProgressRequest is the API request for updating enrollment progress
type UpdateProgressRequest struct {
Progress int `json:"progress" binding:"required"`
CurrentLesson int `json:"current_lesson"`
}
// SubmitQuizRequest is the API request for submitting quiz answers
type SubmitQuizRequest struct {
LessonID uuid.UUID `json:"lesson_id" binding:"required"`
Answers []int `json:"answers" binding:"required"` // Index of selected answer per question
}
// SubmitQuizResponse is the API response for quiz submission
type SubmitQuizResponse struct {
Score int `json:"score"` // 0-100
Passed bool `json:"passed"`
CorrectAnswers int `json:"correct_answers"`
TotalQuestions int `json:"total_questions"`
Results []QuizResult `json:"results"`
}
// QuizResult represents the result for a single quiz question
type QuizResult struct {
Question string `json:"question"`
Correct bool `json:"correct"`
Explanation string `json:"explanation"`
}
// CourseListResponse is the API response for listing courses
type CourseListResponse struct {
Courses []Course `json:"courses"`
Total int `json:"total"`
}
// EnrollmentListResponse is the API response for listing enrollments
type EnrollmentListResponse struct {
Enrollments []Enrollment `json:"enrollments"`
Total int `json:"total"`
}

View File

@@ -0,0 +1,666 @@
package academy
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles academy data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new academy store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Course CRUD Operations
// ============================================================================
// CreateCourse creates a new course
func (s *Store) CreateCourse(ctx context.Context, course *Course) error {
course.ID = uuid.New()
course.CreatedAt = time.Now().UTC()
course.UpdatedAt = course.CreatedAt
if !course.IsActive {
course.IsActive = true
}
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_courses (
id, tenant_id, title, description, category,
duration_minutes, required_for_roles, is_active,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10
)
`,
course.ID, course.TenantID, course.Title, course.Description, string(course.Category),
course.DurationMinutes, requiredForRoles, course.IsActive,
course.CreatedAt, course.UpdatedAt,
)
return err
}
// GetCourse retrieves a course by ID
func (s *Store) GetCourse(ctx context.Context, id uuid.UUID) (*Course, error) {
var course Course
var category string
var requiredForRoles []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, title, description, category,
duration_minutes, required_for_roles, is_active,
created_at, updated_at
FROM academy_courses WHERE id = $1
`, id).Scan(
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
&course.CreatedAt, &course.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
course.Category = CourseCategory(category)
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
if course.RequiredForRoles == nil {
course.RequiredForRoles = []string{}
}
// Load lessons for this course
lessons, err := s.ListLessons(ctx, course.ID)
if err != nil {
return nil, err
}
course.Lessons = lessons
return &course, nil
}
// ListCourses lists courses for a tenant with optional filters
func (s *Store) ListCourses(ctx context.Context, tenantID uuid.UUID, filters *CourseFilters) ([]Course, int, error) {
// Count query
countQuery := "SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
// List query
query := `
SELECT
id, tenant_id, title, description, category,
duration_minutes, required_for_roles, is_active,
created_at, updated_at
FROM academy_courses WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.Category != "" {
query += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, string(filters.Category))
argIdx++
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Category))
countArgIdx++
}
if filters.IsActive != nil {
query += fmt.Sprintf(" AND is_active = $%d", argIdx)
args = append(args, *filters.IsActive)
argIdx++
countQuery += fmt.Sprintf(" AND is_active = $%d", countArgIdx)
countArgs = append(countArgs, *filters.IsActive)
countArgIdx++
}
if filters.Search != "" {
query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx)
args = append(args, "%"+filters.Search+"%")
argIdx++
countQuery += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", countArgIdx, countArgIdx)
countArgs = append(countArgs, "%"+filters.Search+"%")
countArgIdx++
}
}
// Get total count
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var courses []Course
for rows.Next() {
var course Course
var category string
var requiredForRoles []byte
err := rows.Scan(
&course.ID, &course.TenantID, &course.Title, &course.Description, &category,
&course.DurationMinutes, &requiredForRoles, &course.IsActive,
&course.CreatedAt, &course.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
course.Category = CourseCategory(category)
json.Unmarshal(requiredForRoles, &course.RequiredForRoles)
if course.RequiredForRoles == nil {
course.RequiredForRoles = []string{}
}
courses = append(courses, course)
}
if courses == nil {
courses = []Course{}
}
return courses, total, nil
}
// UpdateCourse updates a course
func (s *Store) UpdateCourse(ctx context.Context, course *Course) error {
course.UpdatedAt = time.Now().UTC()
requiredForRoles, _ := json.Marshal(course.RequiredForRoles)
_, err := s.pool.Exec(ctx, `
UPDATE academy_courses SET
title = $2, description = $3, category = $4,
duration_minutes = $5, required_for_roles = $6, is_active = $7,
updated_at = $8
WHERE id = $1
`,
course.ID, course.Title, course.Description, string(course.Category),
course.DurationMinutes, requiredForRoles, course.IsActive,
course.UpdatedAt,
)
return err
}
// DeleteCourse deletes a course and its related data (via CASCADE)
func (s *Store) DeleteCourse(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM academy_courses WHERE id = $1", id)
return err
}
// ============================================================================
// Lesson Operations
// ============================================================================
// CreateLesson creates a new lesson
func (s *Store) CreateLesson(ctx context.Context, lesson *Lesson) error {
lesson.ID = uuid.New()
quizQuestions, _ := json.Marshal(lesson.QuizQuestions)
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_lessons (
id, course_id, title, description, lesson_type,
content_url, duration_minutes, order_index, quiz_questions
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9
)
`,
lesson.ID, lesson.CourseID, lesson.Title, lesson.Description, string(lesson.LessonType),
lesson.ContentURL, lesson.DurationMinutes, lesson.OrderIndex, quizQuestions,
)
return err
}
// ListLessons lists lessons for a course ordered by order_index
func (s *Store) ListLessons(ctx context.Context, courseID uuid.UUID) ([]Lesson, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, course_id, title, description, lesson_type,
content_url, duration_minutes, order_index, quiz_questions
FROM academy_lessons WHERE course_id = $1
ORDER BY order_index ASC
`, courseID)
if err != nil {
return nil, err
}
defer rows.Close()
var lessons []Lesson
for rows.Next() {
var lesson Lesson
var lessonType string
var quizQuestions []byte
err := rows.Scan(
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
)
if err != nil {
return nil, err
}
lesson.LessonType = LessonType(lessonType)
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
if lesson.QuizQuestions == nil {
lesson.QuizQuestions = []QuizQuestion{}
}
lessons = append(lessons, lesson)
}
if lessons == nil {
lessons = []Lesson{}
}
return lessons, nil
}
// GetLesson retrieves a single lesson by ID
func (s *Store) GetLesson(ctx context.Context, id uuid.UUID) (*Lesson, error) {
var lesson Lesson
var lessonType string
var quizQuestions []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, course_id, title, description, lesson_type,
content_url, duration_minutes, order_index, quiz_questions
FROM academy_lessons WHERE id = $1
`, id).Scan(
&lesson.ID, &lesson.CourseID, &lesson.Title, &lesson.Description, &lessonType,
&lesson.ContentURL, &lesson.DurationMinutes, &lesson.OrderIndex, &quizQuestions,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
lesson.LessonType = LessonType(lessonType)
json.Unmarshal(quizQuestions, &lesson.QuizQuestions)
if lesson.QuizQuestions == nil {
lesson.QuizQuestions = []QuizQuestion{}
}
return &lesson, nil
}
// ============================================================================
// Enrollment Operations
// ============================================================================
// CreateEnrollment creates a new enrollment
func (s *Store) CreateEnrollment(ctx context.Context, enrollment *Enrollment) error {
enrollment.ID = uuid.New()
enrollment.CreatedAt = time.Now().UTC()
enrollment.UpdatedAt = enrollment.CreatedAt
if enrollment.Status == "" {
enrollment.Status = EnrollmentStatusNotStarted
}
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_enrollments (
id, tenant_id, course_id, user_id, user_name, user_email,
status, progress_percent, current_lesson_index,
started_at, completed_at, deadline,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
$10, $11, $12,
$13, $14
)
`,
enrollment.ID, enrollment.TenantID, enrollment.CourseID, enrollment.UserID, enrollment.UserName, enrollment.UserEmail,
string(enrollment.Status), enrollment.ProgressPercent, enrollment.CurrentLessonIndex,
enrollment.StartedAt, enrollment.CompletedAt, enrollment.Deadline,
enrollment.CreatedAt, enrollment.UpdatedAt,
)
return err
}
// GetEnrollment retrieves an enrollment by ID
func (s *Store) GetEnrollment(ctx context.Context, id uuid.UUID) (*Enrollment, error) {
var enrollment Enrollment
var status string
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, course_id, user_id, user_name, user_email,
status, progress_percent, current_lesson_index,
started_at, completed_at, deadline,
created_at, updated_at
FROM academy_enrollments WHERE id = $1
`, id).Scan(
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
&enrollment.CreatedAt, &enrollment.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
enrollment.Status = EnrollmentStatus(status)
return &enrollment, nil
}
// ListEnrollments lists enrollments for a tenant with optional filters
func (s *Store) ListEnrollments(ctx context.Context, tenantID uuid.UUID, filters *EnrollmentFilters) ([]Enrollment, int, error) {
// Count query
countQuery := "SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
// List query
query := `
SELECT
id, tenant_id, course_id, user_id, user_name, user_email,
status, progress_percent, current_lesson_index,
started_at, completed_at, deadline,
created_at, updated_at
FROM academy_enrollments WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.CourseID != nil {
query += fmt.Sprintf(" AND course_id = $%d", argIdx)
args = append(args, *filters.CourseID)
argIdx++
countQuery += fmt.Sprintf(" AND course_id = $%d", countArgIdx)
countArgs = append(countArgs, *filters.CourseID)
countArgIdx++
}
if filters.UserID != nil {
query += fmt.Sprintf(" AND user_id = $%d", argIdx)
args = append(args, *filters.UserID)
argIdx++
countQuery += fmt.Sprintf(" AND user_id = $%d", countArgIdx)
countArgs = append(countArgs, *filters.UserID)
countArgIdx++
}
if filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(filters.Status))
argIdx++
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Status))
countArgIdx++
}
}
// Get total count
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var enrollments []Enrollment
for rows.Next() {
var enrollment Enrollment
var status string
err := rows.Scan(
&enrollment.ID, &enrollment.TenantID, &enrollment.CourseID, &enrollment.UserID, &enrollment.UserName, &enrollment.UserEmail,
&status, &enrollment.ProgressPercent, &enrollment.CurrentLessonIndex,
&enrollment.StartedAt, &enrollment.CompletedAt, &enrollment.Deadline,
&enrollment.CreatedAt, &enrollment.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
enrollment.Status = EnrollmentStatus(status)
enrollments = append(enrollments, enrollment)
}
if enrollments == nil {
enrollments = []Enrollment{}
}
return enrollments, total, nil
}
// UpdateEnrollmentProgress updates the progress for an enrollment
func (s *Store) UpdateEnrollmentProgress(ctx context.Context, id uuid.UUID, progress int, currentLesson int) error {
now := time.Now().UTC()
// If progress > 0, set started_at if not already set and update status to in_progress
_, err := s.pool.Exec(ctx, `
UPDATE academy_enrollments SET
progress_percent = $2,
current_lesson_index = $3,
status = CASE
WHEN $2 >= 100 THEN 'completed'
WHEN $2 > 0 THEN 'in_progress'
ELSE status
END,
started_at = CASE
WHEN started_at IS NULL AND $2 > 0 THEN $4
ELSE started_at
END,
completed_at = CASE
WHEN $2 >= 100 THEN $4
ELSE completed_at
END,
updated_at = $4
WHERE id = $1
`, id, progress, currentLesson, now)
return err
}
// CompleteEnrollment marks an enrollment as completed
func (s *Store) CompleteEnrollment(ctx context.Context, id uuid.UUID) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE academy_enrollments SET
status = 'completed',
progress_percent = 100,
completed_at = $2,
updated_at = $2
WHERE id = $1
`, id, now)
return err
}
// ============================================================================
// Certificate Operations
// ============================================================================
// GetCertificate retrieves a certificate by ID
func (s *Store) GetCertificate(ctx context.Context, id uuid.UUID) (*Certificate, error) {
var cert Certificate
err := s.pool.QueryRow(ctx, `
SELECT
id, enrollment_id, user_name, course_title,
issued_at, valid_until, pdf_url
FROM academy_certificates WHERE id = $1
`, id).Scan(
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &cert, nil
}
// GetCertificateByEnrollment retrieves a certificate by enrollment ID
func (s *Store) GetCertificateByEnrollment(ctx context.Context, enrollmentID uuid.UUID) (*Certificate, error) {
var cert Certificate
err := s.pool.QueryRow(ctx, `
SELECT
id, enrollment_id, user_name, course_title,
issued_at, valid_until, pdf_url
FROM academy_certificates WHERE enrollment_id = $1
`, enrollmentID).Scan(
&cert.ID, &cert.EnrollmentID, &cert.UserName, &cert.CourseTitle,
&cert.IssuedAt, &cert.ValidUntil, &cert.PDFURL,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &cert, nil
}
// CreateCertificate creates a new certificate
func (s *Store) CreateCertificate(ctx context.Context, cert *Certificate) error {
cert.ID = uuid.New()
cert.IssuedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO academy_certificates (
id, enrollment_id, user_name, course_title,
issued_at, valid_until, pdf_url
) VALUES (
$1, $2, $3, $4,
$5, $6, $7
)
`,
cert.ID, cert.EnrollmentID, cert.UserName, cert.CourseTitle,
cert.IssuedAt, cert.ValidUntil, cert.PDFURL,
)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated academy statistics for a tenant
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*AcademyStatistics, error) {
stats := &AcademyStatistics{}
// Total active courses
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM academy_courses WHERE tenant_id = $1 AND is_active = true",
tenantID).Scan(&stats.TotalCourses)
// Total enrollments
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalEnrollments)
// Completion rate
if stats.TotalEnrollments > 0 {
var completed int
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM academy_enrollments WHERE tenant_id = $1 AND status = 'completed'",
tenantID).Scan(&completed)
stats.CompletionRate = float64(completed) / float64(stats.TotalEnrollments) * 100
}
// Overdue count (past deadline, not completed)
s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM academy_enrollments
WHERE tenant_id = $1
AND status NOT IN ('completed', 'expired')
AND deadline IS NOT NULL
AND deadline < NOW()`,
tenantID).Scan(&stats.OverdueCount)
// Average completion days
s.pool.QueryRow(ctx,
`SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) / 86400), 0)
FROM academy_enrollments
WHERE tenant_id = $1
AND status = 'completed'
AND started_at IS NOT NULL
AND completed_at IS NOT NULL`,
tenantID).Scan(&stats.AvgCompletionDays)
return stats, nil
}

View File

@@ -0,0 +1,587 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// AcademyHandlers handles academy HTTP requests
type AcademyHandlers struct {
store *academy.Store
}
// NewAcademyHandlers creates new academy handlers
func NewAcademyHandlers(store *academy.Store) *AcademyHandlers {
return &AcademyHandlers{store: store}
}
// ============================================================================
// Course Management
// ============================================================================
// CreateCourse creates a new compliance training course
// POST /sdk/v1/academy/courses
func (h *AcademyHandlers) CreateCourse(c *gin.Context) {
var req academy.CreateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
course := &academy.Course{
TenantID: tenantID,
Title: req.Title,
Description: req.Description,
Category: req.Category,
DurationMinutes: req.DurationMinutes,
RequiredForRoles: req.RequiredForRoles,
IsActive: true,
}
if course.RequiredForRoles == nil {
course.RequiredForRoles = []string{}
}
if err := h.store.CreateCourse(c.Request.Context(), course); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Create lessons if provided
for i := range req.Lessons {
lesson := &academy.Lesson{
CourseID: course.ID,
Title: req.Lessons[i].Title,
Description: req.Lessons[i].Description,
LessonType: req.Lessons[i].LessonType,
ContentURL: req.Lessons[i].ContentURL,
DurationMinutes: req.Lessons[i].DurationMinutes,
OrderIndex: req.Lessons[i].OrderIndex,
QuizQuestions: req.Lessons[i].QuizQuestions,
}
if err := h.store.CreateLesson(c.Request.Context(), lesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
course.Lessons = append(course.Lessons, *lesson)
}
if course.Lessons == nil {
course.Lessons = []academy.Lesson{}
}
c.JSON(http.StatusCreated, gin.H{"course": course})
}
// GetCourse retrieves a course with its lessons
// GET /sdk/v1/academy/courses/:id
func (h *AcademyHandlers) GetCourse(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"})
return
}
course, err := h.store.GetCourse(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
c.JSON(http.StatusOK, gin.H{"course": course})
}
// ListCourses lists courses for the current tenant
// GET /sdk/v1/academy/courses
func (h *AcademyHandlers) ListCourses(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &academy.CourseFilters{
Limit: 50,
}
if category := c.Query("category"); category != "" {
filters.Category = academy.CourseCategory(category)
}
if search := c.Query("search"); search != "" {
filters.Search = search
}
if activeStr := c.Query("is_active"); activeStr != "" {
active := activeStr == "true"
filters.IsActive = &active
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
filters.Limit = limit
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
filters.Offset = offset
}
}
courses, total, err := h.store.ListCourses(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, academy.CourseListResponse{
Courses: courses,
Total: total,
})
}
// UpdateCourse updates a course
// PUT /sdk/v1/academy/courses/:id
func (h *AcademyHandlers) UpdateCourse(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"})
return
}
course, err := h.store.GetCourse(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
var req academy.UpdateCourseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != nil {
course.Title = *req.Title
}
if req.Description != nil {
course.Description = *req.Description
}
if req.Category != nil {
course.Category = *req.Category
}
if req.DurationMinutes != nil {
course.DurationMinutes = *req.DurationMinutes
}
if req.RequiredForRoles != nil {
course.RequiredForRoles = req.RequiredForRoles
}
if req.IsActive != nil {
course.IsActive = *req.IsActive
}
if err := h.store.UpdateCourse(c.Request.Context(), course); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"course": course})
}
// DeleteCourse deletes a course
// DELETE /sdk/v1/academy/courses/:id
func (h *AcademyHandlers) DeleteCourse(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course ID"})
return
}
if err := h.store.DeleteCourse(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "course deleted"})
}
// ============================================================================
// Enrollment Management
// ============================================================================
// CreateEnrollment enrolls a user in a course
// POST /sdk/v1/academy/enrollments
func (h *AcademyHandlers) CreateEnrollment(c *gin.Context) {
var req academy.EnrollUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
// Verify course exists
course, err := h.store.GetCourse(c.Request.Context(), req.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if course == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "course not found"})
return
}
enrollment := &academy.Enrollment{
TenantID: tenantID,
CourseID: req.CourseID,
UserID: req.UserID,
UserName: req.UserName,
UserEmail: req.UserEmail,
Status: academy.EnrollmentStatusNotStarted,
Deadline: req.Deadline,
}
if err := h.store.CreateEnrollment(c.Request.Context(), enrollment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"enrollment": enrollment})
}
// ListEnrollments lists enrollments for the current tenant
// GET /sdk/v1/academy/enrollments
func (h *AcademyHandlers) ListEnrollments(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &academy.EnrollmentFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = academy.EnrollmentStatus(status)
}
if courseIDStr := c.Query("course_id"); courseIDStr != "" {
if courseID, err := uuid.Parse(courseIDStr); err == nil {
filters.CourseID = &courseID
}
}
if userIDStr := c.Query("user_id"); userIDStr != "" {
if userID, err := uuid.Parse(userIDStr); err == nil {
filters.UserID = &userID
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
filters.Limit = limit
}
}
if offsetStr := c.Query("offset"); offsetStr != "" {
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
filters.Offset = offset
}
}
enrollments, total, err := h.store.ListEnrollments(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, academy.EnrollmentListResponse{
Enrollments: enrollments,
Total: total,
})
}
// UpdateProgress updates an enrollment's progress
// PUT /sdk/v1/academy/enrollments/:id/progress
func (h *AcademyHandlers) UpdateProgress(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
var req academy.UpdateProgressRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Progress < 0 || req.Progress > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "progress must be between 0 and 100"})
return
}
if err := h.store.UpdateEnrollmentProgress(c.Request.Context(), id, req.Progress, req.CurrentLesson); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enrollment": updated})
}
// CompleteEnrollment marks an enrollment as completed
// POST /sdk/v1/academy/enrollments/:id/complete
func (h *AcademyHandlers) CompleteEnrollment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status == academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment already completed"})
return
}
if err := h.store.CompleteEnrollment(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Fetch updated enrollment
updated, err := h.store.GetEnrollment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"enrollment": updated,
"message": "enrollment completed",
})
}
// ============================================================================
// Certificate Management
// ============================================================================
// GetCertificate retrieves a certificate
// GET /sdk/v1/academy/certificates/:id
func (h *AcademyHandlers) GetCertificate(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid certificate ID"})
return
}
cert, err := h.store.GetCertificate(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
c.JSON(http.StatusOK, gin.H{"certificate": cert})
}
// GenerateCertificate generates a certificate for a completed enrollment
// POST /sdk/v1/academy/enrollments/:id/certificate
func (h *AcademyHandlers) GenerateCertificate(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
if enrollment.Status != academy.EnrollmentStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "enrollment must be completed before generating certificate"})
return
}
// Check if certificate already exists
existing, err := h.store.GetCertificateByEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if existing != nil {
c.JSON(http.StatusOK, gin.H{"certificate": existing, "message": "certificate already exists"})
return
}
// Get the course for the certificate title
course, err := h.store.GetCourse(c.Request.Context(), enrollment.CourseID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
courseTitle := "Unknown Course"
if course != nil {
courseTitle = course.Title
}
// Certificate is valid for 1 year by default
validUntil := time.Now().UTC().AddDate(1, 0, 0)
cert := &academy.Certificate{
EnrollmentID: enrollmentID,
UserName: enrollment.UserName,
CourseTitle: courseTitle,
ValidUntil: &validUntil,
}
if err := h.store.CreateCertificate(c.Request.Context(), cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"certificate": cert})
}
// ============================================================================
// Quiz Submission
// ============================================================================
// SubmitQuiz submits quiz answers and returns the results
// POST /sdk/v1/academy/enrollments/:id/quiz
func (h *AcademyHandlers) SubmitQuiz(c *gin.Context) {
enrollmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid enrollment ID"})
return
}
var req academy.SubmitQuizRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify enrollment exists
enrollment, err := h.store.GetEnrollment(c.Request.Context(), enrollmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if enrollment == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "enrollment not found"})
return
}
// Get the lesson with quiz questions
lesson, err := h.store.GetLesson(c.Request.Context(), req.LessonID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if lesson == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "lesson not found"})
return
}
if len(lesson.QuizQuestions) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "lesson has no quiz questions"})
return
}
if len(req.Answers) != len(lesson.QuizQuestions) {
c.JSON(http.StatusBadRequest, gin.H{"error": "number of answers must match number of questions"})
return
}
// Grade the quiz
correctCount := 0
var results []academy.QuizResult
for i, question := range lesson.QuizQuestions {
correct := req.Answers[i] == question.CorrectIndex
if correct {
correctCount++
}
results = append(results, academy.QuizResult{
Question: question.Question,
Correct: correct,
Explanation: question.Explanation,
})
}
totalQuestions := len(lesson.QuizQuestions)
score := 0
if totalQuestions > 0 {
score = (correctCount * 100) / totalQuestions
}
// Pass threshold: 70%
passed := score >= 70
response := academy.SubmitQuizResponse{
Score: score,
Passed: passed,
CorrectAnswers: correctCount,
TotalQuestions: totalQuestions,
Results: results,
}
c.JSON(http.StatusOK, response)
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns academy statistics for the current tenant
// GET /sdk/v1/academy/statistics
func (h *AcademyHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -0,0 +1,451 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/dsb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// DSBHandlers handles DSB-as-a-Service portal HTTP requests.
type DSBHandlers struct {
store *dsb.Store
}
// NewDSBHandlers creates new DSB handlers.
func NewDSBHandlers(store *dsb.Store) *DSBHandlers {
return &DSBHandlers{store: store}
}
// getDSBUserID extracts and parses the X-User-ID header as UUID.
func getDSBUserID(c *gin.Context) (uuid.UUID, bool) {
userIDStr := c.GetHeader("X-User-ID")
if userIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "X-User-ID header is required"})
return uuid.Nil, false
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid X-User-ID header: must be a valid UUID"})
return uuid.Nil, false
}
return userID, true
}
// ============================================================================
// Dashboard
// ============================================================================
// GetDashboard returns the aggregated DSB dashboard.
// GET /sdk/v1/dsb/dashboard
func (h *DSBHandlers) GetDashboard(c *gin.Context) {
dsbUserID, ok := getDSBUserID(c)
if !ok {
return
}
dashboard, err := h.store.GetDashboard(c.Request.Context(), dsbUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, dashboard)
}
// ============================================================================
// Assignments
// ============================================================================
// CreateAssignment creates a new DSB-to-tenant assignment.
// POST /sdk/v1/dsb/assignments
func (h *DSBHandlers) CreateAssignment(c *gin.Context) {
var req dsb.CreateAssignmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
assignment := &dsb.Assignment{
DSBUserID: req.DSBUserID,
TenantID: req.TenantID,
Status: req.Status,
ContractStart: req.ContractStart,
ContractEnd: req.ContractEnd,
MonthlyHoursBudget: req.MonthlyHoursBudget,
Notes: req.Notes,
}
if err := h.store.CreateAssignment(c.Request.Context(), assignment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"assignment": assignment})
}
// ListAssignments returns all assignments for the authenticated DSB user.
// GET /sdk/v1/dsb/assignments
func (h *DSBHandlers) ListAssignments(c *gin.Context) {
dsbUserID, ok := getDSBUserID(c)
if !ok {
return
}
assignments, err := h.store.ListAssignments(c.Request.Context(), dsbUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"assignments": assignments,
"total": len(assignments),
})
}
// GetAssignment retrieves a single assignment by ID.
// GET /sdk/v1/dsb/assignments/:id
func (h *DSBHandlers) GetAssignment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
return
}
c.JSON(http.StatusOK, gin.H{"assignment": assignment})
}
// UpdateAssignment updates an existing assignment.
// PUT /sdk/v1/dsb/assignments/:id
func (h *DSBHandlers) UpdateAssignment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
assignment, err := h.store.GetAssignment(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
return
}
var req dsb.UpdateAssignmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Apply non-nil fields
if req.Status != nil {
assignment.Status = *req.Status
}
if req.ContractEnd != nil {
assignment.ContractEnd = req.ContractEnd
}
if req.MonthlyHoursBudget != nil {
assignment.MonthlyHoursBudget = *req.MonthlyHoursBudget
}
if req.Notes != nil {
assignment.Notes = *req.Notes
}
if err := h.store.UpdateAssignment(c.Request.Context(), assignment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"assignment": assignment})
}
// ============================================================================
// Hours
// ============================================================================
// CreateHourEntry creates a new time tracking entry for an assignment.
// POST /sdk/v1/dsb/assignments/:id/hours
func (h *DSBHandlers) CreateHourEntry(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
var req dsb.CreateHourEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
billable := true
if req.Billable != nil {
billable = *req.Billable
}
entry := &dsb.HourEntry{
AssignmentID: assignmentID,
Date: req.Date,
Hours: req.Hours,
Category: req.Category,
Description: req.Description,
Billable: billable,
}
if err := h.store.CreateHourEntry(c.Request.Context(), entry); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"hour_entry": entry})
}
// ListHours returns time entries for an assignment.
// GET /sdk/v1/dsb/assignments/:id/hours?month=YYYY-MM
func (h *DSBHandlers) ListHours(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
month := c.Query("month")
entries, err := h.store.ListHours(c.Request.Context(), assignmentID, month)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"hours": entries,
"total": len(entries),
})
}
// GetHoursSummary returns aggregated hour statistics for an assignment.
// GET /sdk/v1/dsb/assignments/:id/hours/summary?month=YYYY-MM
func (h *DSBHandlers) GetHoursSummary(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
month := c.Query("month")
summary, err := h.store.GetHoursSummary(c.Request.Context(), assignmentID, month)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, summary)
}
// ============================================================================
// Tasks
// ============================================================================
// CreateTask creates a new task for an assignment.
// POST /sdk/v1/dsb/assignments/:id/tasks
func (h *DSBHandlers) CreateTask(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
var req dsb.CreateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
task := &dsb.Task{
AssignmentID: assignmentID,
Title: req.Title,
Description: req.Description,
Category: req.Category,
Priority: req.Priority,
DueDate: req.DueDate,
}
if err := h.store.CreateTask(c.Request.Context(), task); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"task": task})
}
// ListTasks returns tasks for an assignment.
// GET /sdk/v1/dsb/assignments/:id/tasks?status=open
func (h *DSBHandlers) ListTasks(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
status := c.Query("status")
tasks, err := h.store.ListTasks(c.Request.Context(), assignmentID, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"tasks": tasks,
"total": len(tasks),
})
}
// UpdateTask updates an existing task.
// PUT /sdk/v1/dsb/tasks/:taskId
func (h *DSBHandlers) UpdateTask(c *gin.Context) {
taskID, err := uuid.Parse(c.Param("taskId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task ID"})
return
}
// We need to fetch the existing task first. Since tasks belong to assignments,
// we query by task ID directly. For now, we do a lightweight approach: bind the
// update request and apply changes via store.
var req dsb.UpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Fetch current task by querying all tasks and filtering. Since we don't have
// a GetTask(taskID) method, we build the task from partial data and update.
// The store UpdateTask uses the task ID to locate the row.
task := &dsb.Task{ID: taskID}
// We need to get the current values to apply partial updates correctly.
// Query the task directly.
row := h.store.Pool().QueryRow(c.Request.Context(), `
SELECT id, assignment_id, title, description, category, priority, status, due_date, completed_at, created_at, updated_at
FROM dsb_tasks WHERE id = $1
`, taskID)
if err := row.Scan(
&task.ID, &task.AssignmentID, &task.Title, &task.Description,
&task.Category, &task.Priority, &task.Status, &task.DueDate,
&task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
// Apply non-nil fields
if req.Title != nil {
task.Title = *req.Title
}
if req.Description != nil {
task.Description = *req.Description
}
if req.Category != nil {
task.Category = *req.Category
}
if req.Priority != nil {
task.Priority = *req.Priority
}
if req.Status != nil {
task.Status = *req.Status
}
if req.DueDate != nil {
task.DueDate = req.DueDate
}
if err := h.store.UpdateTask(c.Request.Context(), task); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"task": task})
}
// CompleteTask marks a task as completed.
// POST /sdk/v1/dsb/tasks/:taskId/complete
func (h *DSBHandlers) CompleteTask(c *gin.Context) {
taskID, err := uuid.Parse(c.Param("taskId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task ID"})
return
}
if err := h.store.CompleteTask(c.Request.Context(), taskID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "task completed"})
}
// ============================================================================
// Communications
// ============================================================================
// CreateCommunication creates a new communication log entry.
// POST /sdk/v1/dsb/assignments/:id/communications
func (h *DSBHandlers) CreateCommunication(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
var req dsb.CreateCommunicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
comm := &dsb.Communication{
AssignmentID: assignmentID,
Direction: req.Direction,
Channel: req.Channel,
Subject: req.Subject,
Content: req.Content,
Participants: req.Participants,
}
if err := h.store.CreateCommunication(c.Request.Context(), comm); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"communication": comm})
}
// ListCommunications returns all communications for an assignment.
// GET /sdk/v1/dsb/assignments/:id/communications
func (h *DSBHandlers) ListCommunications(c *gin.Context) {
assignmentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid assignment ID"})
return
}
comms, err := h.store.ListCommunications(c.Request.Context(), assignmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"communications": comms,
"total": len(comms),
})
}

View File

@@ -0,0 +1,668 @@
package handlers
import (
"fmt"
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/incidents"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// IncidentHandlers handles incident/breach management HTTP requests
type IncidentHandlers struct {
store *incidents.Store
}
// NewIncidentHandlers creates new incident handlers
func NewIncidentHandlers(store *incidents.Store) *IncidentHandlers {
return &IncidentHandlers{store: store}
}
// ============================================================================
// Incident CRUD
// ============================================================================
// CreateIncident creates a new incident
// POST /sdk/v1/incidents
func (h *IncidentHandlers) CreateIncident(c *gin.Context) {
var req incidents.CreateIncidentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
detectedAt := time.Now().UTC()
if req.DetectedAt != nil {
detectedAt = *req.DetectedAt
}
// Auto-calculate 72h deadline per DSGVO Art. 33
deadline := incidents.Calculate72hDeadline(detectedAt)
incident := &incidents.Incident{
TenantID: tenantID,
Title: req.Title,
Description: req.Description,
Category: req.Category,
Status: incidents.IncidentStatusDetected,
Severity: req.Severity,
DetectedAt: detectedAt,
ReportedBy: userID,
AffectedDataCategories: req.AffectedDataCategories,
AffectedDataSubjectCount: req.AffectedDataSubjectCount,
AffectedSystems: req.AffectedSystems,
AuthorityNotification: &incidents.AuthorityNotification{
Status: incidents.NotificationStatusPending,
Deadline: deadline,
},
DataSubjectNotification: &incidents.DataSubjectNotification{
Required: false,
Status: incidents.NotificationStatusNotRequired,
},
Timeline: []incidents.TimelineEntry{
{
Timestamp: time.Now().UTC(),
Action: "incident_created",
UserID: userID,
Details: "Incident detected and reported",
},
},
}
if err := h.store.CreateIncident(c.Request.Context(), incident); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"incident": incident,
"authority_deadline": deadline,
"hours_until_deadline": time.Until(deadline).Hours(),
})
}
// GetIncident retrieves an incident by ID
// GET /sdk/v1/incidents/:id
func (h *IncidentHandlers) GetIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
// Get measures
measures, _ := h.store.ListMeasures(c.Request.Context(), id)
// Calculate deadline info if authority notification exists
var deadlineInfo gin.H
if incident.AuthorityNotification != nil {
hoursRemaining := time.Until(incident.AuthorityNotification.Deadline).Hours()
deadlineInfo = gin.H{
"deadline": incident.AuthorityNotification.Deadline,
"hours_remaining": hoursRemaining,
"overdue": hoursRemaining < 0,
}
}
c.JSON(http.StatusOK, gin.H{
"incident": incident,
"measures": measures,
"deadline_info": deadlineInfo,
})
}
// ListIncidents lists incidents for a tenant
// GET /sdk/v1/incidents
func (h *IncidentHandlers) ListIncidents(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &incidents.IncidentFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = incidents.IncidentStatus(status)
}
if severity := c.Query("severity"); severity != "" {
filters.Severity = incidents.IncidentSeverity(severity)
}
if category := c.Query("category"); category != "" {
filters.Category = incidents.IncidentCategory(category)
}
incidentList, total, err := h.store.ListIncidents(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, incidents.IncidentListResponse{
Incidents: incidentList,
Total: total,
})
}
// UpdateIncident updates an incident
// PUT /sdk/v1/incidents/:id
func (h *IncidentHandlers) UpdateIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.UpdateIncidentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Title != "" {
incident.Title = req.Title
}
if req.Description != "" {
incident.Description = req.Description
}
if req.Category != "" {
incident.Category = req.Category
}
if req.Status != "" {
incident.Status = req.Status
}
if req.Severity != "" {
incident.Severity = req.Severity
}
if req.AffectedDataCategories != nil {
incident.AffectedDataCategories = req.AffectedDataCategories
}
if req.AffectedDataSubjectCount != nil {
incident.AffectedDataSubjectCount = *req.AffectedDataSubjectCount
}
if req.AffectedSystems != nil {
incident.AffectedSystems = req.AffectedSystems
}
if err := h.store.UpdateIncident(c.Request.Context(), incident); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"incident": incident})
}
// DeleteIncident deletes an incident
// DELETE /sdk/v1/incidents/:id
func (h *IncidentHandlers) DeleteIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
if err := h.store.DeleteIncident(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "incident deleted"})
}
// ============================================================================
// Risk Assessment
// ============================================================================
// AssessRisk performs a risk assessment for an incident
// POST /sdk/v1/incidents/:id/risk-assessment
func (h *IncidentHandlers) AssessRisk(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.RiskAssessmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
// Auto-calculate risk level
riskLevel := incidents.CalculateRiskLevel(req.Likelihood, req.Impact)
notificationRequired := incidents.IsNotificationRequired(riskLevel)
assessment := &incidents.RiskAssessment{
Likelihood: req.Likelihood,
Impact: req.Impact,
RiskLevel: riskLevel,
AssessedAt: time.Now().UTC(),
AssessedBy: userID,
Notes: req.Notes,
}
if err := h.store.UpdateRiskAssessment(c.Request.Context(), id, assessment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update status to assessment
incident.Status = incidents.IncidentStatusAssessment
h.store.UpdateIncident(c.Request.Context(), incident)
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: "risk_assessed",
UserID: userID,
Details: fmt.Sprintf("Risk level: %s (likelihood=%d, impact=%d)", riskLevel, req.Likelihood, req.Impact),
})
// If notification is required, update authority notification status
if notificationRequired && incident.AuthorityNotification != nil {
incident.AuthorityNotification.Status = incidents.NotificationStatusPending
h.store.UpdateAuthorityNotification(c.Request.Context(), id, incident.AuthorityNotification)
// Update status to notification_required
incident.Status = incidents.IncidentStatusNotificationRequired
h.store.UpdateIncident(c.Request.Context(), incident)
}
c.JSON(http.StatusOK, gin.H{
"risk_assessment": assessment,
"notification_required": notificationRequired,
"incident_status": incident.Status,
})
}
// ============================================================================
// Authority Notification (Art. 33)
// ============================================================================
// SubmitAuthorityNotification submits the supervisory authority notification
// POST /sdk/v1/incidents/:id/authority-notification
func (h *IncidentHandlers) SubmitAuthorityNotification(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.SubmitAuthorityNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
now := time.Now().UTC()
// Preserve existing deadline
deadline := incidents.Calculate72hDeadline(incident.DetectedAt)
if incident.AuthorityNotification != nil {
deadline = incident.AuthorityNotification.Deadline
}
notification := &incidents.AuthorityNotification{
Status: incidents.NotificationStatusSent,
Deadline: deadline,
SubmittedAt: &now,
AuthorityName: req.AuthorityName,
ReferenceNumber: req.ReferenceNumber,
ContactPerson: req.ContactPerson,
Notes: req.Notes,
}
if err := h.store.UpdateAuthorityNotification(c.Request.Context(), id, notification); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update incident status
incident.Status = incidents.IncidentStatusNotificationSent
h.store.UpdateIncident(c.Request.Context(), incident)
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: now,
Action: "authority_notified",
UserID: userID,
Details: "Authority notification submitted to " + req.AuthorityName,
})
c.JSON(http.StatusOK, gin.H{
"authority_notification": notification,
"submitted_within_72h": now.Before(deadline),
})
}
// ============================================================================
// Data Subject Notification (Art. 34)
// ============================================================================
// NotifyDataSubjects submits the data subject notification
// POST /sdk/v1/incidents/:id/data-subject-notification
func (h *IncidentHandlers) NotifyDataSubjects(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.NotifyDataSubjectsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
now := time.Now().UTC()
affectedCount := req.AffectedCount
if affectedCount == 0 {
affectedCount = incident.AffectedDataSubjectCount
}
notification := &incidents.DataSubjectNotification{
Required: true,
Status: incidents.NotificationStatusSent,
SentAt: &now,
AffectedCount: affectedCount,
NotificationText: req.NotificationText,
Channel: req.Channel,
}
if err := h.store.UpdateDataSubjectNotification(c.Request.Context(), id, notification); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: now,
Action: "data_subjects_notified",
UserID: userID,
Details: "Data subjects notified via " + req.Channel + " (" + fmt.Sprintf("%d", affectedCount) + " affected)",
})
c.JSON(http.StatusOK, gin.H{
"data_subject_notification": notification,
})
}
// ============================================================================
// Measures
// ============================================================================
// AddMeasure adds a corrective measure to an incident
// POST /sdk/v1/incidents/:id/measures
func (h *IncidentHandlers) AddMeasure(c *gin.Context) {
incidentID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
// Verify incident exists
incident, err := h.store.GetIncident(c.Request.Context(), incidentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.AddMeasureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
measure := &incidents.IncidentMeasure{
IncidentID: incidentID,
Title: req.Title,
Description: req.Description,
MeasureType: req.MeasureType,
Status: incidents.MeasureStatusPlanned,
Responsible: req.Responsible,
DueDate: req.DueDate,
}
if err := h.store.AddMeasure(c.Request.Context(), measure); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), incidentID, incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: "measure_added",
UserID: userID,
Details: "Measure added: " + req.Title + " (" + string(req.MeasureType) + ")",
})
c.JSON(http.StatusCreated, gin.H{"measure": measure})
}
// UpdateMeasure updates a measure
// PUT /sdk/v1/incidents/measures/:measureId
func (h *IncidentHandlers) UpdateMeasure(c *gin.Context) {
measureID, err := uuid.Parse(c.Param("measureId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid measure ID"})
return
}
var req struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
MeasureType incidents.MeasureType `json:"measure_type,omitempty"`
Status incidents.MeasureStatus `json:"status,omitempty"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
measure := &incidents.IncidentMeasure{
ID: measureID,
Title: req.Title,
Description: req.Description,
MeasureType: req.MeasureType,
Status: req.Status,
Responsible: req.Responsible,
DueDate: req.DueDate,
}
if err := h.store.UpdateMeasure(c.Request.Context(), measure); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"measure": measure})
}
// CompleteMeasure marks a measure as completed
// POST /sdk/v1/incidents/measures/:measureId/complete
func (h *IncidentHandlers) CompleteMeasure(c *gin.Context) {
measureID, err := uuid.Parse(c.Param("measureId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid measure ID"})
return
}
if err := h.store.CompleteMeasure(c.Request.Context(), measureID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "measure completed"})
}
// ============================================================================
// Timeline
// ============================================================================
// AddTimelineEntry adds a timeline entry to an incident
// POST /sdk/v1/incidents/:id/timeline
func (h *IncidentHandlers) AddTimelineEntry(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
var req incidents.AddTimelineEntryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
entry := incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: req.Action,
UserID: userID,
Details: req.Details,
}
if err := h.store.AddTimelineEntry(c.Request.Context(), id, entry); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"timeline_entry": entry})
}
// ============================================================================
// Close Incident
// ============================================================================
// CloseIncident closes an incident with root cause analysis
// POST /sdk/v1/incidents/:id/close
func (h *IncidentHandlers) CloseIncident(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid incident ID"})
return
}
incident, err := h.store.GetIncident(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if incident == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "incident not found"})
return
}
var req incidents.CloseIncidentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
if err := h.store.CloseIncident(c.Request.Context(), id, req.RootCause, req.LessonsLearned); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Add timeline entry
h.store.AddTimelineEntry(c.Request.Context(), id, incidents.TimelineEntry{
Timestamp: time.Now().UTC(),
Action: "incident_closed",
UserID: userID,
Details: "Incident closed. Root cause: " + req.RootCause,
})
c.JSON(http.StatusOK, gin.H{
"message": "incident closed",
"root_cause": req.RootCause,
"lessons_learned": req.LessonsLearned,
})
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated incident statistics
// GET /sdk/v1/incidents/statistics
func (h *IncidentHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -0,0 +1,115 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/industry"
"github.com/gin-gonic/gin"
)
// IndustryHandlers handles industry-specific compliance template requests.
// All data is static (embedded Go structs), so no store/database is needed.
type IndustryHandlers struct{}
// NewIndustryHandlers creates new industry handlers
func NewIndustryHandlers() *IndustryHandlers {
return &IndustryHandlers{}
}
// ============================================================================
// Industry Template Endpoints
// ============================================================================
// ListIndustries returns a summary list of all available industry templates.
// GET /sdk/v1/industries
func (h *IndustryHandlers) ListIndustries(c *gin.Context) {
templates := industry.GetAllTemplates()
summaries := make([]industry.IndustrySummary, 0, len(templates))
for _, t := range templates {
summaries = append(summaries, industry.IndustrySummary{
Slug: t.Slug,
Name: t.Name,
Description: t.Description,
Icon: t.Icon,
RegulationCount: len(t.Regulations),
TemplateCount: len(t.VVTTemplates),
})
}
c.JSON(http.StatusOK, industry.IndustryListResponse{
Industries: summaries,
Total: len(summaries),
})
}
// GetIndustry returns the full industry template for a given slug.
// GET /sdk/v1/industries/:slug
func (h *IndustryHandlers) GetIndustry(c *gin.Context) {
slug := c.Param("slug")
tmpl := industry.GetTemplateBySlug(slug)
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
return
}
c.JSON(http.StatusOK, tmpl)
}
// GetVVTTemplates returns only the VVT templates for a given industry.
// GET /sdk/v1/industries/:slug/vvt-templates
func (h *IndustryHandlers) GetVVTTemplates(c *gin.Context) {
slug := c.Param("slug")
tmpl := industry.GetTemplateBySlug(slug)
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
return
}
c.JSON(http.StatusOK, gin.H{
"slug": tmpl.Slug,
"industry": tmpl.Name,
"vvt_templates": tmpl.VVTTemplates,
"total": len(tmpl.VVTTemplates),
})
}
// GetTOMRecommendations returns only the TOM recommendations for a given industry.
// GET /sdk/v1/industries/:slug/tom-recommendations
func (h *IndustryHandlers) GetTOMRecommendations(c *gin.Context) {
slug := c.Param("slug")
tmpl := industry.GetTemplateBySlug(slug)
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
return
}
c.JSON(http.StatusOK, gin.H{
"slug": tmpl.Slug,
"industry": tmpl.Name,
"tom_recommendations": tmpl.TOMRecommendations,
"total": len(tmpl.TOMRecommendations),
})
}
// GetRiskScenarios returns only the risk scenarios for a given industry.
// GET /sdk/v1/industries/:slug/risk-scenarios
func (h *IndustryHandlers) GetRiskScenarios(c *gin.Context) {
slug := c.Param("slug")
tmpl := industry.GetTemplateBySlug(slug)
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "industry template not found", "slug": slug})
return
}
c.JSON(http.StatusOK, gin.H{
"slug": tmpl.Slug,
"industry": tmpl.Name,
"risk_scenarios": tmpl.RiskScenarios,
"total": len(tmpl.RiskScenarios),
})
}

View File

@@ -0,0 +1,268 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/multitenant"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// MultiTenantHandlers handles multi-tenant administration endpoints.
type MultiTenantHandlers struct {
store *multitenant.Store
rbacStore *rbac.Store
}
// NewMultiTenantHandlers creates new multi-tenant handlers.
func NewMultiTenantHandlers(store *multitenant.Store, rbacStore *rbac.Store) *MultiTenantHandlers {
return &MultiTenantHandlers{
store: store,
rbacStore: rbacStore,
}
}
// GetOverview returns all tenants with compliance scores and module highlights.
// GET /sdk/v1/multi-tenant/overview
func (h *MultiTenantHandlers) GetOverview(c *gin.Context) {
overview, err := h.store.GetOverview(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, overview)
}
// GetTenantDetail returns detailed compliance info for one tenant.
// GET /sdk/v1/multi-tenant/tenants/:id
func (h *MultiTenantHandlers) GetTenantDetail(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
return
}
detail, err := h.store.GetTenantDetail(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
return
}
c.JSON(http.StatusOK, detail)
}
// CreateTenant creates a new tenant with default setup.
// It creates the tenant via the RBAC store and then creates a default "main" namespace.
// POST /sdk/v1/multi-tenant/tenants
func (h *MultiTenantHandlers) CreateTenant(c *gin.Context) {
var req multitenant.CreateTenantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build the tenant from the request
tenant := &rbac.Tenant{
Name: req.Name,
Slug: req.Slug,
MaxUsers: req.MaxUsers,
LLMQuotaMonthly: req.LLMQuotaMonthly,
}
// Create tenant via RBAC store (assigns ID, timestamps, defaults)
if err := h.rbacStore.CreateTenant(c.Request.Context(), tenant); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Create default "main" namespace for the new tenant
defaultNamespace := &rbac.Namespace{
TenantID: tenant.ID,
Name: "Main",
Slug: "main",
}
if err := h.rbacStore.CreateNamespace(c.Request.Context(), defaultNamespace); err != nil {
// Tenant was created successfully but namespace creation failed.
// Log and continue -- the tenant is still usable.
c.JSON(http.StatusCreated, gin.H{
"tenant": tenant,
"warning": "tenant created but default namespace creation failed: " + err.Error(),
})
return
}
c.JSON(http.StatusCreated, gin.H{
"tenant": tenant,
"namespace": defaultNamespace,
})
}
// UpdateTenant performs a partial update of tenant settings.
// Only non-nil fields in the request body are applied.
// PUT /sdk/v1/multi-tenant/tenants/:id
func (h *MultiTenantHandlers) UpdateTenant(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
return
}
var req multitenant.UpdateTenantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Fetch the existing tenant so we can apply partial updates
tenant, err := h.rbacStore.GetTenant(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
return
}
// Apply only the fields that were provided
if req.Name != nil {
tenant.Name = *req.Name
}
if req.MaxUsers != nil {
tenant.MaxUsers = *req.MaxUsers
}
if req.LLMQuotaMonthly != nil {
tenant.LLMQuotaMonthly = *req.LLMQuotaMonthly
}
if req.Status != nil {
tenant.Status = rbac.TenantStatus(*req.Status)
}
if err := h.rbacStore.UpdateTenant(c.Request.Context(), tenant); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tenant)
}
// ListNamespaces returns all namespaces for a specific tenant.
// GET /sdk/v1/multi-tenant/tenants/:id/namespaces
func (h *MultiTenantHandlers) ListNamespaces(c *gin.Context) {
tenantID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
return
}
namespaces, err := h.rbacStore.ListNamespaces(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"namespaces": namespaces,
"total": len(namespaces),
})
}
// CreateNamespace creates a new namespace within a tenant.
// POST /sdk/v1/multi-tenant/tenants/:id/namespaces
func (h *MultiTenantHandlers) CreateNamespace(c *gin.Context) {
tenantID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
return
}
// Verify the tenant exists
_, err = h.rbacStore.GetTenant(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
return
}
var req multitenant.CreateNamespaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
namespace := &rbac.Namespace{
TenantID: tenantID,
Name: req.Name,
Slug: req.Slug,
}
// Apply optional fields if provided
if req.IsolationLevel != "" {
namespace.IsolationLevel = rbac.IsolationLevel(req.IsolationLevel)
}
if req.DataClassification != "" {
namespace.DataClassification = rbac.DataClassification(req.DataClassification)
}
if err := h.rbacStore.CreateNamespace(c.Request.Context(), namespace); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, namespace)
}
// SwitchTenant returns the tenant info needed for the frontend to switch context.
// The caller provides a tenant_id and receives back the tenant details needed
// to update the frontend's active tenant state.
// POST /sdk/v1/multi-tenant/switch
func (h *MultiTenantHandlers) SwitchTenant(c *gin.Context) {
var req multitenant.SwitchTenantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID, err := uuid.Parse(req.TenantID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"})
return
}
tenant, err := h.rbacStore.GetTenant(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"})
return
}
// Verify the tenant is active
if tenant.Status != rbac.TenantStatusActive {
c.JSON(http.StatusForbidden, gin.H{
"error": "tenant not active",
"status": string(tenant.Status),
})
return
}
// Get namespaces for the tenant so the frontend can populate namespace selectors
namespaces, err := h.rbacStore.ListNamespaces(c.Request.Context(), tenantID)
if err != nil {
// Non-fatal: return tenant info without namespaces
c.JSON(http.StatusOK, gin.H{
"tenant": multitenant.SwitchTenantResponse{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
Status: string(tenant.Status),
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"tenant": multitenant.SwitchTenantResponse{
TenantID: tenant.ID,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
Status: string(tenant.Status),
},
"namespaces": namespaces,
})
}

View File

@@ -0,0 +1,80 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/reporting"
"github.com/gin-gonic/gin"
)
type ReportingHandlers struct {
store *reporting.Store
}
func NewReportingHandlers(store *reporting.Store) *ReportingHandlers {
return &ReportingHandlers{store: store}
}
// GetExecutiveReport generates a comprehensive compliance report
// GET /sdk/v1/reporting/executive
func (h *ReportingHandlers) GetExecutiveReport(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
}
// GetComplianceScore returns just the overall compliance score
// GET /sdk/v1/reporting/score
func (h *ReportingHandlers) GetComplianceScore(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"compliance_score": report.ComplianceScore,
"risk_level": report.RiskOverview.OverallLevel,
"generated_at": report.GeneratedAt,
})
}
// GetUpcomingDeadlines returns deadlines across all modules
// GET /sdk/v1/reporting/deadlines
func (h *ReportingHandlers) GetUpcomingDeadlines(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"deadlines": report.UpcomingDeadlines,
"total": len(report.UpcomingDeadlines),
})
}
// GetRiskOverview returns the aggregated risk assessment
// GET /sdk/v1/reporting/risks
func (h *ReportingHandlers) GetRiskOverview(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
report, err := h.store.GenerateReport(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report.RiskOverview)
}

View File

@@ -0,0 +1,631 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/sso"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// SSOHandlers handles SSO-related HTTP requests.
type SSOHandlers struct {
store *sso.Store
jwtSecret string
}
// NewSSOHandlers creates new SSO handlers.
func NewSSOHandlers(store *sso.Store, jwtSecret string) *SSOHandlers {
return &SSOHandlers{store: store, jwtSecret: jwtSecret}
}
// ============================================================================
// SSO Configuration CRUD
// ============================================================================
// CreateConfig creates a new SSO configuration for the tenant.
// POST /sdk/v1/sso/configs
func (h *SSOHandlers) CreateConfig(c *gin.Context) {
var req sso.CreateSSOConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
cfg, err := h.store.CreateConfig(c.Request.Context(), tenantID, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"config": cfg})
}
// ListConfigs lists all SSO configurations for the tenant.
// GET /sdk/v1/sso/configs
func (h *SSOHandlers) ListConfigs(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
configs, err := h.store.ListConfigs(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"configs": configs,
"total": len(configs),
})
}
// GetConfig retrieves an SSO configuration by ID.
// GET /sdk/v1/sso/configs/:id
func (h *SSOHandlers) GetConfig(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
configID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config ID"})
return
}
cfg, err := h.store.GetConfig(c.Request.Context(), tenantID, configID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cfg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "sso configuration not found"})
return
}
c.JSON(http.StatusOK, gin.H{"config": cfg})
}
// UpdateConfig updates an SSO configuration.
// PUT /sdk/v1/sso/configs/:id
func (h *SSOHandlers) UpdateConfig(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
configID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config ID"})
return
}
var req sso.UpdateSSOConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg, err := h.store.UpdateConfig(c.Request.Context(), tenantID, configID, &req)
if err != nil {
if err.Error() == "sso configuration not found" {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"config": cfg})
}
// DeleteConfig deletes an SSO configuration.
// DELETE /sdk/v1/sso/configs/:id
func (h *SSOHandlers) DeleteConfig(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
configID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config ID"})
return
}
if err := h.store.DeleteConfig(c.Request.Context(), tenantID, configID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "sso configuration deleted"})
}
// ============================================================================
// SSO Users
// ============================================================================
// ListUsers lists all SSO-provisioned users for the tenant.
// GET /sdk/v1/sso/users
func (h *SSOHandlers) ListUsers(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
users, err := h.store.ListUsers(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"users": users,
"total": len(users),
})
}
// ============================================================================
// OIDC Flow
// ============================================================================
// InitiateOIDCLogin initiates the OIDC authorization code flow.
// It looks up the enabled SSO config for the tenant, builds the authorization
// URL, sets a state cookie, and redirects the user to the IdP.
// GET /sdk/v1/sso/oidc/login
func (h *SSOHandlers) InitiateOIDCLogin(c *gin.Context) {
// Resolve tenant ID from query param
tenantIDStr := c.Query("tenant_id")
if tenantIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id query parameter is required"})
return
}
tenantID, err := uuid.Parse(tenantIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id"})
return
}
// Look up the enabled SSO config
cfg, err := h.store.GetEnabledConfig(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cfg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no enabled SSO configuration found for this tenant"})
return
}
if cfg.ProviderType != sso.ProviderTypeOIDC {
c.JSON(http.StatusBadRequest, gin.H{"error": "SSO configuration is not OIDC"})
return
}
// Discover the authorization endpoint
discoveryURL := strings.TrimSuffix(cfg.OIDCIssuerURL, "/") + "/.well-known/openid-configuration"
authEndpoint, _, _, err := discoverOIDCEndpoints(discoveryURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("OIDC discovery failed: %v", err)})
return
}
// Generate state parameter (random bytes + tenant_id for correlation)
stateBytes := make([]byte, 32)
if _, err := rand.Read(stateBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state"})
return
}
state := base64.URLEncoding.EncodeToString(stateBytes) + "." + tenantID.String()
// Generate nonce
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate nonce"})
return
}
nonce := base64.URLEncoding.EncodeToString(nonceBytes)
// Build authorization URL
scopes := cfg.OIDCScopes
if len(scopes) == 0 {
scopes = []string{"openid", "profile", "email"}
}
params := url.Values{
"client_id": {cfg.OIDCClientID},
"redirect_uri": {cfg.OIDCRedirectURI},
"response_type": {"code"},
"scope": {strings.Join(scopes, " ")},
"state": {state},
"nonce": {nonce},
}
authURL := authEndpoint + "?" + params.Encode()
// Set state cookie for CSRF protection (HttpOnly, 10 min expiry)
c.SetCookie("sso_state", state, 600, "/", "", true, true)
c.SetCookie("sso_nonce", nonce, 600, "/", "", true, true)
c.Redirect(http.StatusFound, authURL)
}
// HandleOIDCCallback handles the OIDC authorization code callback from the IdP.
// It validates the state, exchanges the code for tokens, extracts user info,
// performs JIT user provisioning, and issues a JWT.
// GET /sdk/v1/sso/oidc/callback
func (h *SSOHandlers) HandleOIDCCallback(c *gin.Context) {
// Check for errors from the IdP
if errParam := c.Query("error"); errParam != "" {
errDesc := c.Query("error_description")
c.JSON(http.StatusBadRequest, gin.H{
"error": errParam,
"description": errDesc,
})
return
}
code := c.Query("code")
stateParam := c.Query("state")
if code == "" || stateParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state parameter"})
return
}
// Validate state cookie
stateCookie, err := c.Cookie("sso_state")
if err != nil || stateCookie != stateParam {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state parameter (CSRF check failed)"})
return
}
// Extract tenant ID from state
parts := strings.SplitN(stateParam, ".", 2)
if len(parts) != 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "malformed state parameter"})
return
}
tenantID, err := uuid.Parse(parts[1])
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id in state"})
return
}
// Look up the enabled SSO config
cfg, err := h.store.GetEnabledConfig(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cfg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no enabled SSO configuration found"})
return
}
// Discover OIDC endpoints
discoveryURL := strings.TrimSuffix(cfg.OIDCIssuerURL, "/") + "/.well-known/openid-configuration"
_, tokenEndpoint, userInfoEndpoint, err := discoverOIDCEndpoints(discoveryURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("OIDC discovery failed: %v", err)})
return
}
// Exchange authorization code for tokens
tokenResp, err := exchangeCodeForTokens(tokenEndpoint, code, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURI)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("token exchange failed: %v", err)})
return
}
// Extract user claims from ID token or UserInfo endpoint
claims, err := extractUserClaims(tokenResp, userInfoEndpoint)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to extract user claims: %v", err)})
return
}
sub := getStringClaim(claims, "sub")
email := getStringClaim(claims, "email")
name := getStringClaim(claims, "name")
groups := getStringSliceClaim(claims, "groups")
if sub == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID token missing 'sub' claim"})
return
}
if email == "" {
email = sub
}
if name == "" {
name = email
}
// JIT provision the user
user, err := h.store.UpsertUser(c.Request.Context(), tenantID, cfg.ID, sub, email, name, groups)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("user provisioning failed: %v", err)})
return
}
// Determine roles from role mapping
roles := resolveRoles(cfg, groups)
// Generate JWT
ssoClaims := sso.SSOClaims{
UserID: user.ID,
TenantID: tenantID,
Email: user.Email,
DisplayName: user.DisplayName,
Roles: roles,
SSOConfigID: cfg.ID,
}
jwtToken, err := h.generateJWT(ssoClaims)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("JWT generation failed: %v", err)})
return
}
// Clear state cookies
c.SetCookie("sso_state", "", -1, "/", "", true, true)
c.SetCookie("sso_nonce", "", -1, "/", "", true, true)
// Return JWT as JSON (the frontend can also handle redirect)
c.JSON(http.StatusOK, gin.H{
"token": jwtToken,
"user": user,
"roles": roles,
})
}
// ============================================================================
// JWT Generation
// ============================================================================
// generateJWT creates a signed JWT token containing the SSO claims.
func (h *SSOHandlers) generateJWT(claims sso.SSOClaims) (string, error) {
now := time.Now().UTC()
expiry := now.Add(24 * time.Hour)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": claims.UserID.String(),
"tenant_id": claims.TenantID.String(),
"email": claims.Email,
"display_name": claims.DisplayName,
"roles": claims.Roles,
"sso_config_id": claims.SSOConfigID.String(),
"iss": "ai-compliance-sdk",
"iat": now.Unix(),
"exp": expiry.Unix(),
})
tokenString, err := token.SignedString([]byte(h.jwtSecret))
if err != nil {
return "", fmt.Errorf("failed to sign JWT: %w", err)
}
return tokenString, nil
}
// ============================================================================
// OIDC Discovery & Token Exchange (manual HTTP, no external OIDC library)
// ============================================================================
// oidcDiscoveryResponse holds the relevant fields from the OIDC discovery document.
type oidcDiscoveryResponse struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
JwksURI string `json:"jwks_uri"`
Issuer string `json:"issuer"`
}
// discoverOIDCEndpoints fetches the OIDC discovery document and returns
// the authorization, token, and userinfo endpoints.
func discoverOIDCEndpoints(discoveryURL string) (authEndpoint, tokenEndpoint, userInfoEndpoint string, err error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(discoveryURL)
if err != nil {
return "", "", "", fmt.Errorf("failed to fetch discovery document: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", "", "", fmt.Errorf("discovery endpoint returned %d: %s", resp.StatusCode, string(body))
}
var discovery oidcDiscoveryResponse
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
return "", "", "", fmt.Errorf("failed to decode discovery document: %w", err)
}
if discovery.AuthorizationEndpoint == "" {
return "", "", "", fmt.Errorf("discovery document missing authorization_endpoint")
}
if discovery.TokenEndpoint == "" {
return "", "", "", fmt.Errorf("discovery document missing token_endpoint")
}
return discovery.AuthorizationEndpoint, discovery.TokenEndpoint, discovery.UserinfoEndpoint, nil
}
// oidcTokenResponse holds the response from the OIDC token endpoint.
type oidcTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// exchangeCodeForTokens exchanges an authorization code for tokens at the token endpoint.
func exchangeCodeForTokens(tokenEndpoint, code, clientID, clientSecret, redirectURI string) (*oidcTokenResponse, error) {
client := &http.Client{Timeout: 10 * time.Second}
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"client_id": {clientID},
"redirect_uri": {redirectURI},
}
req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Use client_secret_basic if provided
if clientSecret != "" {
req.SetBasicAuth(clientID, clientSecret)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp oidcTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("failed to decode token response: %w", err)
}
return &tokenResp, nil
}
// extractUserClaims extracts user claims from the ID token payload.
// If the ID token is unavailable or incomplete, it falls back to the UserInfo endpoint.
func extractUserClaims(tokenResp *oidcTokenResponse, userInfoEndpoint string) (map[string]interface{}, error) {
claims := make(map[string]interface{})
// Try to decode ID token payload (without signature verification for claims extraction;
// in production, you should verify the signature using the JWKS endpoint)
if tokenResp.IDToken != "" {
parts := strings.Split(tokenResp.IDToken, ".")
if len(parts) == 3 {
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err == nil {
if err := json.Unmarshal(payload, &claims); err == nil && claims["sub"] != nil {
return claims, nil
}
}
}
}
// Fallback to UserInfo endpoint
if userInfoEndpoint != "" && tokenResp.AccessToken != "" {
userClaims, err := fetchUserInfo(userInfoEndpoint, tokenResp.AccessToken)
if err == nil && userClaims["sub"] != nil {
return userClaims, nil
}
}
if claims["sub"] != nil {
return claims, nil
}
return nil, fmt.Errorf("could not extract user claims from ID token or UserInfo endpoint")
}
// fetchUserInfo calls the OIDC UserInfo endpoint with the access token.
func fetchUserInfo(userInfoEndpoint, accessToken string) (map[string]interface{}, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", userInfoEndpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("userinfo endpoint returned %d", resp.StatusCode)
}
var claims map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil {
return nil, err
}
return claims, nil
}
// ============================================================================
// Claim Extraction Helpers
// ============================================================================
// getStringClaim extracts a string claim from a claims map.
func getStringClaim(claims map[string]interface{}, key string) string {
if v, ok := claims[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// getStringSliceClaim extracts a string slice claim from a claims map.
func getStringSliceClaim(claims map[string]interface{}, key string) []string {
v, ok := claims[key]
if !ok {
return nil
}
switch val := v.(type) {
case []interface{}:
result := make([]string, 0, len(val))
for _, item := range val {
if s, ok := item.(string); ok {
result = append(result, s)
}
}
return result
case []string:
return val
default:
return nil
}
}
// resolveRoles maps SSO groups to internal roles using the config's role mapping.
// If no groups match, the default role is returned.
func resolveRoles(cfg *sso.SSOConfig, groups []string) []string {
if cfg.RoleMapping == nil || len(cfg.RoleMapping) == 0 {
if cfg.DefaultRoleID != nil {
return []string{cfg.DefaultRoleID.String()}
}
return []string{"compliance_user"}
}
roleSet := make(map[string]bool)
for _, group := range groups {
if role, ok := cfg.RoleMapping[group]; ok {
roleSet[role] = true
}
}
if len(roleSet) == 0 {
if cfg.DefaultRoleID != nil {
return []string{cfg.DefaultRoleID.String()}
}
return []string{"compliance_user"}
}
roles := make([]string, 0, len(roleSet))
for role := range roleSet {
roles = append(roles, role)
}
return roles
}

View File

@@ -0,0 +1,850 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/vendor"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// VendorHandlers handles vendor-compliance HTTP requests
type VendorHandlers struct {
store *vendor.Store
}
// NewVendorHandlers creates new vendor handlers
func NewVendorHandlers(store *vendor.Store) *VendorHandlers {
return &VendorHandlers{store: store}
}
// ============================================================================
// Vendor CRUD
// ============================================================================
// CreateVendor creates a new vendor
// POST /sdk/v1/vendors
func (h *VendorHandlers) CreateVendor(c *gin.Context) {
var req vendor.CreateVendorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
v := &vendor.Vendor{
TenantID: tenantID,
Name: req.Name,
LegalForm: req.LegalForm,
Country: req.Country,
Address: req.Address,
Website: req.Website,
ContactName: req.ContactName,
ContactEmail: req.ContactEmail,
ContactPhone: req.ContactPhone,
ContactDepartment: req.ContactDepartment,
Role: req.Role,
ServiceCategory: req.ServiceCategory,
ServiceDescription: req.ServiceDescription,
DataAccessLevel: req.DataAccessLevel,
ProcessingLocations: req.ProcessingLocations,
Certifications: req.Certifications,
ReviewFrequency: req.ReviewFrequency,
ProcessingActivityIDs: req.ProcessingActivityIDs,
TemplateID: req.TemplateID,
Status: vendor.VendorStatusActive,
CreatedBy: userID.String(),
}
if err := h.store.CreateVendor(c.Request.Context(), v); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"vendor": v})
}
// ListVendors lists all vendors for a tenant
// GET /sdk/v1/vendors
func (h *VendorHandlers) ListVendors(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
vendors, err := h.store.ListVendors(c.Request.Context(), tenantID.String())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"vendors": vendors,
"total": len(vendors),
})
}
// GetVendor retrieves a vendor by ID with contracts and findings
// GET /sdk/v1/vendors/:id
func (h *VendorHandlers) GetVendor(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
v, err := h.store.GetVendor(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if v == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "vendor not found"})
return
}
contracts, _ := h.store.ListContracts(c.Request.Context(), tenantID.String(), &id)
findings, _ := h.store.ListFindings(c.Request.Context(), tenantID.String(), &id, nil)
c.JSON(http.StatusOK, gin.H{
"vendor": v,
"contracts": contracts,
"findings": findings,
})
}
// UpdateVendor updates a vendor
// PUT /sdk/v1/vendors/:id
func (h *VendorHandlers) UpdateVendor(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
v, err := h.store.GetVendor(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if v == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "vendor not found"})
return
}
var req vendor.UpdateVendorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Apply non-nil fields
if req.Name != nil {
v.Name = *req.Name
}
if req.LegalForm != nil {
v.LegalForm = *req.LegalForm
}
if req.Country != nil {
v.Country = *req.Country
}
if req.Address != nil {
v.Address = req.Address
}
if req.Website != nil {
v.Website = *req.Website
}
if req.ContactName != nil {
v.ContactName = *req.ContactName
}
if req.ContactEmail != nil {
v.ContactEmail = *req.ContactEmail
}
if req.ContactPhone != nil {
v.ContactPhone = *req.ContactPhone
}
if req.ContactDepartment != nil {
v.ContactDepartment = *req.ContactDepartment
}
if req.Role != nil {
v.Role = *req.Role
}
if req.ServiceCategory != nil {
v.ServiceCategory = *req.ServiceCategory
}
if req.ServiceDescription != nil {
v.ServiceDescription = *req.ServiceDescription
}
if req.DataAccessLevel != nil {
v.DataAccessLevel = *req.DataAccessLevel
}
if req.ProcessingLocations != nil {
v.ProcessingLocations = req.ProcessingLocations
}
if req.Certifications != nil {
v.Certifications = req.Certifications
}
if req.InherentRiskScore != nil {
v.InherentRiskScore = req.InherentRiskScore
}
if req.ResidualRiskScore != nil {
v.ResidualRiskScore = req.ResidualRiskScore
}
if req.ManualRiskAdjustment != nil {
v.ManualRiskAdjustment = req.ManualRiskAdjustment
}
if req.ReviewFrequency != nil {
v.ReviewFrequency = *req.ReviewFrequency
}
if req.LastReviewDate != nil {
v.LastReviewDate = req.LastReviewDate
}
if req.NextReviewDate != nil {
v.NextReviewDate = req.NextReviewDate
}
if req.ProcessingActivityIDs != nil {
v.ProcessingActivityIDs = req.ProcessingActivityIDs
}
if req.Status != nil {
v.Status = *req.Status
}
if req.TemplateID != nil {
v.TemplateID = req.TemplateID
}
if err := h.store.UpdateVendor(c.Request.Context(), v); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"vendor": v})
}
// DeleteVendor deletes a vendor
// DELETE /sdk/v1/vendors/:id
func (h *VendorHandlers) DeleteVendor(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
if err := h.store.DeleteVendor(c.Request.Context(), tenantID.String(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "vendor deleted"})
}
// ============================================================================
// Contract CRUD
// ============================================================================
// CreateContract creates a new contract for a vendor
// POST /sdk/v1/vendors/contracts
func (h *VendorHandlers) CreateContract(c *gin.Context) {
var req vendor.CreateContractRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
contract := &vendor.Contract{
TenantID: tenantID,
VendorID: req.VendorID,
FileName: req.FileName,
OriginalName: req.OriginalName,
MimeType: req.MimeType,
FileSize: req.FileSize,
StoragePath: req.StoragePath,
DocumentType: req.DocumentType,
Parties: req.Parties,
EffectiveDate: req.EffectiveDate,
ExpirationDate: req.ExpirationDate,
AutoRenewal: req.AutoRenewal,
RenewalNoticePeriod: req.RenewalNoticePeriod,
Version: req.Version,
PreviousVersionID: req.PreviousVersionID,
ReviewStatus: "PENDING",
CreatedBy: userID.String(),
}
if contract.Version == "" {
contract.Version = "1.0"
}
if err := h.store.CreateContract(c.Request.Context(), contract); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"contract": contract})
}
// ListContracts lists contracts for a tenant
// GET /sdk/v1/vendors/contracts
func (h *VendorHandlers) ListContracts(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var vendorID *string
if vid := c.Query("vendor_id"); vid != "" {
vendorID = &vid
}
contracts, err := h.store.ListContracts(c.Request.Context(), tenantID.String(), vendorID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"contracts": contracts,
"total": len(contracts),
})
}
// GetContract retrieves a contract by ID
// GET /sdk/v1/vendors/contracts/:id
func (h *VendorHandlers) GetContract(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
contract, err := h.store.GetContract(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if contract == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "contract not found"})
return
}
c.JSON(http.StatusOK, gin.H{"contract": contract})
}
// UpdateContract updates a contract
// PUT /sdk/v1/vendors/contracts/:id
func (h *VendorHandlers) UpdateContract(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
contract, err := h.store.GetContract(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if contract == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "contract not found"})
return
}
var req vendor.UpdateContractRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.DocumentType != nil {
contract.DocumentType = *req.DocumentType
}
if req.Parties != nil {
contract.Parties = req.Parties
}
if req.EffectiveDate != nil {
contract.EffectiveDate = req.EffectiveDate
}
if req.ExpirationDate != nil {
contract.ExpirationDate = req.ExpirationDate
}
if req.AutoRenewal != nil {
contract.AutoRenewal = *req.AutoRenewal
}
if req.RenewalNoticePeriod != nil {
contract.RenewalNoticePeriod = *req.RenewalNoticePeriod
}
if req.ReviewStatus != nil {
contract.ReviewStatus = *req.ReviewStatus
}
if req.ReviewCompletedAt != nil {
contract.ReviewCompletedAt = req.ReviewCompletedAt
}
if req.ComplianceScore != nil {
contract.ComplianceScore = req.ComplianceScore
}
if req.Version != nil {
contract.Version = *req.Version
}
if req.ExtractedText != nil {
contract.ExtractedText = *req.ExtractedText
}
if req.PageCount != nil {
contract.PageCount = req.PageCount
}
if err := h.store.UpdateContract(c.Request.Context(), contract); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"contract": contract})
}
// DeleteContract deletes a contract
// DELETE /sdk/v1/vendors/contracts/:id
func (h *VendorHandlers) DeleteContract(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
if err := h.store.DeleteContract(c.Request.Context(), tenantID.String(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "contract deleted"})
}
// ============================================================================
// Finding CRUD
// ============================================================================
// CreateFinding creates a new compliance finding
// POST /sdk/v1/vendors/findings
func (h *VendorHandlers) CreateFinding(c *gin.Context) {
var req vendor.CreateFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
finding := &vendor.Finding{
TenantID: tenantID,
VendorID: req.VendorID,
ContractID: req.ContractID,
FindingType: req.FindingType,
Category: req.Category,
Severity: req.Severity,
Title: req.Title,
Description: req.Description,
Recommendation: req.Recommendation,
Citations: req.Citations,
Status: vendor.FindingStatusOpen,
Assignee: req.Assignee,
DueDate: req.DueDate,
}
if err := h.store.CreateFinding(c.Request.Context(), finding); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"finding": finding})
}
// ListFindings lists findings for a tenant
// GET /sdk/v1/vendors/findings
func (h *VendorHandlers) ListFindings(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
var vendorID, contractID *string
if vid := c.Query("vendor_id"); vid != "" {
vendorID = &vid
}
if cid := c.Query("contract_id"); cid != "" {
contractID = &cid
}
findings, err := h.store.ListFindings(c.Request.Context(), tenantID.String(), vendorID, contractID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"findings": findings,
"total": len(findings),
})
}
// GetFinding retrieves a finding by ID
// GET /sdk/v1/vendors/findings/:id
func (h *VendorHandlers) GetFinding(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
finding, err := h.store.GetFinding(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if finding == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "finding not found"})
return
}
c.JSON(http.StatusOK, gin.H{"finding": finding})
}
// UpdateFinding updates a finding
// PUT /sdk/v1/vendors/findings/:id
func (h *VendorHandlers) UpdateFinding(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
id := c.Param("id")
finding, err := h.store.GetFinding(c.Request.Context(), tenantID.String(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if finding == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "finding not found"})
return
}
var req vendor.UpdateFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.FindingType != nil {
finding.FindingType = *req.FindingType
}
if req.Category != nil {
finding.Category = *req.Category
}
if req.Severity != nil {
finding.Severity = *req.Severity
}
if req.Title != nil {
finding.Title = *req.Title
}
if req.Description != nil {
finding.Description = *req.Description
}
if req.Recommendation != nil {
finding.Recommendation = *req.Recommendation
}
if req.Citations != nil {
finding.Citations = req.Citations
}
if req.Status != nil {
finding.Status = *req.Status
}
if req.Assignee != nil {
finding.Assignee = *req.Assignee
}
if req.DueDate != nil {
finding.DueDate = req.DueDate
}
if req.Resolution != nil {
finding.Resolution = *req.Resolution
}
if err := h.store.UpdateFinding(c.Request.Context(), finding); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"finding": finding})
}
// ResolveFinding resolves a finding with a resolution description
// POST /sdk/v1/vendors/findings/:id/resolve
func (h *VendorHandlers) ResolveFinding(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
id := c.Param("id")
var req vendor.ResolveFindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.store.ResolveFinding(c.Request.Context(), tenantID.String(), id, req.Resolution, userID.String()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "finding resolved"})
}
// ============================================================================
// Control Instance Operations
// ============================================================================
// UpsertControlInstance creates or updates a control instance
// POST /sdk/v1/vendors/controls
func (h *VendorHandlers) UpsertControlInstance(c *gin.Context) {
var req struct {
VendorID string `json:"vendor_id" binding:"required"`
ControlID string `json:"control_id" binding:"required"`
ControlDomain string `json:"control_domain"`
Status vendor.ControlStatus `json:"status" binding:"required"`
EvidenceIDs json.RawMessage `json:"evidence_ids,omitempty"`
Notes string `json:"notes,omitempty"`
NextAssessmentDate *time.Time `json:"next_assessment_date,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
now := time.Now().UTC()
userIDStr := userID.String()
ci := &vendor.ControlInstance{
TenantID: tenantID,
ControlID: req.ControlID,
ControlDomain: req.ControlDomain,
Status: req.Status,
EvidenceIDs: req.EvidenceIDs,
Notes: req.Notes,
LastAssessedAt: &now,
LastAssessedBy: &userIDStr,
NextAssessmentDate: req.NextAssessmentDate,
}
// Parse VendorID
vendorUUID, err := parseUUID(req.VendorID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid vendor_id"})
return
}
ci.VendorID = vendorUUID
if err := h.store.UpsertControlInstance(c.Request.Context(), ci); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"control_instance": ci})
}
// ListControlInstances lists control instances for a vendor
// GET /sdk/v1/vendors/controls
func (h *VendorHandlers) ListControlInstances(c *gin.Context) {
vendorID := c.Query("vendor_id")
if vendorID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "vendor_id query parameter is required"})
return
}
tenantID := rbac.GetTenantID(c)
instances, err := h.store.ListControlInstances(c.Request.Context(), tenantID.String(), vendorID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"control_instances": instances,
"total": len(instances),
})
}
// ============================================================================
// Template Operations
// ============================================================================
// ListTemplates lists available templates
// GET /sdk/v1/vendors/templates
func (h *VendorHandlers) ListTemplates(c *gin.Context) {
templateType := c.DefaultQuery("type", "VENDOR")
var category, industry *string
if cat := c.Query("category"); cat != "" {
category = &cat
}
if ind := c.Query("industry"); ind != "" {
industry = &ind
}
templates, err := h.store.ListTemplates(c.Request.Context(), templateType, category, industry)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"templates": templates,
"total": len(templates),
})
}
// GetTemplate retrieves a template by its template_id string
// GET /sdk/v1/vendors/templates/:templateId
func (h *VendorHandlers) GetTemplate(c *gin.Context) {
templateID := c.Param("templateId")
if templateID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "template ID is required"})
return
}
tmpl, err := h.store.GetTemplate(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
c.JSON(http.StatusOK, gin.H{"template": tmpl})
}
// CreateTemplate creates a custom template
// POST /sdk/v1/vendors/templates
func (h *VendorHandlers) CreateTemplate(c *gin.Context) {
var req vendor.CreateTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tmpl := &vendor.Template{
TemplateType: req.TemplateType,
TemplateID: req.TemplateID,
Category: req.Category,
NameDE: req.NameDE,
NameEN: req.NameEN,
DescriptionDE: req.DescriptionDE,
DescriptionEN: req.DescriptionEN,
TemplateData: req.TemplateData,
Industry: req.Industry,
Tags: req.Tags,
IsSystem: req.IsSystem,
IsActive: true,
}
// Set tenant for custom (non-system) templates
if !req.IsSystem {
tid := rbac.GetTenantID(c).String()
tmpl.TenantID = &tid
}
if err := h.store.CreateTemplate(c.Request.Context(), tmpl); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"template": tmpl})
}
// ApplyTemplate creates a vendor from a template
// POST /sdk/v1/vendors/templates/:templateId/apply
func (h *VendorHandlers) ApplyTemplate(c *gin.Context) {
templateID := c.Param("templateId")
if templateID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "template ID is required"})
return
}
tmpl, err := h.store.GetTemplate(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if tmpl == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
// Parse template_data to extract suggested vendor fields
var templateData struct {
ServiceCategory string `json:"service_category"`
SuggestedRole string `json:"suggested_role"`
DataAccessLevel string `json:"data_access_level"`
ReviewFrequency string `json:"review_frequency"`
Certifications json.RawMessage `json:"certifications"`
ProcessingLocations json.RawMessage `json:"processing_locations"`
}
if err := json.Unmarshal(tmpl.TemplateData, &templateData); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse template data"})
return
}
// Optional overrides from request body
var overrides struct {
Name string `json:"name"`
Country string `json:"country"`
Website string `json:"website"`
ContactName string `json:"contact_name"`
ContactEmail string `json:"contact_email"`
}
c.ShouldBindJSON(&overrides)
tenantID := rbac.GetTenantID(c)
userID := rbac.GetUserID(c)
v := &vendor.Vendor{
TenantID: tenantID,
Name: overrides.Name,
Country: overrides.Country,
Website: overrides.Website,
ContactName: overrides.ContactName,
ContactEmail: overrides.ContactEmail,
Role: vendor.VendorRole(templateData.SuggestedRole),
ServiceCategory: templateData.ServiceCategory,
DataAccessLevel: templateData.DataAccessLevel,
ReviewFrequency: templateData.ReviewFrequency,
Certifications: templateData.Certifications,
ProcessingLocations: templateData.ProcessingLocations,
Status: vendor.VendorStatusActive,
TemplateID: &templateID,
CreatedBy: userID.String(),
}
if v.Name == "" {
v.Name = tmpl.NameDE
}
if v.Country == "" {
v.Country = "DE"
}
if v.Role == "" {
v.Role = vendor.VendorRoleProcessor
}
if err := h.store.CreateVendor(c.Request.Context(), v); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Increment template usage
_ = h.store.IncrementTemplateUsage(c.Request.Context(), templateID)
c.JSON(http.StatusCreated, gin.H{
"vendor": v,
"template_id": templateID,
"message": "vendor created from template",
})
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated vendor statistics
// GET /sdk/v1/vendors/stats
func (h *VendorHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetVendorStats(c.Request.Context(), tenantID.String())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// ============================================================================
// Helpers
// ============================================================================
func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}

View File

@@ -0,0 +1,538 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/whistleblower"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// WhistleblowerHandlers handles whistleblower HTTP requests
type WhistleblowerHandlers struct {
store *whistleblower.Store
}
// NewWhistleblowerHandlers creates new whistleblower handlers
func NewWhistleblowerHandlers(store *whistleblower.Store) *WhistleblowerHandlers {
return &WhistleblowerHandlers{store: store}
}
// ============================================================================
// Public Handlers (NO auth required — for anonymous reporters)
// ============================================================================
// SubmitReport handles public report submission (no auth required)
// POST /sdk/v1/whistleblower/public/submit
func (h *WhistleblowerHandlers) SubmitReport(c *gin.Context) {
var req whistleblower.PublicReportSubmission
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get tenant ID from header or query param (public endpoint still needs tenant context)
tenantIDStr := c.GetHeader("X-Tenant-ID")
if tenantIDStr == "" {
tenantIDStr = c.Query("tenant_id")
}
if tenantIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id is required"})
return
}
tenantID, err := uuid.Parse(tenantIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant_id"})
return
}
report := &whistleblower.Report{
TenantID: tenantID,
Category: req.Category,
Title: req.Title,
Description: req.Description,
IsAnonymous: req.IsAnonymous,
}
// Only set reporter info if not anonymous
if !req.IsAnonymous {
report.ReporterName = req.ReporterName
report.ReporterEmail = req.ReporterEmail
report.ReporterPhone = req.ReporterPhone
}
if err := h.store.CreateReport(c.Request.Context(), report); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Return reference number and access key (access key only shown ONCE!)
c.JSON(http.StatusCreated, whistleblower.PublicReportResponse{
ReferenceNumber: report.ReferenceNumber,
AccessKey: report.AccessKey,
})
}
// GetReportByAccessKey retrieves a report by access key (for anonymous reporters)
// GET /sdk/v1/whistleblower/public/report?access_key=xxx
func (h *WhistleblowerHandlers) GetReportByAccessKey(c *gin.Context) {
accessKey := c.Query("access_key")
if accessKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "access_key is required"})
return
}
report, err := h.store.GetReportByAccessKey(c.Request.Context(), accessKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
// Return limited fields for public access (no access_key, no internal details)
c.JSON(http.StatusOK, gin.H{
"reference_number": report.ReferenceNumber,
"category": report.Category,
"status": report.Status,
"title": report.Title,
"received_at": report.ReceivedAt,
"deadline_acknowledgment": report.DeadlineAcknowledgment,
"deadline_feedback": report.DeadlineFeedback,
"acknowledged_at": report.AcknowledgedAt,
"closed_at": report.ClosedAt,
})
}
// SendPublicMessage allows a reporter to send a message via access key
// POST /sdk/v1/whistleblower/public/message?access_key=xxx
func (h *WhistleblowerHandlers) SendPublicMessage(c *gin.Context) {
accessKey := c.Query("access_key")
if accessKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "access_key is required"})
return
}
report, err := h.store.GetReportByAccessKey(c.Request.Context(), accessKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := &whistleblower.AnonymousMessage{
ReportID: report.ID,
Direction: whistleblower.MessageDirectionReporterToAdmin,
Content: req.Content,
}
if err := h.store.AddMessage(c.Request.Context(), msg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": msg})
}
// ============================================================================
// Admin Handlers (auth required)
// ============================================================================
// ListReports lists all reports for the tenant
// GET /sdk/v1/whistleblower/reports
func (h *WhistleblowerHandlers) ListReports(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
filters := &whistleblower.ReportFilters{
Limit: 50,
}
if status := c.Query("status"); status != "" {
filters.Status = whistleblower.ReportStatus(status)
}
if category := c.Query("category"); category != "" {
filters.Category = whistleblower.ReportCategory(category)
}
reports, total, err := h.store.ListReports(c.Request.Context(), tenantID, filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, whistleblower.ReportListResponse{
Reports: reports,
Total: total,
})
}
// GetReport retrieves a report by ID (admin)
// GET /sdk/v1/whistleblower/reports/:id
func (h *WhistleblowerHandlers) GetReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
// Get messages and measures for full view
messages, _ := h.store.ListMessages(c.Request.Context(), id)
measures, _ := h.store.ListMeasures(c.Request.Context(), id)
// Do not expose access key to admin either
report.AccessKey = ""
c.JSON(http.StatusOK, gin.H{
"report": report,
"messages": messages,
"measures": measures,
})
}
// UpdateReport updates a report
// PUT /sdk/v1/whistleblower/reports/:id
func (h *WhistleblowerHandlers) UpdateReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.ReportUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
if req.Category != "" {
report.Category = req.Category
}
if req.Status != "" {
report.Status = req.Status
}
if req.Title != "" {
report.Title = req.Title
}
if req.Description != "" {
report.Description = req.Description
}
if req.AssignedTo != nil {
report.AssignedTo = req.AssignedTo
}
report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{
Timestamp: time.Now().UTC(),
Action: "report_updated",
UserID: userID.String(),
Details: "Report updated by admin",
})
if err := h.store.UpdateReport(c.Request.Context(), report); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
report.AccessKey = ""
c.JSON(http.StatusOK, gin.H{"report": report})
}
// DeleteReport deletes a report
// DELETE /sdk/v1/whistleblower/reports/:id
func (h *WhistleblowerHandlers) DeleteReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
if err := h.store.DeleteReport(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "report deleted"})
}
// AcknowledgeReport acknowledges a report (within 7-day HinSchG deadline)
// POST /sdk/v1/whistleblower/reports/:id/acknowledge
func (h *WhistleblowerHandlers) AcknowledgeReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
if report.AcknowledgedAt != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "report already acknowledged"})
return
}
userID := rbac.GetUserID(c)
if err := h.store.AcknowledgeReport(c.Request.Context(), id, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Optionally send acknowledgment message to reporter
var req whistleblower.AcknowledgeRequest
if err := c.ShouldBindJSON(&req); err == nil && req.Message != "" {
msg := &whistleblower.AnonymousMessage{
ReportID: id,
Direction: whistleblower.MessageDirectionAdminToReporter,
Content: req.Message,
}
h.store.AddMessage(c.Request.Context(), msg)
}
// Check if deadline was met
isOverdue := time.Now().UTC().After(report.DeadlineAcknowledgment)
c.JSON(http.StatusOK, gin.H{
"message": "report acknowledged",
"is_overdue": isOverdue,
})
}
// StartInvestigation changes the report status to investigation
// POST /sdk/v1/whistleblower/reports/:id/investigate
func (h *WhistleblowerHandlers) StartInvestigation(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
report, err := h.store.GetReport(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
userID := rbac.GetUserID(c)
report.Status = whistleblower.ReportStatusInvestigation
report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{
Timestamp: time.Now().UTC(),
Action: "investigation_started",
UserID: userID.String(),
Details: "Investigation started",
})
if err := h.store.UpdateReport(c.Request.Context(), report); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "investigation started",
"report": report,
})
}
// AddMeasure adds a corrective measure to a report
// POST /sdk/v1/whistleblower/reports/:id/measures
func (h *WhistleblowerHandlers) AddMeasure(c *gin.Context) {
reportID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
// Verify report exists
report, err := h.store.GetReport(c.Request.Context(), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.AddMeasureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
measure := &whistleblower.Measure{
ReportID: reportID,
Title: req.Title,
Description: req.Description,
Responsible: req.Responsible,
DueDate: req.DueDate,
}
if err := h.store.AddMeasure(c.Request.Context(), measure); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Update report status to measures_taken if not already
if report.Status != whistleblower.ReportStatusMeasuresTaken &&
report.Status != whistleblower.ReportStatusClosed {
report.Status = whistleblower.ReportStatusMeasuresTaken
report.AuditTrail = append(report.AuditTrail, whistleblower.AuditEntry{
Timestamp: time.Now().UTC(),
Action: "measure_added",
UserID: userID.String(),
Details: "Corrective measure added: " + req.Title,
})
h.store.UpdateReport(c.Request.Context(), report)
}
c.JSON(http.StatusCreated, gin.H{"measure": measure})
}
// CloseReport closes a report with a resolution
// POST /sdk/v1/whistleblower/reports/:id/close
func (h *WhistleblowerHandlers) CloseReport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
var req whistleblower.CloseReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := rbac.GetUserID(c)
if err := h.store.CloseReport(c.Request.Context(), id, userID, req.Resolution); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "report closed"})
}
// SendAdminMessage sends a message from admin to reporter
// POST /sdk/v1/whistleblower/reports/:id/messages
func (h *WhistleblowerHandlers) SendAdminMessage(c *gin.Context) {
reportID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
// Verify report exists
report, err := h.store.GetReport(c.Request.Context(), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if report == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "report not found"})
return
}
var req whistleblower.SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := &whistleblower.AnonymousMessage{
ReportID: reportID,
Direction: whistleblower.MessageDirectionAdminToReporter,
Content: req.Content,
}
if err := h.store.AddMessage(c.Request.Context(), msg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"message": msg})
}
// ListMessages lists messages for a report
// GET /sdk/v1/whistleblower/reports/:id/messages
func (h *WhistleblowerHandlers) ListMessages(c *gin.Context) {
reportID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
messages, err := h.store.ListMessages(c.Request.Context(), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"messages": messages,
"total": len(messages),
})
}
// GetStatistics returns whistleblower statistics for the tenant
// GET /sdk/v1/whistleblower/statistics
func (h *WhistleblowerHandlers) GetStatistics(c *gin.Context) {
tenantID := rbac.GetTenantID(c)
stats, err := h.store.GetStatistics(c.Request.Context(), tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

View File

@@ -0,0 +1,164 @@
package dsb
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Core Models
// ============================================================================
// Assignment represents a DSB-to-tenant assignment.
type Assignment struct {
ID uuid.UUID `json:"id"`
DSBUserID uuid.UUID `json:"dsb_user_id"`
TenantID uuid.UUID `json:"tenant_id"`
TenantName string `json:"tenant_name"` // populated via JOIN
TenantSlug string `json:"tenant_slug"` // populated via JOIN
Status string `json:"status"` // active, paused, terminated
ContractStart time.Time `json:"contract_start"`
ContractEnd *time.Time `json:"contract_end,omitempty"`
MonthlyHoursBudget float64 `json:"monthly_hours_budget"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// HourEntry represents a DSB time tracking entry.
type HourEntry struct {
ID uuid.UUID `json:"id"`
AssignmentID uuid.UUID `json:"assignment_id"`
Date time.Time `json:"date"`
Hours float64 `json:"hours"`
Category string `json:"category"` // dsfa_review, consultation, audit, training, incident_response, documentation, meeting, other
Description string `json:"description"`
Billable bool `json:"billable"`
CreatedAt time.Time `json:"created_at"`
}
// Task represents a DSB task/work item.
type Task struct {
ID uuid.UUID `json:"id"`
AssignmentID uuid.UUID `json:"assignment_id"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"` // dsfa_review, dsr_response, incident_review, audit_preparation, policy_review, training, consultation, other
Priority string `json:"priority"` // low, medium, high, urgent
Status string `json:"status"` // open, in_progress, waiting, completed, cancelled
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Communication represents a DSB communication log entry.
type Communication struct {
ID uuid.UUID `json:"id"`
AssignmentID uuid.UUID `json:"assignment_id"`
Direction string `json:"direction"` // inbound, outbound
Channel string `json:"channel"` // email, phone, meeting, portal, letter
Subject string `json:"subject"`
Content string `json:"content"`
Participants string `json:"participants"`
CreatedAt time.Time `json:"created_at"`
}
// ============================================================================
// Dashboard Models
// ============================================================================
// DSBDashboard provides the aggregated overview for a DSB user.
type DSBDashboard struct {
Assignments []AssignmentOverview `json:"assignments"`
TotalAssignments int `json:"total_assignments"`
ActiveAssignments int `json:"active_assignments"`
TotalHoursThisMonth float64 `json:"total_hours_this_month"`
OpenTasks int `json:"open_tasks"`
UrgentTasks int `json:"urgent_tasks"`
GeneratedAt time.Time `json:"generated_at"`
}
// AssignmentOverview enriches an Assignment with aggregated metrics.
type AssignmentOverview struct {
Assignment
ComplianceScore int `json:"compliance_score"`
HoursThisMonth float64 `json:"hours_this_month"`
HoursBudget float64 `json:"hours_budget"`
OpenTaskCount int `json:"open_task_count"`
UrgentTaskCount int `json:"urgent_task_count"`
NextDeadline *time.Time `json:"next_deadline,omitempty"`
}
// ============================================================================
// Request Models
// ============================================================================
// CreateAssignmentRequest is the request body for creating an assignment.
type CreateAssignmentRequest struct {
DSBUserID uuid.UUID `json:"dsb_user_id" binding:"required"`
TenantID uuid.UUID `json:"tenant_id" binding:"required"`
Status string `json:"status"`
ContractStart time.Time `json:"contract_start" binding:"required"`
ContractEnd *time.Time `json:"contract_end,omitempty"`
MonthlyHoursBudget float64 `json:"monthly_hours_budget"`
Notes string `json:"notes"`
}
// UpdateAssignmentRequest is the request body for updating an assignment.
type UpdateAssignmentRequest struct {
Status *string `json:"status,omitempty"`
ContractEnd *time.Time `json:"contract_end,omitempty"`
MonthlyHoursBudget *float64 `json:"monthly_hours_budget,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// CreateHourEntryRequest is the request body for creating a time entry.
type CreateHourEntryRequest struct {
Date time.Time `json:"date" binding:"required"`
Hours float64 `json:"hours" binding:"required"`
Category string `json:"category" binding:"required"`
Description string `json:"description" binding:"required"`
Billable *bool `json:"billable,omitempty"`
}
// CreateTaskRequest is the request body for creating a task.
type CreateTaskRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Category string `json:"category" binding:"required"`
Priority string `json:"priority"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// UpdateTaskRequest is the request body for updating a task.
type UpdateTaskRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Category *string `json:"category,omitempty"`
Priority *string `json:"priority,omitempty"`
Status *string `json:"status,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// CreateCommunicationRequest is the request body for creating a communication entry.
type CreateCommunicationRequest struct {
Direction string `json:"direction" binding:"required"`
Channel string `json:"channel" binding:"required"`
Subject string `json:"subject" binding:"required"`
Content string `json:"content"`
Participants string `json:"participants"`
}
// ============================================================================
// Summary Models
// ============================================================================
// HoursSummary provides aggregated hour statistics for an assignment.
type HoursSummary struct {
TotalHours float64 `json:"total_hours"`
BillableHours float64 `json:"billable_hours"`
ByCategory map[string]float64 `json:"by_category"`
Period string `json:"period"` // YYYY-MM or "all"
}

View File

@@ -0,0 +1,510 @@
package dsb
import (
"context"
"fmt"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/reporting"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store provides database operations for the DSB portal.
type Store struct {
pool *pgxpool.Pool
reportingStore *reporting.Store
}
// NewStore creates a new DSB store.
func NewStore(pool *pgxpool.Pool, reportingStore *reporting.Store) *Store {
return &Store{
pool: pool,
reportingStore: reportingStore,
}
}
// Pool returns the underlying connection pool for direct queries when needed.
func (s *Store) Pool() *pgxpool.Pool {
return s.pool
}
// ============================================================================
// Dashboard
// ============================================================================
// GetDashboard generates the aggregated DSB dashboard for a given DSB user.
func (s *Store) GetDashboard(ctx context.Context, dsbUserID uuid.UUID) (*DSBDashboard, error) {
assignments, err := s.ListAssignments(ctx, dsbUserID)
if err != nil {
return nil, fmt.Errorf("list assignments: %w", err)
}
now := time.Now().UTC()
currentMonth := now.Format("2006-01")
dashboard := &DSBDashboard{
Assignments: make([]AssignmentOverview, 0, len(assignments)),
GeneratedAt: now,
}
for _, a := range assignments {
overview := AssignmentOverview{
Assignment: a,
HoursBudget: a.MonthlyHoursBudget,
}
// Enrich with compliance score (error-tolerant)
if s.reportingStore != nil {
report, err := s.reportingStore.GenerateReport(ctx, a.TenantID)
if err == nil && report != nil {
overview.ComplianceScore = report.ComplianceScore
}
}
// Hours this month
summary, err := s.GetHoursSummary(ctx, a.ID, currentMonth)
if err == nil && summary != nil {
overview.HoursThisMonth = summary.TotalHours
}
// Open and urgent tasks
openTasks, err := s.ListTasks(ctx, a.ID, "open")
if err == nil {
overview.OpenTaskCount = len(openTasks)
for _, t := range openTasks {
if t.Priority == "urgent" {
overview.UrgentTaskCount++
}
if t.DueDate != nil && (overview.NextDeadline == nil || t.DueDate.Before(*overview.NextDeadline)) {
overview.NextDeadline = t.DueDate
}
}
}
// Also count in_progress tasks
inProgressTasks, err := s.ListTasks(ctx, a.ID, "in_progress")
if err == nil {
overview.OpenTaskCount += len(inProgressTasks)
for _, t := range inProgressTasks {
if t.Priority == "urgent" {
overview.UrgentTaskCount++
}
if t.DueDate != nil && (overview.NextDeadline == nil || t.DueDate.Before(*overview.NextDeadline)) {
overview.NextDeadline = t.DueDate
}
}
}
dashboard.Assignments = append(dashboard.Assignments, overview)
dashboard.TotalAssignments++
if a.Status == "active" {
dashboard.ActiveAssignments++
}
dashboard.TotalHoursThisMonth += overview.HoursThisMonth
dashboard.OpenTasks += overview.OpenTaskCount
dashboard.UrgentTasks += overview.UrgentTaskCount
}
return dashboard, nil
}
// ============================================================================
// Assignments
// ============================================================================
// CreateAssignment inserts a new DSB assignment.
func (s *Store) CreateAssignment(ctx context.Context, a *Assignment) error {
a.ID = uuid.New()
now := time.Now().UTC()
a.CreatedAt = now
a.UpdatedAt = now
if a.Status == "" {
a.Status = "active"
}
_, err := s.pool.Exec(ctx, `
INSERT INTO dsb_assignments (id, dsb_user_id, tenant_id, status, contract_start, contract_end, monthly_hours_budget, notes, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, a.ID, a.DSBUserID, a.TenantID, a.Status, a.ContractStart, a.ContractEnd, a.MonthlyHoursBudget, a.Notes, a.CreatedAt, a.UpdatedAt)
if err != nil {
return fmt.Errorf("insert assignment: %w", err)
}
return nil
}
// ListAssignments returns all assignments for a given DSB user, joined with tenant info.
func (s *Store) ListAssignments(ctx context.Context, dsbUserID uuid.UUID) ([]Assignment, error) {
rows, err := s.pool.Query(ctx, `
SELECT a.id, a.dsb_user_id, a.tenant_id, ct.name, ct.slug,
a.status, a.contract_start, a.contract_end,
a.monthly_hours_budget, a.notes, a.created_at, a.updated_at
FROM dsb_assignments a
JOIN compliance_tenants ct ON ct.id = a.tenant_id
WHERE a.dsb_user_id = $1
ORDER BY a.created_at DESC
`, dsbUserID)
if err != nil {
return nil, fmt.Errorf("query assignments: %w", err)
}
defer rows.Close()
var assignments []Assignment
for rows.Next() {
var a Assignment
if err := rows.Scan(
&a.ID, &a.DSBUserID, &a.TenantID, &a.TenantName, &a.TenantSlug,
&a.Status, &a.ContractStart, &a.ContractEnd,
&a.MonthlyHoursBudget, &a.Notes, &a.CreatedAt, &a.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan assignment: %w", err)
}
assignments = append(assignments, a)
}
if assignments == nil {
assignments = []Assignment{}
}
return assignments, nil
}
// GetAssignment retrieves a single assignment by ID.
func (s *Store) GetAssignment(ctx context.Context, id uuid.UUID) (*Assignment, error) {
var a Assignment
err := s.pool.QueryRow(ctx, `
SELECT a.id, a.dsb_user_id, a.tenant_id, ct.name, ct.slug,
a.status, a.contract_start, a.contract_end,
a.monthly_hours_budget, a.notes, a.created_at, a.updated_at
FROM dsb_assignments a
JOIN compliance_tenants ct ON ct.id = a.tenant_id
WHERE a.id = $1
`, id).Scan(
&a.ID, &a.DSBUserID, &a.TenantID, &a.TenantName, &a.TenantSlug,
&a.Status, &a.ContractStart, &a.ContractEnd,
&a.MonthlyHoursBudget, &a.Notes, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("get assignment: %w", err)
}
return &a, nil
}
// UpdateAssignment updates an existing assignment.
func (s *Store) UpdateAssignment(ctx context.Context, a *Assignment) error {
_, err := s.pool.Exec(ctx, `
UPDATE dsb_assignments
SET status = $2, contract_end = $3, monthly_hours_budget = $4, notes = $5, updated_at = NOW()
WHERE id = $1
`, a.ID, a.Status, a.ContractEnd, a.MonthlyHoursBudget, a.Notes)
if err != nil {
return fmt.Errorf("update assignment: %w", err)
}
return nil
}
// ============================================================================
// Hours
// ============================================================================
// CreateHourEntry inserts a new time tracking entry.
func (s *Store) CreateHourEntry(ctx context.Context, h *HourEntry) error {
h.ID = uuid.New()
h.CreatedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO dsb_hours (id, assignment_id, date, hours, category, description, billable, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, h.ID, h.AssignmentID, h.Date, h.Hours, h.Category, h.Description, h.Billable, h.CreatedAt)
if err != nil {
return fmt.Errorf("insert hour entry: %w", err)
}
return nil
}
// ListHours returns time entries for an assignment, optionally filtered by month (YYYY-MM).
func (s *Store) ListHours(ctx context.Context, assignmentID uuid.UUID, month string) ([]HourEntry, error) {
var query string
var args []interface{}
if month != "" {
query = `
SELECT id, assignment_id, date, hours, category, description, billable, created_at
FROM dsb_hours
WHERE assignment_id = $1 AND to_char(date, 'YYYY-MM') = $2
ORDER BY date DESC, created_at DESC
`
args = []interface{}{assignmentID, month}
} else {
query = `
SELECT id, assignment_id, date, hours, category, description, billable, created_at
FROM dsb_hours
WHERE assignment_id = $1
ORDER BY date DESC, created_at DESC
`
args = []interface{}{assignmentID}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query hours: %w", err)
}
defer rows.Close()
var entries []HourEntry
for rows.Next() {
var h HourEntry
if err := rows.Scan(
&h.ID, &h.AssignmentID, &h.Date, &h.Hours, &h.Category,
&h.Description, &h.Billable, &h.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan hour entry: %w", err)
}
entries = append(entries, h)
}
if entries == nil {
entries = []HourEntry{}
}
return entries, nil
}
// GetHoursSummary returns aggregated hour statistics for an assignment, optionally filtered by month.
func (s *Store) GetHoursSummary(ctx context.Context, assignmentID uuid.UUID, month string) (*HoursSummary, error) {
summary := &HoursSummary{
ByCategory: make(map[string]float64),
Period: "all",
}
if month != "" {
summary.Period = month
}
// Total and billable hours
var totalQuery string
var totalArgs []interface{}
if month != "" {
totalQuery = `
SELECT COALESCE(SUM(hours), 0), COALESCE(SUM(CASE WHEN billable THEN hours ELSE 0 END), 0)
FROM dsb_hours
WHERE assignment_id = $1 AND to_char(date, 'YYYY-MM') = $2
`
totalArgs = []interface{}{assignmentID, month}
} else {
totalQuery = `
SELECT COALESCE(SUM(hours), 0), COALESCE(SUM(CASE WHEN billable THEN hours ELSE 0 END), 0)
FROM dsb_hours
WHERE assignment_id = $1
`
totalArgs = []interface{}{assignmentID}
}
err := s.pool.QueryRow(ctx, totalQuery, totalArgs...).Scan(&summary.TotalHours, &summary.BillableHours)
if err != nil {
return nil, fmt.Errorf("query hours summary totals: %w", err)
}
// Hours by category
var catQuery string
var catArgs []interface{}
if month != "" {
catQuery = `
SELECT category, COALESCE(SUM(hours), 0)
FROM dsb_hours
WHERE assignment_id = $1 AND to_char(date, 'YYYY-MM') = $2
GROUP BY category
`
catArgs = []interface{}{assignmentID, month}
} else {
catQuery = `
SELECT category, COALESCE(SUM(hours), 0)
FROM dsb_hours
WHERE assignment_id = $1
GROUP BY category
`
catArgs = []interface{}{assignmentID}
}
rows, err := s.pool.Query(ctx, catQuery, catArgs...)
if err != nil {
return nil, fmt.Errorf("query hours by category: %w", err)
}
defer rows.Close()
for rows.Next() {
var cat string
var hours float64
if err := rows.Scan(&cat, &hours); err != nil {
return nil, fmt.Errorf("scan category hours: %w", err)
}
summary.ByCategory[cat] = hours
}
return summary, nil
}
// ============================================================================
// Tasks
// ============================================================================
// CreateTask inserts a new DSB task.
func (s *Store) CreateTask(ctx context.Context, t *Task) error {
t.ID = uuid.New()
now := time.Now().UTC()
t.CreatedAt = now
t.UpdatedAt = now
if t.Status == "" {
t.Status = "open"
}
if t.Priority == "" {
t.Priority = "medium"
}
_, err := s.pool.Exec(ctx, `
INSERT INTO dsb_tasks (id, assignment_id, title, description, category, priority, status, due_date, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, t.ID, t.AssignmentID, t.Title, t.Description, t.Category, t.Priority, t.Status, t.DueDate, t.CreatedAt, t.UpdatedAt)
if err != nil {
return fmt.Errorf("insert task: %w", err)
}
return nil
}
// ListTasks returns tasks for an assignment, optionally filtered by status.
func (s *Store) ListTasks(ctx context.Context, assignmentID uuid.UUID, status string) ([]Task, error) {
var query string
var args []interface{}
if status != "" {
query = `
SELECT id, assignment_id, title, description, category, priority, status, due_date, completed_at, created_at, updated_at
FROM dsb_tasks
WHERE assignment_id = $1 AND status = $2
ORDER BY CASE priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
ELSE 5
END, due_date ASC NULLS LAST, created_at DESC
`
args = []interface{}{assignmentID, status}
} else {
query = `
SELECT id, assignment_id, title, description, category, priority, status, due_date, completed_at, created_at, updated_at
FROM dsb_tasks
WHERE assignment_id = $1
ORDER BY CASE priority
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
ELSE 5
END, due_date ASC NULLS LAST, created_at DESC
`
args = []interface{}{assignmentID}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query tasks: %w", err)
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var t Task
if err := rows.Scan(
&t.ID, &t.AssignmentID, &t.Title, &t.Description, &t.Category,
&t.Priority, &t.Status, &t.DueDate, &t.CompletedAt,
&t.CreatedAt, &t.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan task: %w", err)
}
tasks = append(tasks, t)
}
if tasks == nil {
tasks = []Task{}
}
return tasks, nil
}
// UpdateTask updates an existing task.
func (s *Store) UpdateTask(ctx context.Context, t *Task) error {
_, err := s.pool.Exec(ctx, `
UPDATE dsb_tasks
SET title = $2, description = $3, category = $4, priority = $5, status = $6, due_date = $7, updated_at = NOW()
WHERE id = $1
`, t.ID, t.Title, t.Description, t.Category, t.Priority, t.Status, t.DueDate)
if err != nil {
return fmt.Errorf("update task: %w", err)
}
return nil
}
// CompleteTask marks a task as completed with the current timestamp.
func (s *Store) CompleteTask(ctx context.Context, taskID uuid.UUID) error {
_, err := s.pool.Exec(ctx, `
UPDATE dsb_tasks
SET status = 'completed', completed_at = NOW(), updated_at = NOW()
WHERE id = $1
`, taskID)
if err != nil {
return fmt.Errorf("complete task: %w", err)
}
return nil
}
// ============================================================================
// Communications
// ============================================================================
// CreateCommunication inserts a new communication log entry.
func (s *Store) CreateCommunication(ctx context.Context, c *Communication) error {
c.ID = uuid.New()
c.CreatedAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO dsb_communications (id, assignment_id, direction, channel, subject, content, participants, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, c.ID, c.AssignmentID, c.Direction, c.Channel, c.Subject, c.Content, c.Participants, c.CreatedAt)
if err != nil {
return fmt.Errorf("insert communication: %w", err)
}
return nil
}
// ListCommunications returns all communication entries for an assignment.
func (s *Store) ListCommunications(ctx context.Context, assignmentID uuid.UUID) ([]Communication, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, assignment_id, direction, channel, subject, content, participants, created_at
FROM dsb_communications
WHERE assignment_id = $1
ORDER BY created_at DESC
`, assignmentID)
if err != nil {
return nil, fmt.Errorf("query communications: %w", err)
}
defer rows.Close()
var comms []Communication
for rows.Next() {
var c Communication
if err := rows.Scan(
&c.ID, &c.AssignmentID, &c.Direction, &c.Channel,
&c.Subject, &c.Content, &c.Participants, &c.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan communication: %w", err)
}
comms = append(comms, c)
}
if comms == nil {
comms = []Communication{}
}
return comms, nil
}

View File

@@ -0,0 +1,305 @@
package incidents
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// IncidentCategory represents the category of a security/data breach incident
type IncidentCategory string
const (
IncidentCategoryDataBreach IncidentCategory = "data_breach"
IncidentCategoryUnauthorizedAccess IncidentCategory = "unauthorized_access"
IncidentCategoryDataLoss IncidentCategory = "data_loss"
IncidentCategorySystemCompromise IncidentCategory = "system_compromise"
IncidentCategoryPhishing IncidentCategory = "phishing"
IncidentCategoryRansomware IncidentCategory = "ransomware"
IncidentCategoryInsiderThreat IncidentCategory = "insider_threat"
IncidentCategoryPhysicalBreach IncidentCategory = "physical_breach"
IncidentCategoryOther IncidentCategory = "other"
)
// IncidentStatus represents the status of an incident through its lifecycle
type IncidentStatus string
const (
IncidentStatusDetected IncidentStatus = "detected"
IncidentStatusAssessment IncidentStatus = "assessment"
IncidentStatusContainment IncidentStatus = "containment"
IncidentStatusNotificationRequired IncidentStatus = "notification_required"
IncidentStatusNotificationSent IncidentStatus = "notification_sent"
IncidentStatusRemediation IncidentStatus = "remediation"
IncidentStatusClosed IncidentStatus = "closed"
)
// IncidentSeverity represents the severity level of an incident
type IncidentSeverity string
const (
IncidentSeverityCritical IncidentSeverity = "critical"
IncidentSeverityHigh IncidentSeverity = "high"
IncidentSeverityMedium IncidentSeverity = "medium"
IncidentSeverityLow IncidentSeverity = "low"
)
// MeasureType represents the type of corrective measure
type MeasureType string
const (
MeasureTypeImmediate MeasureType = "immediate"
MeasureTypeLongTerm MeasureType = "long_term"
)
// MeasureStatus represents the status of a corrective measure
type MeasureStatus string
const (
MeasureStatusPlanned MeasureStatus = "planned"
MeasureStatusInProgress MeasureStatus = "in_progress"
MeasureStatusCompleted MeasureStatus = "completed"
)
// NotificationStatus represents the status of a notification (authority or data subject)
type NotificationStatus string
const (
NotificationStatusNotRequired NotificationStatus = "not_required"
NotificationStatusPending NotificationStatus = "pending"
NotificationStatusSent NotificationStatus = "sent"
NotificationStatusConfirmed NotificationStatus = "confirmed"
)
// ============================================================================
// Main Entities
// ============================================================================
// Incident represents a security or data breach incident per DSGVO Art. 33/34
type Incident struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
// Incident info
Title string `json:"title"`
Description string `json:"description,omitempty"`
Category IncidentCategory `json:"category"`
Status IncidentStatus `json:"status"`
Severity IncidentSeverity `json:"severity"`
// Detection & reporting
DetectedAt time.Time `json:"detected_at"`
ReportedBy uuid.UUID `json:"reported_by"`
// Affected scope
AffectedDataCategories []string `json:"affected_data_categories"` // JSONB
AffectedDataSubjectCount int `json:"affected_data_subject_count"`
AffectedSystems []string `json:"affected_systems"` // JSONB
// Assessments & notifications (JSONB embedded objects)
RiskAssessment *RiskAssessment `json:"risk_assessment,omitempty"`
AuthorityNotification *AuthorityNotification `json:"authority_notification,omitempty"`
DataSubjectNotification *DataSubjectNotification `json:"data_subject_notification,omitempty"`
// Resolution
RootCause string `json:"root_cause,omitempty"`
LessonsLearned string `json:"lessons_learned,omitempty"`
// Timeline (JSONB array)
Timeline []TimelineEntry `json:"timeline"`
// Audit
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// RiskAssessment contains the risk assessment for an incident
type RiskAssessment struct {
Likelihood int `json:"likelihood"` // 1-5
Impact int `json:"impact"` // 1-5
RiskLevel string `json:"risk_level"` // critical, high, medium, low (auto-calculated)
AssessedAt time.Time `json:"assessed_at"`
AssessedBy uuid.UUID `json:"assessed_by"`
Notes string `json:"notes,omitempty"`
}
// AuthorityNotification tracks the supervisory authority notification per DSGVO Art. 33
type AuthorityNotification struct {
Status NotificationStatus `json:"status"`
Deadline time.Time `json:"deadline"` // 72h from detected_at per Art. 33
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
AuthorityName string `json:"authority_name,omitempty"`
ReferenceNumber string `json:"reference_number,omitempty"`
ContactPerson string `json:"contact_person,omitempty"`
Notes string `json:"notes,omitempty"`
}
// DataSubjectNotification tracks the data subject notification per DSGVO Art. 34
type DataSubjectNotification struct {
Required bool `json:"required"`
Status NotificationStatus `json:"status"`
SentAt *time.Time `json:"sent_at,omitempty"`
AffectedCount int `json:"affected_count"`
NotificationText string `json:"notification_text,omitempty"`
Channel string `json:"channel,omitempty"` // email, letter, website
}
// TimelineEntry represents a single event in the incident timeline
type TimelineEntry struct {
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
UserID uuid.UUID `json:"user_id"`
Details string `json:"details,omitempty"`
}
// IncidentMeasure represents a corrective or preventive measure for an incident
type IncidentMeasure struct {
ID uuid.UUID `json:"id"`
IncidentID uuid.UUID `json:"incident_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
MeasureType MeasureType `json:"measure_type"`
Status MeasureStatus `json:"status"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// IncidentStatistics contains aggregated incident statistics for a tenant
type IncidentStatistics struct {
TotalIncidents int `json:"total_incidents"`
OpenIncidents int `json:"open_incidents"`
ByStatus map[string]int `json:"by_status"`
BySeverity map[string]int `json:"by_severity"`
ByCategory map[string]int `json:"by_category"`
NotificationsPending int `json:"notifications_pending"`
AvgResolutionHours float64 `json:"avg_resolution_hours"`
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// CreateIncidentRequest is the API request for creating an incident
type CreateIncidentRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
Category IncidentCategory `json:"category" binding:"required"`
Severity IncidentSeverity `json:"severity" binding:"required"`
DetectedAt *time.Time `json:"detected_at,omitempty"` // defaults to now
AffectedDataCategories []string `json:"affected_data_categories,omitempty"`
AffectedDataSubjectCount int `json:"affected_data_subject_count,omitempty"`
AffectedSystems []string `json:"affected_systems,omitempty"`
}
// UpdateIncidentRequest is the API request for updating an incident
type UpdateIncidentRequest struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Category IncidentCategory `json:"category,omitempty"`
Status IncidentStatus `json:"status,omitempty"`
Severity IncidentSeverity `json:"severity,omitempty"`
AffectedDataCategories []string `json:"affected_data_categories,omitempty"`
AffectedDataSubjectCount *int `json:"affected_data_subject_count,omitempty"`
AffectedSystems []string `json:"affected_systems,omitempty"`
}
// RiskAssessmentRequest is the API request for assessing risk
type RiskAssessmentRequest struct {
Likelihood int `json:"likelihood" binding:"required,min=1,max=5"`
Impact int `json:"impact" binding:"required,min=1,max=5"`
Notes string `json:"notes,omitempty"`
}
// SubmitAuthorityNotificationRequest is the API request for submitting authority notification
type SubmitAuthorityNotificationRequest struct {
AuthorityName string `json:"authority_name" binding:"required"`
ContactPerson string `json:"contact_person,omitempty"`
ReferenceNumber string `json:"reference_number,omitempty"`
Notes string `json:"notes,omitempty"`
}
// NotifyDataSubjectsRequest is the API request for notifying data subjects
type NotifyDataSubjectsRequest struct {
NotificationText string `json:"notification_text" binding:"required"`
Channel string `json:"channel" binding:"required"` // email, letter, website
AffectedCount int `json:"affected_count,omitempty"`
}
// AddMeasureRequest is the API request for adding a corrective measure
type AddMeasureRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description,omitempty"`
MeasureType MeasureType `json:"measure_type" binding:"required"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// CloseIncidentRequest is the API request for closing an incident
type CloseIncidentRequest struct {
RootCause string `json:"root_cause" binding:"required"`
LessonsLearned string `json:"lessons_learned,omitempty"`
}
// AddTimelineEntryRequest is the API request for adding a timeline entry
type AddTimelineEntryRequest struct {
Action string `json:"action" binding:"required"`
Details string `json:"details,omitempty"`
}
// IncidentListResponse is the API response for listing incidents
type IncidentListResponse struct {
Incidents []Incident `json:"incidents"`
Total int `json:"total"`
}
// IncidentFilters defines filters for listing incidents
type IncidentFilters struct {
Status IncidentStatus
Severity IncidentSeverity
Category IncidentCategory
Limit int
Offset int
}
// ============================================================================
// Helper Functions
// ============================================================================
// CalculateRiskLevel calculates the risk level from likelihood and impact scores.
// Risk score = likelihood * impact. Thresholds:
// - critical: score >= 20
// - high: score >= 12
// - medium: score >= 6
// - low: score < 6
func CalculateRiskLevel(likelihood, impact int) string {
score := likelihood * impact
switch {
case score >= 20:
return "critical"
case score >= 12:
return "high"
case score >= 6:
return "medium"
default:
return "low"
}
}
// Calculate72hDeadline calculates the 72-hour notification deadline per DSGVO Art. 33.
// The supervisory authority must be notified within 72 hours of becoming aware of a breach.
func Calculate72hDeadline(detectedAt time.Time) time.Time {
return detectedAt.Add(72 * time.Hour)
}
// IsNotificationRequired determines whether authority notification is required
// based on the assessed risk level. Notification is required for critical and high risk.
func IsNotificationRequired(riskLevel string) bool {
return riskLevel == "critical" || riskLevel == "high"
}

View File

@@ -0,0 +1,571 @@
package incidents
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles incident data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new incident store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Incident CRUD Operations
// ============================================================================
// CreateIncident creates a new incident
func (s *Store) CreateIncident(ctx context.Context, incident *Incident) error {
incident.ID = uuid.New()
incident.CreatedAt = time.Now().UTC()
incident.UpdatedAt = incident.CreatedAt
if incident.Status == "" {
incident.Status = IncidentStatusDetected
}
if incident.AffectedDataCategories == nil {
incident.AffectedDataCategories = []string{}
}
if incident.AffectedSystems == nil {
incident.AffectedSystems = []string{}
}
if incident.Timeline == nil {
incident.Timeline = []TimelineEntry{}
}
affectedDataCategories, _ := json.Marshal(incident.AffectedDataCategories)
affectedSystems, _ := json.Marshal(incident.AffectedSystems)
riskAssessment, _ := json.Marshal(incident.RiskAssessment)
authorityNotification, _ := json.Marshal(incident.AuthorityNotification)
dataSubjectNotification, _ := json.Marshal(incident.DataSubjectNotification)
timeline, _ := json.Marshal(incident.Timeline)
_, err := s.pool.Exec(ctx, `
INSERT INTO incident_incidents (
id, tenant_id, title, description, category, status, severity,
detected_at, reported_by,
affected_data_categories, affected_data_subject_count, affected_systems,
risk_assessment, authority_notification, data_subject_notification,
root_cause, lessons_learned, timeline,
created_at, updated_at, closed_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9,
$10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20, $21
)
`,
incident.ID, incident.TenantID, incident.Title, incident.Description,
string(incident.Category), string(incident.Status), string(incident.Severity),
incident.DetectedAt, incident.ReportedBy,
affectedDataCategories, incident.AffectedDataSubjectCount, affectedSystems,
riskAssessment, authorityNotification, dataSubjectNotification,
incident.RootCause, incident.LessonsLearned, timeline,
incident.CreatedAt, incident.UpdatedAt, incident.ClosedAt,
)
return err
}
// GetIncident retrieves an incident by ID
func (s *Store) GetIncident(ctx context.Context, id uuid.UUID) (*Incident, error) {
var incident Incident
var category, status, severity string
var affectedDataCategories, affectedSystems []byte
var riskAssessment, authorityNotification, dataSubjectNotification []byte
var timeline []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, title, description, category, status, severity,
detected_at, reported_by,
affected_data_categories, affected_data_subject_count, affected_systems,
risk_assessment, authority_notification, data_subject_notification,
root_cause, lessons_learned, timeline,
created_at, updated_at, closed_at
FROM incident_incidents WHERE id = $1
`, id).Scan(
&incident.ID, &incident.TenantID, &incident.Title, &incident.Description,
&category, &status, &severity,
&incident.DetectedAt, &incident.ReportedBy,
&affectedDataCategories, &incident.AffectedDataSubjectCount, &affectedSystems,
&riskAssessment, &authorityNotification, &dataSubjectNotification,
&incident.RootCause, &incident.LessonsLearned, &timeline,
&incident.CreatedAt, &incident.UpdatedAt, &incident.ClosedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
incident.Category = IncidentCategory(category)
incident.Status = IncidentStatus(status)
incident.Severity = IncidentSeverity(severity)
json.Unmarshal(affectedDataCategories, &incident.AffectedDataCategories)
json.Unmarshal(affectedSystems, &incident.AffectedSystems)
json.Unmarshal(riskAssessment, &incident.RiskAssessment)
json.Unmarshal(authorityNotification, &incident.AuthorityNotification)
json.Unmarshal(dataSubjectNotification, &incident.DataSubjectNotification)
json.Unmarshal(timeline, &incident.Timeline)
if incident.AffectedDataCategories == nil {
incident.AffectedDataCategories = []string{}
}
if incident.AffectedSystems == nil {
incident.AffectedSystems = []string{}
}
if incident.Timeline == nil {
incident.Timeline = []TimelineEntry{}
}
return &incident, nil
}
// ListIncidents lists incidents for a tenant with optional filters
func (s *Store) ListIncidents(ctx context.Context, tenantID uuid.UUID, filters *IncidentFilters) ([]Incident, int, error) {
// Count query
countQuery := "SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
if filters != nil {
if filters.Status != "" {
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Status))
countArgIdx++
}
if filters.Severity != "" {
countQuery += fmt.Sprintf(" AND severity = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Severity))
countArgIdx++
}
if filters.Category != "" {
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Category))
countArgIdx++
}
}
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
// Data query
query := `
SELECT
id, tenant_id, title, description, category, status, severity,
detected_at, reported_by,
affected_data_categories, affected_data_subject_count, affected_systems,
risk_assessment, authority_notification, data_subject_notification,
root_cause, lessons_learned, timeline,
created_at, updated_at, closed_at
FROM incident_incidents WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(filters.Status))
argIdx++
}
if filters.Severity != "" {
query += fmt.Sprintf(" AND severity = $%d", argIdx)
args = append(args, string(filters.Severity))
argIdx++
}
if filters.Category != "" {
query += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, string(filters.Category))
argIdx++
}
}
query += " ORDER BY detected_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var incidents []Incident
for rows.Next() {
var incident Incident
var category, status, severity string
var affectedDataCategories, affectedSystems []byte
var riskAssessment, authorityNotification, dataSubjectNotification []byte
var timeline []byte
err := rows.Scan(
&incident.ID, &incident.TenantID, &incident.Title, &incident.Description,
&category, &status, &severity,
&incident.DetectedAt, &incident.ReportedBy,
&affectedDataCategories, &incident.AffectedDataSubjectCount, &affectedSystems,
&riskAssessment, &authorityNotification, &dataSubjectNotification,
&incident.RootCause, &incident.LessonsLearned, &timeline,
&incident.CreatedAt, &incident.UpdatedAt, &incident.ClosedAt,
)
if err != nil {
return nil, 0, err
}
incident.Category = IncidentCategory(category)
incident.Status = IncidentStatus(status)
incident.Severity = IncidentSeverity(severity)
json.Unmarshal(affectedDataCategories, &incident.AffectedDataCategories)
json.Unmarshal(affectedSystems, &incident.AffectedSystems)
json.Unmarshal(riskAssessment, &incident.RiskAssessment)
json.Unmarshal(authorityNotification, &incident.AuthorityNotification)
json.Unmarshal(dataSubjectNotification, &incident.DataSubjectNotification)
json.Unmarshal(timeline, &incident.Timeline)
if incident.AffectedDataCategories == nil {
incident.AffectedDataCategories = []string{}
}
if incident.AffectedSystems == nil {
incident.AffectedSystems = []string{}
}
if incident.Timeline == nil {
incident.Timeline = []TimelineEntry{}
}
incidents = append(incidents, incident)
}
return incidents, total, nil
}
// UpdateIncident updates an incident
func (s *Store) UpdateIncident(ctx context.Context, incident *Incident) error {
incident.UpdatedAt = time.Now().UTC()
affectedDataCategories, _ := json.Marshal(incident.AffectedDataCategories)
affectedSystems, _ := json.Marshal(incident.AffectedSystems)
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
title = $2, description = $3, category = $4, status = $5, severity = $6,
affected_data_categories = $7, affected_data_subject_count = $8, affected_systems = $9,
root_cause = $10, lessons_learned = $11,
updated_at = $12
WHERE id = $1
`,
incident.ID, incident.Title, incident.Description,
string(incident.Category), string(incident.Status), string(incident.Severity),
affectedDataCategories, incident.AffectedDataSubjectCount, affectedSystems,
incident.RootCause, incident.LessonsLearned,
incident.UpdatedAt,
)
return err
}
// DeleteIncident deletes an incident and its related measures (cascade handled by FK)
func (s *Store) DeleteIncident(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM incident_incidents WHERE id = $1", id)
return err
}
// ============================================================================
// Risk Assessment Operations
// ============================================================================
// UpdateRiskAssessment updates the risk assessment for an incident
func (s *Store) UpdateRiskAssessment(ctx context.Context, incidentID uuid.UUID, assessment *RiskAssessment) error {
assessmentJSON, _ := json.Marshal(assessment)
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
risk_assessment = $2,
updated_at = NOW()
WHERE id = $1
`, incidentID, assessmentJSON)
return err
}
// ============================================================================
// Notification Operations
// ============================================================================
// UpdateAuthorityNotification updates the authority notification for an incident
func (s *Store) UpdateAuthorityNotification(ctx context.Context, incidentID uuid.UUID, notification *AuthorityNotification) error {
notificationJSON, _ := json.Marshal(notification)
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
authority_notification = $2,
updated_at = NOW()
WHERE id = $1
`, incidentID, notificationJSON)
return err
}
// UpdateDataSubjectNotification updates the data subject notification for an incident
func (s *Store) UpdateDataSubjectNotification(ctx context.Context, incidentID uuid.UUID, notification *DataSubjectNotification) error {
notificationJSON, _ := json.Marshal(notification)
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
data_subject_notification = $2,
updated_at = NOW()
WHERE id = $1
`, incidentID, notificationJSON)
return err
}
// ============================================================================
// Measure Operations
// ============================================================================
// AddMeasure adds a corrective measure to an incident
func (s *Store) AddMeasure(ctx context.Context, measure *IncidentMeasure) error {
measure.ID = uuid.New()
measure.CreatedAt = time.Now().UTC()
if measure.Status == "" {
measure.Status = MeasureStatusPlanned
}
_, err := s.pool.Exec(ctx, `
INSERT INTO incident_measures (
id, incident_id, title, description, measure_type, status,
responsible, due_date, completed_at, created_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10
)
`,
measure.ID, measure.IncidentID, measure.Title, measure.Description,
string(measure.MeasureType), string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt,
)
return err
}
// ListMeasures lists all measures for an incident
func (s *Store) ListMeasures(ctx context.Context, incidentID uuid.UUID) ([]IncidentMeasure, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, incident_id, title, description, measure_type, status,
responsible, due_date, completed_at, created_at
FROM incident_measures WHERE incident_id = $1
ORDER BY created_at ASC
`, incidentID)
if err != nil {
return nil, err
}
defer rows.Close()
var measures []IncidentMeasure
for rows.Next() {
var m IncidentMeasure
var measureType, status string
err := rows.Scan(
&m.ID, &m.IncidentID, &m.Title, &m.Description,
&measureType, &status,
&m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.MeasureType = MeasureType(measureType)
m.Status = MeasureStatus(status)
measures = append(measures, m)
}
return measures, nil
}
// UpdateMeasure updates an existing measure
func (s *Store) UpdateMeasure(ctx context.Context, measure *IncidentMeasure) error {
_, err := s.pool.Exec(ctx, `
UPDATE incident_measures SET
title = $2, description = $3, measure_type = $4, status = $5,
responsible = $6, due_date = $7, completed_at = $8
WHERE id = $1
`,
measure.ID, measure.Title, measure.Description,
string(measure.MeasureType), string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt,
)
return err
}
// CompleteMeasure marks a measure as completed
func (s *Store) CompleteMeasure(ctx context.Context, id uuid.UUID) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE incident_measures SET
status = $2,
completed_at = $3
WHERE id = $1
`, id, string(MeasureStatusCompleted), now)
return err
}
// ============================================================================
// Timeline Operations
// ============================================================================
// AddTimelineEntry appends a timeline entry to the incident's JSONB timeline array
func (s *Store) AddTimelineEntry(ctx context.Context, incidentID uuid.UUID, entry TimelineEntry) error {
entryJSON, err := json.Marshal(entry)
if err != nil {
return err
}
// Use the || operator to append to the JSONB array
_, err = s.pool.Exec(ctx, `
UPDATE incident_incidents SET
timeline = COALESCE(timeline, '[]'::jsonb) || $2::jsonb,
updated_at = NOW()
WHERE id = $1
`, incidentID, string(entryJSON))
return err
}
// ============================================================================
// Close Incident
// ============================================================================
// CloseIncident closes an incident with root cause and lessons learned
func (s *Store) CloseIncident(ctx context.Context, id uuid.UUID, rootCause, lessonsLearned string) error {
now := time.Now().UTC()
_, err := s.pool.Exec(ctx, `
UPDATE incident_incidents SET
status = $2,
root_cause = $3,
lessons_learned = $4,
closed_at = $5,
updated_at = $5
WHERE id = $1
`, id, string(IncidentStatusClosed), rootCause, lessonsLearned, now)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated incident statistics for a tenant
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*IncidentStatistics, error) {
stats := &IncidentStatistics{
ByStatus: make(map[string]int),
BySeverity: make(map[string]int),
ByCategory: make(map[string]int),
}
// Total incidents
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalIncidents)
// Open incidents (not closed)
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM incident_incidents WHERE tenant_id = $1 AND status != 'closed'",
tenantID).Scan(&stats.OpenIncidents)
// By status
rows, err := s.pool.Query(ctx,
"SELECT status, COUNT(*) FROM incident_incidents WHERE tenant_id = $1 GROUP BY status",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var status string
var count int
rows.Scan(&status, &count)
stats.ByStatus[status] = count
}
}
// By severity
rows, err = s.pool.Query(ctx,
"SELECT severity, COUNT(*) FROM incident_incidents WHERE tenant_id = $1 GROUP BY severity",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var severity string
var count int
rows.Scan(&severity, &count)
stats.BySeverity[severity] = count
}
}
// By category
rows, err = s.pool.Query(ctx,
"SELECT category, COUNT(*) FROM incident_incidents WHERE tenant_id = $1 GROUP BY category",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var category string
var count int
rows.Scan(&category, &count)
stats.ByCategory[category] = count
}
}
// Notifications pending
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM incident_incidents
WHERE tenant_id = $1
AND (authority_notification->>'status' = 'pending'
OR data_subject_notification->>'status' = 'pending')
`, tenantID).Scan(&stats.NotificationsPending)
// Average resolution hours (for closed incidents)
s.pool.QueryRow(ctx, `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - detected_at)) / 3600), 0)
FROM incident_incidents
WHERE tenant_id = $1 AND status = 'closed' AND closed_at IS NOT NULL
`, tenantID).Scan(&stats.AvgResolutionHours)
return stats, nil
}

View File

@@ -0,0 +1,65 @@
package industry
// ============================================================================
// Industry-Specific Compliance Templates (Phase 3.3)
// Static reference data — no database migration needed.
// ============================================================================
// IndustryTemplate represents a complete compliance package for a specific industry
type IndustryTemplate struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Regulations []string `json:"regulations"`
VVTTemplates []VVTTemplate `json:"vvt_templates"`
TOMRecommendations []TOMRecommendation `json:"tom_recommendations"`
RiskScenarios []RiskScenario `json:"risk_scenarios"`
}
// VVTTemplate represents a pre-configured processing activity record template
type VVTTemplate struct {
Name string `json:"name"`
Purpose string `json:"purpose"`
LegalBasis string `json:"legal_basis"`
DataCategories []string `json:"data_categories"`
DataSubjects []string `json:"data_subjects"`
RetentionPeriod string `json:"retention_period"`
}
// TOMRecommendation represents a recommended technical/organizational measure
type TOMRecommendation struct {
Category string `json:"category"`
Name string `json:"name"`
Description string `json:"description"`
Priority string `json:"priority"`
}
// RiskScenario represents an industry-specific data protection risk scenario
type RiskScenario struct {
Name string `json:"name"`
Description string `json:"description"`
Likelihood string `json:"likelihood"`
Impact string `json:"impact"`
Mitigation string `json:"mitigation"`
}
// ============================================================================
// API Response Types
// ============================================================================
// IndustryListResponse is the API response for listing all industries
type IndustryListResponse struct {
Industries []IndustrySummary `json:"industries"`
Total int `json:"total"`
}
// IndustrySummary is a condensed view of an industry template for list endpoints
type IndustrySummary struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
RegulationCount int `json:"regulation_count"`
TemplateCount int `json:"template_count"`
}

View File

@@ -0,0 +1,558 @@
package industry
// ============================================================================
// Static Industry Template Data
// ============================================================================
// allTemplates holds all pre-configured industry compliance packages.
// This is static reference data embedded in the binary — no database required.
var allTemplates = []IndustryTemplate{
itSoftwareTemplate(),
healthcareTemplate(),
financeTemplate(),
manufacturingTemplate(),
}
// GetAllTemplates returns all available industry templates.
func GetAllTemplates() []IndustryTemplate {
return allTemplates
}
// GetTemplateBySlug returns the industry template matching the given slug,
// or nil if no match is found.
func GetTemplateBySlug(slug string) *IndustryTemplate {
for i := range allTemplates {
if allTemplates[i].Slug == slug {
return &allTemplates[i]
}
}
return nil
}
// ============================================================================
// IT & Software
// ============================================================================
func itSoftwareTemplate() IndustryTemplate {
return IndustryTemplate{
Slug: "it-software",
Name: "IT & Software",
Description: "Compliance-Paket fuer IT-Unternehmen, SaaS-Anbieter und Softwareentwickler mit Fokus auf AI Act, DSGVO fuer Cloud-Dienste und NIS2.",
Icon: "\U0001F4BB",
Regulations: []string{"DSGVO", "AI Act", "NIS2", "ePrivacy"},
VVTTemplates: []VVTTemplate{
{
Name: "SaaS-Kundendaten",
Purpose: "Verarbeitung personenbezogener Daten von SaaS-Kunden zur Bereitstellung der vertraglichen Dienstleistung, einschliesslich Account-Verwaltung, Nutzungsanalyse und Abrechnung.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfullung)",
DataCategories: []string{"Name", "E-Mail-Adresse", "Unternehmenszugehoerigkeit", "Nutzungsdaten", "Rechnungsdaten", "IP-Adresse"},
DataSubjects: []string{"Kunden", "Endnutzer der SaaS-Plattform"},
RetentionPeriod: "Vertragsdauer + 10 Jahre (handelsrechtliche Aufbewahrungspflicht)",
},
{
Name: "Cloud-Hosting",
Purpose: "Speicherung und Verarbeitung von Kundendaten in Cloud-Infrastruktur (IaaS/PaaS) zur Gewaehrleistung der Verfuegbarkeit und Skalierbarkeit der Dienste.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfullung), Art. 28 DSGVO (Auftragsverarbeitung)",
DataCategories: []string{"Alle vom Kunden eingestellten Daten", "Metadaten", "Logdateien", "Zugangsdaten"},
DataSubjects: []string{"Kunden", "Endnutzer", "Mitarbeiter der Kunden"},
RetentionPeriod: "Vertragsdauer + 30 Tage Backup-Retention",
},
{
Name: "KI-Modelltraining",
Purpose: "Verwendung von (pseudonymisierten) Daten zum Training, zur Validierung und Verbesserung von KI-/ML-Modellen unter Einhaltung des AI Act.",
LegalBasis: "Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse), ggf. Art. 6 Abs. 1 lit. a (Einwilligung)",
DataCategories: []string{"Pseudonymisierte Nutzungsdaten", "Textdaten", "Interaktionsmuster", "Feedback-Daten"},
DataSubjects: []string{"Nutzer der KI-Funktionen", "Trainingsdaten-Quellen"},
RetentionPeriod: "Bis Modell-Abloesung, max. 5 Jahre; Trainingsdaten nach Pseudonymisierung unbegrenzt",
},
{
Name: "Software-Analytics",
Purpose: "Erhebung anonymisierter und pseudonymisierter Nutzungsstatistiken zur Produktverbesserung, Fehleranalyse und Performance-Monitoring.",
LegalBasis: "Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse)",
DataCategories: []string{"Geraetemertkmale", "Browserinformationen", "Nutzungsverhalten", "Crash-Reports", "Performance-Metriken"},
DataSubjects: []string{"Endnutzer der Software"},
RetentionPeriod: "Rohdaten 90 Tage, aggregierte Daten 2 Jahre",
},
{
Name: "Newsletter/Marketing",
Purpose: "Versand von Produkt-Newslettern, Release-Benachrichtigungen und Marketing-Kommunikation an registrierte Nutzer und Interessenten.",
LegalBasis: "Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)",
DataCategories: []string{"E-Mail-Adresse", "Name", "Unternehmen", "Oeffnungs- und Klickraten", "Abonnement-Praeferenzen"},
DataSubjects: []string{"Newsletter-Abonnenten", "Leads", "Bestandskunden"},
RetentionPeriod: "Bis Widerruf der Einwilligung + 30 Tage Abwicklung",
},
{
Name: "Bewerbermanagement",
Purpose: "Verarbeitung von Bewerberdaten im Rahmen des Recruiting-Prozesses einschliesslich Sichtung, Kommunikation und Entscheidungsfindung.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO (vorvertragliche Massnahmen), ss 26 BDSG",
DataCategories: []string{"Lebenslauf", "Anschreiben", "Zeugnisse", "Kontaktdaten", "Gehaltsvorstellungen", "Bewertungsnotizen"},
DataSubjects: []string{"Bewerber", "Empfehlungsgeber"},
RetentionPeriod: "6 Monate nach Abschluss des Verfahrens (AGG-Frist), bei Einwilligung laenger",
},
},
TOMRecommendations: []TOMRecommendation{
{
Category: "encryption",
Name: "Verschluesselung at rest und in transit",
Description: "Alle gespeicherten Daten mit AES-256 verschluesseln. Saemtlichen Netzwerkverkehr ueber TLS 1.3 absichern. Zertifikats-Management automatisieren.",
Priority: "critical",
},
{
Category: "access_control",
Name: "Multi-Faktor-Authentifizierung (MFA)",
Description: "MFA fuer alle administrativen Zugaenge, Produktionssysteme und CI/CD-Pipelines erzwingen. FIDO2/WebAuthn bevorzugen.",
Priority: "critical",
},
{
Category: "monitoring",
Name: "Penetration Testing",
Description: "Regelmaessige externe Penetrationstests (mind. jaehrlich) und kontinuierliche Schwachstellenscans der oeffentlich erreichbaren Infrastruktur durchfuehren.",
Priority: "high",
},
{
Category: "development",
Name: "Code Reviews und Secure Coding",
Description: "Verpflichtende Code-Reviews fuer alle Aenderungen. SAST/DAST-Tools in die CI/CD-Pipeline integrieren. OWASP Top 10 als Mindeststandard.",
Priority: "high",
},
{
Category: "supply_chain",
Name: "Dependency Scanning",
Description: "Automatisiertes Scanning aller Abhaengigkeiten (SBOM) auf bekannte Schwachstellen. Alerts bei kritischen CVEs. Regelmaessige Updates erzwingen.",
Priority: "high",
},
{
Category: "incident_response",
Name: "Incident Response Plan",
Description: "Dokumentierter Incident-Response-Prozess mit definierten Eskalationsstufen, Meldepflichten (72h DSGVO) und regelmaessigen Uebungen (Tabletop Exercises).",
Priority: "critical",
},
},
RiskScenarios: []RiskScenario{
{
Name: "Datenleck durch Cloud-Fehlkonfiguration",
Description: "Oeffentlich zugaengliche S3-Buckets, fehlende Netzwerk-Segmentierung oder falsch konfigurierte Firewalls legen Kundendaten offen.",
Likelihood: "high",
Impact: "critical",
Mitigation: "Infrastructure-as-Code mit automatisierten Compliance-Checks (z.B. Checkov, tfsec), Cloud Security Posture Management (CSPM) einsetzen, regelmaessige Audits der Cloud-Konfiguration.",
},
{
Name: "Supply-Chain-Angriff",
Description: "Kompromittierte Abhaengigkeit (npm, PyPI, Go-Module) schleust Schadcode in den Build-Prozess ein und gelangt in die Produktionsumgebung.",
Likelihood: "medium",
Impact: "critical",
Mitigation: "Dependency Pinning, Signaturtruefung, SBOM-Generierung, private Registries, regelmaessige Audits aller Drittanbieter-Komponenten.",
},
{
Name: "KI-Bias und Diskriminierung",
Description: "KI-Modelle produzieren diskriminierende Ergebnisse aufgrund verzerrter Trainingsdaten. Verstoss gegen AI Act und Gleichbehandlungsgrundsaetze.",
Likelihood: "medium",
Impact: "high",
Mitigation: "Bias-Audits vor und nach Deployment, diverse Trainingsdaten, Erklaerbarkeits-Dokumentation gemaess AI Act, menschliche Ueberpruefung (Human-in-the-Loop).",
},
{
Name: "Insider-Bedrohung",
Description: "Ein Mitarbeiter mit privilegiertem Zugang exfiltriert Kundendaten, Quellcode oder Geschaeftsgeheimnisse — absichtlich oder durch Social Engineering.",
Likelihood: "low",
Impact: "critical",
Mitigation: "Least-Privilege-Prinzip, privilegierte Zugangssteuerung (PAM), Audit-Logging aller Admin-Aktionen, Vier-Augen-Prinzip fuer kritische Operationen, Security-Awareness-Trainings.",
},
},
}
}
// ============================================================================
// Gesundheitswesen
// ============================================================================
func healthcareTemplate() IndustryTemplate {
return IndustryTemplate{
Slug: "healthcare",
Name: "Gesundheitswesen",
Description: "Compliance-Paket fuer Arztpraxen, Krankenhaeuser, Labore und Gesundheits-IT mit besonderem Fokus auf Art. 9 DSGVO (besondere Datenkategorien) und Patientendatenschutz.",
Icon: "\U0001F3E5",
Regulations: []string{"DSGVO", "BDSG \u00a722", "SGB V", "MDR", "DiGAV"},
VVTTemplates: []VVTTemplate{
{
Name: "Patientenakte (ePA)",
Purpose: "Fuehrung elektronischer Patientenakten zur medizinischen Dokumentation, Behandlungsplanung und abrechnungstechnischen Erfassung.",
LegalBasis: "Art. 9 Abs. 2 lit. h DSGVO i.V.m. \u00a722 BDSG, \u00a7630f BGB (Dokumentationspflicht)",
DataCategories: []string{"Diagnosen", "Befunde", "Medikation", "Vitalwerte", "Anamnese", "Stammdaten", "Versicherungsdaten"},
DataSubjects: []string{"Patienten"},
RetentionPeriod: "10 Jahre nach Abschluss der Behandlung (\u00a7630f BGB), bei Strahlentherapie 30 Jahre",
},
{
Name: "Terminverwaltung",
Purpose: "Planung, Vergabe und Erinnerung von Behandlungsterminen einschliesslich Online-Terminbuchung.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfullung), Art. 9 Abs. 2 lit. h DSGVO",
DataCategories: []string{"Name", "Kontaktdaten", "Terminzeitpunkt", "Fachrichtung/Behandlungsgrund", "Versicherungsstatus"},
DataSubjects: []string{"Patienten", "Angehoerige (bei Terminerstellung fuer Dritte)"},
RetentionPeriod: "Vergangene Termine: 1 Jahr, bei medizinischer Relevanz gemaess Patientenakte",
},
{
Name: "Labor- und Befunddaten",
Purpose: "Erfassung, Uebermittlung und Archivierung von Laborergebnissen, bildgebenden Befunden und pathologischen Berichten.",
LegalBasis: "Art. 9 Abs. 2 lit. h DSGVO, \u00a710 MBO-Ae",
DataCategories: []string{"Laborwerte", "Bildgebung (DICOM)", "Pathologiebefunde", "Mikrobiologische Ergebnisse", "Genetische Daten"},
DataSubjects: []string{"Patienten"},
RetentionPeriod: "10 Jahre, genetische Daten 30 Jahre",
},
{
Name: "Telemedizin",
Purpose: "Durchfuehrung von Videosprechstunden und telemedizinischen Konsultationen einschliesslich Uebertragung medizinischer Daten.",
LegalBasis: "Art. 9 Abs. 2 lit. h DSGVO, \u00a7630a BGB, Fernbehandlungs-Richtlinien",
DataCategories: []string{"Audio-/Videodaten", "Chatprotokolle", "Uebermittelte Dokumente", "Verbindungsmetadaten", "Behandlungsnotizen"},
DataSubjects: []string{"Patienten", "Behandelnde Aerzte"},
RetentionPeriod: "Aufzeichnungen gemaess Patientenakte (10 Jahre), Verbindungsdaten 90 Tage",
},
{
Name: "Forschungsdaten",
Purpose: "Verwendung pseudonymisierter oder anonymisierter Patientendaten fuer klinische Studien und medizinische Forschung.",
LegalBasis: "Art. 9 Abs. 2 lit. j DSGVO, \u00a727 BDSG, ggf. Einwilligung gemaess Art. 9 Abs. 2 lit. a",
DataCategories: []string{"Pseudonymisierte Diagnosen", "Behandlungsverlaeufe", "Demografische Daten", "Genetische Daten (anonymisiert)", "Studienergebnisse"},
DataSubjects: []string{"Studienteilnehmer", "Patienten (retrospektiv, pseudonymisiert)"},
RetentionPeriod: "Studienende + 15 Jahre (GCP-ICH), Forschungsdaten gemaess Foerderrichtlinien",
},
{
Name: "Abrechnung (KV/Krankenversicherung)",
Purpose: "Erstellung und Uebermittlung von Abrechnungsdaten an Kassenaerztliche Vereinigungen und Krankenkassen.",
LegalBasis: "Art. 6 Abs. 1 lit. c DSGVO (rechtliche Verpflichtung), \u00a7284 SGB V, \u00a7295 SGB V",
DataCategories: []string{"Versichertennummer", "Diagnose-Codes (ICD-10)", "Leistungsziffern (EBM/GOAe)", "Behandlungsdaten", "Zuzahlungsstatus"},
DataSubjects: []string{"Patienten", "Versicherte"},
RetentionPeriod: "10 Jahre (steuerrechtlich), Abrechnungsdaten 4 Jahre (\u00a7305 SGB V)",
},
},
TOMRecommendations: []TOMRecommendation{
{
Category: "encryption",
Name: "Ende-zu-Ende-Verschluesselung",
Description: "Saemtliche Kommunikation mit Gesundheitsdaten (E-Mail, Telemedizin, Befunduebermittlung) Ende-zu-Ende verschluesseln. Zertifizierte Loesungen gemaess gematik-Spezifikation einsetzen.",
Priority: "critical",
},
{
Category: "access_control",
Name: "Rollenbasierte Zugriffskontrolle (RBAC)",
Description: "Feingranulare Zugriffsrechte basierend auf Behandlungskontext: Nur behandelnde Aerzte sehen relevante Patientendaten. Need-to-know-Prinzip konsequent umsetzen.",
Priority: "critical",
},
{
Category: "monitoring",
Name: "Audit-Logging",
Description: "Lueckenloses Protokollieren aller Zugriffe auf Patientendaten mit Zeitstempel, Benutzer, Aktion und Begruendung. Logs manipulationssicher speichern (WORM).",
Priority: "critical",
},
{
Category: "physical_security",
Name: "Physische Sicherheit",
Description: "Zutrittskontrolle zu Serverraeumen und medizinischen Arbeitsbereichen. Bildschirmsperren, Clean-Desk-Policy. Sicherer Umgang mit physischen Patientenakten.",
Priority: "high",
},
{
Category: "data_minimization",
Name: "Pseudonymisierung",
Description: "Konsequente Pseudonymisierung bei Datenweitergabe (Forschung, Qualitaetssicherung, Abrechnung). Zuordnungstabellen separat und besonders geschuetzt speichern.",
Priority: "high",
},
},
RiskScenarios: []RiskScenario{
{
Name: "Unbefugter Zugriff auf Patientendaten",
Description: "Mitarbeiter ohne Behandlungsbezug greifen auf Patientenakten zu (z.B. prominente Patienten). Verstoss gegen aerztliche Schweigepflicht und DSGVO.",
Likelihood: "high",
Impact: "critical",
Mitigation: "Striktes RBAC mit Behandlungskontext-Pruefung, automatische Anomalie-Erkennung bei ungewoehnlichen Zugriffen, regelmaessige Audit-Log-Auswertung, Sanktionskatalog.",
},
{
Name: "Ransomware-Angriff auf Krankenhaus-IT",
Description: "Verschluesselungstrojaner legt Krankenhaus-Informationssystem lahm. Patientenversorgung gefaehrdet, Notbetrieb erforderlich.",
Likelihood: "medium",
Impact: "critical",
Mitigation: "Netzwerksegmentierung (Medizingeraete, Verwaltung, Gaeste), Offline-Backups, Notfallplaene fuer Papierbetrieb, regelmaessige Sicherheitsupdates, Mitarbeiterschulung gegen Phishing.",
},
{
Name: "Datenverlust bei Systemausfall",
Description: "Hardware-Defekt oder Softwarefehler fuehrt zum Verlust aktueller Patientendaten, Befunde oder Medikationsplaene.",
Likelihood: "medium",
Impact: "high",
Mitigation: "Redundante Systeme (Clustering), automatische Backups mit verifizierter Wiederherstellung, unterbrechungsfreie Stromversorgung (USV), Disaster-Recovery-Plan mit RTOs unter 4 Stunden.",
},
{
Name: "Verletzung der aerztlichen Schweigepflicht",
Description: "Versehentliche oder vorsaetzliche Weitergabe von Patientendaten an Unberechtigte (z.B. Angehoerige ohne Vollmacht, Arbeitgeber, Medien).",
Likelihood: "medium",
Impact: "high",
Mitigation: "Schulungen zur Schweigepflicht (\u00a7203 StGB), klare Prozesse fuer Auskunftsersuchen, Dokumentation von Einwilligungen und Vollmachten, sichere Kommunikationskanaele.",
},
},
}
}
// ============================================================================
// Finanzdienstleister
// ============================================================================
func financeTemplate() IndustryTemplate {
return IndustryTemplate{
Slug: "finance",
Name: "Finanzdienstleister",
Description: "Compliance-Paket fuer Banken, Versicherungen, Zahlungsdienstleister und FinTechs mit Fokus auf BaFin-Anforderungen, PSD2 und Geldwaeschepraeventions.",
Icon: "\U0001F3E6",
Regulations: []string{"DSGVO", "KWG", "ZAG", "GwG", "MaRisk", "BAIT/DORA", "PSD2"},
VVTTemplates: []VVTTemplate{
{
Name: "Kontoeroeffnung / KYC",
Purpose: "Identitaetspruefung und Legitimation von Neukunden im Rahmen der Know-Your-Customer-Pflichten gemaess Geldwaeschegesetz.",
LegalBasis: "Art. 6 Abs. 1 lit. c DSGVO (rechtliche Verpflichtung), \u00a710 GwG, \u00a7154 AO",
DataCategories: []string{"Personalausweisdaten", "Adressdaten", "Geburtsdatum", "Staatsangehoerigkeit", "PEP-Status", "Wirtschaftliche Berechtigung", "Video-Identifikation"},
DataSubjects: []string{"Neukunden", "Wirtschaftlich Berechtigte", "Vertretungsberechtigte"},
RetentionPeriod: "5 Jahre nach Ende der Geschaeftsbeziehung (\u00a78 GwG), Identifizierungsdaten 10 Jahre",
},
{
Name: "Zahlungsverarbeitung",
Purpose: "Ausfuehrung und Dokumentation von Zahlungstransaktionen (Ueberweisungen, Lastschriften, Kartenzahlungen) im Rahmen der Kontovertragserfullung.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfullung), \u00a7675f BGB, PSD2",
DataCategories: []string{"IBAN/Kontonummer", "Transaktionsbetrag", "Verwendungszweck", "Empfaengerdaten", "Zeitstempel", "Autorisierungsdaten"},
DataSubjects: []string{"Kontoinhaber", "Zahlungsempfaenger", "Zahlungspflichtige"},
RetentionPeriod: "10 Jahre (\u00a7257 HGB, \u00a7147 AO)",
},
{
Name: "Kreditpruefung / Scoring",
Purpose: "Bonitaetspruefung und Kreditwuerdigkeitsbewertung auf Basis interner und externer Daten zur Kreditentscheidung.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO (vorvertragliche Massnahmen), \u00a731 BDSG (Scoring)",
DataCategories: []string{"Einkommensnachweise", "Schufa-Score", "Beschaeftigungsstatus", "Bestehende Verbindlichkeiten", "Sicherheiten", "Scoring-Ergebnis"},
DataSubjects: []string{"Kreditantragsteller", "Buergen", "Mithaftende"},
RetentionPeriod: "Kreditlaufzeit + 3 Jahre, bei Ablehnung 6 Monate",
},
{
Name: "Wertpapierhandel",
Purpose: "Ausfuehrung und Dokumentation von Wertpapiergeschaeften, Anlageberatung und Geeignetheitspruefung.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO, \u00a763 WpHG (Aufzeichnungspflichten), MiFID II",
DataCategories: []string{"Depotdaten", "Orderdaten", "Risikoprofil", "Anlageerfahrung", "Geeignetheitserklaerung", "Telefonaufzeichnungen"},
DataSubjects: []string{"Depotinhaber", "Bevollmaechtigte", "Anlageberater"},
RetentionPeriod: "10 Jahre (\u00a7257 HGB), Telefonaufzeichnungen 5 Jahre (MiFID II)",
},
{
Name: "Geldwaesche-Monitoring",
Purpose: "Kontinuierliche Ueberwachung von Transaktionsmustern zur Erkennung verdaechtiger Aktivitaeten und Erfuellung der Meldepflichten gegenueber der FIU.",
LegalBasis: "Art. 6 Abs. 1 lit. c DSGVO (rechtliche Verpflichtung), \u00a325h KWG, \u00a756 GwG",
DataCategories: []string{"Transaktionshistorie", "Risikobewertung", "Verdachtsmeldungen (SAR)", "PEP-Screening-Ergebnisse", "Sanktionslistenabgleich"},
DataSubjects: []string{"Kunden", "Transaktionspartner", "Verdachtspersonen"},
RetentionPeriod: "5 Jahre nach Ende der Geschaeftsbeziehung (\u00a78 GwG), Verdachtsmeldungen 10 Jahre",
},
{
Name: "Versicherungsantraege",
Purpose: "Verarbeitung von Antrags- und Risikodaten zur Pruefung, Annahme und Verwaltung von Versicherungsvertraegen.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfullung), bei Gesundheitsdaten Art. 9 Abs. 2 lit. f DSGVO",
DataCategories: []string{"Antragsdaten", "Gesundheitsfragen", "Schadenhistorie", "Risikofaktoren", "Praemienberechnung", "Leistungsansprueche"},
DataSubjects: []string{"Versicherungsnehmer", "Versicherte Personen", "Bezugsberechtigte", "Geschaedigte"},
RetentionPeriod: "Vertragsdauer + 10 Jahre (Verjaehrung), Lebensversicherung bis Ablauf aller Ansprueche",
},
},
TOMRecommendations: []TOMRecommendation{
{
Category: "encryption",
Name: "HSM fuer Schluesselverwaltung",
Description: "Hardware Security Modules (HSM) fuer kryptographische Schluessel, insbesondere bei Zahlungsverkehr und digitalen Signaturen. PCI-DSS-konform.",
Priority: "critical",
},
{
Category: "monitoring",
Name: "Transaktionsmonitoring",
Description: "Echtzeit-Ueberwachung aller Finanztransaktionen auf Anomalien, Betrugsversuche und verdaechtige Muster. Regelbasierte und KI-gestuetzte Erkennung.",
Priority: "critical",
},
{
Category: "access_control",
Name: "Vier-Augen-Prinzip",
Description: "Kritische Transaktionen (Kreditfreigaben, Grossueberweisungen, Konfigurationsaenderungen) benoetigen Freigabe durch zwei unabhaengige Personen.",
Priority: "critical",
},
{
Category: "network_security",
Name: "DDoS-Schutz",
Description: "Mehrstufiger DDoS-Schutz fuer Online-Banking und Zahlungsverkehr-Infrastruktur. Redundante Anbindung, Traffic-Scrubbing, automatische Skalierung.",
Priority: "high",
},
{
Category: "business_continuity",
Name: "Backup und Disaster Recovery",
Description: "Taeglich gesicherte Datenbanken mit geografisch getrennter Aufbewahrung. RTO unter 2 Stunden fuer Kernbanksysteme, RPO unter 15 Minuten.",
Priority: "critical",
},
{
Category: "testing",
Name: "Penetration Testing (TIBER-EU)",
Description: "Threat-Intelligence-basierte Red-Teaming-Tests gemaess TIBER-EU-Framework. Jaehrliche Durchfuehrung durch externe, BaFin-akkreditierte Tester.",
Priority: "high",
},
},
RiskScenarios: []RiskScenario{
{
Name: "Betrug und Identitaetsdiebstahl",
Description: "Kriminelle nutzen gestohlene Identitaetsdaten zur Kontoeroeffnung, Kreditaufnahme oder fuer nicht autorisierte Transaktionen.",
Likelihood: "high",
Impact: "high",
Mitigation: "Starke Kundenauthentifizierung (SCA) gemaess PSD2, Echtzeit-Betrugs-Scoring, Video-Ident mit Liveness-Detection, biometrische Verifikation, Transaktionslimits.",
},
{
Name: "Insiderhandel-Datenleck",
Description: "Vorabinformationen ueber boersenrelevante Entscheidungen (M&A, Quartalsberichte) gelangen an Unberechtigte.",
Likelihood: "low",
Impact: "critical",
Mitigation: "Insiderverzeichnisse fuehren, Chinese Walls zwischen Abteilungen, Kommunikations-Monitoring, Handelsverbote fuer Insider, regelmaessige Compliance-Schulungen.",
},
{
Name: "Systemausfall bei Zahlungsverkehr",
Description: "Ausfall des Kernbanksystems oder der Zahlungsverkehrsinfrastruktur fuehrt zu Nicht-Verfuegbarkeit von Transaktionen, Geldautomaten und Online-Banking.",
Likelihood: "medium",
Impact: "critical",
Mitigation: "Hochverfuegbarkeits-Architektur (Active-Active), automatischer Failover, regelmaessige Disaster-Recovery-Tests, Notfall-Kommunikationsplan fuer Kunden und BaFin.",
},
{
Name: "Geldwaesche-Compliance-Verstoss",
Description: "Mangelhafte KYC-Prozesse oder unzureichendes Transaktionsmonitoring fuehren zu einem Compliance-Verstoss mit BaFin-Sanktionen.",
Likelihood: "medium",
Impact: "critical",
Mitigation: "Automatisiertes Transaction-Monitoring mit regelmaessiger Kalibrierung, jaehrliche GwG-Schulungen, interne Revision der AML-Prozesse, PEP- und Sanktionslisten-Screening in Echtzeit.",
},
},
}
}
// ============================================================================
// Produktion / Industrie
// ============================================================================
func manufacturingTemplate() IndustryTemplate {
return IndustryTemplate{
Slug: "manufacturing",
Name: "Produktion / Industrie",
Description: "Compliance-Paket fuer produzierende Unternehmen mit Fokus auf NIS2-Anforderungen, OT-Security, IoT-Sicherheit und Schutz industrieller Steuerungssysteme.",
Icon: "\U0001F3ED",
Regulations: []string{"DSGVO", "NIS2", "Maschinenverordnung", "BetrSichV", "IT-Sicherheitsgesetz 2.0"},
VVTTemplates: []VVTTemplate{
{
Name: "Mitarbeiterdaten / Zeiterfassung",
Purpose: "Erfassung von Arbeitszeiten, Schichtplanung und Anwesenheitsdaten zur Lohnabrechnung und Einhaltung des Arbeitszeitgesetzes.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfullung), \u00a726 BDSG, \u00a716 ArbZG",
DataCategories: []string{"Mitarbeiterstammdaten", "Arbeitszeitdaten", "Schichtplaene", "Fehlzeiten", "Ueberstunden", "Zutrittsdaten"},
DataSubjects: []string{"Mitarbeiter", "Leiharbeiter", "Praktikanten"},
RetentionPeriod: "Lohnunterlagen 6 Jahre (\u00a7257 HGB), Arbeitszeitnachweise 2 Jahre (\u00a716 ArbZG)",
},
{
Name: "Lieferantenmanagement",
Purpose: "Verwaltung von Lieferantendaten, Bestellprozessen und Qualitaetsbewertungen im Rahmen der Supply-Chain.",
LegalBasis: "Art. 6 Abs. 1 lit. b DSGVO (Vertragserfullung), Art. 6 Abs. 1 lit. f (berechtigtes Interesse)",
DataCategories: []string{"Ansprechpartner", "Kontaktdaten", "Lieferkonditionen", "Qualitaetsbewertungen", "Zertifizierungen", "Bankverbindungen"},
DataSubjects: []string{"Ansprechpartner der Lieferanten", "Subunternehmer"},
RetentionPeriod: "Vertragsdauer + 10 Jahre (Gewaehrleistung und Steuerrecht)",
},
{
Name: "IoT-Sensordaten",
Purpose: "Erfassung und Auswertung von Sensor- und Maschinendaten fuer Produktionsoptimierung, Predictive Maintenance und Qualitaetssicherung.",
LegalBasis: "Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse), bei Personenbezug ggf. Art. 6 Abs. 1 lit. a (Einwilligung)",
DataCategories: []string{"Maschinenkennung", "Temperatur/Druck/Vibration", "Produktionszaehler", "Energieverbrauch", "Standortdaten (Intralogistik)", "Bediener-ID (falls zugeordnet)"},
DataSubjects: []string{"Maschinenbediener (indirekt)", "Instandhalter"},
RetentionPeriod: "Rohdaten 1 Jahr, aggregierte Daten 5 Jahre, qualitaetsrelevant 10 Jahre",
},
{
Name: "Qualitaetskontrolle",
Purpose: "Dokumentation von Qualitaetspruefungen, Chargenrueckverfolgbarkeit und Reklamationsmanagement.",
LegalBasis: "Art. 6 Abs. 1 lit. c DSGVO (rechtliche Verpflichtung), Maschinenverordnung, Produkthaftung",
DataCategories: []string{"Pruefprotokolle", "Chargennnummern", "Messwerte", "Pruefer-ID", "Fotos/Videos der Pruefung", "Reklamationsdaten"},
DataSubjects: []string{"Pruefer", "Reklamierende Kunden"},
RetentionPeriod: "Produktlebensdauer + 10 Jahre (Produkthaftung), sicherheitskritisch 30 Jahre",
},
{
Name: "Videoueberwachung",
Purpose: "Ueberwachung von Produktionshallen, Lagerbereichen und Aussenbereichen zum Schutz vor Diebstahl, Sabotage und zur Arbeitssicherheit.",
LegalBasis: "Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse), Betriebsvereinbarung",
DataCategories: []string{"Videoaufnahmen", "Zeitstempel", "Kamerastandort", "Bewegungserkennung"},
DataSubjects: []string{"Mitarbeiter", "Besucher", "Lieferanten", "Unbefugte"},
RetentionPeriod: "72 Stunden Standard, bei Vorfaellen bis Abschluss der Ermittlung (max. 10 Tage ohne konkreten Anlass)",
},
{
Name: "Zugangskontrolle (physisch und logisch)",
Purpose: "Steuerung und Protokollierung des Zutritts zu Produktionsbereichen, Gefahrstofflagern und IT-Raeumen mittels Chipkarten/Biometrie.",
LegalBasis: "Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse), BetrSichV, bei Biometrie Art. 9 Abs. 2 lit. b DSGVO",
DataCategories: []string{"Mitarbeiter-ID", "Zutrittszeitpunkt", "Zutrittsbereich", "Chipkartennummer", "Biometrische Daten (optional)"},
DataSubjects: []string{"Mitarbeiter", "Externe Dienstleister", "Besucher"},
RetentionPeriod: "Zutrittsprotokolle 90 Tage, sicherheitsrelevante Bereiche 1 Jahr",
},
},
TOMRecommendations: []TOMRecommendation{
{
Category: "network_security",
Name: "Netzwerksegmentierung (IT/OT)",
Description: "Strikte Trennung von Office-IT und Operational Technology (OT) durch DMZ, Firewalls und unidirektionale Gateways. Purdue-Modell als Referenzarchitektur.",
Priority: "critical",
},
{
Category: "patch_management",
Name: "IoT-Patch-Management",
Description: "Zentrales Management aller IoT-Geraete und Firmware-Versionen. Geplante Wartungsfenster fuer Updates, Risikobewertung vor Patches auf Produktionssystemen.",
Priority: "high",
},
{
Category: "physical_security",
Name: "Physische Zutrittskontrolle",
Description: "Mehrstufiges Zutrittskonzept (Gelaende, Gebaeude, Produktionshalle, Leitstand). Besuchermanagement, Begleitung in Sicherheitsbereichen, Videoprotokollierung.",
Priority: "high",
},
{
Category: "business_continuity",
Name: "Backup industrieller Steuerungen",
Description: "Regelmaessige Sicherung von SPS-Programmen, SCADA-Konfigurationen und Roboterprogrammen. Offline-Aufbewahrung der Backups, dokumentierte Restore-Prozeduren.",
Priority: "critical",
},
{
Category: "incident_response",
Name: "Notfallplaene fuer Produktionsausfall",
Description: "Dokumentierte Notfallplaene fuer Cyber-Angriffe auf OT-Systeme. Manuelle Rueckfallebenen, Kommunikationsketten, Kontakt zu BSI und CERT. Jaehrliche Uebungen.",
Priority: "critical",
},
},
RiskScenarios: []RiskScenario{
{
Name: "OT-Cyberangriff auf Produktionsanlage",
Description: "Angreifer kompromittiert SCADA/SPS-Systeme und manipuliert Produktionsprozesse. Moegliche Folgen: Produktionsausfall, Qualitaetsmaengel, Personengefaehrdung.",
Likelihood: "medium",
Impact: "critical",
Mitigation: "Netzwerksegmentierung (IT/OT), Anomalie-Erkennung im OT-Netzwerk, Haertung der Steuerungssysteme, Deaktivierung nicht benoetigter Dienste und Ports, regelmaessige Sicherheitsaudits.",
},
{
Name: "Ausfall der Lieferkette durch Cybervorfall",
Description: "Ein Cyberangriff auf einen kritischen Zulieferer fuehrt zum Stillstand der eigenen Produktion mangels Materialverfuegbarkeit oder kompromittierter Daten.",
Likelihood: "medium",
Impact: "high",
Mitigation: "Diversifikation der Lieferantenbasis, vertragliche Cybersecurity-Anforderungen an Zulieferer, regelmaessige Risikobewertung der Supply Chain, Notfallbestaende fuer kritische Komponenten.",
},
{
Name: "Industriespionage",
Description: "Wettbewerber oder staatliche Akteure greifen Konstruktionsdaten, Fertigungsverfahren oder strategische Planungen ab.",
Likelihood: "medium",
Impact: "critical",
Mitigation: "DLP-Loesungen (Data Loss Prevention), Verschluesselung von CAD/CAM-Daten, Geheimhaltungsvereinbarungen, Informationsklassifizierung, USB-Port-Kontrolle, Mitarbeiter-Sensibilisierung.",
},
{
Name: "IoT-Botnet-Kompromittierung",
Description: "Ungepatchte IoT-Sensoren und Aktoren werden Teil eines Botnets und dienen als Angriffsinfrastruktur oder Einfallstor ins Unternehmensnetz.",
Likelihood: "high",
Impact: "high",
Mitigation: "Default-Passwoerter aendern, Firmware-Updates automatisieren, IoT-Geraete in eigenem VLAN isolieren, Netzwerk-Traffic-Monitoring, Geraete-Inventar fuehren, unsichere Geraete ersetzen.",
},
},
}
}

View File

@@ -0,0 +1,77 @@
package multitenant
import (
"time"
"github.com/google/uuid"
)
// TenantOverview provides a consolidated view of a tenant's compliance status
// including scores, module highlights, and namespace information.
type TenantOverview struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
MaxUsers int `json:"max_users"`
LLMQuotaMonthly int `json:"llm_quota_monthly"`
ComplianceScore int `json:"compliance_score"`
RiskLevel string `json:"risk_level"`
NamespaceCount int `json:"namespace_count"`
// Module highlights
OpenIncidents int `json:"open_incidents"`
OpenReports int `json:"open_reports"` // whistleblower
PendingDSRs int `json:"pending_dsrs"`
TrainingRate float64 `json:"training_completion_rate"`
VendorRiskHigh int `json:"vendor_risk_high"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MultiTenantOverviewResponse wraps the list of tenant overviews with aggregate metrics.
type MultiTenantOverviewResponse struct {
Tenants []TenantOverview `json:"tenants"`
Total int `json:"total"`
AverageScore int `json:"average_score"`
GeneratedAt time.Time `json:"generated_at"`
}
// CreateTenantRequest represents a request to create a new tenant.
type CreateTenantRequest struct {
Name string `json:"name" binding:"required"`
Slug string `json:"slug" binding:"required"`
MaxUsers int `json:"max_users"`
LLMQuotaMonthly int `json:"llm_quota_monthly"`
}
// UpdateTenantRequest represents a partial update to an existing tenant.
// Pointer fields allow distinguishing between "not provided" and "zero value".
type UpdateTenantRequest struct {
Name *string `json:"name"`
MaxUsers *int `json:"max_users"`
LLMQuotaMonthly *int `json:"llm_quota_monthly"`
Status *string `json:"status"`
}
// CreateNamespaceRequest represents a request to create a new namespace within a tenant.
type CreateNamespaceRequest struct {
Name string `json:"name" binding:"required"`
Slug string `json:"slug" binding:"required"`
IsolationLevel string `json:"isolation_level"`
DataClassification string `json:"data_classification"`
}
// SwitchTenantRequest represents a request to switch the active tenant context.
type SwitchTenantRequest struct {
TenantID string `json:"tenant_id" binding:"required"`
}
// SwitchTenantResponse contains the tenant info needed for the frontend to switch context.
type SwitchTenantResponse struct {
TenantID uuid.UUID `json:"tenant_id"`
TenantName string `json:"tenant_name"`
TenantSlug string `json:"tenant_slug"`
Status string `json:"status"`
}

View File

@@ -0,0 +1,148 @@
package multitenant
import (
"context"
"fmt"
"log"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
"github.com/breakpilot/ai-compliance-sdk/internal/reporting"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store provides aggregated multi-tenant views by combining data from the
// existing RBAC store, reporting store, and direct SQL queries for module highlights.
type Store struct {
pool *pgxpool.Pool
rbacStore *rbac.Store
reportingStore *reporting.Store
}
// NewStore creates a new multi-tenant store.
func NewStore(pool *pgxpool.Pool, rbacStore *rbac.Store, reportingStore *reporting.Store) *Store {
return &Store{
pool: pool,
rbacStore: rbacStore,
reportingStore: reportingStore,
}
}
// GetOverview retrieves all tenants with their compliance scores and module highlights.
// It aggregates data from the RBAC tenant list, the reporting compliance score,
// and direct SQL counts for namespaces, incidents, reports, DSRs, training, and vendors.
// Individual query failures are tolerated and result in zero-value defaults.
func (s *Store) GetOverview(ctx context.Context) (*MultiTenantOverviewResponse, error) {
tenants, err := s.rbacStore.ListTenants(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list tenants: %w", err)
}
overviews := make([]TenantOverview, 0, len(tenants))
totalScore := 0
for _, tenant := range tenants {
overview := s.buildTenantOverview(ctx, tenant)
totalScore += overview.ComplianceScore
overviews = append(overviews, overview)
}
averageScore := 0
if len(overviews) > 0 {
averageScore = totalScore / len(overviews)
}
return &MultiTenantOverviewResponse{
Tenants: overviews,
Total: len(overviews),
AverageScore: averageScore,
GeneratedAt: time.Now().UTC(),
}, nil
}
// GetTenantDetail returns detailed compliance info for a specific tenant.
func (s *Store) GetTenantDetail(ctx context.Context, tenantID uuid.UUID) (*TenantOverview, error) {
tenant, err := s.rbacStore.GetTenant(ctx, tenantID)
if err != nil {
return nil, fmt.Errorf("failed to get tenant: %w", err)
}
overview := s.buildTenantOverview(ctx, tenant)
return &overview, nil
}
// buildTenantOverview constructs a TenantOverview by fetching compliance scores
// and module highlights for a single tenant. Errors are logged but do not
// propagate -- missing data defaults to zero values.
func (s *Store) buildTenantOverview(ctx context.Context, tenant *rbac.Tenant) TenantOverview {
overview := TenantOverview{
ID: tenant.ID,
Name: tenant.Name,
Slug: tenant.Slug,
Status: string(tenant.Status),
MaxUsers: tenant.MaxUsers,
LLMQuotaMonthly: tenant.LLMQuotaMonthly,
CreatedAt: tenant.CreatedAt,
UpdatedAt: tenant.UpdatedAt,
}
// Compliance score and risk level derived from an executive report.
// GenerateReport computes the compliance score and risk overview internally.
report, err := s.reportingStore.GenerateReport(ctx, tenant.ID)
if err != nil {
log.Printf("multitenant: failed to generate report for tenant %s: %v", tenant.ID, err)
} else {
overview.ComplianceScore = report.ComplianceScore
overview.RiskLevel = report.RiskOverview.OverallLevel
}
// Namespace count
overview.NamespaceCount = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM compliance_namespaces WHERE tenant_id = $1")
// Open incidents
overview.OpenIncidents = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM incidents WHERE tenant_id = $1 AND status IN ('new', 'investigating', 'containment')")
// Open whistleblower reports
overview.OpenReports = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 AND status IN ('new', 'acknowledged', 'investigating')")
// Pending DSR requests
overview.PendingDSRs = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM dsr_requests WHERE tenant_id = $1 AND status IN ('new', 'in_progress')")
// Training completion rate (average progress, 0-100)
overview.TrainingRate = s.avgSafe(ctx, tenant.ID,
"SELECT COALESCE(AVG(CASE WHEN status = 'completed' THEN 100.0 ELSE progress END), 0) FROM academy_enrollments WHERE tenant_id = $1")
// High-risk vendors
overview.VendorRiskHigh = s.countSafe(ctx, tenant.ID,
"SELECT COUNT(*) FROM vendors WHERE tenant_id = $1 AND risk_level = 'high'")
return overview
}
// countSafe executes a COUNT(*) query that takes a single tenant_id parameter.
// If the query fails for any reason (e.g. table does not exist), it returns 0.
func (s *Store) countSafe(ctx context.Context, tenantID uuid.UUID, query string) int {
var count int
err := s.pool.QueryRow(ctx, query, tenantID).Scan(&count)
if err != nil {
// Tolerate errors -- table may not exist or query may fail
return 0
}
return count
}
// avgSafe executes an AVG query that takes a single tenant_id parameter.
// If the query fails for any reason, it returns 0.
func (s *Store) avgSafe(ctx context.Context, tenantID uuid.UUID, query string) float64 {
var avg float64
err := s.pool.QueryRow(ctx, query, tenantID).Scan(&avg)
if err != nil {
return 0
}
return avg
}

View File

@@ -0,0 +1,97 @@
package reporting
import "time"
type ExecutiveReport struct {
GeneratedAt time.Time `json:"generated_at"`
TenantID string `json:"tenant_id"`
ComplianceScore int `json:"compliance_score"` // 0-100 overall score
// Module summaries
DSGVO DSGVOSummary `json:"dsgvo"`
Vendors VendorSummary `json:"vendors"`
Incidents IncidentSummary `json:"incidents"`
Whistleblower WhistleblowerSummary `json:"whistleblower"`
Academy AcademySummary `json:"academy"`
// Cross-module metrics
RiskOverview RiskOverview `json:"risk_overview"`
UpcomingDeadlines []Deadline `json:"upcoming_deadlines"`
RecentActivity []ActivityEntry `json:"recent_activity"`
}
type DSGVOSummary struct {
ProcessingActivities int `json:"processing_activities"`
ActiveProcessings int `json:"active_processings"`
TOMsImplemented int `json:"toms_implemented"`
TOMsPlanned int `json:"toms_planned"`
TOMsTotal int `json:"toms_total"`
CompletionPercent int `json:"completion_percent"` // TOMsImplemented / total * 100
OpenDSRs int `json:"open_dsrs"`
OverdueDSRs int `json:"overdue_dsrs"`
DSFAsCompleted int `json:"dsfas_completed"`
RetentionPolicies int `json:"retention_policies"`
}
type VendorSummary struct {
TotalVendors int `json:"total_vendors"`
ActiveVendors int `json:"active_vendors"`
ByRiskLevel map[string]int `json:"by_risk_level"`
PendingReviews int `json:"pending_reviews"`
ExpiredContracts int `json:"expired_contracts"`
}
type IncidentSummary struct {
TotalIncidents int `json:"total_incidents"`
OpenIncidents int `json:"open_incidents"`
CriticalIncidents int `json:"critical_incidents"`
NotificationsPending int `json:"notifications_pending"`
AvgResolutionHours float64 `json:"avg_resolution_hours"`
}
type WhistleblowerSummary struct {
TotalReports int `json:"total_reports"`
OpenReports int `json:"open_reports"`
OverdueAcknowledgments int `json:"overdue_acknowledgments"`
OverdueFeedbacks int `json:"overdue_feedbacks"`
AvgResolutionDays float64 `json:"avg_resolution_days"`
}
type AcademySummary struct {
TotalCourses int `json:"total_courses"`
TotalEnrollments int `json:"total_enrollments"`
CompletionRate float64 `json:"completion_rate"` // 0-100
OverdueCount int `json:"overdue_count"`
AvgCompletionDays float64 `json:"avg_completion_days"`
}
type RiskOverview struct {
OverallLevel string `json:"overall_level"` // LOW, MEDIUM, HIGH, CRITICAL
ModuleRisks []ModuleRisk `json:"module_risks"`
OpenFindings int `json:"open_findings"`
CriticalFindings int `json:"critical_findings"`
}
type ModuleRisk struct {
Module string `json:"module"`
Level string `json:"level"` // LOW, MEDIUM, HIGH, CRITICAL
Score int `json:"score"` // 0-100
Issues int `json:"issues"`
}
type Deadline struct {
Module string `json:"module"`
Type string `json:"type"`
Description string `json:"description"`
DueDate time.Time `json:"due_date"`
DaysLeft int `json:"days_left"`
Severity string `json:"severity"` // INFO, WARNING, URGENT, OVERDUE
}
type ActivityEntry struct {
Timestamp time.Time `json:"timestamp"`
Module string `json:"module"`
Action string `json:"action"`
Description string `json:"description"`
UserID string `json:"user_id,omitempty"`
}

View File

@@ -0,0 +1,520 @@
package reporting
import (
"context"
"math"
"sort"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/academy"
"github.com/breakpilot/ai-compliance-sdk/internal/dsgvo"
"github.com/breakpilot/ai-compliance-sdk/internal/incidents"
"github.com/breakpilot/ai-compliance-sdk/internal/vendor"
"github.com/breakpilot/ai-compliance-sdk/internal/whistleblower"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
dsgvoStore *dsgvo.Store
vendorStore *vendor.Store
incidentStore *incidents.Store
whistleStore *whistleblower.Store
academyStore *academy.Store
}
func NewStore(pool *pgxpool.Pool, ds *dsgvo.Store, vs *vendor.Store, is *incidents.Store, ws *whistleblower.Store, as *academy.Store) *Store {
return &Store{
pool: pool,
dsgvoStore: ds,
vendorStore: vs,
incidentStore: is,
whistleStore: ws,
academyStore: as,
}
}
func (s *Store) GenerateReport(ctx context.Context, tenantID uuid.UUID) (*ExecutiveReport, error) {
report := &ExecutiveReport{
GeneratedAt: time.Now().UTC(),
TenantID: tenantID.String(),
}
tid := tenantID.String()
// 1. Gather DSGVO stats
dsgvoStats, err := s.dsgvoStore.GetStats(ctx, tenantID)
if err == nil && dsgvoStats != nil {
total := dsgvoStats.TOMsImplemented + dsgvoStats.TOMsPlanned
pct := 0
if total > 0 {
pct = int(math.Round(float64(dsgvoStats.TOMsImplemented) / float64(total) * 100))
}
report.DSGVO = DSGVOSummary{
ProcessingActivities: dsgvoStats.ProcessingActivities,
ActiveProcessings: dsgvoStats.ActiveProcessings,
TOMsImplemented: dsgvoStats.TOMsImplemented,
TOMsPlanned: dsgvoStats.TOMsPlanned,
TOMsTotal: total,
CompletionPercent: pct,
OpenDSRs: dsgvoStats.OpenDSRs,
OverdueDSRs: dsgvoStats.OverdueDSRs,
DSFAsCompleted: dsgvoStats.DSFAsCompleted,
RetentionPolicies: dsgvoStats.RetentionPolicies,
}
}
// 2. Gather vendor stats
vendorStats, err := s.vendorStore.GetVendorStats(ctx, tid)
if err == nil && vendorStats != nil {
active := 0
if v, ok := vendorStats.ByStatus["ACTIVE"]; ok {
active = v
}
report.Vendors = VendorSummary{
TotalVendors: vendorStats.TotalVendors,
ActiveVendors: active,
ByRiskLevel: vendorStats.ByRiskLevel,
PendingReviews: vendorStats.PendingReviews,
ExpiredContracts: vendorStats.ExpiredContracts,
}
}
// 3. Gather incident stats
incidentStats, err := s.incidentStore.GetStatistics(ctx, tenantID)
if err == nil && incidentStats != nil {
critical := 0
if v, ok := incidentStats.BySeverity["CRITICAL"]; ok {
critical = v
}
report.Incidents = IncidentSummary{
TotalIncidents: incidentStats.TotalIncidents,
OpenIncidents: incidentStats.OpenIncidents,
CriticalIncidents: critical,
NotificationsPending: incidentStats.NotificationsPending,
AvgResolutionHours: incidentStats.AvgResolutionHours,
}
}
// 4. Gather whistleblower stats
whistleStats, err := s.whistleStore.GetStatistics(ctx, tenantID)
if err == nil && whistleStats != nil {
openReports := 0
for status, count := range whistleStats.ByStatus {
if status != "CLOSED" && status != "ARCHIVED" {
openReports += count
}
}
report.Whistleblower = WhistleblowerSummary{
TotalReports: whistleStats.TotalReports,
OpenReports: openReports,
OverdueAcknowledgments: whistleStats.OverdueAcknowledgments,
OverdueFeedbacks: whistleStats.OverdueFeedbacks,
AvgResolutionDays: whistleStats.AvgResolutionDays,
}
}
// 5. Gather academy stats
academyStats, err := s.academyStore.GetStatistics(ctx, tenantID)
if err == nil && academyStats != nil {
report.Academy = AcademySummary{
TotalCourses: academyStats.TotalCourses,
TotalEnrollments: academyStats.TotalEnrollments,
CompletionRate: academyStats.CompletionRate,
OverdueCount: academyStats.OverdueCount,
AvgCompletionDays: academyStats.AvgCompletionDays,
}
}
// 6. Calculate risk overview
report.RiskOverview = s.calculateRiskOverview(report)
// 7. Calculate compliance score (0-100)
report.ComplianceScore = s.calculateComplianceScore(report)
// 8. Gather upcoming deadlines from DB
report.UpcomingDeadlines = s.getUpcomingDeadlines(ctx, tenantID)
// 9. Gather recent activity from DB
report.RecentActivity = s.getRecentActivity(ctx, tenantID)
return report, nil
}
func (s *Store) calculateRiskOverview(report *ExecutiveReport) RiskOverview {
modules := []ModuleRisk{}
// DSGVO risk based on overdue DSRs and missing TOMs
dsgvoScore := 100
dsgvoIssues := report.DSGVO.OverdueDSRs + report.DSGVO.TOMsPlanned
if report.DSGVO.OverdueDSRs > 0 {
dsgvoScore -= report.DSGVO.OverdueDSRs * 15
}
if report.DSGVO.TOMsTotal > 0 {
dsgvoScore = int(math.Round(float64(report.DSGVO.CompletionPercent)))
}
if dsgvoScore < 0 {
dsgvoScore = 0
}
modules = append(modules, ModuleRisk{Module: "DSGVO", Level: riskLevel(dsgvoScore), Score: dsgvoScore, Issues: dsgvoIssues})
// Vendor risk based on high-risk vendors and pending reviews
vendorScore := 100
vendorIssues := report.Vendors.PendingReviews + report.Vendors.ExpiredContracts
highRisk := 0
if v, ok := report.Vendors.ByRiskLevel["HIGH"]; ok {
highRisk += v
}
if v, ok := report.Vendors.ByRiskLevel["CRITICAL"]; ok {
highRisk += v
}
if report.Vendors.TotalVendors > 0 {
vendorScore = 100 - int(math.Round(float64(highRisk)/float64(report.Vendors.TotalVendors)*100))
}
vendorScore -= report.Vendors.PendingReviews * 5
vendorScore -= report.Vendors.ExpiredContracts * 10
if vendorScore < 0 {
vendorScore = 0
}
modules = append(modules, ModuleRisk{Module: "Vendors", Level: riskLevel(vendorScore), Score: vendorScore, Issues: vendorIssues})
// Incident risk
incidentScore := 100
incidentIssues := report.Incidents.OpenIncidents
incidentScore -= report.Incidents.CriticalIncidents * 20
incidentScore -= report.Incidents.OpenIncidents * 5
incidentScore -= report.Incidents.NotificationsPending * 15
if incidentScore < 0 {
incidentScore = 0
}
modules = append(modules, ModuleRisk{Module: "Incidents", Level: riskLevel(incidentScore), Score: incidentScore, Issues: incidentIssues})
// Whistleblower compliance
whistleScore := 100
whistleIssues := report.Whistleblower.OverdueAcknowledgments + report.Whistleblower.OverdueFeedbacks
whistleScore -= report.Whistleblower.OverdueAcknowledgments * 20
whistleScore -= report.Whistleblower.OverdueFeedbacks * 10
if whistleScore < 0 {
whistleScore = 0
}
modules = append(modules, ModuleRisk{Module: "Whistleblower", Level: riskLevel(whistleScore), Score: whistleScore, Issues: whistleIssues})
// Academy compliance
academyScore := int(math.Round(report.Academy.CompletionRate))
academyIssues := report.Academy.OverdueCount
modules = append(modules, ModuleRisk{Module: "Academy", Level: riskLevel(academyScore), Score: academyScore, Issues: academyIssues})
// Overall score is the average across modules
totalScore := 0
for _, m := range modules {
totalScore += m.Score
}
if len(modules) > 0 {
totalScore = totalScore / len(modules)
}
totalFindings := 0
criticalFindings := 0
for _, m := range modules {
totalFindings += m.Issues
if m.Level == "CRITICAL" {
criticalFindings += m.Issues
}
}
return RiskOverview{
OverallLevel: riskLevel(totalScore),
ModuleRisks: modules,
OpenFindings: totalFindings,
CriticalFindings: criticalFindings,
}
}
func riskLevel(score int) string {
switch {
case score >= 75:
return "LOW"
case score >= 50:
return "MEDIUM"
case score >= 25:
return "HIGH"
default:
return "CRITICAL"
}
}
func (s *Store) calculateComplianceScore(report *ExecutiveReport) int {
scores := []int{}
weights := []int{}
// DSGVO: weight 30 (most important)
if report.DSGVO.TOMsTotal > 0 {
scores = append(scores, report.DSGVO.CompletionPercent)
} else {
scores = append(scores, 0)
}
weights = append(weights, 30)
// Vendor compliance: weight 20
vendorScore := 100
if report.Vendors.TotalVendors > 0 {
vendorScore -= report.Vendors.PendingReviews * 10
vendorScore -= report.Vendors.ExpiredContracts * 15
}
if vendorScore < 0 {
vendorScore = 0
}
scores = append(scores, vendorScore)
weights = append(weights, 20)
// Incident handling: weight 20
incidentScore := 100
incidentScore -= report.Incidents.OpenIncidents * 10
incidentScore -= report.Incidents.NotificationsPending * 20
if incidentScore < 0 {
incidentScore = 0
}
scores = append(scores, incidentScore)
weights = append(weights, 20)
// Whistleblower: weight 15
whistleScore := 100
whistleScore -= report.Whistleblower.OverdueAcknowledgments * 25
whistleScore -= report.Whistleblower.OverdueFeedbacks * 15
if whistleScore < 0 {
whistleScore = 0
}
scores = append(scores, whistleScore)
weights = append(weights, 15)
// Academy: weight 15
academyScore := int(math.Round(report.Academy.CompletionRate))
scores = append(scores, academyScore)
weights = append(weights, 15)
totalWeight := 0
weightedSum := 0
for i, sc := range scores {
weightedSum += sc * weights[i]
totalWeight += weights[i]
}
if totalWeight == 0 {
return 0
}
return int(math.Round(float64(weightedSum) / float64(totalWeight)))
}
func (s *Store) getUpcomingDeadlines(ctx context.Context, tenantID uuid.UUID) []Deadline {
deadlines := []Deadline{}
now := time.Now().UTC()
// Vendor reviews due
rows, err := s.pool.Query(ctx, `
SELECT name, next_review_date FROM vendor_vendors
WHERE tenant_id = $1 AND next_review_date IS NOT NULL
ORDER BY next_review_date ASC LIMIT 10
`, tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var name string
var dueDate time.Time
if err := rows.Scan(&name, &dueDate); err != nil {
continue
}
daysLeft := int(dueDate.Sub(now).Hours() / 24)
severity := "INFO"
if daysLeft < 0 {
severity = "OVERDUE"
} else if daysLeft <= 7 {
severity = "URGENT"
} else if daysLeft <= 30 {
severity = "WARNING"
}
deadlines = append(deadlines, Deadline{
Module: "Vendors",
Type: "REVIEW",
Description: "Vendor-Review: " + name,
DueDate: dueDate,
DaysLeft: daysLeft,
Severity: severity,
})
}
}
// Contract expirations
rows2, err := s.pool.Query(ctx, `
SELECT vv.name, vc.expiration_date, vc.document_type FROM vendor_contracts vc
JOIN vendor_vendors vv ON vc.vendor_id = vv.id
WHERE vc.tenant_id = $1 AND vc.expiration_date IS NOT NULL
ORDER BY vc.expiration_date ASC LIMIT 10
`, tenantID)
if err == nil {
defer rows2.Close()
for rows2.Next() {
var name, docType string
var dueDate time.Time
if err := rows2.Scan(&name, &dueDate, &docType); err != nil {
continue
}
daysLeft := int(dueDate.Sub(now).Hours() / 24)
severity := "INFO"
if daysLeft < 0 {
severity = "OVERDUE"
} else if daysLeft <= 14 {
severity = "URGENT"
} else if daysLeft <= 60 {
severity = "WARNING"
}
deadlines = append(deadlines, Deadline{
Module: "Contracts",
Type: "EXPIRATION",
Description: docType + " läuft ab: " + name,
DueDate: dueDate,
DaysLeft: daysLeft,
Severity: severity,
})
}
}
// DSR deadlines (overdue)
rows3, err := s.pool.Query(ctx, `
SELECT request_type, deadline FROM dsgvo_dsr_requests
WHERE tenant_id = $1 AND status NOT IN ('COMPLETED', 'REJECTED')
AND deadline IS NOT NULL
ORDER BY deadline ASC LIMIT 10
`, tenantID)
if err == nil {
defer rows3.Close()
for rows3.Next() {
var reqType string
var dueDate time.Time
if err := rows3.Scan(&reqType, &dueDate); err != nil {
continue
}
daysLeft := int(dueDate.Sub(now).Hours() / 24)
severity := "INFO"
if daysLeft < 0 {
severity = "OVERDUE"
} else if daysLeft <= 3 {
severity = "URGENT"
} else if daysLeft <= 14 {
severity = "WARNING"
}
deadlines = append(deadlines, Deadline{
Module: "DSR",
Type: "RESPONSE",
Description: "Betroffenenrecht: " + reqType,
DueDate: dueDate,
DaysLeft: daysLeft,
Severity: severity,
})
}
}
// Sort by due date ascending
sort.Slice(deadlines, func(i, j int) bool {
return deadlines[i].DueDate.Before(deadlines[j].DueDate)
})
// Limit to top 15
if len(deadlines) > 15 {
deadlines = deadlines[:15]
}
return deadlines
}
func (s *Store) getRecentActivity(ctx context.Context, tenantID uuid.UUID) []ActivityEntry {
activities := []ActivityEntry{}
// Recent vendors created/updated
rows, _ := s.pool.Query(ctx, `
SELECT name, created_at, 'CREATED' as action FROM vendor_vendors
WHERE tenant_id = $1 AND created_at > NOW() - INTERVAL '30 days'
UNION ALL
SELECT name, updated_at, 'UPDATED' FROM vendor_vendors
WHERE tenant_id = $1 AND updated_at > created_at AND updated_at > NOW() - INTERVAL '30 days'
ORDER BY 2 DESC LIMIT 5
`, tenantID)
if rows != nil {
defer rows.Close()
for rows.Next() {
var name, action string
var ts time.Time
if err := rows.Scan(&name, &ts, &action); err != nil {
continue
}
desc := "Vendor "
if action == "CREATED" {
desc += "angelegt: "
} else {
desc += "aktualisiert: "
}
activities = append(activities, ActivityEntry{
Timestamp: ts,
Module: "Vendors",
Action: action,
Description: desc + name,
})
}
}
// Recent incidents
rows2, _ := s.pool.Query(ctx, `
SELECT title, created_at, severity FROM incidents
WHERE tenant_id = $1 AND created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC LIMIT 5
`, tenantID)
if rows2 != nil {
defer rows2.Close()
for rows2.Next() {
var title, severity string
var ts time.Time
if err := rows2.Scan(&title, &ts, &severity); err != nil {
continue
}
activities = append(activities, ActivityEntry{
Timestamp: ts,
Module: "Incidents",
Action: "CREATED",
Description: "Datenpanne (" + severity + "): " + title,
})
}
}
// Recent whistleblower reports (admin view)
rows3, _ := s.pool.Query(ctx, `
SELECT category, created_at FROM whistleblower_reports
WHERE tenant_id = $1 AND created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC LIMIT 5
`, tenantID)
if rows3 != nil {
defer rows3.Close()
for rows3.Next() {
var category string
var ts time.Time
if err := rows3.Scan(&category, &ts); err != nil {
continue
}
activities = append(activities, ActivityEntry{
Timestamp: ts,
Module: "Whistleblower",
Action: "REPORT",
Description: "Neue Meldung: " + category,
})
}
}
// Sort by timestamp descending (most recent first)
sort.Slice(activities, func(i, j int) bool {
return activities[i].Timestamp.After(activities[j].Timestamp)
})
if len(activities) > 20 {
activities = activities[:20]
}
return activities
}

View File

@@ -0,0 +1,158 @@
package sso
import (
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// ProviderType represents the SSO authentication protocol.
type ProviderType string
const (
// ProviderTypeOIDC represents OpenID Connect authentication.
ProviderTypeOIDC ProviderType = "oidc"
// ProviderTypeSAML represents SAML 2.0 authentication.
ProviderTypeSAML ProviderType = "saml"
)
// ============================================================================
// Main Entities
// ============================================================================
// SSOConfig represents a per-tenant SSO provider configuration supporting
// OIDC and SAML authentication protocols.
type SSOConfig struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
ProviderType ProviderType `json:"provider_type" db:"provider_type"`
Name string `json:"name" db:"name"`
Enabled bool `json:"enabled" db:"enabled"`
// OIDC settings
OIDCIssuerURL string `json:"oidc_issuer_url,omitempty" db:"oidc_issuer_url"`
OIDCClientID string `json:"oidc_client_id,omitempty" db:"oidc_client_id"`
OIDCClientSecret string `json:"oidc_client_secret,omitempty" db:"oidc_client_secret"`
OIDCRedirectURI string `json:"oidc_redirect_uri,omitempty" db:"oidc_redirect_uri"`
OIDCScopes []string `json:"oidc_scopes,omitempty" db:"oidc_scopes"`
// SAML settings (for future use)
SAMLEntityID string `json:"saml_entity_id,omitempty" db:"saml_entity_id"`
SAMLSSOURL string `json:"saml_sso_url,omitempty" db:"saml_sso_url"`
SAMLCertificate string `json:"saml_certificate,omitempty" db:"saml_certificate"`
SAMLACS_URL string `json:"saml_acs_url,omitempty" db:"saml_acs_url"`
// Role mapping: maps SSO group/role names to internal role IDs
RoleMapping map[string]string `json:"role_mapping" db:"role_mapping"`
DefaultRoleID *uuid.UUID `json:"default_role_id,omitempty" db:"default_role_id"`
AutoProvision bool `json:"auto_provision" db:"auto_provision"`
// Audit
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// SSOUser represents a JIT-provisioned user authenticated via an SSO provider.
type SSOUser struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
SSOConfigID uuid.UUID `json:"sso_config_id" db:"sso_config_id"`
ExternalID string `json:"external_id" db:"external_id"`
Email string `json:"email" db:"email"`
DisplayName string `json:"display_name" db:"display_name"`
Groups []string `json:"groups" db:"groups"`
LastLogin *time.Time `json:"last_login,omitempty" db:"last_login"`
IsActive bool `json:"is_active" db:"is_active"`
// Audit
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ============================================================================
// API Request Types
// ============================================================================
// CreateSSOConfigRequest is the API request for creating an SSO configuration.
type CreateSSOConfigRequest struct {
ProviderType ProviderType `json:"provider_type" binding:"required"`
Name string `json:"name" binding:"required"`
Enabled bool `json:"enabled"`
OIDCIssuerURL string `json:"oidc_issuer_url"`
OIDCClientID string `json:"oidc_client_id"`
OIDCClientSecret string `json:"oidc_client_secret"`
OIDCRedirectURI string `json:"oidc_redirect_uri"`
OIDCScopes []string `json:"oidc_scopes"`
RoleMapping map[string]string `json:"role_mapping"`
DefaultRoleID *uuid.UUID `json:"default_role_id"`
AutoProvision bool `json:"auto_provision"`
}
// UpdateSSOConfigRequest is the API request for partially updating an SSO
// configuration. Pointer fields allow distinguishing between "not provided"
// (nil) and "set to zero value".
type UpdateSSOConfigRequest struct {
Name *string `json:"name"`
Enabled *bool `json:"enabled"`
OIDCIssuerURL *string `json:"oidc_issuer_url"`
OIDCClientID *string `json:"oidc_client_id"`
OIDCClientSecret *string `json:"oidc_client_secret"`
OIDCRedirectURI *string `json:"oidc_redirect_uri"`
OIDCScopes []string `json:"oidc_scopes"`
RoleMapping map[string]string `json:"role_mapping"`
DefaultRoleID *uuid.UUID `json:"default_role_id"`
AutoProvision *bool `json:"auto_provision"`
}
// ============================================================================
// JWT / Session Types
// ============================================================================
// SSOClaims holds the claims embedded in JWT tokens issued after successful
// SSO authentication. These are used for downstream authorization decisions.
type SSOClaims struct {
UserID uuid.UUID `json:"user_id"`
TenantID uuid.UUID `json:"tenant_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Roles []string `json:"roles"`
SSOConfigID uuid.UUID `json:"sso_config_id"`
}
// ============================================================================
// List / Filter Types
// ============================================================================
// SSOConfigFilters defines filters for listing SSO configurations.
type SSOConfigFilters struct {
ProviderType ProviderType
Enabled *bool
Search string
Limit int
Offset int
}
// SSOUserFilters defines filters for listing SSO users.
type SSOUserFilters struct {
SSOConfigID *uuid.UUID
Email string
IsActive *bool
Limit int
Offset int
}
// SSOConfigListResponse is the API response for listing SSO configurations.
type SSOConfigListResponse struct {
Configs []SSOConfig `json:"configs"`
Total int `json:"total"`
}
// SSOUserListResponse is the API response for listing SSO users.
type SSOUserListResponse struct {
Users []SSOUser `json:"users"`
Total int `json:"total"`
}

View File

@@ -0,0 +1,477 @@
package sso
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles SSO configuration and user data persistence.
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new SSO store.
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// SSO Configuration CRUD Operations
// ============================================================================
// CreateConfig creates a new SSO configuration for a tenant.
func (s *Store) CreateConfig(ctx context.Context, tenantID uuid.UUID, req *CreateSSOConfigRequest) (*SSOConfig, error) {
now := time.Now().UTC()
cfg := &SSOConfig{
ID: uuid.New(),
TenantID: tenantID,
ProviderType: req.ProviderType,
Name: req.Name,
Enabled: req.Enabled,
OIDCIssuerURL: req.OIDCIssuerURL,
OIDCClientID: req.OIDCClientID,
OIDCClientSecret: req.OIDCClientSecret,
OIDCRedirectURI: req.OIDCRedirectURI,
OIDCScopes: req.OIDCScopes,
RoleMapping: req.RoleMapping,
DefaultRoleID: req.DefaultRoleID,
AutoProvision: req.AutoProvision,
CreatedAt: now,
UpdatedAt: now,
}
// Apply defaults
if len(cfg.OIDCScopes) == 0 {
cfg.OIDCScopes = []string{"openid", "profile", "email"}
}
if cfg.RoleMapping == nil {
cfg.RoleMapping = map[string]string{}
}
roleMappingJSON, err := json.Marshal(cfg.RoleMapping)
if err != nil {
return nil, fmt.Errorf("failed to marshal role_mapping: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO sso_configurations (
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13, $14,
$15, $16, $17,
$18, $19
)
`,
cfg.ID, cfg.TenantID, string(cfg.ProviderType), cfg.Name, cfg.Enabled,
cfg.OIDCIssuerURL, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURI, cfg.OIDCScopes,
cfg.SAMLEntityID, cfg.SAMLSSOURL, cfg.SAMLCertificate, cfg.SAMLACS_URL,
roleMappingJSON, cfg.DefaultRoleID, cfg.AutoProvision,
cfg.CreatedAt, cfg.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to insert sso configuration: %w", err)
}
return cfg, nil
}
// GetConfig retrieves an SSO configuration by ID and tenant.
func (s *Store) GetConfig(ctx context.Context, tenantID, configID uuid.UUID) (*SSOConfig, error) {
var cfg SSOConfig
var providerType string
var roleMappingJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
FROM sso_configurations
WHERE id = $1 AND tenant_id = $2
`, configID, tenantID).Scan(
&cfg.ID, &cfg.TenantID, &providerType, &cfg.Name, &cfg.Enabled,
&cfg.OIDCIssuerURL, &cfg.OIDCClientID, &cfg.OIDCClientSecret, &cfg.OIDCRedirectURI, &cfg.OIDCScopes,
&cfg.SAMLEntityID, &cfg.SAMLSSOURL, &cfg.SAMLCertificate, &cfg.SAMLACS_URL,
&roleMappingJSON, &cfg.DefaultRoleID, &cfg.AutoProvision,
&cfg.CreatedAt, &cfg.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get sso configuration: %w", err)
}
cfg.ProviderType = ProviderType(providerType)
cfg.RoleMapping = unmarshalRoleMapping(roleMappingJSON)
return &cfg, nil
}
// GetConfigByName retrieves an SSO configuration by name and tenant.
func (s *Store) GetConfigByName(ctx context.Context, tenantID uuid.UUID, name string) (*SSOConfig, error) {
var cfg SSOConfig
var providerType string
var roleMappingJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
FROM sso_configurations
WHERE tenant_id = $1 AND name = $2
`, tenantID, name).Scan(
&cfg.ID, &cfg.TenantID, &providerType, &cfg.Name, &cfg.Enabled,
&cfg.OIDCIssuerURL, &cfg.OIDCClientID, &cfg.OIDCClientSecret, &cfg.OIDCRedirectURI, &cfg.OIDCScopes,
&cfg.SAMLEntityID, &cfg.SAMLSSOURL, &cfg.SAMLCertificate, &cfg.SAMLACS_URL,
&roleMappingJSON, &cfg.DefaultRoleID, &cfg.AutoProvision,
&cfg.CreatedAt, &cfg.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get sso configuration by name: %w", err)
}
cfg.ProviderType = ProviderType(providerType)
cfg.RoleMapping = unmarshalRoleMapping(roleMappingJSON)
return &cfg, nil
}
// ListConfigs lists all SSO configurations for a tenant.
func (s *Store) ListConfigs(ctx context.Context, tenantID uuid.UUID) ([]SSOConfig, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
FROM sso_configurations
WHERE tenant_id = $1
ORDER BY name ASC
`, tenantID)
if err != nil {
return nil, fmt.Errorf("failed to list sso configurations: %w", err)
}
defer rows.Close()
var configs []SSOConfig
for rows.Next() {
cfg, err := scanSSOConfig(rows)
if err != nil {
return nil, err
}
configs = append(configs, *cfg)
}
return configs, nil
}
// UpdateConfig updates an existing SSO configuration with partial updates.
func (s *Store) UpdateConfig(ctx context.Context, tenantID, configID uuid.UUID, req *UpdateSSOConfigRequest) (*SSOConfig, error) {
cfg, err := s.GetConfig(ctx, tenantID, configID)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, fmt.Errorf("sso configuration not found")
}
// Apply partial updates
if req.Name != nil {
cfg.Name = *req.Name
}
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if req.OIDCIssuerURL != nil {
cfg.OIDCIssuerURL = *req.OIDCIssuerURL
}
if req.OIDCClientID != nil {
cfg.OIDCClientID = *req.OIDCClientID
}
if req.OIDCClientSecret != nil {
cfg.OIDCClientSecret = *req.OIDCClientSecret
}
if req.OIDCRedirectURI != nil {
cfg.OIDCRedirectURI = *req.OIDCRedirectURI
}
if req.OIDCScopes != nil {
cfg.OIDCScopes = req.OIDCScopes
}
if req.RoleMapping != nil {
cfg.RoleMapping = req.RoleMapping
}
if req.DefaultRoleID != nil {
cfg.DefaultRoleID = req.DefaultRoleID
}
if req.AutoProvision != nil {
cfg.AutoProvision = *req.AutoProvision
}
cfg.UpdatedAt = time.Now().UTC()
roleMappingJSON, err := json.Marshal(cfg.RoleMapping)
if err != nil {
return nil, fmt.Errorf("failed to marshal role_mapping: %w", err)
}
_, err = s.pool.Exec(ctx, `
UPDATE sso_configurations SET
name = $3, enabled = $4,
oidc_issuer_url = $5, oidc_client_id = $6, oidc_client_secret = $7,
oidc_redirect_uri = $8, oidc_scopes = $9,
saml_entity_id = $10, saml_sso_url = $11, saml_certificate = $12, saml_acs_url = $13,
role_mapping = $14, default_role_id = $15, auto_provision = $16,
updated_at = $17
WHERE id = $1 AND tenant_id = $2
`,
cfg.ID, cfg.TenantID,
cfg.Name, cfg.Enabled,
cfg.OIDCIssuerURL, cfg.OIDCClientID, cfg.OIDCClientSecret,
cfg.OIDCRedirectURI, cfg.OIDCScopes,
cfg.SAMLEntityID, cfg.SAMLSSOURL, cfg.SAMLCertificate, cfg.SAMLACS_URL,
roleMappingJSON, cfg.DefaultRoleID, cfg.AutoProvision,
cfg.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to update sso configuration: %w", err)
}
return cfg, nil
}
// DeleteConfig deletes an SSO configuration by ID and tenant.
func (s *Store) DeleteConfig(ctx context.Context, tenantID, configID uuid.UUID) error {
_, err := s.pool.Exec(ctx,
"DELETE FROM sso_configurations WHERE id = $1 AND tenant_id = $2",
configID, tenantID,
)
if err != nil {
return fmt.Errorf("failed to delete sso configuration: %w", err)
}
return nil
}
// GetEnabledConfig retrieves the active/enabled SSO configuration for a tenant.
func (s *Store) GetEnabledConfig(ctx context.Context, tenantID uuid.UUID) (*SSOConfig, error) {
var cfg SSOConfig
var providerType string
var roleMappingJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
FROM sso_configurations
WHERE tenant_id = $1 AND enabled = true
LIMIT 1
`, tenantID).Scan(
&cfg.ID, &cfg.TenantID, &providerType, &cfg.Name, &cfg.Enabled,
&cfg.OIDCIssuerURL, &cfg.OIDCClientID, &cfg.OIDCClientSecret, &cfg.OIDCRedirectURI, &cfg.OIDCScopes,
&cfg.SAMLEntityID, &cfg.SAMLSSOURL, &cfg.SAMLCertificate, &cfg.SAMLACS_URL,
&roleMappingJSON, &cfg.DefaultRoleID, &cfg.AutoProvision,
&cfg.CreatedAt, &cfg.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get enabled sso configuration: %w", err)
}
cfg.ProviderType = ProviderType(providerType)
cfg.RoleMapping = unmarshalRoleMapping(roleMappingJSON)
return &cfg, nil
}
// ============================================================================
// SSO User Operations
// ============================================================================
// UpsertUser inserts or updates an SSO user via JIT provisioning.
// On conflict (tenant_id, sso_config_id, external_id), the user's email,
// display name, groups, and last login timestamp are updated.
func (s *Store) UpsertUser(ctx context.Context, tenantID, ssoConfigID uuid.UUID, externalID, email, displayName string, groups []string) (*SSOUser, error) {
now := time.Now().UTC()
id := uuid.New()
var user SSOUser
err := s.pool.QueryRow(ctx, `
INSERT INTO sso_users (
id, tenant_id, sso_config_id,
external_id, email, display_name, groups,
last_login, is_active,
created_at, updated_at
) VALUES (
$1, $2, $3,
$4, $5, $6, $7,
$8, true,
$8, $8
)
ON CONFLICT (tenant_id, sso_config_id, external_id) DO UPDATE SET
email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
groups = EXCLUDED.groups,
last_login = EXCLUDED.last_login,
is_active = true,
updated_at = EXCLUDED.updated_at
RETURNING
id, tenant_id, sso_config_id,
external_id, email, display_name, groups,
last_login, is_active,
created_at, updated_at
`,
id, tenantID, ssoConfigID,
externalID, email, displayName, groups,
now,
).Scan(
&user.ID, &user.TenantID, &user.SSOConfigID,
&user.ExternalID, &user.Email, &user.DisplayName, &user.Groups,
&user.LastLogin, &user.IsActive,
&user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to upsert sso user: %w", err)
}
return &user, nil
}
// GetUserByExternalID looks up an SSO user by their external identity provider ID.
func (s *Store) GetUserByExternalID(ctx context.Context, tenantID, ssoConfigID uuid.UUID, externalID string) (*SSOUser, error) {
var user SSOUser
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, sso_config_id,
external_id, email, display_name, groups,
last_login, is_active,
created_at, updated_at
FROM sso_users
WHERE tenant_id = $1 AND sso_config_id = $2 AND external_id = $3
`, tenantID, ssoConfigID, externalID).Scan(
&user.ID, &user.TenantID, &user.SSOConfigID,
&user.ExternalID, &user.Email, &user.DisplayName, &user.Groups,
&user.LastLogin, &user.IsActive,
&user.CreatedAt, &user.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get sso user by external id: %w", err)
}
return &user, nil
}
// ListUsers lists all SSO-provisioned users for a tenant.
func (s *Store) ListUsers(ctx context.Context, tenantID uuid.UUID) ([]SSOUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, tenant_id, sso_config_id,
external_id, email, display_name, groups,
last_login, is_active,
created_at, updated_at
FROM sso_users
WHERE tenant_id = $1
ORDER BY display_name ASC
`, tenantID)
if err != nil {
return nil, fmt.Errorf("failed to list sso users: %w", err)
}
defer rows.Close()
var users []SSOUser
for rows.Next() {
user, err := scanSSOUser(rows)
if err != nil {
return nil, err
}
users = append(users, *user)
}
return users, nil
}
// ============================================================================
// Row Scanning Helpers
// ============================================================================
// scanSSOConfig scans an SSO configuration row from pgx.Rows.
func scanSSOConfig(rows pgx.Rows) (*SSOConfig, error) {
var cfg SSOConfig
var providerType string
var roleMappingJSON []byte
err := rows.Scan(
&cfg.ID, &cfg.TenantID, &providerType, &cfg.Name, &cfg.Enabled,
&cfg.OIDCIssuerURL, &cfg.OIDCClientID, &cfg.OIDCClientSecret, &cfg.OIDCRedirectURI, &cfg.OIDCScopes,
&cfg.SAMLEntityID, &cfg.SAMLSSOURL, &cfg.SAMLCertificate, &cfg.SAMLACS_URL,
&roleMappingJSON, &cfg.DefaultRoleID, &cfg.AutoProvision,
&cfg.CreatedAt, &cfg.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan sso configuration: %w", err)
}
cfg.ProviderType = ProviderType(providerType)
cfg.RoleMapping = unmarshalRoleMapping(roleMappingJSON)
return &cfg, nil
}
// scanSSOUser scans an SSO user row from pgx.Rows.
func scanSSOUser(rows pgx.Rows) (*SSOUser, error) {
var user SSOUser
err := rows.Scan(
&user.ID, &user.TenantID, &user.SSOConfigID,
&user.ExternalID, &user.Email, &user.DisplayName, &user.Groups,
&user.LastLogin, &user.IsActive,
&user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan sso user: %w", err)
}
return &user, nil
}
// unmarshalRoleMapping safely unmarshals JSONB role_mapping bytes into a map.
func unmarshalRoleMapping(data []byte) map[string]string {
if data == nil {
return map[string]string{}
}
var m map[string]string
if err := json.Unmarshal(data, &m); err != nil {
return map[string]string{}
}
return m
}

View File

@@ -0,0 +1,488 @@
package vendor
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// VendorRole represents the GDPR role of a vendor in data processing
type VendorRole string
const (
VendorRoleProcessor VendorRole = "PROCESSOR"
VendorRoleController VendorRole = "CONTROLLER"
VendorRoleJointController VendorRole = "JOINT_CONTROLLER"
VendorRoleSubProcessor VendorRole = "SUB_PROCESSOR"
VendorRoleThirdParty VendorRole = "THIRD_PARTY"
)
// VendorStatus represents the lifecycle status of a vendor
type VendorStatus string
const (
VendorStatusActive VendorStatus = "ACTIVE"
VendorStatusInactive VendorStatus = "INACTIVE"
VendorStatusPendingReview VendorStatus = "PENDING_REVIEW"
VendorStatusTerminated VendorStatus = "TERMINATED"
)
// DocumentType represents the type of a contract/compliance document
type DocumentType string
const (
DocumentTypeAVV DocumentType = "AVV"
DocumentTypeMSA DocumentType = "MSA"
DocumentTypeSLA DocumentType = "SLA"
DocumentTypeSCC DocumentType = "SCC"
DocumentTypeNDA DocumentType = "NDA"
DocumentTypeTOMAnnex DocumentType = "TOM_ANNEX"
DocumentTypeCertification DocumentType = "CERTIFICATION"
DocumentTypeSubProcessorList DocumentType = "SUB_PROCESSOR_LIST"
)
// FindingType represents the type of a compliance finding
type FindingType string
const (
FindingTypeOK FindingType = "OK"
FindingTypeGap FindingType = "GAP"
FindingTypeRisk FindingType = "RISK"
FindingTypeUnknown FindingType = "UNKNOWN"
)
// FindingStatus represents the resolution status of a finding
type FindingStatus string
const (
FindingStatusOpen FindingStatus = "OPEN"
FindingStatusInProgress FindingStatus = "IN_PROGRESS"
FindingStatusResolved FindingStatus = "RESOLVED"
FindingStatusAccepted FindingStatus = "ACCEPTED"
FindingStatusFalsePositive FindingStatus = "FALSE_POSITIVE"
)
// ControlStatus represents the assessment status of a control instance
type ControlStatus string
const (
ControlStatusPass ControlStatus = "PASS"
ControlStatusPartial ControlStatus = "PARTIAL"
ControlStatusFail ControlStatus = "FAIL"
ControlStatusNotApplicable ControlStatus = "NOT_APPLICABLE"
ControlStatusPlanned ControlStatus = "PLANNED"
)
// ============================================================================
// Main Entities
// ============================================================================
// Vendor represents a third-party vendor/service provider subject to GDPR compliance
type Vendor struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
// Basic info
Name string `json:"name" db:"name"`
LegalForm string `json:"legal_form,omitempty" db:"legal_form"`
Country string `json:"country" db:"country"`
Address json.RawMessage `json:"address,omitempty" db:"address"`
Website string `json:"website,omitempty" db:"website"`
// Contact
ContactName string `json:"contact_name,omitempty" db:"contact_name"`
ContactEmail string `json:"contact_email,omitempty" db:"contact_email"`
ContactPhone string `json:"contact_phone,omitempty" db:"contact_phone"`
ContactDepartment string `json:"contact_department,omitempty" db:"contact_department"`
// GDPR role & service
Role VendorRole `json:"role" db:"role"`
ServiceCategory string `json:"service_category,omitempty" db:"service_category"`
ServiceDescription string `json:"service_description,omitempty" db:"service_description"`
DataAccessLevel string `json:"data_access_level,omitempty" db:"data_access_level"`
// Processing details (JSONB)
ProcessingLocations json.RawMessage `json:"processing_locations,omitempty" db:"processing_locations"`
Certifications json.RawMessage `json:"certifications,omitempty" db:"certifications"`
// Risk scoring
InherentRiskScore *int `json:"inherent_risk_score,omitempty" db:"inherent_risk_score"`
ResidualRiskScore *int `json:"residual_risk_score,omitempty" db:"residual_risk_score"`
ManualRiskAdjustment *int `json:"manual_risk_adjustment,omitempty" db:"manual_risk_adjustment"`
// Review schedule
ReviewFrequency string `json:"review_frequency,omitempty" db:"review_frequency"`
LastReviewDate *time.Time `json:"last_review_date,omitempty" db:"last_review_date"`
NextReviewDate *time.Time `json:"next_review_date,omitempty" db:"next_review_date"`
// Links to processing activities (JSONB)
ProcessingActivityIDs json.RawMessage `json:"processing_activity_ids,omitempty" db:"processing_activity_ids"`
// Status & template
Status VendorStatus `json:"status" db:"status"`
TemplateID *string `json:"template_id,omitempty" db:"template_id"`
// Audit
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
CreatedBy string `json:"created_by" db:"created_by"`
}
// Contract represents a contract/AVV document associated with a vendor
type Contract struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
VendorID uuid.UUID `json:"vendor_id" db:"vendor_id"`
// File metadata
FileName string `json:"file_name" db:"file_name"`
OriginalName string `json:"original_name" db:"original_name"`
MimeType string `json:"mime_type" db:"mime_type"`
FileSize *int64 `json:"file_size,omitempty" db:"file_size"`
StoragePath string `json:"storage_path" db:"storage_path"`
// Document classification
DocumentType DocumentType `json:"document_type" db:"document_type"`
// Contract details
Parties json.RawMessage `json:"parties,omitempty" db:"parties"`
EffectiveDate *time.Time `json:"effective_date,omitempty" db:"effective_date"`
ExpirationDate *time.Time `json:"expiration_date,omitempty" db:"expiration_date"`
AutoRenewal bool `json:"auto_renewal" db:"auto_renewal"`
RenewalNoticePeriod string `json:"renewal_notice_period,omitempty" db:"renewal_notice_period"`
// Review
ReviewStatus string `json:"review_status" db:"review_status"`
ReviewCompletedAt *time.Time `json:"review_completed_at,omitempty" db:"review_completed_at"`
ComplianceScore *int `json:"compliance_score,omitempty" db:"compliance_score"`
// Versioning
Version string `json:"version" db:"version"`
PreviousVersionID *string `json:"previous_version_id,omitempty" db:"previous_version_id"`
// Extracted content
ExtractedText string `json:"extracted_text,omitempty" db:"extracted_text"`
PageCount *int `json:"page_count,omitempty" db:"page_count"`
// Audit
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
CreatedBy string `json:"created_by" db:"created_by"`
}
// Finding represents a compliance finding from a contract review
type Finding struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
ContractID *string `json:"contract_id,omitempty" db:"contract_id"`
VendorID uuid.UUID `json:"vendor_id" db:"vendor_id"`
// Finding details
FindingType FindingType `json:"finding_type" db:"finding_type"`
Category string `json:"category" db:"category"`
Severity string `json:"severity" db:"severity"`
Title string `json:"title" db:"title"`
Description string `json:"description" db:"description"`
Recommendation string `json:"recommendation,omitempty" db:"recommendation"`
// Evidence (JSONB)
Citations json.RawMessage `json:"citations,omitempty" db:"citations"`
// Resolution workflow
Status FindingStatus `json:"status" db:"status"`
Assignee string `json:"assignee,omitempty" db:"assignee"`
DueDate *time.Time `json:"due_date,omitempty" db:"due_date"`
Resolution string `json:"resolution,omitempty" db:"resolution"`
ResolvedAt *time.Time `json:"resolved_at,omitempty" db:"resolved_at"`
ResolvedBy *string `json:"resolved_by,omitempty" db:"resolved_by"`
// Audit
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ControlInstance represents an applied control assessment for a specific vendor
type ControlInstance struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
VendorID uuid.UUID `json:"vendor_id" db:"vendor_id"`
// Control reference
ControlID string `json:"control_id" db:"control_id"`
ControlDomain string `json:"control_domain" db:"control_domain"`
// Assessment
Status ControlStatus `json:"status" db:"status"`
EvidenceIDs json.RawMessage `json:"evidence_ids,omitempty" db:"evidence_ids"`
Notes string `json:"notes,omitempty" db:"notes"`
// Assessment tracking
LastAssessedAt *time.Time `json:"last_assessed_at,omitempty" db:"last_assessed_at"`
LastAssessedBy *string `json:"last_assessed_by,omitempty" db:"last_assessed_by"`
NextAssessmentDate *time.Time `json:"next_assessment_date,omitempty" db:"next_assessment_date"`
// Audit
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Template represents a pre-filled vendor compliance template
type Template struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID *string `json:"tenant_id,omitempty" db:"tenant_id"`
// Template classification
TemplateType string `json:"template_type" db:"template_type"`
TemplateID string `json:"template_id" db:"template_id"`
Category string `json:"category" db:"category"`
// Localized names & descriptions
NameDE string `json:"name_de" db:"name_de"`
NameEN string `json:"name_en" db:"name_en"`
DescriptionDE string `json:"description_de" db:"description_de"`
DescriptionEN string `json:"description_en" db:"description_en"`
// Template content (JSONB)
TemplateData json.RawMessage `json:"template_data" db:"template_data"`
// Classification
Industry string `json:"industry,omitempty" db:"industry"`
Tags json.RawMessage `json:"tags,omitempty" db:"tags"`
// Flags
IsSystem bool `json:"is_system" db:"is_system"`
IsActive bool `json:"is_active" db:"is_active"`
// Usage tracking
UsageCount int `json:"usage_count" db:"usage_count"`
// Audit
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ============================================================================
// Statistics
// ============================================================================
// VendorStats contains aggregated vendor compliance statistics for a tenant
type VendorStats struct {
TotalVendors int `json:"total_vendors"`
ByStatus map[string]int `json:"by_status"`
ByRole map[string]int `json:"by_role"`
ByRiskLevel map[string]int `json:"by_risk_level"`
PendingReviews int `json:"pending_reviews"`
ExpiredContracts int `json:"expired_contracts"`
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// -- Vendor -------------------------------------------------------------------
// CreateVendorRequest is the API request for creating a vendor
type CreateVendorRequest struct {
Name string `json:"name" binding:"required"`
LegalForm string `json:"legal_form,omitempty"`
Country string `json:"country" binding:"required"`
Address json.RawMessage `json:"address,omitempty"`
Website string `json:"website,omitempty"`
ContactName string `json:"contact_name,omitempty"`
ContactEmail string `json:"contact_email,omitempty"`
ContactPhone string `json:"contact_phone,omitempty"`
ContactDepartment string `json:"contact_department,omitempty"`
Role VendorRole `json:"role" binding:"required"`
ServiceCategory string `json:"service_category,omitempty"`
ServiceDescription string `json:"service_description,omitempty"`
DataAccessLevel string `json:"data_access_level,omitempty"`
ProcessingLocations json.RawMessage `json:"processing_locations,omitempty"`
Certifications json.RawMessage `json:"certifications,omitempty"`
ReviewFrequency string `json:"review_frequency,omitempty"`
ProcessingActivityIDs json.RawMessage `json:"processing_activity_ids,omitempty"`
TemplateID *string `json:"template_id,omitempty"`
}
// UpdateVendorRequest is the API request for updating a vendor
type UpdateVendorRequest struct {
Name *string `json:"name,omitempty"`
LegalForm *string `json:"legal_form,omitempty"`
Country *string `json:"country,omitempty"`
Address json.RawMessage `json:"address,omitempty"`
Website *string `json:"website,omitempty"`
ContactName *string `json:"contact_name,omitempty"`
ContactEmail *string `json:"contact_email,omitempty"`
ContactPhone *string `json:"contact_phone,omitempty"`
ContactDepartment *string `json:"contact_department,omitempty"`
Role *VendorRole `json:"role,omitempty"`
ServiceCategory *string `json:"service_category,omitempty"`
ServiceDescription *string `json:"service_description,omitempty"`
DataAccessLevel *string `json:"data_access_level,omitempty"`
ProcessingLocations json.RawMessage `json:"processing_locations,omitempty"`
Certifications json.RawMessage `json:"certifications,omitempty"`
InherentRiskScore *int `json:"inherent_risk_score,omitempty"`
ResidualRiskScore *int `json:"residual_risk_score,omitempty"`
ManualRiskAdjustment *int `json:"manual_risk_adjustment,omitempty"`
ReviewFrequency *string `json:"review_frequency,omitempty"`
LastReviewDate *time.Time `json:"last_review_date,omitempty"`
NextReviewDate *time.Time `json:"next_review_date,omitempty"`
ProcessingActivityIDs json.RawMessage `json:"processing_activity_ids,omitempty"`
Status *VendorStatus `json:"status,omitempty"`
TemplateID *string `json:"template_id,omitempty"`
}
// -- Contract -----------------------------------------------------------------
// CreateContractRequest is the API request for creating a contract
type CreateContractRequest struct {
VendorID uuid.UUID `json:"vendor_id" binding:"required"`
FileName string `json:"file_name" binding:"required"`
OriginalName string `json:"original_name" binding:"required"`
MimeType string `json:"mime_type" binding:"required"`
FileSize *int64 `json:"file_size,omitempty"`
StoragePath string `json:"storage_path" binding:"required"`
DocumentType DocumentType `json:"document_type" binding:"required"`
Parties json.RawMessage `json:"parties,omitempty"`
EffectiveDate *time.Time `json:"effective_date,omitempty"`
ExpirationDate *time.Time `json:"expiration_date,omitempty"`
AutoRenewal bool `json:"auto_renewal"`
RenewalNoticePeriod string `json:"renewal_notice_period,omitempty"`
Version string `json:"version,omitempty"`
PreviousVersionID *string `json:"previous_version_id,omitempty"`
}
// UpdateContractRequest is the API request for updating a contract
type UpdateContractRequest struct {
DocumentType *DocumentType `json:"document_type,omitempty"`
Parties json.RawMessage `json:"parties,omitempty"`
EffectiveDate *time.Time `json:"effective_date,omitempty"`
ExpirationDate *time.Time `json:"expiration_date,omitempty"`
AutoRenewal *bool `json:"auto_renewal,omitempty"`
RenewalNoticePeriod *string `json:"renewal_notice_period,omitempty"`
ReviewStatus *string `json:"review_status,omitempty"`
ReviewCompletedAt *time.Time `json:"review_completed_at,omitempty"`
ComplianceScore *int `json:"compliance_score,omitempty"`
Version *string `json:"version,omitempty"`
ExtractedText *string `json:"extracted_text,omitempty"`
PageCount *int `json:"page_count,omitempty"`
}
// -- Finding ------------------------------------------------------------------
// CreateFindingRequest is the API request for creating a compliance finding
type CreateFindingRequest struct {
ContractID *string `json:"contract_id,omitempty"`
VendorID uuid.UUID `json:"vendor_id" binding:"required"`
FindingType FindingType `json:"finding_type" binding:"required"`
Category string `json:"category" binding:"required"`
Severity string `json:"severity" binding:"required"`
Title string `json:"title" binding:"required"`
Description string `json:"description" binding:"required"`
Recommendation string `json:"recommendation,omitempty"`
Citations json.RawMessage `json:"citations,omitempty"`
Assignee string `json:"assignee,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// UpdateFindingRequest is the API request for updating a finding
type UpdateFindingRequest struct {
FindingType *FindingType `json:"finding_type,omitempty"`
Category *string `json:"category,omitempty"`
Severity *string `json:"severity,omitempty"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Recommendation *string `json:"recommendation,omitempty"`
Citations json.RawMessage `json:"citations,omitempty"`
Status *FindingStatus `json:"status,omitempty"`
Assignee *string `json:"assignee,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
Resolution *string `json:"resolution,omitempty"`
}
// ResolveFindingRequest is the API request for resolving a finding
type ResolveFindingRequest struct {
Resolution string `json:"resolution" binding:"required"`
}
// -- ControlInstance ----------------------------------------------------------
// UpdateControlInstanceRequest is the API request for updating a control instance
type UpdateControlInstanceRequest struct {
Status *ControlStatus `json:"status,omitempty"`
EvidenceIDs json.RawMessage `json:"evidence_ids,omitempty"`
Notes *string `json:"notes,omitempty"`
NextAssessmentDate *time.Time `json:"next_assessment_date,omitempty"`
}
// -- Template -----------------------------------------------------------------
// CreateTemplateRequest is the API request for creating a vendor template
type CreateTemplateRequest struct {
TemplateType string `json:"template_type" binding:"required"`
TemplateID string `json:"template_id" binding:"required"`
Category string `json:"category" binding:"required"`
NameDE string `json:"name_de" binding:"required"`
NameEN string `json:"name_en" binding:"required"`
DescriptionDE string `json:"description_de,omitempty"`
DescriptionEN string `json:"description_en,omitempty"`
TemplateData json.RawMessage `json:"template_data" binding:"required"`
Industry string `json:"industry,omitempty"`
Tags json.RawMessage `json:"tags,omitempty"`
IsSystem bool `json:"is_system"`
}
// ============================================================================
// List / Filter Types
// ============================================================================
// VendorFilters defines filters for listing vendors
type VendorFilters struct {
Status VendorStatus
Role VendorRole
Search string
Limit int
Offset int
}
// ContractFilters defines filters for listing contracts
type ContractFilters struct {
VendorID *uuid.UUID
DocumentType DocumentType
ReviewStatus string
Limit int
Offset int
}
// FindingFilters defines filters for listing findings
type FindingFilters struct {
VendorID *uuid.UUID
ContractID *string
Status FindingStatus
FindingType FindingType
Severity string
Limit int
Offset int
}
// VendorListResponse is the API response for listing vendors
type VendorListResponse struct {
Vendors []Vendor `json:"vendors"`
Total int `json:"total"`
}
// ContractListResponse is the API response for listing contracts
type ContractListResponse struct {
Contracts []Contract `json:"contracts"`
Total int `json:"total"`
}
// FindingListResponse is the API response for listing findings
type FindingListResponse struct {
Findings []Finding `json:"findings"`
Total int `json:"total"`
}

1116
ai-compliance-sdk/internal/vendor/store.go vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
package whistleblower
import (
"crypto/rand"
"fmt"
"time"
"github.com/google/uuid"
)
// ============================================================================
// Constants / Enums
// ============================================================================
// ReportCategory represents the category of a whistleblower report
type ReportCategory string
const (
ReportCategoryCorruption ReportCategory = "corruption"
ReportCategoryFraud ReportCategory = "fraud"
ReportCategoryDataProtection ReportCategory = "data_protection"
ReportCategoryDiscrimination ReportCategory = "discrimination"
ReportCategoryEnvironment ReportCategory = "environment"
ReportCategoryCompetition ReportCategory = "competition"
ReportCategoryProductSafety ReportCategory = "product_safety"
ReportCategoryTaxEvasion ReportCategory = "tax_evasion"
ReportCategoryOther ReportCategory = "other"
)
// ReportStatus represents the status of a whistleblower report
type ReportStatus string
const (
ReportStatusNew ReportStatus = "new"
ReportStatusAcknowledged ReportStatus = "acknowledged"
ReportStatusUnderReview ReportStatus = "under_review"
ReportStatusInvestigation ReportStatus = "investigation"
ReportStatusMeasuresTaken ReportStatus = "measures_taken"
ReportStatusClosed ReportStatus = "closed"
ReportStatusRejected ReportStatus = "rejected"
)
// MessageDirection represents the direction of an anonymous message
type MessageDirection string
const (
MessageDirectionReporterToAdmin MessageDirection = "reporter_to_admin"
MessageDirectionAdminToReporter MessageDirection = "admin_to_reporter"
)
// MeasureStatus represents the status of a corrective measure
type MeasureStatus string
const (
MeasureStatusPlanned MeasureStatus = "planned"
MeasureStatusInProgress MeasureStatus = "in_progress"
MeasureStatusCompleted MeasureStatus = "completed"
)
// ============================================================================
// Main Entities
// ============================================================================
// Report represents a whistleblower report (Hinweis) per HinSchG
type Report struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
ReferenceNumber string `json:"reference_number"` // e.g. "WB-2026-0001"
AccessKey string `json:"access_key,omitempty"` // for anonymous access, only returned once
// Report content
Category ReportCategory `json:"category"`
Status ReportStatus `json:"status"`
Title string `json:"title"`
Description string `json:"description"`
// Reporter info (optional, for non-anonymous reports)
IsAnonymous bool `json:"is_anonymous"`
ReporterName *string `json:"reporter_name,omitempty"`
ReporterEmail *string `json:"reporter_email,omitempty"`
ReporterPhone *string `json:"reporter_phone,omitempty"`
// HinSchG deadlines
ReceivedAt time.Time `json:"received_at"`
DeadlineAcknowledgment time.Time `json:"deadline_acknowledgment"` // 7 days from received_at per HinSchG
DeadlineFeedback time.Time `json:"deadline_feedback"` // 3 months from received_at per HinSchG
// Status timestamps
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
// Assignment
AssignedTo *uuid.UUID `json:"assigned_to,omitempty"`
// Resolution
Resolution string `json:"resolution,omitempty"`
// Audit trail (stored as JSONB)
AuditTrail []AuditEntry `json:"audit_trail"`
// Timestamps
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AnonymousMessage represents a message exchanged between reporter and admin
type AnonymousMessage struct {
ID uuid.UUID `json:"id"`
ReportID uuid.UUID `json:"report_id"`
Direction MessageDirection `json:"direction"`
Content string `json:"content"`
SentAt time.Time `json:"sent_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
}
// Measure represents a corrective measure taken for a report
type Measure struct {
ID uuid.UUID `json:"id"`
ReportID uuid.UUID `json:"report_id"`
Title string `json:"title"`
Description string `json:"description"`
Status MeasureStatus `json:"status"`
Responsible string `json:"responsible"`
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AuditEntry represents an entry in the audit trail
type AuditEntry struct {
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
UserID string `json:"user_id"`
Details string `json:"details"`
}
// WhistleblowerStatistics contains aggregated statistics for a tenant
type WhistleblowerStatistics struct {
TotalReports int `json:"total_reports"`
ByStatus map[string]int `json:"by_status"`
ByCategory map[string]int `json:"by_category"`
OverdueAcknowledgments int `json:"overdue_acknowledgments"`
OverdueFeedbacks int `json:"overdue_feedbacks"`
AvgResolutionDays float64 `json:"avg_resolution_days"`
}
// ============================================================================
// API Request/Response Types
// ============================================================================
// PublicReportSubmission is the request for submitting a report (NO auth required)
type PublicReportSubmission struct {
Category ReportCategory `json:"category" binding:"required"`
Title string `json:"title" binding:"required"`
Description string `json:"description" binding:"required"`
IsAnonymous bool `json:"is_anonymous"`
ReporterName *string `json:"reporter_name,omitempty"`
ReporterEmail *string `json:"reporter_email,omitempty"`
ReporterPhone *string `json:"reporter_phone,omitempty"`
}
// PublicReportResponse is returned after submitting a report (access_key only shown once!)
type PublicReportResponse struct {
ReferenceNumber string `json:"reference_number"`
AccessKey string `json:"access_key"`
}
// ReportUpdateRequest is the request for updating a report (admin)
type ReportUpdateRequest struct {
Category ReportCategory `json:"category,omitempty"`
Status ReportStatus `json:"status,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
AssignedTo *uuid.UUID `json:"assigned_to,omitempty"`
}
// AcknowledgeRequest is the request for acknowledging a report
type AcknowledgeRequest struct {
Message string `json:"message,omitempty"` // optional acknowledgment message to reporter
}
// CloseReportRequest is the request for closing a report
type CloseReportRequest struct {
Resolution string `json:"resolution" binding:"required"`
}
// AddMeasureRequest is the request for adding a corrective measure
type AddMeasureRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Responsible string `json:"responsible" binding:"required"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// UpdateMeasureRequest is the request for updating a measure
type UpdateMeasureRequest struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Status MeasureStatus `json:"status,omitempty"`
Responsible string `json:"responsible,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
}
// SendMessageRequest is the request for sending an anonymous message
type SendMessageRequest struct {
Content string `json:"content" binding:"required"`
}
// ReportListResponse is the response for listing reports
type ReportListResponse struct {
Reports []Report `json:"reports"`
Total int `json:"total"`
}
// ReportFilters defines filters for listing reports
type ReportFilters struct {
Status ReportStatus
Category ReportCategory
Limit int
Offset int
}
// ============================================================================
// Helper Functions
// ============================================================================
// generateAccessKey generates a random 12-character alphanumeric key
func generateAccessKey() string {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 12)
randomBytes := make([]byte, 12)
rand.Read(randomBytes)
for i := range b {
b[i] = charset[int(randomBytes[i])%len(charset)]
}
return string(b)
}
// generateReferenceNumber generates a reference number like "WB-2026-0042"
func generateReferenceNumber(year int, sequence int) string {
return fmt.Sprintf("WB-%d-%04d", year, sequence)
}

View File

@@ -0,0 +1,591 @@
package whistleblower
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles whistleblower data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new whistleblower store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Report CRUD Operations
// ============================================================================
// CreateReport creates a new whistleblower report with auto-generated reference number and access key
func (s *Store) CreateReport(ctx context.Context, report *Report) error {
report.ID = uuid.New()
now := time.Now().UTC()
report.CreatedAt = now
report.UpdatedAt = now
report.ReceivedAt = now
report.DeadlineAcknowledgment = now.AddDate(0, 0, 7) // 7 days per HinSchG
report.DeadlineFeedback = now.AddDate(0, 3, 0) // 3 months per HinSchG
if report.Status == "" {
report.Status = ReportStatusNew
}
// Generate access key
report.AccessKey = generateAccessKey()
// Generate reference number
year := now.Year()
seq, err := s.GetNextSequenceNumber(ctx, report.TenantID, year)
if err != nil {
return fmt.Errorf("failed to get sequence number: %w", err)
}
report.ReferenceNumber = generateReferenceNumber(year, seq)
// Initialize audit trail
if report.AuditTrail == nil {
report.AuditTrail = []AuditEntry{}
}
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_created",
UserID: "system",
Details: "Report submitted",
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
INSERT INTO whistleblower_reports (
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8,
$9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20,
$21, $22
)
`,
report.ID, report.TenantID, report.ReferenceNumber, report.AccessKey,
string(report.Category), string(report.Status), report.Title, report.Description,
report.IsAnonymous, report.ReporterName, report.ReporterEmail, report.ReporterPhone,
report.ReceivedAt, report.DeadlineAcknowledgment, report.DeadlineFeedback,
report.AcknowledgedAt, report.ClosedAt, report.AssignedTo,
auditTrailJSON, report.Resolution,
report.CreatedAt, report.UpdatedAt,
)
return err
}
// GetReport retrieves a report by ID
func (s *Store) GetReport(ctx context.Context, id uuid.UUID) (*Report, error) {
var report Report
var category, status string
var auditTrailJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
FROM whistleblower_reports WHERE id = $1
`, id).Scan(
&report.ID, &report.TenantID, &report.ReferenceNumber, &report.AccessKey,
&category, &status, &report.Title, &report.Description,
&report.IsAnonymous, &report.ReporterName, &report.ReporterEmail, &report.ReporterPhone,
&report.ReceivedAt, &report.DeadlineAcknowledgment, &report.DeadlineFeedback,
&report.AcknowledgedAt, &report.ClosedAt, &report.AssignedTo,
&auditTrailJSON, &report.Resolution,
&report.CreatedAt, &report.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
report.Category = ReportCategory(category)
report.Status = ReportStatus(status)
json.Unmarshal(auditTrailJSON, &report.AuditTrail)
return &report, nil
}
// GetReportByAccessKey retrieves a report by its access key (for public anonymous access)
func (s *Store) GetReportByAccessKey(ctx context.Context, accessKey string) (*Report, error) {
var id uuid.UUID
err := s.pool.QueryRow(ctx,
"SELECT id FROM whistleblower_reports WHERE access_key = $1",
accessKey,
).Scan(&id)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return s.GetReport(ctx, id)
}
// ListReports lists reports for a tenant with optional filters
func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *ReportFilters) ([]Report, int, error) {
// Count total
countQuery := "SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
if filters != nil {
if filters.Status != "" {
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Status))
countArgIdx++
}
if filters.Category != "" {
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Category))
countArgIdx++
}
}
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
// Build data query
query := `
SELECT
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
FROM whistleblower_reports WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(filters.Status))
argIdx++
}
if filters.Category != "" {
query += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, string(filters.Category))
argIdx++
}
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var reports []Report
for rows.Next() {
var report Report
var category, status string
var auditTrailJSON []byte
err := rows.Scan(
&report.ID, &report.TenantID, &report.ReferenceNumber, &report.AccessKey,
&category, &status, &report.Title, &report.Description,
&report.IsAnonymous, &report.ReporterName, &report.ReporterEmail, &report.ReporterPhone,
&report.ReceivedAt, &report.DeadlineAcknowledgment, &report.DeadlineFeedback,
&report.AcknowledgedAt, &report.ClosedAt, &report.AssignedTo,
&auditTrailJSON, &report.Resolution,
&report.CreatedAt, &report.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
report.Category = ReportCategory(category)
report.Status = ReportStatus(status)
json.Unmarshal(auditTrailJSON, &report.AuditTrail)
// Do not expose access key in list responses
report.AccessKey = ""
reports = append(reports, report)
}
return reports, total, nil
}
// UpdateReport updates a report
func (s *Store) UpdateReport(ctx context.Context, report *Report) error {
report.UpdatedAt = time.Now().UTC()
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err := s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
category = $2, status = $3, title = $4, description = $5,
assigned_to = $6, audit_trail = $7, resolution = $8,
updated_at = $9
WHERE id = $1
`,
report.ID,
string(report.Category), string(report.Status), report.Title, report.Description,
report.AssignedTo, auditTrailJSON, report.Resolution,
report.UpdatedAt,
)
return err
}
// AcknowledgeReport acknowledges a report, setting acknowledged_at and adding an audit entry
func (s *Store) AcknowledgeReport(ctx context.Context, id uuid.UUID, userID uuid.UUID) error {
report, err := s.GetReport(ctx, id)
if err != nil || report == nil {
return fmt.Errorf("report not found")
}
now := time.Now().UTC()
report.AcknowledgedAt = &now
report.Status = ReportStatusAcknowledged
report.UpdatedAt = now
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_acknowledged",
UserID: userID.String(),
Details: "Report acknowledged within HinSchG deadline",
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
status = $2, acknowledged_at = $3,
audit_trail = $4, updated_at = $5
WHERE id = $1
`,
id, string(ReportStatusAcknowledged), now,
auditTrailJSON, now,
)
return err
}
// CloseReport closes a report with a resolution
func (s *Store) CloseReport(ctx context.Context, id uuid.UUID, userID uuid.UUID, resolution string) error {
report, err := s.GetReport(ctx, id)
if err != nil || report == nil {
return fmt.Errorf("report not found")
}
now := time.Now().UTC()
report.ClosedAt = &now
report.Status = ReportStatusClosed
report.Resolution = resolution
report.UpdatedAt = now
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_closed",
UserID: userID.String(),
Details: "Report closed with resolution: " + resolution,
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
status = $2, closed_at = $3, resolution = $4,
audit_trail = $5, updated_at = $6
WHERE id = $1
`,
id, string(ReportStatusClosed), now, resolution,
auditTrailJSON, now,
)
return err
}
// DeleteReport deletes a report and its related data (cascading via FK)
func (s *Store) DeleteReport(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM whistleblower_measures WHERE report_id = $1", id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_messages WHERE report_id = $1", id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_reports WHERE id = $1", id)
return err
}
// ============================================================================
// Message Operations
// ============================================================================
// AddMessage adds an anonymous message to a report
func (s *Store) AddMessage(ctx context.Context, msg *AnonymousMessage) error {
msg.ID = uuid.New()
msg.SentAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO whistleblower_messages (
id, report_id, direction, content, sent_at, read_at
) VALUES (
$1, $2, $3, $4, $5, $6
)
`,
msg.ID, msg.ReportID, string(msg.Direction), msg.Content, msg.SentAt, msg.ReadAt,
)
return err
}
// ListMessages lists messages for a report
func (s *Store) ListMessages(ctx context.Context, reportID uuid.UUID) ([]AnonymousMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, report_id, direction, content, sent_at, read_at
FROM whistleblower_messages WHERE report_id = $1
ORDER BY sent_at ASC
`, reportID)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []AnonymousMessage
for rows.Next() {
var msg AnonymousMessage
var direction string
err := rows.Scan(
&msg.ID, &msg.ReportID, &direction, &msg.Content, &msg.SentAt, &msg.ReadAt,
)
if err != nil {
return nil, err
}
msg.Direction = MessageDirection(direction)
messages = append(messages, msg)
}
return messages, nil
}
// ============================================================================
// Measure Operations
// ============================================================================
// AddMeasure adds a corrective measure to a report
func (s *Store) AddMeasure(ctx context.Context, measure *Measure) error {
measure.ID = uuid.New()
measure.CreatedAt = time.Now().UTC()
if measure.Status == "" {
measure.Status = MeasureStatusPlanned
}
_, err := s.pool.Exec(ctx, `
INSERT INTO whistleblower_measures (
id, report_id, title, description, status,
responsible, due_date, completed_at, created_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9
)
`,
measure.ID, measure.ReportID, measure.Title, measure.Description, string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt,
)
return err
}
// ListMeasures lists measures for a report
func (s *Store) ListMeasures(ctx context.Context, reportID uuid.UUID) ([]Measure, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, report_id, title, description, status,
responsible, due_date, completed_at, created_at
FROM whistleblower_measures WHERE report_id = $1
ORDER BY created_at ASC
`, reportID)
if err != nil {
return nil, err
}
defer rows.Close()
var measures []Measure
for rows.Next() {
var m Measure
var status string
err := rows.Scan(
&m.ID, &m.ReportID, &m.Title, &m.Description, &status,
&m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.Status = MeasureStatus(status)
measures = append(measures, m)
}
return measures, nil
}
// UpdateMeasure updates a measure
func (s *Store) UpdateMeasure(ctx context.Context, measure *Measure) error {
_, err := s.pool.Exec(ctx, `
UPDATE whistleblower_measures SET
title = $2, description = $3, status = $4,
responsible = $5, due_date = $6, completed_at = $7
WHERE id = $1
`,
measure.ID,
measure.Title, measure.Description, string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt,
)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated whistleblower statistics for a tenant
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*WhistleblowerStatistics, error) {
stats := &WhistleblowerStatistics{
ByStatus: make(map[string]int),
ByCategory: make(map[string]int),
}
// Total reports
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalReports)
// By status
rows, err := s.pool.Query(ctx,
"SELECT status, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY status",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var status string
var count int
rows.Scan(&status, &count)
stats.ByStatus[status] = count
}
}
// By category
rows, err = s.pool.Query(ctx,
"SELECT category, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY category",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var category string
var count int
rows.Scan(&category, &count)
stats.ByCategory[category] = count
}
}
// Overdue acknowledgments: reports past deadline_acknowledgment that haven't been acknowledged
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM whistleblower_reports
WHERE tenant_id = $1
AND acknowledged_at IS NULL
AND status = 'new'
AND deadline_acknowledgment < NOW()
`, tenantID).Scan(&stats.OverdueAcknowledgments)
// Overdue feedbacks: reports past deadline_feedback that are still open
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM whistleblower_reports
WHERE tenant_id = $1
AND closed_at IS NULL
AND status NOT IN ('closed', 'rejected')
AND deadline_feedback < NOW()
`, tenantID).Scan(&stats.OverdueFeedbacks)
// Average resolution days (for closed reports)
s.pool.QueryRow(ctx, `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - received_at)) / 86400), 0)
FROM whistleblower_reports
WHERE tenant_id = $1 AND closed_at IS NOT NULL
`, tenantID).Scan(&stats.AvgResolutionDays)
return stats, nil
}
// ============================================================================
// Sequence Number
// ============================================================================
// GetNextSequenceNumber gets and increments the sequence number for reference number generation
func (s *Store) GetNextSequenceNumber(ctx context.Context, tenantID uuid.UUID, year int) (int, error) {
var seq int
err := s.pool.QueryRow(ctx, `
INSERT INTO whistleblower_sequences (tenant_id, year, last_sequence)
VALUES ($1, $2, 1)
ON CONFLICT (tenant_id, year) DO UPDATE SET
last_sequence = whistleblower_sequences.last_sequence + 1
RETURNING last_sequence
`, tenantID, year).Scan(&seq)
if err != nil {
return 0, err
}
return seq, nil
}