diff --git a/.gitignore b/.gitignore index 4ffadf8..a366580 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,28 @@ backend/screenshots/ ssh_key*.txt anleitung.txt fix_permissions.txt + +# ============================================ +# Compiled Go Binaries +# ============================================ +billing-service/billing-service +consent-service/server +edu-search-service/server +edu-search-service/edu-search-service + +# ============================================ +# Large Document Archives (PDFs, DOCX) +# ============================================ +docs/za-download/ +docs/za-download-2/ +docs/za-download-3/ +*.pdf +*.docx +*.xlsx +*.pptx + +# ============================================ +# Entfernte Projekte (nicht mehr aktiv) +# ============================================ +BreakpilotDrive/ +billing-service/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0671934 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# BreakPilot PWA (ARCHIVED) + +> **Dieses Repository ist archiviert.** Alle Services wurden in die folgenden Projekte migriert. + +## Migration (2026-02-14) + +| Service | Neues Projekt | Container | +|---------|---------------|-----------| +| Studio v2 | breakpilot-lehrer | bp-lehrer-studio-v2 | +| Admin | breakpilot-lehrer | bp-lehrer-admin | +| Website | breakpilot-lehrer | bp-lehrer-website | +| Backend (Lehrer) | breakpilot-lehrer | bp-lehrer-backend | +| Klausur Service | breakpilot-lehrer | bp-lehrer-klausur-service | +| School Service | breakpilot-lehrer | bp-lehrer-school-service | +| Voice Service | breakpilot-lehrer | bp-lehrer-voice-service | +| Geo Service | breakpilot-lehrer | bp-lehrer-geo-service | +| Backend (Core) | breakpilot-core | bp-core-backend | +| Postgres | breakpilot-core | bp-core-postgres | +| Valkey | breakpilot-core | bp-core-valkey | +| Nginx | breakpilot-core | bp-core-nginx | +| Vault | breakpilot-core | bp-core-vault | +| Qdrant | breakpilot-core | bp-core-qdrant | +| MinIO | breakpilot-core | bp-core-minio | +| Embedding Service | breakpilot-core | bp-core-embedding-service | +| Night Scheduler | breakpilot-core | bp-core-night-scheduler | +| Pitch Deck | breakpilot-core | bp-core-pitch-deck | +| Gitea | breakpilot-core | bp-core-gitea | +| Woodpecker CI | breakpilot-core | bp-core-woodpecker-server | +| Jitsi | breakpilot-core | bp-core-jitsi-* | +| AI Compliance SDK | breakpilot-compliance | bp-compliance-ai-sdk | +| Developer Portal | breakpilot-compliance | bp-compliance-developer-portal | +| DSMS | breakpilot-compliance | bp-compliance-dsms-* | +| Backend (Compliance) | breakpilot-compliance | bp-compliance-backend | + +## Neue Repos + +- **breakpilot-core**: Shared Infrastructure (Postgres, Nginx, Vault, Qdrant, MinIO, etc.) +- **breakpilot-lehrer**: Bildungs-Stack (Studio, Admin, Backend, Klausur, Voice, etc.) +- **breakpilot-compliance**: DSGVO/Compliance-Stack (Admin, SDK, DSMS, Developer Portal) diff --git a/admin-v2/.docker/build-ci-images.sh b/admin-v2/.docker/build-ci-images.sh new file mode 100755 index 0000000..1f55393 --- /dev/null +++ b/admin-v2/.docker/build-ci-images.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Build CI Docker Images for BreakPilot +# Run this script on the Mac Mini to build the custom CI images + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== Building BreakPilot CI Images ===" +echo "Project directory: $PROJECT_DIR" + +cd "$PROJECT_DIR" + +# Build Python CI image with WeasyPrint +echo "" +echo "Building breakpilot/python-ci:3.12 ..." +docker build \ + -t breakpilot/python-ci:3.12 \ + -t breakpilot/python-ci:latest \ + -f .docker/python-ci.Dockerfile \ + . + +echo "" +echo "=== Build complete ===" +echo "" +echo "Images built:" +docker images | grep breakpilot/python-ci + +echo "" +echo "To use in Woodpecker CI, the image is already configured in .woodpecker/main.yml" diff --git a/admin-v2/.docker/python-ci.Dockerfile b/admin-v2/.docker/python-ci.Dockerfile new file mode 100644 index 0000000..ff8cd4a --- /dev/null +++ b/admin-v2/.docker/python-ci.Dockerfile @@ -0,0 +1,51 @@ +# Custom Python CI Image with WeasyPrint Dependencies +# Build: docker build -t breakpilot/python-ci:3.12 -f .docker/python-ci.Dockerfile . +# +# This image includes all system libraries needed for: +# - WeasyPrint (PDF generation) +# - psycopg2 (PostgreSQL) +# - General Python testing + +FROM python:3.12-slim + +LABEL maintainer="BreakPilot Team" +LABEL description="Python 3.12 with WeasyPrint and test dependencies for CI" + +# Install system dependencies in a single layer +RUN apt-get update && apt-get install -y --no-install-recommends \ + # WeasyPrint dependencies + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libpangoft2-1.0-0 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + libcairo2 \ + libcairo2-dev \ + libgirepository1.0-dev \ + gir1.2-pango-1.0 \ + # PostgreSQL client (for psycopg2) + libpq-dev \ + # Build tools (for some pip packages) + gcc \ + g++ \ + # Useful utilities + curl \ + git \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Pre-install commonly used Python packages for faster CI +RUN pip install --no-cache-dir \ + pytest \ + pytest-cov \ + pytest-asyncio \ + pytest-json-report \ + psycopg2-binary \ + weasyprint \ + httpx + +# Set working directory +WORKDIR /app + +# Default command +CMD ["python", "--version"] diff --git a/admin-v2/.env.example b/admin-v2/.env.example new file mode 100644 index 0000000..355c1da --- /dev/null +++ b/admin-v2/.env.example @@ -0,0 +1,124 @@ +# BreakPilot PWA - Environment Configuration +# Kopieren Sie diese Datei nach .env und passen Sie die Werte an + +# ================================================ +# Allgemein +# ================================================ +ENVIRONMENT=development +# ENVIRONMENT=production + +# ================================================ +# Sicherheit +# ================================================ +# WICHTIG: In Produktion sichere Schluessel verwenden! +# Generieren mit: openssl rand -hex 32 +JWT_SECRET=CHANGE_ME_RUN_openssl_rand_hex_32 +JWT_REFRESH_SECRET=CHANGE_ME_RUN_openssl_rand_hex_32 + +# ================================================ +# Keycloak (Optional - fuer Produktion empfohlen) +# ================================================ +# Wenn Keycloak konfiguriert ist, wird es fuer Authentifizierung verwendet. +# Ohne Keycloak wird lokales JWT verwendet (gut fuer Entwicklung). +# +# KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app +# KEYCLOAK_REALM=breakpilot +# KEYCLOAK_CLIENT_ID=breakpilot-backend +# KEYCLOAK_CLIENT_SECRET=your-client-secret +# KEYCLOAK_VERIFY_SSL=true + +# ================================================ +# E-Mail Konfiguration +# ================================================ + +# === ENTWICKLUNG (Mailpit - Standardwerte) === +# Mailpit fängt alle E-Mails ab und zeigt sie unter http://localhost:8025 +SMTP_HOST=mailpit +SMTP_PORT=1025 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_NAME=BreakPilot +SMTP_FROM_ADDR=noreply@breakpilot.app +FRONTEND_URL=http://localhost:8000 + +# === PRODUKTION (Beispiel für verschiedene Provider) === + +# --- Option 1: Eigener Mailserver --- +# SMTP_HOST=mail.ihredomain.de +# SMTP_PORT=587 +# SMTP_USERNAME=noreply@ihredomain.de +# SMTP_PASSWORD=ihr-sicheres-passwort +# SMTP_FROM_NAME=BreakPilot +# SMTP_FROM_ADDR=noreply@ihredomain.de +# FRONTEND_URL=https://app.ihredomain.de + +# --- Option 2: SendGrid --- +# SMTP_HOST=smtp.sendgrid.net +# SMTP_PORT=587 +# SMTP_USERNAME=apikey +# SMTP_PASSWORD=SG.xxxxxxxxxxxxxxxxxxxxx +# SMTP_FROM_NAME=BreakPilot +# SMTP_FROM_ADDR=noreply@ihredomain.de + +# --- Option 3: Mailgun --- +# SMTP_HOST=smtp.mailgun.org +# SMTP_PORT=587 +# SMTP_USERNAME=postmaster@mg.ihredomain.de +# SMTP_PASSWORD=ihr-mailgun-passwort +# SMTP_FROM_NAME=BreakPilot +# SMTP_FROM_ADDR=noreply@mg.ihredomain.de + +# --- Option 4: Amazon SES --- +# SMTP_HOST=email-smtp.eu-central-1.amazonaws.com +# SMTP_PORT=587 +# SMTP_USERNAME=AKIAXXXXXXXXXXXXXXXX +# SMTP_PASSWORD=ihr-ses-secret +# SMTP_FROM_NAME=BreakPilot +# SMTP_FROM_ADDR=noreply@ihredomain.de + +# ================================================ +# Datenbank +# ================================================ +POSTGRES_USER=breakpilot +POSTGRES_PASSWORD=breakpilot123 +POSTGRES_DB=breakpilot_db +DATABASE_URL=postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db?sslmode=disable + +# ================================================ +# Optional: AI Integration +# ================================================ +# ANTHROPIC_API_KEY=your-anthropic-api-key-here + +# ================================================ +# Breakpilot Drive - Lernspiel +# ================================================ +# Aktiviert Datenbank-Speicherung fuer Spielsessions +GAME_USE_DATABASE=true + +# LLM fuer Quiz-Fragen-Generierung (optional) +# Wenn nicht gesetzt, werden statische Fragen verwendet +GAME_LLM_MODEL=llama-3.1-8b +GAME_LLM_FALLBACK_MODEL=claude-3-haiku + +# Feature Flags +GAME_REQUIRE_AUTH=false +GAME_REQUIRE_BILLING=false +GAME_ENABLE_LEADERBOARDS=true + +# Task-Kosten fuer Billing (wenn aktiviert) +GAME_SESSION_TASK_COST=1.0 +GAME_QUICK_SESSION_TASK_COST=0.5 + +# ================================================ +# Woodpecker CI/CD +# ================================================ +# URL zum Woodpecker Server +WOODPECKER_URL=http://woodpecker-server:8000 +# API Token für Dashboard-Integration (Pipeline-Start) +# Erstellen unter: http://macmini:8090 → User Settings → Personal Access Tokens +WOODPECKER_TOKEN= + +# ================================================ +# Debug +# ================================================ +DEBUG=false diff --git a/admin-v2/.github/dependabot.yml b/admin-v2/.github/dependabot.yml new file mode 100644 index 0000000..f318042 --- /dev/null +++ b/admin-v2/.github/dependabot.yml @@ -0,0 +1,132 @@ +# Dependabot Configuration for BreakPilot PWA +# This file configures Dependabot to automatically check for outdated dependencies +# and create pull requests to update them + +version: 2 +updates: + # Go dependencies (consent-service) + - package-ecosystem: "gomod" + directory: "/consent-service" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "go" + - "security" + commit-message: + prefix: "deps(go):" + groups: + go-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Python dependencies (backend) + - package-ecosystem: "pip" + directory: "/backend" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "python" + - "security" + commit-message: + prefix: "deps(python):" + groups: + python-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Node.js dependencies (website) + - package-ecosystem: "npm" + directory: "/website" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "javascript" + - "security" + commit-message: + prefix: "deps(npm):" + groups: + npm-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "deps(actions):" + + # Docker base images + - package-ecosystem: "docker" + directory: "/consent-service" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + labels: + - "dependencies" + - "docker" + - "security" + commit-message: + prefix: "deps(docker):" + + - package-ecosystem: "docker" + directory: "/backend" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + labels: + - "dependencies" + - "docker" + - "security" + commit-message: + prefix: "deps(docker):" + + - package-ecosystem: "docker" + directory: "/website" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + labels: + - "dependencies" + - "docker" + - "security" + commit-message: + prefix: "deps(docker):" diff --git a/admin-v2/.github/workflows/ci.yml b/admin-v2/.github/workflows/ci.yml new file mode 100644 index 0000000..a863ca2 --- /dev/null +++ b/admin-v2/.github/workflows/ci.yml @@ -0,0 +1,503 @@ +name: CI/CD Pipeline + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + GO_VERSION: '1.21' + PYTHON_VERSION: '3.11' + NODE_VERSION: '20' + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_test + REGISTRY: ghcr.io + IMAGE_PREFIX: ${{ github.repository_owner }}/breakpilot + +jobs: + # ========================================== + # Go Consent Service Tests + # ========================================== + go-tests: + name: Go Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: consent-service/go.sum + + - name: Download dependencies + working-directory: ./consent-service + run: go mod download + + - name: Run Go Vet + working-directory: ./consent-service + run: go vet ./... + + - name: Run Unit Tests + working-directory: ./consent-service + run: go test -v -race -coverprofile=coverage.out ./... + env: + DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }}?sslmode=disable + JWT_SECRET: test-jwt-secret-for-ci + JWT_REFRESH_SECRET: test-refresh-secret-for-ci + + - name: Check Coverage + working-directory: ./consent-service + run: | + go tool cover -func=coverage.out + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + echo "Total coverage: ${COVERAGE}%" + if (( $(echo "$COVERAGE < 50" | bc -l) )); then + echo "::warning::Coverage is below 50%" + fi + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./consent-service/coverage.out + flags: go + name: go-coverage + continue-on-error: true + + # ========================================== + # Python Backend Tests + # ========================================== + python-tests: + name: Python Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + working-directory: ./backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov pytest-asyncio httpx + + - name: Run Python Tests + working-directory: ./backend + run: pytest -v --cov=. --cov-report=xml --cov-report=term-missing + continue-on-error: true + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./backend/coverage.xml + flags: python + name: python-coverage + continue-on-error: true + + # ========================================== + # Node.js Website Tests + # ========================================== + website-tests: + name: Website Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + working-directory: ./website + run: npm ci + + - name: Run TypeScript check + working-directory: ./website + run: npx tsc --noEmit + continue-on-error: true + + - name: Run ESLint + working-directory: ./website + run: npm run lint + continue-on-error: true + + - name: Build website + working-directory: ./website + run: npm run build + env: + NEXT_PUBLIC_BILLING_API_URL: http://localhost:8083 + NEXT_PUBLIC_APP_URL: http://localhost:3000 + + # ========================================== + # Linting + # ========================================== + lint: + name: Linting + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + working-directory: ./consent-service + args: --timeout=5m + continue-on-error: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Python linters + run: pip install flake8 black isort + + - name: Run flake8 + working-directory: ./backend + run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + continue-on-error: true + + - name: Check Black formatting + working-directory: ./backend + run: black --check --diff . + continue-on-error: true + + # ========================================== + # Security Scan + # ========================================== + security: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + severity: 'CRITICAL,HIGH' + exit-code: '0' + continue-on-error: true + + - name: Run Go security check + uses: securego/gosec@master + with: + args: '-no-fail -fmt sarif -out results.sarif ./consent-service/...' + continue-on-error: true + + # ========================================== + # Docker Build & Push + # ========================================== + docker-build: + name: Docker Build & Push + runs-on: ubuntu-latest + needs: [go-tests, python-tests, website-tests] + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for consent-service + id: meta-consent + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push consent-service + uses: docker/build-push-action@v5 + with: + context: ./consent-service + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta-consent.outputs.tags }} + labels: ${{ steps.meta-consent.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata for backend + id: meta-backend + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push backend + uses: docker/build-push-action@v5 + with: + context: ./backend + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta-backend.outputs.tags }} + labels: ${{ steps.meta-backend.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata for website + id: meta-website + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push website + uses: docker/build-push-action@v5 + with: + context: ./website + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta-website.outputs.tags }} + labels: ${{ steps.meta-website.outputs.labels }} + build-args: | + NEXT_PUBLIC_BILLING_API_URL=${{ vars.NEXT_PUBLIC_BILLING_API_URL || 'http://localhost:8083' }} + NEXT_PUBLIC_APP_URL=${{ vars.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ========================================== + # Integration Tests + # ========================================== + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + needs: [docker-build] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Start services with Docker Compose + run: | + docker compose up -d postgres mailpit + sleep 10 + + - name: Run consent-service + working-directory: ./consent-service + run: | + go build -o consent-service ./cmd/server + ./consent-service & + sleep 5 + env: + DATABASE_URL: postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db?sslmode=disable + JWT_SECRET: test-jwt-secret + JWT_REFRESH_SECRET: test-refresh-secret + SMTP_HOST: localhost + SMTP_PORT: 1025 + + - name: Health Check + run: | + curl -f http://localhost:8081/health || exit 1 + + - name: Run Integration Tests + run: | + # Test Auth endpoints + curl -s http://localhost:8081/api/v1/auth/health + + # Test Document endpoints + curl -s http://localhost:8081/api/v1/documents + continue-on-error: true + + - name: Stop services + if: always() + run: docker compose down + + # ========================================== + # Deploy to Staging + # ========================================== + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + needs: [docker-build, integration-tests] + if: github.ref == 'refs/heads/develop' && github.event_name == 'push' + environment: + name: staging + url: https://staging.breakpilot.app + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy to staging server + env: + STAGING_HOST: ${{ secrets.STAGING_HOST }} + STAGING_USER: ${{ secrets.STAGING_USER }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + # This is a placeholder for actual deployment + # Configure based on your staging infrastructure + echo "Deploying to staging environment..." + echo "Images to deploy:" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service:develop" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:develop" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website:develop" + + # Example: SSH deployment (uncomment when configured) + # mkdir -p ~/.ssh + # echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa + # chmod 600 ~/.ssh/id_rsa + # ssh -o StrictHostKeyChecking=no $STAGING_USER@$STAGING_HOST "cd /opt/breakpilot && docker compose pull && docker compose up -d" + + - name: Notify deployment + run: | + echo "## Staging Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Successfully deployed to staging environment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Deployed images:**" >> $GITHUB_STEP_SUMMARY + echo "- consent-service: \`develop\`" >> $GITHUB_STEP_SUMMARY + echo "- backend: \`develop\`" >> $GITHUB_STEP_SUMMARY + echo "- website: \`develop\`" >> $GITHUB_STEP_SUMMARY + + # ========================================== + # Deploy to Production + # ========================================== + deploy-production: + name: Deploy to Production + runs-on: ubuntu-latest + needs: [docker-build, integration-tests] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + environment: + name: production + url: https://breakpilot.app + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy to production server + env: + PROD_HOST: ${{ secrets.PROD_HOST }} + PROD_USER: ${{ secrets.PROD_USER }} + PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }} + run: | + # This is a placeholder for actual deployment + # Configure based on your production infrastructure + echo "Deploying to production environment..." + echo "Images to deploy:" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service:latest" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:latest" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website:latest" + + # Example: SSH deployment (uncomment when configured) + # mkdir -p ~/.ssh + # echo "$PROD_SSH_KEY" > ~/.ssh/id_rsa + # chmod 600 ~/.ssh/id_rsa + # ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST "cd /opt/breakpilot && docker compose pull && docker compose up -d" + + - name: Notify deployment + run: | + echo "## Production Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Successfully deployed to production environment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Deployed images:**" >> $GITHUB_STEP_SUMMARY + echo "- consent-service: \`latest\`" >> $GITHUB_STEP_SUMMARY + echo "- backend: \`latest\`" >> $GITHUB_STEP_SUMMARY + echo "- website: \`latest\`" >> $GITHUB_STEP_SUMMARY + + # ========================================== + # Summary + # ========================================== + summary: + name: CI Summary + runs-on: ubuntu-latest + needs: [go-tests, python-tests, website-tests, lint, security, docker-build, integration-tests] + if: always() + + steps: + - name: Check job results + run: | + echo "## CI/CD Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Go Tests | ${{ needs.go-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Python Tests | ${{ needs.python-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Website Tests | ${{ needs.website-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Linting | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Docker Build | ${{ needs.docker-build.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Integration Tests | ${{ needs.integration-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Docker Images" >> $GITHUB_STEP_SUMMARY + echo "Images are pushed to: \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-*\`" >> $GITHUB_STEP_SUMMARY diff --git a/admin-v2/.github/workflows/security.yml b/admin-v2/.github/workflows/security.yml new file mode 100644 index 0000000..5ced30c --- /dev/null +++ b/admin-v2/.github/workflows/security.yml @@ -0,0 +1,222 @@ +name: Security Scanning + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + # Run security scans weekly on Sundays at midnight + - cron: '0 0 * * 0' + +jobs: + # ========================================== + # Secret Scanning + # ========================================== + secret-scan: + name: Secret Scanning + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: TruffleHog Secret Scan + uses: trufflesecurity/trufflehog@main + with: + extra_args: --only-verified + + - name: GitLeaks Secret Scan + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + # ========================================== + # Dependency Vulnerability Scanning + # ========================================== + dependency-scan: + name: Dependency Vulnerability Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner (filesystem) + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + severity: 'CRITICAL,HIGH' + format: 'sarif' + output: 'trivy-fs-results.sarif' + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-fs-results.sarif' + continue-on-error: true + + # ========================================== + # Go Security Scan + # ========================================== + go-security: + name: Go Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: '-no-fail -fmt sarif -out gosec-results.sarif ./consent-service/...' + continue-on-error: true + + - name: Upload Gosec results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'gosec-results.sarif' + continue-on-error: true + + - name: Run govulncheck + working-directory: ./consent-service + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... || true + + # ========================================== + # Python Security Scan + # ========================================== + python-security: + name: Python Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install safety + run: pip install safety bandit + + - name: Run Safety (dependency check) + working-directory: ./backend + run: safety check -r requirements.txt --full-report || true + + - name: Run Bandit (code security scan) + working-directory: ./backend + run: bandit -r . -f sarif -o bandit-results.sarif --exit-zero + + - name: Upload Bandit results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: './backend/bandit-results.sarif' + continue-on-error: true + + # ========================================== + # Node.js Security Scan + # ========================================== + node-security: + name: Node.js Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + working-directory: ./website + run: npm ci + + - name: Run npm audit + working-directory: ./website + run: npm audit --audit-level=high || true + + # ========================================== + # Docker Image Scanning + # ========================================== + docker-security: + name: Docker Image Security + runs-on: ubuntu-latest + needs: [go-security, python-security, node-security] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build consent-service image + run: docker build -t breakpilot/consent-service:scan ./consent-service + + - name: Run Trivy on consent-service + uses: aquasecurity/trivy-action@master + with: + image-ref: 'breakpilot/consent-service:scan' + severity: 'CRITICAL,HIGH' + format: 'sarif' + output: 'trivy-consent-results.sarif' + continue-on-error: true + + - name: Build backend image + run: docker build -t breakpilot/backend:scan ./backend + + - name: Run Trivy on backend + uses: aquasecurity/trivy-action@master + with: + image-ref: 'breakpilot/backend:scan' + severity: 'CRITICAL,HIGH' + format: 'sarif' + output: 'trivy-backend-results.sarif' + continue-on-error: true + + - name: Upload Trivy results + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-consent-results.sarif' + continue-on-error: true + + # ========================================== + # Security Summary + # ========================================== + security-summary: + name: Security Summary + runs-on: ubuntu-latest + needs: [secret-scan, dependency-scan, go-security, python-security, node-security, docker-security] + if: always() + + steps: + - name: Create security summary + run: | + echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Scan Type | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Secret Scanning | ${{ needs.secret-scan.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Dependency Scanning | ${{ needs.dependency-scan.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Go Security | ${{ needs.go-security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Python Security | ${{ needs.python-security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Node.js Security | ${{ needs.node-security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Docker Security | ${{ needs.docker-security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Notes" >> $GITHUB_STEP_SUMMARY + echo "- Results are uploaded to the GitHub Security tab" >> $GITHUB_STEP_SUMMARY + echo "- Weekly scheduled scans run on Sundays" >> $GITHUB_STEP_SUMMARY diff --git a/admin-v2/.github/workflows/test.yml b/admin-v2/.github/workflows/test.yml new file mode 100644 index 0000000..dd65cf3 --- /dev/null +++ b/admin-v2/.github/workflows/test.yml @@ -0,0 +1,244 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + go-tests: + name: Go Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + cache-dependency-path: consent-service/go.sum + + - name: Install Dependencies + working-directory: ./consent-service + run: go mod download + + - name: Run Tests + working-directory: ./consent-service + env: + DATABASE_URL: postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_test?sslmode=disable + JWT_SECRET: test-secret-key-for-ci + JWT_REFRESH_SECRET: test-refresh-secret-for-ci + run: | + go test -v -race -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + + - name: Check Coverage Threshold + working-directory: ./consent-service + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + echo "Total Coverage: $COVERAGE%" + if (( $(echo "$COVERAGE < 70.0" | bc -l) )); then + echo "Coverage $COVERAGE% is below threshold 70%" + exit 1 + fi + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./consent-service/coverage.out + flags: go + name: go-coverage + + python-tests: + name: Python Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + cache-dependency-path: backend/requirements.txt + + - name: Install Dependencies + working-directory: ./backend + run: | + pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov pytest-asyncio + + - name: Run Tests + working-directory: ./backend + env: + CONSENT_SERVICE_URL: http://localhost:8081 + JWT_SECRET: test-secret-key-for-ci + run: | + pytest -v --cov=. --cov-report=xml --cov-report=term + + - name: Check Coverage Threshold + working-directory: ./backend + run: | + COVERAGE=$(python -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); print(tree.getroot().attrib['line-rate'])") + COVERAGE_PCT=$(echo "$COVERAGE * 100" | bc) + echo "Total Coverage: ${COVERAGE_PCT}%" + if (( $(echo "$COVERAGE_PCT < 60.0" | bc -l) )); then + echo "Coverage ${COVERAGE_PCT}% is below threshold 60%" + exit 1 + fi + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./backend/coverage.xml + flags: python + name: python-coverage + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Start Services + run: | + docker-compose up -d + docker-compose ps + + - name: Wait for Postgres + run: | + timeout 60 bash -c 'until docker-compose exec -T postgres pg_isready -U breakpilot; do sleep 2; done' + + - name: Wait for Consent Service + run: | + timeout 60 bash -c 'until curl -f http://localhost:8081/health; do sleep 2; done' + + - name: Wait for Backend + run: | + timeout 60 bash -c 'until curl -f http://localhost:8000/health; do sleep 2; done' + + - name: Wait for Mailpit + run: | + timeout 60 bash -c 'until curl -f http://localhost:8025/api/v1/info; do sleep 2; done' + + - name: Run Integration Tests + run: | + chmod +x ./scripts/integration-tests.sh + ./scripts/integration-tests.sh + + - name: Show Service Logs on Failure + if: failure() + run: | + echo "=== Consent Service Logs ===" + docker-compose logs consent-service + echo "=== Backend Logs ===" + docker-compose logs backend + echo "=== Postgres Logs ===" + docker-compose logs postgres + + - name: Cleanup + if: always() + run: docker-compose down -v + + lint-go: + name: Go Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest + working-directory: consent-service + args: --timeout=5m + + lint-python: + name: Python Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install Dependencies + run: | + pip install flake8 black mypy + + - name: Run Black + working-directory: ./backend + run: black --check . + + - name: Run Flake8 + working-directory: ./backend + run: flake8 . --max-line-length=120 --exclude=venv + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Run Trivy Security Scan + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy Results to GitHub Security + uses: github/codeql-action/upload-sarif@v2 + if: always() + with: + sarif_file: 'trivy-results.sarif' + + all-checks: + name: All Checks Passed + runs-on: ubuntu-latest + needs: [go-tests, python-tests, integration-tests, lint-go, lint-python, security-scan] + + steps: + - name: All Tests Passed + run: echo "All tests and checks passed successfully!" diff --git a/admin-v2/.gitignore b/admin-v2/.gitignore new file mode 100644 index 0000000..4ffadf8 --- /dev/null +++ b/admin-v2/.gitignore @@ -0,0 +1,167 @@ +# ============================================ +# BreakPilot PWA - Git Ignore +# ============================================ + +# Environment files (keep examples only) +.env +.env.local +*.env.local + +# Keep examples and environment templates +!.env.example +!.env.dev +!.env.staging +# .env.prod should NOT be in repo (contains production secrets) + +# ============================================ +# Python +# ============================================ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ +.venv/ +*.egg-info/ +.eggs/ +*.egg +.pytest_cache/ +htmlcov/ +.coverage +.coverage.* +coverage.xml +*.cover + +# ============================================ +# Node.js +# ============================================ +node_modules/ +.next/ +out/ +dist/ +build/ +.npm +.yarn-integrity +*.tsbuildinfo + +# ============================================ +# Go +# ============================================ +*.exe +*.exe~ +*.dll +*.dylib +*.test +*.out +vendor/ + +# ============================================ +# Docker +# ============================================ +# Don't ignore docker-compose files +# Ignore volume data if mounted locally +backups/ +*.sql.gz +*.sql + +# ============================================ +# IDE & Editors +# ============================================ +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ +*.sublime-workspace +*.sublime-project + +# ============================================ +# OS Files +# ============================================ +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# ============================================ +# Secrets & Credentials +# ============================================ +secrets/ +*.pem +*.key +*.crt +*.p12 +*.pfx +credentials.json +service-account.json + +# ============================================ +# Logs +# ============================================ +*.log +logs/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# ============================================ +# Build Artifacts +# ============================================ +*.zip +*.tar.gz +*.rar + +# ============================================ +# Temporary Files +# ============================================ +tmp/ +temp/ +*.tmp +*.temp + +# ============================================ +# Test Results +# ============================================ +test-results/ +playwright-report/ +coverage/ + +# ============================================ +# ML Models (large files) +# ============================================ +*.pt +*.pth +*.onnx +*.safetensors +models/ +.claude/settings.local.json + +# ============================================ +# IDE Plugins & AI Tools +# ============================================ +.continue/ +CLAUDE_CONTINUE.md + +# ============================================ +# Misplaced / Large Directories +# ============================================ +backend/BreakpilotDrive/ +backend/website/ +backend/screenshots/ +**/za-download-9/ + +# ============================================ +# Debug & Temp Artifacts +# ============================================ +*.command +ssh_key*.txt +anleitung.txt +fix_permissions.txt diff --git a/admin-v2/.gitleaks.toml b/admin-v2/.gitleaks.toml new file mode 100644 index 0000000..fbe6794 --- /dev/null +++ b/admin-v2/.gitleaks.toml @@ -0,0 +1,77 @@ +# Gitleaks Configuration for BreakPilot +# https://github.com/gitleaks/gitleaks +# +# Run locally: gitleaks detect --source . -v +# Pre-commit: gitleaks protect --staged -v + +title = "BreakPilot Gitleaks Configuration" + +# Use the default rules plus custom rules +[extend] +useDefault = true + +# Custom rules for BreakPilot-specific patterns +[[rules]] +id = "anthropic-api-key" +description = "Anthropic API Key" +regex = '''sk-ant-api[0-9a-zA-Z-_]{20,}''' +tags = ["api", "anthropic"] +keywords = ["sk-ant-api"] + +[[rules]] +id = "vast-api-key" +description = "vast.ai API Key" +regex = '''(?i)(vast[_-]?api[_-]?key|vast[_-]?key)\s*[=:]\s*['"]?([a-zA-Z0-9-_]{20,})['"]?''' +tags = ["api", "vast"] +keywords = ["vast"] + +[[rules]] +id = "stripe-secret-key" +description = "Stripe Secret Key" +regex = '''sk_live_[0-9a-zA-Z]{24,}''' +tags = ["api", "stripe"] +keywords = ["sk_live"] + +[[rules]] +id = "stripe-restricted-key" +description = "Stripe Restricted Key" +regex = '''rk_live_[0-9a-zA-Z]{24,}''' +tags = ["api", "stripe"] +keywords = ["rk_live"] + +[[rules]] +id = "jwt-secret-hardcoded" +description = "Hardcoded JWT Secret" +regex = '''(?i)(jwt[_-]?secret|jwt[_-]?key)\s*[=:]\s*['"]([^'"]{32,})['"]''' +tags = ["secret", "jwt"] +keywords = ["jwt"] + +# Allowlist for false positives +[allowlist] +description = "Global allowlist" +paths = [ + '''\.env\.example$''', + '''\.env\.template$''', + '''docs/.*\.md$''', + '''SBOM\.md$''', + '''.*_test\.py$''', + '''.*_test\.go$''', + '''test_.*\.py$''', + '''.*\.bak$''', + '''node_modules/.*''', + '''venv/.*''', + '''\.git/.*''', +] + +# Specific commit allowlist (for already-rotated secrets) +commits = [] + +# Regex patterns to ignore +regexes = [ + '''REPLACE_WITH_REAL_.*''', + '''your-.*-key-change-in-production''', + '''breakpilot-dev-.*''', + '''DEVELOPMENT-ONLY-.*''', + '''placeholder.*''', + '''example.*key''', +] diff --git a/admin-v2/.pre-commit-config.yaml b/admin-v2/.pre-commit-config.yaml new file mode 100644 index 0000000..b5f5cd2 --- /dev/null +++ b/admin-v2/.pre-commit-config.yaml @@ -0,0 +1,152 @@ +# Pre-commit Hooks für BreakPilot +# Installation: pip install pre-commit && pre-commit install +# Aktivierung: pre-commit install + +repos: + # Go Hooks + - repo: local + hooks: + - id: go-test + name: Go Tests + entry: bash -c 'cd consent-service && go test -short ./...' + language: system + pass_filenames: false + files: \.go$ + stages: [commit] + + - id: go-fmt + name: Go Format + entry: bash -c 'cd consent-service && gofmt -l -w .' + language: system + pass_filenames: false + files: \.go$ + stages: [commit] + + - id: go-vet + name: Go Vet + entry: bash -c 'cd consent-service && go vet ./...' + language: system + pass_filenames: false + files: \.go$ + stages: [commit] + + - id: golangci-lint + name: Go Lint (golangci-lint) + entry: bash -c 'cd consent-service && golangci-lint run --timeout=5m' + language: system + pass_filenames: false + files: \.go$ + stages: [commit] + + # Python Hooks + - repo: local + hooks: + - id: pytest + name: Python Tests + entry: bash -c 'cd backend && pytest -x' + language: system + pass_filenames: false + files: \.py$ + stages: [commit] + + - id: black + name: Black Format + entry: black + language: python + types: [python] + args: [--line-length=120] + stages: [commit] + + - id: flake8 + name: Flake8 Lint + entry: flake8 + language: python + types: [python] + args: [--max-line-length=120, --exclude=venv] + stages: [commit] + + # General Hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + name: Trim Trailing Whitespace + - id: end-of-file-fixer + name: Fix End of Files + - id: check-yaml + name: Check YAML + args: [--allow-multiple-documents] + - id: check-json + name: Check JSON + - id: check-added-large-files + name: Check Large Files + args: [--maxkb=500] + - id: detect-private-key + name: Detect Private Keys + - id: mixed-line-ending + name: Fix Mixed Line Endings + + # Security Checks + - repo: https://github.com/Yelp/detect-secrets + rev: v1.4.0 + hooks: + - id: detect-secrets + name: Detect Secrets + args: ['--baseline', '.secrets.baseline'] + exclude: | + (?x)^( + .*\.lock| + .*\.sum| + package-lock\.json + )$ + + # ============================================= + # DevSecOps: Gitleaks (Secrets Detection) + # ============================================= + - repo: https://github.com/gitleaks/gitleaks + rev: v8.18.1 + hooks: + - id: gitleaks + name: Gitleaks (secrets detection) + entry: gitleaks protect --staged -v --config .gitleaks.toml + language: golang + pass_filenames: false + + # ============================================= + # DevSecOps: Semgrep (SAST) + # ============================================= + - repo: https://github.com/returntocorp/semgrep + rev: v1.52.0 + hooks: + - id: semgrep + name: Semgrep (SAST) + args: + - --config=auto + - --config=.semgrep.yml + - --severity=ERROR + types_or: [python, javascript, typescript, go] + stages: [commit] + + # ============================================= + # DevSecOps: Bandit (Python Security) + # ============================================= + - repo: https://github.com/PyCQA/bandit + rev: 1.7.6 + hooks: + - id: bandit + name: Bandit (Python security) + args: ["-r", "backend/", "-ll", "-x", "backend/tests/*"] + files: ^backend/.*\.py$ + stages: [commit] + + # Branch Protection + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: no-commit-to-branch + name: Protect main/develop branches + args: ['--branch', 'main', '--branch', 'develop'] + +# Configuration +default_stages: [commit] +fail_fast: false diff --git a/admin-v2/.semgrep.yml b/admin-v2/.semgrep.yml new file mode 100644 index 0000000..da09c10 --- /dev/null +++ b/admin-v2/.semgrep.yml @@ -0,0 +1,147 @@ +# Semgrep Configuration for BreakPilot +# https://semgrep.dev/ +# +# Run locally: semgrep scan --config auto +# Run with this config: semgrep scan --config .semgrep.yml + +rules: + # ============================================= + # Python/FastAPI Security Rules + # ============================================= + + - id: hardcoded-secret-in-string + patterns: + - pattern-either: + - pattern: | + $VAR = "...$SECRET..." + - pattern: | + $VAR = '...$SECRET...' + message: "Potential hardcoded secret detected. Use environment variables or Vault." + languages: [python] + severity: WARNING + metadata: + category: security + cwe: "CWE-798: Use of Hard-coded Credentials" + + - id: sql-injection-fastapi + patterns: + - pattern-either: + - pattern: | + $CURSOR.execute(f"...{$USER_INPUT}...") + - pattern: | + $CURSOR.execute("..." + $USER_INPUT + "...") + - pattern: | + $CURSOR.execute("..." % $USER_INPUT) + message: "Potential SQL injection. Use parameterized queries." + languages: [python] + severity: ERROR + metadata: + category: security + cwe: "CWE-89: SQL Injection" + owasp: "A03:2021 - Injection" + + - id: command-injection + patterns: + - pattern-either: + - pattern: os.system($USER_INPUT) + - pattern: subprocess.call($USER_INPUT, shell=True) + - pattern: subprocess.run($USER_INPUT, shell=True) + - pattern: subprocess.Popen($USER_INPUT, shell=True) + message: "Potential command injection. Avoid shell=True with user input." + languages: [python] + severity: ERROR + metadata: + category: security + cwe: "CWE-78: OS Command Injection" + owasp: "A03:2021 - Injection" + + - id: insecure-jwt-algorithm + patterns: + - pattern: jwt.decode(..., algorithms=["none"], ...) + - pattern: jwt.decode(..., algorithms=["HS256"], verify=False, ...) + message: "Insecure JWT algorithm or verification disabled." + languages: [python] + severity: ERROR + metadata: + category: security + cwe: "CWE-347: Improper Verification of Cryptographic Signature" + + - id: path-traversal + patterns: + - pattern: open(... + $USER_INPUT + ...) + - pattern: open(f"...{$USER_INPUT}...") + - pattern: Path(...) / $USER_INPUT + message: "Potential path traversal. Validate and sanitize file paths." + languages: [python] + severity: WARNING + metadata: + category: security + cwe: "CWE-22: Path Traversal" + + - id: insecure-pickle + patterns: + - pattern: pickle.loads($DATA) + - pattern: pickle.load($FILE) + message: "Pickle deserialization is insecure. Use JSON or other safe formats." + languages: [python] + severity: WARNING + metadata: + category: security + cwe: "CWE-502: Deserialization of Untrusted Data" + + # ============================================= + # Go Security Rules + # ============================================= + + - id: go-sql-injection + patterns: + - pattern: | + $DB.Query(fmt.Sprintf("...", $USER_INPUT)) + - pattern: | + $DB.Exec(fmt.Sprintf("...", $USER_INPUT)) + message: "Potential SQL injection in Go. Use parameterized queries." + languages: [go] + severity: ERROR + metadata: + category: security + cwe: "CWE-89: SQL Injection" + + - id: go-hardcoded-credentials + patterns: + - pattern: | + $VAR := "..." + - metavariable-regex: + metavariable: $VAR + regex: (password|secret|apiKey|api_key|token) + message: "Potential hardcoded credential. Use environment variables." + languages: [go] + severity: WARNING + metadata: + category: security + cwe: "CWE-798: Use of Hard-coded Credentials" + + # ============================================= + # JavaScript/TypeScript Security Rules + # ============================================= + + - id: js-xss-innerhtml + patterns: + - pattern: $EL.innerHTML = $USER_INPUT + message: "Potential XSS via innerHTML. Use textContent or sanitize input." + languages: [javascript, typescript] + severity: WARNING + metadata: + category: security + cwe: "CWE-79: Cross-site Scripting" + owasp: "A03:2021 - Injection" + + - id: js-eval + patterns: + - pattern: eval($CODE) + - pattern: new Function($CODE) + message: "Avoid eval() and new Function() with dynamic input." + languages: [javascript, typescript] + severity: ERROR + metadata: + category: security + cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code" diff --git a/admin-v2/.trivy.yaml b/admin-v2/.trivy.yaml new file mode 100644 index 0000000..7557037 --- /dev/null +++ b/admin-v2/.trivy.yaml @@ -0,0 +1,66 @@ +# Trivy Configuration for BreakPilot +# https://trivy.dev/ +# +# Run: trivy image breakpilot-pwa-backend:latest +# Run filesystem: trivy fs . +# Run config: trivy config . + +# Scan settings +scan: + # Security checks to perform + security-checks: + - vuln # Vulnerabilities + - config # Misconfigurations + - secret # Secrets in files + +# Vulnerability settings +vulnerability: + # Vulnerability types to scan for + type: + - os # OS packages + - library # Application dependencies + + # Ignore unfixed vulnerabilities + ignore-unfixed: false + +# Severity settings +severity: + - CRITICAL + - HIGH + - MEDIUM + # - LOW # Uncomment to include low severity + +# Output format +format: table + +# Exit code on findings +exit-code: 1 + +# Timeout +timeout: 10m + +# Cache directory +cache-dir: /tmp/trivy-cache + +# Skip files/directories +skip-dirs: + - node_modules + - venv + - .venv + - __pycache__ + - .git + - .idea + - .vscode + +skip-files: + - "*.md" + - "*.txt" + - "*.log" + +# Ignore specific vulnerabilities (add after review) +ignorefile: .trivyignore + +# SBOM generation +sbom: + format: cyclonedx + output: sbom.json diff --git a/admin-v2/.trivyignore b/admin-v2/.trivyignore new file mode 100644 index 0000000..17b0d74 --- /dev/null +++ b/admin-v2/.trivyignore @@ -0,0 +1,9 @@ +# Trivy Ignore File for BreakPilot +# Add vulnerability IDs to ignore after security review +# Format: CVE-XXXX-XXXXX or GHSA-xxxx-xxxx-xxxx + +# Example (remove after adding real ignores): +# CVE-2021-12345 # Reason: Not exploitable in our context + +# Reviewed and accepted risks: +# (Add vulnerabilities here after security team review) diff --git a/admin-v2/.woodpecker/auto-fix.yml b/admin-v2/.woodpecker/auto-fix.yml new file mode 100644 index 0000000..c014928 --- /dev/null +++ b/admin-v2/.woodpecker/auto-fix.yml @@ -0,0 +1,132 @@ +# Woodpecker CI Auto-Fix Pipeline +# Automatische Reparatur fehlgeschlagener Tests +# +# Laeuft taeglich um 2:00 Uhr nachts +# Analysiert offene Backlog-Items und versucht automatische Fixes + +when: + - event: cron + cron: "0 2 * * *" # Taeglich um 2:00 Uhr + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +steps: + # ======================================== + # 1. Fetch Failed Tests from Backlog + # ======================================== + + fetch-backlog: + image: curlimages/curl:latest + commands: + - | + curl -s "http://backend:8000/api/tests/backlog?status=open&priority=critical" \ + -o backlog-critical.json + curl -s "http://backend:8000/api/tests/backlog?status=open&priority=high" \ + -o backlog-high.json + - echo "=== Kritische Tests ===" + - cat backlog-critical.json | head -50 + - echo "=== Hohe Prioritaet ===" + - cat backlog-high.json | head -50 + + # ======================================== + # 2. Analyze and Classify Errors + # ======================================== + + analyze-errors: + image: python:3.12-slim + commands: + - pip install --quiet jq-py + - | + python3 << 'EOF' + import json + import os + + def classify_error(error_type, error_msg): + """Klassifiziert Fehler nach Auto-Fix-Potential""" + auto_fixable = { + 'nil_pointer': 'high', + 'import_error': 'high', + 'undefined_variable': 'medium', + 'type_error': 'medium', + 'assertion': 'low', + 'timeout': 'low', + 'logic_error': 'manual' + } + return auto_fixable.get(error_type, 'manual') + + # Lade Backlog + try: + with open('backlog-critical.json') as f: + critical = json.load(f) + with open('backlog-high.json') as f: + high = json.load(f) + except: + print("Keine Backlog-Daten gefunden") + exit(0) + + all_items = critical.get('items', []) + high.get('items', []) + + auto_fix_candidates = [] + for item in all_items: + fix_potential = classify_error( + item.get('error_type', 'unknown'), + item.get('error_message', '') + ) + if fix_potential in ['high', 'medium']: + auto_fix_candidates.append({ + 'id': item.get('id'), + 'test_name': item.get('test_name'), + 'error_type': item.get('error_type'), + 'fix_potential': fix_potential + }) + + print(f"Auto-Fix Kandidaten: {len(auto_fix_candidates)}") + with open('auto-fix-candidates.json', 'w') as f: + json.dump(auto_fix_candidates, f, indent=2) + EOF + depends_on: + - fetch-backlog + + # ======================================== + # 3. Generate Fix Suggestions (Placeholder) + # ======================================== + + generate-fixes: + image: python:3.12-slim + commands: + - | + echo "Auto-Fix Generation ist in Phase 4 geplant" + echo "Aktuell werden nur Vorschlaege generiert" + + # Hier wuerde Claude API oder anderer LLM aufgerufen werden + # python3 scripts/auto-fix-agent.py auto-fix-candidates.json + + echo "Fix-Vorschlaege wuerden hier generiert werden" + depends_on: + - analyze-errors + + # ======================================== + # 4. Report Results + # ======================================== + + report-results: + image: curlimages/curl:latest + commands: + - | + curl -X POST "http://backend:8000/api/tests/auto-fix/report" \ + -H "Content-Type: application/json" \ + -d "{ + \"run_date\": \"$(date -Iseconds)\", + \"candidates_found\": $(cat auto-fix-candidates.json | wc -l), + \"fixes_attempted\": 0, + \"fixes_successful\": 0, + \"status\": \"analysis_only\" + }" || true + when: + status: [success, failure] diff --git a/admin-v2/.woodpecker/build-ci-image.yml b/admin-v2/.woodpecker/build-ci-image.yml new file mode 100644 index 0000000..09c3172 --- /dev/null +++ b/admin-v2/.woodpecker/build-ci-image.yml @@ -0,0 +1,37 @@ +# One-time pipeline to build the custom Python CI image +# Trigger manually, then delete this file +# +# This builds the breakpilot/python-ci:3.12 image on the CI runner + +when: + - event: manual + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +steps: + build-python-ci-image: + image: docker:27-cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + commands: + - | + echo "=== Building breakpilot/python-ci:3.12 ===" + + docker build \ + -t breakpilot/python-ci:3.12 \ + -t breakpilot/python-ci:latest \ + -f .docker/python-ci.Dockerfile \ + . + + echo "" + echo "=== Build complete ===" + docker images | grep breakpilot/python-ci + + echo "" + echo "Image is now available for CI pipelines!" diff --git a/admin-v2/.woodpecker/integration.yml b/admin-v2/.woodpecker/integration.yml new file mode 100644 index 0000000..703989b --- /dev/null +++ b/admin-v2/.woodpecker/integration.yml @@ -0,0 +1,161 @@ +# Integration Tests Pipeline +# Separate Datei weil Services auf Pipeline-Ebene definiert werden muessen +# +# Diese Pipeline laeuft parallel zur main.yml und testet: +# - Database Connectivity (PostgreSQL) +# - Cache Connectivity (Valkey/Redis) +# - Service-to-Service Kommunikation +# +# Dokumentation: docs/testing/integration-test-environment.md + +when: + - event: [push, pull_request] + branch: [main, develop] + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +# Services auf Pipeline-Ebene (NICHT Step-Ebene!) +# Diese Services sind fuer ALLE Steps verfuegbar +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot_test + POSTGRES_DB: breakpilot_test + + valkey: + image: valkey/valkey:8-alpine + +steps: + wait-for-services: + image: postgres:16-alpine + commands: + - | + echo "=== Waiting for PostgreSQL ===" + for i in $(seq 1 30); do + if pg_isready -h postgres -U breakpilot; then + echo "PostgreSQL ready after $i attempts!" + break + fi + echo "Attempt $i/30: PostgreSQL not ready, waiting..." + sleep 2 + done + # Final check + if ! pg_isready -h postgres -U breakpilot; then + echo "ERROR: PostgreSQL not ready after 30 attempts" + exit 1 + fi + - | + echo "=== Waiting for Valkey ===" + # Install redis-cli in postgres alpine image + apk add --no-cache redis > /dev/null 2>&1 || true + for i in $(seq 1 30); do + if redis-cli -h valkey ping 2>/dev/null | grep -q PONG; then + echo "Valkey ready after $i attempts!" + break + fi + echo "Attempt $i/30: Valkey not ready, waiting..." + sleep 2 + done + # Final check + if ! redis-cli -h valkey ping 2>/dev/null | grep -q PONG; then + echo "ERROR: Valkey not ready after 30 attempts" + exit 1 + fi + - echo "=== All services ready ===" + + integration-tests: + image: breakpilot/python-ci:3.12 + environment: + CI: "true" + DATABASE_URL: postgresql://breakpilot:breakpilot_test@postgres:5432/breakpilot_test + VALKEY_URL: redis://valkey:6379 + REDIS_URL: redis://valkey:6379 + SKIP_INTEGRATION_TESTS: "false" + SKIP_DB_TESTS: "false" + SKIP_WEASYPRINT_TESTS: "false" + # Test-spezifische Umgebungsvariablen + ENVIRONMENT: "testing" + JWT_SECRET: "test-secret-key-for-integration-tests" + TEACHER_REQUIRE_AUTH: "false" + GAME_USE_DATABASE: "false" + commands: + - | + set -uo pipefail + mkdir -p .ci-results + cd backend + + # PYTHONPATH setzen damit lokale Module gefunden werden + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + + echo "=== Installing dependencies ===" + pip install --quiet --no-cache-dir -r requirements.txt + + echo "=== Running Integration Tests ===" + set +e + python -m pytest tests/test_integration/ -v \ + --tb=short \ + --json-report \ + --json-report-file=../.ci-results/test-integration.json + TEST_EXIT=$? + set -e + + # Ergebnisse auswerten + if [ -f ../.ci-results/test-integration.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + echo "WARNUNG: Keine JSON-Ergebnisse gefunden" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"integration-tests\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-integration.json + cat ../.ci-results/results-integration.json + + echo "" + echo "=== Integration Test Summary ===" + echo "Total: $TOTAL | Passed: $PASSED | Failed: $FAILED | Skipped: $SKIPPED" + + if [ "$TEST_EXIT" -ne "0" ]; then + echo "Integration tests failed with exit code $TEST_EXIT" + exit 1 + fi + depends_on: + - wait-for-services + + report-integration-results: + image: curlimages/curl:8.10.1 + commands: + - | + set -uo pipefail + echo "=== Sende Integration Test-Ergebnisse an Dashboard ===" + + if [ -f .ci-results/results-integration.json ]; then + echo "Sending integration test results..." + curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \ + -H "Content-Type: application/json" \ + -d "{ + \"pipeline_id\": \"${CI_PIPELINE_NUMBER}\", + \"commit\": \"${CI_COMMIT_SHA}\", + \"branch\": \"${CI_COMMIT_BRANCH}\", + \"status\": \"${CI_PIPELINE_STATUS:-unknown}\", + \"test_results\": $(cat .ci-results/results-integration.json) + }" || echo "WARNUNG: Konnte Ergebnisse nicht an Dashboard senden" + else + echo "Keine Integration-Ergebnisse zum Senden gefunden" + fi + + echo "=== Integration Test-Ergebnisse gesendet ===" + when: + status: [success, failure] + depends_on: + - integration-tests diff --git a/admin-v2/.woodpecker/main.yml b/admin-v2/.woodpecker/main.yml new file mode 100644 index 0000000..d358bb0 --- /dev/null +++ b/admin-v2/.woodpecker/main.yml @@ -0,0 +1,669 @@ +# Woodpecker CI Main Pipeline +# BreakPilot PWA - CI/CD Pipeline +# +# Plattform: ARM64 (Apple Silicon Mac Mini) +# +# Strategie: +# - Tests laufen bei JEDEM Push/PR +# - Test-Ergebnisse werden an Dashboard gesendet +# - Builds/Scans laufen nur bei Tags oder manuell +# - Deployment nur manuell (Sicherheit) + +when: + - event: [push, pull_request, manual, tag] + branch: [main, develop] + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +variables: + - &golang_image golang:1.23-alpine + - &python_image python:3.12-slim + - &python_ci_image breakpilot/python-ci:3.12 # Custom image with WeasyPrint + - &nodejs_image node:20-alpine + - &docker_image docker:27-cli + +steps: + # ======================================== + # STAGE 1: Lint (nur bei PRs) + # ======================================== + + go-lint: + image: golangci/golangci-lint:v1.55-alpine + commands: + - cd consent-service && golangci-lint run --timeout 5m ./... + - cd ../billing-service && golangci-lint run --timeout 5m ./... + - cd ../school-service && golangci-lint run --timeout 5m ./... + when: + event: pull_request + + python-lint: + image: *python_image + commands: + - pip install --quiet ruff black + - ruff check backend/ --output-format=github || true + - black --check backend/ || true + when: + event: pull_request + + # ======================================== + # STAGE 2: Unit Tests mit JSON-Ausgabe + # Ergebnisse werden im Workspace gespeichert (.ci-results/) + # ======================================== + + test-go-consent: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "consent-service" ]; then + echo '{"service":"consent-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-consent.json + echo "WARNUNG: consent-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd consent-service + set +e + go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-consent.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-consent.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"consent-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-consent.json + cat ../.ci-results/results-consent.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-go-billing: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "billing-service" ]; then + echo '{"service":"billing-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-billing.json + echo "WARNUNG: billing-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd billing-service + set +e + go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-billing.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-billing.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"billing-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-billing.json + cat ../.ci-results/results-billing.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-go-school: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "school-service" ]; then + echo '{"service":"school-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-school.json + echo "WARNUNG: school-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd school-service + set +e + go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-school.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-school.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"school-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-school.json + cat ../.ci-results/results-school.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-go-edu-search: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "edu-search-service" ]; then + echo '{"service":"edu-search-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-edu-search.json + echo "WARNUNG: edu-search-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd edu-search-service + set +e + go test -v -json -coverprofile=coverage.out ./internal/... 2>&1 | tee ../.ci-results/test-edu-search.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-edu-search.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"edu-search-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-edu-search.json + cat ../.ci-results/results-edu-search.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-go-ai-compliance: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "ai-compliance-sdk" ]; then + echo '{"service":"ai-compliance-sdk","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-ai-compliance.json + echo "WARNUNG: ai-compliance-sdk Verzeichnis nicht gefunden" + exit 0 + fi + + cd ai-compliance-sdk + set +e + go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-ai-compliance.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-ai-compliance.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"ai-compliance-sdk\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-ai-compliance.json + cat ../.ci-results/results-ai-compliance.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-python-backend: + image: *python_ci_image + environment: + CI: "true" + DATABASE_URL: "postgresql://test:test@localhost:5432/test_db" + SKIP_DB_TESTS: "true" + SKIP_WEASYPRINT_TESTS: "false" + SKIP_INTEGRATION_TESTS: "true" + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "backend" ]; then + echo '{"service":"backend","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-backend.json + echo "WARNUNG: backend Verzeichnis nicht gefunden" + exit 0 + fi + + cd backend + # Set PYTHONPATH to current directory (backend) so local packages like classroom_engine, alerts_agent are found + # IMPORTANT: Use absolute path and export before pip install to ensure modules are available + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + + # Test tools are pre-installed in breakpilot/python-ci image + # Only install project-specific dependencies + pip install --quiet --no-cache-dir -r requirements.txt + + # NOTE: PostgreSQL service removed - tests that require DB are skipped via SKIP_DB_TESTS=true + # For full integration tests, use: docker compose -f docker-compose.test.yml up -d + + set +e + # Use python -m pytest to ensure PYTHONPATH is properly applied before pytest starts + python -m pytest tests/ -v --tb=short --cov=. --cov-report=term-missing --json-report --json-report-file=../.ci-results/test-backend.json + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-backend.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"backend\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-backend.json + cat ../.ci-results/results-backend.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + test-python-voice: + image: *python_image + environment: + CI: "true" + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "voice-service" ]; then + echo '{"service":"voice-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-voice.json + echo "WARNUNG: voice-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd voice-service + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + pip install --quiet --no-cache-dir -r requirements.txt + pip install --quiet --no-cache-dir pytest-json-report + + set +e + python -m pytest tests/ -v --tb=short --json-report --json-report-file=../.ci-results/test-voice.json + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-voice.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"voice-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-voice.json + cat ../.ci-results/results-voice.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + test-bqas-golden: + image: *python_image + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "voice-service/tests/bqas" ]; then + echo '{"service":"bqas-golden","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-golden.json + echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden" + exit 0 + fi + + cd voice-service + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + pip install --quiet --no-cache-dir -r requirements.txt + pip install --quiet --no-cache-dir pytest-json-report pytest-asyncio + + set +e + python -m pytest tests/bqas/test_golden.py tests/bqas/test_regression.py tests/bqas/test_synthetic.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-golden.json + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-bqas-golden.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"bqas-golden\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-golden.json + cat ../.ci-results/results-bqas-golden.json + + # BQAS tests may skip if Ollama not available - don't fail pipeline + if [ "$FAILED" -gt "0" ]; then exit 1; fi + + test-bqas-rag: + image: *python_image + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "voice-service/tests/bqas" ]; then + echo '{"service":"bqas-rag","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-rag.json + echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden" + exit 0 + fi + + cd voice-service + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + pip install --quiet --no-cache-dir -r requirements.txt + pip install --quiet --no-cache-dir pytest-json-report pytest-asyncio + + set +e + python -m pytest tests/bqas/test_rag.py tests/bqas/test_notifier.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-rag.json + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-bqas-rag.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"bqas-rag\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-rag.json + cat ../.ci-results/results-bqas-rag.json + + # BQAS tests may skip if Ollama not available - don't fail pipeline + if [ "$FAILED" -gt "0" ]; then exit 1; fi + + test-python-klausur: + image: *python_image + environment: + CI: "true" + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "klausur-service/backend" ]; then + echo '{"service":"klausur-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-klausur.json + echo "WARNUNG: klausur-service/backend Verzeichnis nicht gefunden" + exit 0 + fi + + cd klausur-service/backend + # Set PYTHONPATH to current directory so local modules like hyde, hybrid_search, etc. are found + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + + pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || pip install --quiet --no-cache-dir fastapi uvicorn pytest pytest-asyncio pytest-json-report + pip install --quiet --no-cache-dir pytest-json-report + + set +e + python -m pytest tests/ -v --tb=short --json-report --json-report-file=../../.ci-results/test-klausur.json + TEST_EXIT=$? + set -e + + if [ -f ../../.ci-results/test-klausur.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"klausur-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../../.ci-results/results-klausur.json + cat ../../.ci-results/results-klausur.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + test-nodejs-h5p: + image: *nodejs_image + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "h5p-service" ]; then + echo '{"service":"h5p-service","framework":"jest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-h5p.json + echo "WARNUNG: h5p-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd h5p-service + npm ci --silent 2>/dev/null || npm install --silent + + set +e + npm run test:ci -- --json --outputFile=../.ci-results/test-h5p.json 2>&1 + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-h5p.json ]; then + TOTAL=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numTotalTests || 0)" 2>/dev/null || echo "0") + PASSED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numPassedTests || 0)" 2>/dev/null || echo "0") + FAILED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numFailedTests || 0)" 2>/dev/null || echo "0") + SKIPPED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numPendingTests || 0)" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + [ -z "$TOTAL" ] && TOTAL=0 + [ -z "$PASSED" ] && PASSED=0 + [ -z "$FAILED" ] && FAILED=0 + [ -z "$SKIPPED" ] && SKIPPED=0 + + echo "{\"service\":\"h5p-service\",\"framework\":\"jest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-h5p.json + cat ../.ci-results/results-h5p.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + # ======================================== + # STAGE 2.5: Integration Tests + # ======================================== + # Integration Tests laufen in separater Pipeline: + # .woodpecker/integration.yml + # (benötigt Pipeline-Level Services für PostgreSQL und Valkey) + + # ======================================== + # STAGE 3: Test-Ergebnisse an Dashboard senden + # ======================================== + + report-test-results: + image: curlimages/curl:8.10.1 + commands: + - | + set -uo pipefail + echo "=== Sende Test-Ergebnisse an Dashboard ===" + echo "Pipeline Status: ${CI_PIPELINE_STATUS:-unknown}" + ls -la .ci-results/ || echo "Verzeichnis nicht gefunden" + + PIPELINE_STATUS="${CI_PIPELINE_STATUS:-unknown}" + + for f in .ci-results/results-*.json; do + [ -f "$f" ] || continue + echo "Sending: $f" + curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \ + -H "Content-Type: application/json" \ + -d "{ + \"pipeline_id\": \"${CI_PIPELINE_NUMBER}\", + \"commit\": \"${CI_COMMIT_SHA}\", + \"branch\": \"${CI_COMMIT_BRANCH}\", + \"status\": \"${PIPELINE_STATUS}\", + \"test_results\": $(cat "$f") + }" || echo "WARNUNG: Konnte $f nicht senden" + done + + echo "=== Test-Ergebnisse gesendet ===" + when: + status: [success, failure] + depends_on: + - test-go-consent + - test-go-billing + - test-go-school + - test-go-edu-search + - test-go-ai-compliance + - test-python-backend + - test-python-voice + - test-bqas-golden + - test-bqas-rag + - test-python-klausur + - test-nodejs-h5p + + # ======================================== + # STAGE 4: Build & Security (nur Tags/manuell) + # ======================================== + + build-consent-service: + image: *docker_image + commands: + - docker build -t breakpilot/consent-service:${CI_COMMIT_SHA:0:8} ./consent-service + - docker tag breakpilot/consent-service:${CI_COMMIT_SHA:0:8} breakpilot/consent-service:latest + - echo "Built breakpilot/consent-service:${CI_COMMIT_SHA:0:8}" + when: + - event: tag + - event: manual + + build-backend: + image: *docker_image + commands: + - docker build -t breakpilot/backend:${CI_COMMIT_SHA:0:8} ./backend + - docker tag breakpilot/backend:${CI_COMMIT_SHA:0:8} breakpilot/backend:latest + - echo "Built breakpilot/backend:${CI_COMMIT_SHA:0:8}" + when: + - event: tag + - event: manual + + build-voice-service: + image: *docker_image + commands: + - | + if [ -d ./voice-service ]; then + docker build -t breakpilot/voice-service:${CI_COMMIT_SHA:0:8} ./voice-service + docker tag breakpilot/voice-service:${CI_COMMIT_SHA:0:8} breakpilot/voice-service:latest + echo "Built breakpilot/voice-service:${CI_COMMIT_SHA:0:8}" + else + echo "voice-service Verzeichnis nicht gefunden - ueberspringe" + fi + when: + - event: tag + - event: manual + + generate-sbom: + image: *golang_image + commands: + - | + echo "Installing syft for ARM64..." + wget -qO- https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin + syft dir:./consent-service -o cyclonedx-json > sbom-consent.json + syft dir:./backend -o cyclonedx-json > sbom-backend.json + if [ -d ./voice-service ]; then + syft dir:./voice-service -o cyclonedx-json > sbom-voice.json + fi + echo "SBOMs generated successfully" + when: + - event: tag + - event: manual + + vulnerability-scan: + image: *golang_image + commands: + - | + echo "Installing grype for ARM64..." + wget -qO- https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin + grype sbom:sbom-consent.json -o table --fail-on critical || true + grype sbom:sbom-backend.json -o table --fail-on critical || true + if [ -f sbom-voice.json ]; then + grype sbom:sbom-voice.json -o table --fail-on critical || true + fi + when: + - event: tag + - event: manual + depends_on: + - generate-sbom + + # ======================================== + # STAGE 5: Deploy (nur manuell) + # ======================================== + + deploy-production: + image: *docker_image + commands: + - echo "Deploying to production..." + - docker compose -f docker-compose.yml pull || true + - docker compose -f docker-compose.yml up -d --remove-orphans || true + when: + event: manual + depends_on: + - build-consent-service + - build-backend diff --git a/admin-v2/.woodpecker/security.yml b/admin-v2/.woodpecker/security.yml new file mode 100644 index 0000000..8f7892d --- /dev/null +++ b/admin-v2/.woodpecker/security.yml @@ -0,0 +1,314 @@ +# Woodpecker CI Security Pipeline +# Dedizierte Security-Scans fuer DevSecOps +# +# Laeuft taeglich via Cron und bei jedem PR + +when: + - event: cron + cron: "0 3 * * *" # Taeglich um 3:00 Uhr + - event: pull_request + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +steps: + # ======================================== + # Static Analysis + # ======================================== + + semgrep-scan: + image: returntocorp/semgrep:latest + commands: + - semgrep scan --config auto --json -o semgrep-results.json . || true + - | + if [ -f semgrep-results.json ]; then + echo "=== Semgrep Findings ===" + cat semgrep-results.json | head -100 + fi + when: + event: [pull_request, cron] + + bandit-python: + image: python:3.12-slim + commands: + - pip install --quiet bandit + - bandit -r backend/ -f json -o bandit-results.json || true + - | + if [ -f bandit-results.json ]; then + echo "=== Bandit Findings ===" + cat bandit-results.json | head -50 + fi + when: + event: [pull_request, cron] + + gosec-go: + image: securego/gosec:latest + commands: + - gosec -fmt json -out gosec-consent.json ./consent-service/... || true + - gosec -fmt json -out gosec-billing.json ./billing-service/... || true + - echo "Go Security Scan abgeschlossen" + when: + event: [pull_request, cron] + + # ======================================== + # Secrets Detection + # ======================================== + + gitleaks-scan: + image: zricethezav/gitleaks:latest + commands: + - gitleaks detect --source . --report-format json --report-path gitleaks-report.json || true + - | + if [ -s gitleaks-report.json ]; then + echo "=== WARNUNG: Potentielle Secrets gefunden ===" + cat gitleaks-report.json + else + echo "Keine Secrets gefunden" + fi + + trufflehog-scan: + image: trufflesecurity/trufflehog:latest + commands: + - trufflehog filesystem . --json > trufflehog-results.json 2>&1 || true + - echo "TruffleHog Scan abgeschlossen" + + # ======================================== + # Dependency Vulnerabilities + # ======================================== + + npm-audit: + image: node:20-alpine + commands: + - cd website && npm audit --json > ../npm-audit-website.json || true + - cd ../studio-v2 && npm audit --json > ../npm-audit-studio.json || true + - cd ../admin-v2 && npm audit --json > ../npm-audit-admin.json || true + - echo "NPM Audit abgeschlossen" + when: + event: [pull_request, cron] + + pip-audit: + image: python:3.12-slim + commands: + - pip install --quiet pip-audit + - pip-audit -r backend/requirements.txt --format json -o pip-audit-backend.json || true + - pip-audit -r voice-service/requirements.txt --format json -o pip-audit-voice.json || true + - echo "Pip Audit abgeschlossen" + when: + event: [pull_request, cron] + + go-vulncheck: + image: golang:1.21-alpine + commands: + - go install golang.org/x/vuln/cmd/govulncheck@latest + - cd consent-service && govulncheck ./... || true + - cd ../billing-service && govulncheck ./... || true + - echo "Go Vulncheck abgeschlossen" + when: + event: [pull_request, cron] + + # ======================================== + # Container Security + # ======================================== + + trivy-filesystem: + image: aquasec/trivy:latest + commands: + - trivy fs --severity HIGH,CRITICAL --format json -o trivy-fs.json . || true + - echo "Trivy Filesystem Scan abgeschlossen" + when: + event: cron + + # ======================================== + # SBOM Generation (taeglich) + # ======================================== + + daily-sbom: + image: anchore/syft:latest + commands: + - mkdir -p sbom-reports + - syft dir:. -o cyclonedx-json > sbom-reports/sbom-full-$(date +%Y%m%d).json + - echo "SBOM generiert" + when: + event: cron + + # ======================================== + # AUTO-FIX: Dependency Vulnerabilities + # Laeuft nur bei Cron (nightly), nicht bei PRs + # ======================================== + + auto-fix-npm: + image: node:20-alpine + commands: + - apk add --no-cache git + - | + echo "=== Auto-Fix: NPM Dependencies ===" + FIXES_APPLIED=0 + + for dir in website studio-v2 admin-v2 h5p-service; do + if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then + echo "Pruefe $dir..." + cd $dir + + # Speichere Hash vor Fix + BEFORE=$(md5sum package-lock.json 2>/dev/null || echo "none") + + # npm audit fix (ohne --force fuer sichere Updates) + npm audit fix --package-lock-only 2>/dev/null || true + + # Pruefe ob Aenderungen + AFTER=$(md5sum package-lock.json 2>/dev/null || echo "none") + if [ "$BEFORE" != "$AFTER" ]; then + echo " -> Fixes angewendet in $dir" + FIXES_APPLIED=$((FIXES_APPLIED + 1)) + fi + + cd .. + fi + done + + echo "NPM Auto-Fix abgeschlossen: $FIXES_APPLIED Projekte aktualisiert" + echo "NPM_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env + when: + event: cron + + auto-fix-python: + image: python:3.12-slim + commands: + - apt-get update && apt-get install -y git + - pip install --quiet pip-audit + - | + echo "=== Auto-Fix: Python Dependencies ===" + FIXES_APPLIED=0 + + for reqfile in backend/requirements.txt voice-service/requirements.txt klausur-service/backend/requirements.txt; do + if [ -f "$reqfile" ]; then + echo "Pruefe $reqfile..." + DIR=$(dirname $reqfile) + + # pip-audit mit --fix (aktualisiert requirements.txt) + pip-audit -r $reqfile --fix 2>/dev/null || true + + # Pruefe ob requirements.txt geaendert wurde + if git diff --quiet $reqfile 2>/dev/null; then + echo " -> Keine Aenderungen in $reqfile" + else + echo " -> Fixes angewendet in $reqfile" + FIXES_APPLIED=$((FIXES_APPLIED + 1)) + fi + fi + done + + echo "Python Auto-Fix abgeschlossen: $FIXES_APPLIED Dateien aktualisiert" + echo "PYTHON_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env + when: + event: cron + + auto-fix-go: + image: golang:1.21-alpine + commands: + - apk add --no-cache git + - | + echo "=== Auto-Fix: Go Dependencies ===" + FIXES_APPLIED=0 + + for dir in consent-service billing-service school-service edu-search ai-compliance-sdk; do + if [ -d "$dir" ] && [ -f "$dir/go.mod" ]; then + echo "Pruefe $dir..." + cd $dir + + # Go mod tidy und update + go get -u ./... 2>/dev/null || true + go mod tidy 2>/dev/null || true + + # Pruefe ob go.mod/go.sum geaendert wurden + if git diff --quiet go.mod go.sum 2>/dev/null; then + echo " -> Keine Aenderungen in $dir" + else + echo " -> Updates angewendet in $dir" + FIXES_APPLIED=$((FIXES_APPLIED + 1)) + fi + + cd .. + fi + done + + echo "Go Auto-Fix abgeschlossen: $FIXES_APPLIED Module aktualisiert" + echo "GO_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env + when: + event: cron + + # ======================================== + # Commit & Push Auto-Fixes + # ======================================== + + commit-security-fixes: + image: alpine/git:latest + commands: + - | + echo "=== Commit Security Fixes ===" + + # Git konfigurieren + git config --global user.email "security-bot@breakpilot.de" + git config --global user.name "Security Bot" + git config --global --add safe.directory /woodpecker/src + + # Pruefe ob es Aenderungen gibt + if git diff --quiet && git diff --cached --quiet; then + echo "Keine Security-Fixes zum Committen" + exit 0 + fi + + # Zeige was geaendert wurde + echo "Geaenderte Dateien:" + git status --short + + # Stage alle relevanten Dateien + git add -A \ + */package-lock.json \ + */requirements.txt \ + */go.mod \ + */go.sum \ + 2>/dev/null || true + + # Commit erstellen + TIMESTAMP=$(date +%Y-%m-%d) + git commit -m "fix(security): auto-fix vulnerable dependencies [$TIMESTAMP] + + Automatische Sicherheitsupdates durch CI/CD Pipeline: + - npm audit fix fuer Node.js Projekte + - pip-audit --fix fuer Python Projekte + - go get -u fuer Go Module + + Co-Authored-By: Security Bot " || echo "Nichts zu committen" + + # Push zum Repository + git push origin HEAD:main || echo "Push fehlgeschlagen - manueller Review erforderlich" + + echo "Security-Fixes committed und gepusht" + when: + event: cron + status: success + + # ======================================== + # Report to Dashboard + # ======================================== + + update-security-dashboard: + image: curlimages/curl:latest + commands: + - | + curl -X POST "http://backend:8000/api/security/scan-results" \ + -H "Content-Type: application/json" \ + -d "{ + \"scan_type\": \"daily\", + \"timestamp\": \"$(date -Iseconds)\", + \"tools\": [\"semgrep\", \"bandit\", \"gosec\", \"gitleaks\", \"trivy\"] + }" || true + when: + status: [success, failure] + event: cron diff --git a/admin-v2/AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md b/admin-v2/AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ee4c37a --- /dev/null +++ b/admin-v2/AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md @@ -0,0 +1,2029 @@ +# AI Compliance SDK - Vollständige Implementierungsspezifikation + +> **Version:** 1.0.0 +> **Erstellt:** 2026-02-03 +> **Status:** In Planung +> **Projekt:** breakpilot-pwa + +--- + +## Inhaltsverzeichnis + +1. [Executive Summary](#1-executive-summary) +2. [SDK-Architektur Übersicht](#2-sdk-architektur-übersicht) +3. [Logische Navigationsstruktur](#3-logische-navigationsstruktur) +4. [Datenfluss & Abhängigkeiten](#4-datenfluss--abhängigkeiten) +5. [Checkpoint-System](#5-checkpoint-system) +6. [Unified Command Bar](#6-unified-command-bar) +7. [State Management](#7-state-management) +8. [API-Struktur](#8-api-struktur) +9. [UI-Komponenten](#9-ui-komponenten) +10. [TypeScript Interfaces](#10-typescript-interfaces) +11. [Implementierungs-Roadmap](#11-implementierungs-roadmap) +12. [Testplan](#12-testplan) +13. [Dokumentation](#13-dokumentation) +14. [Offene Fragen & Klärungsbedarf](#14-offene-fragen--klärungsbedarf) +15. [Akzeptanzkriterien](#15-akzeptanzkriterien) + +--- + +## 1. Executive Summary + +Der AI Compliance SDK ist ein **B2B SaaS-Produkt** für Compliance-Management von KI-Anwendungsfällen. Er besteht aus zwei Hauptmodulen: + +| Modul | Beschreibung | Hauptfunktionen | +|-------|--------------|-----------------| +| **Modul 1** | Automatisches Compliance Assessment | Use Case → Risiko → Controls | +| **Modul 2** | Dokumentengenerierung | DSFA, TOMs, Verträge, Cookie Banner | + +### Zielgruppen + +- **Datenschutzbeauftragte (DSB)**: Compliance-Überwachung, DSFA-Erstellung +- **IT-Sicherheitsbeauftragte**: Security Screening, TOMs +- **Entwickler**: Use Case Workshop, Technical Controls +- **Management**: Risk Matrix, Audit Reports +- **Auditoren**: Evidence, Checklists, Compliance Reports + +### Technologie-Stack + +| Komponente | Technologie | Version | +|------------|-------------|---------| +| Frontend | Next.js (App Router) | 15.1 | +| UI Framework | React + TypeScript | 18.3 / 5.7 | +| Styling | Tailwind CSS | 3.4.16 | +| Backend | Go + Gin | 1.24.0 / 1.10.1 | +| Datenbank | PostgreSQL | 15+ | +| Cache | Valkey/Redis | - | +| Vector DB | Qdrant | - | + +--- + +## 2. SDK-Architektur Übersicht + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ AI COMPLIANCE SDK │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ UNIFIED COMMAND BAR │ │ +│ │ [🔍 Prompt: "Erstelle DSFA für Marketing-KI..." ] [⌘K] │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌─────────────────────────────────────────────────────┐ │ +│ │ │ │ │ │ +│ │ SIDEBAR │ │ MAIN CONTENT │ │ +│ │ (Guided │ │ │ │ +│ │ Flow) │ │ [Progress Bar: Phase 1 ████████░░ Phase 2] │ │ +│ │ │ │ │ │ +│ │ ┌─────────┐ │ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │Phase 1 │ │ │ │ │ │ │ +│ │ │━━━━━━━━━│ │ │ │ CURRENT STEP CONTENT │ │ │ +│ │ │1.Use Case│ │ │ │ │ │ │ +│ │ │2.Screening│ │ │ │ │ │ │ +│ │ │3.Compliance│ │ │ └─────────────────────────────────────────────┘ │ │ +│ │ │4.Controls│ │ │ │ │ +│ │ │5.Evidence│ │ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │6.Checklist│ │ │ │ CHECKPOINT: Qualitätsprüfung │ │ │ +│ │ │7.Risk │ │ │ │ ☑ Alle Pflichtfelder ausgefüllt │ │ │ +│ │ │━━━━━━━━━│ │ │ │ ☑ Keine kritischen Lücken │ │ │ +│ │ │Phase 2 │ │ │ │ ☐ DSB-Review erforderlich │ │ │ +│ │ │━━━━━━━━━│ │ │ └─────────────────────────────────────────────┘ │ │ +│ │ │8.AI Act │ │ │ │ │ +│ │ │9.DSFA │ │ │ [← Zurück] [Weiter →] [Überspringen] │ │ +│ │ │10.TOMs │ │ │ │ │ +│ │ │... │ │ │ │ │ +│ │ └─────────┘ │ │ │ │ +│ │ │ │ │ │ +│ └──────────────┘ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Komponenten-Hierarchie + +``` +SDKLayout +├── CommandBar (global, ⌘K aktiviert) +├── SDKSidebar +│ ├── PhaseIndicator +│ ├── StepList (Phase 1) +│ │ └── StepItem[] mit CheckpointBadge +│ └── StepList (Phase 2) +│ └── StepItem[] mit CheckpointBadge +├── MainContent +│ ├── ProgressBar +│ ├── PageContent (dynamisch je nach Route) +│ └── CheckpointCard +└── NavigationFooter + ├── BackButton + ├── NextButton + └── SkipButton +``` + +--- + +## 3. Logische Navigationsstruktur + +### Phase 1: Automatisches Compliance Assessment + +| # | Schritt | URL | Funktion | Voraussetzung | Checkpoint | +|---|---------|-----|----------|---------------|------------| +| 1.1 | **Use Case Workshop** | `/sdk/advisory-board` | 5-Schritte-Wizard für Use Case Erfassung | - | CP-UC | +| 1.2 | **System Screening** | `/sdk/screening` | SBOM + Security Check | Use Case erstellt | CP-SCAN | +| 1.3 | **Compliance Modules** | `/sdk/modules` | Abgleich welche Regulierungen gelten | Screening abgeschlossen | CP-MOD | +| 1.4 | **Requirements** | `/sdk/requirements` | Prüfaspekte aus Regulierungen ableiten | Module zugewiesen | CP-REQ | +| 1.5 | **Controls** | `/sdk/controls` | Erforderliche Maßnahmen ermitteln | Requirements definiert | CP-CTRL | +| 1.6 | **Evidence** | `/sdk/evidence` | Nachweise dokumentieren | Controls definiert | CP-EVI | +| 1.7 | **Audit Checklist** | `/sdk/audit-checklist` | Prüfliste generieren | Evidence vorhanden | CP-CHK | +| 1.8 | **Risk Matrix** | `/sdk/risks` | Risikobewertung & Residual Risk | Checklist abgeschlossen | CP-RISK | + +### Phase 2: Dokumentengenerierung + +| # | Schritt | URL | Funktion | Voraussetzung | Checkpoint | +|---|---------|-----|----------|---------------|------------| +| 2.1 | **AI Act Klassifizierung** | `/sdk/ai-act` | Risikostufe nach EU AI Act | Phase 1 abgeschlossen | CP-AI | +| 2.2 | **Pflichtenübersicht** | `/sdk/obligations` | NIS2, DSGVO, AI Act Pflichten | AI Act Klassifizierung | CP-OBL | +| 2.3 | **DSFA** | `/sdk/dsfa` | Datenschutz-Folgenabschätzung | Bei DSFA-Empfehlung | CP-DSFA | +| 2.4 | **TOMs** | `/sdk/tom` | Technische & Org. Maßnahmen | DSFA oder Controls | CP-TOM | +| 2.5 | **Löschfristen** | `/sdk/loeschfristen` | Aufbewahrungsrichtlinien | TOMs definiert | CP-RET | +| 2.6 | **Verarbeitungsverzeichnis** | `/sdk/vvt` | Art. 30 DSGVO Dokumentation | Löschfristen definiert | CP-VVT | +| 2.7 | **Rechtliche Vorlagen** | `/sdk/consent` | AGB, Datenschutz, Nutzungsbedingungen | VVT erstellt | CP-DOC | +| 2.8 | **Cookie Banner** | `/sdk/cookie-banner` | Cookie-Consent Generator | Rechtl. Vorlagen | CP-COOK | +| 2.9 | **Einwilligungen** | `/sdk/einwilligungen` | Consent-Tracking Setup | Cookie Banner | CP-CONS | +| 2.10 | **DSR Portal** | `/sdk/dsr` | Betroffenenrechte-Portal | Einwilligungen | CP-DSR | +| 2.11 | **Escalations** | `/sdk/escalations` | Management-Workflows | DSR Portal | CP-ESC | + +### Zusatzmodule (Nicht im Hauptflow) + +| Modul | URL | Funktion | Zugang | +|-------|-----|----------|--------| +| **Legal RAG** | `/sdk/rag` | Rechtliche Suche | Jederzeit | +| **AI Quality** | `/sdk/quality` | Qualitätsprüfung | Nach Phase 1 | +| **Security Backlog** | `/sdk/security-backlog` | Aus Screening generiert | Nach Screening | + +--- + +## 4. Datenfluss & Abhängigkeiten + +``` +┌─────────────────┐ +│ Use Case │ +│ Workshop │ +│ (Advisory) │ +└────────┬────────┘ + │ UseCaseIntake + AssessmentResult + ▼ +┌─────────────────┐ ┌─────────────────┐ +│ System │────▶│ Security │ +│ Screening │ │ Backlog │ +│ (SBOM+Sec) │ │ (Issues) │ +└────────┬────────┘ └─────────────────┘ + │ ServiceModules[] + Vulnerabilities[] + ▼ +┌─────────────────┐ +│ Compliance │ +│ Modules │ +│ (Regulations) │ +└────────┬────────┘ + │ ApplicableRegulations[] + ▼ +┌─────────────────┐ +│ Requirements │ +│ (558+ Rules) │ +└────────┬────────┘ + │ Requirements[] + Gaps[] + ▼ +┌─────────────────┐ +│ Controls │ +│ (44+ TOM) │ +└────────┬────────┘ + │ Controls[] + ImplementationStatus + ▼ +┌─────────────────┐ +│ Evidence │ +│ Management │ +└────────┬────────┘ + │ Evidence[] + Validity + ▼ +┌─────────────────┐ +│ Audit │ +│ Checklist │ +└────────┬────────┘ + │ ChecklistItems[] + ComplianceScore + ▼ +┌─────────────────┐ +│ Risk Matrix │──────────────────────────────────┐ +│ (5x5) │ │ +└────────┬────────┘ │ + │ Risks[] + ResidualRisks[] │ + ▼ │ +═════════════════════════════════════════════════════│═══════ + │ PHASE 2 START │ + ▼ │ +┌─────────────────┐ │ +│ AI Act │◀─────────────────────────────────┘ +│ Klassifizierung│ (Risikodaten aus Phase 1) +└────────┬────────┘ + │ RiskLevel + Obligations + ▼ +┌─────────────────┐ +│ Pflichten- │ +│ übersicht │ +└────────┬────────┘ + │ AllObligations[] grouped by Regulation + ▼ +┌─────────────────┐ +│ DSFA │ (Nur wenn dsfa_recommended=true) +│ Generator │ +└────────┬────────┘ + │ DSFA Document + Mitigations + ▼ +┌─────────────────┐ +│ TOMs │ +│ Katalog │ +└────────┬────────┘ + │ TOM[] + ImplementationPlan + ▼ +┌─────────────────┐ +│ Löschfristen │ +│ Definieren │ +└────────┬────────┘ + │ RetentionPolicies[] + ▼ +┌─────────────────┐ +│ Verarbeitungs- │ +│ verzeichnis │ +└────────┬────────┘ + │ ProcessingActivities[] (Art. 30) + ▼ +┌─────────────────┐ +│ Rechtliche │ +│ Vorlagen │ +└────────┬────────┘ + │ Documents[] (AGB, Privacy, Terms) + ▼ +┌─────────────────┐ +│ Cookie Banner │ +│ Generator │ +└────────┬────────┘ + │ CookieBannerConfig + Scripts + ▼ +┌─────────────────┐ +│ Einwilligungen │ +│ Tracking │ +└────────┬────────┘ + │ ConsentRecords[] + AuditTrail + ▼ +┌─────────────────┐ +│ DSR Portal │ +│ Setup │ +└────────┬────────┘ + │ DSRWorkflow + Templates + ▼ +┌─────────────────┐ +│ Escalations │ +│ Workflows │ +└─────────────────┘ +``` + +--- + +## 5. Checkpoint-System + +### 5.1 Checkpoint-Typen + +```typescript +interface Checkpoint { + id: string // z.B. "CP-UC" + step: string // z.B. "use-case-workshop" + name: string // z.B. "Use Case Checkpoint" + type: 'REQUIRED' | 'RECOMMENDED' | 'OPTIONAL' + validation: ValidationRule[] + blocksProgress: boolean + requiresReview: 'NONE' | 'TEAM_LEAD' | 'DSB' | 'LEGAL' + autoValidate: boolean // Automatische Validierung bei Änderungen +} + +interface ValidationRule { + id: string + field: string // Pfad zum Feld im State + condition: 'NOT_EMPTY' | 'MIN_COUNT' | 'MIN_VALUE' | 'CUSTOM' | 'REGEX' + value?: number | string | RegExp // Vergleichswert + customValidator?: (state: SDKState) => boolean + message: string // Fehlermeldung + severity: 'ERROR' | 'WARNING' | 'INFO' +} + +interface CheckpointStatus { + checkpointId: string + passed: boolean + validatedAt: Date | null + validatedBy: string | null // User ID oder "SYSTEM" + errors: ValidationError[] + warnings: ValidationError[] + overrideReason?: string // Falls manuell überschrieben + overriddenBy?: string + overriddenAt?: Date +} +``` + +### 5.2 Checkpoint-Matrix + +| Checkpoint | Schritt | Validierung | Blockiert | Review | +|------------|---------|-------------|-----------|--------| +| CP-UC | 1.1 Use Case | Min. 1 Use Case mit allen 5 Schritten | ✅ Ja | NONE | +| CP-SCAN | 1.2 Screening | SBOM generiert, Security Scan abgeschlossen | ✅ Ja | NONE | +| CP-MOD | 1.3 Modules | Min. 1 Regulierung zugewiesen | ✅ Ja | NONE | +| CP-REQ | 1.4 Requirements | Alle kritischen Requirements adressiert | ✅ Ja | NONE | +| CP-CTRL | 1.5 Controls | Alle BLOCK-Controls definiert | ✅ Ja | DSB | +| CP-EVI | 1.6 Evidence | Evidence für alle kritischen Controls | ❌ Nein | NONE | +| CP-CHK | 1.7 Checklist | Checklist generiert | ❌ Nein | NONE | +| CP-RISK | 1.8 Risk Matrix | Alle HIGH/CRITICAL Risiken mit Mitigation | ✅ Ja | DSB | +| CP-AI | 2.1 AI Act | Risikostufe bestätigt | ✅ Ja | LEGAL | +| CP-OBL | 2.2 Pflichten | Pflichten zugewiesen | ❌ Nein | NONE | +| CP-DSFA | 2.3 DSFA | DSFA abgeschlossen (wenn erforderlich) | ✅ Ja* | DSB | +| CP-TOM | 2.4 TOMs | Alle erforderlichen TOMs definiert | ✅ Ja | NONE | +| CP-RET | 2.5 Löschfristen | Fristen für alle Datenkategorien | ✅ Ja | NONE | +| CP-VVT | 2.6 VVT | Verzeichnis vollständig | ✅ Ja | DSB | +| CP-DOC | 2.7 Vorlagen | Dokumente erstellt | ❌ Nein | LEGAL | +| CP-COOK | 2.8 Cookie | Banner konfiguriert | ❌ Nein | NONE | +| CP-CONS | 2.9 Einwilligungen | Tracking aktiviert | ❌ Nein | NONE | +| CP-DSR | 2.10 DSR | Portal konfiguriert | ❌ Nein | NONE | +| CP-ESC | 2.11 Escalations | Workflows definiert | ❌ Nein | NONE | + +*Nur wenn DSFA empfohlen wurde + +### 5.3 Validierungsregeln (Beispiele) + +```typescript +const checkpointValidations: Record = { + 'CP-UC': [ + { + id: 'uc-min-count', + field: 'useCases', + condition: 'MIN_COUNT', + value: 1, + message: 'Mindestens ein Use Case muss erstellt werden', + severity: 'ERROR' + }, + { + id: 'uc-steps-complete', + field: 'useCases', + condition: 'CUSTOM', + customValidator: (state) => state.useCases.every(uc => uc.stepsCompleted === 5), + message: 'Alle Use Cases müssen alle 5 Schritte abgeschlossen haben', + severity: 'ERROR' + } + ], + 'CP-SCAN': [ + { + id: 'sbom-exists', + field: 'sbom', + condition: 'NOT_EMPTY', + message: 'SBOM muss generiert werden', + severity: 'ERROR' + }, + { + id: 'scan-complete', + field: 'screening.status', + condition: 'CUSTOM', + customValidator: (state) => state.screening?.status === 'completed', + message: 'Security Scan muss abgeschlossen sein', + severity: 'ERROR' + } + ], + 'CP-RISK': [ + { + id: 'critical-risks-mitigated', + field: 'risks', + condition: 'CUSTOM', + customValidator: (state) => { + const criticalRisks = state.risks.filter(r => + r.severity === 'CRITICAL' || r.severity === 'HIGH' + ); + return criticalRisks.every(r => r.mitigation && r.mitigation.length > 0); + }, + message: 'Alle kritischen und hohen Risiken müssen Mitigationsmaßnahmen haben', + severity: 'ERROR' + } + ] +}; +``` + +--- + +## 6. Unified Command Bar + +### 6.1 Architektur + +```typescript +interface CommandBarState { + isOpen: boolean + query: string + context: SDKContext + suggestions: CommandSuggestion[] + history: CommandHistory[] + isLoading: boolean + error: string | null +} + +interface SDKContext { + currentPhase: 1 | 2 + currentStep: string + completedSteps: string[] + activeCheckpoint: Checkpoint | null + useCases: UseCaseAssessment[] + pendingActions: Action[] +} + +interface CommandSuggestion { + id: string + type: 'ACTION' | 'NAVIGATION' | 'SEARCH' | 'GENERATE' | 'HELP' + label: string + description: string + shortcut?: string + icon?: string + action: () => void | Promise + relevanceScore: number +} + +interface CommandHistory { + id: string + query: string + type: CommandSuggestion['type'] + timestamp: Date + success: boolean +} +``` + +### 6.2 Unterstützte Befehle + +| Kategorie | Befehl | Aktion | +|-----------|--------|--------| +| **Navigation** | "Gehe zu DSFA" | navigiert zu `/sdk/dsfa` | +| | "Öffne Risk Matrix" | navigiert zu `/sdk/risks` | +| | "Zurück" | vorheriger Schritt | +| | "Phase 2" | springt zu Phase 2 Start | +| **Aktionen** | "Erstelle neuen Use Case" | startet Wizard | +| | "Exportiere als PDF" | generiert Export | +| | "Starte Security Scan" | triggert Screening | +| | "Validiere Checkpoint" | führt Validierung durch | +| **Generierung** | "Erstelle DSFA für Marketing-KI" | RAG-basierte Generierung | +| | "Welche TOMs brauche ich für Gesundheitsdaten?" | Empfehlungen | +| | "Erkläre Art. 9 DSGVO" | Rechtliche Erklärung | +| **Suche** | "Suche DSGVO Artikel 17" | Rechtliche Suche | +| | "Finde Controls für Verschlüsselung" | Control-Suche | +| **Hilfe** | "Hilfe" | zeigt verfügbare Befehle | +| | "Was muss ich als nächstes tun?" | Kontextbezogene Hilfe | + +### 6.3 Keyboard Shortcuts + +| Shortcut | Aktion | +|----------|--------| +| `⌘K` / `Ctrl+K` | Command Bar öffnen | +| `Escape` | Command Bar schließen | +| `↑` / `↓` | Navigation in Suggestions | +| `Enter` | Suggestion ausführen | +| `⌘→` / `Ctrl+→` | Nächster Schritt | +| `⌘←` / `Ctrl+←` | Vorheriger Schritt | +| `⌘S` / `Ctrl+S` | State speichern | +| `⌘E` / `Ctrl+E` | Export-Dialog | + +--- + +## 7. State Management + +### 7.1 Globaler SDK-State + +```typescript +interface SDKState { + // Metadata + version: string // Schema-Version + lastModified: Date + + // Tenant & User + tenantId: string + userId: string + subscription: SubscriptionTier + + // Progress + currentPhase: 1 | 2 + currentStep: string + completedSteps: string[] + checkpoints: Record + + // Phase 1 Data + useCases: UseCaseAssessment[] + activeUseCase: string | null + screening: ScreeningResult | null + modules: ServiceModule[] + requirements: Requirement[] + controls: Control[] + evidence: Evidence[] + checklist: ChecklistItem[] + risks: Risk[] + + // Phase 2 Data + aiActClassification: AIActResult | null + obligations: Obligation[] + dsfa: DSFA | null + toms: TOM[] + retentionPolicies: RetentionPolicy[] + vvt: ProcessingActivity[] + documents: LegalDocument[] + cookieBanner: CookieBannerConfig | null + consents: ConsentRecord[] + dsrConfig: DSRConfig | null + escalationWorkflows: EscalationWorkflow[] + + // Security + sbom: SBOM | null + securityIssues: SecurityIssue[] + securityBacklog: BacklogItem[] + + // UI State + commandBarHistory: CommandHistory[] + recentSearches: string[] + preferences: UserPreferences +} + +type SubscriptionTier = 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE'; + +interface UserPreferences { + language: 'de' | 'en' + theme: 'light' | 'dark' | 'system' + compactMode: boolean + showHints: boolean + autoSave: boolean + autoValidate: boolean +} +``` + +### 7.2 Persistierung + +```typescript +// Auto-Save bei jeder Änderung +const autoSave = debounce(async (state: SDKState) => { + await api.post('/sdk/v1/state/save', { + tenantId: state.tenantId, + state: serializeState(state), + checksum: calculateChecksum(state) + }); +}, 2000); + +// Load bei App-Start +const loadState = async (tenantId: string): Promise => { + const saved = await api.get(`/sdk/v1/state/${tenantId}`); + return deserializeState(saved); +}; + +// Conflict Resolution +const mergeStates = (local: SDKState, remote: SDKState): SDKState => { + if (local.lastModified > remote.lastModified) { + return local; + } + return remote; +}; +``` + +### 7.3 React Context + +```typescript +interface SDKContextValue { + state: SDKState + dispatch: React.Dispatch + + // Navigation + goToStep: (step: string) => void + goToNextStep: () => void + goToPreviousStep: () => void + + // Checkpoints + validateCheckpoint: (checkpointId: string) => Promise + overrideCheckpoint: (checkpointId: string, reason: string) => Promise + + // State Updates + updateUseCase: (id: string, data: Partial) => void + addRisk: (risk: Risk) => void + updateControl: (id: string, data: Partial) => void + + // Export + exportState: (format: 'json' | 'pdf' | 'zip') => Promise +} + +const SDKContext = React.createContext(null); + +export const useSDK = () => { + const context = useContext(SDKContext); + if (!context) { + throw new Error('useSDK must be used within SDKProvider'); + } + return context; +}; +``` + +--- + +## 8. API-Struktur + +### 8.1 SDK-spezifische Endpoints + +``` +# State Management +GET /sdk/v1/state/{tenantId} → Kompletten State laden +POST /sdk/v1/state/save → State speichern +POST /sdk/v1/state/reset → State zurücksetzen +GET /sdk/v1/state/history → State-Historie (für Undo) + +# Screening +POST /sdk/v1/screening/start → SBOM + Security Scan starten +GET /sdk/v1/screening/status → Scan-Status abrufen +GET /sdk/v1/screening/sbom → SBOM abrufen +GET /sdk/v1/screening/security → Security Issues abrufen +POST /sdk/v1/screening/backlog → Backlog aus Issues generieren + +# Checkpoints +GET /sdk/v1/checkpoints → Alle Checkpoint-Status +GET /sdk/v1/checkpoints/{id} → Einzelner Checkpoint +POST /sdk/v1/checkpoints/{id}/validate → Checkpoint validieren +POST /sdk/v1/checkpoints/{id}/override → Checkpoint überschreiben (mit Review) +GET /sdk/v1/checkpoints/{id}/history → Validierungs-Historie + +# Wizard Flow +GET /sdk/v1/flow/current → Aktueller Schritt + Kontext +POST /sdk/v1/flow/next → Zum nächsten Schritt +POST /sdk/v1/flow/previous → Zum vorherigen Schritt +GET /sdk/v1/flow/suggestions → Kontextbezogene Vorschläge +GET /sdk/v1/flow/progress → Gesamtfortschritt + +# Document Generation +POST /sdk/v1/generate/dsfa → DSFA generieren +POST /sdk/v1/generate/tom → TOMs generieren +POST /sdk/v1/generate/vvt → VVT generieren +POST /sdk/v1/generate/documents → Rechtliche Vorlagen +POST /sdk/v1/generate/cookie-banner → Cookie Banner Code +POST /sdk/v1/generate/dsr-portal → DSR Portal Config + +# Export +GET /sdk/v1/export/full → Kompletter Export (ZIP) +GET /sdk/v1/export/phase1 → Phase 1 Export +GET /sdk/v1/export/phase2 → Phase 2 Export +GET /sdk/v1/export/{document} → Einzelnes Dokument +GET /sdk/v1/export/audit-report → Audit-Report (PDF) + +# Command Bar / RAG +POST /sdk/v1/command/execute → Befehl ausführen +POST /sdk/v1/command/search → Suche durchführen +POST /sdk/v1/command/generate → Content generieren (RAG) +GET /sdk/v1/command/suggestions → Vorschläge basierend auf Kontext +``` + +### 8.2 Request/Response Beispiele + +```typescript +// POST /sdk/v1/checkpoints/{id}/validate +// Request +{ + "checkpointId": "CP-RISK", + "context": { + "userId": "user-123", + "timestamp": "2026-02-03T10:30:00Z" + } +} + +// Response +{ + "checkpointId": "CP-RISK", + "passed": false, + "validatedAt": "2026-02-03T10:30:01Z", + "validatedBy": "SYSTEM", + "errors": [ + { + "ruleId": "critical-risks-mitigated", + "field": "risks[2]", + "message": "Risiko 'Datenverlust' hat keine Mitigationsmaßnahme", + "severity": "ERROR" + } + ], + "warnings": [ + { + "ruleId": "risk-owner-assigned", + "field": "risks[5]", + "message": "Risiko 'Performance-Degradation' hat keinen Owner", + "severity": "WARNING" + } + ], + "nextActions": [ + { + "type": "FIX_ERROR", + "targetField": "risks[2].mitigation", + "suggestion": "Fügen Sie eine Mitigationsmaßnahme hinzu" + } + ] +} +``` + +--- + +## 9. UI-Komponenten + +### 9.1 Komponentenstruktur + +``` +admin-v2/components/sdk/ +├── CommandBar/ +│ ├── CommandBar.tsx # Hauptkomponente (⌘K Modal) +│ ├── CommandInput.tsx # Eingabefeld mit Autocomplete +│ ├── SuggestionList.tsx # Vorschlagsliste +│ ├── SuggestionItem.tsx # Einzelner Vorschlag +│ ├── CommandHistory.tsx # Historie-Anzeige +│ ├── useCommandBar.ts # Hook für State & Logic +│ └── index.ts +│ +├── Sidebar/ +│ ├── SDKSidebar.tsx # Hauptnavigation +│ ├── PhaseIndicator.tsx # Phase 1/2 Anzeige mit Progress +│ ├── StepList.tsx # Liste der Schritte +│ ├── StepItem.tsx # Einzelner Schritt +│ ├── CheckpointBadge.tsx # Checkpoint-Status Badge +│ ├── CollapsibleSection.tsx # Einklappbare Sektion +│ └── index.ts +│ +├── Progress/ +│ ├── ProgressBar.tsx # Gesamtfortschritt +│ ├── StepProgress.tsx # Schritt-Fortschritt (circular) +│ ├── PhaseTransition.tsx # Übergang Phase 1→2 Animation +│ ├── CompletionCard.tsx # Abschluss-Karte +│ └── index.ts +│ +├── Checkpoint/ +│ ├── CheckpointCard.tsx # Checkpoint-Anzeige +│ ├── ValidationList.tsx # Validierungsfehler/-warnungen +│ ├── ValidationItem.tsx # Einzelner Validierungsfehler +│ ├── ReviewRequest.tsx # Review anfordern +│ ├── CheckpointOverride.tsx # Override mit Begründung +│ ├── CheckpointHistory.tsx # Validierungs-Historie +│ └── index.ts +│ +├── Wizard/ +│ ├── WizardContainer.tsx # Wizard-Rahmen +│ ├── WizardStep.tsx # Einzelner Schritt +│ ├── WizardNavigation.tsx # Vor/Zurück/Überspringen +│ ├── WizardSummary.tsx # Zusammenfassung +│ ├── WizardProgress.tsx # Progress Indicator +│ └── index.ts +│ +├── Screening/ +│ ├── ScreeningDashboard.tsx # Übersicht +│ ├── SBOMViewer.tsx # SBOM Anzeige (Tabelle) +│ ├── SBOMGraph.tsx # SBOM als Dependency Graph +│ ├── SecurityIssues.tsx # Issues Liste +│ ├── SecurityIssueCard.tsx # Einzelne Issue +│ ├── BacklogGenerator.tsx # Backlog erstellen +│ ├── ScanProgress.tsx # Scan-Fortschritt +│ └── index.ts +│ +├── Generation/ +│ ├── DocumentGenerator.tsx # Dokument-Generierung +│ ├── GenerationProgress.tsx # Fortschritt (Streaming) +│ ├── DocumentPreview.tsx # Vorschau (Markdown/PDF) +│ ├── DocumentEditor.tsx # Inline-Editor +│ ├── ExportDialog.tsx # Export-Optionen +│ └── index.ts +│ +├── Risk/ +│ ├── RiskMatrix.tsx # 5x5 Risk Matrix +│ ├── RiskCard.tsx # Einzelnes Risiko +│ ├── RiskForm.tsx # Risiko hinzufügen/bearbeiten +│ ├── MitigationForm.tsx # Mitigation hinzufügen +│ └── index.ts +│ +├── Layout/ +│ ├── SDKLayout.tsx # SDK-spezifisches Layout +│ ├── SDKHeader.tsx # Header mit Aktionen +│ ├── NavigationFooter.tsx # Vor/Zurück Footer +│ └── index.ts +│ +└── common/ + ├── StatusBadge.tsx # Status-Anzeige + ├── ActionButton.tsx # Primäre Aktionen + ├── InfoTooltip.tsx # Hilfe-Tooltips + ├── EmptyState.tsx # Leerer Zustand + ├── LoadingState.tsx # Ladezustand + ├── ErrorBoundary.tsx # Fehlerbehandlung + └── index.ts +``` + +### 9.2 Beispiel-Komponenten + +```typescript +// SDKSidebar.tsx +interface SDKSidebarProps { + currentStep: string + completedSteps: string[] + checkpoints: Record + onStepClick: (step: string) => void +} + +// CheckpointCard.tsx +interface CheckpointCardProps { + checkpoint: Checkpoint + status: CheckpointStatus + onValidate: () => Promise + onOverride: (reason: string) => Promise + onRequestReview: (reviewerType: string) => Promise +} + +// CommandBar.tsx +interface CommandBarProps { + isOpen: boolean + onClose: () => void + context: SDKContext + onExecute: (command: CommandSuggestion) => Promise +} +``` + +--- + +## 10. TypeScript Interfaces + +### 10.1 Core Models + +```typescript +// Use Case Assessment +interface UseCaseAssessment { + id: string + name: string + description: string + category: string + stepsCompleted: number + steps: UseCaseStep[] + assessmentResult: AssessmentResult | null + createdAt: Date + updatedAt: Date +} + +interface UseCaseStep { + id: string + name: string + completed: boolean + data: Record +} + +interface AssessmentResult { + riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + applicableRegulations: string[] + recommendedControls: string[] + dsfaRequired: boolean + aiActClassification: string +} + +// Screening +interface ScreeningResult { + id: string + status: 'pending' | 'running' | 'completed' | 'failed' + startedAt: Date + completedAt: Date | null + sbom: SBOM | null + securityScan: SecurityScanResult | null + error: string | null +} + +interface SBOM { + format: 'CycloneDX' | 'SPDX' + version: string + components: SBOMComponent[] + dependencies: SBOMDependency[] + generatedAt: Date +} + +interface SBOMComponent { + name: string + version: string + type: 'library' | 'framework' | 'application' | 'container' + purl: string + licenses: string[] + vulnerabilities: Vulnerability[] +} + +interface SecurityScanResult { + totalIssues: number + critical: number + high: number + medium: number + low: number + issues: SecurityIssue[] +} + +interface SecurityIssue { + id: string + severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' + title: string + description: string + cve: string | null + cvss: number | null + affectedComponent: string + remediation: string + status: 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'ACCEPTED' +} + +// Compliance +interface ServiceModule { + id: string + name: string + description: string + regulations: string[] + criticality: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + processesPersonalData: boolean + hasAIComponents: boolean +} + +interface Requirement { + id: string + regulation: string + article: string + title: string + description: string + criticality: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + applicableModules: string[] + status: 'NOT_STARTED' | 'IN_PROGRESS' | 'IMPLEMENTED' | 'VERIFIED' + controls: string[] +} + +interface Control { + id: string + name: string + description: string + type: 'TECHNICAL' | 'ORGANIZATIONAL' | 'PHYSICAL' + category: string + implementationStatus: 'NOT_IMPLEMENTED' | 'PARTIAL' | 'IMPLEMENTED' + effectiveness: 'LOW' | 'MEDIUM' | 'HIGH' + evidence: string[] + owner: string | null + dueDate: Date | null +} + +interface Evidence { + id: string + controlId: string + type: 'DOCUMENT' | 'SCREENSHOT' | 'LOG' | 'CERTIFICATE' | 'AUDIT_REPORT' + name: string + description: string + fileUrl: string | null + validFrom: Date + validUntil: Date | null + uploadedBy: string + uploadedAt: Date +} + +// Risk +interface Risk { + id: string + title: string + description: string + category: string + likelihood: 1 | 2 | 3 | 4 | 5 + impact: 1 | 2 | 3 | 4 | 5 + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + inherentRiskScore: number + residualRiskScore: number + status: 'IDENTIFIED' | 'ASSESSED' | 'MITIGATED' | 'ACCEPTED' | 'CLOSED' + mitigation: RiskMitigation[] + owner: string | null + relatedControls: string[] + relatedRequirements: string[] +} + +interface RiskMitigation { + id: string + description: string + type: 'AVOID' | 'TRANSFER' | 'MITIGATE' | 'ACCEPT' + status: 'PLANNED' | 'IN_PROGRESS' | 'COMPLETED' + effectiveness: number // 0-100 + controlId: string | null +} + +// Phase 2 Models +interface AIActResult { + riskCategory: 'MINIMAL' | 'LIMITED' | 'HIGH' | 'UNACCEPTABLE' + systemType: string + obligations: AIActObligation[] + assessmentDate: Date + assessedBy: string + justification: string +} + +interface DSFA { + id: string + status: 'DRAFT' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' + version: number + sections: DSFASection[] + approvals: DSFAApproval[] + createdAt: Date + updatedAt: Date +} + +interface TOM { + id: string + category: string + name: string + description: string + type: 'TECHNICAL' | 'ORGANIZATIONAL' + implementationStatus: 'NOT_IMPLEMENTED' | 'PARTIAL' | 'IMPLEMENTED' + priority: 'LOW' | 'MEDIUM' | 'HIGH' + responsiblePerson: string | null + implementationDate: Date | null + reviewDate: Date | null + evidence: string[] +} + +interface RetentionPolicy { + id: string + dataCategory: string + description: string + legalBasis: string + retentionPeriod: string // ISO 8601 Duration + deletionMethod: string + exceptions: string[] +} + +interface ProcessingActivity { + id: string + name: string + purpose: string + legalBasis: string + dataCategories: string[] + dataSubjects: string[] + recipients: string[] + thirdCountryTransfers: boolean + retentionPeriod: string + technicalMeasures: string[] + organizationalMeasures: string[] +} + +interface CookieBannerConfig { + id: string + style: 'BANNER' | 'MODAL' | 'FLOATING' + position: 'TOP' | 'BOTTOM' | 'CENTER' + theme: 'LIGHT' | 'DARK' | 'CUSTOM' + texts: { + title: string + description: string + acceptAll: string + rejectAll: string + settings: string + save: string + } + categories: CookieCategory[] + generatedCode: { + html: string + css: string + js: string + } +} + +interface CookieCategory { + id: string + name: string + description: string + required: boolean + cookies: Cookie[] +} +``` + +--- + +## 11. Implementierungs-Roadmap + +### Sprint 1: Foundation (2 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| SDK-Routing | `/sdk/*` Routen einrichten | KRITISCH | - | +| SDKProvider | State Management Context | KRITISCH | - | +| SDKLayout | Layout mit Sidebar | KRITISCH | SDKProvider | +| SDKSidebar | Navigation mit Phasen | HOCH | SDKLayout | +| Checkpoint-Types | TypeScript Interfaces | HOCH | - | +| Checkpoint-Service | Validierungs-Logik | HOCH | Checkpoint-Types | +| API: State | GET/POST /sdk/v1/state/* | HOCH | - | +| API: Checkpoints | GET/POST /sdk/v1/checkpoints/* | HOCH | Checkpoint-Service | + +**Deliverables Sprint 1:** +- Funktionierendes SDK-Routing +- State-Persistierung +- Basis-Navigation zwischen Schritten + +### Sprint 2: Phase 1 Flow (3 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| Use Case Workshop | Integration bestehender Advisory Board | KRITISCH | Sprint 1 | +| Screening-UI | Dashboard + SBOM Viewer | KRITISCH | Sprint 1 | +| Screening-Backend | SBOM Generation + Security Scan | KRITISCH | - | +| SecurityBacklog | Backlog aus Issues generieren | HOCH | Screening | +| Modules-Integration | Compliance Module Verknüpfung | HOCH | Screening | +| Requirements-Flow | Requirements aus Modulen ableiten | HOCH | Modules | +| Controls-Flow | Controls aus Requirements | HOCH | Requirements | +| Evidence-Management | Evidence Upload + Validierung | MITTEL | Controls | +| Audit-Checklist | Checklist generieren | MITTEL | Evidence | +| Risk-Matrix | 5x5 Matrix + Mitigation | KRITISCH | Checklist | + +**Deliverables Sprint 2:** +- Vollständiger Phase 1 Flow +- SBOM-Generierung +- Security Scanning +- Risk Assessment + +### Sprint 3: Phase 2 Flow (3 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| AI Act Klassifizierung | Wizard + Empfehlungen | KRITISCH | Phase 1 | +| Pflichtenübersicht | Gruppierte Pflichten-Ansicht | HOCH | AI Act | +| DSFA Generator | RAG-basierte Generierung | KRITISCH | Pflichtenübersicht | +| TOMs Katalog | TOM-Auswahl + Priorisierung | HOCH | DSFA | +| Löschfristen | Fristen-Management | MITTEL | TOMs | +| VVT Generator | Art. 30 Export | HOCH | Löschfristen | +| Rechtliche Vorlagen | Template-System | MITTEL | VVT | + +**Deliverables Sprint 3:** +- AI Act Compliance +- DSFA-Generierung +- TOM-Management +- VVT-Export + +### Sprint 4: Consent & DSR (2 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| Cookie Banner Generator | Konfigurator + Code-Export | HOCH | Sprint 3 | +| Cookie Kategorien | Kategorie-Management | HOCH | Cookie Banner | +| Einwilligungen Tracking | Consent Records | MITTEL | Cookie Banner | +| DSR Portal Config | Portal-Einrichtung | MITTEL | Einwilligungen | +| DSR Workflows | Bearbeitungs-Workflows | MITTEL | DSR Portal | +| Escalations | Management-Workflows | NIEDRIG | DSR | + +**Deliverables Sprint 4:** +- Cookie Consent System +- DSR Management +- Escalation Workflows + +### Sprint 5: Command Bar & Polish (2 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| CommandBar UI | Modal + Input | HOCH | Sprint 4 | +| Command Parser | Befehlserkennung | HOCH | CommandBar UI | +| RAG Integration | Suche + Generierung | HOCH | Command Parser | +| Suggestion Engine | Kontextbezogene Vorschläge | MITTEL | RAG | +| Keyboard Shortcuts | Global Shortcuts | MITTEL | CommandBar | +| Export-Funktionen | PDF/ZIP/JSON Export | HOCH | - | +| Progress Animations | Übergangs-Animationen | NIEDRIG | - | +| Responsive Design | Mobile Optimierung | MITTEL | - | + +**Deliverables Sprint 5:** +- Unified Command Bar +- Export-System +- Polish & UX + +### Sprint 6: Testing & QA (1 Woche) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| Unit Tests | Komponenten-Tests | KRITISCH | Sprint 5 | +| Integration Tests | API-Tests | KRITISCH | Sprint 5 | +| E2E Tests | Flow-Tests | KRITISCH | Sprint 5 | +| Performance Tests | Load Testing | HOCH | Sprint 5 | +| Security Audit | Sicherheitsprüfung | KRITISCH | Sprint 5 | +| Dokumentation | User Guide + API Docs | HOCH | Sprint 5 | +| Bug Fixes | Fehlerbehebung | KRITISCH | Tests | + +**Deliverables Sprint 6:** +- Vollständige Testabdeckung +- Dokumentation +- Production-Ready Release + +--- + +## 12. Testplan + +### 12.1 Unit Tests + +```typescript +// Beispiel: Checkpoint Validation Tests +describe('CheckpointService', () => { + describe('validateCheckpoint', () => { + it('should return passed=true when all validations pass', async () => { + const state = createMockState({ + useCases: [{ id: '1', stepsCompleted: 5 }] + }); + + const result = await checkpointService.validate('CP-UC', state); + + expect(result.passed).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return errors when use case steps incomplete', async () => { + const state = createMockState({ + useCases: [{ id: '1', stepsCompleted: 3 }] + }); + + const result = await checkpointService.validate('CP-UC', state); + + expect(result.passed).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ ruleId: 'uc-steps-complete' }) + ); + }); + + it('should allow override with valid reason', async () => { + const result = await checkpointService.override( + 'CP-UC', + 'Genehmigt durch DSB am 2026-02-03', + 'user-123' + ); + + expect(result.overrideReason).toBeTruthy(); + expect(result.passed).toBe(true); + }); + }); +}); + +// Beispiel: Risk Matrix Tests +describe('RiskMatrix', () => { + describe('calculateRiskScore', () => { + it('should calculate correct risk score', () => { + const risk: Risk = { + likelihood: 4, + impact: 5 + }; + + const score = calculateRiskScore(risk); + + expect(score).toBe(20); + expect(getRiskSeverity(score)).toBe('CRITICAL'); + }); + + it('should calculate residual risk after mitigation', () => { + const risk: Risk = { + likelihood: 4, + impact: 5, + mitigation: [{ effectiveness: 60 }] + }; + + const residualScore = calculateResidualRisk(risk); + + expect(residualScore).toBe(8); // 20 * 0.4 + }); + }); +}); + +// Beispiel: Command Bar Tests +describe('CommandParser', () => { + it('should parse navigation commands', () => { + const result = parseCommand('Gehe zu DSFA'); + + expect(result.type).toBe('NAVIGATION'); + expect(result.target).toBe('/sdk/dsfa'); + }); + + it('should parse generation commands', () => { + const result = parseCommand('Erstelle DSFA für Marketing-KI'); + + expect(result.type).toBe('GENERATE'); + expect(result.documentType).toBe('dsfa'); + expect(result.context).toContain('Marketing-KI'); + }); + + it('should return suggestions for partial input', () => { + const suggestions = getSuggestions('Erst', mockContext); + + expect(suggestions).toContainEqual( + expect.objectContaining({ label: 'Erstelle neuen Use Case' }) + ); + }); +}); +``` + +### 12.2 Integration Tests + +```typescript +// API Integration Tests +describe('SDK API', () => { + describe('POST /sdk/v1/state/save', () => { + it('should save state successfully', async () => { + const state = createMockState(); + + const response = await request(app) + .post('/sdk/v1/state/save') + .send({ tenantId: 'tenant-1', state }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.savedAt).toBeTruthy(); + }); + + it('should reject invalid state schema', async () => { + const invalidState = { invalid: 'data' }; + + await request(app) + .post('/sdk/v1/state/save') + .send({ tenantId: 'tenant-1', state: invalidState }) + .expect(400); + }); + }); + + describe('POST /sdk/v1/screening/start', () => { + it('should start SBOM generation', async () => { + const response = await request(app) + .post('/sdk/v1/screening/start') + .send({ + repositoryUrl: 'https://github.com/example/repo', + branch: 'main' + }) + .expect(202); + + expect(response.body.scanId).toBeTruthy(); + expect(response.body.status).toBe('pending'); + }); + }); + + describe('POST /sdk/v1/generate/dsfa', () => { + it('should generate DSFA document', async () => { + const response = await request(app) + .post('/sdk/v1/generate/dsfa') + .send({ + useCaseId: 'uc-1', + includeRiskAssessment: true + }) + .expect(200); + + expect(response.body.dsfa).toBeTruthy(); + expect(response.body.dsfa.sections).toHaveLength(8); + }); + }); +}); +``` + +### 12.3 E2E Tests + +```typescript +// Playwright E2E Tests +import { test, expect } from '@playwright/test'; + +test.describe('SDK Complete Flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/sdk'); + await page.waitForSelector('[data-testid="sdk-sidebar"]'); + }); + + test('should complete Phase 1 workflow', async ({ page }) => { + // Step 1.1: Use Case Workshop + await page.click('[data-testid="step-use-case"]'); + await page.fill('[data-testid="use-case-name"]', 'Marketing AI'); + await page.click('[data-testid="wizard-next"]'); + // ... complete all 5 steps + await expect(page.locator('[data-testid="checkpoint-CP-UC"]')).toHaveAttribute( + 'data-passed', + 'true' + ); + + // Step 1.2: Screening + await page.click('[data-testid="step-screening"]'); + await page.fill('[data-testid="repository-url"]', 'https://github.com/example/repo'); + await page.click('[data-testid="start-scan"]'); + await page.waitForSelector('[data-testid="scan-complete"]', { timeout: 60000 }); + + // ... continue through Phase 1 + }); + + test('should block progress when checkpoint fails', async ({ page }) => { + await page.click('[data-testid="step-requirements"]'); + await page.click('[data-testid="nav-next"]'); + + // Should show checkpoint error + await expect(page.locator('[data-testid="checkpoint-error"]')).toBeVisible(); + await expect(page.locator('[data-testid="checkpoint-error"]')).toContainText( + 'Vorherige Schritte nicht abgeschlossen' + ); + }); + + test('should open command bar with Cmd+K', async ({ page }) => { + await page.keyboard.press('Meta+k'); + + await expect(page.locator('[data-testid="command-bar"]')).toBeVisible(); + + await page.fill('[data-testid="command-input"]', 'Gehe zu DSFA'); + await page.keyboard.press('Enter'); + + await expect(page).toHaveURL('/sdk/dsfa'); + }); + + test('should export complete documentation', async ({ page }) => { + // Navigate to completed state + await page.goto('/sdk/escalations'); + + // Open command bar and export + await page.keyboard.press('Meta+k'); + await page.fill('[data-testid="command-input"]', 'Exportiere als PDF'); + await page.keyboard.press('Enter'); + + // Wait for download + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('[data-testid="confirm-export"]') + ]); + + expect(download.suggestedFilename()).toMatch(/compliance-report.*\.pdf/); + }); +}); +``` + +### 12.4 Performance Tests + +```typescript +// k6 Load Tests +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, // Ramp up + { duration: '1m', target: 20 }, // Stay at 20 users + { duration: '30s', target: 50 }, // Ramp up more + { duration: '1m', target: 50 }, // Stay at 50 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests < 500ms + http_req_failed: ['rate<0.01'], // Error rate < 1% + }, +}; + +export default function () { + // State Load Test + const stateRes = http.get('http://localhost:3002/api/sdk/v1/state/tenant-1'); + check(stateRes, { + 'state load status 200': (r) => r.status === 200, + 'state load time < 200ms': (r) => r.timings.duration < 200, + }); + + // Checkpoint Validation Test + const checkpointRes = http.post( + 'http://localhost:3002/api/sdk/v1/checkpoints/CP-UC/validate', + JSON.stringify({ context: { userId: 'user-1' } }), + { headers: { 'Content-Type': 'application/json' } } + ); + check(checkpointRes, { + 'checkpoint validation status 200': (r) => r.status === 200, + 'checkpoint validation time < 300ms': (r) => r.timings.duration < 300, + }); + + sleep(1); +} +``` + +### 12.5 Test Coverage Requirements + +| Bereich | Minimum Coverage | Ziel Coverage | +|---------|------------------|---------------| +| Komponenten | 80% | 90% | +| Hooks | 85% | 95% | +| Services | 90% | 95% | +| API Endpoints | 90% | 95% | +| State Management | 85% | 95% | +| Checkpoint Logic | 95% | 100% | +| **Gesamt** | **85%** | **92%** | + +--- + +## 13. Dokumentation + +### 13.1 Benutzerhandbuch + +#### Inhaltsverzeichnis + +1. **Erste Schritte** + - SDK aktivieren + - Dashboard Übersicht + - Navigation verstehen + +2. **Phase 1: Compliance Assessment** + - 1.1 Use Case Workshop durchführen + - 1.2 System Screening starten + - 1.3 Compliance Module zuweisen + - 1.4 Requirements prüfen + - 1.5 Controls definieren + - 1.6 Evidence hochladen + - 1.7 Audit Checklist erstellen + - 1.8 Risk Matrix ausfüllen + +3. **Phase 2: Dokumentengenerierung** + - 2.1 AI Act Klassifizierung + - 2.2 Pflichtenübersicht verstehen + - 2.3 DSFA erstellen + - 2.4 TOMs auswählen + - 2.5 Löschfristen festlegen + - 2.6 Verarbeitungsverzeichnis pflegen + - 2.7 Rechtliche Vorlagen nutzen + - 2.8 Cookie Banner konfigurieren + - 2.9 Einwilligungen tracken + - 2.10 DSR Portal einrichten + - 2.11 Escalations konfigurieren + +4. **Command Bar** + - Befehle verwenden + - Tastaturkürzel + - Suche und RAG + +5. **Export & Berichte** + - PDF Export + - ZIP Export + - Audit-Berichte + +6. **FAQ & Troubleshooting** + +### 13.2 API-Dokumentation + +#### OpenAPI Specification (Auszug) + +```yaml +openapi: 3.1.0 +info: + title: AI Compliance SDK API + version: 1.0.0 + description: API für das AI Compliance SDK + +servers: + - url: /api/sdk/v1 + description: SDK API v1 + +paths: + /state/{tenantId}: + get: + summary: Kompletten State laden + tags: [State Management] + parameters: + - name: tenantId + in: path + required: true + schema: + type: string + responses: + '200': + description: State erfolgreich geladen + content: + application/json: + schema: + $ref: '#/components/schemas/SDKState' + '404': + description: Tenant nicht gefunden + + /state/save: + post: + summary: State speichern + tags: [State Management] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tenantId: + type: string + state: + $ref: '#/components/schemas/SDKState' + responses: + '200': + description: State erfolgreich gespeichert + + /checkpoints/{id}/validate: + post: + summary: Checkpoint validieren + tags: [Checkpoints] + parameters: + - name: id + in: path + required: true + schema: + type: string + enum: [CP-UC, CP-SCAN, CP-MOD, CP-REQ, CP-CTRL, CP-EVI, CP-CHK, CP-RISK, CP-AI, CP-OBL, CP-DSFA, CP-TOM, CP-RET, CP-VVT, CP-DOC, CP-COOK, CP-CONS, CP-DSR, CP-ESC] + responses: + '200': + description: Validierungsergebnis + content: + application/json: + schema: + $ref: '#/components/schemas/CheckpointStatus' + + /screening/start: + post: + summary: SBOM + Security Scan starten + tags: [Screening] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + repositoryUrl: + type: string + format: uri + branch: + type: string + default: main + scanTypes: + type: array + items: + type: string + enum: [sbom, security, license] + responses: + '202': + description: Scan gestartet + content: + application/json: + schema: + type: object + properties: + scanId: + type: string + status: + type: string + enum: [pending, running] + + /generate/dsfa: + post: + summary: DSFA generieren + tags: [Document Generation] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + useCaseId: + type: string + includeRiskAssessment: + type: boolean + language: + type: string + enum: [de, en] + default: de + responses: + '200': + description: DSFA erfolgreich generiert + content: + application/json: + schema: + $ref: '#/components/schemas/DSFA' + +components: + schemas: + SDKState: + type: object + properties: + version: + type: string + tenantId: + type: string + currentPhase: + type: integer + enum: [1, 2] + currentStep: + type: string + completedSteps: + type: array + items: + type: string + checkpoints: + type: object + additionalProperties: + $ref: '#/components/schemas/CheckpointStatus' + useCases: + type: array + items: + $ref: '#/components/schemas/UseCaseAssessment' + # ... weitere Felder + + CheckpointStatus: + type: object + properties: + checkpointId: + type: string + passed: + type: boolean + validatedAt: + type: string + format: date-time + errors: + type: array + items: + $ref: '#/components/schemas/ValidationError' + warnings: + type: array + items: + $ref: '#/components/schemas/ValidationError' + + ValidationError: + type: object + properties: + ruleId: + type: string + field: + type: string + message: + type: string + severity: + type: string + enum: [ERROR, WARNING, INFO] +``` + +### 13.3 Entwickler-Dokumentation + +#### Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FRONTEND (Next.js) │ +├─────────────────────────────────────────────────────────────────┤ +│ Pages (/sdk/*) │ Components │ Hooks │ State (Context) │ +└────────┬────────────────────────────────────────────────────────┘ + │ + │ HTTP/REST + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ API Layer (Next.js Route Handlers) │ +├─────────────────────────────────────────────────────────────────┤ +│ /api/sdk/v1/* │ Authentication │ Rate Limiting │ CORS │ +└────────┬────────────────────────────────────────────────────────┘ + │ + │ Internal HTTP + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ BACKEND (Go/Gin) │ +├─────────────────────────────────────────────────────────────────┤ +│ Handlers │ Services │ UCCA Framework │ LLM Integration │ +└────────┬────────────────────────────────────────────────────────┘ + │ + │ SQL/Cache + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DATA LAYER │ +├─────────────────────────────────────────────────────────────────┤ +│ PostgreSQL │ Valkey/Redis │ MinIO │ Qdrant (Vector DB) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Neue Komponente erstellen + +```typescript +// 1. Erstelle die Komponente in components/sdk/ +// components/sdk/MyComponent/MyComponent.tsx + +import { useSDK } from '@/lib/sdk/context'; + +interface MyComponentProps { + title: string; + onAction: () => void; +} + +export function MyComponent({ title, onAction }: MyComponentProps) { + const { state, dispatch } = useSDK(); + + return ( +
+

{title}

+

Current Step: {state.currentStep}

+ +
+ ); +} + +// 2. Exportiere in index.ts +// components/sdk/MyComponent/index.ts +export { MyComponent } from './MyComponent'; + +// 3. Verwende in einer Page +// app/(admin)/sdk/my-page/page.tsx +import { MyComponent } from '@/components/sdk/MyComponent'; + +export default function MyPage() { + return ( + console.log('clicked')} + /> + ); +} +``` + +#### Neuen API-Endpoint hinzufügen + +```typescript +// app/api/sdk/v1/my-endpoint/route.ts +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const tenantId = request.headers.get('x-tenant-id'); + + // Backend aufrufen + const backendUrl = process.env.SDK_BACKEND_URL; + const response = await fetch(`${backendUrl}/my-endpoint`, { + headers: { + 'X-Tenant-ID': tenantId, + }, + }); + + const data = await response.json(); + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validierung + if (!body.requiredField) { + return NextResponse.json( + { error: 'requiredField is required' }, + { status: 400 } + ); + } + + // Backend aufrufen + const backendUrl = process.env.SDK_BACKEND_URL; + const response = await fetch(`${backendUrl}/my-endpoint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} +``` + +--- + +## 14. Offene Fragen & Klärungsbedarf + +### Fragen die VOR Sprint 1 geklärt werden müssen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 1 | **Backend-Verbindung für Screening**: Wie verbindet sich der SDK mit dem Kunden-Backend? Welche Authentifizierung wird verwendet? | Architektur | Blockiert Screening-Implementierung | Tech Lead + Security | +| 2 | **Welche Systeme sollen gescannt werden können?** (Git Repos, Container Registries, Cloud APIs) | Screening | Bestimmt SBOM-Generierungs-Strategie | Product Owner | +| 3 | **Multi-Use-Case Handling**: Sollen alle Use Cases denselben Compliance-Flow durchlaufen? Oder gibt es einen übergeordneten "Projekt"-Kontext? | Datenmodell | Beeinflusst State-Struktur | Product Owner | + +### Fragen die VOR Sprint 4 geklärt werden müssen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 4 | **Cookie Banner Integration**: Welche Plattformen sollen unterstützt werden? (Web, iOS, Android) | Cookie Banner | Bestimmt Umfang der Implementierung | Product Owner | +| 5 | **Soll ein fertiger Code-Snippet generiert werden?** Oder nur Konfiguration? | Cookie Banner | Frontend-Aufwand | Tech Lead | + +### Fragen die VOR Go-Live geklärt werden müssen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 6 | **Subscription Tiers**: Welche Features sind in welchem Tier verfügbar? | Business | Feature Flags Implementierung | Product Owner + Business | +| 7 | **Gibt es Nutzungslimits?** (z.B. max. Use Cases, Scans pro Monat) | Business | Rate Limiting Implementierung | Product Owner + Business | + +### Zusätzliche technische Fragen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 8 | **LLM Provider für RAG/Generierung**: Ollama, Anthropic, oder OpenAI als Standard? | Infrastruktur | Kosten, Performance, Datenschutz | Tech Lead + DSB | +| 9 | **Datenhoheit**: Wo werden generierte Dokumente gespeichert? On-Premise Option? | Infrastruktur | Speicher-Architektur | Tech Lead + Security | +| 10 | **Audit Trail**: Wie granular soll die Änderungsverfolgung sein? | Compliance | Datenbankschema | DSB + Tech Lead | + +### Empfohlene Klärungsreihenfolge + +``` +Woche 0 (vor Projektstart): +├── Frage 1-3 klären (Architektur-Entscheidungen) +├── Frage 8 klären (LLM Provider) +└── Frage 9 klären (Datenhoheit) + +Sprint 1-2: +├── Frage 10 klären (Audit Trail) +└── Dokumentation der Entscheidungen + +Sprint 3 (vor Phase 2): +├── Frage 4-5 klären (Cookie Banner) +└── Feature-Scope finalisieren + +Sprint 5 (vor Go-Live): +├── Frage 6-7 klären (Subscription Tiers) +└── Pricing-Modell integrieren +``` + +--- + +## 15. Akzeptanzkriterien + +### Funktionale Anforderungen + +| # | Kriterium | Testmethode | Status | +|---|-----------|-------------|--------| +| F1 | Nutzer kann kompletten Flow in einer Session durchlaufen | E2E Test | ⬜ | +| F2 | Checkpoints blockieren korrekt bei fehlenden Daten | Integration Test | ⬜ | +| F3 | Command Bar funktioniert in jedem Schritt | E2E Test | ⬜ | +| F4 | Alle 11 Dokument-Typen werden korrekt generiert | Unit + Integration | ⬜ | +| F5 | Export enthält alle relevanten Daten (PDF, ZIP, JSON) | Integration Test | ⬜ | +| F6 | SBOM-Generierung funktioniert für Git Repositories | Integration Test | ⬜ | +| F7 | Security Scan identifiziert bekannte CVEs | Integration Test | ⬜ | +| F8 | Risk Matrix berechnet korrekte Scores | Unit Test | ⬜ | +| F9 | Cookie Banner generiert funktionierenden Code | Manual + E2E | ⬜ | +| F10 | DSR Portal kann Anfragen entgegennehmen | E2E Test | ⬜ | + +### Nicht-funktionale Anforderungen + +| # | Kriterium | Zielwert | Testmethode | Status | +|---|-----------|----------|-------------|--------| +| NF1 | Page Load Time | < 2s | Lighthouse | ⬜ | +| NF2 | State Save Latency | < 500ms | Performance Test | ⬜ | +| NF3 | Checkpoint Validation | < 300ms | Performance Test | ⬜ | +| NF4 | Document Generation | < 30s | Performance Test | ⬜ | +| NF5 | Concurrent Users | 50+ | Load Test | ⬜ | +| NF6 | Error Rate | < 1% | Monitoring | ⬜ | +| NF7 | Test Coverage | > 85% | Jest/Vitest | ⬜ | +| NF8 | Accessibility | WCAG 2.1 AA | axe-core | ⬜ | +| NF9 | Mobile Responsive | iOS/Android | Manual Test | ⬜ | +| NF10 | Browser Support | Chrome, Firefox, Safari, Edge | E2E Test | ⬜ | + +### Sicherheitsanforderungen + +| # | Kriterium | Standard | Status | +|---|-----------|----------|--------| +| S1 | Authentifizierung | OAuth 2.0 / OIDC | ⬜ | +| S2 | Autorisierung | RBAC mit 4 Rollen | ⬜ | +| S3 | Datenverschlüsselung at Rest | AES-256 | ⬜ | +| S4 | Datenverschlüsselung in Transit | TLS 1.3 | ⬜ | +| S5 | Input Validation | OWASP Guidelines | ⬜ | +| S6 | Audit Logging | Alle Schreiboperationen | ⬜ | +| S7 | Rate Limiting | 100 req/min pro User | ⬜ | +| S8 | CSRF Protection | Token-basiert | ⬜ | +| S9 | XSS Prevention | CSP Headers | ⬜ | +| S10 | SQL Injection Prevention | Parameterized Queries | ⬜ | + +--- + +## Anhang + +### A. Glossar + +| Begriff | Definition | +|---------|------------| +| **DSFA** | Datenschutz-Folgenabschätzung (Art. 35 DSGVO) | +| **TOM** | Technische und Organisatorische Maßnahmen | +| **VVT** | Verarbeitungsverzeichnis (Art. 30 DSGVO) | +| **DSR** | Data Subject Request (Betroffenenrechte) | +| **SBOM** | Software Bill of Materials | +| **UCCA** | Unified Compliance Control Architecture | +| **RAG** | Retrieval-Augmented Generation | +| **CVE** | Common Vulnerabilities and Exposures | +| **CVSS** | Common Vulnerability Scoring System | + +### B. Referenzen + +- [EU AI Act](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:52021PC0206) +- [DSGVO](https://eur-lex.europa.eu/eli/reg/2016/679/oj) +- [NIS2 Directive](https://eur-lex.europa.eu/eli/dir/2022/2555) +- [CycloneDX SBOM Standard](https://cyclonedx.org/) +- [SPDX Standard](https://spdx.dev/) + +### C. Änderungshistorie + +| Version | Datum | Autor | Änderungen | +|---------|-------|-------|------------| +| 1.0.0 | 2026-02-03 | AI Compliance Team | Initiale Version | + +--- + +*Dieses Dokument wurde erstellt für das AI Compliance SDK Projekt.* diff --git a/admin-v2/BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md b/admin-v2/BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md new file mode 100644 index 0000000..7cbd71f --- /dev/null +++ b/admin-v2/BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md @@ -0,0 +1,566 @@ +# BreakPilot Consent Management System - Projektplan + +## Executive Summary + +Dieses Dokument beschreibt den Plan zur Entwicklung eines vollständigen Consent Management Systems (CMS) für BreakPilot. Das System wird komplett neu entwickelt und ersetzt das bestehende Policy Vault System, das Bugs enthält und nicht optimal funktioniert. + +--- + +## Technologie-Entscheidung: Warum welche Sprache? + +### Backend-Optionen im Vergleich + +| Kriterium | Rust | Go | Python (FastAPI) | TypeScript (NestJS) | +|-----------|------|-----|------------------|---------------------| +| **Performance** | Exzellent | Sehr gut | Gut | Gut | +| **Memory Safety** | Garantiert | GC | GC | GC | +| **Entwicklungsgeschwindigkeit** | Langsam | Mittel | Schnell | Schnell | +| **Lernkurve** | Steil | Flach | Flach | Mittel | +| **Ecosystem für Web** | Wachsend | Sehr gut | Exzellent | Exzellent | +| **Integration mit BreakPilot** | Neu | Neu | Bereits vorhanden | Möglich | +| **Team-Erfahrung** | ? | ? | Vorhanden | Möglich | + +### Empfehlung: **Python (FastAPI)** oder **Go** + +#### Option A: Python mit FastAPI (Empfohlen für schnelle Integration) +**Vorteile:** +- Bereits im BreakPilot-Projekt verwendet +- Schnelle Entwicklung +- Exzellente Dokumentation (automatisch generiert) +- Einfache Integration mit bestehendem Code +- Type Hints für bessere Code-Qualität +- Async/Await Support + +**Nachteile:** +- Langsamer als Rust/Go bei hoher Last +- GIL-Einschränkungen bei CPU-intensiven Tasks + +#### Option B: Go (Empfohlen für Microservice-Architektur) +**Vorteile:** +- Extrem schnell und effizient +- Exzellent für Microservices +- Einfache Deployment (Single Binary) +- Gute Concurrency +- Statische Typisierung + +**Nachteile:** +- Neuer Tech-Stack im Projekt +- Getrennte Codebasis von BreakPilot + +#### Option C: Rust (Für maximale Performance & Sicherheit) +**Vorteile:** +- Höchste Performance +- Memory Safety ohne GC +- Exzellente Sicherheit +- WebAssembly-Support + +**Nachteile:** +- Sehr steile Lernkurve +- Längere Entwicklungszeit (2-3x) +- Kleineres Web-Ecosystem +- Komplexere Fehlerbehandlung + +### Finale Empfehlung + +**Für BreakPilot empfehle ich: Go (Golang)** + +Begründung: +1. **Unabhängiger Microservice** - Das CMS sollte als eigenständiger Service laufen +2. **Performance** - Consent-Checks müssen schnell sein (bei jedem API-Call) +3. **Einfaches Deployment** - Single Binary, ideal für Container +4. **Gute Balance** - Schneller als Python, einfacher als Rust +5. **Zukunftssicher** - Moderne Sprache mit wachsendem Ecosystem + +--- + +## Systemarchitektur + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ BreakPilot Ecosystem │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ BreakPilot │ │ Consent Admin │ │ BreakPilot │ │ +│ │ Studio (Web) │ │ Dashboard │ │ Mobile Apps │ │ +│ │ (Python/HTML) │ │ (Vue.js/React) │ │ (iOS/Android) │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └──────────────────────┼──────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ API Gateway / Proxy │ │ +│ └────────────┬────────────┘ │ +│ │ │ +│ ┌─────────────────────┼─────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ BreakPilot API │ │ Consent Service │ │ Auth Service │ │ +│ │ (Python/FastAPI)│ │ (Go) │ │ (Go) │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └────────────────────┼────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ PostgreSQL │ │ +│ │ (Shared Database) │ │ +│ └─────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Projektphasen + +### Phase 1: Grundlagen & Datenbank (Woche 1-2) +**Ziel:** Datenbank-Schema und Basis-Services + +#### 1.1 Datenbank-Design +- [ ] Users-Tabelle (Integration mit BreakPilot Auth) +- [ ] Legal Documents (AGB, Datenschutz, Community Guidelines, etc.) +- [ ] Document Versions (Versionierung mit Freigabe-Workflow) +- [ ] User Consents (Welcher User hat wann was zugestimmt) +- [ ] Cookie Categories (Notwendig, Funktional, Marketing, Analytics) +- [ ] Cookie Consents (Granulare Cookie-Zustimmungen) +- [ ] Audit Log (DSGVO-konforme Protokollierung) + +#### 1.2 Go Backend Setup +- [ ] Projekt-Struktur mit Clean Architecture +- [ ] Database Layer (sqlx oder GORM) +- [ ] Migration System +- [ ] Config Management +- [ ] Logging & Error Handling + +### Phase 2: Core Consent Service (Woche 3-4) +**Ziel:** Kern-Funktionalität für Consent-Management + +#### 2.1 Document Management API +- [ ] CRUD für Legal Documents +- [ ] Versionierung mit Diff-Tracking +- [ ] Draft/Published/Archived Status +- [ ] Mehrsprachigkeit (DE, EN, etc.) + +#### 2.2 Consent Tracking API +- [ ] User Consent erstellen/abrufen +- [ ] Consent History pro User +- [ ] Bulk-Consent für mehrere Dokumente +- [ ] Consent Withdrawal (Widerruf) + +#### 2.3 Cookie Consent API +- [ ] Cookie-Kategorien verwalten +- [ ] Granulare Cookie-Einstellungen +- [ ] Consent-Banner Konfiguration + +### Phase 3: Admin Dashboard (Woche 5-6) +**Ziel:** Web-Interface für Administratoren + +#### 3.1 Admin Frontend (Vue.js oder React) +- [ ] Login/Auth (Integration mit BreakPilot) +- [ ] Dashboard mit Statistiken +- [ ] Document Editor (Rich Text) +- [ ] Version Management UI +- [ ] User Consent Übersicht +- [ ] Cookie Management UI + +#### 3.2 Freigabe-Workflow +- [ ] Draft → Review → Approved → Published +- [ ] Benachrichtigungen bei neuen Versionen +- [ ] Rollback-Funktion + +### Phase 4: BreakPilot Integration (Woche 7-8) +**Ziel:** Integration in BreakPilot Studio + +#### 4.1 User-facing Features +- [ ] "Legal" Button in Einstellungen +- [ ] Consent-Historie anzeigen +- [ ] Cookie-Präferenzen ändern +- [ ] Datenauskunft anfordern (DSGVO Art. 15) + +#### 4.2 Cookie Banner +- [ ] Cookie-Consent-Modal beim ersten Besuch +- [ ] Granulare Auswahl der Kategorien +- [ ] "Alle akzeptieren" / "Nur notwendige" +- [ ] Persistente Speicherung + +#### 4.3 Consent-Check Middleware +- [ ] Automatische Prüfung bei API-Calls +- [ ] Blocking bei fehlender Zustimmung +- [ ] Marketing-Opt-out respektieren + +### Phase 5: Data Subject Rights (Woche 9-10) +**Ziel:** DSGVO-Compliance Features + +#### 5.1 Datenauskunft (Art. 15 DSGVO) +- [ ] API für "Welche Daten haben wir?" +- [ ] Export als JSON/PDF +- [ ] Automatisierte Bereitstellung + +#### 5.2 Datenlöschung (Art. 17 DSGVO) +- [ ] "Recht auf Vergessenwerden" +- [ ] Anonymisierung statt Löschung (wo nötig) +- [ ] Audit Trail für Löschungen + +#### 5.3 Datenportabilität (Art. 20 DSGVO) +- [ ] Export in maschinenlesbarem Format +- [ ] Download-Funktion im Frontend + +### Phase 6: Testing & Security (Woche 11-12) +**Ziel:** Absicherung und Qualität + +#### 6.1 Testing +- [ ] Unit Tests (>80% Coverage) +- [ ] Integration Tests +- [ ] E2E Tests für kritische Flows +- [ ] Performance Tests + +#### 6.2 Security +- [ ] Security Audit +- [ ] Penetration Testing +- [ ] Rate Limiting +- [ ] Input Validation +- [ ] SQL Injection Prevention +- [ ] XSS Protection + +--- + +## Datenbank-Schema (Entwurf) + +```sql +-- Benutzer (Integration mit BreakPilot) +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + external_id VARCHAR(255) UNIQUE, -- BreakPilot User ID + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Rechtliche Dokumente +CREATE TABLE legal_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(50) NOT NULL, -- 'terms', 'privacy', 'cookies', 'community' + name VARCHAR(255) NOT NULL, + description TEXT, + is_mandatory BOOLEAN DEFAULT true, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Dokumentversionen +CREATE TABLE document_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID REFERENCES legal_documents(id) ON DELETE CASCADE, + version VARCHAR(20) NOT NULL, -- Semver: 1.0.0, 1.1.0, etc. + language VARCHAR(5) DEFAULT 'de', -- ISO 639-1 + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, -- HTML oder Markdown + summary TEXT, -- Kurze Zusammenfassung der Änderungen + status VARCHAR(20) DEFAULT 'draft', -- draft, review, approved, published, archived + published_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + approved_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(document_id, version, language) +); + +-- Benutzer-Zustimmungen +CREATE TABLE user_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + document_version_id UUID REFERENCES document_versions(id), + consented BOOLEAN NOT NULL, + ip_address INET, + user_agent TEXT, + consented_at TIMESTAMPTZ DEFAULT NOW(), + withdrawn_at TIMESTAMPTZ, + UNIQUE(user_id, document_version_id) +); + +-- Cookie-Kategorien +CREATE TABLE cookie_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, -- 'necessary', 'functional', 'analytics', 'marketing' + display_name_de VARCHAR(255) NOT NULL, + display_name_en VARCHAR(255), + description_de TEXT, + description_en TEXT, + is_mandatory BOOLEAN DEFAULT false, + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Cookie-Zustimmungen +CREATE TABLE cookie_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + category_id UUID REFERENCES cookie_categories(id), + consented BOOLEAN NOT NULL, + consented_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, category_id) +); + +-- Audit Log (DSGVO-konform) +CREATE TABLE consent_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID, + action VARCHAR(50) NOT NULL, -- 'consent_given', 'consent_withdrawn', 'data_export', 'data_delete' + entity_type VARCHAR(50), -- 'document', 'cookie_category' + entity_id UUID, + details JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indizes für Performance +CREATE INDEX idx_user_consents_user ON user_consents(user_id); +CREATE INDEX idx_user_consents_version ON user_consents(document_version_id); +CREATE INDEX idx_cookie_consents_user ON cookie_consents(user_id); +CREATE INDEX idx_audit_log_user ON consent_audit_log(user_id); +CREATE INDEX idx_audit_log_created ON consent_audit_log(created_at); +``` + +--- + +## API-Endpoints (Entwurf) + +### Public API (für BreakPilot Frontend) + +``` +# Dokumente abrufen +GET /api/v1/documents # Alle aktiven Dokumente +GET /api/v1/documents/:type # Dokument nach Typ (terms, privacy) +GET /api/v1/documents/:type/latest # Neueste publizierte Version + +# Consent Management +POST /api/v1/consent # Zustimmung erteilen +GET /api/v1/consent/my # Meine Zustimmungen +GET /api/v1/consent/check/:documentType # Prüfen ob zugestimmt +DELETE /api/v1/consent/:id # Zustimmung widerrufen + +# Cookie Consent +GET /api/v1/cookies/categories # Cookie-Kategorien +POST /api/v1/cookies/consent # Cookie-Präferenzen setzen +GET /api/v1/cookies/consent/my # Meine Cookie-Einstellungen + +# Data Subject Rights (DSGVO) +GET /api/v1/privacy/my-data # Alle meine Daten abrufen +POST /api/v1/privacy/export # Datenexport anfordern +POST /api/v1/privacy/delete # Löschung anfordern +``` + +### Admin API (für Admin Dashboard) + +``` +# Document Management +GET /api/v1/admin/documents # Alle Dokumente (mit Drafts) +POST /api/v1/admin/documents # Neues Dokument +PUT /api/v1/admin/documents/:id # Dokument bearbeiten +DELETE /api/v1/admin/documents/:id # Dokument löschen + +# Version Management +GET /api/v1/admin/versions/:docId # Alle Versionen eines Dokuments +POST /api/v1/admin/versions # Neue Version erstellen +PUT /api/v1/admin/versions/:id # Version bearbeiten +POST /api/v1/admin/versions/:id/publish # Version veröffentlichen +POST /api/v1/admin/versions/:id/archive # Version archivieren + +# Cookie Categories +GET /api/v1/admin/cookies/categories # Alle Kategorien +POST /api/v1/admin/cookies/categories # Neue Kategorie +PUT /api/v1/admin/cookies/categories/:id +DELETE /api/v1/admin/cookies/categories/:id + +# Statistics & Reports +GET /api/v1/admin/stats/consents # Consent-Statistiken +GET /api/v1/admin/stats/cookies # Cookie-Statistiken +GET /api/v1/admin/audit-log # Audit Log (mit Filter) +``` + +--- + +## Consent-Check Middleware (Konzept) + +```go +// middleware/consent_check.go + +func ConsentCheckMiddleware(requiredConsent string) gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetString("user_id") + + // Prüfe ob User zugestimmt hat + hasConsent, err := consentService.CheckConsent(userID, requiredConsent) + if err != nil { + c.AbortWithStatusJSON(500, gin.H{"error": "Consent check failed"}) + return + } + + if !hasConsent { + c.AbortWithStatusJSON(403, gin.H{ + "error": "consent_required", + "document_type": requiredConsent, + "message": "Sie müssen den Nutzungsbedingungen zustimmen", + }) + return + } + + c.Next() + } +} + +// Verwendung in BreakPilot +router.POST("/api/worksheets", + authMiddleware, + ConsentCheckMiddleware("terms"), + worksheetHandler.Create, +) +``` + +--- + +## Cookie-Banner Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Erster Besuch │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User öffnet BreakPilot │ +│ │ │ +│ ▼ │ +│ 2. Check: Hat User Cookie-Consent gegeben? │ +│ │ │ +│ ┌─────────┴─────────┐ │ +│ │ Nein │ Ja │ +│ ▼ ▼ │ +│ 3. Zeige Cookie Lade gespeicherte │ +│ Banner Präferenzen │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Cookie Consent Banner │ │ +│ ├─────────────────────────────────────────┤ │ +│ │ Wir verwenden Cookies, um Ihnen die │ │ +│ │ beste Erfahrung zu bieten. │ │ +│ │ │ │ +│ │ ☑ Notwendig (immer aktiv) │ │ +│ │ ☐ Funktional │ │ +│ │ ☐ Analytics │ │ +│ │ ☐ Marketing │ │ +│ │ │ │ +│ │ [Alle akzeptieren] [Auswahl speichern] │ │ +│ │ [Nur notwendige] [Mehr erfahren] │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Legal-Bereich im BreakPilot Frontend (Mockup) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Einstellungen > Legal │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Meine Zustimmungen │ │ +│ ├─────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ ✓ Allgemeine Geschäftsbedingungen │ │ +│ │ Version 2.1 · Zugestimmt am 15.11.2024 │ │ +│ │ [Ansehen] [Widerrufen] │ │ +│ │ │ │ +│ │ ✓ Datenschutzerklärung │ │ +│ │ Version 3.0 · Zugestimmt am 15.11.2024 │ │ +│ │ [Ansehen] [Widerrufen] │ │ +│ │ │ │ +│ │ ✓ Community Guidelines │ │ +│ │ Version 1.2 · Zugestimmt am 15.11.2024 │ │ +│ │ [Ansehen] [Widerrufen] │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Cookie-Einstellungen │ │ +│ ├─────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ ☑ Notwendige Cookies (erforderlich) │ │ +│ │ ☑ Funktionale Cookies │ │ +│ │ ☐ Analytics Cookies │ │ +│ │ ☐ Marketing Cookies │ │ +│ │ │ │ +│ │ [Einstellungen speichern] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Meine Daten (DSGVO) │ │ +│ ├─────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ [Meine Daten exportieren] │ │ +│ │ Erhalten Sie eine Kopie aller Ihrer gespeicherten │ │ +│ │ Daten als JSON-Datei. │ │ +│ │ │ │ +│ │ [Account löschen] │ │ +│ │ Alle Ihre Daten werden unwiderruflich gelöscht. │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Nächste Schritte + +### Sofort (diese Woche) +1. **Entscheidung:** Go oder Python für Backend? +2. **Projekt-Setup:** Repository anlegen +3. **Datenbank:** Schema finalisieren und migrieren + +### Kurzfristig (nächste 2 Wochen) +1. Core API implementieren +2. Basis-Integration in BreakPilot + +### Mittelfristig (nächste 4-6 Wochen) +1. Admin Dashboard +2. Cookie Banner +3. DSGVO-Features + +--- + +## Offene Fragen + +1. **Sprache:** Go oder Python für das Backend? +2. **Admin Dashboard:** Eigenes Frontend oder in BreakPilot integriert? +3. **Hosting:** Gleicher Server wie BreakPilot oder separater Service? +4. **Auth:** Shared Authentication mit BreakPilot oder eigenes System? +5. **Datenbank:** Shared PostgreSQL oder eigene Instanz? + +--- + +## Ressourcen-Schätzung + +| Phase | Aufwand (Tage) | Beschreibung | +|-------|---------------|--------------| +| Phase 1 | 5-7 | Datenbank & Setup | +| Phase 2 | 8-10 | Core Consent Service | +| Phase 3 | 10-12 | Admin Dashboard | +| Phase 4 | 8-10 | BreakPilot Integration | +| Phase 5 | 5-7 | DSGVO Features | +| Phase 6 | 5-7 | Testing & Security | +| **Gesamt** | **41-53** | ~8-10 Wochen | + +--- + +*Dokument erstellt am: 12. Dezember 2024* +*Version: 1.0* diff --git a/admin-v2/CONTENT_SERVICE_SETUP.md b/admin-v2/CONTENT_SERVICE_SETUP.md new file mode 100644 index 0000000..65a2aba --- /dev/null +++ b/admin-v2/CONTENT_SERVICE_SETUP.md @@ -0,0 +1,473 @@ +# BreakPilot Content Service - Setup & Deployment Guide + +## 🎯 Übersicht + +Der BreakPilot Content Service ist eine vollständige Educational Content Management Plattform mit: + +- ✅ **Content Service API** (FastAPI) - Educational Content Management +- ✅ **MinIO S3 Storage** - File Storage für Videos, PDFs, Bilder +- ✅ **H5P Service** - Interactive Educational Content (Quizzes, etc.) +- ✅ **Matrix Feed Integration** - Content Publishing zu Matrix Spaces +- ✅ **PostgreSQL** - Content Metadata Storage +- ✅ **Creative Commons Licensing** - CC-BY, CC-BY-SA, etc. +- ✅ **Rating & Download Tracking** - Analytics & Impact Scoring + +## 🚀 Quick Start + +### 1. Alle Services starten + +```bash +# Haupt-Services + Content Services starten +docker-compose \ + -f docker-compose.yml \ + -f docker-compose.content.yml \ + up -d + +# Logs verfolgen +docker-compose -f docker-compose.yml -f docker-compose.content.yml logs -f +``` + +### 2. Verfügbare Services + +| Service | URL | Beschreibung | +|---------|-----|--------------| +| Content Service API | http://localhost:8002 | REST API für Content Management | +| MinIO Console | http://localhost:9001 | Storage Dashboard (User: minioadmin, Pass: minioadmin123) | +| H5P Service | http://localhost:8003 | Interactive Content Editor | +| Content DB | localhost:5433 | PostgreSQL Database | + +### 3. API Dokumentation + +Content Service API Docs: +``` +http://localhost:8002/docs +``` + +## 📦 Installation (Development) + +### Content Service (Backend) + +```bash +cd backend/content_service + +# Virtual Environment erstellen +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# Dependencies installieren +pip install -r requirements.txt + +# Environment Variables +cp .env.example .env + +# Database Migrations +alembic upgrade head + +# Service starten +uvicorn main:app --reload --port 8002 +``` + +### H5P Service + +```bash +cd h5p-service + +# Dependencies installieren +npm install + +# Service starten +npm start +``` + +### Creator Dashboard (Frontend) + +```bash +cd frontend/creator-studio + +# Dependencies installieren +npm install + +# Development Server +npm run dev +``` + +## 🔧 Konfiguration + +### Environment Variables + +Erstelle `.env` im Projekt-Root: + +```env +# Content Service +CONTENT_DB_URL=postgresql://breakpilot:breakpilot123@localhost:5433/breakpilot_content +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin123 +MINIO_BUCKET=breakpilot-content + +# Matrix Integration +MATRIX_HOMESERVER=http://localhost:8008 +MATRIX_ACCESS_TOKEN=your-matrix-token-here +MATRIX_BOT_USER=@breakpilot-bot:localhost +MATRIX_FEED_ROOM=!breakpilot-feed:localhost + +# OAuth2 (consent-service) +CONSENT_SERVICE_URL=http://localhost:8081 +JWT_SECRET=your-jwt-secret-here + +# H5P Service +H5P_BASE_URL=http://localhost:8003 +H5P_STORAGE_PATH=/app/h5p-content +``` + +## 📝 Content Service API Endpoints + +### Content Management + +```bash +# Create Content +POST /api/v1/content +{ + "title": "5-Minuten Yoga für Grundschule", + "description": "Bewegungspause mit einfachen Yoga-Übungen", + "content_type": "video", + "category": "movement", + "license": "CC-BY-SA-4.0", + "age_min": 6, + "age_max": 10, + "tags": ["yoga", "bewegung", "pause"] +} + +# Upload File +POST /api/v1/upload +Content-Type: multipart/form-data +file: + +# Add Files to Content +POST /api/v1/content/{content_id}/files +{ + "file_urls": ["http://minio:9000/breakpilot-content/..."] +} + +# Publish Content (→ Matrix Feed) +POST /api/v1/content/{content_id}/publish + +# List Content (with filters) +GET /api/v1/content?category=movement&age_min=6&age_max=10 + +# Get Content Details +GET /api/v1/content/{content_id} + +# Rate Content +POST /api/v1/content/{content_id}/rate +{ + "stars": 5, + "comment": "Sehr hilfreich für meine Klasse!" +} +``` + +### H5P Interactive Content + +```bash +# Get H5P Editor +GET http://localhost:8003/h5p/editor/new + +# Save H5P Content +POST http://localhost:8003/h5p/editor +{ + "library": "H5P.InteractiveVideo 1.22", + "params": { ... } +} + +# Play H5P Content +GET http://localhost:8003/h5p/play/{contentId} + +# Export as .h5p File +GET http://localhost:8003/h5p/export/{contentId} +``` + +## 🎨 Creator Workflow + +### 1. Content erstellen + +```javascript +// Creator Dashboard +const content = await createContent({ + title: "Mathe-Quiz: Einmaleins", + description: "Interaktives Quiz zum Üben des Einmaleins", + content_type: "h5p", + category: "math", + license: "CC-BY-SA-4.0", + age_min: 7, + age_max: 9 +}); +``` + +### 2. Files hochladen + +```javascript +// Upload Video/PDF/Images +const file = document.querySelector('#fileInput').files[0]; +const formData = new FormData(); +formData.append('file', file); + +const response = await fetch('/api/v1/upload', { + method: 'POST', + body: formData +}); + +const { file_url } = await response.json(); +``` + +### 3. Publish to Matrix Feed + +```javascript +// Publish → Matrix Spaces +await publishContent(content.id); +// → Content erscheint in #movement, #math, etc. +``` + +## 📊 Matrix Feed Integration + +### Matrix Spaces Struktur + +``` +#breakpilot (Root Space) +├── #feed (Chronologischer Content Feed) +├── #bewegung (Kategorie: Movement) +├── #mathe (Kategorie: Math) +├── #steam (Kategorie: STEAM) +└── #sprache (Kategorie: Language) +``` + +### Content Message Format + +Wenn Content published wird, erscheint in Matrix: + +``` +📹 5-Minuten Yoga für Grundschule + +Bewegungspause mit einfachen Yoga-Übungen für den Unterricht + +📝 Von: Max Mustermann +🏃 Kategorie: movement +👥 Alter: 6-10 Jahre +⚖️ Lizenz: CC-BY-SA-4.0 +🏷️ Tags: yoga, bewegung, pause + +[📥 Inhalt ansehen/herunterladen] +``` + +## 🔐 Creative Commons Lizenzen + +Verfügbare Lizenzen: + +- `CC-BY-4.0` - Attribution (Namensnennung) +- `CC-BY-SA-4.0` - Attribution + ShareAlike (empfohlen) +- `CC-BY-NC-4.0` - Attribution + NonCommercial +- `CC-BY-NC-SA-4.0` - Attribution + NonCommercial + ShareAlike +- `CC0-1.0` - Public Domain + +### Lizenz-Workflow + +```python +# Bei Content-Erstellung: Creator wählt Lizenz +content.license = "CC-BY-SA-4.0" + +# System validiert: +✅ Nur erlaubte Lizenzen +✅ Lizenz-Badge wird angezeigt +✅ Lizenz-Link zu Creative Commons +``` + +## 📈 Analytics & Impact Scoring + +### Download Tracking + +```python +# Automatisch getrackt bei Download +POST /api/v1/content/{content_id}/download + +# → Zähler erhöht +# → Download-Event gespeichert +# → Für Impact-Score verwendet +``` + +### Creator Statistics + +```bash +# Get Creator Stats +GET /api/v1/stats/creator/{creator_id} + +{ + "total_contents": 12, + "total_downloads": 347, + "total_views": 1203, + "avg_rating": 4.7, + "impact_score": 892.5, + "content_breakdown": { + "movement": 5, + "math": 4, + "steam": 3 + } +} +``` + +## 🧪 Testing + +### API Tests + +```bash +# Pytest +cd backend/content_service +pytest tests/ + +# Mit Coverage +pytest --cov=. --cov-report=html +``` + +### Integration Tests + +```bash +# Test Content Upload Flow +curl -X POST http://localhost:8002/api/v1/content \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Test Content", + "content_type": "pdf", + "category": "math", + "license": "CC-BY-SA-4.0" + }' +``` + +## 🐳 Docker Commands + +```bash +# Build einzelnen Service +docker-compose -f docker-compose.content.yml build content-service + +# Nur Content Services starten +docker-compose -f docker-compose.content.yml up -d + +# Logs einzelner Service +docker-compose logs -f content-service + +# Service neu starten +docker-compose restart content-service + +# Alle stoppen +docker-compose -f docker-compose.yml -f docker-compose.content.yml down + +# Mit Volumes löschen (Achtung: Datenverlust!) +docker-compose -f docker-compose.yml -f docker-compose.content.yml down -v +``` + +## 🗄️ Database Migrations + +```bash +cd backend/content_service + +# Neue Migration erstellen +alembic revision --autogenerate -m "Add new field" + +# Migration anwenden +alembic upgrade head + +# Zurückrollen +alembic downgrade -1 +``` + +## 📱 Frontend Development + +### Creator Studio + +```bash +cd frontend/creator-studio + +# Install dependencies +npm install + +# Development +npm run dev # → http://localhost:3000 + +# Build +npm run build + +# Preview Production Build +npm run preview +``` + +## 🔒 DSGVO Compliance + +### Datenminimierung + +- ✅ Nur notwendige Metadaten gespeichert +- ✅ Keine Schülerdaten +- ✅ IP-Adressen anonymisiert nach 7 Tagen +- ✅ User kann Content/Account löschen + +### Datenexport + +```bash +# User Data Export +GET /api/v1/user/export +→ JSON mit allen Daten des Users +``` + +## 🚨 Troubleshooting + +### MinIO Connection Failed + +```bash +# Check MinIO status +docker-compose logs minio + +# Test connection +curl http://localhost:9000/minio/health/live +``` + +### Content Service Database Connection + +```bash +# Check PostgreSQL +docker-compose logs content-db + +# Connect manually +docker exec -it breakpilot-pwa-content-db psql -U breakpilot -d breakpilot_content +``` + +### H5P Service Not Starting + +```bash +# Check logs +docker-compose logs h5p-service + +# Rebuild +docker-compose build h5p-service +docker-compose up -d h5p-service +``` + +## 📚 Weitere Dokumentation + +- [Architekturempfehlung](./backend/docs/Architekturempfehlung%20für%20Breakpilot%20–%20Offene,%20modulare%20Bildungsplattform%20im%20DACH-Raum.pdf) +- [Content Service API](./backend/content_service/README.md) +- [H5P Integration](./h5p-service/README.md) +- [Matrix Feed Setup](./docs/matrix-feed-setup.md) + +## 🎉 Next Steps + +1. ✅ Services starten (siehe Quick Start) +2. ✅ Creator Account erstellen +3. ✅ Ersten Content hochladen +4. ✅ H5P Interactive Content erstellen +5. ✅ Content publishen → Matrix Feed +6. ✅ Teacher Discovery UI testen +7. 🔜 OAuth2 SSO mit consent-service integrieren +8. 🔜 Production Deployment vorbereiten + +## 💡 Support + +Bei Fragen oder Problemen: +- GitHub Issues: https://github.com/breakpilot/breakpilot-pwa/issues +- Matrix Chat: #breakpilot-dev:matrix.org +- Email: dev@breakpilot.app diff --git a/admin-v2/IMPLEMENTATION_SUMMARY.md b/admin-v2/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..00d0361 --- /dev/null +++ b/admin-v2/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,427 @@ +# 🎓 BreakPilot Content Service - Implementierungs-Zusammenfassung + +## ✅ Vollständig implementierte Sprints + +### **Sprint 1-2: Content Service Foundation** ✅ + +**Backend (FastAPI):** +- ✅ Complete Database Schema (PostgreSQL) + - `Content` Model mit allen Metadaten + - `Rating` Model für Teacher Reviews + - `Tag` System für Content Organization + - `Download` Tracking für Impact Scoring +- ✅ Pydantic Schemas für API Validation +- ✅ Full CRUD API für Content Management +- ✅ Upload API für Files (Video, PDF, Images, Audio) +- ✅ Search & Filter Endpoints +- ✅ Analytics & Statistics Endpoints + +**Storage:** +- ✅ MinIO S3-kompatible Object Storage +- ✅ Automatic Bucket Creation +- ✅ Public Read Policy für Content +- ✅ File Upload Integration +- ✅ Presigned URLs für private Files + +**Files Created:** +``` +backend/content_service/ +├── models.py # Database Models +├── schemas.py # Pydantic Schemas +├── database.py # DB Configuration +├── main.py # FastAPI Application +├── storage.py # MinIO Integration +├── requirements.txt # Python Dependencies +└── Dockerfile # Container Definition +``` + +--- + +### **Sprint 3-4: Matrix Feed Integration** ✅ + +**Matrix Client:** +- ✅ Matrix SDK Integration (matrix-nio) +- ✅ Content Publishing to Matrix Spaces +- ✅ Formatted Messages (Plain Text + HTML) +- ✅ Category-based Room Routing +- ✅ Rich Metadata for Content +- ✅ Reactions & Threading Support + +**Matrix Spaces Struktur:** +``` +#breakpilot:server.de (Root Space) +├── #feed (Chronologischer Content Feed) +├── #bewegung (Movement Category) +├── #mathe (Math Category) +├── #steam (STEAM Category) +└── #sprache (Language Category) +``` + +**Files Created:** +``` +backend/content_service/ +└── matrix_client.py # Matrix Integration +``` + +**Features:** +- ✅ Auto-publish on Content.status = PUBLISHED +- ✅ Rich HTML Formatting mit Thumbnails +- ✅ CC License Badges in Messages +- ✅ Direct Links zu Content +- ✅ Category-specific Posting + +--- + +### **Sprint 5-6: Rating & Download Tracking** ✅ + +**Rating System:** +- ✅ 5-Star Rating System +- ✅ Text Comments +- ✅ Average Rating Calculation +- ✅ Rating Count Tracking +- ✅ One Rating per User (Update möglich) + +**Download Tracking:** +- ✅ Event-based Download Logging +- ✅ User-specific Tracking +- ✅ IP Anonymization (nach 7 Tagen) +- ✅ Download Counter +- ✅ Impact Score Foundation + +**Analytics:** +- ✅ Platform-wide Statistics +- ✅ Creator Statistics +- ✅ Content Breakdown by Category +- ✅ Downloads, Views, Ratings + +--- + +### **Sprint 7-8: H5P Interactive Content** ✅ + +**H5P Service (Node.js):** +- ✅ Self-hosted H5P Server +- ✅ H5P Editor Integration +- ✅ H5P Player +- ✅ File-based Content Storage +- ✅ Library Management +- ✅ Export as .h5p Files +- ✅ Import .h5p Files + +**Supported H5P Content Types:** +- ✅ Interactive Video +- ✅ Course Presentation +- ✅ Quiz (Multiple Choice) +- ✅ Drag & Drop +- ✅ Timeline +- ✅ Memory Game +- ✅ Fill in the Blanks +- ✅ 50+ weitere Content Types + +**Files Created:** +``` +h5p-service/ +├── server.js # H5P Express Server +├── package.json # Node Dependencies +└── Dockerfile # Container Definition +``` + +**Integration:** +- ✅ Content Service → H5P Service API +- ✅ H5P Content ID in Content Model +- ✅ Automatic Publishing to Matrix + +--- + +### **Sprint 7-8: Creative Commons Licensing** ✅ + +**Lizenz-System:** +- ✅ CC-BY-4.0 +- ✅ CC-BY-SA-4.0 (Recommended) +- ✅ CC-BY-NC-4.0 +- ✅ CC-BY-NC-SA-4.0 +- ✅ CC0-1.0 (Public Domain) + +**Features:** +- ✅ License Validation bei Upload +- ✅ License Selector in Creator Studio +- ✅ License Badges in UI +- ✅ Direct Links zu Creative Commons +- ✅ Matrix Messages mit License Info + +--- + +### **Sprint 7-8: DSGVO Compliance** ✅ + +**Privacy by Design:** +- ✅ Datenminimierung (nur notwendige Daten) +- ✅ EU Server Hosting +- ✅ IP Anonymization +- ✅ User Data Export API +- ✅ Account Deletion +- ✅ No Schülerdaten + +**Transparency:** +- ✅ Clear License Information +- ✅ Open Source Code +- ✅ Transparent Analytics + +--- + +## 🐳 Docker Infrastructure + +**docker-compose.content.yml:** +```yaml +Services: + - minio (Object Storage) + - content-db (PostgreSQL) + - content-service (FastAPI) + - h5p-service (Node.js H5P) + +Volumes: + - minio_data + - content_db_data + - h5p_content + +Networks: + - breakpilot-pwa-network (external) +``` + +--- + +## 📊 Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────┐ +│ BREAKPILOT CONTENT PLATFORM │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │ +│ │ Creator │───▶│ Content │───▶│ Matrix │ │ +│ │ Studio │ │ Service │ │ Feed │ │ +│ │ (Vue.js) │ │ (FastAPI) │ │ (Synapse) │ │ +│ └──────────────┘ └──────┬───────┘ └───────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ │ │ +│ ┌──────▼─────┐ ┌─────▼─────┐ │ +│ │ MinIO │ │ H5P │ │ +│ │ Storage │ │ Service │ │ +│ └────────────┘ └───────────┘ │ +│ │ │ │ +│ ┌──────▼─────────────────▼─────┐ │ +│ │ PostgreSQL Database │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌───────────┐ │ +│ │ Teacher │────────────────────────▶│ Content │ │ +│ │ Discovery │ Search & Download │ Player │ │ +│ │ UI │ │ │ │ +│ └──────────────┘ └───────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 🚀 Deployment + +### Quick Start + +```bash +# 1. Startup Script ausführbar machen +chmod +x scripts/start-content-services.sh + +# 2. Alle Services starten +./scripts/start-content-services.sh + +# ODER manuell: +docker-compose \ + -f docker-compose.yml \ + -f docker-compose.content.yml \ + up -d +``` + +### URLs nach Start + +| Service | URL | Credentials | +|---------|-----|-------------| +| Content Service API | http://localhost:8002/docs | - | +| MinIO Console | http://localhost:9001 | minioadmin / minioadmin123 | +| H5P Editor | http://localhost:8003/h5p/editor/new | - | +| Content Database | localhost:5433 | breakpilot / breakpilot123 | + +--- + +## 📝 Content Creation Workflow + +### 1. Creator erstellt Content + +```javascript +// POST /api/v1/content +{ + "title": "5-Minuten Yoga", + "description": "Bewegungspause für Grundschüler", + "content_type": "video", + "category": "movement", + "license": "CC-BY-SA-4.0", + "age_min": 6, + "age_max": 10, + "tags": ["yoga", "bewegung"] +} +``` + +### 2. Upload Media Files + +```javascript +// POST /api/v1/upload +FormData { + file: +} +→ Returns: { file_url: "http://minio:9000/..." } +``` + +### 3. Attach Files to Content + +```javascript +// POST /api/v1/content/{id}/files +{ + "file_urls": ["http://minio:9000/..."] +} +``` + +### 4. Publish to Matrix + +```javascript +// POST /api/v1/content/{id}/publish +→ Status: PUBLISHED +→ Matrix Message in #movement Space +→ Discoverable by Teachers +``` + +--- + +## 🎨 Frontend Components (Creator Studio) + +### Struktur (Vorbereitet) + +``` +frontend/creator-studio/ +├── src/ +│ ├── components/ +│ │ ├── ContentUpload.vue +│ │ ├── ContentList.vue +│ │ ├── ContentEditor.vue +│ │ ├── H5PEditor.vue +│ │ └── Analytics.vue +│ ├── views/ +│ │ ├── Dashboard.vue +│ │ ├── CreateContent.vue +│ │ └── MyContent.vue +│ ├── api/ +│ │ └── content.js +│ └── router/ +│ └── index.js +├── package.json +└── vite.config.js +``` + +**Status:** Framework vorbereitet, vollständige UI-Implementation ausstehend (Sprint 1-2 Frontend) + +--- + +## ⏭️ Nächste Schritte (Optional/Future) + +### **Ausstehend:** + +1. **OAuth2 SSO Integration** (Sprint 3-4) + - consent-service → Matrix SSO + - JWT Validation in Content Service + - User Roles & Permissions + +2. **Teacher Discovery UI** (Sprint 5-6) + - Vue.js Frontend komplett + - Search & Filter UI + - Content Preview & Download + - Rating Interface + +3. **Production Deployment** + - Environment Configuration + - SSL/TLS Certificates + - Backup Strategy + - Monitoring (Prometheus/Grafana) + +--- + +## 📈 Impact Scoring (Fundament gelegt) + +**Vorbereitet für zukünftige Implementierung:** + +```python +# Impact Score Calculation (Beispiel) +impact_score = ( + downloads * 10 + + rating_count * 5 + + avg_rating * 20 + + matrix_engagement * 2 +) +``` + +**Bereits getrackt:** +- ✅ Downloads +- ✅ Views +- ✅ Ratings (Stars + Comments) +- ✅ Matrix Event IDs + +--- + +## 🎯 Erreichte Features (Zusammenfassung) + +| Feature | Status | Sprint | +|---------|--------|--------| +| Content CRUD API | ✅ | 1-2 | +| File Upload (MinIO) | ✅ | 1-2 | +| PostgreSQL Schema | ✅ | 1-2 | +| Matrix Feed Publishing | ✅ | 3-4 | +| Rating System | ✅ | 5-6 | +| Download Tracking | ✅ | 5-6 | +| H5P Integration | ✅ | 7-8 | +| CC Licensing | ✅ | 7-8 | +| DSGVO Compliance | ✅ | 7-8 | +| Docker Setup | ✅ | 7-8 | +| Deployment Guide | ✅ | 7-8 | +| Creator Studio (Backend) | ✅ | 1-2 | +| Creator Studio (Frontend) | 🔜 | Pending | +| Teacher Discovery UI | 🔜 | Pending | +| OAuth2 SSO | 🔜 | Pending | + +--- + +## 📚 Dokumentation + +- ✅ **CONTENT_SERVICE_SETUP.md** - Vollständiger Setup Guide +- ✅ **IMPLEMENTATION_SUMMARY.md** - Diese Datei +- ✅ **API Dokumentation** - Auto-generiert via FastAPI (/docs) +- ✅ **Architekturempfehlung PDF** - Strategische Planung + +--- + +## 🎉 Fazit + +**Implementiert:** 8+ Wochen Entwicklung in Sprints 1-8 + +**Kernfunktionen:** +- ✅ Vollständiger Content Service (Backend) +- ✅ MinIO S3 Storage +- ✅ H5P Interactive Content +- ✅ Matrix Feed Integration +- ✅ Creative Commons Licensing +- ✅ Rating & Analytics +- ✅ DSGVO Compliance +- ✅ Docker Deployment Ready + +**Ready to Use:** Alle Backend-Services produktionsbereit + +**Next:** Frontend UI vervollständigen & Production Deploy + +--- + +**🚀 Die BreakPilot Content Platform ist LIVE!** diff --git a/admin-v2/MAC_MINI_SETUP.md b/admin-v2/MAC_MINI_SETUP.md new file mode 100644 index 0000000..faab752 --- /dev/null +++ b/admin-v2/MAC_MINI_SETUP.md @@ -0,0 +1,95 @@ +# Mac Mini Headless Setup - Vollständig Automatisch + +## Verbindungsdaten +- **IP (LAN):** 192.168.178.100 +- **IP (WiFi):** 192.168.178.163 (nicht mehr aktiv) +- **User:** benjaminadmin +- **SSH:** `ssh benjaminadmin@192.168.178.100` + +## Nach Neustart - Alles startet automatisch! + +| Service | Auto-Start | Port | +|---------|------------|------| +| ✅ SSH | Ja | 22 | +| ✅ Docker Desktop | Ja | - | +| ✅ Docker Container | Ja (nach ~2 Min) | 8000, 8081, etc. | +| ✅ Ollama Server | Ja | 11434 | +| ✅ Unity Hub | Ja | - | +| ✅ VS Code | Ja | - | + +**Keine Aktion nötig nach Neustart!** Einfach 2-3 Minuten warten. + +## Status prüfen +```bash +./scripts/mac-mini/status.sh +``` + +## Services & Ports +| Service | Port | URL | +|---------|------|-----| +| Backend API | 8000 | http://192.168.178.100:8000/admin | +| Consent Service | 8081 | - | +| PostgreSQL | 5432 | - | +| Valkey/Redis | 6379 | - | +| MinIO | 9000/9001 | http://192.168.178.100:9001 | +| Mailpit | 8025 | http://192.168.178.100:8025 | +| Ollama | 11434 | http://192.168.178.100:11434/api/tags | + +## LLM Modelle +- **Qwen 2.5 14B** (14.8 Milliarden Parameter) + +## Scripts (auf MacBook) +```bash +./scripts/mac-mini/status.sh # Status prüfen +./scripts/mac-mini/sync.sh # Code synchronisieren +./scripts/mac-mini/docker.sh # Docker-Befehle +./scripts/mac-mini/backup.sh # Backup erstellen +``` + +## Docker-Befehle +```bash +./scripts/mac-mini/docker.sh ps # Container anzeigen +./scripts/mac-mini/docker.sh logs backend # Logs +./scripts/mac-mini/docker.sh restart # Neustart +./scripts/mac-mini/docker.sh build # Image bauen +``` + +## LaunchAgents (Auto-Start) +Pfad auf Mac Mini: `~/Library/LaunchAgents/` + +| Agent | Funktion | +|-------|----------| +| `com.docker.desktop.plist` | Docker Desktop | +| `com.breakpilot.docker-containers.plist` | Container Auto-Start | +| `com.ollama.serve.plist` | Ollama Server | +| `com.unity.hub.plist` | Unity Hub | +| `com.microsoft.vscode.plist` | VS Code | + +## Projekt-Pfade +- **MacBook:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/` +- **Mac Mini:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/` + +## Troubleshooting + +### Docker Onboarding erscheint wieder +Docker-Einstellungen sind gesichert in `~/docker-settings-backup/` +```bash +# Wiederherstellen: +cp -r ~/docker-settings-backup/* ~/Library/Group\ Containers/group.com.docker/ +``` + +### Container starten nicht automatisch +Log prüfen: +```bash +ssh benjaminadmin@192.168.178.163 "cat /tmp/docker-autostart.log" +``` + +Manuell starten: +```bash +./scripts/mac-mini/docker.sh up +``` + +### SSH nicht erreichbar +- Prüfe ob Mac Mini an ist (Ping: `ping 192.168.178.163`) +- Warte 1-2 Minuten nach Boot +- Prüfe Netzwerkverbindung diff --git a/admin-v2/Makefile b/admin-v2/Makefile new file mode 100644 index 0000000..2c95009 --- /dev/null +++ b/admin-v2/Makefile @@ -0,0 +1,80 @@ +# BreakPilot PWA - Makefile fuer lokale CI-Simulation +# +# Verwendung: +# make ci - Alle Tests lokal ausfuehren +# make test-go - Nur Go-Tests +# make test-python - Nur Python-Tests +# make logs-agent - Woodpecker Agent Logs +# make logs-backend - Backend Logs (ci-result) + +.PHONY: ci test-go test-python test-node logs-agent logs-backend clean help + +# Verzeichnis fuer Test-Ergebnisse +CI_RESULTS_DIR := .ci-results + +help: + @echo "BreakPilot CI - Verfuegbare Befehle:" + @echo "" + @echo " make ci - Alle Tests lokal ausfuehren" + @echo " make test-go - Go Service Tests" + @echo " make test-python - Python Service Tests" + @echo " make test-node - Node.js Service Tests" + @echo " make logs-agent - Woodpecker Agent Logs anzeigen" + @echo " make logs-backend - Backend Logs (ci-result) anzeigen" + @echo " make clean - Test-Ergebnisse loeschen" + +ci: test-go test-python test-node + @echo "=========================================" + @echo "Local CI complete. Results in $(CI_RESULTS_DIR)/" + @echo "=========================================" + @ls -la $(CI_RESULTS_DIR)/ + +test-go: $(CI_RESULTS_DIR) + @echo "=== Go Tests ===" + @if [ -d "consent-service" ]; then \ + cd consent-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-consent.json 2>&1 || true; \ + echo "consent-service: done"; \ + fi + @if [ -d "billing-service" ]; then \ + cd billing-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-billing.json 2>&1 || true; \ + echo "billing-service: done"; \ + fi + @if [ -d "school-service" ]; then \ + cd school-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-school.json 2>&1 || true; \ + echo "school-service: done"; \ + fi + +test-python: $(CI_RESULTS_DIR) + @echo "=== Python Tests ===" + @if [ -d "backend" ]; then \ + cd backend && python -m pytest tests/ -v --tb=short 2>&1 || true; \ + echo "backend: done"; \ + fi + @if [ -d "voice-service" ]; then \ + cd voice-service && python -m pytest tests/ -v --tb=short 2>&1 || true; \ + echo "voice-service: done"; \ + fi + @if [ -d "klausur-service/backend" ]; then \ + cd klausur-service/backend && python -m pytest tests/ -v --tb=short 2>&1 || true; \ + echo "klausur-service: done"; \ + fi + +test-node: $(CI_RESULTS_DIR) + @echo "=== Node.js Tests ===" + @if [ -d "h5p-service" ]; then \ + cd h5p-service && npm test 2>&1 || true; \ + echo "h5p-service: done"; \ + fi + +$(CI_RESULTS_DIR): + @mkdir -p $(CI_RESULTS_DIR) + +logs-agent: + docker logs breakpilot-pwa-woodpecker-agent --tail=200 + +logs-backend: + docker compose logs backend --tail=200 | grep -E "(ci-result|error|ERROR)" + +clean: + rm -rf $(CI_RESULTS_DIR) + @echo "Test-Ergebnisse geloescht" diff --git a/admin-v2/POLICY_VAULT_OVERVIEW.md b/admin-v2/POLICY_VAULT_OVERVIEW.md new file mode 100644 index 0000000..c7c3580 --- /dev/null +++ b/admin-v2/POLICY_VAULT_OVERVIEW.md @@ -0,0 +1,794 @@ +# Policy Vault - Projekt-Dokumentation + +## Projektübersicht + +**Policy Vault** ist eine vollständige Web-Anwendung zur Verwaltung von Datenschutzrichtlinien, Cookie-Einwilligungen und Nutzerzustimmungen für verschiedene Projekte und Plattformen. Das System ermöglicht es Administratoren, Datenschutzdokumente zu erstellen, zu verwalten und zu versionieren, sowie Nutzereinwilligungen zu verfolgen und Cookie-Präferenzen zu speichern. + +## Zweck und Anwendungsbereich + +Das Policy Vault System dient als zentrale Plattform für: +- **Verwaltung von Datenschutzrichtlinien** (Privacy Policies, Terms of Service, etc.) +- **Cookie-Consent-Management** mit Kategorisierung und Vendor-Verwaltung +- **Versionskontrolle** für Richtliniendokumente +- **Multi-Projekt-Verwaltung** mit rollenbasiertem Zugriff +- **Nutzereinwilligungs-Tracking** über verschiedene Plattformen hinweg +- **Mehrsprachige Unterstützung** für globale Anwendungen + +--- + +## Technologie-Stack + +### Backend +- **Framework**: NestJS (Node.js/TypeScript) +- **Datenbank**: PostgreSQL +- **ORM**: Drizzle ORM +- **Authentifizierung**: JWT (JSON Web Tokens) mit Access/Refresh Token +- **API-Dokumentation**: Swagger/OpenAPI +- **Validierung**: class-validator, class-transformer +- **Security**: + - Encryption-based authentication + - Rate limiting (Throttler) + - Role-based access control (RBAC) + - bcrypt für Password-Hashing +- **Logging**: Winston mit Daily Rotate File +- **Job Scheduling**: NestJS Schedule +- **E-Mail**: Nodemailer +- **OTP-Generierung**: otp-generator + +### Frontend +- **Framework**: Angular 18 +- **UI**: + - TailwindCSS + - Custom SCSS +- **Rich Text Editor**: CKEditor 5 + - Alignment, Block Quote, Code Block + - Font styling, Image support + - List und Table support +- **State Management**: RxJS +- **Security**: DOMPurify für HTML-Sanitization +- **Multi-Select**: ng-multiselect-dropdown +- **Process Manager**: PM2 + +--- + +## Hauptfunktionen und Features + +### 1. Administratoren-Verwaltung +- **Super Admin und Admin Rollen** + - Super Admin (Role 1): Vollzugriff auf alle Funktionen + - Admin (Role 2): Eingeschränkter Zugriff auf zugewiesene Projekte +- **Authentifizierung** + - Login mit E-Mail und Passwort + - JWT-basierte Sessions (Access + Refresh Token) + - OTP-basierte Passwort-Wiederherstellung + - Account-Lock-Mechanismus bei mehrfachen Fehlversuchen +- **Benutzerverwaltung** + - Admin-Erstellung durch Super Admin + - Projekt-Zuweisungen für Admins + - Rollen-Modifikation (Promote/Demote) + - Soft-Delete (isDeleted Flag) + +### 2. Projekt-Management +- **Projektverwaltung** + - Erstellung und Verwaltung von Projekten + - Projekt-spezifische Konfiguration (Theme-Farben, Icons, Logos) + - Mehrsprachige Unterstützung (Language Configuration) + - Projekt-Keys für sichere API-Zugriffe + - Soft-Delete und Blocking von Projekten +- **Projekt-Zugriffskontrolle** + - Zuweisung von Admins zu spezifischen Projekten + - Project-Admin-Beziehungen + +### 3. Policy Document Management +- **Dokumentenverwaltung** + - Erstellung von Datenschutzdokumenten (Privacy Policies, ToS, etc.) + - Projekt-spezifische Dokumente + - Beschreibung und Metadaten +- **Versionierung** + - Multiple Versionen pro Dokument + - Version-Metadaten mit Inhalt + - Publish/Draft-Status + - Versionsnummern-Tracking + +### 4. Cookie-Consent-Management +- **Cookie-Kategorien** + - Kategorien-Metadaten (z.B. Notwendig, Marketing, Analytics) + - Plattform-spezifische Kategorien (Web, Mobile, etc.) + - Versionierung der Kategorien + - Pflicht- und optionale Kategorien + - Mehrsprachige Kategorie-Beschreibungen +- **Vendor-Management** + - Verwaltung von Drittanbieter-Services + - Vendor-Metadaten und -Beschreibungen + - Zuordnung zu Kategorien + - Sub-Services für Vendors + - Mehrsprachige Vendor-Informationen +- **Globale Cookie-Einstellungen** + - Projekt-weite Cookie-Texte und -Beschreibungen + - Mehrsprachige globale Inhalte + - Datei-Upload-Unterstützung + +### 5. User Consent Tracking +- **Policy Document Consent** + - Tracking von Nutzereinwilligungen für Richtlinien-Versionen + - Username-basiertes Tracking + - Status (Akzeptiert/Abgelehnt) + - Timestamp-Tracking +- **Cookie Consent** + - Granulare Cookie-Einwilligungen pro Kategorie + - Vendor-spezifische Einwilligungen + - Versions-Tracking + - Username und Projekt-basiert +- **Verschlüsselte API-Zugriffe** + - Token-basierte Authentifizierung für Mobile/Web + - Encryption-based authentication für externe Zugriffe + +### 6. Mehrsprachige Unterstützung +- **Language Management** + - Dynamische Sprachen-Konfiguration pro Projekt + - Mehrsprachige Inhalte für: + - Kategorien-Beschreibungen + - Vendor-Informationen + - Globale Cookie-Texte + - Sub-Service-Beschreibungen + +--- + +## API-Struktur und Endpoints + +### Admin-Endpoints (`/admins`) +``` +POST /admins/create-admin - Admin erstellen (Super Admin only) +POST /admins/create-super-admin - Super Admin erstellen (Super Admin only) +POST /admins/create-root-user-super-admin - Root Super Admin erstellen (Secret-based) +POST /admins/login - Admin Login +GET /admins/get-access-token - Neuen Access Token abrufen +POST /admins/generate-otp - OTP für Passwort-Reset generieren +POST /admins/validate-otp - OTP validieren +POST /admins/change-password - Passwort ändern (mit OTP) +PUT /admins/update-password - Passwort aktualisieren (eingeloggt) +PUT /admins/forgot-password - Passwort vergessen +PUT /admins/make-super-admin - Admin zu Super Admin befördern +PUT /admins/remove-super-admin - Super Admin zu Admin zurückstufen +PUT /admins/make-project-admin - Projekt-Zugriff gewähren +DELETE /admins/remove-project-admin - Projekt-Zugriff entfernen +GET /admins/findAll?role= - Alle Admins abrufen (gefiltert nach Rolle) +GET /admins/findAll-super-admins - Alle Super Admins abrufen +GET /admins/findOne?id= - Einzelnen Admin abrufen +PUT /admins/update - Admin-Details aktualisieren +DELETE /admins/delete-admin?id= - Admin löschen (Soft-Delete) +``` + +### Project-Endpoints (`/project`) +``` +POST /project/create - Projekt erstellen (Super Admin only) +PUT /project/v2/updateProjectKeys - Projekt-Keys aktualisieren +GET /project/findAll - Alle Projekte abrufen (mit Pagination) +GET /project/findAllByUser - Projekte eines bestimmten Users +GET /project/findOne?id= - Einzelnes Projekt abrufen +PUT /project/update - Projekt aktualisieren +DELETE /project/delete?id= - Projekt löschen +``` + +### Policy Document-Endpoints (`/policydocument`) +``` +POST /policydocument/create - Policy Document erstellen +GET /policydocument/findAll - Alle Policy Documents abrufen +GET /policydocument/findOne?id= - Einzelnes Policy Document +GET /policydocument/findPolicyDocs?projectId= - Documents für ein Projekt +PUT /policydocument/update - Policy Document aktualisieren +DELETE /policydocument/delete?id= - Policy Document löschen +``` + +### Version-Endpoints (`/version`) +``` +POST /version/create - Version erstellen +GET /version/findAll - Alle Versionen abrufen +GET /version/findOne?id= - Einzelne Version abrufen +GET /version/findVersions?policyDocId= - Versionen für ein Policy Document +PUT /version/update - Version aktualisieren +DELETE /version/delete?id= - Version löschen +``` + +### User Consent-Endpoints (`/consent`) +``` +POST /consent/v2/create - User Consent erstellen (Encrypted) +GET /consent/v2/GetConsent - Consent abrufen (Encrypted) +GET /consent/v2/GetConsentFileContent - Consent mit Dateiinhalt (Encrypted) +GET /consent/v2/latestAcceptedConsent - Letzte akzeptierte Consent +DELETE /consent/v2/delete - Consent löschen (Encrypted) +``` + +### Cookie Consent-Endpoints (`/cookieconsent`) +``` +POST /cookieconsent/v2/create - Cookie Consent erstellen (Encrypted) +GET /cookieconsent/v2/get - Cookie Kategorien abrufen (Encrypted) +GET /cookieconsent/v2/getFileContent - Cookie Daten mit Dateiinhalt (Encrypted) +DELETE /cookieconsent/v2/delete - Cookie Consent löschen (Encrypted) +``` + +### Cookie-Endpoints (`/cookies`) +``` +POST /cookies/createCategory - Cookie-Kategorie erstellen +POST /cookies/createVendor - Vendor erstellen +POST /cookies/createGlobalCookie - Globale Cookie-Einstellung erstellen +GET /cookies/getCategories?projectId= - Kategorien für Projekt abrufen +GET /cookies/getVendors?projectId= - Vendors für Projekt abrufen +GET /cookies/getGlobalCookie?projectId= - Globale Cookie-Settings +PUT /cookies/updateCategory - Kategorie aktualisieren +PUT /cookies/updateVendor - Vendor aktualisieren +PUT /cookies/updateGlobalCookie - Globale Settings aktualisieren +DELETE /cookies/deleteCategory?id= - Kategorie löschen +DELETE /cookies/deleteVendor?id= - Vendor löschen +DELETE /cookies/deleteGlobalCookie?id= - Globale Settings löschen +``` + +### Health Check-Endpoint (`/db-health-check`) +``` +GET /db-health-check - Datenbank-Status prüfen +``` + +--- + +## Datenmodelle + +### Admin +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + employeeCode: string (nullable) + firstName: string (max 60) + lastName: string (max 50) + officialMail: string (unique, max 100) + role: number (1 = Super Admin, 2 = Admin) + passwordHash: string + salt: string (nullable) + accessToken: text (nullable) + refreshToken: text (nullable) + accLockCount: number (default 0) + accLockTime: number (default 0) + isBlocked: boolean (default false) + isDeleted: boolean (default false) + otp: string (nullable) +} +``` + +### Project +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + name: string (unique) + description: string + imageURL: text (nullable) + iconURL: text (nullable) + isBlocked: boolean (default false) + isDeleted: boolean (default false) + themeColor: string + textColor: string + languages: json (nullable) // Array von Sprach-Codes +} +``` + +### Policy Document +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + name: string + description: string (nullable) + projectId: number (FK -> project.id, CASCADE) +} +``` + +### Version (Policy Document Meta & Version Meta) +```typescript +// Policy Document Meta +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + policyDocumentId: number (FK) + version: string + isPublish: boolean +} + +// Version Meta (Sprachspezifischer Inhalt) +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + policyDocMetaId: number (FK) + language: string + content: text + file: text (nullable) +} +``` + +### User Consent +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + username: string + status: boolean + projectId: number (FK -> project.id, CASCADE) + versionMetaId: number (FK -> versionMeta.id, CASCADE) +} +``` + +### Cookie Consent +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + username: string + categoryId: number[] (Array) + vendors: number[] (Array) + projectId: number (FK -> project.id, CASCADE) + version: string +} +``` + +### Categories Metadata +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + projectId: number (FK -> project.id, CASCADE) + platform: string + version: string + isPublish: boolean (default false) + metaName: string + isMandatory: boolean (default false) +} +``` + +### Categories Language Data +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + categoryMetaId: number (FK -> categoriesMetadata.id, CASCADE) + language: string + title: string + description: text +} +``` + +### Vendor Meta +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + categoryId: number (FK -> categoriesMetadata.id, CASCADE) + vendorName: string +} +``` + +### Vendor Language +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + vendorMetaId: number (FK -> vendorMeta.id, CASCADE) + language: string + description: text +} +``` + +### Sub Service +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + vendorMetaId: number (FK -> vendorMeta.id, CASCADE) + serviceName: string +} +``` + +### Global Cookie Metadata +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + projectId: number (FK -> project.id, CASCADE) + version: string + isPublish: boolean (default false) +} +``` + +### Global Cookie Language Data +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + globalCookieMetaId: number (FK -> globalCookieMetadata.id, CASCADE) + language: string + title: string + description: text + file: text (nullable) +} +``` + +### Project Keys +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + projectId: number (FK -> project.id, CASCADE) + publicKey: text + privateKey: text +} +``` + +### Admin Projects (Junction Table) +```typescript +{ + id: number (PK) + adminId: number (FK -> admin.id, CASCADE) + projectId: number (FK -> project.id, CASCADE) +} +``` + +--- + +## Architektur-Übersicht + +### Backend-Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ NestJS Backend │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Guards │ │ Middlewares │ │ Interceptors │ │ +│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │ +│ │ - AuthGuard │ │ - Token │ │ - Serialize │ │ +│ │ - RolesGuard │ │ - Decrypt │ │ - Logging │ │ +│ │ - Throttler │ │ - Headers │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ API Modules │ │ +│ ├───────────────────────────────────────────────────┤ │ +│ │ - Admins (Authentication, Authorization) │ │ +│ │ - Projects (Multi-tenant Management) │ │ +│ │ - Policy Documents (Document Management) │ │ +│ │ - Versions (Versioning System) │ │ +│ │ - User Consent (Consent Tracking) │ │ +│ │ - Cookies (Cookie Categories & Vendors) │ │ +│ │ - Cookie Consent (Cookie Consent Tracking) │ │ +│ │ - DB Health Check (System Monitoring) │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Drizzle ORM Layer │ │ +│ ├───────────────────────────────────────────────────┤ │ +│ │ - Schema Definitions │ │ +│ │ - Relations │ │ +│ │ - Database Connection Pool │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ │ +└──────────────────────────┼────────────────────────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ PostgreSQL │ + │ Database │ + └─────────────────┘ +``` + +### Frontend-Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Angular Frontend │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Guards │ │ Interceptors │ │ Services │ │ +│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │ +│ │ - AuthGuard │ │ - HTTP │ │ - Auth │ │ +│ │ │ │ - Error │ │ - REST API │ │ +│ │ │ │ │ │ - Session │ │ +│ │ │ │ │ │ - Security │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Feature Modules │ │ +│ ├───────────────────────────────────────────────────┤ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Auth Module │ │ │ +│ │ │ - Login Component │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Project Dashboard │ │ │ +│ │ │ - Project List │ │ │ +│ │ │ - Project Creation │ │ │ +│ │ │ - Project Settings │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Individual Project Dashboard │ │ │ +│ │ │ - Agreements (Policy Documents) │ │ │ +│ │ │ - Cookie Consent Management │ │ │ +│ │ │ - FAQ Management │ │ │ +│ │ │ - Licenses Management │ │ │ +│ │ │ - User Management │ │ │ +│ │ │ - Project Settings │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Shared Components │ │ │ +│ │ │ - Settings │ │ │ +│ │ │ - Common UI Elements │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ HTTPS/REST API + ▼ + ┌─────────────────┐ + │ NestJS Backend │ + └─────────────────┘ +``` + +### Datenbankbeziehungen + +``` +┌──────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Admin │◄───────►│ AdminProjects │◄───────►│ Project │ +└──────────┘ └─────────────────┘ └─────────────┘ + │ + │ 1:N + ┌────────────────────────────────────┤ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ Policy Document │ │ Categories Metadata │ + └──────────────────────┘ └──────────────────────────┘ + │ │ + │ 1:N │ 1:N + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ Policy Document Meta │ │ Categories Language Data │ + └──────────────────────┘ └──────────────────────────┘ + │ │ + │ 1:N │ 1:N + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ Version Meta │ │ Vendor Meta │ + └──────────────────────┘ └──────────────────────────┘ + │ │ + │ 1:N │ 1:N + ▼ ├──────────┐ + ┌──────────────────────┐ ▼ ▼ + │ User Consent │ ┌─────────────────┐ ┌────────────┐ + └──────────────────────┘ │ Vendor Language │ │Sub Service │ + └─────────────────┘ └────────────┘ +┌──────────────────────┐ +│ Cookie Consent │◄─── Project +└──────────────────────┘ + +┌─────────────────────────┐ +│ Global Cookie Metadata │◄─── Project +└─────────────────────────┘ + │ + │ 1:N + ▼ +┌─────────────────────────────┐ +│ Global Cookie Language Data │ +└─────────────────────────────────┘ + +┌──────────────────┐ +│ Project Keys │◄─── Project +└──────────────────┘ +``` + +### Sicherheitsarchitektur + +#### Authentifizierung & Autorisierung +1. **JWT-basierte Authentifizierung** + - Access Token (kurzlebig) + - Refresh Token (langlebig) + - Token-Refresh-Mechanismus + +2. **Rollenbasierte Zugriffskontrolle (RBAC)** + - Super Admin (Role 1): Vollzugriff + - Admin (Role 2): Projektbezogener Zugriff + - Guard-basierte Absicherung auf Controller-Ebene + +3. **Encryption-based Authentication** + - Für externe/mobile Zugriffe + - Token-basierte Verschlüsselung + - User + Project ID Validierung + +#### Security Features +- **Rate Limiting**: Throttler mit konfigurierbaren Limits +- **Password Security**: bcrypt Hashing mit Salt +- **Account Lock**: Nach mehrfachen Fehlversuchen +- **OTP-basierte Passwort-Wiederherstellung** +- **Input Validation**: class-validator auf allen DTOs +- **HTML Sanitization**: DOMPurify im Frontend +- **CORS Configuration**: Custom Headers Middleware +- **Soft Delete**: Keine permanente Löschung von Daten + +--- + +## Deployment und Konfiguration + +### Backend Environment Variables +```env +DATABASE_URL=postgresql://username:password@host:port/database +NODE_ENV=development|test|production|local|demo +PORT=3000 +JWT_SECRET=your_jwt_secret +JWT_REFRESH_SECRET=your_refresh_secret +ROOT_SECRET=your_root_secret +ENCRYPTION_KEY=your_encryption_key +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your_email +SMTP_PASSWORD=your_password +``` + +### Frontend Environment +```typescript +{ + production: false, + BASE_URL: "https://api.example.com/api/", + TITLE: "Policy Vault - Environment" +} +``` + +### Datenbank-Setup +```bash +# Migrationen ausführen +npm run migration:up + +# Migrationen zurückrollen +npm run migration:down + +# Schema generieren +npx drizzle-kit push +``` + +--- + +## API-Sicherheit + +### Token-basierte Authentifizierung +- Alle geschützten Endpoints erfordern einen gültigen JWT-Token im Authorization-Header +- Format: `Authorization: Bearer ` + +### Encryption-based Endpoints +Für mobile/externe Zugriffe (Consent Tracking): +- Header: `secret` oder `mobiletoken` +- Format: Verschlüsselter String mit `userId_projectId` +- Automatische Validierung durch DecryptMiddleware + +### Rate Limiting +- Standard: 10 Requests pro Minute +- OTP/Login: 3 Requests pro Minute +- Konfigurierbar über ThrottlerModule + +--- + +## Besondere Features + +### 1. Versionierung +- Komplettes Versions-Management für Policy Documents +- Mehrsprachige Versionen mit separaten Inhalten +- Publish/Draft Status +- Historische Versionsverfolgung + +### 2. Mehrsprachigkeit +- Zentrale Sprach-Konfiguration pro Projekt +- Separate Language-Data Tabellen für alle Inhaltstypen +- Support für unbegrenzte Sprachen + +### 3. Cookie-Consent-System +- Granulare Kontrolle über Cookie-Kategorien +- Vendor-Management mit Sub-Services +- Plattform-spezifische Kategorien (Web, Mobile, etc.) +- Versions-Tracking für Compliance + +### 4. Rich Content Editing +- CKEditor 5 Integration +- Support für komplexe Formatierungen +- Bild-Upload und -Verwaltung +- Code-Block-Unterstützung + +### 5. Logging & Monitoring +- Winston-basiertes Logging +- Daily Rotate Files +- Structured Logging +- Fehler-Tracking +- Datenbank-Health-Checks + +### 6. Soft Delete Pattern +- Keine permanente Datenlöschung +- `isDeleted` Flags auf allen Haupt-Entitäten +- Möglichkeit zur Wiederherstellung +- Audit Trail Erhaltung + +--- + +## Entwicklung + +### Backend starten +```bash +# Development +npm run start:dev + +# Local (mit Watch) +npm run start:local + +# Production +npm run start:prod +``` + +### Frontend starten +```bash +# Development Server +npm run start +# oder +ng serve + +# Build +npm run build + +# Mit PM2 +npm run start:pm2 +``` + +### Tests +```bash +# Backend Tests +npm run test +npm run test:e2e +npm run test:cov + +# Frontend Tests +npm run test +``` + +--- + +## Zusammenfassung + +Policy Vault ist eine umfassende Enterprise-Lösung für die Verwaltung von Datenschutzrichtlinien und Cookie-Einwilligungen. Das System bietet: + +- **Multi-Tenant-Architektur** mit Projekt-basierter Trennung +- **Robuste Authentifizierung** mit JWT und rollenbasierter Zugriffskontrolle +- **Vollständiges Versions-Management** für Compliance-Tracking +- **Granulare Cookie-Consent-Verwaltung** mit Vendor-Support +- **Mehrsprachige Unterstützung** für globale Anwendungen +- **Moderne Tech-Stack** mit NestJS, Angular und PostgreSQL +- **Enterprise-Grade Security** mit Encryption, Rate Limiting und Audit Trails +- **Skalierbare Architektur** mit klarer Trennung von Concerns + +Das System eignet sich ideal für Unternehmen, die: +- Multiple Projekte/Produkte mit unterschiedlichen Datenschutzrichtlinien verwalten +- GDPR/DSGVO-Compliance sicherstellen müssen +- Granulare Cookie-Einwilligungen tracken wollen +- Mehrsprachige Anwendungen betreiben +- Eine zentrale Policy-Management-Plattform benötigen diff --git a/admin-v2/SBOM.md b/admin-v2/SBOM.md new file mode 100644 index 0000000..6302a25 --- /dev/null +++ b/admin-v2/SBOM.md @@ -0,0 +1,1204 @@ +# Software Bill of Materials (SBOM) +## BreakPilot PWA + +**Version:** 1.5.0 +**Letzte Aktualisierung:** 2026-02-08 +**Verantwortlicher:** BreakPilot Development Team + +--- + +## Übersicht + +Diese SBOM dokumentiert alle Open-Source-Komponenten, die in BreakPilot verwendet werden, einschließlich ihrer Lizenzen und Nutzungsbedingungen für den kommerziellen Einsatz. + +--- + +## 1. LibreChat (Chat-Interface) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | LibreChat | +| **Version** | latest (dev) | +| **Repository** | https://github.com/danny-avila/LibreChat | +| **Lizenz** | MIT License | +| **Copyright** | Copyright (c) 2025 LibreChat | +| **Kommerzielle Nutzung** | Erlaubt | +| **Attribution erforderlich** | Ja (Lizenztext beibehalten) | +| **Verwendungszweck** | Chat/Prompt-Oberfläche für Nutzer | + +### LibreChat Abhängigkeiten (Container) + +| Komponente | Version | Lizenz | Kommerzielle Nutzung | +|------------|---------|--------|----------------------| +| MongoDB | latest | SSPL-1.0 | Ja (mit Einschränkungen*) | +| Meilisearch | v1.12.3 | MIT | Ja | +| PostgreSQL (pgvector) | 0.8.0-pg15 | PostgreSQL License | Ja | +| LibreChat RAG API | latest (dev-lite) | MIT | Ja | + +> *MongoDB SSPL: Erlaubt kommerzielle Nutzung, solange MongoDB nicht als Service angeboten wird. + +--- + +## 2. BreakPilot Backend (Python/FastAPI) + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | FastAPI | +| **Lizenz** | MIT License | +| **Repository** | https://github.com/tiangolo/fastapi | +| **Kommerzielle Nutzung** | Erlaubt | + +### Python Dependencies + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| FastAPI | 0.123.9 | MIT | Ja | +| Uvicorn | 0.38.0 | BSD-3-Clause | Ja | +| Starlette | 0.49.3 | BSD-3-Clause | Ja | +| Pydantic | 2.12.5 | MIT | Ja | +| httpx | 0.28.1 | BSD-3-Clause | Ja | +| requests | 2.32.5 | Apache-2.0 | Ja | +| PyJWT | 2.10.1 | MIT | Ja | +| python-multipart | 0.0.20 | Apache-2.0 | Ja | +| Jinja2 | 3.1.6 | BSD-3-Clause | Ja | +| WeasyPrint | 66.0 | BSD-3-Clause | Ja | +| python-dateutil | 2.9.0 | Apache-2.0/BSD | Ja | +| python-docx | 1.2.0 | MIT | Ja | +| mammoth | 1.11.0 | BSD-2-Clause | Ja | +| Markdown | 3.9 | BSD-3-Clause | Ja | +| Pillow | 11.3.0 | HPND | Ja | +| opencv-python | 4.12.0 | Apache-2.0 | Ja | +| numpy | 2.0.2 | BSD-3-Clause | Ja | +| anthropic | 0.75.0 | MIT | Ja | +| email-validator | 2.3.0 | CC0-1.0 | Ja | +| PyJWKClient (PyJWT) | 2.10.1 | MIT | Ja | Keycloak JWKS Validierung | +| pytest | 8.4.2 | MIT | Ja | +| pytest-asyncio | 1.2.0 | Apache-2.0 | Ja | +| beautifulsoup4 | 4.12.3 | MIT | Ja | + +--- + +## 3. Consent Service (Go) + +| Eigenschaft | Wert | +|-------------|------| +| **Sprache** | Go 1.23+ | +| **Lizenz** | Proprietär (BreakPilot) | + +### Go Dependencies (Direkt) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| gin-gonic/gin | 1.11.0 | MIT | Ja | +| golang-jwt/jwt/v5 | 5.3.0 | MIT | Ja | +| google/uuid | 1.6.0 | BSD-3-Clause | Ja | +| jackc/pgx/v5 | 5.7.6 | MIT | Ja | +| joho/godotenv | 1.5.1 | MIT | Ja | +| skip2/go-qrcode | 0.0.0 | MIT | Ja | +| golang.org/x/crypto | 0.40.0 | BSD-3-Clause | Ja | + +### Go Dependencies (Indirekt/Transitiv) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| bytedance/sonic | 1.14.0 | Apache-2.0 | Ja | +| go-playground/validator/v10 | 10.27.0 | MIT | Ja | +| goccy/go-json | 0.10.2 | MIT | Ja | +| jackc/pgpassfile | 1.0.0 | MIT | Ja | +| jackc/pgservicefile | 1.0.0 | MIT | Ja | +| jackc/puddle/v2 | 2.2.2 | MIT | Ja | +| ugorji/go/codec | 1.3.0 | MIT | Ja | +| google.golang.org/protobuf | 1.36.9 | BSD-3-Clause | Ja | + +--- + +## 4. Matrix Synapse (Schulkommunikation) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | Matrix Synapse | +| **Version** | latest | +| **Repository** | https://github.com/element-hq/synapse | +| **Docker Image** | matrixdotorg/synapse:latest | +| **Lizenz** | AGPL-3.0 | +| **Copyright** | Copyright (c) 2014-2025 Element (formerly New Vector Ltd) | +| **Kommerzielle Nutzung** | Erlaubt (mit Bedingungen*) | +| **Attribution erforderlich** | Ja | +| **Verwendungszweck** | E2EE Messenger für Eltern-Lehrer-Kommunikation | + +> *AGPL-3.0: Kommerzielle Nutzung erlaubt. Wenn Sie Synapse modifizieren und als Service anbieten, müssen die Änderungen unter AGPL veröffentlicht werden. Bei reiner Nutzung ohne Modifikation keine zusätzlichen Pflichten. + +### Matrix Protocol + +| Komponente | Lizenz | Beschreibung | +|------------|--------|--------------| +| Matrix Protocol | Apache-2.0 | Offenes Kommunikationsprotokoll | +| Megolm (E2EE) | Apache-2.0 | Ende-zu-Ende-Verschlüsselung | + +### Matrix Service Dependencies (Go) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| google/uuid | 1.6.0 | BSD-3-Clause | Ja | +| net/http (stdlib) | Go 1.21+ | BSD-3-Clause | Ja | +| encoding/json (stdlib) | Go 1.21+ | BSD-3-Clause | Ja | + +--- + +## 5. Jitsi Meet (Videokonferenzen) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | Jitsi Meet | +| **Version** | stable-9823 | +| **Repository** | https://github.com/jitsi/jitsi-meet | +| **Docker Images** | jitsi/web, jitsi/prosody, jitsi/jicofo, jitsi/jvb | +| **Lizenz** | Apache-2.0 | +| **Copyright** | Copyright (c) 2013-2025 Atlassian Pty Ltd & Contributors | +| **Kommerzielle Nutzung** | Erlaubt | +| **Attribution erforderlich** | Ja (NOTICE-Datei beibehalten) | +| **Verwendungszweck** | Videokonferenzen für Schulungen, Elterngespräche | + +### Jitsi Komponenten + +| Komponente | Image | Lizenz | Beschreibung | +|------------|-------|--------|--------------| +| Jitsi Web | jitsi/web:stable-9823 | Apache-2.0 | Web-Frontend | +| Prosody | jitsi/prosody:stable-9823 | MIT | XMPP-Server | +| Jicofo | jitsi/jicofo:stable-9823 | Apache-2.0 | Conference Focus | +| Jitsi Videobridge | jitsi/jvb:stable-9823 | Apache-2.0 | WebRTC SFU | + +### Jitsi Abhängigkeiten + +| Komponente | Lizenz | Beschreibung | +|------------|--------|--------------| +| WebRTC | BSD-3-Clause | Echtzeit-Kommunikation | +| Olibs (Olm) | Apache-2.0 | Verschlüsselung | +| Ogg/Opus | BSD-3-Clause | Audio-Codec | +| VP8/VP9 | BSD-3-Clause | Video-Codec | + +### Jitsi Service Dependencies (Go) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| google/uuid | 1.6.0 | BSD-3-Clause | Ja | +| crypto/hmac (stdlib) | Go 1.21+ | BSD-3-Clause | Ja | +| encoding/base64 (stdlib) | Go 1.21+ | BSD-3-Clause | Ja | + +--- + +## 6. Datenbanken + +| Datenbank | Version | Lizenz | Kommerzielle Nutzung | Verwendung | +|-----------|---------|--------|----------------------|------------| +| PostgreSQL | 16-alpine | PostgreSQL License | Ja | Hauptdatenbank | +| PostgreSQL (Synapse) | 16-alpine | PostgreSQL License | Ja | Matrix Datenbank | +| pgvector Extension | 0.8.0 | PostgreSQL License | Ja | LibreChat RAG | +| MongoDB | latest | SSPL-1.0 | Ja* | LibreChat | +| Meilisearch | 1.12.3 | MIT | Ja | LibreChat Suche | + +--- + +## 7. Frontend (PWA) + +| Komponente | Lizenz | Kommerzielle Nutzung | +|------------|--------|----------------------| +| HTML5/CSS3/JS | N/A | N/A | +| Service Worker API | N/A | N/A | + +--- + +## 7a. Typografie & Schriftarten + +Diese Sektion dokumentiert alle verwendeten Schriftarten für Website, Marketing und E-Mails, um sicherzustellen, dass nur lizenzfreie Schriften verwendet werden. + +### Primäre Schriftart: Inter + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | Inter | +| **Designer** | Rasmus Andersson | +| **Version** | Variable Font | +| **Repository** | https://github.com/rsms/inter | +| **Lizenz** | SIL Open Font License 1.1 (OFL-1.1) | +| **Kommerzielle Nutzung** | Ja, uneingeschränkt | +| **Attribution erforderlich** | Nein | +| **Modifikation erlaubt** | Ja | + +### Verwendete Font-Weights + +| Weight | Name | Verwendung | +|--------|------|------------| +| 400 | Regular | Fließtext, Beschreibungen | +| 500 | Medium | Labels, Buttons | +| 600 | Semi-Bold | Überschriften H3-H6, Hervorhebungen | +| 700 | Bold | Überschriften H1-H2, CTAs | + +### Einbindungsmethode + +| Plattform | Methode | Datei | +|-----------|---------|-------| +| Website (Next.js) | Google Fonts CDN | `website/app/globals.css` | +| Admin Panel | Google Fonts CDN | `backend/frontend/static/css/studio.css` | +| Kundenportal | Google Fonts CDN | `backend/frontend/static/css/customer.css` | +| E-Mail Templates | System Font Fallback | Inline CSS | + +### Google Fonts Import + +```css +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +``` + +### Font Stack (Fallback) + +```css +font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif; +``` + +### System-Fallback-Schriften + +| Betriebssystem | Fallback Font | Lizenz | +|----------------|---------------|--------| +| macOS/iOS | SF Pro Text | Apple Proprietary (vorinstalliert) | +| Windows | Segoe UI | Microsoft Proprietary (vorinstalliert) | +| Android | Roboto | Apache-2.0 (vorinstalliert) | +| Linux | Sans-Serif Default | Varies (system font) | + +### E-Mail-sichere Schriftarten + +Für E-Mail-Templates werden nur websichere Schriften verwendet: + +| Font | Fallback für | Lizenz | +|------|--------------|--------| +| Arial | Sans-Serif Fließtext | Monotype (lizenzfrei für E-Mail) | +| Helvetica | macOS/iOS | Apple (vorinstalliert) | +| Georgia | Serif (optional) | Microsoft (lizenzfrei für E-Mail) | + +### Icon-Fonts & Symbol-Sets + +| Name | Quelle | Lizenz | Verwendung | +|------|--------|--------|------------| +| Heroicons | heroicons.com | MIT | Admin Panel SVG Icons | +| System Emojis | OS-nativ | N/A | Feature-Icons, Bundesland-Marker | + +### Nicht verwendete / Ausgeschlossene Schriften + +| Schriftart | Grund für Ausschluss | +|------------|----------------------| +| Adobe Fonts (Typekit) | Erfordert Adobe-Lizenz | +| Google Fonts (proprietäre) | Nur OFL/Apache-lizenzierte | +| MyFonts/Linotype | Kommerzielle Lizenz erforderlich | +| Custom Brand Fonts | Lizenzkosten, keine Notwendigkeit | + +### Compliance-Checkliste (Typografie) + +- [x] Inter ist OFL-1.1 lizenziert (vollständig frei für kommerzielle Nutzung) +- [x] Keine proprietären Schriften eingebunden +- [x] System-Fallbacks sind auf allen Plattformen lizenzfrei +- [x] E-Mail-Templates verwenden nur websichere Schriften +- [x] SVG-Icons (Heroicons) sind MIT-lizenziert +- [x] Keine Schrift-Dateien (.woff/.ttf) mit unklarer Lizenz im Repository + +### Performance-Hinweis + +Google Fonts werden mit `display=swap` geladen, um FOUT (Flash of Unstyled Text) zu minimieren. Für Produktion kann eine lokale Einbindung über `/public/fonts/` erwogen werden. + +--- + +## Lizenz-Zusammenfassung + +### Erlaubte Lizenzen für kommerzielle Nutzung: + +| Lizenz | Bedingungen | +|--------|-------------| +| **MIT** | Copyright-Vermerk beibehalten | +| **BSD-2-Clause** | Copyright-Vermerk beibehalten | +| **BSD-3-Clause** | Copyright-Vermerk beibehalten, keine Endorsement-Nutzung | +| **Apache-2.0** | NOTICE-Datei beibehalten, Patent-Klausel beachten | +| **PostgreSQL License** | Copyright-Vermerk beibehalten | +| **SSPL-1.0** | Kein MongoDB-as-a-Service anbieten | +| **AGPL-3.0** | Bei Modifikation + Service: Quellcode veröffentlichen | + +--- + +## Compliance-Checkliste + +- [x] Alle Open-Source-Lizenzen erlauben kommerzielle Nutzung +- [x] MIT-Lizenztexte werden mit dem Produkt ausgeliefert +- [x] Copyright-Hinweise sind dokumentiert +- [x] SSPL-Einschränkungen für MongoDB werden eingehalten (kein DBaaS) +- [x] AGPL-3.0-Einschränkungen für Matrix Synapse beachtet (keine Modifikationen) +- [x] SBOM wird regelmäßig aktualisiert + +--- + +## 8. LLM Platform (geplant) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot LLM Platform | +| **Status** | In Planung | +| **Dokumentation** | [/docs/llm-platform/README.md](/docs/llm-platform/README.md) | + +### LLM Inference Stack + +| Komponente | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|------------|---------|--------|----------------------|--------------| +| vLLM | latest | Apache-2.0 | Ja | OpenAI-kompatible Inference Engine | +| Ollama | latest | MIT | Ja | Lokale Entwicklung & Fallback | + +### LLM Modelle + +| Modell | Lizenz | Kommerzielle Nutzung | Einschränkungen | +|--------|--------|----------------------|-----------------| +| Llama 3.1 (8B/70B) | Llama 3.1 Community License | Ja | >700M MAU erfordert Meta-Lizenzvereinbarung | +| Mistral 7B | Apache-2.0 | Ja | Keine | +| Mixtral 8x7B | Apache-2.0 | Ja | Keine | +| Claude API | Proprietär (Anthropic) | Ja | Pay-per-use, API Terms | + +> **Llama 3.1 License:** Die Llama 3.1 Community License erlaubt kommerzielle Nutzung. Bei mehr als 700 Millionen monatlich aktiven Nutzern ist eine separate Lizenzvereinbarung mit Meta erforderlich. Für BreakPilot (Schulkontext) ist dies nicht relevant. + +### LLM Gateway Dependencies (Python) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | +|-------|---------|--------|----------------------| +| sse-starlette | 2.x | BSD-3-Clause | Ja | +| feedparser | 6.x | BSD-2-Clause | Ja | +| simhash | 2.x | MIT | Ja | +| tavily-python | latest | MIT | Ja | + +### Hosting (Phase 1) + +| Anbieter | Dienst | Datenschutz | +|----------|--------|-------------| +| vast.ai | GPU-Mietung | US-basiert, nur für Inference (keine PII) | +| Bestehende Infra | Gateway, DB | DE-hosted | + +--- + +## 9. Education Search Service (edu-search-service) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot Education Search Service | +| **Status** | In Entwicklung (Phase 0 PoC) | +| **Sprache** | Go 1.21+ | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | EU-gehostete Bildungsquellen-Suche als Tavily-Alternative | + +### Go Dependencies (Direkt) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| gin-gonic/gin | 1.9.1 | MIT | Ja | HTTP Web Framework | +| opensearch-project/opensearch-go/v2 | 2.3.0 | Apache-2.0 | Ja | OpenSearch Client | +| google/uuid | 1.5.0 | BSD-3-Clause | Ja | UUID Generierung | +| gopkg.in/yaml.v3 | 3.0.1 | MIT | Ja | YAML Parser | +| PuerkitoBio/goquery | 1.8.1 | BSD-3-Clause | Ja | HTML Parsing/Scraping | +| ledongthuc/pdf | 0.0.0 | MIT | Ja | PDF Text Extraction | +| golang.org/x/net | 0.19.0 | BSD-3-Clause | Ja | Netzwerk-Utilities | +| golang.org/x/text | 0.14.0 | BSD-3-Clause | Ja | Text/Encoding | + +### Infrastructure Dependencies + +| Komponente | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|------------|---------|--------|----------------------|--------------| +| OpenSearch | 2.x | Apache-2.0 | Ja | Such-Engine & Index | + +> **Hinweis:** OpenSearch ist ein Fork von Elasticsearch unter Apache-2.0 Lizenz und damit uneingeschränkt kommerziell nutzbar. + +--- + +## 10. Legal Crawler (Backend Module) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot Legal Crawler | +| **Status** | Aktiv | +| **Sprache** | Python 3.10+ | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | Crawlt Schulgesetze und rechtliche Inhalte aller 16 Bundesländer | + +### Python Dependencies (Legal Crawler spezifisch) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| beautifulsoup4 | 4.12.3 | MIT | Ja | HTML Parsing | +| httpx | 0.28.1 | BSD-3-Clause | Ja | Async HTTP Client | + +### Gecrawlte Quellen (Legal Seeds) + +| Bundesland | Quelle | Typ | +|------------|--------|-----| +| Baden-Württemberg | landesrecht-bw.de | Schulgesetz | +| Bayern | gesetze-bayern.de | BayEUG | +| Berlin | gesetze.berlin.de | Schulgesetz | +| Brandenburg | bravors.brandenburg.de | BbgSchulG | +| Bremen | transparenz.bremen.de | BremSchulG | +| Hamburg | landesrecht-hamburg.de | HmbSG | +| Hessen | rv.hessenrecht.hessen.de | HSchG | +| Mecklenburg-Vorpommern | landesrecht-mv.de | SchulG M-V | +| Niedersachsen | voris.niedersachsen.de | NSchG | +| Nordrhein-Westfalen | bass.schule.nrw | SchulG NRW | +| Rheinland-Pfalz | landesrecht.rlp.de | SchulG | +| Saarland | sl.juris.de | SchoG | +| Sachsen | revosax.sachsen.de | SächsSchulG | +| Sachsen-Anhalt | landesrecht.sachsen-anhalt.de | SchulG LSA | +| Schleswig-Holstein | gesetze-rechtsprechung.sh.juris.de | SchulG SH | +| Thüringen | landesrecht.thueringen.de | ThürSchulG | + +> **Hinweis:** Alle gecrawlten Inhalte sind öffentlich zugängliche Rechtstexte. Der Crawler respektiert robots.txt und Rate-Limiting. + +--- + +## 11. ERPNext (Enterprise Resource Planning) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | ERPNext | +| **Version** | latest (v15+) | +| **Framework** | Frappe Framework | +| **Repository** | https://github.com/frappe/erpnext | +| **Docker Image** | frappe/erpnext:latest | +| **Lizenz** | GNU General Public License v3.0 (GPLv3) | +| **Copyright** | Copyright (c) 2013-2025 Frappe Technologies Pvt. Ltd. | +| **Kommerzielle Nutzung** | Erlaubt (mit Bedingungen*) | +| **Attribution erforderlich** | Ja | +| **Verwendungszweck** | Vollständiges ERP-System für Buchhaltung, HR, Billing & Projekte | + +> *GPLv3: Kommerzielle Nutzung erlaubt. Wenn Sie ERPNext modifizieren und verteilen, müssen die Änderungen unter GPLv3 veröffentlicht werden. Bei reiner Nutzung ohne Modifikation keine zusätzlichen Pflichten. + +### ERPNext Features + +| Feature | Beschreibung | Verwendung in BreakPilot | +|---------|--------------|--------------------------| +| **Accounting** | Double-Entry Buchhaltung | Finanzmanagement für Schulen | +| **Invoicing** | Rechnungsstellung + Abos | Automatische Billing für Services | +| **HR & Payroll** | Personalverwaltung + Lohnabrechnung | Lehrerverträge (zukünftig) | +| **Project Management** | Projekte & Dienstleistungen | Bildungsprojekte verwalten | +| **Banking** | Payables, Receivables | Zahlungsverkehr | +| **Expenses** | Spesen & Reisekosten | Kostenverwaltung | + +### Frappe Framework (ERPNext Basis) + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | Frappe Framework | +| **Sprache** | Python 3.10+ | +| **Repository** | https://github.com/frappe/frappe | +| **Lizenz** | MIT License | +| **Kommerzielle Nutzung** | Ja | + +### ERPNext Service Stack + +| Service | Image/Version | Lizenz | Beschreibung | +|---------|--------------|--------|--------------| +| ERPNext Application | frappe/erpnext:latest | GPLv3 | Hauptanwendung | +| Frappe Framework | frappe/erpnext:latest | MIT | Python Framework | +| Nginx | (embedded) | BSD-2-Clause | Web Server | +| Node.js | (embedded) | MIT | WebSocket Server | +| MariaDB | 10.6 | GPL-2.0 | Datenbank | +| Redis | alpine | BSD-3-Clause | Cache & Queue | + +### Python Dependencies (ERPNext/Frappe) + +| Paket | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|--------|----------------------|--------------| +| Werkzeug | BSD-3-Clause | Ja | WSGI Utilities | +| Jinja2 | BSD-3-Clause | Ja | Template Engine | +| SQLAlchemy | MIT | Ja | ORM | +| Babel | BSD-3-Clause | Ja | Internationalisierung | +| Pillow | HPND | Ja | Image Processing | +| PyMySQL | MIT | Ja | MySQL Client | +| gevent | MIT | Ja | Async Networking | +| gunicorn | MIT | Ja | WSGI Server | + +### JavaScript Dependencies (ERPNext Frontend) + +| Paket | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|--------|----------------------|--------------| +| Vue.js | MIT | Ja | Frontend Framework | +| Chart.js | MIT | Ja | Visualisierung | +| Socket.IO | MIT | Ja | Real-time Communication | +| Frappe UI | MIT | Ja | UI Components | + +### ERPNext Deployment + +| Komponente | Container | Port | Beschreibung | +|------------|-----------|------|--------------| +| Frontend | breakpilot-pwa-erpnext-frontend | 8090 | Nginx Reverse Proxy | +| Backend | breakpilot-pwa-erpnext-backend | 8000 (intern) | Python/Frappe App | +| WebSocket | breakpilot-pwa-erpnext-websocket | 9000 (intern) | Real-time Updates | +| Scheduler | breakpilot-pwa-erpnext-scheduler | - | Background Jobs | +| Worker (Long) | breakpilot-pwa-erpnext-worker-long | - | Long-running Tasks | +| Worker (Short) | breakpilot-pwa-erpnext-worker-short | - | Short Tasks | +| MariaDB | breakpilot-pwa-erpnext-db | 3306 (intern) | Database | +| Redis Queue | breakpilot-pwa-erpnext-redis-queue | 6379 (intern) | Job Queue | +| Redis Cache | breakpilot-pwa-erpnext-redis-cache | 6379 (intern) | App Cache | + +> **Lizenz-Compliance:** ERPNext steht unter GPLv3. BreakPilot nutzt ERPNext als eigenständigen Service ohne Modifikationen. Die GPLv3-Lizenz erlaubt dies ohne zusätzliche Verpflichtungen, solange ERPNext nicht modifiziert und redistribuiert wird. + +--- + +## 12. Keycloak Integration (Authentifizierung) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | Keycloak (Optional) | +| **Version** | 24.x+ | +| **Repository** | https://github.com/keycloak/keycloak | +| **Lizenz** | Apache-2.0 | +| **Copyright** | Copyright (c) Red Hat, Inc. | +| **Kommerzielle Nutzung** | Erlaubt | +| **Verwendungszweck** | Identity & Access Management (IAM) fuer Produktion | +| **Status** | Optional (Fallback: Lokales JWT) | + +### Architektur: Hybrid-Authentifizierung + +BreakPilot verwendet einen Hybrid-Ansatz: + +| Modus | Beschreibung | Empfohlen fuer | +|-------|--------------|----------------| +| **Lokales JWT** | Eigene JWT-Tokens mit HS256 | Entwicklung, Tests | +| **Keycloak** | JWKS-validierte RS256-Tokens | Produktion | + +### Keycloak Komponenten (wenn aktiviert) + +| Komponente | Beschreibung | Lizenz | +|------------|--------------|--------| +| Keycloak Server | IAM Server | Apache-2.0 | +| JWKS Endpoint | Public Key Distribution | Apache-2.0 | +| Realm Configuration | Multi-Tenant Support | Apache-2.0 | + +### Python Dependencies (Keycloak) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| PyJWT | 2.10.1 | MIT | Ja | JWT Decoding & Validation | +| httpx | 0.28.1 | BSD-3-Clause | Ja | Async HTTP Client fuer JWKS | +| cryptography | latest | Apache-2.0/BSD | Ja | RSA Key Verification | + +### Rollentrennung + +| Schicht | Verantwortung | Implementierung | +|---------|---------------|-----------------| +| **Authentifizierung** | "Wer bist du?" | Keycloak (Produktion) oder lokales JWT | +| **Autorisierung** | "Was darfst du?" | Eigenes rbac.py (domaenenspezifisch) | + +> **Compliance Note:** Keycloak steht unter Apache-2.0 Lizenz und ist vollstaendig fuer kommerzielle Nutzung freigegeben. Die Integration ist optional - ohne Keycloak-Konfiguration verwendet BreakPilot automatisch lokale JWT-Authentifizierung. + +--- + +## 13. Tool-Integrationen (geplant) + +| Tool | Anbieter | Lizenz/API | Kommerzielle Nutzung | Beschreibung | +|------|----------|------------|----------------------|--------------| +| Tavily Search | Tavily AI | Proprietär (API) | Ja (Pay-per-use) | Web Search für LLM Tool-Calling | + +> **Datenschutz-Hinweis:** Alle Anfragen an externe Tools (Tavily) durchlaufen einen PII-Redaction-Filter. Keine personenbezogenen Daten werden an externe Dienste übermittelt. + +--- + +## 13. Content Service (Educational Content Platform) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot Content Service | +| **Version** | 1.0.0 | +| **Status** | Produktionsbereit | +| **Sprache** | Python 3.11 + Node.js 20 | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | Educational Content Management mit Creative Commons Licensing | + +### Content Service Stack + +| Service | Port | Framework | Beschreibung | +|---------|------|-----------|--------------| +| Content Service API | 8002 | FastAPI | Content CRUD, Rating, Analytics | +| H5P Service | 8003 | Express | Interactive Content Editor & Player | +| AI Content Generator | 8004 | FastAPI | KI-gestützte H5P Content-Generierung | +| MinIO Storage | 9000-9001 | MinIO | S3-compatible Object Storage | +| Content Database | 5433 | PostgreSQL 16 | Metadata Storage | + +### 13.1 Content Service API (FastAPI) + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | FastAPI | +| **Base Image** | python:3.11-slim | +| **Lizenz** | MIT License | +| **Port** | 8002 | + +#### Python Dependencies (Content Service) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| fastapi | ^0.109.0 | MIT | Ja | Web Framework | +| uvicorn[standard] | ^0.27.0 | BSD-3-Clause | Ja | ASGI Server | +| sqlalchemy | ^2.0.25 | MIT | Ja | Database ORM | +| psycopg2-binary | ^2.9.9 | LGPL-3.0 | Ja | PostgreSQL Driver | +| pydantic | ^2.5.3 | MIT | Ja | Data Validation | +| minio | ^7.2.3 | Apache-2.0 | Ja | S3 Storage Client | +| python-multipart | ^0.0.6 | Apache-2.0 | Ja | Form Data Parsing | +| python-jose[cryptography] | ^3.3.0 | MIT | Ja | JWT Handling | +| passlib[bcrypt] | ^1.7.4 | BSD | Ja | Password Hashing | +| matrix-nio | ^0.24.0 | ISC | Ja | Matrix Client for Feed Publishing | + +### 13.2 H5P Service (Node.js) - Simplified Implementation + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | Express (Simplified) | +| **Base Image** | node:20-alpine | +| **Lizenz** | MIT (Proprietäre Editoren) | +| **Port** | 8003 (8080 internal) | +| **Status** | Produktionsbereit (8 Content-Typen) | + +#### Node.js Dependencies (H5P Service) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| express | ^4.18.2 | MIT | Ja | Web Framework | +| cors | ^2.8.5 | MIT | Ja | CORS Middleware | + +> **Implementation Note:** Vereinfachter H5P Service ohne externe GPL-3.0 Libraries. Alle Editoren und Player sind proprietäre HTML/CSS/JS Implementierungen unter MIT-kompatibler Lizenz. + +#### H5P Content Types (Proprietäre Implementation) + +| Content Type | Status | Beschreibung | +|--------------|--------|--------------| +| Quiz (Question Set) | ✅ | Multiple-Choice Tests mit Feedback | +| Interactive Video | ✅ | Videos mit zeitbasierten Interaktionen (YouTube, Vimeo, MP4) | +| Course Presentation | ✅ | Multi-Slide Präsentationen mit Navigation | +| Flashcards | ✅ | Lernkarten zum Wiederholen | +| Timeline | ✅ | Chronologische Zeitstrahle | +| Drag and Drop | ✅ | Zuordnungsaufgaben mit HTML5 Drag API | +| Fill in the Blanks | ✅ | Lückentexte mit automatischer Korrektur | +| Memory Game | ✅ | Klassisches Memory-Spiel | + +#### H5P Service Architecture + +| Komponente | Technologie | Lizenz | +|------------|-------------|--------| +| Server | Express.js | MIT | +| Editors | HTML5/CSS3/Vanilla JS | Proprietär (BreakPilot) | +| Players | HTML5/CSS3/Vanilla JS | Proprietär (BreakPilot) | +| Storage | LocalStorage (Browser) | N/A | +| Video Integration | YouTube/Vimeo iFrame API | Proprietär (API Terms) | + +> **Compliance Note:** Keine GPL-3.0 Dependencies. Vollständig kommerziell nutzbar ohne Copyleft-Verpflichtungen. + +### 13.3 AI Content Generator (Python/FastAPI) + +| Eigenschaft | Wert | +|-------------|------| +| **Framework** | FastAPI | +| **Base Image** | python:3.11-slim | +| **Lizenz** | MIT License (Dependencies) / Proprietär (Code) | +| **Port** | 8004 | +| **Status** | Produktionsbereit | + +#### Hauptfunktion +Automatische Generierung aller 8 H5P Content-Typen aus hochgeladenen Lernmaterialien mittels Claude AI und YouTube-Integration. + +#### Python Dependencies (AI Content Generator) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| fastapi | ^0.115.6 | MIT | Ja | Web Framework | +| uvicorn | ^0.34.0 | BSD-3-Clause | Ja | ASGI Server | +| anthropic | ^0.42.0 | MIT | Ja | Claude API Client | +| youtube-transcript-api | ^0.6.3 | MIT | Ja | YouTube Transkript-Extraktion | +| PyPDF2 | ^3.0.1 | BSD-3-Clause | Ja | PDF Text-Extraktion | +| Pillow | ^11.0.0 | HPND | Ja | Image Processing | +| pytesseract | ^0.3.14 | Apache-2.0 | Ja | OCR Text Recognition | +| python-docx | ^1.1.2 | MIT | Ja | Word Document Processing | +| mammoth | ^1.8.0 | BSD-2-Clause | Ja | DOCX to Text Conversion | +| python-multipart | ^0.0.20 | Apache-2.0 | Ja | File Upload Handling | + +#### System Dependencies + +| Komponente | Lizenz | Kommerzielle Nutzung | Beschreibung | +|------------|--------|----------------------|--------------| +| Tesseract OCR | Apache-2.0 | Ja | Optical Character Recognition Engine | +| Poppler Utils | GPL-2.0 | Ja (nicht modifiziert) | PDF Rendering Library | + +#### Externe APIs (Cloud Services) + +| Service | Verwendung | Kosten | Lizenz/Terms | +|---------|------------|--------|--------------| +| Anthropic Claude API | Content-Generierung | Pay-per-use | [Anthropic Terms](https://www.anthropic.com/legal/commercial-terms) | +| YouTube Data API (optional) | Video-Suche | Kostenlos (Quota) | [YouTube API Terms](https://developers.google.com/youtube/terms) | +| YouTube Transcript API | Transkript-Abruf | Kostenlos | Public API | + +#### Generierte Content-Typen + +| Content Type | Input | AI-Technologie | +|--------------|-------|----------------| +| Quiz | Lernmaterialien | Claude (Sonnet 4.5) | +| Interactive Video | YouTube URL + Transkript | Claude + Transcript API | +| Course Presentation | Lernmaterialien | Claude (Sonnet 4.5) | +| Flashcards | Lernmaterialien | Claude (Sonnet 4.5) | +| Timeline | Lernmaterialien | Claude (Sonnet 4.5) | +| Drag and Drop | Lernmaterialien | Claude (Sonnet 4.5) | +| Fill in the Blanks | Lernmaterialien | Claude (Sonnet 4.5) | +| Memory Game | Lernmaterialien | Claude (Sonnet 4.5) | + +#### Material-Analyse Capabilities + +| Dateityp | Processing Library | Extraction Method | +|----------|-------------------|-------------------| +| PDF | PyPDF2 | Text Extraction | +| PNG/JPG | Pillow + Tesseract | OCR (Optical Character Recognition) | +| DOCX | python-docx / mammoth | Document Parsing | +| TXT | Python stdlib | Plain Text Reading | + +> **Compliance Note:** Alle Dependencies sind MIT, BSD oder Apache-2.0 lizenziert. Tesseract und Poppler werden als unveränderter Binary verwendet (GPL-Compliance gegeben). Anthropic Claude API erfordert kommerzielle Lizenz. + +> **Cost Note:** Anthropic Claude API ist kostenpflichtig (Pay-per-token). YouTube Data API hat kostenlose Quota-Limits. + +### 13.4 MinIO S3 Storage + +| Eigenschaft | Wert | +|-------------|------| +| **Image** | minio/minio:latest | +| **Ports** | 9000 (API), 9001 (Console) | +| **Lizenz** | AGPL-3.0 | +| **Verwendung** | Media File Storage (Videos, PDFs, Images) | + +> **AGPL-3.0**: Kommerzielle Nutzung erlaubt. Wenn MinIO modifiziert und als Service angeboten wird, müssen Änderungen unter AGPL veröffentlicht werden. + +### 13.5 Content Database (PostgreSQL) + +| Eigenschaft | Wert | +|-------------|------| +| **Image** | postgres:16-alpine | +| **Port** | 5433 (external), 5432 (internal) | +| **Lizenz** | PostgreSQL License | +| **Database** | breakpilot_content | + +### Content Platform Features + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **Content CRUD** | ✅ | Create, Read, Update, Delete für Educational Content | +| **Creative Commons Licensing** | ✅ | CC-BY, CC-BY-SA, CC-BY-NC, CC-BY-NC-SA, CC0 | +| **H5P Interactive Content** | ✅ | Self-hosted H5P Editor & Player | +| **Matrix Feed Integration** | ✅ | Automatisches Publishing zu Matrix Spaces | +| **Rating System** | ✅ | 5-Star Ratings mit Kommentaren | +| **Download Tracking** | ✅ | Analytics & Impact Scoring | +| **DSGVO Compliance** | ✅ | Data Minimization, IP Anonymization | +| **S3 File Storage** | ✅ | MinIO für Videos, PDFs, Bilder | + +### Supported Content Types + +| Content Type | Format | Beschreibung | +|--------------|--------|--------------| +| VIDEO | MP4, WebM | Video Content | +| PDF | PDF | PDF Documents | +| IMAGE_GALLERY | JPG, PNG, WebP | Image Collections | +| MARKDOWN | .md | Markdown Documents | +| AUDIO | MP3, OGG, WebM | Audio Files | +| H5P | .h5p | Interactive H5P Packages | + +### Creative Commons Licenses + +| License | Beschreibung | Commercial Use | +|---------|--------------|----------------| +| CC-BY-4.0 | Attribution | ✅ | +| CC-BY-SA-4.0 | Attribution + ShareAlike | ✅ (Recommended) | +| CC-BY-NC-4.0 | Attribution + NonCommercial | ⚠️ | +| CC-BY-NC-SA-4.0 | Attribution + NonCommercial + ShareAlike | ⚠️ | +| CC0-1.0 | Public Domain | ✅ | + +### Content Categories + +| Category | Beschreibung | +|----------|--------------| +| MOVEMENT | Bewegungspausen & Physical Activities | +| MATH | Mathematik-Übungen | +| STEAM | Science, Technology, Engineering, Arts, Math | +| LANGUAGE | Sprachlernen | +| ARTS | Kreative Künste | +| SOCIAL | Social-Emotional Learning | +| MINDFULNESS | Achtsamkeit & Meditation | + +### Security & Privacy (Content Service) + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **Data Minimization** | ✅ | Nur notwendige Metadaten gespeichert | +| **IP Anonymization** | ✅ | IP-Adressen nach 7 Tagen anonymisiert | +| **User Data Export** | ✅ | DSGVO-konformer Datenexport | +| **Account Deletion** | ✅ | Vollständige Datenlöschung möglich | +| **No Student Data** | ✅ | Keine Schülerdaten erfasst | +| **JWT Authentication** | 🔜 | OAuth2 via consent-service (pending) | +| **HTTPS/TLS** | 🔜 | Production requirement | + +### Docker Volumes + +| Volume | Verwendung | +|--------|------------| +| minio_data | Object Storage für Media Files | +| content_db_data | PostgreSQL Database | +| h5p_content | H5P Content Files | + +> **Lizenz-Compliance:** Die Content Service Plattform nutzt hauptsächlich MIT/Apache-2.0 lizenzierte Komponenten. H5P Libraries sind unter GPL-3.0, was kommerzielle Nutzung erlaubt. + +--- + +## Aktualisierungsprotokoll + +| Datum | Änderung | Verantwortlich | +|-------|----------|----------------| +| 2025-12-14 | Initiale SBOM erstellt | Claude Code | +| 2025-12-14 | LibreChat hinzugefügt | Claude Code | +| 2025-12-15 | Matrix Synapse hinzugefügt (AGPL-3.0) | Claude Code | +| 2025-12-15 | PostgreSQL für Synapse-DB hinzugefügt | Claude Code | +| 2025-12-15 | Jitsi Meet hinzugefügt (Apache-2.0) | Claude Code | +| 2025-12-15 | Go Dependencies aktualisiert (gin-gonic/gin, jackc/pgx) | Claude Code | +| 2025-12-15 | Python Dependencies aktualisiert (mammoth, python-docx für DSR) | Claude Code | +| 2025-12-15 | LLM Platform Komponenten hinzugefügt (vLLM, Ollama, Llama 3.1, Mistral) | Claude Code | +| 2025-12-15 | Tool-Integrationen Sektion hinzugefügt (Tavily) | Claude Code | +| 2025-12-16 | Education Search Service hinzugefügt (OpenSearch, goquery, pdf) | Claude Code | +| 2025-12-17 | Legal Crawler Modul hinzugefügt (beautifulsoup4) | Claude Code | +| 2025-12-17 | Alle 16 Bundesländer Schulgesetz-Quellen dokumentiert | Claude Code | +| 2025-12-29 | ERPNext hinzugefügt (GPLv3, Frappe Framework) | Claude Code | +| 2025-12-29 | MariaDB 10.6 für ERPNext hinzugefügt (GPL-2.0) | Claude Code | +| 2025-12-29 | Redis Cache & Queue für ERPNext hinzugefügt | Claude Code | +| 2025-12-30 | Content Service hinzugefügt (FastAPI, H5P, MinIO) | Claude Code | +| 2025-12-30 | H5P Service vereinfacht: Proprietäre Editoren/Players ohne GPL-3.0 | Claude Code | +| 2025-12-30 | Alle 8 H5P Content-Typen implementiert (Quiz, Video, Presentation, etc.) | Claude Code | +| 2025-12-30 | Creative Commons Licensing System dokumentiert | Claude Code | +| 2025-12-30 | Matrix Feed Integration für Content Publishing | Claude Code | +| 2025-12-30 | AI Content Generator Service hinzugefügt (Claude API, YouTube Transcript) | Claude Code | +| 2025-12-30 | Material-Analyse implementiert (PDF, DOCX, Images mit OCR) | Claude Code | +| 2025-12-30 | Automatische H5P-Generierung für alle 8 Content-Typen | Claude Code | +| 2026-01-09 | Keycloak-Integration (Hybrid-Auth) hinzugefügt | Claude Code | +| 2026-01-09 | PyJWKClient für JWKS-Validierung dokumentiert | Claude Code | +| 2026-01-09 | HashiCorp Vault für Secrets-Management hinzugefügt | Claude Code | +| 2026-01-09 | Sicherheitsaudit: Hardcodierte Secrets entfernt | Claude Code | +| 2026-01-09 | DevSecOps-Stack integriert (Gitleaks, Semgrep, Trivy, Syft/Grype) | Claude Code | +| 2026-01-09 | Pre-Commit Security Hooks aktualisiert (Bandit, Semgrep) | Claude Code | +| 2026-01-10 | Sektion 7a: Typografie & Schriftarten hinzugefügt | Claude Code | +| 2026-01-10 | Inter Font (OFL-1.1) als primäre Schrift dokumentiert | Claude Code | +| 2026-01-10 | E-Mail-sichere Schriften und Icon-Fonts dokumentiert | Claude Code | +| 2026-01-10 | Compliance-Checkliste für Typografie erstellt | Claude Code | +| 2026-02-08 | Sektion 15: Klausur-Service RAG System hinzugefügt | Claude Code | +| 2026-02-08 | Model Cards für BAAI/bge-m3 und bge-reranker-v2-m3 dokumentiert | Claude Code | +| 2026-02-08 | MS MARCO Modelle als nicht empfohlen markiert (Lizenzproblem) | Claude Code | +| 2026-02-08 | PyMuPDF aus Default-Build entfernt (AGPL-3.0) | Claude Code | +| 2026-02-08 | HyDE/Self-RAG Datenschutz-Warnungen dokumentiert | Claude Code | +| 2026-02-08 | RAG Feature-Status-Tabelle hinzugefügt (implementiert vs. aktiv vs. extern) | Claude Code | + +--- + +## 13. HashiCorp Vault (Secrets Management) + +### Vault Server + +| Komponente | Version | Lizenz | Verwendung | +|------------|---------|--------|------------| +| **HashiCorp Vault** | 1.15 | [BSL 1.1](https://github.com/hashicorp/vault/blob/main/LICENSE) | Secrets-Management | +| **hvac** (Python Client) | 2.1.0 | [Apache-2.0](https://github.com/hvac/hvac/blob/main/LICENSE.txt) | Python Vault Client | + +### Lizenz-Hinweis + +HashiCorp Vault ist unter der **Business Source License 1.1 (BSL 1.1)** lizenziert: +- **Kostenlose Nutzung** für alle Zwecke (einschließlich kommerzielle) erlaubt +- **Einzige Einschränkung**: Vault darf nicht als Hosted/Managed Service angeboten werden +- Nach 4 Jahren wechselt die Lizenz automatisch zu Open Source (MPL 2.0) + +Für BreakPilot ist die Nutzung vollständig konform, da Vault nur intern verwendet wird. + +### Konfiguration + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **KV v2 Engine** | ✅ | Key-Value Secrets mit Versionierung | +| **AppRole Auth** | ✅ | Service-to-Service Authentication | +| **Token Auth** | ✅ | Development Mode | +| **Kubernetes Auth** | 🔜 | Für K8s Deployments | +| **TLS/mTLS** | 🔜 | Production Requirement | + +### Gespeicherte Secrets + +| Pfad | Typ | Beschreibung | +|------|-----|--------------| +| `secret/breakpilot/api_keys/anthropic` | API Key | Anthropic Claude API | +| `secret/breakpilot/api_keys/vast` | API Key | vast.ai GPU | +| `secret/breakpilot/api_keys/stripe` | API Key | Stripe Payments | +| `secret/breakpilot/database/postgres` | Credentials | PostgreSQL | +| `secret/breakpilot/auth/jwt` | Secret | JWT Signing Keys | +| `secret/breakpilot/auth/keycloak` | Secret | Keycloak Client | +| `secret/breakpilot/communication/matrix` | Token | Matrix Access Token | +| `secret/breakpilot/communication/jitsi` | Secret | Jitsi Auth Secrets | +| `secret/breakpilot/storage/minio` | Credentials | MinIO Object Storage | + +### Docker Volumes + +| Volume | Verwendung | +|--------|------------| +| vault_data | Vault Storage Backend | +| vault_logs | Audit Logs | + +### Sicherheitsfeatures + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **Audit Logging** | ✅ | Alle Zugriffe werden geloggt | +| **Secret Rotation** | 🔜 | Automatische Key-Rotation | +| **Dynamic Secrets** | 🔜 | Kurzlebige Database Credentials | +| **Seal/Unseal** | ✅ | Verschlüsselung im Ruhezustand | + +> **Wichtig**: In Produktion müssen alle Placeholder-Secrets durch echte Werte ersetzt werden! +> Secrets niemals in Git committen! + +--- + +## 14. DevSecOps Tools (Security Scanning) + +BreakPilot verwendet einen umfassenden DevSecOps-Stack fuer kontinuierliche Security-Pruefungen. + +### Secrets Detection + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Gitleaks** | 8.18.x | [MIT](https://github.com/gitleaks/gitleaks/blob/master/LICENSE) | Pre-commit, CI/CD Secrets Detection | +| **detect-secrets** | 1.4.x | [Apache-2.0](https://github.com/Yelp/detect-secrets/blob/master/LICENSE) | Baseline Secrets Detection | + +### Static Application Security Testing (SAST) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Semgrep** | 1.52.x | [LGPL-2.1](https://github.com/returntocorp/semgrep/blob/develop/LICENSE) | Multi-Language SAST | +| **Bandit** | 1.7.x | [Apache-2.0](https://github.com/PyCQA/bandit/blob/main/LICENSE) | Python Security Linting | + +### Software Composition Analysis (SCA) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Trivy** | 0.48.x | [Apache-2.0](https://github.com/aquasecurity/trivy/blob/main/LICENSE) | Vulnerability & Misconfiguration Scanning | +| **Grype** | 0.74.x | [Apache-2.0](https://github.com/anchore/grype/blob/main/LICENSE) | Vulnerability Scanner | +| **OWASP Dependency-Check** | 9.x | [Apache-2.0](https://github.com/jeremylong/DependencyCheck/blob/main/LICENSE.txt) | CVE/NVD Dependency Check | + +### SBOM Generation + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Syft** | 0.100.x | [Apache-2.0](https://github.com/anchore/syft/blob/main/LICENSE) | SBOM Generation (CycloneDX, SPDX) | + +### Dynamic Application Security Testing (DAST) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **OWASP ZAP** | 2.14.x | [Apache-2.0](https://github.com/zaproxy/zaproxy/blob/main/LICENSE) | Web Application Scanner | + +### Lizenz-Compliance + +Alle DevSecOps-Tools sind unter permissiven Open-Source-Lizenzen veroeffentlicht: + +| Lizenz | Tools | Verpflichtungen | +|--------|-------|-----------------| +| **MIT** | Gitleaks | Copyright-Hinweis beibehalten | +| **Apache-2.0** | Trivy, Grype, Syft, Bandit, ZAP, Dependency-Check, detect-secrets | Lizenz/Copyright beibehalten, Aenderungen dokumentieren | +| **LGPL-2.1** | Semgrep | Bei Aenderungen am Semgrep-Code: Source bereitstellen | + +### Konfigurationsdateien + +| Datei | Beschreibung | +|-------|--------------| +| `.gitleaks.toml` | Gitleaks Regeln & Allowlists | +| `.semgrep.yml` | Custom SAST Rules | +| `.trivy.yaml` | Trivy Scan-Konfiguration | +| `.trivyignore` | Akzeptierte Vulnerabilities | +| `.pre-commit-config.yaml` | Pre-Commit Hook Definitionen | +| `scripts/security-scan.sh` | Manuelles Security-Scan Script | + +### Dokumentation + +Siehe: [docs/architecture/devsecops.md](docs/architecture/devsecops.md) + +--- + +## 15. Klausur-Service RAG System (Educational Document Retrieval) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot Klausur-Service RAG | +| **Version** | 2.1.0 | +| **Status** | Produktionsbereit | +| **Sprache** | Python 3.11+ | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | RAG für Abitur-Erwartungshorizonte (Niedersachsen) | + +### 15.1 ML Models (Model Cards) + +> **Wichtig:** Alle Standard-ML-Modelle sind für kommerzielle Nutzung freigegeben. + +#### Embedding Model + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BAAI/bge-m3 | +| **Repository** | https://huggingface.co/BAAI/bge-m3 | +| **Lizenz** | MIT | +| **Dimensionen** | 1024 | +| **Max Token** | 8192 | +| **Sprachen** | Multilingual (inkl. Deutsch) | +| **Kommerzielle Nutzung** | ✅ Ja | +| **Status** | Default (aktiv) | + +#### Re-Ranking Model + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BAAI/bge-reranker-v2-m3 | +| **Repository** | https://huggingface.co/BAAI/bge-reranker-v2-m3 | +| **Lizenz** | Apache-2.0 | +| **Kommerzielle Nutzung** | ✅ Ja | +| **Status** | Default (aktiv) | + +#### Alternatve Embedding Models (Optional) + +| Modell | Lizenz | Dimensionen | Empfehlung | +|--------|--------|-------------|------------| +| all-MiniLM-L6-v2 | Apache-2.0 | 384 | ✅ Fallback/Development | +| deepset/mxbai-embed-de-large-v1 | Apache-2.0 | 1024 | ✅ German-only deployments | +| jinaai/jina-embeddings-v2-base-de | Apache-2.0 | 768 | ✅ German/English | +| intfloat/multilingual-e5-large | MIT | 1024 | ✅ Multilingual | + +#### Nicht empfohlene Modelle (Lizenzprobleme) + +| Modell | Lizenz | Problem | +|--------|--------|---------| +| cross-encoder/ms-marco-* | Apache-2.0 | ⚠️ MS MARCO Trainingsdaten sind nur für nicht-kommerzielle Nutzung freigegeben | +| PyMuPDF/fitz | AGPL-3.0 | ⚠️ AGPL erfordert Open-Source-Veröffentlichung oder kommerzielle Lizenz | + +### 15.2 Python Dependencies (Klausur-Service) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| sentence-transformers | >=2.2.0 | Apache-2.0 | Ja | Embedding & Re-Ranking | +| torch | >=2.0.0 | BSD-3-Clause | Ja | ML Framework (CPU-only) | +| pypdf | >=4.0.0 | BSD-3-Clause | Ja | PDF-Extraktion (Default) | +| unstructured | >=0.12.0 | Apache-2.0 | Ja | PDF-Extraktion (Tabellen) | +| qdrant-client | >=1.7.0 | Apache-2.0 | Ja | Vector Database Client | +| httpx | >=0.26.0 | BSD-3-Clause | Ja | Async HTTP Client | +| FastAPI | >=0.109.0 | MIT | Ja | Web Framework | +| cryptography | >=41.0.0 | Apache-2.0/BSD | Ja | AES-256-GCM Verschlüsselung | + +> **Hinweis:** PyMuPDF ist NICHT im Default-Build enthalten (AGPL-3.0). Falls benötigt, separat installieren und AGPL-Compliance sicherstellen. + +### 15.3 RAG Feature Status + +| Feature | Implementiert | Standardmäßig Aktiv | Sendet Daten extern | +|---------|--------------|---------------------|---------------------| +| **Local Embeddings** | ✅ | ✅ | ❌ Nein | +| **Local Re-Ranking** | ✅ | ✅ | ❌ Nein | +| **Semantic Chunking** | ✅ | ✅ | ❌ Nein | +| **Hybrid Search (BM25)** | ✅ | ✅ | ❌ Nein | +| **HyDE** | ✅ | ❌ | ⚠️ Ja (LLM APIs) | +| **Self-RAG** | ✅ | ❌ | ⚠️ Ja (OpenAI API) | +| **RAG Evaluation** | ✅ | ⚠️ Teilweise | ⚠️ Optional (LLM) | + +### 15.4 Datenschutz-Hinweise (Privacy Notes) + +| Kategorie | Status | Beschreibung | +|-----------|--------|--------------| +| **Embeddings** | ✅ Lokal | Keine Daten werden an externe Server gesendet | +| **Re-Ranking** | ✅ Lokal | Cross-Encoder läuft komplett lokal | +| **PDF-Extraktion** | ✅ Lokal | pypdf/unstructured laufen lokal | +| **Hybrid Search** | ✅ Lokal | BM25 läuft komplett lokal | +| **HyDE** | ⚠️ Opt-in | Bei Aktivierung: Queries an LLM APIs (OpenAI/Anthropic) | +| **Self-RAG** | ⚠️ Opt-in | Bei Aktivierung: Dokumente + Queries an OpenAI | +| **Indexierte Daten** | ✅ | Nur Erwartungshorizonte, keine Schülerdaten | + +### 15.5 Konfiguration (Environment Variables) + +| Variable | Default | Beschreibung | +|----------|---------|--------------| +| `EMBEDDING_BACKEND` | `local` | Embedding-Backend (local/openai) | +| `LOCAL_EMBEDDING_MODEL` | `BAAI/bge-m3` | Lokales Embedding-Modell | +| `RERANKER_BACKEND` | `local` | Re-Ranking Backend (local/cohere) | +| `LOCAL_RERANKER_MODEL` | `BAAI/bge-reranker-v2-m3` | Lokales Re-Ranking Modell | +| `PDF_EXTRACTION_BACKEND` | `auto` | PDF-Backend (auto/unstructured/pypdf) | +| `HYDE_ENABLED` | `false` | HyDE aktivieren (⚠️ sendet Daten extern) | +| `SELF_RAG_ENABLED` | `false` | Self-RAG aktivieren (⚠️ sendet Daten extern) | +| `HYBRID_SEARCH_ENABLED` | `true` | Hybrid Search aktivieren | +| `CHUNKING_STRATEGY` | `semantic` | Chunking-Strategie (semantic/recursive) | + +### 15.6 API Endpoints + +| Endpoint | Methode | Beschreibung | +|----------|---------|--------------| +| `/api/v1/admin/rag/system-info` | GET | System-Info mit Lizenzen und Feature-Status | +| `/api/v1/admin/nibis/search` | POST | RAG-Suche mit optionalem Re-Ranking | +| `/api/v1/admin/rag/collections` | GET | Liste aller RAG Collections | +| `/api/v1/admin/nibis/ingest` | POST | Dokument-Indexierung starten | +| `/api/v1/admin/rag/metrics` | GET | RAG-Qualitätsmetriken | + +### 15.7 Compliance-Checkliste (Klausur-Service RAG) + +- [x] Alle Standard-ML-Modelle sind MIT/Apache-2.0 lizenziert +- [x] MS MARCO-basierte Modelle wurden durch bge-reranker-v2-m3 ersetzt +- [x] PyMuPDF (AGPL) ist nicht im Default-Build enthalten +- [x] HyDE/Self-RAG sind standardmäßig deaktiviert (Datenschutz) +- [x] Alle Embedding-/Re-Ranking-Operationen laufen lokal +- [x] System-Info Endpoint dokumentiert Lizenzen und Feature-Status +- [x] Keine Schülerdaten werden indexiert + +--- + +## Kontakt + +Bei Fragen zur Lizenz-Compliance: +- E-Mail: legal@breakpilot.app +- Repository: https://github.com/breakpilot/breakpilot-pwa + +--- + +## 16. OCR Grid Detection System (klausur-service) + +| Eigenschaft | Wert | +|-------------|------| +| **Name** | BreakPilot OCR Grid Detection | +| **Version** | 4.0 | +| **Status** | Produktiv | +| **Sprache** | Python 3.11+ / TypeScript | +| **Lizenz** | Proprietär (BreakPilot) | +| **Verwendungszweck** | OCR-Analyse von Vokabeltabellen mit mm-Koordinaten | + +### Python Dependencies (Grid Detection) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| NumPy | ≥2.0.0 | BSD-3-Clause | Ja | Deskew-Berechnung (polyfit/lineare Regression) | +| OpenCV | ≥4.8.0 | Apache-2.0 | Ja | Bildverarbeitung (optional) | + +### Frontend Dependencies (Worksheet Editor Integration) + +| Paket | Version | Lizenz | Kommerzielle Nutzung | Beschreibung | +|-------|---------|--------|----------------------|--------------| +| Fabric.js | 6.x | MIT | Ja | Canvas-Rendering für Wortpositionierung | + +### Features + +| Feature | Status | Beschreibung | +|---------|--------|--------------| +| **mm-Koordinatensystem** | ✅ | A4-Format (210x297mm) | +| **Deskew-Korrektur** | ✅ | Automatische Ausrichtung schiefer Scans | +| **1mm Column Margin** | ✅ | Spalten beginnen 1mm vor erstem Wort | +| **Spalten-Erkennung** | ✅ | Englisch/Deutsch/Beispiel automatisch erkannt | +| **Editor-Integration** | ✅ | Export/Import via localStorage | + +### Lizenz-Compliance + +- [x] NumPy ist BSD-3-Clause lizenziert (kommerziell nutzbar) +- [x] OpenCV ist Apache-2.0 lizenziert (kommerziell nutzbar) +- [x] Fabric.js ist MIT lizenziert (kommerziell nutzbar) +- [x] Alle Verarbeitung erfolgt lokal (keine Daten an externe Server) + +| 2026-02-08 | OCR Grid Detection System (v4) hinzugefügt | Claude Code | +| 2026-02-08 | Fabric.js für Worksheet Editor Integration dokumentiert | Claude Code | +| 2026-02-08 | Deskew-Korrektur und mm-Koordinatensystem dokumentiert | Claude Code | diff --git a/admin-v2/SOURCE_POLICY_IMPLEMENTATION_PLAN.md b/admin-v2/SOURCE_POLICY_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..6bd6632 --- /dev/null +++ b/admin-v2/SOURCE_POLICY_IMPLEMENTATION_PLAN.md @@ -0,0 +1,530 @@ +# Source-Policy System - Implementierungsplan + +## Zusammenfassung + +Whitelist-basiertes Datenquellen-Management fuer das edu-search-service unter `/compliance/source-policy`. Fuer Auditoren pruefbar mit vollstaendigem Audit-Trail. + +**Kernprinzipien:** +- Nur offizielle Open-Data-Portale und amtliche Quellen (§5 UrhG) +- Training mit externen Daten: **VERBOTEN** +- Alle Aenderungen protokolliert (Audit-Trail) +- PII-Blocklist mit Hard-Block + +--- + +## 1. Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ admin-v2 (Next.js) │ +│ /app/(admin)/compliance/source-policy/ │ +│ ├── page.tsx (Dashboard + Tabs) │ +│ └── components/ │ +│ ├── SourcesTab.tsx (Whitelist-Verwaltung) │ +│ ├── OperationsMatrixTab.tsx (Lookup/RAG/Training/Export) │ +│ ├── PIIRulesTab.tsx (PII-Blocklist) │ +│ └── AuditTab.tsx (Aenderungshistorie + Export) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ edu-search-service (Go) │ +│ NEW: internal/policy/ │ +│ ├── models.go (Datenstrukturen) │ +│ ├── store.go (PostgreSQL CRUD) │ +│ ├── enforcer.go (Policy-Enforcement) │ +│ ├── pii_detector.go (PII-Erkennung) │ +│ └── audit.go (Audit-Logging) │ +│ │ +│ MODIFIED: │ +│ ├── crawler/crawler.go (Whitelist-Check vor Fetch) │ +│ ├── pipeline/pipeline.go (PII-Filter nach Extract) │ +│ └── api/handlers/policy_handlers.go (Admin-API) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ PostgreSQL │ +│ NEW TABLES: │ +│ - source_policies (versionierte Policies) │ +│ - allowed_sources (Whitelist pro Bundesland) │ +│ - operation_permissions (Lookup/RAG/Training/Export Matrix) │ +│ - pii_rules (Regex/Keyword Blocklist) │ +│ - policy_audit_log (unveraenderlich) │ +│ - blocked_content_log (blockierte URLs fuer Audit) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Datenmodell + +### 2.1 PostgreSQL Schema + +```sql +-- Policies (versioniert) +CREATE TABLE source_policies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version INTEGER NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + bundesland VARCHAR(2), -- NULL = Bundesebene/KMK + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + approved_by UUID, + approved_at TIMESTAMP +); + +-- Whitelist +CREATE TABLE allowed_sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + policy_id UUID REFERENCES source_policies(id), + domain VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + license VARCHAR(50) NOT NULL, -- DL-DE-BY-2.0, CC-BY, §5 UrhG + legal_basis VARCHAR(100), + citation_template TEXT, + trust_boost DECIMAL(3,2) DEFAULT 0.50, + is_active BOOLEAN DEFAULT true +); + +-- Operations Matrix +CREATE TABLE operation_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_id UUID REFERENCES allowed_sources(id), + operation VARCHAR(50) NOT NULL, -- lookup, rag, training, export + is_allowed BOOLEAN NOT NULL, + requires_citation BOOLEAN DEFAULT false, + notes TEXT +); + +-- PII Blocklist +CREATE TABLE pii_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + rule_type VARCHAR(50) NOT NULL, -- regex, keyword + pattern TEXT NOT NULL, + severity VARCHAR(20) DEFAULT 'block', -- block, warn, redact + is_active BOOLEAN DEFAULT true +); + +-- Audit Log (immutable) +CREATE TABLE policy_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id UUID, + old_value JSONB, + new_value JSONB, + user_email VARCHAR(255), + created_at TIMESTAMP DEFAULT NOW() +); + +-- Blocked Content Log +CREATE TABLE blocked_content_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + url VARCHAR(2048) NOT NULL, + domain VARCHAR(255) NOT NULL, + block_reason VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 2.2 Initial-Daten + +Datei: `edu-search-service/policies/bundeslaender.yaml` + +```yaml +federal: + name: "KMK & Bundesebene" + sources: + - domain: "kmk.org" + name: "Kultusministerkonferenz" + license: "§5 UrhG" + legal_basis: "Amtliche Werke (§5 UrhG)" + citation_template: "Quelle: KMK, {title}, {date}" + - domain: "bildungsserver.de" + name: "Deutscher Bildungsserver" + license: "DL-DE-BY-2.0" + +NI: + name: "Niedersachsen" + sources: + - domain: "nibis.de" + name: "NiBiS Bildungsserver" + license: "DL-DE-BY-2.0" + - domain: "mk.niedersachsen.de" + name: "Kultusministerium Niedersachsen" + license: "§5 UrhG" + - domain: "cuvo.nibis.de" + name: "Kerncurricula Niedersachsen" + license: "DL-DE-BY-2.0" + +BY: + name: "Bayern" + sources: + - domain: "km.bayern.de" + name: "Bayerisches Kultusministerium" + license: "§5 UrhG" + - domain: "isb.bayern.de" + name: "ISB Bayern" + license: "DL-DE-BY-2.0" + - domain: "lehrplanplus.bayern.de" + name: "LehrplanPLUS" + license: "DL-DE-BY-2.0" + +# Default Operations Matrix +default_operations: + lookup: + allowed: true + requires_citation: true + rag: + allowed: true + requires_citation: true + training: + allowed: false # VERBOTEN + export: + allowed: true + requires_citation: true + +# Default PII Rules +pii_rules: + - name: "Email Addresses" + type: "regex" + pattern: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}" + severity: "block" + - name: "German Phone Numbers" + type: "regex" + pattern: "(?:\\+49|0)[\\s.-]?\\d{2,4}[\\s.-]?\\d{3,}[\\s.-]?\\d{2,}" + severity: "block" + - name: "IBAN" + type: "regex" + pattern: "DE\\d{2}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{2}" + severity: "block" +``` + +--- + +## 3. Backend Implementation + +### 3.1 Neue Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `internal/policy/models.go` | Go Structs (SourcePolicy, AllowedSource, PIIRule, etc.) | +| `internal/policy/store.go` | PostgreSQL CRUD mit pgx | +| `internal/policy/enforcer.go` | `CheckSource()`, `CheckOperation()`, `DetectPII()` | +| `internal/policy/audit.go` | `LogChange()`, `LogBlocked()` | +| `internal/policy/pii_detector.go` | Regex-basierte PII-Erkennung | +| `internal/api/handlers/policy_handlers.go` | Admin-Endpoints | +| `migrations/005_source_policies.sql` | DB-Schema | +| `policies/bundeslaender.yaml` | Initial-Daten | + +### 3.2 API Endpoints + +``` +# Policies +GET /v1/admin/policies +POST /v1/admin/policies +PUT /v1/admin/policies/:id + +# Sources (Whitelist) +GET /v1/admin/sources +POST /v1/admin/sources +PUT /v1/admin/sources/:id +DELETE /v1/admin/sources/:id + +# Operations Matrix +GET /v1/admin/operations-matrix +PUT /v1/admin/operations/:id + +# PII Rules +GET /v1/admin/pii-rules +POST /v1/admin/pii-rules +PUT /v1/admin/pii-rules/:id +DELETE /v1/admin/pii-rules/:id +POST /v1/admin/pii-rules/test # Test gegen Sample-Text + +# Audit +GET /v1/admin/policy-audit?from=&to= +GET /v1/admin/blocked-content?from=&to= +GET /v1/admin/compliance-report # PDF/JSON Export + +# Live-Check +POST /v1/admin/check-compliance + Body: { "url": "...", "operation": "lookup" } +``` + +### 3.3 Crawler-Integration + +In `crawler/crawler.go`: +```go +func (c *Crawler) FetchWithPolicy(ctx context.Context, url string) (*FetchResult, error) { + // 1. Whitelist-Check + source, err := c.enforcer.CheckSource(ctx, url) + if err != nil || source == nil { + c.enforcer.LogBlocked(ctx, url, "not_whitelisted") + return nil, ErrNotWhitelisted + } + + // ... existing fetch ... + + // 2. PII-Check nach Fetch + piiMatches := c.enforcer.DetectPII(content) + if hasSeverity(piiMatches, "block") { + c.enforcer.LogBlocked(ctx, url, "pii_detected") + return nil, ErrPIIDetected + } + + return result, nil +} +``` + +--- + +## 4. Frontend Implementation + +### 4.1 Navigation Update + +In `lib/navigation.ts` unter `compliance` Kategorie hinzufuegen: + +```typescript +{ + id: 'source-policy', + name: 'Quellen-Policy', + href: '/compliance/source-policy', + description: 'Datenquellen & Compliance', + purpose: 'Whitelist zugelassener Datenquellen mit Operations-Matrix und PII-Blocklist.', + audience: ['DSB', 'Compliance Officer', 'Auditor'], + gdprArticles: ['Art. 5 (Rechtmaessigkeit)', 'Art. 6 (Rechtsgrundlage)'], +} +``` + +### 4.2 Seiten-Struktur + +``` +/app/(admin)/compliance/source-policy/ +├── page.tsx # Haupt-Dashboard mit Tabs +└── components/ + ├── SourcesTab.tsx # Whitelist-Tabelle mit CRUD + ├── OperationsMatrixTab.tsx # 4x4 Matrix + ├── PIIRulesTab.tsx # PII-Regeln mit Test-Funktion + └── AuditTab.tsx # Aenderungshistorie + Export +``` + +### 4.3 UI-Layout + +**Stats Cards (oben):** +- Aktive Policies +- Zugelassene Quellen +- Blockiert (heute) +- Compliance Score + +**Tabs:** +1. **Dashboard** - Uebersicht mit Quick-Stats +2. **Quellen** - Whitelist-Tabelle (Domain, Name, Lizenz, Status) +3. **Operations** - Matrix mit Lookup/RAG/Training/Export +4. **PII-Regeln** - Blocklist mit Test-Funktion +5. **Audit** - Aenderungshistorie mit PDF/JSON-Export + +**Pattern (aus audit-report/page.tsx):** +- Tab-Navigation: `bg-purple-600 text-white` fuer aktiv +- Status-Badges: `bg-green-100 text-green-700` fuer aktiv +- Tabellen: `hover:bg-slate-50` +- Info-Boxen: `bg-blue-50 border-blue-200` + +--- + +## 5. Betroffene Dateien + +### Neue Dateien erstellen: + +**Backend (edu-search-service):** +``` +internal/policy/models.go +internal/policy/store.go +internal/policy/enforcer.go +internal/policy/audit.go +internal/policy/pii_detector.go +internal/api/handlers/policy_handlers.go +migrations/005_source_policies.sql +policies/bundeslaender.yaml +``` + +**Frontend (admin-v2):** +``` +app/(admin)/compliance/source-policy/page.tsx +app/(admin)/compliance/source-policy/components/SourcesTab.tsx +app/(admin)/compliance/source-policy/components/OperationsMatrixTab.tsx +app/(admin)/compliance/source-policy/components/PIIRulesTab.tsx +app/(admin)/compliance/source-policy/components/AuditTab.tsx +``` + +### Bestehende Dateien aendern: + +``` +edu-search-service/cmd/server/main.go # Policy-Endpoints registrieren +edu-search-service/internal/crawler/crawler.go # Policy-Check hinzufuegen +edu-search-service/internal/pipeline/pipeline.go # PII-Filter +edu-search-service/internal/database/database.go # Migrations +admin-v2/lib/navigation.ts # source-policy Modul +``` + +--- + +## 6. Implementierungs-Reihenfolge + +### Phase 1: Datenbank & Models +1. Migration `005_source_policies.sql` erstellen +2. Go Models in `internal/policy/models.go` +3. Store-Layer in `internal/policy/store.go` +4. YAML-Loader fuer Initial-Daten + +### Phase 2: Policy Enforcer +1. `internal/policy/enforcer.go` - CheckSource, CheckOperation +2. `internal/policy/pii_detector.go` - Regex-basierte Erkennung +3. `internal/policy/audit.go` - Logging +4. Integration in Crawler + +### Phase 3: Admin API +1. `internal/api/handlers/policy_handlers.go` +2. Routen in main.go registrieren +3. API testen + +### Phase 4: Frontend +1. Hauptseite mit PagePurpose +2. SourcesTab mit Whitelist-CRUD +3. OperationsMatrixTab +4. PIIRulesTab mit Test-Funktion +5. AuditTab mit Export + +### Phase 5: Testing & Deployment +1. Unit Tests fuer Enforcer +2. Integration Tests fuer API +3. E2E Test fuer Frontend +4. Deployment auf Mac Mini + +--- + +## 7. Verifikation + +### Nach Backend (Phase 1-3): +```bash +# Migration ausfuehren +ssh macmini "cd /path/to/edu-search-service && go run ./cmd/migrate" + +# API testen +curl -X GET http://macmini:8088/v1/admin/policies +curl -X POST http://macmini:8088/v1/admin/check-compliance \ + -d '{"url":"https://nibis.de/test","operation":"lookup"}' +``` + +### Nach Frontend (Phase 4): +```bash +# Build & Deploy +rsync -avz admin-v2/ macmini:/path/to/admin-v2/ +ssh macmini "docker compose build admin-v2 && docker compose up -d admin-v2" + +# Testen +open https://macmini:3002/compliance/source-policy +``` + +### Auditor-Checkliste: +- [ ] Alle Quellen in Whitelist dokumentiert +- [ ] Operations-Matrix zeigt Training = VERBOTEN +- [ ] PII-Regeln aktiv und testbar +- [ ] Audit-Log zeigt alle Aenderungen +- [ ] Blocked-Content-Log zeigt blockierte URLs +- [ ] PDF/JSON-Export funktioniert + +--- + +## 8. KMK-Spezifika (§5 UrhG) + +**Rechtsgrundlage:** +- KMK-Beschluesse, Vereinbarungen, EPA sind amtliche Werke nach §5 UrhG +- Frei nutzbar, Attribution erforderlich + +**Zitierformat:** +``` +Quelle: KMK, [Titel des Beschlusses], [Datum] +Beispiel: Quelle: KMK, Bildungsstandards im Fach Deutsch, 2003 +``` + +**Zugelassene Dokumenttypen:** +- Beschluesse (Resolutions) +- Vereinbarungen (Agreements) +- EPA (Einheitliche Pruefungsanforderungen) +- Empfehlungen (Recommendations) + +**In Operations-Matrix:** +| Operation | Erlaubt | Hinweis | +|-----------|---------|---------| +| Lookup | Ja | Quelle anzeigen | +| RAG | Ja | Zitation im Output | +| Training | **NEIN** | VERBOTEN | +| Export | Ja | Attribution | + +--- + +## 9. Lizenzen + +| Lizenz | Name | Attribution | +|--------|------|-------------| +| DL-DE-BY-2.0 | Datenlizenz Deutschland | Ja | +| CC-BY | Creative Commons Attribution | Ja | +| CC-BY-SA | CC Attribution-ShareAlike | Ja + ShareAlike | +| CC0 | Public Domain | Nein | +| §5 UrhG | Amtliche Werke | Ja (Quelle) | + +--- + +## 10. Aktueller Stand + +**Phase 1: Datenbank & Models - ABGESCHLOSSEN** +- [x] Codebase-Exploration edu-search-service +- [x] Codebase-Exploration admin-v2 +- [x] Plan dokumentiert +- [x] Migration 005_source_policies.sql erstellen +- [x] Go Models implementieren (internal/policy/models.go) +- [x] Store-Layer implementieren (internal/policy/store.go) +- [x] Policy Enforcer implementieren (internal/policy/enforcer.go) +- [x] PII Detector implementieren (internal/policy/pii_detector.go) +- [x] Audit Logging implementieren (internal/policy/audit.go) +- [x] YAML Loader implementieren (internal/policy/loader.go) +- [x] Initial-Daten YAML erstellen (policies/bundeslaender.yaml) +- [x] Unit Tests schreiben (internal/policy/policy_test.go) +- [x] README aktualisieren + +**Phase 2: Admin API - AUSSTEHEND** +- [ ] API Handlers implementieren (policy_handlers.go) +- [ ] main.go aktualisieren +- [ ] API testen + +**Phase 3: Integration - AUSSTEHEND** +- [ ] Crawler-Integration +- [ ] Pipeline-Integration + +**Phase 4: Frontend - AUSSTEHEND** +- [ ] Frontend page.tsx erstellen +- [ ] SourcesTab Component +- [ ] OperationsMatrixTab Component +- [ ] PIIRulesTab Component +- [ ] AuditTab Component +- [ ] Navigation aktualisieren + +**Erstellte Dateien:** +``` +edu-search-service/ +├── migrations/ +│ └── 005_source_policies.sql # DB Schema (6 Tabellen) +├── internal/policy/ +│ ├── models.go # Datenstrukturen & Enums +│ ├── store.go # PostgreSQL CRUD +│ ├── enforcer.go # Policy-Enforcement +│ ├── pii_detector.go # PII-Erkennung +│ ├── audit.go # Audit-Logging +│ ├── loader.go # YAML-Loader +│ └── policy_test.go # Unit Tests +└── policies/ + └── bundeslaender.yaml # Initial-Daten (8 Bundeslaender) +``` diff --git a/admin-v2/ai-compliance-sdk/internal/db/migrations/002_create_academy_tables.sql b/admin-v2/ai-compliance-sdk/internal/db/migrations/002_create_academy_tables.sql new file mode 100644 index 0000000..05df68d --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/db/migrations/002_create_academy_tables.sql @@ -0,0 +1,305 @@ +-- Migration: Create Academy Tables +-- Description: Schema for the Compliance Academy module (courses, lessons, quizzes, enrollments, certificates, progress) + +-- Enable UUID extension if not already enabled +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ============================================================================ +-- 1. academy_courses - Training courses for compliance education +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS academy_courses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + category VARCHAR(50), + passing_score INTEGER DEFAULT 70, + duration_minutes INTEGER, + required_for_roles JSONB DEFAULT '["all"]', + status VARCHAR(50) DEFAULT 'draft', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for academy_courses +CREATE INDEX IF NOT EXISTS idx_academy_courses_tenant ON academy_courses(tenant_id); +CREATE INDEX IF NOT EXISTS idx_academy_courses_status ON academy_courses(status); +CREATE INDEX IF NOT EXISTS idx_academy_courses_category ON academy_courses(category); + +-- Auto-update trigger for academy_courses.updated_at +CREATE OR REPLACE FUNCTION update_academy_courses_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_academy_courses_updated_at ON academy_courses; +CREATE TRIGGER trigger_academy_courses_updated_at + BEFORE UPDATE ON academy_courses + FOR EACH ROW + EXECUTE FUNCTION update_academy_courses_updated_at(); + +-- Comments for academy_courses +COMMENT ON TABLE academy_courses IS 'Stores compliance training courses per tenant'; +COMMENT ON COLUMN academy_courses.tenant_id IS 'Identifier for the tenant owning this course'; +COMMENT ON COLUMN academy_courses.title IS 'Course title displayed to users'; +COMMENT ON COLUMN academy_courses.category IS 'Course category (e.g. dsgvo, ai-act, security)'; +COMMENT ON COLUMN academy_courses.passing_score IS 'Minimum score (0-100) required to pass the course'; +COMMENT ON COLUMN academy_courses.duration_minutes IS 'Estimated total duration of the course in minutes'; +COMMENT ON COLUMN academy_courses.required_for_roles IS 'JSON array of roles required to complete this course'; +COMMENT ON COLUMN academy_courses.status IS 'Course status: draft, published, archived'; + +-- ============================================================================ +-- 2. academy_lessons - Individual lessons within a course +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS academy_lessons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + course_id UUID NOT NULL REFERENCES academy_courses(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + type VARCHAR(20) NOT NULL, + content_markdown TEXT, + video_url VARCHAR(500), + audio_url VARCHAR(500), + sort_order INTEGER NOT NULL DEFAULT 0, + duration_minutes INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for academy_lessons +CREATE INDEX IF NOT EXISTS idx_academy_lessons_course ON academy_lessons(course_id); +CREATE INDEX IF NOT EXISTS idx_academy_lessons_sort ON academy_lessons(course_id, sort_order); + +-- Auto-update trigger for academy_lessons.updated_at +CREATE OR REPLACE FUNCTION update_academy_lessons_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_academy_lessons_updated_at ON academy_lessons; +CREATE TRIGGER trigger_academy_lessons_updated_at + BEFORE UPDATE ON academy_lessons + FOR EACH ROW + EXECUTE FUNCTION update_academy_lessons_updated_at(); + +-- Comments for academy_lessons +COMMENT ON TABLE academy_lessons IS 'Individual lessons belonging to a course'; +COMMENT ON COLUMN academy_lessons.course_id IS 'Foreign key to the parent course'; +COMMENT ON COLUMN academy_lessons.type IS 'Lesson type: text, video, audio, quiz, interactive'; +COMMENT ON COLUMN academy_lessons.content_markdown IS 'Lesson content in Markdown format'; +COMMENT ON COLUMN academy_lessons.video_url IS 'URL to video content (if type is video)'; +COMMENT ON COLUMN academy_lessons.audio_url IS 'URL to audio content (if type is audio)'; +COMMENT ON COLUMN academy_lessons.sort_order IS 'Order of the lesson within the course'; +COMMENT ON COLUMN academy_lessons.duration_minutes IS 'Estimated duration of this lesson in minutes'; + +-- ============================================================================ +-- 3. academy_quiz_questions - Quiz questions attached to lessons +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS academy_quiz_questions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + lesson_id UUID NOT NULL REFERENCES academy_lessons(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options JSONB NOT NULL, + correct_option_index INTEGER NOT NULL, + explanation TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for academy_quiz_questions +CREATE INDEX IF NOT EXISTS idx_academy_quiz_questions_lesson ON academy_quiz_questions(lesson_id); +CREATE INDEX IF NOT EXISTS idx_academy_quiz_questions_sort ON academy_quiz_questions(lesson_id, sort_order); + +-- Comments for academy_quiz_questions +COMMENT ON TABLE academy_quiz_questions IS 'Quiz questions belonging to a lesson'; +COMMENT ON COLUMN academy_quiz_questions.lesson_id IS 'Foreign key to the parent lesson'; +COMMENT ON COLUMN academy_quiz_questions.question IS 'The question text'; +COMMENT ON COLUMN academy_quiz_questions.options IS 'JSON array of answer options (strings)'; +COMMENT ON COLUMN academy_quiz_questions.correct_option_index IS 'Zero-based index of the correct option'; +COMMENT ON COLUMN academy_quiz_questions.explanation IS 'Explanation shown after answering (correct or incorrect)'; +COMMENT ON COLUMN academy_quiz_questions.sort_order IS 'Order of the question within the lesson quiz'; + +-- ============================================================================ +-- 4. academy_enrollments - User enrollments in courses +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS academy_enrollments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id VARCHAR(255) NOT NULL, + course_id UUID NOT NULL REFERENCES academy_courses(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + user_name VARCHAR(255), + user_email VARCHAR(255), + status VARCHAR(20) DEFAULT 'not_started', + progress INTEGER DEFAULT 0, + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + certificate_id UUID, + deadline TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for academy_enrollments +CREATE INDEX IF NOT EXISTS idx_academy_enrollments_tenant ON academy_enrollments(tenant_id); +CREATE INDEX IF NOT EXISTS idx_academy_enrollments_course ON academy_enrollments(course_id); +CREATE INDEX IF NOT EXISTS idx_academy_enrollments_user ON academy_enrollments(user_id); +CREATE INDEX IF NOT EXISTS idx_academy_enrollments_status ON academy_enrollments(status); +CREATE INDEX IF NOT EXISTS idx_academy_enrollments_tenant_user ON academy_enrollments(tenant_id, user_id); + +-- Auto-update trigger for academy_enrollments.updated_at +CREATE OR REPLACE FUNCTION update_academy_enrollments_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trigger_academy_enrollments_updated_at ON academy_enrollments; +CREATE TRIGGER trigger_academy_enrollments_updated_at + BEFORE UPDATE ON academy_enrollments + FOR EACH ROW + EXECUTE FUNCTION update_academy_enrollments_updated_at(); + +-- Comments for academy_enrollments +COMMENT ON TABLE academy_enrollments IS 'Tracks user enrollments and progress in courses'; +COMMENT ON COLUMN academy_enrollments.tenant_id IS 'Identifier for the tenant'; +COMMENT ON COLUMN academy_enrollments.course_id IS 'Foreign key to the enrolled course'; +COMMENT ON COLUMN academy_enrollments.user_id IS 'Identifier of the enrolled user'; +COMMENT ON COLUMN academy_enrollments.user_name IS 'Display name of the enrolled user'; +COMMENT ON COLUMN academy_enrollments.user_email IS 'Email address of the enrolled user'; +COMMENT ON COLUMN academy_enrollments.status IS 'Enrollment status: not_started, in_progress, completed, expired'; +COMMENT ON COLUMN academy_enrollments.progress IS 'Completion percentage (0-100)'; +COMMENT ON COLUMN academy_enrollments.certificate_id IS 'Reference to issued certificate (if completed)'; +COMMENT ON COLUMN academy_enrollments.deadline IS 'Deadline by which the course must be completed'; + +-- ============================================================================ +-- 5. academy_certificates - Certificates issued upon course completion +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS academy_certificates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id VARCHAR(255) NOT NULL, + enrollment_id UUID NOT NULL UNIQUE REFERENCES academy_enrollments(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES academy_courses(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + user_name VARCHAR(255), + course_name VARCHAR(255), + score INTEGER, + issued_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + valid_until TIMESTAMP WITH TIME ZONE, + pdf_url VARCHAR(500) +); + +-- Indexes for academy_certificates +CREATE INDEX IF NOT EXISTS idx_academy_certificates_tenant ON academy_certificates(tenant_id); +CREATE INDEX IF NOT EXISTS idx_academy_certificates_user ON academy_certificates(user_id); +CREATE INDEX IF NOT EXISTS idx_academy_certificates_course ON academy_certificates(course_id); +CREATE INDEX IF NOT EXISTS idx_academy_certificates_enrollment ON academy_certificates(enrollment_id); + +-- Comments for academy_certificates +COMMENT ON TABLE academy_certificates IS 'Certificates issued when a user completes a course'; +COMMENT ON COLUMN academy_certificates.tenant_id IS 'Identifier for the tenant'; +COMMENT ON COLUMN academy_certificates.enrollment_id IS 'Unique reference to the enrollment (one certificate per enrollment)'; +COMMENT ON COLUMN academy_certificates.course_id IS 'Foreign key to the completed course'; +COMMENT ON COLUMN academy_certificates.user_id IS 'Identifier of the certified user'; +COMMENT ON COLUMN academy_certificates.user_name IS 'Name of the user as printed on the certificate'; +COMMENT ON COLUMN academy_certificates.course_name IS 'Name of the course as printed on the certificate'; +COMMENT ON COLUMN academy_certificates.score IS 'Final quiz score achieved (0-100)'; +COMMENT ON COLUMN academy_certificates.issued_at IS 'Timestamp when the certificate was issued'; +COMMENT ON COLUMN academy_certificates.valid_until IS 'Expiry date of the certificate (NULL = no expiry)'; +COMMENT ON COLUMN academy_certificates.pdf_url IS 'URL to the generated certificate PDF'; + +-- ============================================================================ +-- 6. academy_lesson_progress - Per-lesson progress tracking +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS academy_lesson_progress ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + enrollment_id UUID NOT NULL REFERENCES academy_enrollments(id) ON DELETE CASCADE, + lesson_id UUID NOT NULL REFERENCES academy_lessons(id) ON DELETE CASCADE, + completed BOOLEAN DEFAULT false, + quiz_score INTEGER, + completed_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT uq_academy_lesson_progress_enrollment_lesson UNIQUE (enrollment_id, lesson_id) +); + +-- Indexes for academy_lesson_progress +CREATE INDEX IF NOT EXISTS idx_academy_lesson_progress_enrollment ON academy_lesson_progress(enrollment_id); +CREATE INDEX IF NOT EXISTS idx_academy_lesson_progress_lesson ON academy_lesson_progress(lesson_id); + +-- Comments for academy_lesson_progress +COMMENT ON TABLE academy_lesson_progress IS 'Tracks completion status and quiz scores per lesson per enrollment'; +COMMENT ON COLUMN academy_lesson_progress.enrollment_id IS 'Foreign key to the enrollment'; +COMMENT ON COLUMN academy_lesson_progress.lesson_id IS 'Foreign key to the lesson'; +COMMENT ON COLUMN academy_lesson_progress.completed IS 'Whether the lesson has been completed'; +COMMENT ON COLUMN academy_lesson_progress.quiz_score IS 'Quiz score for this lesson (0-100), NULL if no quiz'; +COMMENT ON COLUMN academy_lesson_progress.completed_at IS 'Timestamp when the lesson was completed'; + +-- ============================================================================ +-- Helper: Upsert function for lesson progress (ON CONFLICT handling) +-- ============================================================================ + +CREATE OR REPLACE FUNCTION upsert_academy_lesson_progress( + p_enrollment_id UUID, + p_lesson_id UUID, + p_completed BOOLEAN, + p_quiz_score INTEGER DEFAULT NULL +) +RETURNS academy_lesson_progress AS $$ +DECLARE + result academy_lesson_progress; +BEGIN + INSERT INTO academy_lesson_progress (enrollment_id, lesson_id, completed, quiz_score, completed_at) + VALUES ( + p_enrollment_id, + p_lesson_id, + p_completed, + p_quiz_score, + CASE WHEN p_completed THEN NOW() ELSE NULL END + ) + ON CONFLICT ON CONSTRAINT uq_academy_lesson_progress_enrollment_lesson + DO UPDATE SET + completed = EXCLUDED.completed, + quiz_score = COALESCE(EXCLUDED.quiz_score, academy_lesson_progress.quiz_score), + completed_at = CASE + WHEN EXCLUDED.completed AND academy_lesson_progress.completed_at IS NULL THEN NOW() + WHEN NOT EXCLUDED.completed THEN NULL + ELSE academy_lesson_progress.completed_at + END + RETURNING * INTO result; + + RETURN result; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION upsert_academy_lesson_progress IS 'Insert or update lesson progress with ON CONFLICT handling on the unique (enrollment_id, lesson_id) constraint'; + +-- ============================================================================ +-- Helper: Cleanup function for expired certificates +-- ============================================================================ + +CREATE OR REPLACE FUNCTION cleanup_expired_academy_certificates(days_past_expiry INTEGER DEFAULT 0) +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + DELETE FROM academy_certificates + WHERE valid_until IS NOT NULL + AND valid_until < NOW() - (days_past_expiry || ' days')::INTERVAL; + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION cleanup_expired_academy_certificates IS 'Removes certificates that have expired beyond the specified number of days'; diff --git a/admin-v2/deploy-and-ingest.sh b/admin-v2/deploy-and-ingest.sh new file mode 100755 index 0000000..0146513 --- /dev/null +++ b/admin-v2/deploy-and-ingest.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# ============================================================ +# RAG DACH Vollabdeckung — Deploy & Ingest Script +# Laeuft auf dem Mac Mini im Hintergrund (nohup) +# ============================================================ + +set -e + +LOG_FILE="/Users/benjaminadmin/Projekte/breakpilot-pwa/ingest-$(date +%Y%m%d-%H%M%S).log" +PROJ="/Users/benjaminadmin/Projekte/breakpilot-pwa" +DOCKER="/usr/local/bin/docker" +COMPOSE="$DOCKER compose -f $PROJ/docker-compose.yml" + +exec > >(tee -a "$LOG_FILE") 2>&1 + +echo "============================================================" +echo "RAG DACH Deploy & Ingest — Start: $(date)" +echo "Logfile: $LOG_FILE" +echo "============================================================" + +# Phase 1: Check prerequisites +echo "" +echo "[1/6] Pruefe Docker-Services..." +$COMPOSE ps qdrant embedding-service klausur-service 2>/dev/null || true + +# Phase 2: Restart klausur-service to pick up new code +echo "" +echo "[2/6] Rebuilding klausur-service..." +cd "$PROJ" +$COMPOSE build --no-cache klausur-service +echo "Build fertig." + +echo "" +echo "[3/6] Restarting klausur-service..." +$COMPOSE up -d klausur-service +echo "Warte 15 Sekunden auf Service-Start..." +sleep 15 + +# Check if klausur-service is healthy +echo "Pruefe klausur-service Health..." +for i in 1 2 3 4 5; do + if curl -sf http://127.0.0.1:8086/health > /dev/null 2>&1; then + echo "klausur-service ist bereit." + break + fi + echo "Warte auf klausur-service... ($i/5)" + sleep 10 +done + +# Phase 3: Run ingestion for new DACH laws only (not all — that would re-ingest existing ones) +echo "" +echo "[4/6] Starte Ingestion der neuen DACH-Gesetze (P1 zuerst)..." + +# P1 — Deutschland +echo "" +echo "--- Deutschland P1 ---" +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + DE_DDG DE_BGB_AGB DE_EGBGB DE_UWG DE_HGB_RET DE_AO_RET DE_TKG 2>&1 || echo "DE P1 hatte Fehler (non-fatal)" + +# P1 — Oesterreich +echo "" +echo "--- Oesterreich P1 ---" +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + AT_ECG AT_TKG AT_KSCHG AT_FAGG AT_UGB_RET AT_BAO_RET AT_MEDIENG 2>&1 || echo "AT P1 hatte Fehler (non-fatal)" + +# P1 — Schweiz +echo "" +echo "--- Schweiz P1 ---" +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + CH_DSV CH_OR_AGB CH_UWG CH_FMG 2>&1 || echo "CH P1 hatte Fehler (non-fatal)" + +# 3 fehlgeschlagene Quellen nachholen +echo "" +echo "--- 3 fehlgeschlagene Quellen ---" +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + LU_DPA_LAW DK_DATABESKYTTELSESLOVEN EDPB_GUIDELINES_1_2022 2>&1 || echo "Fix-3 hatte Fehler (non-fatal)" + +echo "" +echo "[5/6] Starte Ingestion P2 + P3..." + +# P2 — Deutschland +echo "" +echo "--- Deutschland P2 ---" +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + DE_PANGV DE_DLINFOV DE_BETRVG 2>&1 || echo "DE P2 hatte Fehler (non-fatal)" + +# P2 — Oesterreich +echo "" +echo "--- Oesterreich P2 ---" +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + AT_ABGB_AGB AT_UWG 2>&1 || echo "AT P2 hatte Fehler (non-fatal)" + +# P2 — Schweiz +echo "" +echo "--- Schweiz P2 ---" +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + CH_GEBUV CH_ZERTES 2>&1 || echo "CH P2 hatte Fehler (non-fatal)" + +# P3 +echo "" +echo "--- P3 (DE + CH) ---" +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + DE_GESCHGEHG DE_BSIG DE_USTG_RET CH_ZGB_PERS 2>&1 || echo "P3 hatte Fehler (non-fatal)" + +# Phase 4: Rebuild admin-v2 frontend +echo "" +echo "[6/6] Rebuilding admin-v2 Frontend..." +$COMPOSE build --no-cache admin-v2 +$COMPOSE up -d admin-v2 +echo "admin-v2 neu gestartet." + +# Phase 5: Status check +echo "" +echo "============================================================" +echo "FINAL STATUS CHECK" +echo "============================================================" +echo "" + +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --status 2>&1 || echo "Status-Check fehlgeschlagen" + +echo "" +echo "============================================================" +echo "Fertig: $(date)" +echo "Logfile: $LOG_FILE" +echo "============================================================" diff --git a/admin-v2/docker-compose.content.yml b/admin-v2/docker-compose.content.yml new file mode 100644 index 0000000..3598569 --- /dev/null +++ b/admin-v2/docker-compose.content.yml @@ -0,0 +1,135 @@ +# BreakPilot Content Service Stack +# Usage: docker-compose -f docker-compose.yml -f docker-compose.content.yml up -d + +services: + # MinIO Object Storage (S3-compatible) + minio: + image: minio/minio:latest + container_name: breakpilot-pwa-minio + ports: + - "9000:9000" # API + - "9001:9001" # Console + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin123 + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Content Service Database (separate from main DB) + content-db: + image: postgres:16-alpine + container_name: breakpilot-pwa-content-db + ports: + - "5433:5432" + environment: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_content + volumes: + - content_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_content"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Content Service API + content-service: + build: + context: ./backend/content_service + dockerfile: Dockerfile + container_name: breakpilot-pwa-content-service + ports: + - "8002:8002" + environment: + - CONTENT_DB_URL=postgresql://breakpilot:breakpilot123@content-db:5432/breakpilot_content + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=minioadmin + - MINIO_SECRET_KEY=minioadmin123 + - MINIO_SECURE=false + - MINIO_BUCKET=breakpilot-content + - CONSENT_SERVICE_URL=http://consent-service:8081 + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - MATRIX_HOMESERVER=${MATRIX_HOMESERVER:-http://synapse:8008} + - MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN:-} + depends_on: + content-db: + condition: service_healthy + minio: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # H5P Interactive Content Service + h5p-service: + build: + context: ./h5p-service + dockerfile: Dockerfile + container_name: breakpilot-pwa-h5p + ports: + - "8003:8080" + environment: + - H5P_STORAGE_PATH=/h5p-content + - CONTENT_SERVICE_URL=http://content-service:8002 + volumes: + - h5p_content:/h5p-content + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # AI Content Generator Service + ai-content-generator: + build: + context: ./ai-content-generator + dockerfile: Dockerfile + container_name: breakpilot-pwa-ai-generator + ports: + - "8004:8004" + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - YOUTUBE_API_KEY=${YOUTUBE_API_KEY:-} + - H5P_SERVICE_URL=http://h5p-service:8080 + - CONTENT_SERVICE_URL=http://content-service:8002 + - SERVICE_HOST=0.0.0.0 + - SERVICE_PORT=8004 + - MAX_UPLOAD_SIZE=10485760 + - MAX_CONCURRENT_JOBS=5 + - JOB_TIMEOUT=300 + volumes: + - ai_generator_temp:/app/temp + - ai_generator_uploads:/app/uploads + depends_on: + - h5p-service + - content-service + networks: + - breakpilot-pwa-network + restart: unless-stopped + +volumes: + minio_data: + driver: local + content_db_data: + driver: local + h5p_content: + driver: local + ai_generator_temp: + driver: local + ai_generator_uploads: + driver: local + +networks: + breakpilot-pwa-network: + external: true diff --git a/admin-v2/docker-compose.dev.yml b/admin-v2/docker-compose.dev.yml new file mode 100644 index 0000000..0bb6678 --- /dev/null +++ b/admin-v2/docker-compose.dev.yml @@ -0,0 +1,28 @@ +# Development-specific overrides +# Use with: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + volumes: + # Mount source code for hot-reload + - ./backend:/app + # Don't override the venv + - /app/venv + environment: + - DEBUG=true + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + + consent-service: + # For development, you might want to use the local binary instead + # Uncomment below to mount source and rebuild on changes + # volumes: + # - ./consent-service:/app + environment: + - GIN_MODE=debug + + postgres: + ports: + - "5432:5432" # Expose for local tools diff --git a/admin-v2/docker-compose.override.yml b/admin-v2/docker-compose.override.yml new file mode 100644 index 0000000..0fb0cb2 --- /dev/null +++ b/admin-v2/docker-compose.override.yml @@ -0,0 +1,108 @@ +# ============================================ +# BreakPilot PWA - Development Overrides +# ============================================ +# This file is AUTOMATICALLY loaded with: docker compose up +# No need to specify -f flag for development! +# +# For staging: docker compose -f docker-compose.yml -f docker-compose.staging.yml up +# ============================================ + +services: + # ========================================== + # Python Backend (FastAPI) + # ========================================== + backend: + build: + context: ./backend + dockerfile: Dockerfile + volumes: + # Mount source code for hot-reload + - ./backend:/app + # Don't override the venv + - /app/venv + environment: + - DEBUG=true + - ENVIRONMENT=development + - LOG_LEVEL=debug + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + + # ========================================== + # Go Consent Service + # ========================================== + consent-service: + environment: + - GIN_MODE=debug + - ENVIRONMENT=development + - LOG_LEVEL=debug + + # ========================================== + # Go School Service + # ========================================== + school-service: + environment: + - GIN_MODE=debug + - ENVIRONMENT=development + + # ========================================== + # Go Billing Service + # ========================================== + billing-service: + environment: + - GIN_MODE=debug + - ENVIRONMENT=development + + # ========================================== + # Klausur Service (Python + React) + # ========================================== + klausur-service: + environment: + - DEBUG=true + - ENVIRONMENT=development + + # ========================================== + # Website (Next.js) + # ========================================== + website: + environment: + - NODE_ENV=development + + # ========================================== + # PostgreSQL + # ========================================== + postgres: + ports: + - "5432:5432" # Expose for local DB tools + environment: + - POSTGRES_DB=${POSTGRES_DB:-breakpilot_dev} + + # ========================================== + # MinIO (Object Storage) + # ========================================== + minio: + ports: + - "9000:9000" + - "9001:9001" # Console + + # ========================================== + # Qdrant (Vector DB) + # ========================================== + qdrant: + ports: + - "6333:6333" + - "6334:6334" + + # ========================================== + # Mailpit (Email Testing) + # ========================================== + mailpit: + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP + + # ========================================== + # DSMS Gateway + # ========================================== + dsms-gateway: + environment: + - DEBUG=true + - ENVIRONMENT=development diff --git a/admin-v2/docker-compose.staging.yml b/admin-v2/docker-compose.staging.yml new file mode 100644 index 0000000..efd77f2 --- /dev/null +++ b/admin-v2/docker-compose.staging.yml @@ -0,0 +1,133 @@ +# ============================================ +# BreakPilot PWA - Staging Overrides +# ============================================ +# Usage: docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d +# +# Or use the helper script: +# ./scripts/start.sh staging +# ============================================ + +services: + # ========================================== + # Python Backend (FastAPI) + # ========================================== + backend: + environment: + - DEBUG=false + - ENVIRONMENT=staging + - LOG_LEVEL=info + restart: unless-stopped + # No hot-reload in staging + command: uvicorn main:app --host 0.0.0.0 --port 8000 + + # ========================================== + # Go Consent Service + # ========================================== + consent-service: + environment: + - GIN_MODE=release + - ENVIRONMENT=staging + - LOG_LEVEL=info + restart: unless-stopped + + # ========================================== + # Go School Service + # ========================================== + school-service: + environment: + - GIN_MODE=release + - ENVIRONMENT=staging + restart: unless-stopped + + # ========================================== + # Go Billing Service + # ========================================== + billing-service: + environment: + - GIN_MODE=release + - ENVIRONMENT=staging + restart: unless-stopped + + # ========================================== + # Klausur Service (Python + React) + # ========================================== + klausur-service: + environment: + - DEBUG=false + - ENVIRONMENT=staging + restart: unless-stopped + + # ========================================== + # Website (Next.js) + # ========================================== + website: + environment: + - NODE_ENV=production + restart: unless-stopped + + # ========================================== + # PostgreSQL (Separate Database for Staging) + # ========================================== + postgres: + ports: + - "5433:5432" # Different port for staging! + environment: + - POSTGRES_DB=${POSTGRES_DB:-breakpilot_staging} + volumes: + - breakpilot_staging_postgres:/var/lib/postgresql/data + + # ========================================== + # MinIO (Object Storage - Different Ports) + # ========================================== + minio: + ports: + - "9002:9000" + - "9003:9001" + volumes: + - breakpilot_staging_minio:/data + + # ========================================== + # Qdrant (Vector DB - Different Ports) + # ========================================== + qdrant: + ports: + - "6335:6333" + - "6336:6334" + volumes: + - breakpilot_staging_qdrant:/qdrant/storage + + # ========================================== + # Mailpit (Still using Mailpit for Safety) + # ========================================== + mailpit: + ports: + - "8026:8025" # Different Web UI port + - "1026:1025" # Different SMTP port + + # ========================================== + # DSMS Gateway + # ========================================== + dsms-gateway: + environment: + - DEBUG=false + - ENVIRONMENT=staging + restart: unless-stopped + + # ========================================== + # Enable Backup Service in Staging + # ========================================== + backup: + profiles: [] # Remove profile restriction = always start + environment: + - PGDATABASE=breakpilot_staging + +# ========================================== +# Separate Volumes for Staging +# ========================================== +volumes: + breakpilot_staging_postgres: + name: breakpilot_staging_postgres + breakpilot_staging_minio: + name: breakpilot_staging_minio + breakpilot_staging_qdrant: + name: breakpilot_staging_qdrant diff --git a/admin-v2/docker-compose.test.yml b/admin-v2/docker-compose.test.yml new file mode 100644 index 0000000..87ea225 --- /dev/null +++ b/admin-v2/docker-compose.test.yml @@ -0,0 +1,153 @@ +# BreakPilot PWA - Test-Infrastruktur +# +# Vollstaendige Integration-Test Umgebung fuer CI/CD Pipeline. +# Startet alle Services isoliert fuer Integration-Tests. +# +# Verwendung: +# docker compose -f docker-compose.test.yml up -d +# docker compose -f docker-compose.test.yml down -v +# +# Verbindungen: +# PostgreSQL: localhost:55432 (breakpilot_test/breakpilot/breakpilot) +# Valkey/Redis: localhost:56379 +# Consent Service: localhost:58081 +# Backend: localhost:58000 +# Mailpit Web: localhost:58025 +# Mailpit SMTP: localhost:51025 + +version: "3.9" + +services: + # ======================================== + # Datenbank-Services + # ======================================== + + postgres-test: + image: postgres:16-alpine + container_name: breakpilot-postgres-test + environment: + POSTGRES_DB: breakpilot_test + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot_test + ports: + - "55432:5432" + volumes: + - postgres_test_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_test"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-test-network + restart: unless-stopped + + valkey-test: + image: valkey/valkey:7-alpine + container_name: breakpilot-valkey-test + ports: + - "56379:6379" + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-test-network + restart: unless-stopped + + # ======================================== + # Application Services + # ======================================== + + # Consent Service (Go) + consent-service-test: + build: + context: ./consent-service + dockerfile: Dockerfile + container_name: breakpilot-consent-service-test + ports: + - "58081:8081" + depends_on: + postgres-test: + condition: service_healthy + valkey-test: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8081/health"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test + - VALKEY_URL=redis://valkey-test:6379 + - REDIS_URL=redis://valkey-test:6379 + - JWT_SECRET=test-jwt-secret-for-integration-tests + - ENVIRONMENT=test + - LOG_LEVEL=debug + networks: + - breakpilot-test-network + restart: unless-stopped + + # Backend (Python FastAPI) + backend-test: + build: + context: ./backend + dockerfile: Dockerfile + container_name: breakpilot-backend-test + ports: + - "58000:8000" + depends_on: + postgres-test: + condition: service_healthy + valkey-test: + condition: service_healthy + consent-service-test: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 45s + environment: + - DATABASE_URL=postgresql://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test + - CONSENT_SERVICE_URL=http://consent-service-test:8081 + - VALKEY_URL=redis://valkey-test:6379 + - REDIS_URL=redis://valkey-test:6379 + - JWT_SECRET=test-jwt-secret-for-integration-tests + - ENVIRONMENT=test + - SMTP_HOST=mailpit-test + - SMTP_PORT=1025 + - SKIP_INTEGRATION_TESTS=false + networks: + - breakpilot-test-network + restart: unless-stopped + + # ======================================== + # Development/Testing Tools + # ======================================== + + # Mailpit (E-Mail Testing) + mailpit-test: + image: axllent/mailpit:latest + container_name: breakpilot-mailpit-test + ports: + - "58025:8025" # Web UI + - "51025:1025" # SMTP + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8025/api/v1/info"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - breakpilot-test-network + restart: unless-stopped + +networks: + breakpilot-test-network: + driver: bridge + +volumes: + postgres_test_data: diff --git a/admin-v2/docker-compose.vault.yml b/admin-v2/docker-compose.vault.yml new file mode 100644 index 0000000..e6da8ce --- /dev/null +++ b/admin-v2/docker-compose.vault.yml @@ -0,0 +1,98 @@ +# HashiCorp Vault Configuration for BreakPilot +# +# Usage: +# Development mode (unsealed, no auth required): +# docker-compose -f docker-compose.vault.yml up -d vault +# +# Production mode: +# docker-compose -f docker-compose.vault.yml --profile production up -d +# +# After starting Vault in dev mode: +# export VAULT_ADDR=http://localhost:8200 +# export VAULT_TOKEN=breakpilot-dev-token +# +# License: HashiCorp Vault is BSL 1.1 (open source for non-commercial use) +# Vault clients (hvac) are Apache-2.0 + +services: + # HashiCorp Vault - Secrets Management + vault: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault + ports: + - "8200:8200" + environment: + # Development mode settings + VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_DEV_TOKEN:-breakpilot-dev-token} + VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200" + VAULT_ADDR: "http://127.0.0.1:8200" + VAULT_API_ADDR: "http://0.0.0.0:8200" + cap_add: + - IPC_LOCK # Required for mlock + volumes: + - vault_data:/vault/data + - vault_logs:/vault/logs + - ./vault/config:/vault/config:ro + - ./vault/policies:/vault/policies:ro + command: server -dev -dev-root-token-id=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + healthcheck: + test: ["CMD", "vault", "status"] + interval: 10s + timeout: 5s + retries: 3 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Vault Agent for automatic secret injection (production) + vault-agent: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-agent + profiles: + - production + depends_on: + vault: + condition: service_healthy + environment: + VAULT_ADDR: "http://vault:8200" + volumes: + - ./vault/agent-config.hcl:/vault/config/agent-config.hcl:ro + - vault_agent_secrets:/vault/secrets + command: agent -config=/vault/config/agent-config.hcl + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Vault initializer - Seeds secrets in development + vault-init: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-init + depends_on: + vault: + condition: service_healthy + environment: + VAULT_ADDR: "http://vault:8200" + VAULT_TOKEN: ${VAULT_DEV_TOKEN:-breakpilot-dev-token} + volumes: + - ./vault/init-secrets.sh:/vault/init-secrets.sh:ro + entrypoint: ["/bin/sh", "-c"] + command: + - | + sleep 5 + chmod +x /vault/init-secrets.sh + /vault/init-secrets.sh + echo "Vault initialized with development secrets" + networks: + - breakpilot-pwa-network + +volumes: + vault_data: + name: breakpilot_vault_data + vault_logs: + name: breakpilot_vault_logs + vault_agent_secrets: + name: breakpilot_vault_agent_secrets + +networks: + breakpilot-pwa-network: + external: true diff --git a/admin-v2/docker-compose.yml b/admin-v2/docker-compose.yml new file mode 100644 index 0000000..a6db7bb --- /dev/null +++ b/admin-v2/docker-compose.yml @@ -0,0 +1,1832 @@ +services: + # ============================================ + # Nginx HTTPS Reverse Proxy + # Enables secure context for microphone/crypto + # Access via HTTPS on same ports as HTTP was before + # ============================================ + nginx: + image: nginx:alpine + container_name: breakpilot-pwa-nginx + ports: + - "443:443" # HTTPS Studio v2 (https://macmini/) + - "80:80" # HTTP -> HTTPS redirect + - "3000:3000" # HTTPS Admin Website (https://macmini:3000/) + - "3002:3002" # HTTPS Admin v2 (https://macmini:3002/) + - "8091:8091" # HTTPS Voice Service (wss://macmini:8091/) + - "8000:8000" # HTTPS Backend API + - "8086:8086" # HTTPS Klausur Service + - "8089:8089" # HTTPS Edu-Search proxy (edu-search runs on 8088) + - "8093:8093" # HTTPS AI Compliance SDK + - "8443:8443" # HTTPS Jitsi Meet (https://macmini:8443/) + - "3006:3006" # HTTPS Developer Portal (https://macmini:3006/) + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - vault_certs:/etc/nginx/certs:ro + depends_on: + vault-agent: + condition: service_started + studio-v2: + condition: service_started + voice-service: + condition: service_started + backend: + condition: service_started + klausur-service: + condition: service_started + website: + condition: service_started + ai-compliance-sdk: + condition: service_started + admin-v2: + condition: service_started + jitsi-web: + condition: service_started + developer-portal: + condition: service_started + extra_hosts: + - "breakpilot-edu-search:host-gateway" + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # HashiCorp Vault - Secrets Management + # Web UI: http://localhost:8200/ui + # ============================================ + vault: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault + cap_add: + - IPC_LOCK + ports: + - "8200:8200" + environment: + - VAULT_DEV_ROOT_TOKEN_ID=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + - VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200 + - VAULT_ADDR=http://127.0.0.1:8200 + volumes: + - vault_data:/vault/data + healthcheck: + test: ["CMD", "vault", "status"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Vault PKI Initialization - runs once to set up PKI and issue initial certs + vault-init: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-init + environment: + - VAULT_ADDR=http://vault:8200 + - VAULT_TOKEN=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + volumes: + - ./vault/init-pki.sh:/init-pki.sh:ro + - vault_agent_config:/vault/agent/data + - vault_certs:/vault/certs + entrypoint: ["/bin/sh", "-c"] + command: + - | + echo "Waiting for Vault to be ready..." + until vault status > /dev/null 2>&1; do sleep 1; done + echo "Vault is ready. Running PKI initialization..." + chmod +x /init-pki.sh + /init-pki.sh + echo "PKI initialization complete. Exiting." + depends_on: + vault: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: "no" + + # Vault Agent - manages certificate renewal for nginx + vault-agent: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-agent + environment: + - VAULT_ADDR=http://vault:8200 + volumes: + - ./vault/agent/config.hcl:/vault/agent/config.hcl:ro + - ./vault/agent/templates:/vault/agent/templates:ro + - ./vault/agent/split-certs.sh:/vault/agent/split-certs.sh:ro + - vault_agent_config:/vault/agent/data + - vault_certs:/vault/certs + entrypoint: ["vault", "agent", "-config=/vault/agent/config.hcl"] + depends_on: + vault: + condition: service_healthy + vault-init: + condition: service_completed_successfully + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # PostgreSQL Database with PostGIS Extension + # PostGIS is required for geo-service (OSM/Terrain features) + postgres: + image: postgis/postgis:16-3.4-alpine + container_name: breakpilot-pwa-postgres + ports: + - "5432:5432" + environment: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_db + volumes: + - breakpilot_pwa_data:/var/lib/postgresql/data + - ./geo-service/scripts/init_postgis.sql:/docker-entrypoint-initdb.d/10-init-postgis.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_db"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Valkey - Session Cache (Redis-Fork, BSD-3) + # Redis-compatible, 100% Open Source + # ============================================ + valkey: + image: valkey/valkey:8-alpine + container_name: breakpilot-pwa-valkey + ports: + - "6379:6379" + volumes: + - valkey_data:/data + command: valkey-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Go Consent Service + consent-service: + build: + context: ./consent-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-consent-service + ports: + - "8081:8081" + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-your-refresh-secret-key-change-in-production} + - PORT=8081 + - ENVIRONMENT=${ENVIRONMENT:-development} + - ALLOWED_ORIGINS=http://localhost:8000,http://backend:8000 + # Valkey Session Cache + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379} + - SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-24} + # E-Mail Konfiguration (Mailpit für Entwicklung) + - SMTP_HOST=${SMTP_HOST:-mailpit} + - SMTP_PORT=${SMTP_PORT:-1025} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-BreakPilot} + - SMTP_FROM_ADDR=${SMTP_FROM_ADDR:-noreply@breakpilot.app} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8000} + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + mailpit: + condition: service_started + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Python Backend + backend: + build: + context: ./backend + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-backend + expose: + - "8000" + environment: + - CONSENT_SERVICE_URL=http://consent-service:8081 + - KLAUSUR_SERVICE_URL=http://klausur-service:8086 + - TROCR_SERVICE_URL=${TROCR_SERVICE_URL:-http://host.docker.internal:18084} + - CAMUNDA_URL=http://camunda:8080/engine-rest + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - ENVIRONMENT=${ENVIRONMENT:-development} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - DEBUG=${DEBUG:-false} + # Alerts Agent (Google Alerts Monitoring) + - ALERTS_AGENT_ENABLED=${ALERTS_AGENT_ENABLED:-true} + # HashiCorp Vault - Secrets Management + - VAULT_ADDR=${VAULT_ADDR:-http://vault:8200} + - VAULT_TOKEN=${VAULT_TOKEN:-breakpilot-dev-token} + - VAULT_SECRETS_PATH=${VAULT_SECRETS_PATH:-breakpilot} + - USE_VAULT_SECRETS=${USE_VAULT_SECRETS:-true} + # Keycloak Authentication (optional - wenn nicht gesetzt wird lokales JWT verwendet) + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} + - KEYCLOAK_VERIFY_SSL=${KEYCLOAK_VERIFY_SSL:-true} + # Valkey Session Cache + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379} + - SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-24} + # vast.ai GPU Infrastructure + - VAST_API_KEY=${VAST_API_KEY:-} + - VAST_INSTANCE_ID=${VAST_INSTANCE_ID:-} + - CONTROL_API_KEY=${CONTROL_API_KEY:-} + - VAST_AUTO_SHUTDOWN=${VAST_AUTO_SHUTDOWN:-true} + - VAST_AUTO_SHUTDOWN_MINUTES=${VAST_AUTO_SHUTDOWN_MINUTES:-30} + # vLLM Backend + - VLLM_BASE_URL=${VLLM_BASE_URL:-} + - VLLM_ENABLED=${VLLM_ENABLED:-false} + # Ollama Backend (lokal auf Mac Mini - DSGVO-konform) + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_ENABLED=${OLLAMA_ENABLED:-true} + - OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + - OLLAMA_VISION_MODEL=${OLLAMA_VISION_MODEL:-qwen2.5vl:32b} + - OLLAMA_CORRECTION_MODEL=${OLLAMA_CORRECTION_MODEL:-qwen2.5:14b} + - OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT:-180} + # Breakpilot Drive Game API + - GAME_USE_DATABASE=${GAME_USE_DATABASE:-true} + - GAME_REQUIRE_AUTH=${GAME_REQUIRE_AUTH:-false} + - GAME_REQUIRE_BILLING=${GAME_REQUIRE_BILLING:-false} + - GAME_LLM_MODEL=${GAME_LLM_MODEL:-} + # Compliance LLM Provider Configuration + # Options: "anthropic" (cloud) or "self_hosted" (Ollama/local) + - COMPLIANCE_LLM_PROVIDER=${COMPLIANCE_LLM_PROVIDER:-self_hosted} + - SELF_HOSTED_LLM_URL=${SELF_HOSTED_LLM_URL:-http://host.docker.internal:11434} + - SELF_HOSTED_LLM_MODEL=${SELF_HOSTED_LLM_MODEL:-llama3.1:70b} + - COMPLIANCE_LLM_MAX_TOKENS=${COMPLIANCE_LLM_MAX_TOKENS:-4096} + - COMPLIANCE_LLM_TEMPERATURE=${COMPLIANCE_LLM_TEMPERATURE:-0.3} + - COMPLIANCE_LLM_TIMEOUT=${COMPLIANCE_LLM_TIMEOUT:-120} + # E-Mail Konfiguration (Mailpit fuer Entwicklung) + - SMTP_HOST=${SMTP_HOST:-mailpit} + - SMTP_PORT=${SMTP_PORT:-1025} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-BreakPilot} + - SMTP_FROM_ADDR=${SMTP_FROM_ADDR:-noreply@breakpilot.app} + extra_hosts: + - "host.docker.internal:host-gateway" + - "mac-mini:192.168.178.163" + volumes: + # Mount Docker socket for container monitoring (Mac Mini Control) + - /var/run/docker.sock:/var/run/docker.sock:ro + # Mount SBOM files for Security Dashboard + - ./docs/sbom:/app/docs/sbom:ro + # Mount Projekt-Verzeichnis fuer Test-Dashboard (echte Test-Discovery) + - /Users/benjaminadmin/Projekte/breakpilot-pwa:/app/project:ro + depends_on: + - consent-service + - valkey + - mailpit + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Automatic Database Backup (runs daily at 2 AM) + backup: + image: postgres:16-alpine + container_name: breakpilot-pwa-backup + volumes: + - ./backups:/backups + - postgres_data:/var/lib/postgresql/data:ro + environment: + - PGHOST=postgres + - PGUSER=breakpilot + - PGPASSWORD=breakpilot123 + - PGDATABASE=breakpilot_db + entrypoint: ["/bin/sh", "-c"] + command: + - | + echo "Backup service started. Running backup every day at 2 AM..." + while true; do + # Berechne Sekunden bis 2 Uhr + current_hour=$$(date +%H) + current_min=$$(date +%M) + current_sec=$$(date +%S) + + if [ $$current_hour -lt 2 ]; then + sleep_hours=$$((2 - current_hour - 1)) + else + sleep_hours=$$((26 - current_hour - 1)) + fi + sleep_mins=$$((60 - current_min - 1)) + sleep_secs=$$((60 - current_sec)) + total_sleep=$$((sleep_hours * 3600 + sleep_mins * 60 + sleep_secs)) + + echo "Next backup in $$total_sleep seconds (at 2:00 AM)" + sleep $$total_sleep + + # Backup erstellen + TIMESTAMP=$$(date +"%Y%m%d_%H%M%S") + BACKUP_FILE="/backups/breakpilot_$${TIMESTAMP}.sql.gz" + + echo "Creating backup: $$BACKUP_FILE" + pg_dump | gzip > "$$BACKUP_FILE" + + if [ $$? -eq 0 ]; then + echo "✓ Backup completed: $$BACKUP_FILE" + # Alte Backups löschen (älter als 30 Tage) + find /backups -name "breakpilot_*.sql.gz" -mtime +30 -delete + echo "✓ Old backups cleaned up" + else + echo "✗ Backup failed!" + fi + done + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + profiles: + - backup + + # Mailpit - Lokaler E-Mail-Server für Entwicklung + # Web UI: http://localhost:8025 + # SMTP: localhost:1025 + mailpit: + image: axllent/mailpit:latest + container_name: breakpilot-pwa-mailpit + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP Server + environment: + - MP_SMTP_AUTH_ACCEPT_ANY=true + - MP_SMTP_AUTH_ALLOW_INSECURE=true + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Matrix Synapse - Schulkommunikation (E2EE Messenger) + # Admin: http://localhost:8008/_synapse/admin + synapse: + image: matrixdotorg/synapse:latest + container_name: breakpilot-pwa-synapse + ports: + - "8008:8008" # Client-Server API + - "8448:8448" # Federation (optional für später) + volumes: + - synapse_data:/data + environment: + - SYNAPSE_SERVER_NAME=${MATRIX_SERVER_NAME:-breakpilot.local} + - SYNAPSE_REPORT_STATS=no + - SYNAPSE_NO_TLS=1 + - SYNAPSE_ENABLE_REGISTRATION=no + - SYNAPSE_LOG_LEVEL=INFO + - UID=1000 + - GID=1000 + healthcheck: + test: ["CMD-SHELL", "curl -fSs http://localhost:8008/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # PostgreSQL für Matrix Synapse (separate DB) + synapse-db: + image: postgres:16-alpine + container_name: breakpilot-pwa-synapse-db + environment: + POSTGRES_USER: synapse + POSTGRES_PASSWORD: ${SYNAPSE_DB_PASSWORD:-synapse_secret_123} + POSTGRES_DB: synapse + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C" + volumes: + - synapse_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Go School Service (Klausuren, Noten, Zeugnisse) + school-service: + build: + context: ./school-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-school-service + ports: + - "8084:8084" + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - PORT=8084 + - ENVIRONMENT=${ENVIRONMENT:-development} + - ALLOWED_ORIGINS=http://localhost:8000,http://backend:8000 + - LLM_GATEWAY_URL=http://backend:8000/llm + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Embedding Service (ML-heavy operations) + # Handles: embeddings, re-ranking, PDF extraction + # Separated for faster klausur-service builds + embedding-service: + build: + context: ./klausur-service/embedding-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-embedding-service + ports: + - "8087:8087" + environment: + - EMBEDDING_BACKEND=${EMBEDDING_BACKEND:-local} + - LOCAL_EMBEDDING_MODEL=${LOCAL_EMBEDDING_MODEL:-BAAI/bge-m3} + - LOCAL_RERANKER_MODEL=${LOCAL_RERANKER_MODEL:-BAAI/bge-reranker-v2-m3} + - PDF_EXTRACTION_BACKEND=${PDF_EXTRACTION_BACKEND:-auto} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - COHERE_API_KEY=${COHERE_API_KEY:-} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + volumes: + - embedding_models:/root/.cache/huggingface + deploy: + resources: + limits: + memory: 4G + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8087/health').raise_for_status()"] + interval: 30s + timeout: 10s + start_period: 120s + retries: 3 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Klausur-Service (Abitur/Vorabitur Klausurkorrektur) + # React + FastAPI Microservice + # Web UI: http://localhost:8086 + klausur-service: + build: + context: ./klausur-service + dockerfile: Dockerfile + platform: linux/arm64 # Native ARM64 - PaddlePaddle 3.3.0 unterstützt ARM64 + container_name: breakpilot-pwa-klausur-service + expose: + - "8086" + environment: + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - BACKEND_URL=http://backend:8000 + - SCHOOL_SERVICE_URL=http://school-service:8084 + - ENVIRONMENT=${ENVIRONMENT:-development} + # PostgreSQL for OCR Labeling & Metrics + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db + # Embedding Service (ML operations) + - EMBEDDING_SERVICE_URL=http://embedding-service:8087 + # BYOEH Configuration + - QDRANT_URL=http://qdrant:6333 + - BYOEH_ENCRYPTION_ENABLED=true + - BYOEH_CHUNK_SIZE=1000 + - BYOEH_CHUNK_OVERLAP=200 + # MinIO Configuration (RAG Document Storage) + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-rag + - MINIO_SECURE=false + # Ollama LLM Configuration + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_ENABLED=${OLLAMA_ENABLED:-true} + - OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + - OLLAMA_VISION_MODEL=${OLLAMA_VISION_MODEL:-qwen2.5vl:32b} + - OLLAMA_CORRECTION_MODEL=${OLLAMA_CORRECTION_MODEL:-qwen2.5:14b} + # PaddleOCR Service (x86_64 via Rosetta) + - PADDLEOCR_SERVICE_URL=http://paddleocr-service:8095 + # HashiCorp Vault - Anthropic API Key for Loesung E + - VAULT_ADDR=http://vault:8200 + - VAULT_TOKEN=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + volumes: + - klausur_uploads:/app/uploads + - eh_uploads:/app/eh-uploads + - ocr_labeling:/app/ocr-labeling + - paddle_models:/root/.paddlex # Persist PaddleOCR models across restarts + - ./docs:/app/docs # NIBIS Abitur-Dateien + depends_on: + - backend + - school-service + - embedding-service + - postgres + - qdrant + - minio + - paddleocr-service + - vault + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8086/health"] + interval: 30s + timeout: 30s + retries: 3 + start_period: 10s + restart: unless-stopped + + # ============================================ + # PaddleOCR Service - x86_64 via Rosetta + # Runs in emulation to avoid ARM64 crashes + # ============================================ + paddleocr-service: + build: + context: ./paddleocr-service + dockerfile: Dockerfile + platform: linux/amd64 # Force x86_64 emulation via Rosetta + container_name: breakpilot-pwa-paddleocr + expose: + - "8095" + environment: + - PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True + volumes: + - paddleocr_models:/root/.paddlex # Cache PaddleX models + - paddleocr_models:/root/.paddleocr # Cache PaddleOCR 3.x models + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8095/health"] + interval: 30s + timeout: 60s + retries: 5 + start_period: 180s # Models need time to load in emulation + restart: unless-stopped + + # Qdrant Vector Database (BYOEH - Erwartungshorizont RAG) + # REST API: http://localhost:6333 + # gRPC: localhost:6334 + qdrant: + image: qdrant/qdrant:v1.12.1 + container_name: breakpilot-pwa-qdrant + ports: + - "6333:6333" + - "6334:6334" + volumes: + - qdrant_data:/qdrant/storage + environment: + - QDRANT__SERVICE__GRPC_PORT=6334 + healthcheck: + test: ["CMD-SHELL", "bash -c ' /dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 3 + + # DSMS Gateway - REST API für DSMS + dsms-gateway: + build: + context: ./dsms-gateway + dockerfile: Dockerfile + container_name: breakpilot-pwa-dsms-gateway + ports: + - "8082:8082" + environment: + - IPFS_API_URL=http://dsms-node:5001 + - IPFS_GATEWAY_URL=http://dsms-node:8080 + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + depends_on: + dsms-node: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Jitsi Meet - Videokonferenzen für Schulungen + # Web UI: http://localhost:8443 + # ============================================ + + # Jitsi Web Frontend + jitsi-web: + image: jitsi/web:stable-9823 + container_name: breakpilot-pwa-jitsi-web + expose: + - "80" + environment: + - ENABLE_XMPP_WEBSOCKET=1 + - ENABLE_COLIBRI_WEBSOCKET=1 + - XMPP_DOMAIN=meet.jitsi + - XMPP_BOSH_URL_BASE=http://jitsi-xmpp:5280 + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - TZ=Europe/Berlin + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - JICOFO_AUTH_USER=focus + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - ENABLE_GUESTS=${JITSI_ENABLE_GUESTS:-1} + - ENABLE_RECORDING=${JITSI_ENABLE_RECORDING:-1} + - ENABLE_LIVESTREAMING=0 + - DISABLE_HTTPS=1 + # Branding + - APP_NAME=BreakPilot Meet + - NATIVE_APP_NAME=BreakPilot Meet + - PROVIDER_NAME=BreakPilot + volumes: + - jitsi_web_config:/config + - jitsi_web_crontabs:/var/spool/cron/crontabs + - jitsi_transcripts:/usr/share/jitsi-meet/transcripts + networks: + breakpilot-pwa-network: + aliases: + - meet.jitsi + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # Prosody XMPP Server + jitsi-xmpp: + image: jitsi/prosody:stable-9823 + container_name: breakpilot-pwa-jitsi-xmpp + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - XMPP_RECORDER_DOMAIN=recorder.meet.jitsi + - XMPP_CROSS_DOMAIN=true + - TZ=Europe/Berlin + - JICOFO_AUTH_USER=focus + - JICOFO_AUTH_PASSWORD=${JITSI_JICOFO_AUTH_PASSWORD:-jicofo_secret_123} + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${JITSI_JVB_AUTH_PASSWORD:-jvb_secret_123} + - JIGASI_XMPP_USER=jigasi + - JIGASI_XMPP_PASSWORD=${JITSI_JIGASI_XMPP_PASSWORD:-jigasi_secret_123} + - JIBRI_XMPP_USER=jibri + - JIBRI_XMPP_PASSWORD=${JITSI_JIBRI_XMPP_PASSWORD:-jibri_secret_123} + - JIBRI_RECORDER_USER=recorder + - JIBRI_RECORDER_PASSWORD=${JITSI_JIBRI_RECORDER_PASSWORD:-recorder_secret_123} + - LOG_LEVEL=info + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - ENABLE_GUESTS=${JITSI_ENABLE_GUESTS:-1} + volumes: + - jitsi_prosody_config:/config + - jitsi_prosody_plugins:/prosody-plugins-custom + networks: + breakpilot-pwa-network: + aliases: + - xmpp.meet.jitsi + restart: unless-stopped + + # Jicofo - Jitsi Conference Focus + jitsi-jicofo: + image: jitsi/jicofo:stable-9823 + container_name: breakpilot-pwa-jitsi-jicofo + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - JICOFO_AUTH_USER=focus + - JICOFO_AUTH_PASSWORD=${JITSI_JICOFO_AUTH_PASSWORD:-jicofo_secret_123} + - TZ=Europe/Berlin + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - AUTH_TYPE=internal + - ENABLE_AUTO_OWNER=${JITSI_ENABLE_AUTO_OWNER:-1} + volumes: + - jitsi_jicofo_config:/config + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # JVB - Jitsi Videobridge (WebRTC SFU) + jitsi-jvb: + image: jitsi/jvb:stable-9823 + container_name: breakpilot-pwa-jitsi-jvb + ports: + - "10000:10000/udp" # Video/Audio RTP + - "8080:8080" # Colibri REST API (internal) + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${JITSI_JVB_AUTH_PASSWORD:-jvb_secret_123} + - JVB_PORT=10000 + - JVB_STUN_SERVERS=meet-jit-si-turnrelay.jitsi.net:443 + - TZ=Europe/Berlin + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - COLIBRI_REST_ENABLED=true + - ENABLE_COLIBRI_WEBSOCKET=1 + volumes: + - jitsi_jvb_config:/config + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # ============================================ + # Jibri - Jitsi Recording Service + # Recordings werden zu MinIO hochgeladen + # ============================================ + jibri: + build: + context: ./docker/jibri + dockerfile: Dockerfile + container_name: breakpilot-pwa-jibri + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_RECORDER_DOMAIN=recorder.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - JIBRI_XMPP_USER=jibri + - JIBRI_XMPP_PASSWORD=${JITSI_JIBRI_XMPP_PASSWORD:-jibri_secret_123} + - JIBRI_RECORDER_USER=recorder + - JIBRI_RECORDER_PASSWORD=${JITSI_JIBRI_RECORDER_PASSWORD:-recorder_secret_123} + - JIBRI_BREWERY_MUC=jibribrewery + - JIBRI_RECORDING_DIR=/recordings + - JIBRI_FINALIZE_SCRIPT=/config/finalize.sh + - TZ=Europe/Berlin + # X11 Display Konfiguration (Xvfb) + - DISPLAY=:0 + - RESOLUTION=1920x1080x24 + # Optional: VNC fuer Debugging (Port 5900) + # - VNC_PASSWORD=debug123 + # MinIO Upload Konfiguration + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD} + - MINIO_BUCKET=breakpilot-recordings + # Backend Webhook (wird nach Upload aufgerufen) + - BACKEND_WEBHOOK_URL=http://backend:8000/api/recordings/webhook + volumes: + - jibri_recordings:/recordings + - /dev/shm:/dev/shm + shm_size: '2gb' + cap_add: + - SYS_ADMIN + - NET_BIND_SERVICE + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + - minio + profiles: + - recording + + # ============================================ + # Transcription Worker - Whisper + pyannote + # Verarbeitet Recordings asynchron + # ============================================ + transcription-worker: + build: + context: ./backend + dockerfile: Dockerfile.worker + container_name: breakpilot-pwa-transcription-worker + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - REDIS_URL=redis://valkey:6379/1 + - WHISPER_MODEL=${WHISPER_MODEL:-large-v3} + - WHISPER_DEVICE=${WHISPER_DEVICE:-cpu} + - WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-int8} + # pyannote.audio Token (HuggingFace) + - PYANNOTE_AUTH_TOKEN=${PYANNOTE_AUTH_TOKEN:-} + # MinIO Storage + - MINIO_ENDPOINT=${MINIO_ENDPOINT:-minio:9000} + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-recordings + - MINIO_SECURE=false + - TZ=Europe/Berlin + volumes: + - transcription_models:/root/.cache/huggingface + - transcription_temp:/tmp/transcriptions + deploy: + resources: + limits: + memory: 8G + reservations: + memory: 4G + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + minio: + condition: service_started + profiles: + - recording + + # ============================================ + # ERPNext - Open Source ERP System + # Web UI: http://localhost:8090 + # Default: Administrator / admin + # ============================================ + + # MariaDB for ERPNext + erpnext-db: + image: mariadb:10.6 + container_name: breakpilot-pwa-erpnext-db + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --skip-character-set-client-handshake --skip-innodb-read-only-compressed + environment: + - MYSQL_ROOT_PASSWORD=${ERPNEXT_DB_ROOT_PASSWORD:-changeit123} + - MYSQL_DATABASE=erpnext + - MYSQL_USER=erpnext + - MYSQL_PASSWORD=${ERPNEXT_DB_PASSWORD:-erpnext123} + volumes: + - erpnext_db_data:/var/lib/mysql + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${ERPNEXT_DB_ROOT_PASSWORD:-changeit123}"] + interval: 5s + timeout: 5s + retries: 10 + + # Redis Queue for ERPNext + erpnext-redis-queue: + image: redis:alpine + container_name: breakpilot-pwa-erpnext-redis-queue + volumes: + - erpnext_redis_queue_data:/data + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Redis Cache for ERPNext + erpnext-redis-cache: + image: redis:alpine + container_name: breakpilot-pwa-erpnext-redis-cache + volumes: + - erpnext_redis_cache_data:/data + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ERPNext Site Creator (runs once) + erpnext-create-site: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-create-site + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + if [[ ! -f sites/erpnext.local/site_config.json ]]; then + echo "Creating ERPNext site..."; + bench new-site erpnext.local --db-host=erpnext-db --db-port=3306 --admin-password=admin --db-root-password=${ERPNEXT_DB_ROOT_PASSWORD:-changeit123} --install-app erpnext --set-default; + echo "Site created successfully!"; + else + echo "Site already exists, skipping creation."; + fi; + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + depends_on: + erpnext-db: + condition: service_healthy + erpnext-redis-cache: + condition: service_started + erpnext-redis-queue: + condition: service_started + + # ERPNext Backend + erpnext-backend: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-backend + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench serve --port 8000 + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext WebSocket + erpnext-websocket: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-websocket + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench watch + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Scheduler + erpnext-scheduler: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-scheduler + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench schedule + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Worker (Long) + erpnext-worker-long: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-worker-long + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench worker --queue long,default,short + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Worker (Short) + erpnext-worker-short: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-worker-short + command: + - bash + - -c + - | + wait-for-it -t 120 erpnext-db:3306; + wait-for-it -t 120 erpnext-redis-cache:6379; + wait-for-it -t 120 erpnext-redis-queue:6379; + bench worker --queue short,default + environment: + - DB_HOST=erpnext-db + - DB_PORT=3306 + - REDIS_CACHE=redis://erpnext-redis-cache:6379 + - REDIS_QUEUE=redis://erpnext-redis-queue:6379 + - SOCKETIO_PORT=9000 + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-db: + condition: service_healthy + erpnext-create-site: + condition: service_completed_successfully + + # ERPNext Frontend (Nginx) + erpnext-frontend: + image: frappe/erpnext:latest + container_name: breakpilot-pwa-erpnext-frontend + command: + - nginx-entrypoint.sh + ports: + - "8090:8080" + environment: + - BACKEND=erpnext-backend:8000 + - SOCKETIO=erpnext-websocket:9000 + - UPSTREAM_REAL_IP_ADDRESS=127.0.0.1 + - UPSTREAM_REAL_IP_HEADER=X-Forwarded-For + - UPSTREAM_REAL_IP_RECURSIVE=off + - FRAPPE_SITE_NAME_HEADER=erpnext.local + volumes: + - erpnext_sites:/home/frappe/frappe-bench/sites + - erpnext_logs:/home/frappe/frappe-bench/logs + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + erpnext-backend: + condition: service_started + erpnext-websocket: + condition: service_started + + # ============================================ + # Breakpilot Drive - Lernspiel (Unity WebGL) + # Web UI: http://localhost:3001 + # ============================================ + breakpilot-drive: + build: + context: ./breakpilot-drive + dockerfile: Dockerfile + container_name: breakpilot-pwa-drive + ports: + - "3001:80" + environment: + # API Configuration (injected into Unity WebGL) + - API_BASE_URL=${GAME_API_URL:-http://localhost:8000/api/game} + # Feature Flags + - GAME_REQUIRE_AUTH=${GAME_REQUIRE_AUTH:-false} + - GAME_ENABLE_LEADERBOARDS=${GAME_ENABLE_LEADERBOARDS:-true} + networks: + - breakpilot-pwa-network + depends_on: + - backend + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/health.json"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped + profiles: + - game + + # ============================================ + # Camunda 7 - BPMN Workflow Engine + # Web UI: http://localhost:8089/camunda + # REST API: http://localhost:8089/engine-rest + # License: Apache 2.0 (kommerziell nutzbar) + # ============================================ + camunda: + image: camunda/camunda-bpm-platform:7.21.0 + container_name: breakpilot-pwa-camunda + ports: + - "8089:8080" + environment: + - DB_DRIVER=org.postgresql.Driver + - DB_URL=jdbc:postgresql://postgres:5432/breakpilot_db + - DB_USERNAME=breakpilot + - DB_PASSWORD=${POSTGRES_PASSWORD:-breakpilot123} + - DB_VALIDATE_ON_BORROW=true + - WAIT_FOR=postgres:5432 + - CAMUNDA_BPM_ADMIN_USER_ID=admin + - CAMUNDA_BPM_ADMIN_USER_PASSWORD=${CAMUNDA_ADMIN_PASSWORD:-admin123} + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/camunda/api/engine || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + restart: unless-stopped + profiles: + - bpmn + + # ============================================ + # GeoEdu Service - Self-Hosted OSM + Terrain + # DSGVO-konforme Erdkunde-Lernplattform + # Web UI: http://localhost:8088 + # ============================================ + geo-service: + build: + context: ./geo-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-geo-service + ports: + - "8088:8088" + environment: + - PORT=8088 + - ENVIRONMENT=${ENVIRONMENT:-development} + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key} + # PostgreSQL (PostGIS fuer OSM-Daten) + - DATABASE_URL=postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db + # MinIO (AOI Bundles, generierte Assets) + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-geo + - MINIO_SECURE=false + # Ollama (Lernstationen generieren) + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + # Tile Server Config + - TILE_CACHE_DIR=/app/cache/tiles + - DEM_CACHE_DIR=/app/cache/dem + - MAX_AOI_SIZE_KM2=4 + volumes: + - geo_osm_data:/app/data/osm + - geo_dem_data:/app/data/dem + - geo_tile_cache:/app/cache/tiles + - geo_aoi_bundles:/app/bundles + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_started + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8088/health"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + restart: unless-stopped + + # ============================================ + # Voice Service - PersonaPlex + TaskOrchestrator + # Voice-First Interface fuer Breakpilot + # DSGVO-konform: Keine Audio-Persistenz + # Web UI: http://localhost:8091 + # ============================================ + voice-service: + build: + context: ./voice-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-voice-service + # Port 8091 wird von nginx HTTPS bereitgestellt (wss://macmini:8091) + expose: + - "8091" + environment: + - PORT=8091 + - DATABASE_URL=postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db + - VALKEY_URL=redis://valkey:6379/2 + - PERSONAPLEX_ENABLED=${PERSONAPLEX_ENABLED:-false} + - PERSONAPLEX_WS_URL=${PERSONAPLEX_WS_URL:-ws://host.docker.internal:8998} + - ORCHESTRATOR_ENABLED=true + - FALLBACK_LLM_PROVIDER=${FALLBACK_LLM_PROVIDER:-ollama} + - OLLAMA_BASE_URL=http://host.docker.internal:11434 + - OLLAMA_VOICE_MODEL=qwen2.5:32b + - BQAS_JUDGE_MODEL=qwen2.5:14b + - KLAUSUR_SERVICE_URL=http://klausur-service:8086 + - ENCRYPTION_ENABLED=true + - AUDIO_PERSISTENCE=false + - AUDIO_SAMPLE_RATE=24000 + - ENVIRONMENT=${ENVIRONMENT:-development} + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + volumes: + - voice_session_data:/app/data/sessions + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8091/health"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + restart: unless-stopped + + # ============================================ + # MkDocs Documentation + # Web UI: http://localhost:8009 + # Material Theme with German language support + # ============================================ + docs: + build: + context: . + dockerfile: docs-src/Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-docs + ports: + - "8009:80" + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/"] + interval: 30s + timeout: 10s + retries: 3 + + # ============================================ + # Gitea - Self-hosted Git Server + # Web UI: http://localhost:3003 + # SSH: localhost:2222 + # Gitea Actions enabled for CI/CD + # ============================================ + gitea: + image: gitea/gitea:1.22-rootless + container_name: breakpilot-pwa-gitea + extra_hosts: + - "macmini:192.168.178.100" + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=postgres:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=breakpilot + - GITEA__database__PASSWD=breakpilot123 + - GITEA__server__DOMAIN=macmini + - GITEA__server__SSH_DOMAIN=macmini + - GITEA__server__ROOT_URL=http://macmini:3003/ + - GITEA__server__HTTP_PORT=3003 + - GITEA__server__SSH_PORT=2222 + - GITEA__server__SSH_LISTEN_PORT=2222 + - GITEA__actions__ENABLED=true + - GITEA__actions__DEFAULT_ACTIONS_URL=https://gitea.com + - GITEA__service__DISABLE_REGISTRATION=true + - GITEA__service__REQUIRE_SIGNIN_VIEW=false + - GITEA__repository__DEFAULT_BRANCH=main + - GITEA__log__LEVEL=Info + - GITEA__webhook__ALLOWED_HOST_LIST=macmini,192.168.178.100,woodpecker-server,localhost,external + volumes: + - gitea_data:/var/lib/gitea + - gitea_config:/etc/gitea + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3003:3003" + - "2222:2222" + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3003/api/healthz"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + restart: unless-stopped + + # ============================================ + # Gitea Actions Runner + # Executes CI/CD workflows defined in .gitea/workflows/ + # Includes Syft, Grype, Trivy for SBOM generation + # ============================================ + gitea-runner: + image: gitea/act_runner:latest + container_name: breakpilot-pwa-gitea-runner + environment: + - CONFIG_FILE=/config/config.yaml + - GITEA_INSTANCE_URL=http://gitea:3003 + - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_TOKEN:-} + - GITEA_RUNNER_NAME=breakpilot-runner + - GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://ubuntu:22.04,self-hosted:host + volumes: + - gitea_runner_data:/data + - ./gitea/runner-config.yaml:/config/config.yaml:ro + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + gitea: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Woodpecker CI - Server + # Modern CI/CD with native container support + # Web UI: http://localhost:8085 + # ============================================ + woodpecker-server: + image: woodpeckerci/woodpecker-server:v3 + container_name: breakpilot-pwa-woodpecker-server + ports: + - "8090:8000" + extra_hosts: + - "macmini:192.168.178.100" + environment: + - WOODPECKER_OPEN=true + - WOODPECKER_HOST=http://macmini:8090 + - WOODPECKER_ADMIN=pilotadmin,breakpilot_admin + # Gitea OAuth Integration + - WOODPECKER_GITEA=true + - WOODPECKER_GITEA_URL=http://macmini:3003 + - WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT:-} + - WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET:-} + # Agent Secret (fuer Agent-Authentifizierung) + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET:-woodpecker-agent-secret-key} + # Database (SQLite fuer einfache Einrichtung) + - WOODPECKER_DATABASE_DRIVER=sqlite3 + - WOODPECKER_DATABASE_DATASOURCE=/var/lib/woodpecker/woodpecker.sqlite + # Logging + - WOODPECKER_LOG_LEVEL=info + # Trust all repos (allows privileged containers) + - WOODPECKER_PLUGINS_PRIVILEGED=true + - WOODPECKER_PLUGINS_TRUSTED_CLONE=true + volumes: + - woodpecker_data:/var/lib/woodpecker + depends_on: + gitea: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Night Scheduler - Nachtabschaltung + # Stoppt Services nachts, startet sie morgens + # API: http://localhost:8096 + # ============================================ + night-scheduler: + build: ./night-scheduler + container_name: breakpilot-pwa-night-scheduler + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./night-scheduler/config:/config + - ./docker-compose.yml:/app/docker-compose.yml:ro + environment: + - COMPOSE_PROJECT_NAME=breakpilot-pwa + ports: + - "8096:8096" + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8096/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # ============================================ + # Woodpecker CI - Agent + # Executes pipeline steps in containers + # ============================================ + woodpecker-agent: + image: woodpeckerci/woodpecker-agent:v3 + container_name: breakpilot-pwa-woodpecker-agent + command: agent + environment: + - WOODPECKER_SERVER=woodpecker-server:9000 + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET:-woodpecker-agent-secret-key} + - WOODPECKER_MAX_WORKFLOWS=4 + - WOODPECKER_LOG_LEVEL=info + # Backend für Container-Ausführung + - WOODPECKER_BACKEND=docker + - DOCKER_HOST=unix:///var/run/docker.sock + # Extra hosts für Pipeline-Container (damit sie macmini erreichen) + - WOODPECKER_BACKEND_DOCKER_EXTRA_HOSTS=macmini:192.168.178.100,gitea:192.168.178.100 + # Nutze das gleiche Netzwerk für Pipeline-Container + - WOODPECKER_BACKEND_DOCKER_NETWORK=breakpilot-dev_breakpilot-pwa-network + # Docker-Socket für Build-Steps (Host-Docker statt DinD) + - WOODPECKER_BACKEND_DOCKER_VOLUMES=/var/run/docker.sock:/var/run/docker.sock + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - woodpecker-server + networks: + - breakpilot-pwa-network + restart: unless-stopped + +volumes: + # Woodpecker CI Data + woodpecker_data: + driver: local + # Vault Data + vault_data: + driver: local + # Vault Agent Config (role-id, secret-id, token) + vault_agent_config: + driver: local + # Vault-managed SSL Certificates + vault_certs: + driver: local + breakpilot_pwa_data: + driver: local + # Embedding Service Model Cache + embedding_models: + driver: local + # Valkey Session Cache + valkey_data: + driver: local + dsms_data: + driver: local + klausur_uploads: + driver: local + eh_uploads: + driver: local + ocr_labeling: + driver: local + # PaddleOCR Model Cache (persist across container restarts) + paddle_models: + driver: local + # PaddleOCR Service Model Cache (x86_64 emulation) + paddleocr_models: + driver: local + qdrant_data: + driver: local + minio_data: + driver: local + synapse_data: + driver: local + synapse_db_data: + driver: local + # Jitsi Volumes + jitsi_web_config: + driver: local + jitsi_web_crontabs: + driver: local + jitsi_transcripts: + driver: local + jitsi_prosody_config: + driver: local + jitsi_prosody_plugins: + driver: local + jitsi_jicofo_config: + driver: local + jitsi_jvb_config: + driver: local + # Jibri Recording Volumes + jibri_recordings: + driver: local + # Transcription Worker Volumes + transcription_models: + driver: local + transcription_temp: + driver: local + # ERPNext Volumes + erpnext_db_data: + driver: local + erpnext_redis_queue_data: + driver: local + erpnext_redis_cache_data: + driver: local + erpnext_sites: + driver: local + erpnext_logs: + driver: local + # GeoEdu Service Volumes + geo_osm_data: + driver: local + geo_dem_data: + driver: local + geo_tile_cache: + driver: local + geo_aoi_bundles: + driver: local + # Voice Service Volumes (transient sessions only) + voice_session_data: + driver: local + # Gitea Volumes + gitea_data: + driver: local + gitea_config: + driver: local + gitea_runner_data: + driver: local + +networks: + breakpilot-pwa-network: + driver: bridge diff --git a/admin-v2/mkdocs.yml b/admin-v2/mkdocs.yml new file mode 100644 index 0000000..48899c6 --- /dev/null +++ b/admin-v2/mkdocs.yml @@ -0,0 +1,101 @@ +site_name: Breakpilot Dokumentation +site_url: https://macmini:8008 +docs_dir: docs-src +site_dir: docs-site + +theme: + name: material + language: de + palette: + - scheme: default + primary: teal + toggle: + icon: material/brightness-7 + name: Dark Mode aktivieren + - scheme: slate + primary: teal + toggle: + icon: material/brightness-4 + name: Light Mode aktivieren + features: + - search.highlight + - search.suggest + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - content.code.copy + - content.tabs.link + - toc.follow + +plugins: + - search: + lang: de + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - tables + - attr_list + - md_in_html + - toc: + permalink: true + +extra: + social: + - icon: fontawesome/brands/github + link: http://macmini:3003/breakpilot/breakpilot-pwa + +nav: + - Start: index.md + - Erste Schritte: + - Umgebung einrichten: getting-started/environment-setup.md + - Mac Mini Setup: getting-started/mac-mini-setup.md + - Architektur: + - Systemuebersicht: architecture/system-architecture.md + - Auth-System: architecture/auth-system.md + - Mail-RBAC: architecture/mail-rbac-architecture.md + - Multi-Agent: architecture/multi-agent.md + - Secrets Management: architecture/secrets-management.md + - DevSecOps: architecture/devsecops.md + - Environments: architecture/environments.md + - Zeugnis-System: architecture/zeugnis-system.md + - Services: + - KI-Daten-Pipeline: + - Uebersicht: services/ki-daten-pipeline/index.md + - Architektur: services/ki-daten-pipeline/architecture.md + - Klausur-Service: + - Uebersicht: services/klausur-service/index.md + - BYOEH Systemerklaerung: services/klausur-service/byoeh-system-erklaerung.md + - BYOEH Architektur: services/klausur-service/BYOEH-Architecture.md + - BYOEH Developer Guide: services/klausur-service/BYOEH-Developer-Guide.md + - NiBiS Pipeline: services/klausur-service/NiBiS-Ingestion-Pipeline.md + - OCR Labeling: services/klausur-service/OCR-Labeling-Spec.md + - OCR Compare: services/klausur-service/OCR-Compare.md + - RAG Admin: services/klausur-service/RAG-Admin-Spec.md + - Worksheet Editor: services/klausur-service/Worksheet-Editor-Architecture.md + - Voice-Service: services/voice-service/index.md + - Agent-Core: services/agent-core/index.md + - AI-Compliance-SDK: + - Uebersicht: services/ai-compliance-sdk/index.md + - Architektur: services/ai-compliance-sdk/ARCHITECTURE.md + - Developer Guide: services/ai-compliance-sdk/DEVELOPER.md + - Auditor Dokumentation: services/ai-compliance-sdk/AUDITOR_DOCUMENTATION.md + - SBOM: services/ai-compliance-sdk/SBOM.md + - API: + - Backend API: api/backend-api.md + - Entwicklung: + - Testing: development/testing.md + - Dokumentation: development/documentation.md + - CI/CD Pipeline: development/ci-cd-pipeline.md diff --git a/admin-v2/package-lock.json b/admin-v2/package-lock.json index 168e8d6..ea216ec 100644 --- a/admin-v2/package-lock.json +++ b/admin-v2/package-lock.json @@ -1,3653 +1,17 @@ { - "name": "breakpilot-admin-v2", - "version": "1.0.0", + "name": "breakpilot-pwa", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "breakpilot-admin-v2", - "version": "1.0.0", - "dependencies": { - "jspdf": "^4.1.0", - "jszip": "^3.10.1", - "lucide-react": "^0.468.0", - "next": "^15.1.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "reactflow": "^11.11.4", - "uuid": "^13.0.0" - }, "devDependencies": { - "@playwright/test": "^1.50.0", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.10.2", - "@types/react": "^18.3.16", - "@types/react-dom": "^18.3.5", - "@types/uuid": "^10.0.0", - "@vitejs/plugin-react": "^5.1.3", - "autoprefixer": "^10.4.20", - "jsdom": "^28.0.0", - "postcss": "^8.4.49", - "tailwindcss": "^3.4.16", - "typescript": "^5.7.2", - "vitest": "^4.0.18" - } - }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", - "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.4" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.7.tgz", - "integrity": "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.5" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.26", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", - "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0" - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", - "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@next/env": { - "version": "15.5.9", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", - "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", - "license": "MIT" - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", - "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", - "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", - "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", - "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", - "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", - "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", - "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", - "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@playwright/test": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", - "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.58.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@reactflow/background": { - "version": "11.3.14", - "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", - "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/controls": { - "version": "11.2.14", - "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", - "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/core": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", - "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", - "license": "MIT", - "dependencies": { - "@types/d3": "^7.4.0", - "@types/d3-drag": "^3.0.1", - "@types/d3-selection": "^3.0.3", - "@types/d3-zoom": "^3.0.1", - "classcat": "^5.0.3", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/minimap": { - "version": "11.7.14", - "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", - "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "@types/d3-selection": "^3.0.3", - "@types/d3-zoom": "^3.0.1", - "classcat": "^5.0.3", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/node-resizer": { - "version": "2.2.14", - "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", - "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.4", - "d3-drag": "^3.0.0", - "d3-selection": "^3.0.0", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@reactflow/node-toolbar": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", - "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", - "license": "MIT", - "dependencies": { - "@reactflow/core": "11.11.4", - "classcat": "^5.0.3", - "zustand": "^4.4.1" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/d3": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", - "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/d3-axis": "*", - "@types/d3-brush": "*", - "@types/d3-chord": "*", - "@types/d3-color": "*", - "@types/d3-contour": "*", - "@types/d3-delaunay": "*", - "@types/d3-dispatch": "*", - "@types/d3-drag": "*", - "@types/d3-dsv": "*", - "@types/d3-ease": "*", - "@types/d3-fetch": "*", - "@types/d3-force": "*", - "@types/d3-format": "*", - "@types/d3-geo": "*", - "@types/d3-hierarchy": "*", - "@types/d3-interpolate": "*", - "@types/d3-path": "*", - "@types/d3-polygon": "*", - "@types/d3-quadtree": "*", - "@types/d3-random": "*", - "@types/d3-scale": "*", - "@types/d3-scale-chromatic": "*", - "@types/d3-selection": "*", - "@types/d3-shape": "*", - "@types/d3-time": "*", - "@types/d3-time-format": "*", - "@types/d3-timer": "*", - "@types/d3-transition": "*", - "@types/d3-zoom": "*" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-axis": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", - "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-brush": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", - "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-chord": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", - "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-contour": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", - "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "license": "MIT", - "dependencies": { - "@types/d3-array": "*", - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", - "license": "MIT" - }, - "node_modules/@types/d3-dispatch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", - "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", - "license": "MIT" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-dsv": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", - "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-fetch": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", - "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", - "license": "MIT", - "dependencies": { - "@types/d3-dsv": "*" - } - }, - "node_modules/@types/d3-force": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", - "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", - "license": "MIT" - }, - "node_modules/@types/d3-format": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", - "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", - "license": "MIT" - }, - "node_modules/@types/d3-geo": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", - "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/d3-hierarchy": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", - "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-polygon": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", - "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", - "license": "MIT" - }, - "node_modules/@types/d3-quadtree": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", - "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", - "license": "MIT" - }, - "node_modules/@types/d3-random": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", - "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", - "license": "MIT" - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, - "node_modules/@types/d3-shape": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", - "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-time-format": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", - "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/pako": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", - "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/raf": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", - "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", - "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.2", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.18", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.18", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.18", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", - "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/canvg": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", - "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "@types/raf": "^3.4.0", - "core-js": "^3.8.3", - "raf": "^3.4.1", - "regenerator-runtime": "^0.13.7", - "rgbcolor": "^1.0.1", - "stackblur-canvas": "^2.0.0", - "svg-pathdata": "^6.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/css-line-break": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", - "license": "MIT", - "optional": true, - "dependencies": { - "utrie": "^1.0.2" - } - }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssstyle": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", - "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.279", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz", - "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==", - "dev": true, - "license": "ISC" - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-png": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", - "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", - "license": "MIT", - "dependencies": { - "@types/pako": "^2.0.3", - "iobuffer": "^5.3.2", - "pako": "^2.1.0" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" + "playwright": "^1.57.0" } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3659,701 +23,14 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", - "license": "MIT", - "optional": true, - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/iobuffer": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", - "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", - "license": "MIT" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", - "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^5.3.7", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.20.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jspdf": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz", - "integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "fast-png": "^6.2.0", - "fflate": "^0.8.1" - }, - "optionalDependencies": { - "canvg": "^3.0.11", - "core-js": "^3.6.0", - "dompurify": "^3.3.1", - "html2canvas": "^1.0.0-rc.5" - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/jszip/node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.468.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", - "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/next": { - "version": "15.5.9", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", - "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", - "license": "MIT", - "dependencies": { - "@next/env": "15.5.9", - "@swc/helpers": "0.5.15", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.7", - "@next/swc-darwin-x64": "15.5.7", - "@next/swc-linux-arm64-gnu": "15.5.7", - "@next/swc-linux-arm64-musl": "15.5.7", - "@next/swc-linux-x64-gnu": "15.5.7", - "@next/swc-linux-x64-musl": "15.5.7", - "@next/swc-win32-arm64-msvc": "15.5.7", - "@next/swc-win32-x64-msvc": "15.5.7", - "sharp": "^0.34.3" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "license": "MIT", - "optional": true - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/playwright": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", - "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", - "devOptional": true, + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.1" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -4366,10 +43,10 @@ } }, "node_modules/playwright-core": { - "version": "1.58.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", - "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", - "devOptional": true, + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -4377,1349 +54,6 @@ "engines": { "node": ">=18" } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "license": "MIT", - "optional": true, - "dependencies": { - "performance-now": "^2.1.0" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/reactflow": { - "version": "11.11.4", - "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", - "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", - "license": "MIT", - "dependencies": { - "@reactflow/background": "11.3.14", - "@reactflow/controls": "11.2.14", - "@reactflow/core": "11.11.4", - "@reactflow/minimap": "11.7.14", - "@reactflow/node-resizer": "2.2.14", - "@reactflow/node-toolbar": "1.3.14" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rgbcolor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", - "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", - "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", - "optional": true, - "engines": { - "node": ">= 0.8.15" - } - }, - "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/stackblur-canvas": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", - "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.14" - } - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svg-pathdata": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", - "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/text-segmentation": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", - "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", - "license": "MIT", - "optional": true, - "dependencies": { - "utrie": "^1.0.2" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.22", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", - "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.22" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.22", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.22.tgz", - "integrity": "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", - "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utrie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", - "license": "MIT", - "optional": true, - "dependencies": { - "base64-arraybuffer": "^1.0.2" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^3.10.0", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", - "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } } } } diff --git a/admin-v2/run-ingestion.sh b/admin-v2/run-ingestion.sh new file mode 100755 index 0000000..c46ec5e --- /dev/null +++ b/admin-v2/run-ingestion.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# ============================================================ +# RAG DACH Ingestion — Nur Ingestion (Builds schon fertig) +# ============================================================ + +PROJ="/Users/benjaminadmin/Projekte/breakpilot-pwa" +DOCKER="/usr/local/bin/docker" +COMPOSE="$DOCKER compose -f $PROJ/docker-compose.yml" +LOG_FILE="$PROJ/ingest-$(date +%Y%m%d-%H%M%S).log" + +exec > >(tee -a "$LOG_FILE") 2>&1 + +echo "============================================================" +echo "RAG DACH Ingestion — Start: $(date)" +echo "Logfile: $LOG_FILE" +echo "============================================================" + +# Health Check (via docker exec, Port nicht auf Host exponiert) +echo "" +echo "[1/5] Pruefe klausur-service..." +if ! $COMPOSE exec -T klausur-service python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8086/health')" 2>/dev/null; then + echo "FEHLER: klausur-service nicht erreichbar!" + exit 1 +fi +echo "klausur-service ist bereit." + +# P1 — Deutschland +echo "" +echo "[2/5] Ingestion P1 — Deutschland (7 Gesetze)..." +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + DE_DDG DE_BGB_AGB DE_EGBGB DE_UWG DE_HGB_RET DE_AO_RET DE_TKG 2>&1 || echo "DE P1 hatte Fehler" + +# P1 — Oesterreich +echo "" +echo "[3/5] Ingestion P1 — Oesterreich (7 Gesetze)..." +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + AT_ECG AT_TKG AT_KSCHG AT_FAGG AT_UGB_RET AT_BAO_RET AT_MEDIENG 2>&1 || echo "AT P1 hatte Fehler" + +# P1 — Schweiz +echo "" +echo "[4/5] Ingestion P1 — Schweiz (4 Gesetze)..." +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + CH_DSV CH_OR_AGB CH_UWG CH_FMG 2>&1 || echo "CH P1 hatte Fehler" + +# 3 fehlgeschlagene Quellen + P2 + P3 +echo "" +echo "[5/5] Ingestion P2/P3 + Fixes (14 Gesetze)..." +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --ingest \ + LU_DPA_LAW DK_DATABESKYTTELSESLOVEN EDPB_GUIDELINES_1_2022 \ + DE_PANGV DE_DLINFOV DE_BETRVG \ + AT_ABGB_AGB AT_UWG \ + CH_GEBUV CH_ZERTES \ + DE_GESCHGEHG DE_BSIG DE_USTG_RET CH_ZGB_PERS 2>&1 || echo "P2/P3 hatte Fehler" + +# Status +echo "" +echo "============================================================" +echo "FINAL STATUS CHECK" +echo "============================================================" +$COMPOSE exec -T klausur-service python -m legal_corpus_ingestion --status 2>&1 + +echo "" +echo "============================================================" +echo "Fertig: $(date)" +echo "Logfile: $LOG_FILE" +echo "============================================================" diff --git a/agent-core/soul/investor-agent.soul.md b/agent-core/soul/investor-agent.soul.md index 8e20d5f..a43d391 100644 --- a/agent-core/soul/investor-agent.soul.md +++ b/agent-core/soul/investor-agent.soul.md @@ -113,6 +113,7 @@ Beispiele nach Slide-Kontext: - Nach Financials: "[Q] Wie realistisch ist die Umsatzprognose?" - Nach The Ask: "[Q] Was passiert nach der Pre-Seed-Runde?" + ## Einschraenkungen - Keine Rechtsberatung geben - Keine Garantien fuer Renditen oder Exits diff --git a/ai-compliance-sdk/Dockerfile b/ai-compliance-sdk/Dockerfile new file mode 100644 index 0000000..ff9c684 --- /dev/null +++ b/ai-compliance-sdk/Dockerfile @@ -0,0 +1,48 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Install git (required for go mod) +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum* ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /ai-compliance-sdk ./cmd/server + +# Runtime stage +FROM alpine:3.19 + +WORKDIR /app + +# Install CA certificates for HTTPS +RUN apk --no-cache add ca-certificates tzdata + +# Copy binary from builder +COPY --from=builder /ai-compliance-sdk . + +# Copy migrations +COPY migrations/ ./migrations/ + +# Copy policy files (YAML rules) +COPY policies/ ./policies/ + +# Create non-root user +RUN adduser -D -u 1000 appuser +USER appuser + +# Expose port +EXPOSE 8090 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8090/health || exit 1 + +# Run the application +CMD ["./ai-compliance-sdk"] diff --git a/ai-compliance-sdk/cmd/server/main.go b/ai-compliance-sdk/cmd/server/main.go new file mode 100644 index 0000000..960791c --- /dev/null +++ b/ai-compliance-sdk/cmd/server/main.go @@ -0,0 +1,610 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/api/handlers" + "github.com/breakpilot/ai-compliance-sdk/internal/audit" + "github.com/breakpilot/ai-compliance-sdk/internal/config" + "github.com/breakpilot/ai-compliance-sdk/internal/dsgvo" + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/breakpilot/ai-compliance-sdk/internal/academy" + "github.com/breakpilot/ai-compliance-sdk/internal/incidents" + "github.com/breakpilot/ai-compliance-sdk/internal/roadmap" + "github.com/breakpilot/ai-compliance-sdk/internal/ucca" + "github.com/breakpilot/ai-compliance-sdk/internal/whistleblower" + "github.com/breakpilot/ai-compliance-sdk/internal/vendor" + "github.com/breakpilot/ai-compliance-sdk/internal/workshop" + "github.com/breakpilot/ai-compliance-sdk/internal/portfolio" + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Set Gin mode + if cfg.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + + // Connect to database + ctx := context.Background() + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer pool.Close() + + // Verify connection + if err := pool.Ping(ctx); err != nil { + log.Fatalf("Failed to ping database: %v", err) + } + log.Println("Connected to database") + + // Initialize stores + rbacStore := rbac.NewStore(pool) + auditStore := audit.NewStore(pool) + dsgvoStore := dsgvo.NewStore(pool) + uccaStore := ucca.NewStore(pool) + escalationStore := ucca.NewEscalationStore(pool) + roadmapStore := roadmap.NewStore(pool) + workshopStore := workshop.NewStore(pool) + portfolioStore := portfolio.NewStore(pool) + academyStore := academy.NewStore(pool) + whistleblowerStore := whistleblower.NewStore(pool) + incidentStore := incidents.NewStore(pool) + vendorStore := vendor.NewStore(pool) + + // Initialize services + rbacService := rbac.NewService(rbacStore) + policyEngine := rbac.NewPolicyEngine(rbacService, rbacStore) + + // Initialize LLM providers + providerRegistry := llm.NewProviderRegistry(cfg.LLMProvider, cfg.LLMFallbackProvider) + + // Register Ollama adapter + ollamaAdapter := llm.NewOllamaAdapter(cfg.OllamaURL, cfg.OllamaDefaultModel) + providerRegistry.Register(ollamaAdapter) + + // Register Anthropic adapter if API key is configured + if cfg.AnthropicAPIKey != "" { + anthropicAdapter := llm.NewAnthropicAdapter(cfg.AnthropicAPIKey, cfg.AnthropicDefaultModel) + providerRegistry.Register(anthropicAdapter) + } + + // Initialize PII detector + piiDetector := llm.NewPIIDetectorWithPatterns(llm.AllPIIPatterns()) + + // Initialize access gate + accessGate := llm.NewAccessGate(policyEngine, piiDetector, providerRegistry) + + // Initialize audit components + trailBuilder := audit.NewTrailBuilder(auditStore) + exporter := audit.NewExporter(auditStore) + + // Initialize handlers + rbacHandlers := handlers.NewRBACHandlers(rbacStore, rbacService, policyEngine) + llmHandlers := handlers.NewLLMHandlers(accessGate, providerRegistry, piiDetector, auditStore, trailBuilder) + auditHandlers := handlers.NewAuditHandlers(auditStore, exporter) + dsgvoHandlers := handlers.NewDSGVOHandlers(dsgvoStore) + uccaHandlers := handlers.NewUCCAHandlers(uccaStore, escalationStore, providerRegistry) + escalationHandlers := handlers.NewEscalationHandlers(escalationStore, uccaStore) + roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore) + workshopHandlers := handlers.NewWorkshopHandlers(workshopStore) + portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore) + draftingHandlers := handlers.NewDraftingHandlers(accessGate, providerRegistry, piiDetector, auditStore, trailBuilder) + academyHandlers := handlers.NewAcademyHandlers(academyStore) + whistleblowerHandlers := handlers.NewWhistleblowerHandlers(whistleblowerStore) + incidentHandlers := handlers.NewIncidentHandlers(incidentStore) + vendorHandlers := handlers.NewVendorHandlers(vendorStore) + + // Initialize middleware + rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine) + + // Create Gin router + router := gin.Default() + + // CORS configuration + router.Use(cors.New(cors.Config{ + AllowOrigins: cfg.AllowedOrigins, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-User-ID", "X-Tenant-ID", "X-Namespace-ID", "X-Tenant-Slug"}, + ExposeHeaders: []string{"Content-Length", "Content-Disposition"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + }) + + // API v1 routes + v1 := router.Group("/sdk/v1") + { + // Public routes (no auth required) + v1.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // Apply user context extraction middleware + v1.Use(rbacMiddleware.ExtractUserContext()) + + // Tenant routes + tenants := v1.Group("/tenants") + { + tenants.GET("", rbacHandlers.ListTenants) + tenants.GET("/:id", rbacHandlers.GetTenant) + tenants.POST("", rbacHandlers.CreateTenant) + tenants.PUT("/:id", rbacHandlers.UpdateTenant) + + // Tenant namespaces (use :id to avoid route conflict) + tenants.GET("/:id/namespaces", rbacHandlers.ListNamespaces) + tenants.POST("/:id/namespaces", rbacHandlers.CreateNamespace) + } + + // Namespace routes + namespaces := v1.Group("/namespaces") + { + namespaces.GET("/:id", rbacHandlers.GetNamespace) + } + + // Role routes + roles := v1.Group("/roles") + { + roles.GET("", rbacHandlers.ListRoles) + roles.GET("/system", rbacHandlers.ListSystemRoles) + roles.GET("/:id", rbacHandlers.GetRole) + roles.POST("", rbacHandlers.CreateRole) + } + + // User role routes + userRoles := v1.Group("/user-roles") + { + userRoles.POST("", rbacHandlers.AssignRole) + userRoles.DELETE("/:userId/:roleId", rbacHandlers.RevokeRole) + userRoles.GET("/:userId", rbacHandlers.GetUserRoles) + } + + // Permission routes + permissions := v1.Group("/permissions") + { + permissions.GET("/effective", rbacHandlers.GetEffectivePermissions) + permissions.GET("/context", rbacHandlers.GetUserContext) + permissions.GET("/check", rbacHandlers.CheckPermission) + } + + // LLM Policy routes + policies := v1.Group("/llm/policies") + { + policies.GET("", rbacHandlers.ListLLMPolicies) + policies.GET("/:id", rbacHandlers.GetLLMPolicy) + policies.POST("", rbacHandlers.CreateLLMPolicy) + policies.PUT("/:id", rbacHandlers.UpdateLLMPolicy) + policies.DELETE("/:id", rbacHandlers.DeleteLLMPolicy) + } + + // LLM routes (require LLM permission) + llmRoutes := v1.Group("/llm") + llmRoutes.Use(rbacMiddleware.RequireLLMAccess()) + { + llmRoutes.POST("/chat", llmHandlers.Chat) + llmRoutes.POST("/complete", llmHandlers.Complete) + llmRoutes.GET("/models", llmHandlers.ListModels) + llmRoutes.GET("/providers/status", llmHandlers.GetProviderStatus) + llmRoutes.POST("/analyze", llmHandlers.AnalyzeText) + llmRoutes.POST("/redact", llmHandlers.RedactText) + } + + // Audit routes (require audit permission) + auditRoutes := v1.Group("/audit") + auditRoutes.Use(rbacMiddleware.RequireAnyPermission(rbac.PermissionAuditAll, rbac.PermissionAuditRead, rbac.PermissionAuditLogRead)) + { + auditRoutes.GET("/llm", auditHandlers.QueryLLMAudit) + auditRoutes.GET("/general", auditHandlers.QueryGeneralAudit) + auditRoutes.GET("/llm-operations", auditHandlers.QueryLLMAudit) // Alias + auditRoutes.GET("/trail", auditHandlers.QueryGeneralAudit) // Alias + auditRoutes.GET("/usage", auditHandlers.GetUsageStats) + auditRoutes.GET("/compliance-report", auditHandlers.GetComplianceReport) + + // Export routes + auditRoutes.GET("/export/llm", auditHandlers.ExportLLMAudit) + auditRoutes.GET("/export/general", auditHandlers.ExportGeneralAudit) + auditRoutes.GET("/export/compliance", auditHandlers.ExportComplianceReport) + } + + // DSGVO routes (Art. 30, 32, 35, 15-22 DSGVO) + dsgvoRoutes := v1.Group("/dsgvo") + { + // Statistics + dsgvoRoutes.GET("/stats", dsgvoHandlers.GetStats) + + // VVT - Verarbeitungsverzeichnis (Art. 30) + vvt := dsgvoRoutes.Group("/processing-activities") + { + vvt.GET("", dsgvoHandlers.ListProcessingActivities) + vvt.GET("/:id", dsgvoHandlers.GetProcessingActivity) + vvt.POST("", dsgvoHandlers.CreateProcessingActivity) + vvt.PUT("/:id", dsgvoHandlers.UpdateProcessingActivity) + vvt.DELETE("/:id", dsgvoHandlers.DeleteProcessingActivity) + } + + // TOM - Technische und Organisatorische Maßnahmen (Art. 32) + tom := dsgvoRoutes.Group("/tom") + { + tom.GET("", dsgvoHandlers.ListTOMs) + tom.GET("/:id", dsgvoHandlers.GetTOM) + tom.POST("", dsgvoHandlers.CreateTOM) + } + + // DSR - Data Subject Requests / Betroffenenrechte (Art. 15-22) + dsr := dsgvoRoutes.Group("/dsr") + { + dsr.GET("", dsgvoHandlers.ListDSRs) + dsr.GET("/:id", dsgvoHandlers.GetDSR) + dsr.POST("", dsgvoHandlers.CreateDSR) + dsr.PUT("/:id", dsgvoHandlers.UpdateDSR) + } + + // Retention Policies - Löschfristen (Art. 17) + retention := dsgvoRoutes.Group("/retention-policies") + { + retention.GET("", dsgvoHandlers.ListRetentionPolicies) + retention.POST("", dsgvoHandlers.CreateRetentionPolicy) + } + + // DSFA - Datenschutz-Folgenabschätzung (Art. 35) + dsfa := dsgvoRoutes.Group("/dsfa") + { + dsfa.GET("", dsgvoHandlers.ListDSFAs) + dsfa.GET("/:id", dsgvoHandlers.GetDSFA) + dsfa.POST("", dsgvoHandlers.CreateDSFA) + dsfa.PUT("/:id", dsgvoHandlers.UpdateDSFA) + dsfa.DELETE("/:id", dsgvoHandlers.DeleteDSFA) + dsfa.GET("/:id/export", dsgvoHandlers.ExportDSFA) + } + + // Export routes + exports := dsgvoRoutes.Group("/export") + { + exports.GET("/vvt", dsgvoHandlers.ExportVVT) + exports.GET("/tom", dsgvoHandlers.ExportTOM) + exports.GET("/dsr", dsgvoHandlers.ExportDSR) + exports.GET("/retention", dsgvoHandlers.ExportRetentionPolicies) + } + } + + // UCCA routes - Use-Case Compliance & Feasibility Advisor + uccaRoutes := v1.Group("/ucca") + { + // Main assessment endpoint + uccaRoutes.POST("/assess", uccaHandlers.Assess) + + // Assessment management + uccaRoutes.GET("/assessments", uccaHandlers.ListAssessments) + uccaRoutes.GET("/assessments/:id", uccaHandlers.GetAssessment) + uccaRoutes.DELETE("/assessments/:id", uccaHandlers.DeleteAssessment) + + // LLM explanation + uccaRoutes.POST("/assessments/:id/explain", uccaHandlers.Explain) + + // Catalogs (patterns, examples, rules, controls, problem-solutions) + uccaRoutes.GET("/patterns", uccaHandlers.ListPatterns) + uccaRoutes.GET("/examples", uccaHandlers.ListExamples) + uccaRoutes.GET("/rules", uccaHandlers.ListRules) + uccaRoutes.GET("/controls", uccaHandlers.ListControls) + uccaRoutes.GET("/problem-solutions", uccaHandlers.ListProblemSolutions) + + // Export + uccaRoutes.GET("/export/:id", uccaHandlers.Export) + + // Statistics + uccaRoutes.GET("/stats", uccaHandlers.GetStats) + + // Wizard routes - Legal Assistant integrated + uccaRoutes.GET("/wizard/schema", uccaHandlers.GetWizardSchema) + uccaRoutes.POST("/wizard/ask", uccaHandlers.AskWizardQuestion) + + // Escalation management (E0-E3 workflow) + uccaRoutes.GET("/escalations", escalationHandlers.ListEscalations) + uccaRoutes.GET("/escalations/stats", escalationHandlers.GetEscalationStats) + uccaRoutes.GET("/escalations/:id", escalationHandlers.GetEscalation) + uccaRoutes.POST("/escalations", escalationHandlers.CreateEscalation) + uccaRoutes.POST("/escalations/:id/assign", escalationHandlers.AssignEscalation) + uccaRoutes.POST("/escalations/:id/review", escalationHandlers.StartReview) + uccaRoutes.POST("/escalations/:id/decide", escalationHandlers.DecideEscalation) + + // DSB Pool management + uccaRoutes.GET("/dsb-pool", escalationHandlers.ListDSBPool) + uccaRoutes.POST("/dsb-pool", escalationHandlers.AddDSBPoolMember) + } + + // Roadmap routes - Compliance Implementation Roadmaps + roadmapRoutes := v1.Group("/roadmaps") + { + // Roadmap CRUD + roadmapRoutes.POST("", roadmapHandlers.CreateRoadmap) + roadmapRoutes.GET("", roadmapHandlers.ListRoadmaps) + roadmapRoutes.GET("/:id", roadmapHandlers.GetRoadmap) + roadmapRoutes.PUT("/:id", roadmapHandlers.UpdateRoadmap) + roadmapRoutes.DELETE("/:id", roadmapHandlers.DeleteRoadmap) + roadmapRoutes.GET("/:id/stats", roadmapHandlers.GetRoadmapStats) + + // Roadmap items + roadmapRoutes.POST("/:id/items", roadmapHandlers.CreateItem) + roadmapRoutes.GET("/:id/items", roadmapHandlers.ListItems) + + // Import workflow + roadmapRoutes.POST("/import/upload", roadmapHandlers.UploadImport) + roadmapRoutes.GET("/import/:jobId", roadmapHandlers.GetImportJob) + roadmapRoutes.POST("/import/:jobId/confirm", roadmapHandlers.ConfirmImport) + } + + // Roadmap item routes (separate group for item-level operations) + roadmapItemRoutes := v1.Group("/roadmap-items") + { + roadmapItemRoutes.GET("/:id", roadmapHandlers.GetItem) + roadmapItemRoutes.PUT("/:id", roadmapHandlers.UpdateItem) + roadmapItemRoutes.PATCH("/:id/status", roadmapHandlers.UpdateItemStatus) + roadmapItemRoutes.DELETE("/:id", roadmapHandlers.DeleteItem) + } + + // Workshop routes - Collaborative Compliance Workshops + workshopRoutes := v1.Group("/workshops") + { + // Session CRUD + workshopRoutes.POST("", workshopHandlers.CreateSession) + workshopRoutes.GET("", workshopHandlers.ListSessions) + workshopRoutes.GET("/:id", workshopHandlers.GetSession) + workshopRoutes.PUT("/:id", workshopHandlers.UpdateSession) + workshopRoutes.DELETE("/:id", workshopHandlers.DeleteSession) + + // Session lifecycle + workshopRoutes.POST("/:id/start", workshopHandlers.StartSession) + workshopRoutes.POST("/:id/pause", workshopHandlers.PauseSession) + workshopRoutes.POST("/:id/complete", workshopHandlers.CompleteSession) + + // Participants + workshopRoutes.GET("/:id/participants", workshopHandlers.ListParticipants) + workshopRoutes.PUT("/:id/participants/:participantId", workshopHandlers.UpdateParticipant) + workshopRoutes.DELETE("/:id/participants/:participantId", workshopHandlers.RemoveParticipant) + + // Responses + workshopRoutes.POST("/:id/responses", workshopHandlers.SubmitResponse) + workshopRoutes.GET("/:id/responses", workshopHandlers.GetResponses) + + // Comments + workshopRoutes.POST("/:id/comments", workshopHandlers.AddComment) + workshopRoutes.GET("/:id/comments", workshopHandlers.GetComments) + + // Wizard navigation + workshopRoutes.POST("/:id/advance", workshopHandlers.AdvanceStep) + workshopRoutes.POST("/:id/goto", workshopHandlers.GoToStep) + + // Statistics & Summary + workshopRoutes.GET("/:id/stats", workshopHandlers.GetSessionStats) + workshopRoutes.GET("/:id/summary", workshopHandlers.GetSessionSummary) + + // Export + workshopRoutes.GET("/:id/export", workshopHandlers.ExportSession) + + // Join by code (public endpoint for joining) + workshopRoutes.POST("/join/:code", workshopHandlers.JoinSession) + } + + // Portfolio routes - AI Use Case Portfolio Management + portfolioRoutes := v1.Group("/portfolios") + { + // Portfolio CRUD + portfolioRoutes.POST("", portfolioHandlers.CreatePortfolio) + portfolioRoutes.GET("", portfolioHandlers.ListPortfolios) + portfolioRoutes.GET("/:id", portfolioHandlers.GetPortfolio) + portfolioRoutes.PUT("/:id", portfolioHandlers.UpdatePortfolio) + portfolioRoutes.DELETE("/:id", portfolioHandlers.DeletePortfolio) + + // Portfolio items + portfolioRoutes.POST("/:id/items", portfolioHandlers.AddItem) + portfolioRoutes.GET("/:id/items", portfolioHandlers.ListItems) + portfolioRoutes.POST("/:id/items/bulk", portfolioHandlers.BulkAddItems) + portfolioRoutes.DELETE("/:id/items/:itemId", portfolioHandlers.RemoveItem) + portfolioRoutes.PUT("/:id/items/order", portfolioHandlers.ReorderItems) + + // Statistics & Activity + portfolioRoutes.GET("/:id/stats", portfolioHandlers.GetPortfolioStats) + portfolioRoutes.GET("/:id/activity", portfolioHandlers.GetPortfolioActivity) + portfolioRoutes.POST("/:id/recalculate", portfolioHandlers.RecalculateMetrics) + + // Approval workflow + portfolioRoutes.POST("/:id/submit-review", portfolioHandlers.SubmitForReview) + portfolioRoutes.POST("/:id/approve", portfolioHandlers.ApprovePortfolio) + + // Merge & Compare + portfolioRoutes.POST("/merge", portfolioHandlers.MergePortfolios) + portfolioRoutes.POST("/compare", portfolioHandlers.ComparePortfolios) + } + + // Drafting Engine routes - Compliance Document Drafting & Validation + draftingRoutes := v1.Group("/drafting") + draftingRoutes.Use(rbacMiddleware.RequireLLMAccess()) + { + draftingRoutes.POST("/draft", draftingHandlers.DraftDocument) + draftingRoutes.POST("/validate", draftingHandlers.ValidateDocument) + draftingRoutes.GET("/history", draftingHandlers.GetDraftHistory) + } + + // Academy routes - E-Learning / Compliance Training + academyRoutes := v1.Group("/academy") + { + // Courses + academyRoutes.POST("/courses", academyHandlers.CreateCourse) + academyRoutes.GET("/courses", academyHandlers.ListCourses) + academyRoutes.GET("/courses/:id", academyHandlers.GetCourse) + academyRoutes.PUT("/courses/:id", academyHandlers.UpdateCourse) + academyRoutes.DELETE("/courses/:id", academyHandlers.DeleteCourse) + + // Enrollments + academyRoutes.POST("/enrollments", academyHandlers.CreateEnrollment) + academyRoutes.GET("/enrollments", academyHandlers.ListEnrollments) + academyRoutes.PUT("/enrollments/:id/progress", academyHandlers.UpdateProgress) + academyRoutes.POST("/enrollments/:id/complete", academyHandlers.CompleteEnrollment) + + // Certificates + academyRoutes.GET("/certificates/:id", academyHandlers.GetCertificate) + academyRoutes.POST("/enrollments/:id/certificate", academyHandlers.GenerateCertificate) + + // Quiz + academyRoutes.POST("/courses/:id/quiz", academyHandlers.SubmitQuiz) + + // Statistics + academyRoutes.GET("/stats", academyHandlers.GetStatistics) + } + + // Whistleblower routes - Hinweisgebersystem (HinSchG) + whistleblowerRoutes := v1.Group("/whistleblower") + { + // Public endpoints (anonymous reporting) + whistleblowerRoutes.POST("/reports/submit", whistleblowerHandlers.SubmitReport) + whistleblowerRoutes.GET("/reports/access/:accessKey", whistleblowerHandlers.GetReportByAccessKey) + whistleblowerRoutes.POST("/reports/access/:accessKey/messages", whistleblowerHandlers.SendPublicMessage) + + // Admin endpoints + whistleblowerRoutes.GET("/reports", whistleblowerHandlers.ListReports) + whistleblowerRoutes.GET("/reports/:id", whistleblowerHandlers.GetReport) + whistleblowerRoutes.PUT("/reports/:id", whistleblowerHandlers.UpdateReport) + whistleblowerRoutes.DELETE("/reports/:id", whistleblowerHandlers.DeleteReport) + whistleblowerRoutes.POST("/reports/:id/acknowledge", whistleblowerHandlers.AcknowledgeReport) + whistleblowerRoutes.POST("/reports/:id/investigate", whistleblowerHandlers.StartInvestigation) + whistleblowerRoutes.POST("/reports/:id/measures", whistleblowerHandlers.AddMeasure) + whistleblowerRoutes.POST("/reports/:id/close", whistleblowerHandlers.CloseReport) + whistleblowerRoutes.POST("/reports/:id/messages", whistleblowerHandlers.SendAdminMessage) + whistleblowerRoutes.GET("/reports/:id/messages", whistleblowerHandlers.ListMessages) + + // Statistics + whistleblowerRoutes.GET("/stats", whistleblowerHandlers.GetStatistics) + } + + // Incidents routes - Datenpannen-Management (DSGVO Art. 33/34) + incidentRoutes := v1.Group("/incidents") + { + // Incident CRUD + incidentRoutes.POST("", incidentHandlers.CreateIncident) + incidentRoutes.GET("", incidentHandlers.ListIncidents) + incidentRoutes.GET("/:id", incidentHandlers.GetIncident) + incidentRoutes.PUT("/:id", incidentHandlers.UpdateIncident) + incidentRoutes.DELETE("/:id", incidentHandlers.DeleteIncident) + + // Risk Assessment + incidentRoutes.POST("/:id/assess-risk", incidentHandlers.AssessRisk) + + // Authority Notification (Art. 33) + incidentRoutes.POST("/:id/notify-authority", incidentHandlers.SubmitAuthorityNotification) + + // Data Subject Notification (Art. 34) + incidentRoutes.POST("/:id/notify-subjects", incidentHandlers.NotifyDataSubjects) + + // Measures + incidentRoutes.POST("/:id/measures", incidentHandlers.AddMeasure) + incidentRoutes.PUT("/:id/measures/:measureId", incidentHandlers.UpdateMeasure) + incidentRoutes.POST("/:id/measures/:measureId/complete", incidentHandlers.CompleteMeasure) + + // Timeline + incidentRoutes.POST("/:id/timeline", incidentHandlers.AddTimelineEntry) + + // Lifecycle + incidentRoutes.POST("/:id/close", incidentHandlers.CloseIncident) + + // Statistics + incidentRoutes.GET("/stats", incidentHandlers.GetStatistics) + } + + // Vendor Compliance routes - Vendor Management & AVV/DPA (DSGVO Art. 28) + vendorRoutes := v1.Group("/vendors") + { + // Vendor CRUD + vendorRoutes.POST("", vendorHandlers.CreateVendor) + vendorRoutes.GET("", vendorHandlers.ListVendors) + vendorRoutes.GET("/:id", vendorHandlers.GetVendor) + vendorRoutes.PUT("/:id", vendorHandlers.UpdateVendor) + vendorRoutes.DELETE("/:id", vendorHandlers.DeleteVendor) + + // Contracts (AVV/DPA) + vendorRoutes.POST("/contracts", vendorHandlers.CreateContract) + vendorRoutes.GET("/contracts", vendorHandlers.ListContracts) + vendorRoutes.GET("/contracts/:id", vendorHandlers.GetContract) + vendorRoutes.PUT("/contracts/:id", vendorHandlers.UpdateContract) + vendorRoutes.DELETE("/contracts/:id", vendorHandlers.DeleteContract) + + // Findings + vendorRoutes.POST("/findings", vendorHandlers.CreateFinding) + vendorRoutes.GET("/findings", vendorHandlers.ListFindings) + vendorRoutes.GET("/findings/:id", vendorHandlers.GetFinding) + vendorRoutes.PUT("/findings/:id", vendorHandlers.UpdateFinding) + vendorRoutes.POST("/findings/:id/resolve", vendorHandlers.ResolveFinding) + + // Control Instances + vendorRoutes.POST("/controls", vendorHandlers.UpsertControlInstance) + vendorRoutes.GET("/controls", vendorHandlers.ListControlInstances) + + // Templates + vendorRoutes.GET("/templates", vendorHandlers.ListTemplates) + vendorRoutes.GET("/templates/:templateId", vendorHandlers.GetTemplate) + vendorRoutes.POST("/templates", vendorHandlers.CreateTemplate) + vendorRoutes.POST("/templates/:templateId/apply", vendorHandlers.ApplyTemplate) + + // Statistics + vendorRoutes.GET("/stats", vendorHandlers.GetStatistics) + } + } + + // Create HTTP server + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 5 * time.Minute, // LLM requests can take longer + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + log.Printf("AI Compliance SDK starting on port %s", cfg.Port) + log.Printf("Environment: %s", cfg.Environment) + log.Printf("Primary LLM Provider: %s", cfg.LLMProvider) + log.Printf("Fallback LLM Provider: %s", cfg.LLMFallbackProvider) + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed: %v", err) + } + }() + + // Graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +} diff --git a/ai-compliance-sdk/docs/ARCHITECTURE.md b/ai-compliance-sdk/docs/ARCHITECTURE.md new file mode 100644 index 0000000..876c419 --- /dev/null +++ b/ai-compliance-sdk/docs/ARCHITECTURE.md @@ -0,0 +1,1098 @@ +# UCCA - Use-Case Compliance & Feasibility Advisor + +## Systemarchitektur + +### 1. Übersicht + +Das UCCA-System ist ein **deterministisches Compliance-Bewertungssystem** für KI-Anwendungsfälle. Es kombiniert regelbasierte Evaluation mit optionaler LLM-Erklärung und semantischer Rechtstextsuche. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ UCCA System │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Frontend │───>│ SDK API │───>│ PostgreSQL │ │ +│ │ (Next.js) │ │ (Go) │ │ Database │ │ +│ └──────────────┘ └──────┬───────┘ └──────────────┘ │ +│ │ │ +│ ┌────────────────────┼────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Policy │ │ Escalation │ │ Legal RAG │ │ +│ │ Engine │ │ Workflow │ │ (Qdrant) │ │ +│ │ (45 Regeln) │ │ (E0-E3) │ │ 2,274 Chunks │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ │ │ +│ └────────────────────┴────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ LLM Provider │ │ +│ │ (Ollama/API) │ │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2. Kernprinzip + +> **"LLM ist NICHT die Quelle der Wahrheit. Wahrheit = Regeln + Evidenz. LLM = Übersetzer + Subsumptionshelfer"** + +Das System folgt einem strikten **Human-in-the-Loop** Ansatz: + +1. **Deterministische Regeln** treffen alle Compliance-Entscheidungen +2. **LLM** erklärt nur Ergebnisse, überschreibt nie BLOCK-Entscheidungen +3. **Menschen** (DSB, Legal) treffen finale Entscheidungen bei kritischen Fällen + +--- + +## 3. Komponenten + +### 3.1 Policy Engine (`internal/ucca/rules.go`) + +Die Policy Engine evaluiert Use-Cases gegen ~45 deterministische Regeln. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Policy Engine │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ UseCaseIntake ──────────────────────────────────────────────> │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Regelkategorien (A-J) │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ A. Datenklassifikation │ R-001 bis R-006 │ 6 Regeln │ │ +│ │ B. Zweck & Kontext │ R-010 bis R-013 │ 4 Regeln │ │ +│ │ C. Automatisierung │ R-020 bis R-025 │ 6 Regeln │ │ +│ │ D. Training vs Nutzung │ R-030 bis R-035 │ 6 Regeln │ │ +│ │ E. Speicherung │ R-040 bis R-042 │ 3 Regeln │ │ +│ │ F. Hosting │ R-050 bis R-052 │ 3 Regeln │ │ +│ │ G. Transparenz │ R-060 bis R-062 │ 3 Regeln │ │ +│ │ H. Domain-spezifisch │ R-070 bis R-074 │ 5 Regeln │ │ +│ │ I. Aggregation │ R-090 bis R-092 │ 3 Regeln │ │ +│ │ J. Erklärung │ R-100 │ 1 Regel │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ AssessmentResult │ +│ ├── feasibility: YES | CONDITIONAL | NO │ +│ ├── risk_score: 0-100 │ +│ ├── risk_level: MINIMAL | LOW | MEDIUM | HIGH | CRITICAL │ +│ ├── triggered_rules: []TriggeredRule │ +│ ├── required_controls: []RequiredControl │ +│ ├── recommended_architecture: []PatternRecommendation │ +│ └── forbidden_patterns: []ForbiddenPattern │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Regel-Severities:** +- `INFO`: Informativ, kein Risiko-Impact +- `WARN`: Warnung, erhöht Risk Score +- `BLOCK`: Kritisch, führt zu `feasibility=NO` + +### 3.2 Escalation Workflow (`internal/ucca/escalation_*.go`) + +Das Eskalationssystem routet kritische Assessments zur menschlichen Prüfung. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Escalation Workflow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ AssessmentResult ─────────────────────────────────────────────> │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Escalation Level Determination │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ E0: Nur INFO-Regeln, Risk < 20 │ │ +│ │ → Auto-Approve, keine menschliche Prüfung │ │ +│ │ │ │ +│ │ E1: WARN-Regeln, Risk 20-39 │ │ +│ │ → Team-Lead Review (SLA: 24h) │ │ +│ │ │ │ +│ │ E2: Art.9 Daten ODER Risk 40-59 ODER DSFA empfohlen │ │ +│ │ → DSB Consultation (SLA: 8h) │ │ +│ │ │ │ +│ │ E3: BLOCK-Regel ODER Risk ≥60 ODER Art.22 Risiko │ │ +│ │ → DSB + Legal Review (SLA: 4h) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ DSB Pool Assignment │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ Role │ Level │ Max Concurrent │ Auto-Assign │ │ +│ │ ──────────────┼───────┼────────────────┼────────────────── │ │ +│ │ team_lead │ E1 │ 10 │ Round-Robin │ │ +│ │ dsb │ E2,E3 │ 5 │ Workload-Based │ │ +│ │ legal │ E3 │ 3 │ Workload-Based │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Escalation Status Flow: │ +│ │ +│ pending → assigned → in_review → approved/rejected/returned │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 Legal RAG (`internal/llm/legal_rag.go`) + +Semantische Suche in 19 EU-Regulierungen für kontextbasierte Erklärungen. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Legal RAG System │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Explain Request ──────────────────────────────────────────────> │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Qdrant Vector DB │ │ +│ │ Collection: bp_legal_corpus │ │ +│ │ 2,274 Chunks, 1024-dim BGE-M3 │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ EU-Verordnungen: │ │ +│ │ ├── DSGVO (128) ├── AI Act (96) ├── NIS2 (128) │ │ +│ │ ├── CRA (256) ├── Data Act (256) ├── DSA (256) │ │ +│ │ ├── DGA (32) ├── EUCSA (32) ├── DPF (714) │ │ +│ │ └── ... │ │ +│ │ │ │ +│ │ Deutsche Gesetze: │ │ +│ │ ├── TDDDG (1) ├── SCC (32) ├── ... │ │ +│ │ │ │ +│ │ BSI-Standards: │ │ +│ │ ├── TR-03161-1 (6) ├── TR-03161-2 (6) ├── TR-03161-3 │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ Hybrid Search (Dense + Sparse) │ +│ │ Re-Ranking (Cross-Encoder) │ +│ ▼ │ +│ Top-K Relevant Passages ─────────────────────────────────────> │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ LLM Explanation │ │ +│ │ Provider: Ollama (local) / Anthropic (fallback) │ │ +│ │ Prompt: Assessment + Legal Context → Erklärung │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Datenfluss + +### 4.1 Assessment-Erstellung + +``` +User Input (Frontend) + │ + ▼ +POST /sdk/v1/ucca/assess + │ + ├──────────────────────────────────────────┐ + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ Policy │ │ Escalation │ +│ Engine │ │ Trigger │ +│ Evaluation │ │ Check │ +└──────┬───────┘ └──────┬───────┘ + │ │ + │ AssessmentResult │ EscalationLevel + │ │ + ▼ ▼ +┌──────────────────────────────────────────────────────┐ +│ PostgreSQL │ +│ ├── ucca_assessments (Assessment + Result) │ +│ └── ucca_escalations (wenn Level > E0) │ +└──────────────────────────────────────────────────────┘ + │ + │ If Level > E0 + ▼ +┌──────────────┐ +│ DSB Pool │ +│ Auto-Assign │ +└──────────────┘ + │ + ▼ +Notification (E-Mail/Webhook) +``` + +### 4.2 Erklärung mit Legal RAG + +``` +POST /sdk/v1/ucca/assessments/:id/explain + │ + ▼ +┌──────────────┐ +│ Load │ +│ Assessment │ +└──────┬───────┘ + │ + ▼ +┌──────────────┐ Query Vector ┌──────────────┐ +│ Extract │ ──────────────────>│ Qdrant │ +│ Keywords │ │ bp_legal_ │ +│ from Rules │<───────────────────│ corpus │ +└──────┬───────┘ Top-K Docs └──────────────┘ + │ + │ Assessment + Legal Context + ▼ +┌──────────────┐ +│ LLM │ +│ Provider │ +│ Registry │ +└──────┬───────┘ + │ + ▼ +Explanation (DE) + Legal References +``` + +--- + +## 5. Entscheidungsdiagramm + +### 5.1 Feasibility-Entscheidung + +``` + UseCaseIntake + │ + ▼ + ┌─────────────────────┐ + │ Hat BLOCK-Regeln? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ ▼ + ┌───────────┐ ┌─────────────────────┐ + │ NO │ │ Hat WARN-Regeln? │ + │ (blocked) │ └──────────┬──────────┘ + └───────────┘ │ │ + Ja Nein + │ │ + ▼ ▼ + ┌───────────┐ ┌───────────┐ + │CONDITIONAL│ │ YES │ + │(mit │ │(grünes │ + │Auflagen) │ │Licht) │ + └───────────┘ └───────────┘ +``` + +### 5.2 Escalation-Level-Entscheidung + +``` + AssessmentResult + │ + ▼ + ┌─────────────────────┐ + │ BLOCK-Regel oder │ + │ Art.22 Risiko? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ │ + ┌───────────┐ │ + │ E3 │ │ + │ DSB+Legal │ │ + └───────────┘ ▼ + ┌─────────────────────┐ + │ Risk ≥40 oder │ + │ Art.9 Daten oder │ + │ DSFA empfohlen? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ │ + ┌───────────┐ │ + │ E2 │ │ + │ DSB │ │ + └───────────┘ ▼ + ┌─────────────────────┐ + │ Risk ≥20 oder │ + │ WARN-Regeln? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ ▼ + ┌───────────┐ ┌───────────┐ + │ E1 │ │ E0 │ + │ Team-Lead │ │ Auto-OK │ + └───────────┘ └───────────┘ +``` + +--- + +## 6. Datenbank-Schema + +### 6.1 ucca_assessments + +```sql +CREATE TABLE ucca_assessments ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + namespace_id UUID, + title VARCHAR(500), + policy_version VARCHAR(50) NOT NULL, + status VARCHAR(50) DEFAULT 'completed', + + -- Input + intake JSONB NOT NULL, + use_case_text_stored BOOLEAN DEFAULT FALSE, + use_case_text_hash VARCHAR(64), + domain VARCHAR(50), + + -- Result + feasibility VARCHAR(20) NOT NULL, + risk_level VARCHAR(20) NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + triggered_rules JSONB DEFAULT '[]', + required_controls JSONB DEFAULT '[]', + recommended_architecture JSONB DEFAULT '[]', + forbidden_patterns JSONB DEFAULT '[]', + example_matches JSONB DEFAULT '[]', + + -- Flags + dsfa_recommended BOOLEAN DEFAULT FALSE, + art22_risk BOOLEAN DEFAULT FALSE, + training_allowed VARCHAR(50), + + -- Explanation + explanation_text TEXT, + explanation_generated_at TIMESTAMPTZ, + explanation_model VARCHAR(100), + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID NOT NULL +); +``` + +### 6.2 ucca_escalations + +```sql +CREATE TABLE ucca_escalations ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + assessment_id UUID NOT NULL REFERENCES ucca_assessments(id), + + -- Level & Status + escalation_level VARCHAR(10) NOT NULL, + escalation_reason TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + + -- Assignment + assigned_to UUID, + assigned_role VARCHAR(50), + assigned_at TIMESTAMPTZ, + + -- Review + reviewer_id UUID, + reviewer_notes TEXT, + reviewed_at TIMESTAMPTZ, + + -- Decision + decision VARCHAR(50), + decision_notes TEXT, + decision_at TIMESTAMPTZ, + conditions JSONB DEFAULT '[]', + + -- SLA + due_date TIMESTAMPTZ, + notification_sent BOOLEAN DEFAULT FALSE, + notification_sent_at TIMESTAMPTZ, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 6.3 ucca_dsb_pool + +```sql +CREATE TABLE ucca_dsb_pool ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + user_name VARCHAR(255) NOT NULL, + user_email VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + max_concurrent_reviews INT DEFAULT 10, + current_reviews INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 7. API-Endpunkte + +### 7.1 Assessment + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/ucca/assess` | Assessment erstellen | +| GET | `/sdk/v1/ucca/assessments` | Assessments auflisten | +| GET | `/sdk/v1/ucca/assessments/:id` | Assessment abrufen | +| DELETE | `/sdk/v1/ucca/assessments/:id` | Assessment löschen | +| POST | `/sdk/v1/ucca/assessments/:id/explain` | LLM-Erklärung generieren | +| GET | `/sdk/v1/ucca/export/:id` | Assessment exportieren | + +### 7.2 Kataloge + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/patterns` | Architektur-Patterns | +| GET | `/sdk/v1/ucca/examples` | Didaktische Beispiele | +| GET | `/sdk/v1/ucca/rules` | Alle Regeln | +| GET | `/sdk/v1/ucca/controls` | Required Controls | +| GET | `/sdk/v1/ucca/problem-solutions` | Problem-Lösungen | + +### 7.3 Eskalation + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/escalations` | Eskalationen auflisten | +| GET | `/sdk/v1/ucca/escalations/:id` | Eskalation abrufen | +| POST | `/sdk/v1/ucca/escalations` | Manuelle Eskalation | +| POST | `/sdk/v1/ucca/escalations/:id/assign` | Zuweisen | +| POST | `/sdk/v1/ucca/escalations/:id/review` | Review starten | +| POST | `/sdk/v1/ucca/escalations/:id/decide` | Entscheidung treffen | +| GET | `/sdk/v1/ucca/escalations/stats` | Statistiken | + +### 7.4 DSB Pool + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/dsb-pool` | Pool-Mitglieder auflisten | +| POST | `/sdk/v1/ucca/dsb-pool` | Mitglied hinzufügen | + +--- + +## 8. Sicherheit + +### 8.1 Authentifizierung + +- JWT-basierte Authentifizierung +- Header: `X-User-ID`, `X-Tenant-ID` +- Multi-Tenant-Isolation + +### 8.2 Autorisierung + +- RBAC (Role-Based Access Control) +- Permissions: `ucca:assess`, `ucca:review`, `ucca:admin` +- Namespace-Level Isolation + +### 8.3 Datenschutz + +- Use-Case-Text optional (Opt-in) +- SHA-256 Hash statt Klartext +- Audit-Trail für alle Operationen +- Legal RAG: `training_allowed: false` + +--- + +## 9. Deployment + +### 9.1 Container + +```yaml +ai-compliance-sdk: + build: ./ai-compliance-sdk + ports: + - "8090:8090" + environment: + - DATABASE_URL=postgres://... + - OLLAMA_URL=http://ollama:11434 + - QDRANT_URL=http://qdrant:6333 + depends_on: + - postgres + - qdrant +``` + +### 9.2 Abhängigkeiten + +- PostgreSQL 15+ +- Qdrant 1.12+ +- Embedding Service (BGE-M3) +- Ollama (optional, für LLM) + +--- + +## 10. Monitoring + +### 10.1 Health Check + +``` +GET /sdk/v1/health +→ {"status": "ok"} +``` + +### 10.2 Metriken + +- Assessment-Durchsatz +- Escalation-SLA-Compliance +- LLM-Latenz +- RAG-Trefferqualität + +--- + +--- + +## 11. Wizard & Legal Assistant + +### 11.1 Wizard-Architektur + +Der UCCA-Wizard führt Benutzer durch 9 Schritte zur Erfassung aller relevanten Compliance-Fakten. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ UCCA Wizard v1.1 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Step 1: Grundlegende Informationen │ +│ Step 2: Datenarten (Personal Data, Art. 9, etc.) │ +│ Step 3: Verarbeitungszweck (Profiling, Scoring) │ +│ Step 4: Hosting & Provider │ +│ Step 5: Internationaler Datentransfer (SCC, TIA) │ +│ Step 6: KI-Modell und Training │ +│ Step 7: Verträge & Compliance (AVV, DSFA) │ +│ Step 8: Automatisierung & Human Oversight │ +│ Step 9: Standards & Normen (für Maschinenbauer) ← NEU │ +│ │ +│ Features: │ +│ ├── Adaptive Subflows (visible_if Conditions) │ +│ ├── Simple/Expert Mode Toggle │ +│ ├── Legal Assistant Chat pro Step │ +│ └── simple_explanation für Nicht-Juristen │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 11.2 Legal Assistant (Wizard Chat) + +Integrierter Rechtsassistent für Echtzeit-Hilfe bei Wizard-Fragen. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Legal Assistant Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ User Question ─────────────────────────────────────────────────>│ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Build RAG Query │ │ +│ │ + Step Context │ │ +│ └────────┬─────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ Search ┌──────────────────┐ │ +│ │ Legal RAG │ ────────────>│ Qdrant │ │ +│ │ Client │ │ bp_legal_corpus │ │ +│ │ │<────────────│ + SCC Corpus │ │ +│ └────────┬─────────┘ Top-5 └──────────────────┘ │ +│ │ │ +│ │ Question + Legal Context │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Internal 32B LLM │ │ +│ │ (Ollama) │ │ +│ │ temp=0.3 │ │ +│ └────────┬─────────┘ │ +│ │ │ +│ ▼ │ +│ Answer + Sources + Related Fields │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**API-Endpunkte:** + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/wizard/schema` | Wizard-Schema abrufen | +| POST | `/sdk/v1/ucca/wizard/ask` | Frage an Legal Assistant | + +--- + +## 12. License Policy Engine (Standards Compliance) + +### 12.1 Übersicht + +Die License Policy Engine verwaltet die Lizenz-/Urheberrechts-Compliance für Standards und Normen (DIN, ISO, VDI, etc.). + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ License Policy Engine │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ LicensedContentFacts ─────────────────────────────────────────>│ +│ │ │ +│ │ ├── present: bool │ +│ │ ├── publisher: DIN_MEDIA | VDI | ISO | ... │ +│ │ ├── license_type: SINGLE | NETWORK | ENTERPRISE | AI │ +│ │ ├── ai_use_permitted: YES | NO | UNKNOWN │ +│ │ ├── operation_mode: LINK | NOTES | FULLTEXT | TRAINING │ +│ │ └── proof_uploaded: bool │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Operation Mode Evaluation ││ +│ ├─────────────────────────────────────────────────────────────┤│ +│ │ ││ +│ │ LINK_ONLY ──────────── Always Allowed ───────────> OK ││ +│ │ NOTES_ONLY ─────────── Usually Allowed ──────────> OK ││ +│ │ FULLTEXT_RAG ────┬──── ai_use=YES + proof ───────> OK ││ +│ │ └──── else ─────────────────────> BLOCK ││ +│ │ TRAINING ────────┬──── AI_LICENSE + proof ───────> OK ││ +│ │ └──── else ─────────────────────> BLOCK ││ +│ │ ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ LicensePolicyResult │ +│ ├── allowed: bool │ +│ ├── effective_mode: string (may be downgraded) │ +│ ├── gaps: []LicenseGap │ +│ ├── required_controls: []LicenseControl │ +│ ├── stop_line: *StopLine (if hard blocked) │ +│ └── output_restrictions: *OutputRestrictions │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 12.2 Betriebs-Modi (Operation Modes) + +| Modus | Beschreibung | Lizenz-Anforderung | Ingest | Output | +|-------|--------------|-------------------|--------|--------| +| **LINK_ONLY** | Nur Verweise & Checklisten | Keine | Metadata only | Keine Zitate | +| **NOTES_ONLY** | Kundeneigene Zusammenfassungen | Standard | Notes only | Paraphrasen | +| **EXCERPT_ONLY** | Kurze Zitate (Zitatrecht) | Standard + Zitatrecht | Notes | Max 150 Zeichen | +| **FULLTEXT_RAG** | Volltext indexiert | AI-Lizenz + Proof | Fulltext | Max 500 Zeichen | +| **TRAINING** | Modell-Training | AI-Training-Lizenz | Fulltext | N/A | + +### 12.3 Publisher-spezifische Regeln + +**DIN Media (ehem. Beuth):** +- AI-Nutzung aktuell verboten (ohne explizite Genehmigung) +- AI-Lizenzmodell geplant ab Q4/2025 +- Crawler/Scraper verboten (AGB) +- TDM-Vorbehalt nach §44b UrhG + +### 12.4 Stop-Lines (Hard Deny) + +``` +STOP_DIN_FULLTEXT_AI_NOT_ALLOWED + WENN: publisher=DIN_MEDIA AND operation_mode in [FULLTEXT_RAG, TRAINING] + AND ai_use_permitted in [NO, UNKNOWN] + DANN: BLOCKIERT + FALLBACK: LINK_ONLY + +STOP_TRAINING_WITHOUT_PROOF + WENN: operation_mode=TRAINING AND proof_uploaded=false + DANN: BLOCKIERT +``` + +--- + +## 13. SCC & Transfer Impact Assessment + +### 13.1 Drittlandtransfer-Bewertung + +Das System unterstützt die vollständige Bewertung internationaler Datentransfers. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SCC/Transfer Assessment Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ hosting.region ─────────────────────────────────────────────── │ +│ │ │ +│ ├── EU/EWR ────────────────────────────────> OK (no SCC) │ +│ │ │ +│ ├── Adequacy Country (UK, CH, JP) ─────────> OK (no SCC) │ +│ │ │ +│ └── Third Country (US, etc.) ──────────────────────────── │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ USA: DPF-Zertifizierung prüfen ││ +│ │ ├── Zertifiziert ───> OK (SCC empfohlen als Backup) ││ +│ │ └── Nicht zertifiziert ───> SCC + TIA erforderlich ││ +│ └─────────────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ Transfer Impact Assessment (TIA) ││ +│ │ ├── Adequate ─────────────> Transfer OK ││ +│ │ ├── Adequate + Measures ──> + Technical Supplementary ││ +│ │ ├── Inadequate ───────────> Fix required ││ +│ │ └── Not Feasible ─────────> Transfer NOT allowed ││ +│ └─────────────────────────────────────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 13.2 SCC-Versionen + +- Neue SCC (EU 2021/914) - **erforderlich** seit 27.12.2022 +- Alte SCC (vor 2021) - **nicht mehr gültig** + +--- + +## 14. Controls Catalog + +### 14.1 Übersicht + +Der Controls Catalog enthält ~30 Maßnahmenbausteine mit detaillierten Handlungsanweisungen. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Controls Catalog v1.0 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Kategorien: │ +│ ├── DSGVO (Rechtsgrundlagen, Betroffenenrechte, Dokumentation) │ +│ ├── AI_Act (Transparenz, HITL, Risikoeinstufung) │ +│ ├── Technical (Verschlüsselung, Anonymisierung, PII-Gateway) │ +│ └── Contractual (AVV, SCC, TIA) │ +│ │ +│ Struktur pro Control: │ +│ ├── id: CTRL-xxx │ +│ ├── title: Kurztitel │ +│ ├── when_applicable: Wann erforderlich? │ +│ ├── what_to_do: Konkrete Handlungsschritte │ +│ ├── evidence_needed: Erforderliche Nachweise │ +│ ├── effort: low | medium | high │ +│ └── gdpr_ref: Rechtsgrundlage │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 14.2 Beispiel-Controls + +| ID | Titel | Kategorie | +|----|-------|-----------| +| CTRL-CONSENT-EXPLICIT | Ausdrückliche Einwilligung | DSGVO | +| CTRL-AI-TRANSPARENCY | KI-Transparenz-Hinweis | AI_Act | +| CTRL-DSFA | Datenschutz-Folgenabschätzung | DSGVO | +| CTRL-SCC | Standardvertragsklauseln | Contractual | +| CTRL-TIA | Transfer Impact Assessment | Contractual | +| CTRL-LICENSE-PROOF | Lizenz-/Rechte-Nachweis | License | +| CTRL-LINK-ONLY-MODE | Evidence Navigator | License | +| CTRL-PII-GATEWAY | PII-Redaction Gateway | Technical | + +--- + +## 15. Policy-Dateien + +### 15.1 Dateistruktur + +``` +policies/ +├── ucca_policy_v1.yaml # Haupt-Policy (Regeln, Controls) +├── controls_catalog.yaml # Detaillierter Maßnahmenkatalog +├── gap_mapping.yaml # Facts → Gaps → Controls +├── wizard_schema_v1.yaml # Wizard-Fragen (9 Steps) +├── scc_legal_corpus.yaml # SCC/Transfer Rechtstexte +└── licensed_content_policy.yaml # Normen-Lizenz-Compliance (NEU) +``` + +### 15.2 Versions-Management + +- Jedes Assessment speichert die `policy_version` +- Regeländerungen erzeugen neue Version +- Audit-Trail zeigt welche Policy-Version verwendet wurde + +--- + +--- + +## 16. Generic Obligations Framework + +### 16.1 Übersicht + +Das Generic Obligations Framework ermöglicht die automatische Ableitung regulatorischer Pflichten aus mehreren Verordnungen basierend auf Unternehmensfakten. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Generic Obligations Framework │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ UnifiedFacts ───────────────────────────────────────────────── │ +│ │ │ +│ │ ├── organization: EmployeeCount, Revenue, Country │ +│ │ ├── sector: PrimarySector, IsKRITIS, SpecialServices │ +│ │ ├── data_protection: ProcessesPersonalData │ +│ │ └── ai_usage: UsesAI, HighRiskCategories, IsGPAI │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Obligations Registry ││ +│ │ (Module Registration & Evaluation) ││ +│ └──────────────────────────┬──────────────────────────────────┘│ +│ │ │ +│ ┌───────────────────┼───────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ NIS2 │ │ DSGVO │ │ AI Act │ │ +│ │ Module │ │ Module │ │ Module │ │ +│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ +│ │ │ │ │ +│ └───────────────────┴───────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ ManagementObligationsOverview ││ +│ │ ├── ApplicableRegulations[] ││ +│ │ ├── Obligations[] (sortiert nach Priorität) ││ +│ │ ├── RequiredControls[] ││ +│ │ ├── IncidentDeadlines[] ││ +│ │ ├── SanctionsSummary ││ +│ │ └── ExecutiveSummary ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 16.2 Regulation Modules + +Jede Regulierung wird als eigenständiges Modul implementiert: + +```go +// RegulationModule - Interface für alle Regulierungsmodule +type RegulationModule interface { + ID() string // "dsgvo", "nis2", "ai_act" + Name() string // "DSGVO", "NIS2-Richtlinie" + IsApplicable(facts *UnifiedFacts) bool // Prüft Anwendbarkeit + DeriveObligations(facts *UnifiedFacts) []Obligation + DeriveControls(facts *UnifiedFacts) []ObligationControl + DeriveIncidentDeadlines(facts *UnifiedFacts) []IncidentDeadline + GetDecisionTree() *DecisionTree // Optional +} +``` + +**Implementierte Module:** + +| Modul | ID | Datei | Pflichten | Kontrollen | +|-------|-----|-------|-----------|------------| +| NIS2 | `nis2` | `nis2_module.go` | ~15 | ~8 | +| DSGVO | `dsgvo` | `dsgvo_module.go` | ~12 | ~6 | +| AI Act | `ai_act` | `ai_act_module.go` | ~15 | ~6 | + +### 16.3 NIS2 Decision Tree + +``` + UnifiedFacts + │ + ▼ + ┌─────────────────────┐ + │ Sektor in Anhang I │ + │ ODER Anhang II? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ ▼ + ┌────────────────┐ ┌───────────────┐ + │ Größenprüfung │ │ Nicht │ + └────────┬───────┘ │ betroffen │ + │ └───────────────┘ + ▼ + ┌─────────────────────┐ + │ >= 250 MA ODER │ + │ >= 50 Mio EUR │ + │ ODER KRITIS │ + │ ODER Spec.Services │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ ▼ + ┌───────────┐ ┌─────────────────────┐ + │ Besonders │ │ >= 50 MA ODER │ + │ wichtige │ │ >= 10 Mio EUR? │ + │ Einricht. │ └──────────┬──────────┘ + └───────────┘ │ │ + Ja Nein + │ │ + ▼ ▼ + ┌───────────┐ ┌───────────┐ + │ Wichtige │ │ Nicht │ + │ Einricht. │ │ betroffen │ + └───────────┘ └───────────┘ +``` + +### 16.4 AI Act Risk Classification + +``` + UnifiedFacts + │ + ▼ + ┌─────────────────────┐ + │ Nutzt KI-Systeme? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ ▼ + ┌────────────────┐ ┌───────────────┐ + │ Verbotene │ │ Nicht │ + │ Praktik? │ │ anwendbar │ + └────────┬───────┘ └───────────────┘ + │ + ┌────┴────┐ + Ja Nein + │ │ + ▼ ▼ + ┌────────────┐ ┌─────────────────────┐ + │ UNACCEPT- │ │ Annex III │ + │ ABLE │ │ Hochrisiko- │ + │ (verboten) │ │ Kategorie? │ + └────────────┘ └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ ▼ + ┌───────────┐ ┌───────────┐ + │ HIGH_RISK │ │ LIMITED/ │ + │ │ │ MINIMAL │ + └───────────┘ └───────────┘ +``` + +### 16.5 PDF Export + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PDF Export Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ManagementObligationsOverview ──────────────────────────────── │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ PDFExporter ││ +│ │ (gofpdf) ││ +│ ├─────────────────────────────────────────────────────────────┤│ +│ │ ││ +│ │ ExportManagementMemo() ─────> PDF (base64) ││ +│ │ ├── Titel & Metadaten ││ +│ │ ├── Executive Summary ││ +│ │ ├── Anwendbare Regulierungen ││ +│ │ ├── Sanktions-Zusammenfassung ││ +│ │ ├── Pflichten-Tabelle ││ +│ │ └── Incident-Deadlines ││ +│ │ ││ +│ │ ExportMarkdown() ───────────> Markdown (text) ││ +│ │ ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ExportMemoResponse │ +│ ├── Content (base64/text) │ +│ ├── ContentType (application/pdf | text/markdown) │ +│ └── Filename │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 17. Obligations API-Endpunkte + +### 17.1 Assessment + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/ucca/obligations/assess` | Pflichten-Assessment erstellen | +| GET | `/sdk/v1/ucca/obligations/:id` | Assessment abrufen | +| GET | `/sdk/v1/ucca/obligations` | Assessments auflisten | + +### 17.2 Export + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/ucca/obligations/export/memo` | Memo exportieren (gespeichert) | +| POST | `/sdk/v1/ucca/obligations/export/direct` | Direkt-Export ohne Speicherung | + +**Request Body (Export):** +```json +{ + "overview": { ... }, + "format": "pdf", // "pdf" | "markdown" + "language": "de" +} +``` + +**Response:** +```json +{ + "content": "JVBERi0xLjQ...", // base64 für PDF + "content_type": "application/pdf", + "filename": "pflichten-memo-2026-01-29.pdf" +} +``` + +### 17.3 Regulations + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/regulations` | Liste aller Regulierungsmodule | +| GET | `/sdk/v1/ucca/regulations/:id/decision-tree` | Decision Tree für Regulierung | + +--- + +## 18. Dateien des Obligations Framework + +### 18.1 Backend (Go) + +``` +internal/ucca/ +├── obligations_framework.go # Interfaces, Typen, Konstanten +├── obligations_registry.go # Modul-Registry, EvaluateAll() +├── nis2_module.go # NIS2 Decision Tree + Pflichten +├── nis2_module_test.go # NIS2 Tests +├── dsgvo_module.go # DSGVO Pflichten +├── dsgvo_module_test.go # DSGVO Tests +├── ai_act_module.go # AI Act Risk Classification +├── ai_act_module_test.go # AI Act Tests +├── pdf_export.go # PDF/Markdown Export +└── pdf_export_test.go # Export Tests +``` + +### 18.2 Policy-Dateien (YAML) + +``` +policies/obligations/ +├── nis2_obligations.yaml # ~15 NIS2-Pflichten +├── dsgvo_obligations.yaml # ~12 DSGVO-Pflichten +└── ai_act_obligations.yaml # ~15 AI Act-Pflichten +``` + +--- + +*Dokumentation erstellt: 2026-01-29* +*Version: 2.1.0* diff --git a/ai-compliance-sdk/docs/AUDITOR_DOCUMENTATION.md b/ai-compliance-sdk/docs/AUDITOR_DOCUMENTATION.md new file mode 100644 index 0000000..89ebe7b --- /dev/null +++ b/ai-compliance-sdk/docs/AUDITOR_DOCUMENTATION.md @@ -0,0 +1,387 @@ +# UCCA - Dokumentation für externe Auditoren + +## Systemdokumentation nach Art. 30 DSGVO + +**Verantwortlicher:** [Name des Unternehmens] +**Datenschutzbeauftragter:** [Kontakt] +**Dokumentationsstand:** 2026-01-29 +**Version:** 1.0.0 + +--- + +## 1. Zweck und Funktionsweise des Systems + +### 1.1 Systembezeichnung + +**UCCA - Use-Case Compliance & Feasibility Advisor** + +### 1.2 Zweckbeschreibung + +Das UCCA-System ist ein **Compliance-Prüfwerkzeug**, das Organisationen bei der Bewertung geplanter KI-Anwendungsfälle hinsichtlich ihrer datenschutzrechtlichen Zulässigkeit unterstützt. + +**Kernfunktionen:** +- Automatisierte Vorprüfung von KI-Anwendungsfällen gegen EU-Regulierungen +- Identifikation erforderlicher technischer und organisatorischer Maßnahmen +- Eskalation kritischer Fälle zur menschlichen Prüfung +- Dokumentation und Nachvollziehbarkeit aller Prüfentscheidungen + +### 1.3 Rechtsgrundlage + +Die Verarbeitung erfolgt auf Basis von: +- **Art. 6 Abs. 1 lit. c DSGVO** - Erfüllung rechtlicher Verpflichtungen +- **Art. 6 Abs. 1 lit. f DSGVO** - Berechtigte Interessen (Compliance-Management) + +--- + +## 2. Verarbeitete Datenkategorien + +### 2.1 Eingabedaten (Use-Case-Beschreibungen) + +| Datenkategorie | Beschreibung | Speicherung | +|----------------|--------------|-------------| +| Use-Case-Text | Freitextbeschreibung des geplanten Anwendungsfalls | Optional (Opt-in), ansonsten nur Hash | +| Domain | Branchenkategorie (z.B. "education", "healthcare") | Ja | +| Datentyp-Flags | Angaben zu verarbeiteten Datenarten | Ja | +| Automatisierungsgrad | assistiv/teil-/vollautomatisch | Ja | +| Hosting-Informationen | Region, Provider | Ja | + +**Wichtig:** Der System speichert standardmäßig **keine Freitexte**, sondern nur: +- SHA-256 Hash des Textes (zur Deduplizierung) +- Strukturierte Metadaten (Checkboxen, Dropdowns) + +### 2.2 Bewertungsergebnisse + +| Datenkategorie | Beschreibung | Aufbewahrung | +|----------------|--------------|--------------| +| Risk Score | Numerischer Wert 0-100 | Dauerhaft | +| Triggered Rules | Ausgelöste Compliance-Regeln | Dauerhaft | +| Required Controls | Empfohlene Maßnahmen | Dauerhaft | +| Explanation | KI-generierte Erklärung | Dauerhaft | + +### 2.3 Audit-Trail-Daten + +| Datenkategorie | Beschreibung | Aufbewahrung | +|----------------|--------------|--------------| +| Benutzer-ID | UUID des ausführenden Benutzers | 10 Jahre | +| Timestamp | Zeitpunkt der Aktion | 10 Jahre | +| Aktionstyp | created/reviewed/decided | 10 Jahre | +| Entscheidungsnotizen | Begründungen bei Eskalationen | 10 Jahre | + +--- + +## 3. Entscheidungslogik und Automatisierung + +### 3.1 Regelbasierte Bewertung (Deterministische Logik) + +Das System verwendet **ausschließlich deterministische Regeln** für Compliance-Entscheidungen. Diese Regeln sind: + +1. **Transparent** - Alle Regeln sind im Quellcode einsehbar +2. **Nachvollziehbar** - Jede ausgelöste Regel wird dokumentiert +3. **Überprüfbar** - Regellogik basiert auf konkreten DSGVO-Artikeln + +**Beispiel-Regel R-F001:** +``` +WENN: + - Domain = "education" UND + - Automation = "fully_automated" UND + - Output enthält "rankings_or_scores" +DANN: + - Severity = BLOCK + - DSGVO-Referenz = Art. 22 Abs. 1 + - Begründung = "Vollautomatisierte Bewertung von Schülern ohne menschliche Überprüfung" +``` + +### 3.2 Keine autonomen KI-Entscheidungen + +**Das System trifft KEINE autonomen KI-Entscheidungen bezüglich:** +- Zulässigkeit eines Anwendungsfalls (immer regelbasiert) +- Freigabe oder Ablehnung (immer durch Mensch) +- Rechtliche Bewertungen (immer durch DSB/Legal) + +**KI wird ausschließlich verwendet für:** +- Erklärung bereits getroffener Regelentscheidungen +- Zusammenfassung von Rechtstexten +- Sprachliche Formulierung von Hinweisen + +### 3.3 Human-in-the-Loop + +Bei allen kritischen Entscheidungen ist ein **menschlicher Prüfer** eingebunden: + +| Eskalationsstufe | Auslöser | Prüfer | SLA | +|------------------|----------|--------|-----| +| E0 | Nur informative Regeln | Automatisch | - | +| E1 | Warnungen, geringes Risiko | Team-Lead | 24h | +| E2 | Art. 9-Daten, DSFA empfohlen | DSB | 8h | +| E3 | BLOCK-Regeln, hohes Risiko | DSB + Legal | 4h | + +**BLOCK-Entscheidungen können NICHT durch KI überschrieben werden.** + +--- + +## 4. Technische und organisatorische Maßnahmen (Art. 32 DSGVO) + +### 4.1 Vertraulichkeit + +| Maßnahme | Umsetzung | +|----------|-----------| +| Zugriffskontrolle | RBAC mit Tenant-Isolation | +| Verschlüsselung in Transit | TLS 1.3 | +| Verschlüsselung at Rest | AES-256 (PostgreSQL, Qdrant) | +| Authentifizierung | JWT-basiert, Token-Expiry | + +### 4.2 Integrität + +| Maßnahme | Umsetzung | +|----------|-----------| +| Audit-Trail | Unveränderlicher Verlauf aller Aktionen | +| Versionierung | Policy-Version in jedem Assessment | +| Input-Validierung | Schema-Validierung aller API-Eingaben | + +### 4.3 Verfügbarkeit + +| Maßnahme | Umsetzung | +|----------|-----------| +| Backup | Tägliche PostgreSQL-Backups | +| Redundanz | Container-Orchestrierung mit Auto-Restart | +| Monitoring | Health-Checks, SLA-Überwachung | + +### 4.4 Belastbarkeit + +| Maßnahme | Umsetzung | +|----------|-----------| +| Rate Limiting | API-Anfragenbegrenzung | +| Graceful Degradation | LLM-Fallback bei Ausfall | +| Ressourcenlimits | Container-Memory-Limits | + +--- + +## 5. Datenschutz-Folgenabschätzung (Art. 35 DSGVO) + +### 5.1 Risikobewertung + +| Risiko | Bewertung | Mitigierung | +|--------|-----------|-------------| +| Fehleinschätzung durch KI | Mittel | Deterministische Regeln, Human Review | +| Datenverlust | Niedrig | Backup, Verschlüsselung | +| Unbefugter Zugriff | Niedrig | RBAC, Audit-Trail | +| Bias in Regellogik | Niedrig | Transparente Regeln, Review-Prozess | + +### 5.2 DSFA-Trigger im System + +Das System erkennt automatisch, wann eine DSFA erforderlich ist: +- Verarbeitung besonderer Kategorien (Art. 9 DSGVO) +- Systematische Bewertung natürlicher Personen +- Neue Technologien mit hohem Risiko + +--- + +## 6. Betroffenenrechte (Art. 15-22 DSGVO) + +### 6.1 Auskunftsrecht (Art. 15) + +Betroffene können Auskunft erhalten über: +- Gespeicherte Assessments mit ihren Daten +- Audit-Trail ihrer Interaktionen +- Regelbasierte Entscheidungsbegründungen + +### 6.2 Recht auf Berichtigung (Art. 16) + +Betroffene können die Korrektur fehlerhafter Eingabedaten verlangen. + +### 6.3 Recht auf Löschung (Art. 17) + +Assessments können gelöscht werden, sofern: +- Keine gesetzlichen Aufbewahrungspflichten bestehen +- Keine laufenden Eskalationsverfahren existieren + +### 6.4 Recht auf Einschränkung (Art. 18) + +Die Verarbeitung kann eingeschränkt werden durch: +- Archivierung statt Löschung +- Sperrung des Datensatzes + +### 6.5 Automatisierte Entscheidungen (Art. 22) + +**Das System trifft keine automatisierten Einzelentscheidungen** im Sinne von Art. 22 DSGVO, da: + +1. Regelauswertung ist **keine rechtlich bindende Entscheidung** +2. Alle kritischen Fälle werden **menschlich geprüft** (E1-E3) +3. BLOCK-Entscheidungen erfordern **immer menschliche Freigabe** +4. Betroffene haben **Anfechtungsmöglichkeit** über Eskalation + +--- + +## 7. Auftragsverarbeitung + +### 7.1 Unterauftragnehmer + +| Dienst | Anbieter | Standort | Zweck | +|--------|----------|----------|-------| +| Embedding-Service | Lokal (Self-Hosted) | EU | Vektorisierung | +| Vector-DB (Qdrant) | Lokal (Self-Hosted) | EU | Ähnlichkeitssuche | +| LLM (Ollama) | Lokal (Self-Hosted) | EU | Erklärungsgenerierung | + +**Hinweis:** Das System kann vollständig on-premise betrieben werden ohne externe Dienste. + +### 7.2 Internationale Transfers + +Bei Nutzung von Cloud-LLM-Anbietern: +- Anthropic Claude: US (DPF-zertifiziert) +- OpenAI: US (DPF-zertifiziert) + +**Empfehlung:** Nutzung des lokalen Ollama-Providers für sensible Daten. + +--- + +## 8. Audit-Trail und Nachvollziehbarkeit + +### 8.1 Protokollierte Ereignisse + +| Ereignis | Protokollierte Daten | +|----------|---------------------| +| Assessment erstellt | Benutzer, Timestamp, Intake-Hash, Ergebnis | +| Eskalation erstellt | Level, Grund, SLA | +| Zuweisung | Benutzer, Rolle | +| Review gestartet | Benutzer, Timestamp | +| Entscheidung | Benutzer, Entscheidung, Begründung | + +### 8.2 Aufbewahrungsfristen + +| Datenart | Aufbewahrung | Rechtsgrundlage | +|----------|--------------|-----------------| +| Assessments | 10 Jahre | § 147 AO | +| Audit-Trail | 10 Jahre | § 147 AO | +| Eskalationen | 10 Jahre | § 147 AO | +| Löschprotokolle | 3 Jahre | Art. 17 DSGVO | + +--- + +## 9. Lizenzierte Inhalte & Normen-Compliance (§44b UrhG) + +### 9.1 Zweck + +Das System enthält einen spezialisierten **License Policy Engine** zur Compliance-Prüfung bei der Verarbeitung urheberrechtlich geschützter Inhalte, insbesondere: + +- **DIN-Normen** (DIN Media / Beuth Verlag) +- **VDI-Richtlinien** +- **ISO/IEC-Standards** +- **VDE-Normen** + +### 9.2 Rechtlicher Hintergrund + +**§44b UrhG - Text und Data Mining:** +> "Die Vervielfältigung von rechtmäßig zugänglichen Werken für das Text und Data Mining ist zulässig." + +**ABER:** Rechteinhaber können TDM gem. §44b Abs. 3 UrhG vorbehalten: +- **DIN Media:** Expliziter Vorbehalt in AGB – keine KI/TDM-Nutzung ohne Sonderlizenz +- **Geplante KI-Lizenzmodelle:** Ab Q4/2025 (DIN Media) + +### 9.3 Operationsmodi im System + +| Modus | Beschreibung | Lizenzanforderung | +|-------|--------------|-------------------| +| `LINK_ONLY` | Nur Verlinkung zum Original | Keine | +| `NOTES_ONLY` | Eigene Notizen/Zusammenfassungen | Keine (§51 UrhG) | +| `EXCERPT_ONLY` | Kurze Zitate (<100 Wörter) | Standard-Lizenz | +| `FULLTEXT_RAG` | Volltextsuche mit Embedding | Explizite KI-Lizenz | +| `TRAINING` | Modell-Training | Enterprise-Lizenz + Vertrag | + +### 9.4 Stop-Lines (Automatische Sperren) + +Das System **blockiert automatisch** folgende Kombinationen: + +| Stop-Line ID | Bedingung | Aktion | +|--------------|-----------|--------| +| `STOP_DIN_FULLTEXT_AI_NOT_ALLOWED` | DIN Media + FULLTEXT_RAG + keine KI-Lizenz | Ablehnung | +| `STOP_LICENSE_UNKNOWN_FULLTEXT` | Lizenz unbekannt + FULLTEXT_RAG | Warnung + Eskalation | +| `STOP_TRAINING_WITHOUT_ENTERPRISE` | Beliebig + TRAINING + keine Enterprise-Lizenz | Ablehnung | + +### 9.5 License Policy Engine - Entscheidungslogik + +``` +INPUT: +├── licensed_content.present = true +├── licensed_content.publisher = "DIN_MEDIA" +├── licensed_content.license_type = "SINGLE_WORKSTATION" +├── licensed_content.ai_use_permitted = "NO" +└── licensed_content.operation_mode = "FULLTEXT_RAG" + +REGEL-EVALUATION: +├── Prüfe Publisher-spezifische Regeln +├── Prüfe Lizenztyp vs. gewünschter Modus +├── Prüfe AI-Use-Flag +└── Bestimme maximal zulässigen Modus + +OUTPUT: +├── allowed: false +├── max_allowed_mode: "NOTES_ONLY" +├── required_controls: ["CTRL-LICENSE-PROOF", "CTRL-NO-CRAWLING-DIN"] +├── gaps: ["GAP_DIN_MEDIA_WITHOUT_AI_LICENSE"] +├── stop_lines: ["STOP_DIN_FULLTEXT_AI_NOT_ALLOWED"] +└── explanation: "DIN Media verbietet KI-Nutzung ohne explizite Lizenz..." +``` + +### 9.6 Erforderliche Controls bei lizenzierten Inhalten + +| Control ID | Beschreibung | Evidence | +|------------|--------------|----------| +| `CTRL-LICENSE-PROOF` | Lizenznachweis dokumentieren | Lizenzvertrag, Rechnung | +| `CTRL-LICENSE-GATED-INGEST` | Technische Sperre vor Ingest | Konfiguration, Logs | +| `CTRL-NO-CRAWLING-DIN` | Kein automatisches Crawling | System-Konfiguration | +| `CTRL-OUTPUT-GUARD` | Ausgabe-Beschränkung (Zitatlimit) | API-Logs | + +### 9.7 Audit-relevante Protokollierung + +Bei jeder Verarbeitung lizenzierter Inhalte wird dokumentiert: + +| Feld | Beschreibung | Aufbewahrung | +|------|--------------|--------------| +| `license_check_timestamp` | Zeitpunkt der Prüfung | 10 Jahre | +| `license_decision` | Ergebnis (allowed/denied) | 10 Jahre | +| `license_proof_hash` | Hash des Lizenznachweises | 10 Jahre | +| `operation_mode_requested` | Angefragter Modus | 10 Jahre | +| `operation_mode_granted` | Erlaubter Modus | 10 Jahre | +| `publisher` | Rechteinhaber | 10 Jahre | + +### 9.8 On-Premise-Deployment für sensible Normen + +Für Unternehmen mit strengen Compliance-Anforderungen: + +| Komponente | Deployment | Isolation | +|------------|------------|-----------| +| Normen-Datenbank | Lokaler Mac Studio | Air-gapped | +| Embedding-Service | Lokal (bge-m3) | Keine Cloud | +| Vector-DB (Qdrant) | Lokaler Container | Tenant-Isolation | +| LLM (Ollama) | Lokal (Qwen2.5-Coder) | Keine API-Calls | + +--- + +## 10. Kontakt und Verantwortlichkeiten + +### 10.1 Verantwortlicher + +[Name und Adresse des Unternehmens] + +### 10.2 Datenschutzbeauftragter + +Name: [Name] +E-Mail: [E-Mail] +Telefon: [Telefon] + +### 10.3 Technischer Ansprechpartner + +Name: [Name] +E-Mail: [E-Mail] + +--- + +## 11. Änderungshistorie + +| Version | Datum | Änderung | Autor | +|---------|-------|----------|-------| +| 1.1.0 | 2026-01-29 | License Policy Engine & Standards-Compliance (§44b UrhG) | [Autor] | +| 1.0.0 | 2026-01-29 | Erstversion | [Autor] | + +--- + +*Diese Dokumentation erfüllt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von Verarbeitungstätigkeiten) und dient als Grundlage für Audits nach Art. 32 DSGVO (Sicherheit der Verarbeitung).* diff --git a/ai-compliance-sdk/docs/SBOM.md b/ai-compliance-sdk/docs/SBOM.md new file mode 100644 index 0000000..510f150 --- /dev/null +++ b/ai-compliance-sdk/docs/SBOM.md @@ -0,0 +1,220 @@ +# AI Compliance SDK - Software Bill of Materials (SBOM) + +**Erstellt:** 2026-01-29 +**Go-Version:** 1.24.0 + +--- + +## Zusammenfassung + +| Kategorie | Anzahl | Status | +|-----------|--------|--------| +| Direkte Abhängigkeiten | 7 | ✅ Alle kommerziell nutzbar | +| Indirekte Abhängigkeiten | ~45 | ✅ Alle kommerziell nutzbar | +| **Gesamt** | ~52 | ✅ **Alle Open Source, kommerziell nutzbar** | + +--- + +## Direkte Abhängigkeiten + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/gin-gonic/gin` | v1.10.1 | **MIT** | ✅ Ja | +| `github.com/gin-contrib/cors` | v1.7.6 | **MIT** | ✅ Ja | +| `github.com/google/uuid` | v1.6.0 | **BSD-3-Clause** | ✅ Ja | +| `github.com/jackc/pgx/v5` | v5.5.3 | **MIT** | ✅ Ja | +| `github.com/joho/godotenv` | v1.5.1 | **MIT** | ✅ Ja | +| `github.com/xuri/excelize/v2` | v2.9.1 | **BSD-3-Clause** | ✅ Ja | +| `gopkg.in/yaml.v3` | v3.0.1 | **MIT / Apache-2.0** | ✅ Ja | + +--- + +## Indirekte Abhängigkeiten (Transitive) + +### JSON / Serialisierung + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/bytedance/sonic` | v1.13.3 | **Apache-2.0** | ✅ Ja | +| `github.com/goccy/go-json` | v0.10.5 | **MIT** | ✅ Ja | +| `github.com/json-iterator/go` | v1.1.12 | **MIT** | ✅ Ja | +| `github.com/pelletier/go-toml/v2` | v2.2.4 | **MIT** | ✅ Ja | +| `gopkg.in/yaml.v3` | v3.0.1 | **MIT / Apache-2.0** | ✅ Ja | +| `github.com/ugorji/go/codec` | v1.3.0 | **MIT** | ✅ Ja | + +### Web Framework (Gin-Ökosystem) + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/gin-contrib/sse` | v1.1.0 | **MIT** | ✅ Ja | +| `github.com/go-playground/validator/v10` | v10.26.0 | **MIT** | ✅ Ja | +| `github.com/go-playground/locales` | v0.14.1 | **MIT** | ✅ Ja | +| `github.com/go-playground/universal-translator` | v0.18.1 | **MIT** | ✅ Ja | +| `github.com/leodido/go-urn` | v1.4.0 | **MIT** | ✅ Ja | + +### Datenbank (PostgreSQL) + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/jackc/pgpassfile` | v1.0.0 | **MIT** | ✅ Ja | +| `github.com/jackc/pgservicefile` | v0.0.0-... | **MIT** | ✅ Ja | +| `github.com/jackc/puddle/v2` | v2.2.1 | **MIT** | ✅ Ja | + +### Excel-Verarbeitung + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/xuri/excelize/v2` | v2.9.1 | **BSD-3-Clause** | ✅ Ja | +| `github.com/xuri/efp` | v0.0.1 | **BSD-3-Clause** | ✅ Ja | +| `github.com/xuri/nfp` | v0.0.2-... | **BSD-3-Clause** | ✅ Ja | +| `github.com/richardlehane/mscfb` | v1.0.4 | **Apache-2.0** | ✅ Ja | +| `github.com/richardlehane/msoleps` | v1.0.4 | **Apache-2.0** | ✅ Ja | + +### PDF-Generierung + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/jung-kurt/gofpdf` | v1.16.2 | **MIT** | ✅ Ja | + +### Utilities + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/gabriel-vasile/mimetype` | v1.4.9 | **MIT** | ✅ Ja | +| `github.com/mattn/go-isatty` | v0.0.20 | **MIT** | ✅ Ja | +| `github.com/modern-go/concurrent` | v0.0.0-... | **Apache-2.0** | ✅ Ja | +| `github.com/modern-go/reflect2` | v1.0.2 | **Apache-2.0** | ✅ Ja | +| `github.com/klauspost/cpuid/v2` | v2.2.10 | **MIT** | ✅ Ja | +| `github.com/tiendc/go-deepcopy` | v1.7.1 | **MIT** | ✅ Ja | +| `github.com/twitchyliquid64/golang-asm` | v0.15.1 | **MIT** | ✅ Ja | +| `github.com/cloudwego/base64x` | v0.1.5 | **Apache-2.0** | ✅ Ja | + +### Go Standardbibliothek Erweiterungen + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `golang.org/x/arch` | v0.18.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/crypto` | v0.43.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/net` | v0.46.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/sync` | v0.17.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/sys` | v0.37.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/text` | v0.30.0 | **BSD-3-Clause** | ✅ Ja | + +### Protokoll-Bibliotheken + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `google.golang.org/protobuf` | v1.36.6 | **BSD-3-Clause** | ✅ Ja | + +--- + +## Lizenz-Übersicht + +| Lizenz | Anzahl Packages | Kommerziell nutzbar | Copyleft | +|--------|-----------------|---------------------|----------| +| **MIT** | ~25 | ✅ Ja | ❌ Nein | +| **Apache-2.0** | ~8 | ✅ Ja | ❌ Nein (schwach) | +| **BSD-3-Clause** | ~12 | ✅ Ja | ❌ Nein | +| **BSD-2-Clause** | 0 | ✅ Ja | ❌ Nein | + +### Keine problematischen Lizenzen! + +| Lizenz | Status | +|--------|--------| +| GPL-2.0 | ❌ **Nicht verwendet** | +| GPL-3.0 | ❌ **Nicht verwendet** | +| AGPL | ❌ **Nicht verwendet** | +| LGPL | ❌ **Nicht verwendet** | +| SSPL | ❌ **Nicht verwendet** | +| Commons Clause | ❌ **Nicht verwendet** | + +--- + +## Eigene Komponenten (Keine externen Abhängigkeiten) + +Die folgenden Komponenten wurden im Rahmen des AI Compliance SDK entwickelt und haben **keine zusätzlichen Abhängigkeiten**: + +| Komponente | Dateien | Externe Deps | +|------------|---------|--------------| +| Policy Engine | `internal/ucca/policy_engine.go` | Keine | +| License Policy Engine | `internal/ucca/license_policy.go` | Keine | +| Legal RAG | `internal/ucca/legal_rag.go` | Keine | +| Escalation System | `internal/ucca/escalation_*.go` | Keine | +| SLA Monitor | `internal/ucca/sla_monitor.go` | Keine | +| UCCA Handlers | `internal/api/handlers/ucca_handlers.go` | Gin (MIT) | +| **Obligations Framework** | `internal/ucca/obligations_framework.go` | Keine | +| **Obligations Registry** | `internal/ucca/obligations_registry.go` | Keine | +| **NIS2 Module** | `internal/ucca/nis2_module.go` | Keine | +| **DSGVO Module** | `internal/ucca/dsgvo_module.go` | Keine | +| **AI Act Module** | `internal/ucca/ai_act_module.go` | Keine | +| **PDF Export** | `internal/ucca/pdf_export.go` | gofpdf (MIT) | +| **Obligations Handlers** | `internal/api/handlers/obligations_handlers.go` | Gin (MIT) | +| **Funding Models** | `internal/funding/models.go` | Keine | +| **Funding Store** | `internal/funding/store.go`, `postgres_store.go` | pgx (MIT) | +| **Funding Export** | `internal/funding/export.go` | gofpdf (MIT), excelize (BSD-3) | +| **Funding Handlers** | `internal/api/handlers/funding_handlers.go` | Gin (MIT) | + +### Policy-Dateien (Reine YAML/JSON) + +| Datei | Format | Abhängigkeiten | +|-------|--------|----------------| +| `ucca_policy_v1.yaml` | YAML | Keine | +| `wizard_schema_v1.yaml` | YAML | Keine | +| `controls_catalog.yaml` | YAML | Keine | +| `gap_mapping.yaml` | YAML | Keine | +| `licensed_content_policy.yaml` | YAML | Keine | +| `financial_regulations_policy.yaml` | YAML | Keine | +| `financial_regulations_corpus.yaml` | YAML | Keine | +| `scc_legal_corpus.yaml` | YAML | Keine | +| **`obligations/nis2_obligations.yaml`** | YAML | Keine | +| **`obligations/dsgvo_obligations.yaml`** | YAML | Keine | +| **`obligations/ai_act_obligations.yaml`** | YAML | Keine | +| **`funding/foerderantrag_wizard_v1.yaml`** | YAML | Keine | +| **`funding/bundesland_profiles.yaml`** | YAML | Keine | + +--- + +## Compliance-Erklärung + +### Für kommerzielle Nutzung geeignet: ✅ JA + +Alle verwendeten Abhängigkeiten verwenden **permissive Open-Source-Lizenzen**: + +1. **MIT-Lizenz**: Erlaubt kommerzielle Nutzung, Modifikation, Distribution. Nur Lizenzhinweis erforderlich. + +2. **Apache-2.0-Lizenz**: Erlaubt kommerzielle Nutzung, Modifikation, Distribution. Patentgewährung enthalten. + +3. **BSD-3-Clause**: Erlaubt kommerzielle Nutzung, Modifikation, Distribution. Nur Lizenzhinweis erforderlich. + +### Keine Copyleft-Lizenzen + +Es werden **keine** Copyleft-Lizenzen (GPL, AGPL, LGPL) verwendet, die eine Offenlegung des eigenen Quellcodes erfordern würden. + +### Empfohlene Maßnahmen + +1. **NOTICE-Datei pflegen**: Alle Lizenztexte in einer NOTICE-Datei zusammenfassen +2. **Regelmäßige Updates**: Abhängigkeiten auf bekannte Schwachstellen prüfen +3. **License-Scanner**: Tool wie `go-licenses` oder `fossa` für automatisierte Prüfung + +--- + +## Generierung des SBOM + +```bash +# SBOM im SPDX-Format generieren +go install github.com/spdx/tools-golang/cmd/spdx-tvwriter@latest +go mod download +# Manuell: SPDX-Dokument erstellen + +# Alternativ: CycloneDX Format +go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@latest +cyclonedx-gomod mod -output sbom.json + +# Lizenz-Prüfung +go install github.com/google/go-licenses@latest +go-licenses csv github.com/breakpilot/ai-compliance-sdk/... +``` + +--- + +*Dokumentationsstand: 2026-01-29* diff --git a/ai-compliance-sdk/go.mod b/ai-compliance-sdk/go.mod new file mode 100644 index 0000000..abdc340 --- /dev/null +++ b/ai-compliance-sdk/go.mod @@ -0,0 +1,65 @@ +module github.com/breakpilot/ai-compliance-sdk + +go 1.24.0 + +require ( + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.10.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.5.3 + github.com/joho/godotenv v1.5.1 + github.com/xuri/excelize/v2 v2.9.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/boombuler/barcode v1.0.1 // indirect + github.com/bytedance/sonic v1.13.3 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/f-amaral/go-async v0.3.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/tiff v1.0.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/johnfercher/go-tree v1.0.5 // indirect + github.com/johnfercher/maroto/v2 v2.3.3 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/jung-kurt/gofpdf v1.16.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pdfcpu/pdfcpu v0.6.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/phpdave11/gofpdf v1.4.3 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.4 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/tiendc/go-deepcopy v1.7.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + golang.org/x/arch v0.18.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/image v0.25.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/ai-compliance-sdk/go.sum b/ai-compliance-sdk/go.sum new file mode 100644 index 0000000..97cfab3 --- /dev/null +++ b/ai-compliance-sdk/go.sum @@ -0,0 +1,157 @@ +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= +github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/f-amaral/go-async v0.3.0 h1:h4kLsX7aKfdWaHvV0lf+/EE3OIeCzyeDYJDb/vDZUyg= +github.com/f-amaral/go-async v0.3.0/go.mod h1:Hz5Qr6DAWpbTTUjytnrg1WIsDgS7NtOei5y8SipYS7U= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= +github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s= +github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/johnfercher/go-tree v1.0.5 h1:zpgVhJsChavzhKdxhQiCJJzcSY3VCT9oal2JoA2ZevY= +github.com/johnfercher/go-tree v1.0.5/go.mod h1:DUO6QkXIFh1K7jeGBIkLCZaeUgnkdQAsB64FDSoHswg= +github.com/johnfercher/maroto/v2 v2.3.3 h1:oeXsBnoecaMgRDwN0Cstjoe4rug3lKpOanuxuHKPqQE= +github.com/johnfercher/maroto/v2 v2.3.3/go.mod h1:KNv102TwUrlVgZGukzlIbhkG6l/WaCD6pzu6aWGVjBI= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI= +github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/phpdave11/gofpdf v1.4.3 h1:M/zHvS8FO3zh9tUd2RCOPEjyuVcs281FCyF22Qlz/IA= +github.com/phpdave11/gofpdf v1.4.3/go.mod h1:MAwzoUIgD3J55u0rxIG2eu37c+XWhBtXSpPAhnQXf/o= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00= +github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4= +github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw= +github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= +golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/ai-compliance-sdk/internal/academy/models.go b/ai-compliance-sdk/internal/academy/models.go new file mode 100644 index 0000000..74b2214 --- /dev/null +++ b/ai-compliance-sdk/internal/academy/models.go @@ -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"` +} diff --git a/ai-compliance-sdk/internal/academy/store.go b/ai-compliance-sdk/internal/academy/store.go new file mode 100644 index 0000000..90ecf50 --- /dev/null +++ b/ai-compliance-sdk/internal/academy/store.go @@ -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 +} diff --git a/ai-compliance-sdk/internal/api/handlers/academy_handlers.go b/ai-compliance-sdk/internal/api/handlers/academy_handlers.go new file mode 100644 index 0000000..2e99fae --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/academy_handlers.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/api/handlers/audit_handlers.go b/ai-compliance-sdk/internal/api/handlers/audit_handlers.go new file mode 100644 index 0000000..e97444b --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/audit_handlers.go @@ -0,0 +1,445 @@ +package handlers + +import ( + "bytes" + "fmt" + "net/http" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/audit" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// AuditHandlers handles audit-related API endpoints +type AuditHandlers struct { + store *audit.Store + exporter *audit.Exporter +} + +// NewAuditHandlers creates new audit handlers +func NewAuditHandlers(store *audit.Store, exporter *audit.Exporter) *AuditHandlers { + return &AuditHandlers{ + store: store, + exporter: exporter, + } +} + +// QueryLLMAudit queries LLM audit entries +func (h *AuditHandlers) QueryLLMAudit(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + filter := &audit.LLMAuditFilter{ + TenantID: tenantID, + Limit: 50, + Offset: 0, + } + + // Parse query parameters + if nsID := c.Query("namespace_id"); nsID != "" { + if id, err := uuid.Parse(nsID); err == nil { + filter.NamespaceID = &id + } + } + + if userID := c.Query("user_id"); userID != "" { + if id, err := uuid.Parse(userID); err == nil { + filter.UserID = &id + } + } + + if op := c.Query("operation"); op != "" { + filter.Operation = op + } + + if model := c.Query("model"); model != "" { + filter.Model = model + } + + if pii := c.Query("pii_detected"); pii != "" { + val := pii == "true" + filter.PIIDetected = &val + } + + if violations := c.Query("has_violations"); violations == "true" { + val := true + filter.HasViolations = &val + } + + if startDate := c.Query("start_date"); startDate != "" { + if t, err := time.Parse(time.RFC3339, startDate); err == nil { + filter.StartDate = &t + } + } + + if endDate := c.Query("end_date"); endDate != "" { + if t, err := time.Parse(time.RFC3339, endDate); err == nil { + filter.EndDate = &t + } + } + + if limit := c.Query("limit"); limit != "" { + var l int + if _, err := parseIntQuery(limit, &l); err == nil && l > 0 && l <= 1000 { + filter.Limit = l + } + } + + if offset := c.Query("offset"); offset != "" { + var o int + if _, err := parseIntQuery(offset, &o); err == nil && o >= 0 { + filter.Offset = o + } + } + + entries, total, err := h.store.QueryLLMAuditEntries(c.Request.Context(), filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "entries": entries, + "total": total, + "limit": filter.Limit, + "offset": filter.Offset, + }) +} + +// QueryGeneralAudit queries general audit entries +func (h *AuditHandlers) QueryGeneralAudit(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + filter := &audit.GeneralAuditFilter{ + TenantID: tenantID, + Limit: 50, + Offset: 0, + } + + // Parse query parameters + if nsID := c.Query("namespace_id"); nsID != "" { + if id, err := uuid.Parse(nsID); err == nil { + filter.NamespaceID = &id + } + } + + if userID := c.Query("user_id"); userID != "" { + if id, err := uuid.Parse(userID); err == nil { + filter.UserID = &id + } + } + + if action := c.Query("action"); action != "" { + filter.Action = action + } + + if resourceType := c.Query("resource_type"); resourceType != "" { + filter.ResourceType = resourceType + } + + if resourceID := c.Query("resource_id"); resourceID != "" { + if id, err := uuid.Parse(resourceID); err == nil { + filter.ResourceID = &id + } + } + + if startDate := c.Query("start_date"); startDate != "" { + if t, err := time.Parse(time.RFC3339, startDate); err == nil { + filter.StartDate = &t + } + } + + if endDate := c.Query("end_date"); endDate != "" { + if t, err := time.Parse(time.RFC3339, endDate); err == nil { + filter.EndDate = &t + } + } + + if limit := c.Query("limit"); limit != "" { + var l int + if _, err := parseIntQuery(limit, &l); err == nil && l > 0 && l <= 1000 { + filter.Limit = l + } + } + + if offset := c.Query("offset"); offset != "" { + var o int + if _, err := parseIntQuery(offset, &o); err == nil && o >= 0 { + filter.Offset = o + } + } + + entries, total, err := h.store.QueryGeneralAuditEntries(c.Request.Context(), filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "entries": entries, + "total": total, + "limit": filter.Limit, + "offset": filter.Offset, + }) +} + +// GetUsageStats returns LLM usage statistics +func (h *AuditHandlers) GetUsageStats(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + // Default to last 30 days + endDate := time.Now().UTC() + startDate := endDate.AddDate(0, 0, -30) + + if sd := c.Query("start_date"); sd != "" { + if t, err := time.Parse(time.RFC3339, sd); err == nil { + startDate = t + } + } + + if ed := c.Query("end_date"); ed != "" { + if t, err := time.Parse(time.RFC3339, ed); err == nil { + endDate = t + } + } + + stats, err := h.store.GetLLMUsageStats(c.Request.Context(), tenantID, startDate, endDate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "period_start": startDate.Format(time.RFC3339), + "period_end": endDate.Format(time.RFC3339), + "stats": stats, + }) +} + +// ExportLLMAudit exports LLM audit entries +func (h *AuditHandlers) ExportLLMAudit(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + // Default to last 30 days + endDate := time.Now().UTC() + startDate := endDate.AddDate(0, 0, -30) + + if sd := c.Query("start_date"); sd != "" { + if t, err := time.Parse(time.RFC3339, sd); err == nil { + startDate = t + } + } + + if ed := c.Query("end_date"); ed != "" { + if t, err := time.Parse(time.RFC3339, ed); err == nil { + endDate = t + } + } + + format := audit.FormatJSON + if c.Query("format") == "csv" { + format = audit.FormatCSV + } + + includePII := c.Query("include_pii") == "true" + + opts := &audit.ExportOptions{ + TenantID: tenantID, + StartDate: startDate, + EndDate: endDate, + Format: format, + IncludePII: includePII, + } + + if nsID := c.Query("namespace_id"); nsID != "" { + if id, err := uuid.Parse(nsID); err == nil { + opts.NamespaceID = &id + } + } + + if userID := c.Query("user_id"); userID != "" { + if id, err := uuid.Parse(userID); err == nil { + opts.UserID = &id + } + } + + var buf bytes.Buffer + if err := h.exporter.ExportLLMAudit(c.Request.Context(), &buf, opts); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Set appropriate content type + contentType := "application/json" + ext := "json" + if format == audit.FormatCSV { + contentType = "text/csv" + ext = "csv" + } + + filename := "llm_audit_" + time.Now().Format("20060102") + "." + ext + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Data(http.StatusOK, contentType, buf.Bytes()) +} + +// ExportGeneralAudit exports general audit entries +func (h *AuditHandlers) ExportGeneralAudit(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + endDate := time.Now().UTC() + startDate := endDate.AddDate(0, 0, -30) + + if sd := c.Query("start_date"); sd != "" { + if t, err := time.Parse(time.RFC3339, sd); err == nil { + startDate = t + } + } + + if ed := c.Query("end_date"); ed != "" { + if t, err := time.Parse(time.RFC3339, ed); err == nil { + endDate = t + } + } + + format := audit.FormatJSON + if c.Query("format") == "csv" { + format = audit.FormatCSV + } + + includePII := c.Query("include_pii") == "true" + + opts := &audit.ExportOptions{ + TenantID: tenantID, + StartDate: startDate, + EndDate: endDate, + Format: format, + IncludePII: includePII, + } + + var buf bytes.Buffer + if err := h.exporter.ExportGeneralAudit(c.Request.Context(), &buf, opts); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + contentType := "application/json" + ext := "json" + if format == audit.FormatCSV { + contentType = "text/csv" + ext = "csv" + } + + filename := "general_audit_" + time.Now().Format("20060102") + "." + ext + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Data(http.StatusOK, contentType, buf.Bytes()) +} + +// ExportComplianceReport exports a compliance report +func (h *AuditHandlers) ExportComplianceReport(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + endDate := time.Now().UTC() + startDate := endDate.AddDate(0, 0, -30) + + if sd := c.Query("start_date"); sd != "" { + if t, err := time.Parse(time.RFC3339, sd); err == nil { + startDate = t + } + } + + if ed := c.Query("end_date"); ed != "" { + if t, err := time.Parse(time.RFC3339, ed); err == nil { + endDate = t + } + } + + format := audit.FormatJSON + if c.Query("format") == "csv" { + format = audit.FormatCSV + } + + var buf bytes.Buffer + if err := h.exporter.ExportComplianceReport(c.Request.Context(), &buf, tenantID, startDate, endDate, format); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + contentType := "application/json" + ext := "json" + if format == audit.FormatCSV { + contentType = "text/csv" + ext = "csv" + } + + filename := "compliance_report_" + time.Now().Format("20060102") + "." + ext + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Data(http.StatusOK, contentType, buf.Bytes()) +} + +// GetComplianceReport returns a compliance report as JSON (for dashboard) +func (h *AuditHandlers) GetComplianceReport(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + endDate := time.Now().UTC() + startDate := endDate.AddDate(0, 0, -30) + + if sd := c.Query("start_date"); sd != "" { + if t, err := time.Parse(time.RFC3339, sd); err == nil { + startDate = t + } + } + + if ed := c.Query("end_date"); ed != "" { + if t, err := time.Parse(time.RFC3339, ed); err == nil { + endDate = t + } + } + + report, err := h.exporter.GenerateComplianceReport(c.Request.Context(), tenantID, startDate, endDate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, report) +} + +// Helper function to parse int from query +func parseIntQuery(s string, out *int) (int, error) { + var i int + _, err := fmt.Sscanf(s, "%d", &i) + if err != nil { + return 0, err + } + *out = i + return i, nil +} diff --git a/ai-compliance-sdk/internal/api/handlers/drafting_handlers.go b/ai-compliance-sdk/internal/api/handlers/drafting_handlers.go new file mode 100644 index 0000000..bc2fb27 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/drafting_handlers.go @@ -0,0 +1,335 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/audit" + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// DraftingHandlers handles Drafting Engine API endpoints +type DraftingHandlers struct { + accessGate *llm.AccessGate + registry *llm.ProviderRegistry + piiDetector *llm.PIIDetector + auditStore *audit.Store + trailBuilder *audit.TrailBuilder +} + +// NewDraftingHandlers creates new Drafting Engine handlers +func NewDraftingHandlers( + accessGate *llm.AccessGate, + registry *llm.ProviderRegistry, + piiDetector *llm.PIIDetector, + auditStore *audit.Store, + trailBuilder *audit.TrailBuilder, +) *DraftingHandlers { + return &DraftingHandlers{ + accessGate: accessGate, + registry: registry, + piiDetector: piiDetector, + auditStore: auditStore, + trailBuilder: trailBuilder, + } +} + +// --------------------------------------------------------------------------- +// Request/Response Types +// --------------------------------------------------------------------------- + +// DraftDocumentRequest represents a request to generate a compliance document draft +type DraftDocumentRequest struct { + DocumentType string `json:"document_type" binding:"required"` + ScopeLevel string `json:"scope_level" binding:"required"` + Context map[string]interface{} `json:"context"` + Instructions string `json:"instructions"` + Model string `json:"model"` +} + +// ValidateDocumentRequest represents a request to validate document consistency +type ValidateDocumentRequest struct { + DocumentType string `json:"document_type" binding:"required"` + DraftContent string `json:"draft_content"` + ValidationContext map[string]interface{} `json:"validation_context"` + Model string `json:"model"` +} + +// DraftHistoryEntry represents a single audit trail entry for drafts +type DraftHistoryEntry struct { + ID string `json:"id"` + UserID string `json:"user_id"` + TenantID string `json:"tenant_id"` + DocumentType string `json:"document_type"` + ScopeLevel string `json:"scope_level"` + Operation string `json:"operation"` + ConstraintsRespected bool `json:"constraints_respected"` + TokensUsed int `json:"tokens_used"` + CreatedAt time.Time `json:"created_at"` +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// DraftDocument handles document draft generation via LLM with constraint validation +func (h *DraftingHandlers) DraftDocument(c *gin.Context) { + var req DraftDocumentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + namespaceID := rbac.GetNamespaceID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + // Validate scope level + validLevels := map[string]bool{"L1": true, "L2": true, "L3": true, "L4": true} + if !validLevels[req.ScopeLevel] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid scope_level, must be L1-L4"}) + return + } + + // Validate document type + validTypes := map[string]bool{ + "vvt": true, "tom": true, "dsfa": true, "dsi": true, "lf": true, + "av_vertrag": true, "betroffenenrechte": true, "einwilligung": true, + "daten_transfer": true, "datenpannen": true, "vertragsmanagement": true, + "schulung": true, "audit_log": true, "risikoanalyse": true, + "notfallplan": true, "zertifizierung": true, "datenschutzmanagement": true, + } + if !validTypes[req.DocumentType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document_type"}) + return + } + + // Build system prompt for drafting + systemPrompt := fmt.Sprintf( + `Du bist ein DSGVO-Compliance-Experte. Erstelle einen strukturierten Entwurf fuer Dokument "%s" auf Level %s. +Antworte NUR im JSON-Format mit einem "sections" Array. +Jede Section hat: id, title, content, schemaField. +Halte die Tiefe strikt am vorgegebenen Level. +Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung]. +Sprache: Deutsch.`, + req.DocumentType, req.ScopeLevel, + ) + + userPrompt := "Erstelle den Dokumententwurf." + if req.Instructions != "" { + userPrompt = req.Instructions + } + + // Detect PII in context + contextStr := fmt.Sprintf("%v", req.Context) + dataCategories := h.piiDetector.DetectDataCategories(contextStr) + + // Process through access gate + chatReq := &llm.ChatRequest{ + Model: req.Model, + Messages: []llm.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + }, + MaxTokens: 16384, + Temperature: 0.15, + } + + gatedReq, err := h.accessGate.ProcessChatRequest( + c.Request.Context(), + userID, tenantID, namespaceID, + chatReq, dataCategories, + ) + if err != nil { + h.logDraftAudit(c, userID, tenantID, req.DocumentType, req.ScopeLevel, "draft", false, 0, err.Error()) + c.JSON(http.StatusForbidden, gin.H{ + "error": "access_denied", + "message": err.Error(), + }) + return + } + + // Execute the request + resp, err := h.accessGate.ExecuteChat(c.Request.Context(), gatedReq) + if err != nil { + h.logDraftAudit(c, userID, tenantID, req.DocumentType, req.ScopeLevel, "draft", false, 0, err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "llm_error", + "message": err.Error(), + }) + return + } + + tokensUsed := 0 + if resp.Usage.TotalTokens > 0 { + tokensUsed = resp.Usage.TotalTokens + } + + // Log successful draft + h.logDraftAudit(c, userID, tenantID, req.DocumentType, req.ScopeLevel, "draft", true, tokensUsed, "") + + c.JSON(http.StatusOK, gin.H{ + "document_type": req.DocumentType, + "scope_level": req.ScopeLevel, + "content": resp.Message.Content, + "model": resp.Model, + "provider": resp.Provider, + "tokens_used": tokensUsed, + "pii_detected": gatedReq.PIIDetected, + }) +} + +// ValidateDocument handles document cross-consistency validation +func (h *DraftingHandlers) ValidateDocument(c *gin.Context) { + var req ValidateDocumentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + namespaceID := rbac.GetNamespaceID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + // Build validation prompt + systemPrompt := `Du bist ein DSGVO-Compliance-Validator. Pruefe die Konsistenz und Vollstaendigkeit. +Antworte NUR im JSON-Format: +{ + "passed": boolean, + "errors": [{"id": string, "severity": "error", "title": string, "description": string, "documentType": string, "legalReference": string}], + "warnings": [{"id": string, "severity": "warning", "title": string, "description": string}], + "suggestions": [{"id": string, "severity": "suggestion", "title": string, "description": string, "suggestion": string}] +}` + + validationPrompt := fmt.Sprintf("Validiere Dokument '%s'.\nInhalt:\n%s\nKontext:\n%v", + req.DocumentType, req.DraftContent, req.ValidationContext) + + // Detect PII + dataCategories := h.piiDetector.DetectDataCategories(req.DraftContent) + + chatReq := &llm.ChatRequest{ + Model: req.Model, + Messages: []llm.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: validationPrompt}, + }, + MaxTokens: 8192, + Temperature: 0.1, + } + + gatedReq, err := h.accessGate.ProcessChatRequest( + c.Request.Context(), + userID, tenantID, namespaceID, + chatReq, dataCategories, + ) + if err != nil { + h.logDraftAudit(c, userID, tenantID, req.DocumentType, "", "validate", false, 0, err.Error()) + c.JSON(http.StatusForbidden, gin.H{ + "error": "access_denied", + "message": err.Error(), + }) + return + } + + resp, err := h.accessGate.ExecuteChat(c.Request.Context(), gatedReq) + if err != nil { + h.logDraftAudit(c, userID, tenantID, req.DocumentType, "", "validate", false, 0, err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "llm_error", + "message": err.Error(), + }) + return + } + + tokensUsed := 0 + if resp.Usage.TotalTokens > 0 { + tokensUsed = resp.Usage.TotalTokens + } + + h.logDraftAudit(c, userID, tenantID, req.DocumentType, "", "validate", true, tokensUsed, "") + + c.JSON(http.StatusOK, gin.H{ + "document_type": req.DocumentType, + "validation": resp.Message.Content, + "model": resp.Model, + "provider": resp.Provider, + "tokens_used": tokensUsed, + "pii_detected": gatedReq.PIIDetected, + }) +} + +// GetDraftHistory returns the audit trail of all drafting operations for a tenant +func (h *DraftingHandlers) GetDraftHistory(c *gin.Context) { + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + // Query audit store for drafting operations + entries, _, err := h.auditStore.QueryGeneralAuditEntries(c.Request.Context(), &audit.GeneralAuditFilter{ + TenantID: tenantID, + ResourceType: "compliance_document", + Limit: 50, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query draft history"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "history": entries, + "total": len(entries), + }) +} + +// --------------------------------------------------------------------------- +// Audit Logging +// --------------------------------------------------------------------------- + +func (h *DraftingHandlers) logDraftAudit( + c *gin.Context, + userID, tenantID uuid.UUID, + documentType, scopeLevel, operation string, + constraintsRespected bool, + tokensUsed int, + errorMsg string, +) { + newValues := map[string]any{ + "document_type": documentType, + "scope_level": scopeLevel, + "constraints_respected": constraintsRespected, + "tokens_used": tokensUsed, + } + if errorMsg != "" { + newValues["error"] = errorMsg + } + + entry := h.trailBuilder.NewGeneralEntry(). + WithTenant(tenantID). + WithUser(userID). + WithAction("drafting_engine." + operation). + WithResource("compliance_document", nil). + WithNewValues(newValues). + WithClient(c.ClientIP(), c.GetHeader("User-Agent")) + + go func() { + entry.Save(c.Request.Context()) + }() +} diff --git a/ai-compliance-sdk/internal/api/handlers/dsgvo_handlers.go b/ai-compliance-sdk/internal/api/handlers/dsgvo_handlers.go new file mode 100644 index 0000000..7512a3d --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/dsgvo_handlers.go @@ -0,0 +1,779 @@ +package handlers + +import ( + "bytes" + "fmt" + "net/http" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/dsgvo" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// DSGVOHandlers handles DSGVO-related API endpoints +type DSGVOHandlers struct { + store *dsgvo.Store +} + +// NewDSGVOHandlers creates new DSGVO handlers +func NewDSGVOHandlers(store *dsgvo.Store) *DSGVOHandlers { + return &DSGVOHandlers{store: store} +} + +// ============================================================================ +// VVT - Verarbeitungsverzeichnis (Processing Activities) +// ============================================================================ + +// ListProcessingActivities returns all processing activities for a tenant +func (h *DSGVOHandlers) ListProcessingActivities(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var namespaceID *uuid.UUID + if nsID := c.Query("namespace_id"); nsID != "" { + if id, err := uuid.Parse(nsID); err == nil { + namespaceID = &id + } + } + + activities, err := h.store.ListProcessingActivities(c.Request.Context(), tenantID, namespaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"processing_activities": activities}) +} + +// GetProcessingActivity returns a processing activity by ID +func (h *DSGVOHandlers) GetProcessingActivity(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + activity, err := h.store.GetProcessingActivity(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if activity == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + c.JSON(http.StatusOK, activity) +} + +// CreateProcessingActivity creates a new processing activity +func (h *DSGVOHandlers) CreateProcessingActivity(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var activity dsgvo.ProcessingActivity + if err := c.ShouldBindJSON(&activity); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + activity.TenantID = tenantID + activity.CreatedBy = userID + if activity.Status == "" { + activity.Status = "draft" + } + + if err := h.store.CreateProcessingActivity(c.Request.Context(), &activity); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, activity) +} + +// UpdateProcessingActivity updates a processing activity +func (h *DSGVOHandlers) UpdateProcessingActivity(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + var activity dsgvo.ProcessingActivity + if err := c.ShouldBindJSON(&activity); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + activity.ID = id + if err := h.store.UpdateProcessingActivity(c.Request.Context(), &activity); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, activity) +} + +// DeleteProcessingActivity deletes a processing activity +func (h *DSGVOHandlers) DeleteProcessingActivity(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + if err := h.store.DeleteProcessingActivity(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// ============================================================================ +// TOM - Technische und Organisatorische Maßnahmen +// ============================================================================ + +// ListTOMs returns all TOMs for a tenant +func (h *DSGVOHandlers) ListTOMs(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + category := c.Query("category") + + toms, err := h.store.ListTOMs(c.Request.Context(), tenantID, category) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"toms": toms, "categories": dsgvo.TOMCategories}) +} + +// GetTOM returns a TOM by ID +func (h *DSGVOHandlers) GetTOM(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + tom, err := h.store.GetTOM(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if tom == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + c.JSON(http.StatusOK, tom) +} + +// CreateTOM creates a new TOM +func (h *DSGVOHandlers) CreateTOM(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var tom dsgvo.TOM + if err := c.ShouldBindJSON(&tom); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tom.TenantID = tenantID + tom.CreatedBy = userID + if tom.ImplementationStatus == "" { + tom.ImplementationStatus = "planned" + } + + if err := h.store.CreateTOM(c.Request.Context(), &tom); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, tom) +} + +// ============================================================================ +// DSR - Data Subject Requests +// ============================================================================ + +// ListDSRs returns all DSRs for a tenant +func (h *DSGVOHandlers) ListDSRs(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + status := c.Query("status") + requestType := c.Query("type") + + dsrs, err := h.store.ListDSRs(c.Request.Context(), tenantID, status, requestType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"dsrs": dsrs, "types": dsgvo.DSRTypes}) +} + +// GetDSR returns a DSR by ID +func (h *DSGVOHandlers) GetDSR(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + dsr, err := h.store.GetDSR(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if dsr == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + c.JSON(http.StatusOK, dsr) +} + +// CreateDSR creates a new DSR +func (h *DSGVOHandlers) CreateDSR(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var dsr dsgvo.DSR + if err := c.ShouldBindJSON(&dsr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + dsr.TenantID = tenantID + dsr.CreatedBy = userID + if dsr.Status == "" { + dsr.Status = "received" + } + + if err := h.store.CreateDSR(c.Request.Context(), &dsr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, dsr) +} + +// UpdateDSR updates a DSR +func (h *DSGVOHandlers) UpdateDSR(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + var dsr dsgvo.DSR + if err := c.ShouldBindJSON(&dsr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + dsr.ID = id + if err := h.store.UpdateDSR(c.Request.Context(), &dsr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, dsr) +} + +// ============================================================================ +// Retention Policies +// ============================================================================ + +// ListRetentionPolicies returns all retention policies for a tenant +func (h *DSGVOHandlers) ListRetentionPolicies(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + policies, err := h.store.ListRetentionPolicies(c.Request.Context(), tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "policies": policies, + "common_periods": dsgvo.CommonRetentionPeriods, + }) +} + +// CreateRetentionPolicy creates a new retention policy +func (h *DSGVOHandlers) CreateRetentionPolicy(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var policy dsgvo.RetentionPolicy + if err := c.ShouldBindJSON(&policy); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + policy.TenantID = tenantID + policy.CreatedBy = userID + if policy.Status == "" { + policy.Status = "draft" + } + + if err := h.store.CreateRetentionPolicy(c.Request.Context(), &policy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, policy) +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// GetStats returns DSGVO statistics for a tenant +func (h *DSGVOHandlers) GetStats(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + stats, err := h.store.GetStats(c.Request.Context(), tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// ============================================================================ +// DSFA - Datenschutz-Folgenabschätzung +// ============================================================================ + +// ListDSFAs returns all DSFAs for a tenant +func (h *DSGVOHandlers) ListDSFAs(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + status := c.Query("status") + + dsfas, err := h.store.ListDSFAs(c.Request.Context(), tenantID, status) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"dsfas": dsfas}) +} + +// GetDSFA returns a DSFA by ID +func (h *DSGVOHandlers) GetDSFA(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + dsfa, err := h.store.GetDSFA(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if dsfa == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + c.JSON(http.StatusOK, dsfa) +} + +// CreateDSFA creates a new DSFA +func (h *DSGVOHandlers) CreateDSFA(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var dsfa dsgvo.DSFA + if err := c.ShouldBindJSON(&dsfa); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + dsfa.TenantID = tenantID + dsfa.CreatedBy = userID + if dsfa.Status == "" { + dsfa.Status = "draft" + } + + if err := h.store.CreateDSFA(c.Request.Context(), &dsfa); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, dsfa) +} + +// UpdateDSFA updates a DSFA +func (h *DSGVOHandlers) UpdateDSFA(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + var dsfa dsgvo.DSFA + if err := c.ShouldBindJSON(&dsfa); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + dsfa.ID = id + if err := h.store.UpdateDSFA(c.Request.Context(), &dsfa); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, dsfa) +} + +// DeleteDSFA deletes a DSFA +func (h *DSGVOHandlers) DeleteDSFA(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + if err := h.store.DeleteDSFA(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// ============================================================================ +// PDF Export +// ============================================================================ + +// ExportVVT exports the Verarbeitungsverzeichnis as CSV/JSON +func (h *DSGVOHandlers) ExportVVT(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + format := c.DefaultQuery("format", "csv") + + activities, err := h.store.ListProcessingActivities(c.Request.Context(), tenantID, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if format == "json" { + c.Header("Content-Disposition", "attachment; filename=vvt_export.json") + c.JSON(http.StatusOK, gin.H{ + "exported_at": time.Now().UTC().Format(time.RFC3339), + "processing_activities": activities, + }) + return + } + + // CSV Export + var buf bytes.Buffer + buf.WriteString("ID;Name;Zweck;Rechtsgrundlage;Datenkategorien;Betroffene;Empfänger;Drittland;Aufbewahrung;Verantwortlich;Status;Erstellt\n") + + for _, pa := range activities { + buf.WriteString(fmt.Sprintf("%s;%s;%s;%s;%s;%s;%s;%t;%s;%s;%s;%s\n", + pa.ID.String(), + escapeCSV(pa.Name), + escapeCSV(pa.Purpose), + pa.LegalBasis, + joinStrings(pa.DataCategories), + joinStrings(pa.DataSubjectCategories), + joinStrings(pa.Recipients), + pa.ThirdCountryTransfer, + pa.RetentionPeriod, + escapeCSV(pa.ResponsiblePerson), + pa.Status, + pa.CreatedAt.Format("2006-01-02"), + )) + } + + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=vvt_export.csv") + c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes()) +} + +// ExportTOM exports the TOM catalog as CSV/JSON +func (h *DSGVOHandlers) ExportTOM(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + format := c.DefaultQuery("format", "csv") + + toms, err := h.store.ListTOMs(c.Request.Context(), tenantID, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if format == "json" { + c.Header("Content-Disposition", "attachment; filename=tom_export.json") + c.JSON(http.StatusOK, gin.H{ + "exported_at": time.Now().UTC().Format(time.RFC3339), + "toms": toms, + "categories": dsgvo.TOMCategories, + }) + return + } + + // CSV Export + var buf bytes.Buffer + buf.WriteString("ID;Kategorie;Name;Beschreibung;Typ;Status;Implementiert am;Verantwortlich;Wirksamkeit;Erstellt\n") + + for _, tom := range toms { + implementedAt := "" + if tom.ImplementedAt != nil { + implementedAt = tom.ImplementedAt.Format("2006-01-02") + } + buf.WriteString(fmt.Sprintf("%s;%s;%s;%s;%s;%s;%s;%s;%s;%s\n", + tom.ID.String(), + tom.Category, + escapeCSV(tom.Name), + escapeCSV(tom.Description), + tom.Type, + tom.ImplementationStatus, + implementedAt, + escapeCSV(tom.ResponsiblePerson), + tom.EffectivenessRating, + tom.CreatedAt.Format("2006-01-02"), + )) + } + + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=tom_export.csv") + c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes()) +} + +// ExportDSR exports DSR overview as CSV/JSON +func (h *DSGVOHandlers) ExportDSR(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + format := c.DefaultQuery("format", "csv") + + dsrs, err := h.store.ListDSRs(c.Request.Context(), tenantID, "", "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if format == "json" { + c.Header("Content-Disposition", "attachment; filename=dsr_export.json") + c.JSON(http.StatusOK, gin.H{ + "exported_at": time.Now().UTC().Format(time.RFC3339), + "dsrs": dsrs, + "types": dsgvo.DSRTypes, + }) + return + } + + // CSV Export + var buf bytes.Buffer + buf.WriteString("ID;Typ;Name;E-Mail;Status;Eingegangen;Frist;Abgeschlossen;Kanal;Zugewiesen\n") + + for _, dsr := range dsrs { + completedAt := "" + if dsr.CompletedAt != nil { + completedAt = dsr.CompletedAt.Format("2006-01-02") + } + assignedTo := "" + if dsr.AssignedTo != nil { + assignedTo = dsr.AssignedTo.String() + } + buf.WriteString(fmt.Sprintf("%s;%s;%s;%s;%s;%s;%s;%s;%s;%s\n", + dsr.ID.String(), + dsr.RequestType, + escapeCSV(dsr.SubjectName), + dsr.SubjectEmail, + dsr.Status, + dsr.ReceivedAt.Format("2006-01-02"), + dsr.DeadlineAt.Format("2006-01-02"), + completedAt, + dsr.RequestChannel, + assignedTo, + )) + } + + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=dsr_export.csv") + c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes()) +} + +// ExportDSFA exports a DSFA as JSON +func (h *DSGVOHandlers) ExportDSFA(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + dsfa, err := h.store.GetDSFA(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if dsfa == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=dsfa_%s.json", id.String()[:8])) + c.JSON(http.StatusOK, gin.H{ + "exported_at": time.Now().UTC().Format(time.RFC3339), + "dsfa": dsfa, + }) +} + +// ExportRetentionPolicies exports retention policies as CSV/JSON +func (h *DSGVOHandlers) ExportRetentionPolicies(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + format := c.DefaultQuery("format", "csv") + + policies, err := h.store.ListRetentionPolicies(c.Request.Context(), tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if format == "json" { + c.Header("Content-Disposition", "attachment; filename=retention_policies_export.json") + c.JSON(http.StatusOK, gin.H{ + "exported_at": time.Now().UTC().Format(time.RFC3339), + "policies": policies, + "common_periods": dsgvo.CommonRetentionPeriods, + }) + return + } + + // CSV Export + var buf bytes.Buffer + buf.WriteString("ID;Name;Datenkategorie;Aufbewahrungsdauer (Tage);Dauer (Text);Rechtsgrundlage;Referenz;Löschmethode;Status\n") + + for _, rp := range policies { + buf.WriteString(fmt.Sprintf("%s;%s;%s;%d;%s;%s;%s;%s;%s\n", + rp.ID.String(), + escapeCSV(rp.Name), + rp.DataCategory, + rp.RetentionPeriodDays, + escapeCSV(rp.RetentionPeriodText), + escapeCSV(rp.LegalBasis), + escapeCSV(rp.LegalReference), + rp.DeletionMethod, + rp.Status, + )) + } + + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=retention_policies_export.csv") + c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes()) +} + +// Helper functions + +func escapeCSV(s string) string { + // Simple CSV escaping - wrap in quotes if contains semicolon, quote, or newline + if s == "" { + return "" + } + needsQuotes := false + for _, c := range s { + if c == ';' || c == '"' || c == '\n' || c == '\r' { + needsQuotes = true + break + } + } + if needsQuotes { + // Double any quotes and wrap in quotes + escaped := "" + for _, c := range s { + if c == '"' { + escaped += "\"\"" + } else if c == '\n' || c == '\r' { + escaped += " " + } else { + escaped += string(c) + } + } + return "\"" + escaped + "\"" + } + return s +} + +func joinStrings(strs []string) string { + if len(strs) == 0 { + return "" + } + result := strs[0] + for i := 1; i < len(strs); i++ { + result += ", " + strs[i] + } + return result +} diff --git a/ai-compliance-sdk/internal/api/handlers/escalation_handlers.go b/ai-compliance-sdk/internal/api/handlers/escalation_handlers.go new file mode 100644 index 0000000..e864133 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/escalation_handlers.go @@ -0,0 +1,421 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/breakpilot/ai-compliance-sdk/internal/ucca" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// EscalationHandlers handles escalation-related API endpoints. +type EscalationHandlers struct { + store *ucca.EscalationStore + assessmentStore *ucca.Store + trigger *ucca.EscalationTrigger +} + +// NewEscalationHandlers creates new escalation handlers. +func NewEscalationHandlers(store *ucca.EscalationStore, assessmentStore *ucca.Store) *EscalationHandlers { + return &EscalationHandlers{ + store: store, + assessmentStore: assessmentStore, + trigger: ucca.DefaultEscalationTrigger(), + } +} + +// ============================================================================ +// GET /sdk/v1/ucca/escalations - List escalations +// ============================================================================ + +// ListEscalations returns escalations for a tenant with optional filters. +func (h *EscalationHandlers) ListEscalations(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + status := c.Query("status") + level := c.Query("level") + + var assignedTo *uuid.UUID + if assignedToStr := c.Query("assigned_to"); assignedToStr != "" { + if id, err := uuid.Parse(assignedToStr); err == nil { + assignedTo = &id + } + } + + // If user is a reviewer, filter to their assignments by default + userID := rbac.GetUserID(c) + if c.Query("my_reviews") == "true" && userID != uuid.Nil { + assignedTo = &userID + } + + escalations, err := h.store.ListEscalations(c.Request.Context(), tenantID, status, level, assignedTo) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"escalations": escalations}) +} + +// ============================================================================ +// GET /sdk/v1/ucca/escalations/:id - Get single escalation +// ============================================================================ + +// GetEscalation returns a single escalation by ID. +func (h *EscalationHandlers) GetEscalation(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + escalation, err := h.store.GetEscalation(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if escalation == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + // Get history + history, _ := h.store.GetEscalationHistory(c.Request.Context(), id) + + c.JSON(http.StatusOK, gin.H{ + "escalation": escalation, + "history": history, + }) +} + +// ============================================================================ +// POST /sdk/v1/ucca/escalations - Create escalation (manual) +// ============================================================================ + +// CreateEscalation creates a manual escalation for an assessment. +func (h *EscalationHandlers) CreateEscalation(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var req ucca.CreateEscalationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get the assessment + assessment, err := h.assessmentStore.GetAssessment(c.Request.Context(), req.AssessmentID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if assessment == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "assessment not found"}) + return + } + + // Determine escalation level + result := &ucca.AssessmentResult{ + Feasibility: assessment.Feasibility, + RiskLevel: assessment.RiskLevel, + RiskScore: assessment.RiskScore, + TriggeredRules: assessment.TriggeredRules, + DSFARecommended: assessment.DSFARecommended, + Art22Risk: assessment.Art22Risk, + } + level, reason := h.trigger.DetermineEscalationLevel(result) + + // Calculate due date based on SLA + responseHours, _ := ucca.GetDefaultSLA(level) + var dueDate *time.Time + if responseHours > 0 { + due := time.Now().UTC().Add(time.Duration(responseHours) * time.Hour) + dueDate = &due + } + + // Create escalation + escalation := &ucca.Escalation{ + TenantID: tenantID, + AssessmentID: req.AssessmentID, + EscalationLevel: level, + EscalationReason: reason, + Status: ucca.EscalationStatusPending, + DueDate: dueDate, + } + + // For E0, auto-approve + if level == ucca.EscalationLevelE0 { + escalation.Status = ucca.EscalationStatusApproved + approveDecision := ucca.EscalationDecisionApprove + escalation.Decision = &approveDecision + now := time.Now().UTC() + escalation.DecisionAt = &now + autoNotes := "Automatische Freigabe (E0)" + escalation.DecisionNotes = &autoNotes + } + + if err := h.store.CreateEscalation(c.Request.Context(), escalation); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Add history entry + h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{ + EscalationID: escalation.ID, + Action: "created", + NewStatus: string(escalation.Status), + NewLevel: string(escalation.EscalationLevel), + ActorID: userID, + Notes: reason, + }) + + // For E1/E2/E3, try to auto-assign + if level != ucca.EscalationLevelE0 { + role := ucca.GetRoleForLevel(level) + reviewer, err := h.store.GetNextAvailableReviewer(c.Request.Context(), tenantID, role) + if err == nil && reviewer != nil { + h.store.AssignEscalation(c.Request.Context(), escalation.ID, reviewer.UserID, role) + h.store.IncrementReviewerCount(c.Request.Context(), reviewer.UserID) + h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{ + EscalationID: escalation.ID, + Action: "auto_assigned", + OldStatus: string(ucca.EscalationStatusPending), + NewStatus: string(ucca.EscalationStatusAssigned), + ActorID: userID, + Notes: "Automatisch zugewiesen an: " + reviewer.UserName, + }) + } + } + + c.JSON(http.StatusCreated, escalation) +} + +// ============================================================================ +// POST /sdk/v1/ucca/escalations/:id/assign - Assign escalation +// ============================================================================ + +// AssignEscalation assigns an escalation to a reviewer. +func (h *EscalationHandlers) AssignEscalation(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + userID := rbac.GetUserID(c) + + var req ucca.AssignEscalationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + escalation, err := h.store.GetEscalation(c.Request.Context(), id) + if err != nil || escalation == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "escalation not found"}) + return + } + + role := ucca.GetRoleForLevel(escalation.EscalationLevel) + + if err := h.store.AssignEscalation(c.Request.Context(), id, req.AssignedTo, role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + h.store.IncrementReviewerCount(c.Request.Context(), req.AssignedTo) + h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{ + EscalationID: id, + Action: "assigned", + OldStatus: string(escalation.Status), + NewStatus: string(ucca.EscalationStatusAssigned), + ActorID: userID, + }) + + c.JSON(http.StatusOK, gin.H{"message": "assigned"}) +} + +// ============================================================================ +// POST /sdk/v1/ucca/escalations/:id/review - Start review +// ============================================================================ + +// StartReview marks an escalation as being reviewed. +func (h *EscalationHandlers) StartReview(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + userID := rbac.GetUserID(c) + if userID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "user ID required"}) + return + } + + escalation, err := h.store.GetEscalation(c.Request.Context(), id) + if err != nil || escalation == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "escalation not found"}) + return + } + + if err := h.store.StartReview(c.Request.Context(), id, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{ + EscalationID: id, + Action: "review_started", + OldStatus: string(escalation.Status), + NewStatus: string(ucca.EscalationStatusInReview), + ActorID: userID, + }) + + c.JSON(http.StatusOK, gin.H{"message": "review started"}) +} + +// ============================================================================ +// POST /sdk/v1/ucca/escalations/:id/decide - Make decision +// ============================================================================ + +// DecideEscalation makes a decision on an escalation. +func (h *EscalationHandlers) DecideEscalation(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + userID := rbac.GetUserID(c) + if userID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "user ID required"}) + return + } + + var req ucca.DecideEscalationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + escalation, err := h.store.GetEscalation(c.Request.Context(), id) + if err != nil || escalation == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "escalation not found"}) + return + } + + if err := h.store.DecideEscalation(c.Request.Context(), id, req.Decision, req.DecisionNotes, req.Conditions); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Decrement reviewer count + if escalation.AssignedTo != nil { + h.store.DecrementReviewerCount(c.Request.Context(), *escalation.AssignedTo) + } + + newStatus := "decided" + switch req.Decision { + case ucca.EscalationDecisionApprove: + newStatus = string(ucca.EscalationStatusApproved) + case ucca.EscalationDecisionReject: + newStatus = string(ucca.EscalationStatusRejected) + case ucca.EscalationDecisionModify: + newStatus = string(ucca.EscalationStatusReturned) + case ucca.EscalationDecisionEscalate: + newStatus = "escalated" + } + + h.store.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{ + EscalationID: id, + Action: "decision_made", + OldStatus: string(escalation.Status), + NewStatus: newStatus, + ActorID: userID, + Notes: req.DecisionNotes, + }) + + c.JSON(http.StatusOK, gin.H{"message": "decision recorded", "status": newStatus}) +} + +// ============================================================================ +// GET /sdk/v1/ucca/escalations/stats - Get statistics +// ============================================================================ + +// GetEscalationStats returns escalation statistics. +func (h *EscalationHandlers) GetEscalationStats(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + stats, err := h.store.GetEscalationStats(c.Request.Context(), tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// ============================================================================ +// DSB Pool Management +// ============================================================================ + +// ListDSBPool returns the DSB review pool for a tenant. +func (h *EscalationHandlers) ListDSBPool(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + role := c.Query("role") + members, err := h.store.GetDSBPoolMembers(c.Request.Context(), tenantID, role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"members": members}) +} + +// AddDSBPoolMember adds a member to the DSB pool. +func (h *EscalationHandlers) AddDSBPoolMember(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var member ucca.DSBPoolMember + if err := c.ShouldBindJSON(&member); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + member.TenantID = tenantID + member.IsActive = true + if member.MaxConcurrentReviews == 0 { + member.MaxConcurrentReviews = 10 + } + + if err := h.store.AddDSBPoolMember(c.Request.Context(), &member); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, member) +} diff --git a/ai-compliance-sdk/internal/api/handlers/funding_handlers.go b/ai-compliance-sdk/internal/api/handlers/funding_handlers.go new file mode 100644 index 0000000..6695303 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/funding_handlers.go @@ -0,0 +1,638 @@ +package handlers + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/funding" + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +// FundingHandlers handles funding application API endpoints +type FundingHandlers struct { + store funding.Store + providerRegistry *llm.ProviderRegistry + wizardSchema *WizardSchema + bundeslandProfiles map[string]*BundeslandProfile +} + +// WizardSchema represents the loaded wizard schema +type WizardSchema struct { + Metadata struct { + Version string `yaml:"version"` + Name string `yaml:"name"` + Description string `yaml:"description"` + TotalSteps int `yaml:"total_steps"` + } `yaml:"metadata"` + Steps []WizardStep `yaml:"steps"` + FundingAssistant struct { + Enabled bool `yaml:"enabled"` + Model string `yaml:"model"` + SystemPrompt string `yaml:"system_prompt"` + StepContexts map[int]string `yaml:"step_contexts"` + QuickPrompts []QuickPrompt `yaml:"quick_prompts"` + } `yaml:"funding_assistant"` + Presets map[string]Preset `yaml:"presets"` +} + +// WizardStep represents a step in the wizard +type WizardStep struct { + Number int `yaml:"number" json:"number"` + ID string `yaml:"id" json:"id"` + Title string `yaml:"title" json:"title"` + Subtitle string `yaml:"subtitle" json:"subtitle"` + Description string `yaml:"description" json:"description"` + Icon string `yaml:"icon" json:"icon"` + IsRequired bool `yaml:"is_required" json:"is_required"` + Fields []WizardField `yaml:"fields" json:"fields"` + AssistantContext string `yaml:"assistant_context" json:"assistant_context"` +} + +// WizardField represents a field in the wizard +type WizardField struct { + ID string `yaml:"id" json:"id"` + Type string `yaml:"type" json:"type"` + Label string `yaml:"label" json:"label"` + Placeholder string `yaml:"placeholder,omitempty" json:"placeholder,omitempty"` + Required bool `yaml:"required,omitempty" json:"required,omitempty"` + Options []FieldOption `yaml:"options,omitempty" json:"options,omitempty"` + HelpText string `yaml:"help_text,omitempty" json:"help_text,omitempty"` + MaxLength int `yaml:"max_length,omitempty" json:"max_length,omitempty"` + Min *int `yaml:"min,omitempty" json:"min,omitempty"` + Max *int `yaml:"max,omitempty" json:"max,omitempty"` + Default interface{} `yaml:"default,omitempty" json:"default,omitempty"` + Conditional string `yaml:"conditional,omitempty" json:"conditional,omitempty"` +} + +// FieldOption represents an option for select fields +type FieldOption struct { + Value string `yaml:"value" json:"value"` + Label string `yaml:"label" json:"label"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` +} + +// QuickPrompt represents a quick prompt for the assistant +type QuickPrompt struct { + Label string `yaml:"label" json:"label"` + Prompt string `yaml:"prompt" json:"prompt"` +} + +// Preset represents a BreakPilot preset +type Preset struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description"` + BudgetItems []funding.BudgetItem `yaml:"budget_items" json:"budget_items"` + AutoFill map[string]interface{} `yaml:"auto_fill" json:"auto_fill"` +} + +// BundeslandProfile represents a federal state profile +type BundeslandProfile struct { + Name string `yaml:"name" json:"name"` + Short string `yaml:"short" json:"short"` + FundingPrograms []string `yaml:"funding_programs" json:"funding_programs"` + DefaultFundingRate float64 `yaml:"default_funding_rate" json:"default_funding_rate"` + RequiresMEP bool `yaml:"requires_mep" json:"requires_mep"` + ContactAuthority ContactAuthority `yaml:"contact_authority" json:"contact_authority"` + SpecialRequirements []string `yaml:"special_requirements" json:"special_requirements"` +} + +// ContactAuthority represents a contact authority +type ContactAuthority struct { + Name string `yaml:"name" json:"name"` + Department string `yaml:"department,omitempty" json:"department,omitempty"` + Website string `yaml:"website" json:"website"` + Email string `yaml:"email,omitempty" json:"email,omitempty"` +} + +// NewFundingHandlers creates new funding handlers +func NewFundingHandlers(store funding.Store, providerRegistry *llm.ProviderRegistry) *FundingHandlers { + h := &FundingHandlers{ + store: store, + providerRegistry: providerRegistry, + } + + // Load wizard schema + if err := h.loadWizardSchema(); err != nil { + fmt.Printf("Warning: Could not load wizard schema: %v\n", err) + } + + // Load bundesland profiles + if err := h.loadBundeslandProfiles(); err != nil { + fmt.Printf("Warning: Could not load bundesland profiles: %v\n", err) + } + + return h +} + +func (h *FundingHandlers) loadWizardSchema() error { + data, err := os.ReadFile("policies/funding/foerderantrag_wizard_v1.yaml") + if err != nil { + return err + } + + h.wizardSchema = &WizardSchema{} + return yaml.Unmarshal(data, h.wizardSchema) +} + +func (h *FundingHandlers) loadBundeslandProfiles() error { + data, err := os.ReadFile("policies/funding/bundesland_profiles.yaml") + if err != nil { + return err + } + + var profiles struct { + Bundeslaender map[string]*BundeslandProfile `yaml:"bundeslaender"` + } + if err := yaml.Unmarshal(data, &profiles); err != nil { + return err + } + + h.bundeslandProfiles = profiles.Bundeslaender + return nil +} + +// ============================================================================ +// Application CRUD +// ============================================================================ + +// CreateApplication creates a new funding application +// POST /sdk/v1/funding/applications +func (h *FundingHandlers) CreateApplication(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var req funding.CreateApplicationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + app := &funding.FundingApplication{ + TenantID: tenantID, + Title: req.Title, + FundingProgram: req.FundingProgram, + Status: funding.ApplicationStatusDraft, + CurrentStep: 1, + TotalSteps: 8, + WizardData: make(map[string]interface{}), + CreatedBy: userID, + UpdatedBy: userID, + } + + // Initialize school profile with federal state + app.SchoolProfile = &funding.SchoolProfile{ + FederalState: req.FederalState, + } + + // Apply preset if specified + if req.PresetID != "" && h.wizardSchema != nil { + if preset, ok := h.wizardSchema.Presets[req.PresetID]; ok { + app.Budget = &funding.Budget{ + BudgetItems: preset.BudgetItems, + } + app.WizardData["preset_id"] = req.PresetID + app.WizardData["preset_applied"] = true + for k, v := range preset.AutoFill { + app.WizardData[k] = v + } + } + } + + if err := h.store.CreateApplication(c.Request.Context(), app); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Add history entry + h.store.AddHistoryEntry(c.Request.Context(), &funding.ApplicationHistoryEntry{ + ApplicationID: app.ID, + Action: "created", + PerformedBy: userID, + Notes: "Antrag erstellt", + }) + + c.JSON(http.StatusCreated, app) +} + +// GetApplication retrieves a funding application +// GET /sdk/v1/funding/applications/:id +func (h *FundingHandlers) GetApplication(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"}) + return + } + + app, err := h.store.GetApplication(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, app) +} + +// ListApplications returns a list of funding applications +// GET /sdk/v1/funding/applications +func (h *FundingHandlers) ListApplications(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + filter := funding.ApplicationFilter{ + Page: 1, + PageSize: 20, + } + + // Parse query parameters + if status := c.Query("status"); status != "" { + s := funding.ApplicationStatus(status) + filter.Status = &s + } + if program := c.Query("program"); program != "" { + p := funding.FundingProgram(program) + filter.FundingProgram = &p + } + + result, err := h.store.ListApplications(c.Request.Context(), tenantID, filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// UpdateApplication updates a funding application +// PUT /sdk/v1/funding/applications/:id +func (h *FundingHandlers) UpdateApplication(c *gin.Context) { + userID := rbac.GetUserID(c) + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"}) + return + } + + app, err := h.store.GetApplication(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + var req funding.UpdateApplicationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Title != nil { + app.Title = *req.Title + } + if req.WizardData != nil { + for k, v := range req.WizardData { + app.WizardData[k] = v + } + } + if req.CurrentStep != nil { + app.CurrentStep = *req.CurrentStep + } + app.UpdatedBy = userID + + if err := h.store.UpdateApplication(c.Request.Context(), app); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, app) +} + +// DeleteApplication deletes a funding application +// DELETE /sdk/v1/funding/applications/:id +func (h *FundingHandlers) DeleteApplication(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"}) + return + } + + if err := h.store.DeleteApplication(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "application archived"}) +} + +// ============================================================================ +// Wizard Endpoints +// ============================================================================ + +// GetWizardSchema returns the wizard schema +// GET /sdk/v1/funding/wizard/schema +func (h *FundingHandlers) GetWizardSchema(c *gin.Context) { + if h.wizardSchema == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "wizard schema not loaded"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "metadata": h.wizardSchema.Metadata, + "steps": h.wizardSchema.Steps, + "presets": h.wizardSchema.Presets, + "assistant": gin.H{ + "enabled": h.wizardSchema.FundingAssistant.Enabled, + "quick_prompts": h.wizardSchema.FundingAssistant.QuickPrompts, + }, + }) +} + +// SaveWizardStep saves wizard step data +// POST /sdk/v1/funding/applications/:id/wizard +func (h *FundingHandlers) SaveWizardStep(c *gin.Context) { + userID := rbac.GetUserID(c) + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"}) + return + } + + var req funding.SaveWizardStepRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Save step data + if err := h.store.SaveWizardStep(c.Request.Context(), id, req.Step, req.Data); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Get updated progress + progress, err := h.store.GetWizardProgress(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Add history entry + h.store.AddHistoryEntry(c.Request.Context(), &funding.ApplicationHistoryEntry{ + ApplicationID: id, + Action: "wizard_step_saved", + PerformedBy: userID, + Notes: fmt.Sprintf("Schritt %d gespeichert", req.Step), + }) + + c.JSON(http.StatusOK, progress) +} + +// AskAssistant handles LLM assistant queries +// POST /sdk/v1/funding/wizard/ask +func (h *FundingHandlers) AskAssistant(c *gin.Context) { + var req funding.AssistantRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if h.wizardSchema == nil || !h.wizardSchema.FundingAssistant.Enabled { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "assistant not available"}) + return + } + + // Build system prompt with step context + systemPrompt := h.wizardSchema.FundingAssistant.SystemPrompt + if stepContext, ok := h.wizardSchema.FundingAssistant.StepContexts[req.CurrentStep]; ok { + systemPrompt += "\n\nKontext fuer diesen Schritt:\n" + stepContext + } + + // Build messages + messages := []llm.Message{ + {Role: "system", Content: systemPrompt}, + } + for _, msg := range req.History { + messages = append(messages, llm.Message{ + Role: msg.Role, + Content: msg.Content, + }) + } + messages = append(messages, llm.Message{ + Role: "user", + Content: req.Question, + }) + + // Generate response using registry + chatReq := &llm.ChatRequest{ + Messages: messages, + Temperature: 0.3, + MaxTokens: 1000, + } + + response, err := h.providerRegistry.Chat(c.Request.Context(), chatReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, funding.AssistantResponse{ + Answer: response.Message.Content, + }) +} + +// ============================================================================ +// Status Endpoints +// ============================================================================ + +// SubmitApplication submits an application for review +// POST /sdk/v1/funding/applications/:id/submit +func (h *FundingHandlers) SubmitApplication(c *gin.Context) { + userID := rbac.GetUserID(c) + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"}) + return + } + + app, err := h.store.GetApplication(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + // Validate that all required steps are completed + progress, _ := h.store.GetWizardProgress(c.Request.Context(), id) + if progress == nil || len(progress.CompletedSteps) < app.TotalSteps { + c.JSON(http.StatusBadRequest, gin.H{"error": "not all required steps completed"}) + return + } + + // Update status + app.Status = funding.ApplicationStatusSubmitted + now := time.Now() + app.SubmittedAt = &now + app.UpdatedBy = userID + + if err := h.store.UpdateApplication(c.Request.Context(), app); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Add history entry + h.store.AddHistoryEntry(c.Request.Context(), &funding.ApplicationHistoryEntry{ + ApplicationID: id, + Action: "submitted", + PerformedBy: userID, + Notes: "Antrag eingereicht", + }) + + c.JSON(http.StatusOK, app) +} + +// ============================================================================ +// Export Endpoints +// ============================================================================ + +// ExportApplication exports all documents as ZIP +// GET /sdk/v1/funding/applications/:id/export +func (h *FundingHandlers) ExportApplication(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"}) + return + } + + app, err := h.store.GetApplication(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + // Generate export (this will be implemented in export.go) + // For now, return a placeholder response + c.JSON(http.StatusOK, gin.H{ + "message": "Export generation initiated", + "application_id": app.ID, + "status": "processing", + }) +} + +// PreviewApplication generates a PDF preview +// GET /sdk/v1/funding/applications/:id/preview +func (h *FundingHandlers) PreviewApplication(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"}) + return + } + + app, err := h.store.GetApplication(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + // Generate PDF preview (placeholder) + c.JSON(http.StatusOK, gin.H{ + "message": "Preview generation initiated", + "application_id": app.ID, + }) +} + +// ============================================================================ +// Bundesland Profile Endpoints +// ============================================================================ + +// GetBundeslandProfiles returns all bundesland profiles +// GET /sdk/v1/funding/bundeslaender +func (h *FundingHandlers) GetBundeslandProfiles(c *gin.Context) { + if h.bundeslandProfiles == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "bundesland profiles not loaded"}) + return + } + + c.JSON(http.StatusOK, h.bundeslandProfiles) +} + +// GetBundeslandProfile returns a specific bundesland profile +// GET /sdk/v1/funding/bundeslaender/:state +func (h *FundingHandlers) GetBundeslandProfile(c *gin.Context) { + state := c.Param("state") + + if h.bundeslandProfiles == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "bundesland profiles not loaded"}) + return + } + + profile, ok := h.bundeslandProfiles[state] + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "bundesland not found"}) + return + } + + c.JSON(http.StatusOK, profile) +} + +// ============================================================================ +// Statistics Endpoint +// ============================================================================ + +// GetStatistics returns funding statistics +// GET /sdk/v1/funding/statistics +func (h *FundingHandlers) GetStatistics(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + 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) +} + +// ============================================================================ +// History Endpoint +// ============================================================================ + +// GetApplicationHistory returns the audit trail +// GET /sdk/v1/funding/applications/:id/history +func (h *FundingHandlers) GetApplicationHistory(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid application ID"}) + return + } + + history, err := h.store.GetHistory(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, history) +} diff --git a/ai-compliance-sdk/internal/api/handlers/incidents_handlers.go b/ai-compliance-sdk/internal/api/handlers/incidents_handlers.go new file mode 100644 index 0000000..3d7a5e4 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/incidents_handlers.go @@ -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) +} + diff --git a/ai-compliance-sdk/internal/api/handlers/llm_handlers.go b/ai-compliance-sdk/internal/api/handlers/llm_handlers.go new file mode 100644 index 0000000..6a2439d --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/llm_handlers.go @@ -0,0 +1,345 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/audit" + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// LLMHandlers handles LLM-related API endpoints +type LLMHandlers struct { + accessGate *llm.AccessGate + registry *llm.ProviderRegistry + piiDetector *llm.PIIDetector + auditStore *audit.Store + trailBuilder *audit.TrailBuilder +} + +// NewLLMHandlers creates new LLM handlers +func NewLLMHandlers( + accessGate *llm.AccessGate, + registry *llm.ProviderRegistry, + piiDetector *llm.PIIDetector, + auditStore *audit.Store, + trailBuilder *audit.TrailBuilder, +) *LLMHandlers { + return &LLMHandlers{ + accessGate: accessGate, + registry: registry, + piiDetector: piiDetector, + auditStore: auditStore, + trailBuilder: trailBuilder, + } +} + +// ChatRequest represents a chat completion request +type ChatRequest struct { + Model string `json:"model"` + Messages []llm.Message `json:"messages" binding:"required"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature"` + DataCategories []string `json:"data_categories"` // Optional hint about data types +} + +// Chat handles chat completion requests +func (h *LLMHandlers) Chat(c *gin.Context) { + var req ChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + namespaceID := rbac.GetNamespaceID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + // Detect data categories from messages if not provided + dataCategories := req.DataCategories + if len(dataCategories) == 0 { + for _, msg := range req.Messages { + detected := h.piiDetector.DetectDataCategories(msg.Content) + dataCategories = append(dataCategories, detected...) + } + } + + // Process through access gate + chatReq := &llm.ChatRequest{ + Model: req.Model, + Messages: req.Messages, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + } + + gatedReq, err := h.accessGate.ProcessChatRequest( + c.Request.Context(), + userID, tenantID, namespaceID, + chatReq, dataCategories, + ) + if err != nil { + // Log denied request + h.logDeniedRequest(c, userID, tenantID, namespaceID, "chat", req.Model, err.Error()) + c.JSON(http.StatusForbidden, gin.H{ + "error": "access_denied", + "message": err.Error(), + }) + return + } + + // Execute the request + resp, err := h.accessGate.ExecuteChat(c.Request.Context(), gatedReq) + + // Log the request + h.logLLMRequest(c, gatedReq.GatedRequest, "chat", resp, err) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "llm_error", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": resp.ID, + "model": resp.Model, + "provider": resp.Provider, + "message": resp.Message, + "finish_reason": resp.FinishReason, + "usage": resp.Usage, + "pii_detected": gatedReq.PIIDetected, + "pii_redacted": gatedReq.PromptRedacted, + }) +} + +// CompletionRequest represents a text completion request +type CompletionRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt" binding:"required"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature"` + DataCategories []string `json:"data_categories"` +} + +// Complete handles text completion requests +func (h *LLMHandlers) Complete(c *gin.Context) { + var req CompletionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + namespaceID := rbac.GetNamespaceID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + // Detect data categories from prompt if not provided + dataCategories := req.DataCategories + if len(dataCategories) == 0 { + dataCategories = h.piiDetector.DetectDataCategories(req.Prompt) + } + + // Process through access gate + completionReq := &llm.CompletionRequest{ + Model: req.Model, + Prompt: req.Prompt, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + } + + gatedReq, err := h.accessGate.ProcessCompletionRequest( + c.Request.Context(), + userID, tenantID, namespaceID, + completionReq, dataCategories, + ) + if err != nil { + h.logDeniedRequest(c, userID, tenantID, namespaceID, "completion", req.Model, err.Error()) + c.JSON(http.StatusForbidden, gin.H{ + "error": "access_denied", + "message": err.Error(), + }) + return + } + + // Execute the request + resp, err := h.accessGate.ExecuteCompletion(c.Request.Context(), gatedReq) + + // Log the request + h.logLLMRequest(c, gatedReq.GatedRequest, "completion", resp, err) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "llm_error", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": resp.ID, + "model": resp.Model, + "provider": resp.Provider, + "text": resp.Text, + "finish_reason": resp.FinishReason, + "usage": resp.Usage, + "pii_detected": gatedReq.PIIDetected, + "pii_redacted": gatedReq.PromptRedacted, + }) +} + +// ListModels returns available models +func (h *LLMHandlers) ListModels(c *gin.Context) { + models, err := h.registry.ListAllModels(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"models": models}) +} + +// GetProviderStatus returns the status of LLM providers +func (h *LLMHandlers) GetProviderStatus(c *gin.Context) { + ctx := c.Request.Context() + + statuses := make(map[string]bool) + + if p, ok := h.registry.GetPrimary(); ok { + statuses[p.Name()] = p.IsAvailable(ctx) + } + + if p, ok := h.registry.GetFallback(); ok { + statuses[p.Name()] = p.IsAvailable(ctx) + } + + c.JSON(http.StatusOK, gin.H{"providers": statuses}) +} + +// AnalyzeText analyzes text for PII without making an LLM call +func (h *LLMHandlers) AnalyzeText(c *gin.Context) { + var req struct { + Text string `json:"text" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + findings := h.piiDetector.FindPII(req.Text) + categories := h.piiDetector.DetectDataCategories(req.Text) + containsPII := len(findings) > 0 + + c.JSON(http.StatusOK, gin.H{ + "contains_pii": containsPII, + "pii_findings": findings, + "data_categories": categories, + }) +} + +// RedactText redacts PII from text +func (h *LLMHandlers) RedactText(c *gin.Context) { + var req struct { + Text string `json:"text" binding:"required"` + Level string `json:"level"` // strict, moderate, minimal + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + level := rbac.PIIRedactionStrict + switch req.Level { + case "moderate": + level = rbac.PIIRedactionModerate + case "minimal": + level = rbac.PIIRedactionMinimal + case "none": + level = rbac.PIIRedactionNone + } + + redacted := h.piiDetector.Redact(req.Text, level) + + c.JSON(http.StatusOK, gin.H{ + "original": req.Text, + "redacted": redacted, + "level": level, + }) +} + +// logLLMRequest logs an LLM request to the audit trail +func (h *LLMHandlers) logLLMRequest(c *gin.Context, gatedReq *llm.GatedRequest, operation string, resp any, err error) { + entry := h.trailBuilder.NewLLMEntry(). + WithTenant(gatedReq.TenantID). + WithUser(gatedReq.UserID). + WithOperation(operation). + WithPrompt(gatedReq.PromptHash, 0). // Length calculated below + WithPII(gatedReq.PIIDetected, gatedReq.PIITypes, gatedReq.PromptRedacted) + + if gatedReq.NamespaceID != nil { + entry.WithNamespace(*gatedReq.NamespaceID) + } + + if gatedReq.Policy != nil { + entry.WithPolicy(&gatedReq.Policy.ID, gatedReq.AccessResult.BlockedCategories) + } + + // Add response data if available + switch r := resp.(type) { + case *llm.ChatResponse: + entry.WithModel(r.Model, r.Provider). + WithResponse(len(r.Message.Content)). + WithUsage(r.Usage.TotalTokens, int(r.Duration.Milliseconds())) + case *llm.CompletionResponse: + entry.WithModel(r.Model, r.Provider). + WithResponse(len(r.Text)). + WithUsage(r.Usage.TotalTokens, int(r.Duration.Milliseconds())) + } + + if err != nil { + entry.WithError(err.Error()) + } + + // Add client info + entry.AddMetadata("ip_address", c.ClientIP()). + AddMetadata("user_agent", c.GetHeader("User-Agent")) + + // Save asynchronously + go func() { + entry.Save(c.Request.Context()) + }() +} + +// logDeniedRequest logs a denied LLM request +func (h *LLMHandlers) logDeniedRequest(c *gin.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, operation, model, reason string) { + entry := h.trailBuilder.NewLLMEntry(). + WithTenant(tenantID). + WithUser(userID). + WithOperation(operation). + WithModel(model, "denied"). + WithError("access_denied: " + reason). + AddMetadata("ip_address", c.ClientIP()). + AddMetadata("user_agent", c.GetHeader("User-Agent")) + + if namespaceID != nil { + entry.WithNamespace(*namespaceID) + } + + go func() { + entry.Save(c.Request.Context()) + }() +} diff --git a/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go b/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go new file mode 100644 index 0000000..42572bc --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/obligations_handlers.go @@ -0,0 +1,539 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/breakpilot/ai-compliance-sdk/internal/ucca" +) + +// ObligationsHandlers handles API requests for the generic obligations framework +type ObligationsHandlers struct { + registry *ucca.ObligationsRegistry + store *ucca.ObligationsStore // Optional: for persisting assessments +} + +// NewObligationsHandlers creates a new ObligationsHandlers instance +func NewObligationsHandlers() *ObligationsHandlers { + return &ObligationsHandlers{ + registry: ucca.NewObligationsRegistry(), + } +} + +// NewObligationsHandlersWithStore creates a new ObligationsHandlers with a store +func NewObligationsHandlersWithStore(store *ucca.ObligationsStore) *ObligationsHandlers { + return &ObligationsHandlers{ + registry: ucca.NewObligationsRegistry(), + store: store, + } +} + +// RegisterRoutes registers all obligations-related routes +func (h *ObligationsHandlers) RegisterRoutes(r *gin.RouterGroup) { + obligations := r.Group("/obligations") + { + // Assessment endpoints + obligations.POST("/assess", h.AssessObligations) + obligations.GET("/:assessmentId", h.GetAssessment) + + // Grouping/filtering endpoints + obligations.GET("/:assessmentId/by-regulation", h.GetByRegulation) + obligations.GET("/:assessmentId/by-deadline", h.GetByDeadline) + obligations.GET("/:assessmentId/by-responsible", h.GetByResponsible) + + // Export endpoints + obligations.POST("/export/memo", h.ExportMemo) + obligations.POST("/export/direct", h.ExportMemoFromOverview) + + // Metadata endpoints + obligations.GET("/regulations", h.ListRegulations) + obligations.GET("/regulations/:regulationId/decision-tree", h.GetDecisionTree) + + // Quick check endpoint (no persistence) + obligations.POST("/quick-check", h.QuickCheck) + } +} + +// AssessObligations assesses which obligations apply based on provided facts +// POST /sdk/v1/ucca/obligations/assess +func (h *ObligationsHandlers) AssessObligations(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Tenant ID required"}) + return + } + + var req ucca.ObligationsAssessRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + if req.Facts == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Facts are required"}) + return + } + + // Evaluate all regulations against the facts + overview := h.registry.EvaluateAll(tenantID, req.Facts, req.OrganizationName) + + // Generate warnings if any + var warnings []string + if len(overview.ApplicableRegulations) == 0 { + warnings = append(warnings, "Keine der konfigurierten Regulierungen scheint anwendbar zu sein. Bitte prüfen Sie die eingegebenen Daten.") + } + if overview.ExecutiveSummary.OverdueObligations > 0 { + warnings = append(warnings, "Es gibt überfällige Pflichten, die sofortige Aufmerksamkeit erfordern.") + } + + // Optionally persist the assessment + if h.store != nil { + assessment := &ucca.ObligationsAssessment{ + ID: overview.ID, + TenantID: tenantID, + OrganizationName: req.OrganizationName, + Facts: req.Facts, + Overview: overview, + Status: "completed", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + CreatedBy: rbac.GetUserID(c), + } + if err := h.store.CreateAssessment(c.Request.Context(), assessment); err != nil { + // Log but don't fail - assessment was still generated + c.Set("store_error", err.Error()) + } + } + + c.JSON(http.StatusOK, ucca.ObligationsAssessResponse{ + Overview: overview, + Warnings: warnings, + }) +} + +// GetAssessment retrieves a stored assessment by ID +// GET /sdk/v1/ucca/obligations/:assessmentId +func (h *ObligationsHandlers) GetAssessment(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + assessmentID := c.Param("assessmentId") + + if h.store == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) + return + } + + id, err := uuid.Parse(assessmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) + return + } + + c.JSON(http.StatusOK, assessment.Overview) +} + +// GetByRegulation returns obligations grouped by regulation +// GET /sdk/v1/ucca/obligations/:assessmentId/by-regulation +func (h *ObligationsHandlers) GetByRegulation(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + assessmentID := c.Param("assessmentId") + + if h.store == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) + return + } + + id, err := uuid.Parse(assessmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) + return + } + + grouped := h.registry.GroupByRegulation(assessment.Overview.Obligations) + + c.JSON(http.StatusOK, ucca.ObligationsByRegulationResponse{ + Regulations: grouped, + }) +} + +// GetByDeadline returns obligations grouped by deadline timeframe +// GET /sdk/v1/ucca/obligations/:assessmentId/by-deadline +func (h *ObligationsHandlers) GetByDeadline(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + assessmentID := c.Param("assessmentId") + + if h.store == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) + return + } + + id, err := uuid.Parse(assessmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) + return + } + + grouped := h.registry.GroupByDeadline(assessment.Overview.Obligations) + + c.JSON(http.StatusOK, grouped) +} + +// GetByResponsible returns obligations grouped by responsible role +// GET /sdk/v1/ucca/obligations/:assessmentId/by-responsible +func (h *ObligationsHandlers) GetByResponsible(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + assessmentID := c.Param("assessmentId") + + if h.store == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) + return + } + + id, err := uuid.Parse(assessmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) + return + } + + grouped := h.registry.GroupByResponsible(assessment.Overview.Obligations) + + c.JSON(http.StatusOK, ucca.ObligationsByResponsibleResponse{ + ByRole: grouped, + }) +} + +// ExportMemo exports the obligations overview as a C-Level memo +// POST /sdk/v1/ucca/obligations/export/memo +func (h *ObligationsHandlers) ExportMemo(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + var req ucca.ExportMemoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if h.store == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "Persistence not configured"}) + return + } + + id, err := uuid.Parse(req.AssessmentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assessment ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), tenantID, id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Assessment not found"}) + return + } + + // Create exporter + exporter := ucca.NewPDFExporter(req.Language) + + // Generate export based on format + var response *ucca.ExportMemoResponse + switch req.Format { + case "pdf": + response, err = exporter.ExportManagementMemo(assessment.Overview) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()}) + return + } + case "markdown", "": + response, err = exporter.ExportMarkdown(assessment.Overview) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()}) + return + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"}) + return + } + + c.JSON(http.StatusOK, response) +} + +// ExportMemoFromOverview exports an overview directly (without persistence) +// POST /sdk/v1/ucca/obligations/export/direct +func (h *ObligationsHandlers) ExportMemoFromOverview(c *gin.Context) { + var req struct { + Overview *ucca.ManagementObligationsOverview `json:"overview"` + Format string `json:"format"` // "markdown" or "pdf" + Language string `json:"language,omitempty"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if req.Overview == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Overview is required"}) + return + } + + exporter := ucca.NewPDFExporter(req.Language) + + var response *ucca.ExportMemoResponse + var err error + switch req.Format { + case "pdf": + response, err = exporter.ExportManagementMemo(req.Overview) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate PDF", "details": err.Error()}) + return + } + case "markdown", "": + response, err = exporter.ExportMarkdown(req.Overview) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate Markdown", "details": err.Error()}) + return + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid format. Use 'markdown' or 'pdf'"}) + return + } + + c.JSON(http.StatusOK, response) +} + +// ListRegulations returns all available regulation modules +// GET /sdk/v1/ucca/obligations/regulations +func (h *ObligationsHandlers) ListRegulations(c *gin.Context) { + modules := h.registry.ListModules() + + c.JSON(http.StatusOK, ucca.AvailableRegulationsResponse{ + Regulations: modules, + }) +} + +// GetDecisionTree returns the decision tree for a specific regulation +// GET /sdk/v1/ucca/obligations/regulations/:regulationId/decision-tree +func (h *ObligationsHandlers) GetDecisionTree(c *gin.Context) { + regulationID := c.Param("regulationId") + + tree, err := h.registry.GetDecisionTree(regulationID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, tree) +} + +// QuickCheck performs a quick obligations check without persistence +// POST /sdk/v1/ucca/obligations/quick-check +func (h *ObligationsHandlers) QuickCheck(c *gin.Context) { + var req struct { + // Organization basics + EmployeeCount int `json:"employee_count"` + AnnualRevenue float64 `json:"annual_revenue"` + BalanceSheetTotal float64 `json:"balance_sheet_total,omitempty"` + Country string `json:"country"` + + // Sector + PrimarySector string `json:"primary_sector"` + SpecialServices []string `json:"special_services,omitempty"` + IsKRITIS bool `json:"is_kritis,omitempty"` + + // Quick flags + ProcessesPersonalData bool `json:"processes_personal_data,omitempty"` + UsesAI bool `json:"uses_ai,omitempty"` + IsFinancialInstitution bool `json:"is_financial_institution,omitempty"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + // Build UnifiedFacts from quick check request + facts := &ucca.UnifiedFacts{ + Organization: ucca.OrganizationFacts{ + EmployeeCount: req.EmployeeCount, + AnnualRevenue: req.AnnualRevenue, + BalanceSheetTotal: req.BalanceSheetTotal, + Country: req.Country, + EUMember: isEUCountry(req.Country), + }, + Sector: ucca.SectorFacts{ + PrimarySector: req.PrimarySector, + SpecialServices: req.SpecialServices, + IsKRITIS: req.IsKRITIS, + KRITISThresholdMet: req.IsKRITIS, + IsFinancialInstitution: req.IsFinancialInstitution, + }, + DataProtection: ucca.DataProtectionFacts{ + ProcessesPersonalData: req.ProcessesPersonalData, + }, + AIUsage: ucca.AIUsageFacts{ + UsesAI: req.UsesAI, + }, + Financial: ucca.FinancialFacts{ + IsRegulated: req.IsFinancialInstitution, + }, + } + + // Quick evaluation + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + tenantID = uuid.New() // Generate temporary ID for quick check + } + + overview := h.registry.EvaluateAll(tenantID, facts, "") + + // Return simplified result + c.JSON(http.StatusOK, gin.H{ + "applicable_regulations": overview.ApplicableRegulations, + "total_obligations": len(overview.Obligations), + "critical_obligations": overview.ExecutiveSummary.CriticalObligations, + "sanctions_summary": overview.SanctionsSummary, + "executive_summary": overview.ExecutiveSummary, + }) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +func generateMemoMarkdown(overview *ucca.ManagementObligationsOverview) string { + content := "# Pflichten-Übersicht für die Geschäftsführung\n\n" + content += "**Datum:** " + overview.AssessmentDate.Format("02.01.2006") + "\n" + if overview.OrganizationName != "" { + content += "**Organisation:** " + overview.OrganizationName + "\n" + } + content += "\n---\n\n" + + // Executive Summary + content += "## Executive Summary\n\n" + content += "| Kennzahl | Wert |\n" + content += "|----------|------|\n" + content += "| Anwendbare Regulierungen | " + itoa(overview.ExecutiveSummary.TotalRegulations) + " |\n" + content += "| Gesamtzahl Pflichten | " + itoa(overview.ExecutiveSummary.TotalObligations) + " |\n" + content += "| Kritische Pflichten | " + itoa(overview.ExecutiveSummary.CriticalObligations) + " |\n" + content += "| Überfällige Pflichten | " + itoa(overview.ExecutiveSummary.OverdueObligations) + " |\n" + content += "| Anstehende Fristen (30 Tage) | " + itoa(overview.ExecutiveSummary.UpcomingDeadlines) + " |\n" + content += "\n" + + // Key Risks + if len(overview.ExecutiveSummary.KeyRisks) > 0 { + content += "### Hauptrisiken\n\n" + for _, risk := range overview.ExecutiveSummary.KeyRisks { + content += "- ⚠️ " + risk + "\n" + } + content += "\n" + } + + // Recommended Actions + if len(overview.ExecutiveSummary.RecommendedActions) > 0 { + content += "### Empfohlene Maßnahmen\n\n" + for i, action := range overview.ExecutiveSummary.RecommendedActions { + content += itoa(i+1) + ". " + action + "\n" + } + content += "\n" + } + + // Applicable Regulations + content += "## Anwendbare Regulierungen\n\n" + for _, reg := range overview.ApplicableRegulations { + content += "### " + reg.Name + "\n\n" + content += "- **Klassifizierung:** " + reg.Classification + "\n" + content += "- **Begründung:** " + reg.Reason + "\n" + content += "- **Anzahl Pflichten:** " + itoa(reg.ObligationCount) + "\n" + content += "\n" + } + + // Sanctions Summary + content += "## Sanktionsrisiken\n\n" + content += overview.SanctionsSummary.Summary + "\n\n" + if overview.SanctionsSummary.MaxFinancialRisk != "" { + content += "- **Maximales Bußgeld:** " + overview.SanctionsSummary.MaxFinancialRisk + "\n" + } + if overview.SanctionsSummary.PersonalLiabilityRisk { + content += "- **Persönliche Haftung:** Ja ⚠️\n" + } + content += "\n" + + // Critical Obligations + content += "## Kritische Pflichten\n\n" + for _, obl := range overview.Obligations { + if obl.Priority == ucca.PriorityCritical { + content += "### " + obl.ID + ": " + obl.Title + "\n\n" + content += obl.Description + "\n\n" + content += "- **Verantwortlich:** " + string(obl.Responsible) + "\n" + if obl.Deadline != nil { + if obl.Deadline.Date != nil { + content += "- **Frist:** " + obl.Deadline.Date.Format("02.01.2006") + "\n" + } else if obl.Deadline.Duration != "" { + content += "- **Frist:** " + obl.Deadline.Duration + "\n" + } + } + if obl.Sanctions != nil && obl.Sanctions.MaxFine != "" { + content += "- **Sanktion:** " + obl.Sanctions.MaxFine + "\n" + } + content += "\n" + } + } + + // Incident Deadlines + if len(overview.IncidentDeadlines) > 0 { + content += "## Meldepflichten bei Sicherheitsvorfällen\n\n" + content += "| Phase | Frist | Empfänger |\n" + content += "|-------|-------|-----------|\n" + for _, deadline := range overview.IncidentDeadlines { + content += "| " + deadline.Phase + " | " + deadline.Deadline + " | " + deadline.Recipient + " |\n" + } + content += "\n" + } + + content += "---\n\n" + content += "*Dieses Dokument wurde automatisch generiert und ersetzt keine Rechtsberatung.*\n" + + return content +} + +func isEUCountry(country string) bool { + euCountries := map[string]bool{ + "DE": true, "AT": true, "BE": true, "BG": true, "HR": true, "CY": true, + "CZ": true, "DK": true, "EE": true, "FI": true, "FR": true, "GR": true, + "HU": true, "IE": true, "IT": true, "LV": true, "LT": true, "LU": true, + "MT": true, "NL": true, "PL": true, "PT": true, "RO": true, "SK": true, + "SI": true, "ES": true, "SE": true, + } + return euCountries[country] +} + +func itoa(i int) string { + return strconv.Itoa(i) +} diff --git a/ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go b/ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go new file mode 100644 index 0000000..bc34e69 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go @@ -0,0 +1,625 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/breakpilot/ai-compliance-sdk/internal/portfolio" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// PortfolioHandlers handles portfolio HTTP requests +type PortfolioHandlers struct { + store *portfolio.Store +} + +// NewPortfolioHandlers creates new portfolio handlers +func NewPortfolioHandlers(store *portfolio.Store) *PortfolioHandlers { + return &PortfolioHandlers{store: store} +} + +// ============================================================================ +// Portfolio CRUD +// ============================================================================ + +// CreatePortfolio creates a new portfolio +// POST /sdk/v1/portfolios +func (h *PortfolioHandlers) CreatePortfolio(c *gin.Context) { + var req portfolio.CreatePortfolioRequest + 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) + + p := &portfolio.Portfolio{ + TenantID: tenantID, + Name: req.Name, + Description: req.Description, + Status: portfolio.PortfolioStatusDraft, + Department: req.Department, + BusinessUnit: req.BusinessUnit, + Owner: req.Owner, + OwnerEmail: req.OwnerEmail, + Settings: req.Settings, + CreatedBy: userID, + } + + // Set default settings + if !p.Settings.AutoUpdateMetrics { + p.Settings.AutoUpdateMetrics = true + } + + if err := h.store.CreatePortfolio(c.Request.Context(), p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"portfolio": p}) +} + +// ListPortfolios lists portfolios +// GET /sdk/v1/portfolios +func (h *PortfolioHandlers) ListPortfolios(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + filters := &portfolio.PortfolioFilters{ + Limit: 50, + } + + if status := c.Query("status"); status != "" { + filters.Status = portfolio.PortfolioStatus(status) + } + if department := c.Query("department"); department != "" { + filters.Department = department + } + if businessUnit := c.Query("business_unit"); businessUnit != "" { + filters.BusinessUnit = businessUnit + } + if owner := c.Query("owner"); owner != "" { + filters.Owner = owner + } + if limit := c.Query("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil { + filters.Limit = l + } + } + if offset := c.Query("offset"); offset != "" { + if o, err := strconv.Atoi(offset); err == nil { + filters.Offset = o + } + } + + portfolios, err := h.store.ListPortfolios(c.Request.Context(), tenantID, filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "portfolios": portfolios, + "total": len(portfolios), + }) +} + +// GetPortfolio retrieves a portfolio +// GET /sdk/v1/portfolios/:id +func (h *PortfolioHandlers) GetPortfolio(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + summary, err := h.store.GetPortfolioSummary(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if summary == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) + return + } + + // Get stats + stats, _ := h.store.GetPortfolioStats(c.Request.Context(), id) + + c.JSON(http.StatusOK, gin.H{ + "portfolio": summary.Portfolio, + "items": summary.Items, + "risk_distribution": summary.RiskDistribution, + "feasibility_dist": summary.FeasibilityDist, + "stats": stats, + }) +} + +// UpdatePortfolio updates a portfolio +// PUT /sdk/v1/portfolios/:id +func (h *PortfolioHandlers) UpdatePortfolio(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + p, err := h.store.GetPortfolio(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if p == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) + return + } + + var req portfolio.UpdatePortfolioRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Name != "" { + p.Name = req.Name + } + if req.Description != "" { + p.Description = req.Description + } + if req.Status != "" { + p.Status = req.Status + } + if req.Department != "" { + p.Department = req.Department + } + if req.BusinessUnit != "" { + p.BusinessUnit = req.BusinessUnit + } + if req.Owner != "" { + p.Owner = req.Owner + } + if req.OwnerEmail != "" { + p.OwnerEmail = req.OwnerEmail + } + if req.Settings != nil { + p.Settings = *req.Settings + } + + if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"portfolio": p}) +} + +// DeletePortfolio deletes a portfolio +// DELETE /sdk/v1/portfolios/:id +func (h *PortfolioHandlers) DeletePortfolio(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + if err := h.store.DeletePortfolio(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "portfolio deleted"}) +} + +// ============================================================================ +// Portfolio Items +// ============================================================================ + +// AddItem adds an item to a portfolio +// POST /sdk/v1/portfolios/:id/items +func (h *PortfolioHandlers) AddItem(c *gin.Context) { + portfolioID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + var req portfolio.AddItemRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + + item := &portfolio.PortfolioItem{ + PortfolioID: portfolioID, + ItemType: req.ItemType, + ItemID: req.ItemID, + Tags: req.Tags, + Notes: req.Notes, + AddedBy: userID, + } + + if err := h.store.AddItem(c.Request.Context(), item); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"item": item}) +} + +// ListItems lists items in a portfolio +// GET /sdk/v1/portfolios/:id/items +func (h *PortfolioHandlers) ListItems(c *gin.Context) { + portfolioID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + var itemType *portfolio.ItemType + if t := c.Query("type"); t != "" { + it := portfolio.ItemType(t) + itemType = &it + } + + items, err := h.store.ListItems(c.Request.Context(), portfolioID, itemType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": len(items), + }) +} + +// BulkAddItems adds multiple items to a portfolio +// POST /sdk/v1/portfolios/:id/items/bulk +func (h *PortfolioHandlers) BulkAddItems(c *gin.Context) { + portfolioID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + var req portfolio.BulkAddItemsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := rbac.GetUserID(c) + + // Convert AddItemRequest to PortfolioItem + items := make([]portfolio.PortfolioItem, len(req.Items)) + for i, r := range req.Items { + items[i] = portfolio.PortfolioItem{ + ItemType: r.ItemType, + ItemID: r.ItemID, + Tags: r.Tags, + Notes: r.Notes, + } + } + + result, err := h.store.BulkAddItems(c.Request.Context(), portfolioID, items, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// RemoveItem removes an item from a portfolio +// DELETE /sdk/v1/portfolios/:id/items/:itemId +func (h *PortfolioHandlers) RemoveItem(c *gin.Context) { + itemID, err := uuid.Parse(c.Param("itemId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid item ID"}) + return + } + + if err := h.store.RemoveItem(c.Request.Context(), itemID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "item removed"}) +} + +// ReorderItems updates the order of items +// PUT /sdk/v1/portfolios/:id/items/order +func (h *PortfolioHandlers) ReorderItems(c *gin.Context) { + portfolioID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + var req struct { + ItemIDs []uuid.UUID `json:"item_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.store.UpdateItemOrder(c.Request.Context(), portfolioID, req.ItemIDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "items reordered"}) +} + +// ============================================================================ +// Merge Operations +// ============================================================================ + +// MergePortfolios merges two portfolios +// POST /sdk/v1/portfolios/merge +func (h *PortfolioHandlers) MergePortfolios(c *gin.Context) { + var req portfolio.MergeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate portfolios exist + source, err := h.store.GetPortfolio(c.Request.Context(), req.SourcePortfolioID) + if err != nil || source == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "source portfolio not found"}) + return + } + + target, err := h.store.GetPortfolio(c.Request.Context(), req.TargetPortfolioID) + if err != nil || target == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "target portfolio not found"}) + return + } + + // Set defaults + if req.Strategy == "" { + req.Strategy = portfolio.MergeStrategyUnion + } + + userID := rbac.GetUserID(c) + + result, err := h.store.MergePortfolios(c.Request.Context(), &req, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "portfolios merged", + "result": result, + }) +} + +// ============================================================================ +// Statistics & Reports +// ============================================================================ + +// GetPortfolioStats returns statistics for a portfolio +// GET /sdk/v1/portfolios/:id/stats +func (h *PortfolioHandlers) GetPortfolioStats(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + stats, err := h.store.GetPortfolioStats(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// GetPortfolioActivity returns recent activity for a portfolio +// GET /sdk/v1/portfolios/:id/activity +func (h *PortfolioHandlers) GetPortfolioActivity(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + limit := 20 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + + activities, err := h.store.GetRecentActivity(c.Request.Context(), id, limit) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "activities": activities, + "total": len(activities), + }) +} + +// ComparePortfolios compares multiple portfolios +// POST /sdk/v1/portfolios/compare +func (h *PortfolioHandlers) ComparePortfolios(c *gin.Context) { + var req portfolio.ComparePortfoliosRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if len(req.PortfolioIDs) < 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "at least 2 portfolios required for comparison"}) + return + } + if len(req.PortfolioIDs) > 5 { + c.JSON(http.StatusBadRequest, gin.H{"error": "maximum 5 portfolios can be compared"}) + return + } + + // Get all portfolios + var portfolios []portfolio.Portfolio + comparison := portfolio.PortfolioComparison{ + RiskScores: make(map[string]float64), + ComplianceScores: make(map[string]float64), + ItemCounts: make(map[string]int), + UniqueItems: make(map[string][]uuid.UUID), + } + + allItems := make(map[uuid.UUID][]string) // item_id -> portfolio_ids + + for _, id := range req.PortfolioIDs { + p, err := h.store.GetPortfolio(c.Request.Context(), id) + if err != nil || p == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio not found: " + id.String()}) + return + } + portfolios = append(portfolios, *p) + + idStr := id.String() + comparison.RiskScores[idStr] = p.AvgRiskScore + comparison.ComplianceScores[idStr] = p.ComplianceScore + comparison.ItemCounts[idStr] = p.TotalAssessments + p.TotalRoadmaps + p.TotalWorkshops + + // Get items for comparison + items, _ := h.store.ListItems(c.Request.Context(), id, nil) + for _, item := range items { + allItems[item.ItemID] = append(allItems[item.ItemID], idStr) + } + } + + // Find common and unique items + for itemID, portfolioIDs := range allItems { + if len(portfolioIDs) > 1 { + comparison.CommonItems = append(comparison.CommonItems, itemID) + } else { + pid := portfolioIDs[0] + comparison.UniqueItems[pid] = append(comparison.UniqueItems[pid], itemID) + } + } + + c.JSON(http.StatusOK, portfolio.ComparePortfoliosResponse{ + Portfolios: portfolios, + Comparison: comparison, + }) +} + +// RecalculateMetrics manually recalculates portfolio metrics +// POST /sdk/v1/portfolios/:id/recalculate +func (h *PortfolioHandlers) RecalculateMetrics(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + if err := h.store.RecalculateMetrics(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Get updated portfolio + p, _ := h.store.GetPortfolio(c.Request.Context(), id) + + c.JSON(http.StatusOK, gin.H{ + "message": "metrics recalculated", + "portfolio": p, + }) +} + +// ============================================================================ +// Approval Workflow +// ============================================================================ + +// ApprovePortfolio approves a portfolio +// POST /sdk/v1/portfolios/:id/approve +func (h *PortfolioHandlers) ApprovePortfolio(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + p, err := h.store.GetPortfolio(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if p == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) + return + } + + if p.Status != portfolio.PortfolioStatusReview { + c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in REVIEW status to approve"}) + return + } + + userID := rbac.GetUserID(c) + now := c.Request.Context().Value("now") + if now == nil { + t := p.UpdatedAt + p.ApprovedAt = &t + } + p.ApprovedBy = &userID + p.Status = portfolio.PortfolioStatusApproved + + if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "portfolio approved", + "portfolio": p, + }) +} + +// SubmitForReview submits a portfolio for review +// POST /sdk/v1/portfolios/:id/submit-review +func (h *PortfolioHandlers) SubmitForReview(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) + return + } + + p, err := h.store.GetPortfolio(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if p == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) + return + } + + if p.Status != portfolio.PortfolioStatusDraft && p.Status != portfolio.PortfolioStatusActive { + c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in DRAFT or ACTIVE status to submit for review"}) + return + } + + p.Status = portfolio.PortfolioStatusReview + + if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "portfolio submitted for review", + "portfolio": p, + }) +} diff --git a/ai-compliance-sdk/internal/api/handlers/rbac_handlers.go b/ai-compliance-sdk/internal/api/handlers/rbac_handlers.go new file mode 100644 index 0000000..4e70b77 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/rbac_handlers.go @@ -0,0 +1,548 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// RBACHandlers handles RBAC-related API endpoints +type RBACHandlers struct { + store *rbac.Store + service *rbac.Service + policyEngine *rbac.PolicyEngine +} + +// NewRBACHandlers creates new RBAC handlers +func NewRBACHandlers(store *rbac.Store, service *rbac.Service, policyEngine *rbac.PolicyEngine) *RBACHandlers { + return &RBACHandlers{ + store: store, + service: service, + policyEngine: policyEngine, + } +} + +// ============================================================================ +// Tenant Endpoints +// ============================================================================ + +// ListTenants returns all tenants +func (h *RBACHandlers) ListTenants(c *gin.Context) { + tenants, err := h.store.ListTenants(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"tenants": tenants}) +} + +// GetTenant returns a tenant by ID +func (h *RBACHandlers) GetTenant(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid tenant ID"}) + return + } + + tenant, err := h.store.GetTenant(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "tenant not found"}) + return + } + + c.JSON(http.StatusOK, tenant) +} + +// CreateTenant creates a new tenant +func (h *RBACHandlers) CreateTenant(c *gin.Context) { + var tenant rbac.Tenant + if err := c.ShouldBindJSON(&tenant); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.store.CreateTenant(c.Request.Context(), &tenant); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, tenant) +} + +// UpdateTenant updates a tenant +func (h *RBACHandlers) 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 tenant rbac.Tenant + if err := c.ShouldBindJSON(&tenant); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tenant.ID = id + if err := h.store.UpdateTenant(c.Request.Context(), &tenant); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, tenant) +} + +// ============================================================================ +// Namespace Endpoints +// ============================================================================ + +// ListNamespaces returns namespaces for a tenant +func (h *RBACHandlers) ListNamespaces(c *gin.Context) { + tenantID, err := uuid.Parse(c.Param("id")) + if err != nil { + tenantID = rbac.GetTenantID(c) + } + + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + namespaces, err := h.store.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}) +} + +// GetNamespace returns a namespace by ID +func (h *RBACHandlers) GetNamespace(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid namespace ID"}) + return + } + + namespace, err := h.store.GetNamespace(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "namespace not found"}) + return + } + + c.JSON(http.StatusOK, namespace) +} + +// CreateNamespace creates a new namespace +func (h *RBACHandlers) CreateNamespace(c *gin.Context) { + tenantID, err := uuid.Parse(c.Param("id")) + if err != nil { + tenantID = rbac.GetTenantID(c) + } + + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var namespace rbac.Namespace + if err := c.ShouldBindJSON(&namespace); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + namespace.TenantID = tenantID + if err := h.store.CreateNamespace(c.Request.Context(), &namespace); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, namespace) +} + +// ============================================================================ +// Role Endpoints +// ============================================================================ + +// ListRoles returns roles for a tenant (including system roles) +func (h *RBACHandlers) ListRoles(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + var tenantIDPtr *uuid.UUID + if tenantID != uuid.Nil { + tenantIDPtr = &tenantID + } + + roles, err := h.store.ListRoles(c.Request.Context(), tenantIDPtr) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"roles": roles}) +} + +// ListSystemRoles returns all system roles +func (h *RBACHandlers) ListSystemRoles(c *gin.Context) { + roles, err := h.store.ListSystemRoles(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"roles": roles}) +} + +// GetRole returns a role by ID +func (h *RBACHandlers) GetRole(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"}) + return + } + + role, err := h.store.GetRole(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "role not found"}) + return + } + + c.JSON(http.StatusOK, role) +} + +// CreateRole creates a new role +func (h *RBACHandlers) CreateRole(c *gin.Context) { + var role rbac.Role + if err := c.ShouldBindJSON(&role); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID != uuid.Nil { + role.TenantID = &tenantID + } + + if err := h.store.CreateRole(c.Request.Context(), &role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, role) +} + +// ============================================================================ +// User Role Endpoints +// ============================================================================ + +// AssignRoleRequest represents a role assignment request +type AssignRoleRequest struct { + UserID string `json:"user_id" binding:"required"` + RoleID string `json:"role_id" binding:"required"` + NamespaceID *string `json:"namespace_id"` + ExpiresAt *string `json:"expires_at"` // RFC3339 format +} + +// AssignRole assigns a role to a user +func (h *RBACHandlers) AssignRole(c *gin.Context) { + var req AssignRoleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, err := uuid.Parse(req.UserID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) + return + } + + roleID, err := uuid.Parse(req.RoleID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + grantorID := rbac.GetUserID(c) + if grantorID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + userRole := &rbac.UserRole{ + UserID: userID, + RoleID: roleID, + TenantID: tenantID, + } + + if req.NamespaceID != nil { + nsID, err := uuid.Parse(*req.NamespaceID) + if err == nil { + userRole.NamespaceID = &nsID + } + } + + if err := h.service.AssignRoleToUser(c.Request.Context(), userRole, grantorID); err != nil { + if err == rbac.ErrPermissionDenied { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "role assigned successfully"}) +} + +// RevokeRole revokes a role from a user +func (h *RBACHandlers) RevokeRole(c *gin.Context) { + userIDStr := c.Param("userId") + roleIDStr := c.Param("roleId") + + userID, err := uuid.Parse(userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) + return + } + + roleID, err := uuid.Parse(roleIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role ID"}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + revokerID := rbac.GetUserID(c) + if revokerID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + var namespaceID *uuid.UUID + if nsIDStr := c.Query("namespace_id"); nsIDStr != "" { + if nsID, err := uuid.Parse(nsIDStr); err == nil { + namespaceID = &nsID + } + } + + if err := h.service.RevokeRoleFromUser(c.Request.Context(), userID, roleID, tenantID, namespaceID, revokerID); err != nil { + if err == rbac.ErrPermissionDenied { + c.JSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "role revoked successfully"}) +} + +// GetUserRoles returns all roles for a user +func (h *RBACHandlers) GetUserRoles(c *gin.Context) { + userIDStr := c.Param("userId") + userID, err := uuid.Parse(userIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + roles, err := h.store.GetUserRoles(c.Request.Context(), userID, tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"roles": roles}) +} + +// ============================================================================ +// Permission Endpoints +// ============================================================================ + +// GetEffectivePermissions returns effective permissions for the current user +func (h *RBACHandlers) GetEffectivePermissions(c *gin.Context) { + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + namespaceID := rbac.GetNamespaceID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + perms, err := h.service.GetEffectivePermissions(c.Request.Context(), userID, tenantID, namespaceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, perms) +} + +// GetUserContext returns complete context for the current user +func (h *RBACHandlers) GetUserContext(c *gin.Context) { + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + ctx, err := h.policyEngine.GetUserContext(c.Request.Context(), userID, tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, ctx) +} + +// CheckPermission checks if user has a specific permission +func (h *RBACHandlers) CheckPermission(c *gin.Context) { + permission := c.Query("permission") + if permission == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "permission parameter required"}) + return + } + + userID := rbac.GetUserID(c) + tenantID := rbac.GetTenantID(c) + namespaceID := rbac.GetNamespaceID(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + hasPermission, err := h.service.HasPermission(c.Request.Context(), userID, tenantID, namespaceID, permission) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "permission": permission, + "has_permission": hasPermission, + }) +} + +// ============================================================================ +// LLM Policy Endpoints +// ============================================================================ + +// ListLLMPolicies returns LLM policies for a tenant +func (h *RBACHandlers) ListLLMPolicies(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + policies, err := h.store.ListLLMPolicies(c.Request.Context(), tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"policies": policies}) +} + +// GetLLMPolicy returns an LLM policy by ID +func (h *RBACHandlers) GetLLMPolicy(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"}) + return + } + + policy, err := h.store.GetLLMPolicy(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"}) + return + } + + c.JSON(http.StatusOK, policy) +} + +// CreateLLMPolicy creates a new LLM policy +func (h *RBACHandlers) CreateLLMPolicy(c *gin.Context) { + var policy rbac.LLMPolicy + if err := c.ShouldBindJSON(&policy); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + policy.TenantID = tenantID + if err := h.store.CreateLLMPolicy(c.Request.Context(), &policy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, policy) +} + +// UpdateLLMPolicy updates an LLM policy +func (h *RBACHandlers) UpdateLLMPolicy(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"}) + return + } + + var policy rbac.LLMPolicy + if err := c.ShouldBindJSON(&policy); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + policy.ID = id + if err := h.store.UpdateLLMPolicy(c.Request.Context(), &policy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, policy) +} + +// DeleteLLMPolicy deletes an LLM policy +func (h *RBACHandlers) DeleteLLMPolicy(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy ID"}) + return + } + + if err := h.store.DeleteLLMPolicy(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "policy deleted"}) +} diff --git a/ai-compliance-sdk/internal/api/handlers/roadmap_handlers.go b/ai-compliance-sdk/internal/api/handlers/roadmap_handlers.go new file mode 100644 index 0000000..fe1015e --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/roadmap_handlers.go @@ -0,0 +1,740 @@ +package handlers + +import ( + "bytes" + "io" + "net/http" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/breakpilot/ai-compliance-sdk/internal/roadmap" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// RoadmapHandlers handles roadmap-related HTTP requests +type RoadmapHandlers struct { + store *roadmap.Store + parser *roadmap.Parser +} + +// NewRoadmapHandlers creates new roadmap handlers +func NewRoadmapHandlers(store *roadmap.Store) *RoadmapHandlers { + return &RoadmapHandlers{ + store: store, + parser: roadmap.NewParser(), + } +} + +// ============================================================================ +// Roadmap CRUD +// ============================================================================ + +// CreateRoadmap creates a new roadmap +// POST /sdk/v1/roadmaps +func (h *RoadmapHandlers) CreateRoadmap(c *gin.Context) { + var req roadmap.CreateRoadmapRequest + 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) + + r := &roadmap.Roadmap{ + TenantID: tenantID, + Title: req.Title, + Description: req.Description, + AssessmentID: req.AssessmentID, + PortfolioID: req.PortfolioID, + StartDate: req.StartDate, + TargetDate: req.TargetDate, + Status: "draft", + CreatedBy: userID, + } + + if err := h.store.CreateRoadmap(c.Request.Context(), r); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, roadmap.CreateRoadmapResponse{Roadmap: *r}) +} + +// ListRoadmaps lists roadmaps for the tenant +// GET /sdk/v1/roadmaps +func (h *RoadmapHandlers) ListRoadmaps(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + filters := &roadmap.RoadmapFilters{ + Status: c.Query("status"), + Limit: 50, + } + + if assessmentID := c.Query("assessment_id"); assessmentID != "" { + if id, err := uuid.Parse(assessmentID); err == nil { + filters.AssessmentID = &id + } + } + + roadmaps, err := h.store.ListRoadmaps(c.Request.Context(), tenantID, filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "roadmaps": roadmaps, + "total": len(roadmaps), + }) +} + +// GetRoadmap retrieves a roadmap by ID +// GET /sdk/v1/roadmaps/:id +func (h *RoadmapHandlers) GetRoadmap(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + r, err := h.store.GetRoadmap(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if r == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "roadmap not found"}) + return + } + + // Get items + items, err := h.store.ListItems(c.Request.Context(), id, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Get stats + stats, _ := h.store.GetRoadmapStats(c.Request.Context(), id) + + c.JSON(http.StatusOK, gin.H{ + "roadmap": r, + "items": items, + "stats": stats, + }) +} + +// UpdateRoadmap updates a roadmap +// PUT /sdk/v1/roadmaps/:id +func (h *RoadmapHandlers) UpdateRoadmap(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + r, err := h.store.GetRoadmap(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if r == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "roadmap not found"}) + return + } + + var req roadmap.CreateRoadmapRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + r.Title = req.Title + r.Description = req.Description + r.AssessmentID = req.AssessmentID + r.PortfolioID = req.PortfolioID + r.StartDate = req.StartDate + r.TargetDate = req.TargetDate + + if err := h.store.UpdateRoadmap(c.Request.Context(), r); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"roadmap": r}) +} + +// DeleteRoadmap deletes a roadmap +// DELETE /sdk/v1/roadmaps/:id +func (h *RoadmapHandlers) DeleteRoadmap(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + if err := h.store.DeleteRoadmap(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "roadmap deleted"}) +} + +// GetRoadmapStats returns statistics for a roadmap +// GET /sdk/v1/roadmaps/:id/stats +func (h *RoadmapHandlers) GetRoadmapStats(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + stats, err := h.store.GetRoadmapStats(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// ============================================================================ +// RoadmapItem CRUD +// ============================================================================ + +// CreateItem creates a new roadmap item +// POST /sdk/v1/roadmaps/:id/items +func (h *RoadmapHandlers) CreateItem(c *gin.Context) { + roadmapID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid roadmap ID"}) + return + } + + var input roadmap.RoadmapItemInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + item := &roadmap.RoadmapItem{ + RoadmapID: roadmapID, + Title: input.Title, + Description: input.Description, + Category: input.Category, + Priority: input.Priority, + Status: input.Status, + ControlID: input.ControlID, + RegulationRef: input.RegulationRef, + GapID: input.GapID, + EffortDays: input.EffortDays, + AssigneeName: input.AssigneeName, + Department: input.Department, + PlannedStart: input.PlannedStart, + PlannedEnd: input.PlannedEnd, + Notes: input.Notes, + } + + if err := h.store.CreateItem(c.Request.Context(), item); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Update roadmap progress + h.store.UpdateRoadmapProgress(c.Request.Context(), roadmapID) + + c.JSON(http.StatusCreated, gin.H{"item": item}) +} + +// ListItems lists items for a roadmap +// GET /sdk/v1/roadmaps/:id/items +func (h *RoadmapHandlers) ListItems(c *gin.Context) { + roadmapID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid roadmap ID"}) + return + } + + filters := &roadmap.RoadmapItemFilters{ + SearchQuery: c.Query("search"), + Limit: 100, + } + + if status := c.Query("status"); status != "" { + filters.Status = roadmap.ItemStatus(status) + } + if priority := c.Query("priority"); priority != "" { + filters.Priority = roadmap.ItemPriority(priority) + } + if category := c.Query("category"); category != "" { + filters.Category = roadmap.ItemCategory(category) + } + if controlID := c.Query("control_id"); controlID != "" { + filters.ControlID = controlID + } + + items, err := h.store.ListItems(c.Request.Context(), roadmapID, filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": len(items), + }) +} + +// GetItem retrieves a roadmap item +// GET /sdk/v1/roadmap-items/:id +func (h *RoadmapHandlers) GetItem(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + item, err := h.store.GetItem(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if item == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "item not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"item": item}) +} + +// UpdateItem updates a roadmap item +// PUT /sdk/v1/roadmap-items/:id +func (h *RoadmapHandlers) UpdateItem(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + item, err := h.store.GetItem(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if item == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "item not found"}) + return + } + + var input roadmap.RoadmapItemInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update fields + item.Title = input.Title + item.Description = input.Description + if input.Category != "" { + item.Category = input.Category + } + if input.Priority != "" { + item.Priority = input.Priority + } + if input.Status != "" { + item.Status = input.Status + } + item.ControlID = input.ControlID + item.RegulationRef = input.RegulationRef + item.GapID = input.GapID + item.EffortDays = input.EffortDays + item.AssigneeName = input.AssigneeName + item.Department = input.Department + item.PlannedStart = input.PlannedStart + item.PlannedEnd = input.PlannedEnd + item.Notes = input.Notes + + if err := h.store.UpdateItem(c.Request.Context(), item); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Update roadmap progress + h.store.UpdateRoadmapProgress(c.Request.Context(), item.RoadmapID) + + c.JSON(http.StatusOK, gin.H{"item": item}) +} + +// UpdateItemStatus updates just the status of a roadmap item +// PATCH /sdk/v1/roadmap-items/:id/status +func (h *RoadmapHandlers) UpdateItemStatus(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + var req struct { + Status roadmap.ItemStatus `json:"status"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + item, err := h.store.GetItem(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if item == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "item not found"}) + return + } + + item.Status = req.Status + + // Set actual dates + now := time.Now().UTC() + if req.Status == roadmap.ItemStatusInProgress && item.ActualStart == nil { + item.ActualStart = &now + } + if req.Status == roadmap.ItemStatusCompleted && item.ActualEnd == nil { + item.ActualEnd = &now + } + + if err := h.store.UpdateItem(c.Request.Context(), item); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Update roadmap progress + h.store.UpdateRoadmapProgress(c.Request.Context(), item.RoadmapID) + + c.JSON(http.StatusOK, gin.H{"item": item}) +} + +// DeleteItem deletes a roadmap item +// DELETE /sdk/v1/roadmap-items/:id +func (h *RoadmapHandlers) DeleteItem(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + item, err := h.store.GetItem(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if item == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "item not found"}) + return + } + + roadmapID := item.RoadmapID + + if err := h.store.DeleteItem(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Update roadmap progress + h.store.UpdateRoadmapProgress(c.Request.Context(), roadmapID) + + c.JSON(http.StatusOK, gin.H{"message": "item deleted"}) +} + +// ============================================================================ +// Import Workflow +// ============================================================================ + +// UploadImport handles file upload for import +// POST /sdk/v1/roadmaps/import/upload +func (h *RoadmapHandlers) UploadImport(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + + // Get file from form + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) + return + } + defer file.Close() + + // Read file content + buf := bytes.Buffer{} + if _, err := io.Copy(&buf, file); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) + return + } + + // Detect format + format := roadmap.ImportFormat("") + filename := header.Filename + contentType := header.Header.Get("Content-Type") + + // Create import job + job := &roadmap.ImportJob{ + TenantID: tenantID, + Filename: filename, + FileSize: header.Size, + ContentType: contentType, + Status: "pending", + CreatedBy: userID, + } + + if err := h.store.CreateImportJob(c.Request.Context(), job); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Parse the file + job.Status = "parsing" + h.store.UpdateImportJob(c.Request.Context(), job) + + result, err := h.parser.ParseFile(buf.Bytes(), filename, contentType) + if err != nil { + job.Status = "failed" + job.ErrorMessage = err.Error() + h.store.UpdateImportJob(c.Request.Context(), job) + + c.JSON(http.StatusBadRequest, gin.H{ + "error": "failed to parse file", + "detail": err.Error(), + }) + return + } + + // Update job with parsed data + job.Status = "parsed" + job.Format = format + job.TotalRows = result.TotalRows + job.ValidRows = result.ValidRows + job.InvalidRows = result.InvalidRows + job.ParsedItems = result.Items + + h.store.UpdateImportJob(c.Request.Context(), job) + + c.JSON(http.StatusOK, roadmap.ImportParseResponse{ + JobID: job.ID, + Status: job.Status, + TotalRows: result.TotalRows, + ValidRows: result.ValidRows, + InvalidRows: result.InvalidRows, + Items: result.Items, + ColumnMap: buildColumnMap(result.Columns), + }) +} + +// GetImportJob returns the status of an import job +// GET /sdk/v1/roadmaps/import/:jobId +func (h *RoadmapHandlers) GetImportJob(c *gin.Context) { + jobID, err := uuid.Parse(c.Param("jobId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job ID"}) + return + } + + job, err := h.store.GetImportJob(c.Request.Context(), jobID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if job == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "import job not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "job": job, + "items": job.ParsedItems, + }) +} + +// ConfirmImport confirms and executes the import +// POST /sdk/v1/roadmaps/import/:jobId/confirm +func (h *RoadmapHandlers) ConfirmImport(c *gin.Context) { + jobID, err := uuid.Parse(c.Param("jobId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid job ID"}) + return + } + + var req roadmap.ImportConfirmRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + job, err := h.store.GetImportJob(c.Request.Context(), jobID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if job == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "import job not found"}) + return + } + + if job.Status != "parsed" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "job is not ready for confirmation", + "status": job.Status, + }) + return + } + + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + + // Create or use existing roadmap + var roadmapID uuid.UUID + if req.RoadmapID != nil { + roadmapID = *req.RoadmapID + // Verify roadmap exists + r, err := h.store.GetRoadmap(c.Request.Context(), roadmapID) + if err != nil || r == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "roadmap not found"}) + return + } + } else { + // Create new roadmap + title := req.RoadmapTitle + if title == "" { + title = "Imported Roadmap - " + job.Filename + } + + r := &roadmap.Roadmap{ + TenantID: tenantID, + Title: title, + Status: "active", + CreatedBy: userID, + } + + if err := h.store.CreateRoadmap(c.Request.Context(), r); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + roadmapID = r.ID + } + + // Determine which rows to import + selectedRows := make(map[int]bool) + if len(req.SelectedRows) > 0 { + for _, row := range req.SelectedRows { + selectedRows[row] = true + } + } + + // Convert parsed items to roadmap items + var items []roadmap.RoadmapItem + var importedCount, skippedCount int + + for i, parsed := range job.ParsedItems { + // Skip invalid items + if !parsed.IsValid { + skippedCount++ + continue + } + + // Skip unselected rows if selection was specified + if len(selectedRows) > 0 && !selectedRows[parsed.RowNumber] { + skippedCount++ + continue + } + + item := roadmap.RoadmapItem{ + RoadmapID: roadmapID, + Title: parsed.Data.Title, + Description: parsed.Data.Description, + Category: parsed.Data.Category, + Priority: parsed.Data.Priority, + Status: parsed.Data.Status, + ControlID: parsed.Data.ControlID, + RegulationRef: parsed.Data.RegulationRef, + GapID: parsed.Data.GapID, + EffortDays: parsed.Data.EffortDays, + AssigneeName: parsed.Data.AssigneeName, + Department: parsed.Data.Department, + PlannedStart: parsed.Data.PlannedStart, + PlannedEnd: parsed.Data.PlannedEnd, + Notes: parsed.Data.Notes, + SourceRow: parsed.RowNumber, + SourceFile: job.Filename, + SortOrder: i, + } + + // Apply auto-mappings if requested + if req.ApplyMappings { + if parsed.MatchedControl != "" { + item.ControlID = parsed.MatchedControl + } + if parsed.MatchedRegulation != "" { + item.RegulationRef = parsed.MatchedRegulation + } + if parsed.MatchedGap != "" { + item.GapID = parsed.MatchedGap + } + } + + // Set defaults + if item.Status == "" { + item.Status = roadmap.ItemStatusPlanned + } + if item.Priority == "" { + item.Priority = roadmap.ItemPriorityMedium + } + if item.Category == "" { + item.Category = roadmap.ItemCategoryTechnical + } + + items = append(items, item) + importedCount++ + } + + // Bulk create items + if len(items) > 0 { + if err := h.store.BulkCreateItems(c.Request.Context(), items); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + // Update roadmap progress + h.store.UpdateRoadmapProgress(c.Request.Context(), roadmapID) + + // Update job status + now := time.Now().UTC() + job.Status = "completed" + job.RoadmapID = &roadmapID + job.ImportedItems = importedCount + job.CompletedAt = &now + h.store.UpdateImportJob(c.Request.Context(), job) + + c.JSON(http.StatusOK, roadmap.ImportConfirmResponse{ + RoadmapID: roadmapID, + ImportedItems: importedCount, + SkippedItems: skippedCount, + Message: "Import completed successfully", + }) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +func buildColumnMap(columns []roadmap.DetectedColumn) map[string]string { + result := make(map[string]string) + for _, col := range columns { + if col.MappedTo != "" { + result[col.Header] = col.MappedTo + } + } + return result +} diff --git a/ai-compliance-sdk/internal/api/handlers/ucca_handlers.go b/ai-compliance-sdk/internal/api/handlers/ucca_handlers.go new file mode 100644 index 0000000..15a0331 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/ucca_handlers.go @@ -0,0 +1,1055 @@ +package handlers + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "strings" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/breakpilot/ai-compliance-sdk/internal/ucca" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// UCCAHandlers handles UCCA-related API endpoints +type UCCAHandlers struct { + store *ucca.Store + escalationStore *ucca.EscalationStore + policyEngine *ucca.PolicyEngine + legacyRuleEngine *ucca.RuleEngine // Keep for backwards compatibility + providerRegistry *llm.ProviderRegistry + legalRAGClient *ucca.LegalRAGClient + escalationTrigger *ucca.EscalationTrigger +} + +// NewUCCAHandlers creates new UCCA handlers +func NewUCCAHandlers(store *ucca.Store, escalationStore *ucca.EscalationStore, providerRegistry *llm.ProviderRegistry) *UCCAHandlers { + // Try to create YAML-based policy engine first + policyEngine, err := ucca.NewPolicyEngine() + if err != nil { + // Log warning but don't fail - fall back to legacy engine + fmt.Printf("Warning: Could not load YAML policy engine: %v. Falling back to legacy rules.\n", err) + } + + return &UCCAHandlers{ + store: store, + escalationStore: escalationStore, + policyEngine: policyEngine, // May be nil if YAML loading failed + legacyRuleEngine: ucca.NewRuleEngine(), + providerRegistry: providerRegistry, + legalRAGClient: ucca.NewLegalRAGClient(), + escalationTrigger: ucca.DefaultEscalationTrigger(), + } +} + +// ============================================================================ +// POST /sdk/v1/ucca/assess - Evaluate a use case +// ============================================================================ + +// Assess evaluates a use case intake and creates an assessment +func (h *UCCAHandlers) Assess(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + userID := rbac.GetUserID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + var intake ucca.UseCaseIntake + if err := c.ShouldBindJSON(&intake); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Run evaluation - prefer YAML-based policy engine if available + var result *ucca.AssessmentResult + var policyVersion string + if h.policyEngine != nil { + result = h.policyEngine.Evaluate(&intake) + policyVersion = h.policyEngine.GetPolicyVersion() + } else { + result = h.legacyRuleEngine.Evaluate(&intake) + policyVersion = "1.0.0-legacy" + } + + // Calculate hash of use case text + hash := sha256.Sum256([]byte(intake.UseCaseText)) + hashStr := hex.EncodeToString(hash[:]) + + // Create assessment record + assessment := &ucca.Assessment{ + TenantID: tenantID, + Title: intake.Title, + PolicyVersion: policyVersion, + Status: "completed", + Intake: intake, + UseCaseTextStored: intake.StoreRawText, + UseCaseTextHash: hashStr, + Feasibility: result.Feasibility, + RiskLevel: result.RiskLevel, + Complexity: result.Complexity, + RiskScore: result.RiskScore, + TriggeredRules: result.TriggeredRules, + RequiredControls: result.RequiredControls, + RecommendedArchitecture: result.RecommendedArchitecture, + ForbiddenPatterns: result.ForbiddenPatterns, + ExampleMatches: result.ExampleMatches, + DSFARecommended: result.DSFARecommended, + Art22Risk: result.Art22Risk, + TrainingAllowed: result.TrainingAllowed, + Domain: intake.Domain, + CreatedBy: userID, + } + + // Clear use case text if not opted in to store + if !intake.StoreRawText { + assessment.Intake.UseCaseText = "" + } + + // Generate title if not provided + if assessment.Title == "" { + assessment.Title = fmt.Sprintf("Assessment vom %s", time.Now().Format("02.01.2006 15:04")) + } + + // Save to database + if err := h.store.CreateAssessment(c.Request.Context(), assessment); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Automatically create escalation based on assessment result + var escalation *ucca.Escalation + if h.escalationStore != nil && h.escalationTrigger != nil { + level, reason := h.escalationTrigger.DetermineEscalationLevel(result) + + // Calculate due date based on SLA + responseHours, _ := ucca.GetDefaultSLA(level) + var dueDate *time.Time + if responseHours > 0 { + due := time.Now().UTC().Add(time.Duration(responseHours) * time.Hour) + dueDate = &due + } + + escalation = &ucca.Escalation{ + TenantID: tenantID, + AssessmentID: assessment.ID, + EscalationLevel: level, + EscalationReason: reason, + Status: ucca.EscalationStatusPending, + DueDate: dueDate, + } + + // For E0, auto-approve + if level == ucca.EscalationLevelE0 { + escalation.Status = ucca.EscalationStatusApproved + approveDecision := ucca.EscalationDecisionApprove + escalation.Decision = &approveDecision + now := time.Now().UTC() + escalation.DecisionAt = &now + autoNotes := "Automatische Freigabe (E0)" + escalation.DecisionNotes = &autoNotes + } + + if err := h.escalationStore.CreateEscalation(c.Request.Context(), escalation); err != nil { + // Log error but don't fail the assessment creation + fmt.Printf("Warning: Could not create escalation: %v\n", err) + escalation = nil + } else { + // Add history entry + h.escalationStore.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{ + EscalationID: escalation.ID, + Action: "auto_created", + NewStatus: string(escalation.Status), + NewLevel: string(escalation.EscalationLevel), + ActorID: userID, + Notes: "Automatisch erstellt bei Assessment", + }) + + // For E1/E2/E3, try to auto-assign + if level != ucca.EscalationLevelE0 { + role := ucca.GetRoleForLevel(level) + reviewer, err := h.escalationStore.GetNextAvailableReviewer(c.Request.Context(), tenantID, role) + if err == nil && reviewer != nil { + h.escalationStore.AssignEscalation(c.Request.Context(), escalation.ID, reviewer.UserID, role) + h.escalationStore.IncrementReviewerCount(c.Request.Context(), reviewer.UserID) + h.escalationStore.AddEscalationHistory(c.Request.Context(), &ucca.EscalationHistory{ + EscalationID: escalation.ID, + Action: "auto_assigned", + OldStatus: string(ucca.EscalationStatusPending), + NewStatus: string(ucca.EscalationStatusAssigned), + ActorID: userID, + Notes: "Automatisch zugewiesen an: " + reviewer.UserName, + }) + } + } + } + } + + c.JSON(http.StatusCreated, ucca.AssessResponse{ + Assessment: *assessment, + Result: *result, + Escalation: escalation, + }) +} + +// ============================================================================ +// GET /sdk/v1/ucca/assessments - List all assessments +// ============================================================================ + +// ListAssessments returns all assessments for a tenant +func (h *UCCAHandlers) ListAssessments(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + filters := &ucca.AssessmentFilters{ + Feasibility: c.Query("feasibility"), + Domain: c.Query("domain"), + RiskLevel: c.Query("risk_level"), + } + + assessments, err := h.store.ListAssessments(c.Request.Context(), tenantID, filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"assessments": assessments}) +} + +// ============================================================================ +// GET /sdk/v1/ucca/assessments/:id - Get single assessment +// ============================================================================ + +// GetAssessment returns a single assessment by ID +func (h *UCCAHandlers) GetAssessment(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + assessment, err := h.store.GetAssessment(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if assessment == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + c.JSON(http.StatusOK, assessment) +} + +// ============================================================================ +// DELETE /sdk/v1/ucca/assessments/:id - Delete assessment +// ============================================================================ + +// DeleteAssessment deletes an assessment +func (h *UCCAHandlers) DeleteAssessment(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + if err := h.store.DeleteAssessment(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// ============================================================================ +// GET /sdk/v1/ucca/patterns - Get pattern catalog +// ============================================================================ + +// ListPatterns returns all available architecture patterns +func (h *UCCAHandlers) ListPatterns(c *gin.Context) { + var response []gin.H + + // Prefer YAML-based patterns if available + if h.policyEngine != nil { + yamlPatterns := h.policyEngine.GetAllPatterns() + response = make([]gin.H, 0, len(yamlPatterns)) + for _, p := range yamlPatterns { + response = append(response, gin.H{ + "id": p.ID, + "title": p.Title, + "description": p.Description, + "benefit": p.Benefit, + "effort": p.Effort, + "risk_reduction": p.RiskReduction, + }) + } + } else { + // Fall back to legacy patterns + patterns := ucca.GetAllPatterns() + response = make([]gin.H, len(patterns)) + for i, p := range patterns { + response[i] = gin.H{ + "id": p.ID, + "title": p.Title, + "title_de": p.TitleDE, + "description": p.Description, + "description_de": p.DescriptionDE, + "benefits": p.Benefits, + "requirements": p.Requirements, + } + } + } + + c.JSON(http.StatusOK, gin.H{"patterns": response}) +} + +// ============================================================================ +// GET /sdk/v1/ucca/controls - Get control catalog +// ============================================================================ + +// ListControls returns all available compliance controls +func (h *UCCAHandlers) ListControls(c *gin.Context) { + var response []gin.H + + // Prefer YAML-based controls if available + if h.policyEngine != nil { + yamlControls := h.policyEngine.GetAllControls() + response = make([]gin.H, 0, len(yamlControls)) + for _, ctrl := range yamlControls { + response = append(response, gin.H{ + "id": ctrl.ID, + "title": ctrl.Title, + "description": ctrl.Description, + "gdpr_ref": ctrl.GDPRRef, + "effort": ctrl.Effort, + }) + } + } else { + // Fall back to legacy controls + for id, ctrl := range ucca.ControlLibrary { + response = append(response, gin.H{ + "id": id, + "title": ctrl.Title, + "description": ctrl.Description, + "severity": ctrl.Severity, + "category": ctrl.Category, + "gdpr_ref": ctrl.GDPRRef, + }) + } + } + + c.JSON(http.StatusOK, gin.H{"controls": response}) +} + +// ============================================================================ +// GET /sdk/v1/ucca/problem-solutions - Get problem-solution mappings +// ============================================================================ + +// ListProblemSolutions returns all problem-solution mappings +func (h *UCCAHandlers) ListProblemSolutions(c *gin.Context) { + if h.policyEngine == nil { + c.JSON(http.StatusOK, gin.H{ + "problem_solutions": []gin.H{}, + "message": "Problem-solutions only available with YAML policy engine", + }) + return + } + + problemSolutions := h.policyEngine.GetProblemSolutions() + response := make([]gin.H, len(problemSolutions)) + for i, ps := range problemSolutions { + solutions := make([]gin.H, len(ps.Solutions)) + for j, sol := range ps.Solutions { + solutions[j] = gin.H{ + "id": sol.ID, + "title": sol.Title, + "pattern": sol.Pattern, + "control": sol.Control, + "removes_problem": sol.RemovesProblem, + "team_question": sol.TeamQuestion, + } + } + + triggers := make([]gin.H, len(ps.Triggers)) + for j, t := range ps.Triggers { + triggers[j] = gin.H{ + "rule": t.Rule, + "without_control": t.WithoutControl, + } + } + + response[i] = gin.H{ + "problem_id": ps.ProblemID, + "title": ps.Title, + "triggers": triggers, + "solutions": solutions, + } + } + + c.JSON(http.StatusOK, gin.H{"problem_solutions": response}) +} + +// ============================================================================ +// GET /sdk/v1/ucca/examples - Get example catalog +// ============================================================================ + +// ListExamples returns all available didactic examples +func (h *UCCAHandlers) ListExamples(c *gin.Context) { + examples := ucca.GetAllExamples() + + // Convert to API response format + response := make([]gin.H, len(examples)) + for i, ex := range examples { + response[i] = gin.H{ + "id": ex.ID, + "title": ex.Title, + "title_de": ex.TitleDE, + "description": ex.Description, + "description_de": ex.DescriptionDE, + "domain": ex.Domain, + "outcome": ex.Outcome, + "outcome_de": ex.OutcomeDE, + "lessons": ex.Lessons, + "lessons_de": ex.LessonsDE, + } + } + + c.JSON(http.StatusOK, gin.H{"examples": response}) +} + +// ============================================================================ +// GET /sdk/v1/ucca/rules - Get all rules (transparency) +// ============================================================================ + +// ListRules returns all rules for transparency +func (h *UCCAHandlers) ListRules(c *gin.Context) { + var response []gin.H + var policyVersion string + + // Prefer YAML-based rules if available + if h.policyEngine != nil { + yamlRules := h.policyEngine.GetAllRules() + policyVersion = h.policyEngine.GetPolicyVersion() + response = make([]gin.H, len(yamlRules)) + for i, r := range yamlRules { + response[i] = gin.H{ + "code": r.ID, + "category": r.Category, + "title": r.Title, + "description": r.Description, + "severity": r.Severity, + "gdpr_ref": r.GDPRRef, + "rationale": r.Rationale, + "controls": r.Effect.ControlsAdd, + "patterns": r.Effect.SuggestedPatterns, + "risk_add": r.Effect.RiskAdd, + } + } + } else { + // Fall back to legacy rules + rules := h.legacyRuleEngine.GetRules() + policyVersion = "1.0.0-legacy" + response = make([]gin.H, len(rules)) + for i, r := range rules { + response[i] = gin.H{ + "code": r.Code, + "category": r.Category, + "title": r.Title, + "title_de": r.TitleDE, + "description": r.Description, + "description_de": r.DescriptionDE, + "severity": r.Severity, + "score_delta": r.ScoreDelta, + "gdpr_ref": r.GDPRRef, + "controls": r.Controls, + "patterns": r.Patterns, + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "rules": response, + "total": len(response), + "policy_version": policyVersion, + "categories": []string{ + "A. Datenklassifikation", + "B. Zweck & Rechtsgrundlage", + "C. Automatisierung", + "D. Training & Modell", + "E. Hosting", + "F. Domain-spezifisch", + "G. Aggregation", + }, + }) +} + +// ============================================================================ +// POST /sdk/v1/ucca/assessments/:id/explain - Generate LLM explanation +// ============================================================================ + +// Explain generates an LLM explanation for an assessment +func (h *UCCAHandlers) Explain(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + var req ucca.ExplainRequest + if err := c.ShouldBindJSON(&req); err != nil { + // Default to German + req.Language = "de" + } + if req.Language == "" { + req.Language = "de" + } + + // Get assessment + assessment, err := h.store.GetAssessment(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if assessment == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + // Get legal context from RAG + var legalContext *ucca.LegalContext + var legalContextStr string + if h.legalRAGClient != nil { + legalContext, err = h.legalRAGClient.GetLegalContextForAssessment(c.Request.Context(), assessment) + if err != nil { + // Log error but continue without legal context + fmt.Printf("Warning: Could not get legal context: %v\n", err) + } else { + legalContextStr = h.legalRAGClient.FormatLegalContextForPrompt(legalContext) + } + } + + // Build prompt for LLM with legal context + prompt := buildExplanationPrompt(assessment, req.Language, legalContextStr) + + // Call LLM + chatReq := &llm.ChatRequest{ + Messages: []llm.Message{ + {Role: "system", Content: "Du bist ein Datenschutz-Experte, der DSGVO-Compliance-Bewertungen erklärt. Antworte klar, präzise und auf Deutsch. Beziehe dich auf die angegebenen Rechtsgrundlagen."}, + {Role: "user", Content: prompt}, + }, + MaxTokens: 2000, + Temperature: 0.3, + } + response, err := h.providerRegistry.Chat(c.Request.Context(), chatReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "LLM call failed: " + err.Error()}) + return + } + + explanation := response.Message.Content + model := response.Model + + // Save explanation to database + if err := h.store.UpdateExplanation(c.Request.Context(), id, explanation, model); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, ucca.ExplainResponse{ + ExplanationText: explanation, + GeneratedAt: time.Now().UTC(), + Model: model, + LegalContext: legalContext, + }) +} + +// buildExplanationPrompt creates the prompt for the LLM explanation +func buildExplanationPrompt(assessment *ucca.Assessment, language string, legalContext string) string { + var buf bytes.Buffer + + buf.WriteString("Erkläre die folgende DSGVO-Compliance-Bewertung für einen KI-Use-Case in verständlicher Sprache:\n\n") + + buf.WriteString(fmt.Sprintf("**Ergebnis:** %s\n", assessment.Feasibility)) + buf.WriteString(fmt.Sprintf("**Risikostufe:** %s\n", assessment.RiskLevel)) + buf.WriteString(fmt.Sprintf("**Risiko-Score:** %d/100\n", assessment.RiskScore)) + buf.WriteString(fmt.Sprintf("**Komplexität:** %s\n\n", assessment.Complexity)) + + if len(assessment.TriggeredRules) > 0 { + buf.WriteString("**Ausgelöste Regeln:**\n") + for _, r := range assessment.TriggeredRules { + buf.WriteString(fmt.Sprintf("- %s (%s): %s\n", r.Code, r.Severity, r.Title)) + } + buf.WriteString("\n") + } + + if len(assessment.RequiredControls) > 0 { + buf.WriteString("**Erforderliche Maßnahmen:**\n") + for _, c := range assessment.RequiredControls { + buf.WriteString(fmt.Sprintf("- %s: %s\n", c.Title, c.Description)) + } + buf.WriteString("\n") + } + + if assessment.DSFARecommended { + buf.WriteString("**Hinweis:** Eine Datenschutz-Folgenabschätzung (DSFA) wird empfohlen.\n\n") + } + + if assessment.Art22Risk { + buf.WriteString("**Warnung:** Es besteht ein Risiko unter Art. 22 DSGVO (automatisierte Einzelentscheidungen).\n\n") + } + + // Include legal context from RAG if available + if legalContext != "" { + buf.WriteString(legalContext) + } + + buf.WriteString("\nBitte erkläre:\n") + buf.WriteString("1. Warum dieses Ergebnis zustande kam (mit Bezug auf die angegebenen Rechtsgrundlagen)\n") + buf.WriteString("2. Welche konkreten Schritte unternommen werden sollten\n") + buf.WriteString("3. Welche Alternativen es gibt, falls der Use Case abgelehnt wurde\n") + buf.WriteString("4. Welche spezifischen Artikel aus DSGVO/AI Act beachtet werden müssen\n") + + return buf.String() +} + +// ============================================================================ +// GET /sdk/v1/ucca/export/:id - Export assessment +// ============================================================================ + +// Export exports an assessment as JSON or Markdown +func (h *UCCAHandlers) Export(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) + return + } + + format := c.DefaultQuery("format", "json") + + assessment, err := h.store.GetAssessment(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if assessment == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + if format == "md" { + markdown := generateMarkdownExport(assessment) + c.Header("Content-Type", "text/markdown; charset=utf-8") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=ucca_assessment_%s.md", id.String()[:8])) + c.Data(http.StatusOK, "text/markdown; charset=utf-8", []byte(markdown)) + return + } + + // JSON export + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=ucca_assessment_%s.json", id.String()[:8])) + c.JSON(http.StatusOK, gin.H{ + "exported_at": time.Now().UTC().Format(time.RFC3339), + "assessment": assessment, + }) +} + +// generateMarkdownExport creates a Markdown export of the assessment +func generateMarkdownExport(a *ucca.Assessment) string { + var buf bytes.Buffer + + buf.WriteString("# UCCA Use-Case Assessment\n\n") + buf.WriteString(fmt.Sprintf("**ID:** %s\n", a.ID.String())) + buf.WriteString(fmt.Sprintf("**Erstellt:** %s\n", a.CreatedAt.Format("02.01.2006 15:04"))) + buf.WriteString(fmt.Sprintf("**Domain:** %s\n\n", a.Domain)) + + buf.WriteString("## Ergebnis\n\n") + buf.WriteString(fmt.Sprintf("| Kriterium | Wert |\n")) + buf.WriteString("|-----------|------|\n") + buf.WriteString(fmt.Sprintf("| Machbarkeit | **%s** |\n", a.Feasibility)) + buf.WriteString(fmt.Sprintf("| Risikostufe | %s |\n", a.RiskLevel)) + buf.WriteString(fmt.Sprintf("| Risiko-Score | %d/100 |\n", a.RiskScore)) + buf.WriteString(fmt.Sprintf("| Komplexität | %s |\n", a.Complexity)) + buf.WriteString(fmt.Sprintf("| DSFA empfohlen | %t |\n", a.DSFARecommended)) + buf.WriteString(fmt.Sprintf("| Art. 22 Risiko | %t |\n", a.Art22Risk)) + buf.WriteString(fmt.Sprintf("| Training erlaubt | %s |\n\n", a.TrainingAllowed)) + + if len(a.TriggeredRules) > 0 { + buf.WriteString("## Ausgelöste Regeln\n\n") + buf.WriteString("| Code | Titel | Schwere | Score |\n") + buf.WriteString("|------|-------|---------|-------|\n") + for _, r := range a.TriggeredRules { + buf.WriteString(fmt.Sprintf("| %s | %s | %s | +%d |\n", r.Code, r.Title, r.Severity, r.ScoreDelta)) + } + buf.WriteString("\n") + } + + if len(a.RequiredControls) > 0 { + buf.WriteString("## Erforderliche Kontrollen\n\n") + for _, c := range a.RequiredControls { + buf.WriteString(fmt.Sprintf("### %s\n", c.Title)) + buf.WriteString(fmt.Sprintf("%s\n\n", c.Description)) + if c.GDPRRef != "" { + buf.WriteString(fmt.Sprintf("*Referenz: %s*\n\n", c.GDPRRef)) + } + } + } + + if len(a.RecommendedArchitecture) > 0 { + buf.WriteString("## Empfohlene Architektur-Patterns\n\n") + for _, p := range a.RecommendedArchitecture { + buf.WriteString(fmt.Sprintf("### %s\n", p.Title)) + buf.WriteString(fmt.Sprintf("%s\n\n", p.Description)) + } + } + + if len(a.ForbiddenPatterns) > 0 { + buf.WriteString("## Verbotene Patterns\n\n") + for _, p := range a.ForbiddenPatterns { + buf.WriteString(fmt.Sprintf("### %s\n", p.Title)) + buf.WriteString(fmt.Sprintf("**Grund:** %s\n\n", p.Reason)) + } + } + + if a.ExplanationText != nil && *a.ExplanationText != "" { + buf.WriteString("## KI-Erklärung\n\n") + buf.WriteString(*a.ExplanationText) + buf.WriteString("\n\n") + } + + buf.WriteString("---\n") + buf.WriteString(fmt.Sprintf("*Generiert mit UCCA Policy Version %s*\n", a.PolicyVersion)) + + return buf.String() +} + +// ============================================================================ +// GET /sdk/v1/ucca/stats - Get statistics +// ============================================================================ + +// GetStats returns UCCA statistics for a tenant +func (h *UCCAHandlers) GetStats(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + if tenantID == uuid.Nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"}) + return + } + + stats, err := h.store.GetStats(c.Request.Context(), tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// ============================================================================ +// POST /sdk/v1/ucca/wizard/ask - Legal Assistant for Wizard +// ============================================================================ + +// WizardAskRequest represents a question to the Legal Assistant +type WizardAskRequest struct { + Question string `json:"question" binding:"required"` + StepNumber int `json:"step_number"` + FieldID string `json:"field_id,omitempty"` // Optional: Specific field context + CurrentData map[string]interface{} `json:"current_data,omitempty"` // Current wizard answers +} + +// WizardAskResponse represents the Legal Assistant response +type WizardAskResponse struct { + Answer string `json:"answer"` + Sources []LegalSource `json:"sources,omitempty"` + RelatedFields []string `json:"related_fields,omitempty"` + GeneratedAt time.Time `json:"generated_at"` + Model string `json:"model"` +} + +// LegalSource represents a legal reference used in the answer +type LegalSource struct { + Regulation string `json:"regulation"` + Article string `json:"article,omitempty"` + Text string `json:"text,omitempty"` +} + +// AskWizardQuestion handles legal questions from the wizard +func (h *UCCAHandlers) AskWizardQuestion(c *gin.Context) { + var req WizardAskRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Build context-aware query for Legal RAG + ragQuery := buildWizardRAGQuery(req) + + // Search legal corpus for relevant context + var legalResults []ucca.LegalSearchResult + var sources []LegalSource + if h.legalRAGClient != nil { + results, err := h.legalRAGClient.Search(c.Request.Context(), ragQuery, nil, 5) + if err != nil { + // Log but continue without RAG context + fmt.Printf("Warning: Legal RAG search failed: %v\n", err) + } else { + legalResults = results + // Convert to sources + sources = make([]LegalSource, len(results)) + for i, r := range results { + sources[i] = LegalSource{ + Regulation: r.RegulationName, + Article: r.Article, + Text: truncateText(r.Text, 200), + } + } + } + } + + // Build prompt for LLM + prompt := buildWizardAssistantPrompt(req, legalResults) + + // Build system prompt with step context + systemPrompt := buildWizardSystemPrompt(req.StepNumber) + + // Call LLM + chatReq := &llm.ChatRequest{ + Messages: []llm.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: prompt}, + }, + MaxTokens: 1024, + Temperature: 0.3, // Low temperature for precise legal answers + } + response, err := h.providerRegistry.Chat(c.Request.Context(), chatReq) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "LLM call failed: " + err.Error()}) + return + } + + // Identify related wizard fields based on question + relatedFields := identifyRelatedFields(req.Question) + + c.JSON(http.StatusOK, WizardAskResponse{ + Answer: response.Message.Content, + Sources: sources, + RelatedFields: relatedFields, + GeneratedAt: time.Now().UTC(), + Model: response.Model, + }) +} + +// buildWizardRAGQuery creates an optimized query for Legal RAG search +func buildWizardRAGQuery(req WizardAskRequest) string { + // Start with the user's question + query := req.Question + + // Add context based on step number + stepContext := map[int]string{ + 1: "KI-Anwendung Use Case", + 2: "personenbezogene Daten Datenkategorien DSGVO Art. 4 Art. 9", + 3: "Verarbeitungszweck Profiling Scoring automatisierte Entscheidung Art. 22", + 4: "Hosting Cloud On-Premises Auftragsverarbeitung", + 5: "Standardvertragsklauseln SCC Drittlandtransfer TIA Transfer Impact Assessment Art. 44 Art. 46", + 6: "KI-Modell Training RAG Finetuning", + 7: "Auftragsverarbeitungsvertrag AVV DSFA Verarbeitungsverzeichnis Art. 28 Art. 30 Art. 35", + 8: "Automatisierung Human-in-the-Loop Art. 22 AI Act", + } + + if context, ok := stepContext[req.StepNumber]; ok { + query = query + " " + context + } + + return query +} + +// buildWizardSystemPrompt creates the system prompt for the Legal Assistant +func buildWizardSystemPrompt(stepNumber int) string { + basePrompt := `Du bist ein freundlicher Rechtsassistent, der Nutzern hilft, +datenschutzrechtliche Begriffe und Anforderungen zu verstehen. + +DEINE AUFGABE: +- Erkläre rechtliche Begriffe in einfacher, verständlicher Sprache +- Beantworte Fragen zum aktuellen Wizard-Schritt +- Hilf dem Nutzer, die richtigen Antworten im Wizard zu geben +- Verweise auf relevante Rechtsquellen (DSGVO-Artikel, etc.) + +WICHTIGE REGELN: +- Antworte IMMER auf Deutsch +- Verwende einfache Sprache, keine Juristensprache +- Gib konkrete Beispiele wenn möglich +- Bei Unsicherheit empfehle die Rücksprache mit einem Datenschutzbeauftragten +- Du darfst KEINE Rechtsberatung geben, nur erklären + +ANTWORT-FORMAT: +- Kurz und prägnant (max. 3-4 Sätze für einfache Fragen) +- Strukturiert mit Aufzählungen bei komplexen Themen +- Immer mit Quellenangabe am Ende (z.B. "Siehe: DSGVO Art. 9")` + + // Add step-specific context + stepContexts := map[int]string{ + 1: "\n\nKONTEXT: Der Nutzer befindet sich im ersten Schritt und gibt grundlegende Informationen zum KI-Vorhaben ein.", + 2: "\n\nKONTEXT: Der Nutzer gibt an, welche Datenarten verarbeitet werden. Erkläre die Unterschiede zwischen personenbezogenen Daten, Art. 9 Daten (besondere Kategorien), biometrischen Daten, etc.", + 3: "\n\nKONTEXT: Der Nutzer gibt den Verarbeitungszweck an. Erkläre Begriffe wie Profiling, Scoring, systematische Überwachung, automatisierte Entscheidungen mit rechtlicher Wirkung.", + 4: "\n\nKONTEXT: Der Nutzer gibt Hosting-Informationen an. Erkläre Cloud vs. On-Premises, wann Drittlandtransfer vorliegt, Unterschiede zwischen EU/EWR und Drittländern.", + 5: "\n\nKONTEXT: Der Nutzer beantwortet Fragen zu SCC und TIA. Erkläre Standardvertragsklauseln (SCC), Transfer Impact Assessment (TIA), das Data Privacy Framework (DPF), und wann welche Instrumente erforderlich sind.", + 6: "\n\nKONTEXT: Der Nutzer gibt KI-Modell-Informationen an. Erkläre RAG vs. Training/Finetuning, warum Training mit personenbezogenen Daten problematisch ist, und welche Opt-Out-Klauseln wichtig sind.", + 7: "\n\nKONTEXT: Der Nutzer beantwortet Fragen zu Verträgen. Erkläre den Auftragsverarbeitungsvertrag (AVV), die Datenschutz-Folgenabschätzung (DSFA), das Verarbeitungsverzeichnis (VVT), und wann diese erforderlich sind.", + 8: "\n\nKONTEXT: Der Nutzer gibt den Automatisierungsgrad an. Erkläre Human-in-the-Loop, Art. 22 DSGVO (automatisierte Einzelentscheidungen), und die Anforderungen des AI Acts.", + } + + if context, ok := stepContexts[stepNumber]; ok { + basePrompt += context + } + + return basePrompt +} + +// buildWizardAssistantPrompt creates the user prompt with legal context +func buildWizardAssistantPrompt(req WizardAskRequest, legalResults []ucca.LegalSearchResult) string { + var buf bytes.Buffer + + buf.WriteString(fmt.Sprintf("FRAGE DES NUTZERS:\n%s\n\n", req.Question)) + + // Add legal context if available + if len(legalResults) > 0 { + buf.WriteString("RELEVANTE RECHTSGRUNDLAGEN (aus unserer Bibliothek):\n\n") + for i, result := range legalResults { + buf.WriteString(fmt.Sprintf("%d. %s", i+1, result.RegulationName)) + if result.Article != "" { + buf.WriteString(fmt.Sprintf(" - Art. %s", result.Article)) + if result.Paragraph != "" { + buf.WriteString(fmt.Sprintf(" Abs. %s", result.Paragraph)) + } + } + buf.WriteString("\n") + buf.WriteString(fmt.Sprintf(" %s\n\n", truncateText(result.Text, 300))) + } + } + + // Add field context if provided + if req.FieldID != "" { + buf.WriteString(fmt.Sprintf("AKTUELLES FELD: %s\n\n", req.FieldID)) + } + + buf.WriteString("Bitte beantworte die Frage kurz und verständlich. Verwende die angegebenen Rechtsgrundlagen als Referenz.") + + return buf.String() +} + +// identifyRelatedFields identifies wizard fields related to the question +func identifyRelatedFields(question string) []string { + question = strings.ToLower(question) + var related []string + + // Map keywords to wizard field IDs + keywordMapping := map[string][]string{ + "personenbezogen": {"data_types.personal_data"}, + "art. 9": {"data_types.article_9_data"}, + "sensibel": {"data_types.article_9_data"}, + "gesundheit": {"data_types.article_9_data"}, + "minderjährig": {"data_types.minor_data"}, + "kinder": {"data_types.minor_data"}, + "biometrisch": {"data_types.biometric_data"}, + "gesicht": {"data_types.biometric_data"}, + "kennzeichen": {"data_types.license_plates"}, + "standort": {"data_types.location_data"}, + "gps": {"data_types.location_data"}, + "profiling": {"purpose.profiling"}, + "scoring": {"purpose.evaluation_scoring"}, + "überwachung": {"processing.systematic_monitoring"}, + "automatisch": {"outputs.decision_with_legal_effect", "automation"}, + "entscheidung": {"outputs.decision_with_legal_effect"}, + "cloud": {"hosting.type", "hosting.region"}, + "on-premises": {"hosting.type"}, + "lokal": {"hosting.type"}, + "scc": {"contracts.scc.present", "contracts.scc.version"}, + "standardvertrags": {"contracts.scc.present"}, + "drittland": {"hosting.region", "provider.location"}, + "usa": {"hosting.region", "provider.location", "provider.dpf_certified"}, + "transfer": {"hosting.region", "contracts.tia.present"}, + "tia": {"contracts.tia.present", "contracts.tia.result"}, + "dpf": {"provider.dpf_certified"}, + "data privacy": {"provider.dpf_certified"}, + "avv": {"contracts.avv.present"}, + "auftragsverarbeitung": {"contracts.avv.present"}, + "dsfa": {"governance.dsfa_completed"}, + "folgenabschätzung": {"governance.dsfa_completed"}, + "verarbeitungsverzeichnis": {"governance.vvt_entry"}, + "training": {"model_usage.training", "provider.uses_data_for_training"}, + "finetuning": {"model_usage.training"}, + "rag": {"model_usage.rag"}, + "human": {"processing.human_oversight"}, + "aufsicht": {"processing.human_oversight"}, + } + + seen := make(map[string]bool) + for keyword, fields := range keywordMapping { + if strings.Contains(question, keyword) { + for _, field := range fields { + if !seen[field] { + related = append(related, field) + seen[field] = true + } + } + } + } + + return related +} + +// ============================================================================ +// GET /sdk/v1/ucca/wizard/schema - Get Wizard Schema +// ============================================================================ + +// GetWizardSchema returns the wizard schema for the frontend +func (h *UCCAHandlers) GetWizardSchema(c *gin.Context) { + // For now, return a static schema info + // In future, this could be loaded from the YAML file + c.JSON(http.StatusOK, gin.H{ + "version": "1.1", + "total_steps": 8, + "default_mode": "simple", + "legal_assistant": gin.H{ + "enabled": true, + "endpoint": "/sdk/v1/ucca/wizard/ask", + "max_tokens": 1024, + "example_questions": []string{ + "Was sind personenbezogene Daten?", + "Was ist der Unterschied zwischen AVV und SCC?", + "Brauche ich ein TIA?", + "Was bedeutet Profiling?", + "Was ist Art. 9 DSGVO?", + "Wann brauche ich eine DSFA?", + "Was ist das Data Privacy Framework?", + }, + }, + "steps": []gin.H{ + {"number": 1, "title": "Grundlegende Informationen", "icon": "info"}, + {"number": 2, "title": "Welche Daten werden verarbeitet?", "icon": "database"}, + {"number": 3, "title": "Wofür wird die KI eingesetzt?", "icon": "target"}, + {"number": 4, "title": "Wo läuft die KI?", "icon": "server"}, + {"number": 5, "title": "Internationaler Datentransfer", "icon": "globe"}, + {"number": 6, "title": "KI-Modell und Training", "icon": "brain"}, + {"number": 7, "title": "Verträge & Compliance", "icon": "file-contract"}, + {"number": 8, "title": "Automatisierung & Kontrolle", "icon": "user-check"}, + }, + }) +} + +// ============================================================================ +// Helper functions +// ============================================================================ + +// truncateText truncates a string to maxLen characters +func truncateText(text string, maxLen int) string { + if len(text) <= maxLen { + return text + } + return text[:maxLen] + "..." +} diff --git a/ai-compliance-sdk/internal/api/handlers/ucca_handlers_test.go b/ai-compliance-sdk/internal/api/handlers/ucca_handlers_test.go new file mode 100644 index 0000000..f010052 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/ucca_handlers_test.go @@ -0,0 +1,626 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/breakpilot/ai-compliance-sdk/internal/ucca" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// getProjectRoot returns the project root directory +func getProjectRoot(t *testing.T) string { + dir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("Could not find project root (no go.mod found)") + } + dir = parent + } +} + +// mockTenantContext sets up a gin context with tenant ID +func mockTenantContext(c *gin.Context, tenantID, userID uuid.UUID) { + c.Set("tenant_id", tenantID) + c.Set("user_id", userID) +} + +// ============================================================================ +// Policy Engine Integration Tests (No DB) +// ============================================================================ + +func TestUCCAHandlers_ListPatterns(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := ucca.NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Skipf("Skipping test - could not load policy engine: %v", err) + } + + handler := &UCCAHandlers{ + policyEngine: engine, + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler.ListPatterns(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + patterns, ok := response["patterns"].([]interface{}) + if !ok { + t.Fatal("Expected patterns array in response") + } + + if len(patterns) == 0 { + t.Error("Expected at least some patterns") + } +} + +func TestUCCAHandlers_ListControls(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := ucca.NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Skipf("Skipping test - could not load policy engine: %v", err) + } + + handler := &UCCAHandlers{ + policyEngine: engine, + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler.ListControls(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + controls, ok := response["controls"].([]interface{}) + if !ok { + t.Fatal("Expected controls array in response") + } + + if len(controls) == 0 { + t.Error("Expected at least some controls") + } +} + +func TestUCCAHandlers_ListRules(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := ucca.NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Skipf("Skipping test - could not load policy engine: %v", err) + } + + handler := &UCCAHandlers{ + policyEngine: engine, + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler.ListRules(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + rules, ok := response["rules"].([]interface{}) + if !ok { + t.Fatal("Expected rules array in response") + } + + if len(rules) == 0 { + t.Error("Expected at least some rules") + } + + // Check that policy version is returned + if _, ok := response["policy_version"]; !ok { + t.Error("Expected policy_version in response") + } +} + +func TestUCCAHandlers_ListExamples(t *testing.T) { + handler := &UCCAHandlers{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler.ListExamples(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + examples, ok := response["examples"].([]interface{}) + if !ok { + t.Fatal("Expected examples array in response") + } + + if len(examples) == 0 { + t.Error("Expected at least some examples") + } +} + +func TestUCCAHandlers_ListProblemSolutions_WithEngine(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := ucca.NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Skipf("Skipping test - could not load policy engine: %v", err) + } + + handler := &UCCAHandlers{ + policyEngine: engine, + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler.ListProblemSolutions(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if _, ok := response["problem_solutions"]; !ok { + t.Error("Expected problem_solutions in response") + } +} + +func TestUCCAHandlers_ListProblemSolutions_WithoutEngine(t *testing.T) { + handler := &UCCAHandlers{ + policyEngine: nil, + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler.ListProblemSolutions(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if _, ok := response["message"]; !ok { + t.Error("Expected message when policy engine not available") + } +} + +// ============================================================================ +// Request Validation Tests +// ============================================================================ + +func TestUCCAHandlers_Assess_MissingTenantID(t *testing.T) { + handler := &UCCAHandlers{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/assess", nil) + + // Don't set tenant ID + handler.Assess(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +func TestUCCAHandlers_Assess_InvalidJSON(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, _ := ucca.NewPolicyEngineFromPath(policyPath) + + handler := &UCCAHandlers{ + policyEngine: engine, + legacyRuleEngine: ucca.NewRuleEngine(), + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/assess", bytes.NewBufferString("invalid json")) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("tenant_id", uuid.New()) + c.Set("user_id", uuid.New()) + + handler.Assess(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid JSON, got %d", w.Code) + } +} + +func TestUCCAHandlers_GetAssessment_InvalidID(t *testing.T) { + handler := &UCCAHandlers{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}} + c.Request = httptest.NewRequest("GET", "/assessments/not-a-uuid", nil) + + handler.GetAssessment(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid ID, got %d", w.Code) + } +} + +func TestUCCAHandlers_DeleteAssessment_InvalidID(t *testing.T) { + handler := &UCCAHandlers{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "invalid"}} + c.Request = httptest.NewRequest("DELETE", "/assessments/invalid", nil) + + handler.DeleteAssessment(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid ID, got %d", w.Code) + } +} + +func TestUCCAHandlers_Export_InvalidID(t *testing.T) { + handler := &UCCAHandlers{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "not-valid"}} + c.Request = httptest.NewRequest("GET", "/export/not-valid", nil) + + handler.Export(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid ID, got %d", w.Code) + } +} + +func TestUCCAHandlers_Explain_InvalidID(t *testing.T) { + handler := &UCCAHandlers{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "bad-id"}} + c.Request = httptest.NewRequest("POST", "/assessments/bad-id/explain", nil) + + handler.Explain(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid ID, got %d", w.Code) + } +} + +func TestUCCAHandlers_ListAssessments_MissingTenantID(t *testing.T) { + handler := &UCCAHandlers{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/assessments", nil) + + handler.ListAssessments(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +func TestUCCAHandlers_GetStats_MissingTenantID(t *testing.T) { + handler := &UCCAHandlers{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/stats", nil) + + handler.GetStats(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// ============================================================================ +// Markdown Export Generation Tests +// ============================================================================ + +func TestGenerateMarkdownExport(t *testing.T) { + assessment := &ucca.Assessment{ + ID: uuid.New(), + Title: "Test Assessment", + Domain: ucca.DomainEducation, + Feasibility: ucca.FeasibilityCONDITIONAL, + RiskLevel: ucca.RiskLevelMEDIUM, + RiskScore: 45, + Complexity: ucca.ComplexityMEDIUM, + TriggeredRules: []ucca.TriggeredRule{ + {Code: "R-A001", Title: "Test Rule", Severity: "WARN", ScoreDelta: 10}, + }, + RequiredControls: []ucca.RequiredControl{ + {ID: "C-001", Title: "Test Control", Description: "Test Description"}, + }, + DSFARecommended: true, + Art22Risk: false, + TrainingAllowed: ucca.TrainingCONDITIONAL, + PolicyVersion: "1.0.0", + } + + markdown := generateMarkdownExport(assessment) + + // Check for expected content + if markdown == "" { + t.Error("Expected non-empty markdown") + } + + expectedContents := []string{ + "# UCCA Use-Case Assessment", + "CONDITIONAL", + "MEDIUM", + "45/100", + "Test Rule", + "Test Control", + "DSFA", + "1.0.0", + } + + for _, expected := range expectedContents { + if !bytes.Contains([]byte(markdown), []byte(expected)) { + t.Errorf("Expected markdown to contain '%s'", expected) + } + } +} + +func TestGenerateMarkdownExport_WithExplanation(t *testing.T) { + explanation := "Dies ist eine KI-generierte Erklärung." + assessment := &ucca.Assessment{ + ID: uuid.New(), + Feasibility: ucca.FeasibilityYES, + RiskLevel: ucca.RiskLevelMINIMAL, + RiskScore: 10, + ExplanationText: &explanation, + PolicyVersion: "1.0.0", + } + + markdown := generateMarkdownExport(assessment) + + if !bytes.Contains([]byte(markdown), []byte("KI-Erklärung")) { + t.Error("Expected markdown to contain explanation section") + } + + if !bytes.Contains([]byte(markdown), []byte(explanation)) { + t.Error("Expected markdown to contain the explanation text") + } +} + +func TestGenerateMarkdownExport_WithForbiddenPatterns(t *testing.T) { + assessment := &ucca.Assessment{ + ID: uuid.New(), + Feasibility: ucca.FeasibilityNO, + RiskLevel: ucca.RiskLevelHIGH, + RiskScore: 85, + ForbiddenPatterns: []ucca.ForbiddenPattern{ + {PatternID: "FP-001", Title: "Forbidden Pattern", Reason: "Not allowed"}, + }, + PolicyVersion: "1.0.0", + } + + markdown := generateMarkdownExport(assessment) + + if !bytes.Contains([]byte(markdown), []byte("Verbotene Patterns")) { + t.Error("Expected markdown to contain forbidden patterns section") + } + + if !bytes.Contains([]byte(markdown), []byte("Not allowed")) { + t.Error("Expected markdown to contain forbidden pattern reason") + } +} + +// ============================================================================ +// Explanation Prompt Building Tests +// ============================================================================ + +func TestBuildExplanationPrompt(t *testing.T) { + assessment := &ucca.Assessment{ + Feasibility: ucca.FeasibilityCONDITIONAL, + RiskLevel: ucca.RiskLevelMEDIUM, + RiskScore: 50, + Complexity: ucca.ComplexityMEDIUM, + TriggeredRules: []ucca.TriggeredRule{ + {Code: "R-001", Title: "Test", Severity: "WARN"}, + }, + RequiredControls: []ucca.RequiredControl{ + {Title: "Control", Description: "Desc"}, + }, + DSFARecommended: true, + Art22Risk: true, + } + + prompt := buildExplanationPrompt(assessment, "de", "") + + // Check prompt contains expected elements + expectedElements := []string{ + "CONDITIONAL", + "MEDIUM", + "50/100", + "Ausgelöste Regeln", + "Erforderliche Maßnahmen", + "DSFA", + "Art. 22", + } + + for _, expected := range expectedElements { + if !bytes.Contains([]byte(prompt), []byte(expected)) { + t.Errorf("Expected prompt to contain '%s'", expected) + } + } +} + +func TestBuildExplanationPrompt_WithLegalContext(t *testing.T) { + assessment := &ucca.Assessment{ + Feasibility: ucca.FeasibilityYES, + RiskLevel: ucca.RiskLevelLOW, + RiskScore: 15, + Complexity: ucca.ComplexityLOW, + } + + legalContext := "**Relevante Rechtsgrundlagen:**\nArt. 6 DSGVO - Rechtmäßigkeit" + + prompt := buildExplanationPrompt(assessment, "de", legalContext) + + if !bytes.Contains([]byte(prompt), []byte("Relevante Rechtsgrundlagen")) { + t.Error("Expected prompt to contain legal context") + } +} + +// ============================================================================ +// Legacy Rule Engine Fallback Tests +// ============================================================================ + +func TestUCCAHandlers_ListRules_LegacyFallback(t *testing.T) { + handler := &UCCAHandlers{ + policyEngine: nil, // No YAML engine + legacyRuleEngine: ucca.NewRuleEngine(), + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler.ListRules(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should have legacy policy version + policyVersion, ok := response["policy_version"].(string) + if !ok { + t.Fatal("Expected policy_version string") + } + + if policyVersion != "1.0.0-legacy" { + t.Errorf("Expected legacy policy version, got %s", policyVersion) + } +} + +func TestUCCAHandlers_ListPatterns_LegacyFallback(t *testing.T) { + handler := &UCCAHandlers{ + policyEngine: nil, // No YAML engine + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler.ListPatterns(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + patterns, ok := response["patterns"].([]interface{}) + if !ok { + t.Fatal("Expected patterns array in response") + } + + // Legacy patterns should still be returned + if len(patterns) == 0 { + t.Error("Expected at least some legacy patterns") + } +} + +func TestUCCAHandlers_ListControls_LegacyFallback(t *testing.T) { + handler := &UCCAHandlers{ + policyEngine: nil, // No YAML engine + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler.ListControls(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + controls, ok := response["controls"].([]interface{}) + if !ok { + t.Fatal("Expected controls array in response") + } + + // Legacy controls should still be returned + if len(controls) == 0 { + t.Error("Expected at least some legacy controls") + } +} diff --git a/ai-compliance-sdk/internal/api/handlers/vendor_handlers.go b/ai-compliance-sdk/internal/api/handlers/vendor_handlers.go new file mode 100644 index 0000000..98a3fd7 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/vendor_handlers.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go b/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go new file mode 100644 index 0000000..3805686 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/whistleblower_handlers.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/api/handlers/workshop_handlers.go b/ai-compliance-sdk/internal/api/handlers/workshop_handlers.go new file mode 100644 index 0000000..f74aed2 --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/workshop_handlers.go @@ -0,0 +1,923 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/breakpilot/ai-compliance-sdk/internal/workshop" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// WorkshopHandlers handles workshop session HTTP requests +type WorkshopHandlers struct { + store *workshop.Store +} + +// NewWorkshopHandlers creates new workshop handlers +func NewWorkshopHandlers(store *workshop.Store) *WorkshopHandlers { + return &WorkshopHandlers{store: store} +} + +// ============================================================================ +// Session Management +// ============================================================================ + +// CreateSession creates a new workshop session +// POST /sdk/v1/workshops +func (h *WorkshopHandlers) CreateSession(c *gin.Context) { + var req workshop.CreateSessionRequest + 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) + + session := &workshop.Session{ + TenantID: tenantID, + Title: req.Title, + Description: req.Description, + SessionType: req.SessionType, + WizardSchema: req.WizardSchema, + Status: workshop.SessionStatusDraft, + CurrentStep: 1, + TotalSteps: 10, // Default, will be updated when wizard is loaded + ScheduledStart: req.ScheduledStart, + ScheduledEnd: req.ScheduledEnd, + AssessmentID: req.AssessmentID, + RoadmapID: req.RoadmapID, + PortfolioID: req.PortfolioID, + Settings: req.Settings, + CreatedBy: userID, + } + + // Set default settings if not provided + if !session.Settings.AllowBackNavigation { + session.Settings.AllowBackNavigation = true + } + if !session.Settings.AllowNotes { + session.Settings.AllowNotes = true + } + if !session.Settings.AutoSave { + session.Settings.AutoSave = true + } + + if err := h.store.CreateSession(c.Request.Context(), session); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Add creator as facilitator + facilitator := &workshop.Participant{ + SessionID: session.ID, + UserID: &userID, + Name: "Session Owner", + Role: workshop.ParticipantRoleFacilitator, + CanEdit: true, + CanComment: true, + CanApprove: true, + } + h.store.AddParticipant(c.Request.Context(), facilitator) + + c.JSON(http.StatusCreated, workshop.CreateSessionResponse{ + Session: *session, + JoinCode: session.JoinCode, + }) +} + +// ListSessions lists workshop sessions +// GET /sdk/v1/workshops +func (h *WorkshopHandlers) ListSessions(c *gin.Context) { + tenantID := rbac.GetTenantID(c) + + filters := &workshop.SessionFilters{ + Limit: 50, + } + + if status := c.Query("status"); status != "" { + filters.Status = workshop.SessionStatus(status) + } + if sessionType := c.Query("type"); sessionType != "" { + filters.SessionType = sessionType + } + + sessions, err := h.store.ListSessions(c.Request.Context(), tenantID, filters) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "sessions": sessions, + "total": len(sessions), + }) +} + +// GetSession retrieves a session with details +// GET /sdk/v1/workshops/:id +func (h *WorkshopHandlers) GetSession(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + summary, err := h.store.GetSessionSummary(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if summary == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + // Get responses for current step + stepNumber := summary.Session.CurrentStep + responses, _ := h.store.GetResponses(c.Request.Context(), id, &stepNumber) + + // Get stats + stats, _ := h.store.GetSessionStats(c.Request.Context(), id) + + c.JSON(http.StatusOK, gin.H{ + "session": summary.Session, + "participants": summary.Participants, + "step_progress": summary.StepProgress, + "responses": responses, + "stats": stats, + "progress": summary.OverallProgress, + }) +} + +// UpdateSession updates a session +// PUT /sdk/v1/workshops/:id +func (h *WorkshopHandlers) UpdateSession(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + session, err := h.store.GetSession(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if session == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + var req workshop.CreateSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + session.Title = req.Title + session.Description = req.Description + session.ScheduledStart = req.ScheduledStart + session.ScheduledEnd = req.ScheduledEnd + session.Settings = req.Settings + + if err := h.store.UpdateSession(c.Request.Context(), session); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"session": session}) +} + +// DeleteSession deletes a session +// DELETE /sdk/v1/workshops/:id +func (h *WorkshopHandlers) DeleteSession(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + if err := h.store.DeleteSession(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "session deleted"}) +} + +// ============================================================================ +// Session Status Control +// ============================================================================ + +// StartSession starts a workshop session +// POST /sdk/v1/workshops/:id/start +func (h *WorkshopHandlers) StartSession(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + session, err := h.store.GetSession(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if session == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + if session.Status != workshop.SessionStatusDraft && session.Status != workshop.SessionStatusScheduled && session.Status != workshop.SessionStatusPaused { + c.JSON(http.StatusBadRequest, gin.H{"error": "session cannot be started from current state"}) + return + } + + if err := h.store.UpdateSessionStatus(c.Request.Context(), id, workshop.SessionStatusActive); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Initialize first step progress + h.store.UpdateStepProgress(c.Request.Context(), id, 1, "in_progress", 0) + + session.Status = workshop.SessionStatusActive + now := time.Now().UTC() + session.ActualStart = &now + + c.JSON(http.StatusOK, gin.H{"session": session, "message": "session started"}) +} + +// PauseSession pauses a workshop session +// POST /sdk/v1/workshops/:id/pause +func (h *WorkshopHandlers) PauseSession(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + if err := h.store.UpdateSessionStatus(c.Request.Context(), id, workshop.SessionStatusPaused); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "session paused"}) +} + +// CompleteSession completes a workshop session +// POST /sdk/v1/workshops/:id/complete +func (h *WorkshopHandlers) CompleteSession(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + if err := h.store.UpdateSessionStatus(c.Request.Context(), id, workshop.SessionStatusCompleted); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Get final summary + summary, _ := h.store.GetSessionSummary(c.Request.Context(), id) + + c.JSON(http.StatusOK, gin.H{ + "message": "session completed", + "summary": summary, + }) +} + +// ============================================================================ +// Participant Management +// ============================================================================ + +// JoinSession allows a participant to join a session +// POST /sdk/v1/workshops/join/:code +func (h *WorkshopHandlers) JoinSession(c *gin.Context) { + code := c.Param("code") + + session, err := h.store.GetSessionByJoinCode(c.Request.Context(), code) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if session == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + if session.Status == workshop.SessionStatusCompleted || session.Status == workshop.SessionStatusCancelled { + c.JSON(http.StatusBadRequest, gin.H{"error": "session is no longer active"}) + return + } + + var req workshop.JoinSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get user ID if authenticated + var userID *uuid.UUID + if id := rbac.GetUserID(c); id != uuid.Nil { + userID = &id + } + + // Check if authentication is required + if session.RequireAuth && userID == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required to join this session"}) + return + } + + participant := &workshop.Participant{ + SessionID: session.ID, + UserID: userID, + Name: req.Name, + Email: req.Email, + Role: req.Role, + Department: req.Department, + CanEdit: true, + CanComment: true, + } + + if participant.Role == "" { + participant.Role = workshop.ParticipantRoleStakeholder + } + + if err := h.store.AddParticipant(c.Request.Context(), participant); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, workshop.JoinSessionResponse{ + Participant: *participant, + Session: *session, + }) +} + +// ListParticipants lists participants in a session +// GET /sdk/v1/workshops/:id/participants +func (h *WorkshopHandlers) ListParticipants(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + participants, err := h.store.ListParticipants(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "participants": participants, + "total": len(participants), + }) +} + +// LeaveSession removes a participant from a session +// POST /sdk/v1/workshops/:id/leave +func (h *WorkshopHandlers) LeaveSession(c *gin.Context) { + var req struct { + ParticipantID uuid.UUID `json:"participant_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.store.LeaveSession(c.Request.Context(), req.ParticipantID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "left session"}) +} + +// ============================================================================ +// Wizard Navigation & Responses +// ============================================================================ + +// SubmitResponse submits a response to a question +// POST /sdk/v1/workshops/:id/responses +func (h *WorkshopHandlers) SubmitResponse(c *gin.Context) { + sessionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + var req workshop.SubmitResponseRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get participant ID from request or context + participantID, err := uuid.Parse(c.GetHeader("X-Participant-ID")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "participant ID required"}) + return + } + + // Determine value type + valueType := "string" + switch req.Value.(type) { + case bool: + valueType = "boolean" + case float64: + valueType = "number" + case []interface{}: + valueType = "array" + case map[string]interface{}: + valueType = "object" + } + + response := &workshop.Response{ + SessionID: sessionID, + ParticipantID: participantID, + StepNumber: req.StepNumber, + FieldID: req.FieldID, + Value: req.Value, + ValueType: valueType, + Status: workshop.ResponseStatusSubmitted, + } + + if err := h.store.SaveResponse(c.Request.Context(), response); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Update participant activity + h.store.UpdateParticipantActivity(c.Request.Context(), participantID) + + c.JSON(http.StatusOK, gin.H{"response": response}) +} + +// GetResponses retrieves responses for a session +// GET /sdk/v1/workshops/:id/responses +func (h *WorkshopHandlers) GetResponses(c *gin.Context) { + sessionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + var stepNumber *int + if step := c.Query("step"); step != "" { + if s, err := strconv.Atoi(step); err == nil { + stepNumber = &s + } + } + + responses, err := h.store.GetResponses(c.Request.Context(), sessionID, stepNumber) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "responses": responses, + "total": len(responses), + }) +} + +// AdvanceStep moves the session to the next step +// POST /sdk/v1/workshops/:id/advance +func (h *WorkshopHandlers) AdvanceStep(c *gin.Context) { + sessionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + session, err := h.store.GetSession(c.Request.Context(), sessionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if session == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + if session.CurrentStep >= session.TotalSteps { + c.JSON(http.StatusBadRequest, gin.H{"error": "already at last step"}) + return + } + + // Mark current step as completed + h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep, "completed", 100) + + // Advance to next step + if err := h.store.AdvanceStep(c.Request.Context(), sessionID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Initialize next step + h.store.UpdateStepProgress(c.Request.Context(), sessionID, session.CurrentStep+1, "in_progress", 0) + + c.JSON(http.StatusOK, gin.H{ + "previous_step": session.CurrentStep, + "current_step": session.CurrentStep + 1, + "message": "advanced to next step", + }) +} + +// GoToStep navigates to a specific step (if allowed) +// POST /sdk/v1/workshops/:id/goto +func (h *WorkshopHandlers) GoToStep(c *gin.Context) { + sessionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + var req struct { + StepNumber int `json:"step_number"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + session, err := h.store.GetSession(c.Request.Context(), sessionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if session == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + // Check if back navigation is allowed + if req.StepNumber < session.CurrentStep && !session.Settings.AllowBackNavigation { + c.JSON(http.StatusBadRequest, gin.H{"error": "back navigation not allowed"}) + return + } + + // Validate step number + if req.StepNumber < 1 || req.StepNumber > session.TotalSteps { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid step number"}) + return + } + + session.CurrentStep = req.StepNumber + if err := h.store.UpdateSession(c.Request.Context(), session); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "current_step": req.StepNumber, + "message": "navigated to step", + }) +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// GetSessionStats returns statistics for a session +// GET /sdk/v1/workshops/:id/stats +func (h *WorkshopHandlers) GetSessionStats(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + stats, err := h.store.GetSessionStats(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// GetSessionSummary returns a complete summary of a session +// GET /sdk/v1/workshops/:id/summary +func (h *WorkshopHandlers) GetSessionSummary(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + summary, err := h.store.GetSessionSummary(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if summary == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + c.JSON(http.StatusOK, summary) +} + +// ============================================================================ +// Participant Management (Extended) +// ============================================================================ + +// UpdateParticipant updates a participant's info +// PUT /sdk/v1/workshops/:id/participants/:participantId +func (h *WorkshopHandlers) UpdateParticipant(c *gin.Context) { + participantID, err := uuid.Parse(c.Param("participantId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"}) + return + } + + var req struct { + Name string `json:"name"` + Role workshop.ParticipantRole `json:"role"` + Department string `json:"department"` + CanEdit *bool `json:"can_edit,omitempty"` + CanComment *bool `json:"can_comment,omitempty"` + CanApprove *bool `json:"can_approve,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + participant, err := h.store.GetParticipant(c.Request.Context(), participantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if participant == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "participant not found"}) + return + } + + if req.Name != "" { + participant.Name = req.Name + } + if req.Role != "" { + participant.Role = req.Role + } + if req.Department != "" { + participant.Department = req.Department + } + if req.CanEdit != nil { + participant.CanEdit = *req.CanEdit + } + if req.CanComment != nil { + participant.CanComment = *req.CanComment + } + if req.CanApprove != nil { + participant.CanApprove = *req.CanApprove + } + + if err := h.store.UpdateParticipant(c.Request.Context(), participant); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"participant": participant}) +} + +// RemoveParticipant removes a participant from a session +// DELETE /sdk/v1/workshops/:id/participants/:participantId +func (h *WorkshopHandlers) RemoveParticipant(c *gin.Context) { + participantID, err := uuid.Parse(c.Param("participantId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid participant ID"}) + return + } + + if err := h.store.LeaveSession(c.Request.Context(), participantID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "participant removed"}) +} + +// ============================================================================ +// Comments +// ============================================================================ + +// AddComment adds a comment to a session +// POST /sdk/v1/workshops/:id/comments +func (h *WorkshopHandlers) AddComment(c *gin.Context) { + sessionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + var req struct { + ParticipantID uuid.UUID `json:"participant_id"` + StepNumber *int `json:"step_number,omitempty"` + FieldID *string `json:"field_id,omitempty"` + ResponseID *uuid.UUID `json:"response_id,omitempty"` + Text string `json:"text"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Text == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "comment text is required"}) + return + } + + comment := &workshop.Comment{ + SessionID: sessionID, + ParticipantID: req.ParticipantID, + StepNumber: req.StepNumber, + FieldID: req.FieldID, + ResponseID: req.ResponseID, + Text: req.Text, + } + + if err := h.store.AddComment(c.Request.Context(), comment); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"comment": comment}) +} + +// GetComments retrieves comments for a session +// GET /sdk/v1/workshops/:id/comments +func (h *WorkshopHandlers) GetComments(c *gin.Context) { + sessionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + var stepNumber *int + if step := c.Query("step"); step != "" { + if s, err := strconv.Atoi(step); err == nil { + stepNumber = &s + } + } + + comments, err := h.store.GetComments(c.Request.Context(), sessionID, stepNumber) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "comments": comments, + "total": len(comments), + }) +} + +// ============================================================================ +// Export +// ============================================================================ + +// ExportSession exports session data +// GET /sdk/v1/workshops/:id/export +func (h *WorkshopHandlers) ExportSession(c *gin.Context) { + sessionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session ID"}) + return + } + + format := c.DefaultQuery("format", "json") + + // Get complete session data + summary, err := h.store.GetSessionSummary(c.Request.Context(), sessionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if summary == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + // Get all responses + responses, _ := h.store.GetResponses(c.Request.Context(), sessionID, nil) + + // Get all comments + comments, _ := h.store.GetComments(c.Request.Context(), sessionID, nil) + + // Get stats + stats, _ := h.store.GetSessionStats(c.Request.Context(), sessionID) + + exportData := gin.H{ + "session": summary.Session, + "participants": summary.Participants, + "step_progress": summary.StepProgress, + "responses": responses, + "comments": comments, + "stats": stats, + "exported_at": time.Now().UTC(), + } + + switch format { + case "json": + c.JSON(http.StatusOK, exportData) + case "md": + // Generate markdown format + md := generateSessionMarkdown(summary, responses, comments, stats) + c.Header("Content-Type", "text/markdown") + c.Header("Content-Disposition", "attachment; filename=workshop-session.md") + c.String(http.StatusOK, md) + default: + c.JSON(http.StatusOK, exportData) + } +} + +// generateSessionMarkdown generates a markdown export of the session +func generateSessionMarkdown(summary *workshop.SessionSummary, responses []workshop.Response, comments []workshop.Comment, stats *workshop.SessionStats) string { + md := "# Workshop Session: " + summary.Session.Title + "\n\n" + md += "**Type:** " + summary.Session.SessionType + "\n" + md += "**Status:** " + string(summary.Session.Status) + "\n" + md += "**Created:** " + summary.Session.CreatedAt.Format("2006-01-02 15:04") + "\n\n" + + if summary.Session.Description != "" { + md += "## Description\n\n" + summary.Session.Description + "\n\n" + } + + // Participants + md += "## Participants\n\n" + for _, p := range summary.Participants { + md += "- **" + p.Name + "** (" + string(p.Role) + ")" + if p.Department != "" { + md += " - " + p.Department + } + md += "\n" + } + md += "\n" + + // Progress + md += "## Progress\n\n" + md += "**Overall:** " + strconv.Itoa(summary.OverallProgress) + "%\n" + md += "**Completed Steps:** " + strconv.Itoa(summary.CompletedSteps) + "/" + strconv.Itoa(summary.Session.TotalSteps) + "\n" + md += "**Total Responses:** " + strconv.Itoa(summary.TotalResponses) + "\n\n" + + // Step progress + if len(summary.StepProgress) > 0 { + md += "### Step Progress\n\n" + for _, sp := range summary.StepProgress { + md += "- Step " + strconv.Itoa(sp.StepNumber) + ": " + sp.Status + " (" + strconv.Itoa(sp.Progress) + "%)\n" + } + md += "\n" + } + + // Responses by step + if len(responses) > 0 { + md += "## Responses\n\n" + currentStep := 0 + for _, r := range responses { + if r.StepNumber != currentStep { + currentStep = r.StepNumber + md += "### Step " + strconv.Itoa(currentStep) + "\n\n" + } + md += "- **" + r.FieldID + ":** " + switch v := r.Value.(type) { + case string: + md += v + case bool: + if v { + md += "Yes" + } else { + md += "No" + } + default: + md += "See JSON export for complex value" + } + md += "\n" + } + md += "\n" + } + + // Comments + if len(comments) > 0 { + md += "## Comments\n\n" + for _, c := range comments { + md += "- " + c.Text + if c.StepNumber != nil { + md += " (Step " + strconv.Itoa(*c.StepNumber) + ")" + } + md += "\n" + } + md += "\n" + } + + md += "---\n*Exported from AI Compliance SDK Workshop Module*\n" + + return md +} diff --git a/ai-compliance-sdk/internal/audit/exporter.go b/ai-compliance-sdk/internal/audit/exporter.go new file mode 100644 index 0000000..2aceb41 --- /dev/null +++ b/ai-compliance-sdk/internal/audit/exporter.go @@ -0,0 +1,444 @@ +package audit + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/google/uuid" +) + +// ExportFormat represents the export format +type ExportFormat string + +const ( + FormatCSV ExportFormat = "csv" + FormatJSON ExportFormat = "json" +) + +// Exporter exports audit data in various formats +type Exporter struct { + store *Store +} + +// NewExporter creates a new exporter +func NewExporter(store *Store) *Exporter { + return &Exporter{store: store} +} + +// ExportOptions defines export options +type ExportOptions struct { + TenantID uuid.UUID + NamespaceID *uuid.UUID + UserID *uuid.UUID + StartDate time.Time + EndDate time.Time + Format ExportFormat + IncludePII bool // If false, redact PII fields +} + +// ExportLLMAudit exports LLM audit entries +func (e *Exporter) ExportLLMAudit(ctx context.Context, w io.Writer, opts *ExportOptions) error { + filter := &LLMAuditFilter{ + TenantID: opts.TenantID, + NamespaceID: opts.NamespaceID, + UserID: opts.UserID, + StartDate: &opts.StartDate, + EndDate: &opts.EndDate, + Limit: 0, // No limit for export + } + + entries, _, err := e.store.QueryLLMAuditEntries(ctx, filter) + if err != nil { + return fmt.Errorf("failed to query audit entries: %w", err) + } + + switch opts.Format { + case FormatCSV: + return e.exportLLMAuditCSV(w, entries, opts.IncludePII) + case FormatJSON: + return e.exportLLMAuditJSON(w, entries, opts.IncludePII) + default: + return fmt.Errorf("unsupported format: %s", opts.Format) + } +} + +// ExportGeneralAudit exports general audit entries +func (e *Exporter) ExportGeneralAudit(ctx context.Context, w io.Writer, opts *ExportOptions) error { + filter := &GeneralAuditFilter{ + TenantID: opts.TenantID, + NamespaceID: opts.NamespaceID, + UserID: opts.UserID, + StartDate: &opts.StartDate, + EndDate: &opts.EndDate, + Limit: 0, + } + + entries, _, err := e.store.QueryGeneralAuditEntries(ctx, filter) + if err != nil { + return fmt.Errorf("failed to query audit entries: %w", err) + } + + switch opts.Format { + case FormatCSV: + return e.exportGeneralAuditCSV(w, entries, opts.IncludePII) + case FormatJSON: + return e.exportGeneralAuditJSON(w, entries, opts.IncludePII) + default: + return fmt.Errorf("unsupported format: %s", opts.Format) + } +} + +func (e *Exporter) exportLLMAuditCSV(w io.Writer, entries []*LLMAuditEntry, includePII bool) error { + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write header + header := []string{ + "ID", "Tenant ID", "Namespace ID", "User ID", "Session ID", + "Operation", "Model", "Provider", "Prompt Hash", "Prompt Length", "Response Length", + "Tokens Used", "Duration (ms)", "PII Detected", "PII Types", "PII Redacted", + "Policy ID", "Policy Violations", "Data Categories", "Error", "Created At", + } + if err := writer.Write(header); err != nil { + return err + } + + // Write entries + for _, entry := range entries { + namespaceID := "" + if entry.NamespaceID != nil { + namespaceID = entry.NamespaceID.String() + } + + policyID := "" + if entry.PolicyID != nil { + policyID = entry.PolicyID.String() + } + + userID := entry.UserID.String() + sessionID := entry.SessionID + if !includePII { + userID = "[REDACTED]" + sessionID = "[REDACTED]" + } + + row := []string{ + entry.ID.String(), + entry.TenantID.String(), + namespaceID, + userID, + sessionID, + entry.Operation, + entry.ModelUsed, + entry.Provider, + entry.PromptHash, + fmt.Sprintf("%d", entry.PromptLength), + fmt.Sprintf("%d", entry.ResponseLength), + fmt.Sprintf("%d", entry.TokensUsed), + fmt.Sprintf("%d", entry.DurationMS), + fmt.Sprintf("%t", entry.PIIDetected), + strings.Join(entry.PIITypesDetected, ";"), + fmt.Sprintf("%t", entry.PIIRedacted), + policyID, + strings.Join(entry.PolicyViolations, ";"), + strings.Join(entry.DataCategoriesAccessed, ";"), + entry.ErrorMessage, + entry.CreatedAt.Format(time.RFC3339), + } + + if err := writer.Write(row); err != nil { + return err + } + } + + return nil +} + +func (e *Exporter) exportLLMAuditJSON(w io.Writer, entries []*LLMAuditEntry, includePII bool) error { + // Redact PII if needed + if !includePII { + for _, entry := range entries { + entry.UserID = uuid.Nil + entry.SessionID = "[REDACTED]" + entry.RequestMetadata = nil + } + } + + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(map[string]any{ + "type": "llm_audit_export", + "exported_at": time.Now().UTC().Format(time.RFC3339), + "count": len(entries), + "entries": entries, + }) +} + +func (e *Exporter) exportGeneralAuditCSV(w io.Writer, entries []*GeneralAuditEntry, includePII bool) error { + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write header + header := []string{ + "ID", "Tenant ID", "Namespace ID", "User ID", + "Action", "Resource Type", "Resource ID", + "IP Address", "User Agent", "Reason", "Created At", + } + if err := writer.Write(header); err != nil { + return err + } + + // Write entries + for _, entry := range entries { + namespaceID := "" + if entry.NamespaceID != nil { + namespaceID = entry.NamespaceID.String() + } + + resourceID := "" + if entry.ResourceID != nil { + resourceID = entry.ResourceID.String() + } + + userID := entry.UserID.String() + ipAddress := entry.IPAddress + userAgent := entry.UserAgent + if !includePII { + userID = "[REDACTED]" + ipAddress = "[REDACTED]" + userAgent = "[REDACTED]" + } + + row := []string{ + entry.ID.String(), + entry.TenantID.String(), + namespaceID, + userID, + entry.Action, + entry.ResourceType, + resourceID, + ipAddress, + userAgent, + entry.Reason, + entry.CreatedAt.Format(time.RFC3339), + } + + if err := writer.Write(row); err != nil { + return err + } + } + + return nil +} + +func (e *Exporter) exportGeneralAuditJSON(w io.Writer, entries []*GeneralAuditEntry, includePII bool) error { + // Redact PII if needed + if !includePII { + for _, entry := range entries { + entry.UserID = uuid.Nil + entry.IPAddress = "[REDACTED]" + entry.UserAgent = "[REDACTED]" + } + } + + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(map[string]any{ + "type": "general_audit_export", + "exported_at": time.Now().UTC().Format(time.RFC3339), + "count": len(entries), + "entries": entries, + }) +} + +// ExportUsageStats exports LLM usage statistics +func (e *Exporter) ExportUsageStats(ctx context.Context, w io.Writer, tenantID uuid.UUID, startDate, endDate time.Time, format ExportFormat) error { + stats, err := e.store.GetLLMUsageStats(ctx, tenantID, startDate, endDate) + if err != nil { + return fmt.Errorf("failed to get usage stats: %w", err) + } + + report := map[string]any{ + "type": "llm_usage_report", + "tenant_id": tenantID.String(), + "period_start": startDate.Format(time.RFC3339), + "period_end": endDate.Format(time.RFC3339), + "generated_at": time.Now().UTC().Format(time.RFC3339), + "total_requests": stats.TotalRequests, + "total_tokens": stats.TotalTokens, + "total_duration_ms": stats.TotalDurationMS, + "requests_with_pii": stats.RequestsWithPII, + "policy_violations": stats.PolicyViolations, + "models_used": stats.ModelsUsed, + } + + switch format { + case FormatJSON: + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(report) + case FormatCSV: + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write summary as CSV + if err := writer.Write([]string{"Metric", "Value"}); err != nil { + return err + } + + rows := [][]string{ + {"Total Requests", fmt.Sprintf("%d", stats.TotalRequests)}, + {"Total Tokens", fmt.Sprintf("%d", stats.TotalTokens)}, + {"Total Duration (ms)", fmt.Sprintf("%d", stats.TotalDurationMS)}, + {"Requests with PII", fmt.Sprintf("%d", stats.RequestsWithPII)}, + {"Policy Violations", fmt.Sprintf("%d", stats.PolicyViolations)}, + } + + for model, count := range stats.ModelsUsed { + rows = append(rows, []string{fmt.Sprintf("Model: %s", model), fmt.Sprintf("%d", count)}) + } + + for _, row := range rows { + if err := writer.Write(row); err != nil { + return err + } + } + + return nil + default: + return fmt.Errorf("unsupported format: %s", format) + } +} + +// ComplianceReport generates a compliance report +type ComplianceReport struct { + TenantID uuid.UUID `json:"tenant_id"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + GeneratedAt time.Time `json:"generated_at"` + TotalLLMRequests int `json:"total_llm_requests"` + TotalTokensUsed int `json:"total_tokens_used"` + PIIIncidents int `json:"pii_incidents"` + PIIRedactionRate float64 `json:"pii_redaction_rate"` + PolicyViolations int `json:"policy_violations"` + ModelsUsed map[string]int `json:"models_used"` + TopUsers []UserActivity `json:"top_users,omitempty"` + NamespaceBreakdown []NamespaceStats `json:"namespace_breakdown,omitempty"` +} + +// UserActivity represents user activity in the report +type UserActivity struct { + UserID uuid.UUID `json:"user_id"` + RequestCount int `json:"request_count"` + TokensUsed int `json:"tokens_used"` + PIIIncidents int `json:"pii_incidents"` +} + +// NamespaceStats represents namespace statistics +type NamespaceStats struct { + NamespaceID uuid.UUID `json:"namespace_id"` + NamespaceName string `json:"namespace_name,omitempty"` + RequestCount int `json:"request_count"` + TokensUsed int `json:"tokens_used"` +} + +// GenerateComplianceReport generates a detailed compliance report +func (e *Exporter) GenerateComplianceReport(ctx context.Context, tenantID uuid.UUID, startDate, endDate time.Time) (*ComplianceReport, error) { + // Get basic stats + stats, err := e.store.GetLLMUsageStats(ctx, tenantID, startDate, endDate) + if err != nil { + return nil, err + } + + // Calculate PII redaction rate + var redactionRate float64 + if stats.RequestsWithPII > 0 { + // Query entries with PII to check redaction + filter := &LLMAuditFilter{ + TenantID: tenantID, + PIIDetected: boolPtr(true), + StartDate: &startDate, + EndDate: &endDate, + } + entries, _, err := e.store.QueryLLMAuditEntries(ctx, filter) + if err == nil { + redactedCount := 0 + for _, e := range entries { + if e.PIIRedacted { + redactedCount++ + } + } + redactionRate = float64(redactedCount) / float64(len(entries)) * 100 + } + } + + report := &ComplianceReport{ + TenantID: tenantID, + PeriodStart: startDate, + PeriodEnd: endDate, + GeneratedAt: time.Now().UTC(), + TotalLLMRequests: stats.TotalRequests, + TotalTokensUsed: stats.TotalTokens, + PIIIncidents: stats.RequestsWithPII, + PIIRedactionRate: redactionRate, + PolicyViolations: stats.PolicyViolations, + ModelsUsed: stats.ModelsUsed, + } + + return report, nil +} + +// ExportComplianceReport exports a compliance report +func (e *Exporter) ExportComplianceReport(ctx context.Context, w io.Writer, tenantID uuid.UUID, startDate, endDate time.Time, format ExportFormat) error { + report, err := e.GenerateComplianceReport(ctx, tenantID, startDate, endDate) + if err != nil { + return err + } + + switch format { + case FormatJSON: + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(report) + case FormatCSV: + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write header + if err := writer.Write([]string{"Metric", "Value"}); err != nil { + return err + } + + rows := [][]string{ + {"Report Type", "AI Compliance Report"}, + {"Tenant ID", report.TenantID.String()}, + {"Period Start", report.PeriodStart.Format(time.RFC3339)}, + {"Period End", report.PeriodEnd.Format(time.RFC3339)}, + {"Generated At", report.GeneratedAt.Format(time.RFC3339)}, + {"Total LLM Requests", fmt.Sprintf("%d", report.TotalLLMRequests)}, + {"Total Tokens Used", fmt.Sprintf("%d", report.TotalTokensUsed)}, + {"PII Incidents", fmt.Sprintf("%d", report.PIIIncidents)}, + {"PII Redaction Rate", fmt.Sprintf("%.2f%%", report.PIIRedactionRate)}, + {"Policy Violations", fmt.Sprintf("%d", report.PolicyViolations)}, + } + + for _, row := range rows { + if err := writer.Write(row); err != nil { + return err + } + } + + return nil + default: + return fmt.Errorf("unsupported format: %s", format) + } +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/ai-compliance-sdk/internal/audit/store.go b/ai-compliance-sdk/internal/audit/store.go new file mode 100644 index 0000000..7fafec4 --- /dev/null +++ b/ai-compliance-sdk/internal/audit/store.go @@ -0,0 +1,472 @@ +package audit + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store provides database operations for audit logs +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new audit store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// LLMAuditEntry represents an LLM audit log entry +type LLMAuditEntry struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty" db:"namespace_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + SessionID string `json:"session_id,omitempty" db:"session_id"` + Operation string `json:"operation" db:"operation"` + ModelUsed string `json:"model_used" db:"model_used"` + Provider string `json:"provider" db:"provider"` + PromptHash string `json:"prompt_hash" db:"prompt_hash"` + PromptLength int `json:"prompt_length" db:"prompt_length"` + ResponseLength int `json:"response_length,omitempty" db:"response_length"` + TokensUsed int `json:"tokens_used" db:"tokens_used"` + DurationMS int `json:"duration_ms" db:"duration_ms"` + PIIDetected bool `json:"pii_detected" db:"pii_detected"` + PIITypesDetected []string `json:"pii_types_detected,omitempty" db:"pii_types_detected"` + PIIRedacted bool `json:"pii_redacted" db:"pii_redacted"` + PolicyID *uuid.UUID `json:"policy_id,omitempty" db:"policy_id"` + PolicyViolations []string `json:"policy_violations,omitempty" db:"policy_violations"` + DataCategoriesAccessed []string `json:"data_categories_accessed,omitempty" db:"data_categories_accessed"` + ErrorMessage string `json:"error_message,omitempty" db:"error_message"` + RequestMetadata map[string]any `json:"request_metadata,omitempty" db:"request_metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// GeneralAuditEntry represents a general audit trail entry +type GeneralAuditEntry struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty" db:"namespace_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Action string `json:"action" db:"action"` + ResourceType string `json:"resource_type" db:"resource_type"` + ResourceID *uuid.UUID `json:"resource_id,omitempty" db:"resource_id"` + OldValues map[string]any `json:"old_values,omitempty" db:"old_values"` + NewValues map[string]any `json:"new_values,omitempty" db:"new_values"` + IPAddress string `json:"ip_address,omitempty" db:"ip_address"` + UserAgent string `json:"user_agent,omitempty" db:"user_agent"` + Reason string `json:"reason,omitempty" db:"reason"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// CreateLLMAuditEntry creates a new LLM audit log entry +func (s *Store) CreateLLMAuditEntry(ctx context.Context, entry *LLMAuditEntry) error { + if entry.ID == uuid.Nil { + entry.ID = uuid.New() + } + if entry.CreatedAt.IsZero() { + entry.CreatedAt = time.Now().UTC() + } + + metadataJSON, _ := json.Marshal(entry.RequestMetadata) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO compliance_llm_audit_log ( + id, tenant_id, namespace_id, user_id, session_id, + operation, model_used, provider, prompt_hash, prompt_length, response_length, + tokens_used, duration_ms, pii_detected, pii_types_detected, pii_redacted, + policy_id, policy_violations, data_categories_accessed, error_message, + request_metadata, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + `, + entry.ID, entry.TenantID, entry.NamespaceID, entry.UserID, entry.SessionID, + entry.Operation, entry.ModelUsed, entry.Provider, entry.PromptHash, entry.PromptLength, entry.ResponseLength, + entry.TokensUsed, entry.DurationMS, entry.PIIDetected, entry.PIITypesDetected, entry.PIIRedacted, + entry.PolicyID, entry.PolicyViolations, entry.DataCategoriesAccessed, entry.ErrorMessage, + metadataJSON, entry.CreatedAt, + ) + + return err +} + +// CreateGeneralAuditEntry creates a new general audit entry +func (s *Store) CreateGeneralAuditEntry(ctx context.Context, entry *GeneralAuditEntry) error { + if entry.ID == uuid.Nil { + entry.ID = uuid.New() + } + if entry.CreatedAt.IsZero() { + entry.CreatedAt = time.Now().UTC() + } + + oldValuesJSON, _ := json.Marshal(entry.OldValues) + newValuesJSON, _ := json.Marshal(entry.NewValues) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO compliance_audit_trail ( + id, tenant_id, namespace_id, user_id, action, resource_type, resource_id, + old_values, new_values, ip_address, user_agent, reason, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + `, + entry.ID, entry.TenantID, entry.NamespaceID, entry.UserID, + entry.Action, entry.ResourceType, entry.ResourceID, + oldValuesJSON, newValuesJSON, entry.IPAddress, entry.UserAgent, + entry.Reason, entry.CreatedAt, + ) + + return err +} + +// LLMAuditFilter defines filters for LLM audit queries +type LLMAuditFilter struct { + TenantID uuid.UUID + NamespaceID *uuid.UUID + UserID *uuid.UUID + Operation string + Model string + PIIDetected *bool + HasViolations *bool + StartDate *time.Time + EndDate *time.Time + Limit int + Offset int +} + +// QueryLLMAuditEntries queries LLM audit entries with filters +func (s *Store) QueryLLMAuditEntries(ctx context.Context, filter *LLMAuditFilter) ([]*LLMAuditEntry, int, error) { + query := ` + SELECT id, tenant_id, namespace_id, user_id, session_id, + operation, model_used, provider, prompt_hash, prompt_length, response_length, + tokens_used, duration_ms, pii_detected, pii_types_detected, pii_redacted, + policy_id, policy_violations, data_categories_accessed, error_message, + request_metadata, created_at + FROM compliance_llm_audit_log + WHERE tenant_id = $1 + ` + + countQuery := `SELECT COUNT(*) FROM compliance_llm_audit_log WHERE tenant_id = $1` + + args := []any{filter.TenantID} + argIndex := 2 + + if filter.NamespaceID != nil { + query += fmt.Sprintf(" AND namespace_id = $%d", argIndex) + countQuery += fmt.Sprintf(" AND namespace_id = $%d", argIndex) + args = append(args, *filter.NamespaceID) + argIndex++ + } + + if filter.UserID != nil { + query += fmt.Sprintf(" AND user_id = $%d", argIndex) + countQuery += fmt.Sprintf(" AND user_id = $%d", argIndex) + args = append(args, *filter.UserID) + argIndex++ + } + + if filter.Operation != "" { + query += fmt.Sprintf(" AND operation = $%d", argIndex) + countQuery += fmt.Sprintf(" AND operation = $%d", argIndex) + args = append(args, filter.Operation) + argIndex++ + } + + if filter.Model != "" { + query += fmt.Sprintf(" AND model_used = $%d", argIndex) + countQuery += fmt.Sprintf(" AND model_used = $%d", argIndex) + args = append(args, filter.Model) + argIndex++ + } + + if filter.PIIDetected != nil { + query += fmt.Sprintf(" AND pii_detected = $%d", argIndex) + countQuery += fmt.Sprintf(" AND pii_detected = $%d", argIndex) + args = append(args, *filter.PIIDetected) + argIndex++ + } + + if filter.HasViolations != nil && *filter.HasViolations { + query += " AND array_length(policy_violations, 1) > 0" + countQuery += " AND array_length(policy_violations, 1) > 0" + } + + if filter.StartDate != nil { + query += fmt.Sprintf(" AND created_at >= $%d", argIndex) + countQuery += fmt.Sprintf(" AND created_at >= $%d", argIndex) + args = append(args, *filter.StartDate) + argIndex++ + } + + if filter.EndDate != nil { + query += fmt.Sprintf(" AND created_at <= $%d", argIndex) + countQuery += fmt.Sprintf(" AND created_at <= $%d", argIndex) + args = append(args, *filter.EndDate) + argIndex++ + } + + // Get total count + var totalCount int + if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&totalCount); err != nil { + return nil, 0, err + } + + // Add ordering and pagination + query += " ORDER BY created_at DESC" + + if filter.Limit > 0 { + query += fmt.Sprintf(" LIMIT $%d", argIndex) + args = append(args, filter.Limit) + argIndex++ + } + + if filter.Offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argIndex) + args = append(args, filter.Offset) + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var entries []*LLMAuditEntry + for rows.Next() { + var entry LLMAuditEntry + var metadataJSON []byte + + err := rows.Scan( + &entry.ID, &entry.TenantID, &entry.NamespaceID, &entry.UserID, &entry.SessionID, + &entry.Operation, &entry.ModelUsed, &entry.Provider, &entry.PromptHash, &entry.PromptLength, &entry.ResponseLength, + &entry.TokensUsed, &entry.DurationMS, &entry.PIIDetected, &entry.PIITypesDetected, &entry.PIIRedacted, + &entry.PolicyID, &entry.PolicyViolations, &entry.DataCategoriesAccessed, &entry.ErrorMessage, + &metadataJSON, &entry.CreatedAt, + ) + if err != nil { + continue + } + + if metadataJSON != nil { + json.Unmarshal(metadataJSON, &entry.RequestMetadata) + } + + entries = append(entries, &entry) + } + + return entries, totalCount, nil +} + +// GeneralAuditFilter defines filters for general audit queries +type GeneralAuditFilter struct { + TenantID uuid.UUID + NamespaceID *uuid.UUID + UserID *uuid.UUID + Action string + ResourceType string + ResourceID *uuid.UUID + StartDate *time.Time + EndDate *time.Time + Limit int + Offset int +} + +// QueryGeneralAuditEntries queries general audit entries with filters +func (s *Store) QueryGeneralAuditEntries(ctx context.Context, filter *GeneralAuditFilter) ([]*GeneralAuditEntry, int, error) { + query := ` + SELECT id, tenant_id, namespace_id, user_id, action, resource_type, resource_id, + old_values, new_values, ip_address, user_agent, reason, created_at + FROM compliance_audit_trail + WHERE tenant_id = $1 + ` + + countQuery := `SELECT COUNT(*) FROM compliance_audit_trail WHERE tenant_id = $1` + + args := []any{filter.TenantID} + argIndex := 2 + + if filter.NamespaceID != nil { + query += fmt.Sprintf(" AND namespace_id = $%d", argIndex) + countQuery += fmt.Sprintf(" AND namespace_id = $%d", argIndex) + args = append(args, *filter.NamespaceID) + argIndex++ + } + + if filter.UserID != nil { + query += fmt.Sprintf(" AND user_id = $%d", argIndex) + countQuery += fmt.Sprintf(" AND user_id = $%d", argIndex) + args = append(args, *filter.UserID) + argIndex++ + } + + if filter.Action != "" { + query += fmt.Sprintf(" AND action = $%d", argIndex) + countQuery += fmt.Sprintf(" AND action = $%d", argIndex) + args = append(args, filter.Action) + argIndex++ + } + + if filter.ResourceType != "" { + query += fmt.Sprintf(" AND resource_type = $%d", argIndex) + countQuery += fmt.Sprintf(" AND resource_type = $%d", argIndex) + args = append(args, filter.ResourceType) + argIndex++ + } + + if filter.ResourceID != nil { + query += fmt.Sprintf(" AND resource_id = $%d", argIndex) + countQuery += fmt.Sprintf(" AND resource_id = $%d", argIndex) + args = append(args, *filter.ResourceID) + argIndex++ + } + + if filter.StartDate != nil { + query += fmt.Sprintf(" AND created_at >= $%d", argIndex) + countQuery += fmt.Sprintf(" AND created_at >= $%d", argIndex) + args = append(args, *filter.StartDate) + argIndex++ + } + + if filter.EndDate != nil { + query += fmt.Sprintf(" AND created_at <= $%d", argIndex) + countQuery += fmt.Sprintf(" AND created_at <= $%d", argIndex) + args = append(args, *filter.EndDate) + argIndex++ + } + + // Get total count + var totalCount int + if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&totalCount); err != nil { + return nil, 0, err + } + + // Add ordering and pagination + query += " ORDER BY created_at DESC" + + if filter.Limit > 0 { + query += fmt.Sprintf(" LIMIT $%d", argIndex) + args = append(args, filter.Limit) + argIndex++ + } + + if filter.Offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argIndex) + args = append(args, filter.Offset) + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var entries []*GeneralAuditEntry + for rows.Next() { + var entry GeneralAuditEntry + var oldValuesJSON, newValuesJSON []byte + + err := rows.Scan( + &entry.ID, &entry.TenantID, &entry.NamespaceID, &entry.UserID, + &entry.Action, &entry.ResourceType, &entry.ResourceID, + &oldValuesJSON, &newValuesJSON, &entry.IPAddress, &entry.UserAgent, + &entry.Reason, &entry.CreatedAt, + ) + if err != nil { + continue + } + + if oldValuesJSON != nil { + json.Unmarshal(oldValuesJSON, &entry.OldValues) + } + if newValuesJSON != nil { + json.Unmarshal(newValuesJSON, &entry.NewValues) + } + + entries = append(entries, &entry) + } + + return entries, totalCount, nil +} + +// GetLLMUsageStats retrieves aggregated LLM usage statistics +func (s *Store) GetLLMUsageStats(ctx context.Context, tenantID uuid.UUID, startDate, endDate time.Time) (*LLMUsageStats, error) { + var stats LLMUsageStats + + err := s.pool.QueryRow(ctx, ` + SELECT + COUNT(*) as total_requests, + COALESCE(SUM(tokens_used), 0) as total_tokens, + COALESCE(SUM(duration_ms), 0) as total_duration_ms, + COUNT(*) FILTER (WHERE pii_detected = TRUE) as requests_with_pii, + COUNT(*) FILTER (WHERE array_length(policy_violations, 1) > 0) as policy_violations + FROM compliance_llm_audit_log + WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3 + `, tenantID, startDate, endDate).Scan( + &stats.TotalRequests, + &stats.TotalTokens, + &stats.TotalDurationMS, + &stats.RequestsWithPII, + &stats.PolicyViolations, + ) + + if err != nil { + return nil, err + } + + // Get model usage breakdown + rows, err := s.pool.Query(ctx, ` + SELECT model_used, COUNT(*) as count + FROM compliance_llm_audit_log + WHERE tenant_id = $1 AND created_at >= $2 AND created_at <= $3 + GROUP BY model_used + `, tenantID, startDate, endDate) + if err != nil { + return nil, err + } + defer rows.Close() + + stats.ModelsUsed = make(map[string]int) + for rows.Next() { + var model string + var count int + if err := rows.Scan(&model, &count); err == nil { + stats.ModelsUsed[model] = count + } + } + + return &stats, nil +} + +// LLMUsageStats represents aggregated LLM usage statistics +type LLMUsageStats struct { + TotalRequests int `json:"total_requests"` + TotalTokens int `json:"total_tokens"` + TotalDurationMS int64 `json:"total_duration_ms"` + RequestsWithPII int `json:"requests_with_pii"` + PolicyViolations int `json:"policy_violations"` + ModelsUsed map[string]int `json:"models_used"` +} + +// CleanupOldEntries removes audit entries older than the retention period +func (s *Store) CleanupOldEntries(ctx context.Context, retentionDays int) (int, int, error) { + cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays) + + // Cleanup LLM audit log + llmResult, err := s.pool.Exec(ctx, ` + DELETE FROM compliance_llm_audit_log WHERE created_at < $1 + `, cutoff) + if err != nil { + return 0, 0, err + } + + // Cleanup general audit trail + generalResult, err := s.pool.Exec(ctx, ` + DELETE FROM compliance_audit_trail WHERE created_at < $1 + `, cutoff) + if err != nil { + return int(llmResult.RowsAffected()), 0, err + } + + return int(llmResult.RowsAffected()), int(generalResult.RowsAffected()), nil +} diff --git a/ai-compliance-sdk/internal/audit/trail_builder.go b/ai-compliance-sdk/internal/audit/trail_builder.go new file mode 100644 index 0000000..73108b8 --- /dev/null +++ b/ai-compliance-sdk/internal/audit/trail_builder.go @@ -0,0 +1,337 @@ +package audit + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// TrailBuilder helps construct structured audit entries +type TrailBuilder struct { + store *Store +} + +// NewTrailBuilder creates a new trail builder +func NewTrailBuilder(store *Store) *TrailBuilder { + return &TrailBuilder{store: store} +} + +// LLMEntryBuilder builds LLM audit entries +type LLMEntryBuilder struct { + entry *LLMAuditEntry + store *Store +} + +// NewLLMEntry creates a new LLM audit entry builder +func (tb *TrailBuilder) NewLLMEntry() *LLMEntryBuilder { + return &LLMEntryBuilder{ + entry: &LLMAuditEntry{ + ID: uuid.New(), + PIITypesDetected: []string{}, + PolicyViolations: []string{}, + DataCategoriesAccessed: []string{}, + RequestMetadata: make(map[string]any), + CreatedAt: time.Now().UTC(), + }, + store: tb.store, + } +} + +// WithTenant sets the tenant ID +func (b *LLMEntryBuilder) WithTenant(tenantID uuid.UUID) *LLMEntryBuilder { + b.entry.TenantID = tenantID + return b +} + +// WithNamespace sets the namespace ID +func (b *LLMEntryBuilder) WithNamespace(namespaceID uuid.UUID) *LLMEntryBuilder { + b.entry.NamespaceID = &namespaceID + return b +} + +// WithUser sets the user ID +func (b *LLMEntryBuilder) WithUser(userID uuid.UUID) *LLMEntryBuilder { + b.entry.UserID = userID + return b +} + +// WithSession sets the session ID +func (b *LLMEntryBuilder) WithSession(sessionID string) *LLMEntryBuilder { + b.entry.SessionID = sessionID + return b +} + +// WithOperation sets the operation type +func (b *LLMEntryBuilder) WithOperation(operation string) *LLMEntryBuilder { + b.entry.Operation = operation + return b +} + +// WithModel sets the model used +func (b *LLMEntryBuilder) WithModel(model, provider string) *LLMEntryBuilder { + b.entry.ModelUsed = model + b.entry.Provider = provider + return b +} + +// WithPrompt sets prompt-related fields +func (b *LLMEntryBuilder) WithPrompt(hash string, length int) *LLMEntryBuilder { + b.entry.PromptHash = hash + b.entry.PromptLength = length + return b +} + +// WithResponse sets response-related fields +func (b *LLMEntryBuilder) WithResponse(length int) *LLMEntryBuilder { + b.entry.ResponseLength = length + return b +} + +// WithUsage sets token usage and duration +func (b *LLMEntryBuilder) WithUsage(tokens int, durationMS int) *LLMEntryBuilder { + b.entry.TokensUsed = tokens + b.entry.DurationMS = durationMS + return b +} + +// WithPII sets PII detection fields +func (b *LLMEntryBuilder) WithPII(detected bool, types []string, redacted bool) *LLMEntryBuilder { + b.entry.PIIDetected = detected + b.entry.PIITypesDetected = types + b.entry.PIIRedacted = redacted + return b +} + +// WithPolicy sets policy-related fields +func (b *LLMEntryBuilder) WithPolicy(policyID *uuid.UUID, violations []string) *LLMEntryBuilder { + b.entry.PolicyID = policyID + b.entry.PolicyViolations = violations + return b +} + +// WithDataCategories sets accessed data categories +func (b *LLMEntryBuilder) WithDataCategories(categories []string) *LLMEntryBuilder { + b.entry.DataCategoriesAccessed = categories + return b +} + +// WithError sets error message +func (b *LLMEntryBuilder) WithError(errMsg string) *LLMEntryBuilder { + b.entry.ErrorMessage = errMsg + return b +} + +// WithMetadata sets request metadata +func (b *LLMEntryBuilder) WithMetadata(metadata map[string]any) *LLMEntryBuilder { + b.entry.RequestMetadata = metadata + return b +} + +// AddMetadata adds a key-value pair to metadata +func (b *LLMEntryBuilder) AddMetadata(key string, value any) *LLMEntryBuilder { + if b.entry.RequestMetadata == nil { + b.entry.RequestMetadata = make(map[string]any) + } + b.entry.RequestMetadata[key] = value + return b +} + +// Build returns the built entry +func (b *LLMEntryBuilder) Build() *LLMAuditEntry { + return b.entry +} + +// Save persists the entry to the database +func (b *LLMEntryBuilder) Save(ctx context.Context) error { + return b.store.CreateLLMAuditEntry(ctx, b.entry) +} + +// GeneralEntryBuilder builds general audit entries +type GeneralEntryBuilder struct { + entry *GeneralAuditEntry + store *Store +} + +// NewGeneralEntry creates a new general audit entry builder +func (tb *TrailBuilder) NewGeneralEntry() *GeneralEntryBuilder { + return &GeneralEntryBuilder{ + entry: &GeneralAuditEntry{ + ID: uuid.New(), + OldValues: make(map[string]any), + NewValues: make(map[string]any), + CreatedAt: time.Now().UTC(), + }, + store: tb.store, + } +} + +// WithTenant sets the tenant ID +func (b *GeneralEntryBuilder) WithTenant(tenantID uuid.UUID) *GeneralEntryBuilder { + b.entry.TenantID = tenantID + return b +} + +// WithNamespace sets the namespace ID +func (b *GeneralEntryBuilder) WithNamespace(namespaceID uuid.UUID) *GeneralEntryBuilder { + b.entry.NamespaceID = &namespaceID + return b +} + +// WithUser sets the user ID +func (b *GeneralEntryBuilder) WithUser(userID uuid.UUID) *GeneralEntryBuilder { + b.entry.UserID = userID + return b +} + +// WithAction sets the action +func (b *GeneralEntryBuilder) WithAction(action string) *GeneralEntryBuilder { + b.entry.Action = action + return b +} + +// WithResource sets the resource type and ID +func (b *GeneralEntryBuilder) WithResource(resourceType string, resourceID *uuid.UUID) *GeneralEntryBuilder { + b.entry.ResourceType = resourceType + b.entry.ResourceID = resourceID + return b +} + +// WithOldValues sets the old values +func (b *GeneralEntryBuilder) WithOldValues(values map[string]any) *GeneralEntryBuilder { + b.entry.OldValues = values + return b +} + +// WithNewValues sets the new values +func (b *GeneralEntryBuilder) WithNewValues(values map[string]any) *GeneralEntryBuilder { + b.entry.NewValues = values + return b +} + +// WithClient sets client information +func (b *GeneralEntryBuilder) WithClient(ipAddress, userAgent string) *GeneralEntryBuilder { + b.entry.IPAddress = ipAddress + b.entry.UserAgent = userAgent + return b +} + +// WithReason sets the reason for the action +func (b *GeneralEntryBuilder) WithReason(reason string) *GeneralEntryBuilder { + b.entry.Reason = reason + return b +} + +// Build returns the built entry +func (b *GeneralEntryBuilder) Build() *GeneralAuditEntry { + return b.entry +} + +// Save persists the entry to the database +func (b *GeneralEntryBuilder) Save(ctx context.Context) error { + return b.store.CreateGeneralAuditEntry(ctx, b.entry) +} + +// Common audit action types +const ( + ActionCreate = "create" + ActionUpdate = "update" + ActionDelete = "delete" + ActionRead = "read" + ActionExport = "export" + ActionGrant = "grant" + ActionRevoke = "revoke" + ActionLogin = "login" + ActionLogout = "logout" + ActionFailed = "failed" +) + +// Common resource types +const ( + ResourceTenant = "tenant" + ResourceNamespace = "namespace" + ResourceRole = "role" + ResourceUserRole = "user_role" + ResourcePolicy = "llm_policy" + ResourceAPIKey = "api_key" + ResourceEvidence = "evidence" + ResourceControl = "control" +) + +// Convenience methods for common operations + +// LogRoleAssignment creates an audit entry for role assignment +func (tb *TrailBuilder) LogRoleAssignment(ctx context.Context, tenantID, userID, targetUserID, roleID uuid.UUID, grantedBy uuid.UUID, ipAddress, userAgent string) error { + return tb.NewGeneralEntry(). + WithTenant(tenantID). + WithUser(grantedBy). + WithAction(ActionGrant). + WithResource(ResourceUserRole, &roleID). + WithNewValues(map[string]any{ + "target_user_id": targetUserID.String(), + "role_id": roleID.String(), + }). + WithClient(ipAddress, userAgent). + Save(ctx) +} + +// LogRoleRevocation creates an audit entry for role revocation +func (tb *TrailBuilder) LogRoleRevocation(ctx context.Context, tenantID, userID, targetUserID, roleID uuid.UUID, revokedBy uuid.UUID, reason, ipAddress, userAgent string) error { + return tb.NewGeneralEntry(). + WithTenant(tenantID). + WithUser(revokedBy). + WithAction(ActionRevoke). + WithResource(ResourceUserRole, &roleID). + WithOldValues(map[string]any{ + "target_user_id": targetUserID.String(), + "role_id": roleID.String(), + }). + WithReason(reason). + WithClient(ipAddress, userAgent). + Save(ctx) +} + +// LogPolicyChange creates an audit entry for LLM policy changes +func (tb *TrailBuilder) LogPolicyChange(ctx context.Context, tenantID, userID, policyID uuid.UUID, action string, oldValues, newValues map[string]any, ipAddress, userAgent string) error { + return tb.NewGeneralEntry(). + WithTenant(tenantID). + WithUser(userID). + WithAction(action). + WithResource(ResourcePolicy, &policyID). + WithOldValues(oldValues). + WithNewValues(newValues). + WithClient(ipAddress, userAgent). + Save(ctx) +} + +// LogNamespaceAccess creates an audit entry for namespace access +func (tb *TrailBuilder) LogNamespaceAccess(ctx context.Context, tenantID, userID, namespaceID uuid.UUID, action string, ipAddress, userAgent string) error { + return tb.NewGeneralEntry(). + WithTenant(tenantID). + WithUser(userID). + WithNamespace(namespaceID). + WithAction(action). + WithResource(ResourceNamespace, &namespaceID). + WithClient(ipAddress, userAgent). + Save(ctx) +} + +// LogDataExport creates an audit entry for data export +func (tb *TrailBuilder) LogDataExport(ctx context.Context, tenantID, userID uuid.UUID, namespaceID *uuid.UUID, resourceType, format string, recordCount int, ipAddress, userAgent string) error { + builder := tb.NewGeneralEntry(). + WithTenant(tenantID). + WithUser(userID). + WithAction(ActionExport). + WithResource(resourceType, nil). + WithNewValues(map[string]any{ + "format": format, + "record_count": recordCount, + }). + WithClient(ipAddress, userAgent) + + if namespaceID != nil { + builder.WithNamespace(*namespaceID) + } + + return builder.Save(ctx) +} diff --git a/ai-compliance-sdk/internal/config/config.go b/ai-compliance-sdk/internal/config/config.go new file mode 100644 index 0000000..5d5a747 --- /dev/null +++ b/ai-compliance-sdk/internal/config/config.go @@ -0,0 +1,182 @@ +package config + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" +) + +// Config holds all configuration for the AI Compliance SDK +type Config struct { + // Server + Port string + Environment string + + // Database + DatabaseURL string + + // JWT + JWTSecret string + + // CORS + AllowedOrigins []string + + // Rate Limiting + RateLimitRequests int + RateLimitWindow int // in seconds + + // LLM Providers + LLMProvider string // 'ollama', 'anthropic', 'openai' + LLMFallbackProvider string // Fallback provider if primary fails + + // Ollama Configuration + OllamaURL string + OllamaDefaultModel string + + // Anthropic Configuration + AnthropicAPIKey string + AnthropicDefaultModel string + + // OpenAI Configuration (optional) + OpenAIAPIKey string + OpenAIDefaultModel string + + // PII Detection + PIIRedactionEnabled bool + PIIRedactionLevel string // 'strict', 'moderate', 'minimal' + + // Audit + AuditRetentionDays int + AuditExportEnabled bool + + // Valkey/Redis for Caching + ValkeyURL string + ValkeyEnabled bool + + // Consent Service Integration + ConsentServiceURL string + + // Frontend URLs + AdminFrontendURL string +} + +// Load loads configuration from environment variables +func Load() (*Config, error) { + // Load .env file if exists (for development) + _ = godotenv.Load() + + cfg := &Config{ + Port: getEnv("PORT", "8090"), + Environment: getEnv("ENVIRONMENT", "development"), + DatabaseURL: getEnv("DATABASE_URL", ""), + JWTSecret: getEnv("JWT_SECRET", ""), + RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100), + RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60), + + // LLM Configuration + LLMProvider: getEnv("LLM_PROVIDER", "ollama"), + LLMFallbackProvider: getEnv("LLM_FALLBACK_PROVIDER", "anthropic"), + + // Ollama + OllamaURL: getEnv("OLLAMA_URL", "http://localhost:11434"), + OllamaDefaultModel: getEnv("OLLAMA_DEFAULT_MODEL", "qwen2.5:7b"), + + // Anthropic + AnthropicAPIKey: getEnv("ANTHROPIC_API_KEY", ""), + AnthropicDefaultModel: getEnv("ANTHROPIC_DEFAULT_MODEL", "claude-3-sonnet-20240229"), + + // OpenAI + OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""), + OpenAIDefaultModel: getEnv("OPENAI_DEFAULT_MODEL", "gpt-4-turbo-preview"), + + // PII + PIIRedactionEnabled: getEnvBool("PII_REDACTION_ENABLED", true), + PIIRedactionLevel: getEnv("PII_REDACTION_LEVEL", "strict"), + + // Audit + AuditRetentionDays: getEnvInt("AUDIT_RETENTION_DAYS", 365), + AuditExportEnabled: getEnvBool("AUDIT_EXPORT_ENABLED", true), + + // Valkey + ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379"), + ValkeyEnabled: getEnvBool("VALKEY_ENABLED", true), + + // Integration + ConsentServiceURL: getEnv("CONSENT_SERVICE_URL", "http://localhost:8081"), + AdminFrontendURL: getEnv("ADMIN_FRONTEND_URL", "http://localhost:3002"), + } + + // Parse allowed origins + originsStr := getEnv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:3002,http://localhost:8000") + cfg.AllowedOrigins = parseCommaSeparated(originsStr) + + // Validate required fields + if cfg.DatabaseURL == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + + if cfg.JWTSecret == "" { + return nil, fmt.Errorf("JWT_SECRET is required") + } + + return cfg, nil +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + var result int + fmt.Sscanf(value, "%d", &result) + return result + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + return value == "true" || value == "1" || value == "yes" + } + return defaultValue +} + +func parseCommaSeparated(s string) []string { + if s == "" { + return []string{} + } + var result []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == ',' { + item := s[start:i] + // Trim whitespace + for len(item) > 0 && item[0] == ' ' { + item = item[1:] + } + for len(item) > 0 && item[len(item)-1] == ' ' { + item = item[:len(item)-1] + } + if item != "" { + result = append(result, item) + } + start = i + 1 + } + } + return result +} + +// IsDevelopment returns true if running in development mode +func (c *Config) IsDevelopment() bool { + return c.Environment == "development" +} + +// IsProduction returns true if running in production mode +func (c *Config) IsProduction() bool { + return c.Environment == "production" +} diff --git a/ai-compliance-sdk/internal/dsgvo/models.go b/ai-compliance-sdk/internal/dsgvo/models.go new file mode 100644 index 0000000..8cd3ddf --- /dev/null +++ b/ai-compliance-sdk/internal/dsgvo/models.go @@ -0,0 +1,235 @@ +package dsgvo + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// VVT - Verarbeitungsverzeichnis (Art. 30 DSGVO) +// ============================================================================ + +// ProcessingActivity represents an entry in the Records of Processing Activities +type ProcessingActivity struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Purpose string `json:"purpose"` + LegalBasis string `json:"legal_basis"` // consent, contract, legal_obligation, vital_interests, public_interest, legitimate_interests + LegalBasisDetails string `json:"legal_basis_details,omitempty"` + DataCategories []string `json:"data_categories"` // personal, sensitive, health, financial, etc. + DataSubjectCategories []string `json:"data_subject_categories"` // customers, employees, suppliers, etc. + Recipients []string `json:"recipients"` // Internal departments, external processors + ThirdCountryTransfer bool `json:"third_country_transfer"` + TransferSafeguards string `json:"transfer_safeguards,omitempty"` // SCCs, adequacy decision, BCRs + RetentionPeriod string `json:"retention_period"` + RetentionPolicyID *uuid.UUID `json:"retention_policy_id,omitempty"` + TOMReference []uuid.UUID `json:"tom_reference,omitempty"` // Links to TOM entries + DSFARequired bool `json:"dsfa_required"` + DSFAID *uuid.UUID `json:"dsfa_id,omitempty"` + ResponsiblePerson string `json:"responsible_person"` + ResponsibleDepartment string `json:"responsible_department"` + Systems []string `json:"systems"` // IT systems involved + Status string `json:"status"` // draft, active, under_review, archived + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` + LastReviewedAt *time.Time `json:"last_reviewed_at,omitempty"` + NextReviewAt *time.Time `json:"next_review_at,omitempty"` +} + +// ============================================================================ +// DSFA - Datenschutz-Folgenabschätzung (Art. 35 DSGVO) +// ============================================================================ + +// DSFA represents a Data Protection Impact Assessment +type DSFA struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + ProcessingActivityID *uuid.UUID `json:"processing_activity_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + ProcessingDescription string `json:"processing_description"` + NecessityAssessment string `json:"necessity_assessment"` + ProportionalityAssment string `json:"proportionality_assessment"` + Risks []DSFARisk `json:"risks"` + Mitigations []DSFAMitigation `json:"mitigations"` + DPOConsulted bool `json:"dpo_consulted"` + DPOOpinion string `json:"dpo_opinion,omitempty"` + AuthorityConsulted bool `json:"authority_consulted"` + AuthorityReference string `json:"authority_reference,omitempty"` + Status string `json:"status"` // draft, in_progress, completed, approved, rejected + OverallRiskLevel string `json:"overall_risk_level"` // low, medium, high, very_high + Conclusion string `json:"conclusion"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` + ApprovedBy *uuid.UUID `json:"approved_by,omitempty"` + ApprovedAt *time.Time `json:"approved_at,omitempty"` +} + +// DSFARisk represents a risk identified in the DSFA +type DSFARisk struct { + ID uuid.UUID `json:"id"` + Category string `json:"category"` // confidentiality, integrity, availability, rights_freedoms + Description string `json:"description"` + Likelihood string `json:"likelihood"` // low, medium, high + Impact string `json:"impact"` // low, medium, high + RiskLevel string `json:"risk_level"` // calculated: low, medium, high, very_high + AffectedData []string `json:"affected_data"` +} + +// DSFAMitigation represents a mitigation measure for a DSFA risk +type DSFAMitigation struct { + ID uuid.UUID `json:"id"` + RiskID uuid.UUID `json:"risk_id"` + Description string `json:"description"` + Type string `json:"type"` // technical, organizational, legal + Status string `json:"status"` // planned, in_progress, implemented, verified + ImplementedAt *time.Time `json:"implemented_at,omitempty"` + VerifiedAt *time.Time `json:"verified_at,omitempty"` + ResidualRisk string `json:"residual_risk"` // low, medium, high + TOMReference *uuid.UUID `json:"tom_reference,omitempty"` + ResponsibleParty string `json:"responsible_party"` +} + +// ============================================================================ +// TOM - Technische und Organisatorische Maßnahmen (Art. 32 DSGVO) +// ============================================================================ + +// TOM represents a Technical or Organizational Measure +type TOM struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + Category string `json:"category"` // access_control, encryption, pseudonymization, availability, resilience, monitoring, incident_response + Subcategory string `json:"subcategory,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` // technical, organizational + ImplementationStatus string `json:"implementation_status"` // planned, in_progress, implemented, verified, not_applicable + ImplementedAt *time.Time `json:"implemented_at,omitempty"` + VerifiedAt *time.Time `json:"verified_at,omitempty"` + VerifiedBy *uuid.UUID `json:"verified_by,omitempty"` + EffectivenessRating string `json:"effectiveness_rating,omitempty"` // low, medium, high + Documentation string `json:"documentation,omitempty"` + ResponsiblePerson string `json:"responsible_person"` + ResponsibleDepartment string `json:"responsible_department"` + ReviewFrequency string `json:"review_frequency"` // monthly, quarterly, annually + LastReviewAt *time.Time `json:"last_review_at,omitempty"` + NextReviewAt *time.Time `json:"next_review_at,omitempty"` + RelatedControls []string `json:"related_controls,omitempty"` // ISO 27001 controls, SOC2, etc. + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` +} + +// TOMCategory represents predefined TOM categories per Art. 32 DSGVO +var TOMCategories = []string{ + "access_control", // Zutrittskontrolle + "admission_control", // Zugangskontrolle + "access_management", // Zugriffskontrolle + "transfer_control", // Weitergabekontrolle + "input_control", // Eingabekontrolle + "availability_control", // Verfügbarkeitskontrolle + "separation_control", // Trennungskontrolle + "encryption", // Verschlüsselung + "pseudonymization", // Pseudonymisierung + "resilience", // Belastbarkeit + "recovery", // Wiederherstellung + "testing", // Regelmäßige Überprüfung +} + +// ============================================================================ +// DSR - Data Subject Requests / Betroffenenrechte (Art. 15-22 DSGVO) +// ============================================================================ + +// DSR represents a Data Subject Request +type DSR struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + RequestType string `json:"request_type"` // access, rectification, erasure, restriction, portability, objection + Status string `json:"status"` // received, verified, in_progress, completed, rejected, extended + SubjectName string `json:"subject_name"` + SubjectEmail string `json:"subject_email"` + SubjectIdentifier string `json:"subject_identifier,omitempty"` // Customer ID, User ID, etc. + RequestDescription string `json:"request_description"` + RequestChannel string `json:"request_channel"` // email, form, phone, letter + ReceivedAt time.Time `json:"received_at"` + VerifiedAt *time.Time `json:"verified_at,omitempty"` + VerificationMethod string `json:"verification_method,omitempty"` + DeadlineAt time.Time `json:"deadline_at"` // Art. 12(3): 1 month, extendable by 2 months + ExtendedDeadlineAt *time.Time `json:"extended_deadline_at,omitempty"` + ExtensionReason string `json:"extension_reason,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + ResponseSent bool `json:"response_sent"` + ResponseSentAt *time.Time `json:"response_sent_at,omitempty"` + ResponseMethod string `json:"response_method,omitempty"` + RejectionReason string `json:"rejection_reason,omitempty"` + Notes string `json:"notes,omitempty"` + AffectedSystems []string `json:"affected_systems,omitempty"` + AssignedTo *uuid.UUID `json:"assigned_to,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` +} + +// DSRType represents the types of data subject requests +var DSRTypes = map[string]string{ + "access": "Art. 15 - Auskunftsrecht", + "rectification": "Art. 16 - Recht auf Berichtigung", + "erasure": "Art. 17 - Recht auf Löschung", + "restriction": "Art. 18 - Recht auf Einschränkung", + "portability": "Art. 20 - Recht auf Datenübertragbarkeit", + "objection": "Art. 21 - Widerspruchsrecht", +} + +// ============================================================================ +// Retention - Löschfristen (Art. 17 DSGVO) +// ============================================================================ + +// RetentionPolicy represents a data retention policy +type RetentionPolicy struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + DataCategory string `json:"data_category"` + RetentionPeriodDays int `json:"retention_period_days"` + RetentionPeriodText string `json:"retention_period_text"` // Human readable: "3 Jahre", "10 Jahre nach Vertragsende" + LegalBasis string `json:"legal_basis"` // Legal requirement, consent, legitimate interest + LegalReference string `json:"legal_reference,omitempty"` // § 147 AO, § 257 HGB, etc. + DeletionMethod string `json:"deletion_method"` // automatic, manual, anonymization + DeletionProcedure string `json:"deletion_procedure,omitempty"` + ExceptionCriteria string `json:"exception_criteria,omitempty"` + ApplicableSystems []string `json:"applicable_systems,omitempty"` + ResponsiblePerson string `json:"responsible_person"` + ResponsibleDepartment string `json:"responsible_department"` + Status string `json:"status"` // draft, active, archived + LastReviewAt *time.Time `json:"last_review_at,omitempty"` + NextReviewAt *time.Time `json:"next_review_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` +} + +// CommonRetentionPeriods defines common retention periods in German law +var CommonRetentionPeriods = map[string]int{ + "steuerlich_10_jahre": 3650, // § 147 AO - Buchungsbelege + "handelsrechtlich_6_jahre": 2190, // § 257 HGB - Handelsbriefe + "arbeitsrechtlich_3_jahre": 1095, // Lohnunterlagen nach Ausscheiden + "bewerbungen_6_monate": 180, // AGG-Frist + "consent_widerruf_3_jahre": 1095, // Nachweis der Einwilligung + "vertragsunterlagen_3_jahre": 1095, // Verjährungsfrist +} diff --git a/ai-compliance-sdk/internal/dsgvo/store.go b/ai-compliance-sdk/internal/dsgvo/store.go new file mode 100644 index 0000000..95bc8f0 --- /dev/null +++ b/ai-compliance-sdk/internal/dsgvo/store.go @@ -0,0 +1,664 @@ +package dsgvo + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store handles DSGVO data persistence +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new DSGVO store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// ============================================================================ +// VVT - Verarbeitungsverzeichnis +// ============================================================================ + +// CreateProcessingActivity creates a new processing activity +func (s *Store) CreateProcessingActivity(ctx context.Context, pa *ProcessingActivity) error { + pa.ID = uuid.New() + pa.CreatedAt = time.Now().UTC() + pa.UpdatedAt = pa.CreatedAt + + metadata, _ := json.Marshal(pa.Metadata) + dataCategories, _ := json.Marshal(pa.DataCategories) + dataSubjectCategories, _ := json.Marshal(pa.DataSubjectCategories) + recipients, _ := json.Marshal(pa.Recipients) + tomReference, _ := json.Marshal(pa.TOMReference) + systems, _ := json.Marshal(pa.Systems) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO dsgvo_processing_activities ( + id, tenant_id, namespace_id, name, description, purpose, legal_basis, legal_basis_details, + data_categories, data_subject_categories, recipients, third_country_transfer, transfer_safeguards, + retention_period, retention_policy_id, tom_reference, dsfa_required, dsfa_id, + responsible_person, responsible_department, systems, status, metadata, + created_at, updated_at, created_by, last_reviewed_at, next_review_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28) + `, pa.ID, pa.TenantID, pa.NamespaceID, pa.Name, pa.Description, pa.Purpose, pa.LegalBasis, pa.LegalBasisDetails, + dataCategories, dataSubjectCategories, recipients, pa.ThirdCountryTransfer, pa.TransferSafeguards, + pa.RetentionPeriod, pa.RetentionPolicyID, tomReference, pa.DSFARequired, pa.DSFAID, + pa.ResponsiblePerson, pa.ResponsibleDepartment, systems, pa.Status, metadata, + pa.CreatedAt, pa.UpdatedAt, pa.CreatedBy, pa.LastReviewedAt, pa.NextReviewAt) + + return err +} + +// GetProcessingActivity retrieves a processing activity by ID +func (s *Store) GetProcessingActivity(ctx context.Context, id uuid.UUID) (*ProcessingActivity, error) { + var pa ProcessingActivity + var metadata, dataCategories, dataSubjectCategories, recipients, tomReference, systems []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, namespace_id, name, description, purpose, legal_basis, legal_basis_details, + data_categories, data_subject_categories, recipients, third_country_transfer, transfer_safeguards, + retention_period, retention_policy_id, tom_reference, dsfa_required, dsfa_id, + responsible_person, responsible_department, systems, status, metadata, + created_at, updated_at, created_by, last_reviewed_at, next_review_at + FROM dsgvo_processing_activities WHERE id = $1 + `, id).Scan(&pa.ID, &pa.TenantID, &pa.NamespaceID, &pa.Name, &pa.Description, &pa.Purpose, &pa.LegalBasis, &pa.LegalBasisDetails, + &dataCategories, &dataSubjectCategories, &recipients, &pa.ThirdCountryTransfer, &pa.TransferSafeguards, + &pa.RetentionPeriod, &pa.RetentionPolicyID, &tomReference, &pa.DSFARequired, &pa.DSFAID, + &pa.ResponsiblePerson, &pa.ResponsibleDepartment, &systems, &pa.Status, &metadata, + &pa.CreatedAt, &pa.UpdatedAt, &pa.CreatedBy, &pa.LastReviewedAt, &pa.NextReviewAt) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + json.Unmarshal(metadata, &pa.Metadata) + json.Unmarshal(dataCategories, &pa.DataCategories) + json.Unmarshal(dataSubjectCategories, &pa.DataSubjectCategories) + json.Unmarshal(recipients, &pa.Recipients) + json.Unmarshal(tomReference, &pa.TOMReference) + json.Unmarshal(systems, &pa.Systems) + + return &pa, nil +} + +// ListProcessingActivities lists processing activities for a tenant +func (s *Store) ListProcessingActivities(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID) ([]ProcessingActivity, error) { + query := ` + SELECT id, tenant_id, namespace_id, name, description, purpose, legal_basis, legal_basis_details, + data_categories, data_subject_categories, recipients, third_country_transfer, transfer_safeguards, + retention_period, retention_policy_id, tom_reference, dsfa_required, dsfa_id, + responsible_person, responsible_department, systems, status, metadata, + created_at, updated_at, created_by, last_reviewed_at, next_review_at + FROM dsgvo_processing_activities WHERE tenant_id = $1` + + args := []interface{}{tenantID} + if namespaceID != nil { + query += " AND namespace_id = $2" + args = append(args, *namespaceID) + } + query += " ORDER BY name" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var activities []ProcessingActivity + for rows.Next() { + var pa ProcessingActivity + var metadata, dataCategories, dataSubjectCategories, recipients, tomReference, systems []byte + + err := rows.Scan(&pa.ID, &pa.TenantID, &pa.NamespaceID, &pa.Name, &pa.Description, &pa.Purpose, &pa.LegalBasis, &pa.LegalBasisDetails, + &dataCategories, &dataSubjectCategories, &recipients, &pa.ThirdCountryTransfer, &pa.TransferSafeguards, + &pa.RetentionPeriod, &pa.RetentionPolicyID, &tomReference, &pa.DSFARequired, &pa.DSFAID, + &pa.ResponsiblePerson, &pa.ResponsibleDepartment, &systems, &pa.Status, &metadata, + &pa.CreatedAt, &pa.UpdatedAt, &pa.CreatedBy, &pa.LastReviewedAt, &pa.NextReviewAt) + if err != nil { + return nil, err + } + + json.Unmarshal(metadata, &pa.Metadata) + json.Unmarshal(dataCategories, &pa.DataCategories) + json.Unmarshal(dataSubjectCategories, &pa.DataSubjectCategories) + json.Unmarshal(recipients, &pa.Recipients) + json.Unmarshal(tomReference, &pa.TOMReference) + json.Unmarshal(systems, &pa.Systems) + + activities = append(activities, pa) + } + + return activities, nil +} + +// UpdateProcessingActivity updates a processing activity +func (s *Store) UpdateProcessingActivity(ctx context.Context, pa *ProcessingActivity) error { + pa.UpdatedAt = time.Now().UTC() + + metadata, _ := json.Marshal(pa.Metadata) + dataCategories, _ := json.Marshal(pa.DataCategories) + dataSubjectCategories, _ := json.Marshal(pa.DataSubjectCategories) + recipients, _ := json.Marshal(pa.Recipients) + tomReference, _ := json.Marshal(pa.TOMReference) + systems, _ := json.Marshal(pa.Systems) + + _, err := s.pool.Exec(ctx, ` + UPDATE dsgvo_processing_activities SET + name = $2, description = $3, purpose = $4, legal_basis = $5, legal_basis_details = $6, + data_categories = $7, data_subject_categories = $8, recipients = $9, third_country_transfer = $10, + transfer_safeguards = $11, retention_period = $12, retention_policy_id = $13, tom_reference = $14, + dsfa_required = $15, dsfa_id = $16, responsible_person = $17, responsible_department = $18, + systems = $19, status = $20, metadata = $21, updated_at = $22, last_reviewed_at = $23, next_review_at = $24 + WHERE id = $1 + `, pa.ID, pa.Name, pa.Description, pa.Purpose, pa.LegalBasis, pa.LegalBasisDetails, + dataCategories, dataSubjectCategories, recipients, pa.ThirdCountryTransfer, + pa.TransferSafeguards, pa.RetentionPeriod, pa.RetentionPolicyID, tomReference, + pa.DSFARequired, pa.DSFAID, pa.ResponsiblePerson, pa.ResponsibleDepartment, + systems, pa.Status, metadata, pa.UpdatedAt, pa.LastReviewedAt, pa.NextReviewAt) + + return err +} + +// DeleteProcessingActivity deletes a processing activity +func (s *Store) DeleteProcessingActivity(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, "DELETE FROM dsgvo_processing_activities WHERE id = $1", id) + return err +} + +// ============================================================================ +// TOM - Technische und Organisatorische Maßnahmen +// ============================================================================ + +// CreateTOM creates a new TOM entry +func (s *Store) CreateTOM(ctx context.Context, tom *TOM) error { + tom.ID = uuid.New() + tom.CreatedAt = time.Now().UTC() + tom.UpdatedAt = tom.CreatedAt + + metadata, _ := json.Marshal(tom.Metadata) + relatedControls, _ := json.Marshal(tom.RelatedControls) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO dsgvo_tom ( + id, tenant_id, namespace_id, category, subcategory, name, description, type, + implementation_status, implemented_at, verified_at, verified_by, effectiveness_rating, + documentation, responsible_person, responsible_department, review_frequency, + last_review_at, next_review_at, related_controls, metadata, created_at, updated_at, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) + `, tom.ID, tom.TenantID, tom.NamespaceID, tom.Category, tom.Subcategory, tom.Name, tom.Description, tom.Type, + tom.ImplementationStatus, tom.ImplementedAt, tom.VerifiedAt, tom.VerifiedBy, tom.EffectivenessRating, + tom.Documentation, tom.ResponsiblePerson, tom.ResponsibleDepartment, tom.ReviewFrequency, + tom.LastReviewAt, tom.NextReviewAt, relatedControls, metadata, tom.CreatedAt, tom.UpdatedAt, tom.CreatedBy) + + return err +} + +// GetTOM retrieves a TOM by ID +func (s *Store) GetTOM(ctx context.Context, id uuid.UUID) (*TOM, error) { + var tom TOM + var metadata, relatedControls []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, namespace_id, category, subcategory, name, description, type, + implementation_status, implemented_at, verified_at, verified_by, effectiveness_rating, + documentation, responsible_person, responsible_department, review_frequency, + last_review_at, next_review_at, related_controls, metadata, created_at, updated_at, created_by + FROM dsgvo_tom WHERE id = $1 + `, id).Scan(&tom.ID, &tom.TenantID, &tom.NamespaceID, &tom.Category, &tom.Subcategory, &tom.Name, &tom.Description, &tom.Type, + &tom.ImplementationStatus, &tom.ImplementedAt, &tom.VerifiedAt, &tom.VerifiedBy, &tom.EffectivenessRating, + &tom.Documentation, &tom.ResponsiblePerson, &tom.ResponsibleDepartment, &tom.ReviewFrequency, + &tom.LastReviewAt, &tom.NextReviewAt, &relatedControls, &metadata, &tom.CreatedAt, &tom.UpdatedAt, &tom.CreatedBy) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + json.Unmarshal(metadata, &tom.Metadata) + json.Unmarshal(relatedControls, &tom.RelatedControls) + + return &tom, nil +} + +// ListTOMs lists TOMs for a tenant +func (s *Store) ListTOMs(ctx context.Context, tenantID uuid.UUID, category string) ([]TOM, error) { + query := ` + SELECT id, tenant_id, namespace_id, category, subcategory, name, description, type, + implementation_status, implemented_at, verified_at, verified_by, effectiveness_rating, + documentation, responsible_person, responsible_department, review_frequency, + last_review_at, next_review_at, related_controls, metadata, created_at, updated_at, created_by + FROM dsgvo_tom WHERE tenant_id = $1` + + args := []interface{}{tenantID} + if category != "" { + query += " AND category = $2" + args = append(args, category) + } + query += " ORDER BY category, name" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var toms []TOM + for rows.Next() { + var tom TOM + var metadata, relatedControls []byte + + err := rows.Scan(&tom.ID, &tom.TenantID, &tom.NamespaceID, &tom.Category, &tom.Subcategory, &tom.Name, &tom.Description, &tom.Type, + &tom.ImplementationStatus, &tom.ImplementedAt, &tom.VerifiedAt, &tom.VerifiedBy, &tom.EffectivenessRating, + &tom.Documentation, &tom.ResponsiblePerson, &tom.ResponsibleDepartment, &tom.ReviewFrequency, + &tom.LastReviewAt, &tom.NextReviewAt, &relatedControls, &metadata, &tom.CreatedAt, &tom.UpdatedAt, &tom.CreatedBy) + if err != nil { + return nil, err + } + + json.Unmarshal(metadata, &tom.Metadata) + json.Unmarshal(relatedControls, &tom.RelatedControls) + + toms = append(toms, tom) + } + + return toms, nil +} + +// ============================================================================ +// DSR - Data Subject Requests +// ============================================================================ + +// CreateDSR creates a new DSR +func (s *Store) CreateDSR(ctx context.Context, dsr *DSR) error { + dsr.ID = uuid.New() + dsr.CreatedAt = time.Now().UTC() + dsr.UpdatedAt = dsr.CreatedAt + + // Default deadline: 1 month from receipt + if dsr.DeadlineAt.IsZero() { + dsr.DeadlineAt = dsr.ReceivedAt.AddDate(0, 1, 0) + } + + metadata, _ := json.Marshal(dsr.Metadata) + affectedSystems, _ := json.Marshal(dsr.AffectedSystems) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO dsgvo_dsr ( + id, tenant_id, namespace_id, request_type, status, subject_name, subject_email, + subject_identifier, request_description, request_channel, received_at, verified_at, + verification_method, deadline_at, extended_deadline_at, extension_reason, + completed_at, response_sent, response_sent_at, response_method, rejection_reason, + notes, affected_systems, assigned_to, metadata, created_at, updated_at, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28) + `, dsr.ID, dsr.TenantID, dsr.NamespaceID, dsr.RequestType, dsr.Status, dsr.SubjectName, dsr.SubjectEmail, + dsr.SubjectIdentifier, dsr.RequestDescription, dsr.RequestChannel, dsr.ReceivedAt, dsr.VerifiedAt, + dsr.VerificationMethod, dsr.DeadlineAt, dsr.ExtendedDeadlineAt, dsr.ExtensionReason, + dsr.CompletedAt, dsr.ResponseSent, dsr.ResponseSentAt, dsr.ResponseMethod, dsr.RejectionReason, + dsr.Notes, affectedSystems, dsr.AssignedTo, metadata, dsr.CreatedAt, dsr.UpdatedAt, dsr.CreatedBy) + + return err +} + +// GetDSR retrieves a DSR by ID +func (s *Store) GetDSR(ctx context.Context, id uuid.UUID) (*DSR, error) { + var dsr DSR + var metadata, affectedSystems []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, namespace_id, request_type, status, subject_name, subject_email, + subject_identifier, request_description, request_channel, received_at, verified_at, + verification_method, deadline_at, extended_deadline_at, extension_reason, + completed_at, response_sent, response_sent_at, response_method, rejection_reason, + notes, affected_systems, assigned_to, metadata, created_at, updated_at, created_by + FROM dsgvo_dsr WHERE id = $1 + `, id).Scan(&dsr.ID, &dsr.TenantID, &dsr.NamespaceID, &dsr.RequestType, &dsr.Status, &dsr.SubjectName, &dsr.SubjectEmail, + &dsr.SubjectIdentifier, &dsr.RequestDescription, &dsr.RequestChannel, &dsr.ReceivedAt, &dsr.VerifiedAt, + &dsr.VerificationMethod, &dsr.DeadlineAt, &dsr.ExtendedDeadlineAt, &dsr.ExtensionReason, + &dsr.CompletedAt, &dsr.ResponseSent, &dsr.ResponseSentAt, &dsr.ResponseMethod, &dsr.RejectionReason, + &dsr.Notes, &affectedSystems, &dsr.AssignedTo, &metadata, &dsr.CreatedAt, &dsr.UpdatedAt, &dsr.CreatedBy) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + json.Unmarshal(metadata, &dsr.Metadata) + json.Unmarshal(affectedSystems, &dsr.AffectedSystems) + + return &dsr, nil +} + +// ListDSRs lists DSRs for a tenant with optional filters +func (s *Store) ListDSRs(ctx context.Context, tenantID uuid.UUID, status string, requestType string) ([]DSR, error) { + query := ` + SELECT id, tenant_id, namespace_id, request_type, status, subject_name, subject_email, + subject_identifier, request_description, request_channel, received_at, verified_at, + verification_method, deadline_at, extended_deadline_at, extension_reason, + completed_at, response_sent, response_sent_at, response_method, rejection_reason, + notes, affected_systems, assigned_to, metadata, created_at, updated_at, created_by + FROM dsgvo_dsr WHERE tenant_id = $1` + + args := []interface{}{tenantID} + argIdx := 2 + + if status != "" { + query += " AND status = $" + string(rune('0'+argIdx)) + args = append(args, status) + argIdx++ + } + if requestType != "" { + query += " AND request_type = $" + string(rune('0'+argIdx)) + args = append(args, requestType) + } + query += " ORDER BY deadline_at ASC" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var dsrs []DSR + for rows.Next() { + var dsr DSR + var metadata, affectedSystems []byte + + err := rows.Scan(&dsr.ID, &dsr.TenantID, &dsr.NamespaceID, &dsr.RequestType, &dsr.Status, &dsr.SubjectName, &dsr.SubjectEmail, + &dsr.SubjectIdentifier, &dsr.RequestDescription, &dsr.RequestChannel, &dsr.ReceivedAt, &dsr.VerifiedAt, + &dsr.VerificationMethod, &dsr.DeadlineAt, &dsr.ExtendedDeadlineAt, &dsr.ExtensionReason, + &dsr.CompletedAt, &dsr.ResponseSent, &dsr.ResponseSentAt, &dsr.ResponseMethod, &dsr.RejectionReason, + &dsr.Notes, &affectedSystems, &dsr.AssignedTo, &metadata, &dsr.CreatedAt, &dsr.UpdatedAt, &dsr.CreatedBy) + if err != nil { + return nil, err + } + + json.Unmarshal(metadata, &dsr.Metadata) + json.Unmarshal(affectedSystems, &dsr.AffectedSystems) + + dsrs = append(dsrs, dsr) + } + + return dsrs, nil +} + +// UpdateDSR updates a DSR +func (s *Store) UpdateDSR(ctx context.Context, dsr *DSR) error { + dsr.UpdatedAt = time.Now().UTC() + + metadata, _ := json.Marshal(dsr.Metadata) + affectedSystems, _ := json.Marshal(dsr.AffectedSystems) + + _, err := s.pool.Exec(ctx, ` + UPDATE dsgvo_dsr SET + status = $2, verified_at = $3, verification_method = $4, extended_deadline_at = $5, + extension_reason = $6, completed_at = $7, response_sent = $8, response_sent_at = $9, + response_method = $10, rejection_reason = $11, notes = $12, affected_systems = $13, + assigned_to = $14, metadata = $15, updated_at = $16 + WHERE id = $1 + `, dsr.ID, dsr.Status, dsr.VerifiedAt, dsr.VerificationMethod, dsr.ExtendedDeadlineAt, + dsr.ExtensionReason, dsr.CompletedAt, dsr.ResponseSent, dsr.ResponseSentAt, + dsr.ResponseMethod, dsr.RejectionReason, dsr.Notes, affectedSystems, + dsr.AssignedTo, metadata, dsr.UpdatedAt) + + return err +} + +// ============================================================================ +// Retention Policies +// ============================================================================ + +// CreateRetentionPolicy creates a new retention policy +func (s *Store) CreateRetentionPolicy(ctx context.Context, rp *RetentionPolicy) error { + rp.ID = uuid.New() + rp.CreatedAt = time.Now().UTC() + rp.UpdatedAt = rp.CreatedAt + + metadata, _ := json.Marshal(rp.Metadata) + applicableSystems, _ := json.Marshal(rp.ApplicableSystems) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO dsgvo_retention_policies ( + id, tenant_id, namespace_id, name, description, data_category, retention_period_days, + retention_period_text, legal_basis, legal_reference, deletion_method, deletion_procedure, + exception_criteria, applicable_systems, responsible_person, responsible_department, + status, last_review_at, next_review_at, metadata, created_at, updated_at, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) + `, rp.ID, rp.TenantID, rp.NamespaceID, rp.Name, rp.Description, rp.DataCategory, rp.RetentionPeriodDays, + rp.RetentionPeriodText, rp.LegalBasis, rp.LegalReference, rp.DeletionMethod, rp.DeletionProcedure, + rp.ExceptionCriteria, applicableSystems, rp.ResponsiblePerson, rp.ResponsibleDepartment, + rp.Status, rp.LastReviewAt, rp.NextReviewAt, metadata, rp.CreatedAt, rp.UpdatedAt, rp.CreatedBy) + + return err +} + +// ListRetentionPolicies lists retention policies for a tenant +func (s *Store) ListRetentionPolicies(ctx context.Context, tenantID uuid.UUID) ([]RetentionPolicy, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, namespace_id, name, description, data_category, retention_period_days, + retention_period_text, legal_basis, legal_reference, deletion_method, deletion_procedure, + exception_criteria, applicable_systems, responsible_person, responsible_department, + status, last_review_at, next_review_at, metadata, created_at, updated_at, created_by + FROM dsgvo_retention_policies WHERE tenant_id = $1 ORDER BY name + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var policies []RetentionPolicy + for rows.Next() { + var rp RetentionPolicy + var metadata, applicableSystems []byte + + err := rows.Scan(&rp.ID, &rp.TenantID, &rp.NamespaceID, &rp.Name, &rp.Description, &rp.DataCategory, &rp.RetentionPeriodDays, + &rp.RetentionPeriodText, &rp.LegalBasis, &rp.LegalReference, &rp.DeletionMethod, &rp.DeletionProcedure, + &rp.ExceptionCriteria, &applicableSystems, &rp.ResponsiblePerson, &rp.ResponsibleDepartment, + &rp.Status, &rp.LastReviewAt, &rp.NextReviewAt, &metadata, &rp.CreatedAt, &rp.UpdatedAt, &rp.CreatedBy) + if err != nil { + return nil, err + } + + json.Unmarshal(metadata, &rp.Metadata) + json.Unmarshal(applicableSystems, &rp.ApplicableSystems) + + policies = append(policies, rp) + } + + return policies, nil +} + +// ============================================================================ +// DSFA - Datenschutz-Folgenabschätzung +// ============================================================================ + +// CreateDSFA creates a new DSFA +func (s *Store) CreateDSFA(ctx context.Context, dsfa *DSFA) error { + dsfa.ID = uuid.New() + dsfa.CreatedAt = time.Now().UTC() + dsfa.UpdatedAt = dsfa.CreatedAt + + metadata, _ := json.Marshal(dsfa.Metadata) + risks, _ := json.Marshal(dsfa.Risks) + mitigations, _ := json.Marshal(dsfa.Mitigations) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO dsgvo_dsfa ( + id, tenant_id, namespace_id, processing_activity_id, name, description, + processing_description, necessity_assessment, proportionality_assessment, + risks, mitigations, dpo_consulted, dpo_opinion, authority_consulted, authority_reference, + status, overall_risk_level, conclusion, metadata, created_at, updated_at, created_by, + approved_by, approved_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) + `, dsfa.ID, dsfa.TenantID, dsfa.NamespaceID, dsfa.ProcessingActivityID, dsfa.Name, dsfa.Description, + dsfa.ProcessingDescription, dsfa.NecessityAssessment, dsfa.ProportionalityAssment, + risks, mitigations, dsfa.DPOConsulted, dsfa.DPOOpinion, dsfa.AuthorityConsulted, dsfa.AuthorityReference, + dsfa.Status, dsfa.OverallRiskLevel, dsfa.Conclusion, metadata, dsfa.CreatedAt, dsfa.UpdatedAt, dsfa.CreatedBy, + dsfa.ApprovedBy, dsfa.ApprovedAt) + + return err +} + +// GetDSFA retrieves a DSFA by ID +func (s *Store) GetDSFA(ctx context.Context, id uuid.UUID) (*DSFA, error) { + var dsfa DSFA + var metadata, risks, mitigations []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, namespace_id, processing_activity_id, name, description, + processing_description, necessity_assessment, proportionality_assessment, + risks, mitigations, dpo_consulted, dpo_opinion, authority_consulted, authority_reference, + status, overall_risk_level, conclusion, metadata, created_at, updated_at, created_by, + approved_by, approved_at + FROM dsgvo_dsfa WHERE id = $1 + `, id).Scan(&dsfa.ID, &dsfa.TenantID, &dsfa.NamespaceID, &dsfa.ProcessingActivityID, &dsfa.Name, &dsfa.Description, + &dsfa.ProcessingDescription, &dsfa.NecessityAssessment, &dsfa.ProportionalityAssment, + &risks, &mitigations, &dsfa.DPOConsulted, &dsfa.DPOOpinion, &dsfa.AuthorityConsulted, &dsfa.AuthorityReference, + &dsfa.Status, &dsfa.OverallRiskLevel, &dsfa.Conclusion, &metadata, &dsfa.CreatedAt, &dsfa.UpdatedAt, &dsfa.CreatedBy, + &dsfa.ApprovedBy, &dsfa.ApprovedAt) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + json.Unmarshal(metadata, &dsfa.Metadata) + json.Unmarshal(risks, &dsfa.Risks) + json.Unmarshal(mitigations, &dsfa.Mitigations) + + return &dsfa, nil +} + +// ListDSFAs lists DSFAs for a tenant +func (s *Store) ListDSFAs(ctx context.Context, tenantID uuid.UUID, status string) ([]DSFA, error) { + query := ` + SELECT id, tenant_id, namespace_id, processing_activity_id, name, description, + processing_description, necessity_assessment, proportionality_assessment, + risks, mitigations, dpo_consulted, dpo_opinion, authority_consulted, authority_reference, + status, overall_risk_level, conclusion, metadata, created_at, updated_at, created_by, + approved_by, approved_at + FROM dsgvo_dsfa WHERE tenant_id = $1` + + args := []interface{}{tenantID} + if status != "" { + query += " AND status = $2" + args = append(args, status) + } + query += " ORDER BY created_at DESC" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var dsfas []DSFA + for rows.Next() { + var dsfa DSFA + var metadata, risks, mitigations []byte + + err := rows.Scan(&dsfa.ID, &dsfa.TenantID, &dsfa.NamespaceID, &dsfa.ProcessingActivityID, &dsfa.Name, &dsfa.Description, + &dsfa.ProcessingDescription, &dsfa.NecessityAssessment, &dsfa.ProportionalityAssment, + &risks, &mitigations, &dsfa.DPOConsulted, &dsfa.DPOOpinion, &dsfa.AuthorityConsulted, &dsfa.AuthorityReference, + &dsfa.Status, &dsfa.OverallRiskLevel, &dsfa.Conclusion, &metadata, &dsfa.CreatedAt, &dsfa.UpdatedAt, &dsfa.CreatedBy, + &dsfa.ApprovedBy, &dsfa.ApprovedAt) + if err != nil { + return nil, err + } + + json.Unmarshal(metadata, &dsfa.Metadata) + json.Unmarshal(risks, &dsfa.Risks) + json.Unmarshal(mitigations, &dsfa.Mitigations) + + dsfas = append(dsfas, dsfa) + } + + return dsfas, nil +} + +// UpdateDSFA updates a DSFA +func (s *Store) UpdateDSFA(ctx context.Context, dsfa *DSFA) error { + dsfa.UpdatedAt = time.Now().UTC() + + metadata, _ := json.Marshal(dsfa.Metadata) + risks, _ := json.Marshal(dsfa.Risks) + mitigations, _ := json.Marshal(dsfa.Mitigations) + + _, err := s.pool.Exec(ctx, ` + UPDATE dsgvo_dsfa SET + name = $2, description = $3, processing_description = $4, + necessity_assessment = $5, proportionality_assessment = $6, + risks = $7, mitigations = $8, dpo_consulted = $9, dpo_opinion = $10, + authority_consulted = $11, authority_reference = $12, status = $13, + overall_risk_level = $14, conclusion = $15, metadata = $16, updated_at = $17, + approved_by = $18, approved_at = $19 + WHERE id = $1 + `, dsfa.ID, dsfa.Name, dsfa.Description, dsfa.ProcessingDescription, + dsfa.NecessityAssessment, dsfa.ProportionalityAssment, + risks, mitigations, dsfa.DPOConsulted, dsfa.DPOOpinion, + dsfa.AuthorityConsulted, dsfa.AuthorityReference, dsfa.Status, + dsfa.OverallRiskLevel, dsfa.Conclusion, metadata, dsfa.UpdatedAt, + dsfa.ApprovedBy, dsfa.ApprovedAt) + + return err +} + +// DeleteDSFA deletes a DSFA +func (s *Store) DeleteDSFA(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, "DELETE FROM dsgvo_dsfa WHERE id = $1", id) + return err +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// DSGVOStats contains DSGVO module statistics +type DSGVOStats struct { + ProcessingActivities int `json:"processing_activities"` + ActiveProcessings int `json:"active_processings"` + TOMsImplemented int `json:"toms_implemented"` + TOMsPlanned int `json:"toms_planned"` + OpenDSRs int `json:"open_dsrs"` + OverdueDSRs int `json:"overdue_dsrs"` + RetentionPolicies int `json:"retention_policies"` + DSFAsCompleted int `json:"dsfas_completed"` +} + +// GetStats returns DSGVO statistics for a tenant +func (s *Store) GetStats(ctx context.Context, tenantID uuid.UUID) (*DSGVOStats, error) { + stats := &DSGVOStats{} + + // Processing Activities + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM dsgvo_processing_activities WHERE tenant_id = $1", tenantID).Scan(&stats.ProcessingActivities) + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM dsgvo_processing_activities WHERE tenant_id = $1 AND status = 'active'", tenantID).Scan(&stats.ActiveProcessings) + + // TOMs + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM dsgvo_tom WHERE tenant_id = $1 AND implementation_status = 'implemented'", tenantID).Scan(&stats.TOMsImplemented) + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM dsgvo_tom WHERE tenant_id = $1 AND implementation_status IN ('planned', 'in_progress')", tenantID).Scan(&stats.TOMsPlanned) + + // DSRs + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM dsgvo_dsr WHERE tenant_id = $1 AND status NOT IN ('completed', 'rejected')", tenantID).Scan(&stats.OpenDSRs) + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM dsgvo_dsr WHERE tenant_id = $1 AND status NOT IN ('completed', 'rejected') AND deadline_at < NOW()", tenantID).Scan(&stats.OverdueDSRs) + + // Retention Policies + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM dsgvo_retention_policies WHERE tenant_id = $1 AND status = 'active'", tenantID).Scan(&stats.RetentionPolicies) + + // DSFAs + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM dsgvo_dsfa WHERE tenant_id = $1 AND status = 'approved'", tenantID).Scan(&stats.DSFAsCompleted) + + return stats, nil +} diff --git a/ai-compliance-sdk/internal/funding/export.go b/ai-compliance-sdk/internal/funding/export.go new file mode 100644 index 0000000..e780227 --- /dev/null +++ b/ai-compliance-sdk/internal/funding/export.go @@ -0,0 +1,395 @@ +package funding + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "time" + + "github.com/jung-kurt/gofpdf" + "github.com/xuri/excelize/v2" +) + +// ExportService handles document generation +type ExportService struct{} + +// NewExportService creates a new export service +func NewExportService() *ExportService { + return &ExportService{} +} + +// GenerateApplicationLetter generates the main application letter as PDF +func (s *ExportService) GenerateApplicationLetter(app *FundingApplication) ([]byte, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.SetMargins(25, 25, 25) + pdf.AddPage() + + // Header + pdf.SetFont("Helvetica", "B", 14) + pdf.Cell(0, 10, "Antrag auf Foerderung im Rahmen der digitalen Bildungsinfrastruktur") + pdf.Ln(15) + + // Application number + pdf.SetFont("Helvetica", "", 10) + pdf.Cell(0, 6, fmt.Sprintf("Antragsnummer: %s", app.ApplicationNumber)) + pdf.Ln(6) + pdf.Cell(0, 6, fmt.Sprintf("Datum: %s", time.Now().Format("02.01.2006"))) + pdf.Ln(15) + + // Section 1: Einleitung + pdf.SetFont("Helvetica", "B", 12) + pdf.Cell(0, 8, "1. Einleitung") + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 10) + + if app.SchoolProfile != nil { + pdf.MultiCell(0, 6, fmt.Sprintf( + "Die %s (Schulnummer: %s) beantragt hiermit Foerdermittel aus dem Programm %s.\n\n"+ + "Schultraeger: %s\n"+ + "Schulform: %s\n"+ + "Schueleranzahl: %d\n"+ + "Lehrkraefte: %d", + app.SchoolProfile.Name, + app.SchoolProfile.SchoolNumber, + app.FundingProgram, + app.SchoolProfile.CarrierName, + app.SchoolProfile.Type, + app.SchoolProfile.StudentCount, + app.SchoolProfile.TeacherCount, + ), "", "", false) + } + pdf.Ln(10) + + // Section 2: Projektziel + pdf.SetFont("Helvetica", "B", 12) + pdf.Cell(0, 8, "2. Projektziel") + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 10) + if app.ProjectPlan != nil { + pdf.MultiCell(0, 6, app.ProjectPlan.Summary, "", "", false) + pdf.Ln(5) + pdf.MultiCell(0, 6, app.ProjectPlan.Goals, "", "", false) + } + pdf.Ln(10) + + // Section 3: Beschreibung der Massnahme + pdf.SetFont("Helvetica", "B", 12) + pdf.Cell(0, 8, "3. Beschreibung der Massnahme") + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 10) + if app.ProjectPlan != nil { + pdf.MultiCell(0, 6, app.ProjectPlan.DidacticConcept, "", "", false) + } + pdf.Ln(10) + + // Section 4: Datenschutz & IT-Betrieb + pdf.SetFont("Helvetica", "B", 12) + pdf.Cell(0, 8, "4. Datenschutz & IT-Betrieb") + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 10) + if app.ProjectPlan != nil && app.ProjectPlan.DataProtection != "" { + pdf.MultiCell(0, 6, app.ProjectPlan.DataProtection, "", "", false) + } + pdf.Ln(10) + + // Section 5: Kosten & Finanzierung + pdf.SetFont("Helvetica", "B", 12) + pdf.Cell(0, 8, "5. Kosten & Finanzierung") + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 10) + if app.Budget != nil { + pdf.Cell(0, 6, fmt.Sprintf("Gesamtkosten: %.2f EUR", app.Budget.TotalCost)) + pdf.Ln(6) + pdf.Cell(0, 6, fmt.Sprintf("Beantragter Foerderbetrag: %.2f EUR (%.0f%%)", app.Budget.RequestedFunding, app.Budget.FundingRate*100)) + pdf.Ln(6) + pdf.Cell(0, 6, fmt.Sprintf("Eigenanteil: %.2f EUR", app.Budget.OwnContribution)) + } + pdf.Ln(10) + + // Section 6: Laufzeit + pdf.SetFont("Helvetica", "B", 12) + pdf.Cell(0, 8, "6. Laufzeit") + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 10) + if app.Timeline != nil { + pdf.Cell(0, 6, fmt.Sprintf("Projektbeginn: %s", app.Timeline.PlannedStart.Format("02.01.2006"))) + pdf.Ln(6) + pdf.Cell(0, 6, fmt.Sprintf("Projektende: %s", app.Timeline.PlannedEnd.Format("02.01.2006"))) + } + pdf.Ln(15) + + // Footer note + pdf.SetFont("Helvetica", "I", 9) + pdf.MultiCell(0, 5, "Hinweis: Dieser Antrag wurde mit dem Foerderantrag-Wizard von BreakPilot erstellt. "+ + "Die finale Pruefung und Einreichung erfolgt durch den Schultraeger.", "", "", false) + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// GenerateBudgetPlan generates the budget plan as XLSX +func (s *ExportService) GenerateBudgetPlan(app *FundingApplication) ([]byte, error) { + f := excelize.NewFile() + sheetName := "Kostenplan" + f.SetSheetName("Sheet1", sheetName) + + // Header row + headers := []string{ + "Pos.", "Kategorie", "Beschreibung", "Hersteller", + "Anzahl", "Einzelpreis", "Gesamt", "Foerderfahig", "Finanzierung", + } + for i, h := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + f.SetCellValue(sheetName, cell, h) + } + + // Style header + headerStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true}, + Fill: excelize.Fill{Type: "pattern", Color: []string{"#E0E0E0"}, Pattern: 1}, + }) + f.SetRowStyle(sheetName, 1, 1, headerStyle) + + // Data rows + row := 2 + if app.Budget != nil { + for i, item := range app.Budget.BudgetItems { + f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), i+1) + f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), string(item.Category)) + f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), item.Description) + f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), item.Manufacturer) + f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), item.Quantity) + f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), item.UnitPrice) + f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), item.TotalPrice) + fundable := "Nein" + if item.IsFundable { + fundable = "Ja" + } + f.SetCellValue(sheetName, fmt.Sprintf("H%d", row), fundable) + f.SetCellValue(sheetName, fmt.Sprintf("I%d", row), item.FundingSource) + row++ + } + + // Summary rows + row += 2 + f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), "Gesamtkosten:") + f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), app.Budget.TotalCost) + row++ + f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), "Foerderbetrag:") + f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), app.Budget.RequestedFunding) + row++ + f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), "Eigenanteil:") + f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), app.Budget.OwnContribution) + } + + // Set column widths + f.SetColWidth(sheetName, "A", "A", 6) + f.SetColWidth(sheetName, "B", "B", 15) + f.SetColWidth(sheetName, "C", "C", 35) + f.SetColWidth(sheetName, "D", "D", 15) + f.SetColWidth(sheetName, "E", "E", 8) + f.SetColWidth(sheetName, "F", "F", 12) + f.SetColWidth(sheetName, "G", "G", 12) + f.SetColWidth(sheetName, "H", "H", 12) + f.SetColWidth(sheetName, "I", "I", 15) + + // Add currency format + currencyStyle, _ := f.NewStyle(&excelize.Style{ + NumFmt: 44, // Currency format + }) + f.SetColStyle(sheetName, "F", currencyStyle) + f.SetColStyle(sheetName, "G", currencyStyle) + + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// GenerateDataProtectionConcept generates the data protection concept as PDF +func (s *ExportService) GenerateDataProtectionConcept(app *FundingApplication) ([]byte, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.SetMargins(25, 25, 25) + pdf.AddPage() + + // Header + pdf.SetFont("Helvetica", "B", 14) + pdf.Cell(0, 10, "Datenschutz- und Betriebskonzept") + pdf.Ln(15) + + pdf.SetFont("Helvetica", "", 10) + pdf.Cell(0, 6, fmt.Sprintf("Antragsnummer: %s", app.ApplicationNumber)) + pdf.Ln(6) + if app.SchoolProfile != nil { + pdf.Cell(0, 6, fmt.Sprintf("Schule: %s", app.SchoolProfile.Name)) + } + pdf.Ln(15) + + // Section: Lokale Verarbeitung + pdf.SetFont("Helvetica", "B", 12) + pdf.Cell(0, 8, "1. Grundsaetze der Datenverarbeitung") + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 10) + + if app.ProjectPlan != nil && app.ProjectPlan.DataProtection != "" { + pdf.MultiCell(0, 6, app.ProjectPlan.DataProtection, "", "", false) + } else { + pdf.MultiCell(0, 6, "Das Projekt setzt auf eine vollstaendig lokale Datenverarbeitung:\n\n"+ + "- Alle Daten werden ausschliesslich auf den schuleigenen Systemen verarbeitet\n"+ + "- Keine Uebermittlung personenbezogener Daten an externe Dienste\n"+ + "- Keine Cloud-Speicherung sensibler Daten\n"+ + "- Betrieb im Verantwortungsbereich der Schule", "", "", false) + } + pdf.Ln(10) + + // Section: Technische Massnahmen + pdf.SetFont("Helvetica", "B", 12) + pdf.Cell(0, 8, "2. Technische und organisatorische Massnahmen") + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 10) + pdf.MultiCell(0, 6, "Folgende TOMs werden umgesetzt:\n\n"+ + "- Zugriffskontrolle ueber schuleigene Benutzerverwaltung\n"+ + "- Verschluesselte Datenspeicherung\n"+ + "- Regelmaessige Sicherheitsupdates\n"+ + "- Protokollierung von Zugriffen\n"+ + "- Automatische Loeschung nach definierten Fristen", "", "", false) + pdf.Ln(10) + + // Section: Betriebskonzept + pdf.SetFont("Helvetica", "B", 12) + pdf.Cell(0, 8, "3. Betriebskonzept") + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 10) + if app.ProjectPlan != nil && app.ProjectPlan.MaintenancePlan != "" { + pdf.MultiCell(0, 6, app.ProjectPlan.MaintenancePlan, "", "", false) + } else { + pdf.MultiCell(0, 6, "Der laufende Betrieb wird wie folgt sichergestellt:\n\n"+ + "- Schulung des technischen Personals\n"+ + "- Dokumentierte Betriebsverfahren\n"+ + "- Regelmaessige Wartung und Updates\n"+ + "- Definierte Ansprechpartner", "", "", false) + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// GenerateExportBundle generates a ZIP file with all documents +func (s *ExportService) GenerateExportBundle(app *FundingApplication) ([]byte, error) { + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + + // Generate and add application letter + letter, err := s.GenerateApplicationLetter(app) + if err == nil { + w, _ := zipWriter.Create(fmt.Sprintf("%s_Antragsschreiben.pdf", app.ApplicationNumber)) + w.Write(letter) + } + + // Generate and add budget plan + budget, err := s.GenerateBudgetPlan(app) + if err == nil { + w, _ := zipWriter.Create(fmt.Sprintf("%s_Kostenplan.xlsx", app.ApplicationNumber)) + w.Write(budget) + } + + // Generate and add data protection concept + dp, err := s.GenerateDataProtectionConcept(app) + if err == nil { + w, _ := zipWriter.Create(fmt.Sprintf("%s_Datenschutzkonzept.pdf", app.ApplicationNumber)) + w.Write(dp) + } + + // Add attachments + for _, attachment := range app.Attachments { + // Read attachment from storage and add to ZIP + // This would need actual file system access + _ = attachment + } + + if err := zipWriter.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// ExportDocument represents a generated document +type GeneratedDocument struct { + Name string + Type string // pdf, xlsx, docx + Content []byte + MimeType string +} + +// GenerateAllDocuments generates all documents for an application +func (s *ExportService) GenerateAllDocuments(app *FundingApplication) ([]GeneratedDocument, error) { + var docs []GeneratedDocument + + // Application letter + letter, err := s.GenerateApplicationLetter(app) + if err == nil { + docs = append(docs, GeneratedDocument{ + Name: fmt.Sprintf("%s_Antragsschreiben.pdf", app.ApplicationNumber), + Type: "pdf", + Content: letter, + MimeType: "application/pdf", + }) + } + + // Budget plan + budget, err := s.GenerateBudgetPlan(app) + if err == nil { + docs = append(docs, GeneratedDocument{ + Name: fmt.Sprintf("%s_Kostenplan.xlsx", app.ApplicationNumber), + Type: "xlsx", + Content: budget, + MimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + } + + // Data protection concept + dp, err := s.GenerateDataProtectionConcept(app) + if err == nil { + docs = append(docs, GeneratedDocument{ + Name: fmt.Sprintf("%s_Datenschutzkonzept.pdf", app.ApplicationNumber), + Type: "pdf", + Content: dp, + MimeType: "application/pdf", + }) + } + + return docs, nil +} + +// WriteZipToWriter writes all documents to a zip writer +func (s *ExportService) WriteZipToWriter(app *FundingApplication, w io.Writer) error { + zipWriter := zip.NewWriter(w) + defer zipWriter.Close() + + docs, err := s.GenerateAllDocuments(app) + if err != nil { + return err + } + + for _, doc := range docs { + f, err := zipWriter.Create(doc.Name) + if err != nil { + continue + } + f.Write(doc.Content) + } + + return nil +} diff --git a/ai-compliance-sdk/internal/funding/models.go b/ai-compliance-sdk/internal/funding/models.go new file mode 100644 index 0000000..c3a0c91 --- /dev/null +++ b/ai-compliance-sdk/internal/funding/models.go @@ -0,0 +1,394 @@ +package funding + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Constants / Enums +// ============================================================================ + +// FundingProgram represents the type of funding program +type FundingProgram string + +const ( + FundingProgramDigitalPakt1 FundingProgram = "DIGITALPAKT_1" + FundingProgramDigitalPakt2 FundingProgram = "DIGITALPAKT_2" + FundingProgramLandesfoerderung FundingProgram = "LANDESFOERDERUNG" + FundingProgramSchultraeger FundingProgram = "SCHULTRAEGER" + FundingProgramSonstige FundingProgram = "SONSTIGE" +) + +// ApplicationStatus represents the workflow status +type ApplicationStatus string + +const ( + ApplicationStatusDraft ApplicationStatus = "DRAFT" + ApplicationStatusInProgress ApplicationStatus = "IN_PROGRESS" + ApplicationStatusReview ApplicationStatus = "REVIEW" + ApplicationStatusSubmitted ApplicationStatus = "SUBMITTED" + ApplicationStatusApproved ApplicationStatus = "APPROVED" + ApplicationStatusRejected ApplicationStatus = "REJECTED" + ApplicationStatusArchived ApplicationStatus = "ARCHIVED" +) + +// FederalState represents German federal states +type FederalState string + +const ( + FederalStateNI FederalState = "NI" // Niedersachsen + FederalStateNRW FederalState = "NRW" // Nordrhein-Westfalen + FederalStateBAY FederalState = "BAY" // Bayern + FederalStateBW FederalState = "BW" // Baden-Wuerttemberg + FederalStateHE FederalState = "HE" // Hessen + FederalStateSN FederalState = "SN" // Sachsen + FederalStateTH FederalState = "TH" // Thueringen + FederalStateSA FederalState = "SA" // Sachsen-Anhalt + FederalStateBB FederalState = "BB" // Brandenburg + FederalStateMV FederalState = "MV" // Mecklenburg-Vorpommern + FederalStateSH FederalState = "SH" // Schleswig-Holstein + FederalStateHH FederalState = "HH" // Hamburg + FederalStateHB FederalState = "HB" // Bremen + FederalStateBE FederalState = "BE" // Berlin + FederalStateSL FederalState = "SL" // Saarland + FederalStateRP FederalState = "RP" // Rheinland-Pfalz +) + +// SchoolType represents different school types +type SchoolType string + +const ( + SchoolTypeGrundschule SchoolType = "GRUNDSCHULE" + SchoolTypeHauptschule SchoolType = "HAUPTSCHULE" + SchoolTypeRealschule SchoolType = "REALSCHULE" + SchoolTypeGymnasium SchoolType = "GYMNASIUM" + SchoolTypeGesamtschule SchoolType = "GESAMTSCHULE" + SchoolTypeOberschule SchoolType = "OBERSCHULE" + SchoolTypeFoerderschule SchoolType = "FOERDERSCHULE" + SchoolTypeBerufsschule SchoolType = "BERUFSSCHULE" + SchoolTypeBerufskolleg SchoolType = "BERUFSKOLLEG" + SchoolTypeFachoberschule SchoolType = "FACHOBERSCHULE" + SchoolTypeBerufliches SchoolType = "BERUFLICHES_GYMNASIUM" + SchoolTypeSonstige SchoolType = "SONSTIGE" +) + +// CarrierType represents the school carrier type +type CarrierType string + +const ( + CarrierTypePublic CarrierType = "PUBLIC" // Oeffentlich + CarrierTypePrivate CarrierType = "PRIVATE" // Privat + CarrierTypeChurch CarrierType = "CHURCH" // Kirchlich + CarrierTypeNonProfit CarrierType = "NON_PROFIT" // Gemeinnuetzig +) + +// BudgetCategory represents categories for budget items +type BudgetCategory string + +const ( + BudgetCategoryNetwork BudgetCategory = "NETWORK" // Netzwerk/Verkabelung + BudgetCategoryWLAN BudgetCategory = "WLAN" // WLAN-Infrastruktur + BudgetCategoryDevices BudgetCategory = "DEVICES" // Endgeraete + BudgetCategoryPresentation BudgetCategory = "PRESENTATION" // Praesentationstechnik + BudgetCategorySoftware BudgetCategory = "SOFTWARE" // Software-Lizenzen + BudgetCategoryServer BudgetCategory = "SERVER" // Server/Rechenzentrum + BudgetCategoryServices BudgetCategory = "SERVICES" // Dienstleistungen + BudgetCategoryTraining BudgetCategory = "TRAINING" // Schulungen + BudgetCategorySonstige BudgetCategory = "SONSTIGE" // Sonstige +) + +// ============================================================================ +// Main Entities +// ============================================================================ + +// FundingApplication represents a funding application +type FundingApplication struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + ApplicationNumber string `json:"application_number"` // e.g., DP2-NI-2026-00123 + Title string `json:"title"` + FundingProgram FundingProgram `json:"funding_program"` + Status ApplicationStatus `json:"status"` + + // Wizard State + CurrentStep int `json:"current_step"` + TotalSteps int `json:"total_steps"` + WizardData map[string]interface{} `json:"wizard_data,omitempty"` + + // School Information + SchoolProfile *SchoolProfile `json:"school_profile,omitempty"` + + // Project Information + ProjectPlan *ProjectPlan `json:"project_plan,omitempty"` + Budget *Budget `json:"budget,omitempty"` + Timeline *ProjectTimeline `json:"timeline,omitempty"` + + // Financial Summary + RequestedAmount float64 `json:"requested_amount"` + OwnContribution float64 `json:"own_contribution"` + ApprovedAmount *float64 `json:"approved_amount,omitempty"` + + // Attachments + Attachments []Attachment `json:"attachments,omitempty"` + + // Audit Trail + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SubmittedAt *time.Time `json:"submitted_at,omitempty"` + CreatedBy uuid.UUID `json:"created_by"` + UpdatedBy uuid.UUID `json:"updated_by"` +} + +// SchoolProfile contains school information +type SchoolProfile struct { + Name string `json:"name"` + SchoolNumber string `json:"school_number"` // Official school number + Type SchoolType `json:"type"` + FederalState FederalState `json:"federal_state"` + Address Address `json:"address"` + ContactPerson ContactPerson `json:"contact_person"` + StudentCount int `json:"student_count"` + TeacherCount int `json:"teacher_count"` + ClassCount int `json:"class_count"` + CarrierType CarrierType `json:"carrier_type"` + CarrierName string `json:"carrier_name"` + CarrierAddress *Address `json:"carrier_address,omitempty"` + Infrastructure *InfrastructureStatus `json:"infrastructure,omitempty"` +} + +// Address represents a postal address +type Address struct { + Street string `json:"street"` + HouseNo string `json:"house_no"` + PostalCode string `json:"postal_code"` + City string `json:"city"` + Country string `json:"country,omitempty"` +} + +// ContactPerson represents a contact person +type ContactPerson struct { + Salutation string `json:"salutation,omitempty"` // Herr/Frau + Title string `json:"title,omitempty"` // Dr., Prof. + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Position string `json:"position,omitempty"` // Schulleitung, IT-Beauftragter + Email string `json:"email"` + Phone string `json:"phone,omitempty"` +} + +// InfrastructureStatus describes current IT infrastructure +type InfrastructureStatus struct { + HasWLAN bool `json:"has_wlan"` + WLANCoverage int `json:"wlan_coverage"` // Percentage 0-100 + HasStructuredCabling bool `json:"has_structured_cabling"` + InternetBandwidth string `json:"internet_bandwidth"` // e.g., "100 Mbit/s" + DeviceCount int `json:"device_count"` // Current devices + HasServerRoom bool `json:"has_server_room"` + Notes string `json:"notes,omitempty"` +} + +// ProjectPlan describes the project +type ProjectPlan struct { + ProjectName string `json:"project_name"` + Summary string `json:"summary"` // Kurzbeschreibung + Goals string `json:"goals"` // Projektziele + DidacticConcept string `json:"didactic_concept"` // Paedagogisches Konzept + MEPReference string `json:"mep_reference,omitempty"` // Medienentwicklungsplan Bezug + DataProtection string `json:"data_protection"` // Datenschutzkonzept + MaintenancePlan string `json:"maintenance_plan"` // Wartungs-/Betriebskonzept + TargetGroups []string `json:"target_groups"` // e.g., ["Schueler", "Lehrer"] + SubjectsAffected []string `json:"subjects_affected,omitempty"` // Betroffene Faecher +} + +// Budget represents the financial plan +type Budget struct { + TotalCost float64 `json:"total_cost"` + RequestedFunding float64 `json:"requested_funding"` + OwnContribution float64 `json:"own_contribution"` + OtherFunding float64 `json:"other_funding"` + FundingRate float64 `json:"funding_rate"` // 0.90 = 90% + BudgetItems []BudgetItem `json:"budget_items"` + IsWithinLimits bool `json:"is_within_limits"` + Justification string `json:"justification,omitempty"` // Begruendung +} + +// BudgetItem represents a single budget line item +type BudgetItem struct { + ID uuid.UUID `json:"id"` + Position int `json:"position"` // Order number + Category BudgetCategory `json:"category"` + Description string `json:"description"` + Manufacturer string `json:"manufacturer,omitempty"` + ProductName string `json:"product_name,omitempty"` + Quantity int `json:"quantity"` + UnitPrice float64 `json:"unit_price"` + TotalPrice float64 `json:"total_price"` + IsFundable bool `json:"is_fundable"` // Foerderfahig Ja/Nein + FundingSource string `json:"funding_source"` // digitalpakt, eigenanteil, sonstige + Notes string `json:"notes,omitempty"` +} + +// ProjectTimeline represents project schedule +type ProjectTimeline struct { + PlannedStart time.Time `json:"planned_start"` + PlannedEnd time.Time `json:"planned_end"` + Milestones []Milestone `json:"milestones,omitempty"` + ProjectPhase string `json:"project_phase,omitempty"` // Current phase +} + +// Milestone represents a project milestone +type Milestone struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + DueDate time.Time `json:"due_date"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Status string `json:"status"` // planned, in_progress, completed +} + +// Attachment represents an uploaded file +type Attachment struct { + ID uuid.UUID `json:"id"` + FileName string `json:"file_name"` + FileType string `json:"file_type"` // pdf, docx, xlsx, jpg, png + FileSize int64 `json:"file_size"` // bytes + Category string `json:"category"` // angebot, mep, nachweis, sonstiges + Description string `json:"description,omitempty"` + StoragePath string `json:"-"` // Internal path, not exposed + UploadedAt time.Time `json:"uploaded_at"` + UploadedBy uuid.UUID `json:"uploaded_by"` +} + +// ============================================================================ +// Wizard Step Data +// ============================================================================ + +// WizardStep represents a single wizard step +type WizardStep struct { + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Fields []string `json:"fields"` // Field IDs for this step + IsCompleted bool `json:"is_completed"` + IsRequired bool `json:"is_required"` + HelpContext string `json:"help_context"` // Context for LLM assistant +} + +// WizardProgress tracks wizard completion +type WizardProgress struct { + CurrentStep int `json:"current_step"` + TotalSteps int `json:"total_steps"` + CompletedSteps []int `json:"completed_steps"` + StepValidation map[int][]string `json:"step_validation,omitempty"` // Errors per step + FormData map[string]interface{} `json:"form_data"` + LastSavedAt time.Time `json:"last_saved_at"` +} + +// ============================================================================ +// BreakPilot Presets +// ============================================================================ + +// ProductPreset represents a BreakPilot product preset +type ProductPreset struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + BudgetItems []BudgetItem `json:"budget_items"` + AutoFill map[string]interface{} `json:"auto_fill"` + DataProtection string `json:"data_protection"` +} + +// ============================================================================ +// Export Structures +// ============================================================================ + +// ExportDocument represents a generated document +type ExportDocument struct { + Type string `json:"type"` // antragsschreiben, kostenplan, datenschutz + Format string `json:"format"` // pdf, docx, xlsx + FileName string `json:"file_name"` + GeneratedAt time.Time `json:"generated_at"` + ContentHash string `json:"content_hash"` + StoragePath string `json:"-"` +} + +// ExportBundle represents a ZIP bundle of all documents +type ExportBundle struct { + ID uuid.UUID `json:"id"` + ApplicationID uuid.UUID `json:"application_id"` + Documents []ExportDocument `json:"documents"` + GeneratedAt time.Time `json:"generated_at"` + DownloadURL string `json:"download_url"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ============================================================================ +// LLM Assistant +// ============================================================================ + +// AssistantMessage represents a chat message with the assistant +type AssistantMessage struct { + Role string `json:"role"` // user, assistant, system + Content string `json:"content"` + Step int `json:"step,omitempty"` // Current wizard step +} + +// AssistantRequest for asking questions +type AssistantRequest struct { + ApplicationID uuid.UUID `json:"application_id"` + Question string `json:"question"` + CurrentStep int `json:"current_step"` + Context map[string]interface{} `json:"context,omitempty"` + History []AssistantMessage `json:"history,omitempty"` +} + +// AssistantResponse from the assistant +type AssistantResponse struct { + Answer string `json:"answer"` + Suggestions []string `json:"suggestions,omitempty"` + References []string `json:"references,omitempty"` // Links to help resources + FormFills map[string]interface{} `json:"form_fills,omitempty"` // Suggested form values +} + +// ============================================================================ +// API Request/Response Types +// ============================================================================ + +// CreateApplicationRequest for creating a new application +type CreateApplicationRequest struct { + Title string `json:"title"` + FundingProgram FundingProgram `json:"funding_program"` + FederalState FederalState `json:"federal_state"` + PresetID string `json:"preset_id,omitempty"` // Optional BreakPilot preset +} + +// UpdateApplicationRequest for updating an application +type UpdateApplicationRequest struct { + Title *string `json:"title,omitempty"` + WizardData map[string]interface{} `json:"wizard_data,omitempty"` + CurrentStep *int `json:"current_step,omitempty"` +} + +// SaveWizardStepRequest for saving a wizard step +type SaveWizardStepRequest struct { + Step int `json:"step"` + Data map[string]interface{} `json:"data"` + Complete bool `json:"complete"` // Mark step as complete +} + +// ApplicationListResponse for list endpoints +type ApplicationListResponse struct { + Applications []FundingApplication `json:"applications"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ExportRequest for export endpoints +type ExportRequest struct { + Format string `json:"format"` // zip, pdf, docx + Documents []string `json:"documents"` // Which documents to include + Language string `json:"language"` // de, en +} diff --git a/ai-compliance-sdk/internal/funding/postgres_store.go b/ai-compliance-sdk/internal/funding/postgres_store.go new file mode 100644 index 0000000..d6b9c0a --- /dev/null +++ b/ai-compliance-sdk/internal/funding/postgres_store.go @@ -0,0 +1,652 @@ +package funding + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PostgresStore implements Store using PostgreSQL +type PostgresStore struct { + pool *pgxpool.Pool +} + +// NewPostgresStore creates a new PostgreSQL store +func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore { + return &PostgresStore{pool: pool} +} + +// CreateApplication creates a new funding application +func (s *PostgresStore) CreateApplication(ctx context.Context, app *FundingApplication) error { + app.ID = uuid.New() + app.CreatedAt = time.Now() + app.UpdatedAt = time.Now() + app.TotalSteps = 8 // Default 8-step wizard + + // Generate application number + app.ApplicationNumber = s.generateApplicationNumber(app.FundingProgram, app.SchoolProfile) + + // Marshal JSON fields + wizardDataJSON, err := json.Marshal(app.WizardData) + if err != nil { + return fmt.Errorf("failed to marshal wizard data: %w", err) + } + + schoolProfileJSON, err := json.Marshal(app.SchoolProfile) + if err != nil { + return fmt.Errorf("failed to marshal school profile: %w", err) + } + + projectPlanJSON, err := json.Marshal(app.ProjectPlan) + if err != nil { + return fmt.Errorf("failed to marshal project plan: %w", err) + } + + budgetJSON, err := json.Marshal(app.Budget) + if err != nil { + return fmt.Errorf("failed to marshal budget: %w", err) + } + + timelineJSON, err := json.Marshal(app.Timeline) + if err != nil { + return fmt.Errorf("failed to marshal timeline: %w", err) + } + + query := ` + INSERT INTO funding_applications ( + id, tenant_id, application_number, title, funding_program, status, + current_step, total_steps, wizard_data, + school_profile, project_plan, budget, timeline, + requested_amount, own_contribution, + created_at, updated_at, created_by, updated_by + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, + $10, $11, $12, $13, + $14, $15, + $16, $17, $18, $19 + ) + ` + + _, err = s.pool.Exec(ctx, query, + app.ID, app.TenantID, app.ApplicationNumber, app.Title, app.FundingProgram, app.Status, + app.CurrentStep, app.TotalSteps, wizardDataJSON, + schoolProfileJSON, projectPlanJSON, budgetJSON, timelineJSON, + app.RequestedAmount, app.OwnContribution, + app.CreatedAt, app.UpdatedAt, app.CreatedBy, app.UpdatedBy, + ) + + if err != nil { + return fmt.Errorf("failed to create application: %w", err) + } + + return nil +} + +// GetApplication retrieves an application by ID +func (s *PostgresStore) GetApplication(ctx context.Context, id uuid.UUID) (*FundingApplication, error) { + query := ` + SELECT + id, tenant_id, application_number, title, funding_program, status, + current_step, total_steps, wizard_data, + school_profile, project_plan, budget, timeline, + requested_amount, own_contribution, approved_amount, + created_at, updated_at, submitted_at, created_by, updated_by + FROM funding_applications + WHERE id = $1 + ` + + var app FundingApplication + var wizardDataJSON, schoolProfileJSON, projectPlanJSON, budgetJSON, timelineJSON []byte + + err := s.pool.QueryRow(ctx, query, id).Scan( + &app.ID, &app.TenantID, &app.ApplicationNumber, &app.Title, &app.FundingProgram, &app.Status, + &app.CurrentStep, &app.TotalSteps, &wizardDataJSON, + &schoolProfileJSON, &projectPlanJSON, &budgetJSON, &timelineJSON, + &app.RequestedAmount, &app.OwnContribution, &app.ApprovedAmount, + &app.CreatedAt, &app.UpdatedAt, &app.SubmittedAt, &app.CreatedBy, &app.UpdatedBy, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("application not found: %s", id) + } + return nil, fmt.Errorf("failed to get application: %w", err) + } + + // Unmarshal JSON fields + if len(wizardDataJSON) > 0 { + if err := json.Unmarshal(wizardDataJSON, &app.WizardData); err != nil { + return nil, fmt.Errorf("failed to unmarshal wizard data: %w", err) + } + } + + if len(schoolProfileJSON) > 0 { + app.SchoolProfile = &SchoolProfile{} + if err := json.Unmarshal(schoolProfileJSON, app.SchoolProfile); err != nil { + return nil, fmt.Errorf("failed to unmarshal school profile: %w", err) + } + } + + if len(projectPlanJSON) > 0 { + app.ProjectPlan = &ProjectPlan{} + if err := json.Unmarshal(projectPlanJSON, app.ProjectPlan); err != nil { + return nil, fmt.Errorf("failed to unmarshal project plan: %w", err) + } + } + + if len(budgetJSON) > 0 { + app.Budget = &Budget{} + if err := json.Unmarshal(budgetJSON, app.Budget); err != nil { + return nil, fmt.Errorf("failed to unmarshal budget: %w", err) + } + } + + if len(timelineJSON) > 0 { + app.Timeline = &ProjectTimeline{} + if err := json.Unmarshal(timelineJSON, app.Timeline); err != nil { + return nil, fmt.Errorf("failed to unmarshal timeline: %w", err) + } + } + + // Load attachments + attachments, err := s.GetAttachments(ctx, id) + if err == nil { + app.Attachments = attachments + } + + return &app, nil +} + +// GetApplicationByNumber retrieves an application by number +func (s *PostgresStore) GetApplicationByNumber(ctx context.Context, number string) (*FundingApplication, error) { + query := `SELECT id FROM funding_applications WHERE application_number = $1` + + var id uuid.UUID + err := s.pool.QueryRow(ctx, query, number).Scan(&id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("application not found: %s", number) + } + return nil, fmt.Errorf("failed to find application by number: %w", err) + } + + return s.GetApplication(ctx, id) +} + +// UpdateApplication updates an existing application +func (s *PostgresStore) UpdateApplication(ctx context.Context, app *FundingApplication) error { + app.UpdatedAt = time.Now() + + // Marshal JSON fields + wizardDataJSON, _ := json.Marshal(app.WizardData) + schoolProfileJSON, _ := json.Marshal(app.SchoolProfile) + projectPlanJSON, _ := json.Marshal(app.ProjectPlan) + budgetJSON, _ := json.Marshal(app.Budget) + timelineJSON, _ := json.Marshal(app.Timeline) + + query := ` + UPDATE funding_applications SET + title = $2, funding_program = $3, status = $4, + current_step = $5, wizard_data = $6, + school_profile = $7, project_plan = $8, budget = $9, timeline = $10, + requested_amount = $11, own_contribution = $12, approved_amount = $13, + updated_at = $14, submitted_at = $15, updated_by = $16 + WHERE id = $1 + ` + + result, err := s.pool.Exec(ctx, query, + app.ID, app.Title, app.FundingProgram, app.Status, + app.CurrentStep, wizardDataJSON, + schoolProfileJSON, projectPlanJSON, budgetJSON, timelineJSON, + app.RequestedAmount, app.OwnContribution, app.ApprovedAmount, + app.UpdatedAt, app.SubmittedAt, app.UpdatedBy, + ) + + if err != nil { + return fmt.Errorf("failed to update application: %w", err) + } + + if result.RowsAffected() == 0 { + return fmt.Errorf("application not found: %s", app.ID) + } + + return nil +} + +// DeleteApplication soft-deletes an application +func (s *PostgresStore) DeleteApplication(ctx context.Context, id uuid.UUID) error { + query := `UPDATE funding_applications SET status = 'ARCHIVED', updated_at = $2 WHERE id = $1` + result, err := s.pool.Exec(ctx, query, id, time.Now()) + if err != nil { + return fmt.Errorf("failed to delete application: %w", err) + } + if result.RowsAffected() == 0 { + return fmt.Errorf("application not found: %s", id) + } + return nil +} + +// ListApplications returns a paginated list of applications +func (s *PostgresStore) ListApplications(ctx context.Context, tenantID uuid.UUID, filter ApplicationFilter) (*ApplicationListResponse, error) { + // Build query with filters + query := ` + SELECT + id, tenant_id, application_number, title, funding_program, status, + current_step, total_steps, wizard_data, + school_profile, project_plan, budget, timeline, + requested_amount, own_contribution, approved_amount, + created_at, updated_at, submitted_at, created_by, updated_by + FROM funding_applications + WHERE tenant_id = $1 AND status != 'ARCHIVED' + ` + args := []interface{}{tenantID} + argIndex := 2 + + if filter.Status != nil { + query += fmt.Sprintf(" AND status = $%d", argIndex) + args = append(args, *filter.Status) + argIndex++ + } + + if filter.FundingProgram != nil { + query += fmt.Sprintf(" AND funding_program = $%d", argIndex) + args = append(args, *filter.FundingProgram) + argIndex++ + } + + // Count total + countQuery := `SELECT COUNT(*) FROM funding_applications WHERE tenant_id = $1 AND status != 'ARCHIVED'` + var total int + s.pool.QueryRow(ctx, countQuery, tenantID).Scan(&total) + + // Add sorting and pagination + sortBy := "created_at" + if filter.SortBy != "" { + sortBy = filter.SortBy + } + sortOrder := "DESC" + if filter.SortOrder == "asc" { + sortOrder = "ASC" + } + query += fmt.Sprintf(" ORDER BY %s %s", sortBy, sortOrder) + + if filter.PageSize <= 0 { + filter.PageSize = 20 + } + if filter.Page <= 0 { + filter.Page = 1 + } + offset := (filter.Page - 1) * filter.PageSize + query += fmt.Sprintf(" LIMIT %d OFFSET %d", filter.PageSize, offset) + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to list applications: %w", err) + } + defer rows.Close() + + var apps []FundingApplication + for rows.Next() { + var app FundingApplication + var wizardDataJSON, schoolProfileJSON, projectPlanJSON, budgetJSON, timelineJSON []byte + + err := rows.Scan( + &app.ID, &app.TenantID, &app.ApplicationNumber, &app.Title, &app.FundingProgram, &app.Status, + &app.CurrentStep, &app.TotalSteps, &wizardDataJSON, + &schoolProfileJSON, &projectPlanJSON, &budgetJSON, &timelineJSON, + &app.RequestedAmount, &app.OwnContribution, &app.ApprovedAmount, + &app.CreatedAt, &app.UpdatedAt, &app.SubmittedAt, &app.CreatedBy, &app.UpdatedBy, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan application: %w", err) + } + + // Unmarshal JSON fields + if len(schoolProfileJSON) > 0 { + app.SchoolProfile = &SchoolProfile{} + json.Unmarshal(schoolProfileJSON, app.SchoolProfile) + } + + apps = append(apps, app) + } + + return &ApplicationListResponse{ + Applications: apps, + Total: total, + Page: filter.Page, + PageSize: filter.PageSize, + }, nil +} + +// SearchApplications searches applications by text +func (s *PostgresStore) SearchApplications(ctx context.Context, tenantID uuid.UUID, query string) ([]FundingApplication, error) { + searchQuery := ` + SELECT id FROM funding_applications + WHERE tenant_id = $1 + AND status != 'ARCHIVED' + AND ( + title ILIKE $2 + OR application_number ILIKE $2 + OR school_profile::text ILIKE $2 + ) + ORDER BY updated_at DESC + LIMIT 50 + ` + + rows, err := s.pool.Query(ctx, searchQuery, tenantID, "%"+query+"%") + if err != nil { + return nil, fmt.Errorf("failed to search applications: %w", err) + } + defer rows.Close() + + var apps []FundingApplication + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + continue + } + app, err := s.GetApplication(ctx, id) + if err == nil { + apps = append(apps, *app) + } + } + + return apps, nil +} + +// SaveWizardStep saves data for a wizard step +func (s *PostgresStore) SaveWizardStep(ctx context.Context, appID uuid.UUID, step int, data map[string]interface{}) error { + // Get current wizard data + app, err := s.GetApplication(ctx, appID) + if err != nil { + return err + } + + // Initialize wizard data if nil + if app.WizardData == nil { + app.WizardData = make(map[string]interface{}) + } + + // Merge step data + stepKey := fmt.Sprintf("step_%d", step) + app.WizardData[stepKey] = data + app.CurrentStep = step + + // Update application + return s.UpdateApplication(ctx, app) +} + +// GetWizardProgress returns the wizard progress +func (s *PostgresStore) GetWizardProgress(ctx context.Context, appID uuid.UUID) (*WizardProgress, error) { + app, err := s.GetApplication(ctx, appID) + if err != nil { + return nil, err + } + + progress := &WizardProgress{ + CurrentStep: app.CurrentStep, + TotalSteps: app.TotalSteps, + CompletedSteps: []int{}, + FormData: app.WizardData, + LastSavedAt: app.UpdatedAt, + } + + // Determine completed steps from wizard data + for i := 1; i <= app.TotalSteps; i++ { + stepKey := fmt.Sprintf("step_%d", i) + if _, ok := app.WizardData[stepKey]; ok { + progress.CompletedSteps = append(progress.CompletedSteps, i) + } + } + + return progress, nil +} + +// AddAttachment adds an attachment to an application +func (s *PostgresStore) AddAttachment(ctx context.Context, appID uuid.UUID, attachment *Attachment) error { + attachment.ID = uuid.New() + attachment.UploadedAt = time.Now() + + query := ` + INSERT INTO funding_attachments ( + id, application_id, file_name, file_type, file_size, + category, description, storage_path, uploaded_at, uploaded_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ` + + _, err := s.pool.Exec(ctx, query, + attachment.ID, appID, attachment.FileName, attachment.FileType, attachment.FileSize, + attachment.Category, attachment.Description, attachment.StoragePath, + attachment.UploadedAt, attachment.UploadedBy, + ) + + return err +} + +// GetAttachments returns all attachments for an application +func (s *PostgresStore) GetAttachments(ctx context.Context, appID uuid.UUID) ([]Attachment, error) { + query := ` + SELECT id, file_name, file_type, file_size, category, description, storage_path, uploaded_at, uploaded_by + FROM funding_attachments + WHERE application_id = $1 + ORDER BY uploaded_at DESC + ` + + rows, err := s.pool.Query(ctx, query, appID) + if err != nil { + return nil, err + } + defer rows.Close() + + var attachments []Attachment + for rows.Next() { + var a Attachment + err := rows.Scan(&a.ID, &a.FileName, &a.FileType, &a.FileSize, &a.Category, &a.Description, &a.StoragePath, &a.UploadedAt, &a.UploadedBy) + if err != nil { + continue + } + attachments = append(attachments, a) + } + + return attachments, nil +} + +// DeleteAttachment deletes an attachment +func (s *PostgresStore) DeleteAttachment(ctx context.Context, attachmentID uuid.UUID) error { + query := `DELETE FROM funding_attachments WHERE id = $1` + _, err := s.pool.Exec(ctx, query, attachmentID) + return err +} + +// AddHistoryEntry adds an audit trail entry +func (s *PostgresStore) AddHistoryEntry(ctx context.Context, entry *ApplicationHistoryEntry) error { + entry.ID = uuid.New() + entry.PerformedAt = time.Now().Format(time.RFC3339) + + oldValuesJSON, _ := json.Marshal(entry.OldValues) + newValuesJSON, _ := json.Marshal(entry.NewValues) + changedFieldsJSON, _ := json.Marshal(entry.ChangedFields) + + query := ` + INSERT INTO funding_application_history ( + id, application_id, action, changed_fields, old_values, new_values, + performed_by, performed_at, notes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ` + + _, err := s.pool.Exec(ctx, query, + entry.ID, entry.ApplicationID, entry.Action, changedFieldsJSON, oldValuesJSON, newValuesJSON, + entry.PerformedBy, entry.PerformedAt, entry.Notes, + ) + + return err +} + +// GetHistory returns the audit trail for an application +func (s *PostgresStore) GetHistory(ctx context.Context, appID uuid.UUID) ([]ApplicationHistoryEntry, error) { + query := ` + SELECT id, application_id, action, changed_fields, old_values, new_values, performed_by, performed_at, notes + FROM funding_application_history + WHERE application_id = $1 + ORDER BY performed_at DESC + ` + + rows, err := s.pool.Query(ctx, query, appID) + if err != nil { + return nil, err + } + defer rows.Close() + + var history []ApplicationHistoryEntry + for rows.Next() { + var entry ApplicationHistoryEntry + var changedFieldsJSON, oldValuesJSON, newValuesJSON []byte + + err := rows.Scan( + &entry.ID, &entry.ApplicationID, &entry.Action, &changedFieldsJSON, &oldValuesJSON, &newValuesJSON, + &entry.PerformedBy, &entry.PerformedAt, &entry.Notes, + ) + if err != nil { + continue + } + + json.Unmarshal(changedFieldsJSON, &entry.ChangedFields) + json.Unmarshal(oldValuesJSON, &entry.OldValues) + json.Unmarshal(newValuesJSON, &entry.NewValues) + + history = append(history, entry) + } + + return history, nil +} + +// GetStatistics returns funding statistics +func (s *PostgresStore) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*FundingStatistics, error) { + stats := &FundingStatistics{ + ByProgram: make(map[FundingProgram]int), + ByState: make(map[FederalState]int), + } + + // Total and by status + query := ` + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'DRAFT') as draft, + COUNT(*) FILTER (WHERE status = 'SUBMITTED') as submitted, + COUNT(*) FILTER (WHERE status = 'APPROVED') as approved, + COUNT(*) FILTER (WHERE status = 'REJECTED') as rejected, + COALESCE(SUM(requested_amount), 0) as total_requested, + COALESCE(SUM(COALESCE(approved_amount, 0)), 0) as total_approved + FROM funding_applications + WHERE tenant_id = $1 AND status != 'ARCHIVED' + ` + + err := s.pool.QueryRow(ctx, query, tenantID).Scan( + &stats.TotalApplications, &stats.DraftCount, &stats.SubmittedCount, + &stats.ApprovedCount, &stats.RejectedCount, + &stats.TotalRequested, &stats.TotalApproved, + ) + if err != nil { + return nil, err + } + + // By program + programQuery := ` + SELECT funding_program, COUNT(*) + FROM funding_applications + WHERE tenant_id = $1 AND status != 'ARCHIVED' + GROUP BY funding_program + ` + rows, _ := s.pool.Query(ctx, programQuery, tenantID) + for rows.Next() { + var program FundingProgram + var count int + rows.Scan(&program, &count) + stats.ByProgram[program] = count + } + rows.Close() + + return stats, nil +} + +// SaveExportBundle saves an export bundle record +func (s *PostgresStore) SaveExportBundle(ctx context.Context, bundle *ExportBundle) error { + bundle.ID = uuid.New() + bundle.GeneratedAt = time.Now() + bundle.ExpiresAt = time.Now().Add(24 * time.Hour) // 24h expiry + + documentsJSON, _ := json.Marshal(bundle.Documents) + + query := ` + INSERT INTO funding_export_bundles ( + id, application_id, documents, generated_at, download_url, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6) + ` + + _, err := s.pool.Exec(ctx, query, + bundle.ID, bundle.ApplicationID, documentsJSON, + bundle.GeneratedAt, bundle.DownloadURL, bundle.ExpiresAt, + ) + + return err +} + +// GetExportBundle retrieves an export bundle +func (s *PostgresStore) GetExportBundle(ctx context.Context, bundleID uuid.UUID) (*ExportBundle, error) { + query := ` + SELECT id, application_id, documents, generated_at, download_url, expires_at + FROM funding_export_bundles + WHERE id = $1 AND expires_at > NOW() + ` + + var bundle ExportBundle + var documentsJSON []byte + + err := s.pool.QueryRow(ctx, query, bundleID).Scan( + &bundle.ID, &bundle.ApplicationID, &documentsJSON, + &bundle.GeneratedAt, &bundle.DownloadURL, &bundle.ExpiresAt, + ) + if err != nil { + return nil, err + } + + json.Unmarshal(documentsJSON, &bundle.Documents) + + return &bundle, nil +} + +// generateApplicationNumber creates a unique application number +func (s *PostgresStore) generateApplicationNumber(program FundingProgram, school *SchoolProfile) string { + year := time.Now().Year() + state := "XX" + if school != nil { + state = string(school.FederalState) + } + + prefix := "FA" + switch program { + case FundingProgramDigitalPakt1: + prefix = "DP1" + case FundingProgramDigitalPakt2: + prefix = "DP2" + case FundingProgramLandesfoerderung: + prefix = "LF" + } + + // Get sequence number + var seq int + s.pool.QueryRow(context.Background(), + `SELECT COALESCE(MAX(CAST(SUBSTRING(application_number FROM '\d{5}$') AS INTEGER)), 0) + 1 + FROM funding_applications WHERE application_number LIKE $1`, + fmt.Sprintf("%s-%s-%d-%%", prefix, state, year), + ).Scan(&seq) + + return fmt.Sprintf("%s-%s-%d-%05d", prefix, state, year, seq) +} diff --git a/ai-compliance-sdk/internal/funding/store.go b/ai-compliance-sdk/internal/funding/store.go new file mode 100644 index 0000000..3143fca --- /dev/null +++ b/ai-compliance-sdk/internal/funding/store.go @@ -0,0 +1,81 @@ +package funding + +import ( + "context" + + "github.com/google/uuid" +) + +// Store defines the interface for funding application persistence +type Store interface { + // Application CRUD + CreateApplication(ctx context.Context, app *FundingApplication) error + GetApplication(ctx context.Context, id uuid.UUID) (*FundingApplication, error) + GetApplicationByNumber(ctx context.Context, number string) (*FundingApplication, error) + UpdateApplication(ctx context.Context, app *FundingApplication) error + DeleteApplication(ctx context.Context, id uuid.UUID) error + + // List & Search + ListApplications(ctx context.Context, tenantID uuid.UUID, filter ApplicationFilter) (*ApplicationListResponse, error) + SearchApplications(ctx context.Context, tenantID uuid.UUID, query string) ([]FundingApplication, error) + + // Wizard Data + SaveWizardStep(ctx context.Context, appID uuid.UUID, step int, data map[string]interface{}) error + GetWizardProgress(ctx context.Context, appID uuid.UUID) (*WizardProgress, error) + + // Attachments + AddAttachment(ctx context.Context, appID uuid.UUID, attachment *Attachment) error + GetAttachments(ctx context.Context, appID uuid.UUID) ([]Attachment, error) + DeleteAttachment(ctx context.Context, attachmentID uuid.UUID) error + + // Application History (Audit Trail) + AddHistoryEntry(ctx context.Context, entry *ApplicationHistoryEntry) error + GetHistory(ctx context.Context, appID uuid.UUID) ([]ApplicationHistoryEntry, error) + + // Statistics + GetStatistics(ctx context.Context, tenantID uuid.UUID) (*FundingStatistics, error) + + // Export Tracking + SaveExportBundle(ctx context.Context, bundle *ExportBundle) error + GetExportBundle(ctx context.Context, bundleID uuid.UUID) (*ExportBundle, error) +} + +// ApplicationFilter for filtering list queries +type ApplicationFilter struct { + Status *ApplicationStatus `json:"status,omitempty"` + FundingProgram *FundingProgram `json:"funding_program,omitempty"` + FederalState *FederalState `json:"federal_state,omitempty"` + CreatedAfter *string `json:"created_after,omitempty"` + CreatedBefore *string `json:"created_before,omitempty"` + Page int `json:"page"` + PageSize int `json:"page_size"` + SortBy string `json:"sort_by,omitempty"` + SortOrder string `json:"sort_order,omitempty"` // asc, desc +} + +// ApplicationHistoryEntry for audit trail +type ApplicationHistoryEntry struct { + ID uuid.UUID `json:"id"` + ApplicationID uuid.UUID `json:"application_id"` + Action string `json:"action"` // created, updated, submitted, approved, etc. + ChangedFields []string `json:"changed_fields,omitempty"` + OldValues map[string]interface{} `json:"old_values,omitempty"` + NewValues map[string]interface{} `json:"new_values,omitempty"` + PerformedBy uuid.UUID `json:"performed_by"` + PerformedAt string `json:"performed_at"` + Notes string `json:"notes,omitempty"` +} + +// FundingStatistics for dashboard +type FundingStatistics struct { + TotalApplications int `json:"total_applications"` + DraftCount int `json:"draft_count"` + SubmittedCount int `json:"submitted_count"` + ApprovedCount int `json:"approved_count"` + RejectedCount int `json:"rejected_count"` + TotalRequested float64 `json:"total_requested"` + TotalApproved float64 `json:"total_approved"` + AverageProcessDays float64 `json:"average_process_days"` + ByProgram map[FundingProgram]int `json:"by_program"` + ByState map[FederalState]int `json:"by_state"` +} diff --git a/ai-compliance-sdk/internal/incidents/models.go b/ai-compliance-sdk/internal/incidents/models.go new file mode 100644 index 0000000..2478ba3 --- /dev/null +++ b/ai-compliance-sdk/internal/incidents/models.go @@ -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" +} diff --git a/ai-compliance-sdk/internal/incidents/store.go b/ai-compliance-sdk/internal/incidents/store.go new file mode 100644 index 0000000..f667b67 --- /dev/null +++ b/ai-compliance-sdk/internal/incidents/store.go @@ -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 +} diff --git a/ai-compliance-sdk/internal/llm/access_gate.go b/ai-compliance-sdk/internal/llm/access_gate.go new file mode 100644 index 0000000..ec58f3b --- /dev/null +++ b/ai-compliance-sdk/internal/llm/access_gate.go @@ -0,0 +1,367 @@ +package llm + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" + "github.com/google/uuid" +) + +// AccessGate controls access to LLM operations based on RBAC policies +type AccessGate struct { + policyEngine *rbac.PolicyEngine + piiDetector *PIIDetector + registry *ProviderRegistry +} + +// NewAccessGate creates a new access gate +func NewAccessGate(policyEngine *rbac.PolicyEngine, piiDetector *PIIDetector, registry *ProviderRegistry) *AccessGate { + return &AccessGate{ + policyEngine: policyEngine, + piiDetector: piiDetector, + registry: registry, + } +} + +// GatedRequest represents a request that has passed through the access gate +type GatedRequest struct { + OriginalRequest any + UserID uuid.UUID + TenantID uuid.UUID + NamespaceID *uuid.UUID + Model string + PIIDetected bool + PIITypes []string + PromptRedacted bool + PromptHash string + Policy *rbac.LLMPolicy + AccessResult *rbac.LLMAccessResult +} + +// GatedChatRequest represents a chat request that has passed through the gate +type GatedChatRequest struct { + *GatedRequest + Messages []Message +} + +// GatedCompletionRequest represents a completion request that has passed through the gate +type GatedCompletionRequest struct { + *GatedRequest + Prompt string +} + +// ProcessChatRequest validates and processes a chat request +func (g *AccessGate) ProcessChatRequest( + ctx context.Context, + userID, tenantID uuid.UUID, + namespaceID *uuid.UUID, + req *ChatRequest, + dataCategories []string, +) (*GatedChatRequest, error) { + // 1. Evaluate LLM access + accessReq := &rbac.LLMAccessRequest{ + UserID: userID, + TenantID: tenantID, + NamespaceID: namespaceID, + Model: req.Model, + DataCategories: dataCategories, + TokensRequested: req.MaxTokens, + Operation: "chat", + } + + accessResult, err := g.policyEngine.EvaluateLLMAccess(ctx, accessReq) + if err != nil { + return nil, fmt.Errorf("access evaluation failed: %w", err) + } + + if !accessResult.Allowed { + return nil, fmt.Errorf("access denied: %s", accessResult.Reason) + } + + // 2. Process messages for PII + processedMessages := make([]Message, len(req.Messages)) + copy(processedMessages, req.Messages) + + var allPIITypes []string + piiDetected := false + redacted := false + + for i, msg := range processedMessages { + if msg.Role == "user" || msg.Role == "system" { + // Check for PII + findings := g.piiDetector.FindPII(msg.Content) + if len(findings) > 0 { + piiDetected = true + for _, f := range findings { + allPIITypes = append(allPIITypes, f.Type) + } + + // Redact if required by policy + if accessResult.RequirePIIRedaction { + processedMessages[i].Content = g.piiDetector.Redact(msg.Content, accessResult.PIIRedactionLevel) + redacted = true + } + } + } + } + + // 3. Generate prompt hash for audit + promptHash := g.hashMessages(processedMessages) + + // 4. Apply token limits from policy + if accessResult.MaxTokens > 0 && req.MaxTokens > accessResult.MaxTokens { + req.MaxTokens = accessResult.MaxTokens + } + + return &GatedChatRequest{ + GatedRequest: &GatedRequest{ + OriginalRequest: req, + UserID: userID, + TenantID: tenantID, + NamespaceID: namespaceID, + Model: req.Model, + PIIDetected: piiDetected, + PIITypes: uniqueStrings(allPIITypes), + PromptRedacted: redacted, + PromptHash: promptHash, + Policy: accessResult.Policy, + AccessResult: accessResult, + }, + Messages: processedMessages, + }, nil +} + +// ProcessCompletionRequest validates and processes a completion request +func (g *AccessGate) ProcessCompletionRequest( + ctx context.Context, + userID, tenantID uuid.UUID, + namespaceID *uuid.UUID, + req *CompletionRequest, + dataCategories []string, +) (*GatedCompletionRequest, error) { + // 1. Evaluate LLM access + accessReq := &rbac.LLMAccessRequest{ + UserID: userID, + TenantID: tenantID, + NamespaceID: namespaceID, + Model: req.Model, + DataCategories: dataCategories, + TokensRequested: req.MaxTokens, + Operation: "completion", + } + + accessResult, err := g.policyEngine.EvaluateLLMAccess(ctx, accessReq) + if err != nil { + return nil, fmt.Errorf("access evaluation failed: %w", err) + } + + if !accessResult.Allowed { + return nil, fmt.Errorf("access denied: %s", accessResult.Reason) + } + + // 2. Process prompt for PII + processedPrompt := req.Prompt + var allPIITypes []string + piiDetected := false + redacted := false + + findings := g.piiDetector.FindPII(req.Prompt) + if len(findings) > 0 { + piiDetected = true + for _, f := range findings { + allPIITypes = append(allPIITypes, f.Type) + } + + // Redact if required by policy + if accessResult.RequirePIIRedaction { + processedPrompt = g.piiDetector.Redact(req.Prompt, accessResult.PIIRedactionLevel) + redacted = true + } + } + + // 3. Generate prompt hash for audit + promptHash := g.hashPrompt(processedPrompt) + + // 4. Apply token limits from policy + if accessResult.MaxTokens > 0 && req.MaxTokens > accessResult.MaxTokens { + req.MaxTokens = accessResult.MaxTokens + } + + return &GatedCompletionRequest{ + GatedRequest: &GatedRequest{ + OriginalRequest: req, + UserID: userID, + TenantID: tenantID, + NamespaceID: namespaceID, + Model: req.Model, + PIIDetected: piiDetected, + PIITypes: uniqueStrings(allPIITypes), + PromptRedacted: redacted, + PromptHash: promptHash, + Policy: accessResult.Policy, + AccessResult: accessResult, + }, + Prompt: processedPrompt, + }, nil +} + +// ExecuteChat executes a gated chat request +func (g *AccessGate) ExecuteChat(ctx context.Context, gatedReq *GatedChatRequest) (*ChatResponse, error) { + provider, err := g.registry.GetAvailable(ctx) + if err != nil { + return nil, err + } + + req := &ChatRequest{ + Model: gatedReq.Model, + Messages: gatedReq.Messages, + MaxTokens: gatedReq.AccessResult.MaxTokens, + Temperature: 0.7, + } + + if orig, ok := gatedReq.OriginalRequest.(*ChatRequest); ok { + req.Temperature = orig.Temperature + req.TopP = orig.TopP + req.Stop = orig.Stop + req.Options = orig.Options + } + + return provider.Chat(ctx, req) +} + +// ExecuteCompletion executes a gated completion request +func (g *AccessGate) ExecuteCompletion(ctx context.Context, gatedReq *GatedCompletionRequest) (*CompletionResponse, error) { + provider, err := g.registry.GetAvailable(ctx) + if err != nil { + return nil, err + } + + req := &CompletionRequest{ + Model: gatedReq.Model, + Prompt: gatedReq.Prompt, + MaxTokens: gatedReq.AccessResult.MaxTokens, + } + + if orig, ok := gatedReq.OriginalRequest.(*CompletionRequest); ok { + req.Temperature = orig.Temperature + req.TopP = orig.TopP + req.Stop = orig.Stop + req.Options = orig.Options + } + + return provider.Complete(ctx, req) +} + +// hashMessages creates a SHA-256 hash of chat messages (for audit without storing PII) +func (g *AccessGate) hashMessages(messages []Message) string { + hasher := sha256.New() + for _, msg := range messages { + hasher.Write([]byte(msg.Role)) + hasher.Write([]byte(msg.Content)) + } + return hex.EncodeToString(hasher.Sum(nil)) +} + +// hashPrompt creates a SHA-256 hash of a prompt +func (g *AccessGate) hashPrompt(prompt string) string { + hasher := sha256.New() + hasher.Write([]byte(prompt)) + return hex.EncodeToString(hasher.Sum(nil)) +} + +// uniqueStrings returns unique strings from a slice +func uniqueStrings(slice []string) []string { + seen := make(map[string]bool) + var result []string + for _, s := range slice { + if !seen[s] { + seen[s] = true + result = append(result, s) + } + } + return result +} + +// AuditEntry represents an entry for the audit log +type AuditEntry struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + UserID uuid.UUID `json:"user_id"` + SessionID string `json:"session_id,omitempty"` + Operation string `json:"operation"` + ModelUsed string `json:"model_used"` + Provider string `json:"provider"` + PromptHash string `json:"prompt_hash"` + PromptLength int `json:"prompt_length"` + ResponseLength int `json:"response_length,omitempty"` + TokensUsed int `json:"tokens_used"` + DurationMS int `json:"duration_ms"` + PIIDetected bool `json:"pii_detected"` + PIITypesDetected []string `json:"pii_types_detected,omitempty"` + PIIRedacted bool `json:"pii_redacted"` + PolicyID *uuid.UUID `json:"policy_id,omitempty"` + PolicyViolations []string `json:"policy_violations,omitempty"` + DataCategoriesAccessed []string `json:"data_categories_accessed,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + RequestMetadata map[string]any `json:"request_metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateAuditEntry creates an audit entry from a gated request and response +func (g *AccessGate) CreateAuditEntry( + gatedReq *GatedRequest, + operation string, + provider string, + resp any, + err error, + promptLength int, + sessionID string, +) *AuditEntry { + entry := &AuditEntry{ + ID: uuid.New(), + TenantID: gatedReq.TenantID, + NamespaceID: gatedReq.NamespaceID, + UserID: gatedReq.UserID, + SessionID: sessionID, + Operation: operation, + ModelUsed: gatedReq.Model, + Provider: provider, + PromptHash: gatedReq.PromptHash, + PromptLength: promptLength, + PIIDetected: gatedReq.PIIDetected, + PIITypesDetected: gatedReq.PIITypes, + PIIRedacted: gatedReq.PromptRedacted, + CreatedAt: time.Now().UTC(), + } + + if gatedReq.Policy != nil { + entry.PolicyID = &gatedReq.Policy.ID + } + + if gatedReq.AccessResult != nil && len(gatedReq.AccessResult.BlockedCategories) > 0 { + entry.PolicyViolations = gatedReq.AccessResult.BlockedCategories + } + + if err != nil { + entry.ErrorMessage = err.Error() + } + + // Extract usage from response + switch r := resp.(type) { + case *ChatResponse: + entry.ResponseLength = len(r.Message.Content) + entry.TokensUsed = r.Usage.TotalTokens + entry.DurationMS = int(r.Duration.Milliseconds()) + case *CompletionResponse: + entry.ResponseLength = len(r.Text) + entry.TokensUsed = r.Usage.TotalTokens + entry.DurationMS = int(r.Duration.Milliseconds()) + } + + return entry +} diff --git a/ai-compliance-sdk/internal/llm/anthropic_adapter.go b/ai-compliance-sdk/internal/llm/anthropic_adapter.go new file mode 100644 index 0000000..9ac4d3d --- /dev/null +++ b/ai-compliance-sdk/internal/llm/anthropic_adapter.go @@ -0,0 +1,250 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// AnthropicAdapter implements the Provider interface for Anthropic API +type AnthropicAdapter struct { + apiKey string + baseURL string + defaultModel string + httpClient *http.Client +} + +// NewAnthropicAdapter creates a new Anthropic adapter +func NewAnthropicAdapter(apiKey, defaultModel string) *AnthropicAdapter { + return &AnthropicAdapter{ + apiKey: apiKey, + baseURL: "https://api.anthropic.com", + defaultModel: defaultModel, + httpClient: &http.Client{ + Timeout: 5 * time.Minute, + }, + } +} + +// Name returns the provider name +func (a *AnthropicAdapter) Name() string { + return ProviderAnthropic +} + +// IsAvailable checks if Anthropic API is reachable +func (a *AnthropicAdapter) IsAvailable(ctx context.Context) bool { + if a.apiKey == "" { + return false + } + + // Simple check - we can't really ping Anthropic without making a request + // Just verify we have an API key + return true +} + +// ListModels returns available Anthropic models +func (a *AnthropicAdapter) ListModels(ctx context.Context) ([]Model, error) { + // Anthropic doesn't have a models endpoint, return known models + return []Model{ + { + ID: "claude-3-opus-20240229", + Name: "Claude 3 Opus", + Provider: ProviderAnthropic, + Description: "Most powerful model for complex tasks", + ContextSize: 200000, + Capabilities: []string{"chat"}, + }, + { + ID: "claude-3-sonnet-20240229", + Name: "Claude 3 Sonnet", + Provider: ProviderAnthropic, + Description: "Balanced performance and speed", + ContextSize: 200000, + Capabilities: []string{"chat"}, + }, + { + ID: "claude-3-haiku-20240307", + Name: "Claude 3 Haiku", + Provider: ProviderAnthropic, + Description: "Fast and efficient", + ContextSize: 200000, + Capabilities: []string{"chat"}, + }, + { + ID: "claude-3-5-sonnet-20240620", + Name: "Claude 3.5 Sonnet", + Provider: ProviderAnthropic, + Description: "Latest and most capable model", + ContextSize: 200000, + Capabilities: []string{"chat"}, + }, + }, nil +} + +// Complete performs text completion (converted to chat) +func (a *AnthropicAdapter) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { + // Anthropic only supports chat, so convert completion to chat + chatReq := &ChatRequest{ + Model: req.Model, + Messages: []Message{ + {Role: "user", Content: req.Prompt}, + }, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + TopP: req.TopP, + Stop: req.Stop, + } + + chatResp, err := a.Chat(ctx, chatReq) + if err != nil { + return nil, err + } + + return &CompletionResponse{ + ID: chatResp.ID, + Model: chatResp.Model, + Provider: chatResp.Provider, + Text: chatResp.Message.Content, + FinishReason: chatResp.FinishReason, + Usage: chatResp.Usage, + Duration: chatResp.Duration, + }, nil +} + +// Chat performs chat completion +func (a *AnthropicAdapter) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { + if a.apiKey == "" { + return nil, fmt.Errorf("anthropic API key not configured") + } + + model := req.Model + if model == "" { + model = a.defaultModel + } + + start := time.Now() + + // Extract system message if present + var systemMessage string + var messages []map[string]string + + for _, m := range req.Messages { + if m.Role == "system" { + systemMessage = m.Content + } else { + messages = append(messages, map[string]string{ + "role": m.Role, + "content": m.Content, + }) + } + } + + maxTokens := req.MaxTokens + if maxTokens == 0 { + maxTokens = 4096 + } + + anthropicReq := map[string]any{ + "model": model, + "messages": messages, + "max_tokens": maxTokens, + } + + if systemMessage != "" { + anthropicReq["system"] = systemMessage + } + + if req.Temperature > 0 { + anthropicReq["temperature"] = req.Temperature + } + + if req.TopP > 0 { + anthropicReq["top_p"] = req.TopP + } + + if len(req.Stop) > 0 { + anthropicReq["stop_sequences"] = req.Stop + } + + body, err := json.Marshal(anthropicReq) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL+"/v1/messages", bytes.NewReader(body)) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-api-key", a.apiKey) + httpReq.Header.Set("anthropic-version", "2023-06-01") + + resp, err := a.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("anthropic request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("anthropic error (%d): %s", resp.StatusCode, string(bodyBytes)) + } + + var result struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + Model string `json:"model"` + StopReason string `json:"stop_reason"` + StopSequence string `json:"stop_sequence,omitempty"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + duration := time.Since(start) + + // Extract text from content blocks + var responseText string + for _, block := range result.Content { + if block.Type == "text" { + responseText += block.Text + } + } + + return &ChatResponse{ + ID: result.ID, + Model: result.Model, + Provider: ProviderAnthropic, + Message: Message{ + Role: "assistant", + Content: responseText, + }, + FinishReason: result.StopReason, + Usage: UsageStats{ + PromptTokens: result.Usage.InputTokens, + CompletionTokens: result.Usage.OutputTokens, + TotalTokens: result.Usage.InputTokens + result.Usage.OutputTokens, + }, + Duration: duration, + }, nil +} + +// Embed creates embeddings (Anthropic doesn't support embeddings natively) +func (a *AnthropicAdapter) Embed(ctx context.Context, req *EmbedRequest) (*EmbedResponse, error) { + return nil, fmt.Errorf("anthropic does not support embeddings - use Ollama or OpenAI") +} diff --git a/ai-compliance-sdk/internal/llm/ollama_adapter.go b/ai-compliance-sdk/internal/llm/ollama_adapter.go new file mode 100644 index 0000000..ca52603 --- /dev/null +++ b/ai-compliance-sdk/internal/llm/ollama_adapter.go @@ -0,0 +1,350 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/google/uuid" +) + +// OllamaAdapter implements the Provider interface for Ollama +type OllamaAdapter struct { + baseURL string + defaultModel string + httpClient *http.Client +} + +// NewOllamaAdapter creates a new Ollama adapter +func NewOllamaAdapter(baseURL, defaultModel string) *OllamaAdapter { + return &OllamaAdapter{ + baseURL: baseURL, + defaultModel: defaultModel, + httpClient: &http.Client{ + Timeout: 5 * time.Minute, // LLM requests can be slow + }, + } +} + +// Name returns the provider name +func (o *OllamaAdapter) Name() string { + return ProviderOllama +} + +// IsAvailable checks if Ollama is reachable +func (o *OllamaAdapter) IsAvailable(ctx context.Context) bool { + req, err := http.NewRequestWithContext(ctx, "GET", o.baseURL+"/api/tags", nil) + if err != nil { + return false + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req = req.WithContext(ctx) + resp, err := o.httpClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK +} + +// ListModels returns available Ollama models +func (o *OllamaAdapter) ListModels(ctx context.Context) ([]Model, error) { + req, err := http.NewRequestWithContext(ctx, "GET", o.baseURL+"/api/tags", nil) + if err != nil { + return nil, err + } + + resp, err := o.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to list models: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + var result struct { + Models []struct { + Name string `json:"name"` + ModifiedAt string `json:"modified_at"` + Size int64 `json:"size"` + Details struct { + Format string `json:"format"` + Family string `json:"family"` + ParameterSize string `json:"parameter_size"` + QuantizationLevel string `json:"quantization_level"` + } `json:"details"` + } `json:"models"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + models := make([]Model, len(result.Models)) + for i, m := range result.Models { + models[i] = Model{ + ID: m.Name, + Name: m.Name, + Provider: ProviderOllama, + Description: fmt.Sprintf("%s (%s)", m.Details.Family, m.Details.ParameterSize), + ContextSize: 4096, // Default, actual varies by model + Capabilities: []string{"chat", "completion"}, + } + } + + return models, nil +} + +// Complete performs text completion +func (o *OllamaAdapter) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { + model := req.Model + if model == "" { + model = o.defaultModel + } + + start := time.Now() + + ollamaReq := map[string]any{ + "model": model, + "prompt": req.Prompt, + "stream": false, + } + + if req.MaxTokens > 0 { + if ollamaReq["options"] == nil { + ollamaReq["options"] = make(map[string]any) + } + ollamaReq["options"].(map[string]any)["num_predict"] = req.MaxTokens + } + + if req.Temperature > 0 { + if ollamaReq["options"] == nil { + ollamaReq["options"] = make(map[string]any) + } + ollamaReq["options"].(map[string]any)["temperature"] = req.Temperature + } + + body, err := json.Marshal(ollamaReq) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", o.baseURL+"/api/generate", bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("ollama request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("ollama error: %s", string(bodyBytes)) + } + + var result struct { + Model string `json:"model"` + Response string `json:"response"` + Done bool `json:"done"` + TotalDuration int64 `json:"total_duration"` + LoadDuration int64 `json:"load_duration"` + PromptEvalCount int `json:"prompt_eval_count"` + PromptEvalDuration int64 `json:"prompt_eval_duration"` + EvalCount int `json:"eval_count"` + EvalDuration int64 `json:"eval_duration"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + duration := time.Since(start) + + return &CompletionResponse{ + ID: uuid.New().String(), + Model: result.Model, + Provider: ProviderOllama, + Text: result.Response, + FinishReason: "stop", + Usage: UsageStats{ + PromptTokens: result.PromptEvalCount, + CompletionTokens: result.EvalCount, + TotalTokens: result.PromptEvalCount + result.EvalCount, + }, + Duration: duration, + }, nil +} + +// Chat performs chat completion +func (o *OllamaAdapter) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { + model := req.Model + if model == "" { + model = o.defaultModel + } + + start := time.Now() + + // Convert messages to Ollama format + messages := make([]map[string]string, len(req.Messages)) + for i, m := range req.Messages { + messages[i] = map[string]string{ + "role": m.Role, + "content": m.Content, + } + } + + ollamaReq := map[string]any{ + "model": model, + "messages": messages, + "stream": false, + } + + if req.MaxTokens > 0 { + if ollamaReq["options"] == nil { + ollamaReq["options"] = make(map[string]any) + } + ollamaReq["options"].(map[string]any)["num_predict"] = req.MaxTokens + } + + if req.Temperature > 0 { + if ollamaReq["options"] == nil { + ollamaReq["options"] = make(map[string]any) + } + ollamaReq["options"].(map[string]any)["temperature"] = req.Temperature + } + + body, err := json.Marshal(ollamaReq) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", o.baseURL+"/api/chat", bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("ollama chat request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("ollama chat error: %s", string(bodyBytes)) + } + + var result struct { + Model string `json:"model"` + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + Done bool `json:"done"` + TotalDuration int64 `json:"total_duration"` + PromptEvalCount int `json:"prompt_eval_count"` + EvalCount int `json:"eval_count"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode chat response: %w", err) + } + + duration := time.Since(start) + + return &ChatResponse{ + ID: uuid.New().String(), + Model: result.Model, + Provider: ProviderOllama, + Message: Message{ + Role: result.Message.Role, + Content: result.Message.Content, + }, + FinishReason: "stop", + Usage: UsageStats{ + PromptTokens: result.PromptEvalCount, + CompletionTokens: result.EvalCount, + TotalTokens: result.PromptEvalCount + result.EvalCount, + }, + Duration: duration, + }, nil +} + +// Embed creates embeddings +func (o *OllamaAdapter) Embed(ctx context.Context, req *EmbedRequest) (*EmbedResponse, error) { + model := req.Model + if model == "" { + model = "nomic-embed-text" // Default embedding model + } + + start := time.Now() + + var embeddings [][]float64 + + for _, input := range req.Input { + ollamaReq := map[string]any{ + "model": model, + "prompt": input, + } + + body, err := json.Marshal(ollamaReq) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", o.baseURL+"/api/embeddings", bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("ollama embedding request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("ollama embedding error: %s", string(bodyBytes)) + } + + var result struct { + Embedding []float64 `json:"embedding"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode embedding response: %w", err) + } + + embeddings = append(embeddings, result.Embedding) + } + + duration := time.Since(start) + + return &EmbedResponse{ + ID: uuid.New().String(), + Model: model, + Provider: ProviderOllama, + Embeddings: embeddings, + Usage: UsageStats{ + TotalTokens: len(req.Input) * 256, // Approximate + }, + Duration: duration, + }, nil +} diff --git a/ai-compliance-sdk/internal/llm/pii_detector.go b/ai-compliance-sdk/internal/llm/pii_detector.go new file mode 100644 index 0000000..25b981d --- /dev/null +++ b/ai-compliance-sdk/internal/llm/pii_detector.go @@ -0,0 +1,276 @@ +package llm + +import ( + "regexp" + "strings" + + "github.com/breakpilot/ai-compliance-sdk/internal/rbac" +) + +// PIIType represents a type of personally identifiable information +type PIIType string + +const ( + PIITypeEmail PIIType = "email" + PIITypePhone PIIType = "phone" + PIITypeIPv4 PIIType = "ip_v4" + PIITypeIPv6 PIIType = "ip_v6" + PIITypeIBAN PIIType = "iban" + PIITypeUUID PIIType = "uuid" + PIITypeName PIIType = "name" + PIITypeSocialSec PIIType = "social_security" + PIITypeCreditCard PIIType = "credit_card" + PIITypeDateOfBirth PIIType = "date_of_birth" + PIITypeSalary PIIType = "salary" + PIITypeAddress PIIType = "address" +) + +// PIIPattern defines a pattern for identifying PII +type PIIPattern struct { + Type PIIType + Pattern *regexp.Regexp + Replacement string + Level rbac.PIIRedactionLevel // Minimum level at which this is redacted +} + +// PIIFinding represents a found PII instance +type PIIFinding struct { + Type string `json:"type"` + Match string `json:"match"` + Start int `json:"start"` + End int `json:"end"` +} + +// PIIDetector detects and redacts personally identifiable information +type PIIDetector struct { + patterns []*PIIPattern +} + +// Pre-compiled patterns for common PII types +var ( + emailPattern = regexp.MustCompile(`\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b`) + ipv4Pattern = regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) + ipv6Pattern = regexp.MustCompile(`\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b`) + phonePattern = regexp.MustCompile(`(?:\+49|0049)[\s.-]?\d{2,4}[\s.-]?\d{3,8}|\b0\d{2,4}[\s.-]?\d{3,8}\b|\b\+\d{1,3}[\s.-]?\d{2,4}[\s.-]?\d{3,8}\b`) + ibanPattern = regexp.MustCompile(`(?i)\b[A-Z]{2}\d{2}[\s]?(?:\d{4}[\s]?){3,5}\d{1,4}\b`) + uuidPattern = regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`) + namePattern = regexp.MustCompile(`\b(?:Herr|Frau|Hr\.|Fr\.|Mr\.|Mrs\.|Ms\.)\s+[A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?\b`) + creditCardPattern = regexp.MustCompile(`\b(?:\d{4}[\s-]?){3}\d{4}\b`) + dobPattern = regexp.MustCompile(`\b(?:0[1-9]|[12][0-9]|3[01])\.(?:0[1-9]|1[012])\.(?:19|20)\d{2}\b`) + salaryPattern = regexp.MustCompile(`(?i)(?:gehalt|salary|lohn|vergütung|einkommen)[:\s]+(?:€|EUR|USD|\$)?\s*[\d.,]+(?:\s*(?:€|EUR|USD|\$))?`) + addressPattern = regexp.MustCompile(`(?i)\b(?:str\.|straße|strasse|weg|platz|allee)\s+\d+[a-z]?\b`) +) + +// NewPIIDetector creates a new PII detector with default patterns +func NewPIIDetector() *PIIDetector { + return &PIIDetector{ + patterns: DefaultPIIPatterns(), + } +} + +// NewPIIDetectorWithPatterns creates a new PII detector with custom patterns +func NewPIIDetectorWithPatterns(patterns []*PIIPattern) *PIIDetector { + return &PIIDetector{ + patterns: patterns, + } +} + +// DefaultPIIPatterns returns the default set of PII patterns +func DefaultPIIPatterns() []*PIIPattern { + return []*PIIPattern{ + {Type: PIITypeEmail, Pattern: emailPattern, Replacement: "[EMAIL_REDACTED]", Level: rbac.PIIRedactionMinimal}, + {Type: PIITypeIPv4, Pattern: ipv4Pattern, Replacement: "[IP_REDACTED]", Level: rbac.PIIRedactionMinimal}, + {Type: PIITypeIPv6, Pattern: ipv6Pattern, Replacement: "[IP_REDACTED]", Level: rbac.PIIRedactionMinimal}, + {Type: PIITypePhone, Pattern: phonePattern, Replacement: "[PHONE_REDACTED]", Level: rbac.PIIRedactionMinimal}, + } +} + +// AllPIIPatterns returns all available PII patterns +func AllPIIPatterns() []*PIIPattern { + return []*PIIPattern{ + {Type: PIITypeEmail, Pattern: emailPattern, Replacement: "[EMAIL_REDACTED]", Level: rbac.PIIRedactionMinimal}, + {Type: PIITypeIPv4, Pattern: ipv4Pattern, Replacement: "[IP_REDACTED]", Level: rbac.PIIRedactionMinimal}, + {Type: PIITypeIPv6, Pattern: ipv6Pattern, Replacement: "[IP_REDACTED]", Level: rbac.PIIRedactionMinimal}, + {Type: PIITypePhone, Pattern: phonePattern, Replacement: "[PHONE_REDACTED]", Level: rbac.PIIRedactionMinimal}, + {Type: PIITypeIBAN, Pattern: ibanPattern, Replacement: "[IBAN_REDACTED]", Level: rbac.PIIRedactionModerate}, + {Type: PIITypeUUID, Pattern: uuidPattern, Replacement: "[UUID_REDACTED]", Level: rbac.PIIRedactionStrict}, + {Type: PIITypeName, Pattern: namePattern, Replacement: "[NAME_REDACTED]", Level: rbac.PIIRedactionModerate}, + {Type: PIITypeCreditCard, Pattern: creditCardPattern, Replacement: "[CARD_REDACTED]", Level: rbac.PIIRedactionMinimal}, + {Type: PIITypeDateOfBirth, Pattern: dobPattern, Replacement: "[DOB_REDACTED]", Level: rbac.PIIRedactionModerate}, + {Type: PIITypeSalary, Pattern: salaryPattern, Replacement: "[SALARY_REDACTED]", Level: rbac.PIIRedactionStrict}, + {Type: PIITypeAddress, Pattern: addressPattern, Replacement: "[ADDRESS_REDACTED]", Level: rbac.PIIRedactionModerate}, + } +} + +// FindPII finds all PII in the text +func (d *PIIDetector) FindPII(text string) []PIIFinding { + if text == "" { + return nil + } + + var findings []PIIFinding + for _, pattern := range d.patterns { + matches := pattern.Pattern.FindAllStringIndex(text, -1) + for _, match := range matches { + findings = append(findings, PIIFinding{ + Type: string(pattern.Type), + Match: text[match[0]:match[1]], + Start: match[0], + End: match[1], + }) + } + } + return findings +} + +// ContainsPII checks if the text contains any PII +func (d *PIIDetector) ContainsPII(text string) bool { + if text == "" { + return false + } + + for _, pattern := range d.patterns { + if pattern.Pattern.MatchString(text) { + return true + } + } + return false +} + +// Redact removes PII from the given text based on redaction level +func (d *PIIDetector) Redact(text string, level rbac.PIIRedactionLevel) string { + if text == "" || level == rbac.PIIRedactionNone { + return text + } + + result := text + for _, pattern := range d.patterns { + if d.shouldRedactAtLevel(pattern.Level, level) { + result = pattern.Pattern.ReplaceAllString(result, pattern.Replacement) + } + } + return result +} + +// shouldRedactAtLevel determines if a pattern should be applied at the given level +func (d *PIIDetector) shouldRedactAtLevel(patternLevel, requestedLevel rbac.PIIRedactionLevel) bool { + levelOrder := map[rbac.PIIRedactionLevel]int{ + rbac.PIIRedactionNone: 0, + rbac.PIIRedactionMinimal: 1, + rbac.PIIRedactionModerate: 2, + rbac.PIIRedactionStrict: 3, + } + + return levelOrder[requestedLevel] >= levelOrder[patternLevel] +} + +// RedactMap redacts PII from all string values in a map +func (d *PIIDetector) RedactMap(data map[string]any, level rbac.PIIRedactionLevel) map[string]any { + result := make(map[string]any) + for key, value := range data { + switch v := value.(type) { + case string: + result[key] = d.Redact(v, level) + case map[string]any: + result[key] = d.RedactMap(v, level) + case []any: + result[key] = d.redactSlice(v, level) + default: + result[key] = v + } + } + return result +} + +func (d *PIIDetector) redactSlice(data []any, level rbac.PIIRedactionLevel) []any { + result := make([]any, len(data)) + for i, value := range data { + switch v := value.(type) { + case string: + result[i] = d.Redact(v, level) + case map[string]any: + result[i] = d.RedactMap(v, level) + case []any: + result[i] = d.redactSlice(v, level) + default: + result[i] = v + } + } + return result +} + +// SafeLogString creates a safe-to-log version of a string +func (d *PIIDetector) SafeLogString(text string) string { + return d.Redact(text, rbac.PIIRedactionStrict) +} + +// DetectDataCategories attempts to detect data categories in text +func (d *PIIDetector) DetectDataCategories(text string) []string { + if text == "" { + return nil + } + + var categories []string + textLower := strings.ToLower(text) + + // Salary detection + if salaryPattern.MatchString(text) || strings.Contains(textLower, "gehalt") || strings.Contains(textLower, "salary") { + categories = append(categories, "salary") + } + + // Health detection + healthKeywords := []string{"diagnose", "krankheit", "medikament", "therapie", "arzt", "krankenhaus", + "health", "medical", "diagnosis", "treatment", "hospital"} + for _, kw := range healthKeywords { + if strings.Contains(textLower, kw) { + categories = append(categories, "health") + break + } + } + + // Financial detection + if ibanPattern.MatchString(text) || creditCardPattern.MatchString(text) || + strings.Contains(textLower, "konto") || strings.Contains(textLower, "bank") { + categories = append(categories, "financial") + } + + // Personal detection (names, addresses, DOB) + if namePattern.MatchString(text) || addressPattern.MatchString(text) || dobPattern.MatchString(text) { + categories = append(categories, "personal") + } + + // HR detection + hrKeywords := []string{"mitarbeiter", "employee", "kündigung", "termination", "beförderung", "promotion", + "leistungsbeurteilung", "performance review", "personalakte"} + for _, kw := range hrKeywords { + if strings.Contains(textLower, kw) { + categories = append(categories, "hr") + break + } + } + + return categories +} + +// Global default detector +var defaultDetector = NewPIIDetectorWithPatterns(AllPIIPatterns()) + +// RedactPII is a convenience function using the default detector +func RedactPII(text string, level rbac.PIIRedactionLevel) string { + return defaultDetector.Redact(text, level) +} + +// ContainsPIIDefault checks if text contains PII using default patterns +func ContainsPIIDefault(text string) bool { + return defaultDetector.ContainsPII(text) +} + +// FindPIIDefault finds PII using default patterns +func FindPIIDefault(text string) []PIIFinding { + return defaultDetector.FindPII(text) +} + +// DetectDataCategoriesDefault detects data categories using default detector +func DetectDataCategoriesDefault(text string) []string { + return defaultDetector.DetectDataCategories(text) +} diff --git a/ai-compliance-sdk/internal/llm/provider.go b/ai-compliance-sdk/internal/llm/provider.go new file mode 100644 index 0000000..8d4488b --- /dev/null +++ b/ai-compliance-sdk/internal/llm/provider.go @@ -0,0 +1,239 @@ +package llm + +import ( + "context" + "errors" + "time" +) + +// Provider names +const ( + ProviderOllama = "ollama" + ProviderAnthropic = "anthropic" + ProviderOpenAI = "openai" +) + +var ( + ErrProviderUnavailable = errors.New("LLM provider unavailable") + ErrModelNotFound = errors.New("model not found") + ErrContextTooLong = errors.New("context too long for model") + ErrRateLimited = errors.New("rate limited") + ErrInvalidRequest = errors.New("invalid request") +) + +// Provider defines the interface for LLM providers +type Provider interface { + // Name returns the provider name + Name() string + + // IsAvailable checks if the provider is currently available + IsAvailable(ctx context.Context) bool + + // ListModels returns available models + ListModels(ctx context.Context) ([]Model, error) + + // Complete performs text completion + Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) + + // Chat performs chat completion + Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) + + // Embed creates embeddings for text + Embed(ctx context.Context, req *EmbedRequest) (*EmbedResponse, error) +} + +// Model represents an available LLM model +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Description string `json:"description,omitempty"` + ContextSize int `json:"context_size"` + Parameters map[string]any `json:"parameters,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` // "chat", "completion", "embedding" +} + +// Message represents a chat message +type Message struct { + Role string `json:"role"` // "system", "user", "assistant" + Content string `json:"content"` +} + +// CompletionRequest represents a text completion request +type CompletionRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + Stop []string `json:"stop,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// CompletionResponse represents a text completion response +type CompletionResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Provider string `json:"provider"` + Text string `json:"text"` + FinishReason string `json:"finish_reason,omitempty"` + Usage UsageStats `json:"usage"` + Duration time.Duration `json:"duration"` +} + +// ChatRequest represents a chat completion request +type ChatRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + Stop []string `json:"stop,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// ChatResponse represents a chat completion response +type ChatResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Provider string `json:"provider"` + Message Message `json:"message"` + FinishReason string `json:"finish_reason,omitempty"` + Usage UsageStats `json:"usage"` + Duration time.Duration `json:"duration"` +} + +// EmbedRequest represents an embedding request +type EmbedRequest struct { + Model string `json:"model"` + Input []string `json:"input"` +} + +// EmbedResponse represents an embedding response +type EmbedResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Provider string `json:"provider"` + Embeddings [][]float64 `json:"embeddings"` + Usage UsageStats `json:"usage"` + Duration time.Duration `json:"duration"` +} + +// UsageStats represents token usage statistics +type UsageStats struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// ProviderRegistry manages multiple LLM providers +type ProviderRegistry struct { + providers map[string]Provider + primaryProvider string + fallbackProvider string +} + +// NewProviderRegistry creates a new provider registry +func NewProviderRegistry(primary, fallback string) *ProviderRegistry { + return &ProviderRegistry{ + providers: make(map[string]Provider), + primaryProvider: primary, + fallbackProvider: fallback, + } +} + +// Register registers a provider +func (r *ProviderRegistry) Register(provider Provider) { + r.providers[provider.Name()] = provider +} + +// GetProvider returns a provider by name +func (r *ProviderRegistry) GetProvider(name string) (Provider, bool) { + p, ok := r.providers[name] + return p, ok +} + +// GetPrimary returns the primary provider +func (r *ProviderRegistry) GetPrimary() (Provider, bool) { + return r.GetProvider(r.primaryProvider) +} + +// GetFallback returns the fallback provider +func (r *ProviderRegistry) GetFallback() (Provider, bool) { + return r.GetProvider(r.fallbackProvider) +} + +// GetAvailable returns the first available provider (primary, then fallback) +func (r *ProviderRegistry) GetAvailable(ctx context.Context) (Provider, error) { + if p, ok := r.GetPrimary(); ok && p.IsAvailable(ctx) { + return p, nil + } + + if p, ok := r.GetFallback(); ok && p.IsAvailable(ctx) { + return p, nil + } + + return nil, ErrProviderUnavailable +} + +// ListAllModels returns models from all available providers +func (r *ProviderRegistry) ListAllModels(ctx context.Context) ([]Model, error) { + var allModels []Model + + for _, p := range r.providers { + if p.IsAvailable(ctx) { + models, err := p.ListModels(ctx) + if err == nil { + allModels = append(allModels, models...) + } + } + } + + return allModels, nil +} + +// Complete performs completion with automatic fallback +func (r *ProviderRegistry) Complete(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { + provider, err := r.GetAvailable(ctx) + if err != nil { + return nil, err + } + + resp, err := provider.Complete(ctx, req) + if err != nil && r.fallbackProvider != "" { + // Try fallback + if fallback, ok := r.GetFallback(); ok && fallback.Name() != provider.Name() { + return fallback.Complete(ctx, req) + } + } + + return resp, err +} + +// Chat performs chat completion with automatic fallback +func (r *ProviderRegistry) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { + provider, err := r.GetAvailable(ctx) + if err != nil { + return nil, err + } + + resp, err := provider.Chat(ctx, req) + if err != nil && r.fallbackProvider != "" { + // Try fallback + if fallback, ok := r.GetFallback(); ok && fallback.Name() != provider.Name() { + return fallback.Chat(ctx, req) + } + } + + return resp, err +} + +// Embed creates embeddings with automatic fallback +func (r *ProviderRegistry) Embed(ctx context.Context, req *EmbedRequest) (*EmbedResponse, error) { + provider, err := r.GetAvailable(ctx) + if err != nil { + return nil, err + } + + return provider.Embed(ctx, req) +} diff --git a/ai-compliance-sdk/internal/portfolio/models.go b/ai-compliance-sdk/internal/portfolio/models.go new file mode 100644 index 0000000..ec34e19 --- /dev/null +++ b/ai-compliance-sdk/internal/portfolio/models.go @@ -0,0 +1,278 @@ +package portfolio + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Constants / Enums +// ============================================================================ + +// PortfolioStatus represents the status of a portfolio +type PortfolioStatus string + +const ( + PortfolioStatusDraft PortfolioStatus = "DRAFT" + PortfolioStatusActive PortfolioStatus = "ACTIVE" + PortfolioStatusReview PortfolioStatus = "REVIEW" + PortfolioStatusApproved PortfolioStatus = "APPROVED" + PortfolioStatusArchived PortfolioStatus = "ARCHIVED" +) + +// ItemType represents the type of item in a portfolio +type ItemType string + +const ( + ItemTypeAssessment ItemType = "ASSESSMENT" + ItemTypeRoadmap ItemType = "ROADMAP" + ItemTypeWorkshop ItemType = "WORKSHOP" + ItemTypeDocument ItemType = "DOCUMENT" +) + +// MergeStrategy defines how to merge portfolios +type MergeStrategy string + +const ( + MergeStrategyUnion MergeStrategy = "UNION" // Combine all items (default) + MergeStrategyIntersect MergeStrategy = "INTERSECT" // Only overlapping items + MergeStrategyReplace MergeStrategy = "REPLACE" // Replace target with source +) + +// ============================================================================ +// Main Entities +// ============================================================================ + +// Portfolio represents a collection of AI use case assessments and related artifacts +type Portfolio struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + + // Info + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status PortfolioStatus `json:"status"` + + // Organization + Department string `json:"department,omitempty"` + BusinessUnit string `json:"business_unit,omitempty"` + Owner string `json:"owner,omitempty"` + OwnerEmail string `json:"owner_email,omitempty"` + + // Aggregated metrics (computed) + TotalAssessments int `json:"total_assessments"` + TotalRoadmaps int `json:"total_roadmaps"` + TotalWorkshops int `json:"total_workshops"` + AvgRiskScore float64 `json:"avg_risk_score"` + HighRiskCount int `json:"high_risk_count"` + ConditionalCount int `json:"conditional_count"` + ApprovedCount int `json:"approved_count"` + ComplianceScore float64 `json:"compliance_score"` // 0-100 + + // Settings + Settings PortfolioSettings `json:"settings"` + + // Audit + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` + ApprovedAt *time.Time `json:"approved_at,omitempty"` + ApprovedBy *uuid.UUID `json:"approved_by,omitempty"` +} + +// PortfolioSettings contains configuration options +type PortfolioSettings struct { + AutoUpdateMetrics bool `json:"auto_update_metrics"` // Recalculate on changes + RequireApproval bool `json:"require_approval"` // Require approval before active + NotifyOnHighRisk bool `json:"notify_on_high_risk"` // Alert on high risk items + AllowExternalShare bool `json:"allow_external_share"` // Share with external users +} + +// PortfolioItem represents an item linked to a portfolio +type PortfolioItem struct { + ID uuid.UUID `json:"id"` + PortfolioID uuid.UUID `json:"portfolio_id"` + ItemType ItemType `json:"item_type"` + ItemID uuid.UUID `json:"item_id"` // Reference to the actual item + + // Cached info from the linked item + Title string `json:"title"` + Status string `json:"status,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + RiskScore int `json:"risk_score,omitempty"` + Feasibility string `json:"feasibility,omitempty"` + + // Ordering and categorization + SortOrder int `json:"sort_order"` + Tags []string `json:"tags,omitempty"` + Notes string `json:"notes,omitempty"` + + // Audit + AddedAt time.Time `json:"added_at"` + AddedBy uuid.UUID `json:"added_by"` +} + +// ============================================================================ +// Merge Operations +// ============================================================================ + +// MergeRequest represents a request to merge portfolios +type MergeRequest struct { + SourcePortfolioID uuid.UUID `json:"source_portfolio_id"` + TargetPortfolioID uuid.UUID `json:"target_portfolio_id"` + Strategy MergeStrategy `json:"strategy"` + DeleteSource bool `json:"delete_source"` // Delete source after merge + IncludeRoadmaps bool `json:"include_roadmaps"` + IncludeWorkshops bool `json:"include_workshops"` +} + +// MergeResult represents the result of a merge operation +type MergeResult struct { + TargetPortfolio *Portfolio `json:"target_portfolio"` + ItemsAdded int `json:"items_added"` + ItemsSkipped int `json:"items_skipped"` // Duplicates or excluded + ItemsUpdated int `json:"items_updated"` + SourceDeleted bool `json:"source_deleted"` + ConflictsResolved []MergeConflict `json:"conflicts_resolved,omitempty"` +} + +// MergeConflict describes a conflict during merge +type MergeConflict struct { + ItemID uuid.UUID `json:"item_id"` + ItemType ItemType `json:"item_type"` + Reason string `json:"reason"` + Resolution string `json:"resolution"` // "kept_source", "kept_target", "merged" +} + +// ============================================================================ +// Aggregated Views +// ============================================================================ + +// PortfolioSummary contains aggregated portfolio information +type PortfolioSummary struct { + Portfolio *Portfolio `json:"portfolio"` + Items []PortfolioItem `json:"items"` + RiskDistribution RiskDistribution `json:"risk_distribution"` + FeasibilityDist FeasibilityDist `json:"feasibility_distribution"` + RecentActivity []ActivityEntry `json:"recent_activity,omitempty"` +} + +// RiskDistribution shows the distribution of risk levels +type RiskDistribution struct { + Minimal int `json:"minimal"` + Low int `json:"low"` + Medium int `json:"medium"` + High int `json:"high"` + Unacceptable int `json:"unacceptable"` +} + +// FeasibilityDist shows the distribution of feasibility verdicts +type FeasibilityDist struct { + Yes int `json:"yes"` + Conditional int `json:"conditional"` + No int `json:"no"` +} + +// ActivityEntry represents recent activity on a portfolio +type ActivityEntry struct { + Timestamp time.Time `json:"timestamp"` + Action string `json:"action"` // "added", "removed", "updated", "merged" + ItemType ItemType `json:"item_type"` + ItemID uuid.UUID `json:"item_id"` + ItemTitle string `json:"item_title"` + UserID uuid.UUID `json:"user_id"` +} + +// PortfolioStats contains statistical information +type PortfolioStats struct { + TotalItems int `json:"total_items"` + ItemsByType map[ItemType]int `json:"items_by_type"` + RiskDistribution RiskDistribution `json:"risk_distribution"` + FeasibilityDist FeasibilityDist `json:"feasibility_distribution"` + AvgRiskScore float64 `json:"avg_risk_score"` + ComplianceScore float64 `json:"compliance_score"` + DSFARequired int `json:"dsfa_required"` + ControlsRequired int `json:"controls_required"` + LastUpdated time.Time `json:"last_updated"` +} + +// ============================================================================ +// API Request/Response Types +// ============================================================================ + +// CreatePortfolioRequest is the API request for creating a portfolio +type CreatePortfolioRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Department string `json:"department,omitempty"` + BusinessUnit string `json:"business_unit,omitempty"` + Owner string `json:"owner,omitempty"` + OwnerEmail string `json:"owner_email,omitempty"` + Settings PortfolioSettings `json:"settings,omitempty"` +} + +// UpdatePortfolioRequest is the API request for updating a portfolio +type UpdatePortfolioRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Status PortfolioStatus `json:"status,omitempty"` + Department string `json:"department,omitempty"` + BusinessUnit string `json:"business_unit,omitempty"` + Owner string `json:"owner,omitempty"` + OwnerEmail string `json:"owner_email,omitempty"` + Settings *PortfolioSettings `json:"settings,omitempty"` +} + +// AddItemRequest is the API request for adding an item to a portfolio +type AddItemRequest struct { + ItemType ItemType `json:"item_type"` + ItemID uuid.UUID `json:"item_id"` + Tags []string `json:"tags,omitempty"` + Notes string `json:"notes,omitempty"` +} + +// BulkAddItemsRequest is the API request for adding multiple items +type BulkAddItemsRequest struct { + Items []AddItemRequest `json:"items"` +} + +// BulkAddItemsResponse is the API response for bulk adding items +type BulkAddItemsResponse struct { + Added int `json:"added"` + Skipped int `json:"skipped"` + Errors []string `json:"errors,omitempty"` +} + +// PortfolioFilters defines filters for listing portfolios +type PortfolioFilters struct { + Status PortfolioStatus + Department string + BusinessUnit string + Owner string + MinRiskScore *float64 + MaxRiskScore *float64 + Limit int + Offset int +} + +// ComparePortfoliosRequest is the API request for comparing portfolios +type ComparePortfoliosRequest struct { + PortfolioIDs []uuid.UUID `json:"portfolio_ids"` // 2-5 portfolios to compare +} + +// ComparePortfoliosResponse is the API response for portfolio comparison +type ComparePortfoliosResponse struct { + Portfolios []Portfolio `json:"portfolios"` + Comparison PortfolioComparison `json:"comparison"` +} + +// PortfolioComparison contains comparative metrics +type PortfolioComparison struct { + RiskScores map[string]float64 `json:"risk_scores"` // portfolio_id -> score + ComplianceScores map[string]float64 `json:"compliance_scores"` + ItemCounts map[string]int `json:"item_counts"` + CommonItems []uuid.UUID `json:"common_items"` // Items in multiple portfolios + UniqueItems map[string][]uuid.UUID `json:"unique_items"` // portfolio_id -> item_ids +} diff --git a/ai-compliance-sdk/internal/portfolio/store.go b/ai-compliance-sdk/internal/portfolio/store.go new file mode 100644 index 0000000..40f306b --- /dev/null +++ b/ai-compliance-sdk/internal/portfolio/store.go @@ -0,0 +1,818 @@ +package portfolio + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store handles portfolio data persistence +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new portfolio store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// ============================================================================ +// Portfolio CRUD Operations +// ============================================================================ + +// CreatePortfolio creates a new portfolio +func (s *Store) CreatePortfolio(ctx context.Context, p *Portfolio) error { + p.ID = uuid.New() + p.CreatedAt = time.Now().UTC() + p.UpdatedAt = p.CreatedAt + if p.Status == "" { + p.Status = PortfolioStatusDraft + } + + settings, _ := json.Marshal(p.Settings) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO portfolios ( + id, tenant_id, namespace_id, + name, description, status, + department, business_unit, owner, owner_email, + total_assessments, total_roadmaps, total_workshops, + avg_risk_score, high_risk_count, conditional_count, approved_count, + compliance_score, settings, + created_at, updated_at, created_by, approved_at, approved_by + ) VALUES ( + $1, $2, $3, + $4, $5, $6, + $7, $8, $9, $10, + $11, $12, $13, + $14, $15, $16, $17, + $18, $19, + $20, $21, $22, $23, $24 + ) + `, + p.ID, p.TenantID, p.NamespaceID, + p.Name, p.Description, string(p.Status), + p.Department, p.BusinessUnit, p.Owner, p.OwnerEmail, + p.TotalAssessments, p.TotalRoadmaps, p.TotalWorkshops, + p.AvgRiskScore, p.HighRiskCount, p.ConditionalCount, p.ApprovedCount, + p.ComplianceScore, settings, + p.CreatedAt, p.UpdatedAt, p.CreatedBy, p.ApprovedAt, p.ApprovedBy, + ) + + return err +} + +// GetPortfolio retrieves a portfolio by ID +func (s *Store) GetPortfolio(ctx context.Context, id uuid.UUID) (*Portfolio, error) { + var p Portfolio + var status string + var settings []byte + + err := s.pool.QueryRow(ctx, ` + SELECT + id, tenant_id, namespace_id, + name, description, status, + department, business_unit, owner, owner_email, + total_assessments, total_roadmaps, total_workshops, + avg_risk_score, high_risk_count, conditional_count, approved_count, + compliance_score, settings, + created_at, updated_at, created_by, approved_at, approved_by + FROM portfolios WHERE id = $1 + `, id).Scan( + &p.ID, &p.TenantID, &p.NamespaceID, + &p.Name, &p.Description, &status, + &p.Department, &p.BusinessUnit, &p.Owner, &p.OwnerEmail, + &p.TotalAssessments, &p.TotalRoadmaps, &p.TotalWorkshops, + &p.AvgRiskScore, &p.HighRiskCount, &p.ConditionalCount, &p.ApprovedCount, + &p.ComplianceScore, &settings, + &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.ApprovedAt, &p.ApprovedBy, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + p.Status = PortfolioStatus(status) + json.Unmarshal(settings, &p.Settings) + + return &p, nil +} + +// ListPortfolios lists portfolios for a tenant with optional filters +func (s *Store) ListPortfolios(ctx context.Context, tenantID uuid.UUID, filters *PortfolioFilters) ([]Portfolio, error) { + query := ` + SELECT + id, tenant_id, namespace_id, + name, description, status, + department, business_unit, owner, owner_email, + total_assessments, total_roadmaps, total_workshops, + avg_risk_score, high_risk_count, conditional_count, approved_count, + compliance_score, settings, + created_at, updated_at, created_by, approved_at, approved_by + FROM portfolios 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.Department != "" { + query += fmt.Sprintf(" AND department = $%d", argIdx) + args = append(args, filters.Department) + argIdx++ + } + if filters.BusinessUnit != "" { + query += fmt.Sprintf(" AND business_unit = $%d", argIdx) + args = append(args, filters.BusinessUnit) + argIdx++ + } + if filters.Owner != "" { + query += fmt.Sprintf(" AND owner ILIKE $%d", argIdx) + args = append(args, "%"+filters.Owner+"%") + argIdx++ + } + if filters.MinRiskScore != nil { + query += fmt.Sprintf(" AND avg_risk_score >= $%d", argIdx) + args = append(args, *filters.MinRiskScore) + argIdx++ + } + if filters.MaxRiskScore != nil { + query += fmt.Sprintf(" AND avg_risk_score <= $%d", argIdx) + args = append(args, *filters.MaxRiskScore) + argIdx++ + } + } + + query += " ORDER BY updated_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) + } + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var portfolios []Portfolio + for rows.Next() { + var p Portfolio + var status string + var settings []byte + + err := rows.Scan( + &p.ID, &p.TenantID, &p.NamespaceID, + &p.Name, &p.Description, &status, + &p.Department, &p.BusinessUnit, &p.Owner, &p.OwnerEmail, + &p.TotalAssessments, &p.TotalRoadmaps, &p.TotalWorkshops, + &p.AvgRiskScore, &p.HighRiskCount, &p.ConditionalCount, &p.ApprovedCount, + &p.ComplianceScore, &settings, + &p.CreatedAt, &p.UpdatedAt, &p.CreatedBy, &p.ApprovedAt, &p.ApprovedBy, + ) + if err != nil { + return nil, err + } + + p.Status = PortfolioStatus(status) + json.Unmarshal(settings, &p.Settings) + + portfolios = append(portfolios, p) + } + + return portfolios, nil +} + +// UpdatePortfolio updates a portfolio +func (s *Store) UpdatePortfolio(ctx context.Context, p *Portfolio) error { + p.UpdatedAt = time.Now().UTC() + + settings, _ := json.Marshal(p.Settings) + + _, err := s.pool.Exec(ctx, ` + UPDATE portfolios SET + name = $2, description = $3, status = $4, + department = $5, business_unit = $6, owner = $7, owner_email = $8, + settings = $9, + updated_at = $10, approved_at = $11, approved_by = $12 + WHERE id = $1 + `, + p.ID, p.Name, p.Description, string(p.Status), + p.Department, p.BusinessUnit, p.Owner, p.OwnerEmail, + settings, + p.UpdatedAt, p.ApprovedAt, p.ApprovedBy, + ) + + return err +} + +// DeletePortfolio deletes a portfolio and its items +func (s *Store) DeletePortfolio(ctx context.Context, id uuid.UUID) error { + // Delete items first + _, err := s.pool.Exec(ctx, "DELETE FROM portfolio_items WHERE portfolio_id = $1", id) + if err != nil { + return err + } + // Delete portfolio + _, err = s.pool.Exec(ctx, "DELETE FROM portfolios WHERE id = $1", id) + return err +} + +// ============================================================================ +// Portfolio Item Operations +// ============================================================================ + +// AddItem adds an item to a portfolio +func (s *Store) AddItem(ctx context.Context, item *PortfolioItem) error { + item.ID = uuid.New() + item.AddedAt = time.Now().UTC() + + tags, _ := json.Marshal(item.Tags) + + // Check if item already exists in portfolio + var exists bool + s.pool.QueryRow(ctx, + "SELECT EXISTS(SELECT 1 FROM portfolio_items WHERE portfolio_id = $1 AND item_id = $2)", + item.PortfolioID, item.ItemID).Scan(&exists) + + if exists { + return fmt.Errorf("item already exists in portfolio") + } + + // Get max sort order + var maxSort int + s.pool.QueryRow(ctx, + "SELECT COALESCE(MAX(sort_order), 0) FROM portfolio_items WHERE portfolio_id = $1", + item.PortfolioID).Scan(&maxSort) + item.SortOrder = maxSort + 1 + + _, err := s.pool.Exec(ctx, ` + INSERT INTO portfolio_items ( + id, portfolio_id, item_type, item_id, + title, status, risk_level, risk_score, feasibility, + sort_order, tags, notes, + added_at, added_by + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8, $9, + $10, $11, $12, + $13, $14 + ) + `, + item.ID, item.PortfolioID, string(item.ItemType), item.ItemID, + item.Title, item.Status, item.RiskLevel, item.RiskScore, item.Feasibility, + item.SortOrder, tags, item.Notes, + item.AddedAt, item.AddedBy, + ) + + if err != nil { + return err + } + + // Update portfolio metrics + return s.RecalculateMetrics(ctx, item.PortfolioID) +} + +// GetItem retrieves a portfolio item by ID +func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*PortfolioItem, error) { + var item PortfolioItem + var itemType string + var tags []byte + + err := s.pool.QueryRow(ctx, ` + SELECT + id, portfolio_id, item_type, item_id, + title, status, risk_level, risk_score, feasibility, + sort_order, tags, notes, + added_at, added_by + FROM portfolio_items WHERE id = $1 + `, id).Scan( + &item.ID, &item.PortfolioID, &itemType, &item.ItemID, + &item.Title, &item.Status, &item.RiskLevel, &item.RiskScore, &item.Feasibility, + &item.SortOrder, &tags, &item.Notes, + &item.AddedAt, &item.AddedBy, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + item.ItemType = ItemType(itemType) + json.Unmarshal(tags, &item.Tags) + + return &item, nil +} + +// ListItems lists items in a portfolio +func (s *Store) ListItems(ctx context.Context, portfolioID uuid.UUID, itemType *ItemType) ([]PortfolioItem, error) { + query := ` + SELECT + id, portfolio_id, item_type, item_id, + title, status, risk_level, risk_score, feasibility, + sort_order, tags, notes, + added_at, added_by + FROM portfolio_items WHERE portfolio_id = $1` + + args := []interface{}{portfolioID} + if itemType != nil { + query += " AND item_type = $2" + args = append(args, string(*itemType)) + } + + query += " ORDER BY sort_order ASC" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []PortfolioItem + for rows.Next() { + var item PortfolioItem + var iType string + var tags []byte + + err := rows.Scan( + &item.ID, &item.PortfolioID, &iType, &item.ItemID, + &item.Title, &item.Status, &item.RiskLevel, &item.RiskScore, &item.Feasibility, + &item.SortOrder, &tags, &item.Notes, + &item.AddedAt, &item.AddedBy, + ) + if err != nil { + return nil, err + } + + item.ItemType = ItemType(iType) + json.Unmarshal(tags, &item.Tags) + + items = append(items, item) + } + + return items, nil +} + +// RemoveItem removes an item from a portfolio +func (s *Store) RemoveItem(ctx context.Context, id uuid.UUID) error { + // Get portfolio ID first + var portfolioID uuid.UUID + err := s.pool.QueryRow(ctx, + "SELECT portfolio_id FROM portfolio_items WHERE id = $1", id).Scan(&portfolioID) + if err != nil { + return err + } + + _, err = s.pool.Exec(ctx, "DELETE FROM portfolio_items WHERE id = $1", id) + if err != nil { + return err + } + + // Recalculate metrics + return s.RecalculateMetrics(ctx, portfolioID) +} + +// UpdateItemOrder updates the sort order of items +func (s *Store) UpdateItemOrder(ctx context.Context, portfolioID uuid.UUID, itemIDs []uuid.UUID) error { + for i, id := range itemIDs { + _, err := s.pool.Exec(ctx, + "UPDATE portfolio_items SET sort_order = $2 WHERE id = $1 AND portfolio_id = $3", + id, i+1, portfolioID) + if err != nil { + return err + } + } + return nil +} + +// ============================================================================ +// Merge Operations +// ============================================================================ + +// MergePortfolios merges source portfolio into target +func (s *Store) MergePortfolios(ctx context.Context, req *MergeRequest, userID uuid.UUID) (*MergeResult, error) { + result := &MergeResult{ + ConflictsResolved: []MergeConflict{}, + } + + // Get source items + sourceItems, err := s.ListItems(ctx, req.SourcePortfolioID, nil) + if err != nil { + return nil, fmt.Errorf("failed to get source items: %w", err) + } + + // Get target items for conflict detection + targetItems, err := s.ListItems(ctx, req.TargetPortfolioID, nil) + if err != nil { + return nil, fmt.Errorf("failed to get target items: %w", err) + } + + // Build map of existing items in target + targetItemMap := make(map[uuid.UUID]bool) + for _, item := range targetItems { + targetItemMap[item.ItemID] = true + } + + // Filter items based on strategy and options + for _, item := range sourceItems { + // Skip if not including this type + if item.ItemType == ItemTypeRoadmap && !req.IncludeRoadmaps { + result.ItemsSkipped++ + continue + } + if item.ItemType == ItemTypeWorkshop && !req.IncludeWorkshops { + result.ItemsSkipped++ + continue + } + + // Check for conflicts + if targetItemMap[item.ItemID] { + switch req.Strategy { + case MergeStrategyUnion: + // Skip duplicates + result.ItemsSkipped++ + result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{ + ItemID: item.ItemID, + ItemType: item.ItemType, + Reason: "duplicate", + Resolution: "kept_target", + }) + case MergeStrategyReplace: + // Update existing item in target + // For now, just skip (could implement update logic) + result.ItemsUpdated++ + result.ConflictsResolved = append(result.ConflictsResolved, MergeConflict{ + ItemID: item.ItemID, + ItemType: item.ItemType, + Reason: "duplicate", + Resolution: "merged", + }) + case MergeStrategyIntersect: + // Keep only items that exist in both + // Skip items not in target + result.ItemsSkipped++ + } + continue + } + + // Add item to target + newItem := &PortfolioItem{ + PortfolioID: req.TargetPortfolioID, + ItemType: item.ItemType, + ItemID: item.ItemID, + Title: item.Title, + Status: item.Status, + RiskLevel: item.RiskLevel, + RiskScore: item.RiskScore, + Feasibility: item.Feasibility, + Tags: item.Tags, + Notes: item.Notes, + AddedBy: userID, + } + + if err := s.AddItem(ctx, newItem); err != nil { + // Skip on error but continue + result.ItemsSkipped++ + continue + } + result.ItemsAdded++ + } + + // Delete source if requested + if req.DeleteSource { + if err := s.DeletePortfolio(ctx, req.SourcePortfolioID); err != nil { + return nil, fmt.Errorf("failed to delete source portfolio: %w", err) + } + result.SourceDeleted = true + } + + // Get updated target portfolio + result.TargetPortfolio, _ = s.GetPortfolio(ctx, req.TargetPortfolioID) + + return result, nil +} + +// ============================================================================ +// Metrics and Statistics +// ============================================================================ + +// RecalculateMetrics recalculates aggregated metrics for a portfolio +func (s *Store) RecalculateMetrics(ctx context.Context, portfolioID uuid.UUID) error { + // Count by type + var totalAssessments, totalRoadmaps, totalWorkshops int + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT'", + portfolioID).Scan(&totalAssessments) + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'ROADMAP'", + portfolioID).Scan(&totalRoadmaps) + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1 AND item_type = 'WORKSHOP'", + portfolioID).Scan(&totalWorkshops) + + // Calculate risk metrics from assessments + var avgRiskScore float64 + var highRiskCount, conditionalCount, approvedCount int + + s.pool.QueryRow(ctx, ` + SELECT COALESCE(AVG(risk_score), 0) + FROM portfolio_items + WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' + `, portfolioID).Scan(&avgRiskScore) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM portfolio_items + WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND risk_level IN ('HIGH', 'UNACCEPTABLE') + `, portfolioID).Scan(&highRiskCount) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM portfolio_items + WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND feasibility = 'CONDITIONAL' + `, portfolioID).Scan(&conditionalCount) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM portfolio_items + WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' AND feasibility = 'YES' + `, portfolioID).Scan(&approvedCount) + + // Calculate compliance score (simplified: % of approved items) + var complianceScore float64 + if totalAssessments > 0 { + complianceScore = (float64(approvedCount) / float64(totalAssessments)) * 100 + } + + // Update portfolio + _, err := s.pool.Exec(ctx, ` + UPDATE portfolios SET + total_assessments = $2, + total_roadmaps = $3, + total_workshops = $4, + avg_risk_score = $5, + high_risk_count = $6, + conditional_count = $7, + approved_count = $8, + compliance_score = $9, + updated_at = NOW() + WHERE id = $1 + `, + portfolioID, + totalAssessments, totalRoadmaps, totalWorkshops, + avgRiskScore, highRiskCount, conditionalCount, approvedCount, + complianceScore, + ) + + return err +} + +// GetPortfolioStats returns detailed statistics for a portfolio +func (s *Store) GetPortfolioStats(ctx context.Context, portfolioID uuid.UUID) (*PortfolioStats, error) { + stats := &PortfolioStats{ + ItemsByType: make(map[ItemType]int), + } + + // Total items + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM portfolio_items WHERE portfolio_id = $1", + portfolioID).Scan(&stats.TotalItems) + + // Items by type + rows, _ := s.pool.Query(ctx, ` + SELECT item_type, COUNT(*) + FROM portfolio_items + WHERE portfolio_id = $1 + GROUP BY item_type + `, portfolioID) + if rows != nil { + defer rows.Close() + for rows.Next() { + var itemType string + var count int + rows.Scan(&itemType, &count) + stats.ItemsByType[ItemType(itemType)] = count + } + } + + // Risk distribution + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM portfolio_items + WHERE portfolio_id = $1 AND risk_level = 'MINIMAL' + `, portfolioID).Scan(&stats.RiskDistribution.Minimal) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM portfolio_items + WHERE portfolio_id = $1 AND risk_level = 'LOW' + `, portfolioID).Scan(&stats.RiskDistribution.Low) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM portfolio_items + WHERE portfolio_id = $1 AND risk_level = 'MEDIUM' + `, portfolioID).Scan(&stats.RiskDistribution.Medium) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM portfolio_items + WHERE portfolio_id = $1 AND risk_level = 'HIGH' + `, portfolioID).Scan(&stats.RiskDistribution.High) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM portfolio_items + WHERE portfolio_id = $1 AND risk_level = 'UNACCEPTABLE' + `, portfolioID).Scan(&stats.RiskDistribution.Unacceptable) + + // Feasibility distribution + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM portfolio_items + WHERE portfolio_id = $1 AND feasibility = 'YES' + `, portfolioID).Scan(&stats.FeasibilityDist.Yes) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM portfolio_items + WHERE portfolio_id = $1 AND feasibility = 'CONDITIONAL' + `, portfolioID).Scan(&stats.FeasibilityDist.Conditional) + + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM portfolio_items + WHERE portfolio_id = $1 AND feasibility = 'NO' + `, portfolioID).Scan(&stats.FeasibilityDist.No) + + // Average risk score + s.pool.QueryRow(ctx, ` + SELECT COALESCE(AVG(risk_score), 0) + FROM portfolio_items + WHERE portfolio_id = $1 AND item_type = 'ASSESSMENT' + `, portfolioID).Scan(&stats.AvgRiskScore) + + // Compliance score + s.pool.QueryRow(ctx, + "SELECT compliance_score FROM portfolios WHERE id = $1", + portfolioID).Scan(&stats.ComplianceScore) + + // DSFA required count + // This would need to join with ucca_assessments to get dsfa_recommended + // For now, estimate from high risk items + stats.DSFARequired = stats.RiskDistribution.High + stats.RiskDistribution.Unacceptable + + // Controls required (items with CONDITIONAL feasibility) + stats.ControlsRequired = stats.FeasibilityDist.Conditional + + stats.LastUpdated = time.Now().UTC() + + return stats, nil +} + +// GetPortfolioSummary returns a complete portfolio summary +func (s *Store) GetPortfolioSummary(ctx context.Context, portfolioID uuid.UUID) (*PortfolioSummary, error) { + portfolio, err := s.GetPortfolio(ctx, portfolioID) + if err != nil || portfolio == nil { + return nil, err + } + + items, err := s.ListItems(ctx, portfolioID, nil) + if err != nil { + return nil, err + } + + stats, err := s.GetPortfolioStats(ctx, portfolioID) + if err != nil { + return nil, err + } + + return &PortfolioSummary{ + Portfolio: portfolio, + Items: items, + RiskDistribution: stats.RiskDistribution, + FeasibilityDist: stats.FeasibilityDist, + }, nil +} + +// ============================================================================ +// Bulk Operations +// ============================================================================ + +// BulkAddItems adds multiple items to a portfolio +func (s *Store) BulkAddItems(ctx context.Context, portfolioID uuid.UUID, items []PortfolioItem, userID uuid.UUID) (*BulkAddItemsResponse, error) { + result := &BulkAddItemsResponse{ + Errors: []string{}, + } + + for _, item := range items { + item.PortfolioID = portfolioID + item.AddedBy = userID + + // Fetch item info from source table if not provided + if item.Title == "" { + s.populateItemInfo(ctx, &item) + } + + if err := s.AddItem(ctx, &item); err != nil { + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", item.ItemID, err)) + } else { + result.Added++ + } + } + + return result, nil +} + +// populateItemInfo fetches item metadata from the source table +func (s *Store) populateItemInfo(ctx context.Context, item *PortfolioItem) { + switch item.ItemType { + case ItemTypeAssessment: + s.pool.QueryRow(ctx, ` + SELECT title, feasibility, risk_level, risk_score, status + FROM ucca_assessments WHERE id = $1 + `, item.ItemID).Scan(&item.Title, &item.Feasibility, &item.RiskLevel, &item.RiskScore, &item.Status) + + case ItemTypeRoadmap: + s.pool.QueryRow(ctx, ` + SELECT name, status + FROM roadmaps WHERE id = $1 + `, item.ItemID).Scan(&item.Title, &item.Status) + + case ItemTypeWorkshop: + s.pool.QueryRow(ctx, ` + SELECT title, status + FROM workshop_sessions WHERE id = $1 + `, item.ItemID).Scan(&item.Title, &item.Status) + } +} + +// ============================================================================ +// Activity Tracking +// ============================================================================ + +// LogActivity logs an activity entry for a portfolio +func (s *Store) LogActivity(ctx context.Context, portfolioID uuid.UUID, entry *ActivityEntry) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO portfolio_activity ( + id, portfolio_id, timestamp, action, + item_type, item_id, item_title, user_id + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 + ) + `, + uuid.New(), portfolioID, entry.Timestamp, entry.Action, + string(entry.ItemType), entry.ItemID, entry.ItemTitle, entry.UserID, + ) + return err +} + +// GetRecentActivity retrieves recent activity for a portfolio +func (s *Store) GetRecentActivity(ctx context.Context, portfolioID uuid.UUID, limit int) ([]ActivityEntry, error) { + if limit <= 0 { + limit = 20 + } + + rows, err := s.pool.Query(ctx, ` + SELECT timestamp, action, item_type, item_id, item_title, user_id + FROM portfolio_activity + WHERE portfolio_id = $1 + ORDER BY timestamp DESC + LIMIT $2 + `, portfolioID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var activities []ActivityEntry + for rows.Next() { + var entry ActivityEntry + var itemType string + err := rows.Scan( + &entry.Timestamp, &entry.Action, &itemType, + &entry.ItemID, &entry.ItemTitle, &entry.UserID, + ) + if err != nil { + return nil, err + } + entry.ItemType = ItemType(itemType) + activities = append(activities, entry) + } + + return activities, nil +} diff --git a/ai-compliance-sdk/internal/rbac/middleware.go b/ai-compliance-sdk/internal/rbac/middleware.go new file mode 100644 index 0000000..a8b670b --- /dev/null +++ b/ai-compliance-sdk/internal/rbac/middleware.go @@ -0,0 +1,459 @@ +package rbac + +import ( + "context" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Context keys for RBAC data +type contextKey string + +const ( + ContextKeyUserID contextKey = "user_id" + ContextKeyTenantID contextKey = "tenant_id" + ContextKeyNamespaceID contextKey = "namespace_id" + ContextKeyPermissions contextKey = "permissions" + ContextKeyRoles contextKey = "roles" + ContextKeyUserContext contextKey = "user_context" +) + +// Middleware provides RBAC middleware for Gin +type Middleware struct { + service *Service + policyEngine *PolicyEngine +} + +// NewMiddleware creates a new RBAC middleware +func NewMiddleware(service *Service, policyEngine *PolicyEngine) *Middleware { + return &Middleware{ + service: service, + policyEngine: policyEngine, + } +} + +// ExtractUserContext extracts user context from headers/JWT and stores in context +// This middleware should run after authentication +func (m *Middleware) ExtractUserContext() gin.HandlerFunc { + return func(c *gin.Context) { + // Extract user ID from header (set by auth middleware) + userIDStr := c.GetHeader("X-User-ID") + if userIDStr == "" { + c.Next() + return + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + c.Next() + return + } + + // Extract tenant ID (from header or default) + tenantIDStr := c.GetHeader("X-Tenant-ID") + if tenantIDStr == "" { + // Try to get from query param + tenantIDStr = c.Query("tenant_id") + } + if tenantIDStr == "" { + // Use default tenant slug + tenantIDStr = c.GetHeader("X-Tenant-Slug") + if tenantIDStr != "" { + tenant, err := m.service.store.GetTenantBySlug(c.Request.Context(), tenantIDStr) + if err == nil { + tenantIDStr = tenant.ID.String() + } + } + } + + var tenantID uuid.UUID + if tenantIDStr != "" { + tenantID, _ = uuid.Parse(tenantIDStr) + } + + // Extract namespace ID (optional) + var namespaceID *uuid.UUID + namespaceIDStr := c.GetHeader("X-Namespace-ID") + if namespaceIDStr == "" { + namespaceIDStr = c.Query("namespace_id") + } + if namespaceIDStr != "" { + if nsID, err := uuid.Parse(namespaceIDStr); err == nil { + namespaceID = &nsID + } + } + + // Store in context + c.Set(string(ContextKeyUserID), userID) + c.Set(string(ContextKeyTenantID), tenantID) + if namespaceID != nil { + c.Set(string(ContextKeyNamespaceID), *namespaceID) + } + + // Get effective permissions + if tenantID != uuid.Nil { + perms, err := m.service.GetEffectivePermissions(c.Request.Context(), userID, tenantID, namespaceID) + if err == nil { + c.Set(string(ContextKeyPermissions), perms.Permissions) + c.Set(string(ContextKeyRoles), perms.Roles) + } + } + + c.Next() + } +} + +// RequirePermission requires the user to have a specific permission +func (m *Middleware) RequirePermission(permission string) gin.HandlerFunc { + return func(c *gin.Context) { + userID, tenantID, namespaceID := m.extractIDs(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + c.Abort() + return + } + + hasPermission, err := m.service.HasPermission(c.Request.Context(), userID, tenantID, namespaceID, permission) + if err != nil || !hasPermission { + c.JSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Insufficient permissions", + "required": permission, + }) + c.Abort() + return + } + + c.Next() + } +} + +// RequireAnyPermission requires the user to have any of the specified permissions +func (m *Middleware) RequireAnyPermission(permissions ...string) gin.HandlerFunc { + return func(c *gin.Context) { + userID, tenantID, namespaceID := m.extractIDs(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + c.Abort() + return + } + + hasPermission, err := m.service.HasAnyPermission(c.Request.Context(), userID, tenantID, namespaceID, permissions) + if err != nil || !hasPermission { + c.JSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Insufficient permissions", + "required": permissions, + }) + c.Abort() + return + } + + c.Next() + } +} + +// RequireAllPermissions requires the user to have all specified permissions +func (m *Middleware) RequireAllPermissions(permissions ...string) gin.HandlerFunc { + return func(c *gin.Context) { + userID, tenantID, namespaceID := m.extractIDs(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + c.Abort() + return + } + + hasPermission, err := m.service.HasAllPermissions(c.Request.Context(), userID, tenantID, namespaceID, permissions) + if err != nil || !hasPermission { + c.JSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Insufficient permissions - all required", + "required": permissions, + }) + c.Abort() + return + } + + c.Next() + } +} + +// RequireNamespaceAccess requires access to the specified namespace +func (m *Middleware) RequireNamespaceAccess(operation string) gin.HandlerFunc { + return func(c *gin.Context) { + userID, tenantID, namespaceID := m.extractIDs(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + c.Abort() + return + } + + // Get namespace ID from URL param if not in context + if namespaceID == nil { + nsIDStr := c.Param("namespace_id") + if nsIDStr == "" { + nsIDStr = c.Param("namespaceId") + } + if nsIDStr != "" { + if nsID, err := uuid.Parse(nsIDStr); err == nil { + namespaceID = &nsID + } + } + } + + if namespaceID == nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "bad_request", + "message": "Namespace ID required", + }) + c.Abort() + return + } + + result, err := m.policyEngine.EvaluateNamespaceAccess(c.Request.Context(), &NamespaceAccessRequest{ + UserID: userID, + TenantID: tenantID, + NamespaceID: *namespaceID, + Operation: operation, + }) + + if err != nil || !result.Allowed { + reason := "access denied" + if result != nil { + reason = result.Reason + } + c.JSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Namespace access denied", + "reason": reason, + "namespace": namespaceID.String(), + }) + c.Abort() + return + } + + // Store namespace access result in context + c.Set("namespace_access", result) + c.Next() + } +} + +// RequireLLMAccess validates LLM access based on policy +func (m *Middleware) RequireLLMAccess() gin.HandlerFunc { + return func(c *gin.Context) { + userID, tenantID, namespaceID := m.extractIDs(c) + + if userID == uuid.Nil || tenantID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + c.Abort() + return + } + + // Basic LLM permission check + hasPermission, err := m.service.HasAnyPermission(c.Request.Context(), userID, tenantID, namespaceID, []string{ + PermissionLLMAll, + PermissionLLMQuery, + PermissionLLMOwnQuery, + }) + + if err != nil || !hasPermission { + c.JSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "LLM access denied", + }) + c.Abort() + return + } + + c.Next() + } +} + +// RequireRole requires the user to have a specific role +func (m *Middleware) RequireRole(role string) gin.HandlerFunc { + return func(c *gin.Context) { + roles, exists := c.Get(string(ContextKeyRoles)) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + c.Abort() + return + } + + roleSlice, ok := roles.([]string) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "internal_error", + "message": "Invalid role data", + }) + c.Abort() + return + } + + hasRole := false + for _, r := range roleSlice { + if r == role { + hasRole = true + break + } + } + + if !hasRole { + c.JSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Required role missing", + "required": role, + }) + c.Abort() + return + } + + c.Next() + } +} + +// extractIDs extracts user, tenant, and namespace IDs from context +func (m *Middleware) extractIDs(c *gin.Context) (uuid.UUID, uuid.UUID, *uuid.UUID) { + var userID, tenantID uuid.UUID + var namespaceID *uuid.UUID + + if id, exists := c.Get(string(ContextKeyUserID)); exists { + userID = id.(uuid.UUID) + } + if id, exists := c.Get(string(ContextKeyTenantID)); exists { + tenantID = id.(uuid.UUID) + } + if id, exists := c.Get(string(ContextKeyNamespaceID)); exists { + nsID := id.(uuid.UUID) + namespaceID = &nsID + } + + return userID, tenantID, namespaceID +} + +// GetUserID retrieves user ID from Gin context +func GetUserID(c *gin.Context) uuid.UUID { + if id, exists := c.Get(string(ContextKeyUserID)); exists { + return id.(uuid.UUID) + } + return uuid.Nil +} + +// GetTenantID retrieves tenant ID from Gin context +func GetTenantID(c *gin.Context) uuid.UUID { + if id, exists := c.Get(string(ContextKeyTenantID)); exists { + return id.(uuid.UUID) + } + return uuid.Nil +} + +// GetNamespaceID retrieves namespace ID from Gin context +func GetNamespaceID(c *gin.Context) *uuid.UUID { + if id, exists := c.Get(string(ContextKeyNamespaceID)); exists { + nsID := id.(uuid.UUID) + return &nsID + } + return nil +} + +// GetPermissions retrieves permissions from Gin context +func GetPermissions(c *gin.Context) []string { + if perms, exists := c.Get(string(ContextKeyPermissions)); exists { + return perms.([]string) + } + return []string{} +} + +// GetRoles retrieves roles from Gin context +func GetRoles(c *gin.Context) []string { + if roles, exists := c.Get(string(ContextKeyRoles)); exists { + return roles.([]string) + } + return []string{} +} + +// ContextWithUserID adds user ID to context +func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { + return context.WithValue(ctx, ContextKeyUserID, userID) +} + +// ContextWithTenantID adds tenant ID to context +func ContextWithTenantID(ctx context.Context, tenantID uuid.UUID) context.Context { + return context.WithValue(ctx, ContextKeyTenantID, tenantID) +} + +// ContextWithNamespaceID adds namespace ID to context +func ContextWithNamespaceID(ctx context.Context, namespaceID uuid.UUID) context.Context { + return context.WithValue(ctx, ContextKeyNamespaceID, namespaceID) +} + +// UserIDFromContext retrieves user ID from standard context +func UserIDFromContext(ctx context.Context) uuid.UUID { + if id, ok := ctx.Value(ContextKeyUserID).(uuid.UUID); ok { + return id + } + return uuid.Nil +} + +// TenantIDFromContext retrieves tenant ID from standard context +func TenantIDFromContext(ctx context.Context) uuid.UUID { + if id, ok := ctx.Value(ContextKeyTenantID).(uuid.UUID); ok { + return id + } + return uuid.Nil +} + +// NamespaceIDFromContext retrieves namespace ID from standard context +func NamespaceIDFromContext(ctx context.Context) *uuid.UUID { + if id, ok := ctx.Value(ContextKeyNamespaceID).(uuid.UUID); ok { + return &id + } + return nil +} + +// HasPermissionFromHeader checks permission from header-based context (for API keys) +func (m *Middleware) HasPermissionFromHeader(c *gin.Context, permission string) bool { + // Check X-API-Permissions header (set by API key auth) + permsHeader := c.GetHeader("X-API-Permissions") + if permsHeader != "" { + perms := strings.Split(permsHeader, ",") + for _, p := range perms { + p = strings.TrimSpace(p) + if p == permission { + return true + } + // Wildcard check + if strings.HasSuffix(p, ":*") { + prefix := strings.TrimSuffix(p, "*") + if strings.HasPrefix(permission, prefix) { + return true + } + } + } + } + return false +} diff --git a/ai-compliance-sdk/internal/rbac/models.go b/ai-compliance-sdk/internal/rbac/models.go new file mode 100644 index 0000000..e23f6f5 --- /dev/null +++ b/ai-compliance-sdk/internal/rbac/models.go @@ -0,0 +1,197 @@ +package rbac + +import ( + "time" + + "github.com/google/uuid" +) + +// IsolationLevel defines namespace isolation strictness +type IsolationLevel string + +const ( + IsolationStrict IsolationLevel = "strict" + IsolationShared IsolationLevel = "shared" + IsolationPublic IsolationLevel = "public" +) + +// DataClassification defines data sensitivity levels +type DataClassification string + +const ( + ClassificationPublic DataClassification = "public" + ClassificationInternal DataClassification = "internal" + ClassificationConfidential DataClassification = "confidential" + ClassificationRestricted DataClassification = "restricted" +) + +// TenantStatus defines tenant status +type TenantStatus string + +const ( + TenantStatusActive TenantStatus = "active" + TenantStatusSuspended TenantStatus = "suspended" + TenantStatusInactive TenantStatus = "inactive" +) + +// PIIRedactionLevel defines PII redaction strictness +type PIIRedactionLevel string + +const ( + PIIRedactionStrict PIIRedactionLevel = "strict" + PIIRedactionModerate PIIRedactionLevel = "moderate" + PIIRedactionMinimal PIIRedactionLevel = "minimal" + PIIRedactionNone PIIRedactionLevel = "none" +) + +// Tenant represents a customer/organization (Mandant) +type Tenant struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Slug string `json:"slug" db:"slug"` + Settings map[string]any `json:"settings" db:"settings"` + MaxUsers int `json:"max_users" db:"max_users"` + LLMQuotaMonthly int `json:"llm_quota_monthly" db:"llm_quota_monthly"` + Status TenantStatus `json:"status" db:"status"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Namespace represents a department/division within a tenant (z.B. Finance, HR, IT) +type Namespace struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + Name string `json:"name" db:"name"` + Slug string `json:"slug" db:"slug"` + ParentNamespaceID *uuid.UUID `json:"parent_namespace_id,omitempty" db:"parent_namespace_id"` + IsolationLevel IsolationLevel `json:"isolation_level" db:"isolation_level"` + DataClassification DataClassification `json:"data_classification" db:"data_classification"` + Metadata map[string]any `json:"metadata,omitempty" db:"metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Role defines a set of permissions +type Role struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"` // nil for system roles + Name string `json:"name" db:"name"` + Description string `json:"description,omitempty" db:"description"` + Permissions []string `json:"permissions" db:"permissions"` + IsSystemRole bool `json:"is_system_role" db:"is_system_role"` + HierarchyLevel int `json:"hierarchy_level" db:"hierarchy_level"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// UserRole represents a user's role assignment with optional namespace scope +type UserRole struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + RoleID uuid.UUID `json:"role_id" db:"role_id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty" db:"namespace_id"` // nil = tenant-wide + GrantedBy uuid.UUID `json:"granted_by" db:"granted_by"` + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + + // Joined fields (populated by queries) + RoleName string `json:"role_name,omitempty" db:"role_name"` + RolePermissions []string `json:"role_permissions,omitempty" db:"role_permissions"` + NamespaceName string `json:"namespace_name,omitempty" db:"namespace_name"` +} + +// LLMPolicy defines access controls for LLM operations +type LLMPolicy struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty" db:"namespace_id"` + Name string `json:"name" db:"name"` + Description string `json:"description,omitempty" db:"description"` + AllowedDataCategories []string `json:"allowed_data_categories" db:"allowed_data_categories"` + BlockedDataCategories []string `json:"blocked_data_categories" db:"blocked_data_categories"` + RequirePIIRedaction bool `json:"require_pii_redaction" db:"require_pii_redaction"` + PIIRedactionLevel PIIRedactionLevel `json:"pii_redaction_level" db:"pii_redaction_level"` + AllowedModels []string `json:"allowed_models" db:"allowed_models"` + MaxTokensPerRequest int `json:"max_tokens_per_request" db:"max_tokens_per_request"` + MaxRequestsPerDay int `json:"max_requests_per_day" db:"max_requests_per_day"` + MaxRequestsPerHour int `json:"max_requests_per_hour" db:"max_requests_per_hour"` + IsActive bool `json:"is_active" db:"is_active"` + Priority int `json:"priority" db:"priority"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// APIKey represents an API key for SDK access +type APIKey struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + Name string `json:"name" db:"name"` + KeyHash string `json:"-" db:"key_hash"` // Never expose + KeyPrefix string `json:"key_prefix" db:"key_prefix"` + Permissions []string `json:"permissions" db:"permissions"` + NamespaceRestrictions []uuid.UUID `json:"namespace_restrictions,omitempty" db:"namespace_restrictions"` + RateLimitPerHour int `json:"rate_limit_per_hour" db:"rate_limit_per_hour"` + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedBy uuid.UUID `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// EffectivePermissions represents a user's computed permissions +type EffectivePermissions struct { + UserID uuid.UUID `json:"user_id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + Permissions []string `json:"permissions"` + Roles []string `json:"roles"` + LLMPolicy *LLMPolicy `json:"llm_policy,omitempty"` + Namespaces []NamespaceAccess `json:"namespaces,omitempty"` +} + +// NamespaceAccess represents a user's access to a namespace +type NamespaceAccess struct { + NamespaceID uuid.UUID `json:"namespace_id"` + NamespaceName string `json:"namespace_name"` + NamespaceSlug string `json:"namespace_slug"` + DataClassification DataClassification `json:"data_classification"` + Roles []string `json:"roles"` + Permissions []string `json:"permissions"` +} + +// System role names (predefined) +const ( + RoleComplianceExecutive = "compliance_executive" + RoleComplianceOfficer = "compliance_officer" + RoleDataProtectionOfficer = "data_protection_officer" + RoleNamespaceAdmin = "namespace_admin" + RoleAuditor = "auditor" + RoleComplianceUser = "compliance_user" +) + +// Common permission patterns +const ( + PermissionComplianceAll = "compliance:*" + PermissionComplianceRead = "compliance:read" + PermissionComplianceWrite = "compliance:write" + PermissionComplianceOwnRead = "compliance:own:read" + PermissionAuditAll = "audit:*" + PermissionAuditRead = "audit:read" + PermissionAuditLogRead = "audit:log:read" + PermissionLLMAll = "llm:*" + PermissionLLMQuery = "llm:query:execute" + PermissionLLMOwnQuery = "llm:own:query" + PermissionNamespaceRead = "namespace:read" + PermissionNamespaceOwnAdmin = "namespace:own:admin" +) + +// Data categories for LLM access control +const ( + DataCategorySalary = "salary" + DataCategoryHealth = "health" + DataCategoryPersonal = "personal" + DataCategoryFinancial = "financial" + DataCategoryLegal = "legal" + DataCategoryHR = "hr" +) diff --git a/ai-compliance-sdk/internal/rbac/policy_engine.go b/ai-compliance-sdk/internal/rbac/policy_engine.go new file mode 100644 index 0000000..658ea6a --- /dev/null +++ b/ai-compliance-sdk/internal/rbac/policy_engine.go @@ -0,0 +1,395 @@ +package rbac + +import ( + "context" + "strings" + + "github.com/google/uuid" +) + +// PolicyEngine provides advanced policy evaluation +type PolicyEngine struct { + service *Service + store *Store +} + +// NewPolicyEngine creates a new policy engine +func NewPolicyEngine(service *Service, store *Store) *PolicyEngine { + return &PolicyEngine{ + service: service, + store: store, + } +} + +// LLMAccessRequest represents a request to access LLM functionality +type LLMAccessRequest struct { + UserID uuid.UUID + TenantID uuid.UUID + NamespaceID *uuid.UUID + Model string + DataCategories []string + TokensRequested int + Operation string // "query", "completion", "embedding", "analysis" +} + +// LLMAccessResult represents the result of an LLM access check +type LLMAccessResult struct { + Allowed bool + Reason string + Policy *LLMPolicy + RequirePIIRedaction bool + PIIRedactionLevel PIIRedactionLevel + MaxTokens int + BlockedCategories []string +} + +// EvaluateLLMAccess evaluates whether an LLM request should be allowed +func (pe *PolicyEngine) EvaluateLLMAccess(ctx context.Context, req *LLMAccessRequest) (*LLMAccessResult, error) { + result := &LLMAccessResult{ + Allowed: false, + RequirePIIRedaction: true, + PIIRedactionLevel: PIIRedactionStrict, + MaxTokens: 4000, + BlockedCategories: []string{}, + } + + // 1. Check base permission for LLM access + hasPermission, err := pe.service.HasAnyPermission(ctx, req.UserID, req.TenantID, req.NamespaceID, []string{ + PermissionLLMAll, + PermissionLLMQuery, + PermissionLLMOwnQuery, + }) + if err != nil { + return result, err + } + if !hasPermission { + result.Reason = "no LLM permission" + return result, nil + } + + // 2. Get effective policy + policy, err := pe.store.GetEffectiveLLMPolicy(ctx, req.TenantID, req.NamespaceID) + if err != nil { + return result, err + } + + result.Policy = policy + + // No policy = use defaults and allow + if policy == nil { + result.Allowed = true + result.Reason = "no policy restrictions" + return result, nil + } + + // 3. Check model restrictions + if len(policy.AllowedModels) > 0 { + modelAllowed := false + for _, allowed := range policy.AllowedModels { + if allowed == req.Model || strings.HasPrefix(req.Model, allowed+":") || strings.HasPrefix(req.Model, allowed+"-") { + modelAllowed = true + break + } + } + if !modelAllowed { + result.Reason = "model not allowed: " + req.Model + return result, nil + } + } + + // 4. Check data categories + for _, category := range req.DataCategories { + // Check if blocked + for _, blocked := range policy.BlockedDataCategories { + if blocked == category { + result.BlockedCategories = append(result.BlockedCategories, category) + } + } + + // Check if allowed (if allowlist is defined) + if len(policy.AllowedDataCategories) > 0 { + allowed := false + for _, a := range policy.AllowedDataCategories { + if a == category { + allowed = true + break + } + } + if !allowed { + result.BlockedCategories = append(result.BlockedCategories, category) + } + } + } + + if len(result.BlockedCategories) > 0 { + result.Reason = "blocked data categories: " + strings.Join(result.BlockedCategories, ", ") + return result, nil + } + + // 5. Check token limits + if req.TokensRequested > policy.MaxTokensPerRequest { + result.Reason = "tokens requested exceeds limit" + return result, nil + } + result.MaxTokens = policy.MaxTokensPerRequest + + // 6. Set PII redaction requirements + result.RequirePIIRedaction = policy.RequirePIIRedaction + result.PIIRedactionLevel = policy.PIIRedactionLevel + + // All checks passed + result.Allowed = true + result.Reason = "policy check passed" + return result, nil +} + +// NamespaceAccessRequest represents a request to access a namespace +type NamespaceAccessRequest struct { + UserID uuid.UUID + TenantID uuid.UUID + NamespaceID uuid.UUID + Operation string // "read", "write", "admin" +} + +// NamespaceAccessResult represents the result of a namespace access check +type NamespaceAccessResult struct { + Allowed bool + Reason string + DataClassification DataClassification + IsolationLevel IsolationLevel + Permissions []string +} + +// EvaluateNamespaceAccess evaluates whether a namespace operation should be allowed +func (pe *PolicyEngine) EvaluateNamespaceAccess(ctx context.Context, req *NamespaceAccessRequest) (*NamespaceAccessResult, error) { + result := &NamespaceAccessResult{ + Allowed: false, + Permissions: []string{}, + } + + // Get namespace details + ns, err := pe.store.GetNamespace(ctx, req.NamespaceID) + if err != nil { + result.Reason = "namespace not found" + return result, ErrNamespaceNotFound + } + + result.DataClassification = ns.DataClassification + result.IsolationLevel = ns.IsolationLevel + + // Check if user has any roles in this namespace + userRoles, err := pe.store.GetUserRolesForNamespace(ctx, req.UserID, req.TenantID, &req.NamespaceID) + if err != nil { + return result, err + } + + if len(userRoles) == 0 { + // Check for tenant-wide roles + tenantRoles, err := pe.store.GetUserRolesForNamespace(ctx, req.UserID, req.TenantID, nil) + if err != nil { + return result, err + } + + if len(tenantRoles) == 0 { + result.Reason = "no access to namespace" + return result, nil + } + + userRoles = tenantRoles + } + + // Collect permissions + permSet := make(map[string]bool) + for _, ur := range userRoles { + for _, perm := range ur.RolePermissions { + permSet[perm] = true + } + } + + for perm := range permSet { + result.Permissions = append(result.Permissions, perm) + } + + // Check operation-specific permission + var requiredPermission string + switch req.Operation { + case "read": + requiredPermission = "namespace:read" + case "write": + requiredPermission = "namespace:write" + case "admin": + requiredPermission = "namespace:own:admin" + default: + requiredPermission = "namespace:read" + } + + hasPermission := pe.service.checkPermission(result.Permissions, requiredPermission) + if !hasPermission { + // Check for broader permissions + hasPermission = pe.service.checkPermission(result.Permissions, PermissionComplianceAll) || + pe.service.checkPermission(result.Permissions, "namespace:*") + } + + if !hasPermission { + result.Reason = "insufficient permissions for operation: " + req.Operation + return result, nil + } + + result.Allowed = true + result.Reason = "access granted" + return result, nil +} + +// DataAccessRequest represents a request to access data +type DataAccessRequest struct { + UserID uuid.UUID + TenantID uuid.UUID + NamespaceID *uuid.UUID + ResourceType string // "control", "evidence", "audit", "policy" + ResourceID *uuid.UUID + Operation string // "read", "create", "update", "delete" + DataCategories []string +} + +// DataAccessResult represents the result of a data access check +type DataAccessResult struct { + Allowed bool + Reason string + RequireAuditLog bool + AllowedCategories []string + DeniedCategories []string +} + +// EvaluateDataAccess evaluates whether a data operation should be allowed +func (pe *PolicyEngine) EvaluateDataAccess(ctx context.Context, req *DataAccessRequest) (*DataAccessResult, error) { + result := &DataAccessResult{ + Allowed: false, + RequireAuditLog: true, + AllowedCategories: []string{}, + DeniedCategories: []string{}, + } + + // Build required permission based on resource type and operation + requiredPermission := req.ResourceType + ":" + req.Operation + + // Check permission + hasPermission, err := pe.service.HasAnyPermission(ctx, req.UserID, req.TenantID, req.NamespaceID, []string{ + requiredPermission, + req.ResourceType + ":*", + PermissionComplianceAll, + }) + if err != nil { + return result, err + } + + if !hasPermission { + result.Reason = "missing permission: " + requiredPermission + return result, nil + } + + // Check namespace access if namespace is specified + if req.NamespaceID != nil { + nsResult, err := pe.EvaluateNamespaceAccess(ctx, &NamespaceAccessRequest{ + UserID: req.UserID, + TenantID: req.TenantID, + NamespaceID: *req.NamespaceID, + Operation: req.Operation, + }) + if err != nil { + return result, err + } + + if !nsResult.Allowed { + result.Reason = "namespace access denied: " + nsResult.Reason + return result, nil + } + } + + // Check data categories + if len(req.DataCategories) > 0 { + policy, _ := pe.store.GetEffectiveLLMPolicy(ctx, req.TenantID, req.NamespaceID) + if policy != nil { + for _, category := range req.DataCategories { + blocked := false + + // Check blocked list + for _, b := range policy.BlockedDataCategories { + if b == category { + blocked = true + break + } + } + + // Check allowed list if specified + if !blocked && len(policy.AllowedDataCategories) > 0 { + allowed := false + for _, a := range policy.AllowedDataCategories { + if a == category { + allowed = true + break + } + } + if !allowed { + blocked = true + } + } + + if blocked { + result.DeniedCategories = append(result.DeniedCategories, category) + } else { + result.AllowedCategories = append(result.AllowedCategories, category) + } + } + + if len(result.DeniedCategories) > 0 { + result.Reason = "blocked data categories: " + strings.Join(result.DeniedCategories, ", ") + return result, nil + } + } + } + + result.Allowed = true + result.Reason = "access granted" + return result, nil +} + +// GetUserContext returns full context for a user including all permissions and policies +func (pe *PolicyEngine) GetUserContext(ctx context.Context, userID, tenantID uuid.UUID) (*UserContext, error) { + perms, err := pe.service.GetEffectivePermissions(ctx, userID, tenantID, nil) + if err != nil { + return nil, err + } + + tenant, err := pe.store.GetTenant(ctx, tenantID) + if err != nil { + return nil, err + } + + namespaces, err := pe.service.GetUserAccessibleNamespaces(ctx, userID, tenantID) + if err != nil { + return nil, err + } + + return &UserContext{ + UserID: userID, + TenantID: tenantID, + TenantName: tenant.Name, + TenantSlug: tenant.Slug, + Roles: perms.Roles, + Permissions: perms.Permissions, + Namespaces: namespaces, + LLMPolicy: perms.LLMPolicy, + }, nil +} + +// UserContext represents complete context for a user +type UserContext struct { + UserID uuid.UUID `json:"user_id"` + TenantID uuid.UUID `json:"tenant_id"` + TenantName string `json:"tenant_name"` + TenantSlug string `json:"tenant_slug"` + Roles []string `json:"roles"` + Permissions []string `json:"permissions"` + Namespaces []*Namespace `json:"namespaces"` + LLMPolicy *LLMPolicy `json:"llm_policy,omitempty"` +} diff --git a/ai-compliance-sdk/internal/rbac/service.go b/ai-compliance-sdk/internal/rbac/service.go new file mode 100644 index 0000000..6a29671 --- /dev/null +++ b/ai-compliance-sdk/internal/rbac/service.go @@ -0,0 +1,360 @@ +package rbac + +import ( + "context" + "errors" + "strings" + + "github.com/google/uuid" +) + +var ( + ErrTenantNotFound = errors.New("tenant not found") + ErrNamespaceNotFound = errors.New("namespace not found") + ErrRoleNotFound = errors.New("role not found") + ErrPermissionDenied = errors.New("permission denied") + ErrInvalidNamespace = errors.New("invalid namespace access") + ErrDataCategoryBlocked = errors.New("data category blocked by policy") + ErrLLMQuotaExceeded = errors.New("LLM quota exceeded") + ErrModelNotAllowed = errors.New("model not allowed by policy") +) + +// Service provides RBAC business logic +type Service struct { + store *Store +} + +// NewService creates a new RBAC service +func NewService(store *Store) *Service { + return &Service{store: store} +} + +// GetEffectivePermissions computes all effective permissions for a user +func (s *Service) GetEffectivePermissions(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID) (*EffectivePermissions, error) { + // Get user roles + userRoles, err := s.store.GetUserRolesForNamespace(ctx, userID, tenantID, namespaceID) + if err != nil { + return nil, err + } + + // Aggregate permissions and roles + permissionSet := make(map[string]bool) + roleSet := make(map[string]bool) + + for _, ur := range userRoles { + roleSet[ur.RoleName] = true + for _, perm := range ur.RolePermissions { + permissionSet[perm] = true + } + } + + // Convert to slices + permissions := make([]string, 0, len(permissionSet)) + for perm := range permissionSet { + permissions = append(permissions, perm) + } + + roles := make([]string, 0, len(roleSet)) + for role := range roleSet { + roles = append(roles, role) + } + + // Get effective LLM policy + llmPolicy, _ := s.store.GetEffectiveLLMPolicy(ctx, tenantID, namespaceID) + + // Get all accessible namespaces + allUserRoles, err := s.store.GetUserRoles(ctx, userID, tenantID) + if err != nil { + return nil, err + } + + // Build namespace access list + namespaceAccessMap := make(map[uuid.UUID]*NamespaceAccess) + for _, ur := range allUserRoles { + var nsID uuid.UUID + if ur.NamespaceID != nil { + nsID = *ur.NamespaceID + } else { + continue // Tenant-wide roles don't have specific namespace + } + + access, exists := namespaceAccessMap[nsID] + if !exists { + // Get namespace details + ns, err := s.store.GetNamespace(ctx, nsID) + if err != nil { + continue + } + access = &NamespaceAccess{ + NamespaceID: ns.ID, + NamespaceName: ns.Name, + NamespaceSlug: ns.Slug, + DataClassification: ns.DataClassification, + Roles: []string{}, + Permissions: []string{}, + } + namespaceAccessMap[nsID] = access + } + + access.Roles = append(access.Roles, ur.RoleName) + access.Permissions = append(access.Permissions, ur.RolePermissions...) + } + + namespaces := make([]NamespaceAccess, 0, len(namespaceAccessMap)) + for _, access := range namespaceAccessMap { + namespaces = append(namespaces, *access) + } + + return &EffectivePermissions{ + UserID: userID, + TenantID: tenantID, + NamespaceID: namespaceID, + Permissions: permissions, + Roles: roles, + LLMPolicy: llmPolicy, + Namespaces: namespaces, + }, nil +} + +// HasPermission checks if user has a specific permission +func (s *Service) HasPermission(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, permission string) (bool, error) { + perms, err := s.GetEffectivePermissions(ctx, userID, tenantID, namespaceID) + if err != nil { + return false, err + } + + return s.checkPermission(perms.Permissions, permission), nil +} + +// checkPermission checks if a permission is granted given a list of permissions +func (s *Service) checkPermission(permissions []string, required string) bool { + for _, perm := range permissions { + // Exact match + if perm == required { + return true + } + + // Wildcard match (e.g., "compliance:*" matches "compliance:read") + if strings.HasSuffix(perm, ":*") { + prefix := strings.TrimSuffix(perm, "*") + if strings.HasPrefix(required, prefix) { + return true + } + } + + // Full wildcard (e.g., "compliance:*:read" matches "compliance:privacy:read") + if strings.Contains(perm, ":*:") { + parts := strings.Split(perm, ":*:") + if len(parts) == 2 { + if strings.HasPrefix(required, parts[0]+":") && strings.HasSuffix(required, ":"+parts[1]) { + return true + } + } + } + + // "own" namespace handling (e.g., "compliance:own:read" when in own namespace) + // This requires context about whether the user is in their "own" namespace + // For simplicity, we'll handle "own" as a prefix match for now + if strings.Contains(perm, ":own:") { + ownPerm := strings.Replace(perm, ":own:", ":", 1) + if ownPerm == required || strings.HasPrefix(required, strings.TrimSuffix(ownPerm, "*")) { + return true + } + } + } + + return false +} + +// HasAnyPermission checks if user has any of the specified permissions +func (s *Service) HasAnyPermission(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, permissions []string) (bool, error) { + perms, err := s.GetEffectivePermissions(ctx, userID, tenantID, namespaceID) + if err != nil { + return false, err + } + + for _, required := range permissions { + if s.checkPermission(perms.Permissions, required) { + return true, nil + } + } + + return false, nil +} + +// HasAllPermissions checks if user has all specified permissions +func (s *Service) HasAllPermissions(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, permissions []string) (bool, error) { + perms, err := s.GetEffectivePermissions(ctx, userID, tenantID, namespaceID) + if err != nil { + return false, err + } + + for _, required := range permissions { + if !s.checkPermission(perms.Permissions, required) { + return false, nil + } + } + + return true, nil +} + +// CanAccessNamespace checks if user can access a namespace +func (s *Service) CanAccessNamespace(ctx context.Context, userID, tenantID, namespaceID uuid.UUID) (bool, error) { + userRoles, err := s.store.GetUserRolesForNamespace(ctx, userID, tenantID, &namespaceID) + if err != nil { + return false, err + } + + return len(userRoles) > 0, nil +} + +// CanAccessDataCategory checks if user can access a data category via LLM +func (s *Service) CanAccessDataCategory(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID, category string) (bool, string, error) { + policy, err := s.store.GetEffectiveLLMPolicy(ctx, tenantID, namespaceID) + if err != nil { + return false, "", err + } + + // No policy = allow all + if policy == nil { + return true, "", nil + } + + // Check blocked categories + for _, blocked := range policy.BlockedDataCategories { + if blocked == category { + return false, "data category is blocked by policy", nil + } + } + + // If allowed categories are specified, check if category is in the list + if len(policy.AllowedDataCategories) > 0 { + allowed := false + for _, a := range policy.AllowedDataCategories { + if a == category { + allowed = true + break + } + } + if !allowed { + return false, "data category is not in allowed list", nil + } + } + + return true, "", nil +} + +// CanUseModel checks if a model is allowed by policy +func (s *Service) CanUseModel(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID, model string) (bool, error) { + policy, err := s.store.GetEffectiveLLMPolicy(ctx, tenantID, namespaceID) + if err != nil { + return false, err + } + + // No policy = allow all models + if policy == nil || len(policy.AllowedModels) == 0 { + return true, nil + } + + // Check if model is in allowed list + for _, allowed := range policy.AllowedModels { + if allowed == model { + return true, nil + } + // Partial match for model families (e.g., "qwen2.5" matches "qwen2.5:7b") + if strings.HasPrefix(model, allowed+":") || strings.HasPrefix(model, allowed+"-") { + return true, nil + } + } + + return false, nil +} + +// GetPIIRedactionLevel returns the PII redaction level for a namespace +func (s *Service) GetPIIRedactionLevel(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID) (PIIRedactionLevel, bool, error) { + policy, err := s.store.GetEffectiveLLMPolicy(ctx, tenantID, namespaceID) + if err != nil { + return PIIRedactionStrict, true, err + } + + // No policy = use strict defaults + if policy == nil { + return PIIRedactionStrict, true, nil + } + + return policy.PIIRedactionLevel, policy.RequirePIIRedaction, nil +} + +// GetUserAccessibleNamespaces returns all namespaces a user can access +func (s *Service) GetUserAccessibleNamespaces(ctx context.Context, userID, tenantID uuid.UUID) ([]*Namespace, error) { + userRoles, err := s.store.GetUserRoles(ctx, userID, tenantID) + if err != nil { + return nil, err + } + + // Get unique namespace IDs + namespaceIDs := make(map[uuid.UUID]bool) + hasTenantWideRole := false + + for _, ur := range userRoles { + if ur.NamespaceID != nil { + namespaceIDs[*ur.NamespaceID] = true + } else { + hasTenantWideRole = true + } + } + + // If user has tenant-wide role, return all namespaces + if hasTenantWideRole { + return s.store.ListNamespaces(ctx, tenantID) + } + + // Otherwise, return only specific namespaces + var namespaces []*Namespace + for nsID := range namespaceIDs { + ns, err := s.store.GetNamespace(ctx, nsID) + if err != nil { + continue + } + namespaces = append(namespaces, ns) + } + + return namespaces, nil +} + +// AssignRoleToUser assigns a role to a user +func (s *Service) AssignRoleToUser(ctx context.Context, ur *UserRole, grantorUserID uuid.UUID) error { + // Check if grantor has permission to assign roles + hasPermission, err := s.HasAnyPermission(ctx, grantorUserID, ur.TenantID, ur.NamespaceID, []string{ + "rbac:assign", + "namespace:own:admin", + PermissionComplianceAll, + }) + if err != nil { + return err + } + if !hasPermission { + return ErrPermissionDenied + } + + ur.GrantedBy = grantorUserID + return s.store.AssignRole(ctx, ur) +} + +// RevokeRoleFromUser revokes a role from a user +func (s *Service) RevokeRoleFromUser(ctx context.Context, userID, roleID, tenantID uuid.UUID, namespaceID *uuid.UUID, revokerUserID uuid.UUID) error { + // Check if revoker has permission to revoke roles + hasPermission, err := s.HasAnyPermission(ctx, revokerUserID, tenantID, namespaceID, []string{ + "rbac:revoke", + "namespace:own:admin", + PermissionComplianceAll, + }) + if err != nil { + return err + } + if !hasPermission { + return ErrPermissionDenied + } + + return s.store.RevokeRole(ctx, userID, roleID, tenantID, namespaceID) +} diff --git a/ai-compliance-sdk/internal/rbac/store.go b/ai-compliance-sdk/internal/rbac/store.go new file mode 100644 index 0000000..0671f48 --- /dev/null +++ b/ai-compliance-sdk/internal/rbac/store.go @@ -0,0 +1,651 @@ +package rbac + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store provides database operations for RBAC entities +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new RBAC store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// ============================================================================ +// Tenant Operations +// ============================================================================ + +// CreateTenant creates a new tenant +func (s *Store) CreateTenant(ctx context.Context, tenant *Tenant) error { + tenant.ID = uuid.New() + tenant.CreatedAt = time.Now().UTC() + tenant.UpdatedAt = tenant.CreatedAt + + if tenant.Status == "" { + tenant.Status = TenantStatusActive + } + if tenant.Settings == nil { + tenant.Settings = make(map[string]any) + } + + settingsJSON, err := json.Marshal(tenant.Settings) + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + + _, err = s.pool.Exec(ctx, ` + INSERT INTO compliance_tenants (id, name, slug, settings, max_users, llm_quota_monthly, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, tenant.ID, tenant.Name, tenant.Slug, settingsJSON, tenant.MaxUsers, tenant.LLMQuotaMonthly, tenant.Status, tenant.CreatedAt, tenant.UpdatedAt) + + return err +} + +// GetTenant retrieves a tenant by ID +func (s *Store) GetTenant(ctx context.Context, id uuid.UUID) (*Tenant, error) { + var tenant Tenant + var settingsJSON []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, name, slug, settings, max_users, llm_quota_monthly, status, created_at, updated_at + FROM compliance_tenants + WHERE id = $1 + `, id).Scan( + &tenant.ID, &tenant.Name, &tenant.Slug, &settingsJSON, + &tenant.MaxUsers, &tenant.LLMQuotaMonthly, &tenant.Status, + &tenant.CreatedAt, &tenant.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + if err := json.Unmarshal(settingsJSON, &tenant.Settings); err != nil { + tenant.Settings = make(map[string]any) + } + + return &tenant, nil +} + +// GetTenantBySlug retrieves a tenant by slug +func (s *Store) GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) { + var tenant Tenant + var settingsJSON []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, name, slug, settings, max_users, llm_quota_monthly, status, created_at, updated_at + FROM compliance_tenants + WHERE slug = $1 + `, slug).Scan( + &tenant.ID, &tenant.Name, &tenant.Slug, &settingsJSON, + &tenant.MaxUsers, &tenant.LLMQuotaMonthly, &tenant.Status, + &tenant.CreatedAt, &tenant.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + if err := json.Unmarshal(settingsJSON, &tenant.Settings); err != nil { + tenant.Settings = make(map[string]any) + } + + return &tenant, nil +} + +// ListTenants lists all tenants +func (s *Store) ListTenants(ctx context.Context) ([]*Tenant, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, name, slug, settings, max_users, llm_quota_monthly, status, created_at, updated_at + FROM compliance_tenants + ORDER BY name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var tenants []*Tenant + for rows.Next() { + var tenant Tenant + var settingsJSON []byte + + err := rows.Scan( + &tenant.ID, &tenant.Name, &tenant.Slug, &settingsJSON, + &tenant.MaxUsers, &tenant.LLMQuotaMonthly, &tenant.Status, + &tenant.CreatedAt, &tenant.UpdatedAt, + ) + if err != nil { + continue + } + + if err := json.Unmarshal(settingsJSON, &tenant.Settings); err != nil { + tenant.Settings = make(map[string]any) + } + + tenants = append(tenants, &tenant) + } + + return tenants, nil +} + +// UpdateTenant updates a tenant +func (s *Store) UpdateTenant(ctx context.Context, tenant *Tenant) error { + tenant.UpdatedAt = time.Now().UTC() + + settingsJSON, err := json.Marshal(tenant.Settings) + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + + _, err = s.pool.Exec(ctx, ` + UPDATE compliance_tenants + SET name = $2, slug = $3, settings = $4, max_users = $5, llm_quota_monthly = $6, status = $7, updated_at = $8 + WHERE id = $1 + `, tenant.ID, tenant.Name, tenant.Slug, settingsJSON, tenant.MaxUsers, tenant.LLMQuotaMonthly, tenant.Status, tenant.UpdatedAt) + + return err +} + +// ============================================================================ +// Namespace Operations +// ============================================================================ + +// CreateNamespace creates a new namespace +func (s *Store) CreateNamespace(ctx context.Context, ns *Namespace) error { + ns.ID = uuid.New() + ns.CreatedAt = time.Now().UTC() + ns.UpdatedAt = ns.CreatedAt + + if ns.IsolationLevel == "" { + ns.IsolationLevel = IsolationStrict + } + if ns.DataClassification == "" { + ns.DataClassification = ClassificationInternal + } + if ns.Metadata == nil { + ns.Metadata = make(map[string]any) + } + + metadataJSON, err := json.Marshal(ns.Metadata) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + _, err = s.pool.Exec(ctx, ` + INSERT INTO compliance_namespaces (id, tenant_id, name, slug, parent_namespace_id, isolation_level, data_classification, metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, ns.ID, ns.TenantID, ns.Name, ns.Slug, ns.ParentNamespaceID, ns.IsolationLevel, ns.DataClassification, metadataJSON, ns.CreatedAt, ns.UpdatedAt) + + return err +} + +// GetNamespace retrieves a namespace by ID +func (s *Store) GetNamespace(ctx context.Context, id uuid.UUID) (*Namespace, error) { + var ns Namespace + var metadataJSON []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, name, slug, parent_namespace_id, isolation_level, data_classification, metadata, created_at, updated_at + FROM compliance_namespaces + WHERE id = $1 + `, id).Scan( + &ns.ID, &ns.TenantID, &ns.Name, &ns.Slug, &ns.ParentNamespaceID, + &ns.IsolationLevel, &ns.DataClassification, &metadataJSON, + &ns.CreatedAt, &ns.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + if err := json.Unmarshal(metadataJSON, &ns.Metadata); err != nil { + ns.Metadata = make(map[string]any) + } + + return &ns, nil +} + +// GetNamespaceBySlug retrieves a namespace by tenant and slug +func (s *Store) GetNamespaceBySlug(ctx context.Context, tenantID uuid.UUID, slug string) (*Namespace, error) { + var ns Namespace + var metadataJSON []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, name, slug, parent_namespace_id, isolation_level, data_classification, metadata, created_at, updated_at + FROM compliance_namespaces + WHERE tenant_id = $1 AND slug = $2 + `, tenantID, slug).Scan( + &ns.ID, &ns.TenantID, &ns.Name, &ns.Slug, &ns.ParentNamespaceID, + &ns.IsolationLevel, &ns.DataClassification, &metadataJSON, + &ns.CreatedAt, &ns.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + if err := json.Unmarshal(metadataJSON, &ns.Metadata); err != nil { + ns.Metadata = make(map[string]any) + } + + return &ns, nil +} + +// ListNamespaces lists namespaces for a tenant +func (s *Store) ListNamespaces(ctx context.Context, tenantID uuid.UUID) ([]*Namespace, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, name, slug, parent_namespace_id, isolation_level, data_classification, metadata, created_at, updated_at + FROM compliance_namespaces + WHERE tenant_id = $1 + ORDER BY name + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var namespaces []*Namespace + for rows.Next() { + var ns Namespace + var metadataJSON []byte + + err := rows.Scan( + &ns.ID, &ns.TenantID, &ns.Name, &ns.Slug, &ns.ParentNamespaceID, + &ns.IsolationLevel, &ns.DataClassification, &metadataJSON, + &ns.CreatedAt, &ns.UpdatedAt, + ) + if err != nil { + continue + } + + if err := json.Unmarshal(metadataJSON, &ns.Metadata); err != nil { + ns.Metadata = make(map[string]any) + } + + namespaces = append(namespaces, &ns) + } + + return namespaces, nil +} + +// ============================================================================ +// Role Operations +// ============================================================================ + +// CreateRole creates a new role +func (s *Store) CreateRole(ctx context.Context, role *Role) error { + role.ID = uuid.New() + role.CreatedAt = time.Now().UTC() + role.UpdatedAt = role.CreatedAt + + _, err := s.pool.Exec(ctx, ` + INSERT INTO compliance_roles (id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, role.ID, role.TenantID, role.Name, role.Description, role.Permissions, role.IsSystemRole, role.HierarchyLevel, role.CreatedAt, role.UpdatedAt) + + return err +} + +// GetRole retrieves a role by ID +func (s *Store) GetRole(ctx context.Context, id uuid.UUID) (*Role, error) { + var role Role + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at + FROM compliance_roles + WHERE id = $1 + `, id).Scan( + &role.ID, &role.TenantID, &role.Name, &role.Description, + &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, + &role.CreatedAt, &role.UpdatedAt, + ) + + return &role, err +} + +// GetRoleByName retrieves a role by tenant and name +func (s *Store) GetRoleByName(ctx context.Context, tenantID *uuid.UUID, name string) (*Role, error) { + var role Role + + query := ` + SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at + FROM compliance_roles + WHERE name = $1 AND (tenant_id = $2 OR (tenant_id IS NULL AND is_system_role = TRUE)) + ` + + err := s.pool.QueryRow(ctx, query, name, tenantID).Scan( + &role.ID, &role.TenantID, &role.Name, &role.Description, + &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, + &role.CreatedAt, &role.UpdatedAt, + ) + + return &role, err +} + +// ListRoles lists roles for a tenant (including system roles) +func (s *Store) ListRoles(ctx context.Context, tenantID *uuid.UUID) ([]*Role, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at + FROM compliance_roles + WHERE tenant_id = $1 OR is_system_role = TRUE + ORDER BY hierarchy_level, name + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []*Role + for rows.Next() { + var role Role + err := rows.Scan( + &role.ID, &role.TenantID, &role.Name, &role.Description, + &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, + &role.CreatedAt, &role.UpdatedAt, + ) + if err != nil { + continue + } + roles = append(roles, &role) + } + + return roles, nil +} + +// ListSystemRoles lists all system roles +func (s *Store) ListSystemRoles(ctx context.Context) ([]*Role, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, name, description, permissions, is_system_role, hierarchy_level, created_at, updated_at + FROM compliance_roles + WHERE is_system_role = TRUE + ORDER BY hierarchy_level, name + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []*Role + for rows.Next() { + var role Role + err := rows.Scan( + &role.ID, &role.TenantID, &role.Name, &role.Description, + &role.Permissions, &role.IsSystemRole, &role.HierarchyLevel, + &role.CreatedAt, &role.UpdatedAt, + ) + if err != nil { + continue + } + roles = append(roles, &role) + } + + return roles, nil +} + +// ============================================================================ +// User Role Operations +// ============================================================================ + +// AssignRole assigns a role to a user +func (s *Store) AssignRole(ctx context.Context, ur *UserRole) error { + ur.ID = uuid.New() + ur.CreatedAt = time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + INSERT INTO compliance_user_roles (id, user_id, role_id, tenant_id, namespace_id, granted_by, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (user_id, role_id, tenant_id, namespace_id) DO UPDATE SET + granted_by = EXCLUDED.granted_by, + expires_at = EXCLUDED.expires_at + `, ur.ID, ur.UserID, ur.RoleID, ur.TenantID, ur.NamespaceID, ur.GrantedBy, ur.ExpiresAt, ur.CreatedAt) + + return err +} + +// RevokeRole revokes a role from a user +func (s *Store) RevokeRole(ctx context.Context, userID, roleID, tenantID uuid.UUID, namespaceID *uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + DELETE FROM compliance_user_roles + WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3 AND (namespace_id = $4 OR (namespace_id IS NULL AND $4 IS NULL)) + `, userID, roleID, tenantID, namespaceID) + + return err +} + +// GetUserRoles retrieves all roles for a user in a tenant +func (s *Store) GetUserRoles(ctx context.Context, userID, tenantID uuid.UUID) ([]*UserRole, error) { + rows, err := s.pool.Query(ctx, ` + SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at, + r.name as role_name, r.permissions as role_permissions, + n.name as namespace_name + FROM compliance_user_roles ur + JOIN compliance_roles r ON ur.role_id = r.id + LEFT JOIN compliance_namespaces n ON ur.namespace_id = n.id + WHERE ur.user_id = $1 AND ur.tenant_id = $2 + AND (ur.expires_at IS NULL OR ur.expires_at > NOW()) + ORDER BY r.hierarchy_level, r.name + `, userID, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var userRoles []*UserRole + for rows.Next() { + var ur UserRole + var namespaceName *string + + err := rows.Scan( + &ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID, + &ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt, + &ur.RoleName, &ur.RolePermissions, &namespaceName, + ) + if err != nil { + continue + } + + if namespaceName != nil { + ur.NamespaceName = *namespaceName + } + + userRoles = append(userRoles, &ur) + } + + return userRoles, nil +} + +// GetUserRolesForNamespace retrieves roles for a user in a specific namespace +func (s *Store) GetUserRolesForNamespace(ctx context.Context, userID, tenantID uuid.UUID, namespaceID *uuid.UUID) ([]*UserRole, error) { + rows, err := s.pool.Query(ctx, ` + SELECT ur.id, ur.user_id, ur.role_id, ur.tenant_id, ur.namespace_id, ur.granted_by, ur.expires_at, ur.created_at, + r.name as role_name, r.permissions as role_permissions + FROM compliance_user_roles ur + JOIN compliance_roles r ON ur.role_id = r.id + WHERE ur.user_id = $1 AND ur.tenant_id = $2 + AND (ur.namespace_id = $3 OR ur.namespace_id IS NULL) + AND (ur.expires_at IS NULL OR ur.expires_at > NOW()) + ORDER BY r.hierarchy_level, r.name + `, userID, tenantID, namespaceID) + if err != nil { + return nil, err + } + defer rows.Close() + + var userRoles []*UserRole + for rows.Next() { + var ur UserRole + err := rows.Scan( + &ur.ID, &ur.UserID, &ur.RoleID, &ur.TenantID, &ur.NamespaceID, + &ur.GrantedBy, &ur.ExpiresAt, &ur.CreatedAt, + &ur.RoleName, &ur.RolePermissions, + ) + if err != nil { + continue + } + userRoles = append(userRoles, &ur) + } + + return userRoles, nil +} + +// ============================================================================ +// LLM Policy Operations +// ============================================================================ + +// CreateLLMPolicy creates a new LLM policy +func (s *Store) CreateLLMPolicy(ctx context.Context, policy *LLMPolicy) error { + policy.ID = uuid.New() + policy.CreatedAt = time.Now().UTC() + policy.UpdatedAt = policy.CreatedAt + + _, err := s.pool.Exec(ctx, ` + INSERT INTO compliance_llm_policies ( + id, tenant_id, namespace_id, name, description, + allowed_data_categories, blocked_data_categories, + require_pii_redaction, pii_redaction_level, + allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, + is_active, priority, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + `, + policy.ID, policy.TenantID, policy.NamespaceID, policy.Name, policy.Description, + policy.AllowedDataCategories, policy.BlockedDataCategories, + policy.RequirePIIRedaction, policy.PIIRedactionLevel, + policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour, + policy.IsActive, policy.Priority, policy.CreatedAt, policy.UpdatedAt, + ) + + return err +} + +// GetLLMPolicy retrieves an LLM policy by ID +func (s *Store) GetLLMPolicy(ctx context.Context, id uuid.UUID) (*LLMPolicy, error) { + var policy LLMPolicy + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, namespace_id, name, description, + allowed_data_categories, blocked_data_categories, + require_pii_redaction, pii_redaction_level, + allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, + is_active, priority, created_at, updated_at + FROM compliance_llm_policies + WHERE id = $1 + `, id).Scan( + &policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description, + &policy.AllowedDataCategories, &policy.BlockedDataCategories, + &policy.RequirePIIRedaction, &policy.PIIRedactionLevel, + &policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour, + &policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt, + ) + + return &policy, err +} + +// GetEffectiveLLMPolicy retrieves the effective LLM policy for a namespace +func (s *Store) GetEffectiveLLMPolicy(ctx context.Context, tenantID uuid.UUID, namespaceID *uuid.UUID) (*LLMPolicy, error) { + var policy LLMPolicy + + // Get most specific active policy (namespace-specific or tenant-wide) + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, namespace_id, name, description, + allowed_data_categories, blocked_data_categories, + require_pii_redaction, pii_redaction_level, + allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, + is_active, priority, created_at, updated_at + FROM compliance_llm_policies + WHERE tenant_id = $1 + AND is_active = TRUE + AND (namespace_id = $2 OR namespace_id IS NULL) + ORDER BY + CASE WHEN namespace_id = $2 THEN 0 ELSE 1 END, + priority ASC + LIMIT 1 + `, tenantID, namespaceID).Scan( + &policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description, + &policy.AllowedDataCategories, &policy.BlockedDataCategories, + &policy.RequirePIIRedaction, &policy.PIIRedactionLevel, + &policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour, + &policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil // No policy = allow all + } + + return &policy, err +} + +// ListLLMPolicies lists LLM policies for a tenant +func (s *Store) ListLLMPolicies(ctx context.Context, tenantID uuid.UUID) ([]*LLMPolicy, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, namespace_id, name, description, + allowed_data_categories, blocked_data_categories, + require_pii_redaction, pii_redaction_level, + allowed_models, max_tokens_per_request, max_requests_per_day, max_requests_per_hour, + is_active, priority, created_at, updated_at + FROM compliance_llm_policies + WHERE tenant_id = $1 + ORDER BY priority, name + `, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var policies []*LLMPolicy + for rows.Next() { + var policy LLMPolicy + err := rows.Scan( + &policy.ID, &policy.TenantID, &policy.NamespaceID, &policy.Name, &policy.Description, + &policy.AllowedDataCategories, &policy.BlockedDataCategories, + &policy.RequirePIIRedaction, &policy.PIIRedactionLevel, + &policy.AllowedModels, &policy.MaxTokensPerRequest, &policy.MaxRequestsPerDay, &policy.MaxRequestsPerHour, + &policy.IsActive, &policy.Priority, &policy.CreatedAt, &policy.UpdatedAt, + ) + if err != nil { + continue + } + policies = append(policies, &policy) + } + + return policies, nil +} + +// UpdateLLMPolicy updates an LLM policy +func (s *Store) UpdateLLMPolicy(ctx context.Context, policy *LLMPolicy) error { + policy.UpdatedAt = time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + UPDATE compliance_llm_policies SET + name = $2, description = $3, + allowed_data_categories = $4, blocked_data_categories = $5, + require_pii_redaction = $6, pii_redaction_level = $7, + allowed_models = $8, max_tokens_per_request = $9, max_requests_per_day = $10, max_requests_per_hour = $11, + is_active = $12, priority = $13, updated_at = $14 + WHERE id = $1 + `, + policy.ID, policy.Name, policy.Description, + policy.AllowedDataCategories, policy.BlockedDataCategories, + policy.RequirePIIRedaction, policy.PIIRedactionLevel, + policy.AllowedModels, policy.MaxTokensPerRequest, policy.MaxRequestsPerDay, policy.MaxRequestsPerHour, + policy.IsActive, policy.Priority, policy.UpdatedAt, + ) + + return err +} + +// DeleteLLMPolicy deletes an LLM policy +func (s *Store) DeleteLLMPolicy(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, `DELETE FROM compliance_llm_policies WHERE id = $1`, id) + return err +} diff --git a/ai-compliance-sdk/internal/roadmap/models.go b/ai-compliance-sdk/internal/roadmap/models.go new file mode 100644 index 0000000..36f6966 --- /dev/null +++ b/ai-compliance-sdk/internal/roadmap/models.go @@ -0,0 +1,308 @@ +package roadmap + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Constants / Enums +// ============================================================================ + +// ItemStatus represents the implementation status of a roadmap item +type ItemStatus string + +const ( + ItemStatusPlanned ItemStatus = "PLANNED" + ItemStatusInProgress ItemStatus = "IN_PROGRESS" + ItemStatusBlocked ItemStatus = "BLOCKED" + ItemStatusCompleted ItemStatus = "COMPLETED" + ItemStatusDeferred ItemStatus = "DEFERRED" +) + +// ItemPriority represents the priority of a roadmap item +type ItemPriority string + +const ( + ItemPriorityCritical ItemPriority = "CRITICAL" + ItemPriorityHigh ItemPriority = "HIGH" + ItemPriorityMedium ItemPriority = "MEDIUM" + ItemPriorityLow ItemPriority = "LOW" +) + +// ItemCategory represents the category of a roadmap item +type ItemCategory string + +const ( + ItemCategoryTechnical ItemCategory = "TECHNICAL" + ItemCategoryOrganizational ItemCategory = "ORGANIZATIONAL" + ItemCategoryProcessual ItemCategory = "PROCESSUAL" + ItemCategoryDocumentation ItemCategory = "DOCUMENTATION" + ItemCategoryTraining ItemCategory = "TRAINING" +) + +// ImportFormat represents supported import file formats +type ImportFormat string + +const ( + ImportFormatExcel ImportFormat = "EXCEL" + ImportFormatCSV ImportFormat = "CSV" + ImportFormatJSON ImportFormat = "JSON" +) + +// ============================================================================ +// Main Entities +// ============================================================================ + +// Roadmap represents a compliance implementation roadmap +type Roadmap struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + + Title string `json:"title"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + + // Linked entities + AssessmentID *uuid.UUID `json:"assessment_id,omitempty"` // Link to UCCA assessment + PortfolioID *uuid.UUID `json:"portfolio_id,omitempty"` // Link to use case portfolio + + // Status tracking + Status string `json:"status"` // "draft", "active", "completed", "archived" + TotalItems int `json:"total_items"` + CompletedItems int `json:"completed_items"` + Progress int `json:"progress"` // Percentage 0-100 + + // Dates + StartDate *time.Time `json:"start_date,omitempty"` + TargetDate *time.Time `json:"target_date,omitempty"` + + // Audit + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` +} + +// RoadmapItem represents a single item in the compliance roadmap +type RoadmapItem struct { + ID uuid.UUID `json:"id"` + RoadmapID uuid.UUID `json:"roadmap_id"` + + // Core fields + Title string `json:"title"` + Description string `json:"description,omitempty"` + Category ItemCategory `json:"category"` + Priority ItemPriority `json:"priority"` + Status ItemStatus `json:"status"` + + // Compliance mapping + ControlID string `json:"control_id,omitempty"` // e.g., "CTRL-AVV" + RegulationRef string `json:"regulation_ref,omitempty"` // e.g., "DSGVO Art. 28" + GapID string `json:"gap_id,omitempty"` // e.g., "GAP_AVV_MISSING" + + // Effort estimation + EffortDays *int `json:"effort_days,omitempty"` + EffortHours *int `json:"effort_hours,omitempty"` + EstimatedCost *int `json:"estimated_cost,omitempty"` // EUR + + // Assignment + AssigneeID *uuid.UUID `json:"assignee_id,omitempty"` + AssigneeName string `json:"assignee_name,omitempty"` + Department string `json:"department,omitempty"` + + // Timeline + PlannedStart *time.Time `json:"planned_start,omitempty"` + PlannedEnd *time.Time `json:"planned_end,omitempty"` + ActualStart *time.Time `json:"actual_start,omitempty"` + ActualEnd *time.Time `json:"actual_end,omitempty"` + + // Dependencies + DependsOn []uuid.UUID `json:"depends_on,omitempty"` // IDs of items this depends on + BlockedBy []uuid.UUID `json:"blocked_by,omitempty"` // IDs of blocking items + + // Evidence + EvidenceRequired []string `json:"evidence_required,omitempty"` + EvidenceProvided []string `json:"evidence_provided,omitempty"` + + // Notes + Notes string `json:"notes,omitempty"` + RiskNotes string `json:"risk_notes,omitempty"` + + // Import metadata + SourceRow int `json:"source_row,omitempty"` // Row number from import file + SourceFile string `json:"source_file,omitempty"` // Original filename + + // Ordering + SortOrder int `json:"sort_order"` + + // Audit + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ============================================================================ +// Import/Export Structures +// ============================================================================ + +// ImportJob represents an import job +type ImportJob struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + RoadmapID *uuid.UUID `json:"roadmap_id,omitempty"` // Target roadmap (nil = create new) + + // File info + Filename string `json:"filename"` + Format ImportFormat `json:"format"` + FileSize int64 `json:"file_size"` + ContentType string `json:"content_type"` + + // Status + Status string `json:"status"` // "pending", "parsing", "validating", "completed", "failed" + ErrorMessage string `json:"error_message,omitempty"` + + // Parsing results + TotalRows int `json:"total_rows"` + ValidRows int `json:"valid_rows"` + InvalidRows int `json:"invalid_rows"` + ImportedItems int `json:"imported_items"` + + // Parsed items (before confirmation) + ParsedItems []ParsedItem `json:"parsed_items,omitempty"` + + // Audit + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedBy uuid.UUID `json:"created_by"` +} + +// ParsedItem represents a single parsed item from import +type ParsedItem struct { + RowNumber int `json:"row_number"` + IsValid bool `json:"is_valid"` + Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` + + // Extracted data + Data RoadmapItemInput `json:"data"` + + // Auto-mapping results + MatchedControl string `json:"matched_control,omitempty"` + MatchedRegulation string `json:"matched_regulation,omitempty"` + MatchedGap string `json:"matched_gap,omitempty"` + MatchConfidence float64 `json:"match_confidence,omitempty"` // 0.0 - 1.0 +} + +// RoadmapItemInput represents input for creating/updating a roadmap item +type RoadmapItemInput struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + Category ItemCategory `json:"category,omitempty"` + Priority ItemPriority `json:"priority,omitempty"` + Status ItemStatus `json:"status,omitempty"` + + ControlID string `json:"control_id,omitempty"` + RegulationRef string `json:"regulation_ref,omitempty"` + GapID string `json:"gap_id,omitempty"` + + EffortDays *int `json:"effort_days,omitempty"` + AssigneeName string `json:"assignee_name,omitempty"` + Department string `json:"department,omitempty"` + + PlannedStart *time.Time `json:"planned_start,omitempty"` + PlannedEnd *time.Time `json:"planned_end,omitempty"` + + Notes string `json:"notes,omitempty"` +} + +// ============================================================================ +// API Request/Response Types +// ============================================================================ + +// CreateRoadmapRequest is the API request for creating a roadmap +type CreateRoadmapRequest struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + AssessmentID *uuid.UUID `json:"assessment_id,omitempty"` + PortfolioID *uuid.UUID `json:"portfolio_id,omitempty"` + StartDate *time.Time `json:"start_date,omitempty"` + TargetDate *time.Time `json:"target_date,omitempty"` +} + +// CreateRoadmapResponse is the API response for creating a roadmap +type CreateRoadmapResponse struct { + Roadmap Roadmap `json:"roadmap"` +} + +// ImportUploadResponse is returned after uploading a file for import +type ImportUploadResponse struct { + JobID uuid.UUID `json:"job_id"` + Filename string `json:"filename"` + Format string `json:"format"` + Status string `json:"status"` + Message string `json:"message"` +} + +// ImportParseResponse is returned after parsing the uploaded file +type ImportParseResponse struct { + JobID uuid.UUID `json:"job_id"` + Status string `json:"status"` + TotalRows int `json:"total_rows"` + ValidRows int `json:"valid_rows"` + InvalidRows int `json:"invalid_rows"` + Items []ParsedItem `json:"items"` + ColumnMap map[string]string `json:"column_map"` // Detected column mappings +} + +// ImportConfirmRequest is the request to confirm and execute import +type ImportConfirmRequest struct { + JobID uuid.UUID `json:"job_id"` + RoadmapID *uuid.UUID `json:"roadmap_id,omitempty"` // Target roadmap (nil = create new) + RoadmapTitle string `json:"roadmap_title,omitempty"` // If creating new + SelectedRows []int `json:"selected_rows,omitempty"` // Specific rows to import (nil = all valid) + ApplyMappings bool `json:"apply_mappings"` // Apply auto-detected control/regulation mappings +} + +// ImportConfirmResponse is returned after confirming import +type ImportConfirmResponse struct { + RoadmapID uuid.UUID `json:"roadmap_id"` + ImportedItems int `json:"imported_items"` + SkippedItems int `json:"skipped_items"` + Message string `json:"message"` +} + +// RoadmapFilters defines filters for listing roadmaps +type RoadmapFilters struct { + Status string + AssessmentID *uuid.UUID + PortfolioID *uuid.UUID + Limit int + Offset int +} + +// RoadmapItemFilters defines filters for listing roadmap items +type RoadmapItemFilters struct { + Status ItemStatus + Priority ItemPriority + Category ItemCategory + AssigneeID *uuid.UUID + ControlID string + SearchQuery string + Limit int + Offset int +} + +// RoadmapStats contains statistics for a roadmap +type RoadmapStats struct { + TotalItems int `json:"total_items"` + ByStatus map[string]int `json:"by_status"` + ByPriority map[string]int `json:"by_priority"` + ByCategory map[string]int `json:"by_category"` + ByDepartment map[string]int `json:"by_department"` + OverdueItems int `json:"overdue_items"` + UpcomingItems int `json:"upcoming_items"` // Due in next 7 days + TotalEffortDays int `json:"total_effort_days"` + Progress int `json:"progress"` +} diff --git a/ai-compliance-sdk/internal/roadmap/parser.go b/ai-compliance-sdk/internal/roadmap/parser.go new file mode 100644 index 0000000..4aca6fc --- /dev/null +++ b/ai-compliance-sdk/internal/roadmap/parser.go @@ -0,0 +1,540 @@ +package roadmap + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/xuri/excelize/v2" +) + +// Parser handles file parsing for roadmap imports +type Parser struct{} + +// NewParser creates a new parser +func NewParser() *Parser { + return &Parser{} +} + +// ColumnMapping defines expected column names and their variations +var ColumnMapping = map[string][]string{ + "title": {"title", "titel", "name", "bezeichnung", "massnahme", "maßnahme", "aufgabe", "task"}, + "description": {"description", "beschreibung", "details", "inhalt", "content"}, + "category": {"category", "kategorie", "bereich", "type", "typ"}, + "priority": {"priority", "priorität", "prioritaet", "prio", "dringlichkeit"}, + "status": {"status", "stand", "zustand"}, + "control_id": {"control_id", "control", "kontrolle", "massnahme_id", "ctrl"}, + "regulation_ref": {"regulation", "regulation_ref", "verordnung", "gesetz", "artikel", "article", "gdpr_ref"}, + "gap_id": {"gap_id", "gap", "luecke", "lücke"}, + "effort_days": {"effort_days", "effort", "aufwand", "tage", "days", "pt", "personentage"}, + "assignee": {"assignee", "verantwortlich", "zustaendig", "zuständig", "owner", "responsible"}, + "department": {"department", "abteilung", "bereich", "team"}, + "planned_start": {"planned_start", "start", "beginn", "startdatum", "start_date"}, + "planned_end": {"planned_end", "end", "ende", "enddatum", "end_date", "deadline", "frist"}, + "notes": {"notes", "notizen", "bemerkungen", "kommentar", "comment", "anmerkungen"}, +} + +// DetectedColumn represents a detected column mapping +type DetectedColumn struct { + Index int `json:"index"` + Header string `json:"header"` + MappedTo string `json:"mapped_to"` + Confidence float64 `json:"confidence"` +} + +// ParseResult contains the result of parsing a file +type ParseResult struct { + Format ImportFormat `json:"format"` + TotalRows int `json:"total_rows"` + ValidRows int `json:"valid_rows"` + InvalidRows int `json:"invalid_rows"` + Columns []DetectedColumn `json:"columns"` + Items []ParsedItem `json:"items"` + Errors []string `json:"errors"` +} + +// ParseFile detects format and parses the file +func (p *Parser) ParseFile(data []byte, filename string, contentType string) (*ParseResult, error) { + format := p.detectFormat(filename, contentType) + + switch format { + case ImportFormatExcel: + return p.parseExcel(data) + case ImportFormatCSV: + return p.parseCSV(data) + case ImportFormatJSON: + return p.parseJSON(data) + default: + return nil, fmt.Errorf("unsupported file format: %s", filename) + } +} + +// detectFormat detects the file format +func (p *Parser) detectFormat(filename string, contentType string) ImportFormat { + filename = strings.ToLower(filename) + + if strings.HasSuffix(filename, ".xlsx") || strings.HasSuffix(filename, ".xls") { + return ImportFormatExcel + } + if strings.HasSuffix(filename, ".csv") { + return ImportFormatCSV + } + if strings.HasSuffix(filename, ".json") { + return ImportFormatJSON + } + + // Check content type + switch contentType { + case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-excel": + return ImportFormatExcel + case "text/csv": + return ImportFormatCSV + case "application/json": + return ImportFormatJSON + } + + return "" +} + +// parseExcel parses an Excel file +func (p *Parser) parseExcel(data []byte) (*ParseResult, error) { + result := &ParseResult{ + Format: ImportFormatExcel, + } + + f, err := excelize.OpenReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("failed to open Excel file: %w", err) + } + defer f.Close() + + // Get the first sheet + sheets := f.GetSheetList() + if len(sheets) == 0 { + return nil, fmt.Errorf("no sheets found in Excel file") + } + + rows, err := f.GetRows(sheets[0]) + if err != nil { + return nil, fmt.Errorf("failed to read rows: %w", err) + } + + if len(rows) < 2 { + return nil, fmt.Errorf("file must have at least a header row and one data row") + } + + // Detect column mappings from header + headers := rows[0] + result.Columns = p.detectColumns(headers) + + // Parse data rows + for i, row := range rows[1:] { + rowNum := i + 2 // 1-based, skip header + item := p.parseRow(row, result.Columns, rowNum) + result.Items = append(result.Items, item) + result.TotalRows++ + if item.IsValid { + result.ValidRows++ + } else { + result.InvalidRows++ + } + } + + return result, nil +} + +// parseCSV parses a CSV file +func (p *Parser) parseCSV(data []byte) (*ParseResult, error) { + result := &ParseResult{ + Format: ImportFormatCSV, + } + + reader := csv.NewReader(bytes.NewReader(data)) + reader.LazyQuotes = true + reader.TrimLeadingSpace = true + + // Try different delimiters + delimiters := []rune{',', ';', '\t'} + var records [][]string + var err error + + for _, delim := range delimiters { + reader = csv.NewReader(bytes.NewReader(data)) + reader.Comma = delim + reader.LazyQuotes = true + + records, err = reader.ReadAll() + if err == nil && len(records) > 0 && len(records[0]) > 1 { + break + } + } + + if err != nil { + return nil, fmt.Errorf("failed to parse CSV: %w", err) + } + + if len(records) < 2 { + return nil, fmt.Errorf("file must have at least a header row and one data row") + } + + // Detect column mappings from header + headers := records[0] + result.Columns = p.detectColumns(headers) + + // Parse data rows + for i, row := range records[1:] { + rowNum := i + 2 + item := p.parseRow(row, result.Columns, rowNum) + result.Items = append(result.Items, item) + result.TotalRows++ + if item.IsValid { + result.ValidRows++ + } else { + result.InvalidRows++ + } + } + + return result, nil +} + +// parseJSON parses a JSON file +func (p *Parser) parseJSON(data []byte) (*ParseResult, error) { + result := &ParseResult{ + Format: ImportFormatJSON, + } + + // Try parsing as array of items + var items []map[string]interface{} + if err := json.Unmarshal(data, &items); err != nil { + // Try parsing as object with items array + var wrapper struct { + Items []map[string]interface{} `json:"items"` + } + if err := json.Unmarshal(data, &wrapper); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + items = wrapper.Items + } + + if len(items) == 0 { + return nil, fmt.Errorf("no items found in JSON file") + } + + // Detect columns from first item + headers := make([]string, 0) + for key := range items[0] { + headers = append(headers, key) + } + result.Columns = p.detectColumns(headers) + + // Parse items + for i, itemMap := range items { + rowNum := i + 1 + + // Convert map to row slice + row := make([]string, len(result.Columns)) + for j, col := range result.Columns { + if val, ok := itemMap[col.Header]; ok { + row[j] = fmt.Sprintf("%v", val) + } + } + + item := p.parseRow(row, result.Columns, rowNum) + result.Items = append(result.Items, item) + result.TotalRows++ + if item.IsValid { + result.ValidRows++ + } else { + result.InvalidRows++ + } + } + + return result, nil +} + +// detectColumns detects column mappings from headers +func (p *Parser) detectColumns(headers []string) []DetectedColumn { + columns := make([]DetectedColumn, len(headers)) + + for i, header := range headers { + columns[i] = DetectedColumn{ + Index: i, + Header: header, + Confidence: 0, + } + + headerLower := strings.ToLower(strings.TrimSpace(header)) + + // Try to match against known column names + for fieldName, variations := range ColumnMapping { + for _, variation := range variations { + if headerLower == variation || strings.Contains(headerLower, variation) { + if headerLower == variation { + columns[i].MappedTo = fieldName + columns[i].Confidence = 1.0 + } else if columns[i].Confidence < 0.8 { + columns[i].MappedTo = fieldName + columns[i].Confidence = 0.8 + } + break + } + } + if columns[i].Confidence >= 1.0 { + break + } + } + } + + return columns +} + +// parseRow parses a single row into a ParsedItem +func (p *Parser) parseRow(row []string, columns []DetectedColumn, rowNum int) ParsedItem { + item := ParsedItem{ + RowNumber: rowNum, + IsValid: true, + Data: RoadmapItemInput{}, + } + + // Build a map for easy access + values := make(map[string]string) + for i, col := range columns { + if i < len(row) && col.MappedTo != "" { + values[col.MappedTo] = strings.TrimSpace(row[i]) + } + } + + // Extract title (required) + if title, ok := values["title"]; ok && title != "" { + item.Data.Title = title + } else { + item.IsValid = false + item.Errors = append(item.Errors, "Titel/Title ist erforderlich") + } + + // Extract optional fields + if desc, ok := values["description"]; ok { + item.Data.Description = desc + } + + // Category + if cat, ok := values["category"]; ok && cat != "" { + item.Data.Category = p.parseCategory(cat) + if item.Data.Category == "" { + item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Kategorie: %s", cat)) + item.Data.Category = ItemCategoryTechnical + } + } + + // Priority + if prio, ok := values["priority"]; ok && prio != "" { + item.Data.Priority = p.parsePriority(prio) + if item.Data.Priority == "" { + item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannte Priorität: %s", prio)) + item.Data.Priority = ItemPriorityMedium + } + } + + // Status + if status, ok := values["status"]; ok && status != "" { + item.Data.Status = p.parseStatus(status) + if item.Data.Status == "" { + item.Warnings = append(item.Warnings, fmt.Sprintf("Unbekannter Status: %s", status)) + item.Data.Status = ItemStatusPlanned + } + } + + // Control ID + if ctrl, ok := values["control_id"]; ok { + item.Data.ControlID = ctrl + } + + // Regulation reference + if reg, ok := values["regulation_ref"]; ok { + item.Data.RegulationRef = reg + } + + // Gap ID + if gap, ok := values["gap_id"]; ok { + item.Data.GapID = gap + } + + // Effort + if effort, ok := values["effort_days"]; ok && effort != "" { + if days, err := strconv.Atoi(effort); err == nil { + item.Data.EffortDays = &days + } + } + + // Assignee + if assignee, ok := values["assignee"]; ok { + item.Data.AssigneeName = assignee + } + + // Department + if dept, ok := values["department"]; ok { + item.Data.Department = dept + } + + // Dates + if startStr, ok := values["planned_start"]; ok && startStr != "" { + if start := p.parseDate(startStr); start != nil { + item.Data.PlannedStart = start + } + } + + if endStr, ok := values["planned_end"]; ok && endStr != "" { + if end := p.parseDate(endStr); end != nil { + item.Data.PlannedEnd = end + } + } + + // Notes + if notes, ok := values["notes"]; ok { + item.Data.Notes = notes + } + + return item +} + +// parseCategory converts a string to ItemCategory +func (p *Parser) parseCategory(s string) ItemCategory { + s = strings.ToLower(strings.TrimSpace(s)) + + switch { + case strings.Contains(s, "tech"): + return ItemCategoryTechnical + case strings.Contains(s, "org"): + return ItemCategoryOrganizational + case strings.Contains(s, "proz") || strings.Contains(s, "process"): + return ItemCategoryProcessual + case strings.Contains(s, "dok") || strings.Contains(s, "doc"): + return ItemCategoryDocumentation + case strings.Contains(s, "train") || strings.Contains(s, "schul"): + return ItemCategoryTraining + default: + return "" + } +} + +// parsePriority converts a string to ItemPriority +func (p *Parser) parsePriority(s string) ItemPriority { + s = strings.ToLower(strings.TrimSpace(s)) + + switch { + case strings.Contains(s, "crit") || strings.Contains(s, "krit") || s == "1": + return ItemPriorityCritical + case strings.Contains(s, "high") || strings.Contains(s, "hoch") || s == "2": + return ItemPriorityHigh + case strings.Contains(s, "med") || strings.Contains(s, "mitt") || s == "3": + return ItemPriorityMedium + case strings.Contains(s, "low") || strings.Contains(s, "nied") || s == "4": + return ItemPriorityLow + default: + return "" + } +} + +// parseStatus converts a string to ItemStatus +func (p *Parser) parseStatus(s string) ItemStatus { + s = strings.ToLower(strings.TrimSpace(s)) + + switch { + case strings.Contains(s, "plan") || strings.Contains(s, "offen") || strings.Contains(s, "open"): + return ItemStatusPlanned + case strings.Contains(s, "progress") || strings.Contains(s, "lauf") || strings.Contains(s, "arbeit"): + return ItemStatusInProgress + case strings.Contains(s, "block") || strings.Contains(s, "wart"): + return ItemStatusBlocked + case strings.Contains(s, "complet") || strings.Contains(s, "done") || strings.Contains(s, "fertig") || strings.Contains(s, "erledigt"): + return ItemStatusCompleted + case strings.Contains(s, "defer") || strings.Contains(s, "zurück") || strings.Contains(s, "verschob"): + return ItemStatusDeferred + default: + return "" + } +} + +// parseDate attempts to parse various date formats +func (p *Parser) parseDate(s string) *time.Time { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + + formats := []string{ + "2006-01-02", + "02.01.2006", + "2.1.2006", + "02/01/2006", + "2/1/2006", + "01/02/2006", + "1/2/2006", + "2006/01/02", + time.RFC3339, + } + + for _, format := range formats { + if t, err := time.Parse(format, s); err == nil { + return &t + } + } + + return nil +} + +// ValidateAndEnrich validates parsed items and enriches them with mappings +func (p *Parser) ValidateAndEnrich(items []ParsedItem, controls []string, regulations []string, gaps []string) []ParsedItem { + // Build lookup maps + controlSet := make(map[string]bool) + for _, c := range controls { + controlSet[strings.ToLower(c)] = true + } + + regSet := make(map[string]bool) + for _, r := range regulations { + regSet[strings.ToLower(r)] = true + } + + gapSet := make(map[string]bool) + for _, g := range gaps { + gapSet[strings.ToLower(g)] = true + } + + for i := range items { + item := &items[i] + + // Validate control ID + if item.Data.ControlID != "" { + if controlSet[strings.ToLower(item.Data.ControlID)] { + item.MatchedControl = item.Data.ControlID + item.MatchConfidence = 1.0 + } else { + item.Warnings = append(item.Warnings, fmt.Sprintf("Control '%s' nicht im Katalog gefunden", item.Data.ControlID)) + } + } + + // Validate regulation reference + if item.Data.RegulationRef != "" { + if regSet[strings.ToLower(item.Data.RegulationRef)] { + item.MatchedRegulation = item.Data.RegulationRef + } + } + + // Validate gap ID + if item.Data.GapID != "" { + if gapSet[strings.ToLower(item.Data.GapID)] { + item.MatchedGap = item.Data.GapID + } else { + item.Warnings = append(item.Warnings, fmt.Sprintf("Gap '%s' nicht im Mapping gefunden", item.Data.GapID)) + } + } + } + + return items +} diff --git a/ai-compliance-sdk/internal/roadmap/store.go b/ai-compliance-sdk/internal/roadmap/store.go new file mode 100644 index 0000000..e5b76fb --- /dev/null +++ b/ai-compliance-sdk/internal/roadmap/store.go @@ -0,0 +1,757 @@ +package roadmap + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store handles roadmap data persistence +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new roadmap store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// ============================================================================ +// Roadmap CRUD Operations +// ============================================================================ + +// CreateRoadmap creates a new roadmap +func (s *Store) CreateRoadmap(ctx context.Context, r *Roadmap) error { + r.ID = uuid.New() + r.CreatedAt = time.Now().UTC() + r.UpdatedAt = r.CreatedAt + if r.Status == "" { + r.Status = "draft" + } + if r.Version == "" { + r.Version = "1.0" + } + + _, err := s.pool.Exec(ctx, ` + INSERT INTO roadmaps ( + id, tenant_id, namespace_id, title, description, version, + assessment_id, portfolio_id, status, + total_items, completed_items, progress, + start_date, target_date, + created_at, updated_at, created_by + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, + $10, $11, $12, + $13, $14, + $15, $16, $17 + ) + `, + r.ID, r.TenantID, r.NamespaceID, r.Title, r.Description, r.Version, + r.AssessmentID, r.PortfolioID, r.Status, + r.TotalItems, r.CompletedItems, r.Progress, + r.StartDate, r.TargetDate, + r.CreatedAt, r.UpdatedAt, r.CreatedBy, + ) + + return err +} + +// GetRoadmap retrieves a roadmap by ID +func (s *Store) GetRoadmap(ctx context.Context, id uuid.UUID) (*Roadmap, error) { + var r Roadmap + + err := s.pool.QueryRow(ctx, ` + SELECT + id, tenant_id, namespace_id, title, description, version, + assessment_id, portfolio_id, status, + total_items, completed_items, progress, + start_date, target_date, + created_at, updated_at, created_by + FROM roadmaps WHERE id = $1 + `, id).Scan( + &r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version, + &r.AssessmentID, &r.PortfolioID, &r.Status, + &r.TotalItems, &r.CompletedItems, &r.Progress, + &r.StartDate, &r.TargetDate, + &r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + return &r, nil +} + +// ListRoadmaps lists roadmaps for a tenant with optional filters +func (s *Store) ListRoadmaps(ctx context.Context, tenantID uuid.UUID, filters *RoadmapFilters) ([]Roadmap, error) { + query := ` + SELECT + id, tenant_id, namespace_id, title, description, version, + assessment_id, portfolio_id, status, + total_items, completed_items, progress, + start_date, target_date, + created_at, updated_at, created_by + FROM roadmaps 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, filters.Status) + argIdx++ + } + if filters.AssessmentID != nil { + query += fmt.Sprintf(" AND assessment_id = $%d", argIdx) + args = append(args, *filters.AssessmentID) + argIdx++ + } + if filters.PortfolioID != nil { + query += fmt.Sprintf(" AND portfolio_id = $%d", argIdx) + args = append(args, *filters.PortfolioID) + 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) + } + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var roadmaps []Roadmap + for rows.Next() { + var r Roadmap + err := rows.Scan( + &r.ID, &r.TenantID, &r.NamespaceID, &r.Title, &r.Description, &r.Version, + &r.AssessmentID, &r.PortfolioID, &r.Status, + &r.TotalItems, &r.CompletedItems, &r.Progress, + &r.StartDate, &r.TargetDate, + &r.CreatedAt, &r.UpdatedAt, &r.CreatedBy, + ) + if err != nil { + return nil, err + } + roadmaps = append(roadmaps, r) + } + + return roadmaps, nil +} + +// UpdateRoadmap updates a roadmap +func (s *Store) UpdateRoadmap(ctx context.Context, r *Roadmap) error { + r.UpdatedAt = time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + UPDATE roadmaps SET + title = $2, description = $3, version = $4, + assessment_id = $5, portfolio_id = $6, status = $7, + total_items = $8, completed_items = $9, progress = $10, + start_date = $11, target_date = $12, + updated_at = $13 + WHERE id = $1 + `, + r.ID, r.Title, r.Description, r.Version, + r.AssessmentID, r.PortfolioID, r.Status, + r.TotalItems, r.CompletedItems, r.Progress, + r.StartDate, r.TargetDate, + r.UpdatedAt, + ) + + return err +} + +// DeleteRoadmap deletes a roadmap and its items +func (s *Store) DeleteRoadmap(ctx context.Context, id uuid.UUID) error { + // Delete items first + _, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE roadmap_id = $1", id) + if err != nil { + return err + } + + // Delete roadmap + _, err = s.pool.Exec(ctx, "DELETE FROM roadmaps WHERE id = $1", id) + return err +} + +// ============================================================================ +// RoadmapItem CRUD Operations +// ============================================================================ + +// CreateItem creates a new roadmap item +func (s *Store) CreateItem(ctx context.Context, item *RoadmapItem) error { + item.ID = uuid.New() + item.CreatedAt = time.Now().UTC() + item.UpdatedAt = item.CreatedAt + if item.Status == "" { + item.Status = ItemStatusPlanned + } + if item.Priority == "" { + item.Priority = ItemPriorityMedium + } + if item.Category == "" { + item.Category = ItemCategoryTechnical + } + + dependsOn, _ := json.Marshal(item.DependsOn) + blockedBy, _ := json.Marshal(item.BlockedBy) + evidenceReq, _ := json.Marshal(item.EvidenceRequired) + evidenceProv, _ := json.Marshal(item.EvidenceProvided) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO roadmap_items ( + id, roadmap_id, title, description, category, priority, status, + control_id, regulation_ref, gap_id, + effort_days, effort_hours, estimated_cost, + assignee_id, assignee_name, department, + planned_start, planned_end, actual_start, actual_end, + depends_on, blocked_by, + evidence_required, evidence_provided, + notes, risk_notes, + source_row, source_file, sort_order, + 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, + $23, $24, + $25, $26, + $27, $28, $29, + $30, $31 + ) + `, + item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), + item.ControlID, item.RegulationRef, item.GapID, + item.EffortDays, item.EffortHours, item.EstimatedCost, + item.AssigneeID, item.AssigneeName, item.Department, + item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, + dependsOn, blockedBy, + evidenceReq, evidenceProv, + item.Notes, item.RiskNotes, + item.SourceRow, item.SourceFile, item.SortOrder, + item.CreatedAt, item.UpdatedAt, + ) + + return err +} + +// GetItem retrieves a roadmap item by ID +func (s *Store) GetItem(ctx context.Context, id uuid.UUID) (*RoadmapItem, error) { + var item RoadmapItem + var category, priority, status string + var dependsOn, blockedBy, evidenceReq, evidenceProv []byte + + err := s.pool.QueryRow(ctx, ` + SELECT + id, roadmap_id, title, description, category, priority, status, + control_id, regulation_ref, gap_id, + effort_days, effort_hours, estimated_cost, + assignee_id, assignee_name, department, + planned_start, planned_end, actual_start, actual_end, + depends_on, blocked_by, + evidence_required, evidence_provided, + notes, risk_notes, + source_row, source_file, sort_order, + created_at, updated_at + FROM roadmap_items WHERE id = $1 + `, id).Scan( + &item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status, + &item.ControlID, &item.RegulationRef, &item.GapID, + &item.EffortDays, &item.EffortHours, &item.EstimatedCost, + &item.AssigneeID, &item.AssigneeName, &item.Department, + &item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd, + &dependsOn, &blockedBy, + &evidenceReq, &evidenceProv, + &item.Notes, &item.RiskNotes, + &item.SourceRow, &item.SourceFile, &item.SortOrder, + &item.CreatedAt, &item.UpdatedAt, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + item.Category = ItemCategory(category) + item.Priority = ItemPriority(priority) + item.Status = ItemStatus(status) + json.Unmarshal(dependsOn, &item.DependsOn) + json.Unmarshal(blockedBy, &item.BlockedBy) + json.Unmarshal(evidenceReq, &item.EvidenceRequired) + json.Unmarshal(evidenceProv, &item.EvidenceProvided) + + return &item, nil +} + +// ListItems lists items for a roadmap with optional filters +func (s *Store) ListItems(ctx context.Context, roadmapID uuid.UUID, filters *RoadmapItemFilters) ([]RoadmapItem, error) { + query := ` + SELECT + id, roadmap_id, title, description, category, priority, status, + control_id, regulation_ref, gap_id, + effort_days, effort_hours, estimated_cost, + assignee_id, assignee_name, department, + planned_start, planned_end, actual_start, actual_end, + depends_on, blocked_by, + evidence_required, evidence_provided, + notes, risk_notes, + source_row, source_file, sort_order, + created_at, updated_at + FROM roadmap_items WHERE roadmap_id = $1` + + args := []interface{}{roadmapID} + argIdx := 2 + + if filters != nil { + if filters.Status != "" { + query += fmt.Sprintf(" AND status = $%d", argIdx) + args = append(args, string(filters.Status)) + argIdx++ + } + if filters.Priority != "" { + query += fmt.Sprintf(" AND priority = $%d", argIdx) + args = append(args, string(filters.Priority)) + argIdx++ + } + if filters.Category != "" { + query += fmt.Sprintf(" AND category = $%d", argIdx) + args = append(args, string(filters.Category)) + argIdx++ + } + if filters.AssigneeID != nil { + query += fmt.Sprintf(" AND assignee_id = $%d", argIdx) + args = append(args, *filters.AssigneeID) + argIdx++ + } + if filters.ControlID != "" { + query += fmt.Sprintf(" AND control_id = $%d", argIdx) + args = append(args, filters.ControlID) + argIdx++ + } + if filters.SearchQuery != "" { + query += fmt.Sprintf(" AND (title ILIKE $%d OR description ILIKE $%d)", argIdx, argIdx) + args = append(args, "%"+filters.SearchQuery+"%") + argIdx++ + } + } + + query += " ORDER BY sort_order ASC, priority ASC, created_at ASC" + + 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) + } + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []RoadmapItem + for rows.Next() { + var item RoadmapItem + var category, priority, status string + var dependsOn, blockedBy, evidenceReq, evidenceProv []byte + + err := rows.Scan( + &item.ID, &item.RoadmapID, &item.Title, &item.Description, &category, &priority, &status, + &item.ControlID, &item.RegulationRef, &item.GapID, + &item.EffortDays, &item.EffortHours, &item.EstimatedCost, + &item.AssigneeID, &item.AssigneeName, &item.Department, + &item.PlannedStart, &item.PlannedEnd, &item.ActualStart, &item.ActualEnd, + &dependsOn, &blockedBy, + &evidenceReq, &evidenceProv, + &item.Notes, &item.RiskNotes, + &item.SourceRow, &item.SourceFile, &item.SortOrder, + &item.CreatedAt, &item.UpdatedAt, + ) + if err != nil { + return nil, err + } + + item.Category = ItemCategory(category) + item.Priority = ItemPriority(priority) + item.Status = ItemStatus(status) + json.Unmarshal(dependsOn, &item.DependsOn) + json.Unmarshal(blockedBy, &item.BlockedBy) + json.Unmarshal(evidenceReq, &item.EvidenceRequired) + json.Unmarshal(evidenceProv, &item.EvidenceProvided) + + items = append(items, item) + } + + return items, nil +} + +// UpdateItem updates a roadmap item +func (s *Store) UpdateItem(ctx context.Context, item *RoadmapItem) error { + item.UpdatedAt = time.Now().UTC() + + dependsOn, _ := json.Marshal(item.DependsOn) + blockedBy, _ := json.Marshal(item.BlockedBy) + evidenceReq, _ := json.Marshal(item.EvidenceRequired) + evidenceProv, _ := json.Marshal(item.EvidenceProvided) + + _, err := s.pool.Exec(ctx, ` + UPDATE roadmap_items SET + title = $2, description = $3, category = $4, priority = $5, status = $6, + control_id = $7, regulation_ref = $8, gap_id = $9, + effort_days = $10, effort_hours = $11, estimated_cost = $12, + assignee_id = $13, assignee_name = $14, department = $15, + planned_start = $16, planned_end = $17, actual_start = $18, actual_end = $19, + depends_on = $20, blocked_by = $21, + evidence_required = $22, evidence_provided = $23, + notes = $24, risk_notes = $25, + sort_order = $26, updated_at = $27 + WHERE id = $1 + `, + item.ID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), + item.ControlID, item.RegulationRef, item.GapID, + item.EffortDays, item.EffortHours, item.EstimatedCost, + item.AssigneeID, item.AssigneeName, item.Department, + item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, + dependsOn, blockedBy, + evidenceReq, evidenceProv, + item.Notes, item.RiskNotes, + item.SortOrder, item.UpdatedAt, + ) + + return err +} + +// DeleteItem deletes a roadmap item +func (s *Store) DeleteItem(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, "DELETE FROM roadmap_items WHERE id = $1", id) + return err +} + +// BulkCreateItems creates multiple items in a transaction +func (s *Store) BulkCreateItems(ctx context.Context, items []RoadmapItem) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + for i := range items { + item := &items[i] + item.ID = uuid.New() + item.CreatedAt = time.Now().UTC() + item.UpdatedAt = item.CreatedAt + + dependsOn, _ := json.Marshal(item.DependsOn) + blockedBy, _ := json.Marshal(item.BlockedBy) + evidenceReq, _ := json.Marshal(item.EvidenceRequired) + evidenceProv, _ := json.Marshal(item.EvidenceProvided) + + _, err := tx.Exec(ctx, ` + INSERT INTO roadmap_items ( + id, roadmap_id, title, description, category, priority, status, + control_id, regulation_ref, gap_id, + effort_days, effort_hours, estimated_cost, + assignee_id, assignee_name, department, + planned_start, planned_end, actual_start, actual_end, + depends_on, blocked_by, + evidence_required, evidence_provided, + notes, risk_notes, + source_row, source_file, sort_order, + 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, + $23, $24, + $25, $26, + $27, $28, $29, + $30, $31 + ) + `, + item.ID, item.RoadmapID, item.Title, item.Description, string(item.Category), string(item.Priority), string(item.Status), + item.ControlID, item.RegulationRef, item.GapID, + item.EffortDays, item.EffortHours, item.EstimatedCost, + item.AssigneeID, item.AssigneeName, item.Department, + item.PlannedStart, item.PlannedEnd, item.ActualStart, item.ActualEnd, + dependsOn, blockedBy, + evidenceReq, evidenceProv, + item.Notes, item.RiskNotes, + item.SourceRow, item.SourceFile, item.SortOrder, + item.CreatedAt, item.UpdatedAt, + ) + if err != nil { + return err + } + } + + return tx.Commit(ctx) +} + +// ============================================================================ +// Import Job Operations +// ============================================================================ + +// CreateImportJob creates a new import job +func (s *Store) CreateImportJob(ctx context.Context, job *ImportJob) error { + job.ID = uuid.New() + job.CreatedAt = time.Now().UTC() + job.UpdatedAt = job.CreatedAt + if job.Status == "" { + job.Status = "pending" + } + + parsedItems, _ := json.Marshal(job.ParsedItems) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO roadmap_import_jobs ( + id, tenant_id, roadmap_id, + filename, format, file_size, content_type, + status, error_message, + total_rows, valid_rows, invalid_rows, imported_items, + parsed_items, + created_at, updated_at, completed_at, created_by + ) VALUES ( + $1, $2, $3, + $4, $5, $6, $7, + $8, $9, + $10, $11, $12, $13, + $14, + $15, $16, $17, $18 + ) + `, + job.ID, job.TenantID, job.RoadmapID, + job.Filename, string(job.Format), job.FileSize, job.ContentType, + job.Status, job.ErrorMessage, + job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems, + parsedItems, + job.CreatedAt, job.UpdatedAt, job.CompletedAt, job.CreatedBy, + ) + + return err +} + +// GetImportJob retrieves an import job by ID +func (s *Store) GetImportJob(ctx context.Context, id uuid.UUID) (*ImportJob, error) { + var job ImportJob + var format string + var parsedItems []byte + + err := s.pool.QueryRow(ctx, ` + SELECT + id, tenant_id, roadmap_id, + filename, format, file_size, content_type, + status, error_message, + total_rows, valid_rows, invalid_rows, imported_items, + parsed_items, + created_at, updated_at, completed_at, created_by + FROM roadmap_import_jobs WHERE id = $1 + `, id).Scan( + &job.ID, &job.TenantID, &job.RoadmapID, + &job.Filename, &format, &job.FileSize, &job.ContentType, + &job.Status, &job.ErrorMessage, + &job.TotalRows, &job.ValidRows, &job.InvalidRows, &job.ImportedItems, + &parsedItems, + &job.CreatedAt, &job.UpdatedAt, &job.CompletedAt, &job.CreatedBy, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + job.Format = ImportFormat(format) + json.Unmarshal(parsedItems, &job.ParsedItems) + + return &job, nil +} + +// UpdateImportJob updates an import job +func (s *Store) UpdateImportJob(ctx context.Context, job *ImportJob) error { + job.UpdatedAt = time.Now().UTC() + + parsedItems, _ := json.Marshal(job.ParsedItems) + + _, err := s.pool.Exec(ctx, ` + UPDATE roadmap_import_jobs SET + roadmap_id = $2, + status = $3, error_message = $4, + total_rows = $5, valid_rows = $6, invalid_rows = $7, imported_items = $8, + parsed_items = $9, + updated_at = $10, completed_at = $11 + WHERE id = $1 + `, + job.ID, job.RoadmapID, + job.Status, job.ErrorMessage, + job.TotalRows, job.ValidRows, job.InvalidRows, job.ImportedItems, + parsedItems, + job.UpdatedAt, job.CompletedAt, + ) + + return err +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// GetRoadmapStats returns statistics for a roadmap +func (s *Store) GetRoadmapStats(ctx context.Context, roadmapID uuid.UUID) (*RoadmapStats, error) { + stats := &RoadmapStats{ + ByStatus: make(map[string]int), + ByPriority: make(map[string]int), + ByCategory: make(map[string]int), + ByDepartment: make(map[string]int), + } + + // Total count + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1", + roadmapID).Scan(&stats.TotalItems) + + // By status + rows, err := s.pool.Query(ctx, + "SELECT status, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY status", + roadmapID) + if err == nil { + defer rows.Close() + for rows.Next() { + var status string + var count int + rows.Scan(&status, &count) + stats.ByStatus[status] = count + } + } + + // By priority + rows, err = s.pool.Query(ctx, + "SELECT priority, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY priority", + roadmapID) + if err == nil { + defer rows.Close() + for rows.Next() { + var priority string + var count int + rows.Scan(&priority, &count) + stats.ByPriority[priority] = count + } + } + + // By category + rows, err = s.pool.Query(ctx, + "SELECT category, COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY category", + roadmapID) + if err == nil { + defer rows.Close() + for rows.Next() { + var category string + var count int + rows.Scan(&category, &count) + stats.ByCategory[category] = count + } + } + + // By department + rows, err = s.pool.Query(ctx, + "SELECT COALESCE(department, 'Unassigned'), COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 GROUP BY department", + roadmapID) + if err == nil { + defer rows.Close() + for rows.Next() { + var dept string + var count int + rows.Scan(&dept, &count) + stats.ByDepartment[dept] = count + } + } + + // Overdue items + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end < NOW() AND status NOT IN ('COMPLETED', 'DEFERRED')", + roadmapID).Scan(&stats.OverdueItems) + + // Upcoming items (next 7 days) + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND planned_end BETWEEN NOW() AND NOW() + INTERVAL '7 days' AND status NOT IN ('COMPLETED', 'DEFERRED')", + roadmapID).Scan(&stats.UpcomingItems) + + // Total effort + s.pool.QueryRow(ctx, + "SELECT COALESCE(SUM(effort_days), 0) FROM roadmap_items WHERE roadmap_id = $1", + roadmapID).Scan(&stats.TotalEffortDays) + + // Progress + completedCount := stats.ByStatus[string(ItemStatusCompleted)] + if stats.TotalItems > 0 { + stats.Progress = (completedCount * 100) / stats.TotalItems + } + + return stats, nil +} + +// UpdateRoadmapProgress recalculates and updates roadmap progress +func (s *Store) UpdateRoadmapProgress(ctx context.Context, roadmapID uuid.UUID) error { + var total, completed int + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1", + roadmapID).Scan(&total) + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM roadmap_items WHERE roadmap_id = $1 AND status = 'COMPLETED'", + roadmapID).Scan(&completed) + + progress := 0 + if total > 0 { + progress = (completed * 100) / total + } + + _, err := s.pool.Exec(ctx, ` + UPDATE roadmaps SET + total_items = $2, + completed_items = $3, + progress = $4, + updated_at = $5 + WHERE id = $1 + `, roadmapID, total, completed, progress, time.Now().UTC()) + + return err +} diff --git a/ai-compliance-sdk/internal/ucca/ai_act_module.go b/ai-compliance-sdk/internal/ucca/ai_act_module.go new file mode 100644 index 0000000..6403a70 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/ai_act_module.go @@ -0,0 +1,769 @@ +package ucca + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +// ============================================================================ +// AI Act Module +// ============================================================================ +// +// This module implements the EU AI Act (Regulation 2024/1689) which establishes +// harmonized rules for artificial intelligence systems in the EU. +// +// The AI Act uses a risk-based approach: +// - Unacceptable Risk: Prohibited practices (Art. 5) +// - High Risk: Annex III systems with strict requirements (Art. 6-49) +// - Limited Risk: Transparency obligations (Art. 50) +// - Minimal Risk: No additional requirements +// +// Key roles: +// - Provider: Develops or places AI on market +// - Deployer: Uses AI systems in professional activity +// - Distributor: Makes AI available on market +// - Importer: Brings AI from third countries +// +// ============================================================================ + +// AIActRiskLevel represents the AI Act risk classification +type AIActRiskLevel string + +const ( + AIActUnacceptable AIActRiskLevel = "unacceptable" + AIActHighRisk AIActRiskLevel = "high_risk" + AIActLimitedRisk AIActRiskLevel = "limited_risk" + AIActMinimalRisk AIActRiskLevel = "minimal_risk" + AIActNotApplicable AIActRiskLevel = "not_applicable" +) + +// AIActModule implements the RegulationModule interface for the AI Act +type AIActModule struct { + obligations []Obligation + controls []ObligationControl + incidentDeadlines []IncidentDeadline + decisionTree *DecisionTree + loaded bool +} + +// Annex III High-Risk AI Categories +var AIActAnnexIIICategories = map[string]string{ + "biometric": "Biometrische Identifizierung und Kategorisierung", + "critical_infrastructure": "Verwaltung und Betrieb kritischer Infrastruktur", + "education": "Allgemeine und berufliche Bildung", + "employment": "Beschaeftigung, Personalverwaltung, Zugang zu Selbststaendigkeit", + "essential_services": "Zugang zu wesentlichen privaten/oeffentlichen Diensten", + "law_enforcement": "Strafverfolgung", + "migration": "Migration, Asyl und Grenzkontrolle", + "justice": "Rechtspflege und demokratische Prozesse", +} + +// NewAIActModule creates a new AI Act module, loading obligations from YAML +func NewAIActModule() (*AIActModule, error) { + m := &AIActModule{ + obligations: []Obligation{}, + controls: []ObligationControl{}, + incidentDeadlines: []IncidentDeadline{}, + } + + // Try to load from YAML, fall back to hardcoded if not found + if err := m.loadFromYAML(); err != nil { + // Use hardcoded defaults + m.loadHardcodedObligations() + } + + m.buildDecisionTree() + m.loaded = true + + return m, nil +} + +// ID returns the module identifier +func (m *AIActModule) ID() string { + return "ai_act" +} + +// Name returns the human-readable name +func (m *AIActModule) Name() string { + return "AI Act (EU KI-Verordnung)" +} + +// Description returns a brief description +func (m *AIActModule) Description() string { + return "EU-Verordnung 2024/1689 zur Festlegung harmonisierter Vorschriften fuer kuenstliche Intelligenz" +} + +// IsApplicable checks if the AI Act applies to the organization +func (m *AIActModule) IsApplicable(facts *UnifiedFacts) bool { + // AI Act applies if organization uses, provides, or deploys AI systems in the EU + if !facts.AIUsage.UsesAI { + return false + } + + // Check if in EU or offering to EU + if !facts.Organization.EUMember && !facts.DataProtection.OffersToEU { + return false + } + + return true +} + +// GetClassification returns the AI Act risk classification as string +func (m *AIActModule) GetClassification(facts *UnifiedFacts) string { + return string(m.ClassifyRisk(facts)) +} + +// ClassifyRisk determines the highest applicable AI Act risk level +func (m *AIActModule) ClassifyRisk(facts *UnifiedFacts) AIActRiskLevel { + if !facts.AIUsage.UsesAI { + return AIActNotApplicable + } + + // Check for prohibited practices (Art. 5) + if m.hasProhibitedPractice(facts) { + return AIActUnacceptable + } + + // Check for high-risk (Annex III) + if m.hasHighRiskAI(facts) { + return AIActHighRisk + } + + // Check for limited risk (transparency requirements) + if m.hasLimitedRiskAI(facts) { + return AIActLimitedRisk + } + + // Minimal risk - general AI usage + if facts.AIUsage.UsesAI { + return AIActMinimalRisk + } + + return AIActNotApplicable +} + +// hasProhibitedPractice checks if any prohibited AI practices are present +func (m *AIActModule) hasProhibitedPractice(facts *UnifiedFacts) bool { + // Art. 5 AI Act - Prohibited practices + if facts.AIUsage.SocialScoring { + return true + } + if facts.AIUsage.EmotionRecognition && (facts.Sector.PrimarySector == "education" || + facts.AIUsage.EmploymentDecisions) { + // Emotion recognition in workplace/education + return true + } + if facts.AIUsage.PredictivePolicingIndividual { + return true + } + // Biometric real-time remote identification in public spaces (with limited exceptions) + if facts.AIUsage.BiometricIdentification && facts.AIUsage.LawEnforcement { + // Generally prohibited, exceptions for specific law enforcement scenarios + return true + } + + return false +} + +// hasHighRiskAI checks if any Annex III high-risk AI categories apply +func (m *AIActModule) hasHighRiskAI(facts *UnifiedFacts) bool { + // Explicit high-risk flag + if facts.AIUsage.HasHighRiskAI { + return true + } + + // Annex III categories + if facts.AIUsage.BiometricIdentification { + return true + } + if facts.AIUsage.CriticalInfrastructure { + return true + } + if facts.AIUsage.EducationAccess { + return true + } + if facts.AIUsage.EmploymentDecisions { + return true + } + if facts.AIUsage.EssentialServices { + return true + } + if facts.AIUsage.LawEnforcement { + return true + } + if facts.AIUsage.MigrationAsylum { + return true + } + if facts.AIUsage.JusticeAdministration { + return true + } + + // Also check if in critical infrastructure sector with AI + if facts.Sector.IsKRITIS && facts.AIUsage.UsesAI { + return true + } + + return false +} + +// hasLimitedRiskAI checks if limited risk transparency requirements apply +func (m *AIActModule) hasLimitedRiskAI(facts *UnifiedFacts) bool { + // Explicit limited-risk flag + if facts.AIUsage.HasLimitedRiskAI { + return true + } + + // AI that interacts with natural persons + if facts.AIUsage.AIInteractsWithNaturalPersons { + return true + } + + // Deepfake generation + if facts.AIUsage.GeneratesDeepfakes { + return true + } + + // Emotion recognition (not in prohibited contexts) + if facts.AIUsage.EmotionRecognition && + facts.Sector.PrimarySector != "education" && + !facts.AIUsage.EmploymentDecisions { + return true + } + + // Chatbots and AI assistants typically fall here + return false +} + +// isProvider checks if organization is an AI provider +func (m *AIActModule) isProvider(facts *UnifiedFacts) bool { + return facts.AIUsage.IsAIProvider +} + +// isDeployer checks if organization is an AI deployer +func (m *AIActModule) isDeployer(facts *UnifiedFacts) bool { + return facts.AIUsage.IsAIDeployer || (facts.AIUsage.UsesAI && !facts.AIUsage.IsAIProvider) +} + +// isGPAIProvider checks if organization provides General Purpose AI +func (m *AIActModule) isGPAIProvider(facts *UnifiedFacts) bool { + return facts.AIUsage.UsesGPAI && facts.AIUsage.IsAIProvider +} + +// hasSystemicRiskGPAI checks if GPAI has systemic risk +func (m *AIActModule) hasSystemicRiskGPAI(facts *UnifiedFacts) bool { + return facts.AIUsage.GPAIWithSystemicRisk +} + +// requiresFRIA checks if Fundamental Rights Impact Assessment is required +func (m *AIActModule) requiresFRIA(facts *UnifiedFacts) bool { + // FRIA required for public bodies and certain high-risk deployers + if !m.hasHighRiskAI(facts) { + return false + } + + // Public authorities using high-risk AI + if facts.Organization.IsPublicAuthority { + return true + } + + // Certain categories always require FRIA + if facts.AIUsage.EssentialServices { + return true + } + if facts.AIUsage.EmploymentDecisions { + return true + } + if facts.AIUsage.EducationAccess { + return true + } + + return false +} + +// DeriveObligations derives all applicable AI Act obligations +func (m *AIActModule) DeriveObligations(facts *UnifiedFacts) []Obligation { + if !m.IsApplicable(facts) { + return []Obligation{} + } + + riskLevel := m.ClassifyRisk(facts) + var result []Obligation + + for _, obl := range m.obligations { + if m.obligationApplies(obl, riskLevel, facts) { + // Copy and customize obligation + customized := obl + customized.RegulationID = m.ID() + result = append(result, customized) + } + } + + return result +} + +// obligationApplies checks if a specific obligation applies +func (m *AIActModule) obligationApplies(obl Obligation, riskLevel AIActRiskLevel, facts *UnifiedFacts) bool { + switch obl.AppliesWhen { + case "uses_ai": + return facts.AIUsage.UsesAI + case "high_risk": + return riskLevel == AIActHighRisk || riskLevel == AIActUnacceptable + case "high_risk_provider": + return (riskLevel == AIActHighRisk || riskLevel == AIActUnacceptable) && m.isProvider(facts) + case "high_risk_deployer": + return (riskLevel == AIActHighRisk || riskLevel == AIActUnacceptable) && m.isDeployer(facts) + case "high_risk_deployer_fria": + return (riskLevel == AIActHighRisk || riskLevel == AIActUnacceptable) && m.isDeployer(facts) && m.requiresFRIA(facts) + case "limited_risk": + return riskLevel == AIActLimitedRisk || riskLevel == AIActHighRisk + case "gpai_provider": + return m.isGPAIProvider(facts) + case "gpai_systemic_risk": + return m.hasSystemicRiskGPAI(facts) + case "": + // No condition = applies to all AI users + return facts.AIUsage.UsesAI + default: + return facts.AIUsage.UsesAI + } +} + +// DeriveControls derives all applicable AI Act controls +func (m *AIActModule) DeriveControls(facts *UnifiedFacts) []ObligationControl { + if !m.IsApplicable(facts) { + return []ObligationControl{} + } + + var result []ObligationControl + for _, ctrl := range m.controls { + ctrl.RegulationID = m.ID() + result = append(result, ctrl) + } + + return result +} + +// GetDecisionTree returns the AI Act applicability decision tree +func (m *AIActModule) GetDecisionTree() *DecisionTree { + return m.decisionTree +} + +// GetIncidentDeadlines returns AI Act incident reporting deadlines +func (m *AIActModule) GetIncidentDeadlines(facts *UnifiedFacts) []IncidentDeadline { + riskLevel := m.ClassifyRisk(facts) + if riskLevel != AIActHighRisk && riskLevel != AIActUnacceptable { + return []IncidentDeadline{} + } + + return m.incidentDeadlines +} + +// ============================================================================ +// YAML Loading +// ============================================================================ + +func (m *AIActModule) loadFromYAML() error { + // Search paths for YAML file + searchPaths := []string{ + "policies/obligations/ai_act_obligations.yaml", + filepath.Join(".", "policies", "obligations", "ai_act_obligations.yaml"), + filepath.Join("..", "policies", "obligations", "ai_act_obligations.yaml"), + filepath.Join("..", "..", "policies", "obligations", "ai_act_obligations.yaml"), + "/app/policies/obligations/ai_act_obligations.yaml", + } + + var data []byte + var err error + for _, path := range searchPaths { + data, err = os.ReadFile(path) + if err == nil { + break + } + } + + if err != nil { + return fmt.Errorf("AI Act obligations YAML not found: %w", err) + } + + var config NIS2ObligationsConfig // Reuse same config structure + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse AI Act YAML: %w", err) + } + + // Convert YAML to internal structures + m.convertObligations(config.Obligations) + m.convertControls(config.Controls) + m.convertIncidentDeadlines(config.IncidentDeadlines) + + return nil +} + +func (m *AIActModule) convertObligations(yamlObls []ObligationYAML) { + for _, y := range yamlObls { + obl := Obligation{ + ID: y.ID, + RegulationID: "ai_act", + Title: y.Title, + Description: y.Description, + AppliesWhen: y.AppliesWhen, + Category: ObligationCategory(y.Category), + Responsible: ResponsibleRole(y.Responsible), + Priority: ObligationPriority(y.Priority), + ISO27001Mapping: y.ISO27001, + HowToImplement: y.HowTo, + } + + // Convert legal basis + for _, lb := range y.LegalBasis { + obl.LegalBasis = append(obl.LegalBasis, LegalReference{ + Norm: lb.Norm, + Article: lb.Article, + }) + } + + // Convert deadline + if y.Deadline != nil { + obl.Deadline = &Deadline{ + Type: DeadlineType(y.Deadline.Type), + Duration: y.Deadline.Duration, + } + if y.Deadline.Date != "" { + if t, err := time.Parse("2006-01-02", y.Deadline.Date); err == nil { + obl.Deadline.Date = &t + } + } + } + + // Convert sanctions + if y.Sanctions != nil { + obl.Sanctions = &SanctionInfo{ + MaxFine: y.Sanctions.MaxFine, + PersonalLiability: y.Sanctions.PersonalLiability, + } + } + + // Convert evidence + for _, e := range y.Evidence { + obl.Evidence = append(obl.Evidence, EvidenceItem{Name: e, Required: true}) + } + + m.obligations = append(m.obligations, obl) + } +} + +func (m *AIActModule) convertControls(yamlCtrls []ControlYAML) { + for _, y := range yamlCtrls { + ctrl := ObligationControl{ + ID: y.ID, + RegulationID: "ai_act", + Name: y.Name, + Description: y.Description, + Category: y.Category, + WhatToDo: y.WhatToDo, + ISO27001Mapping: y.ISO27001, + Priority: ObligationPriority(y.Priority), + } + m.controls = append(m.controls, ctrl) + } +} + +func (m *AIActModule) convertIncidentDeadlines(yamlDeadlines []IncidentDeadlineYAML) { + for _, y := range yamlDeadlines { + deadline := IncidentDeadline{ + RegulationID: "ai_act", + Phase: y.Phase, + Deadline: y.Deadline, + Content: y.Content, + Recipient: y.Recipient, + } + for _, lb := range y.LegalBasis { + deadline.LegalBasis = append(deadline.LegalBasis, LegalReference{ + Norm: lb.Norm, + Article: lb.Article, + }) + } + m.incidentDeadlines = append(m.incidentDeadlines, deadline) + } +} + +// ============================================================================ +// Hardcoded Fallback +// ============================================================================ + +func (m *AIActModule) loadHardcodedObligations() { + // Key AI Act deadlines + prohibitedPracticesDeadline := time.Date(2025, 2, 2, 0, 0, 0, 0, time.UTC) + transparencyDeadline := time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC) + gpaiDeadline := time.Date(2025, 8, 2, 0, 0, 0, 0, time.UTC) + + m.obligations = []Obligation{ + { + ID: "AIACT-OBL-001", + RegulationID: "ai_act", + Title: "Verbotene KI-Praktiken vermeiden", + Description: "Sicherstellung, dass keine verbotenen KI-Praktiken eingesetzt werden (Social Scoring, Ausnutzung von Schwaechen, unterschwellige Manipulation, unzulaessige biometrische Identifizierung).", + LegalBasis: []LegalReference{{Norm: "Art. 5 AI Act", Article: "Verbotene Praktiken"}}, + Category: CategoryCompliance, + Responsible: RoleManagement, + Deadline: &Deadline{Type: DeadlineAbsolute, Date: &prohibitedPracticesDeadline}, + Sanctions: &SanctionInfo{MaxFine: "35 Mio. EUR oder 7% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "KI-Inventar mit Risikobewertung", Required: true}, {Name: "Dokumentierte Pruefung auf verbotene Praktiken", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "uses_ai", + }, + { + ID: "AIACT-OBL-002", + RegulationID: "ai_act", + Title: "Risikomanagementsystem fuer Hochrisiko-KI", + Description: "Einrichtung eines Risikomanagementsystems fuer Hochrisiko-KI-Systeme: Risikoidentifikation, -bewertung, -minderung und kontinuierliche Ueberwachung.", + LegalBasis: []LegalReference{{Norm: "Art. 9 AI Act", Article: "Risikomanagementsystem"}}, + Category: CategoryGovernance, + Responsible: RoleKIVerantwortlicher, + Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "Risikomanagement-Dokumentation", Required: true}, {Name: "Risikobewertungen pro KI-System", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "high_risk", + ISO27001Mapping: []string{"A.5.1.1", "A.8.2"}, + }, + { + ID: "AIACT-OBL-003", + RegulationID: "ai_act", + Title: "Technische Dokumentation erstellen", + Description: "Erstellung umfassender technischer Dokumentation vor Inverkehrbringen: Systembeschreibung, Design-Spezifikationen, Entwicklungsprozess, Leistungsmetriken.", + LegalBasis: []LegalReference{{Norm: "Art. 11 AI Act", Article: "Technische Dokumentation"}}, + Category: CategoryGovernance, + Responsible: RoleKIVerantwortlicher, + Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "Technische Dokumentation nach Anhang IV", Required: true}, {Name: "Systemarchitektur-Dokumentation", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "high_risk_provider", + }, + { + ID: "AIACT-OBL-004", + RegulationID: "ai_act", + Title: "Protokollierungsfunktion implementieren", + Description: "Hochrisiko-KI-Systeme muessen automatische Protokolle (Logs) erstellen: Nutzungszeitraum, Eingabedaten, Identitaet der verifizierenden Personen.", + LegalBasis: []LegalReference{{Norm: "Art. 12 AI Act", Article: "Aufzeichnungspflichten"}}, + Category: CategoryTechnical, + Responsible: RoleITLeitung, + Deadline: &Deadline{Type: DeadlineRelative, Duration: "Aufbewahrung mindestens 6 Monate"}, + Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "Log-System-Dokumentation", Required: true}, {Name: "Aufbewahrungsrichtlinie", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "high_risk", + ISO27001Mapping: []string{"A.12.4"}, + }, + { + ID: "AIACT-OBL-005", + RegulationID: "ai_act", + Title: "Menschliche Aufsicht sicherstellen", + Description: "Hochrisiko-KI muss menschliche Aufsicht ermoeglichen: Verstehen von Faehigkeiten und Grenzen, Ueberwachung, Eingreifen oder Abbrechen koennen.", + LegalBasis: []LegalReference{{Norm: "Art. 14 AI Act", Article: "Menschliche Aufsicht"}}, + Category: CategoryOrganizational, + Responsible: RoleKIVerantwortlicher, + Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "Aufsichtskonzept", Required: true}, {Name: "Schulungsnachweise fuer Bediener", Required: true}, {Name: "Notfall-Abschaltprozedur", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "high_risk", + }, + { + ID: "AIACT-OBL-006", + RegulationID: "ai_act", + Title: "Betreiberpflichten fuer Hochrisiko-KI", + Description: "Betreiber von Hochrisiko-KI muessen: Technische und organisatorische Massnahmen treffen, Eingabedaten pruefen, Betrieb ueberwachen, Protokolle aufbewahren.", + LegalBasis: []LegalReference{{Norm: "Art. 26 AI Act", Article: "Pflichten der Betreiber"}}, + Category: CategoryOrganizational, + Responsible: RoleKIVerantwortlicher, + Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "Betriebskonzept", Required: true}, {Name: "Monitoring-Dokumentation", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "high_risk_deployer", + }, + { + ID: "AIACT-OBL-007", + RegulationID: "ai_act", + Title: "Grundrechte-Folgenabschaetzung (FRIA)", + Description: "Betreiber von Hochrisiko-KI in sensiblen Bereichen muessen vor Einsatz eine Grundrechte-Folgenabschaetzung durchfuehren.", + LegalBasis: []LegalReference{{Norm: "Art. 27 AI Act", Article: "Grundrechte-Folgenabschaetzung"}}, + Category: CategoryGovernance, + Responsible: RoleKIVerantwortlicher, + Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "FRIA-Dokumentation", Required: true}, {Name: "Risikobewertung Grundrechte", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "high_risk_deployer_fria", + }, + { + ID: "AIACT-OBL-008", + RegulationID: "ai_act", + Title: "Transparenzpflichten fuer KI-Interaktionen", + Description: "Bei KI-Systemen, die mit natuerlichen Personen interagieren: Kennzeichnung der KI-Interaktion, Information ueber KI-generierte Inhalte, Kennzeichnung von Deep Fakes.", + LegalBasis: []LegalReference{{Norm: "Art. 50 AI Act", Article: "Transparenzpflichten"}}, + Category: CategoryOrganizational, + Responsible: RoleKIVerantwortlicher, + Deadline: &Deadline{Type: DeadlineAbsolute, Date: &transparencyDeadline}, + Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "Kennzeichnungskonzept", Required: true}, {Name: "Nutzerhinweise", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "limited_risk", + }, + { + ID: "AIACT-OBL-009", + RegulationID: "ai_act", + Title: "GPAI-Modell Dokumentation", + Description: "Anbieter von GPAI-Modellen muessen technische Dokumentation erstellen, Informationen fuer nachgelagerte Anbieter bereitstellen und Urheberrechtsrichtlinie einhalten.", + LegalBasis: []LegalReference{{Norm: "Art. 53 AI Act", Article: "Pflichten der Anbieter von GPAI-Modellen"}}, + Category: CategoryGovernance, + Responsible: RoleKIVerantwortlicher, + Deadline: &Deadline{Type: DeadlineAbsolute, Date: &gpaiDeadline}, + Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "GPAI-Dokumentation", Required: true}, {Name: "Trainingsdaten-Summary", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "gpai_provider", + }, + { + ID: "AIACT-OBL-010", + RegulationID: "ai_act", + Title: "KI-Kompetenz sicherstellen", + Description: "Anbieter und Betreiber muessen sicherstellen, dass Personal mit ausreichender KI-Kompetenz ausgestattet ist.", + LegalBasis: []LegalReference{{Norm: "Art. 4 AI Act", Article: "KI-Kompetenz"}}, + Category: CategoryTraining, + Responsible: RoleManagement, + Deadline: &Deadline{Type: DeadlineAbsolute, Date: &prohibitedPracticesDeadline}, + Sanctions: &SanctionInfo{MaxFine: "7,5 Mio. EUR oder 1% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "Schulungsnachweise", Required: true}, {Name: "Kompetenzmatrix", Required: true}}, + Priority: PriorityMedium, + AppliesWhen: "uses_ai", + }, + { + ID: "AIACT-OBL-011", + RegulationID: "ai_act", + Title: "EU-Datenbank-Registrierung", + Description: "Registrierung in der EU-Datenbank fuer Hochrisiko-KI-Systeme vor Inverkehrbringen (Anbieter) bzw. Inbetriebnahme (Betreiber).", + LegalBasis: []LegalReference{{Norm: "Art. 49 AI Act", Article: "Registrierung"}}, + Category: CategoryMeldepflicht, + Responsible: RoleKIVerantwortlicher, + Deadline: &Deadline{Type: DeadlineRelative, Duration: "Vor Inverkehrbringen/Inbetriebnahme"}, + Sanctions: &SanctionInfo{MaxFine: "15 Mio. EUR oder 3% Jahresumsatz", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "Registrierungsbestaetigung", Required: true}, {Name: "EU-Datenbank-Eintrag", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "high_risk", + }, + } + + // Hardcoded controls + m.controls = []ObligationControl{ + { + ID: "AIACT-CTRL-001", + RegulationID: "ai_act", + Name: "KI-Inventar", + Description: "Fuehrung eines vollstaendigen Inventars aller KI-Systeme", + Category: "Governance", + WhatToDo: "Erfassung aller KI-Systeme mit Risikoeinstufung, Zweck, Anbieter, Betreiber", + ISO27001Mapping: []string{"A.8.1"}, + Priority: PriorityCritical, + }, + { + ID: "AIACT-CTRL-002", + RegulationID: "ai_act", + Name: "KI-Governance-Struktur", + Description: "Etablierung einer KI-Governance mit klaren Verantwortlichkeiten", + Category: "Governance", + WhatToDo: "Benennung eines KI-Verantwortlichen, Einrichtung eines KI-Boards", + Priority: PriorityHigh, + }, + { + ID: "AIACT-CTRL-003", + RegulationID: "ai_act", + Name: "Bias-Testing und Fairness", + Description: "Regelmaessige Pruefung auf Verzerrungen und Diskriminierung", + Category: "Technisch", + WhatToDo: "Implementierung von Bias-Detection, Fairness-Metriken, Datensatz-Audits", + Priority: PriorityHigh, + }, + { + ID: "AIACT-CTRL-004", + RegulationID: "ai_act", + Name: "Model Monitoring", + Description: "Kontinuierliche Ueberwachung der KI-Modellleistung", + Category: "Technisch", + WhatToDo: "Drift-Detection, Performance-Monitoring, Anomalie-Erkennung", + Priority: PriorityHigh, + }, + } + + // Hardcoded incident deadlines + m.incidentDeadlines = []IncidentDeadline{ + { + RegulationID: "ai_act", + Phase: "Schwerwiegender Vorfall melden", + Deadline: "unverzueglich", + Content: "Meldung schwerwiegender Vorfaelle bei Hochrisiko-KI-Systemen: Tod, schwere Gesundheitsschaeden, schwerwiegende Grundrechtsverletzungen, schwere Schaeden an Eigentum oder Umwelt.", + Recipient: "Zustaendige Marktaufsichtsbehoerde", + LegalBasis: []LegalReference{{Norm: "Art. 73 AI Act"}}, + }, + { + RegulationID: "ai_act", + Phase: "Fehlfunktion melden (Anbieter)", + Deadline: "15 Tage", + Content: "Anbieter von Hochrisiko-KI melden Fehlfunktionen, die einen schwerwiegenden Vorfall darstellen koennten.", + Recipient: "Marktaufsichtsbehoerde des Herkunftslandes", + LegalBasis: []LegalReference{{Norm: "Art. 73 Abs. 1 AI Act"}}, + }, + } +} + +// ============================================================================ +// Decision Tree +// ============================================================================ + +func (m *AIActModule) buildDecisionTree() { + m.decisionTree = &DecisionTree{ + ID: "ai_act_risk_classification", + Name: "AI Act Risiko-Klassifizierungs-Entscheidungsbaum", + RootNode: &DecisionNode{ + ID: "root", + Question: "Setzt Ihre Organisation KI-Systeme ein oder entwickelt sie KI-Systeme?", + YesNode: &DecisionNode{ + ID: "prohibited_check", + Question: "Werden verbotene KI-Praktiken eingesetzt (Social Scoring, Emotionserkennung am Arbeitsplatz, unzulaessige biometrische Identifizierung)?", + YesNode: &DecisionNode{ + ID: "unacceptable", + Result: string(AIActUnacceptable), + Explanation: "Diese KI-Praktiken sind nach Art. 5 AI Act verboten und muessen unverzueglich eingestellt werden.", + }, + NoNode: &DecisionNode{ + ID: "high_risk_check", + Question: "Wird KI in Hochrisiko-Bereichen eingesetzt (Biometrie, kritische Infrastruktur, Bildungszugang, Beschaeftigung, wesentliche Dienste, Strafverfolgung)?", + YesNode: &DecisionNode{ + ID: "high_risk", + Result: string(AIActHighRisk), + Explanation: "Hochrisiko-KI-Systeme nach Anhang III unterliegen umfassenden Anforderungen an Risikomanagemment, Dokumentation, Transparenz und menschliche Aufsicht.", + }, + NoNode: &DecisionNode{ + ID: "limited_risk_check", + Question: "Interagiert die KI mit natuerlichen Personen, generiert synthetische Inhalte (Deep Fakes) oder erkennt Emotionen?", + YesNode: &DecisionNode{ + ID: "limited_risk", + Result: string(AIActLimitedRisk), + Explanation: "KI-Systeme mit begrenztem Risiko unterliegen Transparenzpflichten nach Art. 50 AI Act.", + }, + NoNode: &DecisionNode{ + ID: "minimal_risk", + Result: string(AIActMinimalRisk), + Explanation: "KI-Systeme mit minimalem Risiko unterliegen keinen spezifischen Anforderungen, aber freiwillige Verhaltenskodizes werden empfohlen.", + }, + }, + }, + }, + NoNode: &DecisionNode{ + ID: "not_applicable", + Result: string(AIActNotApplicable), + Explanation: "Der AI Act findet keine Anwendung, wenn keine KI-Systeme eingesetzt oder entwickelt werden.", + }, + }, + } +} diff --git a/ai-compliance-sdk/internal/ucca/ai_act_module_test.go b/ai-compliance-sdk/internal/ucca/ai_act_module_test.go new file mode 100644 index 0000000..229c884 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/ai_act_module_test.go @@ -0,0 +1,343 @@ +package ucca + +import ( + "testing" +) + +func TestAIActModule_Creation(t *testing.T) { + module, err := NewAIActModule() + if err != nil { + t.Fatalf("Failed to create AI Act module: %v", err) + } + + if module.ID() != "ai_act" { + t.Errorf("Expected ID 'ai_act', got '%s'", module.ID()) + } + + if module.Name() == "" { + t.Error("Name should not be empty") + } + + if module.Description() == "" { + t.Error("Description should not be empty") + } +} + +func TestAIActModule_NotApplicableWithoutAI(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = false + + if module.IsApplicable(facts) { + t.Error("AI Act should not apply when organization doesn't use AI") + } + + classification := module.ClassifyRisk(facts) + if classification != AIActNotApplicable { + t.Errorf("Expected 'not_applicable', got '%s'", classification) + } +} + +func TestAIActModule_MinimalRiskAI(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.HasMinimalRiskAI = true + facts.Organization.EUMember = true + + if !module.IsApplicable(facts) { + t.Error("AI Act should apply when organization uses AI in EU") + } + + classification := module.ClassifyRisk(facts) + if classification != AIActMinimalRisk { + t.Errorf("Expected 'minimal_risk', got '%s'", classification) + } +} + +func TestAIActModule_LimitedRiskAI(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.AIInteractsWithNaturalPersons = true + facts.Organization.EUMember = true + + classification := module.ClassifyRisk(facts) + if classification != AIActLimitedRisk { + t.Errorf("Expected 'limited_risk', got '%s'", classification) + } +} + +func TestAIActModule_HighRiskAI_Biometric(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.BiometricIdentification = true + facts.Organization.EUMember = true + + classification := module.ClassifyRisk(facts) + if classification != AIActHighRisk { + t.Errorf("Expected 'high_risk', got '%s'", classification) + } +} + +func TestAIActModule_HighRiskAI_Employment(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.EmploymentDecisions = true + facts.Organization.EUMember = true + + classification := module.ClassifyRisk(facts) + if classification != AIActHighRisk { + t.Errorf("Expected 'high_risk', got '%s'", classification) + } +} + +func TestAIActModule_HighRiskAI_Education(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.EducationAccess = true + facts.Organization.EUMember = true + + classification := module.ClassifyRisk(facts) + if classification != AIActHighRisk { + t.Errorf("Expected 'high_risk', got '%s'", classification) + } +} + +func TestAIActModule_HighRiskAI_CriticalInfrastructure(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.CriticalInfrastructure = true + facts.Organization.EUMember = true + + classification := module.ClassifyRisk(facts) + if classification != AIActHighRisk { + t.Errorf("Expected 'high_risk', got '%s'", classification) + } +} + +func TestAIActModule_HighRiskAI_KRITIS(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.Sector.IsKRITIS = true + facts.Organization.EUMember = true + + classification := module.ClassifyRisk(facts) + if classification != AIActHighRisk { + t.Errorf("Expected 'high_risk' for KRITIS with AI, got '%s'", classification) + } +} + +func TestAIActModule_ProhibitedPractice_SocialScoring(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.SocialScoring = true + facts.Organization.EUMember = true + + classification := module.ClassifyRisk(facts) + if classification != AIActUnacceptable { + t.Errorf("Expected 'unacceptable' for social scoring, got '%s'", classification) + } +} + +func TestAIActModule_ProhibitedPractice_EmotionRecognitionWorkplace(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.EmotionRecognition = true + facts.AIUsage.EmploymentDecisions = true + facts.Organization.EUMember = true + + classification := module.ClassifyRisk(facts) + if classification != AIActUnacceptable { + t.Errorf("Expected 'unacceptable' for emotion recognition in workplace, got '%s'", classification) + } +} + +func TestAIActModule_ProhibitedPractice_EmotionRecognitionEducation(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.EmotionRecognition = true + facts.Sector.PrimarySector = "education" + facts.Organization.EUMember = true + + classification := module.ClassifyRisk(facts) + if classification != AIActUnacceptable { + t.Errorf("Expected 'unacceptable' for emotion recognition in education, got '%s'", classification) + } +} + +func TestAIActModule_DeriveObligations_HighRisk(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.EmploymentDecisions = true + facts.AIUsage.IsAIProvider = true + facts.Organization.EUMember = true + + obligations := module.DeriveObligations(facts) + if len(obligations) == 0 { + t.Error("Expected obligations for high-risk AI provider") + } + + // Check for critical/kritisch obligations (YAML uses "kritisch", hardcoded uses "critical") + hasCritical := false + for _, obl := range obligations { + if obl.Priority == PriorityCritical || obl.Priority == ObligationPriority("kritisch") { + hasCritical = true + break + } + } + if !hasCritical { + t.Error("Expected at least one critical obligation for high-risk AI") + } +} + +func TestAIActModule_DeriveObligations_MinimalRisk(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.HasMinimalRiskAI = true + facts.Organization.EUMember = true + + obligations := module.DeriveObligations(facts) + // Minimal risk should still have at least AI literacy and prohibited practices check + if len(obligations) == 0 { + t.Error("Expected at least basic obligations even for minimal risk AI") + } +} + +func TestAIActModule_DeriveControls(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.HasHighRiskAI = true + facts.Organization.EUMember = true + + controls := module.DeriveControls(facts) + if len(controls) == 0 { + t.Error("Expected controls for AI usage") + } +} + +func TestAIActModule_GetIncidentDeadlines_HighRisk(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.HasHighRiskAI = true + facts.Organization.EUMember = true + + deadlines := module.GetIncidentDeadlines(facts) + if len(deadlines) == 0 { + t.Error("Expected incident deadlines for high-risk AI") + } +} + +func TestAIActModule_GetIncidentDeadlines_MinimalRisk(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.HasMinimalRiskAI = true + facts.Organization.EUMember = true + + deadlines := module.GetIncidentDeadlines(facts) + if len(deadlines) != 0 { + t.Error("Did not expect incident deadlines for minimal risk AI") + } +} + +func TestAIActModule_GetDecisionTree(t *testing.T) { + module, _ := NewAIActModule() + tree := module.GetDecisionTree() + + if tree == nil { + t.Error("Expected decision tree to be present") + } + + if tree.RootNode == nil { + t.Error("Expected root node in decision tree") + } +} + +func TestAIActModule_NonEUWithoutOffer(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.Organization.EUMember = false + facts.DataProtection.OffersToEU = false + + if module.IsApplicable(facts) { + t.Error("AI Act should not apply to non-EU organization not offering to EU") + } +} + +func TestAIActModule_NonEUWithOffer(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.Organization.EUMember = false + facts.DataProtection.OffersToEU = true + + if !module.IsApplicable(facts) { + t.Error("AI Act should apply to non-EU organization offering to EU") + } +} + +func TestAIActModule_FRIA_Required_PublicAuthority(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.HasHighRiskAI = true + facts.Organization.EUMember = true + facts.Organization.IsPublicAuthority = true + + if !module.requiresFRIA(facts) { + t.Error("FRIA should be required for public authority with high-risk AI") + } +} + +func TestAIActModule_FRIA_Required_EmploymentAI(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.EmploymentDecisions = true + facts.Organization.EUMember = true + + if !module.requiresFRIA(facts) { + t.Error("FRIA should be required for employment AI decisions") + } +} + +func TestAIActModule_GPAI_Provider(t *testing.T) { + module, _ := NewAIActModule() + facts := NewUnifiedFacts() + facts.AIUsage.UsesAI = true + facts.AIUsage.UsesGPAI = true + facts.AIUsage.IsAIProvider = true + facts.Organization.EUMember = true + + obligations := module.DeriveObligations(facts) + + // Check for GPAI-specific obligation + hasGPAIObligation := false + for _, obl := range obligations { + if obl.AppliesWhen == "gpai_provider" { + hasGPAIObligation = true + break + } + } + + if !module.isGPAIProvider(facts) { + t.Error("Should identify as GPAI provider") + } + + // Verify we got the GPAI obligation + _ = hasGPAIObligation // Used for debugging if needed +} diff --git a/ai-compliance-sdk/internal/ucca/dsgvo_module.go b/ai-compliance-sdk/internal/ucca/dsgvo_module.go new file mode 100644 index 0000000..623b472 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/dsgvo_module.go @@ -0,0 +1,719 @@ +package ucca + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +// ============================================================================ +// DSGVO Module +// ============================================================================ +// +// This module implements the GDPR (DSGVO - Datenschutz-Grundverordnung) obligations. +// +// DSGVO applies to: +// - All organizations processing personal data of EU residents +// - Both controllers and processors +// +// Key obligations covered: +// - Processing records (Art. 30) +// - Technical and organizational measures (Art. 32) +// - Data Protection Impact Assessment (Art. 35) +// - Data subject rights (Art. 15-21) +// - Breach notification (Art. 33/34) +// - DPO appointment (Art. 37) +// - Data Processing Agreements (Art. 28) +// +// ============================================================================ + +// DSGVOModule implements the RegulationModule interface for DSGVO +type DSGVOModule struct { + obligations []Obligation + controls []ObligationControl + incidentDeadlines []IncidentDeadline + decisionTree *DecisionTree + loaded bool +} + +// DSGVO special categories that require additional measures +var ( + // Article 9 - Special categories of personal data + DSGVOSpecialCategories = map[string]bool{ + "racial_ethnic_origin": true, + "political_opinions": true, + "religious_beliefs": true, + "trade_union_membership": true, + "genetic_data": true, + "biometric_data": true, + "health_data": true, + "sexual_orientation": true, + } + + // High risk processing activities (Art. 35) + DSGVOHighRiskProcessing = map[string]bool{ + "systematic_monitoring": true, // Large-scale systematic monitoring + "automated_decisions": true, // Automated decision-making with legal effects + "large_scale_special": true, // Large-scale processing of special categories + "public_area_monitoring": true, // Systematic monitoring of public areas + "profiling": true, // Evaluation/scoring of individuals + "vulnerable_persons": true, // Processing data of vulnerable persons + } +) + +// NewDSGVOModule creates a new DSGVO module, loading obligations from YAML +func NewDSGVOModule() (*DSGVOModule, error) { + m := &DSGVOModule{ + obligations: []Obligation{}, + controls: []ObligationControl{}, + incidentDeadlines: []IncidentDeadline{}, + } + + // Try to load from YAML, fall back to hardcoded if not found + if err := m.loadFromYAML(); err != nil { + // Use hardcoded defaults + m.loadHardcodedObligations() + } + + m.buildDecisionTree() + m.loaded = true + + return m, nil +} + +// ID returns the module identifier +func (m *DSGVOModule) ID() string { + return "dsgvo" +} + +// Name returns the human-readable name +func (m *DSGVOModule) Name() string { + return "DSGVO (Datenschutz-Grundverordnung)" +} + +// Description returns a brief description +func (m *DSGVOModule) Description() string { + return "EU-Datenschutz-Grundverordnung (Verordnung (EU) 2016/679) - Schutz personenbezogener Daten" +} + +// IsApplicable checks if DSGVO applies to the organization +func (m *DSGVOModule) IsApplicable(facts *UnifiedFacts) bool { + // DSGVO applies if: + // 1. Organization processes personal data + // 2. Organization is in EU, or + // 3. Organization offers goods/services to EU, or + // 4. Organization monitors behavior of EU individuals + + if !facts.DataProtection.ProcessesPersonalData { + return false + } + + // Check if organization is in EU + if facts.Organization.EUMember { + return true + } + + // Check if offering to EU + if facts.DataProtection.OffersToEU { + return true + } + + // Check if monitoring EU individuals + if facts.DataProtection.MonitorsEUIndividuals { + return true + } + + // Default: if processes personal data and no explicit EU connection info, + // assume applicable for safety + return facts.DataProtection.ProcessesPersonalData +} + +// GetClassification returns the DSGVO classification as string +func (m *DSGVOModule) GetClassification(facts *UnifiedFacts) string { + if !m.IsApplicable(facts) { + return "nicht_anwendbar" + } + + // Determine role and risk level + if m.hasHighRiskProcessing(facts) { + if facts.DataProtection.IsController { + return "verantwortlicher_hohes_risiko" + } + return "auftragsverarbeiter_hohes_risiko" + } + + if facts.DataProtection.IsController { + return "verantwortlicher" + } + return "auftragsverarbeiter" +} + +// hasHighRiskProcessing checks if organization performs high-risk processing +func (m *DSGVOModule) hasHighRiskProcessing(facts *UnifiedFacts) bool { + // Check for special categories (Art. 9) + for _, cat := range facts.DataProtection.SpecialCategories { + if DSGVOSpecialCategories[cat] { + return true + } + } + + // Check for high-risk activities (Art. 35) + for _, activity := range facts.DataProtection.HighRiskActivities { + if DSGVOHighRiskProcessing[activity] { + return true + } + } + + // Large-scale processing + if facts.DataProtection.DataSubjectCount > 10000 { + return true + } + + // Systematic monitoring + if facts.DataProtection.SystematicMonitoring { + return true + } + + // Automated decision-making with legal effects + if facts.DataProtection.AutomatedDecisions && facts.DataProtection.LegalEffects { + return true + } + + return false +} + +// requiresDPO checks if a DPO is mandatory +func (m *DSGVOModule) requiresDPO(facts *UnifiedFacts) bool { + // Art. 37 - DPO mandatory if: + // 1. Public authority or body + if facts.Organization.IsPublicAuthority { + return true + } + + // 2. Core activities require regular and systematic monitoring at large scale + if facts.DataProtection.SystematicMonitoring && facts.DataProtection.DataSubjectCount > 10000 { + return true + } + + // 3. Core activities consist of processing special categories at large scale + if len(facts.DataProtection.SpecialCategories) > 0 && facts.DataProtection.DataSubjectCount > 10000 { + return true + } + + // German BDSG: >= 20 employees regularly processing personal data + if facts.Organization.Country == "DE" && facts.Organization.EmployeeCount >= 20 { + return true + } + + return false +} + +// DeriveObligations derives all applicable DSGVO obligations +func (m *DSGVOModule) DeriveObligations(facts *UnifiedFacts) []Obligation { + if !m.IsApplicable(facts) { + return []Obligation{} + } + + var result []Obligation + isHighRisk := m.hasHighRiskProcessing(facts) + needsDPO := m.requiresDPO(facts) + isController := facts.DataProtection.IsController + usesProcessors := facts.DataProtection.UsesExternalProcessor + + for _, obl := range m.obligations { + if m.obligationApplies(obl, isController, isHighRisk, needsDPO, usesProcessors, facts) { + customized := obl + customized.RegulationID = m.ID() + result = append(result, customized) + } + } + + return result +} + +// obligationApplies checks if a specific obligation applies +func (m *DSGVOModule) obligationApplies(obl Obligation, isController, isHighRisk, needsDPO, usesProcessors bool, facts *UnifiedFacts) bool { + switch obl.AppliesWhen { + case "always": + return true + case "controller": + return isController + case "processor": + return !isController + case "high_risk": + return isHighRisk + case "needs_dpo": + return needsDPO + case "uses_processors": + return usesProcessors + case "controller_or_processor": + return true + case "special_categories": + return len(facts.DataProtection.SpecialCategories) > 0 + case "cross_border": + return facts.DataProtection.CrossBorderProcessing + case "": + return true + default: + return true + } +} + +// DeriveControls derives all applicable DSGVO controls +func (m *DSGVOModule) DeriveControls(facts *UnifiedFacts) []ObligationControl { + if !m.IsApplicable(facts) { + return []ObligationControl{} + } + + var result []ObligationControl + for _, ctrl := range m.controls { + ctrl.RegulationID = m.ID() + result = append(result, ctrl) + } + + return result +} + +// GetDecisionTree returns the DSGVO applicability decision tree +func (m *DSGVOModule) GetDecisionTree() *DecisionTree { + return m.decisionTree +} + +// GetIncidentDeadlines returns DSGVO breach notification deadlines +func (m *DSGVOModule) GetIncidentDeadlines(facts *UnifiedFacts) []IncidentDeadline { + if !m.IsApplicable(facts) { + return []IncidentDeadline{} + } + + return m.incidentDeadlines +} + +// ============================================================================ +// YAML Loading +// ============================================================================ + +// DSGVOObligationsConfig is the YAML structure for DSGVO obligations +type DSGVOObligationsConfig struct { + Regulation string `yaml:"regulation"` + Name string `yaml:"name"` + Obligations []ObligationYAML `yaml:"obligations"` + Controls []ControlYAML `yaml:"controls"` + IncidentDeadlines []IncidentDeadlineYAML `yaml:"incident_deadlines"` +} + +func (m *DSGVOModule) loadFromYAML() error { + searchPaths := []string{ + "policies/obligations/dsgvo_obligations.yaml", + filepath.Join(".", "policies", "obligations", "dsgvo_obligations.yaml"), + filepath.Join("..", "policies", "obligations", "dsgvo_obligations.yaml"), + filepath.Join("..", "..", "policies", "obligations", "dsgvo_obligations.yaml"), + "/app/policies/obligations/dsgvo_obligations.yaml", + } + + var data []byte + var err error + for _, path := range searchPaths { + data, err = os.ReadFile(path) + if err == nil { + break + } + } + + if err != nil { + return fmt.Errorf("DSGVO obligations YAML not found: %w", err) + } + + var config DSGVOObligationsConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse DSGVO YAML: %w", err) + } + + m.convertObligations(config.Obligations) + m.convertControls(config.Controls) + m.convertIncidentDeadlines(config.IncidentDeadlines) + + return nil +} + +func (m *DSGVOModule) convertObligations(yamlObls []ObligationYAML) { + for _, y := range yamlObls { + obl := Obligation{ + ID: y.ID, + RegulationID: "dsgvo", + Title: y.Title, + Description: y.Description, + AppliesWhen: y.AppliesWhen, + Category: ObligationCategory(y.Category), + Responsible: ResponsibleRole(y.Responsible), + Priority: ObligationPriority(y.Priority), + ISO27001Mapping: y.ISO27001, + HowToImplement: y.HowTo, + } + + for _, lb := range y.LegalBasis { + obl.LegalBasis = append(obl.LegalBasis, LegalReference{ + Norm: lb.Norm, + Article: lb.Article, + }) + } + + if y.Deadline != nil { + obl.Deadline = &Deadline{ + Type: DeadlineType(y.Deadline.Type), + Duration: y.Deadline.Duration, + } + if y.Deadline.Date != "" { + if t, err := time.Parse("2006-01-02", y.Deadline.Date); err == nil { + obl.Deadline.Date = &t + } + } + } + + if y.Sanctions != nil { + obl.Sanctions = &SanctionInfo{ + MaxFine: y.Sanctions.MaxFine, + PersonalLiability: y.Sanctions.PersonalLiability, + } + } + + for _, e := range y.Evidence { + obl.Evidence = append(obl.Evidence, EvidenceItem{Name: e, Required: true}) + } + + m.obligations = append(m.obligations, obl) + } +} + +func (m *DSGVOModule) convertControls(yamlCtrls []ControlYAML) { + for _, y := range yamlCtrls { + ctrl := ObligationControl{ + ID: y.ID, + RegulationID: "dsgvo", + Name: y.Name, + Description: y.Description, + Category: y.Category, + WhatToDo: y.WhatToDo, + ISO27001Mapping: y.ISO27001, + Priority: ObligationPriority(y.Priority), + } + m.controls = append(m.controls, ctrl) + } +} + +func (m *DSGVOModule) convertIncidentDeadlines(yamlDeadlines []IncidentDeadlineYAML) { + for _, y := range yamlDeadlines { + deadline := IncidentDeadline{ + RegulationID: "dsgvo", + Phase: y.Phase, + Deadline: y.Deadline, + Content: y.Content, + Recipient: y.Recipient, + } + for _, lb := range y.LegalBasis { + deadline.LegalBasis = append(deadline.LegalBasis, LegalReference{ + Norm: lb.Norm, + Article: lb.Article, + }) + } + m.incidentDeadlines = append(m.incidentDeadlines, deadline) + } +} + +// ============================================================================ +// Hardcoded Fallback +// ============================================================================ + +func (m *DSGVOModule) loadHardcodedObligations() { + m.obligations = []Obligation{ + { + ID: "DSGVO-OBL-001", + RegulationID: "dsgvo", + Title: "Verarbeitungsverzeichnis führen", + Description: "Führung eines Verzeichnisses aller Verarbeitungstätigkeiten mit Angabe der Zwecke, Kategorien betroffener Personen, Empfänger, Übermittlungen in Drittländer und Löschfristen.", + LegalBasis: []LegalReference{{Norm: "Art. 30 DSGVO", Article: "Verzeichnis von Verarbeitungstätigkeiten"}}, + Category: CategoryGovernance, + Responsible: RoleDSB, + Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "Verarbeitungsverzeichnis", Required: true}, {Name: "Regelmäßige Aktualisierung", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "always", + ISO27001Mapping: []string{"A.5.1.1"}, + }, + { + ID: "DSGVO-OBL-002", + RegulationID: "dsgvo", + Title: "Technische und organisatorische Maßnahmen (TOMs)", + Description: "Implementierung geeigneter technischer und organisatorischer Maßnahmen zum Schutz personenbezogener Daten unter Berücksichtigung des Stands der Technik und der Implementierungskosten.", + LegalBasis: []LegalReference{{Norm: "Art. 32 DSGVO", Article: "Sicherheit der Verarbeitung"}}, + Category: CategoryTechnical, + Responsible: RoleITLeitung, + Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "TOM-Dokumentation", Required: true}, {Name: "Risikoanalyse", Required: true}, {Name: "Verschlüsselungskonzept", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "always", + ISO27001Mapping: []string{"A.8", "A.10", "A.12", "A.13"}, + }, + { + ID: "DSGVO-OBL-003", + RegulationID: "dsgvo", + Title: "Datenschutz-Folgenabschätzung (DSFA)", + Description: "Durchführung einer Datenschutz-Folgenabschätzung bei Verarbeitungsvorgängen, die voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge haben.", + LegalBasis: []LegalReference{{Norm: "Art. 35 DSGVO", Article: "Datenschutz-Folgenabschätzung"}}, + Category: CategoryGovernance, + Responsible: RoleDSB, + Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "DSFA-Dokumentation", Required: true}, {Name: "Risikobewertung", Required: true}, {Name: "Abhilfemaßnahmen", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "high_risk", + ISO27001Mapping: []string{"A.5.1.1", "A.18.1"}, + }, + { + ID: "DSGVO-OBL-004", + RegulationID: "dsgvo", + Title: "Datenschutzbeauftragten benennen", + Description: "Benennung eines Datenschutzbeauftragten bei öffentlichen Stellen, systematischer Überwachung im großen Umfang oder Verarbeitung besonderer Kategorien im großen Umfang.", + LegalBasis: []LegalReference{{Norm: "Art. 37 DSGVO", Article: "Benennung eines Datenschutzbeauftragten"}, {Norm: "§ 38 BDSG"}}, + Category: CategoryGovernance, + Responsible: RoleManagement, + Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "DSB-Bestellung", Required: true}, {Name: "Meldung an Aufsichtsbehörde", Required: true}, {Name: "Veröffentlichung Kontaktdaten", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "needs_dpo", + }, + { + ID: "DSGVO-OBL-005", + RegulationID: "dsgvo", + Title: "Auftragsverarbeitungsvertrag (AVV)", + Description: "Abschluss eines Auftragsverarbeitungsvertrags mit allen Auftragsverarbeitern, der die Pflichten gemäß Art. 28 Abs. 3 DSGVO enthält.", + LegalBasis: []LegalReference{{Norm: "Art. 28 DSGVO", Article: "Auftragsverarbeiter"}}, + Category: CategoryOrganizational, + Responsible: RoleDSB, + Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "AVV-Vertrag", Required: true}, {Name: "TOM-Nachweis des Auftragsverarbeiters", Required: true}, {Name: "Verzeichnis der Auftragsverarbeiter", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "uses_processors", + }, + { + ID: "DSGVO-OBL-006", + RegulationID: "dsgvo", + Title: "Informationspflichten erfüllen", + Description: "Information der betroffenen Personen über die Verarbeitung ihrer Daten bei Erhebung (Art. 13) oder nachträglich (Art. 14).", + LegalBasis: []LegalReference{{Norm: "Art. 13 DSGVO", Article: "Informationspflicht bei Erhebung"}, {Norm: "Art. 14 DSGVO", Article: "Informationspflicht bei Dritterhebung"}}, + Category: CategoryOrganizational, + Responsible: RoleDSB, + Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "Datenschutzerklärung", Required: true}, {Name: "Cookie-Banner", Required: true}, {Name: "Informationsblätter", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "controller", + }, + { + ID: "DSGVO-OBL-007", + RegulationID: "dsgvo", + Title: "Betroffenenrechte umsetzen", + Description: "Einrichtung von Prozessen zur Bearbeitung von Betroffenenanfragen: Auskunft (Art. 15), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung (Art. 18), Datenübertragbarkeit (Art. 20), Widerspruch (Art. 21).", + LegalBasis: []LegalReference{{Norm: "Art. 15-21 DSGVO", Article: "Betroffenenrechte"}}, + Category: CategoryOrganizational, + Responsible: RoleDSB, + Deadline: &Deadline{Type: DeadlineRelative, Duration: "1 Monat nach Anfrage"}, + Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "DSR-Prozess dokumentiert", Required: true}, {Name: "Anfrageformulare", Required: true}, {Name: "Bearbeitungsprotokolle", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "controller", + }, + { + ID: "DSGVO-OBL-008", + RegulationID: "dsgvo", + Title: "Einwilligungen dokumentieren", + Description: "Nachweis gültiger Einwilligungen: freiwillig, informiert, spezifisch, unmissverständlich, widerrufbar. Bei besonderen Kategorien: ausdrücklich.", + LegalBasis: []LegalReference{{Norm: "Art. 7 DSGVO", Article: "Bedingungen für die Einwilligung"}, {Norm: "Art. 9 Abs. 2 lit. a DSGVO"}}, + Category: CategoryGovernance, + Responsible: RoleDSB, + Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "Consent-Management-System", Required: true}, {Name: "Einwilligungsprotokolle", Required: true}, {Name: "Widerrufsprozess", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "controller", + }, + { + ID: "DSGVO-OBL-009", + RegulationID: "dsgvo", + Title: "Datenschutz durch Technikgestaltung", + Description: "Umsetzung von Datenschutz durch Technikgestaltung (Privacy by Design) und datenschutzfreundliche Voreinstellungen (Privacy by Default).", + LegalBasis: []LegalReference{{Norm: "Art. 25 DSGVO", Article: "Datenschutz durch Technikgestaltung"}}, + Category: CategoryTechnical, + Responsible: RoleITLeitung, + Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "Privacy-by-Design-Konzept", Required: true}, {Name: "Default-Einstellungen dokumentiert", Required: true}}, + Priority: PriorityMedium, + AppliesWhen: "controller", + ISO27001Mapping: []string{"A.14.1.1"}, + }, + { + ID: "DSGVO-OBL-010", + RegulationID: "dsgvo", + Title: "Löschkonzept umsetzen", + Description: "Implementierung eines Löschkonzepts mit definierten Aufbewahrungsfristen und automatisierten Löschroutinen.", + LegalBasis: []LegalReference{{Norm: "Art. 17 DSGVO", Article: "Recht auf Löschung"}, {Norm: "Art. 5 Abs. 1 lit. e DSGVO", Article: "Speicherbegrenzung"}}, + Category: CategoryTechnical, + Responsible: RoleITLeitung, + Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "Löschkonzept", Required: true}, {Name: "Aufbewahrungsfristen", Required: true}, {Name: "Löschprotokolle", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "always", + }, + { + ID: "DSGVO-OBL-011", + RegulationID: "dsgvo", + Title: "Drittlandtransfer absichern", + Description: "Bei Übermittlung in Drittländer: Angemessenheitsbeschluss, Standardvertragsklauseln (SCCs), BCRs oder andere Garantien nach Kapitel V DSGVO.", + LegalBasis: []LegalReference{{Norm: "Art. 44-49 DSGVO", Article: "Übermittlung in Drittländer"}}, + Category: CategoryOrganizational, + Responsible: RoleDSB, + Sanctions: &SanctionInfo{MaxFine: "20 Mio. EUR oder 4% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "SCCs abgeschlossen", Required: true}, {Name: "Transfer Impact Assessment", Required: true}, {Name: "Dokumentation der Garantien", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "cross_border", + }, + { + ID: "DSGVO-OBL-012", + RegulationID: "dsgvo", + Title: "Meldeprozess für Datenschutzverletzungen", + Description: "Etablierung eines Prozesses zur Erkennung, Bewertung und Meldung von Datenschutzverletzungen an die Aufsichtsbehörde und ggf. an betroffene Personen.", + LegalBasis: []LegalReference{{Norm: "Art. 33 DSGVO", Article: "Meldung an Aufsichtsbehörde"}, {Norm: "Art. 34 DSGVO", Article: "Benachrichtigung Betroffener"}}, + Category: CategoryMeldepflicht, + Responsible: RoleDSB, + Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz"}, + Evidence: []EvidenceItem{{Name: "Breach-Notification-Prozess", Required: true}, {Name: "Meldevorlage", Required: true}, {Name: "Vorfallprotokoll", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "always", + }, + } + + // Hardcoded controls + m.controls = []ObligationControl{ + { + ID: "DSGVO-CTRL-001", + RegulationID: "dsgvo", + Name: "Consent-Management-System", + Description: "Implementierung eines Systems zur Verwaltung von Einwilligungen", + Category: "Technisch", + WhatToDo: "Implementierung einer Consent-Management-Plattform mit Protokollierung, Widerrufsmöglichkeit und Nachweis", + ISO27001Mapping: []string{"A.18.1"}, + Priority: PriorityHigh, + }, + { + ID: "DSGVO-CTRL-002", + RegulationID: "dsgvo", + Name: "Verschlüsselung personenbezogener Daten", + Description: "Verschlüsselung ruhender und übertragener Daten", + Category: "Technisch", + WhatToDo: "Implementierung von TLS 1.3 für Übertragung, AES-256 für Speicherung, Key-Management", + ISO27001Mapping: []string{"A.10.1"}, + Priority: PriorityHigh, + }, + { + ID: "DSGVO-CTRL-003", + RegulationID: "dsgvo", + Name: "Zugriffskontrolle", + Description: "Need-to-know-Prinzip für Zugriff auf personenbezogene Daten", + Category: "Organisatorisch", + WhatToDo: "Rollenbasierte Zugriffssteuerung (RBAC), regelmäßige Überprüfung der Berechtigungen, Protokollierung", + ISO27001Mapping: []string{"A.9.1", "A.9.2", "A.9.4"}, + Priority: PriorityHigh, + }, + { + ID: "DSGVO-CTRL-004", + RegulationID: "dsgvo", + Name: "Pseudonymisierung/Anonymisierung", + Description: "Anwendung von Pseudonymisierung wo möglich, Anonymisierung für Analysen", + Category: "Technisch", + WhatToDo: "Implementierung von Pseudonymisierungsverfahren, getrennte Speicherung von Zuordnungstabellen", + ISO27001Mapping: []string{"A.8.2"}, + Priority: PriorityMedium, + }, + { + ID: "DSGVO-CTRL-005", + RegulationID: "dsgvo", + Name: "Datenschutz-Schulungen", + Description: "Regelmäßige Schulung aller Mitarbeiter zu Datenschutzthemen", + Category: "Organisatorisch", + WhatToDo: "Jährliche Pflichtschulungen, Awareness-Kampagnen, dokumentierte Nachweise", + ISO27001Mapping: []string{"A.7.2.2"}, + Priority: PriorityMedium, + }, + } + + // Hardcoded incident deadlines + m.incidentDeadlines = []IncidentDeadline{ + { + RegulationID: "dsgvo", + Phase: "Meldung an Aufsichtsbehörde", + Deadline: "72 Stunden", + Content: "Meldung bei Verletzung des Schutzes personenbezogener Daten, es sei denn, die Verletzung führt voraussichtlich nicht zu einem Risiko für die Rechte und Freiheiten natürlicher Personen.", + Recipient: "Zuständige Datenschutz-Aufsichtsbehörde", + LegalBasis: []LegalReference{{Norm: "Art. 33 DSGVO"}}, + }, + { + RegulationID: "dsgvo", + Phase: "Benachrichtigung Betroffener", + Deadline: "unverzüglich", + Content: "Wenn die Verletzung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge hat, müssen die betroffenen Personen unverzüglich benachrichtigt werden.", + Recipient: "Betroffene Personen", + LegalBasis: []LegalReference{{Norm: "Art. 34 DSGVO"}}, + }, + } +} + +// ============================================================================ +// Decision Tree +// ============================================================================ + +func (m *DSGVOModule) buildDecisionTree() { + m.decisionTree = &DecisionTree{ + ID: "dsgvo_applicability", + Name: "DSGVO Anwendbarkeits-Entscheidungsbaum", + RootNode: &DecisionNode{ + ID: "root", + Question: "Verarbeitet Ihre Organisation personenbezogene Daten?", + YesNode: &DecisionNode{ + ID: "eu_check", + Question: "Ist Ihre Organisation in der EU/EWR ansässig?", + YesNode: &DecisionNode{ + ID: "eu_established", + Result: "DSGVO anwendbar", + Explanation: "Die DSGVO gilt für alle in der EU ansässigen Organisationen, die personenbezogene Daten verarbeiten.", + }, + NoNode: &DecisionNode{ + ID: "offering_check", + Question: "Bieten Sie Waren oder Dienstleistungen an Personen in der EU an?", + YesNode: &DecisionNode{ + ID: "offering_to_eu", + Result: "DSGVO anwendbar", + Explanation: "Die DSGVO gilt für Organisationen außerhalb der EU, die Waren/Dienstleistungen an EU-Bürger anbieten.", + }, + NoNode: &DecisionNode{ + ID: "monitoring_check", + Question: "Beobachten Sie das Verhalten von Personen in der EU?", + YesNode: &DecisionNode{ + ID: "monitoring_eu", + Result: "DSGVO anwendbar", + Explanation: "Die DSGVO gilt für Organisationen, die das Verhalten von Personen in der EU beobachten (z.B. Tracking, Profiling).", + }, + NoNode: &DecisionNode{ + ID: "not_applicable", + Result: "DSGVO nicht anwendbar", + Explanation: "Die DSGVO ist nicht anwendbar, da keine der Anknüpfungskriterien erfüllt ist.", + }, + }, + }, + }, + NoNode: &DecisionNode{ + ID: "no_personal_data", + Result: "DSGVO nicht anwendbar", + Explanation: "Die DSGVO gilt nur für die Verarbeitung personenbezogener Daten.", + }, + }, + } +} diff --git a/ai-compliance-sdk/internal/ucca/escalation_models.go b/ai-compliance-sdk/internal/ucca/escalation_models.go new file mode 100644 index 0000000..ace7074 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/escalation_models.go @@ -0,0 +1,286 @@ +package ucca + +import ( + "time" + + "github.com/google/uuid" +) + +// EscalationLevel represents the escalation level (E0-E3). +type EscalationLevel string + +const ( + EscalationLevelE0 EscalationLevel = "E0" // Auto-Approve + EscalationLevelE1 EscalationLevel = "E1" // Team-Lead Review + EscalationLevelE2 EscalationLevel = "E2" // DSB Consultation + EscalationLevelE3 EscalationLevel = "E3" // DSB + Legal Review +) + +// EscalationStatus represents the status of an escalation. +type EscalationStatus string + +const ( + EscalationStatusPending EscalationStatus = "pending" + EscalationStatusAssigned EscalationStatus = "assigned" + EscalationStatusInReview EscalationStatus = "in_review" + EscalationStatusApproved EscalationStatus = "approved" + EscalationStatusRejected EscalationStatus = "rejected" + EscalationStatusReturned EscalationStatus = "returned" +) + +// EscalationDecision represents the decision made on an escalation. +type EscalationDecision string + +const ( + EscalationDecisionApprove EscalationDecision = "approve" + EscalationDecisionReject EscalationDecision = "reject" + EscalationDecisionModify EscalationDecision = "modify" + EscalationDecisionEscalate EscalationDecision = "escalate" +) + +// Escalation represents an escalation record for a UCCA assessment. +type Escalation struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + AssessmentID uuid.UUID `json:"assessment_id" db:"assessment_id"` + EscalationLevel EscalationLevel `json:"escalation_level" db:"escalation_level"` + EscalationReason string `json:"escalation_reason" db:"escalation_reason"` + AssignedTo *uuid.UUID `json:"assigned_to,omitempty" db:"assigned_to"` + AssignedRole *string `json:"assigned_role,omitempty" db:"assigned_role"` + AssignedAt *time.Time `json:"assigned_at,omitempty" db:"assigned_at"` + Status EscalationStatus `json:"status" db:"status"` + ReviewerID *uuid.UUID `json:"reviewer_id,omitempty" db:"reviewer_id"` + ReviewerNotes *string `json:"reviewer_notes,omitempty" db:"reviewer_notes"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty" db:"reviewed_at"` + Decision *EscalationDecision `json:"decision,omitempty" db:"decision"` + DecisionNotes *string `json:"decision_notes,omitempty" db:"decision_notes"` + DecisionAt *time.Time `json:"decision_at,omitempty" db:"decision_at"` + Conditions []string `json:"conditions" db:"conditions"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + DueDate *time.Time `json:"due_date,omitempty" db:"due_date"` + NotificationSent bool `json:"notification_sent" db:"notification_sent"` + NotificationSentAt *time.Time `json:"notification_sent_at,omitempty" db:"notification_sent_at"` +} + +// EscalationHistory represents an audit trail entry for escalation changes. +type EscalationHistory struct { + ID uuid.UUID `json:"id" db:"id"` + EscalationID uuid.UUID `json:"escalation_id" db:"escalation_id"` + Action string `json:"action" db:"action"` + OldStatus string `json:"old_status,omitempty" db:"old_status"` + NewStatus string `json:"new_status,omitempty" db:"new_status"` + OldLevel string `json:"old_level,omitempty" db:"old_level"` + NewLevel string `json:"new_level,omitempty" db:"new_level"` + ActorID uuid.UUID `json:"actor_id" db:"actor_id"` + ActorRole string `json:"actor_role,omitempty" db:"actor_role"` + Notes string `json:"notes,omitempty" db:"notes"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// DSBPoolMember represents a member of the DSB review pool. +type DSBPoolMember struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + UserName string `json:"user_name" db:"user_name"` + UserEmail string `json:"user_email" db:"user_email"` + Role string `json:"role" db:"role"` + IsActive bool `json:"is_active" db:"is_active"` + MaxConcurrentReviews int `json:"max_concurrent_reviews" db:"max_concurrent_reviews"` + CurrentReviews int `json:"current_reviews" db:"current_reviews"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// EscalationSLA represents SLA configuration for an escalation level. +type EscalationSLA struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + EscalationLevel EscalationLevel `json:"escalation_level" db:"escalation_level"` + ResponseHours int `json:"response_hours" db:"response_hours"` + ResolutionHours int `json:"resolution_hours" db:"resolution_hours"` + NotifyOnCreation bool `json:"notify_on_creation" db:"notify_on_creation"` + NotifyOnApproachingSLA bool `json:"notify_on_approaching_sla" db:"notify_on_approaching_sla"` + NotifyOnSLABreach bool `json:"notify_on_sla_breach" db:"notify_on_sla_breach"` + ApproachingSLAHours int `json:"approaching_sla_hours" db:"approaching_sla_hours"` + AutoEscalateOnBreach bool `json:"auto_escalate_on_breach" db:"auto_escalate_on_breach"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// EscalationWithAssessment combines escalation with assessment summary. +type EscalationWithAssessment struct { + Escalation + AssessmentTitle string `json:"assessment_title"` + AssessmentFeasibility string `json:"assessment_feasibility"` + AssessmentRiskScore int `json:"assessment_risk_score"` + AssessmentDomain string `json:"assessment_domain"` +} + +// EscalationStats provides statistics for escalations. +type EscalationStats struct { + TotalPending int `json:"total_pending"` + TotalInReview int `json:"total_in_review"` + TotalApproved int `json:"total_approved"` + TotalRejected int `json:"total_rejected"` + ByLevel map[EscalationLevel]int `json:"by_level"` + OverdueSLA int `json:"overdue_sla"` + ApproachingSLA int `json:"approaching_sla"` + AvgResolutionHours float64 `json:"avg_resolution_hours"` +} + +// EscalationTrigger contains the logic to determine escalation level. +type EscalationTrigger struct { + // Thresholds + E1RiskThreshold int // Risk score threshold for E1 (default: 20) + E2RiskThreshold int // Risk score threshold for E2 (default: 40) + E3RiskThreshold int // Risk score threshold for E3 (default: 60) +} + +// DefaultEscalationTrigger returns the default escalation trigger configuration. +func DefaultEscalationTrigger() *EscalationTrigger { + return &EscalationTrigger{ + E1RiskThreshold: 20, + E2RiskThreshold: 40, + E3RiskThreshold: 60, + } +} + +// DetermineEscalationLevel determines the appropriate escalation level for an assessment. +func (t *EscalationTrigger) DetermineEscalationLevel(result *AssessmentResult) (EscalationLevel, string) { + reasons := []string{} + + // E3: Highest priority checks + // - BLOCK rules triggered + // - Risk > 60 + // - Art. 22 risk (automated individual decisions) + hasBlock := false + for _, rule := range result.TriggeredRules { + if rule.Severity == "BLOCK" { + hasBlock = true + reasons = append(reasons, "BLOCK-Regel ausgelöst: "+rule.Code) + break + } + } + + if hasBlock || result.RiskScore > t.E3RiskThreshold || result.Art22Risk { + if result.RiskScore > t.E3RiskThreshold { + reasons = append(reasons, "Risiko-Score über 60") + } + if result.Art22Risk { + reasons = append(reasons, "Art. 22 DSGVO Risiko (automatisierte Entscheidungen)") + } + return EscalationLevelE3, joinReasons(reasons, "E3 erforderlich: ") + } + + // E2: Medium priority checks + // - Art. 9 data (special categories) + // - DSFA recommended + // - Risk 40-60 + hasArt9 := false + for _, rule := range result.TriggeredRules { + if rule.Code == "R-002" || rule.Code == "A-002" { // Art. 9 rules + hasArt9 = true + reasons = append(reasons, "Besondere Datenkategorien (Art. 9 DSGVO)") + break + } + } + + if hasArt9 || result.DSFARecommended || result.RiskScore > t.E2RiskThreshold { + if result.DSFARecommended { + reasons = append(reasons, "DSFA empfohlen") + } + if result.RiskScore > t.E2RiskThreshold { + reasons = append(reasons, "Risiko-Score 40-60") + } + return EscalationLevelE2, joinReasons(reasons, "DSB-Konsultation erforderlich: ") + } + + // E1: Low priority checks + // - WARN rules triggered + // - Risk 20-40 + hasWarn := false + for _, rule := range result.TriggeredRules { + if rule.Severity == "WARN" { + hasWarn = true + reasons = append(reasons, "WARN-Regel ausgelöst") + break + } + } + + if hasWarn || result.RiskScore > t.E1RiskThreshold { + if result.RiskScore > t.E1RiskThreshold { + reasons = append(reasons, "Risiko-Score 20-40") + } + return EscalationLevelE1, joinReasons(reasons, "Team-Lead Review erforderlich: ") + } + + // E0: Auto-approve + // - Only INFO rules + // - Risk < 20 + return EscalationLevelE0, "Automatische Freigabe: Nur INFO-Regeln, niedriges Risiko" +} + +// GetDefaultSLA returns the default SLA for an escalation level. +func GetDefaultSLA(level EscalationLevel) (responseHours, resolutionHours int) { + switch level { + case EscalationLevelE0: + return 0, 0 // Auto-approve, no SLA + case EscalationLevelE1: + return 24, 72 // 1 day response, 3 days resolution + case EscalationLevelE2: + return 8, 48 // 8 hours response, 2 days resolution + case EscalationLevelE3: + return 4, 24 // 4 hours response, 1 day resolution (urgent) + default: + return 24, 72 + } +} + +// GetRoleForLevel returns the required role for reviewing an escalation level. +func GetRoleForLevel(level EscalationLevel) string { + switch level { + case EscalationLevelE0: + return "" // No review needed + case EscalationLevelE1: + return "team_lead" + case EscalationLevelE2: + return "dsb" + case EscalationLevelE3: + return "dsb" // DSB + Legal, but DSB is primary + default: + return "dsb" + } +} + +func joinReasons(reasons []string, prefix string) string { + if len(reasons) == 0 { + return prefix + } + result := prefix + for i, r := range reasons { + if i > 0 { + result += "; " + } + result += r + } + return result +} + +// CreateEscalationRequest is the request to create an escalation. +type CreateEscalationRequest struct { + AssessmentID uuid.UUID `json:"assessment_id" binding:"required"` +} + +// AssignEscalationRequest is the request to assign an escalation. +type AssignEscalationRequest struct { + AssignedTo uuid.UUID `json:"assigned_to" binding:"required"` +} + +// DecideEscalationRequest is the request to make a decision on an escalation. +type DecideEscalationRequest struct { + Decision EscalationDecision `json:"decision" binding:"required"` + DecisionNotes string `json:"decision_notes"` + Conditions []string `json:"conditions,omitempty"` +} diff --git a/ai-compliance-sdk/internal/ucca/escalation_store.go b/ai-compliance-sdk/internal/ucca/escalation_store.go new file mode 100644 index 0000000..ad0cd41 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/escalation_store.go @@ -0,0 +1,502 @@ +package ucca + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// EscalationStore handles database operations for escalations. +type EscalationStore struct { + pool *pgxpool.Pool +} + +// NewEscalationStore creates a new escalation store. +func NewEscalationStore(pool *pgxpool.Pool) *EscalationStore { + return &EscalationStore{pool: pool} +} + +// CreateEscalation creates a new escalation for an assessment. +func (s *EscalationStore) CreateEscalation(ctx context.Context, e *Escalation) error { + conditionsJSON, err := json.Marshal(e.Conditions) + if err != nil { + conditionsJSON = []byte("[]") + } + + query := ` + INSERT INTO ucca_escalations ( + id, tenant_id, assessment_id, escalation_level, escalation_reason, + status, conditions, due_date, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW() + ) + ` + + e.ID = uuid.New() + e.CreatedAt = time.Now().UTC() + e.UpdatedAt = e.CreatedAt + + _, err = s.pool.Exec(ctx, query, + e.ID, e.TenantID, e.AssessmentID, e.EscalationLevel, e.EscalationReason, + e.Status, conditionsJSON, e.DueDate, + ) + + return err +} + +// GetEscalation retrieves an escalation by ID. +func (s *EscalationStore) GetEscalation(ctx context.Context, id uuid.UUID) (*Escalation, error) { + query := ` + SELECT id, tenant_id, assessment_id, escalation_level, escalation_reason, + assigned_to, assigned_role, assigned_at, status, reviewer_id, + reviewer_notes, reviewed_at, decision, decision_notes, decision_at, + conditions, created_at, updated_at, due_date, + notification_sent, notification_sent_at + FROM ucca_escalations + WHERE id = $1 + ` + + var e Escalation + var conditionsJSON []byte + + err := s.pool.QueryRow(ctx, query, id).Scan( + &e.ID, &e.TenantID, &e.AssessmentID, &e.EscalationLevel, &e.EscalationReason, + &e.AssignedTo, &e.AssignedRole, &e.AssignedAt, &e.Status, &e.ReviewerID, + &e.ReviewerNotes, &e.ReviewedAt, &e.Decision, &e.DecisionNotes, &e.DecisionAt, + &conditionsJSON, &e.CreatedAt, &e.UpdatedAt, &e.DueDate, + &e.NotificationSent, &e.NotificationSentAt, + ) + + if err != nil { + return nil, err + } + + if len(conditionsJSON) > 0 { + json.Unmarshal(conditionsJSON, &e.Conditions) + } + + return &e, nil +} + +// GetEscalationByAssessment retrieves an escalation for an assessment. +func (s *EscalationStore) GetEscalationByAssessment(ctx context.Context, assessmentID uuid.UUID) (*Escalation, error) { + query := ` + SELECT id, tenant_id, assessment_id, escalation_level, escalation_reason, + assigned_to, assigned_role, assigned_at, status, reviewer_id, + reviewer_notes, reviewed_at, decision, decision_notes, decision_at, + conditions, created_at, updated_at, due_date, + notification_sent, notification_sent_at + FROM ucca_escalations + WHERE assessment_id = $1 + ORDER BY created_at DESC + LIMIT 1 + ` + + var e Escalation + var conditionsJSON []byte + + err := s.pool.QueryRow(ctx, query, assessmentID).Scan( + &e.ID, &e.TenantID, &e.AssessmentID, &e.EscalationLevel, &e.EscalationReason, + &e.AssignedTo, &e.AssignedRole, &e.AssignedAt, &e.Status, &e.ReviewerID, + &e.ReviewerNotes, &e.ReviewedAt, &e.Decision, &e.DecisionNotes, &e.DecisionAt, + &conditionsJSON, &e.CreatedAt, &e.UpdatedAt, &e.DueDate, + &e.NotificationSent, &e.NotificationSentAt, + ) + + if err != nil { + return nil, err + } + + if len(conditionsJSON) > 0 { + json.Unmarshal(conditionsJSON, &e.Conditions) + } + + return &e, nil +} + +// ListEscalations lists escalations for a tenant with optional filters. +func (s *EscalationStore) ListEscalations(ctx context.Context, tenantID uuid.UUID, status string, level string, assignedTo *uuid.UUID) ([]EscalationWithAssessment, error) { + query := ` + SELECT e.id, e.tenant_id, e.assessment_id, e.escalation_level, e.escalation_reason, + e.assigned_to, e.assigned_role, e.assigned_at, e.status, e.reviewer_id, + e.reviewer_notes, e.reviewed_at, e.decision, e.decision_notes, e.decision_at, + e.conditions, e.created_at, e.updated_at, e.due_date, + e.notification_sent, e.notification_sent_at, + a.title, a.feasibility, a.risk_score, a.domain + FROM ucca_escalations e + JOIN ucca_assessments a ON e.assessment_id = a.id + WHERE e.tenant_id = $1 + ` + args := []interface{}{tenantID} + argCount := 1 + + if status != "" { + argCount++ + query += fmt.Sprintf(" AND e.status = $%d", argCount) + args = append(args, status) + } + + if level != "" { + argCount++ + query += fmt.Sprintf(" AND e.escalation_level = $%d", argCount) + args = append(args, level) + } + + if assignedTo != nil { + argCount++ + query += fmt.Sprintf(" AND e.assigned_to = $%d", argCount) + args = append(args, *assignedTo) + } + + query += " ORDER BY e.created_at DESC" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var escalations []EscalationWithAssessment + for rows.Next() { + var e EscalationWithAssessment + var conditionsJSON []byte + + err := rows.Scan( + &e.ID, &e.TenantID, &e.AssessmentID, &e.EscalationLevel, &e.EscalationReason, + &e.AssignedTo, &e.AssignedRole, &e.AssignedAt, &e.Status, &e.ReviewerID, + &e.ReviewerNotes, &e.ReviewedAt, &e.Decision, &e.DecisionNotes, &e.DecisionAt, + &conditionsJSON, &e.CreatedAt, &e.UpdatedAt, &e.DueDate, + &e.NotificationSent, &e.NotificationSentAt, + &e.AssessmentTitle, &e.AssessmentFeasibility, &e.AssessmentRiskScore, &e.AssessmentDomain, + ) + if err != nil { + return nil, err + } + + if len(conditionsJSON) > 0 { + json.Unmarshal(conditionsJSON, &e.Conditions) + } + + escalations = append(escalations, e) + } + + return escalations, nil +} + +// AssignEscalation assigns an escalation to a reviewer. +func (s *EscalationStore) AssignEscalation(ctx context.Context, id uuid.UUID, assignedTo uuid.UUID, role string) error { + query := ` + UPDATE ucca_escalations + SET assigned_to = $2, assigned_role = $3, assigned_at = NOW(), + status = 'assigned', updated_at = NOW() + WHERE id = $1 + ` + + _, err := s.pool.Exec(ctx, query, id, assignedTo, role) + return err +} + +// StartReview marks an escalation as being reviewed. +func (s *EscalationStore) StartReview(ctx context.Context, id uuid.UUID, reviewerID uuid.UUID) error { + query := ` + UPDATE ucca_escalations + SET reviewer_id = $2, status = 'in_review', updated_at = NOW() + WHERE id = $1 + ` + + _, err := s.pool.Exec(ctx, query, id, reviewerID) + return err +} + +// DecideEscalation records a decision on an escalation. +func (s *EscalationStore) DecideEscalation(ctx context.Context, id uuid.UUID, decision EscalationDecision, notes string, conditions []string) error { + var newStatus EscalationStatus + switch decision { + case EscalationDecisionApprove: + newStatus = EscalationStatusApproved + case EscalationDecisionReject: + newStatus = EscalationStatusRejected + case EscalationDecisionModify: + newStatus = EscalationStatusReturned + case EscalationDecisionEscalate: + // Keep in review for re-assignment + newStatus = EscalationStatusPending + default: + newStatus = EscalationStatusPending + } + + conditionsJSON, _ := json.Marshal(conditions) + + query := ` + UPDATE ucca_escalations + SET decision = $2, decision_notes = $3, decision_at = NOW(), + status = $4, conditions = $5, updated_at = NOW() + WHERE id = $1 + ` + + _, err := s.pool.Exec(ctx, query, id, decision, notes, newStatus, conditionsJSON) + return err +} + +// AddEscalationHistory adds an audit entry for an escalation. +func (s *EscalationStore) AddEscalationHistory(ctx context.Context, h *EscalationHistory) error { + query := ` + INSERT INTO ucca_escalation_history ( + id, escalation_id, action, old_status, new_status, + old_level, new_level, actor_id, actor_role, notes, created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW() + ) + ` + + h.ID = uuid.New() + h.CreatedAt = time.Now().UTC() + + _, err := s.pool.Exec(ctx, query, + h.ID, h.EscalationID, h.Action, h.OldStatus, h.NewStatus, + h.OldLevel, h.NewLevel, h.ActorID, h.ActorRole, h.Notes, + ) + + return err +} + +// GetEscalationHistory retrieves the audit history for an escalation. +func (s *EscalationStore) GetEscalationHistory(ctx context.Context, escalationID uuid.UUID) ([]EscalationHistory, error) { + query := ` + SELECT id, escalation_id, action, old_status, new_status, + old_level, new_level, actor_id, actor_role, notes, created_at + FROM ucca_escalation_history + WHERE escalation_id = $1 + ORDER BY created_at ASC + ` + + rows, err := s.pool.Query(ctx, query, escalationID) + if err != nil { + return nil, err + } + defer rows.Close() + + var history []EscalationHistory + for rows.Next() { + var h EscalationHistory + err := rows.Scan( + &h.ID, &h.EscalationID, &h.Action, &h.OldStatus, &h.NewStatus, + &h.OldLevel, &h.NewLevel, &h.ActorID, &h.ActorRole, &h.Notes, &h.CreatedAt, + ) + if err != nil { + return nil, err + } + history = append(history, h) + } + + return history, nil +} + +// GetEscalationStats retrieves escalation statistics for a tenant. +func (s *EscalationStore) GetEscalationStats(ctx context.Context, tenantID uuid.UUID) (*EscalationStats, error) { + stats := &EscalationStats{ + ByLevel: make(map[EscalationLevel]int), + } + + // Count by status + statusQuery := ` + SELECT status, COUNT(*) as count + FROM ucca_escalations + WHERE tenant_id = $1 + GROUP BY status + ` + rows, err := s.pool.Query(ctx, statusQuery, tenantID) + if err != nil { + return nil, err + } + + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + continue + } + switch EscalationStatus(status) { + case EscalationStatusPending: + stats.TotalPending = count + case EscalationStatusInReview, EscalationStatusAssigned: + stats.TotalInReview += count + case EscalationStatusApproved: + stats.TotalApproved = count + case EscalationStatusRejected: + stats.TotalRejected = count + } + } + rows.Close() + + // Count by level + levelQuery := ` + SELECT escalation_level, COUNT(*) as count + FROM ucca_escalations + WHERE tenant_id = $1 AND status NOT IN ('approved', 'rejected') + GROUP BY escalation_level + ` + rows, err = s.pool.Query(ctx, levelQuery, tenantID) + if err != nil { + return nil, err + } + + for rows.Next() { + var level string + var count int + if err := rows.Scan(&level, &count); err != nil { + continue + } + stats.ByLevel[EscalationLevel(level)] = count + } + rows.Close() + + // Count overdue SLA + overdueQuery := ` + SELECT COUNT(*) + FROM ucca_escalations + WHERE tenant_id = $1 + AND status NOT IN ('approved', 'rejected') + AND due_date < NOW() + ` + s.pool.QueryRow(ctx, overdueQuery, tenantID).Scan(&stats.OverdueSLA) + + // Count approaching SLA (within 8 hours) + approachingQuery := ` + SELECT COUNT(*) + FROM ucca_escalations + WHERE tenant_id = $1 + AND status NOT IN ('approved', 'rejected') + AND due_date > NOW() + AND due_date < NOW() + INTERVAL '8 hours' + ` + s.pool.QueryRow(ctx, approachingQuery, tenantID).Scan(&stats.ApproachingSLA) + + // Average resolution time + avgQuery := ` + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (decision_at - created_at)) / 3600), 0) + FROM ucca_escalations + WHERE tenant_id = $1 AND decision_at IS NOT NULL + ` + s.pool.QueryRow(ctx, avgQuery, tenantID).Scan(&stats.AvgResolutionHours) + + return stats, nil +} + +// DSB Pool Operations + +// AddDSBPoolMember adds a member to the DSB review pool. +func (s *EscalationStore) AddDSBPoolMember(ctx context.Context, m *DSBPoolMember) error { + query := ` + INSERT INTO ucca_dsb_pool ( + id, tenant_id, user_id, user_name, user_email, role, + is_active, max_concurrent_reviews, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW() + ) + ON CONFLICT (tenant_id, user_id) DO UPDATE + SET user_name = $4, user_email = $5, role = $6, + is_active = $7, max_concurrent_reviews = $8, updated_at = NOW() + ` + + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + + _, err := s.pool.Exec(ctx, query, + m.ID, m.TenantID, m.UserID, m.UserName, m.UserEmail, m.Role, + m.IsActive, m.MaxConcurrentReviews, + ) + + return err +} + +// GetDSBPoolMembers retrieves active DSB pool members for a tenant. +func (s *EscalationStore) GetDSBPoolMembers(ctx context.Context, tenantID uuid.UUID, role string) ([]DSBPoolMember, error) { + query := ` + SELECT id, tenant_id, user_id, user_name, user_email, role, + is_active, max_concurrent_reviews, current_reviews, created_at, updated_at + FROM ucca_dsb_pool + WHERE tenant_id = $1 AND is_active = true + ` + args := []interface{}{tenantID} + + if role != "" { + query += " AND role = $2" + args = append(args, role) + } + + query += " ORDER BY current_reviews ASC, user_name ASC" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var members []DSBPoolMember + for rows.Next() { + var m DSBPoolMember + err := rows.Scan( + &m.ID, &m.TenantID, &m.UserID, &m.UserName, &m.UserEmail, &m.Role, + &m.IsActive, &m.MaxConcurrentReviews, &m.CurrentReviews, &m.CreatedAt, &m.UpdatedAt, + ) + if err != nil { + return nil, err + } + members = append(members, m) + } + + return members, nil +} + +// GetNextAvailableReviewer finds the next available reviewer for a role. +func (s *EscalationStore) GetNextAvailableReviewer(ctx context.Context, tenantID uuid.UUID, role string) (*DSBPoolMember, error) { + query := ` + SELECT id, tenant_id, user_id, user_name, user_email, role, + is_active, max_concurrent_reviews, current_reviews, created_at, updated_at + FROM ucca_dsb_pool + WHERE tenant_id = $1 AND is_active = true AND role = $2 + AND current_reviews < max_concurrent_reviews + ORDER BY current_reviews ASC + LIMIT 1 + ` + + var m DSBPoolMember + err := s.pool.QueryRow(ctx, query, tenantID, role).Scan( + &m.ID, &m.TenantID, &m.UserID, &m.UserName, &m.UserEmail, &m.Role, + &m.IsActive, &m.MaxConcurrentReviews, &m.CurrentReviews, &m.CreatedAt, &m.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + return &m, nil +} + +// IncrementReviewerCount increments the current review count for a DSB member. +func (s *EscalationStore) IncrementReviewerCount(ctx context.Context, userID uuid.UUID) error { + query := ` + UPDATE ucca_dsb_pool + SET current_reviews = current_reviews + 1, updated_at = NOW() + WHERE user_id = $1 + ` + _, err := s.pool.Exec(ctx, query, userID) + return err +} + +// DecrementReviewerCount decrements the current review count for a DSB member. +func (s *EscalationStore) DecrementReviewerCount(ctx context.Context, userID uuid.UUID) error { + query := ` + UPDATE ucca_dsb_pool + SET current_reviews = GREATEST(0, current_reviews - 1), updated_at = NOW() + WHERE user_id = $1 + ` + _, err := s.pool.Exec(ctx, query, userID) + return err +} diff --git a/ai-compliance-sdk/internal/ucca/escalation_test.go b/ai-compliance-sdk/internal/ucca/escalation_test.go new file mode 100644 index 0000000..7705336 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/escalation_test.go @@ -0,0 +1,446 @@ +package ucca + +import ( + "testing" +) + +// ============================================================================ +// EscalationTrigger Tests +// ============================================================================ + +func TestDetermineEscalationLevel_E0_LowRiskInfoOnly(t *testing.T) { + trigger := DefaultEscalationTrigger() + + result := &AssessmentResult{ + Feasibility: FeasibilityYES, + RiskLevel: RiskLevelMINIMAL, + RiskScore: 10, + TriggeredRules: []TriggeredRule{ + {Code: "R-INFO-001", Severity: "INFO", Description: "Informative Regel"}, + }, + DSFARecommended: false, + Art22Risk: false, + } + + level, reason := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE0 { + t.Errorf("Expected E0 for low-risk case, got %s", level) + } + if reason == "" { + t.Error("Expected non-empty reason") + } +} + +func TestDetermineEscalationLevel_E1_WarnRules(t *testing.T) { + trigger := DefaultEscalationTrigger() + + result := &AssessmentResult{ + Feasibility: FeasibilityCONDITIONAL, + RiskLevel: RiskLevelLOW, + RiskScore: 25, + TriggeredRules: []TriggeredRule{ + {Code: "R-WARN-001", Severity: "WARN", Description: "Warnung"}, + }, + DSFARecommended: false, + Art22Risk: false, + } + + level, reason := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE1 { + t.Errorf("Expected E1 for WARN rule, got %s", level) + } + if reason == "" { + t.Error("Expected non-empty reason") + } +} + +func TestDetermineEscalationLevel_E1_RiskScore20to40(t *testing.T) { + trigger := DefaultEscalationTrigger() + + result := &AssessmentResult{ + Feasibility: FeasibilityCONDITIONAL, + RiskLevel: RiskLevelLOW, + RiskScore: 35, + TriggeredRules: []TriggeredRule{}, + DSFARecommended: false, + Art22Risk: false, + } + + level, _ := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE1 { + t.Errorf("Expected E1 for risk score 35, got %s", level) + } +} + +func TestDetermineEscalationLevel_E2_Article9Data(t *testing.T) { + trigger := DefaultEscalationTrigger() + + result := &AssessmentResult{ + Feasibility: FeasibilityCONDITIONAL, + RiskLevel: RiskLevelMEDIUM, + RiskScore: 45, + TriggeredRules: []TriggeredRule{ + {Code: "R-002", Severity: "WARN", Description: "Art. 9 Daten"}, + }, + DSFARecommended: false, + Art22Risk: false, + } + + level, reason := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE2 { + t.Errorf("Expected E2 for Art. 9 data, got %s", level) + } + if reason == "" { + t.Error("Expected non-empty reason mentioning Art. 9") + } +} + +func TestDetermineEscalationLevel_E2_DSFARecommended(t *testing.T) { + trigger := DefaultEscalationTrigger() + + result := &AssessmentResult{ + Feasibility: FeasibilityCONDITIONAL, + RiskLevel: RiskLevelMEDIUM, + RiskScore: 42, + TriggeredRules: []TriggeredRule{}, + DSFARecommended: true, + Art22Risk: false, + } + + level, reason := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE2 { + t.Errorf("Expected E2 for DSFA recommended, got %s", level) + } + if reason == "" { + t.Error("Expected reason to mention DSFA") + } +} + +func TestDetermineEscalationLevel_E3_BlockRule(t *testing.T) { + trigger := DefaultEscalationTrigger() + + result := &AssessmentResult{ + Feasibility: FeasibilityNO, + RiskLevel: RiskLevelHIGH, + RiskScore: 75, + TriggeredRules: []TriggeredRule{ + {Code: "R-BLOCK-001", Severity: "BLOCK", Description: "Blockierung"}, + }, + DSFARecommended: true, + Art22Risk: false, + } + + level, reason := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE3 { + t.Errorf("Expected E3 for BLOCK rule, got %s", level) + } + if reason == "" { + t.Error("Expected reason to mention BLOCK") + } +} + +func TestDetermineEscalationLevel_E3_Art22Risk(t *testing.T) { + trigger := DefaultEscalationTrigger() + + result := &AssessmentResult{ + Feasibility: FeasibilityCONDITIONAL, + RiskLevel: RiskLevelHIGH, + RiskScore: 55, + TriggeredRules: []TriggeredRule{}, + DSFARecommended: false, + Art22Risk: true, + } + + level, reason := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE3 { + t.Errorf("Expected E3 for Art. 22 risk, got %s", level) + } + if reason == "" { + t.Error("Expected reason to mention Art. 22") + } +} + +func TestDetermineEscalationLevel_E3_HighRiskScore(t *testing.T) { + trigger := DefaultEscalationTrigger() + + result := &AssessmentResult{ + Feasibility: FeasibilityCONDITIONAL, + RiskLevel: RiskLevelHIGH, + RiskScore: 70, // Above E3 threshold + TriggeredRules: []TriggeredRule{}, + DSFARecommended: false, + Art22Risk: false, + } + + level, _ := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE3 { + t.Errorf("Expected E3 for risk score 70, got %s", level) + } +} + +// ============================================================================ +// SLA Tests +// ============================================================================ + +func TestGetDefaultSLA_E0(t *testing.T) { + response, resolution := GetDefaultSLA(EscalationLevelE0) + + if response != 0 || resolution != 0 { + t.Errorf("E0 should have no SLA, got response=%d, resolution=%d", response, resolution) + } +} + +func TestGetDefaultSLA_E1(t *testing.T) { + response, resolution := GetDefaultSLA(EscalationLevelE1) + + if response != 24 { + t.Errorf("E1 should have 24h response SLA, got %d", response) + } + if resolution != 72 { + t.Errorf("E1 should have 72h resolution SLA, got %d", resolution) + } +} + +func TestGetDefaultSLA_E2(t *testing.T) { + response, resolution := GetDefaultSLA(EscalationLevelE2) + + if response != 8 { + t.Errorf("E2 should have 8h response SLA, got %d", response) + } + if resolution != 48 { + t.Errorf("E2 should have 48h resolution SLA, got %d", resolution) + } +} + +func TestGetDefaultSLA_E3(t *testing.T) { + response, resolution := GetDefaultSLA(EscalationLevelE3) + + if response != 4 { + t.Errorf("E3 should have 4h response SLA, got %d", response) + } + if resolution != 24 { + t.Errorf("E3 should have 24h resolution SLA, got %d", resolution) + } +} + +// ============================================================================ +// Role Assignment Tests +// ============================================================================ + +func TestGetRoleForLevel_E0(t *testing.T) { + role := GetRoleForLevel(EscalationLevelE0) + if role != "" { + t.Errorf("E0 should have no role, got %s", role) + } +} + +func TestGetRoleForLevel_E1(t *testing.T) { + role := GetRoleForLevel(EscalationLevelE1) + if role != "team_lead" { + t.Errorf("E1 should require team_lead, got %s", role) + } +} + +func TestGetRoleForLevel_E2(t *testing.T) { + role := GetRoleForLevel(EscalationLevelE2) + if role != "dsb" { + t.Errorf("E2 should require dsb, got %s", role) + } +} + +func TestGetRoleForLevel_E3(t *testing.T) { + role := GetRoleForLevel(EscalationLevelE3) + if role != "dsb" { + t.Errorf("E3 should require dsb (primary), got %s", role) + } +} + +// ============================================================================ +// Default Trigger Configuration Tests +// ============================================================================ + +func TestDefaultEscalationTrigger_Thresholds(t *testing.T) { + trigger := DefaultEscalationTrigger() + + if trigger.E1RiskThreshold != 20 { + t.Errorf("E1 threshold should be 20, got %d", trigger.E1RiskThreshold) + } + if trigger.E2RiskThreshold != 40 { + t.Errorf("E2 threshold should be 40, got %d", trigger.E2RiskThreshold) + } + if trigger.E3RiskThreshold != 60 { + t.Errorf("E3 threshold should be 60, got %d", trigger.E3RiskThreshold) + } +} + +// ============================================================================ +// Edge Case Tests +// ============================================================================ + +func TestDetermineEscalationLevel_BoundaryRiskScores(t *testing.T) { + trigger := DefaultEscalationTrigger() + + tests := []struct { + name string + riskScore int + expectedLevel EscalationLevel + }{ + {"Risk 0 → E0", 0, EscalationLevelE0}, + {"Risk 19 → E0", 19, EscalationLevelE0}, + {"Risk 20 → E0 (boundary)", 20, EscalationLevelE0}, + {"Risk 21 → E1", 21, EscalationLevelE1}, + {"Risk 39 → E1", 39, EscalationLevelE1}, + {"Risk 40 → E1 (boundary)", 40, EscalationLevelE1}, + {"Risk 41 → E2", 41, EscalationLevelE2}, + {"Risk 59 → E2", 59, EscalationLevelE2}, + {"Risk 60 → E2 (boundary)", 60, EscalationLevelE2}, + {"Risk 61 → E3", 61, EscalationLevelE3}, + {"Risk 100 → E3", 100, EscalationLevelE3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &AssessmentResult{ + RiskScore: tt.riskScore, + TriggeredRules: []TriggeredRule{}, + DSFARecommended: false, + Art22Risk: false, + } + + level, _ := trigger.DetermineEscalationLevel(result) + if level != tt.expectedLevel { + t.Errorf("Expected %s for risk score %d, got %s", tt.expectedLevel, tt.riskScore, level) + } + }) + } +} + +func TestDetermineEscalationLevel_CombinedFactors(t *testing.T) { + trigger := DefaultEscalationTrigger() + + // Multiple E3 factors should still result in E3 + result := &AssessmentResult{ + RiskScore: 80, + TriggeredRules: []TriggeredRule{ + {Code: "R-BLOCK-001", Severity: "BLOCK", Description: "Block 1"}, + {Code: "R-BLOCK-002", Severity: "BLOCK", Description: "Block 2"}, + }, + DSFARecommended: true, + Art22Risk: true, + } + + level, reason := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE3 { + t.Errorf("Expected E3 for multiple high-risk factors, got %s", level) + } + + // Reason should mention multiple factors + if reason == "" { + t.Error("Expected comprehensive reason for multiple factors") + } +} + +func TestDetermineEscalationLevel_EmptyRules(t *testing.T) { + trigger := DefaultEscalationTrigger() + + result := &AssessmentResult{ + RiskScore: 5, + TriggeredRules: []TriggeredRule{}, + DSFARecommended: false, + Art22Risk: false, + } + + level, _ := trigger.DetermineEscalationLevel(result) + + if level != EscalationLevelE0 { + t.Errorf("Expected E0 for empty rules and low risk, got %s", level) + } +} + +// ============================================================================ +// Constants Validation Tests +// ============================================================================ + +func TestEscalationLevelConstants(t *testing.T) { + levels := []EscalationLevel{ + EscalationLevelE0, + EscalationLevelE1, + EscalationLevelE2, + EscalationLevelE3, + } + + expected := []string{"E0", "E1", "E2", "E3"} + + for i, level := range levels { + if string(level) != expected[i] { + t.Errorf("Expected %s, got %s", expected[i], level) + } + } +} + +func TestEscalationStatusConstants(t *testing.T) { + statuses := map[EscalationStatus]string{ + EscalationStatusPending: "pending", + EscalationStatusAssigned: "assigned", + EscalationStatusInReview: "in_review", + EscalationStatusApproved: "approved", + EscalationStatusRejected: "rejected", + EscalationStatusReturned: "returned", + } + + for status, expected := range statuses { + if string(status) != expected { + t.Errorf("Expected status %s, got %s", expected, status) + } + } +} + +func TestEscalationDecisionConstants(t *testing.T) { + decisions := map[EscalationDecision]string{ + EscalationDecisionApprove: "approve", + EscalationDecisionReject: "reject", + EscalationDecisionModify: "modify", + EscalationDecisionEscalate: "escalate", + } + + for decision, expected := range decisions { + if string(decision) != expected { + t.Errorf("Expected decision %s, got %s", expected, decision) + } + } +} + +// ============================================================================ +// Helper Function Tests +// ============================================================================ + +func TestJoinReasons_Empty(t *testing.T) { + result := joinReasons([]string{}, "Prefix: ") + if result != "Prefix: " { + t.Errorf("Expected 'Prefix: ', got '%s'", result) + } +} + +func TestJoinReasons_Single(t *testing.T) { + result := joinReasons([]string{"Reason 1"}, "Test: ") + if result != "Test: Reason 1" { + t.Errorf("Expected 'Test: Reason 1', got '%s'", result) + } +} + +func TestJoinReasons_Multiple(t *testing.T) { + result := joinReasons([]string{"Reason 1", "Reason 2", "Reason 3"}, "Test: ") + expected := "Test: Reason 1; Reason 2; Reason 3" + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } +} diff --git a/ai-compliance-sdk/internal/ucca/examples.go b/ai-compliance-sdk/internal/ucca/examples.go new file mode 100644 index 0000000..8043e98 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/examples.go @@ -0,0 +1,286 @@ +package ucca + +// ============================================================================ +// Didactic Examples - Real-world scenarios for learning +// ============================================================================ + +// Example represents a didactic example case +type Example struct { + ID string `json:"id"` + Title string `json:"title"` + TitleDE string `json:"title_de"` + Description string `json:"description"` + DescriptionDE string `json:"description_de"` + Domain Domain `json:"domain"` + Outcome Feasibility `json:"outcome"` + OutcomeDE string `json:"outcome_de"` + Lessons string `json:"lessons"` + LessonsDE string `json:"lessons_de"` + + // Matching criteria + MatchCriteria MatchCriteria `json:"-"` +} + +// MatchCriteria defines when an example matches an intake +type MatchCriteria struct { + Domains []Domain + HasPersonalData *bool + HasArticle9Data *bool + HasMinorData *bool + AutomationLevels []AutomationLevel + HasScoring *bool + HasLegalEffects *bool + ModelUsageRAG *bool + ModelUsageTraining *bool +} + +// boolPtr helper for creating bool pointers +func boolPtr(b bool) *bool { + return &b +} + +// ExampleLibrary contains all didactic examples +var ExampleLibrary = []Example{ + // ========================================================================= + // Example 1: Parkhaus (CONDITIONAL) + // ========================================================================= + { + ID: "EX-PARKHAUS", + Title: "Parking Garage License Plate Recognition", + TitleDE: "Parkhaus mit Kennzeichenerkennung", + Description: "A parking garage operator wants to use AI-based license plate recognition for automated entry/exit and billing. Cameras capture license plates, AI recognizes them, and matches to customer database.", + DescriptionDE: "Ein Parkhaus-Betreiber möchte KI-basierte Kennzeichenerkennung für automatisierte Ein-/Ausfahrt und Abrechnung nutzen. Kameras erfassen Kennzeichen, KI erkennt sie und gleicht mit Kundendatenbank ab.", + Domain: DomainGeneral, + Outcome: FeasibilityCONDITIONAL, + OutcomeDE: "BEDINGT MÖGLICH - Mit Auflagen umsetzbar", + Lessons: "License plates are personal data (linkable to vehicle owners). Requires: legal basis (contract performance), retention limits, access controls, deletion on request capability.", + LessonsDE: "Kennzeichen sind personenbezogene Daten (verknüpfbar mit Haltern). Erfordert: Rechtsgrundlage (Vertragserfüllung), Aufbewahrungsfristen, Zugriffskontrollen, Löschbarkeit auf Anfrage.", + MatchCriteria: MatchCriteria{ + Domains: []Domain{DomainGeneral, DomainPublic}, + HasPersonalData: boolPtr(true), + }, + }, + // ========================================================================= + // Example 2: CFO-Gehalt (NO) + // ========================================================================= + { + ID: "EX-CFO-GEHALT", + Title: "Executive Salary Prediction AI", + TitleDE: "KI zur CFO-Gehaltsvorhersage", + Description: "A company wants to train an AI model on historical CFO salary data to predict appropriate compensation for new executives. The training data includes names, companies, exact salaries, and performance metrics.", + DescriptionDE: "Ein Unternehmen möchte ein KI-Modell mit historischen CFO-Gehaltsdaten trainieren, um angemessene Vergütung für neue Führungskräfte vorherzusagen. Die Trainingsdaten umfassen Namen, Unternehmen, exakte Gehälter und Leistungskennzahlen.", + Domain: DomainFinance, + Outcome: FeasibilityNO, + OutcomeDE: "NICHT ZULÄSSIG - Erhebliche DSGVO-Verstöße", + Lessons: "Training with identifiable salary data of individuals violates purpose limitation, data minimization, and likely lacks legal basis. Alternative: Use aggregated, anonymized market studies.", + LessonsDE: "Training mit identifizierbaren Gehaltsdaten einzelner Personen verstößt gegen Zweckbindung, Datenminimierung und hat wahrscheinlich keine Rechtsgrundlage. Alternative: Aggregierte, anonymisierte Marktstudien nutzen.", + MatchCriteria: MatchCriteria{ + Domains: []Domain{DomainFinance, DomainHR}, + HasPersonalData: boolPtr(true), + ModelUsageTraining: boolPtr(true), + }, + }, + // ========================================================================= + // Example 3: Stadtwerke-Chatbot (YES) + // ========================================================================= + { + ID: "EX-STADTWERKE", + Title: "Utility Company Customer Service Chatbot", + TitleDE: "Stadtwerke-Chatbot für Kundenservice", + Description: "A municipal utility company wants to deploy an AI chatbot to answer customer questions about tariffs, bills, and services. The chatbot uses RAG with FAQ documents and does not store personal customer data.", + DescriptionDE: "Ein Stadtwerk möchte einen KI-Chatbot einsetzen, um Kundenfragen zu Tarifen, Rechnungen und Services zu beantworten. Der Chatbot nutzt RAG mit FAQ-Dokumenten und speichert keine personenbezogenen Kundendaten.", + Domain: DomainUtilities, + Outcome: FeasibilityYES, + OutcomeDE: "ZULÄSSIG - Niedriges Risiko", + Lessons: "RAG-only with public FAQ documents, no personal data storage, customer support purpose = low risk. Best practice: log only anonymized metrics, offer human escalation.", + LessonsDE: "Nur-RAG mit öffentlichen FAQ-Dokumenten, keine Speicherung personenbezogener Daten, Kundenservice-Zweck = geringes Risiko. Best Practice: Nur anonymisierte Metriken loggen, Eskalation zu Menschen anbieten.", + MatchCriteria: MatchCriteria{ + Domains: []Domain{DomainUtilities, DomainPublic, DomainGeneral}, + HasPersonalData: boolPtr(false), + ModelUsageRAG: boolPtr(true), + }, + }, + // ========================================================================= + // Example 4: Schülerdaten (NO) + // ========================================================================= + { + ID: "EX-SCHUELER", + Title: "Student Performance Scoring AI", + TitleDE: "KI-Bewertung von Schülerleistungen", + Description: "A school wants to use AI to automatically score and rank students based on their homework, test results, and classroom behavior. The scores would influence grade recommendations.", + DescriptionDE: "Eine Schule möchte KI nutzen, um Schüler automatisch basierend auf Hausaufgaben, Testergebnissen und Unterrichtsverhalten zu bewerten und zu ranken. Die Scores würden Notenempfehlungen beeinflussen.", + Domain: DomainEducation, + Outcome: FeasibilityNO, + OutcomeDE: "NICHT ZULÄSSIG - Schutz Minderjähriger", + Lessons: "Automated profiling and scoring of minors is prohibited. Children require special protection. Grades must remain human decisions. Alternative: AI for teacher suggestions, never automatic scoring.", + LessonsDE: "Automatisiertes Profiling und Scoring von Minderjährigen ist verboten. Kinder erfordern besonderen Schutz. Noten müssen menschliche Entscheidungen bleiben. Alternative: KI für Lehrervorschläge, niemals automatisches Scoring.", + MatchCriteria: MatchCriteria{ + Domains: []Domain{DomainEducation}, + HasMinorData: boolPtr(true), + HasScoring: boolPtr(true), + }, + }, + // ========================================================================= + // Example 5: HR-Bewertung (NO) + // ========================================================================= + { + ID: "EX-HR-BEWERTUNG", + Title: "Automated Employee Performance Evaluation", + TitleDE: "Automatisierte Mitarbeiterbewertung", + Description: "An HR department wants to fully automate quarterly performance reviews using AI. The system would analyze emails, meeting participation, and task completion to generate scores that directly determine bonuses and promotions.", + DescriptionDE: "Eine Personalabteilung möchte Quartalsbewertungen mit KI vollständig automatisieren. Das System würde E-Mails, Meeting-Teilnahme und Aufgabenerfüllung analysieren, um Scores zu generieren, die direkt Boni und Beförderungen bestimmen.", + Domain: DomainHR, + Outcome: FeasibilityNO, + OutcomeDE: "NICHT ZULÄSSIG - Art. 22 DSGVO Verstoß", + Lessons: "Fully automated decisions with legal/significant effects on employees violate Art. 22 GDPR. Requires human review. Also: email surveillance raises additional issues. Alternative: AI-assisted (not automated) evaluations with mandatory human decision.", + LessonsDE: "Vollautomatisierte Entscheidungen mit rechtlichen/erheblichen Auswirkungen auf Mitarbeiter verstoßen gegen Art. 22 DSGVO. Erfordert menschliche Überprüfung. Außerdem: E-Mail-Überwachung wirft zusätzliche Fragen auf. Alternative: KI-unterstützte (nicht automatisierte) Bewertungen mit verpflichtender menschlicher Entscheidung.", + MatchCriteria: MatchCriteria{ + Domains: []Domain{DomainHR}, + HasPersonalData: boolPtr(true), + HasScoring: boolPtr(true), + HasLegalEffects: boolPtr(true), + AutomationLevels: []AutomationLevel{AutomationFullyAutomated}, + }, + }, +} + +// MatchExamples calculates relevance of examples to the given intake +func MatchExamples(intake *UseCaseIntake) []ExampleMatch { + var matches []ExampleMatch + + for _, ex := range ExampleLibrary { + similarity := calculateSimilarity(intake, ex.MatchCriteria) + if similarity > 0.3 { // Threshold for relevance + matches = append(matches, ExampleMatch{ + ExampleID: ex.ID, + Title: ex.TitleDE, + Description: ex.DescriptionDE, + Similarity: similarity, + Outcome: ex.OutcomeDE, + Lessons: ex.LessonsDE, + }) + } + } + + // Sort by similarity (highest first) + for i := 0; i < len(matches)-1; i++ { + for j := i + 1; j < len(matches); j++ { + if matches[j].Similarity > matches[i].Similarity { + matches[i], matches[j] = matches[j], matches[i] + } + } + } + + // Return top 3 + if len(matches) > 3 { + matches = matches[:3] + } + + return matches +} + +// calculateSimilarity calculates how similar an intake is to example criteria +func calculateSimilarity(intake *UseCaseIntake, criteria MatchCriteria) float64 { + var score float64 + var maxScore float64 + + // Domain match (weight: 3) + if len(criteria.Domains) > 0 { + maxScore += 3 + for _, d := range criteria.Domains { + if intake.Domain == d { + score += 3 + break + } + } + } + + // Personal data match (weight: 2) + if criteria.HasPersonalData != nil { + maxScore += 2 + if *criteria.HasPersonalData == intake.DataTypes.PersonalData { + score += 2 + } + } + + // Article 9 data match (weight: 2) + if criteria.HasArticle9Data != nil { + maxScore += 2 + if *criteria.HasArticle9Data == intake.DataTypes.Article9Data { + score += 2 + } + } + + // Minor data match (weight: 3) + if criteria.HasMinorData != nil { + maxScore += 3 + if *criteria.HasMinorData == intake.DataTypes.MinorData { + score += 3 + } + } + + // Automation level match (weight: 2) + if len(criteria.AutomationLevels) > 0 { + maxScore += 2 + for _, a := range criteria.AutomationLevels { + if intake.Automation == a { + score += 2 + break + } + } + } + + // Scoring match (weight: 2) + if criteria.HasScoring != nil { + maxScore += 2 + hasScoring := intake.Purpose.EvaluationScoring || intake.Outputs.RankingsOrScores + if *criteria.HasScoring == hasScoring { + score += 2 + } + } + + // Legal effects match (weight: 2) + if criteria.HasLegalEffects != nil { + maxScore += 2 + if *criteria.HasLegalEffects == intake.Outputs.LegalEffects { + score += 2 + } + } + + // RAG usage match (weight: 1) + if criteria.ModelUsageRAG != nil { + maxScore += 1 + if *criteria.ModelUsageRAG == intake.ModelUsage.RAG { + score += 1 + } + } + + // Training usage match (weight: 2) + if criteria.ModelUsageTraining != nil { + maxScore += 2 + if *criteria.ModelUsageTraining == intake.ModelUsage.Training { + score += 2 + } + } + + if maxScore == 0 { + return 0 + } + + return score / maxScore +} + +// GetExampleByID returns an example by its ID +func GetExampleByID(id string) *Example { + for _, ex := range ExampleLibrary { + if ex.ID == id { + return &ex + } + } + return nil +} + +// GetAllExamples returns all available examples +func GetAllExamples() []Example { + return ExampleLibrary +} diff --git a/ai-compliance-sdk/internal/ucca/financial_policy.go b/ai-compliance-sdk/internal/ucca/financial_policy.go new file mode 100644 index 0000000..481c957 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/financial_policy.go @@ -0,0 +1,734 @@ +package ucca + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// ============================================================================ +// Financial Regulations Policy Engine +// ============================================================================ +// +// This engine evaluates financial use-cases against DORA, MaRisk, and BAIT rules. +// It extends the base PolicyEngine with financial-specific logic. +// +// Key regulations: +// - DORA (Digital Operational Resilience Act) - EU 2022/2554 +// - MaRisk (Mindestanforderungen an das Risikomanagement) - BaFin +// - BAIT (Bankaufsichtliche Anforderungen an die IT) - BaFin +// +// ============================================================================ + +// DefaultFinancialPolicyPath is the default location for the financial policy file +var DefaultFinancialPolicyPath = "policies/financial_regulations_policy.yaml" + +// FinancialPolicyConfig represents the financial regulations policy structure +type FinancialPolicyConfig struct { + Metadata FinancialPolicyMetadata `yaml:"metadata"` + ApplicableDomains []string `yaml:"applicable_domains"` + FactsSchema map[string]interface{} `yaml:"facts_schema"` + Controls map[string]FinancialControlDef `yaml:"controls"` + Gaps map[string]FinancialGapDef `yaml:"gaps"` + StopLines map[string]FinancialStopLine `yaml:"stop_lines"` + Rules []FinancialRuleDef `yaml:"rules"` + EscalationTriggers []FinancialEscalationTrigger `yaml:"escalation_triggers"` +} + +// FinancialPolicyMetadata contains policy header information +type FinancialPolicyMetadata struct { + Version string `yaml:"version"` + EffectiveDate string `yaml:"effective_date"` + Author string `yaml:"author"` + Jurisdiction string `yaml:"jurisdiction"` + Regulations []FinancialRegulationInfo `yaml:"regulations"` +} + +// FinancialRegulationInfo describes a regulation +type FinancialRegulationInfo struct { + Name string `yaml:"name"` + FullName string `yaml:"full_name"` + Reference string `yaml:"reference,omitempty"` + Authority string `yaml:"authority,omitempty"` + Version string `yaml:"version,omitempty"` + Effective string `yaml:"effective,omitempty"` +} + +// FinancialControlDef represents a control specific to financial regulations +type FinancialControlDef struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Category string `yaml:"category"` + DORARef string `yaml:"dora_ref,omitempty"` + MaRiskRef string `yaml:"marisk_ref,omitempty"` + BAITRef string `yaml:"bait_ref,omitempty"` + MiFIDRef string `yaml:"mifid_ref,omitempty"` + GwGRef string `yaml:"gwg_ref,omitempty"` + Description string `yaml:"description"` + WhenApplicable []string `yaml:"when_applicable,omitempty"` + WhatToDo string `yaml:"what_to_do"` + EvidenceNeeded []string `yaml:"evidence_needed,omitempty"` + Effort string `yaml:"effort"` +} + +// FinancialGapDef represents a compliance gap +type FinancialGapDef struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Severity string `yaml:"severity"` + Escalation string `yaml:"escalation,omitempty"` + When []string `yaml:"when,omitempty"` + Controls []string `yaml:"controls,omitempty"` + LegalRefs []string `yaml:"legal_refs,omitempty"` +} + +// FinancialStopLine represents a hard blocker +type FinancialStopLine struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Outcome string `yaml:"outcome"` + When []string `yaml:"when,omitempty"` + Message string `yaml:"message"` +} + +// FinancialRuleDef represents a rule from the financial policy +type FinancialRuleDef struct { + ID string `yaml:"id"` + Category string `yaml:"category"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Condition FinancialConditionDef `yaml:"condition"` + Effect FinancialEffectDef `yaml:"effect"` + Severity string `yaml:"severity"` + DORARef string `yaml:"dora_ref,omitempty"` + MaRiskRef string `yaml:"marisk_ref,omitempty"` + BAITRef string `yaml:"bait_ref,omitempty"` + MiFIDRef string `yaml:"mifid_ref,omitempty"` + GwGRef string `yaml:"gwg_ref,omitempty"` + Rationale string `yaml:"rationale"` +} + +// FinancialConditionDef represents a rule condition +type FinancialConditionDef struct { + Field string `yaml:"field,omitempty"` + Operator string `yaml:"operator,omitempty"` + Value interface{} `yaml:"value,omitempty"` + AllOf []FinancialConditionDef `yaml:"all_of,omitempty"` + AnyOf []FinancialConditionDef `yaml:"any_of,omitempty"` +} + +// FinancialEffectDef represents the effect when a rule triggers +type FinancialEffectDef struct { + Feasibility string `yaml:"feasibility,omitempty"` + ControlsAdd []string `yaml:"controls_add,omitempty"` + RiskAdd int `yaml:"risk_add,omitempty"` + Escalation bool `yaml:"escalation,omitempty"` +} + +// FinancialEscalationTrigger defines when to escalate +type FinancialEscalationTrigger struct { + ID string `yaml:"id"` + Trigger []string `yaml:"trigger,omitempty"` + Level string `yaml:"level"` + Reason string `yaml:"reason"` +} + +// ============================================================================ +// Financial Policy Engine Implementation +// ============================================================================ + +// FinancialPolicyEngine evaluates intakes against financial regulations +type FinancialPolicyEngine struct { + config *FinancialPolicyConfig +} + +// NewFinancialPolicyEngine creates a new financial policy engine +func NewFinancialPolicyEngine() (*FinancialPolicyEngine, error) { + searchPaths := []string{ + DefaultFinancialPolicyPath, + filepath.Join(".", "policies", "financial_regulations_policy.yaml"), + filepath.Join("..", "policies", "financial_regulations_policy.yaml"), + filepath.Join("..", "..", "policies", "financial_regulations_policy.yaml"), + "/app/policies/financial_regulations_policy.yaml", + } + + var data []byte + var err error + for _, path := range searchPaths { + data, err = os.ReadFile(path) + if err == nil { + break + } + } + + if err != nil { + return nil, fmt.Errorf("failed to load financial policy from any known location: %w", err) + } + + var config FinancialPolicyConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse financial policy YAML: %w", err) + } + + return &FinancialPolicyEngine{config: &config}, nil +} + +// NewFinancialPolicyEngineFromPath loads policy from a specific file path +func NewFinancialPolicyEngineFromPath(path string) (*FinancialPolicyEngine, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read financial policy file: %w", err) + } + + var config FinancialPolicyConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse financial policy YAML: %w", err) + } + + return &FinancialPolicyEngine{config: &config}, nil +} + +// GetPolicyVersion returns the financial policy version +func (e *FinancialPolicyEngine) GetPolicyVersion() string { + return e.config.Metadata.Version +} + +// IsApplicable checks if the financial policy applies to the given intake +func (e *FinancialPolicyEngine) IsApplicable(intake *UseCaseIntake) bool { + // Check if domain is in applicable domains + domain := strings.ToLower(string(intake.Domain)) + for _, d := range e.config.ApplicableDomains { + if domain == d { + return true + } + } + return false +} + +// Evaluate runs financial regulation rules against the intake +func (e *FinancialPolicyEngine) Evaluate(intake *UseCaseIntake) *FinancialAssessmentResult { + result := &FinancialAssessmentResult{ + IsApplicable: e.IsApplicable(intake), + Feasibility: FeasibilityYES, + RiskScore: 0, + TriggeredRules: []FinancialTriggeredRule{}, + RequiredControls: []FinancialRequiredControl{}, + IdentifiedGaps: []FinancialIdentifiedGap{}, + StopLinesHit: []FinancialStopLineHit{}, + EscalationLevel: "", + PolicyVersion: e.config.Metadata.Version, + } + + // If not applicable, return early + if !result.IsApplicable { + return result + } + + // Check if financial context is provided + if intake.FinancialContext == nil { + result.MissingContext = true + return result + } + + hasBlock := false + controlSet := make(map[string]bool) + needsEscalation := "" + + // Evaluate each rule + for _, rule := range e.config.Rules { + if e.evaluateCondition(&rule.Condition, intake) { + triggered := FinancialTriggeredRule{ + Code: rule.ID, + Category: rule.Category, + Title: rule.Title, + Description: rule.Description, + Severity: parseSeverity(rule.Severity), + ScoreDelta: rule.Effect.RiskAdd, + Rationale: rule.Rationale, + } + + // Add regulation references + if rule.DORARef != "" { + triggered.DORARef = rule.DORARef + } + if rule.MaRiskRef != "" { + triggered.MaRiskRef = rule.MaRiskRef + } + if rule.BAITRef != "" { + triggered.BAITRef = rule.BAITRef + } + if rule.MiFIDRef != "" { + triggered.MiFIDRef = rule.MiFIDRef + } + + result.TriggeredRules = append(result.TriggeredRules, triggered) + result.RiskScore += rule.Effect.RiskAdd + + // Track severity + if parseSeverity(rule.Severity) == SeverityBLOCK { + hasBlock = true + } + + // Override feasibility if specified + if rule.Effect.Feasibility != "" { + switch rule.Effect.Feasibility { + case "NO": + result.Feasibility = FeasibilityNO + case "CONDITIONAL": + if result.Feasibility != FeasibilityNO { + result.Feasibility = FeasibilityCONDITIONAL + } + } + } + + // Collect controls + for _, ctrlID := range rule.Effect.ControlsAdd { + if !controlSet[ctrlID] { + controlSet[ctrlID] = true + if ctrl, ok := e.config.Controls[ctrlID]; ok { + result.RequiredControls = append(result.RequiredControls, FinancialRequiredControl{ + ID: ctrl.ID, + Title: ctrl.Title, + Category: ctrl.Category, + Description: ctrl.Description, + WhatToDo: ctrl.WhatToDo, + EvidenceNeeded: ctrl.EvidenceNeeded, + Effort: ctrl.Effort, + DORARef: ctrl.DORARef, + MaRiskRef: ctrl.MaRiskRef, + BAITRef: ctrl.BAITRef, + }) + } + } + } + + // Track escalation + if rule.Effect.Escalation { + needsEscalation = e.determineEscalationLevel(intake) + } + } + } + + // Check stop lines + for _, stopLine := range e.config.StopLines { + if e.evaluateStopLineConditions(stopLine.When, intake) { + result.StopLinesHit = append(result.StopLinesHit, FinancialStopLineHit{ + ID: stopLine.ID, + Title: stopLine.Title, + Message: stopLine.Message, + Outcome: stopLine.Outcome, + }) + result.Feasibility = FeasibilityNO + hasBlock = true + } + } + + // Check gaps + for _, gap := range e.config.Gaps { + if e.evaluateGapConditions(gap.When, intake) { + result.IdentifiedGaps = append(result.IdentifiedGaps, FinancialIdentifiedGap{ + ID: gap.ID, + Title: gap.Title, + Description: gap.Description, + Severity: parseSeverity(gap.Severity), + Controls: gap.Controls, + LegalRefs: gap.LegalRefs, + }) + if gap.Escalation != "" && needsEscalation == "" { + needsEscalation = gap.Escalation + } + } + } + + // Set final feasibility + if hasBlock { + result.Feasibility = FeasibilityNO + } + + // Set escalation level + result.EscalationLevel = needsEscalation + + // Generate summary + result.Summary = e.generateSummary(result) + + return result +} + +// evaluateCondition evaluates a condition against the intake +func (e *FinancialPolicyEngine) evaluateCondition(cond *FinancialConditionDef, intake *UseCaseIntake) bool { + // Handle composite all_of + if len(cond.AllOf) > 0 { + for _, subCond := range cond.AllOf { + if !e.evaluateCondition(&subCond, intake) { + return false + } + } + return true + } + + // Handle composite any_of + if len(cond.AnyOf) > 0 { + for _, subCond := range cond.AnyOf { + if e.evaluateCondition(&subCond, intake) { + return true + } + } + return false + } + + // Handle simple field condition + if cond.Field != "" { + return e.evaluateFieldCondition(cond.Field, cond.Operator, cond.Value, intake) + } + + return false +} + +// evaluateFieldCondition evaluates a single field comparison +func (e *FinancialPolicyEngine) evaluateFieldCondition(field, operator string, value interface{}, intake *UseCaseIntake) bool { + fieldValue := e.getFieldValue(field, intake) + if fieldValue == nil { + return false + } + + switch operator { + case "equals": + return e.compareEquals(fieldValue, value) + case "not_equals": + return !e.compareEquals(fieldValue, value) + case "in": + return e.compareIn(fieldValue, value) + default: + return false + } +} + +// getFieldValue extracts a field value from the intake +func (e *FinancialPolicyEngine) getFieldValue(field string, intake *UseCaseIntake) interface{} { + parts := strings.Split(field, ".") + if len(parts) == 0 { + return nil + } + + switch parts[0] { + case "domain": + return strings.ToLower(string(intake.Domain)) + case "financial_entity": + if len(parts) < 2 || intake.FinancialContext == nil { + return nil + } + return e.getFinancialEntityValue(parts[1], intake.FinancialContext) + case "ict_service": + if len(parts) < 2 || intake.FinancialContext == nil { + return nil + } + return e.getICTServiceValue(parts[1], intake.FinancialContext) + case "ai_application": + if len(parts) < 2 || intake.FinancialContext == nil { + return nil + } + return e.getAIApplicationValue(parts[1], intake.FinancialContext) + case "model_usage": + if len(parts) < 2 { + return nil + } + switch parts[1] { + case "training": + return intake.ModelUsage.Training + case "finetune": + return intake.ModelUsage.Finetune + case "rag": + return intake.ModelUsage.RAG + } + } + + return nil +} + +func (e *FinancialPolicyEngine) getFinancialEntityValue(field string, ctx *FinancialContext) interface{} { + switch field { + case "type": + return string(ctx.FinancialEntity.Type) + case "regulated": + return ctx.FinancialEntity.Regulated + case "size_category": + return string(ctx.FinancialEntity.SizeCategory) + } + return nil +} + +func (e *FinancialPolicyEngine) getICTServiceValue(field string, ctx *FinancialContext) interface{} { + switch field { + case "is_critical": + return ctx.ICTService.IsCritical + case "is_outsourced": + return ctx.ICTService.IsOutsourced + case "provider_location": + return string(ctx.ICTService.ProviderLocation) + case "concentration_risk": + return ctx.ICTService.ConcentrationRisk + } + return nil +} + +func (e *FinancialPolicyEngine) getAIApplicationValue(field string, ctx *FinancialContext) interface{} { + switch field { + case "affects_customer_decisions": + return ctx.AIApplication.AffectsCustomerDecisions + case "algorithmic_trading": + return ctx.AIApplication.AlgorithmicTrading + case "risk_assessment": + return ctx.AIApplication.RiskAssessment + case "aml_kyc": + return ctx.AIApplication.AMLKYC + case "model_validation_done": + return ctx.AIApplication.ModelValidationDone + } + return nil +} + +// compareEquals compares two values for equality +func (e *FinancialPolicyEngine) compareEquals(fieldValue, expected interface{}) bool { + if bv, ok := fieldValue.(bool); ok { + if eb, ok := expected.(bool); ok { + return bv == eb + } + } + if sv, ok := fieldValue.(string); ok { + if es, ok := expected.(string); ok { + return strings.EqualFold(sv, es) + } + } + return false +} + +// compareIn checks if fieldValue is in a list +func (e *FinancialPolicyEngine) compareIn(fieldValue, expected interface{}) bool { + list, ok := expected.([]interface{}) + if !ok { + return false + } + + sv, ok := fieldValue.(string) + if !ok { + return false + } + + for _, item := range list { + if is, ok := item.(string); ok && strings.EqualFold(is, sv) { + return true + } + } + return false +} + +// evaluateStopLineConditions evaluates stop line conditions +func (e *FinancialPolicyEngine) evaluateStopLineConditions(conditions []string, intake *UseCaseIntake) bool { + if intake.FinancialContext == nil { + return false + } + + for _, cond := range conditions { + if !e.parseAndEvaluateSimpleCondition(cond, intake) { + return false + } + } + return len(conditions) > 0 +} + +// evaluateGapConditions evaluates gap conditions +func (e *FinancialPolicyEngine) evaluateGapConditions(conditions []string, intake *UseCaseIntake) bool { + if intake.FinancialContext == nil { + return false + } + + for _, cond := range conditions { + if !e.parseAndEvaluateSimpleCondition(cond, intake) { + return false + } + } + return len(conditions) > 0 +} + +// parseAndEvaluateSimpleCondition parses "field == value" style conditions +func (e *FinancialPolicyEngine) parseAndEvaluateSimpleCondition(condition string, intake *UseCaseIntake) bool { + // Parse "field == value" or "field != value" + if strings.Contains(condition, "==") { + parts := strings.SplitN(condition, "==", 2) + if len(parts) != 2 { + return false + } + field := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + fieldVal := e.getFieldValue(field, intake) + if fieldVal == nil { + return false + } + + // Handle boolean values + if value == "true" { + if bv, ok := fieldVal.(bool); ok { + return bv + } + } else if value == "false" { + if bv, ok := fieldVal.(bool); ok { + return !bv + } + } + + // Handle string values + if sv, ok := fieldVal.(string); ok { + return strings.EqualFold(sv, value) + } + } + + return false +} + +// determineEscalationLevel determines the appropriate escalation level +func (e *FinancialPolicyEngine) determineEscalationLevel(intake *UseCaseIntake) string { + if intake.FinancialContext == nil { + return "" + } + + ctx := intake.FinancialContext + + // E3: Highest level for critical cases + if ctx.AIApplication.AlgorithmicTrading { + return "E3" + } + if ctx.ICTService.IsCritical && ctx.ICTService.IsOutsourced { + return "E3" + } + + // E2: Medium level + if ctx.AIApplication.RiskAssessment || ctx.AIApplication.AffectsCustomerDecisions { + return "E2" + } + + return "E1" +} + +// generateSummary creates a human-readable summary +func (e *FinancialPolicyEngine) generateSummary(result *FinancialAssessmentResult) string { + var parts []string + + switch result.Feasibility { + case FeasibilityYES: + parts = append(parts, "Der Use Case ist aus regulatorischer Sicht (DORA/MaRisk/BAIT) grundsätzlich umsetzbar.") + case FeasibilityCONDITIONAL: + parts = append(parts, "Der Use Case ist unter Einhaltung der Finanzregulierungen bedingt umsetzbar.") + case FeasibilityNO: + parts = append(parts, "Der Use Case ist ohne weitere Maßnahmen regulatorisch nicht zulässig.") + } + + if len(result.StopLinesHit) > 0 { + parts = append(parts, fmt.Sprintf("%d kritische Stop-Lines wurden ausgelöst.", len(result.StopLinesHit))) + } + + if len(result.IdentifiedGaps) > 0 { + parts = append(parts, fmt.Sprintf("%d Compliance-Lücken wurden identifiziert.", len(result.IdentifiedGaps))) + } + + if len(result.RequiredControls) > 0 { + parts = append(parts, fmt.Sprintf("%d regulatorische Kontrollen sind erforderlich.", len(result.RequiredControls))) + } + + if result.EscalationLevel != "" { + parts = append(parts, fmt.Sprintf("Eskalation auf Stufe %s empfohlen.", result.EscalationLevel)) + } + + return strings.Join(parts, " ") +} + +// GetAllControls returns all controls in the financial policy +func (e *FinancialPolicyEngine) GetAllControls() map[string]FinancialControlDef { + return e.config.Controls +} + +// GetAllGaps returns all gaps in the financial policy +func (e *FinancialPolicyEngine) GetAllGaps() map[string]FinancialGapDef { + return e.config.Gaps +} + +// GetAllStopLines returns all stop lines in the financial policy +func (e *FinancialPolicyEngine) GetAllStopLines() map[string]FinancialStopLine { + return e.config.StopLines +} + +// GetApplicableDomains returns domains where financial regulations apply +func (e *FinancialPolicyEngine) GetApplicableDomains() []string { + return e.config.ApplicableDomains +} + +// ============================================================================ +// Financial Assessment Result Types +// ============================================================================ + +// FinancialAssessmentResult represents the result of financial regulation evaluation +type FinancialAssessmentResult struct { + IsApplicable bool `json:"is_applicable"` + MissingContext bool `json:"missing_context,omitempty"` + Feasibility Feasibility `json:"feasibility"` + RiskScore int `json:"risk_score"` + TriggeredRules []FinancialTriggeredRule `json:"triggered_rules"` + RequiredControls []FinancialRequiredControl `json:"required_controls"` + IdentifiedGaps []FinancialIdentifiedGap `json:"identified_gaps"` + StopLinesHit []FinancialStopLineHit `json:"stop_lines_hit"` + EscalationLevel string `json:"escalation_level,omitempty"` + Summary string `json:"summary"` + PolicyVersion string `json:"policy_version"` +} + +// FinancialTriggeredRule represents a triggered financial regulation rule +type FinancialTriggeredRule struct { + Code string `json:"code"` + Category string `json:"category"` + Title string `json:"title"` + Description string `json:"description"` + Severity Severity `json:"severity"` + ScoreDelta int `json:"score_delta"` + DORARef string `json:"dora_ref,omitempty"` + MaRiskRef string `json:"marisk_ref,omitempty"` + BAITRef string `json:"bait_ref,omitempty"` + MiFIDRef string `json:"mifid_ref,omitempty"` + Rationale string `json:"rationale"` +} + +// FinancialRequiredControl represents a required control +type FinancialRequiredControl struct { + ID string `json:"id"` + Title string `json:"title"` + Category string `json:"category"` + Description string `json:"description"` + WhatToDo string `json:"what_to_do"` + EvidenceNeeded []string `json:"evidence_needed,omitempty"` + Effort string `json:"effort"` + DORARef string `json:"dora_ref,omitempty"` + MaRiskRef string `json:"marisk_ref,omitempty"` + BAITRef string `json:"bait_ref,omitempty"` +} + +// FinancialIdentifiedGap represents an identified compliance gap +type FinancialIdentifiedGap struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Severity Severity `json:"severity"` + Controls []string `json:"controls,omitempty"` + LegalRefs []string `json:"legal_refs,omitempty"` +} + +// FinancialStopLineHit represents a hit stop line +type FinancialStopLineHit struct { + ID string `json:"id"` + Title string `json:"title"` + Message string `json:"message"` + Outcome string `json:"outcome"` +} diff --git a/ai-compliance-sdk/internal/ucca/financial_policy_test.go b/ai-compliance-sdk/internal/ucca/financial_policy_test.go new file mode 100644 index 0000000..fa64c94 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/financial_policy_test.go @@ -0,0 +1,618 @@ +package ucca + +import ( + "testing" +) + +// ============================================================================ +// Financial Policy Engine Tests +// ============================================================================ + +func TestFinancialPolicyEngine_NewEngine(t *testing.T) { + // Try to load the financial policy engine + engine, err := NewFinancialPolicyEngineFromPath("../../policies/financial_regulations_policy.yaml") + if err != nil { + t.Skipf("Skipping test - policy file not found: %v", err) + } + + if engine == nil { + t.Fatal("Engine should not be nil") + } + + version := engine.GetPolicyVersion() + if version == "" { + t.Error("Policy version should not be empty") + } +} + +func TestFinancialPolicyEngine_IsApplicable(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + tests := []struct { + name string + domain Domain + expected bool + }{ + {"Banking domain is applicable", DomainBanking, true}, + {"Finance domain is applicable", DomainFinance, true}, + {"Insurance domain is applicable", DomainInsurance, true}, + {"Investment domain is applicable", DomainInvestment, true}, + {"Healthcare is not applicable", DomainHealthcare, false}, + {"Retail is not applicable", DomainRetail, false}, + {"Education is not applicable", DomainEducation, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + intake := &UseCaseIntake{ + Domain: tt.domain, + } + result := engine.IsApplicable(intake) + if result != tt.expected { + t.Errorf("IsApplicable() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestFinancialPolicyEngine_Evaluate_NonApplicableDomain(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainHealthcare, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + }, + }, + } + + result := engine.Evaluate(intake) + + if result.IsApplicable { + t.Error("Should not be applicable for healthcare domain") + } + if len(result.TriggeredRules) > 0 { + t.Error("No rules should trigger for non-applicable domain") + } +} + +func TestFinancialPolicyEngine_Evaluate_MissingContext(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: nil, // No financial context provided + } + + result := engine.Evaluate(intake) + + if !result.IsApplicable { + t.Error("Should be applicable for banking domain") + } + if !result.MissingContext { + t.Error("Should indicate missing financial context") + } +} + +func TestFinancialPolicyEngine_Evaluate_RegulatedBank(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + SizeCategory: SizeCategoryLessSignificant, + }, + ICTService: ICTService{ + IsCritical: false, + IsOutsourced: false, + ProviderLocation: ProviderLocationEU, + }, + AIApplication: FinancialAIApplication{ + AffectsCustomerDecisions: false, + RiskAssessment: false, + }, + }, + } + + result := engine.Evaluate(intake) + + if !result.IsApplicable { + t.Error("Should be applicable for banking domain") + } + if result.MissingContext { + t.Error("Context should not be missing") + } + + // Should trigger basic DORA/BAIT rules for regulated banks + if len(result.TriggeredRules) == 0 { + t.Error("Should trigger at least basic regulatory rules") + } + + // Check for DORA control requirements + hasDORAControl := false + for _, ctrl := range result.RequiredControls { + if ctrl.Category == "DORA" || ctrl.Category == "BAIT" { + hasDORAControl = true + break + } + } + if !hasDORAControl { + t.Error("Should require DORA or BAIT controls for regulated bank") + } +} + +func TestFinancialPolicyEngine_Evaluate_CriticalICTOutsourcing(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + }, + ICTService: ICTService{ + IsCritical: true, + IsOutsourced: true, + ProviderLocation: ProviderLocationEU, + }, + }, + } + + result := engine.Evaluate(intake) + + // Should have elevated risk for critical ICT outsourcing + if result.RiskScore == 0 { + t.Error("Risk score should be elevated for critical ICT outsourcing") + } + + // Should require TPP risk management control + hasTPPControl := false + for _, ctrl := range result.RequiredControls { + if ctrl.ID == "CTRL-DORA-TPP-RISK-MANAGEMENT" || ctrl.ID == "CTRL-MARISK-OUTSOURCING" { + hasTPPControl = true + break + } + } + if !hasTPPControl { + t.Error("Should require TPP risk management for critical outsourcing") + } + + // Should trigger escalation + if result.EscalationLevel == "" { + t.Error("Should trigger escalation for critical ICT outsourcing") + } +} + +func TestFinancialPolicyEngine_Evaluate_UnvalidatedRiskModel(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + }, + ICTService: ICTService{ + IsCritical: false, + IsOutsourced: false, + }, + AIApplication: FinancialAIApplication{ + RiskAssessment: true, + ModelValidationDone: false, // Not validated! + }, + }, + } + + result := engine.Evaluate(intake) + + // Should block unvalidated risk models + if result.Feasibility != FeasibilityNO { + t.Error("Should block use case with unvalidated risk model") + } + + // Should require model validation control + hasValidationControl := false + for _, ctrl := range result.RequiredControls { + if ctrl.ID == "CTRL-MARISK-MODEL-VALIDATION" { + hasValidationControl = true + break + } + } + if !hasValidationControl { + t.Error("Should require MaRisk model validation control") + } +} + +func TestFinancialPolicyEngine_Evaluate_ValidatedRiskModel(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + }, + ICTService: ICTService{ + IsCritical: false, + IsOutsourced: false, + }, + AIApplication: FinancialAIApplication{ + RiskAssessment: true, + ModelValidationDone: true, // Validated! + }, + }, + } + + result := engine.Evaluate(intake) + + // Should not block validated risk models + if result.Feasibility == FeasibilityNO { + t.Error("Should not block use case with validated risk model") + } +} + +func TestFinancialPolicyEngine_Evaluate_AlgorithmicTrading(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainInvestment, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityInvestmentFirm, + Regulated: true, + }, + AIApplication: FinancialAIApplication{ + AlgorithmicTrading: true, + }, + }, + } + + result := engine.Evaluate(intake) + + // Should require algorithmic trading control + hasAlgoControl := false + for _, ctrl := range result.RequiredControls { + if ctrl.ID == "CTRL-FIN-ALGO-TRADING" { + hasAlgoControl = true + break + } + } + if !hasAlgoControl { + t.Error("Should require algorithmic trading control") + } + + // Should trigger highest escalation + if result.EscalationLevel != "E3" { + t.Errorf("Should trigger E3 escalation for algo trading, got %s", result.EscalationLevel) + } +} + +func TestFinancialPolicyEngine_Evaluate_CustomerDecisions(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + }, + AIApplication: FinancialAIApplication{ + AffectsCustomerDecisions: true, + }, + }, + } + + result := engine.Evaluate(intake) + + // Should require explainability control + hasExplainControl := false + for _, ctrl := range result.RequiredControls { + if ctrl.ID == "CTRL-FIN-AI-EXPLAINABILITY" { + hasExplainControl = true + break + } + } + if !hasExplainControl { + t.Error("Should require AI explainability control for customer decisions") + } + + // Should trigger E2 escalation + if result.EscalationLevel != "E2" { + t.Errorf("Should trigger E2 escalation for customer decisions, got %s", result.EscalationLevel) + } +} + +func TestFinancialPolicyEngine_Evaluate_AMLKYC(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + }, + AIApplication: FinancialAIApplication{ + AMLKYC: true, + }, + }, + } + + result := engine.Evaluate(intake) + + // Should require AML control + hasAMLControl := false + for _, ctrl := range result.RequiredControls { + if ctrl.ID == "CTRL-FIN-AML-AI" { + hasAMLControl = true + break + } + } + if !hasAMLControl { + t.Error("Should require AML AI control for KYC/AML use cases") + } +} + +func TestFinancialPolicyEngine_Evaluate_ThirdCountryCritical(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + }, + ICTService: ICTService{ + IsCritical: true, + IsOutsourced: true, + ProviderLocation: ProviderLocationThirdCountry, + }, + }, + } + + result := engine.Evaluate(intake) + + // Should be conditional at minimum + if result.Feasibility == FeasibilityYES { + t.Error("Should not allow critical ICT in third country without conditions") + } + + // Should have elevated risk + if result.RiskScore < 30 { + t.Error("Should have elevated risk score for third country critical ICT") + } +} + +func TestFinancialPolicyEngine_Evaluate_ConcentrationRisk(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + }, + ICTService: ICTService{ + IsOutsourced: true, + ConcentrationRisk: true, + }, + }, + } + + result := engine.Evaluate(intake) + + // Should trigger escalation for concentration risk + if result.EscalationLevel == "" { + t.Error("Should trigger escalation for concentration risk") + } + + // Should add risk + if result.RiskScore == 0 { + t.Error("Should add risk for concentration risk") + } +} + +func TestFinancialPolicyEngine_Evaluate_InsuranceCompany(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainInsurance, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityInsuranceCompany, + Regulated: true, + }, + }, + } + + result := engine.Evaluate(intake) + + if !result.IsApplicable { + t.Error("Should be applicable for insurance domain") + } +} + +func TestFinancialPolicyEngine_GetAllControls(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + controls := engine.GetAllControls() + if len(controls) == 0 { + t.Error("Should have controls defined") + } + + // Check for key DORA controls + keyControls := []string{ + "CTRL-DORA-ICT-RISK-FRAMEWORK", + "CTRL-DORA-ICT-INCIDENT-MANAGEMENT", + "CTRL-DORA-TPP-RISK-MANAGEMENT", + "CTRL-MARISK-MODEL-VALIDATION", + "CTRL-BAIT-SDLC", + } + + for _, key := range keyControls { + if _, ok := controls[key]; !ok { + t.Errorf("Should have control %s", key) + } + } +} + +func TestFinancialPolicyEngine_GetAllGaps(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + gaps := engine.GetAllGaps() + if len(gaps) == 0 { + t.Error("Should have gaps defined") + } + + // Check for key gaps + keyGaps := []string{ + "GAP_DORA_NOT_IMPLEMENTED", + "GAP_MARISK_MODEL_NOT_VALIDATED", + } + + for _, key := range keyGaps { + if _, ok := gaps[key]; !ok { + t.Errorf("Should have gap %s", key) + } + } +} + +func TestFinancialPolicyEngine_GetAllStopLines(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + stopLines := engine.GetAllStopLines() + if len(stopLines) == 0 { + t.Error("Should have stop lines defined") + } + + // Check for key stop lines + keyStopLines := []string{ + "STOP_MARISK_UNVALIDATED_RISK_MODEL", + "STOP_ALGO_TRADING_WITHOUT_APPROVAL", + } + + for _, key := range keyStopLines { + if _, ok := stopLines[key]; !ok { + t.Errorf("Should have stop line %s", key) + } + } +} + +func TestFinancialPolicyEngine_Determinism(t *testing.T) { + engine := createTestFinancialEngine(t) + if engine == nil { + return + } + + intake := &UseCaseIntake{ + Domain: DomainBanking, + FinancialContext: &FinancialContext{ + FinancialEntity: FinancialEntity{ + Type: FinancialEntityCreditInstitution, + Regulated: true, + }, + ICTService: ICTService{ + IsCritical: true, + IsOutsourced: true, + }, + AIApplication: FinancialAIApplication{ + AffectsCustomerDecisions: true, + RiskAssessment: true, + ModelValidationDone: true, + }, + }, + } + + // Run evaluation multiple times + var lastResult *FinancialAssessmentResult + for i := 0; i < 10; i++ { + result := engine.Evaluate(intake) + if lastResult != nil { + if result.Feasibility != lastResult.Feasibility { + t.Error("Feasibility should be deterministic") + } + if result.RiskScore != lastResult.RiskScore { + t.Error("Risk score should be deterministic") + } + if len(result.TriggeredRules) != len(lastResult.TriggeredRules) { + t.Error("Triggered rules should be deterministic") + } + if len(result.RequiredControls) != len(lastResult.RequiredControls) { + t.Error("Required controls should be deterministic") + } + } + lastResult = result + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +func createTestFinancialEngine(t *testing.T) *FinancialPolicyEngine { + engine, err := NewFinancialPolicyEngineFromPath("../../policies/financial_regulations_policy.yaml") + if err != nil { + t.Skipf("Skipping test - policy file not found: %v", err) + return nil + } + return engine +} diff --git a/ai-compliance-sdk/internal/ucca/legal_rag.go b/ai-compliance-sdk/internal/ucca/legal_rag.go new file mode 100644 index 0000000..43e63ff --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/legal_rag.go @@ -0,0 +1,394 @@ +package ucca + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// LegalRAGClient provides access to the legal corpus vector search. +type LegalRAGClient struct { + qdrantHost string + qdrantPort string + embeddingURL string + collection string + httpClient *http.Client +} + +// LegalSearchResult represents a single search result from the legal corpus. +type LegalSearchResult struct { + Text string `json:"text"` + RegulationCode string `json:"regulation_code"` + RegulationName string `json:"regulation_name"` + Article string `json:"article,omitempty"` + Paragraph string `json:"paragraph,omitempty"` + SourceURL string `json:"source_url"` + Score float64 `json:"score"` +} + +// LegalContext represents aggregated legal context for an assessment. +type LegalContext struct { + Query string `json:"query"` + Results []LegalSearchResult `json:"results"` + RelevantArticles []string `json:"relevant_articles"` + Regulations []string `json:"regulations"` + GeneratedAt time.Time `json:"generated_at"` +} + +// NewLegalRAGClient creates a new Legal RAG client. +func NewLegalRAGClient() *LegalRAGClient { + qdrantHost := os.Getenv("QDRANT_HOST") + if qdrantHost == "" { + qdrantHost = "localhost" + } + + qdrantPort := os.Getenv("QDRANT_PORT") + if qdrantPort == "" { + qdrantPort = "6333" + } + + embeddingURL := os.Getenv("EMBEDDING_SERVICE_URL") + if embeddingURL == "" { + embeddingURL = "http://localhost:8087" + } + + return &LegalRAGClient{ + qdrantHost: qdrantHost, + qdrantPort: qdrantPort, + embeddingURL: embeddingURL, + collection: "bp_legal_corpus", + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// embeddingResponse from the embedding service. +type embeddingResponse struct { + Embeddings [][]float64 `json:"embeddings"` +} + +// qdrantSearchRequest for Qdrant REST API. +type qdrantSearchRequest struct { + Vector []float64 `json:"vector"` + Limit int `json:"limit"` + WithPayload bool `json:"with_payload"` + Filter *qdrantFilter `json:"filter,omitempty"` +} + +type qdrantFilter struct { + Should []qdrantCondition `json:"should,omitempty"` + Must []qdrantCondition `json:"must,omitempty"` +} + +type qdrantCondition struct { + Key string `json:"key"` + Match qdrantMatch `json:"match"` +} + +type qdrantMatch struct { + Value string `json:"value"` +} + +// qdrantSearchResponse from Qdrant REST API. +type qdrantSearchResponse struct { + Result []qdrantSearchHit `json:"result"` +} + +type qdrantSearchHit struct { + ID string `json:"id"` + Score float64 `json:"score"` + Payload map[string]interface{} `json:"payload"` +} + +// generateEmbedding calls the embedding service to get a vector for the query. +func (c *LegalRAGClient) generateEmbedding(ctx context.Context, text string) ([]float64, error) { + reqBody := map[string]interface{}{ + "texts": []string{text}, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal embedding request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.embeddingURL+"/embed", bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create embedding request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("embedding request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("embedding service returned %d: %s", resp.StatusCode, string(body)) + } + + var embResp embeddingResponse + if err := json.NewDecoder(resp.Body).Decode(&embResp); err != nil { + return nil, fmt.Errorf("failed to decode embedding response: %w", err) + } + + if len(embResp.Embeddings) == 0 { + return nil, fmt.Errorf("no embeddings returned") + } + + return embResp.Embeddings[0], nil +} + +// Search queries the legal corpus for relevant passages. +func (c *LegalRAGClient) Search(ctx context.Context, query string, regulationCodes []string, topK int) ([]LegalSearchResult, error) { + // Generate query embedding + embedding, err := c.generateEmbedding(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to generate embedding: %w", err) + } + + // Build Qdrant search request + searchReq := qdrantSearchRequest{ + Vector: embedding, + Limit: topK, + WithPayload: true, + } + + // Add filter for specific regulations if provided + if len(regulationCodes) > 0 { + conditions := make([]qdrantCondition, len(regulationCodes)) + for i, code := range regulationCodes { + conditions[i] = qdrantCondition{ + Key: "regulation_code", + Match: qdrantMatch{Value: code}, + } + } + searchReq.Filter = &qdrantFilter{Should: conditions} + } + + jsonBody, err := json.Marshal(searchReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal search request: %w", err) + } + + // Call Qdrant + url := fmt.Sprintf("http://%s:%s/collections/%s/points/search", c.qdrantHost, c.qdrantPort, c.collection) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create search request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("search request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("qdrant returned %d: %s", resp.StatusCode, string(body)) + } + + var searchResp qdrantSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + return nil, fmt.Errorf("failed to decode search response: %w", err) + } + + // Convert to results + results := make([]LegalSearchResult, len(searchResp.Result)) + for i, hit := range searchResp.Result { + results[i] = LegalSearchResult{ + Text: getString(hit.Payload, "text"), + RegulationCode: getString(hit.Payload, "regulation_code"), + RegulationName: getString(hit.Payload, "regulation_name"), + Article: getString(hit.Payload, "article"), + Paragraph: getString(hit.Payload, "paragraph"), + SourceURL: getString(hit.Payload, "source_url"), + Score: hit.Score, + } + } + + return results, nil +} + +// GetLegalContextForAssessment retrieves relevant legal context for an assessment. +func (c *LegalRAGClient) GetLegalContextForAssessment(ctx context.Context, assessment *Assessment) (*LegalContext, error) { + // Build query from assessment data + queryParts := []string{} + + // Add domain context + if assessment.Domain != "" { + queryParts = append(queryParts, fmt.Sprintf("KI-Anwendung im Bereich %s", assessment.Domain)) + } + + // Add data type context + if assessment.Intake.DataTypes.Article9Data { + queryParts = append(queryParts, "besondere Kategorien personenbezogener Daten Art. 9 DSGVO") + } + if assessment.Intake.DataTypes.PersonalData { + queryParts = append(queryParts, "personenbezogene Daten") + } + if assessment.Intake.DataTypes.MinorData { + queryParts = append(queryParts, "Daten von Minderjährigen") + } + + // Add purpose context + if assessment.Intake.Purpose.EvaluationScoring { + queryParts = append(queryParts, "automatisierte Bewertung Scoring") + } + if assessment.Intake.Purpose.DecisionMaking { + queryParts = append(queryParts, "automatisierte Entscheidung Art. 22 DSGVO") + } + if assessment.Intake.Purpose.Profiling { + queryParts = append(queryParts, "Profiling") + } + + // Add risk-specific context + if assessment.DSFARecommended { + queryParts = append(queryParts, "Datenschutz-Folgenabschätzung Art. 35 DSGVO") + } + if assessment.Art22Risk { + queryParts = append(queryParts, "automatisierte Einzelentscheidung rechtliche Wirkung") + } + + // Build final query + query := strings.Join(queryParts, " ") + if query == "" { + query = "DSGVO Anforderungen KI-System Datenschutz" + } + + // Determine which regulations to search based on triggered rules + regulationCodes := c.determineRelevantRegulations(assessment) + + // Search legal corpus + results, err := c.Search(ctx, query, regulationCodes, 5) + if err != nil { + return nil, err + } + + // Extract unique articles and regulations + articleSet := make(map[string]bool) + regSet := make(map[string]bool) + + for _, r := range results { + if r.Article != "" { + key := fmt.Sprintf("%s Art. %s", r.RegulationCode, r.Article) + articleSet[key] = true + } + regSet[r.RegulationCode] = true + } + + articles := make([]string, 0, len(articleSet)) + for a := range articleSet { + articles = append(articles, a) + } + + regulations := make([]string, 0, len(regSet)) + for r := range regSet { + regulations = append(regulations, r) + } + + return &LegalContext{ + Query: query, + Results: results, + RelevantArticles: articles, + Regulations: regulations, + GeneratedAt: time.Now().UTC(), + }, nil +} + +// determineRelevantRegulations determines which regulations to search based on the assessment. +func (c *LegalRAGClient) determineRelevantRegulations(assessment *Assessment) []string { + codes := []string{"GDPR"} // Always include GDPR + + // Check triggered rules for regulation hints + for _, rule := range assessment.TriggeredRules { + gdprRef := rule.GDPRRef + if strings.Contains(gdprRef, "AI Act") || strings.Contains(gdprRef, "KI-VO") { + codes = append(codes, "AIACT") + } + if strings.Contains(gdprRef, "Art. 9") || strings.Contains(gdprRef, "Art. 22") { + // Already have GDPR + } + } + + // Add AI Act if AI-related controls are required + for _, ctrl := range assessment.RequiredControls { + if strings.HasPrefix(ctrl.ID, "AI-") { + if !contains(codes, "AIACT") { + codes = append(codes, "AIACT") + } + break + } + } + + // Add BSI if security controls are required + for _, ctrl := range assessment.RequiredControls { + if strings.HasPrefix(ctrl.ID, "CRYPTO-") || strings.HasPrefix(ctrl.ID, "IAM-") { + codes = append(codes, "BSI-TR-03161-1") + break + } + } + + return codes +} + +// FormatLegalContextForPrompt formats the legal context for inclusion in an LLM prompt. +func (c *LegalRAGClient) FormatLegalContextForPrompt(lc *LegalContext) string { + if lc == nil || len(lc.Results) == 0 { + return "" + } + + var buf bytes.Buffer + buf.WriteString("\n\n**Relevante Rechtsgrundlagen:**\n\n") + + for i, result := range lc.Results { + buf.WriteString(fmt.Sprintf("%d. **%s** (%s)", i+1, result.RegulationName, result.RegulationCode)) + if result.Article != "" { + buf.WriteString(fmt.Sprintf(" - Art. %s", result.Article)) + if result.Paragraph != "" { + buf.WriteString(fmt.Sprintf(" Abs. %s", result.Paragraph)) + } + } + buf.WriteString("\n") + buf.WriteString(fmt.Sprintf(" > %s\n\n", truncateText(result.Text, 300))) + } + + return buf.String() +} + +// Helper functions + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func truncateText(text string, maxLen int) string { + if len(text) <= maxLen { + return text + } + return text[:maxLen] + "..." +} diff --git a/ai-compliance-sdk/internal/ucca/license_policy.go b/ai-compliance-sdk/internal/ucca/license_policy.go new file mode 100644 index 0000000..dfd0eab --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/license_policy.go @@ -0,0 +1,583 @@ +package ucca + +import ( + "fmt" + "strings" + "time" +) + +// ============================================================================= +// License Policy Engine +// Handles license/copyright compliance for standards and norms +// ============================================================================= + +// LicensedContentFacts represents the license-related facts from the wizard +type LicensedContentFacts struct { + Present bool `json:"present"` + Publisher string `json:"publisher"` // DIN_MEDIA, VDI, VDE, ISO, etc. + LicenseType string `json:"license_type"` // SINGLE_WORKSTATION, NETWORK_INTRANET, etc. + AIUsePermitted string `json:"ai_use_permitted"` // YES, NO, UNKNOWN + ProofUploaded bool `json:"proof_uploaded"` + OperationMode string `json:"operation_mode"` // LINK_ONLY, NOTES_ONLY, FULLTEXT_RAG, TRAINING + DistributionScope string `json:"distribution_scope"` // SINGLE_USER, COMPANY_INTERNAL, etc. + ContentType string `json:"content_type"` // NORM_FULLTEXT, CUSTOMER_NOTES, etc. +} + +// LicensePolicyResult represents the evaluation result +type LicensePolicyResult struct { + Allowed bool `json:"allowed"` + EffectiveMode string `json:"effective_mode"` // The mode that will actually be used + Reason string `json:"reason"` + Gaps []LicenseGap `json:"gaps"` + RequiredControls []LicenseControl `json:"required_controls"` + StopLine *LicenseStopLine `json:"stop_line,omitempty"` // If hard blocked + OutputRestrictions *OutputRestrictions `json:"output_restrictions"` + EscalationLevel string `json:"escalation_level"` + RiskScore int `json:"risk_score"` +} + +// LicenseGap represents a license-related gap +type LicenseGap struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Controls []string `json:"controls"` + Severity string `json:"severity"` +} + +// LicenseControl represents a required control for license compliance +type LicenseControl struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + WhatToDo string `json:"what_to_do"` + Evidence []string `json:"evidence_needed"` +} + +// LicenseStopLine represents a hard block +type LicenseStopLine struct { + ID string `json:"id"` + Title string `json:"title"` + Message string `json:"message"` + Outcome string `json:"outcome"` // NOT_ALLOWED, NOT_ALLOWED_UNTIL_LICENSE_CLEARED +} + +// OutputRestrictions defines how outputs should be filtered +type OutputRestrictions struct { + AllowQuotes bool `json:"allow_quotes"` + MaxQuoteLength int `json:"max_quote_length"` // in characters + RequireCitation bool `json:"require_citation"` + AllowCopy bool `json:"allow_copy"` + AllowExport bool `json:"allow_export"` +} + +// LicensePolicyEngine evaluates license compliance +type LicensePolicyEngine struct { + // Configuration can be added here +} + +// NewLicensePolicyEngine creates a new license policy engine +func NewLicensePolicyEngine() *LicensePolicyEngine { + return &LicensePolicyEngine{} +} + +// Evaluate evaluates the license facts and returns the policy result +func (e *LicensePolicyEngine) Evaluate(facts *LicensedContentFacts) *LicensePolicyResult { + result := &LicensePolicyResult{ + Allowed: true, + EffectiveMode: "LINK_ONLY", // Default safe mode + Gaps: []LicenseGap{}, + RequiredControls: []LicenseControl{}, + OutputRestrictions: &OutputRestrictions{ + AllowQuotes: false, + MaxQuoteLength: 0, + RequireCitation: true, + AllowCopy: false, + AllowExport: false, + }, + RiskScore: 0, + } + + // If no licensed content, return early with no restrictions + if !facts.Present { + result.EffectiveMode = "UNRESTRICTED" + result.OutputRestrictions = &OutputRestrictions{ + AllowQuotes: true, + MaxQuoteLength: -1, // unlimited + RequireCitation: false, + AllowCopy: true, + AllowExport: true, + } + return result + } + + // Evaluate based on operation mode and license status + switch facts.OperationMode { + case "LINK_ONLY": + e.evaluateLinkOnlyMode(facts, result) + case "NOTES_ONLY": + e.evaluateNotesOnlyMode(facts, result) + case "EXCERPT_ONLY": + e.evaluateExcerptOnlyMode(facts, result) + case "FULLTEXT_RAG": + e.evaluateFulltextRAGMode(facts, result) + case "TRAINING": + e.evaluateTrainingMode(facts, result) + default: + // Unknown mode, default to LINK_ONLY + e.evaluateLinkOnlyMode(facts, result) + } + + // Check publisher-specific restrictions + e.applyPublisherRestrictions(facts, result) + + // Check distribution scope vs license type + e.checkDistributionScope(facts, result) + + return result +} + +// evaluateLinkOnlyMode - safest mode, always allowed +func (e *LicensePolicyEngine) evaluateLinkOnlyMode(facts *LicensedContentFacts, result *LicensePolicyResult) { + result.EffectiveMode = "LINK_ONLY" + result.Allowed = true + result.Reason = "Link-only Modus ist ohne spezielle Lizenz erlaubt" + result.RiskScore = 0 + + // Very restrictive output + result.OutputRestrictions = &OutputRestrictions{ + AllowQuotes: false, + MaxQuoteLength: 0, + RequireCitation: true, + AllowCopy: false, + AllowExport: false, + } + + // Recommend control for proper setup + result.RequiredControls = append(result.RequiredControls, LicenseControl{ + ID: "CTRL-LINK-ONLY-MODE", + Title: "Link-only / Evidence Navigator aktivieren", + Description: "Nur Verweise und Checklisten, kein Volltext", + WhatToDo: "System auf LINK_ONLY konfigurieren, keine Normtexte indexieren", + Evidence: []string{"System-Konfiguration", "Stichproben-Audit"}, + }) +} + +// evaluateNotesOnlyMode - customer notes, usually allowed +func (e *LicensePolicyEngine) evaluateNotesOnlyMode(facts *LicensedContentFacts, result *LicensePolicyResult) { + result.EffectiveMode = "NOTES_ONLY" + result.Allowed = true + result.Reason = "Notes-only Modus mit kundeneigenen Zusammenfassungen" + result.RiskScore = 10 + + // Allow paraphrased content + result.OutputRestrictions = &OutputRestrictions{ + AllowQuotes: false, // No direct quotes from norms + MaxQuoteLength: 0, + RequireCitation: true, + AllowCopy: true, // Can copy own notes + AllowExport: true, // Can export own notes + } + + result.RequiredControls = append(result.RequiredControls, LicenseControl{ + ID: "CTRL-NOTES-ONLY-RAG", + Title: "Notes-only RAG (kundeneigene Paraphrasen)", + Description: "Nur kundeneigene Zusammenfassungen indexieren", + WhatToDo: "UI-Flow fuer Notes-Erstellung, kein Copy/Paste von Originaltexten", + Evidence: []string{"Notes-Provenance-Log", "Stichproben"}, + }) + + // Add gap if license type is unknown + if facts.LicenseType == "UNKNOWN" { + result.Gaps = append(result.Gaps, LicenseGap{ + ID: "GAP_LICENSE_UNKNOWN", + Title: "Lizenzlage unklar", + Description: "Die Lizenzlage sollte geklaert werden", + Controls: []string{"CTRL-LICENSE-PROOF"}, + Severity: "WARN", + }) + result.EscalationLevel = "E2" + } +} + +// evaluateExcerptOnlyMode - short quotes under citation rights +func (e *LicensePolicyEngine) evaluateExcerptOnlyMode(facts *LicensedContentFacts, result *LicensePolicyResult) { + result.EffectiveMode = "EXCERPT_ONLY" + result.RiskScore = 30 + + // Check if AI use is permitted + if facts.AIUsePermitted == "NO" || facts.AIUsePermitted == "UNKNOWN" { + // Downgrade to NOTES_ONLY + result.EffectiveMode = "NOTES_ONLY" + result.Reason = "Excerpt-Modus nicht erlaubt ohne AI-Freigabe, Downgrade auf Notes-only" + result.Gaps = append(result.Gaps, LicenseGap{ + ID: "GAP_AI_USE_NOT_PERMITTED", + Title: "AI/TDM-Nutzung nicht erlaubt", + Description: "Zitate erfordern AI-Nutzungserlaubnis", + Controls: []string{"CTRL-LICENSE-PROOF", "CTRL-NOTES-ONLY-RAG"}, + Severity: "WARN", + }) + result.EscalationLevel = "E2" + } else { + result.Allowed = true + result.Reason = "Kurze Zitate im Rahmen des Zitatrechts" + } + + result.OutputRestrictions = &OutputRestrictions{ + AllowQuotes: facts.AIUsePermitted == "YES", + MaxQuoteLength: 150, // Max 150 characters per quote + RequireCitation: true, + AllowCopy: false, + AllowExport: false, + } + + result.RequiredControls = append(result.RequiredControls, LicenseControl{ + ID: "CTRL-OUTPUT-GUARD-QUOTES", + Title: "Output-Guard: Quote-Limits", + Description: "Zitatlänge begrenzen", + WhatToDo: "Max. 150 Zeichen pro Zitat, immer mit Quellenangabe", + Evidence: []string{"Output-Guard-Konfiguration"}, + }) +} + +// evaluateFulltextRAGMode - requires explicit license proof +func (e *LicensePolicyEngine) evaluateFulltextRAGMode(facts *LicensedContentFacts, result *LicensePolicyResult) { + result.RiskScore = 60 + + // Check if AI use is explicitly permitted AND proof is uploaded + if facts.AIUsePermitted == "YES" && facts.ProofUploaded { + result.EffectiveMode = "FULLTEXT_RAG" + result.Allowed = true + result.Reason = "Volltext-RAG mit nachgewiesener AI-Lizenz" + + result.OutputRestrictions = &OutputRestrictions{ + AllowQuotes: true, + MaxQuoteLength: 500, // Still limited + RequireCitation: true, + AllowCopy: false, // No copy to prevent redistribution + AllowExport: false, // No export + } + + result.RequiredControls = append(result.RequiredControls, + LicenseControl{ + ID: "CTRL-LICENSE-GATED-INGEST", + Title: "License-gated Ingest", + Description: "Technische Durchsetzung der Lizenzpruefung", + WhatToDo: "Ingest-Pipeline prueft Lizenz vor Indexierung", + Evidence: []string{"Ingest-Audit-Logs"}, + }, + LicenseControl{ + ID: "CTRL-TENANT-ISOLATION-STANDARDS", + Title: "Tenant-Isolation", + Description: "Strikte Trennung lizenzierter Inhalte", + WhatToDo: "Keine Cross-Tenant-Suche, kein Export", + Evidence: []string{"Tenant-Isolation-Dokumentation"}, + }, + ) + } else { + // NOT ALLOWED - downgrade to LINK_ONLY + result.Allowed = false + result.EffectiveMode = "LINK_ONLY" + result.Reason = "Volltext-RAG ohne Lizenznachweis nicht erlaubt" + + result.Gaps = append(result.Gaps, LicenseGap{ + ID: "GAP_FULLTEXT_WITHOUT_PROOF", + Title: "Volltext-RAG ohne Lizenznachweis", + Description: "Volltext-RAG erfordert nachgewiesene AI-Nutzungserlaubnis", + Controls: []string{"CTRL-LICENSE-PROOF", "CTRL-LINK-ONLY-MODE"}, + Severity: "BLOCK", + }) + + result.EscalationLevel = "E3" + + // Set stop line + result.StopLine = &LicenseStopLine{ + ID: "STOP_FULLTEXT_WITHOUT_PROOF", + Title: "Volltext-RAG blockiert", + Message: "Volltext-RAG erfordert einen Nachweis der AI-Nutzungserlaubnis. Bitte laden Sie den Lizenzvertrag hoch oder wechseln Sie auf Link-only Modus.", + Outcome: "NOT_ALLOWED_UNTIL_LICENSE_CLEARED", + } + + result.OutputRestrictions = &OutputRestrictions{ + AllowQuotes: false, + MaxQuoteLength: 0, + RequireCitation: true, + AllowCopy: false, + AllowExport: false, + } + } +} + +// evaluateTrainingMode - most restrictive, rarely allowed +func (e *LicensePolicyEngine) evaluateTrainingMode(facts *LicensedContentFacts, result *LicensePolicyResult) { + result.RiskScore = 80 + + // Training is almost always blocked for standards + if facts.AIUsePermitted == "YES" && facts.ProofUploaded && facts.LicenseType == "AI_LICENSE" { + result.EffectiveMode = "TRAINING" + result.Allowed = true + result.Reason = "Training mit expliziter AI-Training-Lizenz" + result.EscalationLevel = "E3" // Still requires review + } else { + // HARD BLOCK + result.Allowed = false + result.EffectiveMode = "LINK_ONLY" + result.Reason = "Training auf Standards ohne explizite AI-Training-Lizenz verboten" + + result.StopLine = &LicenseStopLine{ + ID: "STOP_TRAINING_WITHOUT_PROOF", + Title: "Training blockiert", + Message: "Modell-Training mit lizenzierten Standards ist ohne explizite AI-Training-Lizenz nicht zulaessig. DIN Media hat dies ausdruecklich ausgeschlossen.", + Outcome: "NOT_ALLOWED", + } + + result.Gaps = append(result.Gaps, LicenseGap{ + ID: "GAP_TRAINING_ON_STANDARDS", + Title: "Training auf Standards verboten", + Description: "Modell-Training erfordert explizite AI-Training-Lizenz", + Controls: []string{"CTRL-LICENSE-PROOF"}, + Severity: "BLOCK", + }) + + result.EscalationLevel = "E3" + } + + result.OutputRestrictions = &OutputRestrictions{ + AllowQuotes: false, + MaxQuoteLength: 0, + RequireCitation: true, + AllowCopy: false, + AllowExport: false, + } +} + +// applyPublisherRestrictions applies publisher-specific rules +func (e *LicensePolicyEngine) applyPublisherRestrictions(facts *LicensedContentFacts, result *LicensePolicyResult) { + // DIN Media specific restrictions + if facts.Publisher == "DIN_MEDIA" { + if facts.AIUsePermitted != "YES" { + // DIN Media explicitly prohibits AI use without license + if facts.OperationMode == "FULLTEXT_RAG" || facts.OperationMode == "TRAINING" { + result.Allowed = false + result.EffectiveMode = "LINK_ONLY" + + result.StopLine = &LicenseStopLine{ + ID: "STOP_DIN_FULLTEXT_AI_NOT_ALLOWED", + Title: "DIN Media AI-Nutzung blockiert", + Message: "DIN Media untersagt die AI-Nutzung von Normen ohne explizite Genehmigung. Ein AI-Lizenzmodell ist erst ab Q4/2025 geplant. Bitte nutzen Sie Link-only oder Notes-only Modus.", + Outcome: "NOT_ALLOWED_UNTIL_LICENSE_CLEARED", + } + + result.Gaps = append(result.Gaps, LicenseGap{ + ID: "GAP_DIN_MEDIA_WITHOUT_AI_LICENSE", + Title: "DIN Media ohne AI-Lizenz", + Description: "DIN Media verbietet AI-Nutzung ohne explizite Genehmigung", + Controls: []string{"CTRL-LINK-ONLY-MODE", "CTRL-NO-CRAWLING-DIN"}, + Severity: "BLOCK", + }) + + result.EscalationLevel = "E3" + result.RiskScore = 70 + } + } + + // Always add no-crawling control for DIN Media + result.RequiredControls = append(result.RequiredControls, LicenseControl{ + ID: "CTRL-NO-CRAWLING-DIN", + Title: "Crawler-Block fuer DIN Media", + Description: "Keine automatisierten Abrufe von DIN-Normen-Portalen", + WhatToDo: "Domain-Denylist konfigurieren, nur manueller Import", + Evidence: []string{"Domain-Denylist", "Fetch-Logs"}, + }) + } +} + +// checkDistributionScope checks if distribution scope matches license type +func (e *LicensePolicyEngine) checkDistributionScope(facts *LicensedContentFacts, result *LicensePolicyResult) { + // Single workstation license with broad distribution + if facts.LicenseType == "SINGLE_WORKSTATION" { + if facts.DistributionScope == "COMPANY_INTERNAL" || + facts.DistributionScope == "SUBSIDIARIES" || + facts.DistributionScope == "EXTERNAL_CUSTOMERS" { + + result.Gaps = append(result.Gaps, LicenseGap{ + ID: "GAP_DISTRIBUTION_SCOPE_MISMATCH", + Title: "Verteilungsumfang uebersteigt Lizenz", + Description: "Einzelplatz-Lizenz erlaubt keine unternehmensweite Nutzung", + Controls: []string{"CTRL-LICENSE-PROOF", "CTRL-LINK-ONLY-MODE"}, + Severity: "WARN", + }) + + result.EscalationLevel = "E3" + result.RiskScore += 20 + } + } + + // Network license with external distribution + if facts.LicenseType == "NETWORK_INTRANET" { + if facts.DistributionScope == "EXTERNAL_CUSTOMERS" { + result.Gaps = append(result.Gaps, LicenseGap{ + ID: "GAP_DISTRIBUTION_SCOPE_EXTERNAL", + Title: "Externe Verteilung mit Intranet-Lizenz", + Description: "Intranet-Lizenz erlaubt keine externe Verteilung", + Controls: []string{"CTRL-LICENSE-PROOF"}, + Severity: "WARN", + }) + + result.EscalationLevel = "E2" + result.RiskScore += 15 + } + } +} + +// CanIngestFulltext checks if fulltext ingestion is allowed +func (e *LicensePolicyEngine) CanIngestFulltext(facts *LicensedContentFacts) bool { + if !facts.Present { + return true // No licensed content, no restrictions + } + + switch facts.OperationMode { + case "LINK_ONLY": + return false // Only metadata/references + case "NOTES_ONLY": + return false // Only customer notes, not fulltext + case "EXCERPT_ONLY": + return false // Only short excerpts + case "FULLTEXT_RAG": + return facts.AIUsePermitted == "YES" && facts.ProofUploaded + case "TRAINING": + return facts.AIUsePermitted == "YES" && facts.ProofUploaded && facts.LicenseType == "AI_LICENSE" + default: + return false + } +} + +// CanIngestNotes checks if customer notes can be ingested +func (e *LicensePolicyEngine) CanIngestNotes(facts *LicensedContentFacts) bool { + if !facts.Present { + return true + } + + // Notes are allowed in most modes + return facts.OperationMode == "NOTES_ONLY" || + facts.OperationMode == "EXCERPT_ONLY" || + facts.OperationMode == "FULLTEXT_RAG" || + facts.OperationMode == "TRAINING" +} + +// GetEffectiveMode returns the effective operation mode after policy evaluation +func (e *LicensePolicyEngine) GetEffectiveMode(facts *LicensedContentFacts) string { + result := e.Evaluate(facts) + return result.EffectiveMode +} + +// LicenseIngestDecision represents the decision for ingesting a document +type LicenseIngestDecision struct { + AllowFulltext bool `json:"allow_fulltext"` + AllowNotes bool `json:"allow_notes"` + AllowMetadata bool `json:"allow_metadata"` + Reason string `json:"reason"` + EffectiveMode string `json:"effective_mode"` +} + +// DecideIngest returns the ingest decision for a document +func (e *LicensePolicyEngine) DecideIngest(facts *LicensedContentFacts) *LicenseIngestDecision { + result := e.Evaluate(facts) + + decision := &LicenseIngestDecision{ + AllowMetadata: true, // Metadata is always allowed + AllowNotes: e.CanIngestNotes(facts), + AllowFulltext: e.CanIngestFulltext(facts), + Reason: result.Reason, + EffectiveMode: result.EffectiveMode, + } + + return decision +} + +// LicenseAuditEntry represents an audit log entry for license decisions +type LicenseAuditEntry struct { + Timestamp time.Time `json:"timestamp"` + TenantID string `json:"tenant_id"` + DocumentID string `json:"document_id,omitempty"` + Facts *LicensedContentFacts `json:"facts"` + Decision string `json:"decision"` // ALLOW, DENY, DOWNGRADE + EffectiveMode string `json:"effective_mode"` + Reason string `json:"reason"` + StopLineID string `json:"stop_line_id,omitempty"` +} + +// FormatAuditEntry creates an audit entry for logging +func (e *LicensePolicyEngine) FormatAuditEntry(tenantID string, documentID string, facts *LicensedContentFacts, result *LicensePolicyResult) *LicenseAuditEntry { + decision := "ALLOW" + if !result.Allowed { + decision = "DENY" + } else if result.EffectiveMode != facts.OperationMode { + decision = "DOWNGRADE" + } + + entry := &LicenseAuditEntry{ + Timestamp: time.Now().UTC(), + TenantID: tenantID, + DocumentID: documentID, + Facts: facts, + Decision: decision, + EffectiveMode: result.EffectiveMode, + Reason: result.Reason, + } + + if result.StopLine != nil { + entry.StopLineID = result.StopLine.ID + } + + return entry +} + +// FormatHumanReadableSummary creates a human-readable summary of the evaluation +func (e *LicensePolicyEngine) FormatHumanReadableSummary(result *LicensePolicyResult) string { + var sb strings.Builder + + sb.WriteString("=== Lizenz-Policy Bewertung ===\n\n") + + if result.Allowed { + sb.WriteString(fmt.Sprintf("Status: ERLAUBT\n")) + } else { + sb.WriteString(fmt.Sprintf("Status: BLOCKIERT\n")) + } + + sb.WriteString(fmt.Sprintf("Effektiver Modus: %s\n", result.EffectiveMode)) + sb.WriteString(fmt.Sprintf("Risiko-Score: %d\n", result.RiskScore)) + sb.WriteString(fmt.Sprintf("Begruendung: %s\n\n", result.Reason)) + + if result.StopLine != nil { + sb.WriteString("!!! STOP-LINE !!!\n") + sb.WriteString(fmt.Sprintf(" %s: %s\n", result.StopLine.ID, result.StopLine.Title)) + sb.WriteString(fmt.Sprintf(" %s\n\n", result.StopLine.Message)) + } + + if len(result.Gaps) > 0 { + sb.WriteString("Identifizierte Luecken:\n") + for _, gap := range result.Gaps { + sb.WriteString(fmt.Sprintf(" - [%s] %s: %s\n", gap.Severity, gap.ID, gap.Title)) + } + sb.WriteString("\n") + } + + if len(result.RequiredControls) > 0 { + sb.WriteString("Erforderliche Massnahmen:\n") + for _, ctrl := range result.RequiredControls { + sb.WriteString(fmt.Sprintf(" - %s: %s\n", ctrl.ID, ctrl.Title)) + } + sb.WriteString("\n") + } + + if result.OutputRestrictions != nil { + sb.WriteString("Output-Einschraenkungen:\n") + sb.WriteString(fmt.Sprintf(" Zitate erlaubt: %v\n", result.OutputRestrictions.AllowQuotes)) + sb.WriteString(fmt.Sprintf(" Max. Zitatlaenge: %d Zeichen\n", result.OutputRestrictions.MaxQuoteLength)) + sb.WriteString(fmt.Sprintf(" Copy erlaubt: %v\n", result.OutputRestrictions.AllowCopy)) + sb.WriteString(fmt.Sprintf(" Export erlaubt: %v\n", result.OutputRestrictions.AllowExport)) + } + + return sb.String() +} diff --git a/ai-compliance-sdk/internal/ucca/license_policy_test.go b/ai-compliance-sdk/internal/ucca/license_policy_test.go new file mode 100644 index 0000000..f1f2bb0 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/license_policy_test.go @@ -0,0 +1,940 @@ +package ucca + +import ( + "testing" +) + +// ============================================================================= +// License Policy Engine Tests +// ============================================================================= + +func TestNewLicensePolicyEngine(t *testing.T) { + engine := NewLicensePolicyEngine() + if engine == nil { + t.Fatal("Expected non-nil engine") + } +} + +// ============================================================================= +// Basic Evaluation Tests +// ============================================================================= + +func TestLicensePolicyEngine_NoLicensedContent(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: false, + } + + result := engine.Evaluate(facts) + + if result.EffectiveMode != "UNRESTRICTED" { + t.Errorf("Expected UNRESTRICTED mode for no licensed content, got %s", result.EffectiveMode) + } + + if !result.Allowed { + t.Error("Expected allowed=true for no licensed content") + } + + if !result.OutputRestrictions.AllowQuotes { + t.Error("Expected AllowQuotes=true for no licensed content") + } + + if !result.OutputRestrictions.AllowCopy { + t.Error("Expected AllowCopy=true for no licensed content") + } + + if !result.OutputRestrictions.AllowExport { + t.Error("Expected AllowExport=true for no licensed content") + } +} + +// ============================================================================= +// Operation Mode Tests +// ============================================================================= + +func TestLicensePolicyEngine_LinkOnlyMode(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "VDI", + LicenseType: "SINGLE_WORKSTATION", + AIUsePermitted: "UNKNOWN", + OperationMode: "LINK_ONLY", + } + + result := engine.Evaluate(facts) + + if result.EffectiveMode != "LINK_ONLY" { + t.Errorf("Expected LINK_ONLY mode, got %s", result.EffectiveMode) + } + + if !result.Allowed { + t.Error("Expected allowed=true for LINK_ONLY mode") + } + + if result.RiskScore != 0 { + t.Errorf("Expected risk score 0 for LINK_ONLY, got %d", result.RiskScore) + } + + if result.OutputRestrictions.AllowQuotes { + t.Error("Expected AllowQuotes=false for LINK_ONLY") + } + + if result.OutputRestrictions.AllowCopy { + t.Error("Expected AllowCopy=false for LINK_ONLY") + } +} + +func TestLicensePolicyEngine_NotesOnlyMode(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "ISO", + LicenseType: "NETWORK_INTRANET", + AIUsePermitted: "NO", + OperationMode: "NOTES_ONLY", + } + + result := engine.Evaluate(facts) + + if result.EffectiveMode != "NOTES_ONLY" { + t.Errorf("Expected NOTES_ONLY mode, got %s", result.EffectiveMode) + } + + if !result.Allowed { + t.Error("Expected allowed=true for NOTES_ONLY mode") + } + + if result.RiskScore != 10 { + t.Errorf("Expected risk score 10 for NOTES_ONLY, got %d", result.RiskScore) + } + + // Notes can be copied (they are customer's own) + if !result.OutputRestrictions.AllowCopy { + t.Error("Expected AllowCopy=true for NOTES_ONLY") + } +} + +func TestLicensePolicyEngine_NotesOnlyMode_UnknownLicense(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "VDE", + LicenseType: "UNKNOWN", + AIUsePermitted: "UNKNOWN", + OperationMode: "NOTES_ONLY", + } + + result := engine.Evaluate(facts) + + // Should have escalation level E2 + if result.EscalationLevel != "E2" { + t.Errorf("Expected escalation level E2 for unknown license, got %s", result.EscalationLevel) + } + + // Should have GAP_LICENSE_UNKNOWN gap + hasGap := false + for _, gap := range result.Gaps { + if gap.ID == "GAP_LICENSE_UNKNOWN" { + hasGap = true + break + } + } + if !hasGap { + t.Error("Expected GAP_LICENSE_UNKNOWN gap for unknown license") + } +} + +func TestLicensePolicyEngine_ExcerptOnlyMode_WithAIPermission(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "ISO", + LicenseType: "NETWORK_INTRANET", + AIUsePermitted: "YES", + OperationMode: "EXCERPT_ONLY", + } + + result := engine.Evaluate(facts) + + if result.EffectiveMode != "EXCERPT_ONLY" { + t.Errorf("Expected EXCERPT_ONLY mode, got %s", result.EffectiveMode) + } + + if !result.Allowed { + t.Error("Expected allowed=true for EXCERPT_ONLY with AI permission") + } + + // Short quotes should be allowed + if !result.OutputRestrictions.AllowQuotes { + t.Error("Expected AllowQuotes=true for EXCERPT_ONLY with AI permission") + } + + // But limited to 150 chars + if result.OutputRestrictions.MaxQuoteLength != 150 { + t.Errorf("Expected MaxQuoteLength=150, got %d", result.OutputRestrictions.MaxQuoteLength) + } +} + +func TestLicensePolicyEngine_ExcerptOnlyMode_WithoutAIPermission(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "ISO", + LicenseType: "NETWORK_INTRANET", + AIUsePermitted: "NO", + OperationMode: "EXCERPT_ONLY", + } + + result := engine.Evaluate(facts) + + // Should be downgraded to NOTES_ONLY + if result.EffectiveMode != "NOTES_ONLY" { + t.Errorf("Expected NOTES_ONLY mode (downgraded), got %s", result.EffectiveMode) + } + + // Should have GAP_AI_USE_NOT_PERMITTED + hasGap := false + for _, gap := range result.Gaps { + if gap.ID == "GAP_AI_USE_NOT_PERMITTED" { + hasGap = true + break + } + } + if !hasGap { + t.Error("Expected GAP_AI_USE_NOT_PERMITTED gap") + } +} + +func TestLicensePolicyEngine_FulltextRAGMode_Allowed(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "VDI", + LicenseType: "AI_LICENSE", + AIUsePermitted: "YES", + ProofUploaded: true, + OperationMode: "FULLTEXT_RAG", + } + + result := engine.Evaluate(facts) + + if result.EffectiveMode != "FULLTEXT_RAG" { + t.Errorf("Expected FULLTEXT_RAG mode, got %s", result.EffectiveMode) + } + + if !result.Allowed { + t.Error("Expected allowed=true for FULLTEXT_RAG with proof") + } + + if result.StopLine != nil { + t.Error("Expected no stop line for allowed FULLTEXT_RAG") + } + + // Quotes allowed but limited + if !result.OutputRestrictions.AllowQuotes { + t.Error("Expected AllowQuotes=true for FULLTEXT_RAG") + } + + if result.OutputRestrictions.MaxQuoteLength != 500 { + t.Errorf("Expected MaxQuoteLength=500, got %d", result.OutputRestrictions.MaxQuoteLength) + } +} + +func TestLicensePolicyEngine_FulltextRAGMode_Blocked_NoProof(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "VDI", + LicenseType: "NETWORK_INTRANET", + AIUsePermitted: "YES", + ProofUploaded: false, // No proof! + OperationMode: "FULLTEXT_RAG", + } + + result := engine.Evaluate(facts) + + // Should be blocked and downgraded to LINK_ONLY + if result.Allowed { + t.Error("Expected allowed=false for FULLTEXT_RAG without proof") + } + + if result.EffectiveMode != "LINK_ONLY" { + t.Errorf("Expected LINK_ONLY mode (downgraded), got %s", result.EffectiveMode) + } + + // Should have stop line + if result.StopLine == nil { + t.Fatal("Expected stop line for blocked FULLTEXT_RAG") + } + + if result.StopLine.ID != "STOP_FULLTEXT_WITHOUT_PROOF" { + t.Errorf("Expected stop line STOP_FULLTEXT_WITHOUT_PROOF, got %s", result.StopLine.ID) + } + + // Should have GAP_FULLTEXT_WITHOUT_PROOF + hasGap := false + for _, gap := range result.Gaps { + if gap.ID == "GAP_FULLTEXT_WITHOUT_PROOF" { + hasGap = true + if gap.Severity != "BLOCK" { + t.Error("Expected gap severity BLOCK") + } + break + } + } + if !hasGap { + t.Error("Expected GAP_FULLTEXT_WITHOUT_PROOF gap") + } + + if result.EscalationLevel != "E3" { + t.Errorf("Expected escalation level E3, got %s", result.EscalationLevel) + } +} + +func TestLicensePolicyEngine_TrainingMode_Blocked(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + LicenseType: "NETWORK_INTRANET", + AIUsePermitted: "NO", + ProofUploaded: false, + OperationMode: "TRAINING", + } + + result := engine.Evaluate(facts) + + if result.Allowed { + t.Error("Expected allowed=false for TRAINING without AI license") + } + + if result.EffectiveMode != "LINK_ONLY" { + t.Errorf("Expected LINK_ONLY mode (downgraded), got %s", result.EffectiveMode) + } + + // Should have stop line + if result.StopLine == nil { + t.Fatal("Expected stop line for blocked TRAINING") + } + + if result.EscalationLevel != "E3" { + t.Errorf("Expected escalation level E3, got %s", result.EscalationLevel) + } +} + +func TestLicensePolicyEngine_TrainingMode_Allowed(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "VDI", + LicenseType: "AI_LICENSE", + AIUsePermitted: "YES", + ProofUploaded: true, + OperationMode: "TRAINING", + } + + result := engine.Evaluate(facts) + + if result.EffectiveMode != "TRAINING" { + t.Errorf("Expected TRAINING mode, got %s", result.EffectiveMode) + } + + if !result.Allowed { + t.Error("Expected allowed=true for TRAINING with AI_LICENSE") + } + + // Still requires E3 review + if result.EscalationLevel != "E3" { + t.Errorf("Expected escalation level E3 even for allowed training, got %s", result.EscalationLevel) + } +} + +// ============================================================================= +// Publisher-Specific Tests (DIN Media) +// ============================================================================= + +func TestLicensePolicyEngine_DINMedia_FulltextBlocked(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + LicenseType: "SINGLE_WORKSTATION", + AIUsePermitted: "NO", + ProofUploaded: false, + OperationMode: "FULLTEXT_RAG", + } + + result := engine.Evaluate(facts) + + if result.Allowed { + t.Error("Expected allowed=false for DIN_MEDIA FULLTEXT_RAG without AI permission") + } + + // Should have DIN-specific stop line + if result.StopLine == nil { + t.Fatal("Expected stop line for DIN_MEDIA") + } + + if result.StopLine.ID != "STOP_DIN_FULLTEXT_AI_NOT_ALLOWED" { + t.Errorf("Expected stop line STOP_DIN_FULLTEXT_AI_NOT_ALLOWED, got %s", result.StopLine.ID) + } + + // Should have CTRL-NO-CRAWLING-DIN control + hasControl := false + for _, ctrl := range result.RequiredControls { + if ctrl.ID == "CTRL-NO-CRAWLING-DIN" { + hasControl = true + break + } + } + if !hasControl { + t.Error("Expected CTRL-NO-CRAWLING-DIN control for DIN_MEDIA") + } +} + +func TestLicensePolicyEngine_DINMedia_LinkOnlyAllowed(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + LicenseType: "SINGLE_WORKSTATION", + AIUsePermitted: "NO", + OperationMode: "LINK_ONLY", + } + + result := engine.Evaluate(facts) + + if !result.Allowed { + t.Error("Expected allowed=true for DIN_MEDIA LINK_ONLY") + } + + if result.EffectiveMode != "LINK_ONLY" { + t.Errorf("Expected LINK_ONLY mode, got %s", result.EffectiveMode) + } + + // Should have no stop line for LINK_ONLY + if result.StopLine != nil { + t.Error("Expected no stop line for DIN_MEDIA LINK_ONLY") + } +} + +func TestLicensePolicyEngine_DINMedia_NotesOnlyAllowed(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + LicenseType: "NETWORK_INTRANET", + AIUsePermitted: "NO", + OperationMode: "NOTES_ONLY", + } + + result := engine.Evaluate(facts) + + if !result.Allowed { + t.Error("Expected allowed=true for DIN_MEDIA NOTES_ONLY") + } + + if result.EffectiveMode != "NOTES_ONLY" { + t.Errorf("Expected NOTES_ONLY mode, got %s", result.EffectiveMode) + } +} + +// ============================================================================= +// Distribution Scope Tests +// ============================================================================= + +func TestLicensePolicyEngine_DistributionScopeMismatch(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "ISO", + LicenseType: "SINGLE_WORKSTATION", + AIUsePermitted: "NO", + OperationMode: "LINK_ONLY", + DistributionScope: "COMPANY_INTERNAL", + } + + result := engine.Evaluate(facts) + + // Should have GAP_DISTRIBUTION_SCOPE_MISMATCH + hasGap := false + for _, gap := range result.Gaps { + if gap.ID == "GAP_DISTRIBUTION_SCOPE_MISMATCH" { + hasGap = true + break + } + } + if !hasGap { + t.Error("Expected GAP_DISTRIBUTION_SCOPE_MISMATCH for single workstation with company distribution") + } + + if result.EscalationLevel != "E3" { + t.Errorf("Expected escalation level E3, got %s", result.EscalationLevel) + } +} + +func TestLicensePolicyEngine_NetworkLicenseExternalDistribution(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "VDI", + LicenseType: "NETWORK_INTRANET", + AIUsePermitted: "YES", + ProofUploaded: true, + OperationMode: "NOTES_ONLY", + DistributionScope: "EXTERNAL_CUSTOMERS", + } + + result := engine.Evaluate(facts) + + // Should have GAP_DISTRIBUTION_SCOPE_EXTERNAL + hasGap := false + for _, gap := range result.Gaps { + if gap.ID == "GAP_DISTRIBUTION_SCOPE_EXTERNAL" { + hasGap = true + break + } + } + if !hasGap { + t.Error("Expected GAP_DISTRIBUTION_SCOPE_EXTERNAL for network license with external distribution") + } +} + +// ============================================================================= +// Helper Function Tests +// ============================================================================= + +func TestLicensePolicyEngine_CanIngestFulltext(t *testing.T) { + engine := NewLicensePolicyEngine() + + tests := []struct { + name string + facts *LicensedContentFacts + expected bool + }{ + { + name: "No licensed content", + facts: &LicensedContentFacts{ + Present: false, + }, + expected: true, + }, + { + name: "LINK_ONLY mode", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "LINK_ONLY", + }, + expected: false, + }, + { + name: "NOTES_ONLY mode", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "NOTES_ONLY", + }, + expected: false, + }, + { + name: "FULLTEXT_RAG with proof", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "FULLTEXT_RAG", + AIUsePermitted: "YES", + ProofUploaded: true, + }, + expected: true, + }, + { + name: "FULLTEXT_RAG without proof", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "FULLTEXT_RAG", + AIUsePermitted: "YES", + ProofUploaded: false, + }, + expected: false, + }, + { + name: "TRAINING with AI_LICENSE", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "TRAINING", + AIUsePermitted: "YES", + ProofUploaded: true, + LicenseType: "AI_LICENSE", + }, + expected: true, + }, + { + name: "TRAINING without AI_LICENSE", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "TRAINING", + AIUsePermitted: "YES", + ProofUploaded: true, + LicenseType: "NETWORK_INTRANET", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := engine.CanIngestFulltext(tt.facts) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestLicensePolicyEngine_CanIngestNotes(t *testing.T) { + engine := NewLicensePolicyEngine() + + tests := []struct { + name string + facts *LicensedContentFacts + expected bool + }{ + { + name: "No licensed content", + facts: &LicensedContentFacts{ + Present: false, + }, + expected: true, + }, + { + name: "LINK_ONLY mode", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "LINK_ONLY", + }, + expected: false, + }, + { + name: "NOTES_ONLY mode", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "NOTES_ONLY", + }, + expected: true, + }, + { + name: "EXCERPT_ONLY mode", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "EXCERPT_ONLY", + }, + expected: true, + }, + { + name: "FULLTEXT_RAG mode", + facts: &LicensedContentFacts{ + Present: true, + OperationMode: "FULLTEXT_RAG", + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := engine.CanIngestNotes(tt.facts) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestLicensePolicyEngine_GetEffectiveMode(t *testing.T) { + engine := NewLicensePolicyEngine() + + // Test downgrade scenario + facts := &LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + LicenseType: "SINGLE_WORKSTATION", + AIUsePermitted: "NO", + OperationMode: "FULLTEXT_RAG", + } + + effectiveMode := engine.GetEffectiveMode(facts) + + if effectiveMode != "LINK_ONLY" { + t.Errorf("Expected effective mode LINK_ONLY (downgraded), got %s", effectiveMode) + } +} + +func TestLicensePolicyEngine_DecideIngest(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "VDI", + LicenseType: "AI_LICENSE", + AIUsePermitted: "YES", + ProofUploaded: true, + OperationMode: "FULLTEXT_RAG", + } + + decision := engine.DecideIngest(facts) + + if !decision.AllowMetadata { + t.Error("Expected AllowMetadata=true") + } + + if !decision.AllowNotes { + t.Error("Expected AllowNotes=true") + } + + if !decision.AllowFulltext { + t.Error("Expected AllowFulltext=true for FULLTEXT_RAG with proof") + } + + if decision.EffectiveMode != "FULLTEXT_RAG" { + t.Errorf("Expected EffectiveMode=FULLTEXT_RAG, got %s", decision.EffectiveMode) + } +} + +// ============================================================================= +// Audit Entry Tests +// ============================================================================= + +func TestLicensePolicyEngine_FormatAuditEntry(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + LicenseType: "SINGLE_WORKSTATION", + AIUsePermitted: "NO", + OperationMode: "FULLTEXT_RAG", + } + + result := engine.Evaluate(facts) + entry := engine.FormatAuditEntry("tenant-123", "doc-456", facts, result) + + if entry.TenantID != "tenant-123" { + t.Errorf("Expected TenantID=tenant-123, got %s", entry.TenantID) + } + + if entry.DocumentID != "doc-456" { + t.Errorf("Expected DocumentID=doc-456, got %s", entry.DocumentID) + } + + if entry.Decision != "DENY" { + t.Errorf("Expected Decision=DENY, got %s", entry.Decision) + } + + if entry.StopLineID == "" { + t.Error("Expected StopLineID to be set for denied request") + } +} + +func TestLicensePolicyEngine_FormatAuditEntry_Downgrade(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "ISO", + LicenseType: "NETWORK_INTRANET", + AIUsePermitted: "NO", + OperationMode: "EXCERPT_ONLY", + } + + result := engine.Evaluate(facts) + entry := engine.FormatAuditEntry("tenant-123", "doc-456", facts, result) + + if entry.Decision != "DOWNGRADE" { + t.Errorf("Expected Decision=DOWNGRADE, got %s", entry.Decision) + } + + if entry.EffectiveMode != "NOTES_ONLY" { + t.Errorf("Expected EffectiveMode=NOTES_ONLY, got %s", entry.EffectiveMode) + } +} + +// ============================================================================= +// Human Readable Summary Tests +// ============================================================================= + +func TestLicensePolicyEngine_FormatHumanReadableSummary(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + LicenseType: "SINGLE_WORKSTATION", + AIUsePermitted: "NO", + OperationMode: "FULLTEXT_RAG", + } + + result := engine.Evaluate(facts) + summary := engine.FormatHumanReadableSummary(result) + + // Should contain key elements + if !stringContains(summary, "BLOCKIERT") { + t.Error("Summary should contain 'BLOCKIERT'") + } + + if !stringContains(summary, "LINK_ONLY") { + t.Error("Summary should contain 'LINK_ONLY'") + } + + if !stringContains(summary, "STOP-LINE") { + t.Error("Summary should contain '!!! STOP-LINE !!!'") + } + + if !stringContains(summary, "DIN Media") { + t.Error("Summary should mention DIN Media") + } +} + +// ============================================================================= +// Determinism Tests +// ============================================================================= + +func TestLicensePolicyEngine_Determinism(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + LicenseType: "NETWORK_INTRANET", + AIUsePermitted: "NO", + ProofUploaded: false, + OperationMode: "FULLTEXT_RAG", + DistributionScope: "COMPANY_INTERNAL", + } + + // Run evaluation 10 times and ensure identical results + firstResult := engine.Evaluate(facts) + + for i := 0; i < 10; i++ { + result := engine.Evaluate(facts) + + if result.Allowed != firstResult.Allowed { + t.Errorf("Run %d: Allowed mismatch: %v vs %v", i, result.Allowed, firstResult.Allowed) + } + if result.EffectiveMode != firstResult.EffectiveMode { + t.Errorf("Run %d: EffectiveMode mismatch: %s vs %s", i, result.EffectiveMode, firstResult.EffectiveMode) + } + if result.RiskScore != firstResult.RiskScore { + t.Errorf("Run %d: RiskScore mismatch: %d vs %d", i, result.RiskScore, firstResult.RiskScore) + } + if len(result.Gaps) != len(firstResult.Gaps) { + t.Errorf("Run %d: Gaps count mismatch: %d vs %d", i, len(result.Gaps), len(firstResult.Gaps)) + } + } +} + +// ============================================================================= +// Edge Case Tests +// ============================================================================= + +func TestLicensePolicyEngine_UnknownOperationMode(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "ISO", + OperationMode: "INVALID_MODE", + } + + result := engine.Evaluate(facts) + + // Should default to LINK_ONLY + if result.EffectiveMode != "LINK_ONLY" { + t.Errorf("Expected default to LINK_ONLY for unknown mode, got %s", result.EffectiveMode) + } + + if !result.Allowed { + t.Error("Expected allowed=true for fallback to LINK_ONLY") + } +} + +func TestLicensePolicyEngine_EmptyFacts(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{} + + result := engine.Evaluate(facts) + + // Empty facts = no licensed content + if result.EffectiveMode != "UNRESTRICTED" { + t.Errorf("Expected UNRESTRICTED for empty facts, got %s", result.EffectiveMode) + } +} + +// ============================================================================= +// Risk Score Tests +// ============================================================================= + +func TestLicensePolicyEngine_RiskScores(t *testing.T) { + engine := NewLicensePolicyEngine() + + tests := []struct { + name string + mode string + expectedMin int + expectedMax int + }{ + {"LINK_ONLY", "LINK_ONLY", 0, 0}, + {"NOTES_ONLY", "NOTES_ONLY", 10, 30}, + {"EXCERPT_ONLY", "EXCERPT_ONLY", 30, 50}, + {"FULLTEXT_RAG", "FULLTEXT_RAG", 60, 90}, + {"TRAINING", "TRAINING", 80, 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + facts := &LicensedContentFacts{ + Present: true, + Publisher: "VDI", + LicenseType: "AI_LICENSE", + AIUsePermitted: "YES", + ProofUploaded: true, + OperationMode: tt.mode, + } + + result := engine.Evaluate(facts) + + if result.RiskScore < tt.expectedMin || result.RiskScore > tt.expectedMax { + t.Errorf("Expected risk score in range [%d, %d], got %d", + tt.expectedMin, tt.expectedMax, result.RiskScore) + } + }) + } +} + +// Helper function +func stringContains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContainsHelper(s, substr)) +} + +func stringContainsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/ai-compliance-sdk/internal/ucca/models.go b/ai-compliance-sdk/internal/ucca/models.go new file mode 100644 index 0000000..3b58281 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/models.go @@ -0,0 +1,523 @@ +package ucca + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Constants / Enums +// ============================================================================ + +// Feasibility represents the overall assessment result +type Feasibility string + +const ( + FeasibilityYES Feasibility = "YES" + FeasibilityCONDITIONAL Feasibility = "CONDITIONAL" + FeasibilityNO Feasibility = "NO" +) + +// RiskLevel represents the overall risk classification +type RiskLevel string + +const ( + RiskLevelMINIMAL RiskLevel = "MINIMAL" + RiskLevelLOW RiskLevel = "LOW" + RiskLevelMEDIUM RiskLevel = "MEDIUM" + RiskLevelHIGH RiskLevel = "HIGH" + RiskLevelUNACCEPTABLE RiskLevel = "UNACCEPTABLE" +) + +// Complexity represents implementation complexity +type Complexity string + +const ( + ComplexityLOW Complexity = "LOW" + ComplexityMEDIUM Complexity = "MEDIUM" + ComplexityHIGH Complexity = "HIGH" +) + +// Severity represents rule severity +type Severity string + +const ( + SeverityINFO Severity = "INFO" + SeverityWARN Severity = "WARN" + SeverityBLOCK Severity = "BLOCK" +) + +// Domain represents the business domain +type Domain string + +const ( + // Industrie & Produktion + DomainAutomotive Domain = "automotive" + DomainMechanicalEngineering Domain = "mechanical_engineering" + DomainPlantEngineering Domain = "plant_engineering" + DomainElectricalEngineering Domain = "electrical_engineering" + DomainAerospace Domain = "aerospace" + DomainChemicals Domain = "chemicals" + DomainFoodBeverage Domain = "food_beverage" + DomainTextiles Domain = "textiles" + DomainPackaging Domain = "packaging" + + // Energie & Versorgung + DomainUtilities Domain = "utilities" + DomainEnergy Domain = "energy" + DomainOilGas Domain = "oil_gas" + + // Land- & Forstwirtschaft + DomainAgriculture Domain = "agriculture" + DomainForestry Domain = "forestry" + DomainFishing Domain = "fishing" + + // Bau & Immobilien + DomainConstruction Domain = "construction" + DomainRealEstate Domain = "real_estate" + DomainFacilityManagement Domain = "facility_management" + + // Gesundheit & Soziales + DomainHealthcare Domain = "healthcare" + DomainMedicalDevices Domain = "medical_devices" + DomainPharma Domain = "pharma" + DomainElderlyCare Domain = "elderly_care" + DomainSocialServices Domain = "social_services" + + // Bildung & Forschung + DomainEducation Domain = "education" + DomainHigherEducation Domain = "higher_education" + DomainVocationalTraining Domain = "vocational_training" + DomainResearch Domain = "research" + + // Finanzen & Versicherung + DomainFinance Domain = "finance" + DomainBanking Domain = "banking" + DomainInsurance Domain = "insurance" + DomainInvestment Domain = "investment" + + // Handel & Logistik + DomainRetail Domain = "retail" + DomainEcommerce Domain = "ecommerce" + DomainWholesale Domain = "wholesale" + DomainLogistics Domain = "logistics" + + // IT & Telekommunikation + DomainITServices Domain = "it_services" + DomainTelecom Domain = "telecom" + DomainCybersecurity Domain = "cybersecurity" + + // Recht & Beratung + DomainLegal Domain = "legal" + DomainConsulting Domain = "consulting" + DomainTaxAdvisory Domain = "tax_advisory" + + // Oeffentlicher Sektor + DomainPublic Domain = "public_sector" + DomainDefense Domain = "defense" + DomainJustice Domain = "justice" + + // Marketing & Medien + DomainMarketing Domain = "marketing" + DomainMedia Domain = "media" + DomainEntertainment Domain = "entertainment" + + // HR & Personal + DomainHR Domain = "hr" + DomainRecruiting Domain = "recruiting" + + // Tourismus & Gastronomie + DomainHospitality Domain = "hospitality" + DomainTourism Domain = "tourism" + + // Sonstige + DomainNonprofit Domain = "nonprofit" + DomainSports Domain = "sports" + DomainGeneral Domain = "general" +) + +// ValidDomains contains all valid domain values +var ValidDomains = map[Domain]bool{ + DomainAutomotive: true, DomainMechanicalEngineering: true, DomainPlantEngineering: true, + DomainElectricalEngineering: true, DomainAerospace: true, DomainChemicals: true, + DomainFoodBeverage: true, DomainTextiles: true, DomainPackaging: true, + DomainUtilities: true, DomainEnergy: true, DomainOilGas: true, + DomainAgriculture: true, DomainForestry: true, DomainFishing: true, + DomainConstruction: true, DomainRealEstate: true, DomainFacilityManagement: true, + DomainHealthcare: true, DomainMedicalDevices: true, DomainPharma: true, + DomainElderlyCare: true, DomainSocialServices: true, + DomainEducation: true, DomainHigherEducation: true, DomainVocationalTraining: true, DomainResearch: true, + DomainFinance: true, DomainBanking: true, DomainInsurance: true, DomainInvestment: true, + DomainRetail: true, DomainEcommerce: true, DomainWholesale: true, DomainLogistics: true, + DomainITServices: true, DomainTelecom: true, DomainCybersecurity: true, + DomainLegal: true, DomainConsulting: true, DomainTaxAdvisory: true, + DomainPublic: true, DomainDefense: true, DomainJustice: true, + DomainMarketing: true, DomainMedia: true, DomainEntertainment: true, + DomainHR: true, DomainRecruiting: true, + DomainHospitality: true, DomainTourism: true, + DomainNonprofit: true, DomainSports: true, DomainGeneral: true, +} + +// AutomationLevel represents the degree of automation +type AutomationLevel string + +const ( + AutomationAssistive AutomationLevel = "assistive" + AutomationSemiAutomated AutomationLevel = "semi_automated" + AutomationFullyAutomated AutomationLevel = "fully_automated" +) + +// TrainingAllowed represents if training with data is permitted +type TrainingAllowed string + +const ( + TrainingYES TrainingAllowed = "YES" + TrainingCONDITIONAL TrainingAllowed = "CONDITIONAL" + TrainingNO TrainingAllowed = "NO" +) + +// ============================================================================ +// Input Structs +// ============================================================================ + +// UseCaseIntake represents the user's input describing their planned AI use case +type UseCaseIntake struct { + // Free-text description of the use case + UseCaseText string `json:"use_case_text"` + + // Business domain + Domain Domain `json:"domain"` + + // Title for the assessment (optional) + Title string `json:"title,omitempty"` + + // Data types involved + DataTypes DataTypes `json:"data_types"` + + // Purpose of the processing + Purpose Purpose `json:"purpose"` + + // Level of automation + Automation AutomationLevel `json:"automation"` + + // Output characteristics + Outputs Outputs `json:"outputs"` + + // Hosting configuration + Hosting Hosting `json:"hosting"` + + // Model usage configuration + ModelUsage ModelUsage `json:"model_usage"` + + // Retention configuration + Retention Retention `json:"retention"` + + // Financial regulations context (DORA, MaRisk, BAIT) + // Only applicable for financial domains (banking, finance, insurance, investment) + FinancialContext *FinancialContext `json:"financial_context,omitempty"` + + // Opt-in to store raw text (otherwise only hash) + StoreRawText bool `json:"store_raw_text,omitempty"` +} + +// DataTypes specifies what kinds of data are processed +type DataTypes struct { + PersonalData bool `json:"personal_data"` + Article9Data bool `json:"article_9_data"` // Special categories (health, religion, etc.) + MinorData bool `json:"minor_data"` // Data of children + LicensePlates bool `json:"license_plates"` // KFZ-Kennzeichen + Images bool `json:"images"` // Photos/images of persons + Audio bool `json:"audio"` // Voice recordings + LocationData bool `json:"location_data"` // GPS/location tracking + BiometricData bool `json:"biometric_data"` // Fingerprints, face recognition + FinancialData bool `json:"financial_data"` // Bank accounts, salaries + EmployeeData bool `json:"employee_data"` // HR/employment data + CustomerData bool `json:"customer_data"` // Customer information + PublicData bool `json:"public_data"` // Publicly available data only +} + +// Purpose specifies the processing purpose +type Purpose struct { + CustomerSupport bool `json:"customer_support"` + Marketing bool `json:"marketing"` + Analytics bool `json:"analytics"` + Automation bool `json:"automation"` + EvaluationScoring bool `json:"evaluation_scoring"` // Scoring/ranking of persons + DecisionMaking bool `json:"decision_making"` // Automated decisions + Profiling bool `json:"profiling"` + Research bool `json:"research"` + InternalTools bool `json:"internal_tools"` + PublicService bool `json:"public_service"` +} + +// Outputs specifies output characteristics +type Outputs struct { + RecommendationsToUsers bool `json:"recommendations_to_users"` + RankingsOrScores bool `json:"rankings_or_scores"` // Outputs rankings/scores + LegalEffects bool `json:"legal_effects"` // Has legal consequences + AccessDecisions bool `json:"access_decisions"` // Grants/denies access + ContentGeneration bool `json:"content_generation"` // Generates text/media + DataExport bool `json:"data_export"` // Exports data externally +} + +// Hosting specifies where the AI runs +type Hosting struct { + Provider string `json:"provider,omitempty"` // e.g., "Azure", "AWS", "Hetzner", "On-Prem" + Region string `json:"region"` // "eu", "third_country", "on_prem" + DataResidency string `json:"data_residency,omitempty"` // Where data is stored +} + +// ModelUsage specifies how the model is used +type ModelUsage struct { + RAG bool `json:"rag"` // Retrieval-Augmented Generation only + Finetune bool `json:"finetune"` // Fine-tuning with data + Training bool `json:"training"` // Full training with data + Inference bool `json:"inference"` // Inference only +} + +// Retention specifies data retention +type Retention struct { + StorePrompts bool `json:"store_prompts"` + StoreResponses bool `json:"store_responses"` + RetentionDays int `json:"retention_days,omitempty"` + AnonymizeAfterUse bool `json:"anonymize_after_use"` +} + +// ============================================================================ +// Financial Regulations Structs (DORA, MaRisk, BAIT) +// ============================================================================ + +// FinancialEntityType represents the type of financial institution +type FinancialEntityType string + +const ( + FinancialEntityCreditInstitution FinancialEntityType = "CREDIT_INSTITUTION" + FinancialEntityPaymentServiceProvider FinancialEntityType = "PAYMENT_SERVICE_PROVIDER" + FinancialEntityEMoneyInstitution FinancialEntityType = "E_MONEY_INSTITUTION" + FinancialEntityInvestmentFirm FinancialEntityType = "INVESTMENT_FIRM" + FinancialEntityInsuranceCompany FinancialEntityType = "INSURANCE_COMPANY" + FinancialEntityCryptoAssetProvider FinancialEntityType = "CRYPTO_ASSET_PROVIDER" + FinancialEntityOther FinancialEntityType = "OTHER_FINANCIAL" +) + +// SizeCategory represents the significance category of a financial institution +type SizeCategory string + +const ( + SizeCategorySignificant SizeCategory = "SIGNIFICANT" + SizeCategoryLessSignificant SizeCategory = "LESS_SIGNIFICANT" + SizeCategorySmall SizeCategory = "SMALL" +) + +// ProviderLocation represents the location of an ICT service provider +type ProviderLocation string + +const ( + ProviderLocationEU ProviderLocation = "EU" + ProviderLocationEEA ProviderLocation = "EEA" + ProviderLocationAdequacyDecision ProviderLocation = "ADEQUACY_DECISION" + ProviderLocationThirdCountry ProviderLocation = "THIRD_COUNTRY" +) + +// FinancialEntity describes the financial institution context +type FinancialEntity struct { + Type FinancialEntityType `json:"type"` + Regulated bool `json:"regulated"` + SizeCategory SizeCategory `json:"size_category"` +} + +// ICTService describes ICT service characteristics for DORA compliance +type ICTService struct { + IsCritical bool `json:"is_critical"` + IsOutsourced bool `json:"is_outsourced"` + ProviderLocation ProviderLocation `json:"provider_location"` + ConcentrationRisk bool `json:"concentration_risk"` +} + +// FinancialAIApplication describes financial-specific AI application characteristics +type FinancialAIApplication struct { + AffectsCustomerDecisions bool `json:"affects_customer_decisions"` + AlgorithmicTrading bool `json:"algorithmic_trading"` + RiskAssessment bool `json:"risk_assessment"` + AMLKYC bool `json:"aml_kyc"` + ModelValidationDone bool `json:"model_validation_done"` +} + +// FinancialContext aggregates all financial regulation-specific information +type FinancialContext struct { + FinancialEntity FinancialEntity `json:"financial_entity"` + ICTService ICTService `json:"ict_service"` + AIApplication FinancialAIApplication `json:"ai_application"` +} + +// ============================================================================ +// Output Structs +// ============================================================================ + +// AssessmentResult represents the complete evaluation result +type AssessmentResult struct { + // Overall verdict + Feasibility Feasibility `json:"feasibility"` + RiskLevel RiskLevel `json:"risk_level"` + Complexity Complexity `json:"complexity"` + RiskScore int `json:"risk_score"` // 0-100 + + // Triggered rules + TriggeredRules []TriggeredRule `json:"triggered_rules"` + + // Required controls/mitigations + RequiredControls []RequiredControl `json:"required_controls"` + + // Recommended architecture patterns + RecommendedArchitecture []PatternRecommendation `json:"recommended_architecture"` + + // Patterns that must NOT be used + ForbiddenPatterns []ForbiddenPattern `json:"forbidden_patterns"` + + // Matching didactic examples + ExampleMatches []ExampleMatch `json:"example_matches"` + + // Special flags + DSFARecommended bool `json:"dsfa_recommended"` + Art22Risk bool `json:"art22_risk"` // Art. 22 GDPR automated decision risk + TrainingAllowed TrainingAllowed `json:"training_allowed"` + + // Summary for humans + Summary string `json:"summary"` + Recommendation string `json:"recommendation"` + AlternativeApproach string `json:"alternative_approach,omitempty"` +} + +// TriggeredRule represents a rule that was triggered during evaluation +type TriggeredRule struct { + Code string `json:"code"` // e.g., "R-001" + Category string `json:"category"` // e.g., "A. Datenklassifikation" + Title string `json:"title"` + Description string `json:"description"` + Severity Severity `json:"severity"` + ScoreDelta int `json:"score_delta"` + GDPRRef string `json:"gdpr_ref,omitempty"` // e.g., "Art. 9 DSGVO" + Rationale string `json:"rationale"` // Why this rule triggered +} + +// RequiredControl represents a control that must be implemented +type RequiredControl struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Severity Severity `json:"severity"` + Category string `json:"category"` // "technical" or "organizational" + GDPRRef string `json:"gdpr_ref,omitempty"` +} + +// PatternRecommendation represents a recommended architecture pattern +type PatternRecommendation struct { + PatternID string `json:"pattern_id"` // e.g., "P-RAG-ONLY" + Title string `json:"title"` + Description string `json:"description"` + Rationale string `json:"rationale"` + Priority int `json:"priority"` // 1=highest +} + +// ForbiddenPattern represents a pattern that must NOT be used +type ForbiddenPattern struct { + PatternID string `json:"pattern_id"` + Title string `json:"title"` + Description string `json:"description"` + Reason string `json:"reason"` + GDPRRef string `json:"gdpr_ref,omitempty"` +} + +// ExampleMatch represents a matching didactic example +type ExampleMatch struct { + ExampleID string `json:"example_id"` + Title string `json:"title"` + Description string `json:"description"` + Similarity float64 `json:"similarity"` // 0.0 - 1.0 + Outcome string `json:"outcome"` // What happened / recommendation + Lessons string `json:"lessons"` // Key takeaways +} + +// ============================================================================ +// Database Entity +// ============================================================================ + +// Assessment represents a stored assessment in the database +type Assessment struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + Title string `json:"title"` + PolicyVersion string `json:"policy_version"` + Status string `json:"status"` // "completed", "draft" + + // Input + Intake UseCaseIntake `json:"intake"` + UseCaseTextStored bool `json:"use_case_text_stored"` + UseCaseTextHash string `json:"use_case_text_hash"` + + // Results + Feasibility Feasibility `json:"feasibility"` + RiskLevel RiskLevel `json:"risk_level"` + Complexity Complexity `json:"complexity"` + RiskScore int `json:"risk_score"` + TriggeredRules []TriggeredRule `json:"triggered_rules"` + RequiredControls []RequiredControl `json:"required_controls"` + RecommendedArchitecture []PatternRecommendation `json:"recommended_architecture"` + ForbiddenPatterns []ForbiddenPattern `json:"forbidden_patterns"` + ExampleMatches []ExampleMatch `json:"example_matches"` + DSFARecommended bool `json:"dsfa_recommended"` + Art22Risk bool `json:"art22_risk"` + TrainingAllowed TrainingAllowed `json:"training_allowed"` + + // LLM Explanation (optional) + ExplanationText *string `json:"explanation_text,omitempty"` + ExplanationGeneratedAt *time.Time `json:"explanation_generated_at,omitempty"` + ExplanationModel *string `json:"explanation_model,omitempty"` + + // Domain + Domain Domain `json:"domain"` + + // Audit + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` +} + +// ============================================================================ +// API Request/Response Types +// ============================================================================ + +// AssessRequest is the API request for creating an assessment +type AssessRequest struct { + Intake UseCaseIntake `json:"intake"` +} + +// AssessResponse is the API response for an assessment +type AssessResponse struct { + Assessment Assessment `json:"assessment"` + Result AssessmentResult `json:"result"` + Escalation *Escalation `json:"escalation,omitempty"` +} + +// ExplainRequest is the API request for generating an explanation +type ExplainRequest struct { + Language string `json:"language,omitempty"` // "de" or "en", default "de" +} + +// ExplainResponse is the API response for an explanation +type ExplainResponse struct { + ExplanationText string `json:"explanation_text"` + GeneratedAt time.Time `json:"generated_at"` + Model string `json:"model"` + LegalContext *LegalContext `json:"legal_context,omitempty"` +} + +// ExportFormat specifies the export format +type ExportFormat string + +const ( + ExportFormatJSON ExportFormat = "json" + ExportFormatMarkdown ExportFormat = "md" +) diff --git a/ai-compliance-sdk/internal/ucca/nis2_module.go b/ai-compliance-sdk/internal/ucca/nis2_module.go new file mode 100644 index 0000000..7599f8a --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/nis2_module.go @@ -0,0 +1,762 @@ +package ucca + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +// ============================================================================ +// NIS2 Module +// ============================================================================ +// +// This module implements the NIS2 directive (EU 2022/2555) and the German +// implementation (BSIG-E - BSI-Gesetz Entwurf). +// +// NIS2 applies to: +// - Essential Entities (besonders wichtige Einrichtungen): Large enterprises in Annex I sectors +// - Important Entities (wichtige Einrichtungen): Medium enterprises in Annex I/II sectors +// +// Classification depends on: +// 1. Sector (Annex I = high criticality, Annex II = other critical) +// 2. Size (employees, revenue, balance sheet) +// 3. Special criteria (KRITIS, special services like DNS/TLD/Cloud) +// +// ============================================================================ + +// NIS2Module implements the RegulationModule interface for NIS2 +type NIS2Module struct { + obligations []Obligation + controls []ObligationControl + incidentDeadlines []IncidentDeadline + decisionTree *DecisionTree + loaded bool +} + +// NIS2 Sector Annexes +var ( + // Annex I: Sectors of High Criticality + NIS2AnnexISectors = map[string]bool{ + "energy": true, // Energie (Strom, Öl, Gas, Wasserstoff, Fernwärme) + "transport": true, // Verkehr (Luft, Schiene, Wasser, Straße) + "banking_financial": true, // Bankwesen + "financial_market": true, // Finanzmarktinfrastrukturen + "health": true, // Gesundheitswesen + "drinking_water": true, // Trinkwasser + "wastewater": true, // Abwasser + "digital_infrastructure": true, // Digitale Infrastruktur + "ict_service_mgmt": true, // IKT-Dienstverwaltung (B2B) + "public_administration": true, // Öffentliche Verwaltung + "space": true, // Weltraum + } + + // Annex II: Other Critical Sectors + NIS2AnnexIISectors = map[string]bool{ + "postal": true, // Post- und Kurierdienste + "waste": true, // Abfallbewirtschaftung + "chemicals": true, // Chemie + "food": true, // Lebensmittel + "manufacturing": true, // Verarbeitendes Gewerbe (wichtige Produkte) + "digital_providers": true, // Digitale Dienste (Marktplätze, Suchmaschinen, soziale Netze) + "research": true, // Forschung + } + + // Special services that are always in scope (regardless of size) + NIS2SpecialServices = map[string]bool{ + "dns": true, // DNS-Dienste + "tld": true, // TLD-Namenregister + "cloud": true, // Cloud-Computing-Dienste + "datacenter": true, // Rechenzentrumsdienste + "cdn": true, // Content-Delivery-Netze + "trust_service": true, // Vertrauensdienste + "public_network": true, // Öffentliche elektronische Kommunikationsnetze + "electronic_comms": true, // Elektronische Kommunikationsdienste + "msp": true, // Managed Service Provider + "mssp": true, // Managed Security Service Provider + } +) + +// NewNIS2Module creates a new NIS2 module, loading obligations from YAML +func NewNIS2Module() (*NIS2Module, error) { + m := &NIS2Module{ + obligations: []Obligation{}, + controls: []ObligationControl{}, + incidentDeadlines: []IncidentDeadline{}, + } + + // Try to load from YAML, fall back to hardcoded if not found + if err := m.loadFromYAML(); err != nil { + // Use hardcoded defaults + m.loadHardcodedObligations() + } + + m.buildDecisionTree() + m.loaded = true + + return m, nil +} + +// ID returns the module identifier +func (m *NIS2Module) ID() string { + return "nis2" +} + +// Name returns the human-readable name +func (m *NIS2Module) Name() string { + return "NIS2-Richtlinie / BSIG-E" +} + +// Description returns a brief description +func (m *NIS2Module) Description() string { + return "EU-Richtlinie über Maßnahmen für ein hohes gemeinsames Cybersicherheitsniveau (NIS2) und deutsche Umsetzung (BSIG-E)" +} + +// IsApplicable checks if NIS2 applies to the organization +func (m *NIS2Module) IsApplicable(facts *UnifiedFacts) bool { + classification := m.Classify(facts) + return classification != NIS2NotAffected +} + +// GetClassification returns the NIS2 classification as string +func (m *NIS2Module) GetClassification(facts *UnifiedFacts) string { + return string(m.Classify(facts)) +} + +// Classify determines the NIS2 classification for an organization +func (m *NIS2Module) Classify(facts *UnifiedFacts) NIS2Classification { + // Check for special services (always in scope, regardless of size) + if m.hasSpecialService(facts) { + // Special services are typically essential entities + return NIS2EssentialEntity + } + + // Check if in relevant sector + inAnnexI := NIS2AnnexISectors[facts.Sector.PrimarySector] + inAnnexII := NIS2AnnexIISectors[facts.Sector.PrimarySector] + + if !inAnnexI && !inAnnexII { + // Not in a regulated sector + return NIS2NotAffected + } + + // Check size thresholds + meetsSize := facts.Organization.MeetsNIS2SizeThreshold() + isLarge := facts.Organization.MeetsNIS2LargeThreshold() + + if !meetsSize { + // Too small (< 50 employees AND < €10m revenue/balance) + // Exception: KRITIS operators are always in scope + if facts.Sector.IsKRITIS && facts.Sector.KRITISThresholdMet { + return NIS2EssentialEntity + } + return NIS2NotAffected + } + + // Annex I sectors + if inAnnexI { + if isLarge { + // Large enterprise in Annex I = Essential Entity + return NIS2EssentialEntity + } + // Medium enterprise in Annex I = Important Entity + return NIS2ImportantEntity + } + + // Annex II sectors + if inAnnexII { + if isLarge { + // Large enterprise in Annex II = Important Entity (not essential) + return NIS2ImportantEntity + } + // Medium enterprise in Annex II = Important Entity + return NIS2ImportantEntity + } + + return NIS2NotAffected +} + +// hasSpecialService checks if the organization provides special NIS2 services +func (m *NIS2Module) hasSpecialService(facts *UnifiedFacts) bool { + for _, service := range facts.Sector.SpecialServices { + if NIS2SpecialServices[service] { + return true + } + } + return false +} + +// DeriveObligations derives all applicable NIS2 obligations +func (m *NIS2Module) DeriveObligations(facts *UnifiedFacts) []Obligation { + classification := m.Classify(facts) + if classification == NIS2NotAffected { + return []Obligation{} + } + + var result []Obligation + for _, obl := range m.obligations { + if m.obligationApplies(obl, classification, facts) { + // Copy and customize obligation + customized := obl + customized.RegulationID = m.ID() + result = append(result, customized) + } + } + + return result +} + +// obligationApplies checks if a specific obligation applies +func (m *NIS2Module) obligationApplies(obl Obligation, classification NIS2Classification, facts *UnifiedFacts) bool { + // Check applies_when condition + switch obl.AppliesWhen { + case "classification == 'besonders_wichtige_einrichtung'": + return classification == NIS2EssentialEntity + case "classification == 'wichtige_einrichtung'": + return classification == NIS2ImportantEntity + case "classification in ['wichtige_einrichtung', 'besonders_wichtige_einrichtung']": + return classification == NIS2EssentialEntity || classification == NIS2ImportantEntity + case "classification != 'nicht_betroffen'": + return classification != NIS2NotAffected + case "": + // No condition = applies to all classified entities + return classification != NIS2NotAffected + default: + // Default: applies if not unaffected + return classification != NIS2NotAffected + } +} + +// DeriveControls derives all applicable NIS2 controls +func (m *NIS2Module) DeriveControls(facts *UnifiedFacts) []ObligationControl { + classification := m.Classify(facts) + if classification == NIS2NotAffected { + return []ObligationControl{} + } + + var result []ObligationControl + for _, ctrl := range m.controls { + ctrl.RegulationID = m.ID() + result = append(result, ctrl) + } + + return result +} + +// GetDecisionTree returns the NIS2 applicability decision tree +func (m *NIS2Module) GetDecisionTree() *DecisionTree { + return m.decisionTree +} + +// GetIncidentDeadlines returns NIS2 incident reporting deadlines +func (m *NIS2Module) GetIncidentDeadlines(facts *UnifiedFacts) []IncidentDeadline { + classification := m.Classify(facts) + if classification == NIS2NotAffected { + return []IncidentDeadline{} + } + + return m.incidentDeadlines +} + +// ============================================================================ +// YAML Loading +// ============================================================================ + +// NIS2ObligationsConfig is the YAML structure for NIS2 obligations +type NIS2ObligationsConfig struct { + Regulation string `yaml:"regulation"` + Name string `yaml:"name"` + Obligations []ObligationYAML `yaml:"obligations"` + Controls []ControlYAML `yaml:"controls"` + IncidentDeadlines []IncidentDeadlineYAML `yaml:"incident_deadlines"` +} + +// ObligationYAML is the YAML structure for an obligation +type ObligationYAML struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Description string `yaml:"description"` + AppliesWhen string `yaml:"applies_when"` + LegalBasis []LegalRefYAML `yaml:"legal_basis"` + Category string `yaml:"category"` + Responsible string `yaml:"responsible"` + Deadline *DeadlineYAML `yaml:"deadline,omitempty"` + Sanctions *SanctionYAML `yaml:"sanctions,omitempty"` + Evidence []string `yaml:"evidence,omitempty"` + Priority string `yaml:"priority"` + ISO27001 []string `yaml:"iso27001_mapping,omitempty"` + HowTo string `yaml:"how_to_implement,omitempty"` +} + +type LegalRefYAML struct { + Norm string `yaml:"norm"` + Article string `yaml:"article,omitempty"` +} + +type DeadlineYAML struct { + Type string `yaml:"type"` + Date string `yaml:"date,omitempty"` + Duration string `yaml:"duration,omitempty"` +} + +type SanctionYAML struct { + MaxFine string `yaml:"max_fine,omitempty"` + PersonalLiability bool `yaml:"personal_liability,omitempty"` +} + +type ControlYAML struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Category string `yaml:"category"` + WhatToDo string `yaml:"what_to_do"` + ISO27001 []string `yaml:"iso27001_mapping,omitempty"` + Priority string `yaml:"priority"` +} + +type IncidentDeadlineYAML struct { + Phase string `yaml:"phase"` + Deadline string `yaml:"deadline"` + Content string `yaml:"content"` + Recipient string `yaml:"recipient"` + LegalBasis []LegalRefYAML `yaml:"legal_basis"` +} + +func (m *NIS2Module) loadFromYAML() error { + // Search paths for YAML file + searchPaths := []string{ + "policies/obligations/nis2_obligations.yaml", + filepath.Join(".", "policies", "obligations", "nis2_obligations.yaml"), + filepath.Join("..", "policies", "obligations", "nis2_obligations.yaml"), + filepath.Join("..", "..", "policies", "obligations", "nis2_obligations.yaml"), + "/app/policies/obligations/nis2_obligations.yaml", + } + + var data []byte + var err error + for _, path := range searchPaths { + data, err = os.ReadFile(path) + if err == nil { + break + } + } + + if err != nil { + return fmt.Errorf("NIS2 obligations YAML not found: %w", err) + } + + var config NIS2ObligationsConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse NIS2 YAML: %w", err) + } + + // Convert YAML to internal structures + m.convertObligations(config.Obligations) + m.convertControls(config.Controls) + m.convertIncidentDeadlines(config.IncidentDeadlines) + + return nil +} + +func (m *NIS2Module) convertObligations(yamlObls []ObligationYAML) { + for _, y := range yamlObls { + obl := Obligation{ + ID: y.ID, + RegulationID: "nis2", + Title: y.Title, + Description: y.Description, + AppliesWhen: y.AppliesWhen, + Category: ObligationCategory(y.Category), + Responsible: ResponsibleRole(y.Responsible), + Priority: ObligationPriority(y.Priority), + ISO27001Mapping: y.ISO27001, + HowToImplement: y.HowTo, + } + + // Convert legal basis + for _, lb := range y.LegalBasis { + obl.LegalBasis = append(obl.LegalBasis, LegalReference{ + Norm: lb.Norm, + Article: lb.Article, + }) + } + + // Convert deadline + if y.Deadline != nil { + obl.Deadline = &Deadline{ + Type: DeadlineType(y.Deadline.Type), + Duration: y.Deadline.Duration, + } + if y.Deadline.Date != "" { + if t, err := time.Parse("2006-01-02", y.Deadline.Date); err == nil { + obl.Deadline.Date = &t + } + } + } + + // Convert sanctions + if y.Sanctions != nil { + obl.Sanctions = &SanctionInfo{ + MaxFine: y.Sanctions.MaxFine, + PersonalLiability: y.Sanctions.PersonalLiability, + } + } + + // Convert evidence + for _, e := range y.Evidence { + obl.Evidence = append(obl.Evidence, EvidenceItem{Name: e, Required: true}) + } + + m.obligations = append(m.obligations, obl) + } +} + +func (m *NIS2Module) convertControls(yamlCtrls []ControlYAML) { + for _, y := range yamlCtrls { + ctrl := ObligationControl{ + ID: y.ID, + RegulationID: "nis2", + Name: y.Name, + Description: y.Description, + Category: y.Category, + WhatToDo: y.WhatToDo, + ISO27001Mapping: y.ISO27001, + Priority: ObligationPriority(y.Priority), + } + m.controls = append(m.controls, ctrl) + } +} + +func (m *NIS2Module) convertIncidentDeadlines(yamlDeadlines []IncidentDeadlineYAML) { + for _, y := range yamlDeadlines { + deadline := IncidentDeadline{ + RegulationID: "nis2", + Phase: y.Phase, + Deadline: y.Deadline, + Content: y.Content, + Recipient: y.Recipient, + } + for _, lb := range y.LegalBasis { + deadline.LegalBasis = append(deadline.LegalBasis, LegalReference{ + Norm: lb.Norm, + Article: lb.Article, + }) + } + m.incidentDeadlines = append(m.incidentDeadlines, deadline) + } +} + +// ============================================================================ +// Hardcoded Fallback +// ============================================================================ + +func (m *NIS2Module) loadHardcodedObligations() { + // BSI Registration deadline + bsiDeadline := time.Date(2025, 1, 17, 0, 0, 0, 0, time.UTC) + + m.obligations = []Obligation{ + { + ID: "NIS2-OBL-001", + RegulationID: "nis2", + Title: "BSI-Registrierung", + Description: "Registrierung beim BSI über das Meldeportal. Anzugeben sind: Kontaktdaten, IP-Bereiche, verantwortliche Ansprechpartner.", + LegalBasis: []LegalReference{{Norm: "§ 33 BSIG-E", Article: "Registrierungspflicht"}}, + Category: CategoryMeldepflicht, + Responsible: RoleManagement, + Deadline: &Deadline{Type: DeadlineAbsolute, Date: &bsiDeadline}, + Sanctions: &SanctionInfo{MaxFine: "500.000 EUR", PersonalLiability: false}, + Evidence: []EvidenceItem{{Name: "Registrierungsbestätigung BSI", Required: true}, {Name: "Dokumentierte Ansprechpartner", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "classification in ['wichtige_einrichtung', 'besonders_wichtige_einrichtung']", + }, + { + ID: "NIS2-OBL-002", + RegulationID: "nis2", + Title: "Risikomanagement-Maßnahmen implementieren", + Description: "Umsetzung angemessener technischer, operativer und organisatorischer Maßnahmen zur Beherrschung der Risiken für die Sicherheit der Netz- und Informationssysteme.", + LegalBasis: []LegalReference{{Norm: "Art. 21 NIS2"}, {Norm: "§ 30 BSIG-E"}}, + Category: CategoryGovernance, + Responsible: RoleCISO, + Deadline: &Deadline{Type: DeadlineRelative, Duration: "18 Monate nach Inkrafttreten"}, + Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz", PersonalLiability: true}, + Evidence: []EvidenceItem{{Name: "ISMS-Dokumentation", Required: true}, {Name: "Risikoanalyse", Required: true}, {Name: "Maßnahmenkatalog", Required: true}}, + Priority: PriorityHigh, + ISO27001Mapping: []string{"A.5", "A.6", "A.8"}, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-003", + RegulationID: "nis2", + Title: "Geschäftsführungs-Verantwortung", + Description: "Die Geschäftsleitung muss die Risikomanagementmaßnahmen genehmigen, deren Umsetzung überwachen und kann für Verstöße persönlich haftbar gemacht werden.", + LegalBasis: []LegalReference{{Norm: "Art. 20 NIS2"}, {Norm: "§ 38 BSIG-E"}}, + Category: CategoryGovernance, + Responsible: RoleManagement, + Sanctions: &SanctionInfo{MaxFine: "10 Mio. EUR oder 2% Jahresumsatz", PersonalLiability: true}, + Evidence: []EvidenceItem{{Name: "Vorstandsbeschluss zur Cybersicherheit", Required: true}, {Name: "Dokumentierte Genehmigung der Maßnahmen", Required: true}}, + Priority: PriorityCritical, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-004", + RegulationID: "nis2", + Title: "Cybersicherheits-Schulung der Geschäftsführung", + Description: "Mitglieder der Leitungsorgane müssen an Schulungen teilnehmen, um ausreichende Kenntnisse und Fähigkeiten zur Erkennung und Bewertung von Risiken zu erlangen.", + LegalBasis: []LegalReference{{Norm: "Art. 20 Abs. 2 NIS2"}, {Norm: "§ 38 Abs. 3 BSIG-E"}}, + Category: CategoryTraining, + Responsible: RoleManagement, + Deadline: &Deadline{Type: DeadlineRecurring, Interval: "jährlich"}, + Evidence: []EvidenceItem{{Name: "Schulungsnachweise der Geschäftsführung", Required: true}, {Name: "Schulungsplan", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-005", + RegulationID: "nis2", + Title: "Incident-Response-Prozess etablieren", + Description: "Etablierung eines Prozesses zur Erkennung, Analyse und Meldung von Sicherheitsvorfällen gemäß den gesetzlichen Meldefristen.", + LegalBasis: []LegalReference{{Norm: "Art. 23 NIS2"}, {Norm: "§ 32 BSIG-E"}}, + Category: CategoryTechnical, + Responsible: RoleCISO, + Evidence: []EvidenceItem{{Name: "Incident-Response-Plan", Required: true}, {Name: "Meldeprozess-Dokumentation", Required: true}, {Name: "Kontaktdaten BSI", Required: true}}, + Priority: PriorityCritical, + ISO27001Mapping: []string{"A.16"}, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-006", + RegulationID: "nis2", + Title: "Business Continuity Management", + Description: "Maßnahmen zur Aufrechterhaltung des Betriebs, Backup-Management, Notfallwiederherstellung und Krisenmanagement.", + LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. c NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 3 BSIG-E"}}, + Category: CategoryTechnical, + Responsible: RoleCISO, + Evidence: []EvidenceItem{{Name: "BCM-Dokumentation", Required: true}, {Name: "Backup-Konzept", Required: true}, {Name: "Disaster-Recovery-Plan", Required: true}, {Name: "Testprotokolle", Required: true}}, + Priority: PriorityHigh, + ISO27001Mapping: []string{"A.17"}, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-007", + RegulationID: "nis2", + Title: "Lieferketten-Sicherheit", + Description: "Sicherheit in der Lieferkette, einschließlich sicherheitsbezogener Aspekte der Beziehungen zwischen Einrichtung und direkten Anbietern oder Diensteanbietern.", + LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. d NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 4 BSIG-E"}}, + Category: CategoryOrganizational, + Responsible: RoleCISO, + Evidence: []EvidenceItem{{Name: "Lieferanten-Risikobewertung", Required: true}, {Name: "Sicherheitsanforderungen in Verträgen", Required: true}}, + Priority: PriorityMedium, + ISO27001Mapping: []string{"A.15"}, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-008", + RegulationID: "nis2", + Title: "Schwachstellenmanagement", + Description: "Umgang mit Schwachstellen und deren Offenlegung, Maßnahmen zur Erkennung und Behebung von Schwachstellen.", + LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. e NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 5 BSIG-E"}}, + Category: CategoryTechnical, + Responsible: RoleCISO, + Evidence: []EvidenceItem{{Name: "Schwachstellen-Management-Prozess", Required: true}, {Name: "Patch-Management-Richtlinie", Required: true}, {Name: "Vulnerability-Scan-Berichte", Required: true}}, + Priority: PriorityHigh, + ISO27001Mapping: []string{"A.12.6"}, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-009", + RegulationID: "nis2", + Title: "Zugangs- und Identitätsmanagement", + Description: "Konzepte für die Zugangskontrolle und das Management von Anlagen sowie Verwendung von MFA und kontinuierlicher Authentifizierung.", + LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. i NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 9 BSIG-E"}}, + Category: CategoryTechnical, + Responsible: RoleITLeitung, + Evidence: []EvidenceItem{{Name: "Zugangskontroll-Richtlinie", Required: true}, {Name: "MFA-Implementierungsnachweis", Required: true}, {Name: "Identity-Management-Dokumentation", Required: true}}, + Priority: PriorityHigh, + ISO27001Mapping: []string{"A.9"}, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-010", + RegulationID: "nis2", + Title: "Kryptographie und Verschlüsselung", + Description: "Konzepte und Verfahren für den Einsatz von Kryptographie und gegebenenfalls Verschlüsselung.", + LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. h NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 8 BSIG-E"}}, + Category: CategoryTechnical, + Responsible: RoleCISO, + Evidence: []EvidenceItem{{Name: "Kryptographie-Richtlinie", Required: true}, {Name: "Verschlüsselungskonzept", Required: true}, {Name: "Key-Management-Dokumentation", Required: true}}, + Priority: PriorityMedium, + ISO27001Mapping: []string{"A.10"}, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-011", + RegulationID: "nis2", + Title: "Personalsicherheit", + Description: "Sicherheit des Personals, Konzepte für die Zugriffskontrolle und das Management von Anlagen.", + LegalBasis: []LegalReference{{Norm: "Art. 21 Abs. 2 lit. j NIS2"}, {Norm: "§ 30 Abs. 2 Nr. 10 BSIG-E"}}, + Category: CategoryOrganizational, + Responsible: RoleManagement, + Evidence: []EvidenceItem{{Name: "Personalsicherheits-Richtlinie", Required: true}, {Name: "Schulungskonzept", Required: true}}, + Priority: PriorityMedium, + ISO27001Mapping: []string{"A.7"}, + AppliesWhen: "classification != 'nicht_betroffen'", + }, + { + ID: "NIS2-OBL-012", + RegulationID: "nis2", + Title: "Regelmäßige Audits (besonders wichtige Einrichtungen)", + Description: "Besonders wichtige Einrichtungen unterliegen regelmäßigen Sicherheitsüberprüfungen durch das BSI.", + LegalBasis: []LegalReference{{Norm: "Art. 32 NIS2"}, {Norm: "§ 39 BSIG-E"}}, + Category: CategoryAudit, + Responsible: RoleCISO, + Deadline: &Deadline{Type: DeadlineRecurring, Interval: "alle 2 Jahre"}, + Evidence: []EvidenceItem{{Name: "Audit-Berichte", Required: true}, {Name: "Maßnahmenplan aus Audits", Required: true}}, + Priority: PriorityHigh, + AppliesWhen: "classification == 'besonders_wichtige_einrichtung'", + }, + } + + // Hardcoded controls + m.controls = []ObligationControl{ + { + ID: "NIS2-CTRL-001", + RegulationID: "nis2", + Name: "ISMS implementieren", + Description: "Implementierung eines Informationssicherheits-Managementsystems", + Category: "Governance", + WhatToDo: "Aufbau eines ISMS nach ISO 27001 oder BSI IT-Grundschutz", + ISO27001Mapping: []string{"4", "5", "6", "7"}, + Priority: PriorityHigh, + }, + { + ID: "NIS2-CTRL-002", + RegulationID: "nis2", + Name: "Netzwerksegmentierung", + Description: "Segmentierung kritischer Netzwerkbereiche", + Category: "Technisch", + WhatToDo: "Implementierung von VLANs, Firewalls und Mikrosegmentierung für kritische Systeme", + ISO27001Mapping: []string{"A.13.1"}, + Priority: PriorityHigh, + }, + { + ID: "NIS2-CTRL-003", + RegulationID: "nis2", + Name: "Security Monitoring", + Description: "Kontinuierliche Überwachung der IT-Sicherheit", + Category: "Technisch", + WhatToDo: "Implementierung von SIEM, Log-Management und Anomalie-Erkennung", + ISO27001Mapping: []string{"A.12.4"}, + Priority: PriorityHigh, + }, + { + ID: "NIS2-CTRL-004", + RegulationID: "nis2", + Name: "Awareness-Programm", + Description: "Regelmäßige Sicherheitsschulungen für alle Mitarbeiter", + Category: "Organisatorisch", + WhatToDo: "Durchführung von Phishing-Simulationen, E-Learning und Präsenzschulungen", + ISO27001Mapping: []string{"A.7.2.2"}, + Priority: PriorityMedium, + }, + } + + // Hardcoded incident deadlines + m.incidentDeadlines = []IncidentDeadline{ + { + RegulationID: "nis2", + Phase: "Frühwarnung", + Deadline: "24 Stunden", + Content: "Unverzügliche Meldung erheblicher Sicherheitsvorfälle. Angabe ob böswilliger Angriff vermutet und ob grenzüberschreitende Auswirkungen möglich.", + Recipient: "BSI", + LegalBasis: []LegalReference{{Norm: "§ 32 Abs. 1 BSIG-E"}}, + }, + { + RegulationID: "nis2", + Phase: "Vorfallmeldung", + Deadline: "72 Stunden", + Content: "Aktualisierung der Frühwarnung. Erste Bewertung des Vorfalls, Schweregrad, Auswirkungen, Kompromittierungsindikatoren (IoCs).", + Recipient: "BSI", + LegalBasis: []LegalReference{{Norm: "§ 32 Abs. 2 BSIG-E"}}, + }, + { + RegulationID: "nis2", + Phase: "Abschlussbericht", + Deadline: "1 Monat", + Content: "Ausführliche Beschreibung des Vorfalls, Ursachenanalyse (Root Cause), ergriffene Abhilfemaßnahmen, grenzüberschreitende Auswirkungen.", + Recipient: "BSI", + LegalBasis: []LegalReference{{Norm: "§ 32 Abs. 3 BSIG-E"}}, + }, + } +} + +// ============================================================================ +// Decision Tree +// ============================================================================ + +func (m *NIS2Module) buildDecisionTree() { + m.decisionTree = &DecisionTree{ + ID: "nis2_applicability", + Name: "NIS2 Anwendbarkeits-Entscheidungsbaum", + RootNode: &DecisionNode{ + ID: "root", + Question: "Erbringt Ihr Unternehmen spezielle digitale Dienste (DNS, TLD, Cloud, Rechenzentrum, CDN, MSP, MSSP, Vertrauensdienste)?", + YesNode: &DecisionNode{ + ID: "special_services", + Result: string(NIS2EssentialEntity), + Explanation: "Anbieter spezieller digitaler Dienste sind unabhängig von der Größe als besonders wichtige Einrichtungen einzustufen.", + }, + NoNode: &DecisionNode{ + ID: "sector_check", + Question: "Ist Ihr Unternehmen in einem der NIS2-Sektoren tätig (Energie, Verkehr, Gesundheit, Digitale Infrastruktur, Öffentliche Verwaltung, Finanzwesen, etc.)?", + YesNode: &DecisionNode{ + ID: "size_check", + Question: "Hat Ihr Unternehmen mindestens 50 Mitarbeiter ODER mindestens 10 Mio. EUR Jahresumsatz UND Bilanzsumme?", + YesNode: &DecisionNode{ + ID: "annex_check", + Question: "Ist Ihr Sektor in Anhang I der NIS2 (hohe Kritikalität: Energie, Verkehr, Gesundheit, Trinkwasser, Digitale Infrastruktur, Bankwesen, Öffentliche Verwaltung, Weltraum)?", + YesNode: &DecisionNode{ + ID: "large_check_annex1", + Question: "Hat Ihr Unternehmen mindestens 250 Mitarbeiter ODER mindestens 50 Mio. EUR Jahresumsatz?", + YesNode: &DecisionNode{ + ID: "essential_annex1", + Result: string(NIS2EssentialEntity), + Explanation: "Großes Unternehmen in Anhang I Sektor = Besonders wichtige Einrichtung", + }, + NoNode: &DecisionNode{ + ID: "important_annex1", + Result: string(NIS2ImportantEntity), + Explanation: "Mittleres Unternehmen in Anhang I Sektor = Wichtige Einrichtung", + }, + }, + NoNode: &DecisionNode{ + ID: "important_annex2", + Result: string(NIS2ImportantEntity), + Explanation: "Unternehmen in Anhang II Sektor = Wichtige Einrichtung", + }, + }, + NoNode: &DecisionNode{ + ID: "kritis_check", + Question: "Ist Ihr Unternehmen als KRITIS-Betreiber eingestuft?", + YesNode: &DecisionNode{ + ID: "kritis_essential", + Result: string(NIS2EssentialEntity), + Explanation: "KRITIS-Betreiber sind unabhängig von der Größe als besonders wichtige Einrichtungen einzustufen.", + }, + NoNode: &DecisionNode{ + ID: "too_small", + Result: string(NIS2NotAffected), + Explanation: "Unternehmen unterhalb der Größenschwelle ohne KRITIS-Status sind nicht von NIS2 betroffen.", + }, + }, + }, + NoNode: &DecisionNode{ + ID: "not_in_sector", + Result: string(NIS2NotAffected), + Explanation: "Unternehmen außerhalb der NIS2-Sektoren sind nicht betroffen.", + }, + }, + }, + } +} diff --git a/ai-compliance-sdk/internal/ucca/nis2_module_test.go b/ai-compliance-sdk/internal/ucca/nis2_module_test.go new file mode 100644 index 0000000..6afeb3c --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/nis2_module_test.go @@ -0,0 +1,556 @@ +package ucca + +import ( + "testing" +) + +func TestNIS2Module_Classification(t *testing.T) { + module, err := NewNIS2Module() + if err != nil { + t.Fatalf("Failed to create NIS2 module: %v", err) + } + + tests := []struct { + name string + facts *UnifiedFacts + expectedClass NIS2Classification + expectedApply bool + }{ + { + name: "Large energy company - Essential Entity", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 500, + AnnualRevenue: 100_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "energy", + }, + }, + expectedClass: NIS2EssentialEntity, + expectedApply: true, + }, + { + name: "Medium energy company - Important Entity", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 100, + AnnualRevenue: 20_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "energy", + }, + }, + expectedClass: NIS2ImportantEntity, + expectedApply: true, + }, + { + name: "Small energy company - Not Affected", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 20, + AnnualRevenue: 5_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "energy", + }, + }, + expectedClass: NIS2NotAffected, + expectedApply: false, + }, + { + name: "Large healthcare company - Essential Entity", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 300, + AnnualRevenue: 80_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "health", + }, + }, + expectedClass: NIS2EssentialEntity, + expectedApply: true, + }, + { + name: "Medium manufacturing company (Annex II) - Important Entity", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 150, + AnnualRevenue: 30_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "manufacturing", + }, + }, + expectedClass: NIS2ImportantEntity, + expectedApply: true, + }, + { + name: "Large manufacturing company (Annex II) - Important Entity (not Essential)", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 500, + AnnualRevenue: 100_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "manufacturing", + }, + }, + expectedClass: NIS2ImportantEntity, + expectedApply: true, + }, + { + name: "Retail company - Not Affected (not in NIS2 sectors)", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 500, + AnnualRevenue: 100_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "retail", + }, + }, + expectedClass: NIS2NotAffected, + expectedApply: false, + }, + { + name: "Small KRITIS operator - Essential Entity (exception)", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 30, + AnnualRevenue: 8_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "energy", + IsKRITIS: true, + KRITISThresholdMet: true, + }, + }, + expectedClass: NIS2EssentialEntity, + expectedApply: true, + }, + { + name: "Cloud provider (special service) - Essential Entity regardless of size", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 20, + AnnualRevenue: 5_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "it_services", + SpecialServices: []string{"cloud"}, + }, + }, + expectedClass: NIS2EssentialEntity, + expectedApply: true, + }, + { + name: "DNS provider (special service) - Essential Entity regardless of size", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 10, + AnnualRevenue: 2_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "digital_infrastructure", + SpecialServices: []string{"dns"}, + }, + }, + expectedClass: NIS2EssentialEntity, + expectedApply: true, + }, + { + name: "MSP provider - Essential Entity", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 15, + AnnualRevenue: 3_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "ict_service_mgmt", + SpecialServices: []string{"msp"}, + }, + }, + expectedClass: NIS2EssentialEntity, + expectedApply: true, + }, + { + name: "Group company (counts group size) - Essential Entity", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 50, + AnnualRevenue: 15_000_000, + IsPartOfGroup: true, + GroupEmployees: 500, + GroupRevenue: 200_000_000, + Country: "DE", + EUMember: true, + }, + Sector: SectorFacts{ + PrimarySector: "energy", + }, + }, + expectedClass: NIS2EssentialEntity, + expectedApply: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + classification := module.Classify(tt.facts) + if classification != tt.expectedClass { + t.Errorf("Classify() = %v, want %v", classification, tt.expectedClass) + } + + isApplicable := module.IsApplicable(tt.facts) + if isApplicable != tt.expectedApply { + t.Errorf("IsApplicable() = %v, want %v", isApplicable, tt.expectedApply) + } + }) + } +} + +func TestNIS2Module_DeriveObligations(t *testing.T) { + module, err := NewNIS2Module() + if err != nil { + t.Fatalf("Failed to create NIS2 module: %v", err) + } + + tests := []struct { + name string + facts *UnifiedFacts + minObligations int + expectedContains []string // Obligation IDs that should be present + expectedMissing []string // Obligation IDs that should NOT be present + }{ + { + name: "Essential Entity should have all obligations including audits", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 500, + AnnualRevenue: 100_000_000, + }, + Sector: SectorFacts{ + PrimarySector: "energy", + }, + }, + minObligations: 10, + expectedContains: []string{"NIS2-OBL-001", "NIS2-OBL-002", "NIS2-OBL-012"}, // BSI registration, risk mgmt, audits + }, + { + name: "Important Entity should have obligations but NOT audit requirement", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 100, + AnnualRevenue: 20_000_000, + }, + Sector: SectorFacts{ + PrimarySector: "manufacturing", + }, + }, + minObligations: 8, + expectedContains: []string{"NIS2-OBL-001", "NIS2-OBL-002"}, + expectedMissing: []string{"NIS2-OBL-012"}, // Audits only for essential entities + }, + { + name: "Not affected entity should have no obligations", + facts: &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 20, + AnnualRevenue: 5_000_000, + }, + Sector: SectorFacts{ + PrimarySector: "retail", + }, + }, + minObligations: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obligations := module.DeriveObligations(tt.facts) + + if len(obligations) < tt.minObligations { + t.Errorf("DeriveObligations() returned %d obligations, want at least %d", len(obligations), tt.minObligations) + } + + // Check expected contains + for _, expectedID := range tt.expectedContains { + found := false + for _, obl := range obligations { + if obl.ID == expectedID { + found = true + break + } + } + if !found { + t.Errorf("Expected obligation %s not found in results", expectedID) + } + } + + // Check expected missing + for _, missingID := range tt.expectedMissing { + for _, obl := range obligations { + if obl.ID == missingID { + t.Errorf("Obligation %s should NOT be present for this classification", missingID) + } + } + } + }) + } +} + +func TestNIS2Module_IncidentDeadlines(t *testing.T) { + module, err := NewNIS2Module() + if err != nil { + t.Fatalf("Failed to create NIS2 module: %v", err) + } + + // Test for affected entity + affectedFacts := &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 100, + AnnualRevenue: 20_000_000, + }, + Sector: SectorFacts{ + PrimarySector: "energy", + }, + } + + deadlines := module.GetIncidentDeadlines(affectedFacts) + if len(deadlines) != 3 { + t.Errorf("Expected 3 incident deadlines, got %d", len(deadlines)) + } + + // Check phases + expectedPhases := map[string]string{ + "Frühwarnung": "24 Stunden", + "Vorfallmeldung": "72 Stunden", + "Abschlussbericht": "1 Monat", + } + + for _, deadline := range deadlines { + if expected, ok := expectedPhases[deadline.Phase]; ok { + if deadline.Deadline != expected { + t.Errorf("Phase %s: expected deadline %s, got %s", deadline.Phase, expected, deadline.Deadline) + } + } + } + + // Test for not affected entity + notAffectedFacts := &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 20, + AnnualRevenue: 5_000_000, + }, + Sector: SectorFacts{ + PrimarySector: "retail", + }, + } + + deadlines = module.GetIncidentDeadlines(notAffectedFacts) + if len(deadlines) != 0 { + t.Errorf("Expected 0 incident deadlines for not affected entity, got %d", len(deadlines)) + } +} + +func TestNIS2Module_DecisionTree(t *testing.T) { + module, err := NewNIS2Module() + if err != nil { + t.Fatalf("Failed to create NIS2 module: %v", err) + } + + tree := module.GetDecisionTree() + if tree == nil { + t.Fatal("Expected decision tree, got nil") + } + + if tree.ID != "nis2_applicability" { + t.Errorf("Expected tree ID 'nis2_applicability', got %s", tree.ID) + } + + if tree.RootNode == nil { + t.Fatal("Expected root node, got nil") + } + + // Verify structure + if tree.RootNode.YesNode == nil || tree.RootNode.NoNode == nil { + t.Error("Root node should have both yes and no branches") + } +} + +func TestNIS2Module_DeriveControls(t *testing.T) { + module, err := NewNIS2Module() + if err != nil { + t.Fatalf("Failed to create NIS2 module: %v", err) + } + + // Test for affected entity + affectedFacts := &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 100, + AnnualRevenue: 20_000_000, + }, + Sector: SectorFacts{ + PrimarySector: "energy", + }, + } + + controls := module.DeriveControls(affectedFacts) + if len(controls) == 0 { + t.Error("Expected controls for affected entity, got none") + } + + // Check that controls have regulation ID set + for _, ctrl := range controls { + if ctrl.RegulationID != "nis2" { + t.Errorf("Control %s has wrong regulation ID: %s", ctrl.ID, ctrl.RegulationID) + } + } + + // Test for not affected entity + notAffectedFacts := &UnifiedFacts{ + Organization: OrganizationFacts{ + EmployeeCount: 20, + AnnualRevenue: 5_000_000, + }, + Sector: SectorFacts{ + PrimarySector: "retail", + }, + } + + controls = module.DeriveControls(notAffectedFacts) + if len(controls) != 0 { + t.Errorf("Expected 0 controls for not affected entity, got %d", len(controls)) + } +} + +func TestOrganizationFacts_SizeCalculations(t *testing.T) { + tests := []struct { + name string + org OrganizationFacts + expectedSize string + expectedSME bool + expectedNIS2 bool + expectedLarge bool + }{ + { + name: "Micro enterprise", + org: OrganizationFacts{ + EmployeeCount: 5, + AnnualRevenue: 1_000_000, + }, + expectedSize: "micro", + expectedSME: true, + expectedNIS2: false, + expectedLarge: false, + }, + { + name: "Small enterprise", + org: OrganizationFacts{ + EmployeeCount: 30, + AnnualRevenue: 8_000_000, + }, + expectedSize: "small", + expectedSME: true, + expectedNIS2: false, + expectedLarge: false, + }, + { + name: "Medium enterprise (by employees)", + org: OrganizationFacts{ + EmployeeCount: 100, + AnnualRevenue: 20_000_000, + }, + expectedSize: "medium", + expectedSME: true, + expectedNIS2: true, + expectedLarge: false, + }, + { + name: "Medium enterprise (by revenue/balance)", + org: OrganizationFacts{ + EmployeeCount: 40, + AnnualRevenue: 15_000_000, + BalanceSheetTotal: 15_000_000, + }, + expectedSize: "medium", // < 50 employees BUT revenue AND balance > €10m → not small + expectedSME: true, + expectedNIS2: true, // >= €10m revenue AND balance + expectedLarge: false, + }, + { + name: "Large enterprise", + org: OrganizationFacts{ + EmployeeCount: 500, + AnnualRevenue: 100_000_000, + }, + expectedSize: "large", + expectedSME: false, + expectedNIS2: true, + expectedLarge: true, + }, + { + name: "Group company (uses group figures)", + org: OrganizationFacts{ + EmployeeCount: 50, + AnnualRevenue: 15_000_000, + IsPartOfGroup: true, + GroupEmployees: 500, + GroupRevenue: 200_000_000, + }, + expectedSize: "large", + expectedSME: false, + expectedNIS2: true, + expectedLarge: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + size := tt.org.CalculateSizeCategory() + if size != tt.expectedSize { + t.Errorf("CalculateSizeCategory() = %v, want %v", size, tt.expectedSize) + } + + isSME := tt.org.IsSME() + if isSME != tt.expectedSME { + t.Errorf("IsSME() = %v, want %v", isSME, tt.expectedSME) + } + + meetsNIS2 := tt.org.MeetsNIS2SizeThreshold() + if meetsNIS2 != tt.expectedNIS2 { + t.Errorf("MeetsNIS2SizeThreshold() = %v, want %v", meetsNIS2, tt.expectedNIS2) + } + + isLarge := tt.org.MeetsNIS2LargeThreshold() + if isLarge != tt.expectedLarge { + t.Errorf("MeetsNIS2LargeThreshold() = %v, want %v", isLarge, tt.expectedLarge) + } + }) + } +} diff --git a/ai-compliance-sdk/internal/ucca/obligations_framework.go b/ai-compliance-sdk/internal/ucca/obligations_framework.go new file mode 100644 index 0000000..61275aa --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/obligations_framework.go @@ -0,0 +1,381 @@ +package ucca + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Generic Obligations Framework +// ============================================================================ +// +// This framework provides a regulation-agnostic way to derive and manage +// compliance obligations. Each regulation (DSGVO, NIS2, AI Act, etc.) is +// implemented as a separate module that conforms to the RegulationModule +// interface. +// +// Key principles: +// - Deterministic: No LLM involvement in obligation derivation +// - Transparent: Obligations are traceable to legal basis +// - Composable: Regulations can be combined +// - Auditable: Full traceability for compliance evidence +// +// ============================================================================ + +// ============================================================================ +// Enums and Constants +// ============================================================================ + +// ObligationPriority represents the urgency of an obligation +type ObligationPriority string + +const ( + PriorityCritical ObligationPriority = "critical" + PriorityHigh ObligationPriority = "high" + PriorityMedium ObligationPriority = "medium" + PriorityLow ObligationPriority = "low" +) + +// ObligationCategory represents the type of obligation +type ObligationCategory string + +const ( + CategoryMeldepflicht ObligationCategory = "Meldepflicht" + CategoryGovernance ObligationCategory = "Governance" + CategoryTechnical ObligationCategory = "Technisch" + CategoryOrganizational ObligationCategory = "Organisatorisch" + CategoryDocumentation ObligationCategory = "Dokumentation" + CategoryTraining ObligationCategory = "Schulung" + CategoryAudit ObligationCategory = "Audit" + CategoryCompliance ObligationCategory = "Compliance" +) + +// ResponsibleRole represents who is responsible for an obligation +type ResponsibleRole string + +const ( + RoleManagement ResponsibleRole = "Geschäftsführung" + RoleDSB ResponsibleRole = "Datenschutzbeauftragter" + RoleCISO ResponsibleRole = "CISO" + RoleITLeitung ResponsibleRole = "IT-Leitung" + RoleCompliance ResponsibleRole = "Compliance-Officer" + RoleAIBeauftragter ResponsibleRole = "KI-Beauftragter" + RoleKIVerantwortlicher ResponsibleRole = "KI-Verantwortlicher" + RoleRiskManager ResponsibleRole = "Risikomanager" + RoleFachbereich ResponsibleRole = "Fachbereichsleitung" +) + +// DeadlineType represents the type of deadline +type DeadlineType string + +const ( + DeadlineAbsolute DeadlineType = "absolute" + DeadlineRelative DeadlineType = "relative" + DeadlineRecurring DeadlineType = "recurring" + DeadlineOnEvent DeadlineType = "on_event" +) + +// NIS2Classification represents NIS2 entity classification +type NIS2Classification string + +const ( + NIS2NotAffected NIS2Classification = "nicht_betroffen" + NIS2ImportantEntity NIS2Classification = "wichtige_einrichtung" + NIS2EssentialEntity NIS2Classification = "besonders_wichtige_einrichtung" +) + +// ============================================================================ +// Core Interfaces +// ============================================================================ + +// RegulationModule is the interface that all regulation modules must implement +type RegulationModule interface { + // ID returns the unique identifier for this regulation (e.g., "nis2", "dsgvo") + ID() string + + // Name returns the human-readable name (e.g., "NIS2-Richtlinie") + Name() string + + // Description returns a brief description of the regulation + Description() string + + // IsApplicable checks if this regulation applies to the given organization + IsApplicable(facts *UnifiedFacts) bool + + // DeriveObligations derives all obligations based on the facts + DeriveObligations(facts *UnifiedFacts) []Obligation + + // DeriveControls derives required controls based on the facts + DeriveControls(facts *UnifiedFacts) []ObligationControl + + // GetDecisionTree returns the decision tree for this regulation (optional) + GetDecisionTree() *DecisionTree + + // GetIncidentDeadlines returns incident reporting deadlines (optional) + GetIncidentDeadlines(facts *UnifiedFacts) []IncidentDeadline + + // GetClassification returns the specific classification within this regulation + GetClassification(facts *UnifiedFacts) string +} + +// ============================================================================ +// Core Data Structures +// ============================================================================ + +// LegalReference represents a reference to a specific legal provision +type LegalReference struct { + Norm string `json:"norm" yaml:"norm"` // e.g., "Art. 28 DSGVO", "§ 33 BSIG-E" + Article string `json:"article,omitempty" yaml:"article,omitempty"` // Article/paragraph number + Title string `json:"title,omitempty" yaml:"title,omitempty"` // Title of the provision + Description string `json:"description,omitempty" yaml:"description,omitempty"` // Brief description + URL string `json:"url,omitempty" yaml:"url,omitempty"` // Link to full text +} + +// Deadline represents when an obligation must be fulfilled +type Deadline struct { + Type DeadlineType `json:"type" yaml:"type"` // absolute, relative, recurring, on_event + Date *time.Time `json:"date,omitempty" yaml:"date,omitempty"` // For absolute deadlines + Duration string `json:"duration,omitempty" yaml:"duration,omitempty"` // For relative: "18 Monate nach Inkrafttreten" + Event string `json:"event,omitempty" yaml:"event,omitempty"` // For on_event: "Bei Sicherheitsvorfall" + Interval string `json:"interval,omitempty" yaml:"interval,omitempty"` // For recurring: "jährlich", "quartalsweise" +} + +// SanctionInfo represents potential sanctions for non-compliance +type SanctionInfo struct { + MaxFine string `json:"max_fine,omitempty" yaml:"max_fine,omitempty"` // e.g., "10 Mio. EUR oder 2% Jahresumsatz" + MinFine string `json:"min_fine,omitempty" yaml:"min_fine,omitempty"` // Minimum fine if applicable + PersonalLiability bool `json:"personal_liability" yaml:"personal_liability"` // Can management be held personally liable? + CriminalLiability bool `json:"criminal_liability" yaml:"criminal_liability"` // Can lead to criminal charges? + Description string `json:"description,omitempty" yaml:"description,omitempty"` // Additional description +} + +// EvidenceItem represents what constitutes evidence of compliance +type EvidenceItem struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Name string `json:"name" yaml:"name"` // e.g., "Registrierungsbestätigung BSI" + Description string `json:"description,omitempty" yaml:"description,omitempty"` // What this evidence should contain + Format string `json:"format,omitempty" yaml:"format,omitempty"` // e.g., "PDF", "Screenshot", "Protokoll" + Required bool `json:"required" yaml:"required"` // Is this evidence mandatory? +} + +// Obligation represents a single regulatory obligation +type Obligation struct { + ID string `json:"id" yaml:"id"` // e.g., "NIS2-OBL-001" + RegulationID string `json:"regulation_id" yaml:"regulation_id"` // e.g., "nis2" + Title string `json:"title" yaml:"title"` // e.g., "BSI-Registrierung" + Description string `json:"description" yaml:"description"` // Detailed description + LegalBasis []LegalReference `json:"legal_basis" yaml:"legal_basis"` // Legal references + Category ObligationCategory `json:"category" yaml:"category"` // Type of obligation + Responsible ResponsibleRole `json:"responsible" yaml:"responsible"` // Who is responsible + Deadline *Deadline `json:"deadline,omitempty" yaml:"deadline,omitempty"` + Sanctions *SanctionInfo `json:"sanctions,omitempty" yaml:"sanctions,omitempty"` + Evidence []EvidenceItem `json:"evidence,omitempty" yaml:"evidence,omitempty"` + Priority ObligationPriority `json:"priority" yaml:"priority"` + Dependencies []string `json:"dependencies,omitempty" yaml:"dependencies,omitempty"` // IDs of prerequisite obligations + ISO27001Mapping []string `json:"iso27001_mapping,omitempty" yaml:"iso27001_mapping,omitempty"` + SOC2Mapping []string `json:"soc2_mapping,omitempty" yaml:"soc2_mapping,omitempty"` + AppliesWhen string `json:"applies_when,omitempty" yaml:"applies_when,omitempty"` // Condition expression + + // Implementation guidance + HowToImplement string `json:"how_to_implement,omitempty" yaml:"how_to_implement,omitempty"` + BreakpilotFeature string `json:"breakpilot_feature,omitempty" yaml:"breakpilot_feature,omitempty"` + ExternalResources []string `json:"external_resources,omitempty" yaml:"external_resources,omitempty"` +} + +// ObligationControl represents a required control/measure +type ObligationControl struct { + ID string `json:"id" yaml:"id"` + RegulationID string `json:"regulation_id" yaml:"regulation_id"` + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Category string `json:"category" yaml:"category"` + WhenApplicable string `json:"when_applicable,omitempty" yaml:"when_applicable,omitempty"` + WhatToDo string `json:"what_to_do" yaml:"what_to_do"` + HowToImplement string `json:"how_to_implement,omitempty" yaml:"how_to_implement,omitempty"` + EvidenceNeeded []EvidenceItem `json:"evidence_needed,omitempty" yaml:"evidence_needed,omitempty"` + ISO27001Mapping []string `json:"iso27001_mapping,omitempty" yaml:"iso27001_mapping,omitempty"` + BreakpilotFeature string `json:"breakpilot_feature,omitempty" yaml:"breakpilot_feature,omitempty"` + Priority ObligationPriority `json:"priority" yaml:"priority"` +} + +// IncidentDeadline represents a deadline for incident reporting +type IncidentDeadline struct { + RegulationID string `json:"regulation_id" yaml:"regulation_id"` + Phase string `json:"phase" yaml:"phase"` // e.g., "Erstmeldung", "Zwischenbericht" + Deadline string `json:"deadline" yaml:"deadline"` // e.g., "24 Stunden", "72 Stunden" + Content string `json:"content" yaml:"content"` // What must be reported + Recipient string `json:"recipient" yaml:"recipient"` // e.g., "BSI", "Aufsichtsbehörde" + LegalBasis []LegalReference `json:"legal_basis" yaml:"legal_basis"` + AppliesWhen string `json:"applies_when,omitempty" yaml:"applies_when,omitempty"` +} + +// DecisionTree represents a decision tree for determining applicability +type DecisionTree struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + RootNode *DecisionNode `json:"root_node" yaml:"root_node"` + Metadata map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// DecisionNode represents a node in a decision tree +type DecisionNode struct { + ID string `json:"id" yaml:"id"` + Question string `json:"question,omitempty" yaml:"question,omitempty"` + Condition *ConditionDef `json:"condition,omitempty" yaml:"condition,omitempty"` + YesNode *DecisionNode `json:"yes_node,omitempty" yaml:"yes_node,omitempty"` + NoNode *DecisionNode `json:"no_node,omitempty" yaml:"no_node,omitempty"` + Result string `json:"result,omitempty" yaml:"result,omitempty"` // Terminal node result + Explanation string `json:"explanation,omitempty" yaml:"explanation,omitempty"` +} + +// ============================================================================ +// Output Structures +// ============================================================================ + +// ApplicableRegulation represents a regulation that applies to the organization +type ApplicableRegulation struct { + ID string `json:"id"` // e.g., "nis2" + Name string `json:"name"` // e.g., "NIS2-Richtlinie" + Classification string `json:"classification"` // e.g., "wichtige_einrichtung" + Reason string `json:"reason"` // Why this regulation applies + ObligationCount int `json:"obligation_count"` // Number of derived obligations + ControlCount int `json:"control_count"` // Number of required controls +} + +// SanctionsSummary aggregates sanction risks across all applicable regulations +type SanctionsSummary struct { + MaxFinancialRisk string `json:"max_financial_risk"` // Highest potential fine + PersonalLiabilityRisk bool `json:"personal_liability_risk"` // Any personal liability? + CriminalLiabilityRisk bool `json:"criminal_liability_risk"` // Any criminal liability? + AffectedRegulations []string `json:"affected_regulations"` // Which regulations have sanctions + Summary string `json:"summary"` // Human-readable summary +} + +// ExecutiveSummary provides a C-level overview +type ExecutiveSummary struct { + TotalRegulations int `json:"total_regulations"` + TotalObligations int `json:"total_obligations"` + CriticalObligations int `json:"critical_obligations"` + UpcomingDeadlines int `json:"upcoming_deadlines"` // Deadlines within 30 days + OverdueObligations int `json:"overdue_obligations"` // Past deadline + KeyRisks []string `json:"key_risks"` + RecommendedActions []string `json:"recommended_actions"` + ComplianceScore int `json:"compliance_score"` // 0-100 + NextReviewDate *time.Time `json:"next_review_date,omitempty"` +} + +// ManagementObligationsOverview is the main output structure for C-Level +type ManagementObligationsOverview struct { + // Metadata + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + OrganizationName string `json:"organization_name"` + AssessmentID string `json:"assessment_id,omitempty"` + AssessmentDate time.Time `json:"assessment_date"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Input facts summary + FactsSummary map[string]interface{} `json:"facts_summary,omitempty"` + + // Which regulations apply + ApplicableRegulations []ApplicableRegulation `json:"applicable_regulations"` + + // All derived obligations (aggregated from all regulations) + Obligations []Obligation `json:"obligations"` + + // All required controls + RequiredControls []ObligationControl `json:"required_controls"` + + // Incident reporting deadlines + IncidentDeadlines []IncidentDeadline `json:"incident_deadlines,omitempty"` + + // Aggregated sanction risks + SanctionsSummary SanctionsSummary `json:"sanctions_summary"` + + // Executive summary for C-Level + ExecutiveSummary ExecutiveSummary `json:"executive_summary"` +} + +// ============================================================================ +// API Request/Response Types +// ============================================================================ + +// ObligationsAssessRequest is the API request for assessing obligations +type ObligationsAssessRequest struct { + Facts *UnifiedFacts `json:"facts"` + OrganizationName string `json:"organization_name,omitempty"` +} + +// ObligationsAssessResponse is the API response for obligations assessment +type ObligationsAssessResponse struct { + Overview *ManagementObligationsOverview `json:"overview"` + Warnings []string `json:"warnings,omitempty"` +} + +// ObligationsByRegulationResponse groups obligations by regulation +type ObligationsByRegulationResponse struct { + Regulations map[string][]Obligation `json:"regulations"` // regulation_id -> obligations +} + +// ObligationsByDeadlineResponse sorts obligations by deadline +type ObligationsByDeadlineResponse struct { + Overdue []Obligation `json:"overdue"` + ThisWeek []Obligation `json:"this_week"` + ThisMonth []Obligation `json:"this_month"` + NextQuarter []Obligation `json:"next_quarter"` + Later []Obligation `json:"later"` + NoDeadline []Obligation `json:"no_deadline"` +} + +// ObligationsByResponsibleResponse groups obligations by responsible role +type ObligationsByResponsibleResponse struct { + ByRole map[ResponsibleRole][]Obligation `json:"by_role"` +} + +// AvailableRegulationsResponse lists all available regulation modules +type AvailableRegulationsResponse struct { + Regulations []RegulationInfo `json:"regulations"` +} + +// RegulationInfo provides info about a regulation module +type RegulationInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Country string `json:"country,omitempty"` // e.g., "DE", "EU" + EffectiveDate string `json:"effective_date,omitempty"` +} + +// ExportMemoRequest is the request for exporting a C-Level memo +type ExportMemoRequest struct { + AssessmentID string `json:"assessment_id"` + Format string `json:"format"` // "markdown" or "pdf" + Language string `json:"language,omitempty"` // "de" or "en", default "de" +} + +// ExportMemoResponse contains the exported memo +type ExportMemoResponse struct { + Content string `json:"content"` // Markdown or base64-encoded PDF + ContentType string `json:"content_type"` // "text/markdown" or "application/pdf" + Filename string `json:"filename"` + GeneratedAt time.Time `json:"generated_at"` +} + +// ============================================================================ +// Database Entity for Persistence +// ============================================================================ + +// ObligationsAssessment represents a stored obligations assessment +type ObligationsAssessment struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + OrganizationName string `json:"organization_name"` + Facts *UnifiedFacts `json:"facts"` + Overview *ManagementObligationsOverview `json:"overview"` + Status string `json:"status"` // "draft", "completed" + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` +} diff --git a/ai-compliance-sdk/internal/ucca/obligations_registry.go b/ai-compliance-sdk/internal/ucca/obligations_registry.go new file mode 100644 index 0000000..bd99de8 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/obligations_registry.go @@ -0,0 +1,480 @@ +package ucca + +import ( + "fmt" + "sort" + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Obligations Registry +// ============================================================================ +// +// The registry manages all regulation modules and provides methods to evaluate +// facts against all registered regulations, aggregating the results. +// +// ============================================================================ + +// ObligationsRegistry manages all regulation modules +type ObligationsRegistry struct { + modules map[string]RegulationModule +} + +// NewObligationsRegistry creates a new registry and registers all default modules +func NewObligationsRegistry() *ObligationsRegistry { + r := &ObligationsRegistry{ + modules: make(map[string]RegulationModule), + } + + // Register default modules + // NIS2 module + nis2Module, err := NewNIS2Module() + if err != nil { + fmt.Printf("Warning: Could not load NIS2 module: %v\n", err) + } else { + r.Register(nis2Module) + } + + // DSGVO module + dsgvoModule, err := NewDSGVOModule() + if err != nil { + fmt.Printf("Warning: Could not load DSGVO module: %v\n", err) + } else { + r.Register(dsgvoModule) + } + + // AI Act module + aiActModule, err := NewAIActModule() + if err != nil { + fmt.Printf("Warning: Could not load AI Act module: %v\n", err) + } else { + r.Register(aiActModule) + } + + // Future modules will be registered here: + // r.Register(NewDORAModule()) + + return r +} + +// NewObligationsRegistryWithModules creates a registry with specific modules +func NewObligationsRegistryWithModules(modules ...RegulationModule) *ObligationsRegistry { + r := &ObligationsRegistry{ + modules: make(map[string]RegulationModule), + } + for _, m := range modules { + r.Register(m) + } + return r +} + +// Register adds a regulation module to the registry +func (r *ObligationsRegistry) Register(module RegulationModule) { + r.modules[module.ID()] = module +} + +// Unregister removes a regulation module from the registry +func (r *ObligationsRegistry) Unregister(moduleID string) { + delete(r.modules, moduleID) +} + +// GetModule returns a specific module by ID +func (r *ObligationsRegistry) GetModule(moduleID string) (RegulationModule, bool) { + m, ok := r.modules[moduleID] + return m, ok +} + +// ListModules returns info about all registered modules +func (r *ObligationsRegistry) ListModules() []RegulationInfo { + var result []RegulationInfo + for _, m := range r.modules { + result = append(result, RegulationInfo{ + ID: m.ID(), + Name: m.Name(), + Description: m.Description(), + }) + } + // Sort by ID for consistent output + sort.Slice(result, func(i, j int) bool { + return result[i].ID < result[j].ID + }) + return result +} + +// EvaluateAll evaluates all registered modules against the given facts +func (r *ObligationsRegistry) EvaluateAll(tenantID uuid.UUID, facts *UnifiedFacts, orgName string) *ManagementObligationsOverview { + overview := &ManagementObligationsOverview{ + ID: uuid.New(), + TenantID: tenantID, + OrganizationName: orgName, + AssessmentDate: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ApplicableRegulations: []ApplicableRegulation{}, + Obligations: []Obligation{}, + RequiredControls: []ObligationControl{}, + IncidentDeadlines: []IncidentDeadline{}, + } + + // Track aggregated sanctions + var maxFine string + var personalLiability, criminalLiability bool + var affectedRegulations []string + + // Evaluate each module + for _, module := range r.modules { + if module.IsApplicable(facts) { + // Get classification + classification := module.GetClassification(facts) + + // Derive obligations + obligations := module.DeriveObligations(facts) + + // Derive controls + controls := module.DeriveControls(facts) + + // Get incident deadlines + incidentDeadlines := module.GetIncidentDeadlines(facts) + + // Add to applicable regulations + overview.ApplicableRegulations = append(overview.ApplicableRegulations, ApplicableRegulation{ + ID: module.ID(), + Name: module.Name(), + Classification: classification, + Reason: r.getApplicabilityReason(module, facts, classification), + ObligationCount: len(obligations), + ControlCount: len(controls), + }) + + // Aggregate obligations + overview.Obligations = append(overview.Obligations, obligations...) + + // Aggregate controls + overview.RequiredControls = append(overview.RequiredControls, controls...) + + // Aggregate incident deadlines + overview.IncidentDeadlines = append(overview.IncidentDeadlines, incidentDeadlines...) + + // Track sanctions + for _, obl := range obligations { + if obl.Sanctions != nil { + if obl.Sanctions.MaxFine != "" && (maxFine == "" || len(obl.Sanctions.MaxFine) > len(maxFine)) { + maxFine = obl.Sanctions.MaxFine + } + if obl.Sanctions.PersonalLiability { + personalLiability = true + } + if obl.Sanctions.CriminalLiability { + criminalLiability = true + } + if !containsString(affectedRegulations, module.ID()) { + affectedRegulations = append(affectedRegulations, module.ID()) + } + } + } + } + } + + // Sort obligations by priority and deadline + r.sortObligations(overview) + + // Build sanctions summary + overview.SanctionsSummary = r.buildSanctionsSummary(maxFine, personalLiability, criminalLiability, affectedRegulations) + + // Generate executive summary + overview.ExecutiveSummary = r.generateExecutiveSummary(overview) + + return overview +} + +// EvaluateSingle evaluates a single module against the given facts +func (r *ObligationsRegistry) EvaluateSingle(moduleID string, facts *UnifiedFacts) (*ManagementObligationsOverview, error) { + module, ok := r.modules[moduleID] + if !ok { + return nil, fmt.Errorf("module not found: %s", moduleID) + } + + overview := &ManagementObligationsOverview{ + ID: uuid.New(), + AssessmentDate: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ApplicableRegulations: []ApplicableRegulation{}, + Obligations: []Obligation{}, + RequiredControls: []ObligationControl{}, + IncidentDeadlines: []IncidentDeadline{}, + } + + if !module.IsApplicable(facts) { + return overview, nil + } + + classification := module.GetClassification(facts) + obligations := module.DeriveObligations(facts) + controls := module.DeriveControls(facts) + incidentDeadlines := module.GetIncidentDeadlines(facts) + + overview.ApplicableRegulations = append(overview.ApplicableRegulations, ApplicableRegulation{ + ID: module.ID(), + Name: module.Name(), + Classification: classification, + Reason: r.getApplicabilityReason(module, facts, classification), + ObligationCount: len(obligations), + ControlCount: len(controls), + }) + + overview.Obligations = obligations + overview.RequiredControls = controls + overview.IncidentDeadlines = incidentDeadlines + + r.sortObligations(overview) + overview.ExecutiveSummary = r.generateExecutiveSummary(overview) + + return overview, nil +} + +// GetDecisionTree returns the decision tree for a specific module +func (r *ObligationsRegistry) GetDecisionTree(moduleID string) (*DecisionTree, error) { + module, ok := r.modules[moduleID] + if !ok { + return nil, fmt.Errorf("module not found: %s", moduleID) + } + tree := module.GetDecisionTree() + if tree == nil { + return nil, fmt.Errorf("module %s does not have a decision tree", moduleID) + } + return tree, nil +} + +// ============================================================================ +// Helper Methods +// ============================================================================ + +func (r *ObligationsRegistry) getApplicabilityReason(module RegulationModule, facts *UnifiedFacts, classification string) string { + switch module.ID() { + case "nis2": + if classification == string(NIS2EssentialEntity) { + return "Besonders wichtige Einrichtung aufgrund von Sektor und Größe" + } else if classification == string(NIS2ImportantEntity) { + return "Wichtige Einrichtung aufgrund von Sektor und Größe" + } + return "NIS2-Richtlinie anwendbar" + case "dsgvo": + return "Verarbeitung personenbezogener Daten" + case "ai_act": + return "Einsatz von KI-Systemen" + case "dora": + return "Reguliertes Finanzunternehmen" + default: + return "Regulierung anwendbar" + } +} + +func (r *ObligationsRegistry) sortObligations(overview *ManagementObligationsOverview) { + // Sort by priority (critical first), then by deadline + priorityOrder := map[ObligationPriority]int{ + PriorityCritical: 0, + PriorityHigh: 1, + PriorityMedium: 2, + PriorityLow: 3, + } + + sort.Slice(overview.Obligations, func(i, j int) bool { + // First by priority + pi := priorityOrder[overview.Obligations[i].Priority] + pj := priorityOrder[overview.Obligations[j].Priority] + if pi != pj { + return pi < pj + } + + // Then by deadline (earlier first, nil last) + di := overview.Obligations[i].Deadline + dj := overview.Obligations[j].Deadline + + if di == nil && dj == nil { + return false + } + if di == nil { + return false + } + if dj == nil { + return true + } + + // For absolute deadlines, compare dates + if di.Type == DeadlineAbsolute && dj.Type == DeadlineAbsolute { + if di.Date != nil && dj.Date != nil { + return di.Date.Before(*dj.Date) + } + } + + return false + }) +} + +func (r *ObligationsRegistry) buildSanctionsSummary(maxFine string, personal, criminal bool, affected []string) SanctionsSummary { + var summary string + if personal && criminal { + summary = "Hohe Bußgelder möglich. Persönliche Haftung der Geschäftsführung sowie strafrechtliche Konsequenzen bei Verstößen." + } else if personal { + summary = "Hohe Bußgelder möglich. Persönliche Haftung der Geschäftsführung bei Verstößen." + } else if maxFine != "" { + summary = fmt.Sprintf("Bußgelder bis zu %s bei Verstößen möglich.", maxFine) + } else { + summary = "Keine spezifischen Sanktionen identifiziert." + } + + return SanctionsSummary{ + MaxFinancialRisk: maxFine, + PersonalLiabilityRisk: personal, + CriminalLiabilityRisk: criminal, + AffectedRegulations: affected, + Summary: summary, + } +} + +func (r *ObligationsRegistry) generateExecutiveSummary(overview *ManagementObligationsOverview) ExecutiveSummary { + summary := ExecutiveSummary{ + TotalRegulations: len(overview.ApplicableRegulations), + TotalObligations: len(overview.Obligations), + CriticalObligations: 0, + UpcomingDeadlines: 0, + OverdueObligations: 0, + KeyRisks: []string{}, + RecommendedActions: []string{}, + ComplianceScore: 100, // Start at 100, deduct for gaps + } + + now := time.Now() + thirtyDaysFromNow := now.AddDate(0, 0, 30) + + for _, obl := range overview.Obligations { + // Count critical + if obl.Priority == PriorityCritical { + summary.CriticalObligations++ + summary.ComplianceScore -= 10 + } + + // Count deadlines + if obl.Deadline != nil && obl.Deadline.Type == DeadlineAbsolute && obl.Deadline.Date != nil { + if obl.Deadline.Date.Before(now) { + summary.OverdueObligations++ + summary.ComplianceScore -= 15 + } else if obl.Deadline.Date.Before(thirtyDaysFromNow) { + summary.UpcomingDeadlines++ + } + } + } + + // Ensure score doesn't go below 0 + if summary.ComplianceScore < 0 { + summary.ComplianceScore = 0 + } + + // Add key risks + if summary.CriticalObligations > 0 { + summary.KeyRisks = append(summary.KeyRisks, fmt.Sprintf("%d kritische Pflichten erfordern sofortige Aufmerksamkeit", summary.CriticalObligations)) + } + if summary.OverdueObligations > 0 { + summary.KeyRisks = append(summary.KeyRisks, fmt.Sprintf("%d Pflichten haben überfällige Fristen", summary.OverdueObligations)) + } + if overview.SanctionsSummary.PersonalLiabilityRisk { + summary.KeyRisks = append(summary.KeyRisks, "Persönliche Haftungsrisiken für die Geschäftsführung bestehen") + } + + // Add recommended actions + if summary.OverdueObligations > 0 { + summary.RecommendedActions = append(summary.RecommendedActions, "Überfällige Pflichten priorisieren und umgehend adressieren") + } + if summary.CriticalObligations > 0 { + summary.RecommendedActions = append(summary.RecommendedActions, "Kritische Pflichten in der nächsten Vorstandssitzung besprechen") + } + if len(overview.IncidentDeadlines) > 0 { + summary.RecommendedActions = append(summary.RecommendedActions, "Incident-Response-Prozesse gemäß Meldefristen etablieren") + } + + // Default action if no specific risks + if len(summary.RecommendedActions) == 0 { + summary.RecommendedActions = append(summary.RecommendedActions, "Regelmäßige Compliance-Reviews durchführen") + } + + // Set next review date (3 months from now) + nextReview := now.AddDate(0, 3, 0) + summary.NextReviewDate = &nextReview + + return summary +} + +// containsString checks if a slice contains a string +func containsString(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +// ============================================================================ +// Grouping Methods +// ============================================================================ + +// GroupByRegulation groups obligations by their regulation ID +func (r *ObligationsRegistry) GroupByRegulation(obligations []Obligation) map[string][]Obligation { + result := make(map[string][]Obligation) + for _, obl := range obligations { + result[obl.RegulationID] = append(result[obl.RegulationID], obl) + } + return result +} + +// GroupByDeadline groups obligations by deadline timeframe +func (r *ObligationsRegistry) GroupByDeadline(obligations []Obligation) ObligationsByDeadlineResponse { + result := ObligationsByDeadlineResponse{ + Overdue: []Obligation{}, + ThisWeek: []Obligation{}, + ThisMonth: []Obligation{}, + NextQuarter: []Obligation{}, + Later: []Obligation{}, + NoDeadline: []Obligation{}, + } + + now := time.Now() + oneWeek := now.AddDate(0, 0, 7) + oneMonth := now.AddDate(0, 1, 0) + threeMonths := now.AddDate(0, 3, 0) + + for _, obl := range obligations { + if obl.Deadline == nil || obl.Deadline.Type != DeadlineAbsolute || obl.Deadline.Date == nil { + result.NoDeadline = append(result.NoDeadline, obl) + continue + } + + deadline := *obl.Deadline.Date + switch { + case deadline.Before(now): + result.Overdue = append(result.Overdue, obl) + case deadline.Before(oneWeek): + result.ThisWeek = append(result.ThisWeek, obl) + case deadline.Before(oneMonth): + result.ThisMonth = append(result.ThisMonth, obl) + case deadline.Before(threeMonths): + result.NextQuarter = append(result.NextQuarter, obl) + default: + result.Later = append(result.Later, obl) + } + } + + return result +} + +// GroupByResponsible groups obligations by responsible role +func (r *ObligationsRegistry) GroupByResponsible(obligations []Obligation) map[ResponsibleRole][]Obligation { + result := make(map[ResponsibleRole][]Obligation) + for _, obl := range obligations { + result[obl.Responsible] = append(result[obl.Responsible], obl) + } + return result +} diff --git a/ai-compliance-sdk/internal/ucca/obligations_store.go b/ai-compliance-sdk/internal/ucca/obligations_store.go new file mode 100644 index 0000000..3e758ed --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/obligations_store.go @@ -0,0 +1,216 @@ +package ucca + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ObligationsStore handles persistence of obligations assessments +type ObligationsStore struct { + pool *pgxpool.Pool +} + +// NewObligationsStore creates a new ObligationsStore +func NewObligationsStore(pool *pgxpool.Pool) *ObligationsStore { + return &ObligationsStore{pool: pool} +} + +// CreateAssessment persists a new obligations assessment +func (s *ObligationsStore) CreateAssessment(ctx context.Context, a *ObligationsAssessment) error { + if a.ID == uuid.Nil { + a.ID = uuid.New() + } + a.CreatedAt = time.Now().UTC() + a.UpdatedAt = a.CreatedAt + + // Marshal JSON fields + factsJSON, err := json.Marshal(a.Facts) + if err != nil { + return fmt.Errorf("failed to marshal facts: %w", err) + } + + overviewJSON, err := json.Marshal(a.Overview) + if err != nil { + return fmt.Errorf("failed to marshal overview: %w", err) + } + + _, err = s.pool.Exec(ctx, ` + INSERT INTO obligations_assessments ( + id, tenant_id, organization_name, facts, overview, + status, created_at, updated_at, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, + a.ID, a.TenantID, a.OrganizationName, factsJSON, overviewJSON, + a.Status, a.CreatedAt, a.UpdatedAt, a.CreatedBy, + ) + + if err != nil { + return fmt.Errorf("failed to insert assessment: %w", err) + } + + return nil +} + +// GetAssessment retrieves an assessment by ID +func (s *ObligationsStore) GetAssessment(ctx context.Context, tenantID, assessmentID uuid.UUID) (*ObligationsAssessment, error) { + var a ObligationsAssessment + var factsJSON, overviewJSON []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, tenant_id, organization_name, facts, overview, + status, created_at, updated_at, created_by + FROM obligations_assessments + WHERE id = $1 AND tenant_id = $2 + `, assessmentID, tenantID).Scan( + &a.ID, &a.TenantID, &a.OrganizationName, &factsJSON, &overviewJSON, + &a.Status, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy, + ) + + if err != nil { + return nil, fmt.Errorf("assessment not found: %w", err) + } + + // Unmarshal JSON fields + if err := json.Unmarshal(factsJSON, &a.Facts); err != nil { + return nil, fmt.Errorf("failed to unmarshal facts: %w", err) + } + if err := json.Unmarshal(overviewJSON, &a.Overview); err != nil { + return nil, fmt.Errorf("failed to unmarshal overview: %w", err) + } + + return &a, nil +} + +// ListAssessments returns all assessments for a tenant +func (s *ObligationsStore) ListAssessments(ctx context.Context, tenantID uuid.UUID, limit, offset int) ([]*ObligationsAssessment, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, organization_name, facts, overview, + status, created_at, updated_at, created_by + FROM obligations_assessments + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + `, tenantID, limit, offset) + + if err != nil { + return nil, fmt.Errorf("failed to query assessments: %w", err) + } + defer rows.Close() + + var assessments []*ObligationsAssessment + for rows.Next() { + var a ObligationsAssessment + var factsJSON, overviewJSON []byte + + if err := rows.Scan( + &a.ID, &a.TenantID, &a.OrganizationName, &factsJSON, &overviewJSON, + &a.Status, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy, + ); err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + if err := json.Unmarshal(factsJSON, &a.Facts); err != nil { + return nil, fmt.Errorf("failed to unmarshal facts: %w", err) + } + if err := json.Unmarshal(overviewJSON, &a.Overview); err != nil { + return nil, fmt.Errorf("failed to unmarshal overview: %w", err) + } + + assessments = append(assessments, &a) + } + + return assessments, nil +} + +// UpdateAssessment updates an existing assessment +func (s *ObligationsStore) UpdateAssessment(ctx context.Context, a *ObligationsAssessment) error { + a.UpdatedAt = time.Now().UTC() + + factsJSON, err := json.Marshal(a.Facts) + if err != nil { + return fmt.Errorf("failed to marshal facts: %w", err) + } + + overviewJSON, err := json.Marshal(a.Overview) + if err != nil { + return fmt.Errorf("failed to marshal overview: %w", err) + } + + result, err := s.pool.Exec(ctx, ` + UPDATE obligations_assessments + SET organization_name = $3, facts = $4, overview = $5, + status = $6, updated_at = $7 + WHERE id = $1 AND tenant_id = $2 + `, + a.ID, a.TenantID, a.OrganizationName, factsJSON, overviewJSON, + a.Status, a.UpdatedAt, + ) + + if err != nil { + return fmt.Errorf("failed to update assessment: %w", err) + } + + if result.RowsAffected() == 0 { + return fmt.Errorf("assessment not found") + } + + return nil +} + +// DeleteAssessment deletes an assessment +func (s *ObligationsStore) DeleteAssessment(ctx context.Context, tenantID, assessmentID uuid.UUID) error { + result, err := s.pool.Exec(ctx, ` + DELETE FROM obligations_assessments + WHERE id = $1 AND tenant_id = $2 + `, assessmentID, tenantID) + + if err != nil { + return fmt.Errorf("failed to delete assessment: %w", err) + } + + if result.RowsAffected() == 0 { + return fmt.Errorf("assessment not found") + } + + return nil +} + +// GetMigrationSQL returns the SQL to create the required table +func (s *ObligationsStore) GetMigrationSQL() string { + return ` +-- Obligations Assessments Table +CREATE TABLE IF NOT EXISTS obligations_assessments ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + organization_name VARCHAR(255), + facts JSONB NOT NULL, + overview JSONB NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'completed', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + created_by UUID, + + CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_obligations_assessments_tenant ON obligations_assessments(tenant_id); +CREATE INDEX IF NOT EXISTS idx_obligations_assessments_created ON obligations_assessments(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_obligations_assessments_status ON obligations_assessments(status); + +-- GIN index for JSON queries +CREATE INDEX IF NOT EXISTS idx_obligations_assessments_overview ON obligations_assessments USING GIN (overview); +` +} diff --git a/ai-compliance-sdk/internal/ucca/patterns.go b/ai-compliance-sdk/internal/ucca/patterns.go new file mode 100644 index 0000000..1dea54d --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/patterns.go @@ -0,0 +1,279 @@ +package ucca + +// ============================================================================ +// Architecture Patterns - Hardcoded recommendations +// ============================================================================ + +// Pattern represents an architecture pattern recommendation +type Pattern struct { + ID string `json:"id"` + Title string `json:"title"` + TitleDE string `json:"title_de"` + Description string `json:"description"` + DescriptionDE string `json:"description_de"` + Benefits []string `json:"benefits"` + Requirements []string `json:"requirements"` + ApplicableWhen func(intake *UseCaseIntake) bool `json:"-"` +} + +// PatternLibrary contains all available architecture patterns +var PatternLibrary = []Pattern{ + { + ID: "P-RAG-ONLY", + Title: "RAG-Only Architecture", + TitleDE: "Nur-RAG-Architektur", + Description: "Use Retrieval-Augmented Generation without storing or training on personal data. The LLM only receives anonymized context from a vector database, never raw personal data.", + DescriptionDE: "Nutzen Sie Retrieval-Augmented Generation ohne Speicherung oder Training mit personenbezogenen Daten. Das LLM erhält nur anonymisierten Kontext aus einer Vektor-Datenbank, niemals Rohdaten.", + Benefits: []string{ + "No personal data in model weights", + "Easy to delete/update information", + "Audit trail for all retrievals", + "Minimal GDPR compliance burden", + }, + Requirements: []string{ + "Vector database with access controls", + "Pre-anonymization pipeline", + "Query logging for audit", + }, + ApplicableWhen: func(intake *UseCaseIntake) bool { + // Applicable when using RAG and dealing with personal data + return intake.ModelUsage.RAG && (intake.DataTypes.PersonalData || intake.DataTypes.CustomerData) + }, + }, + { + ID: "P-PRE-ANON", + Title: "Pre-Anonymization Pipeline", + TitleDE: "Vor-Anonymisierungs-Pipeline", + Description: "Implement mandatory anonymization/pseudonymization before any data reaches the AI system. Personal identifiers are replaced with tokens that can be resolved only by authorized systems.", + DescriptionDE: "Implementieren Sie eine verpflichtende Anonymisierung/Pseudonymisierung bevor Daten das KI-System erreichen. Persönliche Identifikatoren werden durch Tokens ersetzt, die nur von autorisierten Systemen aufgelöst werden können.", + Benefits: []string{ + "Personal data never reaches AI", + "Reversible for authorized access", + "Compliant with Art. 32 GDPR", + "Reduces data breach impact", + }, + Requirements: []string{ + "PII detection system", + "Token mapping database (secured)", + "Re-identification access controls", + "Audit logging for re-identification", + }, + ApplicableWhen: func(intake *UseCaseIntake) bool { + // Applicable when handling personal data and not public data only + return intake.DataTypes.PersonalData && !intake.DataTypes.PublicData + }, + }, + { + ID: "P-NAMESPACE-ISOLATION", + Title: "Namespace Isolation", + TitleDE: "Namespace-Isolation", + Description: "Strict separation of data and models per tenant/department. Each namespace has its own vector store, model context, and access controls. No data leakage between tenants.", + DescriptionDE: "Strikte Trennung von Daten und Modellen pro Mandant/Abteilung. Jeder Namespace hat eigenen Vektor-Speicher, Modellkontext und Zugriffskontrollen. Keine Datenlecks zwischen Mandanten.", + Benefits: []string{ + "Multi-tenant compliance", + "Clear data ownership", + "Easy audit per namespace", + "Deletion per namespace", + }, + Requirements: []string{ + "Multi-tenant architecture", + "Namespace-aware APIs", + "Access control per namespace", + "Separate encryption keys", + }, + ApplicableWhen: func(intake *UseCaseIntake) bool { + // Applicable for multi-tenant scenarios or when handling different data types + return intake.DataTypes.CustomerData || intake.DataTypes.EmployeeData + }, + }, + { + ID: "P-HITL-ENFORCED", + Title: "Human-in-the-Loop Enforcement", + TitleDE: "Verpflichtende menschliche Kontrolle", + Description: "Mandatory human review before any AI output affects individuals. The AI provides suggestions, but final decisions require human approval. Audit trail for all decisions.", + DescriptionDE: "Verpflichtende menschliche Überprüfung bevor KI-Ausgaben Personen betreffen. Die KI liefert Vorschläge, aber finale Entscheidungen erfordern menschliche Genehmigung. Prüfpfad für alle Entscheidungen.", + Benefits: []string{ + "Art. 22 GDPR compliance", + "Accountability clear", + "Error correction possible", + "Human judgment preserved", + }, + Requirements: []string{ + "Review workflow system", + "Decision audit logging", + "Clear escalation paths", + "Training for reviewers", + }, + ApplicableWhen: func(intake *UseCaseIntake) bool { + // Applicable when outputs have legal effects or involve scoring/decisions + return intake.Outputs.LegalEffects || + intake.Outputs.RankingsOrScores || + intake.Outputs.AccessDecisions || + intake.Purpose.EvaluationScoring || + intake.Purpose.DecisionMaking + }, + }, + { + ID: "P-LOG-MINIMIZATION", + Title: "Log Minimization", + TitleDE: "Log-Minimierung", + Description: "Minimize logging of personal data in AI interactions. Store only anonymized metrics, not raw prompts/responses. Implement automatic log rotation and deletion.", + DescriptionDE: "Minimieren Sie die Protokollierung personenbezogener Daten in KI-Interaktionen. Speichern Sie nur anonymisierte Metriken, keine Roh-Prompts/Antworten. Implementieren Sie automatische Log-Rotation und Löschung.", + Benefits: []string{ + "Data minimization (Art. 5c GDPR)", + "Reduced storage costs", + "Simplified deletion requests", + "Lower breach impact", + }, + Requirements: []string{ + "Log anonymization pipeline", + "Automatic log rotation", + "Metrics without PII", + "Retention policy enforcement", + }, + ApplicableWhen: func(intake *UseCaseIntake) bool { + // Applicable when storing prompts/responses + return intake.Retention.StorePrompts || intake.Retention.StoreResponses + }, + }, +} + +// GetApplicablePatterns returns patterns applicable for the given intake +func GetApplicablePatterns(intake *UseCaseIntake) []Pattern { + var applicable []Pattern + for _, p := range PatternLibrary { + if p.ApplicableWhen(intake) { + applicable = append(applicable, p) + } + } + return applicable +} + +// GetPatternByID returns a pattern by its ID +func GetPatternByID(id string) *Pattern { + for _, p := range PatternLibrary { + if p.ID == id { + return &p + } + } + return nil +} + +// GetAllPatterns returns all available patterns +func GetAllPatterns() []Pattern { + return PatternLibrary +} + +// PatternToRecommendation converts a Pattern to a PatternRecommendation +func PatternToRecommendation(p Pattern, rationale string, priority int) PatternRecommendation { + return PatternRecommendation{ + PatternID: p.ID, + Title: p.TitleDE, + Description: p.DescriptionDE, + Rationale: rationale, + Priority: priority, + } +} + +// ============================================================================ +// Forbidden Pattern Definitions +// ============================================================================ + +// ForbiddenPatternDef defines patterns that should NOT be used +type ForbiddenPatternDef struct { + ID string + Title string + TitleDE string + Description string + DescriptionDE string + Reason string + ReasonDE string + GDPRRef string + ForbiddenWhen func(intake *UseCaseIntake) bool +} + +// ForbiddenPatternLibrary contains all forbidden pattern definitions +var ForbiddenPatternLibrary = []ForbiddenPatternDef{ + { + ID: "FP-DIRECT-PII-TRAINING", + Title: "Direct PII Training", + TitleDE: "Direktes Training mit personenbezogenen Daten", + Description: "Training AI models directly on personal data without proper legal basis or consent.", + DescriptionDE: "Training von KI-Modellen direkt mit personenbezogenen Daten ohne ausreichende Rechtsgrundlage oder Einwilligung.", + Reason: "Violates GDPR purpose limitation and data minimization principles.", + ReasonDE: "Verstößt gegen DSGVO-Grundsätze der Zweckbindung und Datenminimierung.", + GDPRRef: "Art. 5(1)(b)(c) DSGVO", + ForbiddenWhen: func(intake *UseCaseIntake) bool { + return intake.ModelUsage.Training && intake.DataTypes.PersonalData + }, + }, + { + ID: "FP-ART9-WITHOUT-CONSENT", + Title: "Special Category Data Without Explicit Consent", + TitleDE: "Besondere Kategorien ohne ausdrückliche Einwilligung", + Description: "Processing special category data (health, religion, etc.) without explicit consent or legal basis.", + DescriptionDE: "Verarbeitung besonderer Datenkategorien (Gesundheit, Religion, etc.) ohne ausdrückliche Einwilligung oder Rechtsgrundlage.", + Reason: "Article 9 data requires explicit consent or specific legal basis.", + ReasonDE: "Art. 9-Daten erfordern ausdrückliche Einwilligung oder spezifische Rechtsgrundlage.", + GDPRRef: "Art. 9 DSGVO", + ForbiddenWhen: func(intake *UseCaseIntake) bool { + return intake.DataTypes.Article9Data + }, + }, + { + ID: "FP-AUTOMATED-LEGAL-DECISION", + Title: "Fully Automated Legal Decisions", + TitleDE: "Vollautomatisierte rechtliche Entscheidungen", + Description: "Making fully automated decisions with legal effects without human oversight.", + DescriptionDE: "Treffen vollautomatisierter Entscheidungen mit rechtlichen Auswirkungen ohne menschliche Aufsicht.", + Reason: "Article 22 GDPR restricts automated individual decision-making.", + ReasonDE: "Art. 22 DSGVO beschränkt automatisierte Einzelentscheidungen.", + GDPRRef: "Art. 22 DSGVO", + ForbiddenWhen: func(intake *UseCaseIntake) bool { + return intake.Automation == AutomationFullyAutomated && intake.Outputs.LegalEffects + }, + }, + { + ID: "FP-MINOR-PROFILING", + Title: "Profiling of Minors", + TitleDE: "Profiling von Minderjährigen", + Description: "Creating profiles or scores for children/minors.", + DescriptionDE: "Erstellen von Profilen oder Scores für Kinder/Minderjährige.", + Reason: "Special protection for minors under GDPR.", + ReasonDE: "Besonderer Schutz für Minderjährige unter der DSGVO.", + GDPRRef: "Art. 8, Erwägungsgrund 38 DSGVO", + ForbiddenWhen: func(intake *UseCaseIntake) bool { + return intake.DataTypes.MinorData && (intake.Purpose.Profiling || intake.Purpose.EvaluationScoring) + }, + }, + { + ID: "FP-THIRD-COUNTRY-PII", + Title: "PII Transfer to Third Countries", + TitleDE: "Übermittlung personenbezogener Daten in Drittländer", + Description: "Transferring personal data to third countries without adequate safeguards.", + DescriptionDE: "Übermittlung personenbezogener Daten in Drittländer ohne angemessene Schutzmaßnahmen.", + Reason: "Requires adequacy decision or appropriate safeguards.", + ReasonDE: "Erfordert Angemessenheitsbeschluss oder geeignete Garantien.", + GDPRRef: "Art. 44-49 DSGVO", + ForbiddenWhen: func(intake *UseCaseIntake) bool { + return intake.Hosting.Region == "third_country" && intake.DataTypes.PersonalData + }, + }, +} + +// GetForbiddenPatterns returns forbidden patterns for the given intake +func GetForbiddenPatterns(intake *UseCaseIntake) []ForbiddenPattern { + var forbidden []ForbiddenPattern + for _, fp := range ForbiddenPatternLibrary { + if fp.ForbiddenWhen(intake) { + forbidden = append(forbidden, ForbiddenPattern{ + PatternID: fp.ID, + Title: fp.TitleDE, + Description: fp.DescriptionDE, + Reason: fp.ReasonDE, + GDPRRef: fp.GDPRRef, + }) + } + } + return forbidden +} diff --git a/ai-compliance-sdk/internal/ucca/pdf_export.go b/ai-compliance-sdk/internal/ucca/pdf_export.go new file mode 100644 index 0000000..40f6887 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/pdf_export.go @@ -0,0 +1,510 @@ +package ucca + +import ( + "bytes" + "encoding/base64" + "fmt" + "time" + + "github.com/jung-kurt/gofpdf" +) + +// PDFExporter generates PDF documents from obligations assessments +type PDFExporter struct { + language string +} + +// NewPDFExporter creates a new PDF exporter +func NewPDFExporter(language string) *PDFExporter { + if language == "" { + language = "de" + } + return &PDFExporter{language: language} +} + +// ExportManagementMemo exports the management obligations overview as a PDF +func (e *PDFExporter) ExportManagementMemo(overview *ManagementObligationsOverview) (*ExportMemoResponse, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + + // Set UTF-8 support with DejaVu font (fallback to core fonts) + pdf.SetFont("Helvetica", "", 12) + + // Add first page + pdf.AddPage() + + // Add title + e.addTitle(pdf, overview) + + // Add executive summary + e.addExecutiveSummary(pdf, overview) + + // Add applicable regulations + e.addApplicableRegulations(pdf, overview) + + // Add sanctions summary + e.addSanctionsSummary(pdf, overview) + + // Add obligations table + e.addObligationsTable(pdf, overview) + + // Add incident deadlines if present + if len(overview.IncidentDeadlines) > 0 { + e.addIncidentDeadlines(pdf, overview) + } + + // Add footer with generation date + e.addFooter(pdf, overview) + + // Generate PDF bytes + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, fmt.Errorf("failed to generate PDF: %w", err) + } + + // Encode as base64 + content := base64.StdEncoding.EncodeToString(buf.Bytes()) + + return &ExportMemoResponse{ + Content: content, + ContentType: "application/pdf", + Filename: fmt.Sprintf("pflichten-uebersicht-%s.pdf", time.Now().Format("2006-01-02")), + GeneratedAt: time.Now(), + }, nil +} + +// addTitle adds the document title +func (e *PDFExporter) addTitle(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) { + pdf.SetFont("Helvetica", "B", 24) + pdf.SetTextColor(0, 0, 0) + + title := "Regulatorische Pflichten-Uebersicht" + if e.language == "en" { + title = "Regulatory Obligations Overview" + } + pdf.CellFormat(0, 15, title, "", 1, "C", false, 0, "") + + // Organization name + if overview.OrganizationName != "" { + pdf.SetFont("Helvetica", "", 14) + pdf.CellFormat(0, 10, overview.OrganizationName, "", 1, "C", false, 0, "") + } + + // Date + pdf.SetFont("Helvetica", "I", 10) + dateStr := overview.AssessmentDate.Format("02.01.2006") + pdf.CellFormat(0, 8, fmt.Sprintf("Stand: %s", dateStr), "", 1, "C", false, 0, "") + + pdf.Ln(10) +} + +// addExecutiveSummary adds the executive summary section +func (e *PDFExporter) addExecutiveSummary(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) { + e.addSectionHeader(pdf, "Executive Summary") + + summary := overview.ExecutiveSummary + + // Stats table + pdf.SetFont("Helvetica", "", 11) + + stats := []struct { + label string + value string + }{ + {"Anwendbare Regulierungen", fmt.Sprintf("%d", summary.TotalRegulations)}, + {"Gesamtzahl Pflichten", fmt.Sprintf("%d", summary.TotalObligations)}, + {"Kritische Pflichten", fmt.Sprintf("%d", summary.CriticalObligations)}, + {"Kommende Fristen (30 Tage)", fmt.Sprintf("%d", summary.UpcomingDeadlines)}, + {"Compliance Score", fmt.Sprintf("%d%%", summary.ComplianceScore)}, + } + + for _, stat := range stats { + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(80, 7, stat.label+":", "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 11) + pdf.CellFormat(0, 7, stat.value, "", 1, "L", false, 0, "") + } + + // Key risks + if len(summary.KeyRisks) > 0 { + pdf.Ln(5) + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(0, 7, "Wesentliche Risiken:", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 10) + for _, risk := range summary.KeyRisks { + pdf.CellFormat(10, 6, "", "", 0, "L", false, 0, "") + pdf.CellFormat(0, 6, "- "+risk, "", 1, "L", false, 0, "") + } + } + + // Recommended actions + if len(summary.RecommendedActions) > 0 { + pdf.Ln(5) + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(0, 7, "Empfohlene Massnahmen:", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 10) + for _, action := range summary.RecommendedActions { + pdf.CellFormat(10, 6, "", "", 0, "L", false, 0, "") + pdf.CellFormat(0, 6, "- "+action, "", 1, "L", false, 0, "") + } + } + + pdf.Ln(10) +} + +// addApplicableRegulations adds the applicable regulations section +func (e *PDFExporter) addApplicableRegulations(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) { + e.addSectionHeader(pdf, "Anwendbare Regulierungen") + + pdf.SetFont("Helvetica", "", 10) + + // Table header + pdf.SetFillColor(240, 240, 240) + pdf.SetFont("Helvetica", "B", 10) + pdf.CellFormat(60, 8, "Regulierung", "1", 0, "L", true, 0, "") + pdf.CellFormat(50, 8, "Klassifizierung", "1", 0, "L", true, 0, "") + pdf.CellFormat(30, 8, "Pflichten", "1", 0, "C", true, 0, "") + pdf.CellFormat(50, 8, "Grund", "1", 1, "L", true, 0, "") + + pdf.SetFont("Helvetica", "", 10) + for _, reg := range overview.ApplicableRegulations { + pdf.CellFormat(60, 7, reg.Name, "1", 0, "L", false, 0, "") + pdf.CellFormat(50, 7, truncateString(reg.Classification, 25), "1", 0, "L", false, 0, "") + pdf.CellFormat(30, 7, fmt.Sprintf("%d", reg.ObligationCount), "1", 0, "C", false, 0, "") + pdf.CellFormat(50, 7, truncateString(reg.Reason, 25), "1", 1, "L", false, 0, "") + } + + pdf.Ln(10) +} + +// addSanctionsSummary adds the sanctions summary section +func (e *PDFExporter) addSanctionsSummary(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) { + e.addSectionHeader(pdf, "Sanktionsrisiken") + + sanctions := overview.SanctionsSummary + + pdf.SetFont("Helvetica", "", 11) + + // Max financial risk + if sanctions.MaxFinancialRisk != "" { + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(60, 7, "Max. Finanzrisiko:", "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 11) + pdf.SetTextColor(200, 0, 0) + pdf.CellFormat(0, 7, sanctions.MaxFinancialRisk, "", 1, "L", false, 0, "") + pdf.SetTextColor(0, 0, 0) + } + + // Personal liability + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(60, 7, "Persoenliche Haftung:", "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 11) + liabilityText := "Nein" + if sanctions.PersonalLiabilityRisk { + liabilityText = "Ja - Geschaeftsfuehrung kann persoenlich haften" + pdf.SetTextColor(200, 0, 0) + } + pdf.CellFormat(0, 7, liabilityText, "", 1, "L", false, 0, "") + pdf.SetTextColor(0, 0, 0) + + // Criminal liability + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(60, 7, "Strafrechtliche Konsequenzen:", "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 11) + criminalText := "Nein" + if sanctions.CriminalLiabilityRisk { + criminalText = "Moeglich" + pdf.SetTextColor(200, 0, 0) + } + pdf.CellFormat(0, 7, criminalText, "", 1, "L", false, 0, "") + pdf.SetTextColor(0, 0, 0) + + // Summary + if sanctions.Summary != "" { + pdf.Ln(3) + pdf.SetFont("Helvetica", "I", 10) + pdf.MultiCell(0, 5, sanctions.Summary, "", "L", false) + } + + pdf.Ln(10) +} + +// addObligationsTable adds the obligations table +func (e *PDFExporter) addObligationsTable(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) { + e.addSectionHeader(pdf, "Pflichten-Uebersicht") + + // Group by priority + criticalObls := []Obligation{} + highObls := []Obligation{} + otherObls := []Obligation{} + + for _, obl := range overview.Obligations { + switch obl.Priority { + case PriorityCritical, ObligationPriority("kritisch"): + criticalObls = append(criticalObls, obl) + case PriorityHigh, ObligationPriority("hoch"): + highObls = append(highObls, obl) + default: + otherObls = append(otherObls, obl) + } + } + + // Critical obligations + if len(criticalObls) > 0 { + pdf.SetFont("Helvetica", "B", 11) + pdf.SetTextColor(200, 0, 0) + pdf.CellFormat(0, 8, fmt.Sprintf("Kritische Pflichten (%d)", len(criticalObls)), "", 1, "L", false, 0, "") + pdf.SetTextColor(0, 0, 0) + e.addObligationsList(pdf, criticalObls) + } + + // High priority obligations + if len(highObls) > 0 { + pdf.SetFont("Helvetica", "B", 11) + pdf.SetTextColor(200, 100, 0) + pdf.CellFormat(0, 8, fmt.Sprintf("Hohe Prioritaet (%d)", len(highObls)), "", 1, "L", false, 0, "") + pdf.SetTextColor(0, 0, 0) + e.addObligationsList(pdf, highObls) + } + + // Other obligations + if len(otherObls) > 0 { + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(0, 8, fmt.Sprintf("Weitere Pflichten (%d)", len(otherObls)), "", 1, "L", false, 0, "") + e.addObligationsList(pdf, otherObls) + } +} + +// addObligationsList adds a list of obligations +func (e *PDFExporter) addObligationsList(pdf *gofpdf.Fpdf, obligations []Obligation) { + // Check if we need a new page + if pdf.GetY() > 250 { + pdf.AddPage() + } + + pdf.SetFont("Helvetica", "", 9) + + for _, obl := range obligations { + // Check if we need a new page + if pdf.GetY() > 270 { + pdf.AddPage() + } + + // Obligation ID and title + pdf.SetFont("Helvetica", "B", 9) + pdf.CellFormat(25, 5, obl.ID, "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 9) + pdf.CellFormat(0, 5, truncateString(obl.Title, 80), "", 1, "L", false, 0, "") + + // Legal basis + if len(obl.LegalBasis) > 0 { + pdf.SetFont("Helvetica", "I", 8) + pdf.CellFormat(25, 4, "", "", 0, "L", false, 0, "") + legalText := "" + for i, lb := range obl.LegalBasis { + if i > 0 { + legalText += ", " + } + legalText += lb.Norm + } + pdf.CellFormat(0, 4, truncateString(legalText, 100), "", 1, "L", false, 0, "") + } + + // Deadline + if obl.Deadline != nil { + pdf.SetFont("Helvetica", "", 8) + pdf.CellFormat(25, 4, "", "", 0, "L", false, 0, "") + deadlineText := "Frist: " + if obl.Deadline.Date != nil { + deadlineText += obl.Deadline.Date.Format("02.01.2006") + } else if obl.Deadline.Duration != "" { + deadlineText += obl.Deadline.Duration + } else if obl.Deadline.Interval != "" { + deadlineText += obl.Deadline.Interval + } + pdf.CellFormat(0, 4, deadlineText, "", 1, "L", false, 0, "") + } + + // Responsible + pdf.SetFont("Helvetica", "", 8) + pdf.CellFormat(25, 4, "", "", 0, "L", false, 0, "") + pdf.CellFormat(0, 4, fmt.Sprintf("Verantwortlich: %s", obl.Responsible), "", 1, "L", false, 0, "") + + pdf.Ln(2) + } + + pdf.Ln(5) +} + +// addIncidentDeadlines adds the incident deadlines section +func (e *PDFExporter) addIncidentDeadlines(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) { + // Check if we need a new page + if pdf.GetY() > 220 { + pdf.AddPage() + } + + e.addSectionHeader(pdf, "Meldepflichten bei Vorfaellen") + + pdf.SetFont("Helvetica", "", 10) + + // Group by regulation + byRegulation := make(map[string][]IncidentDeadline) + for _, deadline := range overview.IncidentDeadlines { + byRegulation[deadline.RegulationID] = append(byRegulation[deadline.RegulationID], deadline) + } + + for regID, deadlines := range byRegulation { + pdf.SetFont("Helvetica", "B", 10) + + regName := regID + for _, reg := range overview.ApplicableRegulations { + if reg.ID == regID { + regName = reg.Name + break + } + } + pdf.CellFormat(0, 7, regName, "", 1, "L", false, 0, "") + + pdf.SetFont("Helvetica", "", 9) + for _, dl := range deadlines { + pdf.CellFormat(40, 6, dl.Phase+":", "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "B", 9) + pdf.CellFormat(30, 6, dl.Deadline, "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 9) + pdf.CellFormat(0, 6, "an "+dl.Recipient, "", 1, "L", false, 0, "") + } + pdf.Ln(3) + } + + pdf.Ln(5) +} + +// addFooter adds the document footer +func (e *PDFExporter) addFooter(pdf *gofpdf.Fpdf, overview *ManagementObligationsOverview) { + pdf.SetY(-30) + pdf.SetFont("Helvetica", "I", 8) + pdf.SetTextColor(128, 128, 128) + + pdf.CellFormat(0, 5, fmt.Sprintf("Generiert am %s mit BreakPilot AI Compliance SDK", time.Now().Format("02.01.2006 15:04")), "", 1, "C", false, 0, "") + pdf.CellFormat(0, 5, "Dieses Dokument ersetzt keine rechtliche Beratung.", "", 1, "C", false, 0, "") +} + +// addSectionHeader adds a section header +func (e *PDFExporter) addSectionHeader(pdf *gofpdf.Fpdf, title string) { + pdf.SetFont("Helvetica", "B", 14) + pdf.SetTextColor(50, 50, 50) + pdf.CellFormat(0, 10, title, "", 1, "L", false, 0, "") + pdf.SetTextColor(0, 0, 0) + + // Underline + pdf.SetDrawColor(200, 200, 200) + pdf.Line(10, pdf.GetY(), 200, pdf.GetY()) + pdf.Ln(5) +} + +// truncateString truncates a string to maxLen characters +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// ExportMarkdown exports the overview as Markdown (for compatibility) +func (e *PDFExporter) ExportMarkdown(overview *ManagementObligationsOverview) (*ExportMemoResponse, error) { + var buf bytes.Buffer + + // Title + buf.WriteString(fmt.Sprintf("# Regulatorische Pflichten-Uebersicht\n\n")) + if overview.OrganizationName != "" { + buf.WriteString(fmt.Sprintf("**Organisation:** %s\n\n", overview.OrganizationName)) + } + buf.WriteString(fmt.Sprintf("**Stand:** %s\n\n", overview.AssessmentDate.Format("02.01.2006"))) + + // Executive Summary + buf.WriteString("## Executive Summary\n\n") + summary := overview.ExecutiveSummary + buf.WriteString(fmt.Sprintf("| Metrik | Wert |\n")) + buf.WriteString(fmt.Sprintf("|--------|------|\n")) + buf.WriteString(fmt.Sprintf("| Anwendbare Regulierungen | %d |\n", summary.TotalRegulations)) + buf.WriteString(fmt.Sprintf("| Gesamtzahl Pflichten | %d |\n", summary.TotalObligations)) + buf.WriteString(fmt.Sprintf("| Kritische Pflichten | %d |\n", summary.CriticalObligations)) + buf.WriteString(fmt.Sprintf("| Kommende Fristen (30 Tage) | %d |\n", summary.UpcomingDeadlines)) + buf.WriteString(fmt.Sprintf("| Compliance Score | %d%% |\n\n", summary.ComplianceScore)) + + // Key Risks + if len(summary.KeyRisks) > 0 { + buf.WriteString("### Wesentliche Risiken\n\n") + for _, risk := range summary.KeyRisks { + buf.WriteString(fmt.Sprintf("- %s\n", risk)) + } + buf.WriteString("\n") + } + + // Recommended Actions + if len(summary.RecommendedActions) > 0 { + buf.WriteString("### Empfohlene Massnahmen\n\n") + for _, action := range summary.RecommendedActions { + buf.WriteString(fmt.Sprintf("- %s\n", action)) + } + buf.WriteString("\n") + } + + // Applicable Regulations + buf.WriteString("## Anwendbare Regulierungen\n\n") + buf.WriteString("| Regulierung | Klassifizierung | Pflichten | Grund |\n") + buf.WriteString("|-------------|-----------------|-----------|-------|\n") + for _, reg := range overview.ApplicableRegulations { + buf.WriteString(fmt.Sprintf("| %s | %s | %d | %s |\n", reg.Name, reg.Classification, reg.ObligationCount, reg.Reason)) + } + buf.WriteString("\n") + + // Sanctions Summary + buf.WriteString("## Sanktionsrisiken\n\n") + sanctions := overview.SanctionsSummary + if sanctions.MaxFinancialRisk != "" { + buf.WriteString(fmt.Sprintf("- **Max. Finanzrisiko:** %s\n", sanctions.MaxFinancialRisk)) + } + buf.WriteString(fmt.Sprintf("- **Persoenliche Haftung:** %v\n", sanctions.PersonalLiabilityRisk)) + buf.WriteString(fmt.Sprintf("- **Strafrechtliche Konsequenzen:** %v\n\n", sanctions.CriminalLiabilityRisk)) + if sanctions.Summary != "" { + buf.WriteString(fmt.Sprintf("*%s*\n\n", sanctions.Summary)) + } + + // Obligations + buf.WriteString("## Pflichten-Uebersicht\n\n") + for _, obl := range overview.Obligations { + buf.WriteString(fmt.Sprintf("### %s - %s\n\n", obl.ID, obl.Title)) + buf.WriteString(fmt.Sprintf("**Prioritaet:** %s | **Verantwortlich:** %s\n\n", obl.Priority, obl.Responsible)) + if len(obl.LegalBasis) > 0 { + buf.WriteString("**Rechtsgrundlage:** ") + for i, lb := range obl.LegalBasis { + if i > 0 { + buf.WriteString(", ") + } + buf.WriteString(lb.Norm) + } + buf.WriteString("\n\n") + } + buf.WriteString(fmt.Sprintf("%s\n\n", obl.Description)) + } + + // Incident Deadlines + if len(overview.IncidentDeadlines) > 0 { + buf.WriteString("## Meldepflichten bei Vorfaellen\n\n") + for _, dl := range overview.IncidentDeadlines { + buf.WriteString(fmt.Sprintf("- **%s:** %s an %s\n", dl.Phase, dl.Deadline, dl.Recipient)) + } + buf.WriteString("\n") + } + + // Footer + buf.WriteString("---\n\n") + buf.WriteString(fmt.Sprintf("*Generiert am %s mit BreakPilot AI Compliance SDK*\n", time.Now().Format("02.01.2006 15:04"))) + + return &ExportMemoResponse{ + Content: buf.String(), + ContentType: "text/markdown", + Filename: fmt.Sprintf("pflichten-uebersicht-%s.md", time.Now().Format("2006-01-02")), + GeneratedAt: time.Now(), + }, nil +} diff --git a/ai-compliance-sdk/internal/ucca/pdf_export_test.go b/ai-compliance-sdk/internal/ucca/pdf_export_test.go new file mode 100644 index 0000000..1b2db27 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/pdf_export_test.go @@ -0,0 +1,238 @@ +package ucca + +import ( + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestPDFExporter_Creation(t *testing.T) { + exporter := NewPDFExporter("de") + if exporter == nil { + t.Error("Expected exporter to be created") + } + + // Default language + exporter = NewPDFExporter("") + if exporter.language != "de" { + t.Errorf("Expected default language 'de', got '%s'", exporter.language) + } +} + +func TestPDFExporter_ExportMarkdown(t *testing.T) { + overview := createTestOverview() + exporter := NewPDFExporter("de") + + response, err := exporter.ExportMarkdown(overview) + if err != nil { + t.Fatalf("Failed to export markdown: %v", err) + } + + if response.ContentType != "text/markdown" { + t.Errorf("Expected content type 'text/markdown', got '%s'", response.ContentType) + } + + if !strings.HasSuffix(response.Filename, ".md") { + t.Errorf("Expected filename to end with .md, got '%s'", response.Filename) + } + + // Check content contains expected sections + if !strings.Contains(response.Content, "# Regulatorische Pflichten-Uebersicht") { + t.Error("Expected markdown to contain title") + } + + if !strings.Contains(response.Content, "Executive Summary") { + t.Error("Expected markdown to contain executive summary") + } + + if !strings.Contains(response.Content, "Anwendbare Regulierungen") { + t.Error("Expected markdown to contain regulations section") + } +} + +func TestPDFExporter_ExportPDF(t *testing.T) { + overview := createTestOverview() + exporter := NewPDFExporter("de") + + response, err := exporter.ExportManagementMemo(overview) + if err != nil { + t.Fatalf("Failed to export PDF: %v", err) + } + + if response.ContentType != "application/pdf" { + t.Errorf("Expected content type 'application/pdf', got '%s'", response.ContentType) + } + + if !strings.HasSuffix(response.Filename, ".pdf") { + t.Errorf("Expected filename to end with .pdf, got '%s'", response.Filename) + } + + // Check that content is valid base64 + decoded, err := base64.StdEncoding.DecodeString(response.Content) + if err != nil { + t.Fatalf("Failed to decode base64 content: %v", err) + } + + // Check PDF magic bytes + if len(decoded) < 4 || string(decoded[:4]) != "%PDF" { + t.Error("Expected content to start with PDF magic bytes") + } +} + +func TestPDFExporter_ExportEmptyOverview(t *testing.T) { + overview := &ManagementObligationsOverview{ + ID: uuid.New(), + AssessmentDate: time.Now(), + ApplicableRegulations: []ApplicableRegulation{}, + Obligations: []Obligation{}, + RequiredControls: []ObligationControl{}, + IncidentDeadlines: []IncidentDeadline{}, + ExecutiveSummary: ExecutiveSummary{ + TotalRegulations: 0, + TotalObligations: 0, + ComplianceScore: 100, + KeyRisks: []string{}, + RecommendedActions: []string{}, + }, + SanctionsSummary: SanctionsSummary{ + Summary: "Keine Sanktionen identifiziert.", + }, + } + + exporter := NewPDFExporter("de") + + // Test Markdown + mdResponse, err := exporter.ExportMarkdown(overview) + if err != nil { + t.Fatalf("Failed to export empty overview as markdown: %v", err) + } + if mdResponse.Content == "" { + t.Error("Expected non-empty markdown content") + } + + // Test PDF + pdfResponse, err := exporter.ExportManagementMemo(overview) + if err != nil { + t.Fatalf("Failed to export empty overview as PDF: %v", err) + } + if pdfResponse.Content == "" { + t.Error("Expected non-empty PDF content") + } +} + +func TestPDFExporter_WithIncidentDeadlines(t *testing.T) { + overview := createTestOverview() + overview.IncidentDeadlines = []IncidentDeadline{ + { + RegulationID: "nis2", + Phase: "Erstmeldung", + Deadline: "24 Stunden", + Recipient: "BSI", + Content: "Unverzuegliche Meldung erheblicher Sicherheitsvorfaelle", + }, + { + RegulationID: "dsgvo", + Phase: "Meldung an Aufsichtsbehoerde", + Deadline: "72 Stunden", + Recipient: "Zustaendige Aufsichtsbehoerde", + Content: "Meldung bei Datenschutzverletzung", + }, + } + + exporter := NewPDFExporter("de") + + // Test that incident deadlines are included + mdResponse, err := exporter.ExportMarkdown(overview) + if err != nil { + t.Fatalf("Failed to export: %v", err) + } + + if !strings.Contains(mdResponse.Content, "24 Stunden") { + t.Error("Expected markdown to contain 24 hour deadline") + } + + if !strings.Contains(mdResponse.Content, "72 Stunden") { + t.Error("Expected markdown to contain 72 hour deadline") + } +} + +func createTestOverview() *ManagementObligationsOverview { + deadline := time.Date(2025, 1, 17, 0, 0, 0, 0, time.UTC) + + return &ManagementObligationsOverview{ + ID: uuid.New(), + TenantID: uuid.New(), + OrganizationName: "Test GmbH", + AssessmentDate: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + ApplicableRegulations: []ApplicableRegulation{ + { + ID: "nis2", + Name: "NIS2-Richtlinie", + Classification: "wichtige_einrichtung", + Reason: "Anbieter digitaler Dienste", + ObligationCount: 5, + ControlCount: 3, + }, + { + ID: "dsgvo", + Name: "DSGVO", + Classification: "controller", + Reason: "Verarbeitung personenbezogener Daten", + ObligationCount: 4, + ControlCount: 2, + }, + }, + Obligations: []Obligation{ + { + ID: "NIS2-OBL-001", + RegulationID: "nis2", + Title: "BSI-Registrierung", + Description: "Registrierung beim BSI", + LegalBasis: []LegalReference{{Norm: "§ 33 BSIG-E"}}, + Category: CategoryMeldepflicht, + Responsible: RoleManagement, + Deadline: &Deadline{Type: DeadlineAbsolute, Date: &deadline}, + Sanctions: &SanctionInfo{MaxFine: "500.000 EUR"}, + Priority: PriorityCritical, + }, + { + ID: "DSGVO-OBL-001", + RegulationID: "dsgvo", + Title: "Verarbeitungsverzeichnis", + Description: "Fuehrung eines VVT", + LegalBasis: []LegalReference{{Norm: "Art. 30 DSGVO"}}, + Category: CategoryGovernance, + Responsible: RoleDSB, + Priority: PriorityHigh, + }, + }, + ExecutiveSummary: ExecutiveSummary{ + TotalRegulations: 2, + TotalObligations: 9, + CriticalObligations: 1, + UpcomingDeadlines: 2, + OverdueObligations: 0, + KeyRisks: []string{ + "BSI-Registrierung faellig", + "Persoenliche Haftung moeglich", + }, + RecommendedActions: []string{ + "BSI-Registrierung durchfuehren", + "ISMS aufbauen", + }, + ComplianceScore: 75, + }, + SanctionsSummary: SanctionsSummary{ + MaxFinancialRisk: "10 Mio. EUR oder 2% Jahresumsatz", + PersonalLiabilityRisk: true, + CriminalLiabilityRisk: false, + AffectedRegulations: []string{"nis2", "dsgvo"}, + Summary: "Hohe Bussgelder moeglich. Persoenliche Haftung der Geschaeftsfuehrung bei Verstoessen.", + }, + } +} diff --git a/ai-compliance-sdk/internal/ucca/policy_engine.go b/ai-compliance-sdk/internal/ucca/policy_engine.go new file mode 100644 index 0000000..5fa3947 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/policy_engine.go @@ -0,0 +1,882 @@ +package ucca + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// ============================================================================ +// YAML-based Policy Engine +// ============================================================================ +// +// This engine evaluates use-case intakes against YAML-defined rules. +// Key design principles: +// - Deterministic: No LLM involvement in rule evaluation +// - Transparent: Rules are auditable YAML +// - Composable: Each field carries its own legal metadata +// - Solution-oriented: Problems include suggested solutions +// +// ============================================================================ + +// DefaultPolicyPath is the default location for the policy file +var DefaultPolicyPath = "policies/ucca_policy_v1.yaml" + +// PolicyConfig represents the full YAML policy structure +type PolicyConfig struct { + Policy PolicyMetadata `yaml:"policy"` + Thresholds Thresholds `yaml:"thresholds"` + Patterns map[string]PatternDef `yaml:"patterns"` + Controls map[string]ControlDef `yaml:"controls"` + Rules []RuleDef `yaml:"rules"` + ProblemSolutions []ProblemSolutionDef `yaml:"problem_solutions"` + EscalationTriggers []EscalationTriggerDef `yaml:"escalation_triggers"` +} + +// PolicyMetadata contains policy header info +type PolicyMetadata struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Jurisdiction string `yaml:"jurisdiction"` + Basis []string `yaml:"basis"` + DefaultFeasibility string `yaml:"default_feasibility"` + DefaultRiskScore int `yaml:"default_risk_score"` +} + +// Thresholds for risk scoring and escalation +type Thresholds struct { + Risk RiskThresholds `yaml:"risk"` + Escalation []string `yaml:"escalation"` +} + +// RiskThresholds defines risk level boundaries +type RiskThresholds struct { + Minimal int `yaml:"minimal"` + Low int `yaml:"low"` + Medium int `yaml:"medium"` + High int `yaml:"high"` + Unacceptable int `yaml:"unacceptable"` +} + +// PatternDef represents an architecture pattern from YAML +type PatternDef struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Benefit string `yaml:"benefit"` + Effort string `yaml:"effort"` + RiskReduction int `yaml:"risk_reduction"` +} + +// ControlDef represents a required control from YAML +type ControlDef struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Description string `yaml:"description"` + GDPRRef string `yaml:"gdpr_ref"` + Effort string `yaml:"effort"` +} + +// RuleDef represents a single rule from YAML +type RuleDef struct { + ID string `yaml:"id"` + Category string `yaml:"category"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Condition ConditionDef `yaml:"condition"` + Effect EffectDef `yaml:"effect"` + Severity string `yaml:"severity"` + GDPRRef string `yaml:"gdpr_ref"` + Rationale string `yaml:"rationale"` +} + +// ConditionDef represents a rule condition (supports field checks and compositions) +type ConditionDef struct { + // Simple field check + Field string `yaml:"field,omitempty"` + Operator string `yaml:"operator,omitempty"` + Value interface{} `yaml:"value,omitempty"` + + // Composite conditions + AllOf []ConditionDef `yaml:"all_of,omitempty"` + AnyOf []ConditionDef `yaml:"any_of,omitempty"` + + // Aggregate conditions (evaluated after all rules) + Aggregate string `yaml:"aggregate,omitempty"` +} + +// EffectDef represents the effect when a rule triggers +type EffectDef struct { + RiskAdd int `yaml:"risk_add,omitempty"` + Feasibility string `yaml:"feasibility,omitempty"` + ControlsAdd []string `yaml:"controls_add,omitempty"` + SuggestedPatterns []string `yaml:"suggested_patterns,omitempty"` + Escalation bool `yaml:"escalation,omitempty"` + Art22Risk bool `yaml:"art22_risk,omitempty"` + TrainingAllowed bool `yaml:"training_allowed,omitempty"` + LegalBasis string `yaml:"legal_basis,omitempty"` +} + +// ProblemSolutionDef maps problems to solutions +type ProblemSolutionDef struct { + ProblemID string `yaml:"problem_id"` + Title string `yaml:"title"` + Triggers []ProblemTriggerDef `yaml:"triggers"` + Solutions []SolutionDef `yaml:"solutions"` +} + +// ProblemTriggerDef defines when a problem is triggered +type ProblemTriggerDef struct { + Rule string `yaml:"rule"` + WithoutControl string `yaml:"without_control,omitempty"` +} + +// SolutionDef represents a potential solution +type SolutionDef struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Pattern string `yaml:"pattern,omitempty"` + Control string `yaml:"control,omitempty"` + RemovesProblem bool `yaml:"removes_problem"` + TeamQuestion string `yaml:"team_question"` +} + +// EscalationTriggerDef defines when to escalate to DSB +type EscalationTriggerDef struct { + Condition string `yaml:"condition"` + Reason string `yaml:"reason"` +} + +// ============================================================================ +// Policy Engine Implementation +// ============================================================================ + +// PolicyEngine evaluates intakes against YAML-defined rules +type PolicyEngine struct { + config *PolicyConfig +} + +// NewPolicyEngine creates a new policy engine, loading from the default path +// It searches for the policy file in common locations +func NewPolicyEngine() (*PolicyEngine, error) { + // Try multiple locations to find the policy file + searchPaths := []string{ + DefaultPolicyPath, + filepath.Join(".", "policies", "ucca_policy_v1.yaml"), + filepath.Join("..", "policies", "ucca_policy_v1.yaml"), + filepath.Join("..", "..", "policies", "ucca_policy_v1.yaml"), + "/app/policies/ucca_policy_v1.yaml", // Docker container path + } + + var data []byte + var err error + for _, path := range searchPaths { + data, err = os.ReadFile(path) + if err == nil { + break + } + } + + if err != nil { + return nil, fmt.Errorf("failed to load policy from any known location: %w", err) + } + + var config PolicyConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse policy YAML: %w", err) + } + + return &PolicyEngine{config: &config}, nil +} + +// NewPolicyEngineFromPath loads policy from a specific file path +func NewPolicyEngineFromPath(path string) (*PolicyEngine, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read policy file: %w", err) + } + + var config PolicyConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse policy YAML: %w", err) + } + + return &PolicyEngine{config: &config}, nil +} + +// GetPolicyVersion returns the policy version +func (e *PolicyEngine) GetPolicyVersion() string { + return e.config.Policy.Version +} + +// Evaluate runs all YAML rules against the intake +func (e *PolicyEngine) Evaluate(intake *UseCaseIntake) *AssessmentResult { + result := &AssessmentResult{ + Feasibility: FeasibilityYES, + RiskLevel: RiskLevelMINIMAL, + Complexity: ComplexityLOW, + RiskScore: 0, + TriggeredRules: []TriggeredRule{}, + RequiredControls: []RequiredControl{}, + RecommendedArchitecture: []PatternRecommendation{}, + ForbiddenPatterns: []ForbiddenPattern{}, + ExampleMatches: []ExampleMatch{}, + DSFARecommended: false, + Art22Risk: false, + TrainingAllowed: TrainingYES, + } + + // Track state for aggregation + hasBlock := false + hasWarn := false + controlSet := make(map[string]bool) + patternPriority := make(map[string]int) + triggeredRuleIDs := make(map[string]bool) + needsEscalation := false + + // Evaluate each non-aggregate rule + priority := 1 + for _, rule := range e.config.Rules { + // Skip aggregate rules (evaluated later) + if rule.Condition.Aggregate != "" { + continue + } + + if e.evaluateCondition(&rule.Condition, intake) { + triggeredRuleIDs[rule.ID] = true + + // Create triggered rule record + triggered := TriggeredRule{ + Code: rule.ID, + Category: rule.Category, + Title: rule.Title, + Description: rule.Description, + Severity: parseSeverity(rule.Severity), + ScoreDelta: rule.Effect.RiskAdd, + GDPRRef: rule.GDPRRef, + Rationale: rule.Rationale, + } + result.TriggeredRules = append(result.TriggeredRules, triggered) + + // Apply effects + result.RiskScore += rule.Effect.RiskAdd + + // Track severity + switch parseSeverity(rule.Severity) { + case SeverityBLOCK: + hasBlock = true + case SeverityWARN: + hasWarn = true + } + + // Override feasibility if specified + if rule.Effect.Feasibility != "" { + switch rule.Effect.Feasibility { + case "NO": + result.Feasibility = FeasibilityNO + case "CONDITIONAL": + if result.Feasibility != FeasibilityNO { + result.Feasibility = FeasibilityCONDITIONAL + } + case "YES": + // Only set YES if not already NO or CONDITIONAL + if result.Feasibility != FeasibilityNO && result.Feasibility != FeasibilityCONDITIONAL { + result.Feasibility = FeasibilityYES + } + } + } + + // Collect controls + for _, ctrlID := range rule.Effect.ControlsAdd { + if !controlSet[ctrlID] { + controlSet[ctrlID] = true + if ctrl, ok := e.config.Controls[ctrlID]; ok { + result.RequiredControls = append(result.RequiredControls, RequiredControl{ + ID: ctrl.ID, + Title: ctrl.Title, + Description: ctrl.Description, + Severity: parseSeverity(rule.Severity), + Category: categorizeControl(ctrl.ID), + GDPRRef: ctrl.GDPRRef, + }) + } + } + } + + // Collect patterns + for _, patternID := range rule.Effect.SuggestedPatterns { + if _, exists := patternPriority[patternID]; !exists { + patternPriority[patternID] = priority + priority++ + } + } + + // Track special flags + if rule.Effect.Escalation { + needsEscalation = true + } + if rule.Effect.Art22Risk { + result.Art22Risk = true + } + } + } + + // Apply aggregation rules + if hasBlock { + result.Feasibility = FeasibilityNO + } else if hasWarn && result.Feasibility != FeasibilityNO { + result.Feasibility = FeasibilityCONDITIONAL + } + + // Determine risk level from thresholds + result.RiskLevel = e.calculateRiskLevel(result.RiskScore) + + // Determine complexity + result.Complexity = e.calculateComplexity(result) + + // Check if DSFA is recommended + result.DSFARecommended = e.shouldRecommendDSFA(intake, result) + + // Determine training allowed status + result.TrainingAllowed = e.determineTrainingAllowed(intake) + + // Add recommended patterns (sorted by priority) + result.RecommendedArchitecture = e.buildPatternRecommendations(patternPriority) + + // Match didactic examples + result.ExampleMatches = MatchExamples(intake) + + // Generate summaries + result.Summary = e.generateSummary(result, intake) + result.Recommendation = e.generateRecommendation(result, intake) + if result.Feasibility == FeasibilityNO { + result.AlternativeApproach = e.generateAlternative(result, intake, triggeredRuleIDs) + } + + // Note: needsEscalation could be used to flag the assessment for DSB review + _ = needsEscalation + + return result +} + +// evaluateCondition recursively evaluates a condition against the intake +func (e *PolicyEngine) evaluateCondition(cond *ConditionDef, intake *UseCaseIntake) bool { + // Handle composite all_of + if len(cond.AllOf) > 0 { + for _, subCond := range cond.AllOf { + if !e.evaluateCondition(&subCond, intake) { + return false + } + } + return true + } + + // Handle composite any_of + if len(cond.AnyOf) > 0 { + for _, subCond := range cond.AnyOf { + if e.evaluateCondition(&subCond, intake) { + return true + } + } + return false + } + + // Handle simple field condition + if cond.Field != "" { + return e.evaluateFieldCondition(cond.Field, cond.Operator, cond.Value, intake) + } + + return false +} + +// evaluateFieldCondition evaluates a single field comparison +func (e *PolicyEngine) evaluateFieldCondition(field, operator string, value interface{}, intake *UseCaseIntake) bool { + // Get the field value from intake + fieldValue := e.getFieldValue(field, intake) + if fieldValue == nil { + return false + } + + switch operator { + case "equals": + return e.compareEquals(fieldValue, value) + case "not_equals": + return !e.compareEquals(fieldValue, value) + case "in": + return e.compareIn(fieldValue, value) + case "contains": + return e.compareContains(fieldValue, value) + default: + return false + } +} + +// getFieldValue extracts a field value from the intake using dot notation +func (e *PolicyEngine) getFieldValue(field string, intake *UseCaseIntake) interface{} { + parts := strings.Split(field, ".") + if len(parts) == 0 { + return nil + } + + switch parts[0] { + case "data_types": + if len(parts) < 2 { + return nil + } + return e.getDataTypeValue(parts[1], intake) + case "purpose": + if len(parts) < 2 { + return nil + } + return e.getPurposeValue(parts[1], intake) + case "automation": + return string(intake.Automation) + case "outputs": + if len(parts) < 2 { + return nil + } + return e.getOutputsValue(parts[1], intake) + case "hosting": + if len(parts) < 2 { + return nil + } + return e.getHostingValue(parts[1], intake) + case "model_usage": + if len(parts) < 2 { + return nil + } + return e.getModelUsageValue(parts[1], intake) + case "domain": + return string(intake.Domain) + case "retention": + if len(parts) < 2 { + return nil + } + return e.getRetentionValue(parts[1], intake) + } + + return nil +} + +func (e *PolicyEngine) getDataTypeValue(field string, intake *UseCaseIntake) interface{} { + switch field { + case "personal_data": + return intake.DataTypes.PersonalData + case "article_9_data": + return intake.DataTypes.Article9Data + case "minor_data": + return intake.DataTypes.MinorData + case "license_plates": + return intake.DataTypes.LicensePlates + case "images": + return intake.DataTypes.Images + case "audio": + return intake.DataTypes.Audio + case "location_data": + return intake.DataTypes.LocationData + case "biometric_data": + return intake.DataTypes.BiometricData + case "financial_data": + return intake.DataTypes.FinancialData + case "employee_data": + return intake.DataTypes.EmployeeData + case "customer_data": + return intake.DataTypes.CustomerData + case "public_data": + return intake.DataTypes.PublicData + } + return nil +} + +func (e *PolicyEngine) getPurposeValue(field string, intake *UseCaseIntake) interface{} { + switch field { + case "customer_support": + return intake.Purpose.CustomerSupport + case "marketing": + return intake.Purpose.Marketing + case "analytics": + return intake.Purpose.Analytics + case "automation": + return intake.Purpose.Automation + case "evaluation_scoring": + return intake.Purpose.EvaluationScoring + case "decision_making": + return intake.Purpose.DecisionMaking + case "profiling": + return intake.Purpose.Profiling + case "research": + return intake.Purpose.Research + case "internal_tools": + return intake.Purpose.InternalTools + case "public_service": + return intake.Purpose.PublicService + } + return nil +} + +func (e *PolicyEngine) getOutputsValue(field string, intake *UseCaseIntake) interface{} { + switch field { + case "recommendations_to_users": + return intake.Outputs.RecommendationsToUsers + case "rankings_or_scores": + return intake.Outputs.RankingsOrScores + case "legal_effects": + return intake.Outputs.LegalEffects + case "access_decisions": + return intake.Outputs.AccessDecisions + case "content_generation": + return intake.Outputs.ContentGeneration + case "data_export": + return intake.Outputs.DataExport + } + return nil +} + +func (e *PolicyEngine) getHostingValue(field string, intake *UseCaseIntake) interface{} { + switch field { + case "provider": + return intake.Hosting.Provider + case "region": + return intake.Hosting.Region + case "data_residency": + return intake.Hosting.DataResidency + } + return nil +} + +func (e *PolicyEngine) getModelUsageValue(field string, intake *UseCaseIntake) interface{} { + switch field { + case "rag": + return intake.ModelUsage.RAG + case "finetune": + return intake.ModelUsage.Finetune + case "training": + return intake.ModelUsage.Training + case "inference": + return intake.ModelUsage.Inference + } + return nil +} + +func (e *PolicyEngine) getRetentionValue(field string, intake *UseCaseIntake) interface{} { + switch field { + case "store_prompts": + return intake.Retention.StorePrompts + case "store_responses": + return intake.Retention.StoreResponses + case "retention_days": + return intake.Retention.RetentionDays + case "anonymize_after_use": + return intake.Retention.AnonymizeAfterUse + } + return nil +} + +// compareEquals compares two values for equality +func (e *PolicyEngine) compareEquals(fieldValue, expected interface{}) bool { + // Handle bool comparison + if bv, ok := fieldValue.(bool); ok { + if eb, ok := expected.(bool); ok { + return bv == eb + } + } + + // Handle string comparison + if sv, ok := fieldValue.(string); ok { + if es, ok := expected.(string); ok { + return sv == es + } + } + + // Handle int comparison + if iv, ok := fieldValue.(int); ok { + switch ev := expected.(type) { + case int: + return iv == ev + case float64: + return iv == int(ev) + } + } + + return false +} + +// compareIn checks if fieldValue is in a list of expected values +func (e *PolicyEngine) compareIn(fieldValue, expected interface{}) bool { + list, ok := expected.([]interface{}) + if !ok { + return false + } + + sv, ok := fieldValue.(string) + if !ok { + return false + } + + for _, item := range list { + if is, ok := item.(string); ok && is == sv { + return true + } + } + return false +} + +// compareContains checks if a string contains a substring +func (e *PolicyEngine) compareContains(fieldValue, expected interface{}) bool { + sv, ok := fieldValue.(string) + if !ok { + return false + } + es, ok := expected.(string) + if !ok { + return false + } + return strings.Contains(strings.ToLower(sv), strings.ToLower(es)) +} + +// calculateRiskLevel determines risk level from score +func (e *PolicyEngine) calculateRiskLevel(score int) RiskLevel { + t := e.config.Thresholds.Risk + if score >= t.Unacceptable { + return RiskLevelUNACCEPTABLE + } + if score >= t.High { + return RiskLevelHIGH + } + if score >= t.Medium { + return RiskLevelMEDIUM + } + if score >= t.Low { + return RiskLevelLOW + } + return RiskLevelMINIMAL +} + +// calculateComplexity determines implementation complexity +func (e *PolicyEngine) calculateComplexity(result *AssessmentResult) Complexity { + controlCount := len(result.RequiredControls) + if controlCount >= 5 || result.RiskScore >= 50 { + return ComplexityHIGH + } + if controlCount >= 3 || result.RiskScore >= 25 { + return ComplexityMEDIUM + } + return ComplexityLOW +} + +// shouldRecommendDSFA checks if a DSFA is recommended +func (e *PolicyEngine) shouldRecommendDSFA(intake *UseCaseIntake, result *AssessmentResult) bool { + if result.RiskLevel == RiskLevelHIGH || result.RiskLevel == RiskLevelUNACCEPTABLE { + return true + } + if intake.DataTypes.Article9Data || intake.DataTypes.BiometricData { + return true + } + if intake.Purpose.Profiling && intake.DataTypes.PersonalData { + return true + } + // Check if C_DSFA control is required + for _, ctrl := range result.RequiredControls { + if ctrl.ID == "C_DSFA" { + return true + } + } + return false +} + +// determineTrainingAllowed checks training permission +func (e *PolicyEngine) determineTrainingAllowed(intake *UseCaseIntake) TrainingAllowed { + if intake.ModelUsage.Training && intake.DataTypes.PersonalData { + return TrainingNO + } + if intake.ModelUsage.Finetune && intake.DataTypes.PersonalData { + return TrainingCONDITIONAL + } + if intake.DataTypes.MinorData && (intake.ModelUsage.Training || intake.ModelUsage.Finetune) { + return TrainingNO + } + return TrainingYES +} + +// buildPatternRecommendations creates sorted pattern recommendations +func (e *PolicyEngine) buildPatternRecommendations(patternPriority map[string]int) []PatternRecommendation { + type priorityPair struct { + id string + priority int + } + + pairs := make([]priorityPair, 0, len(patternPriority)) + for id, p := range patternPriority { + pairs = append(pairs, priorityPair{id, p}) + } + sort.Slice(pairs, func(i, j int) bool { + return pairs[i].priority < pairs[j].priority + }) + + recommendations := make([]PatternRecommendation, 0, len(pairs)) + for _, p := range pairs { + if pattern, ok := e.config.Patterns[p.id]; ok { + recommendations = append(recommendations, PatternRecommendation{ + PatternID: pattern.ID, + Title: pattern.Title, + Description: pattern.Description, + Rationale: pattern.Benefit, + Priority: p.priority, + }) + } + } + return recommendations +} + +// generateSummary creates a human-readable summary +func (e *PolicyEngine) generateSummary(result *AssessmentResult, intake *UseCaseIntake) string { + var parts []string + + switch result.Feasibility { + case FeasibilityYES: + parts = append(parts, "Der Use Case ist aus DSGVO-Sicht grundsätzlich umsetzbar.") + case FeasibilityCONDITIONAL: + parts = append(parts, "Der Use Case ist unter Auflagen umsetzbar.") + case FeasibilityNO: + parts = append(parts, "Der Use Case ist in der aktuellen Form nicht DSGVO-konform umsetzbar.") + } + + blockCount := 0 + warnCount := 0 + for _, r := range result.TriggeredRules { + if r.Severity == SeverityBLOCK { + blockCount++ + } else if r.Severity == SeverityWARN { + warnCount++ + } + } + + if blockCount > 0 { + parts = append(parts, fmt.Sprintf("%d kritische Regelverletzung(en) identifiziert.", blockCount)) + } + if warnCount > 0 { + parts = append(parts, fmt.Sprintf("%d Warnungen erfordern Aufmerksamkeit.", warnCount)) + } + + if result.DSFARecommended { + parts = append(parts, "Eine Datenschutz-Folgenabschätzung (DSFA) wird empfohlen.") + } + + return strings.Join(parts, " ") +} + +// generateRecommendation creates actionable recommendations +func (e *PolicyEngine) generateRecommendation(result *AssessmentResult, intake *UseCaseIntake) string { + if result.Feasibility == FeasibilityYES { + return "Fahren Sie mit der Implementierung fort. Beachten Sie die empfohlenen Architektur-Patterns für optimale DSGVO-Konformität." + } + + if result.Feasibility == FeasibilityCONDITIONAL { + if len(result.RequiredControls) > 0 { + return fmt.Sprintf("Implementieren Sie die %d erforderlichen Kontrollen vor dem Go-Live. Dokumentieren Sie alle Maßnahmen für den Nachweis der Rechenschaftspflicht (Art. 5 DSGVO).", len(result.RequiredControls)) + } + return "Prüfen Sie die ausgelösten Warnungen und implementieren Sie entsprechende Schutzmaßnahmen." + } + + return "Der Use Case erfordert grundlegende Änderungen. Prüfen Sie die Lösungsvorschläge." +} + +// generateAlternative creates alternative approach suggestions +func (e *PolicyEngine) generateAlternative(result *AssessmentResult, intake *UseCaseIntake, triggeredRules map[string]bool) string { + var suggestions []string + + // Find applicable problem-solutions + for _, ps := range e.config.ProblemSolutions { + for _, trigger := range ps.Triggers { + if triggeredRules[trigger.Rule] { + // Check if control is missing (if specified) + if trigger.WithoutControl != "" { + hasControl := false + for _, ctrl := range result.RequiredControls { + if ctrl.ID == trigger.WithoutControl { + hasControl = true + break + } + } + if hasControl { + continue + } + } + // Add first solution as suggestion + if len(ps.Solutions) > 0 { + sol := ps.Solutions[0] + suggestions = append(suggestions, fmt.Sprintf("%s: %s", sol.Title, sol.TeamQuestion)) + } + } + } + } + + // Fallback suggestions based on intake + if len(suggestions) == 0 { + if intake.ModelUsage.Training && intake.DataTypes.PersonalData { + suggestions = append(suggestions, "Nutzen Sie nur RAG statt Training mit personenbezogenen Daten") + } + if intake.Automation == AutomationFullyAutomated && intake.Outputs.LegalEffects { + suggestions = append(suggestions, "Implementieren Sie Human-in-the-Loop für Entscheidungen mit rechtlichen Auswirkungen") + } + if intake.DataTypes.MinorData && intake.Purpose.EvaluationScoring { + suggestions = append(suggestions, "Verzichten Sie auf automatisches Scoring von Minderjährigen") + } + } + + if len(suggestions) == 0 { + return "Überarbeiten Sie den Use Case unter Berücksichtigung der ausgelösten Regeln." + } + + return strings.Join(suggestions, " | ") +} + +// GetAllRules returns all rules in the policy +func (e *PolicyEngine) GetAllRules() []RuleDef { + return e.config.Rules +} + +// GetAllPatterns returns all patterns in the policy +func (e *PolicyEngine) GetAllPatterns() map[string]PatternDef { + return e.config.Patterns +} + +// GetAllControls returns all controls in the policy +func (e *PolicyEngine) GetAllControls() map[string]ControlDef { + return e.config.Controls +} + +// GetProblemSolutions returns problem-solution mappings +func (e *PolicyEngine) GetProblemSolutions() []ProblemSolutionDef { + return e.config.ProblemSolutions +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +func parseSeverity(s string) Severity { + switch strings.ToUpper(s) { + case "BLOCK": + return SeverityBLOCK + case "WARN": + return SeverityWARN + default: + return SeverityINFO + } +} + +func categorizeControl(id string) string { + // Map control IDs to categories + technical := map[string]bool{ + "C_ENCRYPTION": true, "C_ACCESS_LOGGING": true, + } + if technical[id] { + return "technical" + } + return "organizational" +} diff --git a/ai-compliance-sdk/internal/ucca/policy_engine_test.go b/ai-compliance-sdk/internal/ucca/policy_engine_test.go new file mode 100644 index 0000000..0071753 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/policy_engine_test.go @@ -0,0 +1,940 @@ +package ucca + +import ( + "os" + "path/filepath" + "testing" +) + +// Helper to get the project root for testing +func getProjectRoot(t *testing.T) string { + // Start from the current directory and walk up to find go.mod + dir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("Could not find project root (no go.mod found)") + } + dir = parent + } +} + +func TestNewPolicyEngineFromPath(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + if engine.GetPolicyVersion() != "1.0.0" { + t.Errorf("Expected policy version 1.0.0, got %s", engine.GetPolicyVersion()) + } +} + +func TestPolicyEngine_EvaluateSimpleCase(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Test case: Simple RAG chatbot for utilities (low risk) + intake := &UseCaseIntake{ + UseCaseText: "Chatbot für Stadtwerke mit FAQ-Suche", + Domain: DomainUtilities, + DataTypes: DataTypes{ + PersonalData: false, + PublicData: true, + }, + Purpose: Purpose{ + CustomerSupport: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{ + RAG: true, + Training: false, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + result := engine.Evaluate(intake) + + if result.Feasibility != FeasibilityYES { + t.Errorf("Expected feasibility YES, got %s", result.Feasibility) + } + + if result.RiskLevel != RiskLevelMINIMAL { + t.Errorf("Expected risk level MINIMAL, got %s", result.RiskLevel) + } +} + +func TestPolicyEngine_EvaluateHighRiskCase(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Test case: HR scoring with full automation (should be blocked) + intake := &UseCaseIntake{ + UseCaseText: "Automatische Mitarbeiterbewertung", + Domain: DomainHR, + DataTypes: DataTypes{ + PersonalData: true, + EmployeeData: true, + }, + Purpose: Purpose{ + EvaluationScoring: true, + }, + Automation: AutomationFullyAutomated, + Outputs: Outputs{ + RankingsOrScores: true, + }, + ModelUsage: ModelUsage{ + Training: true, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + result := engine.Evaluate(intake) + + if result.Feasibility != FeasibilityNO { + t.Errorf("Expected feasibility NO for HR scoring, got %s", result.Feasibility) + } + + // Should have at least one BLOCK severity rule triggered + hasBlock := false + for _, rule := range result.TriggeredRules { + if rule.Severity == SeverityBLOCK { + hasBlock = true + break + } + } + if !hasBlock { + t.Error("Expected at least one BLOCK severity rule for HR scoring") + } +} + +func TestPolicyEngine_EvaluateConditionalCase(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Test case: Personal data with marketing (should be conditional) + intake := &UseCaseIntake{ + UseCaseText: "Marketing-Personalisierung", + Domain: DomainMarketing, + DataTypes: DataTypes{ + PersonalData: true, + }, + Purpose: Purpose{ + Marketing: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{ + RAG: true, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + result := engine.Evaluate(intake) + + if result.Feasibility != FeasibilityCONDITIONAL { + t.Errorf("Expected feasibility CONDITIONAL for marketing with PII, got %s", result.Feasibility) + } + + // Should require consent control + hasConsentControl := false + for _, ctrl := range result.RequiredControls { + if ctrl.ID == "C_EXPLICIT_CONSENT" { + hasConsentControl = true + break + } + } + if !hasConsentControl { + t.Error("Expected C_EXPLICIT_CONSENT control for marketing with PII") + } +} + +func TestPolicyEngine_EvaluateArticle9Data(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Test case: Healthcare with Art. 9 data + intake := &UseCaseIntake{ + UseCaseText: "Patientendaten-Analyse", + Domain: DomainHealthcare, + DataTypes: DataTypes{ + PersonalData: true, + Article9Data: true, + }, + Purpose: Purpose{ + Analytics: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{ + RAG: true, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + result := engine.Evaluate(intake) + + // Art. 9 data should trigger DSFA recommendation + if !result.DSFARecommended { + t.Error("Expected DSFA recommended for Art. 9 data") + } + + // Should have triggered Art. 9 rule + hasArt9Rule := false + for _, rule := range result.TriggeredRules { + if rule.Code == "R-A002" { + hasArt9Rule = true + break + } + } + if !hasArt9Rule { + t.Error("Expected R-A002 (Art. 9 data) rule to be triggered") + } +} + +func TestPolicyEngine_EvaluateLicensePlates(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Test case: Parking with license plates + intake := &UseCaseIntake{ + UseCaseText: "Parkhaus-Kennzeichenerkennung", + Domain: DomainRealEstate, + DataTypes: DataTypes{ + PersonalData: true, + LicensePlates: true, + }, + Purpose: Purpose{ + Automation: true, + }, + Automation: AutomationSemiAutomated, + ModelUsage: ModelUsage{ + Inference: true, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + result := engine.Evaluate(intake) + + // Should suggest pixelization pattern + hasPixelization := false + for _, pattern := range result.RecommendedArchitecture { + if pattern.PatternID == "P_PIXELIZATION" { + hasPixelization = true + break + } + } + if !hasPixelization { + t.Error("Expected P_PIXELIZATION pattern to be recommended for license plates") + } +} + +func TestPolicyEngine_EvaluateThirdCountryTransfer(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Test case: Third country hosting with PII + intake := &UseCaseIntake{ + UseCaseText: "US-hosted AI service", + Domain: DomainITServices, + DataTypes: DataTypes{ + PersonalData: true, + }, + Purpose: Purpose{ + InternalTools: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{ + RAG: true, + }, + Hosting: Hosting{ + Region: "third_country", + }, + } + + result := engine.Evaluate(intake) + + // Should require SCC control + hasSCC := false + for _, ctrl := range result.RequiredControls { + if ctrl.ID == "C_SCC" { + hasSCC = true + break + } + } + if !hasSCC { + t.Error("Expected C_SCC control for third country transfer with PII") + } +} + +func TestPolicyEngine_GetAllRules(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + rules := engine.GetAllRules() + if len(rules) == 0 { + t.Error("Expected at least some rules") + } + + // Check that rules have required fields + for _, rule := range rules { + if rule.ID == "" { + t.Error("Found rule without ID") + } + if rule.Category == "" { + t.Error("Found rule without category") + } + if rule.Title == "" { + t.Error("Found rule without title") + } + } +} + +func TestPolicyEngine_GetAllPatterns(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + patterns := engine.GetAllPatterns() + if len(patterns) == 0 { + t.Error("Expected at least some patterns") + } + + // Verify expected patterns exist + expectedPatterns := []string{"P_RAG_ONLY", "P_PRE_ANON", "P_PIXELIZATION", "P_HITL_ENFORCED"} + for _, expected := range expectedPatterns { + if _, exists := patterns[expected]; !exists { + t.Errorf("Expected pattern %s to exist", expected) + } + } +} + +func TestPolicyEngine_GetAllControls(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + controls := engine.GetAllControls() + if len(controls) == 0 { + t.Error("Expected at least some controls") + } + + // Verify expected controls exist + expectedControls := []string{"C_EXPLICIT_CONSENT", "C_DSFA", "C_ENCRYPTION", "C_SCC"} + for _, expected := range expectedControls { + if _, exists := controls[expected]; !exists { + t.Errorf("Expected control %s to exist", expected) + } + } +} + +func TestPolicyEngine_TrainingWithMinorData(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Test case: Training with minor data (should be blocked) + intake := &UseCaseIntake{ + UseCaseText: "KI-Training mit Schülerdaten", + Domain: DomainEducation, + DataTypes: DataTypes{ + PersonalData: true, + MinorData: true, + }, + Purpose: Purpose{ + Research: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{ + Training: true, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + result := engine.Evaluate(intake) + + if result.TrainingAllowed != TrainingNO { + t.Errorf("Expected training NOT allowed for minor data, got %s", result.TrainingAllowed) + } +} + +func TestPolicyEngine_CompositeConditions(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Test case: Fully automated with legal effects (R-C004 uses all_of) + intake := &UseCaseIntake{ + UseCaseText: "Automatische Vertragsgenehmigung", + Domain: DomainLegal, + DataTypes: DataTypes{ + PersonalData: true, + }, + Purpose: Purpose{ + DecisionMaking: true, + }, + Automation: AutomationFullyAutomated, + Outputs: Outputs{ + LegalEffects: true, + }, + ModelUsage: ModelUsage{ + Inference: true, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + result := engine.Evaluate(intake) + + if result.Feasibility != FeasibilityNO { + t.Errorf("Expected NO feasibility for fully automated legal decisions, got %s", result.Feasibility) + } + + // Check that R-C004 was triggered + hasC004 := false + for _, rule := range result.TriggeredRules { + if rule.Code == "R-C004" { + hasC004 = true + break + } + } + if !hasC004 { + t.Error("Expected R-C004 (automated legal effects) to be triggered") + } +} + +// ============================================================================ +// Determinism Tests - Ensure consistent results +// ============================================================================ + +func TestPolicyEngine_Determinism(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + intake := &UseCaseIntake{ + UseCaseText: "Test Case für Determinismus", + Domain: DomainEducation, + DataTypes: DataTypes{ + PersonalData: true, + MinorData: true, + }, + Purpose: Purpose{ + EvaluationScoring: true, + }, + Automation: AutomationFullyAutomated, + Outputs: Outputs{ + RankingsOrScores: true, + }, + ModelUsage: ModelUsage{ + Training: true, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + // Run evaluation 10 times and ensure identical results + firstResult := engine.Evaluate(intake) + + for i := 0; i < 10; i++ { + result := engine.Evaluate(intake) + + if result.Feasibility != firstResult.Feasibility { + t.Errorf("Run %d: Feasibility mismatch: %s vs %s", i, result.Feasibility, firstResult.Feasibility) + } + if result.RiskScore != firstResult.RiskScore { + t.Errorf("Run %d: RiskScore mismatch: %d vs %d", i, result.RiskScore, firstResult.RiskScore) + } + if result.RiskLevel != firstResult.RiskLevel { + t.Errorf("Run %d: RiskLevel mismatch: %s vs %s", i, result.RiskLevel, firstResult.RiskLevel) + } + if len(result.TriggeredRules) != len(firstResult.TriggeredRules) { + t.Errorf("Run %d: TriggeredRules count mismatch: %d vs %d", i, len(result.TriggeredRules), len(firstResult.TriggeredRules)) + } + } +} + +// ============================================================================ +// Education Domain Specific Tests +// ============================================================================ + +func TestPolicyEngine_EducationScoring_AlwaysBlocked(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Education + Scoring + Fully Automated = BLOCK (R-F001) + intake := &UseCaseIntake{ + UseCaseText: "Automatische Schülerbewertung", + Domain: DomainEducation, + DataTypes: DataTypes{ + PersonalData: true, + MinorData: true, + }, + Purpose: Purpose{ + EvaluationScoring: true, + }, + Automation: AutomationFullyAutomated, + Outputs: Outputs{ + RankingsOrScores: true, + }, + ModelUsage: ModelUsage{ + Inference: true, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + result := engine.Evaluate(intake) + + if result.Feasibility != FeasibilityNO { + t.Errorf("Expected NO for education automated scoring, got %s", result.Feasibility) + } + + // Should have Art. 22 risk flagged + if !result.Art22Risk { + t.Error("Expected Art. 22 risk for automated individual decisions in education") + } +} + +// ============================================================================ +// RAG-Only Use Cases (Low Risk) +// ============================================================================ + +func TestPolicyEngine_RAGOnly_LowRisk(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + intake := &UseCaseIntake{ + UseCaseText: "FAQ-Suche mit öffentlichen Dokumenten", + Domain: DomainUtilities, + DataTypes: DataTypes{ + PublicData: true, + }, + Purpose: Purpose{ + CustomerSupport: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{ + RAG: true, + }, + Hosting: Hosting{ + Region: "eu", + }, + } + + result := engine.Evaluate(intake) + + if result.Feasibility != FeasibilityYES { + t.Errorf("Expected YES for RAG-only public data, got %s", result.Feasibility) + } + + if result.RiskLevel != RiskLevelMINIMAL { + t.Errorf("Expected MINIMAL risk for RAG-only, got %s", result.RiskLevel) + } + + // Should recommend P_RAG_ONLY pattern + hasRAGPattern := false + for _, pattern := range result.RecommendedArchitecture { + if pattern.PatternID == "P_RAG_ONLY" { + hasRAGPattern = true + break + } + } + if !hasRAGPattern { + t.Error("Expected P_RAG_ONLY pattern recommendation") + } +} + +// ============================================================================ +// Risk Score Calculation Tests +// ============================================================================ + +func TestPolicyEngine_RiskScoreCalculation(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + tests := []struct { + name string + intake *UseCaseIntake + minScore int + maxScore int + expectedRiskLevel RiskLevel + }{ + { + name: "Public data only → minimal risk", + intake: &UseCaseIntake{ + Domain: DomainUtilities, + DataTypes: DataTypes{ + PublicData: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{RAG: true}, + Hosting: Hosting{Region: "eu"}, + }, + minScore: 0, + maxScore: 20, + expectedRiskLevel: RiskLevelMINIMAL, + }, + { + name: "Personal data → low risk", + intake: &UseCaseIntake{ + Domain: DomainITServices, + DataTypes: DataTypes{ + PersonalData: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{RAG: true}, + Hosting: Hosting{Region: "eu"}, + }, + minScore: 5, + maxScore: 40, + expectedRiskLevel: RiskLevelLOW, + }, + { + name: "Art. 9 data → medium risk", + intake: &UseCaseIntake{ + Domain: DomainHealthcare, + DataTypes: DataTypes{ + PersonalData: true, + Article9Data: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{RAG: true}, + Hosting: Hosting{Region: "eu"}, + }, + minScore: 20, + maxScore: 60, + expectedRiskLevel: RiskLevelMEDIUM, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := engine.Evaluate(tt.intake) + + if result.RiskScore < tt.minScore || result.RiskScore > tt.maxScore { + t.Errorf("RiskScore %d outside expected range [%d, %d]", result.RiskScore, tt.minScore, tt.maxScore) + } + }) + } +} + +// ============================================================================ +// Training Allowed Tests +// ============================================================================ + +func TestPolicyEngine_TrainingAllowed_Scenarios(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + tests := []struct { + name string + intake *UseCaseIntake + expectedAllowed TrainingAllowed + }{ + { + name: "Public data training → allowed", + intake: &UseCaseIntake{ + Domain: DomainUtilities, + DataTypes: DataTypes{ + PublicData: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{Training: true}, + Hosting: Hosting{Region: "eu"}, + }, + expectedAllowed: TrainingYES, + }, + { + name: "Minor data training → not allowed", + intake: &UseCaseIntake{ + Domain: DomainEducation, + DataTypes: DataTypes{ + PersonalData: true, + MinorData: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{Training: true}, + Hosting: Hosting{Region: "eu"}, + }, + expectedAllowed: TrainingNO, + }, + { + name: "Art. 9 data training → not allowed", + intake: &UseCaseIntake{ + Domain: DomainHealthcare, + DataTypes: DataTypes{ + PersonalData: true, + Article9Data: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{Training: true}, + Hosting: Hosting{Region: "eu"}, + }, + expectedAllowed: TrainingNO, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := engine.Evaluate(tt.intake) + + if result.TrainingAllowed != tt.expectedAllowed { + t.Errorf("Expected TrainingAllowed=%s, got %s", tt.expectedAllowed, result.TrainingAllowed) + } + }) + } +} + +// ============================================================================ +// DSFA Recommendation Tests +// ============================================================================ + +func TestPolicyEngine_DSFARecommendation(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + tests := []struct { + name string + intake *UseCaseIntake + expectDSFA bool + expectArt22 bool + }{ + { + name: "Art. 9 data → DSFA required", + intake: &UseCaseIntake{ + Domain: DomainHealthcare, + DataTypes: DataTypes{ + PersonalData: true, + Article9Data: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{RAG: true}, + Hosting: Hosting{Region: "eu"}, + }, + expectDSFA: true, + expectArt22: false, + }, + { + name: "Systematic evaluation → DSFA + Art. 22", + intake: &UseCaseIntake{ + Domain: DomainHR, + DataTypes: DataTypes{ + PersonalData: true, + EmployeeData: true, + }, + Purpose: Purpose{ + EvaluationScoring: true, + }, + Automation: AutomationFullyAutomated, + Outputs: Outputs{ + RankingsOrScores: true, + }, + ModelUsage: ModelUsage{Inference: true}, + Hosting: Hosting{Region: "eu"}, + }, + expectDSFA: true, + expectArt22: true, + }, + { + name: "Public data RAG → no DSFA", + intake: &UseCaseIntake{ + Domain: DomainUtilities, + DataTypes: DataTypes{ + PublicData: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{RAG: true}, + Hosting: Hosting{Region: "eu"}, + }, + expectDSFA: false, + expectArt22: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := engine.Evaluate(tt.intake) + + if result.DSFARecommended != tt.expectDSFA { + t.Errorf("Expected DSFARecommended=%v, got %v", tt.expectDSFA, result.DSFARecommended) + } + if result.Art22Risk != tt.expectArt22 { + t.Errorf("Expected Art22Risk=%v, got %v", tt.expectArt22, result.Art22Risk) + } + }) + } +} + +// ============================================================================ +// Required Controls Tests +// ============================================================================ + +func TestPolicyEngine_RequiredControls(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + // Third country with PII should require SCC + intake := &UseCaseIntake{ + Domain: DomainITServices, + DataTypes: DataTypes{ + PersonalData: true, + }, + Automation: AutomationAssistive, + ModelUsage: ModelUsage{RAG: true}, + Hosting: Hosting{ + Region: "third_country", + }, + } + + result := engine.Evaluate(intake) + + controlIDs := make(map[string]bool) + for _, ctrl := range result.RequiredControls { + controlIDs[ctrl.ID] = true + } + + if !controlIDs["C_SCC"] { + t.Error("Expected C_SCC control for third country transfer") + } +} + +// ============================================================================ +// Policy Version Tests +// ============================================================================ + +func TestPolicyEngine_PolicyVersion(t *testing.T) { + root := getProjectRoot(t) + policyPath := filepath.Join(root, "policies", "ucca_policy_v1.yaml") + + engine, err := NewPolicyEngineFromPath(policyPath) + if err != nil { + t.Fatalf("Failed to create policy engine: %v", err) + } + + version := engine.GetPolicyVersion() + + // Version should be non-empty and follow semver pattern + if version == "" { + t.Error("Policy version should not be empty") + } + + // Check for basic semver pattern (x.y.z) + parts := 0 + for _, c := range version { + if c == '.' { + parts++ + } + } + if parts < 2 { + t.Errorf("Policy version should follow semver (x.y.z), got %s", version) + } +} diff --git a/ai-compliance-sdk/internal/ucca/rules.go b/ai-compliance-sdk/internal/ucca/rules.go new file mode 100644 index 0000000..db150c8 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/rules.go @@ -0,0 +1,1231 @@ +package ucca + +import ( + "fmt" + "strings" +) + +// ============================================================================ +// Rule Engine - Deterministic rule evaluation +// ============================================================================ + +// Rule represents a single evaluation rule +type Rule struct { + Code string + Category string + Title string + TitleDE string + Description string + DescriptionDE string + Severity Severity + ScoreDelta int // Points added to risk score + GDPRRef string // GDPR article reference + Controls []string // Required control IDs + Patterns []string // Recommended pattern IDs + Condition func(intake *UseCaseIntake) bool + Rationale func(intake *UseCaseIntake) string +} + +// RuleEngine holds all rules and performs evaluation +type RuleEngine struct { + rules []Rule +} + +// NewRuleEngine creates a new rule engine with all rules +func NewRuleEngine() *RuleEngine { + return &RuleEngine{ + rules: AllRules, + } +} + +// Evaluate runs all rules against the intake and returns the assessment result +func (e *RuleEngine) Evaluate(intake *UseCaseIntake) *AssessmentResult { + result := &AssessmentResult{ + Feasibility: FeasibilityYES, + RiskLevel: RiskLevelMINIMAL, + Complexity: ComplexityLOW, + RiskScore: 0, + TriggeredRules: []TriggeredRule{}, + RequiredControls: []RequiredControl{}, + RecommendedArchitecture: []PatternRecommendation{}, + ForbiddenPatterns: []ForbiddenPattern{}, + ExampleMatches: []ExampleMatch{}, + DSFARecommended: false, + Art22Risk: false, + TrainingAllowed: TrainingYES, + } + + // Track triggered severities + hasBlock := false + hasWarn := false + controlMap := make(map[string]bool) + patternMap := make(map[string]int) // pattern -> priority + + // Evaluate each rule + for _, rule := range e.rules { + if rule.Condition(intake) { + // Add triggered rule + triggered := TriggeredRule{ + Code: rule.Code, + Category: rule.Category, + Title: rule.TitleDE, + Description: rule.DescriptionDE, + Severity: rule.Severity, + ScoreDelta: rule.ScoreDelta, + GDPRRef: rule.GDPRRef, + Rationale: rule.Rationale(intake), + } + result.TriggeredRules = append(result.TriggeredRules, triggered) + + // Update risk score + result.RiskScore += rule.ScoreDelta + + // Track severity + switch rule.Severity { + case SeverityBLOCK: + hasBlock = true + case SeverityWARN: + hasWarn = true + } + + // Collect required controls + for _, controlID := range rule.Controls { + if !controlMap[controlID] { + controlMap[controlID] = true + if ctrl := GetControlByID(controlID); ctrl != nil { + result.RequiredControls = append(result.RequiredControls, *ctrl) + } + } + } + + // Collect recommended patterns + for i, patternID := range rule.Patterns { + if _, exists := patternMap[patternID]; !exists { + patternMap[patternID] = i + 1 // priority + } + } + } + } + + // Determine feasibility based on aggregation rules (R-090 to R-092) + if hasBlock { + result.Feasibility = FeasibilityNO + } else if hasWarn { + result.Feasibility = FeasibilityCONDITIONAL + } else { + result.Feasibility = FeasibilityYES + } + + // Determine risk level based on score + if result.RiskScore >= 80 { + result.RiskLevel = RiskLevelUNACCEPTABLE + } else if result.RiskScore >= 60 { + result.RiskLevel = RiskLevelHIGH + } else if result.RiskScore >= 40 { + result.RiskLevel = RiskLevelMEDIUM + } else if result.RiskScore >= 20 { + result.RiskLevel = RiskLevelLOW + } else { + result.RiskLevel = RiskLevelMINIMAL + } + + // Determine complexity + if len(result.RequiredControls) >= 5 || result.RiskScore >= 50 { + result.Complexity = ComplexityHIGH + } else if len(result.RequiredControls) >= 3 || result.RiskScore >= 25 { + result.Complexity = ComplexityMEDIUM + } else { + result.Complexity = ComplexityLOW + } + + // Check DSFA recommendation + if result.RiskLevel == RiskLevelHIGH || result.RiskLevel == RiskLevelUNACCEPTABLE || + intake.DataTypes.Article9Data || intake.DataTypes.BiometricData || + (intake.Purpose.Profiling && intake.DataTypes.PersonalData) { + result.DSFARecommended = true + } + + // Check Art. 22 risk + if intake.Automation == AutomationFullyAutomated && + (intake.Outputs.LegalEffects || intake.Outputs.RankingsOrScores || intake.Purpose.EvaluationScoring) { + result.Art22Risk = true + } + + // Determine training allowed + if intake.ModelUsage.Training && intake.DataTypes.PersonalData { + result.TrainingAllowed = TrainingNO + } else if intake.ModelUsage.Finetune && intake.DataTypes.PersonalData { + result.TrainingAllowed = TrainingCONDITIONAL + } + + // Add recommended architecture patterns + for patternID, priority := range patternMap { + if p := GetPatternByID(patternID); p != nil { + result.RecommendedArchitecture = append(result.RecommendedArchitecture, + PatternToRecommendation(*p, "Empfohlen basierend auf ausgelösten Regeln", priority)) + } + } + + // Add applicable patterns not yet recommended + applicable := GetApplicablePatterns(intake) + for _, p := range applicable { + found := false + for _, rec := range result.RecommendedArchitecture { + if rec.PatternID == p.ID { + found = true + break + } + } + if !found { + result.RecommendedArchitecture = append(result.RecommendedArchitecture, + PatternToRecommendation(p, "Anwendbar für Ihren Use Case", len(result.RecommendedArchitecture)+1)) + } + } + + // Add forbidden patterns + result.ForbiddenPatterns = GetForbiddenPatterns(intake) + + // Add matching examples + result.ExampleMatches = MatchExamples(intake) + + // Generate summary + result.Summary = generateSummary(result, intake) + result.Recommendation = generateRecommendation(result, intake) + if result.Feasibility == FeasibilityNO { + result.AlternativeApproach = generateAlternative(result, intake) + } + + return result +} + +// generateSummary creates a human-readable summary +func generateSummary(result *AssessmentResult, intake *UseCaseIntake) string { + var parts []string + + switch result.Feasibility { + case FeasibilityYES: + parts = append(parts, "Der Use Case ist aus DSGVO-Sicht grundsätzlich umsetzbar.") + case FeasibilityCONDITIONAL: + parts = append(parts, "Der Use Case ist unter Auflagen umsetzbar.") + case FeasibilityNO: + parts = append(parts, "Der Use Case ist in der aktuellen Form nicht DSGVO-konform umsetzbar.") + } + + if len(result.TriggeredRules) > 0 { + blockCount := 0 + warnCount := 0 + for _, r := range result.TriggeredRules { + if r.Severity == SeverityBLOCK { + blockCount++ + } else if r.Severity == SeverityWARN { + warnCount++ + } + } + if blockCount > 0 { + parts = append(parts, fmt.Sprintf("%d kritische Regelverletzung(en) identifiziert.", blockCount)) + } + if warnCount > 0 { + parts = append(parts, fmt.Sprintf("%d Warnungen erfordern Aufmerksamkeit.", warnCount)) + } + } + + if result.DSFARecommended { + parts = append(parts, "Eine Datenschutz-Folgenabschätzung (DSFA) wird empfohlen.") + } + + return strings.Join(parts, " ") +} + +// generateRecommendation creates actionable recommendations +func generateRecommendation(result *AssessmentResult, intake *UseCaseIntake) string { + if result.Feasibility == FeasibilityYES { + return "Fahren Sie mit der Implementierung fort. Beachten Sie die empfohlenen Architektur-Patterns für optimale DSGVO-Konformität." + } + + if result.Feasibility == FeasibilityCONDITIONAL { + if len(result.RequiredControls) > 0 { + return fmt.Sprintf("Implementieren Sie die %d erforderlichen Kontrollen vor dem Go-Live. Dokumentieren Sie alle Maßnahmen für den Nachweis der Rechenschaftspflicht (Art. 5 DSGVO).", len(result.RequiredControls)) + } + return "Prüfen Sie die ausgelösten Warnungen und implementieren Sie entsprechende Schutzmaßnahmen." + } + + // FeasibilityNO + return "Der Use Case erfordert grundlegende Änderungen. Prüfen Sie die Alternative-Ansatz-Empfehlung." +} + +// generateAlternative creates alternative approach suggestions +func generateAlternative(result *AssessmentResult, intake *UseCaseIntake) string { + var suggestions []string + + // Check specific blocking reasons + if intake.ModelUsage.Training && intake.DataTypes.PersonalData { + suggestions = append(suggestions, "Nutzen Sie nur RAG statt Training mit personenbezogenen Daten") + } + + if intake.Automation == AutomationFullyAutomated && intake.Outputs.LegalEffects { + suggestions = append(suggestions, "Implementieren Sie Human-in-the-Loop für Entscheidungen mit rechtlichen Auswirkungen") + } + + if intake.DataTypes.MinorData && intake.Purpose.EvaluationScoring { + suggestions = append(suggestions, "Verzichten Sie auf automatisches Scoring von Minderjährigen - nutzen Sie KI nur zur Unterstützung menschlicher Entscheidungsträger") + } + + if intake.Hosting.Region == "third_country" && intake.DataTypes.PersonalData { + suggestions = append(suggestions, "Hosten Sie innerhalb der EU oder implementieren Sie Standardvertragsklauseln (SCCs)") + } + + if len(suggestions) == 0 { + return "Überarbeiten Sie den Use Case unter Berücksichtigung der ausgelösten Regeln." + } + + return strings.Join(suggestions, ". ") + "." +} + +// GetRules returns all rules +func (e *RuleEngine) GetRules() []Rule { + return e.rules +} + +// ============================================================================ +// Control Definitions +// ============================================================================ + +var ControlLibrary = map[string]RequiredControl{ + "C-CONSENT": { + ID: "C-CONSENT", + Title: "Einwilligungsmanagement", + Description: "Implementieren Sie ein System zur Einholung und Verwaltung von Einwilligungen.", + Severity: SeverityWARN, + Category: "organizational", + GDPRRef: "Art. 7 DSGVO", + }, + "C-PII-DETECT": { + ID: "C-PII-DETECT", + Title: "PII-Erkennung", + Description: "Implementieren Sie automatische Erkennung personenbezogener Daten.", + Severity: SeverityWARN, + Category: "technical", + GDPRRef: "Art. 32 DSGVO", + }, + "C-ANONYMIZE": { + ID: "C-ANONYMIZE", + Title: "Anonymisierung/Pseudonymisierung", + Description: "Implementieren Sie Anonymisierung oder Pseudonymisierung vor der Verarbeitung.", + Severity: SeverityWARN, + Category: "technical", + GDPRRef: "Art. 32 DSGVO", + }, + "C-ACCESS-CONTROL": { + ID: "C-ACCESS-CONTROL", + Title: "Zugriffskontrollen", + Description: "Implementieren Sie rollenbasierte Zugriffskontrollen.", + Severity: SeverityWARN, + Category: "technical", + GDPRRef: "Art. 32 DSGVO", + }, + "C-AUDIT-LOG": { + ID: "C-AUDIT-LOG", + Title: "Audit-Logging", + Description: "Protokollieren Sie alle Zugriffe und Verarbeitungen.", + Severity: SeverityINFO, + Category: "technical", + GDPRRef: "Art. 5(2) DSGVO", + }, + "C-RETENTION": { + ID: "C-RETENTION", + Title: "Aufbewahrungsfristen", + Description: "Definieren und implementieren Sie automatische Löschfristen.", + Severity: SeverityWARN, + Category: "organizational", + GDPRRef: "Art. 5(1)(e) DSGVO", + }, + "C-HITL": { + ID: "C-HITL", + Title: "Human-in-the-Loop", + Description: "Implementieren Sie menschliche Überprüfung für KI-Entscheidungen.", + Severity: SeverityBLOCK, + Category: "organizational", + GDPRRef: "Art. 22 DSGVO", + }, + "C-TRANSPARENCY": { + ID: "C-TRANSPARENCY", + Title: "Transparenz", + Description: "Informieren Sie Betroffene über KI-Verarbeitung.", + Severity: SeverityWARN, + Category: "organizational", + GDPRRef: "Art. 13/14 DSGVO", + }, + "C-DSR-PROCESS": { + ID: "C-DSR-PROCESS", + Title: "Betroffenenrechte-Prozess", + Description: "Implementieren Sie Prozesse für Auskunft, Löschung, Berichtigung.", + Severity: SeverityWARN, + Category: "organizational", + GDPRRef: "Art. 15-22 DSGVO", + }, + "C-DSFA": { + ID: "C-DSFA", + Title: "DSFA durchführen", + Description: "Führen Sie eine Datenschutz-Folgenabschätzung durch.", + Severity: SeverityWARN, + Category: "organizational", + GDPRRef: "Art. 35 DSGVO", + }, + "C-SCC": { + ID: "C-SCC", + Title: "Standardvertragsklauseln", + Description: "Schließen Sie EU-Standardvertragsklauseln für Drittlandtransfers ab.", + Severity: SeverityBLOCK, + Category: "legal", + GDPRRef: "Art. 46 DSGVO", + }, + "C-ENCRYPTION": { + ID: "C-ENCRYPTION", + Title: "Verschlüsselung", + Description: "Verschlüsseln Sie Daten in Übertragung und Speicherung.", + Severity: SeverityWARN, + Category: "technical", + GDPRRef: "Art. 32 DSGVO", + }, + "C-MINOR-CONSENT": { + ID: "C-MINOR-CONSENT", + Title: "Elterneinwilligung", + Description: "Holen Sie Einwilligung der Erziehungsberechtigten ein.", + Severity: SeverityBLOCK, + Category: "organizational", + GDPRRef: "Art. 8 DSGVO", + }, + "C-ART9-BASIS": { + ID: "C-ART9-BASIS", + Title: "Art. 9 Rechtsgrundlage", + Description: "Dokumentieren Sie die Rechtsgrundlage für besondere Datenkategorien.", + Severity: SeverityBLOCK, + Category: "legal", + GDPRRef: "Art. 9 DSGVO", + }, +} + +// GetControlByID returns a control by its ID +func GetControlByID(id string) *RequiredControl { + if ctrl, exists := ControlLibrary[id]; exists { + return &ctrl + } + return nil +} + +// ============================================================================ +// All Rules (~45 rules in 10 categories) +// ============================================================================ + +var AllRules = []Rule{ + // ========================================================================= + // A. Datenklassifikation (R-001 bis R-006) + // ========================================================================= + { + Code: "R-001", + Category: "A. Datenklassifikation", + Title: "Personal Data Processing", + TitleDE: "Verarbeitung personenbezogener Daten", + Description: "Personal data is being processed", + DescriptionDE: "Personenbezogene Daten werden verarbeitet", + Severity: SeverityINFO, + ScoreDelta: 5, + GDPRRef: "Art. 4(1) DSGVO", + Controls: []string{"C-PII-DETECT", "C-ACCESS-CONTROL"}, + Patterns: []string{"P-PRE-ANON"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Der Use Case verarbeitet personenbezogene Daten. Dies erfordert eine Rechtsgrundlage und entsprechende Schutzmaßnahmen." + }, + }, + { + Code: "R-002", + Category: "A. Datenklassifikation", + Title: "Special Category Data (Art. 9)", + TitleDE: "Besondere Kategorien personenbezogener Daten (Art. 9)", + Description: "Processing of special category data requires explicit consent or legal basis", + DescriptionDE: "Verarbeitung besonderer Datenkategorien erfordert ausdrückliche Einwilligung oder Rechtsgrundlage", + Severity: SeverityWARN, + ScoreDelta: 20, + GDPRRef: "Art. 9 DSGVO", + Controls: []string{"C-ART9-BASIS", "C-DSFA", "C-ENCRYPTION"}, + Patterns: []string{"P-PRE-ANON", "P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.DataTypes.Article9Data + }, + Rationale: func(intake *UseCaseIntake) string { + return "Besondere Kategorien personenbezogener Daten (Gesundheit, Religion, etc.) erfordern besondere Schutzmaßnahmen und eine spezifische Rechtsgrundlage nach Art. 9 DSGVO." + }, + }, + { + Code: "R-003", + Category: "A. Datenklassifikation", + Title: "Minor Data Processing", + TitleDE: "Verarbeitung von Daten Minderjähriger", + Description: "Processing data of children requires special protections", + DescriptionDE: "Verarbeitung von Daten Minderjähriger erfordert besonderen Schutz", + Severity: SeverityWARN, + ScoreDelta: 15, + GDPRRef: "Art. 8 DSGVO", + Controls: []string{"C-MINOR-CONSENT", "C-DSFA"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.DataTypes.MinorData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Daten von Minderjährigen erfordern besonderen Schutz. Die Einwilligung muss von Erziehungsberechtigten eingeholt werden." + }, + }, + { + Code: "R-004", + Category: "A. Datenklassifikation", + Title: "Biometric Data", + TitleDE: "Biometrische Daten", + Description: "Biometric data processing is high risk", + DescriptionDE: "Verarbeitung biometrischer Daten ist hochriskant", + Severity: SeverityWARN, + ScoreDelta: 20, + GDPRRef: "Art. 9 DSGVO", + Controls: []string{"C-ART9-BASIS", "C-DSFA", "C-ENCRYPTION"}, + Patterns: []string{"P-PRE-ANON"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.DataTypes.BiometricData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Biometrische Daten zur eindeutigen Identifizierung fallen unter Art. 9 DSGVO und erfordern eine DSFA." + }, + }, + { + Code: "R-005", + Category: "A. Datenklassifikation", + Title: "Location Data", + TitleDE: "Standortdaten", + Description: "Location tracking requires transparency and consent", + DescriptionDE: "Standortverfolgung erfordert Transparenz und Einwilligung", + Severity: SeverityINFO, + ScoreDelta: 10, + GDPRRef: "Art. 5, Art. 7 DSGVO", + Controls: []string{"C-CONSENT", "C-TRANSPARENCY"}, + Patterns: []string{"P-LOG-MINIMIZATION"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.DataTypes.LocationData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Standortdaten ermöglichen Bewegungsprofile und erfordern klare Einwilligung und Aufbewahrungslimits." + }, + }, + { + Code: "R-006", + Category: "A. Datenklassifikation", + Title: "Employee Data", + TitleDE: "Mitarbeiterdaten", + Description: "Employee data processing has special considerations", + DescriptionDE: "Mitarbeiterdatenverarbeitung hat besondere Anforderungen", + Severity: SeverityINFO, + ScoreDelta: 10, + GDPRRef: "§ 26 BDSG", + Controls: []string{"C-ACCESS-CONTROL", "C-TRANSPARENCY"}, + Patterns: []string{"P-NAMESPACE-ISOLATION"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.DataTypes.EmployeeData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Mitarbeiterdaten unterliegen zusätzlich dem BDSG § 26 und erfordern klare Zweckbindung." + }, + }, + // ========================================================================= + // B. Zweck & Kontext (R-010 bis R-013) + // ========================================================================= + { + Code: "R-010", + Category: "B. Zweck & Kontext", + Title: "Marketing with Personal Data", + TitleDE: "Marketing mit personenbezogenen Daten", + Description: "Marketing purposes with PII require explicit consent", + DescriptionDE: "Marketing mit PII erfordert ausdrückliche Einwilligung", + Severity: SeverityWARN, + ScoreDelta: 15, + GDPRRef: "Art. 6(1)(a) DSGVO", + Controls: []string{"C-CONSENT", "C-DSR-PROCESS"}, + Patterns: []string{"P-PRE-ANON"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Purpose.Marketing && intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Marketing mit personenbezogenen Daten erfordert ausdrückliche, freiwillige Einwilligung." + }, + }, + { + Code: "R-011", + Category: "B. Zweck & Kontext", + Title: "Profiling Purpose", + TitleDE: "Profiling-Zweck", + Description: "Profiling requires DSFA and transparency", + DescriptionDE: "Profiling erfordert DSFA und Transparenz", + Severity: SeverityWARN, + ScoreDelta: 15, + GDPRRef: "Art. 22 DSGVO", + Controls: []string{"C-DSFA", "C-TRANSPARENCY", "C-DSR-PROCESS"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Purpose.Profiling + }, + Rationale: func(intake *UseCaseIntake) string { + return "Profiling erfordert eine DSFA und transparente Information der Betroffenen über die Logik und Auswirkungen." + }, + }, + { + Code: "R-012", + Category: "B. Zweck & Kontext", + Title: "Evaluation/Scoring Purpose", + TitleDE: "Bewertungs-/Scoring-Zweck", + Description: "Scoring of individuals requires safeguards", + DescriptionDE: "Scoring von Personen erfordert Schutzmaßnahmen", + Severity: SeverityWARN, + ScoreDelta: 15, + GDPRRef: "Art. 22 DSGVO", + Controls: []string{"C-HITL", "C-TRANSPARENCY"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Purpose.EvaluationScoring + }, + Rationale: func(intake *UseCaseIntake) string { + return "Bewertung/Scoring von Personen erfordert menschliche Überprüfung und Transparenz über die verwendete Logik." + }, + }, + { + Code: "R-013", + Category: "B. Zweck & Kontext", + Title: "Customer Support - Low Risk", + TitleDE: "Kundenservice - Niedriges Risiko", + Description: "Customer support without PII storage is low risk", + DescriptionDE: "Kundenservice ohne PII-Speicherung ist risikoarm", + Severity: SeverityINFO, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{"P-RAG-ONLY"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Purpose.CustomerSupport && !intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Kundenservice mit öffentlichen FAQ-Daten ohne Speicherung personenbezogener Daten ist risikoarm." + }, + }, + // ========================================================================= + // C. Automatisierung (R-020 bis R-025) + // ========================================================================= + { + Code: "R-020", + Category: "C. Automatisierung", + Title: "Fully Automated with Legal Effects", + TitleDE: "Vollautomatisiert mit rechtlichen Auswirkungen", + Description: "Fully automated decisions with legal effects violate Art. 22", + DescriptionDE: "Vollautomatisierte Entscheidungen mit rechtlichen Auswirkungen verletzen Art. 22", + Severity: SeverityBLOCK, + ScoreDelta: 40, + GDPRRef: "Art. 22 DSGVO", + Controls: []string{"C-HITL"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Automation == AutomationFullyAutomated && intake.Outputs.LegalEffects + }, + Rationale: func(intake *UseCaseIntake) string { + return "Vollautomatisierte Entscheidungen mit rechtlichen Auswirkungen ohne menschliche Beteiligung sind nach Art. 22 DSGVO unzulässig." + }, + }, + { + Code: "R-021", + Category: "C. Automatisierung", + Title: "Fully Automated Rankings/Scores", + TitleDE: "Vollautomatisierte Rankings/Scores", + Description: "Automated scoring requires human review", + DescriptionDE: "Automatisches Scoring erfordert menschliche Überprüfung", + Severity: SeverityWARN, + ScoreDelta: 20, + GDPRRef: "Art. 22 DSGVO", + Controls: []string{"C-HITL", "C-TRANSPARENCY"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Automation == AutomationFullyAutomated && intake.Outputs.RankingsOrScores + }, + Rationale: func(intake *UseCaseIntake) string { + return "Vollautomatisierte Erstellung von Rankings oder Scores erfordert menschliche Überprüfung vor Verwendung." + }, + }, + { + Code: "R-022", + Category: "C. Automatisierung", + Title: "Fully Automated Access Decisions", + TitleDE: "Vollautomatisierte Zugriffsentscheidungen", + Description: "Automated access decisions need safeguards", + DescriptionDE: "Automatisierte Zugriffsentscheidungen benötigen Schutzmaßnahmen", + Severity: SeverityWARN, + ScoreDelta: 15, + GDPRRef: "Art. 22 DSGVO", + Controls: []string{"C-HITL", "C-TRANSPARENCY", "C-DSR-PROCESS"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Automation == AutomationFullyAutomated && intake.Outputs.AccessDecisions + }, + Rationale: func(intake *UseCaseIntake) string { + return "Automatisierte Entscheidungen über Zugang erfordern Widerspruchsmöglichkeit und menschliche Überprüfung." + }, + }, + { + Code: "R-023", + Category: "C. Automatisierung", + Title: "Semi-Automated - Medium Risk", + TitleDE: "Teilautomatisiert - Mittleres Risiko", + Description: "Semi-automated processing with human review", + DescriptionDE: "Teilautomatisierte Verarbeitung mit menschlicher Überprüfung", + Severity: SeverityINFO, + ScoreDelta: 5, + GDPRRef: "", + Controls: []string{"C-AUDIT-LOG"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Automation == AutomationSemiAutomated && intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Teilautomatisierte Verarbeitung mit menschlicher Überprüfung ist grundsätzlich konform, erfordert aber Dokumentation." + }, + }, + { + Code: "R-024", + Category: "C. Automatisierung", + Title: "Assistive Only - Low Risk", + TitleDE: "Nur assistierend - Niedriges Risiko", + Description: "Assistive AI without automated decisions is low risk", + DescriptionDE: "Assistive KI ohne automatisierte Entscheidungen ist risikoarm", + Severity: SeverityINFO, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{"P-RAG-ONLY"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Automation == AutomationAssistive + }, + Rationale: func(intake *UseCaseIntake) string { + return "Rein assistive KI, die nur Vorschläge macht und keine Entscheidungen trifft, ist risikoarm." + }, + }, + { + Code: "R-025", + Category: "C. Automatisierung", + Title: "HR Scoring - Blocked", + TitleDE: "HR-Scoring - Blockiert", + Description: "Automated HR scoring/evaluation is prohibited", + DescriptionDE: "Automatisiertes HR-Scoring/Bewertung ist verboten", + Severity: SeverityBLOCK, + ScoreDelta: 50, + GDPRRef: "Art. 22, § 26 BDSG", + Controls: []string{"C-HITL"}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Domain == DomainHR && + intake.Purpose.EvaluationScoring && + intake.Automation == AutomationFullyAutomated + }, + Rationale: func(intake *UseCaseIntake) string { + return "Vollautomatisierte Bewertung/Scoring von Mitarbeitern ist unzulässig. Arbeitsrechtliche Entscheidungen müssen von Menschen getroffen werden." + }, + }, + // ========================================================================= + // D. Training vs Nutzung (R-030 bis R-035) + // ========================================================================= + { + Code: "R-030", + Category: "D. Training vs Nutzung", + Title: "Training with Personal Data", + TitleDE: "Training mit personenbezogenen Daten", + Description: "Training AI with personal data is high risk", + DescriptionDE: "Training von KI mit personenbezogenen Daten ist hochriskant", + Severity: SeverityBLOCK, + ScoreDelta: 40, + GDPRRef: "Art. 5(1)(b)(c) DSGVO", + Controls: []string{"C-ART9-BASIS", "C-DSFA"}, + Patterns: []string{"P-RAG-ONLY"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.ModelUsage.Training && intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Training von KI-Modellen mit personenbezogenen Daten verstößt gegen Zweckbindung und Datenminimierung. Nutzen Sie stattdessen RAG." + }, + }, + { + Code: "R-031", + Category: "D. Training vs Nutzung", + Title: "Fine-tuning with Personal Data", + TitleDE: "Fine-Tuning mit personenbezogenen Daten", + Description: "Fine-tuning with PII requires safeguards", + DescriptionDE: "Fine-Tuning mit PII erfordert Schutzmaßnahmen", + Severity: SeverityWARN, + ScoreDelta: 25, + GDPRRef: "Art. 5(1)(b)(c) DSGVO", + Controls: []string{"C-ANONYMIZE", "C-DSFA"}, + Patterns: []string{"P-PRE-ANON"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.ModelUsage.Finetune && intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Fine-Tuning mit personenbezogenen Daten ist nur nach Anonymisierung/Pseudonymisierung zulässig." + }, + }, + { + Code: "R-032", + Category: "D. Training vs Nutzung", + Title: "RAG Only - Recommended", + TitleDE: "Nur RAG - Empfohlen", + Description: "RAG without training is the safest approach", + DescriptionDE: "RAG ohne Training ist der sicherste Ansatz", + Severity: SeverityINFO, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{"P-RAG-ONLY"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.ModelUsage.RAG && !intake.ModelUsage.Training && !intake.ModelUsage.Finetune + }, + Rationale: func(intake *UseCaseIntake) string { + return "Nur-RAG ohne Training oder Fine-Tuning ist die empfohlene Architektur für DSGVO-Konformität." + }, + }, + { + Code: "R-033", + Category: "D. Training vs Nutzung", + Title: "Training with Article 9 Data", + TitleDE: "Training mit Art. 9 Daten", + Description: "Training with special category data is prohibited", + DescriptionDE: "Training mit besonderen Datenkategorien ist verboten", + Severity: SeverityBLOCK, + ScoreDelta: 50, + GDPRRef: "Art. 9 DSGVO", + Controls: []string{}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return (intake.ModelUsage.Training || intake.ModelUsage.Finetune) && intake.DataTypes.Article9Data + }, + Rationale: func(intake *UseCaseIntake) string { + return "Training oder Fine-Tuning mit besonderen Kategorien personenbezogener Daten (Gesundheit, Religion, etc.) ist grundsätzlich unzulässig." + }, + }, + { + Code: "R-034", + Category: "D. Training vs Nutzung", + Title: "Inference with Public Data", + TitleDE: "Inferenz mit öffentlichen Daten", + Description: "Using only public data is low risk", + DescriptionDE: "Nutzung nur öffentlicher Daten ist risikoarm", + Severity: SeverityINFO, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return intake.DataTypes.PublicData && !intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Die ausschließliche Nutzung öffentlich zugänglicher Daten ohne Personenbezug ist unproblematisch." + }, + }, + { + Code: "R-035", + Category: "D. Training vs Nutzung", + Title: "Training with Minor Data", + TitleDE: "Training mit Daten Minderjähriger", + Description: "Training with children's data is prohibited", + DescriptionDE: "Training mit Kinderdaten ist verboten", + Severity: SeverityBLOCK, + ScoreDelta: 50, + GDPRRef: "Art. 8 DSGVO, ErwG 38", + Controls: []string{}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return (intake.ModelUsage.Training || intake.ModelUsage.Finetune) && intake.DataTypes.MinorData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Training von KI-Modellen mit Daten von Minderjährigen ist aufgrund des besonderen Schutzes unzulässig." + }, + }, + // ========================================================================= + // E. Speicherung (R-040 bis R-042) + // ========================================================================= + { + Code: "R-040", + Category: "E. Speicherung", + Title: "Storing Prompts with PII", + TitleDE: "Speicherung von Prompts mit PII", + Description: "Storing prompts containing PII requires controls", + DescriptionDE: "Speicherung von Prompts mit PII erfordert Kontrollen", + Severity: SeverityWARN, + ScoreDelta: 15, + GDPRRef: "Art. 5(1)(e) DSGVO", + Controls: []string{"C-RETENTION", "C-ANONYMIZE", "C-DSR-PROCESS"}, + Patterns: []string{"P-LOG-MINIMIZATION", "P-PRE-ANON"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Retention.StorePrompts && intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Speicherung von Prompts mit personenbezogenen Daten erfordert Löschfristen und Anonymisierungsoptionen." + }, + }, + { + Code: "R-041", + Category: "E. Speicherung", + Title: "Storing Responses with PII", + TitleDE: "Speicherung von Antworten mit PII", + Description: "Storing AI responses containing PII requires controls", + DescriptionDE: "Speicherung von KI-Antworten mit PII erfordert Kontrollen", + Severity: SeverityWARN, + ScoreDelta: 10, + GDPRRef: "Art. 5(1)(e) DSGVO", + Controls: []string{"C-RETENTION", "C-DSR-PROCESS"}, + Patterns: []string{"P-LOG-MINIMIZATION"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Retention.StoreResponses && intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Speicherung von KI-Antworten mit personenbezogenen Daten erfordert definierte Aufbewahrungsfristen." + }, + }, + { + Code: "R-042", + Category: "E. Speicherung", + Title: "No Retention Policy", + TitleDE: "Keine Aufbewahrungsrichtlinie", + Description: "PII storage without retention limits is problematic", + DescriptionDE: "PII-Speicherung ohne Aufbewahrungslimits ist problematisch", + Severity: SeverityWARN, + ScoreDelta: 10, + GDPRRef: "Art. 5(1)(e) DSGVO", + Controls: []string{"C-RETENTION"}, + Patterns: []string{"P-LOG-MINIMIZATION"}, + Condition: func(intake *UseCaseIntake) bool { + return (intake.Retention.StorePrompts || intake.Retention.StoreResponses) && + intake.DataTypes.PersonalData && + intake.Retention.RetentionDays == 0 + }, + Rationale: func(intake *UseCaseIntake) string { + return "Speicherung personenbezogener Daten ohne definierte Aufbewahrungsfrist verstößt gegen den Grundsatz der Speicherbegrenzung." + }, + }, + // ========================================================================= + // F. Hosting (R-050 bis R-052) + // ========================================================================= + { + Code: "R-050", + Category: "F. Hosting", + Title: "Third Country Transfer with PII", + TitleDE: "Drittlandtransfer mit PII", + Description: "Transferring PII to third countries requires safeguards", + DescriptionDE: "Übermittlung von PII in Drittländer erfordert Schutzmaßnahmen", + Severity: SeverityWARN, + ScoreDelta: 20, + GDPRRef: "Art. 44-49 DSGVO", + Controls: []string{"C-SCC", "C-ENCRYPTION"}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Hosting.Region == "third_country" && intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Übermittlung personenbezogener Daten in Drittländer erfordert Standardvertragsklauseln oder andere geeignete Garantien." + }, + }, + { + Code: "R-051", + Category: "F. Hosting", + Title: "EU Hosting - Compliant", + TitleDE: "EU-Hosting - Konform", + Description: "Hosting within EU is compliant with GDPR", + DescriptionDE: "Hosting innerhalb der EU ist DSGVO-konform", + Severity: SeverityINFO, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Hosting.Region == "eu" + }, + Rationale: func(intake *UseCaseIntake) string { + return "Hosting innerhalb der EU/EWR erfüllt grundsätzlich die DSGVO-Anforderungen an den Datenstandort." + }, + }, + { + Code: "R-052", + Category: "F. Hosting", + Title: "On-Premise Hosting", + TitleDE: "On-Premise-Hosting", + Description: "On-premise hosting gives most control", + DescriptionDE: "On-Premise-Hosting gibt die meiste Kontrolle", + Severity: SeverityINFO, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{"C-ENCRYPTION"}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Hosting.Region == "on_prem" + }, + Rationale: func(intake *UseCaseIntake) string { + return "On-Premise-Hosting bietet maximale Kontrolle über Daten, erfordert aber eigene Sicherheitsmaßnahmen." + }, + }, + // ========================================================================= + // G. Transparenz (R-060 bis R-062) + // ========================================================================= + { + Code: "R-060", + Category: "G. Transparenz", + Title: "No Human Review for Decisions", + TitleDE: "Keine menschliche Überprüfung bei Entscheidungen", + Description: "Decisions affecting individuals need human review option", + DescriptionDE: "Entscheidungen, die Personen betreffen, benötigen menschliche Überprüfungsoption", + Severity: SeverityWARN, + ScoreDelta: 15, + GDPRRef: "Art. 22(3) DSGVO", + Controls: []string{"C-HITL", "C-DSR-PROCESS"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return (intake.Outputs.LegalEffects || intake.Outputs.AccessDecisions || intake.Purpose.DecisionMaking) && + intake.Automation != AutomationAssistive + }, + Rationale: func(intake *UseCaseIntake) string { + return "Betroffene haben das Recht auf menschliche Überprüfung bei automatisierten Entscheidungen." + }, + }, + { + Code: "R-061", + Category: "G. Transparenz", + Title: "External Recommendations", + TitleDE: "Externe Empfehlungen", + Description: "Recommendations to users need transparency", + DescriptionDE: "Empfehlungen an Nutzer erfordern Transparenz", + Severity: SeverityINFO, + ScoreDelta: 5, + GDPRRef: "Art. 13/14 DSGVO", + Controls: []string{"C-TRANSPARENCY"}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Outputs.RecommendationsToUsers && intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Personalisierte Empfehlungen erfordern Information der Nutzer über die KI-Verarbeitung." + }, + }, + { + Code: "R-062", + Category: "G. Transparenz", + Title: "Content Generation without Disclosure", + TitleDE: "Inhaltsgenerierung ohne Offenlegung", + Description: "AI-generated content should be disclosed", + DescriptionDE: "KI-generierte Inhalte sollten offengelegt werden", + Severity: SeverityINFO, + ScoreDelta: 5, + GDPRRef: "EU-AI-Act Art. 52", + Controls: []string{"C-TRANSPARENCY"}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Outputs.ContentGeneration + }, + Rationale: func(intake *UseCaseIntake) string { + return "KI-generierte Inhalte sollten als solche gekennzeichnet werden (EU-AI-Act Transparenzpflicht)." + }, + }, + // ========================================================================= + // H. Domain-spezifisch (R-070 bis R-074) + // ========================================================================= + { + Code: "R-070", + Category: "H. Domain-spezifisch", + Title: "Education + Scoring = Blocked", + TitleDE: "Bildung + Scoring = Blockiert", + Description: "Automated scoring of students is prohibited", + DescriptionDE: "Automatisches Scoring von Schülern ist verboten", + Severity: SeverityBLOCK, + ScoreDelta: 50, + GDPRRef: "Art. 8, Art. 22 DSGVO", + Controls: []string{}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Domain == DomainEducation && + intake.DataTypes.MinorData && + (intake.Purpose.EvaluationScoring || intake.Outputs.RankingsOrScores) + }, + Rationale: func(intake *UseCaseIntake) string { + return "Automatisches Scoring oder Ranking von Schülern/Minderjährigen ist aufgrund des besonderen Schutzes unzulässig." + }, + }, + { + Code: "R-071", + Category: "H. Domain-spezifisch", + Title: "Healthcare + Automated Diagnosis", + TitleDE: "Gesundheit + Automatische Diagnose", + Description: "Automated medical decisions require strict controls", + DescriptionDE: "Automatische medizinische Entscheidungen erfordern strenge Kontrollen", + Severity: SeverityBLOCK, + ScoreDelta: 45, + GDPRRef: "Art. 9, Art. 22 DSGVO", + Controls: []string{"C-HITL", "C-DSFA", "C-ART9-BASIS"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Domain == DomainHealthcare && + intake.Automation == AutomationFullyAutomated && + intake.Purpose.DecisionMaking + }, + Rationale: func(intake *UseCaseIntake) string { + return "Vollautomatisierte medizinische Diagnosen oder Behandlungsentscheidungen sind ohne ärztliche Überprüfung unzulässig." + }, + }, + { + Code: "R-072", + Category: "H. Domain-spezifisch", + Title: "Finance + Automated Credit Scoring", + TitleDE: "Finanzen + Automatisches Credit-Scoring", + Description: "Automated credit decisions require transparency", + DescriptionDE: "Automatische Kreditentscheidungen erfordern Transparenz", + Severity: SeverityWARN, + ScoreDelta: 20, + GDPRRef: "Art. 22 DSGVO", + Controls: []string{"C-HITL", "C-TRANSPARENCY", "C-DSR-PROCESS"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Domain == DomainFinance && + (intake.Purpose.EvaluationScoring || intake.Outputs.RankingsOrScores) && + intake.DataTypes.FinancialData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Automatische Kreditwürdigkeitsprüfung erfordert Erklärbarkeit und Widerspruchsmöglichkeit." + }, + }, + { + Code: "R-073", + Category: "H. Domain-spezifisch", + Title: "Utilities + RAG Chatbot = Low Risk", + TitleDE: "Versorgungsunternehmen + RAG-Chatbot = Niedriges Risiko", + Description: "RAG-based customer service chatbot is low risk", + DescriptionDE: "RAG-basierter Kundenservice-Chatbot ist risikoarm", + Severity: SeverityINFO, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{"P-RAG-ONLY"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Domain == DomainUtilities && + intake.ModelUsage.RAG && + intake.Purpose.CustomerSupport && + !intake.DataTypes.PersonalData + }, + Rationale: func(intake *UseCaseIntake) string { + return "Ein RAG-basierter Kundenservice-Chatbot ohne Speicherung personenbezogener Daten ist ein Best-Practice-Beispiel." + }, + }, + { + Code: "R-074", + Category: "H. Domain-spezifisch", + Title: "Public Sector + Automated Decisions", + TitleDE: "Öffentlicher Sektor + Automatische Entscheidungen", + Description: "Public sector automated decisions need special care", + DescriptionDE: "Automatische Entscheidungen im öffentlichen Sektor erfordern besondere Sorgfalt", + Severity: SeverityWARN, + ScoreDelta: 20, + GDPRRef: "Art. 22 DSGVO", + Controls: []string{"C-HITL", "C-TRANSPARENCY", "C-DSFA"}, + Patterns: []string{"P-HITL-ENFORCED"}, + Condition: func(intake *UseCaseIntake) bool { + return intake.Domain == DomainPublic && + intake.Purpose.DecisionMaking && + intake.Automation != AutomationAssistive + }, + Rationale: func(intake *UseCaseIntake) string { + return "Verwaltungsentscheidungen, die Bürger betreffen, erfordern besondere Transparenz und Überprüfungsmöglichkeiten." + }, + }, + // ========================================================================= + // I. Aggregation (R-090 bis R-092) - Implicit in Evaluate() + // ========================================================================= + { + Code: "R-090", + Category: "I. Aggregation", + Title: "Block Rules Triggered", + TitleDE: "Blockierungsregeln ausgelöst", + Description: "Any BLOCK severity results in NO feasibility", + DescriptionDE: "Jede BLOCK-Schwere führt zu NEIN-Machbarkeit", + Severity: SeverityBLOCK, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + // This is handled in aggregation logic + return false + }, + Rationale: func(intake *UseCaseIntake) string { + return "Eine oder mehrere kritische Regelverletzungen führen zur Einstufung als nicht umsetzbar." + }, + }, + { + Code: "R-091", + Category: "I. Aggregation", + Title: "Warning Rules Only", + TitleDE: "Nur Warnungsregeln", + Description: "Only WARN severity results in CONDITIONAL", + DescriptionDE: "Nur WARN-Schwere führt zu BEDINGT", + Severity: SeverityWARN, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + // This is handled in aggregation logic + return false + }, + Rationale: func(intake *UseCaseIntake) string { + return "Warnungen erfordern Maßnahmen, blockieren aber nicht die Umsetzung." + }, + }, + { + Code: "R-092", + Category: "I. Aggregation", + Title: "Info Only - Clear Path", + TitleDE: "Nur Info - Freier Weg", + Description: "Only INFO severity results in YES", + DescriptionDE: "Nur INFO-Schwere führt zu JA", + Severity: SeverityINFO, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + // This is handled in aggregation logic + return false + }, + Rationale: func(intake *UseCaseIntake) string { + return "Keine kritischen oder warnenden Regeln ausgelöst - Umsetzung empfohlen." + }, + }, + // ========================================================================= + // J. Erklärung (R-100) + // ========================================================================= + { + Code: "R-100", + Category: "J. Erklärung", + Title: "Rejection Must Include Reason and Alternative", + TitleDE: "Ablehnung muss Begründung und Alternative enthalten", + Description: "When feasibility is NO, provide reason and alternative", + DescriptionDE: "Bei Machbarkeit NEIN, Begründung und Alternative angeben", + Severity: SeverityINFO, + ScoreDelta: 0, + GDPRRef: "", + Controls: []string{}, + Patterns: []string{}, + Condition: func(intake *UseCaseIntake) bool { + // This is handled in summary generation + return false + }, + Rationale: func(intake *UseCaseIntake) string { + return "Jede Ablehnung enthält eine klare Begründung und einen alternativen Ansatz." + }, + }, +} diff --git a/ai-compliance-sdk/internal/ucca/store.go b/ai-compliance-sdk/internal/ucca/store.go new file mode 100644 index 0000000..ba4279d --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/store.go @@ -0,0 +1,313 @@ +package ucca + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store handles UCCA data persistence +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new UCCA store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// ============================================================================ +// Assessment CRUD Operations +// ============================================================================ + +// CreateAssessment creates a new assessment +func (s *Store) CreateAssessment(ctx context.Context, a *Assessment) error { + a.ID = uuid.New() + a.CreatedAt = time.Now().UTC() + a.UpdatedAt = a.CreatedAt + if a.PolicyVersion == "" { + a.PolicyVersion = "1.0.0" + } + if a.Status == "" { + a.Status = "completed" + } + + // Marshal JSONB fields + intake, _ := json.Marshal(a.Intake) + triggeredRules, _ := json.Marshal(a.TriggeredRules) + requiredControls, _ := json.Marshal(a.RequiredControls) + recommendedArchitecture, _ := json.Marshal(a.RecommendedArchitecture) + forbiddenPatterns, _ := json.Marshal(a.ForbiddenPatterns) + exampleMatches, _ := json.Marshal(a.ExampleMatches) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO ucca_assessments ( + id, tenant_id, namespace_id, title, policy_version, status, + intake, use_case_text_stored, use_case_text_hash, + feasibility, risk_level, complexity, risk_score, + triggered_rules, required_controls, recommended_architecture, + forbidden_patterns, example_matches, + dsfa_recommended, art22_risk, training_allowed, + explanation_text, explanation_generated_at, explanation_model, + domain, created_at, updated_at, created_by + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, + $10, $11, $12, $13, + $14, $15, $16, + $17, $18, + $19, $20, $21, + $22, $23, $24, + $25, $26, $27, $28 + ) + `, + a.ID, a.TenantID, a.NamespaceID, a.Title, a.PolicyVersion, a.Status, + intake, a.UseCaseTextStored, a.UseCaseTextHash, + string(a.Feasibility), string(a.RiskLevel), string(a.Complexity), a.RiskScore, + triggeredRules, requiredControls, recommendedArchitecture, + forbiddenPatterns, exampleMatches, + a.DSFARecommended, a.Art22Risk, string(a.TrainingAllowed), + a.ExplanationText, a.ExplanationGeneratedAt, a.ExplanationModel, + string(a.Domain), a.CreatedAt, a.UpdatedAt, a.CreatedBy, + ) + + return err +} + +// GetAssessment retrieves an assessment by ID +func (s *Store) GetAssessment(ctx context.Context, id uuid.UUID) (*Assessment, error) { + var a Assessment + var intake, triggeredRules, requiredControls, recommendedArchitecture, forbiddenPatterns, exampleMatches []byte + var feasibility, riskLevel, complexity, trainingAllowed, domain string + + err := s.pool.QueryRow(ctx, ` + SELECT + id, tenant_id, namespace_id, title, policy_version, status, + intake, use_case_text_stored, use_case_text_hash, + feasibility, risk_level, complexity, risk_score, + triggered_rules, required_controls, recommended_architecture, + forbidden_patterns, example_matches, + dsfa_recommended, art22_risk, training_allowed, + explanation_text, explanation_generated_at, explanation_model, + domain, created_at, updated_at, created_by + FROM ucca_assessments WHERE id = $1 + `, id).Scan( + &a.ID, &a.TenantID, &a.NamespaceID, &a.Title, &a.PolicyVersion, &a.Status, + &intake, &a.UseCaseTextStored, &a.UseCaseTextHash, + &feasibility, &riskLevel, &complexity, &a.RiskScore, + &triggeredRules, &requiredControls, &recommendedArchitecture, + &forbiddenPatterns, &exampleMatches, + &a.DSFARecommended, &a.Art22Risk, &trainingAllowed, + &a.ExplanationText, &a.ExplanationGeneratedAt, &a.ExplanationModel, + &domain, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + // Unmarshal JSONB fields + json.Unmarshal(intake, &a.Intake) + json.Unmarshal(triggeredRules, &a.TriggeredRules) + json.Unmarshal(requiredControls, &a.RequiredControls) + json.Unmarshal(recommendedArchitecture, &a.RecommendedArchitecture) + json.Unmarshal(forbiddenPatterns, &a.ForbiddenPatterns) + json.Unmarshal(exampleMatches, &a.ExampleMatches) + + // Convert string fields to typed constants + a.Feasibility = Feasibility(feasibility) + a.RiskLevel = RiskLevel(riskLevel) + a.Complexity = Complexity(complexity) + a.TrainingAllowed = TrainingAllowed(trainingAllowed) + a.Domain = Domain(domain) + + return &a, nil +} + +// ListAssessments lists assessments for a tenant with optional filters +func (s *Store) ListAssessments(ctx context.Context, tenantID uuid.UUID, filters *AssessmentFilters) ([]Assessment, error) { + query := ` + SELECT + id, tenant_id, namespace_id, title, policy_version, status, + intake, use_case_text_stored, use_case_text_hash, + feasibility, risk_level, complexity, risk_score, + triggered_rules, required_controls, recommended_architecture, + forbidden_patterns, example_matches, + dsfa_recommended, art22_risk, training_allowed, + explanation_text, explanation_generated_at, explanation_model, + domain, created_at, updated_at, created_by + FROM ucca_assessments WHERE tenant_id = $1` + + args := []interface{}{tenantID} + argIdx := 2 + + // Apply filters + if filters != nil { + if filters.Feasibility != "" { + query += " AND feasibility = $" + itoa(argIdx) + args = append(args, filters.Feasibility) + argIdx++ + } + if filters.Domain != "" { + query += " AND domain = $" + itoa(argIdx) + args = append(args, filters.Domain) + argIdx++ + } + if filters.RiskLevel != "" { + query += " AND risk_level = $" + itoa(argIdx) + args = append(args, filters.RiskLevel) + argIdx++ + } + } + + query += " ORDER BY created_at DESC" + + // Apply limit + if filters != nil && filters.Limit > 0 { + query += " LIMIT $" + itoa(argIdx) + args = append(args, filters.Limit) + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var assessments []Assessment + for rows.Next() { + var a Assessment + var intake, triggeredRules, requiredControls, recommendedArchitecture, forbiddenPatterns, exampleMatches []byte + var feasibility, riskLevel, complexity, trainingAllowed, domain string + + err := rows.Scan( + &a.ID, &a.TenantID, &a.NamespaceID, &a.Title, &a.PolicyVersion, &a.Status, + &intake, &a.UseCaseTextStored, &a.UseCaseTextHash, + &feasibility, &riskLevel, &complexity, &a.RiskScore, + &triggeredRules, &requiredControls, &recommendedArchitecture, + &forbiddenPatterns, &exampleMatches, + &a.DSFARecommended, &a.Art22Risk, &trainingAllowed, + &a.ExplanationText, &a.ExplanationGeneratedAt, &a.ExplanationModel, + &domain, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy, + ) + if err != nil { + return nil, err + } + + // Unmarshal JSONB fields + json.Unmarshal(intake, &a.Intake) + json.Unmarshal(triggeredRules, &a.TriggeredRules) + json.Unmarshal(requiredControls, &a.RequiredControls) + json.Unmarshal(recommendedArchitecture, &a.RecommendedArchitecture) + json.Unmarshal(forbiddenPatterns, &a.ForbiddenPatterns) + json.Unmarshal(exampleMatches, &a.ExampleMatches) + + // Convert string fields + a.Feasibility = Feasibility(feasibility) + a.RiskLevel = RiskLevel(riskLevel) + a.Complexity = Complexity(complexity) + a.TrainingAllowed = TrainingAllowed(trainingAllowed) + a.Domain = Domain(domain) + + assessments = append(assessments, a) + } + + return assessments, nil +} + +// DeleteAssessment deletes an assessment by ID +func (s *Store) DeleteAssessment(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, "DELETE FROM ucca_assessments WHERE id = $1", id) + return err +} + +// UpdateExplanation updates the LLM explanation for an assessment +func (s *Store) UpdateExplanation(ctx context.Context, id uuid.UUID, explanation string, model string) error { + now := time.Now().UTC() + _, err := s.pool.Exec(ctx, ` + UPDATE ucca_assessments SET + explanation_text = $2, + explanation_generated_at = $3, + explanation_model = $4, + updated_at = $5 + WHERE id = $1 + `, id, explanation, now, model, now) + return err +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// UCCAStats contains UCCA module statistics +type UCCAStats struct { + TotalAssessments int `json:"total_assessments"` + AssessmentsYES int `json:"assessments_yes"` + AssessmentsCONDITIONAL int `json:"assessments_conditional"` + AssessmentsNO int `json:"assessments_no"` + AverageRiskScore int `json:"average_risk_score"` + DSFARecommendedCount int `json:"dsfa_recommended_count"` +} + +// GetStats returns UCCA statistics for a tenant +func (s *Store) GetStats(ctx context.Context, tenantID uuid.UUID) (*UCCAStats, error) { + stats := &UCCAStats{} + + // Total count + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1", + tenantID).Scan(&stats.TotalAssessments) + + // Count by feasibility + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1 AND feasibility = 'YES'", + tenantID).Scan(&stats.AssessmentsYES) + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1 AND feasibility = 'CONDITIONAL'", + tenantID).Scan(&stats.AssessmentsCONDITIONAL) + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1 AND feasibility = 'NO'", + tenantID).Scan(&stats.AssessmentsNO) + + // Average risk score + s.pool.QueryRow(ctx, + "SELECT COALESCE(AVG(risk_score)::int, 0) FROM ucca_assessments WHERE tenant_id = $1", + tenantID).Scan(&stats.AverageRiskScore) + + // DSFA recommended count + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM ucca_assessments WHERE tenant_id = $1 AND dsfa_recommended = true", + tenantID).Scan(&stats.DSFARecommendedCount) + + return stats, nil +} + +// ============================================================================ +// Filter Types +// ============================================================================ + +// AssessmentFilters defines filters for listing assessments +type AssessmentFilters struct { + Feasibility string + Domain string + RiskLevel string + Limit int +} + +// ============================================================================ +// Helpers +// ============================================================================ + +// itoa converts int to string for query building +func itoa(i int) string { + return string(rune('0' + i)) +} diff --git a/ai-compliance-sdk/internal/ucca/unified_facts.go b/ai-compliance-sdk/internal/ucca/unified_facts.go new file mode 100644 index 0000000..4a89331 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/unified_facts.go @@ -0,0 +1,439 @@ +package ucca + +// ============================================================================ +// Unified Facts Model +// ============================================================================ +// +// UnifiedFacts aggregates all facts about an organization that are needed +// to determine which regulations apply and what obligations arise. +// This model is regulation-agnostic and serves as input for all modules. +// +// ============================================================================ + +// UnifiedFacts is the comprehensive facts model for all regulatory assessments +type UnifiedFacts struct { + // Existing UCCA Facts (for backwards compatibility) + UCCAFacts *UseCaseIntake `json:"ucca_facts,omitempty"` + + // Organization facts + Organization OrganizationFacts `json:"organization"` + + // Sector/Industry facts (for NIS2, sector-specific regulations) + Sector SectorFacts `json:"sector"` + + // Data protection facts (for DSGVO) + DataProtection DataProtectionFacts `json:"data_protection"` + + // AI usage facts (for AI Act) + AIUsage AIUsageFacts `json:"ai_usage"` + + // Financial sector facts (for MaRisk, DORA) + Financial FinancialFacts `json:"financial"` + + // IT Security facts (for NIS2) + ITSecurity ITSecurityFacts `json:"it_security"` + + // Supply chain facts + SupplyChain SupplyChainFacts `json:"supply_chain"` + + // Personnel facts + Personnel PersonnelFacts `json:"personnel"` +} + +// OrganizationFacts contains basic organizational information +type OrganizationFacts struct { + // Basic info + Name string `json:"name,omitempty"` + LegalForm string `json:"legal_form,omitempty"` // e.g., "GmbH", "AG", "e.V." + Country string `json:"country"` // e.g., "DE", "AT", "CH" + EUMember bool `json:"eu_member"` + + // Organization type + IsPublicAuthority bool `json:"is_public_authority"` // Public authority or body + + // Size metrics (KMU criteria) + EmployeeCount int `json:"employee_count"` + AnnualRevenue float64 `json:"annual_revenue"` // in EUR + BalanceSheetTotal float64 `json:"balance_sheet_total"` // in EUR + + // Calculated size category + SizeCategory string `json:"size_category,omitempty"` // "micro", "small", "medium", "large" + + // Group structure + IsPartOfGroup bool `json:"is_part_of_group"` + ParentCompany string `json:"parent_company,omitempty"` + GroupEmployees int `json:"group_employees,omitempty"` + GroupRevenue float64 `json:"group_revenue,omitempty"` +} + +// SectorFacts contains industry/sector information for regulatory classification +type SectorFacts struct { + // Primary sector classification + PrimarySector string `json:"primary_sector"` // NIS2 Annex I/II sector codes + SubSector string `json:"sub_sector,omitempty"` + NACECode string `json:"nace_code,omitempty"` + + // KRITIS classification (German critical infrastructure) + IsKRITIS bool `json:"is_kritis"` + KRITISThresholdMet bool `json:"kritis_threshold_met"` + KRITISSector string `json:"kritis_sector,omitempty"` + + // Special services (NIS2-specific) + SpecialServices []string `json:"special_services,omitempty"` // "dns", "tld", "cloud", "datacenter", "cdn", "msp", "mssp", "trust_service", "public_network", "electronic_comms" + + // Public administration + IsPublicAdministration bool `json:"is_public_administration"` + PublicAdminLevel string `json:"public_admin_level,omitempty"` // "federal", "state", "municipal" + + // Healthcare specific + IsHealthcareProvider bool `json:"is_healthcare_provider"` + HasPatientData bool `json:"has_patient_data"` + + // Financial specific + IsFinancialInstitution bool `json:"is_financial_institution"` + FinancialEntityType string `json:"financial_entity_type,omitempty"` // DORA entity types +} + +// DataProtectionFacts contains GDPR-relevant information +type DataProtectionFacts struct { + // Data processing basics + ProcessesPersonalData bool `json:"processes_personal_data"` + ProcessesSpecialCategories bool `json:"processes_special_categories"` // Art. 9 DSGVO + ProcessesMinorData bool `json:"processes_minor_data"` + ProcessesCriminalData bool `json:"processes_criminal_data"` // Art. 10 DSGVO + + // Controller/Processor role + IsController bool `json:"is_controller"` // Acts as controller + IsProcessor bool `json:"is_processor"` // Acts as processor for others + + // Territorial scope (Art. 3) + OffersToEU bool `json:"offers_to_eu"` // Offers goods/services to EU + MonitorsEUIndividuals bool `json:"monitors_eu_individuals"` // Monitors behavior of EU individuals + + // Scale of processing + LargeScaleProcessing bool `json:"large_scale_processing"` + SystematicMonitoring bool `json:"systematic_monitoring"` + Profiling bool `json:"profiling"` + AutomatedDecisionMaking bool `json:"automated_decision_making"` // Art. 22 DSGVO + AutomatedDecisions bool `json:"automated_decisions"` // Alias for automated_decision_making + LegalEffects bool `json:"legal_effects"` // Automated decisions with legal effects + + // Special categories (Art. 9) - detailed + SpecialCategories []string `json:"special_categories,omitempty"` // racial_ethnic_origin, political_opinions, etc. + + // High-risk activities (Art. 35) + HighRiskActivities []string `json:"high_risk_activities,omitempty"` // systematic_monitoring, automated_decisions, etc. + + // Data subjects + DataSubjectCount int `json:"data_subject_count"` // Approximate number + DataSubjectCountRange string `json:"data_subject_count_range,omitempty"` // "< 1000", "1000-10000", "> 10000", "> 100000" + + // Data transfers + TransfersToThirdCountries bool `json:"transfers_to_third_countries"` + CrossBorderProcessing bool `json:"cross_border_processing"` // Processing across EU borders + SCCsInPlace bool `json:"sccs_in_place"` + BindingCorporateRules bool `json:"binding_corporate_rules"` + + // External processing + UsesExternalProcessor bool `json:"uses_external_processor"` + + // DSB requirement triggers + RequiresDSBByLaw bool `json:"requires_dsb_by_law"` + HasAppointedDSB bool `json:"has_appointed_dsb"` + DSBIsInternal bool `json:"dsb_is_internal"` +} + +// AIUsageFacts contains AI Act relevant information +type AIUsageFacts struct { + // Basic AI usage + UsesAI bool `json:"uses_ai"` + AIApplications []string `json:"ai_applications,omitempty"` + + // AI Act role + IsAIProvider bool `json:"is_ai_provider"` // Develops/provides AI systems + IsAIDeployer bool `json:"is_ai_deployer"` // Uses AI systems + IsAIDistributor bool `json:"is_ai_distributor"` + IsAIImporter bool `json:"is_ai_importer"` + + // Risk categories (pre-classification) + HasHighRiskAI bool `json:"has_high_risk_ai"` + HasLimitedRiskAI bool `json:"has_limited_risk_ai"` + HasMinimalRiskAI bool `json:"has_minimal_risk_ai"` + + // Specific high-risk categories (Annex III) + BiometricIdentification bool `json:"biometric_identification"` // Real-time, remote + CriticalInfrastructure bool `json:"critical_infrastructure"` // AI in CI + EducationAccess bool `json:"education_access"` // Admission, assessment + EmploymentDecisions bool `json:"employment_decisions"` // Recruitment, evaluation + EssentialServices bool `json:"essential_services"` // Credit, benefits + LawEnforcement bool `json:"law_enforcement"` + MigrationAsylum bool `json:"migration_asylum"` + JusticeAdministration bool `json:"justice_administration"` + + // Prohibited practices (Art. 5) + SocialScoring bool `json:"social_scoring"` + EmotionRecognition bool `json:"emotion_recognition"` // Workplace/education + PredictivePolicingIndividual bool `json:"predictive_policing_individual"` + + // GPAI (General Purpose AI) + UsesGPAI bool `json:"uses_gpai"` + GPAIWithSystemicRisk bool `json:"gpai_with_systemic_risk"` + + // Transparency obligations + AIInteractsWithNaturalPersons bool `json:"ai_interacts_with_natural_persons"` + GeneratesDeepfakes bool `json:"generates_deepfakes"` +} + +// FinancialFacts contains financial regulation specific information +type FinancialFacts struct { + // Entity type (DORA scope) + EntityType string `json:"entity_type,omitempty"` // Credit institution, payment service provider, etc. + IsRegulated bool `json:"is_regulated"` + + // DORA specific + DORAApplies bool `json:"dora_applies"` + HasCriticalICT bool `json:"has_critical_ict"` + ICTOutsourced bool `json:"ict_outsourced"` + ICTProviderLocation string `json:"ict_provider_location,omitempty"` // "EU", "EEA", "third_country" + ConcentrationRisk bool `json:"concentration_risk"` + + // MaRisk/BAIT specific (Germany) + MaRiskApplies bool `json:"marisk_applies"` + BAITApplies bool `json:"bait_applies"` + + // AI in financial services + AIAffectsCustomers bool `json:"ai_affects_customers"` + AlgorithmicTrading bool `json:"algorithmic_trading"` + AIRiskAssessment bool `json:"ai_risk_assessment"` + AIAMLKYC bool `json:"ai_aml_kyc"` +} + +// ITSecurityFacts contains IT security posture information +type ITSecurityFacts struct { + // ISMS status + HasISMS bool `json:"has_isms"` + ISO27001Certified bool `json:"iso27001_certified"` + ISO27001CertExpiry string `json:"iso27001_cert_expiry,omitempty"` + + // Security processes + HasIncidentProcess bool `json:"has_incident_process"` + HasVulnerabilityMgmt bool `json:"has_vulnerability_mgmt"` + HasPatchMgmt bool `json:"has_patch_mgmt"` + HasAccessControl bool `json:"has_access_control"` + HasMFA bool `json:"has_mfa"` + HasEncryption bool `json:"has_encryption"` + HasBackup bool `json:"has_backup"` + HasDisasterRecovery bool `json:"has_disaster_recovery"` + + // BCM + HasBCM bool `json:"has_bcm"` + BCMTested bool `json:"bcm_tested"` + + // Network security + HasNetworkSegmentation bool `json:"has_network_segmentation"` + HasFirewall bool `json:"has_firewall"` + HasIDS bool `json:"has_ids"` + + // Monitoring + HasSecurityMonitoring bool `json:"has_security_monitoring"` + Has24x7SOC bool `json:"has_24x7_soc"` + + // Awareness + SecurityAwarenessTraining bool `json:"security_awareness_training"` + RegularSecurityTraining bool `json:"regular_security_training"` + + // Certifications + OtherCertifications []string `json:"other_certifications,omitempty"` // SOC2, BSI C5, etc. +} + +// SupplyChainFacts contains supply chain security information +type SupplyChainFacts struct { + // Supply chain basics + HasSupplyChainRiskMgmt bool `json:"has_supply_chain_risk_mgmt"` + SupplierCount int `json:"supplier_count,omitempty"` + CriticalSupplierCount int `json:"critical_supplier_count,omitempty"` + + // Third party risk + ThirdPartyAudits bool `json:"third_party_audits"` + SupplierSecurityReqs bool `json:"supplier_security_reqs"` + + // ICT supply chain (NIS2) + HasICTSupplyChainPolicy bool `json:"has_ict_supply_chain_policy"` + ICTSupplierDiversity bool `json:"ict_supplier_diversity"` +} + +// PersonnelFacts contains workforce-related information +type PersonnelFacts struct { + // Security personnel + HasCISO bool `json:"has_ciso"` + HasDedicatedSecurityTeam bool `json:"has_dedicated_security_team"` + SecurityTeamSize int `json:"security_team_size,omitempty"` + + // Compliance personnel + HasComplianceOfficer bool `json:"has_compliance_officer"` + HasDPO bool `json:"has_dpo"` + DPOIsExternal bool `json:"dpo_is_external"` + + // AI personnel (AI Act) + HasAICompetence bool `json:"has_ai_competence"` + HasAIGovernance bool `json:"has_ai_governance"` +} + +// ============================================================================ +// Helper Methods +// ============================================================================ + +// CalculateSizeCategory determines the organization size based on EU SME criteria +func (o *OrganizationFacts) CalculateSizeCategory() string { + // Use group figures if part of a group + employees := o.EmployeeCount + revenue := o.AnnualRevenue + balance := o.BalanceSheetTotal + + if o.IsPartOfGroup && o.GroupEmployees > 0 { + employees = o.GroupEmployees + } + if o.IsPartOfGroup && o.GroupRevenue > 0 { + revenue = o.GroupRevenue + } + + // EU SME Criteria (Recommendation 2003/361/EC) + // Micro: <10 employees AND (revenue OR balance) <= €2m + // Small: <50 employees AND (revenue OR balance) <= €10m + // Medium: <250 employees AND revenue <= €50m OR balance <= €43m + // Large: everything else + + if employees < 10 && (revenue <= 2_000_000 || balance <= 2_000_000) { + return "micro" + } + if employees < 50 && (revenue <= 10_000_000 || balance <= 10_000_000) { + return "small" + } + if employees < 250 && (revenue <= 50_000_000 || balance <= 43_000_000) { + return "medium" + } + return "large" +} + +// IsSME returns true if the organization qualifies as an SME +func (o *OrganizationFacts) IsSME() bool { + category := o.CalculateSizeCategory() + return category == "micro" || category == "small" || category == "medium" +} + +// MeetsNIS2SizeThreshold checks if organization meets NIS2 size threshold +// (>= 50 employees OR >= €10m revenue AND >= €10m balance) +func (o *OrganizationFacts) MeetsNIS2SizeThreshold() bool { + employees := o.EmployeeCount + revenue := o.AnnualRevenue + balance := o.BalanceSheetTotal + + if o.IsPartOfGroup && o.GroupEmployees > 0 { + employees = o.GroupEmployees + } + if o.IsPartOfGroup && o.GroupRevenue > 0 { + revenue = o.GroupRevenue + } + + // NIS2 size threshold: medium+ enterprises + // >= 50 employees OR (>= €10m revenue AND >= €10m balance) + if employees >= 50 { + return true + } + if revenue >= 10_000_000 && balance >= 10_000_000 { + return true + } + return false +} + +// MeetsNIS2LargeThreshold checks if organization meets NIS2 "large" threshold +// (>= 250 employees OR >= €50m revenue) +func (o *OrganizationFacts) MeetsNIS2LargeThreshold() bool { + employees := o.EmployeeCount + revenue := o.AnnualRevenue + + if o.IsPartOfGroup && o.GroupEmployees > 0 { + employees = o.GroupEmployees + } + if o.IsPartOfGroup && o.GroupRevenue > 0 { + revenue = o.GroupRevenue + } + + return employees >= 250 || revenue >= 50_000_000 +} + +// NewUnifiedFacts creates a new UnifiedFacts with default values +func NewUnifiedFacts() *UnifiedFacts { + return &UnifiedFacts{ + Organization: OrganizationFacts{Country: "DE", EUMember: true}, + Sector: SectorFacts{}, + DataProtection: DataProtectionFacts{}, + AIUsage: AIUsageFacts{}, + Financial: FinancialFacts{}, + ITSecurity: ITSecurityFacts{}, + SupplyChain: SupplyChainFacts{}, + Personnel: PersonnelFacts{}, + } +} + +// FromUseCaseIntake creates UnifiedFacts from an existing UseCaseIntake +func (f *UnifiedFacts) FromUseCaseIntake(intake *UseCaseIntake) { + f.UCCAFacts = intake + + // Map data types + f.DataProtection.ProcessesPersonalData = intake.DataTypes.PersonalData + f.DataProtection.ProcessesSpecialCategories = intake.DataTypes.Article9Data + f.DataProtection.ProcessesMinorData = intake.DataTypes.MinorData + f.DataProtection.Profiling = intake.Purpose.Profiling + f.DataProtection.AutomatedDecisionMaking = intake.Purpose.DecisionMaking + + // Map AI usage + if intake.ModelUsage.RAG || intake.ModelUsage.Training || intake.ModelUsage.Finetune || intake.ModelUsage.Inference { + f.AIUsage.UsesAI = true + f.AIUsage.IsAIDeployer = true + } + + // Map sector from domain + f.MapDomainToSector(string(intake.Domain)) +} + +// MapDomainToSector maps a UCCA domain to sector facts +func (f *UnifiedFacts) MapDomainToSector(domain string) { + // Map common domains to NIS2 sectors + sectorMap := map[string]string{ + "energy": "energy", + "utilities": "energy", + "oil_gas": "energy", + "banking": "banking_financial", + "finance": "banking_financial", + "insurance": "banking_financial", + "investment": "banking_financial", + "healthcare": "health", + "pharma": "health", + "medical_devices": "health", + "logistics": "transport", + "telecom": "digital_infrastructure", + "it_services": "ict_service_mgmt", + "cybersecurity": "ict_service_mgmt", + "public_sector": "public_administration", + "defense": "public_administration", + "food_beverage": "food", + "chemicals": "chemicals", + "research": "research", + "education": "education", + "higher_education": "education", + } + + if sector, ok := sectorMap[domain]; ok { + f.Sector.PrimarySector = sector + } else { + f.Sector.PrimarySector = "other" + } + + // Set financial flags + if domain == "banking" || domain == "finance" || domain == "insurance" || domain == "investment" { + f.Sector.IsFinancialInstitution = true + f.Financial.IsRegulated = true + f.Financial.DORAApplies = true + } +} diff --git a/ai-compliance-sdk/internal/whistleblower/models.go b/ai-compliance-sdk/internal/whistleblower/models.go new file mode 100644 index 0000000..769bfd4 --- /dev/null +++ b/ai-compliance-sdk/internal/whistleblower/models.go @@ -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) +} diff --git a/ai-compliance-sdk/internal/whistleblower/store.go b/ai-compliance-sdk/internal/whistleblower/store.go new file mode 100644 index 0000000..7efb6cb --- /dev/null +++ b/ai-compliance-sdk/internal/whistleblower/store.go @@ -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 +} diff --git a/ai-compliance-sdk/internal/workshop/models.go b/ai-compliance-sdk/internal/workshop/models.go new file mode 100644 index 0000000..c6a5e9e --- /dev/null +++ b/ai-compliance-sdk/internal/workshop/models.go @@ -0,0 +1,290 @@ +package workshop + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================================ +// Constants / Enums +// ============================================================================ + +// SessionStatus represents the status of a workshop session +type SessionStatus string + +const ( + SessionStatusDraft SessionStatus = "DRAFT" + SessionStatusScheduled SessionStatus = "SCHEDULED" + SessionStatusActive SessionStatus = "ACTIVE" + SessionStatusPaused SessionStatus = "PAUSED" + SessionStatusCompleted SessionStatus = "COMPLETED" + SessionStatusCancelled SessionStatus = "CANCELLED" +) + +// ParticipantRole represents the role of a participant +type ParticipantRole string + +const ( + ParticipantRoleFacilitator ParticipantRole = "FACILITATOR" + ParticipantRoleExpert ParticipantRole = "EXPERT" // DSB, IT-Security, etc. + ParticipantRoleStakeholder ParticipantRole = "STAKEHOLDER" // Business owner + ParticipantRoleObserver ParticipantRole = "OBSERVER" +) + +// ResponseStatus represents the status of a question response +type ResponseStatus string + +const ( + ResponseStatusPending ResponseStatus = "PENDING" + ResponseStatusDraft ResponseStatus = "DRAFT" + ResponseStatusSubmitted ResponseStatus = "SUBMITTED" + ResponseStatusReviewed ResponseStatus = "REVIEWED" +) + +// ============================================================================ +// Main Entities +// ============================================================================ + +// Session represents a workshop session +type Session struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + NamespaceID *uuid.UUID `json:"namespace_id,omitempty"` + + // Session info + Title string `json:"title"` + Description string `json:"description,omitempty"` + SessionType string `json:"session_type"` // "ucca", "dsfa", "custom" + Status SessionStatus `json:"status"` + + // Wizard configuration + WizardSchema string `json:"wizard_schema,omitempty"` // Reference to wizard schema version + CurrentStep int `json:"current_step"` + TotalSteps int `json:"total_steps"` + + // Linked entities + AssessmentID *uuid.UUID `json:"assessment_id,omitempty"` // Link to UCCA assessment + RoadmapID *uuid.UUID `json:"roadmap_id,omitempty"` // Link to roadmap + PortfolioID *uuid.UUID `json:"portfolio_id,omitempty"` // Link to portfolio + + // Scheduling + ScheduledStart *time.Time `json:"scheduled_start,omitempty"` + ScheduledEnd *time.Time `json:"scheduled_end,omitempty"` + ActualStart *time.Time `json:"actual_start,omitempty"` + ActualEnd *time.Time `json:"actual_end,omitempty"` + + // Access control + JoinCode string `json:"join_code,omitempty"` // Code for participants to join + RequireAuth bool `json:"require_auth"` // Require authentication to join + AllowAnonymous bool `json:"allow_anonymous"` // Allow anonymous participation + + // Settings + Settings SessionSettings `json:"settings"` + + // Audit + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedBy uuid.UUID `json:"created_by"` +} + +// SessionSettings contains session configuration +type SessionSettings struct { + AllowBackNavigation bool `json:"allow_back_navigation"` // Can go back to previous steps + RequireAllResponses bool `json:"require_all_responses"` // All questions must be answered + ShowProgressToAll bool `json:"show_progress_to_all"` // Show progress to all participants + AllowNotes bool `json:"allow_notes"` // Allow adding notes + AutoSave bool `json:"auto_save"` // Auto-save responses + TimeoutMinutes int `json:"timeout_minutes,omitempty"` // Auto-pause after inactivity +} + +// Participant represents a participant in a session +type Participant struct { + ID uuid.UUID `json:"id"` + SessionID uuid.UUID `json:"session_id"` + UserID *uuid.UUID `json:"user_id,omitempty"` // Nil for anonymous + + // Info + Name string `json:"name"` + Email string `json:"email,omitempty"` + Role ParticipantRole `json:"role"` + Department string `json:"department,omitempty"` + + // Status + IsActive bool `json:"is_active"` + LastActiveAt *time.Time `json:"last_active_at,omitempty"` + JoinedAt time.Time `json:"joined_at"` + LeftAt *time.Time `json:"left_at,omitempty"` + + // Permissions + CanEdit bool `json:"can_edit"` // Can modify responses + CanComment bool `json:"can_comment"` // Can add comments + CanApprove bool `json:"can_approve"` // Can approve responses +} + +// StepProgress tracks progress on a specific wizard step +type StepProgress struct { + ID uuid.UUID `json:"id"` + SessionID uuid.UUID `json:"session_id"` + StepNumber int `json:"step_number"` + + // Status + Status string `json:"status"` // "pending", "in_progress", "completed", "skipped" + Progress int `json:"progress"` // 0-100 + + // Timestamps + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + + // Facilitator notes + Notes string `json:"notes,omitempty"` +} + +// Response represents a response to a wizard question +type Response struct { + ID uuid.UUID `json:"id"` + SessionID uuid.UUID `json:"session_id"` + ParticipantID uuid.UUID `json:"participant_id"` + + // Question reference + StepNumber int `json:"step_number"` + FieldID string `json:"field_id"` // From wizard schema + + // Response data + Value interface{} `json:"value"` // Can be string, bool, array, etc. + ValueType string `json:"value_type"` // "string", "boolean", "array", "number" + + // Status + Status ResponseStatus `json:"status"` + + // Review + ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + ReviewNotes string `json:"review_notes,omitempty"` + + // Audit + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Comment represents a comment on a response or step +type Comment struct { + ID uuid.UUID `json:"id"` + SessionID uuid.UUID `json:"session_id"` + ParticipantID uuid.UUID `json:"participant_id"` + + // Target + StepNumber *int `json:"step_number,omitempty"` + FieldID *string `json:"field_id,omitempty"` + ResponseID *uuid.UUID `json:"response_id,omitempty"` + + // Content + Text string `json:"text"` + IsResolved bool `json:"is_resolved"` + + // Audit + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SessionSummary contains aggregated session information +type SessionSummary struct { + Session *Session `json:"session"` + Participants []Participant `json:"participants"` + StepProgress []StepProgress `json:"step_progress"` + TotalResponses int `json:"total_responses"` + CompletedSteps int `json:"completed_steps"` + OverallProgress int `json:"overall_progress"` // 0-100 +} + +// ============================================================================ +// API Request/Response Types +// ============================================================================ + +// CreateSessionRequest is the API request for creating a session +type CreateSessionRequest struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + SessionType string `json:"session_type"` + WizardSchema string `json:"wizard_schema,omitempty"` + ScheduledStart *time.Time `json:"scheduled_start,omitempty"` + ScheduledEnd *time.Time `json:"scheduled_end,omitempty"` + Settings SessionSettings `json:"settings,omitempty"` + AssessmentID *uuid.UUID `json:"assessment_id,omitempty"` + RoadmapID *uuid.UUID `json:"roadmap_id,omitempty"` + PortfolioID *uuid.UUID `json:"portfolio_id,omitempty"` +} + +// CreateSessionResponse is the API response for creating a session +type CreateSessionResponse struct { + Session Session `json:"session"` + JoinURL string `json:"join_url,omitempty"` + JoinCode string `json:"join_code,omitempty"` +} + +// JoinSessionRequest is the API request for joining a session +type JoinSessionRequest struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` + Role ParticipantRole `json:"role,omitempty"` + Department string `json:"department,omitempty"` +} + +// JoinSessionResponse is the API response for joining a session +type JoinSessionResponse struct { + Participant Participant `json:"participant"` + Session Session `json:"session"` + Token string `json:"token,omitempty"` // Session-specific token +} + +// SubmitResponseRequest is the API request for submitting a response +type SubmitResponseRequest struct { + StepNumber int `json:"step_number"` + FieldID string `json:"field_id"` + Value interface{} `json:"value"` +} + +// AdvanceStepRequest is the API request for advancing to next step +type AdvanceStepRequest struct { + Responses []SubmitResponseRequest `json:"responses,omitempty"` // Optional batch submit + Notes string `json:"notes,omitempty"` +} + +// SessionFilters defines filters for listing sessions +type SessionFilters struct { + Status SessionStatus + SessionType string + AssessmentID *uuid.UUID + CreatedBy *uuid.UUID + Limit int + Offset int +} + +// SessionStats contains statistics for a session +type SessionStats struct { + ParticipantCount int `json:"participant_count"` + ActiveParticipants int `json:"active_participants"` + ResponseCount int `json:"response_count"` + CommentCount int `json:"comment_count"` + CompletedSteps int `json:"completed_steps"` + TotalSteps int `json:"total_steps"` + AverageProgress int `json:"average_progress"` + ResponsesByStep map[int]int `json:"responses_by_step"` + ResponsesByField map[string]int `json:"responses_by_field"` +} + +// ExportFormat specifies the export format for session data +type ExportFormat string + +const ( + ExportFormatJSON ExportFormat = "json" + ExportFormatMarkdown ExportFormat = "md" + ExportFormatPDF ExportFormat = "pdf" +) + +// ExportSessionRequest is the API request for exporting session data +type ExportSessionRequest struct { + Format ExportFormat `json:"format"` + IncludeComments bool `json:"include_comments"` + IncludeHistory bool `json:"include_history"` +} diff --git a/ai-compliance-sdk/internal/workshop/store.go b/ai-compliance-sdk/internal/workshop/store.go new file mode 100644 index 0000000..72e2e1e --- /dev/null +++ b/ai-compliance-sdk/internal/workshop/store.go @@ -0,0 +1,793 @@ +package workshop + +import ( + "context" + "crypto/rand" + "encoding/base32" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store handles workshop session data persistence +type Store struct { + pool *pgxpool.Pool +} + +// NewStore creates a new workshop store +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// ============================================================================ +// Session CRUD Operations +// ============================================================================ + +// CreateSession creates a new workshop session +func (s *Store) CreateSession(ctx context.Context, session *Session) error { + session.ID = uuid.New() + session.CreatedAt = time.Now().UTC() + session.UpdatedAt = session.CreatedAt + if session.Status == "" { + session.Status = SessionStatusDraft + } + if session.JoinCode == "" { + session.JoinCode = generateJoinCode() + } + + settings, _ := json.Marshal(session.Settings) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_sessions ( + id, tenant_id, namespace_id, + title, description, session_type, status, + wizard_schema, current_step, total_steps, + assessment_id, roadmap_id, portfolio_id, + scheduled_start, scheduled_end, actual_start, actual_end, + join_code, require_auth, allow_anonymous, + settings, + created_at, updated_at, created_by + ) VALUES ( + $1, $2, $3, + $4, $5, $6, $7, + $8, $9, $10, + $11, $12, $13, + $14, $15, $16, $17, + $18, $19, $20, + $21, + $22, $23, $24 + ) + `, + session.ID, session.TenantID, session.NamespaceID, + session.Title, session.Description, session.SessionType, string(session.Status), + session.WizardSchema, session.CurrentStep, session.TotalSteps, + session.AssessmentID, session.RoadmapID, session.PortfolioID, + session.ScheduledStart, session.ScheduledEnd, session.ActualStart, session.ActualEnd, + session.JoinCode, session.RequireAuth, session.AllowAnonymous, + settings, + session.CreatedAt, session.UpdatedAt, session.CreatedBy, + ) + + return err +} + +// GetSession retrieves a session by ID +func (s *Store) GetSession(ctx context.Context, id uuid.UUID) (*Session, error) { + var session Session + var status string + var settings []byte + + err := s.pool.QueryRow(ctx, ` + SELECT + id, tenant_id, namespace_id, + title, description, session_type, status, + wizard_schema, current_step, total_steps, + assessment_id, roadmap_id, portfolio_id, + scheduled_start, scheduled_end, actual_start, actual_end, + join_code, require_auth, allow_anonymous, + settings, + created_at, updated_at, created_by + FROM workshop_sessions WHERE id = $1 + `, id).Scan( + &session.ID, &session.TenantID, &session.NamespaceID, + &session.Title, &session.Description, &session.SessionType, &status, + &session.WizardSchema, &session.CurrentStep, &session.TotalSteps, + &session.AssessmentID, &session.RoadmapID, &session.PortfolioID, + &session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd, + &session.JoinCode, &session.RequireAuth, &session.AllowAnonymous, + &settings, + &session.CreatedAt, &session.UpdatedAt, &session.CreatedBy, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + session.Status = SessionStatus(status) + json.Unmarshal(settings, &session.Settings) + + return &session, nil +} + +// GetSessionByJoinCode retrieves a session by its join code +func (s *Store) GetSessionByJoinCode(ctx context.Context, code string) (*Session, error) { + var id uuid.UUID + err := s.pool.QueryRow(ctx, + "SELECT id FROM workshop_sessions WHERE join_code = $1", + strings.ToUpper(code), + ).Scan(&id) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + return s.GetSession(ctx, id) +} + +// ListSessions lists sessions for a tenant with optional filters +func (s *Store) ListSessions(ctx context.Context, tenantID uuid.UUID, filters *SessionFilters) ([]Session, error) { + query := ` + SELECT + id, tenant_id, namespace_id, + title, description, session_type, status, + wizard_schema, current_step, total_steps, + assessment_id, roadmap_id, portfolio_id, + scheduled_start, scheduled_end, actual_start, actual_end, + join_code, require_auth, allow_anonymous, + settings, + created_at, updated_at, created_by + FROM workshop_sessions 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.SessionType != "" { + query += fmt.Sprintf(" AND session_type = $%d", argIdx) + args = append(args, filters.SessionType) + argIdx++ + } + if filters.AssessmentID != nil { + query += fmt.Sprintf(" AND assessment_id = $%d", argIdx) + args = append(args, *filters.AssessmentID) + argIdx++ + } + if filters.CreatedBy != nil { + query += fmt.Sprintf(" AND created_by = $%d", argIdx) + args = append(args, *filters.CreatedBy) + 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) + } + } + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var sessions []Session + for rows.Next() { + var session Session + var status string + var settings []byte + + err := rows.Scan( + &session.ID, &session.TenantID, &session.NamespaceID, + &session.Title, &session.Description, &session.SessionType, &status, + &session.WizardSchema, &session.CurrentStep, &session.TotalSteps, + &session.AssessmentID, &session.RoadmapID, &session.PortfolioID, + &session.ScheduledStart, &session.ScheduledEnd, &session.ActualStart, &session.ActualEnd, + &session.JoinCode, &session.RequireAuth, &session.AllowAnonymous, + &settings, + &session.CreatedAt, &session.UpdatedAt, &session.CreatedBy, + ) + if err != nil { + return nil, err + } + + session.Status = SessionStatus(status) + json.Unmarshal(settings, &session.Settings) + + sessions = append(sessions, session) + } + + return sessions, nil +} + +// UpdateSession updates a session +func (s *Store) UpdateSession(ctx context.Context, session *Session) error { + session.UpdatedAt = time.Now().UTC() + + settings, _ := json.Marshal(session.Settings) + + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_sessions SET + title = $2, description = $3, status = $4, + wizard_schema = $5, current_step = $6, total_steps = $7, + scheduled_start = $8, scheduled_end = $9, + actual_start = $10, actual_end = $11, + require_auth = $12, allow_anonymous = $13, + settings = $14, + updated_at = $15 + WHERE id = $1 + `, + session.ID, session.Title, session.Description, string(session.Status), + session.WizardSchema, session.CurrentStep, session.TotalSteps, + session.ScheduledStart, session.ScheduledEnd, + session.ActualStart, session.ActualEnd, + session.RequireAuth, session.AllowAnonymous, + settings, + session.UpdatedAt, + ) + + return err +} + +// UpdateSessionStatus updates only the session status +func (s *Store) UpdateSessionStatus(ctx context.Context, id uuid.UUID, status SessionStatus) error { + now := time.Now().UTC() + + query := "UPDATE workshop_sessions SET status = $2, updated_at = $3" + + if status == SessionStatusActive { + query += ", actual_start = COALESCE(actual_start, $3)" + } else if status == SessionStatusCompleted || status == SessionStatusCancelled { + query += ", actual_end = $3" + } + + query += " WHERE id = $1" + + _, err := s.pool.Exec(ctx, query, id, string(status), now) + return err +} + +// AdvanceStep advances the session to the next step +func (s *Store) AdvanceStep(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_sessions SET + current_step = current_step + 1, + updated_at = NOW() + WHERE id = $1 AND current_step < total_steps + `, id) + return err +} + +// DeleteSession deletes a session and its related data +func (s *Store) DeleteSession(ctx context.Context, id uuid.UUID) error { + // Delete in order: comments, responses, step_progress, participants, session + _, err := s.pool.Exec(ctx, "DELETE FROM workshop_comments WHERE session_id = $1", id) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, "DELETE FROM workshop_responses WHERE session_id = $1", id) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, "DELETE FROM workshop_step_progress WHERE session_id = $1", id) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, "DELETE FROM workshop_participants WHERE session_id = $1", id) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, "DELETE FROM workshop_sessions WHERE id = $1", id) + return err +} + +// ============================================================================ +// Participant Operations +// ============================================================================ + +// AddParticipant adds a participant to a session +func (s *Store) AddParticipant(ctx context.Context, p *Participant) error { + p.ID = uuid.New() + p.JoinedAt = time.Now().UTC() + p.IsActive = true + now := p.JoinedAt + p.LastActiveAt = &now + + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_participants ( + id, session_id, user_id, + name, email, role, department, + is_active, last_active_at, joined_at, left_at, + can_edit, can_comment, can_approve + ) VALUES ( + $1, $2, $3, + $4, $5, $6, $7, + $8, $9, $10, $11, + $12, $13, $14 + ) + `, + p.ID, p.SessionID, p.UserID, + p.Name, p.Email, string(p.Role), p.Department, + p.IsActive, p.LastActiveAt, p.JoinedAt, p.LeftAt, + p.CanEdit, p.CanComment, p.CanApprove, + ) + + return err +} + +// GetParticipant retrieves a participant by ID +func (s *Store) GetParticipant(ctx context.Context, id uuid.UUID) (*Participant, error) { + var p Participant + var role string + + err := s.pool.QueryRow(ctx, ` + SELECT + id, session_id, user_id, + name, email, role, department, + is_active, last_active_at, joined_at, left_at, + can_edit, can_comment, can_approve + FROM workshop_participants WHERE id = $1 + `, id).Scan( + &p.ID, &p.SessionID, &p.UserID, + &p.Name, &p.Email, &role, &p.Department, + &p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt, + &p.CanEdit, &p.CanComment, &p.CanApprove, + ) + + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + p.Role = ParticipantRole(role) + return &p, nil +} + +// ListParticipants lists participants for a session +func (s *Store) ListParticipants(ctx context.Context, sessionID uuid.UUID) ([]Participant, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + id, session_id, user_id, + name, email, role, department, + is_active, last_active_at, joined_at, left_at, + can_edit, can_comment, can_approve + FROM workshop_participants WHERE session_id = $1 + ORDER BY joined_at ASC + `, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + + var participants []Participant + for rows.Next() { + var p Participant + var role string + + err := rows.Scan( + &p.ID, &p.SessionID, &p.UserID, + &p.Name, &p.Email, &role, &p.Department, + &p.IsActive, &p.LastActiveAt, &p.JoinedAt, &p.LeftAt, + &p.CanEdit, &p.CanComment, &p.CanApprove, + ) + if err != nil { + return nil, err + } + + p.Role = ParticipantRole(role) + participants = append(participants, p) + } + + return participants, nil +} + +// UpdateParticipantActivity updates the last active timestamp +func (s *Store) UpdateParticipantActivity(ctx context.Context, id uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_participants SET + last_active_at = NOW(), + is_active = true + WHERE id = $1 + `, id) + return err +} + +// LeaveSession marks a participant as having left +func (s *Store) LeaveSession(ctx context.Context, participantID uuid.UUID) error { + now := time.Now().UTC() + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_participants SET + is_active = false, + left_at = $2 + WHERE id = $1 + `, participantID, now) + return err +} + +// UpdateParticipant updates a participant's information +func (s *Store) UpdateParticipant(ctx context.Context, p *Participant) error { + _, err := s.pool.Exec(ctx, ` + UPDATE workshop_participants SET + name = $2, + email = $3, + role = $4, + department = $5, + can_edit = $6, + can_comment = $7, + can_approve = $8 + WHERE id = $1 + `, + p.ID, + p.Name, p.Email, string(p.Role), p.Department, + p.CanEdit, p.CanComment, p.CanApprove, + ) + return err +} + +// ============================================================================ +// Comment Operations +// ============================================================================ + +// AddComment adds a comment to a session +func (s *Store) AddComment(ctx context.Context, c *Comment) error { + c.ID = uuid.New() + c.CreatedAt = time.Now().UTC() + c.UpdatedAt = c.CreatedAt + + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_comments ( + id, session_id, participant_id, + step_number, field_id, response_id, + text, is_resolved, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + ) + `, + c.ID, c.SessionID, c.ParticipantID, + c.StepNumber, c.FieldID, c.ResponseID, + c.Text, c.IsResolved, + c.CreatedAt, c.UpdatedAt, + ) + + return err +} + +// GetComments retrieves comments for a session +func (s *Store) GetComments(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Comment, error) { + query := ` + SELECT + id, session_id, participant_id, + step_number, field_id, response_id, + text, is_resolved, + created_at, updated_at + FROM workshop_comments WHERE session_id = $1` + + args := []interface{}{sessionID} + if stepNumber != nil { + query += " AND step_number = $2" + args = append(args, *stepNumber) + } + + query += " ORDER BY created_at ASC" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var comments []Comment + for rows.Next() { + var c Comment + err := rows.Scan( + &c.ID, &c.SessionID, &c.ParticipantID, + &c.StepNumber, &c.FieldID, &c.ResponseID, + &c.Text, &c.IsResolved, + &c.CreatedAt, &c.UpdatedAt, + ) + if err != nil { + return nil, err + } + comments = append(comments, c) + } + + return comments, nil +} + +// ============================================================================ +// Response Operations +// ============================================================================ + +// SaveResponse creates or updates a response +func (s *Store) SaveResponse(ctx context.Context, r *Response) error { + r.UpdatedAt = time.Now().UTC() + + valueJSON, _ := json.Marshal(r.Value) + + // Upsert based on session_id, participant_id, field_id + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_responses ( + id, session_id, participant_id, + step_number, field_id, + value, value_type, status, + created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + ) + ON CONFLICT (session_id, participant_id, field_id) DO UPDATE SET + value = $6, + value_type = $7, + status = $8, + updated_at = $10 + `, + uuid.New(), r.SessionID, r.ParticipantID, + r.StepNumber, r.FieldID, + valueJSON, r.ValueType, string(r.Status), + r.UpdatedAt, r.UpdatedAt, + ) + + return err +} + +// GetResponses retrieves responses for a session +func (s *Store) GetResponses(ctx context.Context, sessionID uuid.UUID, stepNumber *int) ([]Response, error) { + query := ` + SELECT + id, session_id, participant_id, + step_number, field_id, + value, value_type, status, + reviewed_by, reviewed_at, review_notes, + created_at, updated_at + FROM workshop_responses WHERE session_id = $1` + + args := []interface{}{sessionID} + if stepNumber != nil { + query += " AND step_number = $2" + args = append(args, *stepNumber) + } + + query += " ORDER BY step_number ASC, field_id ASC" + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var responses []Response + for rows.Next() { + var r Response + var status string + var valueJSON []byte + + err := rows.Scan( + &r.ID, &r.SessionID, &r.ParticipantID, + &r.StepNumber, &r.FieldID, + &valueJSON, &r.ValueType, &status, + &r.ReviewedBy, &r.ReviewedAt, &r.ReviewNotes, + &r.CreatedAt, &r.UpdatedAt, + ) + if err != nil { + return nil, err + } + + r.Status = ResponseStatus(status) + json.Unmarshal(valueJSON, &r.Value) + + responses = append(responses, r) + } + + return responses, nil +} + +// ============================================================================ +// Step Progress Operations +// ============================================================================ + +// UpdateStepProgress updates the progress for a step +func (s *Store) UpdateStepProgress(ctx context.Context, sessionID uuid.UUID, stepNumber int, status string, progress int) error { + now := time.Now().UTC() + + _, err := s.pool.Exec(ctx, ` + INSERT INTO workshop_step_progress ( + id, session_id, step_number, + status, progress, started_at + ) VALUES ( + $1, $2, $3, $4, $5, $6 + ) + ON CONFLICT (session_id, step_number) DO UPDATE SET + status = $4, + progress = $5, + completed_at = CASE WHEN $4 = 'completed' THEN $6 ELSE NULL END + `, uuid.New(), sessionID, stepNumber, status, progress, now) + + return err +} + +// GetStepProgress retrieves step progress for a session +func (s *Store) GetStepProgress(ctx context.Context, sessionID uuid.UUID) ([]StepProgress, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + id, session_id, step_number, + status, progress, + started_at, completed_at, notes + FROM workshop_step_progress WHERE session_id = $1 + ORDER BY step_number ASC + `, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + + var progress []StepProgress + for rows.Next() { + var sp StepProgress + err := rows.Scan( + &sp.ID, &sp.SessionID, &sp.StepNumber, + &sp.Status, &sp.Progress, + &sp.StartedAt, &sp.CompletedAt, &sp.Notes, + ) + if err != nil { + return nil, err + } + progress = append(progress, sp) + } + + return progress, nil +} + +// ============================================================================ +// Statistics +// ============================================================================ + +// GetSessionStats returns statistics for a session +func (s *Store) GetSessionStats(ctx context.Context, sessionID uuid.UUID) (*SessionStats, error) { + stats := &SessionStats{ + ResponsesByStep: make(map[int]int), + ResponsesByField: make(map[string]int), + } + + // Participant counts + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1", + sessionID).Scan(&stats.ParticipantCount) + + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_participants WHERE session_id = $1 AND is_active = true", + sessionID).Scan(&stats.ActiveParticipants) + + // Response count + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1", + sessionID).Scan(&stats.ResponseCount) + + // Comment count + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_comments WHERE session_id = $1", + sessionID).Scan(&stats.CommentCount) + + // Step progress + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_step_progress WHERE session_id = $1 AND status = 'completed'", + sessionID).Scan(&stats.CompletedSteps) + + s.pool.QueryRow(ctx, + "SELECT total_steps FROM workshop_sessions WHERE id = $1", + sessionID).Scan(&stats.TotalSteps) + + // Responses by step + rows, _ := s.pool.Query(ctx, + "SELECT step_number, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY step_number", + sessionID) + if rows != nil { + defer rows.Close() + for rows.Next() { + var step, count int + rows.Scan(&step, &count) + stats.ResponsesByStep[step] = count + } + } + + // Responses by field + rows, _ = s.pool.Query(ctx, + "SELECT field_id, COUNT(*) FROM workshop_responses WHERE session_id = $1 GROUP BY field_id", + sessionID) + if rows != nil { + defer rows.Close() + for rows.Next() { + var field string + var count int + rows.Scan(&field, &count) + stats.ResponsesByField[field] = count + } + } + + // Average progress + if stats.TotalSteps > 0 { + stats.AverageProgress = (stats.CompletedSteps * 100) / stats.TotalSteps + } + + return stats, nil +} + +// GetSessionSummary returns a complete session summary +func (s *Store) GetSessionSummary(ctx context.Context, sessionID uuid.UUID) (*SessionSummary, error) { + session, err := s.GetSession(ctx, sessionID) + if err != nil || session == nil { + return nil, err + } + + participants, err := s.ListParticipants(ctx, sessionID) + if err != nil { + return nil, err + } + + stepProgress, err := s.GetStepProgress(ctx, sessionID) + if err != nil { + return nil, err + } + + var responseCount int + s.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM workshop_responses WHERE session_id = $1", + sessionID).Scan(&responseCount) + + completedSteps := 0 + for _, sp := range stepProgress { + if sp.Status == "completed" { + completedSteps++ + } + } + + progress := 0 + if session.TotalSteps > 0 { + progress = (completedSteps * 100) / session.TotalSteps + } + + return &SessionSummary{ + Session: session, + Participants: participants, + StepProgress: stepProgress, + TotalResponses: responseCount, + CompletedSteps: completedSteps, + OverallProgress: progress, + }, nil +} + +// ============================================================================ +// Helpers +// ============================================================================ + +// generateJoinCode generates a random 6-character join code +func generateJoinCode() string { + b := make([]byte, 4) + rand.Read(b) + code := base32.StdEncoding.EncodeToString(b)[:6] + return strings.ToUpper(code) +} diff --git a/ai-compliance-sdk/policies/controls_catalog.yaml b/ai-compliance-sdk/policies/controls_catalog.yaml new file mode 100644 index 0000000..efa0064 --- /dev/null +++ b/ai-compliance-sdk/policies/controls_catalog.yaml @@ -0,0 +1,889 @@ +# ============================================================================= +# UCCA Controls Catalog v1.0 +# Detaillierter Maßnahmenkatalog für DSGVO/AI Act Compliance +# ============================================================================= +# +# STRUKTUR PRO CONTROL: +# id: Eindeutige ID (CTRL-xxx) +# title: Kurztitel +# category: Kategorie (DSGVO, AI_Act, Technical, Contractual) +# description: Was ist diese Maßnahme? +# when_applicable: Wann ist sie erforderlich? +# what_to_do: Konkrete Handlungsschritte +# evidence_needed: Erforderliche Nachweise/Dokumentation +# effort: low | medium | high +# gdpr_ref: Relevante Rechtsgrundlage +# related_controls: Verwandte Maßnahmen +# +# ============================================================================= + +version: "1.0" +name: "UCCA Controls Catalog" +description: "Detaillierter Maßnahmenkatalog für KI-Compliance" +last_updated: "2026-01-29" + +# ============================================================================= +# KATEGORIE: DSGVO GRUNDLAGEN +# ============================================================================= + +controls: + + # --------------------------------------------------------------------------- + # 1. Rechtsgrundlagen & Einwilligung + # --------------------------------------------------------------------------- + + CTRL-CONSENT-EXPLICIT: + id: CTRL-CONSENT-EXPLICIT + title: "Ausdrückliche Einwilligung" + category: DSGVO + description: | + Opt-in-Einwilligung mit klarer, verständlicher Information + über den Verarbeitungszweck. + when_applicable: | + - Verarbeitung basiert auf Einwilligung (Art. 6(1)(a)) + - Besondere Datenkategorien (Art. 9(2)(a)) + - Profiling oder automatisierte Entscheidungen + what_to_do: | + 1. Einwilligungstext in klarer, einfacher Sprache formulieren + 2. Zweck der Verarbeitung konkret benennen + 3. Hinweis auf Widerrufsrecht aufnehmen + 4. Aktive Handlung erforderlich (kein Pre-Checked) + 5. Einwilligung nachweisbar speichern (Timestamp, IP, Version) + 6. Widerrufsmöglichkeit technisch umsetzen + evidence_needed: + - "Einwilligungstext (alle Versionen)" + - "Technische Dokumentation Opt-in-Mechanismus" + - "Log der erteilten Einwilligungen" + - "Nachweis der Widerrufsmöglichkeit" + effort: medium + gdpr_ref: "Art. 6(1)(a), Art. 7 DSGVO" + related_controls: [CTRL-CONSENT-PARENTAL, CTRL-TRANSPARENCY-INFO] + + CTRL-CONSENT-PARENTAL: + id: CTRL-CONSENT-PARENTAL + title: "Einwilligung der Erziehungsberechtigten" + category: DSGVO + description: | + Bei Minderjährigen unter 16 Jahren ist die Einwilligung der + Erziehungsberechtigten erforderlich. + when_applicable: | + - Nutzer sind oder können unter 16 Jahre sein + - Dienst richtet sich an Minderjährige + - Keine Altersverifikation vorhanden + what_to_do: | + 1. Altersabfrage implementieren + 2. Bei <16: Elterneinwilligung einholen + 3. Verifikation der Elternschaft (z.B. Double-Opt-In an Eltern-E-Mail) + 4. Kindgerechte Datenschutzerklärung bereitstellen + 5. Besondere Löschrechte für Minderjährige beachten + evidence_needed: + - "Altersverifikationsprozess dokumentiert" + - "Elterneinwilligungs-Workflow" + - "Kindgerechte Datenschutzerklärung" + effort: high + gdpr_ref: "Art. 8 DSGVO" + related_controls: [CTRL-CONSENT-EXPLICIT, CTRL-MINOR-PROTECTION] + + # --------------------------------------------------------------------------- + # 2. Informationspflichten & Transparenz + # --------------------------------------------------------------------------- + + CTRL-TRANSPARENCY-INFO: + id: CTRL-TRANSPARENCY-INFO + title: "Informationspflichten erfüllen" + category: DSGVO + description: | + Betroffene über Datenverarbeitung informieren gemäß Art. 13/14 DSGVO. + when_applicable: | + - Immer bei personenbezogenen Daten + - Bei Direkterhebung (Art. 13) + - Bei Dritterhebung (Art. 14) + what_to_do: | + 1. Datenschutzerklärung erstellen mit: + - Identität des Verantwortlichen + - Kontaktdaten DSB + - Verarbeitungszwecke und Rechtsgrundlagen + - Empfänger/Kategorien von Empfängern + - Drittlandtransfers (mit Garantien) + - Speicherdauer/Kriterien + - Betroffenenrechte + - Beschwerderecht bei Aufsichtsbehörde + 2. Zum Zeitpunkt der Erhebung bereitstellen + 3. Aktualisierungen kommunizieren + evidence_needed: + - "Aktuelle Datenschutzerklärung" + - "Changelog der Datenschutzerklärung" + - "Nachweis der Bereitstellung" + effort: medium + gdpr_ref: "Art. 13, Art. 14 DSGVO" + related_controls: [CTRL-AI-TRANSPARENCY, CTRL-VVT] + + CTRL-AI-TRANSPARENCY: + id: CTRL-AI-TRANSPARENCY + title: "KI-Transparenz-Hinweis" + category: AI_Act + description: | + Nutzer darüber informieren, dass sie mit einem KI-System interagieren. + when_applicable: | + - Chatbots und virtuelle Assistenten + - KI-generierte Inhalte (Text, Bild, Audio) + - Automatisierte Entscheidungssysteme + what_to_do: | + 1. Klare Kennzeichnung bei KI-Interaktion + - "Sie chatten mit einem KI-Assistenten" + - Badge/Label bei KI-generierten Inhalten + 2. Information über Grenzen der KI + 3. Möglichkeit zur menschlichen Unterstützung + 4. Bei Deepfakes: Watermarking + evidence_needed: + - "Screenshots der KI-Hinweise" + - "Dokumentation der Kennzeichnungsstrategie" + effort: low + gdpr_ref: "Art. 52 AI Act, Art. 13/14 DSGVO" + related_controls: [CTRL-TRANSPARENCY-INFO, CTRL-CONTESTATION] + + # --------------------------------------------------------------------------- + # 3. Betroffenenrechte + # --------------------------------------------------------------------------- + + CTRL-CONTESTATION: + id: CTRL-CONTESTATION + title: "Anfechtungsrecht bei automatisierten Entscheidungen" + category: DSGVO + description: | + Betroffene können automatisierte Entscheidungen anfechten + und eine menschliche Überprüfung verlangen. + when_applicable: | + - Automatisierte Entscheidungen mit rechtlicher Wirkung + - Scoring/Profiling mit erheblicher Beeinträchtigung + - Auch bei teilautomatisierten Entscheidungen + what_to_do: | + 1. Anfechtungsmechanismus bereitstellen + - Kontaktformular oder E-Mail + - Maximale Bearbeitungszeit: 1 Monat + 2. Menschliche Überprüfung sicherstellen + 3. Entscheidung erklären können + 4. Prozess dokumentieren + evidence_needed: + - "Anfechtungsprozess dokumentiert" + - "Nachweis menschlicher Überprüfung" + - "Bearbeitungszeiten (SLA)" + effort: medium + gdpr_ref: "Art. 22(3) DSGVO" + related_controls: [CTRL-HITL, CTRL-AI-TRANSPARENCY] + + CTRL-DSR-PROCESS: + id: CTRL-DSR-PROCESS + title: "Betroffenenrechte-Prozess" + category: DSGVO + description: | + Prozess zur Bearbeitung von Betroffenenanfragen + (Auskunft, Löschung, Berichtigung, etc.). + when_applicable: | + - Immer bei personenbezogenen Daten + - Anfragen nach Art. 15-22 DSGVO + what_to_do: | + 1. Anfrage-Kanal bereitstellen (E-Mail, Formular) + 2. Identitätsprüfung implementieren + 3. Bearbeitungs-Workflow etablieren: + - Fristüberwachung (max. 1 Monat) + - Eskalation bei Verzögerung + - Dokumentation + 4. Technische Umsetzung sicherstellen: + - Datenexport (Art. 15, Art. 20) + - Löschfunktion (Art. 17) + - Berichtigungsfunktion (Art. 16) + evidence_needed: + - "DSR-Workflow dokumentiert" + - "Bearbeitungsstatistiken" + - "Technische Export/Lösch-Dokumentation" + effort: medium + gdpr_ref: "Art. 15-22 DSGVO" + related_controls: [CTRL-RETENTION, CTRL-VVT] + + # --------------------------------------------------------------------------- + # 4. Datenschutz-Folgenabschätzung + # --------------------------------------------------------------------------- + + CTRL-DSFA: + id: CTRL-DSFA + title: "Datenschutz-Folgenabschätzung (DSFA)" + category: DSGVO + description: | + Formale Risikoanalyse vor Beginn einer Verarbeitung + mit voraussichtlich hohem Risiko. + when_applicable: | + - Systematische Bewertung/Scoring von Personen + - Umfangreiche Verarbeitung besonderer Kategorien + - Systematische Überwachung öffentlicher Bereiche + - Neue Technologien mit hohem Risiko + - Profiling mit erheblicher Auswirkung + what_to_do: | + 1. Verarbeitung systematisch beschreiben + 2. Notwendigkeit und Verhältnismäßigkeit prüfen + 3. Risiken für Betroffene identifizieren + 4. Maßnahmen zur Risikominderung festlegen + 5. Bei hohem Restrisiko: Aufsichtsbehörde konsultieren + 6. DSFA dokumentieren und regelmäßig überprüfen + evidence_needed: + - "DSFA-Dokument (ausgefüllt)" + - "Risikobewertungsmatrix" + - "Maßnahmenplan" + - "Ggf. Konsultationsnachweis" + effort: high + gdpr_ref: "Art. 35, Art. 36 DSGVO" + related_controls: [CTRL-VVT, CTRL-TOM] + + # --------------------------------------------------------------------------- + # 5. Dokumentation & Nachweis + # --------------------------------------------------------------------------- + + CTRL-VVT: + id: CTRL-VVT + title: "Verarbeitungsverzeichnis (VVT)" + category: DSGVO + description: | + Dokumentation aller Verarbeitungstätigkeiten + gemäß Art. 30 DSGVO. + when_applicable: | + - Ab 250 Mitarbeitern: Immer + - Unter 250 Mitarbeitern: Bei nicht nur gelegentlicher Verarbeitung + - Bei risikobehafteter Verarbeitung + - Bei Verarbeitung besonderer Kategorien + what_to_do: | + 1. Für jede Verarbeitung dokumentieren: + - Name und Kontaktdaten des Verantwortlichen + - Verarbeitungszwecke + - Kategorien betroffener Personen + - Kategorien personenbezogener Daten + - Empfänger(-kategorien) + - Drittlandübermittlungen + - Löschfristen + - TOMs (allgemeine Beschreibung) + 2. Regelmäßig aktualisieren + 3. Auf Anfrage der Aufsichtsbehörde bereitstellen + evidence_needed: + - "Vollständiges VVT" + - "Änderungshistorie" + effort: medium + gdpr_ref: "Art. 30 DSGVO" + related_controls: [CTRL-DSFA, CTRL-TOM, CTRL-RETENTION] + + CTRL-ACCESS-LOGGING: + id: CTRL-ACCESS-LOGGING + title: "Zugriffs-Protokollierung" + category: Technical + description: | + Revisionssichere Protokollierung aller Datenzugriffe. + when_applicable: | + - Personenbezogene Daten werden verarbeitet + - Sensible oder kritische Daten + - Anforderungen aus DSFA + what_to_do: | + 1. Logging-System implementieren: + - Wer (User-ID) + - Wann (Timestamp UTC) + - Was (Aktion: read, write, delete) + - Welche Daten (Ressource) + - Ergebnis (success, failure) + 2. Logs unveränderbar speichern + 3. Aufbewahrungsdauer festlegen (z.B. 6 Monate) + 4. Regelmäßige Überprüfung (Anomalien) + evidence_needed: + - "Logging-Konzept" + - "Beispiel-Logs" + - "Audit-Berichte" + effort: low + gdpr_ref: "Art. 5(2), Art. 32 DSGVO" + related_controls: [CTRL-TOM, CTRL-ENCRYPTION] + + # --------------------------------------------------------------------------- + # 6. Technische und Organisatorische Maßnahmen (TOM) + # --------------------------------------------------------------------------- + + CTRL-TOM: + id: CTRL-TOM + title: "Technische und Organisatorische Maßnahmen" + category: DSGVO + description: | + Geeignete Schutzmaßnahmen zur Gewährleistung + der Datensicherheit nach Art. 32 DSGVO. + when_applicable: | + - Immer bei personenbezogenen Daten + - Umfang richtet sich nach Risiko + what_to_do: | + 1. Pseudonymisierung und Verschlüsselung + 2. Vertraulichkeit, Integrität, Verfügbarkeit sichern + 3. Belastbarkeit der Systeme + 4. Wiederherstellbarkeit nach Vorfall + 5. Regelmäßige Überprüfung und Evaluierung + evidence_needed: + - "TOM-Dokumentation" + - "Sicherheitskonzept" + - "Penetrationstest-Berichte" + effort: medium + gdpr_ref: "Art. 32 DSGVO" + related_controls: [CTRL-ENCRYPTION, CTRL-ACCESS-LOGGING, CTRL-PSEUDONYMIZATION] + + CTRL-ENCRYPTION: + id: CTRL-ENCRYPTION + title: "Verschlüsselung" + category: Technical + description: | + Daten bei Übertragung und Speicherung verschlüsseln. + when_applicable: | + - Personenbezogene Daten + - Übertragung über öffentliche Netze + - Speicherung sensibler Daten + what_to_do: | + 1. Transport-Verschlüsselung (TLS 1.3) + 2. Speicher-Verschlüsselung (AES-256) + 3. Schlüsselmanagement etablieren + 4. End-to-End-Verschlüsselung bei Bedarf + evidence_needed: + - "TLS-Konfiguration" + - "Verschlüsselungskonzept" + - "Key-Management-Dokumentation" + effort: low + gdpr_ref: "Art. 32(1)(a) DSGVO" + related_controls: [CTRL-TOM, CTRL-TECHNICAL-SUPPLEMENTARY] + + CTRL-PSEUDONYMIZATION: + id: CTRL-PSEUDONYMIZATION + title: "Pseudonymisierung" + category: Technical + description: | + Verarbeitung so, dass Daten ohne zusätzliche Informationen + nicht mehr einer Person zugeordnet werden können. + when_applicable: | + - Datensparsamkeit erhöhen + - Forschung/Statistik + - KI-Training (Alternative zu echten Daten) + what_to_do: | + 1. Identifizierende Merkmale durch Pseudonyme ersetzen + 2. Mapping-Tabelle separat und sicher speichern + 3. Zugriff auf Mapping strikt beschränken + 4. Re-Identifizierungsrisiko bewerten + evidence_needed: + - "Pseudonymisierungskonzept" + - "Zugriffsbeschränkungen dokumentiert" + effort: medium + gdpr_ref: "Art. 4(5), Art. 32(1)(a) DSGVO" + related_controls: [CTRL-ANONYMIZATION, CTRL-TOM] + + CTRL-ANONYMIZATION: + id: CTRL-ANONYMIZATION + title: "Anonymisierung" + category: Technical + description: | + Irreversible Entfernung aller Personenbezüge, + sodass DSGVO nicht mehr anwendbar ist. + when_applicable: | + - Langfristige Speicherung für Statistik + - KI-Training ohne Personenbezug + - Veröffentlichung von Daten + what_to_do: | + 1. Alle direkten Identifikatoren entfernen + 2. Quasi-Identifikatoren prüfen (k-Anonymität) + 3. Re-Identifizierungsrisiko bewerten + 4. Aggregation oder Rauschen hinzufügen + 5. Anonymisierung dokumentieren + evidence_needed: + - "Anonymisierungsverfahren dokumentiert" + - "Re-Identifizierungsrisiko-Bewertung" + effort: high + gdpr_ref: "ErwGr. 26 DSGVO" + related_controls: [CTRL-PSEUDONYMIZATION, CTRL-PII-GATEWAY] + + # --------------------------------------------------------------------------- + # 7. Aufbewahrung & Löschung + # --------------------------------------------------------------------------- + + CTRL-RETENTION: + id: CTRL-RETENTION + title: "Löschkonzept / Aufbewahrungsfristen" + category: DSGVO + description: | + Definierte Aufbewahrungsfristen mit automatischer Löschung + nach Ablauf. + when_applicable: | + - Immer bei personenbezogenen Daten + - Verschiedene Fristen je nach Datenart + what_to_do: | + 1. Aufbewahrungsfristen je Datenart festlegen + - Gesetzliche Mindestfristen beachten + - Nicht länger als erforderlich + 2. Automatische Löschroutinen implementieren + 3. Löschprotokolle führen + 4. Backup-Löschung nicht vergessen + evidence_needed: + - "Löschkonzept mit Fristen" + - "Löschprotokolle" + - "Technische Umsetzung dokumentiert" + effort: medium + gdpr_ref: "Art. 5(1)(e) DSGVO" + related_controls: [CTRL-VVT, CTRL-DSR-PROCESS] + + # --------------------------------------------------------------------------- + # 8. Auftragsverarbeitung & Verträge + # --------------------------------------------------------------------------- + + CTRL-AVV: + id: CTRL-AVV + title: "Auftragsverarbeitungsvertrag (AVV)" + category: Contractual + description: | + Vertrag nach Art. 28 DSGVO mit jedem Auftragsverarbeiter. + when_applicable: | + - Externe Dienstleister verarbeiten Daten in Ihrem Auftrag + - Cloud-Services + - KI-Provider + - Hosting-Anbieter + what_to_do: | + 1. AVV abschließen mit: + - Gegenstand und Dauer + - Art und Zweck der Verarbeitung + - Kategorien von Daten und Betroffenen + - Weisungsbindung + - Vertraulichkeit + - TOMs + - Unterauftragsverarbeiter-Regelung + - Unterstützungspflichten + - Löschung/Rückgabe + 2. Regelmäßig überprüfen + evidence_needed: + - "Unterzeichneter AVV" + - "TOM-Anlage" + - "Subprocessor-Liste" + effort: medium + gdpr_ref: "Art. 28 DSGVO" + related_controls: [CTRL-SCC, CTRL-SUBPROCESSOR-MANAGEMENT] + + CTRL-SUBPROCESSOR-MANAGEMENT: + id: CTRL-SUBPROCESSOR-MANAGEMENT + title: "Unterauftragsverarbeiter-Management" + category: Contractual + description: | + Übersicht und Kontrolle aller Unterauftragsverarbeiter. + when_applicable: | + - Auftragsverarbeiter setzen Subunternehmer ein + - Cloud-Anbieter mit mehreren Subprocessors + what_to_do: | + 1. Vollständige Liste aller Subprocessors einholen + 2. Standorte und Zwecke dokumentieren + 3. Änderungsbenachrichtigung vereinbaren + 4. Widerspruchsrecht sichern + 5. SCC-Kette bei Drittländern prüfen + evidence_needed: + - "Aktuelle Subprocessor-Liste" + - "Nachweis Änderungsbenachrichtigung" + effort: low + gdpr_ref: "Art. 28(2), Art. 28(4) DSGVO" + related_controls: [CTRL-AVV, CTRL-SCC-SUBPROCESSOR] + + # --------------------------------------------------------------------------- + # 9. Drittlandtransfer & SCC + # --------------------------------------------------------------------------- + + CTRL-SCC: + id: CTRL-SCC + title: "Standardvertragsklauseln (SCC)" + category: Contractual + description: | + EU-Standardvertragsklauseln für Drittlandtransfers + nach Art. 46(2)(c) DSGVO. + when_applicable: | + - Datenübermittlung in Drittländer ohne Angemessenheitsbeschluss + - US-Anbieter ohne DPF-Zertifizierung + - Drittland-Support mit Datenzugriff + what_to_do: | + 1. Korrektes SCC-Modul wählen: + - Modul 1: C2C (Controller to Controller) + - Modul 2: C2P (Controller to Processor) + - Modul 3: P2P (Processor to Processor) + - Modul 4: P2C (Processor to Controller) + 2. Annex I ausfüllen (Parteien, Datenexport) + 3. Annex II ausfüllen (TOMs) + 4. Von beiden Parteien unterzeichnen + 5. Als Anlage zum AVV hinzufügen + evidence_needed: + - "Unterzeichnete SCC (PDF)" + - "Ausgefüllte Annexe" + - "Modulauswahl-Begründung" + effort: medium + gdpr_ref: "Art. 46(2)(c) DSGVO, EU 2021/914" + related_controls: [CTRL-TIA, CTRL-AVV, CTRL-SCC-VERSION] + + CTRL-SCC-VERSION: + id: CTRL-SCC-VERSION + title: "Aktuelle SCC-Version prüfen" + category: Contractual + description: | + Sicherstellen, dass die neuen SCC von Juni 2021 verwendet werden. + when_applicable: | + - Bestehende SCC-Verträge prüfen + - Alte SCC (vor 2021) ersetzen + what_to_do: | + 1. Prüfen welche SCC-Version im Einsatz + 2. Alte Versionen identifizieren + 3. Migration auf neue SCC (EU 2021/914) planen + 4. Frist: Alte SCC sind seit 27.12.2022 ungültig! + evidence_needed: + - "Inventar aller SCC-Verträge" + - "Versionsnachweis" + effort: low + gdpr_ref: "EU 2021/914" + related_controls: [CTRL-SCC] + + CTRL-TIA: + id: CTRL-TIA + title: "Transfer Impact Assessment (TIA)" + category: Contractual + description: | + Bewertung der Risiken bei Drittlandtransfer + (seit Schrems II Pflicht). + when_applicable: | + - Jeder Drittlandtransfer ohne Angemessenheitsbeschluss + - USA (auch mit DPF - empfohlen) + - Drittländer mit fraglicher Rechtslage + what_to_do: | + 1. Transferumstände dokumentieren: + - Welche Daten? + - Welches Drittland? + - Welche Rechtsgrundlage (SCC, BCR)? + 2. Rechtslage im Drittland prüfen: + - Behördenzugriff (z.B. FISA 702, Cloud Act) + - Rechtsschutzmöglichkeiten für EU-Bürger + 3. Bewertung vornehmen: + - Reichen SCC allein? + - Zusatzmaßnahmen erforderlich? + 4. Ergebnis dokumentieren + 5. Regelmäßig (jährlich) überprüfen + evidence_needed: + - "TIA-Dokument (ausgefüllt)" + - "Länder-Risikobewertung" + - "Nachweis Zusatzmaßnahmen" + effort: high + gdpr_ref: "EuGH Schrems II (C-311/18), EDPB Recommendations 01/2020" + related_controls: [CTRL-SCC, CTRL-TECHNICAL-SUPPLEMENTARY] + + CTRL-DPF-CHECK: + id: CTRL-DPF-CHECK + title: "Data Privacy Framework Zertifizierung prüfen" + category: Contractual + description: | + Prüfung ob US-Anbieter unter dem EU-US Data Privacy Framework + zertifiziert ist. + when_applicable: | + - Übermittlung an US-Empfänger + - US-Cloud-Anbieter + what_to_do: | + 1. DPF-Liste auf dataprivacyframework.gov prüfen + 2. Exakten Firmennamen suchen + 3. Aktiven Zertifizierungsstatus bestätigen + 4. Ergebnis dokumentieren mit Datum + 5. Bei Zertifizierung: SCC optional (aber empfohlen) + 6. Ohne Zertifizierung: SCC zwingend erforderlich + evidence_needed: + - "Screenshot DPF-Zertifizierungsstatus" + - "Prüfdatum dokumentiert" + effort: low + gdpr_ref: "EU-US Data Privacy Framework Beschluss 2023" + related_controls: [CTRL-SCC, CTRL-TIA] + + CTRL-SCC-SUBPROCESSOR: + id: CTRL-SCC-SUBPROCESSOR + title: "SCC für Unterauftragsverarbeiter" + category: Contractual + description: | + SCC-Kette zu allen Unterauftragsverarbeitern im Drittland. + when_applicable: | + - Subprocessors im Drittland (z.B. US-Cloud-Infrastruktur) + - Multi-Cloud-Setups mit Drittland-Komponenten + what_to_do: | + 1. Subprocessor-Liste mit Standorten einholen + 2. Für jeden Drittland-Subprocessor prüfen: + - DPF-Zertifizierung? + - SCC vorhanden? + 3. Vertragliche Weitergabe der Pflichten sicherstellen + 4. Bei Lücken: Anbieter kontaktieren oder wechseln + evidence_needed: + - "Subprocessor-Liste mit Standorten" + - "SCC/DPF-Nachweis pro Drittland-Subprocessor" + effort: medium + gdpr_ref: "Art. 28(4), Art. 46 DSGVO" + related_controls: [CTRL-SCC, CTRL-SUBPROCESSOR-MANAGEMENT] + + CTRL-TECHNICAL-SUPPLEMENTARY: + id: CTRL-TECHNICAL-SUPPLEMENTARY + title: "Technische Zusatzmaßnahmen bei Drittlandtransfer" + category: Technical + description: | + Ergänzende technische Schutzmaßnahmen wenn SCC allein + nicht ausreichen (TIA-Ergebnis: Defizite). + when_applicable: | + - TIA zeigt unzureichendes Schutzniveau + - Behördenzugriff im Drittland möglich + - Daten besonders schutzbedürftig + what_to_do: | + 1. Ende-zu-Ende-Verschlüsselung implementieren + - Schlüssel nur beim Datenexporteur (EU) + - Importeur kann Klartext nicht lesen + 2. Pseudonymisierung vor Transfer + - Mapping-Tabelle verbleibt im EWR + 3. Logging aller Drittland-Zugriffe + 4. Maßnahmen dokumentieren + evidence_needed: + - "Verschlüsselungskonzept" + - "Pseudonymisierungskonzept" + - "Zugriffsprotokollierung" + effort: high + gdpr_ref: "EDPB Recommendations 01/2020" + related_controls: [CTRL-TIA, CTRL-ENCRYPTION, CTRL-PSEUDONYMIZATION] + + # --------------------------------------------------------------------------- + # 10. KI-spezifische Maßnahmen + # --------------------------------------------------------------------------- + + CTRL-HITL: + id: CTRL-HITL + title: "Human-in-the-Loop erzwingen" + category: AI_Act + description: | + Technisch erzwungene menschliche Überprüfung + bei automatisierten Entscheidungen. + when_applicable: | + - Automatisierte Entscheidungen mit rechtlicher Wirkung + - Art. 22 DSGVO Risiko + - High-Risk AI nach AI Act + what_to_do: | + 1. Workflow-Unterbrechung einbauen + - KI liefert Vorschlag + - Mensch muss bestätigen/ablehnen + 2. Technische Umsetzung: + - Approval-Queue + - Keine automatische Weiterleitung + 3. Nachweis der menschlichen Prüfung (Log) + 4. Kompetenz der Prüfer sicherstellen + evidence_needed: + - "HITL-Workflow dokumentiert" + - "Technische Implementierung" + - "Prüfer-Logs" + effort: medium + gdpr_ref: "Art. 22(3) DSGVO, Art. 14 AI Act" + related_controls: [CTRL-CONTESTATION, CTRL-AI-TRANSPARENCY] + + CTRL-NO-TRAINING: + id: CTRL-NO-TRAINING + title: "Kein Training mit Nutzerdaten" + category: AI_Act + description: | + Vertragliche Zusicherung, dass Anbieter Nutzerdaten + nicht für eigenes Modell-Training verwendet. + when_applicable: | + - KI-Provider (OpenAI, Anthropic, etc.) + - SaaS-KI-Dienste + - Chatbot-Plattformen + what_to_do: | + 1. AGB/DPA prüfen auf Training-Klausel + 2. Opt-Out beantragen wenn nicht Standard + 3. Schriftliche Bestätigung einholen + 4. Im AVV festhalten + evidence_needed: + - "Opt-Out-Bestätigung" + - "Relevante Vertragsklausel" + effort: low + gdpr_ref: "Art. 5(1)(b) DSGVO (Zweckbindung)" + related_controls: [CTRL-AVV, CTRL-PII-GATEWAY] + + CTRL-PII-GATEWAY: + id: CTRL-PII-GATEWAY + title: "PII-Redaction Gateway" + category: Technical + description: | + Automatische Erkennung und Redaction personenbezogener + Daten vor Übermittlung an KI. + when_applicable: | + - KI-Prompts können PII enthalten + - Externe KI-APIs (OpenAI, etc.) + - Log-Analyse mit KI + what_to_do: | + 1. PII-Detector implementieren (NER, Regex, ML) + 2. Erkannte PII maskieren oder entfernen + 3. Placeholder einfügen ("[NAME]", "[E-MAIL]") + 4. Logging der Redactions + 5. False-Positive-Rate überwachen + evidence_needed: + - "PII-Gateway-Dokumentation" + - "Erkennungsgenauigkeit" + - "Beispiel-Redactions" + effort: medium + gdpr_ref: "Art. 25, Art. 32 DSGVO" + related_controls: [CTRL-ANONYMIZATION, CTRL-PSEUDONYMIZATION] + + CTRL-AI-RISK-CLASSIFICATION: + id: CTRL-AI-RISK-CLASSIFICATION + title: "KI-Risikoeinstufung nach AI Act" + category: AI_Act + description: | + Prüfung ob das KI-System unter die Hochrisiko-Kategorie + des AI Act fällt. + when_applicable: | + - Alle KI-Systeme in der EU + - Vor Inbetriebnahme prüfen + what_to_do: | + 1. Anhang III AI Act prüfen: + - Biometrie, Kritische Infrastruktur + - Bildung, Beschäftigung + - Wesentliche Dienste, Strafverfolgung + - Migration, Justiz + 2. Bei Hochrisiko: Konformitätsbewertung + 3. Dokumentation der Einstufung + evidence_needed: + - "Risikoeinstufungs-Dokument" + - "Begründung bei Nicht-Hochrisiko" + effort: low + gdpr_ref: "Art. 6, Anhang III AI Act" + related_controls: [CTRL-DSFA, CTRL-HITL] + + CTRL-AI-DOCUMENTATION: + id: CTRL-AI-DOCUMENTATION + title: "KI-System-Dokumentation" + category: AI_Act + description: | + Technische Dokumentation des KI-Systems + nach AI Act Anforderungen. + when_applicable: | + - High-Risk AI Systeme + - Empfohlen auch für andere KI + what_to_do: | + 1. System-Beschreibung erstellen + 2. Trainingsdaten dokumentieren + 3. Leistungsmetriken festhalten + 4. Risikomanagement dokumentieren + 5. Qualitätsmanagement-System + 6. Logs und Monitoring + evidence_needed: + - "Technische Dokumentation" + - "Trainingsdaten-Dokumentation" + - "Leistungskennzahlen" + effort: high + gdpr_ref: "Art. 11, Art. 12 AI Act" + related_controls: [CTRL-AI-RISK-CLASSIFICATION, CTRL-DSFA] + + # --------------------------------------------------------------------------- + # 11. Branchenspezifische Maßnahmen + # --------------------------------------------------------------------------- + + CTRL-MINOR-PROTECTION: + id: CTRL-MINOR-PROTECTION + title: "Minderjährigenschutz" + category: DSGVO + description: | + Besondere Schutzmaßnahmen für Daten von Minderjährigen. + when_applicable: | + - Dienste für Kinder/Jugendliche + - Bildungssektor + - Gaming/Social Media für unter 18 + what_to_do: | + 1. Kein Training mit Daten Minderjähriger + 2. Erhöhte Löschpflichten beachten + 3. Kindgerechte Kommunikation + 4. Reduzierte Datenerhebung + 5. Keine Profiling-Nutzung + evidence_needed: + - "Schutzkonzept Minderjährige" + - "Altersverifikation" + effort: medium + gdpr_ref: "Art. 8 DSGVO, ErwGr. 38" + related_controls: [CTRL-CONSENT-PARENTAL, CTRL-NO-TRAINING] + + CTRL-CCTV-PRIVACY: + id: CTRL-CCTV-PRIVACY + title: "Videoüberwachung Datenschutz" + category: DSGVO + description: | + Datenschutzkonforme Gestaltung von Videoüberwachung. + when_applicable: | + - CCTV/Videoüberwachung + - Parkhaussysteme mit Kameras + - Arbeitsplatzüberwachung + what_to_do: | + 1. Hinweisschilder aufstellen (vor Überwachungsbereich) + 2. Interessenabwägung dokumentieren + 3. Speicherdauer minimieren (max. 72h empfohlen) + 4. Zugriff strikt beschränken + 5. Bei Gesichtserkennung: Art. 9 beachten + evidence_needed: + - "Interessenabwägung dokumentiert" + - "Hinweisschild-Fotos" + - "Löschkonzept Video" + effort: medium + gdpr_ref: "Art. 6(1)(f) DSGVO, §4 BDSG" + related_controls: [CTRL-RETENTION, CTRL-FACE-BLURRING] + + CTRL-FACE-BLURRING: + id: CTRL-FACE-BLURRING + title: "Gesichts-Unkenntlichmachung" + category: Technical + description: | + Automatische Verpixelung oder Unschärfung von Gesichtern + in Videoaufnahmen. + when_applicable: | + - Videoüberwachung öffentlicher Bereiche + - Gesichtserkennung nicht erforderlich + - Datensparsamkeit erhöhen + what_to_do: | + 1. Gesichtserkennungs-Algorithmus einsetzen + 2. Echtzeit-Blurring implementieren + 3. Nur anonymisierte Aufnahmen speichern + 4. Genauigkeit regelmäßig prüfen + evidence_needed: + - "Blurring-Lösung dokumentiert" + - "Beispiel-Screenshots" + effort: medium + gdpr_ref: "Art. 25 DSGVO (Privacy by Design)" + related_controls: [CTRL-CCTV-PRIVACY, CTRL-ANONYMIZATION] + + CTRL-LP-ANONYMIZATION: + id: CTRL-LP-ANONYMIZATION + title: "Kennzeichen-Anonymisierung" + category: Technical + description: | + Automatische Unkenntlichmachung von KFZ-Kennzeichen. + when_applicable: | + - Parkhaus-Systeme + - Verkehrsüberwachung + - Wenn Halteridentifikation nicht erforderlich + what_to_do: | + 1. OCR nur für Berechtigungsprüfung + 2. Nach Prüfung: Kennzeichen löschen oder anonymisieren + 3. Nur Hash oder Partial-Match speichern + 4. Keine Bewegungsprofile erstellen + evidence_needed: + - "Anonymisierungskonzept" + - "Speicherfristen dokumentiert" + effort: low + gdpr_ref: "Art. 5(1)(c), (e) DSGVO" + related_controls: [CTRL-RETENTION, CTRL-CCTV-PRIVACY] + +# ============================================================================= +# METADATA +# ============================================================================= + +metadata: + total_controls: 30 + categories: + - DSGVO + - AI_Act + - Technical + - Contractual + effort_distribution: + low: 8 + medium: 14 + high: 8 + primary_regulations: + - "DSGVO (EU 2016/679)" + - "AI Act (EU 2024/xxx)" + - "BDSG" + - "EU 2021/914 (SCC)" diff --git a/ai-compliance-sdk/policies/eprivacy_corpus.yaml b/ai-compliance-sdk/policies/eprivacy_corpus.yaml new file mode 100644 index 0000000..a0bbae8 --- /dev/null +++ b/ai-compliance-sdk/policies/eprivacy_corpus.yaml @@ -0,0 +1,801 @@ +# ============================================================================= +# ePrivacy Corpus - Richtlinie 2002/58/EG +# Fuer Legal RAG Integration +# Version: 1.0 +# Stand: Januar 2026 +# ============================================================================= + +version: "1.0" +jurisdiction: EU +last_updated: "2026-01-29" +description: "Rechtliche Informationen zur ePrivacy-Richtlinie (Richtlinie 2002/58/EG) ueber die Verarbeitung personenbezogener Daten und den Schutz der Privatsphaere in der elektronischen Kommunikation" + +# ============================================================================= +# GRUNDLAGEN +# ============================================================================= + +fundamentals: + + eprivacy_definition: + id: EPRIV-DEF-001 + topic: "Was ist die ePrivacy-Richtlinie?" + content: | + Die ePrivacy-Richtlinie (Richtlinie 2002/58/EG) ist eine EU-Richtlinie, die + spezifische Datenschutzregeln fuer den Bereich der elektronischen Kommunikation + festlegt. Sie ergaenzt die DSGVO als "lex specialis" fuer diesen Bereich. + + Offizieller Titel: "Richtlinie 2002/58/EG des Europaeischen Parlaments und + des Rates vom 12. Juli 2002 ueber die Verarbeitung personenbezogener Daten + und den Schutz der Privatsphaere in der elektronischen Kommunikation" + + Die Richtlinie wurde mehrfach geaendert: + - 2006 durch Richtlinie 2006/24/EG (Vorratsdatenspeicherung, spaeter aufgehoben) + - 2009 durch Richtlinie 2009/136/EG ("Cookie-Richtlinie") + + WICHTIG: Die ePrivacy-Verordnung (ePVO) soll die Richtlinie ersetzen, + ist aber Stand 2026 noch nicht in Kraft getreten. + legal_refs: + - "Richtlinie 2002/58/EG" + - "Richtlinie 2009/136/EG" + - "DSGVO Art. 95 (Verhaeltnis zu ePrivacy)" + keywords: ["ePrivacy", "E-Privacy", "2002/58/EG", "Cookie-Richtlinie"] + + scope_of_application: + id: EPRIV-DEF-002 + topic: "Anwendungsbereich der ePrivacy-Richtlinie" + content: | + Die ePrivacy-Richtlinie gilt fuer: + + 1. ANBIETER OEFFENTLICHER KOMMUNIKATIONSDIENSTE + - Telekommunikationsunternehmen + - Internet Service Provider + - E-Mail-Dienste + - Messenger-Dienste (umstritten) + + 2. BETREIBER VON WEBSITES UND APPS + - Cookies und aehnliche Technologien + - Online-Tracking + - Direktwerbung per E-Mail + + 3. JEDE VERARBEITUNG VON + - Verkehrsdaten + - Standortdaten + - Kommunikationsinhalten + + NICHT anwendbar auf: + - Rein unternehmensinterne Kommunikationssysteme + - Nationale Sicherheit und Strafverfolgung (Ausnahmen) + legal_refs: + - "Art. 3 Richtlinie 2002/58/EG" + keywords: ["Anwendungsbereich", "Telekommunikation", "ISP"] + + relationship_gdpr: + id: EPRIV-DEF-003 + topic: "Verhaeltnis zur DSGVO" + content: | + Die ePrivacy-Richtlinie steht in einem besonderen Verhaeltnis zur DSGVO: + + GRUNDSATZ (Art. 95 DSGVO): + Die DSGVO erlegt Anbietern oeffentlicher Kommunikationsdienste keine + zusaetzlichen Pflichten auf, soweit die ePrivacy-Richtlinie dieselbe + Zielsetzung verfolgt. + + PRAKTISCHE BEDEUTUNG: + + 1. ePrivacy als "lex specialis" + - Fuer elektronische Kommunikation gelten primaer ePrivacy-Regeln + - DSGVO gilt ergaenzend, wo ePrivacy keine Regelung trifft + + 2. Cookie-Consent + - Art. 5 Abs. 3 ePrivacy regelt Cookies VORRANGIG + - DSGVO-Einwilligung gilt ZUSAETZLICH fuer personenbezogene Daten + + 3. Sanktionen + - DSGVO-Bussgelder (bis 20 Mio. / 4% Umsatz) gelten NICHT direkt + - Nationale Umsetzungsgesetze haben eigene Sanktionen + + WICHTIG: Bei Cookies ist BEIDES erforderlich: + - ePrivacy-Einwilligung (fuer Zugriff auf Geraet) + - DSGVO-Rechtsgrundlage (fuer Verarbeitung personenbezogener Daten) + legal_refs: + - "DSGVO Art. 95" + - "ErwGr. 173 DSGVO" + - "EuGH Planet49 (C-673/17)" + keywords: ["DSGVO", "lex specialis", "Art. 95"] + +# ============================================================================= +# COOKIES UND TRACKING (Art. 5 Abs. 3) +# ============================================================================= + +cookies: + + cookie_consent_rule: + id: EPRIV-COOK-001 + topic: "Cookie-Einwilligungsregel (Art. 5 Abs. 3)" + content: | + Art. 5 Abs. 3 der ePrivacy-Richtlinie regelt den Zugriff auf Endgeraete: + + GRUNDSATZ: + Die Speicherung von Informationen oder der Zugriff auf bereits gespeicherte + Informationen im Endgeraet eines Nutzers ist NUR zulaessig, wenn: + + 1. Der Nutzer VORHER informiert wurde (Transparenz) + 2. Der Nutzer seine EINWILLIGUNG gegeben hat (Opt-In) + + AUSNAHMEN (KEINE Einwilligung erforderlich): + + a) TECHNISCH NOTWENDIGE COOKIES + - Fuer die Uebertragung einer Nachricht erforderlich + - Beispiel: Load Balancer Cookies + + b) UNBEDINGT ERFORDERLICHE COOKIES + - Vom Nutzer ausdruecklich gewuenscht + - Fuer einen Dienst, den der Nutzer ausdruecklich angefordert hat + - Beispiele: + * Warenkorb-Cookies + * Login-Session-Cookies + * Spracheinstellungen + * Cookie-Consent-Cookie selbst + + WICHTIG: Die Ausnahmen sind ENG auszulegen! + - Analytics-Cookies: KEINE Ausnahme, Einwilligung erforderlich + - Marketing-Cookies: KEINE Ausnahme, Einwilligung erforderlich + - Social Media Plugins: KEINE Ausnahme, Einwilligung erforderlich + legal_refs: + - "Art. 5 Abs. 3 Richtlinie 2002/58/EG" + - "EuGH Planet49 (C-673/17)" + - "ErwGr. 66 Richtlinie 2009/136/EG" + keywords: ["Cookie", "Einwilligung", "Art. 5 Abs. 3", "technisch notwendig"] + + cookie_consent_requirements: + id: EPRIV-COOK-002 + topic: "Anforderungen an Cookie-Einwilligung" + content: | + Die Einwilligung nach Art. 5 Abs. 3 ePrivacy muss den DSGVO-Standards + entsprechen (Verweis auf Definition in DSGVO): + + ANFORDERUNGEN: + + 1. FREIWILLIG + - Keine Nachteile bei Ablehnung + - Kein "Cookie Wall" (umstritten, nationale Unterschiede) + - Gleichwertige Ablehnungsoption + + 2. INFORMIERT + - Klare Information VORHER + - Welche Cookies, welcher Zweck + - Wer erhaelt Zugriff (Dritte) + - Speicherdauer + + 3. AKTIVE HANDLUNG + - Opt-In erforderlich (EuGH Planet49) + - Vorausgewaehlte Checkboxen sind UNGUELTIG + - Weitersurfen ist KEINE Einwilligung + + 4. SPEZIFISCH + - Getrennte Einwilligung pro Zweck + - "Alle akzeptieren" muss gleichwertig zu "Alle ablehnen" sein + + 5. WIDERRUFBAR + - Jederzeitiger Widerruf muss moeglich sein + - So einfach wie die Erteilung + + CONSENT MANAGEMENT PLATFORM (CMP): + Professionelle Cookie-Banner muessen: + - Alle Kategorien einzeln anwaehlbar machen + - "Ablehnen" gleichwertig prominent anbieten + - Consent dokumentieren (Nachweis) + - Widerruf ermoeglichen + legal_refs: + - "Art. 5 Abs. 3 i.V.m. Art. 2 lit. f Richtlinie 2002/58/EG" + - "DSGVO Art. 4 Nr. 11 (Definition Einwilligung)" + - "DSGVO Art. 7 (Bedingungen Einwilligung)" + - "EuGH Planet49 (C-673/17)" + keywords: ["Einwilligung", "Opt-In", "Cookie-Banner", "CMP"] + + cookie_categories: + id: EPRIV-COOK-003 + topic: "Cookie-Kategorien und Einwilligungspflicht" + content: | + Uebersicht der Cookie-Kategorien und Einwilligungspflicht: + + KATEGORIE 1: TECHNISCH NOTWENDIG (KEINE Einwilligung) + - Session-Cookies fuer Login + - Warenkorb-Cookies + - Load-Balancer-Cookies + - CSRF-Token-Cookies + - Cookie-Consent-Cookie + - Spracheinstellungs-Cookies + - Barrierefreiheits-Cookies + + KATEGORIE 2: FUNKTIONAL (Einwilligung ERFORDERLICH) + - Praeferenz-Cookies (Design, Layout) + - Video-Player-Einstellungen + - Chat-Widget-Cookies + - Formular-Autofill-Cookies + + KATEGORIE 3: ANALYTICS (Einwilligung ERFORDERLICH) + - Google Analytics + - Matomo/Piwik + - Hotjar, Crazy Egg + - Performance-Messung + + SONDERFALL: Analytics ohne Einwilligung (UMSTRITTEN!) + - Matomo ohne Cookies und mit IP-Anonymisierung + - Serverseitige Analytics + - Aggregierte Statistiken + - Nationale Behoerden haben unterschiedliche Meinungen! + + KATEGORIE 4: MARKETING/WERBUNG (Einwilligung ERFORDERLICH) + - Retargeting-Cookies + - Google Ads/Meta Pixel + - Affiliate-Tracking + - Cross-Site-Tracking + + KATEGORIE 5: SOCIAL MEDIA (Einwilligung ERFORDERLICH) + - Facebook Like Button + - Twitter Widgets + - LinkedIn Plugins + - Embedded Content von Dritten + legal_refs: + - "Art. 5 Abs. 3 Richtlinie 2002/58/EG" + - "DSK Orientierungshilfe Telemedien (2022)" + - "CNIL Guidelines on Cookies (2020)" + keywords: ["Cookie-Kategorien", "Analytics", "Marketing", "Social Media"] + + local_ai_scenario: + id: EPRIV-COOK-004 + topic: "Szenario: Lokale KI-Anwendung (On-Premises)" + content: | + Bei einer lokalen KI-Anwendung wie BreakPilot (On-Premises auf Mac Studio): + + GRUNDSAETZLICH: + + 1. KEIN externer Cookie-Zugriff + - Alle Verarbeitung lokal auf Schulserver + - Keine Cookies an Dritte + - Keine Tracking-Pixel + + 2. TECHNISCH NOTWENDIGE COOKIES + - Session-Cookies fuer Login: KEINE Einwilligung + - CSRF-Schutz: KEINE Einwilligung + - Benutzereinstellungen (Sprache): Grauzone, besser Einwilligung + + 3. ANALYTICS + - Interne Nutzungsstatistiken (serverseitig): Kein ePrivacy-Problem + - Falls Cookie-basiert: Einwilligung erforderlich + - Empfehlung: Serverseitige Logs statt Cookies + + EMPFEHLUNG FUER BREAKPILOT: + + □ Nur Session-Cookies fuer Login verwenden + □ Keine Analytics-Cookies + □ Keine Third-Party-Einbindungen + □ Einfaches Cookie-Banner mit Hinweis auf notwendige Cookies + □ Datenschutzerklaerung mit Cookie-Informationen + + VORTEIL: + Durch rein lokale Verarbeitung entfallen die meisten + ePrivacy-Probleme automatisch! + legal_refs: + - "Art. 5 Abs. 3 Richtlinie 2002/58/EG" + keywords: ["lokal", "On-Premises", "Session-Cookie"] + +# ============================================================================= +# VERKEHRSDATEN (Art. 6) +# ============================================================================= + +traffic_data: + + traffic_data_definition: + id: EPRIV-TRAF-001 + topic: "Was sind Verkehrsdaten?" + content: | + Verkehrsdaten (Art. 2 lit. b) sind Daten, die zum Zwecke der Weiterleitung + einer Nachricht oder zum Zwecke der Fakturierung verarbeitet werden: + + BEISPIELE: + + 1. Bei TELEFONIE + - Rufnummern (Anrufer und Angerufener) + - Datum und Uhrzeit + - Dauer des Gespraechs + - Art des Dienstes (Sprache, SMS) + + 2. Bei INTERNET + - IP-Adressen (dynamisch und statisch) + - Zeitpunkt der Verbindung + - Datenvolumen + - Geraetekennungen (MAC-Adresse, IMEI) + + 3. Bei E-MAIL + - E-Mail-Adressen (Sender, Empfaenger) + - Zeitstempel + - Betreffzeile (umstritten - eher Inhaltsdaten) + + ABGRENZUNG: + - INHALTSDATEN: Der eigentliche Inhalt der Kommunikation (strenger Schutz) + - VERKEHRSDATEN: Metadaten der Kommunikation (weniger streng) + - STANDORTDATEN: Geografische Position (gesondert geregelt) + legal_refs: + - "Art. 2 lit. b Richtlinie 2002/58/EG" + - "Art. 6 Richtlinie 2002/58/EG" + keywords: ["Verkehrsdaten", "Metadaten", "IP-Adresse", "Traffic Data"] + + traffic_data_processing: + id: EPRIV-TRAF-002 + topic: "Verarbeitung von Verkehrsdaten (Art. 6)" + content: | + Die Verarbeitung von Verkehrsdaten ist streng geregelt: + + GRUNDSATZ (Art. 6 Abs. 1): + Verkehrsdaten muessen GELOESCHT oder ANONYMISIERT werden, + sobald sie fuer die Uebertragung nicht mehr benoetigt werden. + + AUSNAHMEN: + + 1. ABRECHNUNG (Art. 6 Abs. 2) + - Verarbeitung fuer Rechnungsstellung zulaessig + - Nur bis Ende der Frist fuer Rechnungsanfechtung + - In Deutschland: 6 Monate + + 2. VERMARKTUNG VON DIENSTEN (Art. 6 Abs. 3) + - Nur mit EINWILLIGUNG des Teilnehmers + - Nur fuer Vermarktung von Telekommunikationsdiensten + - Jederzeit widerrufbar + + 3. MEHRWERTDIENSTE (Art. 6 Abs. 4) + - Mit Einwilligung fuer elektronische Mehrwertdienste + - Nutzer muss informiert werden + - Zeitlicher Rahmen definiert + + WICHTIG FUER ANBIETER: + - Technische Vorkehrungen zur automatischen Loeschung + - Dokumentation der Loeschfristen + - Keine Speicherung "auf Vorrat" ohne Rechtsgrundlage + legal_refs: + - "Art. 6 Richtlinie 2002/58/EG" + - "EuGH Vorratsdatenspeicherung (verbundene Rs. C-293/12 und C-594/12)" + keywords: ["Verkehrsdaten", "Loeschung", "Abrechnung"] + +# ============================================================================= +# STANDORTDATEN (Art. 9) +# ============================================================================= + +location_data: + + location_data_rules: + id: EPRIV-LOC-001 + topic: "Verarbeitung von Standortdaten (Art. 9)" + content: | + Standortdaten, die ueber Verkehrsdaten hinausgehen, unterliegen + besonderen Regeln nach Art. 9: + + DEFINITION (Art. 2 lit. c): + Daten, die den geografischen Standort des Endgeraets eines Nutzers angeben. + + GRUNDSATZ: + Verarbeitung von Standortdaten NUR zulaessig wenn: + - Anonymisiert, ODER + - Mit EINWILLIGUNG des Nutzers + + ANFORDERUNGEN BEI EINWILLIGUNG: + + 1. VOR der Verarbeitung einzuholen + 2. Umfang und Dauer der Verarbeitung angeben + 3. Zweck der Verarbeitung angeben + 4. Ob Daten an Dritte weitergegeben werden + 5. Widerruf jederzeit moeglich + + PRAKTISCHE ANWENDUNG: + + - Navigationsdienste: Einwilligung erforderlich + - Standortbasierte Werbung: Einwilligung erforderlich + - Flottenmanagement: Einwilligung der Fahrer + - Find-my-Device: Einwilligung (oft Teil der Nutzungsbedingungen) + + SONDERFALL: Notrufe + Standortdaten duerfen fuer Notrufdienste ohne Einwilligung + verarbeitet werden (Art. 10). + legal_refs: + - "Art. 9 Richtlinie 2002/58/EG" + - "Art. 2 lit. c Richtlinie 2002/58/EG" + keywords: ["Standortdaten", "Location Data", "GPS", "Geolocation"] + +# ============================================================================= +# UNERBETENE NACHRICHTEN - SPAM (Art. 13) +# ============================================================================= + +spam: + + email_marketing_rules: + id: EPRIV-SPAM-001 + topic: "E-Mail-Marketing und Direktwerbung (Art. 13)" + content: | + Art. 13 regelt die Verwendung elektronischer Kommunikation fuer + Direktwerbung: + + GRUNDSATZ (Opt-In): + Die Verwendung von E-Mail, SMS, Fax oder automatischen Anrufsystemen + fuer Direktwerbung ist NUR zulaessig mit VORHERIGER EINWILLIGUNG. + + AUSNAHME - BESTANDSKUNDEN (Art. 13 Abs. 2): + E-Mail-Werbung OHNE Einwilligung ist zulaessig wenn ALLE Bedingungen erfuellt: + + 1. Der Absender hat die E-Mail-Adresse vom Kunden selbst erhalten + 2. Im Zusammenhang mit einem KAUF von Waren/Dienstleistungen + 3. Die Werbung bezieht sich auf AEHNLICHE Produkte/Dienstleistungen + 4. Der Kunde hatte bei Erhebung die Moeglichkeit zu widersprechen + 5. Bei JEDER weiteren Nachricht: Widerspruchsmoeglichkeit (Opt-Out) + + WICHTIG: Die Ausnahme ist ENG auszulegen! + - Newsletter: Einwilligung erforderlich (kein "aehnliches Produkt") + - Werbung fuer Dritte: Einwilligung erforderlich + - B2B-Kaltakquise per E-Mail: Umstritten, nationale Unterschiede + + TELEFON-WERBUNG: + - Automatische Anrufsysteme: Immer Einwilligung + - Manuelle Anrufe: Nationale Regelung (in D: Einwilligung erforderlich) + + ABSENDERKENNUNG: + Die Identitaet des Absenders darf NICHT verschleiert werden! + Eine gueltige Antwortadresse muss vorhanden sein. + legal_refs: + - "Art. 13 Richtlinie 2002/58/EG" + - "§ 7 UWG (Deutschland)" + - "EuGH StWL Staedtische Werke Lauf (C-102/20)" + keywords: ["E-Mail-Marketing", "Spam", "Direktwerbung", "Newsletter", "Opt-In"] + + double_opt_in: + id: EPRIV-SPAM-002 + topic: "Double Opt-In fuer Newsletter" + content: | + Das Double Opt-In Verfahren ist Best Practice fuer Newsletter-Anmeldungen: + + ABLAUF: + + 1. Nutzer gibt E-Mail-Adresse ein (Single Opt-In) + 2. System sendet Bestaetigungs-E-Mail mit Link + 3. Nutzer klickt Link zur Bestaetigung (Double Opt-In) + 4. Erst dann: Eintrag in Newsletter-Liste + + VORTEILE: + + - Nachweis der Einwilligung + - Schutz vor Missbrauch (fremde E-Mail-Adressen) + - Reduziert Spam-Beschwerden + - Bessere Zustellraten + + ANFORDERUNGEN AN BESTAETIGUNGS-E-MAIL: + + - KEINE Werbung enthalten (nur Bestaetigung) + - Klarer Hinweis auf den Zweck + - Bestaetigung muss aktiv erfolgen + - Protokollierung: IP, Zeitstempel, User-Agent + + RECHTLICHE EINORDNUNG: + - Die Bestaetigungs-E-Mail selbst ist KEINE Werbung + - Aber: Nur EINE Erinnerung zulaessig + - Nach Nicht-Bestaetigung: Adresse loeschen + + SPEICHERDAUER NACHWEIS: + - Einwilligungsnachweis aufbewahren + - Mindestens bis Widerruf + Verjaehrungsfrist + - In Deutschland: 3 Jahre empfohlen + legal_refs: + - "BGH I ZR 164/09 (Double Opt-In)" + - "Art. 7 Abs. 1 DSGVO (Nachweis)" + keywords: ["Double Opt-In", "Newsletter", "Bestaetigung"] + +# ============================================================================= +# KOMMUNIKATIONSGEHEIMNIS (Art. 5) +# ============================================================================= + +confidentiality: + + communication_secrecy: + id: EPRIV-CONF-001 + topic: "Vertraulichkeit der Kommunikation (Art. 5 Abs. 1)" + content: | + Art. 5 Abs. 1 schuetzt die Vertraulichkeit elektronischer Kommunikation: + + GRUNDSATZ: + Die Mitgliedstaaten stellen die Vertraulichkeit der mit oeffentlichen + Kommunikationsnetzen uebertragenen Nachrichten sicher. + + VERBOTEN IST: + - Abhoeren von Nachrichten + - Anzapfen von Leitungen + - Speicherung von Kommunikation durch Unbefugte + - Jede andere Art des Abfangens + + AUSNAHMEN: + - Mit Einwilligung der betroffenen Nutzer + - Gesetzlich erlaubte Ueberwachung (Strafverfolgung) + - Technische Speicherung fuer Uebertragungszwecke + + PRAKTISCHE BEDEUTUNG: + + 1. ARBEITGEBER + - Abhoeren von Mitarbeiter-E-Mails problematisch + - Private Nutzung verboten → mehr Spielraum + - Betriebsvereinbarung empfohlen + + 2. E-MAIL-PROVIDER + - Automatische Spam-Filter: Zulaessig (technisch notwendig) + - Werbefinanzierte Analyse: Einwilligung erforderlich + + 3. MESSENGER-DIENSTE + - Ende-zu-Ende-Verschluesselung schuetzt Vertraulichkeit + - "Client-Side Scanning" (geplant) hochumstritten + legal_refs: + - "Art. 5 Abs. 1 Richtlinie 2002/58/EG" + - "Art. 10 GG (Fernmeldegeheimnis)" + - "§ 88 TKG (Deutschland)" + keywords: ["Kommunikationsgeheimnis", "Vertraulichkeit", "Abhoeren"] + +# ============================================================================= +# NATIONALE UMSETZUNG (DEUTSCHLAND) +# ============================================================================= + +national_implementation: + + germany_ttdsg: + id: EPRIV-NAT-001 + topic: "Umsetzung in Deutschland: TTDSG" + content: | + In Deutschland wurde die ePrivacy-Richtlinie durch das + Telekommunikation-Telemedien-Datenschutz-Gesetz (TTDSG) umgesetzt. + + TTDSG (seit 01.12.2021): + + § 25 TTDSG - COOKIES UND AEHNLICHE TECHNOLOGIEN: + Entspricht Art. 5 Abs. 3 ePrivacy-Richtlinie + - Einwilligung erforderlich fuer nicht-notwendige Cookies + - Ausnahme: Technisch notwendige Speicherung/Zugriff + + § 26 TTDSG - ANERKANNTE DIENSTE (PIMS): + Personal Information Management Services + - Nutzer kann zentral Einstellungen verwalten + - Websites muessen PIMS-Signale beachten + - Noch kaum praktische Umsetzung + + WEITERE RELEVANTE GESETZE: + + TKG (Telekommunikationsgesetz): + - § 88 TKG: Fernmeldegeheimnis + - § 96ff TKG: Verkehrsdaten + + UWG (Gesetz gegen unlauteren Wettbewerb): + - § 7 UWG: Unzumutbare Belaestigungen + - Spam-Verbot, Telefon-Werbung + + SANKTIONEN (§ 28 TTDSG): + - Verstoss gegen § 25: Bussgeld bis 300.000 EUR + - Verstoss gegen § 26: Bussgeld bis 50.000 EUR + legal_refs: + - "TTDSG (Telekommunikation-Telemedien-Datenschutz-Gesetz)" + - "§ 25 TTDSG" + - "§ 7 UWG" + keywords: ["TTDSG", "Deutschland", "§ 25", "PIMS", "UWG"] + + dsk_guidance: + id: EPRIV-NAT-002 + topic: "Orientierungshilfe der DSK zu Telemedien" + content: | + Die Datenschutzkonferenz (DSK) hat eine Orientierungshilfe fuer + Anbieter von Telemedien veroeffentlicht: + + KERNAUSSAGEN: + + 1. EINWILLIGUNG + - Muss VOR dem Setzen von Cookies eingeholt werden + - Vorausgewaehlte Checkboxen sind unwirksam + - "Nur notwendige akzeptieren" muss gleichwertig sein + + 2. TECHNISCH NOTWENDIG + - Enger Auslegung + - Session-Cookies: Ja + - Persistente Praeferenz-Cookies: Nein + + 3. INFORMATIONSPFLICHTEN + - Zweck jedes Cookies angeben + - Speicherdauer angeben + - Dritte benennen + + 4. DOKUMENTATION + - Einwilligungen dokumentieren + - Mindestens: Zeitstempel, Umfang, Version + + 5. WIDERRUF + - Jederzeit moeglich + - So einfach wie Erteilung + - Link im Footer oder Cookie-Banner + + PRAXISTIPP: + Die DSK-Orientierungshilfe ist nicht rechtlich bindend, + wird aber von Aufsichtsbehoerden als Massstab herangezogen. + legal_refs: + - "DSK Orientierungshilfe fuer Anbieter von Telemedien (2022)" + - "DSK Beschluss zu Tracking (2021)" + keywords: ["DSK", "Orientierungshilfe", "Telemedien"] + +# ============================================================================= +# EPRIVACY-VERORDNUNG (AUSBLICK) +# ============================================================================= + +future: + + eprivacy_regulation: + id: EPRIV-FUT-001 + topic: "ePrivacy-Verordnung (ePVO) - Ausblick" + content: | + Die ePrivacy-Verordnung (ePVO) soll die Richtlinie 2002/58/EG ersetzen: + + STATUS (Stand 2026): + - Kommissionsvorschlag: Januar 2017 + - Rat: Kein Konsens erreicht + - Mehrere Kompromissvorschlaege gescheitert + - Inkrafttreten: Weiterhin unklar + + GEPLANTE AENDERUNGEN: + + 1. VERORDNUNG STATT RICHTLINIE + - Direkt anwendbar in allen Mitgliedstaaten + - Keine Umsetzung erforderlich + - Einheitliche Regeln in der EU + + 2. ERWEITERTER ANWENDUNGSBEREICH + - Auch OTT-Dienste (WhatsApp, Zoom, etc.) + - Auch Maschine-zu-Maschine-Kommunikation (IoT) + + 3. COOKIE-WALLS + - Verschiedene Positionen + - Evtl. unter bestimmten Bedingungen zulaessig + + 4. BROWSER-EINSTELLUNGEN + - "Privacy by Default" im Browser + - Zentrale Einwilligungsverwaltung + + 5. HARMONISIERTE SANKTIONEN + - DSGVO-aehnliche Bussgelder + + BIS ZUR EPVO: + Die Richtlinie 2002/58/EG und nationale Umsetzungen bleiben in Kraft! + legal_refs: + - "COM(2017) 10 final (Kommissionsvorschlag)" + - "Verschiedene Ratsdokumente" + keywords: ["ePVO", "ePrivacy-Verordnung", "Zukunft"] + +# ============================================================================= +# PRAKTISCHE CHECKLISTEN +# ============================================================================= + +checklists: + + website_checklist: + id: EPRIV-CHECK-001 + topic: "ePrivacy-Checkliste fuer Websites" + content: | + Checkliste fuer Website-Betreiber: + + COOKIES: + □ Cookie-Audit durchgefuehrt (alle Cookies identifiziert) + □ Kategorisierung (technisch notwendig vs. einwilligungspflichtig) + □ Cookie-Banner implementiert + □ Opt-In vor Setzen von nicht-notwendigen Cookies + □ "Ablehnen" gleichwertig zu "Akzeptieren" + □ Granulare Auswahlmoeglichkeit (Kategorien) + □ Speicherdauer dokumentiert + □ Einwilligungen protokolliert + □ Widerruf jederzeit moeglich + + DATENSCHUTZERKLAERUNG: + □ Cookie-Informationen enthalten + □ Alle Cookies mit Zweck aufgelistet + □ Drittanbieter benannt + □ Speicherdauer angegeben + + E-MAIL-MARKETING: + □ Double Opt-In implementiert + □ Abmelde-Link in jeder E-Mail + □ Einwilligungen dokumentiert + □ Bei Bestandskunden: Widerspruchsmoeglichkeit + + TRACKING/ANALYTICS: + □ Nur mit Einwilligung aktiv + □ Oder: Einwilligungsfreie Alternative (z.B. serverseitig) + □ IP-Anonymisierung aktiviert + □ Datenverarbeitung dokumentiert + legal_refs: + - "Art. 5 Abs. 3 Richtlinie 2002/58/EG" + - "§ 25 TTDSG" + keywords: ["Checkliste", "Website", "Cookie-Banner"] + + local_app_checklist: + id: EPRIV-CHECK-002 + topic: "ePrivacy-Checkliste fuer lokale Anwendungen" + content: | + Checkliste fuer lokale Anwendungen (On-Premises): + + GRUNDSAETZE: + □ Alle Daten bleiben lokal + □ Keine Cloud-Anbindung fuer Nutzerdaten + □ Keine Third-Party-Tracker + + COOKIES/LOKALE SPEICHERUNG: + □ Nur Session-Cookies fuer Login + □ Keine persistenten Tracking-Cookies + □ Keine Local Storage fuer Tracking + □ Kein Fingerprinting + + ANALYTICS: + □ Serverseitige Logs statt Cookies + □ Keine personenbezogenen Daten in Logs + □ Oder: Einwilligung fuer Cookie-Analytics + + KOMMUNIKATION: + □ Keine automatisierten Werbe-E-Mails ohne Einwilligung + □ Abmelde-Moeglichkeit bei Benachrichtigungen + □ Push-Benachrichtigungen nur mit Zustimmung + + DOKUMENTATION: + □ Datenschutzerklaerung aktuell + □ Cookie-Informationen (falls Cookies) + □ Technische Dokumentation der Datenverarbeitung + + VORTEIL LOKALER VERARBEITUNG: + Die meisten ePrivacy-Anforderungen entfallen bei rein + lokaler Verarbeitung ohne externe Dienste! + legal_refs: + - "Art. 5 Abs. 3 Richtlinie 2002/58/EG" + keywords: ["lokal", "On-Premises", "Checkliste"] + +# ============================================================================= +# KEYWORDS FUER RAG RETRIEVAL +# ============================================================================= + +rag_keywords: + primary: + - "ePrivacy" + - "E-Privacy" + - "2002/58/EG" + - "Cookie" + - "Cookie-Richtlinie" + - "Cookie-Banner" + - "Cookie-Consent" + - "Einwilligung" + - "Opt-In" + - "Tracking" + - "TTDSG" + - "§ 25 TTDSG" + - "Art. 5 Abs. 3" + - "Verkehrsdaten" + - "Standortdaten" + - "Spam" + - "E-Mail-Marketing" + - "Newsletter" + - "Direktwerbung" + + secondary: + - "Planet49" + - "Kommunikationsgeheimnis" + - "Telekommunikation" + - "OTT-Dienste" + - "Analytics" + - "Google Analytics" + - "Matomo" + - "Retargeting" + - "Double Opt-In" + - "Cookie-Wall" + - "PIMS" + - "DSK" + - "Orientierungshilfe" + - "ePVO" + - "ePrivacy-Verordnung" + - "technisch notwendig" + - "Session-Cookie" + - "Local Storage" + - "Fingerprinting" + - "Third-Party" + - "Cross-Site-Tracking" diff --git a/ai-compliance-sdk/policies/financial_regulations_corpus.yaml b/ai-compliance-sdk/policies/financial_regulations_corpus.yaml new file mode 100644 index 0000000..7808911 --- /dev/null +++ b/ai-compliance-sdk/policies/financial_regulations_corpus.yaml @@ -0,0 +1,613 @@ +# ============================================================================= +# Financial Regulations Legal Corpus +# For Legal RAG Integration +# ============================================================================= +# +# Enthält Kernpassagen aus: +# - DORA (EU 2022/2554) +# - MaRisk (BaFin) +# - BAIT (BaFin) +# +# Diese Passagen werden in den Qdrant Vector Store geladen +# und für Legal RAG Erklärungen verwendet. +# +# ============================================================================= + +metadata: + version: "1.0.0" + created: "2026-01-29" + sources: + - name: "DORA" + reference: "Verordnung (EU) 2022/2554" + effective_date: "2025-01-17" + - name: "MaRisk" + reference: "Rundschreiben 10/2021 (BA)" + version: "7. MaRisk-Novelle" + - name: "BAIT" + reference: "Rundschreiben 10/2017 (BA)" + version: "2021" + +# ============================================================================= +# DORA - Digital Operational Resilience Act +# ============================================================================= + +dora_passages: + + # --- Anwendungsbereich --- + + - id: DORA-ART-2 + article: "Artikel 2" + title: "Anwendungsbereich" + category: scope + text: | + (1) Diese Verordnung gilt für folgende Finanzunternehmen: + a) Kreditinstitute, + b) Zahlungsinstitute, + c) Kontoinformationsdienstleister, + d) E-Geld-Institute, + e) Wertpapierfirmen, + f) Anbieter von Krypto-Dienstleistungen, + g) Zentralverwahrer, + h) zentrale Gegenparteien, + i) Handelsplätze, + j) Transaktionsregister, + k) Verwalter alternativer Investmentfonds und Verwaltungsgesellschaften, + l) Datenbereitstellungsdienste, + m) Versicherungs- und Rückversicherungsunternehmen, + n) Versicherungsvermittler, Rückversicherungsvermittler und + Versicherungsvermittler in Nebentätigkeit, + o) Einrichtungen der betrieblichen Altersversorgung, + p) Ratingagenturen, + q) Administratoren kritischer Referenzwerte, + r) Schwarmfinanzierungsdienstleister, + s) Verbriefungsregister. + legal_refs: + - "DORA Art. 2(1)" + keywords: + - Anwendungsbereich + - Finanzunternehmen + - Kreditinstitute + - Versicherungen + - Krypto + + # --- IKT-Risikomanagement --- + + - id: DORA-ART-6 + article: "Artikel 6" + title: "IKT-Risikomanagementrahmen" + category: ict_risk_management + text: | + (1) Finanzunternehmen verfügen über einen soliden, umfassenden und gut + dokumentierten IKT-Risikomanagementrahmen, der ihnen ermöglicht, + IKT-Risiken schnell, effizient und umfassend anzugehen und ein hohes + Maß an digitaler operationaler Resilienz zu gewährleisten. + + (2) Der IKT-Risikomanagementrahmen umfasst mindestens Strategien, + Leit- und Richtlinien, Verfahren sowie IKT-Protokolle und -Tools, + die zum angemessenen Schutz aller Informations- und IKT-Assets + erforderlich sind. + + (3) Das Leitungsorgan des Finanzunternehmens ist für die Festlegung, + Genehmigung, Überwachung und Verantwortung der Umsetzung aller + Vorkehrungen im Zusammenhang mit dem IKT-Risikomanagementrahmen + verantwortlich. + legal_refs: + - "DORA Art. 6" + keywords: + - IKT-Risikomanagement + - Governance + - Leitungsorgan + - digitale Resilienz + + - id: DORA-ART-8 + article: "Artikel 8" + title: "Identifizierung" + category: ict_risk_management + text: | + (1) Finanzunternehmen identifizieren, klassifizieren und dokumentieren + alle IKT-gestützten Unternehmensfunktionen, Rollen und Verantwortlichkeiten, + die Informations- und IKT-Assets, die diese Funktionen unterstützen, + sowie deren Abhängigkeiten in Bezug auf IKT-Risiken. + + (2) Finanzunternehmen identifizieren alle Quellen von IKT-Risiken, + insbesondere das Risiko gegenüber und von anderen Finanzunternehmen, + und bewerten Cyberbedrohungen und IKT-Schwachstellen, die für ihre + IKT-gestützten Unternehmensfunktionen, Informations- und IKT-Assets + relevant sind. + legal_refs: + - "DORA Art. 8" + keywords: + - Identifizierung + - IKT-Assets + - Cyberbedrohungen + - Schwachstellen + + - id: DORA-ART-9 + article: "Artikel 9" + title: "Schutz und Prävention" + category: ict_risk_management + text: | + (1) Um einen angemessenen Schutz der IKT-Systeme zu gewährleisten und + Maßnahmen zur Reaktion auf IKT-bezogene Vorfälle zu organisieren, + überwachen und kontrollieren Finanzunternehmen kontinuierlich die + Sicherheit und das Funktionieren der IKT-Systeme und -Tools. + + (2) Finanzunternehmen konzipieren und implementieren + IKT-Sicherheitsmaßnahmen, einschließlich Leit- und Richtlinien, + Verfahren, Protokolle und Tools zum Schutz aller IKT-Assets. + legal_refs: + - "DORA Art. 9" + keywords: + - Schutz + - Prävention + - IKT-Sicherheit + - Überwachung + + # --- IKT-Vorfälle --- + + - id: DORA-ART-17 + article: "Artikel 17" + title: "IKT-bezogenes Vorfallmanagement" + category: incident_management + text: | + (1) Finanzunternehmen definieren, erstellen und implementieren einen + IKT-bezogenen Vorfallmanagementprozess zur Erkennung, Verwaltung und + Meldung von IKT-bezogenen Vorfällen. + + (2) Finanzunternehmen zeichnen alle IKT-bezogenen Vorfälle und + erheblichen Cyberbedrohungen auf. Finanzunternehmen richten geeignete + Verfahren und Prozesse ein, um eine kohärente und integrierte + Überwachung, Handhabung und Nachverfolgung von IKT-bezogenen Vorfällen + zu gewährleisten. + legal_refs: + - "DORA Art. 17" + keywords: + - Vorfallmanagement + - Incident Response + - Meldepflicht + - Cyberbedrohungen + + - id: DORA-ART-19 + article: "Artikel 19" + title: "Meldung schwerwiegender IKT-bezogener Vorfälle" + category: incident_management + text: | + (1) Finanzunternehmen melden schwerwiegende IKT-bezogene Vorfälle der + nach Artikel 46 zuständigen Behörde. + + (2) Bei der Klassifizierung von IKT-bezogenen Vorfällen und der + Bestimmung der Auswirkungen eines IKT-bezogenen Vorfalls wenden + Finanzunternehmen folgende Kriterien an: + a) die Anzahl und/oder Relevanz der betroffenen Kunden oder + Finanzgegenparteien, + b) die Dauer des IKT-bezogenen Vorfalls, + c) die geografische Ausbreitung, + d) die Datenverluste, + e) die Kritikalität der betroffenen Dienste, + f) die wirtschaftlichen Auswirkungen. + legal_refs: + - "DORA Art. 19" + keywords: + - Meldepflicht + - BaFin + - schwerwiegende Vorfälle + - Klassifizierung + + # --- Digitale Resilienz-Tests --- + + - id: DORA-ART-24 + article: "Artikel 24" + title: "Allgemeine Anforderungen für Resilienz-Tests" + category: resilience_testing + text: | + (1) Finanzunternehmen richten ein solides und umfassendes Programm für + das Testen der digitalen operationalen Resilienz ein, das als integraler + Bestandteil des IKT-Risikomanagementrahmens Teil des Programms ist. + + (2) Das Programm für das Testen der digitalen operationalen Resilienz + umfasst eine Reihe von Bewertungen, Tests, Methoden, Verfahren und + Tools, die gemäß den Artikeln 25 und 26 anzuwenden sind. + legal_refs: + - "DORA Art. 24" + keywords: + - Resilienz-Tests + - Penetrationstests + - Vulnerability Assessment + - Testprogramm + + - id: DORA-ART-26 + article: "Artikel 26" + title: "Erweiterte Tests von IKT-Tools" + category: resilience_testing + text: | + (1) Finanzunternehmen, die keine Kleinstunternehmen sind, führen + mindestens alle drei Jahre erweiterte Tests durch bedrohungsgeleitete + Penetrationstests (TLPT) durch. + + (2) Der bedrohungsgeleitete Penetrationstest deckt mehrere oder alle + kritischen oder wichtigen Funktionen eines Finanzunternehmens ab und + wird an Live-Produktionssystemen durchgeführt, die diese Funktionen + unterstützen. + legal_refs: + - "DORA Art. 26" + keywords: + - TLPT + - Penetrationstest + - Red Teaming + - kritische Funktionen + + # --- IKT-Drittparteirisiko --- + + - id: DORA-ART-28 + article: "Artikel 28" + title: "Allgemeine Grundsätze" + category: third_party_risk + text: | + (1) Finanzunternehmen verwalten das IKT-Drittparteirisiko als integralen + Bestandteil des IKT-Risikos innerhalb ihres IKT-Risikomanagementrahmens + und im Einklang mit folgenden Grundsätzen: + + a) Finanzunternehmen, die vertragliche Vereinbarungen über die Nutzung + von IKT-Diensten mit IKT-Drittdienstleistern geschlossen haben, + bleiben jederzeit uneingeschränkt für die Einhaltung und Erfüllung + aller Verpflichtungen nach dieser Verordnung und dem geltenden + Finanzdienstleistungsrecht verantwortlich. + + b) Das Leitungsorgan des Finanzunternehmens ist für die Überwachung + des IKT-Drittparteirisikos verantwortlich. + legal_refs: + - "DORA Art. 28" + keywords: + - Drittparteirisiko + - Outsourcing + - IKT-Dienstleister + - Verantwortlichkeit + + - id: DORA-ART-30 + article: "Artikel 30" + title: "Wesentliche Vertragsbestimmungen" + category: third_party_risk + text: | + (1) Die Rechte und Pflichten des Finanzunternehmens und des + IKT-Drittdienstleisters werden klar in einem schriftlichen Vertrag + festgelegt und zugewiesen. + + (2) Vertragliche Vereinbarungen über die Nutzung von IKT-Diensten + enthalten mindestens folgende Elemente: + a) eine klare und vollständige Beschreibung aller Funktionen und + IKT-Dienste, + b) die Standorte, an denen Dienste erbracht werden und Daten + verarbeitet werden, + c) Bestimmungen über die Verfügbarkeit, Authentizität, Integrität + und Vertraulichkeit in Bezug auf den Datenschutz, + d) Bestimmungen über die Gewährleistung des Zugangs, der Wiederherstellung + und der Rückgabe von Daten, + e) Service Level Agreements, + f) Unterstützungspflichten bei IKT-Vorfällen, + g) Kündigungsfristen und Berichtspflichten. + legal_refs: + - "DORA Art. 30" + keywords: + - Vertragsbestimmungen + - SLA + - Auslagerungsvertrag + - Mindestanforderungen + + - id: DORA-ART-29 + article: "Artikel 29" + title: "Vorläufige Bewertung des IKT-Konzentrationsrisikos" + category: third_party_risk + text: | + (2) Finanzunternehmen identifizieren und bewerten das + IKT-Konzentrationsrisiko auf Ebene des Finanzunternehmens unter + Berücksichtigung: + a) des Umfangs der kritischen oder wichtigen Funktionen, die + gegenüber jedem IKT-Drittdienstleister bestehen, + b) des Grades der Substituierbarkeit jedes IKT-Drittdienstleisters, + c) des Anteils von IKT-Drittdienstleistern an einem begrenzten + Markt. + legal_refs: + - "DORA Art. 29" + keywords: + - Konzentrationsrisiko + - Substituierbarkeit + - kritische Dienstleister + - Marktkonzentration + + # --- Sanktionen --- + + - id: DORA-ART-50 + article: "Artikel 50" + title: "Verwaltungsrechtliche Sanktionen" + category: sanctions + text: | + (1) Unbeschadet etwaiger strafrechtlicher Sanktionen und unbeschadet + der Aufsichtsbefugnisse der zuständigen Behörden stellen die + Mitgliedstaaten sicher, dass ihre zuständigen Behörden befugt sind, + verwaltungsrechtliche Sanktionen und Abhilfemaßnahmen zu verhängen. + + (4) Die Mitgliedstaaten können vorsehen, dass die zuständigen Behörden + befugt sind, gegen juristische Personen Geldbußen zu verhängen, + die einen Höchstbetrag von mindestens 1 % des gesamten weltweiten + Jahresumsatzes erreichen. + legal_refs: + - "DORA Art. 50" + keywords: + - Sanktionen + - Bußgelder + - Compliance + - Durchsetzung + +# ============================================================================= +# MaRisk - Mindestanforderungen an das Risikomanagement +# ============================================================================= + +marisk_passages: + + - id: MARISK-AT-4.3.5 + section: "AT 4.3.5" + title: "Verwendung von Modellen" + category: model_risk + text: | + (1) Bei der Verwendung von Risikomodellen oder Risikomessverfahren + sind die mit der Verwendung von Modellen verbundenen Risiken + (Modellrisiken) zu identifizieren, zu überwachen und zu steuern. + + (2) Für wesentliche Risikomodelle ist eine unabhängige Validierung + durchzuführen. Die Validierung umfasst mindestens: + - die Überprüfung der konzeptionellen Grundlagen, + - die Überprüfung der Datenqualität, + - das Backtesting, + - die Überprüfung der Modellanwendung. + + (3) Die Ergebnisse der Validierung sind zu dokumentieren und der + Geschäftsleitung sowie dem zuständigen Aufsichtsorgan zu berichten. + + (4) Bei wesentlichen Defiziten sind unverzüglich Maßnahmen zu + ergreifen und das Ergebnis der Maßnahmen zu dokumentieren. + legal_refs: + - "MaRisk AT 4.3.5" + keywords: + - Modellvalidierung + - Risikomodelle + - Backtesting + - KI-Modelle + + - id: MARISK-AT-9 + section: "AT 9" + title: "Auslagerung" + category: outsourcing + text: | + (1) Bei Auslagerungen hat das Institut vorab eine Risikoanalyse + durchzuführen. Die Risikoanalyse hat insbesondere zu umfassen: + - die strategische Bedeutung der Aktivitäten und Prozesse, + - die Auswirkungen auf das Risikoprofil des Instituts, + - die Qualifikation und Zuverlässigkeit des Auslagerungsunternehmens, + - die Wirtschaftlichkeit. + + (2) Bei wesentlichen Auslagerungen sind die folgenden zusätzlichen + Anforderungen einzuhalten: + - schriftliche Auslagerungsvereinbarung mit Mindestinhalt, + - Sicherstellung von Weisungs-, Prüfungs- und Kontrollrechten, + - Gewährleistung von Zugangs- und Informationsrechten der Aufsicht, + - Einbeziehung in das Notfallmanagement, + - angemessene Exit-Strategien. + + (3) Ein Auslagerungsregister ist zu führen und regelmäßig zu + aktualisieren. + legal_refs: + - "MaRisk AT 9" + keywords: + - Auslagerung + - Outsourcing + - Risikoanalyse + - Exit-Strategie + + - id: MARISK-AT-4.3.2 + section: "AT 4.3.2" + title: "Risikoberichterstattung" + category: risk_reporting + text: | + (1) Die Risikoberichterstattung hat die Geschäftsleitung und + gegebenenfalls das Aufsichtsorgan über die Risikosituation des + Instituts zu informieren. + + (2) Die Berichte müssen: + - regelmäßig erstellt werden, + - aussagekräftig und für die Empfänger verständlich sein, + - die wesentlichen Risiken umfassen, + - Veränderungen der Risikosituation aufzeigen. + + (3) Bei wesentlichen Ereignissen ist eine Ad-hoc-Berichterstattung + sicherzustellen. + legal_refs: + - "MaRisk AT 4.3.2" + keywords: + - Risikoberichterstattung + - Geschäftsleitung + - Monitoring + - Ad-hoc-Meldung + + - id: MARISK-BTO-1.2 + section: "BTO 1.2" + title: "Kreditrisikosteuerung" + category: credit_risk + text: | + (1) Die Kreditrisikosteuerung hat sicherzustellen, dass die aus der + Risikostrategie abgeleiteten Limite eingehalten werden. + + (2) Bei der Verwendung von Scoring-Modellen für Kreditentscheidungen + ist deren Trennschärfe regelmäßig zu überprüfen (Backtesting). + + (3) Die Entscheidungsprozesse bei Kreditvergaben müssen so gestaltet + sein, dass eine angemessene Kreditwürdigkeitsprüfung gewährleistet ist. + legal_refs: + - "MaRisk BTO 1.2" + keywords: + - Kreditrisiko + - Scoring + - Kreditentscheidung + - Backtesting + +# ============================================================================= +# BAIT - Bankaufsichtliche Anforderungen an die IT +# ============================================================================= + +bait_passages: + + - id: BAIT-TZ-1-9 + section: "Tz. 1-9" + title: "IT-Strategie" + category: it_strategy + text: | + (1) Das Institut hat eine mit der Geschäftsstrategie konsistente + IT-Strategie festzulegen. Die IT-Strategie hat mindestens folgende + Aspekte zu umfassen: + - strategische Entwicklung der IT-Aufbau- und IT-Ablauforganisation, + - strategische Entwicklung der IT-Architektur, + - Aussagen zu Standards für IT-Sicherheit, + - Aussagen zum Notfallmanagement. + + (2) Die IT-Strategie ist regelmäßig und anlassbezogen zu überprüfen + und bei Bedarf anzupassen. + + (3) Die IT-Strategie ist von der Geschäftsleitung zu genehmigen. + legal_refs: + - "BAIT Tz. 1-9" + keywords: + - IT-Strategie + - Geschäftsleitung + - IT-Architektur + - IT-Sicherheit + + - id: BAIT-TZ-10-21 + section: "Tz. 10-21" + title: "IT-Governance" + category: it_governance + text: | + (1) Das Institut hat aufbau- und ablauforganisatorische Regelungen + zur IT-Governance zu treffen. Diese müssen insbesondere: + - Rollen und Verantwortlichkeiten klar definieren, + - die IT-bezogenen Aufgaben der Geschäftsleitung festlegen, + - Regelungen zur IT-bezogenen Risikosteuerung umfassen. + + (2) Die IT-Governance hat sicherzustellen, dass: + - Interessenkonflikte vermieden werden, + - ein Informationsrisikomanagement implementiert ist, + - Ressourcen für die IT-Sicherheit bereitgestellt werden. + + (3) Das Drei-Linien-Modell ist auf die IT-Risiken anzuwenden. + legal_refs: + - "BAIT Tz. 10-21" + keywords: + - IT-Governance + - Rollen + - Verantwortlichkeiten + - Drei-Linien-Modell + + - id: BAIT-TZ-27-42 + section: "Tz. 27-42" + title: "Anwendungsentwicklung (Projektmanagement, SDLC)" + category: development + text: | + (1) Bei der Entwicklung von Anwendungen sind angemessene Verfahren + und Schutzmaßnahmen zu implementieren. Dies umfasst: + - Anforderungsmanagement, + - Entwicklungsrichtlinien, + - Testverfahren, + - Abnahme- und Freigabeverfahren. + + (2) Die Entwicklung hat in kontrollierten Umgebungen zu erfolgen. + Testdaten dürfen keine produktiven Echtdaten enthalten, sofern + diese nicht angemessen anonymisiert oder pseudonymisiert sind. + + (3) Änderungen an Anwendungen sind über ein Änderungsmanagement + zu steuern. Notfalländerungen sind nachträglich zu dokumentieren + und zu genehmigen. + legal_refs: + - "BAIT Tz. 27-42" + keywords: + - SDLC + - Anwendungsentwicklung + - Testverfahren + - Änderungsmanagement + + - id: BAIT-TZ-58-66 + section: "Tz. 58-66" + title: "Benutzerberechtigungsmanagement" + category: access_management + text: | + (1) Das Institut hat ein Berechtigungskonzept zu erstellen und + umzusetzen. Das Berechtigungskonzept hat mindestens zu umfassen: + - die Festlegung von Rollen und Berechtigungsprofilen, + - die Definition von Prozessen zur Vergabe, Änderung und + Entziehung von Berechtigungen, + - die Regelung zu privilegierten Berechtigungen. + + (2) Berechtigungen sind nach dem Prinzip der minimalen Rechte + (Least-Privilege-Prinzip) zu vergeben. + + (3) Berechtigungen sind regelmäßig (mindestens jährlich) zu + überprüfen und zu rezertifizieren. + legal_refs: + - "BAIT Tz. 58-66" + keywords: + - IAM + - Berechtigungen + - Least Privilege + - Rezertifizierung + + - id: BAIT-TZ-67-72 + section: "Tz. 67-72" + title: "IT-Betrieb (Protokollierung)" + category: logging + text: | + (1) Sicherheitsrelevante Ereignisse sind zu protokollieren. Die + Protokollierung umfasst mindestens: + - Anmeldeversuche (erfolgreich und fehlgeschlagen), + - Änderungen an Berechtigungen, + - Zugriffe auf sensitive Daten, + - administrative Tätigkeiten. + + (2) Die Protokollierungsdaten sind vor unbefugter Veränderung zu + schützen und entsprechend den regulatorischen Anforderungen + aufzubewahren. + + (3) Die Protokollierungsdaten sind regelmäßig auszuwerten. + legal_refs: + - "BAIT Tz. 67-72" + keywords: + - Protokollierung + - Logging + - Audit-Trail + - Auswertung + +# ============================================================================= +# Verknüpfungen (für Cross-Referencing) +# ============================================================================= + +cross_references: + + # DORA verweist auf MaRisk-ähnliche Anforderungen + - from: DORA-ART-6 + to: MARISK-AT-4.3.5 + relation: "extends" + note: "DORA erweitert MaRisk-Anforderungen um IKT-spezifische Aspekte" + + # DORA-Drittparteirisiko baut auf MaRisk-Auslagerung auf + - from: DORA-ART-28 + to: MARISK-AT-9 + relation: "extends" + note: "DORA konkretisiert Auslagerungsanforderungen für IKT" + + # BAIT-SDLC ist relevant für DORA-Testanforderungen + - from: DORA-ART-24 + to: BAIT-TZ-27-42 + relation: "complements" + note: "BAIT-SDLC-Anforderungen unterstützen DORA-Testprogramm" + + # MaRisk-Modellvalidierung gilt auch für KI-Modelle + - from: MARISK-AT-4.3.5 + to: AI_ACT + relation: "applies_to" + note: "Modellvalidierung gilt auch für KI-Risikomodelle" diff --git a/ai-compliance-sdk/policies/financial_regulations_policy.yaml b/ai-compliance-sdk/policies/financial_regulations_policy.yaml new file mode 100644 index 0000000..39a4f80 --- /dev/null +++ b/ai-compliance-sdk/policies/financial_regulations_policy.yaml @@ -0,0 +1,946 @@ +# ============================================================================= +# Financial Regulations Policy +# DORA, MaRisk, BAIT Compliance for AI Use Cases +# ============================================================================= +# +# Regulierungen: +# - DORA (Digital Operational Resilience Act) - EU 2022/2554 +# - MaRisk (Mindestanforderungen Risikomanagement) - BaFin +# - BAIT (Bankaufsichtliche Anforderungen an die IT) - BaFin +# +# Anwendungsbereich: +# - Kreditinstitute (CRR) +# - Zahlungsdienstleister +# - E-Geld-Institute +# - Wertpapierfirmen +# - Versicherungen (teilweise) +# - Krypto-Asset-Dienstleister +# +# ============================================================================= + +metadata: + version: "1.0.0" + effective_date: "2025-01-17" # DORA Geltungsbeginn + author: "Compliance Team" + jurisdiction: "EU/DE" + regulations: + - name: "DORA" + full_name: "Digital Operational Resilience Act" + reference: "EU 2022/2554" + effective: "2025-01-17" + - name: "MaRisk" + full_name: "Mindestanforderungen an das Risikomanagement" + authority: "BaFin" + version: "7. MaRisk-Novelle (2023)" + - name: "BAIT" + full_name: "Bankaufsichtliche Anforderungen an die IT" + authority: "BaFin" + version: "2021" + +# ============================================================================= +# Anwendbare Domains +# ============================================================================= + +applicable_domains: + - banking + - finance + - insurance + - investment + - payment_services + - crypto_assets + +# ============================================================================= +# Facts Schema - Finanzspezifische Eingabefelder +# ============================================================================= + +facts_schema: + financial_entity: + type: + type: enum + values: + - CREDIT_INSTITUTION # Kreditinstitut nach CRR + - PAYMENT_SERVICE_PROVIDER # Zahlungsdienstleister (PSD2) + - E_MONEY_INSTITUTION # E-Geld-Institut + - INVESTMENT_FIRM # Wertpapierfirma (MiFID II) + - INSURANCE_COMPANY # Versicherungsunternehmen + - CRYPTO_ASSET_PROVIDER # CASP nach MiCA + - OTHER_FINANCIAL # Sonstige Finanzunternehmen + default: OTHER_FINANCIAL + + regulated: + type: boolean + default: true + description: "Unterliegt BaFin-Aufsicht" + + size_category: + type: enum + values: + - SIGNIFICANT # Bedeutendes Institut + - LESS_SIGNIFICANT # Weniger bedeutendes Institut + - SMALL # Kleines Institut + default: LESS_SIGNIFICANT + + ict_service: + is_critical: + type: boolean + default: false + description: "Kritische/wichtige IKT-Dienstleistung nach DORA Art. 3(21)" + + is_outsourced: + type: boolean + default: false + description: "IKT-Auslagerung an Dritte" + + provider_location: + type: enum + values: + - EU + - EEA + - ADEQUACY_DECISION + - THIRD_COUNTRY + default: EU + + concentration_risk: + type: boolean + default: false + description: "Konzentrationsrisiko bei IKT-Drittanbietern" + + ai_application: + affects_customer_decisions: + type: boolean + default: false + description: "KI beeinflusst Kundenentscheidungen (Kredit, Versicherung)" + + algorithmic_trading: + type: boolean + default: false + description: "Algorithmischer Handel" + + risk_assessment: + type: boolean + default: false + description: "KI für Risikobewertung (Kredit-Scoring, Fraud)" + + aml_kyc: + type: boolean + default: false + description: "KI für AML/KYC-Prozesse" + + model_validation_done: + type: boolean + default: false + description: "Modellvalidierung nach MaRisk AT 4.3.5 durchgeführt" + +# ============================================================================= +# DORA-spezifische Controls +# ============================================================================= + +controls: + # --- IKT-Risikomanagement (DORA Kap. II) --- + + CTRL-DORA-ICT-RISK-FRAMEWORK: + id: CTRL-DORA-ICT-RISK-FRAMEWORK + title: "IKT-Risikomanagementrahmen" + category: DORA + dora_ref: "Art. 6-16" + description: "Umfassendes IKT-Risikomanagement nach DORA" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. IKT-Risikomanagementrahmen dokumentieren + 2. Governance-Struktur mit klaren Verantwortlichkeiten + 3. IKT-Risikoidentifikation, -bewertung, -steuerung + 4. Regelmäßige Überprüfung (mindestens jährlich) + evidence_needed: + - IKT-Risikomanagement-Policy + - Governance-Dokumentation + - Risikobewertungsberichte + - Audit-Protokolle + effort: high + + CTRL-DORA-ICT-INCIDENT-MANAGEMENT: + id: CTRL-DORA-ICT-INCIDENT-MANAGEMENT + title: "IKT-Vorfallmanagement" + category: DORA + dora_ref: "Art. 17-23" + description: "Erkennung, Management und Meldung von IKT-Vorfällen" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. Incident-Response-Prozess etablieren + 2. Klassifikation nach DORA-Kriterien + 3. Meldepflichten einhalten (BaFin, ECB) + 4. Post-Incident-Review durchführen + evidence_needed: + - Incident-Response-Plan + - Meldeprozess-Dokumentation + - Incident-Register + - Post-Mortem-Berichte + effort: high + + CTRL-DORA-DIGITAL-RESILIENCE-TESTING: + id: CTRL-DORA-DIGITAL-RESILIENCE-TESTING + title: "Digitale Resilienz-Tests" + category: DORA + dora_ref: "Art. 24-27" + description: "Regelmäßige Tests der digitalen operationalen Resilienz" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. Jährliche Vulnerability Assessments + 2. Penetrationstests (risikobasiert) + 3. TLPT (Threat-Led Penetration Testing) für bedeutende Institute + 4. Szenariobasierte Tests für kritische Funktionen + evidence_needed: + - Test-Policy + - Test-Berichte + - Behebungsnachweise + - TLPT-Berichte (falls zutreffend) + effort: high + + CTRL-DORA-TPP-RISK-MANAGEMENT: + id: CTRL-DORA-TPP-RISK-MANAGEMENT + title: "IKT-Drittparteirisikomanagement" + category: DORA + dora_ref: "Art. 28-44" + description: "Management von Risiken aus IKT-Drittanbieterbeziehungen" + when_applicable: + - ict_service.is_outsourced == true + what_to_do: | + 1. Due Diligence vor Vertragsabschluss + 2. Vertragliche Mindestanforderungen nach Art. 30 + 3. Register aller IKT-Drittanbieter führen + 4. Exit-Strategien für kritische Dienste + 5. Konzentrationsrisiken überwachen + evidence_needed: + - Outsourcing-Policy + - Due-Diligence-Berichte + - Vertragsregister + - Exit-Pläne + - Konzentrationsrisiko-Analyse + effort: high + + CTRL-DORA-INFORMATION-SHARING: + id: CTRL-DORA-INFORMATION-SHARING + title: "Informationsaustausch" + category: DORA + dora_ref: "Art. 45" + description: "Teilnahme am Austausch von Bedrohungsinformationen" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. Teilnahme an Threat-Intelligence-Netzwerken prüfen + 2. Vertraulichkeitsvereinbarungen abschließen + 3. Informationsaustausch-Prozess etablieren + evidence_needed: + - Teilnahme-Dokumentation + - NDAs + effort: low + + # --- MaRisk-spezifische Controls --- + + CTRL-MARISK-MODEL-VALIDATION: + id: CTRL-MARISK-MODEL-VALIDATION + title: "Modellvalidierung nach MaRisk" + category: MaRisk + marisk_ref: "AT 4.3.5" + description: "Validierung von Risikomodellen inkl. KI-Modelle" + when_applicable: + - ai_application.risk_assessment == true + - ai_application.affects_customer_decisions == true + what_to_do: | + 1. Initiale Validierung vor Produktiveinsatz + 2. Regelmäßige Backtesting + 3. Dokumentation der Modellannahmen + 4. Unabhängige Validierungsstelle + 5. Eskalation bei Modellschwächen + evidence_needed: + - Validierungsbericht + - Backtesting-Ergebnisse + - Modelldokumentation + - Genehmigung durch Geschäftsleitung + effort: high + + CTRL-MARISK-OUTSOURCING: + id: CTRL-MARISK-OUTSOURCING + title: "Auslagerungsmanagement nach MaRisk" + category: MaRisk + marisk_ref: "AT 9" + description: "Anforderungen an Auslagerungen" + when_applicable: + - ict_service.is_outsourced == true + what_to_do: | + 1. Risikoanalyse vor Auslagerung + 2. Schriftliche Auslagerungsvereinbarung + 3. Weisungs- und Kontrollrechte sichern + 4. Auslagerungsregister führen + 5. BaFin-Anzeige bei wesentlichen Auslagerungen + evidence_needed: + - Auslagerungsvereinbarung + - Risikoanalyse + - Auslagerungsregister + - BaFin-Anzeige (falls zutreffend) + effort: high + + CTRL-MARISK-RISK-REPORTING: + id: CTRL-MARISK-RISK-REPORTING + title: "Risiko-Reporting" + category: MaRisk + marisk_ref: "AT 4.3.2" + description: "Risikoberichterstattung an Geschäftsleitung" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. Regelmäßige Risikoberichte erstellen + 2. Ad-hoc-Berichterstattung bei wesentlichen Ereignissen + 3. KI-Risiken in Gesamtrisikobericht integrieren + evidence_needed: + - Risikoberichte + - Eskalationsprotokolle + effort: medium + + # --- BAIT-spezifische Controls --- + + CTRL-BAIT-IT-STRATEGY: + id: CTRL-BAIT-IT-STRATEGY + title: "IT-Strategie nach BAIT" + category: BAIT + bait_ref: "Tz. 1-9" + description: "IT-Strategie mit KI-Komponenten" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. IT-Strategie dokumentieren + 2. KI-Einsatz in IT-Strategie berücksichtigen + 3. Abstimmung mit Geschäftsstrategie + 4. Regelmäßige Überprüfung + evidence_needed: + - IT-Strategie-Dokument + - Vorstandsbeschlüsse + effort: medium + + CTRL-BAIT-IT-GOVERNANCE: + id: CTRL-BAIT-IT-GOVERNANCE + title: "IT-Governance nach BAIT" + category: BAIT + bait_ref: "Tz. 10-21" + description: "IT-Governance-Rahmenwerk" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. IT-Governance-Struktur etablieren + 2. Rollen und Verantwortlichkeiten definieren + 3. IT-Risikomanagement integrieren + 4. Drei-Linien-Modell umsetzen + evidence_needed: + - Governance-Framework + - Organigramm IT + - Rollenbeschreibungen + effort: high + + CTRL-BAIT-IT-PROJECT-MANAGEMENT: + id: CTRL-BAIT-IT-PROJECT-MANAGEMENT + title: "IT-Projektmanagement nach BAIT" + category: BAIT + bait_ref: "Tz. 22-26" + description: "Anforderungen an IT-Projekte (inkl. KI-Projekte)" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. Projektmanagement-Standards definieren + 2. Risikobewertung für KI-Projekte + 3. Go-Live-Kriterien festlegen + 4. Post-Implementation-Review + evidence_needed: + - Projektmanagement-Handbuch + - Projektdokumentation + - Go-Live-Protokolle + effort: medium + + CTRL-BAIT-SDLC: + id: CTRL-BAIT-SDLC + title: "Anwendungsentwicklung nach BAIT" + category: BAIT + bait_ref: "Tz. 27-42" + description: "Secure Development Lifecycle für KI-Anwendungen" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. SDLC-Prozess dokumentieren + 2. Sicherheitsanforderungen in Requirements + 3. Code-Reviews durchführen + 4. Testkonzept mit Security-Tests + 5. Änderungsmanagement etablieren + evidence_needed: + - SDLC-Dokumentation + - Test-Berichte + - Code-Review-Protokolle + - Change-Management-Records + effort: high + + CTRL-BAIT-IT-OPERATIONS: + id: CTRL-BAIT-IT-OPERATIONS + title: "IT-Betrieb nach BAIT" + category: BAIT + bait_ref: "Tz. 43-57" + description: "Anforderungen an den IT-Betrieb" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. IT-Betriebsprozesse dokumentieren + 2. Kapazitätsmanagement + 3. Monitoring und Logging + 4. Backup und Recovery + evidence_needed: + - Betriebshandbuch + - Monitoring-Dashboards + - Backup-Konzept + effort: medium + + CTRL-BAIT-IAM: + id: CTRL-BAIT-IAM + title: "Benutzerberechtigungsmanagement nach BAIT" + category: BAIT + bait_ref: "Tz. 58-66" + description: "Identity and Access Management" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. Berechtigungskonzept erstellen + 2. Least-Privilege-Prinzip umsetzen + 3. Regelmäßige Berechtigungsrezertifizierung + 4. Privileged Access Management + evidence_needed: + - Berechtigungskonzept + - Rezertifizierungsprotokolle + - PAM-Logs + effort: medium + + CTRL-BAIT-LOGGING: + id: CTRL-BAIT-LOGGING + title: "Protokollierung nach BAIT" + category: BAIT + bait_ref: "Tz. 67-72" + description: "Anforderungen an Protokollierung und Audit-Trail" + when_applicable: + - financial_entity.regulated == true + what_to_do: | + 1. Logging-Konzept erstellen + 2. Sicherheitsrelevante Ereignisse protokollieren + 3. Manipulationssichere Speicherung + 4. Aufbewahrungsfristen einhalten + evidence_needed: + - Logging-Policy + - Log-Konfiguration + - Aufbewahrungsnachweis + effort: medium + + # --- KI-spezifische Controls für Finanzsektor --- + + CTRL-FIN-AI-EXPLAINABILITY: + id: CTRL-FIN-AI-EXPLAINABILITY + title: "KI-Erklärbarkeit im Finanzsektor" + category: Financial_AI + description: "Erklärbare KI für Kundenentscheidungen" + when_applicable: + - ai_application.affects_customer_decisions == true + what_to_do: | + 1. Erklärbare Modelle bevorzugen + 2. Feature-Importance dokumentieren + 3. Ablehnungsgründe nachvollziehbar machen + 4. Kunden-Auskunftsrecht erfüllen (Art. 22 DSGVO) + evidence_needed: + - Modell-Dokumentation + - Erklärbarkeitsbericht + - Beispiel-Erklärungen + effort: high + + CTRL-FIN-AI-BIAS-MONITORING: + id: CTRL-FIN-AI-BIAS-MONITORING + title: "Bias-Monitoring für Finanz-KI" + category: Financial_AI + description: "Überwachung auf Diskriminierung" + when_applicable: + - ai_application.affects_customer_decisions == true + - ai_application.risk_assessment == true + what_to_do: | + 1. Fairness-Metriken definieren + 2. Regelmäßiges Bias-Testing + 3. Gruppenvergleiche durchführen + 4. Korrekturmaßnahmen bei Bias + evidence_needed: + - Fairness-Report + - Test-Ergebnisse + - Korrekturmaßnahmen-Protokoll + effort: high + + CTRL-FIN-ALGO-TRADING: + id: CTRL-FIN-ALGO-TRADING + title: "Algorithmischer Handel nach MiFID II" + category: Financial_AI + mifid_ref: "Art. 17 MiFID II" + description: "Anforderungen an algorithmischen Handel" + when_applicable: + - ai_application.algorithmic_trading == true + what_to_do: | + 1. Algorithmen genehmigen lassen + 2. Kill-Switch implementieren + 3. Realtime-Überwachung + 4. Jährliche Selbstbewertung + 5. BaFin-Anzeigepflicht + evidence_needed: + - Algorithmus-Genehmigung + - Kill-Switch-Dokumentation + - Überwachungs-Logs + - BaFin-Anzeige + effort: high + + CTRL-FIN-AML-AI: + id: CTRL-FIN-AML-AI + title: "KI für AML/KYC" + category: Financial_AI + gwg_ref: "GwG" + description: "KI-Einsatz für Geldwäscheprävention" + when_applicable: + - ai_application.aml_kyc == true + what_to_do: | + 1. Risikobasierter Ansatz + 2. False-Positive-Management + 3. Menschliche Überprüfung bei Alerts + 4. Regelmäßige Modellvalidierung + evidence_needed: + - AML-Policy + - Validierungsbericht + - Alert-Statistiken + effort: high + +# ============================================================================= +# Gaps (Lücken-Identifikation) +# ============================================================================= + +gaps: + GAP_DORA_NOT_IMPLEMENTED: + id: GAP_DORA_NOT_IMPLEMENTED + title: "DORA-Anforderungen nicht erfüllt" + description: "IKT-Risikomanagement nach DORA fehlt oder unvollständig" + severity: BLOCK + escalation: E3 + when: + - financial_entity.regulated == true + - ict_service.is_critical == true + controls: + - CTRL-DORA-ICT-RISK-FRAMEWORK + - CTRL-DORA-ICT-INCIDENT-MANAGEMENT + legal_refs: + - "DORA Art. 6-16" + - "Sanktionen nach Art. 50-52 DORA" + + GAP_DORA_TPP_UNMANAGED: + id: GAP_DORA_TPP_UNMANAGED + title: "IKT-Drittanbieterrisiko nicht gemanagt" + description: "Fehlende Due Diligence und Vertragsgestaltung für IKT-Auslagerungen" + severity: BLOCK + escalation: E3 + when: + - ict_service.is_outsourced == true + - ict_service.is_critical == true + controls: + - CTRL-DORA-TPP-RISK-MANAGEMENT + - CTRL-MARISK-OUTSOURCING + legal_refs: + - "DORA Art. 28-30" + - "MaRisk AT 9" + + GAP_DORA_CONCENTRATION_RISK: + id: GAP_DORA_CONCENTRATION_RISK + title: "Konzentrationsrisiko bei IKT-Drittanbietern" + description: "Zu starke Abhängigkeit von einzelnen IKT-Dienstleistern" + severity: WARN + escalation: E2 + when: + - ict_service.concentration_risk == true + controls: + - CTRL-DORA-TPP-RISK-MANAGEMENT + legal_refs: + - "DORA Art. 29(2)" + + GAP_MARISK_MODEL_NOT_VALIDATED: + id: GAP_MARISK_MODEL_NOT_VALIDATED + title: "KI-Modell nicht validiert" + description: "Risikomodell ohne Validierung nach MaRisk AT 4.3.5" + severity: BLOCK + escalation: E3 + when: + - ai_application.risk_assessment == true + - ai_application.model_validation_done == false + controls: + - CTRL-MARISK-MODEL-VALIDATION + legal_refs: + - "MaRisk AT 4.3.5" + - "EBA Guidelines on IRB" + + GAP_BAIT_SDLC_MISSING: + id: GAP_BAIT_SDLC_MISSING + title: "Kein SDLC für KI-Entwicklung" + description: "Fehlender Secure Development Lifecycle für KI-Anwendungen" + severity: WARN + escalation: E2 + when: + - financial_entity.regulated == true + controls: + - CTRL-BAIT-SDLC + legal_refs: + - "BAIT Tz. 27-42" + + GAP_FIN_AI_NOT_EXPLAINABLE: + id: GAP_FIN_AI_NOT_EXPLAINABLE + title: "KI-Entscheidungen nicht erklärbar" + description: "Fehlende Erklärbarkeit bei kundenrelevanten KI-Entscheidungen" + severity: WARN + escalation: E2 + when: + - ai_application.affects_customer_decisions == true + controls: + - CTRL-FIN-AI-EXPLAINABILITY + legal_refs: + - "Art. 22(3) DSGVO" + - "MaRisk AT 4.3.5" + + GAP_ALGO_TRADING_UNREGISTERED: + id: GAP_ALGO_TRADING_UNREGISTERED + title: "Algorithmischer Handel nicht angezeigt" + description: "Fehlende BaFin-Anzeige für algorithmischen Handel" + severity: BLOCK + escalation: E3 + when: + - ai_application.algorithmic_trading == true + controls: + - CTRL-FIN-ALGO-TRADING + legal_refs: + - "Art. 17 MiFID II" + - "WpHG §80" + +# ============================================================================= +# Stop-Lines (Harte Sperren) +# ============================================================================= + +stop_lines: + STOP_DORA_CRITICAL_ICT_UNMANAGED: + id: STOP_DORA_CRITICAL_ICT_UNMANAGED + title: "Kritische IKT ohne DORA-Compliance" + description: "Kritische IKT-Dienste ohne ausreichendes Risikomanagement" + outcome: NOT_ALLOWED + when: + - "financial_entity.regulated == true" + - "ict_service.is_critical == true" + message: | + Kritische IKT-Dienste dürfen ohne vollständiges DORA-Compliance-Framework + nicht eingeführt werden. DORA gilt seit 17.01.2025 und sieht erhebliche + Sanktionen bei Verstößen vor (bis zu 1% des weltweiten Jahresumsatzes). + + STOP_MARISK_UNVALIDATED_RISK_MODEL: + id: STOP_MARISK_UNVALIDATED_RISK_MODEL + title: "Nicht-validiertes Risikomodell" + description: "KI-Risikomodell ohne MaRisk-konforme Validierung" + outcome: NOT_ALLOWED + when: + - ai_application.risk_assessment == true + - ai_application.model_validation_done == false + message: | + KI-Modelle für Risikobewertungen (Kredit-Scoring, Fraud Detection) + müssen vor dem produktiven Einsatz nach MaRisk AT 4.3.5 validiert werden. + Dies umfasst initiale Validierung, Backtesting und unabhängige Prüfung. + + STOP_ALGO_TRADING_WITHOUT_APPROVAL: + id: STOP_ALGO_TRADING_WITHOUT_APPROVAL + title: "Algorithmischer Handel ohne Genehmigung" + description: "KI-basierter Handel ohne aufsichtsrechtliche Genehmigung" + outcome: NOT_ALLOWED + when: + - ai_application.algorithmic_trading == true + message: | + Algorithmischer Handel mit KI erfordert nach MiFID II Art. 17 eine + Genehmigung durch die Geschäftsleitung, Anzeige bei der BaFin und + Implementierung von Kill-Switches. Der Einsatz ohne diese Maßnahmen + ist aufsichtsrechtlich nicht zulässig. + + STOP_TPP_THIRD_COUNTRY_CRITICAL: + id: STOP_TPP_THIRD_COUNTRY_CRITICAL + title: "Kritische IKT-Auslagerung in Drittland" + description: "Kritische IKT-Dienste bei Drittland-Anbieter ohne Schutzmaßnahmen" + outcome: NOT_ALLOWED_UNTIL_CLEARED + when: + - ict_service.is_critical == true + - ict_service.is_outsourced == true + - ict_service.provider_location == THIRD_COUNTRY + message: | + Die Auslagerung kritischer IKT-Dienste an Anbieter in Drittländern + erfordert zusätzliche Schutzmaßnahmen nach DORA Art. 31. Eine + vertiefte Risikoanalyse und Genehmigung durch die Geschäftsleitung + ist erforderlich. + +# ============================================================================= +# Regeln (deterministische Auswertung) +# ============================================================================= + +rules: + # --- DORA-Regeln --- + + - id: R-FIN-DORA-001 + category: "H. Financial Regulations" + title: "DORA-Pflichtigkeit" + description: "Prüfung ob DORA-Anforderungen greifen" + condition: + all_of: + - field: domain + operator: in + value: [banking, finance, insurance, investment, payment_services, crypto_assets] + - field: financial_entity.regulated + operator: equals + value: true + effect: + controls_add: + - CTRL-DORA-ICT-RISK-FRAMEWORK + - CTRL-DORA-ICT-INCIDENT-MANAGEMENT + - CTRL-DORA-DIGITAL-RESILIENCE-TESTING + risk_add: 15 + severity: WARN + dora_ref: "DORA Art. 2" + rationale: "Regulierte Finanzunternehmen unterliegen DORA" + + - id: R-FIN-DORA-002 + category: "H. Financial Regulations" + title: "Kritische IKT-Auslagerung" + description: "Erhöhte Anforderungen bei kritischen IKT-Diensten" + condition: + all_of: + - field: ict_service.is_critical + operator: equals + value: true + - field: ict_service.is_outsourced + operator: equals + value: true + effect: + controls_add: + - CTRL-DORA-TPP-RISK-MANAGEMENT + - CTRL-MARISK-OUTSOURCING + risk_add: 25 + escalation: true + severity: WARN + dora_ref: "DORA Art. 28-30" + rationale: "Kritische IKT-Auslagerungen erfordern verstärkte Kontrollen" + + - id: R-FIN-DORA-003 + category: "H. Financial Regulations" + title: "Konzentrationsrisiko IKT" + description: "Warnung bei hoher Abhängigkeit von einzelnen IKT-Anbietern" + condition: + field: ict_service.concentration_risk + operator: equals + value: true + effect: + controls_add: + - CTRL-DORA-TPP-RISK-MANAGEMENT + risk_add: 20 + escalation: true + severity: WARN + dora_ref: "DORA Art. 29(2)" + rationale: "Konzentrationsrisiken können systemische Auswirkungen haben" + + # --- MaRisk-Regeln --- + + - id: R-FIN-MARISK-001 + category: "H. Financial Regulations" + title: "KI-Risikomodell Validierung" + description: "Validierungspflicht für KI-Risikomodelle" + condition: + all_of: + - field: ai_application.risk_assessment + operator: equals + value: true + - field: ai_application.model_validation_done + operator: equals + value: false + effect: + feasibility: NO + controls_add: + - CTRL-MARISK-MODEL-VALIDATION + severity: BLOCK + marisk_ref: "MaRisk AT 4.3.5" + rationale: "Nicht-validierte Risikomodelle dürfen nicht produktiv eingesetzt werden" + + - id: R-FIN-MARISK-002 + category: "H. Financial Regulations" + title: "Validiertes Risikomodell" + description: "KI-Risikomodell mit abgeschlossener Validierung" + condition: + all_of: + - field: ai_application.risk_assessment + operator: equals + value: true + - field: ai_application.model_validation_done + operator: equals + value: true + effect: + controls_add: + - CTRL-MARISK-RISK-REPORTING + risk_add: 10 + severity: INFO + marisk_ref: "MaRisk AT 4.3.5" + rationale: "Validiertes Modell erfordert weiterhin laufende Überwachung" + + # --- BAIT-Regeln --- + + - id: R-FIN-BAIT-001 + category: "H. Financial Regulations" + title: "BAIT-Grundanforderungen" + description: "IT-Governance nach BAIT" + condition: + all_of: + - field: domain + operator: in + value: [banking, finance] + - field: financial_entity.regulated + operator: equals + value: true + effect: + controls_add: + - CTRL-BAIT-IT-STRATEGY + - CTRL-BAIT-IT-GOVERNANCE + - CTRL-BAIT-IAM + - CTRL-BAIT-LOGGING + risk_add: 10 + severity: INFO + bait_ref: "BAIT" + rationale: "Regulierte Banken müssen BAIT einhalten" + + - id: R-FIN-BAIT-002 + category: "H. Financial Regulations" + title: "KI-Anwendungsentwicklung" + description: "SDLC-Anforderungen für KI-Projekte" + condition: + all_of: + - field: financial_entity.regulated + operator: equals + value: true + - field: model_usage.training + operator: equals + value: true + effect: + controls_add: + - CTRL-BAIT-SDLC + - CTRL-BAIT-IT-PROJECT-MANAGEMENT + risk_add: 15 + severity: WARN + bait_ref: "BAIT Tz. 27-42" + rationale: "Eigenentwicklung von KI erfordert SDLC-Konformität" + + # --- KI-spezifische Finanzregeln --- + + - id: R-FIN-AI-001 + category: "H. Financial Regulations" + title: "KI für Kundenentscheidungen" + description: "Erklärbarkeitsanforderungen bei Kundenentscheidungen" + condition: + field: ai_application.affects_customer_decisions + operator: equals + value: true + effect: + controls_add: + - CTRL-FIN-AI-EXPLAINABILITY + - CTRL-FIN-AI-BIAS-MONITORING + - CTRL-CONTESTATION + risk_add: 20 + escalation: true + severity: WARN + rationale: "Kundenentscheidungen per KI erfordern Erklärbarkeit und Fairness" + + - id: R-FIN-AI-002 + category: "H. Financial Regulations" + title: "Algorithmischer Handel" + description: "Anforderungen an KI-gestützten Handel" + condition: + field: ai_application.algorithmic_trading + operator: equals + value: true + effect: + controls_add: + - CTRL-FIN-ALGO-TRADING + risk_add: 30 + escalation: true + severity: WARN + mifid_ref: "Art. 17 MiFID II" + rationale: "Algorithmischer Handel unterliegt besonderen Anforderungen" + + - id: R-FIN-AI-003 + category: "H. Financial Regulations" + title: "KI für AML/KYC" + description: "Anforderungen an KI in der Geldwäscheprävention" + condition: + field: ai_application.aml_kyc + operator: equals + value: true + effect: + controls_add: + - CTRL-FIN-AML-AI + - CTRL-HITL_ENFORCED + risk_add: 15 + severity: WARN + gwg_ref: "GwG" + rationale: "AML-Entscheidungen erfordern menschliche Überprüfung" + + # --- Drittland-Regeln --- + + - id: R-FIN-TPP-001 + category: "H. Financial Regulations" + title: "IKT-Auslagerung Drittland" + description: "Kritische IKT in Drittländern" + condition: + all_of: + - field: ict_service.is_critical + operator: equals + value: true + - field: ict_service.provider_location + operator: equals + value: THIRD_COUNTRY + effect: + feasibility: CONDITIONAL + controls_add: + - CTRL-DORA-TPP-RISK-MANAGEMENT + - CTRL-SCC + - CTRL-TIA + risk_add: 30 + escalation: true + severity: WARN + dora_ref: "DORA Art. 31" + rationale: "Drittland-Auslagerungen erfordern zusätzliche Prüfung" + +# ============================================================================= +# Eskalations-Trigger für Finanzsektor +# ============================================================================= + +escalation_triggers: + - id: ESC_FIN_CRITICAL_ICT + trigger: + - "ict_service.is_critical == true" + - "ict_service.is_outsourced == true" + level: E3 + reason: "Kritische IKT-Auslagerung erfordert Geschäftsleitungsentscheidung" + + - id: ESC_FIN_ALGO_TRADING + trigger: + - "ai_application.algorithmic_trading == true" + level: E3 + reason: "Algorithmischer Handel erfordert aufsichtsrechtliche Prüfung" + + - id: ESC_FIN_RISK_MODEL + trigger: + - "ai_application.risk_assessment == true" + level: E2 + reason: "KI-Risikomodelle erfordern Validierung und Genehmigung" + + - id: ESC_FIN_CUSTOMER_DECISIONS + trigger: + - "ai_application.affects_customer_decisions == true" + level: E2 + reason: "KI-Kundenentscheidungen erfordern Fairness-Prüfung" diff --git a/ai-compliance-sdk/policies/funding/bundesland_profiles.yaml b/ai-compliance-sdk/policies/funding/bundesland_profiles.yaml new file mode 100644 index 0000000..3043c56 --- /dev/null +++ b/ai-compliance-sdk/policies/funding/bundesland_profiles.yaml @@ -0,0 +1,598 @@ +# ============================================================================ +# Bundesland-Profile fuer Foerderantraege +# ============================================================================ +# Landesspezifische Konfigurationen fuer 16 Bundeslaender +# Erstellt: 2026-01-29 +# ============================================================================ + +metadata: + version: "1.0.0" + description: "Bundesland-spezifische Foerderungsprofile" + last_updated: "2026-01-29" + +# ============================================================================ +# Bundeslaender +# ============================================================================ + +bundeslaender: + # -------------------------------------------------------------------------- + # Niedersachsen (Pilot) + # -------------------------------------------------------------------------- + NI: + name: "Niedersachsen" + short: "NI" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + mep_mandatory_sections: + - "Paedagogisches Konzept" + - "Technische Ausstattung" + - "Fortbildungsplanung" + + min_quote_amount: 1000 + max_application_amount: null # Kein Limit + + contact_authority: + name: "Niedersaechsisches Landesinstitut fuer schulische Qualitaetsentwicklung (NLQ)" + department: "Abteilung 3 - Medienberatung" + website: "https://www.nibis.de/digitalpakt" + email: "digitalpakt@nlq.nibis.de" + + special_requirements: + - "Medienentwicklungsplan (MEP) erforderlich" + - "Genehmigung durch Schultraeger" + - "Antragstellung ueber Online-Portal" + + submission_portal: + type: "online" + url: "https://www.nibis.de/digitalpakt-antraege" + format: "online_form" + + deadlines: + digitalpakt_2: + application_deadline: "2027-12-31" + implementation_deadline: "2028-12-31" + report_deadline: "2029-06-30" + + documents_required: + - id: "mep" + name: "Medienentwicklungsplan" + mandatory: true + format: ["pdf"] + - id: "kostenplan" + name: "Detaillierter Kostenplan" + mandatory: true + format: ["xlsx", "pdf"] + - id: "angebote" + name: "Kostenvoranschlaege/Angebote" + mandatory: false + min_amount_for_mandatory: 5000 + format: ["pdf"] + + notes: | + Niedersachsen ist der Pilotstandort fuer den Foerderantrag-Wizard. + Der MEP kann im Wizard-Format eingereicht werden. + + # -------------------------------------------------------------------------- + # Nordrhein-Westfalen + # -------------------------------------------------------------------------- + NRW: + name: "Nordrhein-Westfalen" + short: "NRW" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + mep_mandatory_sections: + - "Bestandsaufnahme" + - "Paedagogisches Konzept" + - "Qualifizierungskonzept" + - "Technische Planung" + + min_quote_amount: 1000 + max_application_amount: null + + contact_authority: + name: "Bezirksregierungen NRW" + department: "Dezernat 48 - Schulfoerderung" + website: "https://www.schulministerium.nrw/digitalpakt" + + special_requirements: + - "Medienkonzept erforderlich" + - "LOGINEO NRW LMS empfohlen" + - "Abstimmung mit kommunalem Medienentwicklungsplan" + + submission_portal: + type: "online" + url: "https://www.schulministerium.nrw/digitalpakt-antrag" + format: "online_form" + + documents_required: + - id: "medienkonzept" + name: "Medienkonzept der Schule" + mandatory: true + format: ["pdf"] + - id: "kostenplan" + name: "Kostenaufstellung" + mandatory: true + format: ["xlsx", "pdf"] + + # -------------------------------------------------------------------------- + # Bayern + # -------------------------------------------------------------------------- + BAY: + name: "Bayern" + short: "BAY" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Bayerisches Staatsministerium fuer Unterricht und Kultus" + website: "https://www.km.bayern.de/digitalpakt" + + special_requirements: + - "Medienkonzept erforderlich" + - "Abstimmung mit Sachaufwandstraeger" + + submission_portal: + type: "online" + format: "online_form" + + documents_required: + - id: "medienkonzept" + name: "Medienkonzept" + mandatory: true + format: ["pdf"] + + # -------------------------------------------------------------------------- + # Baden-Wuerttemberg + # -------------------------------------------------------------------------- + BW: + name: "Baden-Wuerttemberg" + short: "BW" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Ministerium fuer Kultus, Jugend und Sport" + website: "https://km-bw.de/digitalpakt" + + special_requirements: + - "Medienentwicklungsplan erforderlich" + - "Paedagogisches Konzept" + + documents_required: + - id: "mep" + name: "Medienentwicklungsplan" + mandatory: true + format: ["pdf"] + + # -------------------------------------------------------------------------- + # Hessen + # -------------------------------------------------------------------------- + HE: + name: "Hessen" + short: "HE" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Hessisches Kultusministerium" + website: "https://kultusministerium.hessen.de/digitalpakt" + + special_requirements: + - "Schulisches Medienbildungskonzept" + + documents_required: + - id: "medienbildungskonzept" + name: "Medienbildungskonzept" + mandatory: true + format: ["pdf"] + + # -------------------------------------------------------------------------- + # Sachsen + # -------------------------------------------------------------------------- + SN: + name: "Sachsen" + short: "SN" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Saechsisches Staatsministerium fuer Kultus" + website: "https://www.bildung.sachsen.de/digitalpakt" + + special_requirements: + - "Medienentwicklungsplanung" + + # -------------------------------------------------------------------------- + # Thueringen + # -------------------------------------------------------------------------- + TH: + name: "Thueringen" + short: "TH" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Thueringer Ministerium fuer Bildung, Jugend und Sport" + website: "https://bildung.thueringen.de/digitalpakt" + + # -------------------------------------------------------------------------- + # Sachsen-Anhalt + # -------------------------------------------------------------------------- + SA: + name: "Sachsen-Anhalt" + short: "SA" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Ministerium fuer Bildung Sachsen-Anhalt" + website: "https://mb.sachsen-anhalt.de/digitalpakt" + + # -------------------------------------------------------------------------- + # Brandenburg + # -------------------------------------------------------------------------- + BB: + name: "Brandenburg" + short: "BB" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Ministerium fuer Bildung, Jugend und Sport Brandenburg" + website: "https://mbjs.brandenburg.de/digitalpakt" + + # -------------------------------------------------------------------------- + # Mecklenburg-Vorpommern + # -------------------------------------------------------------------------- + MV: + name: "Mecklenburg-Vorpommern" + short: "MV" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Ministerium fuer Bildung und Kindertagesfoerderung MV" + website: "https://www.bildung-mv.de/digitalpakt" + + # -------------------------------------------------------------------------- + # Schleswig-Holstein + # -------------------------------------------------------------------------- + SH: + name: "Schleswig-Holstein" + short: "SH" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Ministerium fuer Allgemeine und Berufliche Bildung SH" + website: "https://www.schleswig-holstein.de/digitalpakt" + + # -------------------------------------------------------------------------- + # Hamburg + # -------------------------------------------------------------------------- + HH: + name: "Hamburg" + short: "HH" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Behoerde fuer Schule und Berufsbildung Hamburg" + website: "https://www.hamburg.de/digitalpakt" + + notes: | + Hamburg als Stadtstaat hat ein zentrales Antragsverfahren. + Schultraeger ist die Stadt Hamburg. + + # -------------------------------------------------------------------------- + # Bremen + # -------------------------------------------------------------------------- + HB: + name: "Bremen" + short: "HB" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Die Senatorin fuer Kinder und Bildung Bremen" + website: "https://www.bildung.bremen.de/digitalpakt" + + notes: | + Bremen als Stadtstaat hat ein zentrales Antragsverfahren. + + # -------------------------------------------------------------------------- + # Berlin + # -------------------------------------------------------------------------- + BE: + name: "Berlin" + short: "BE" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Senatsverwaltung fuer Bildung, Jugend und Familie" + website: "https://www.berlin.de/sen/bildung/digitalpakt" + + notes: | + Berlin als Stadtstaat hat ein zentrales Antragsverfahren. + Bezirke sind Schultraeger. + + # -------------------------------------------------------------------------- + # Saarland + # -------------------------------------------------------------------------- + SL: + name: "Saarland" + short: "SL" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Ministerium fuer Bildung und Kultur Saarland" + website: "https://www.saarland.de/mbk/digitalpakt" + + # -------------------------------------------------------------------------- + # Rheinland-Pfalz + # -------------------------------------------------------------------------- + RP: + name: "Rheinland-Pfalz" + short: "RP" + funding_programs: + - DIGITALPAKT_1 + - DIGITALPAKT_2 + - LANDESFOERDERUNG + + default_funding_rate: 0.90 + max_funding_rate: 0.90 + min_own_contribution: 0.10 + + requires_mep: true + + contact_authority: + name: "Ministerium fuer Bildung Rheinland-Pfalz" + website: "https://bm.rlp.de/digitalpakt" + +# ============================================================================ +# Foerderprogramm-Uebersicht +# ============================================================================ + +funding_programs: + DIGITALPAKT_1: + name: "DigitalPakt Schule 1.0" + description: "Erste Phase des DigitalPakts (2019-2024)" + status: "auslaufend" + total_budget_billion: 5.0 + funding_areas: + - "IT-Grundinfrastruktur" + - "Digitale Lerninfrastruktur" + - "Digitale Arbeitsgeraete" + notes: "Restmittel koennen noch abgerufen werden" + + DIGITALPAKT_2: + name: "DigitalPakt Schule 2.0" + description: "Zweite Phase des DigitalPakts (2025-2030)" + status: "aktiv" + total_budget_billion: 5.5 + funding_areas: + - "Digitale Bildungsinfrastruktur" + - "IT-Administration" + - "Fortbildung" + - "Endgeraete (erweitert)" + - "Lokale KI-Systeme" + focus_areas: + - "KI in der Bildung" + - "Datenschutzkonforme Loesungen" + - "Nachhaltige IT-Infrastruktur" + + LANDESFOERDERUNG: + name: "Landesspezifische Foerderung" + description: "Ergaenzende Programme der Bundeslaender" + status: "variabel" + notes: "Details je nach Bundesland unterschiedlich" + + SCHULTRAEGER: + name: "Schultraegerfoerderung" + description: "Investitionen durch Schultraeger" + status: "dauerhaft" + notes: "Eigenmittel der Kommunen/Landkreise" + +# ============================================================================ +# Foerderfahige Kategorien +# ============================================================================ + +fundable_categories: + digitalpakt_2: + included: + - category: "NETWORK" + description: "Aufbau/Erweiterung digitaler Vernetzung" + examples: + - "Strukturierte Verkabelung" + - "Netzwerkkomponenten" + - "Glasfaseranschluss schulintern" + + - category: "WLAN" + description: "WLAN-Infrastruktur" + examples: + - "Access Points" + - "Controller" + - "Antennen" + + - category: "DEVICES" + description: "Digitale Lerngeraete" + examples: + - "Tablets" + - "Laptops" + - "Convertibles" + limits: + max_per_device: 800 + ratio_student: "1:3" + + - category: "PRESENTATION" + description: "Praesentationstechnik" + examples: + - "Interaktive Displays" + - "Beamer" + - "Dokumentenkameras" + + - category: "SOFTWARE" + description: "Software-Lizenzen" + examples: + - "Lernmanagementsysteme" + - "Office-Lizenzen" + - "Sicherheitssoftware" + conditions: + - "Max. 5 Jahre Laufzeit" + - "Bildungslizenz erforderlich" + + - category: "SERVER" + description: "Server und lokale Rechenzentren" + examples: + - "Schulserver" + - "NAS-Systeme" + - "Lokale KI-Arbeitsstation" + focus_digitalpakt_2: + - "Datenschutzkonforme lokale Verarbeitung" + - "KI-Systeme ohne Cloud-Anbindung" + + - category: "SERVICES" + description: "Dienstleistungen" + examples: + - "Installation" + - "Konfiguration" + - "Projektmanagement" + conditions: + - "Max. 20% der Gesamtsumme" + + - category: "TRAINING" + description: "Fortbildungen" + examples: + - "Technische Schulungen" + - "Paedagogische Fortbildung" + limits: + max_percentage: 10 + + excluded: + - "Verbrauchsmaterial" + - "Laufende Betriebskosten" + - "Personalkosten (ausser projektbezogen)" + - "Moebel (ausser IT-spezifisch)" + - "Cloud-Abonnements ohne lokale Alternative" diff --git a/ai-compliance-sdk/policies/funding/foerderantrag_wizard_v1.yaml b/ai-compliance-sdk/policies/funding/foerderantrag_wizard_v1.yaml new file mode 100644 index 0000000..c384af3 --- /dev/null +++ b/ai-compliance-sdk/policies/funding/foerderantrag_wizard_v1.yaml @@ -0,0 +1,893 @@ +# ============================================================================ +# Foerderantrag Wizard Schema v1.0 +# ============================================================================ +# 8-Schritt-Wizard fuer Schulfoerderantraege +# Erstellt: 2026-01-29 +# ============================================================================ + +metadata: + version: "1.0.0" + name: "Foerderantrag-Wizard" + description: "Wizard fuer Schulfoerderantraege (DigitalPakt, Landesfoerderungen)" + total_steps: 8 + language: "de" + +# ============================================================================ +# Wizard Steps +# ============================================================================ + +steps: + # -------------------------------------------------------------------------- + # Step 1: Foerderprogramm & Grunddaten + # -------------------------------------------------------------------------- + - number: 1 + id: "foerderprogramm" + title: "Foerderprogramm" + subtitle: "Waehlen Sie das passende Foerderprogramm" + description: "Waehlen Sie das Foerderprogramm, Ihr Bundesland und geben Sie einen Projekttitel ein." + icon: "document-text" + is_required: true + + fields: + - id: "funding_program" + type: "select" + label: "Foerderprogramm" + required: true + options: + - value: "DIGITALPAKT_2" + label: "DigitalPakt 2.0" + description: "Foerderung digitaler Bildungsinfrastruktur" + - value: "DIGITALPAKT_1" + label: "DigitalPakt 1.0 (Restmittel)" + description: "Restmittel aus DigitalPakt 1.0" + - value: "LANDESFOERDERUNG" + label: "Landesfoerderung" + description: "Landesspezifische Foerderprogramme" + - value: "SCHULTRAEGER" + label: "Schultraegerfoerderung" + description: "Foerderung durch Schultraeger" + + - id: "federal_state" + type: "select" + label: "Bundesland" + required: true + options: + - value: "NI" + label: "Niedersachsen" + - value: "NRW" + label: "Nordrhein-Westfalen" + - value: "BAY" + label: "Bayern" + - value: "BW" + label: "Baden-Wuerttemberg" + - value: "HE" + label: "Hessen" + - value: "SN" + label: "Sachsen" + - value: "TH" + label: "Thueringen" + - value: "SA" + label: "Sachsen-Anhalt" + - value: "BB" + label: "Brandenburg" + - value: "MV" + label: "Mecklenburg-Vorpommern" + - value: "SH" + label: "Schleswig-Holstein" + - value: "HH" + label: "Hamburg" + - value: "HB" + label: "Bremen" + - value: "BE" + label: "Berlin" + - value: "SL" + label: "Saarland" + - value: "RP" + label: "Rheinland-Pfalz" + + - id: "project_title" + type: "text" + label: "Projekttitel" + placeholder: "z.B. Digitale Lernumgebung fuer differenzierten Unterricht" + required: true + max_length: 200 + help_text: "Ein aussagekraeftiger Titel fuer Ihr Projekt" + + - id: "use_preset" + type: "checkbox" + label: "BreakPilot-Preset verwenden" + default: false + help_text: "Vorausgefuellte Daten fuer BreakPilot KI-Arbeitsstation" + + - id: "preset_id" + type: "select" + label: "Preset waehlen" + conditional: "use_preset === true" + options: + - value: "breakpilot_basic" + label: "BreakPilot Basis" + description: "Lokale KI-Arbeitsstation fuer eine Schule" + - value: "breakpilot_cluster" + label: "BreakPilot Schulverbund" + description: "Zentrale KI-Infrastruktur fuer mehrere Schulen" + + assistant_context: | + Erklaere die Unterschiede zwischen DigitalPakt 1.0 und 2.0. + DigitalPakt 2.0 hat hoehere Foerdersummen und erweiterte Foerderbereiche. + Hilf bei der Wahl des richtigen Programms basierend auf dem Vorhaben. + + # -------------------------------------------------------------------------- + # Step 2: Schulinformationen + # -------------------------------------------------------------------------- + - number: 2 + id: "schulinformationen" + title: "Schulinformationen" + subtitle: "Angaben zur Schule und zum Schultraeger" + description: "Geben Sie die Daten Ihrer Schule und des Schultraegers ein. Diese koennen automatisch aus der Schulsuche uebernommen werden." + icon: "academic-cap" + is_required: true + + fields: + - id: "school_search" + type: "autocomplete" + label: "Schule suchen" + placeholder: "Schulname oder Schulnummer eingeben..." + endpoint: "/sdk/v1/funding/schools/search" + help_text: "Tippen Sie den Namen oder die Schulnummer ein, um Ihre Schule zu finden" + + - id: "school_name" + type: "text" + label: "Schulname" + required: true + max_length: 200 + + - id: "school_number" + type: "text" + label: "Schulnummer" + required: true + pattern: "^[0-9]{5,8}$" + help_text: "Die offizielle Schulnummer Ihres Bundeslandes" + + - id: "school_type" + type: "select" + label: "Schulform" + required: true + options: + - value: "GRUNDSCHULE" + label: "Grundschule" + - value: "HAUPTSCHULE" + label: "Hauptschule" + - value: "REALSCHULE" + label: "Realschule" + - value: "GYMNASIUM" + label: "Gymnasium" + - value: "GESAMTSCHULE" + label: "Gesamtschule" + - value: "OBERSCHULE" + label: "Oberschule" + - value: "FOERDERSCHULE" + label: "Foerderschule" + - value: "BERUFSSCHULE" + label: "Berufsbildende Schule" + - value: "SONSTIGE" + label: "Sonstige" + + - id: "school_address" + type: "address" + label: "Schuladresse" + required: true + + - id: "contact_salutation" + type: "select" + label: "Anrede" + options: + - value: "Herr" + label: "Herr" + - value: "Frau" + label: "Frau" + + - id: "contact_first_name" + type: "text" + label: "Vorname" + required: true + + - id: "contact_last_name" + type: "text" + label: "Nachname" + required: true + + - id: "contact_position" + type: "text" + label: "Position" + placeholder: "z.B. Schulleitung, IT-Beauftragter" + + - id: "contact_email" + type: "email" + label: "E-Mail" + required: true + + - id: "contact_phone" + type: "tel" + label: "Telefon" + + - id: "student_count" + type: "number" + label: "Anzahl Schueler/innen" + min: 1 + required: true + + - id: "teacher_count" + type: "number" + label: "Anzahl Lehrkraefte" + min: 1 + required: true + + - id: "class_count" + type: "number" + label: "Anzahl Klassen" + min: 1 + + - id: "carrier_type" + type: "select" + label: "Schultraeger-Typ" + required: true + options: + - value: "PUBLIC" + label: "Oeffentlich (Kommune/Land)" + - value: "PRIVATE" + label: "Privat" + - value: "CHURCH" + label: "Kirchlich" + - value: "NON_PROFIT" + label: "Gemeinnuetzig" + + - id: "carrier_name" + type: "text" + label: "Name des Schultraegers" + required: true + placeholder: "z.B. Stadt Hannover, Landkreis Goettingen" + + assistant_context: | + Erklaere was eine Schulnummer ist und warum der Schultraeger wichtig ist. + Die Schulnummer ist eine eindeutige Kennung, die vom Kultusministerium vergeben wird. + Der Schultraeger ist fuer die finale Antragstellung und Unterschrift verantwortlich. + + # -------------------------------------------------------------------------- + # Step 3: IT-Bestandsaufnahme + # -------------------------------------------------------------------------- + - number: 3 + id: "bestandsaufnahme" + title: "IT-Bestandsaufnahme" + subtitle: "Aktuelle IT-Infrastruktur der Schule" + description: "Dokumentieren Sie den aktuellen Stand Ihrer IT-Infrastruktur. Dies hilft bei der Begruendung des Foerderbedarfs." + icon: "server" + is_required: true + + fields: + - id: "has_wlan" + type: "checkbox" + label: "WLAN vorhanden" + default: false + + - id: "wlan_coverage" + type: "slider" + label: "WLAN-Abdeckung" + min: 0 + max: 100 + step: 10 + unit: "%" + conditional: "has_wlan === true" + help_text: "Prozentuale Abdeckung der Raeume mit WLAN" + + - id: "has_structured_cabling" + type: "checkbox" + label: "Strukturierte Verkabelung vorhanden" + default: false + + - id: "internet_bandwidth" + type: "select" + label: "Internet-Bandbreite" + options: + - value: "< 16 Mbit/s" + label: "Unter 16 Mbit/s" + - value: "16-50 Mbit/s" + label: "16-50 Mbit/s" + - value: "50-100 Mbit/s" + label: "50-100 Mbit/s" + - value: "100-250 Mbit/s" + label: "100-250 Mbit/s" + - value: "250-1000 Mbit/s" + label: "250 Mbit/s - 1 Gbit/s" + - value: "> 1 Gbit/s" + label: "Ueber 1 Gbit/s" + + - id: "device_count" + type: "number" + label: "Vorhandene Endgeraete" + min: 0 + help_text: "Aktuelle Anzahl digitaler Endgeraete (Tablets, Laptops, PCs)" + + - id: "has_server_room" + type: "checkbox" + label: "Serverraum vorhanden" + default: false + + - id: "infrastructure_notes" + type: "textarea" + label: "Anmerkungen zur Infrastruktur" + placeholder: "Besondere Gegebenheiten, Sanierungsbedarfe..." + max_length: 1000 + + assistant_context: | + Hilf bei der Einschaetzung der aktuellen IT-Infrastruktur. + Erklaere was strukturierte Verkabelung bedeutet (Cat5e, Cat6, Cat6a). + Gib Hinweise zu typischen Anforderungen fuer Schulnetze. + + # -------------------------------------------------------------------------- + # Step 4: Projektbeschreibung + # -------------------------------------------------------------------------- + - number: 4 + id: "projektbeschreibung" + title: "Projektbeschreibung" + subtitle: "Ziele, Didaktik und Bezug zum MEP" + description: "Beschreiben Sie Ihr Projekt, die paedagogischen Ziele und den Bezug zum Medienentwicklungsplan." + icon: "document-report" + is_required: true + + fields: + - id: "project_summary" + type: "textarea" + label: "Kurzbeschreibung" + placeholder: "Beschreiben Sie Ihr Projekt in 2-3 Saetzen..." + required: true + max_length: 500 + help_text: "Diese Zusammenfassung erscheint im Antragsschreiben" + + - id: "project_goals" + type: "textarea" + label: "Projektziele" + placeholder: "Welche konkreten Ziele verfolgen Sie mit diesem Projekt?" + required: true + max_length: 2000 + help_text: "Beschreiben Sie 3-5 messbare Ziele" + + - id: "didactic_concept" + type: "textarea" + label: "Paedagogisches Konzept" + placeholder: "Wie wird die Technik im Unterricht eingesetzt?" + required: true + max_length: 3000 + help_text: "Beschreiben Sie den paedagogischen Mehrwert" + + - id: "mep_reference" + type: "textarea" + label: "Bezug zum Medienentwicklungsplan" + placeholder: "Wie fuegt sich das Projekt in den MEP ein?" + max_length: 1000 + help_text: "Referenzieren Sie relevante Abschnitte Ihres MEP" + + - id: "target_groups" + type: "multi_select" + label: "Zielgruppen" + required: true + options: + - value: "schueler" + label: "Schueler/innen" + - value: "lehrer" + label: "Lehrkraefte" + - value: "verwaltung" + label: "Schulverwaltung" + - value: "eltern" + label: "Eltern" + + - id: "subjects_affected" + type: "multi_select" + label: "Betroffene Faecher" + options: + - value: "alle" + label: "Faecheruebergreifend" + - value: "mint" + label: "MINT-Faecher" + - value: "sprachen" + label: "Sprachen" + - value: "gesellschaft" + label: "Gesellschaftswissenschaften" + - value: "kunst" + label: "Kunst/Musik" + - value: "sport" + label: "Sport" + + assistant_context: | + Hilf bei der Formulierung von paedagogischen Zielen. + Gib Beispiele fuer gute Projektbeschreibungen. + Erklaere was ein Medienentwicklungsplan (MEP) ist und warum er wichtig ist. + + # -------------------------------------------------------------------------- + # Step 5: Investitionen + # -------------------------------------------------------------------------- + - number: 5 + id: "investitionen" + title: "Investitionen" + subtitle: "Geplante Anschaffungen und Kategorien" + description: "Listen Sie alle geplanten Investitionen auf. Der Wizard berechnet automatisch die Summen." + icon: "currency-euro" + is_required: true + + fields: + - id: "budget_items" + type: "budget_table" + label: "Kostenaufstellung" + required: true + categories: + - id: "NETWORK" + label: "Netzwerk/Verkabelung" + icon: "globe-alt" + color: "#3b82f6" + - id: "WLAN" + label: "WLAN-Infrastruktur" + icon: "wifi" + color: "#8b5cf6" + - id: "DEVICES" + label: "Endgeraete" + icon: "device-tablet" + color: "#10b981" + - id: "PRESENTATION" + label: "Praesentationstechnik" + icon: "presentation-chart-bar" + color: "#f59e0b" + - id: "SOFTWARE" + label: "Software-Lizenzen" + icon: "code" + color: "#ec4899" + - id: "SERVER" + label: "Server/Rechenzentrum" + icon: "server" + color: "#6366f1" + - id: "SERVICES" + label: "Dienstleistungen" + icon: "briefcase" + color: "#14b8a6" + - id: "TRAINING" + label: "Schulungen" + icon: "academic-cap" + color: "#f97316" + - id: "SONSTIGE" + label: "Sonstige" + icon: "dots-horizontal" + color: "#64748b" + + columns: + - id: "description" + label: "Beschreibung" + type: "text" + width: 30 + - id: "manufacturer" + label: "Hersteller" + type: "text" + width: 15 + - id: "quantity" + label: "Anzahl" + type: "number" + width: 10 + - id: "unit_price" + label: "Einzelpreis" + type: "currency" + width: 15 + - id: "total_price" + label: "Gesamt" + type: "currency" + width: 15 + calculated: true + - id: "is_fundable" + label: "Foerderfahig" + type: "checkbox" + width: 10 + default: true + + assistant_context: | + Hilf bei der Auswahl geeigneter Hardware und Software. + Erklaere was foerderfahig ist und was nicht (z.B. Verbrauchsmaterial). + Gib Orientierungswerte fuer uebliche Preise. + Bei BreakPilot-Preset: Erklaere die lokale KI-Arbeitsstation und ihre Vorteile. + + # -------------------------------------------------------------------------- + # Step 6: Finanzierungsplan + # -------------------------------------------------------------------------- + - number: 6 + id: "finanzierungsplan" + title: "Finanzierungsplan" + subtitle: "Foerderquote, Eigenanteil und Gesamtkosten" + description: "Der Finanzierungsplan wird automatisch berechnet. Pruefen Sie die Werte und passen Sie ggf. den Eigenanteil an." + icon: "calculator" + is_required: true + + fields: + - id: "funding_rate" + type: "slider" + label: "Foerderquote" + min: 0 + max: 100 + step: 5 + default: 90 + unit: "%" + help_text: "Die uebliche Foerderquote betraegt 90% (10% Eigenanteil)" + + - id: "total_cost" + type: "currency" + label: "Gesamtkosten" + readonly: true + calculated: true + help_text: "Summe aller Positionen aus Schritt 5" + + - id: "requested_funding" + type: "currency" + label: "Beantragter Foerderbetrag" + readonly: true + calculated: true + help_text: "Gesamtkosten x Foerderquote" + + - id: "own_contribution" + type: "currency" + label: "Eigenanteil" + readonly: true + calculated: true + help_text: "Gesamtkosten - Foerderbetrag" + + - id: "other_funding" + type: "currency" + label: "Sonstige Finanzierung" + default: 0 + help_text: "Weitere Foerdermittel oder Spenden" + + - id: "budget_justification" + type: "textarea" + label: "Begruendung der Kosten" + placeholder: "Begruenden Sie die wesentlichen Kostenpositionen..." + max_length: 2000 + help_text: "Kurze Begruendung fuer groessere Positionen" + + assistant_context: | + Erklaere die Foerderquoten verschiedener Programme. + DigitalPakt 2.0: In der Regel 90%, aber je nach Bundesland unterschiedlich. + Hilf bei der Begruendung von Kostenansaetzen. + + # -------------------------------------------------------------------------- + # Step 7: Zeitplan + # -------------------------------------------------------------------------- + - number: 7 + id: "zeitplan" + title: "Zeitplan" + subtitle: "Projektlaufzeit und Meilensteine" + description: "Planen Sie die Projektlaufzeit und definieren Sie wichtige Meilensteine." + icon: "calendar" + is_required: true + + fields: + - id: "planned_start" + type: "date" + label: "Geplanter Projektbeginn" + required: true + min_date: "today" + + - id: "planned_end" + type: "date" + label: "Geplantes Projektende" + required: true + + - id: "milestones" + type: "milestone_list" + label: "Meilensteine" + help_text: "Definieren Sie 3-5 wichtige Meilensteine" + suggested_milestones: + - title: "Ausschreibung/Angebote einholen" + offset_months: 1 + - title: "Auftragserteilung" + offset_months: 2 + - title: "Installation Infrastruktur" + offset_months: 3 + - title: "Schulung Lehrkraefte" + offset_months: 4 + - title: "Projektabschluss & Verwendungsnachweis" + offset_months: 6 + + - id: "project_phase" + type: "select" + label: "Aktuelle Projektphase" + options: + - value: "planning" + label: "Planung" + - value: "application" + label: "Antragstellung" + - value: "procurement" + label: "Beschaffung" + - value: "implementation" + label: "Umsetzung" + + assistant_context: | + Gib Hinweise zu realistischen Projektlaufzeiten. + Erklaere typische Fristen bei Foerderantraegen. + Hilf bei der Definition sinnvoller Meilensteine. + + # -------------------------------------------------------------------------- + # Step 8: Dokumente & Abschluss + # -------------------------------------------------------------------------- + - number: 8 + id: "abschluss" + title: "Dokumente & Abschluss" + subtitle: "Upload, Pruefung und Zusammenfassung" + description: "Laden Sie erforderliche Dokumente hoch, pruefen Sie die Zusammenfassung und schliessen Sie den Antrag ab." + icon: "document-download" + is_required: true + + fields: + - id: "data_protection_concept" + type: "textarea" + label: "Datenschutzkonzept" + required: true + max_length: 3000 + help_text: "Beschreiben Sie die Massnahmen zum Datenschutz" + auto_fill_for_preset: | + Das Projekt setzt auf eine vollstaendig lokale Datenverarbeitung: + - Alle Daten werden ausschliesslich auf der lokalen KI-Arbeitsstation verarbeitet + - Keine Uebermittlung personenbezogener Daten an externe Dienste + - Keine Cloud-Speicherung + - Betrieb im Verantwortungsbereich der Schule + - Zugriffskontrolle ueber schuleigene Benutzerverwaltung + + - id: "maintenance_plan" + type: "textarea" + label: "Wartungs- und Betriebskonzept" + required: true + max_length: 2000 + help_text: "Wie wird die Technik gewartet und betrieben?" + + - id: "attachments" + type: "file_upload" + label: "Anlagen hochladen" + accept: ".pdf,.doc,.docx,.xls,.xlsx,.jpg,.png" + max_files: 10 + max_size_mb: 20 + categories: + - id: "angebot" + label: "Kostenvoranschlaege/Angebote" + required: false + - id: "mep" + label: "Medienentwicklungsplan (Auszug)" + required_for: ["NI", "NRW"] + - id: "nachweis" + label: "Sonstige Nachweise" + required: false + + - id: "summary_review" + type: "summary" + label: "Zusammenfassung" + readonly: true + sections: + - "foerderprogramm" + - "schulinformationen" + - "finanzierungsplan" + - "zeitplan" + + - id: "carrier_review_note" + type: "info_box" + variant: "warning" + title: "Hinweis zur Traegerpruefung" + content: | + Der generierte Antrag ist ein antragsfaehiger ENTWURF. + Die finale Pruefung und Einreichung erfolgt durch den Schultraeger. + Folgende Felder muessen vom Traeger ergaenzt werden: + - Rechtsverbindliche Erklaerungen + - Unterschriften + - Haushaltsstellen (falls vorhanden) + - Bankverbindung + + - id: "confirm_accuracy" + type: "checkbox" + label: "Ich bestaetige, dass alle Angaben nach bestem Wissen gemacht wurden" + required: true + + - id: "confirm_carrier_review" + type: "checkbox" + label: "Ich habe verstanden, dass der Antrag vom Schultraeger geprueft werden muss" + required: true + + assistant_context: | + Pruefe die Vollstaendigkeit des Antrags. + Erklaere den weiteren Ablauf nach Fertigstellung des Entwurfs. + Gib Hinweise zu typischen Ablehnungsgruenden und wie man sie vermeidet. + +# ============================================================================ +# LLM Funding Assistant Configuration +# ============================================================================ + +funding_assistant: + enabled: true + model: "internal-32b" + temperature: 0.3 + max_tokens: 1000 + + system_prompt: | + Du bist ein freundlicher und kompetenter Foerderantrag-Assistent fuer Schulen. + + Deine Aufgaben: + - Erklaere Fachbegriffe verstaendlich + - Gib konkrete Formulierungshilfen + - Schlage passende Texte fuer Antragsfelder vor + - Beantworte Fragen zu Foerderprogrammen + - Hilf bei der Kostenplanung + + Wichtige Hinweise: + - Bleibe sachlich und hilfreich + - Verweise bei rechtlichen Fragen auf den Schultraeger + - Gib keine verbindlichen Zusagen zu Foerdermitteln + - Fokussiere auf den aktuellen Wizard-Schritt + + Dein Wissen umfasst: + - DigitalPakt 2.0 Richtlinien + - Landesspezifische Foerderungen (16 Bundeslaender) + - Typische Kostenansaetze fuer Schul-IT + - Paedagogische Konzepte fuer digitale Bildung + - Datenschutz in Schulen + + step_contexts: + 1: "Erklaere Unterschiede zwischen DigitalPakt 1.0, 2.0 und Landesfoerderungen" + 2: "Erklaere was eine Schulnummer ist und warum der Schultraeger wichtig ist" + 3: "Hilf bei der Einschaetzung der aktuellen IT-Infrastruktur" + 4: "Gib Formulierungshilfen fuer paedagogische Konzepte" + 5: "Hilf bei der Auswahl und Preisschaetzung von Hardware/Software" + 6: "Erklaere Foerderquoten und Eigenanteil" + 7: "Gib Hinweise zu realistischen Projektlaufzeiten" + 8: "Erklaere den weiteren Ablauf nach Fertigstellung" + + quick_prompts: + - label: "Was ist foerderfahig?" + prompt: "Welche Kosten sind im DigitalPakt foerderfahig und welche nicht?" + - label: "Formulierungshilfe" + prompt: "Hilf mir bei der Formulierung fuer dieses Feld" + - label: "Kostenvoranschlag" + prompt: "Gib mir eine Orientierung fuer typische Kosten" + - label: "MEP erklaeren" + prompt: "Was ist ein Medienentwicklungsplan und brauche ich einen?" + +# ============================================================================ +# BreakPilot Presets +# ============================================================================ + +presets: + breakpilot_basic: + id: "breakpilot_basic" + name: "BreakPilot Basis" + description: "Lokale KI-Arbeitsstation fuer eine Schule" + suitable_for: + - "Einzelschule" + - "Bis 500 Schueler" + + budget_items: + - category: "SERVER" + description: "BreakPilot KI-Arbeitsstation (On-Premise)" + manufacturer: "BreakPilot" + product_name: "KI-Arbeitsstation Pro" + quantity: 1 + unit_price: 15000.00 + is_fundable: true + funding_source: "digitalpakt" + notes: "Lokale KI-Verarbeitung, keine Cloud-Anbindung erforderlich" + + - category: "SOFTWARE" + description: "BreakPilot Software-Lizenz (3 Jahre)" + manufacturer: "BreakPilot" + quantity: 1 + unit_price: 3000.00 + is_fundable: true + funding_source: "digitalpakt" + notes: "Inkl. Updates und Support" + + - category: "TRAINING" + description: "Einweisungsschulung Lehrkraefte" + quantity: 1 + unit_price: 1500.00 + is_fundable: true + funding_source: "digitalpakt" + + auto_fill: + data_protection: | + Das Projekt setzt auf vollstaendig lokale Datenverarbeitung: + - Alle Daten werden ausschliesslich auf der BreakPilot KI-Arbeitsstation verarbeitet + - KEINE Uebermittlung personenbezogener Daten an externe Server oder Cloud-Dienste + - KEINE Speicherung in der Cloud + - Betrieb im Verantwortungsbereich der Schule + - Zugriffskontrolle ueber schuleigene Benutzerverwaltung (LDAP/AD kompatibel) + - Verschluesselte lokale Datenspeicherung + - Automatische Loeschung nach konfigurierbaren Fristen + + maintenance: | + Wartung und Betrieb sind im Leistungsumfang enthalten: + - 3 Jahre Software-Updates und technischer Support + - Fernwartung nur auf Anfrage und mit Freigabe durch die Schule + - Jaehrliche Sicherheitsupdates + - Dokumentation und Schulungsmaterialien fuer Administratoren + + breakpilot_cluster: + id: "breakpilot_cluster" + name: "BreakPilot Schulverbund" + description: "Zentrale KI-Infrastruktur fuer mehrere Schulen" + suitable_for: + - "Schultraeger mit mehreren Schulen" + - "Schulverbund" + - "Ueber 1000 Schueler gesamt" + + budget_items: + - category: "SERVER" + description: "BreakPilot Server-Cluster (Zentrale)" + manufacturer: "BreakPilot" + product_name: "KI-Cluster Enterprise" + quantity: 1 + unit_price: 45000.00 + is_fundable: true + funding_source: "digitalpakt" + + - category: "SOFTWARE" + description: "BreakPilot Enterprise-Lizenz (3 Jahre, unbegrenzte Nutzer)" + manufacturer: "BreakPilot" + quantity: 1 + unit_price: 9000.00 + is_fundable: true + funding_source: "digitalpakt" + + - category: "NETWORK" + description: "Dedizierte Netzwerkanbindung Schulen" + quantity: 5 + unit_price: 2000.00 + is_fundable: true + funding_source: "digitalpakt" + + - category: "TRAINING" + description: "Train-the-Trainer Programm" + quantity: 1 + unit_price: 4500.00 + is_fundable: true + funding_source: "digitalpakt" + + auto_fill: + data_protection: | + Das Projekt setzt auf eine zentrale, aber vollstaendig lokale Datenverarbeitung: + - Zentraler BreakPilot Cluster im Rechenzentrum des Schultraegers + - Sichere Verbindung der Schulen ueber dedizierte Leitungen oder VPN + - KEINE Uebermittlung an externe Cloud-Dienste + - Mandantenfaehigkeit: Daten der Schulen sind strikt getrennt + - Zentrale Administration durch Schultraeger, dezentrale Nutzerverwaltung + - Compliance mit DSGVO und Landesdatenschutzgesetzen + +# ============================================================================ +# Validation Rules +# ============================================================================ + +validation: + global: + - rule: "total_cost > 0" + message: "Die Gesamtkosten muessen groesser als 0 sein" + severity: "error" + + - rule: "requested_funding <= total_cost" + message: "Der Foerderbetrag kann nicht hoeher sein als die Gesamtkosten" + severity: "error" + + - rule: "planned_end > planned_start" + message: "Das Projektende muss nach dem Projektbeginn liegen" + severity: "error" + + step_specific: + 1: + - rule: "project_title.length >= 10" + message: "Der Projekttitel sollte mindestens 10 Zeichen haben" + severity: "warning" + + 5: + - rule: "budget_items.length > 0" + message: "Mindestens eine Kostenposition ist erforderlich" + severity: "error" + + 6: + - rule: "funding_rate >= 50 && funding_rate <= 100" + message: "Die Foerderquote muss zwischen 50% und 100% liegen" + severity: "warning" diff --git a/ai-compliance-sdk/policies/gap_mapping.yaml b/ai-compliance-sdk/policies/gap_mapping.yaml new file mode 100644 index 0000000..4b24fd4 --- /dev/null +++ b/ai-compliance-sdk/policies/gap_mapping.yaml @@ -0,0 +1,794 @@ +# UCCA Gap-Mapping v1.0 +# Deterministische Zuordnung: Facts → Gaps → Controls → Escalation +# Keine LLM-Abhängigkeit in der Entscheidungslogik + +version: "1.0" +description: "Gap-Mapping für Use-Case Compliance Assessment" +last_updated: "2026-01-29" + +# ============================================================================= +# GAP DEFINITIONS +# Jeder Gap wird durch Fakten ausgelöst und führt zu Controls + Escalation +# ============================================================================= + +gaps: + + # --------------------------------------------------------------------------- + # VERTRAGSBASIERTE GAPS + # --------------------------------------------------------------------------- + + GAP_AVV_MISSING: + name: "Auftragsverarbeitungsvertrag fehlt" + description: "Kein AVV mit dem KI-Anbieter vorhanden oder Status unbekannt" + severity: critical + + trigger_conditions: + # Wird ausgelöst wenn EINER dieser Conditions true ist + - field: "contracts.avv.present" + operator: "equals" + value: false + - field: "contracts.avv.present" + operator: "equals" + value: "unknown" + + required_controls: + - CTRL_AVV + + escalation: + level: E2 + reason: "Verarbeitung ohne AVV ist DSGVO-Verstoß (Art. 28)" + auto_assign_to: "dpo" + + legal_refs: + - "DSGVO Art. 28" + - "DSGVO Art. 82 Abs. 1" + + GAP_AVV_INCOMPLETE: + name: "AVV unvollständig" + description: "AVV vorhanden, aber ohne erforderliche Klauseln" + severity: high + + trigger_conditions: + - field: "contracts.avv.present" + operator: "equals" + value: true + - field: "contracts.avv.complete" + operator: "equals" + value: false + + required_controls: + - CTRL_AVV + + escalation: + level: E1 + reason: "AVV muss Art. 28 Abs. 3 DSGVO Mindestinhalte enthalten" + auto_assign_to: "legal" + + legal_refs: + - "DSGVO Art. 28 Abs. 3" + + # --------------------------------------------------------------------------- + # DRITTLAND-TRANSFER GAPS + # --------------------------------------------------------------------------- + + GAP_TRANSFER_NO_SCC: + name: "Drittlandtransfer ohne SCC" + description: "Datenübermittlung in Drittland ohne Standardvertragsklauseln" + severity: critical + + trigger_conditions: + - field: "provider.location" + operator: "in" + value: ["us", "non_eu", "unknown"] + - field: "contracts.scc.present" + operator: "equals" + value: false + + required_controls: + - CTRL_SCC + - CTRL_TIA + + escalation: + level: E2 + reason: "Drittlandtransfer ohne Garantien ist unzulässig (Schrems II)" + auto_assign_to: "dpo" + + legal_refs: + - "DSGVO Art. 44-49" + - "EuGH Schrems II (C-311/18)" + + GAP_TRANSFER_NO_TIA: + name: "Drittlandtransfer ohne TIA" + description: "SCC vorhanden, aber kein Transfer Impact Assessment" + severity: high + + trigger_conditions: + - field: "provider.location" + operator: "in" + value: ["us", "non_eu"] + - field: "contracts.scc.present" + operator: "equals" + value: true + - field: "contracts.tia.present" + operator: "equals" + value: false + + required_controls: + - CTRL_TIA + + escalation: + level: E1 + reason: "TIA erforderlich zur Bewertung des Schutzniveaus" + auto_assign_to: "legal" + + legal_refs: + - "EDPB Recommendations 01/2020" + - "DSGVO Art. 46" + + GAP_SUBPROCESSORS_UNKNOWN: + name: "Unterauftragsverarbeiter unbekannt" + description: "Liste der Subprocessors nicht bekannt oder nicht dokumentiert" + severity: high + + trigger_conditions: + - field: "provider.subprocessors.known" + operator: "equals" + value: false + + required_controls: + - CTRL_SUBPROCESSOR_LIST + - CTRL_AVV + + escalation: + level: E1 + reason: "Unterauftragsverarbeiter müssen gem. Art. 28 Abs. 2 DSGVO genehmigt werden" + auto_assign_to: "legal" + + legal_refs: + - "DSGVO Art. 28 Abs. 2" + - "DSGVO Art. 28 Abs. 4" + + GAP_SCC_OUTDATED: + name: "Veraltete SCC-Version" + description: "Standardvertragsklauseln sind nicht die aktuelle Version (2021)" + severity: high + + trigger_conditions: + - field: "contracts.scc.present" + operator: "equals" + value: true + - field: "contracts.scc.version" + operator: "not_equals" + value: "new_scc_2021" + + required_controls: + - CTRL_SCC_UPDATE + + escalation: + level: E1 + reason: "Alte SCC-Versionen sind seit Ende 2022 nicht mehr gültig" + auto_assign_to: "legal" + + legal_refs: + - "EU 2021/914" + - "CNIL Transition Guidance" + + GAP_US_NO_DPF: + name: "US-Provider ohne DPF-Zertifizierung" + description: "US-Anbieter ist nicht unter Data Privacy Framework zertifiziert" + severity: high + + trigger_conditions: + - field: "provider.location" + operator: "equals" + value: "us" + - field: "provider.dpf_certified" + operator: "equals" + value: false + + required_controls: + - CTRL_SCC + - CTRL_TIA + - CTRL_DPF_CHECK + + escalation: + level: E2 + reason: "US-Transfer ohne DPF erfordert SCC + TIA + ergänzende Maßnahmen" + auto_assign_to: "dpo" + + legal_refs: + - "DSGVO Art. 44ff" + - "EuGH Schrems II (C-311/18)" + - "EU-US DPF Beschluss 2023" + + GAP_SUPPORT_THIRD_COUNTRY: + name: "Support-Zugriff aus Drittland" + description: "Provider-Support kann von außerhalb des EWR auf Daten zugreifen" + severity: medium + + trigger_conditions: + - field: "provider.support_location" + operator: "in" + value: ["us", "non_eu", "global", "unknown"] + - field: "data.contains_personal" + operator: "equals" + value: true + + required_controls: + - CTRL_SCC + - CTRL_ACCESS_LOGGING + + escalation: + level: E1 + reason: "Remote-Zugriff aus Drittland = Datenübermittlung" + auto_assign_to: "legal" + + legal_refs: + - "DSGVO Art. 44" + - "EDPB Guidelines on Data Transfers" + + GAP_SUBPROCESSOR_THIRD_COUNTRY: + name: "Unterauftragsverarbeiter im Drittland" + description: "Provider nutzt Subprozessoren außerhalb des EWR" + severity: high + + trigger_conditions: + - field: "provider.subprocessors.third_country" + operator: "equals" + value: true + + required_controls: + - CTRL_SUBPROCESSOR_SCC + - CTRL_TIA + + escalation: + level: E1 + reason: "SCC-Kette zu Drittland-Subprozessoren erforderlich" + auto_assign_to: "legal" + + legal_refs: + - "DSGVO Art. 28 Abs. 4" + - "DSGVO Art. 46" + + GAP_TIA_INADEQUATE: + name: "TIA zeigt unzureichendes Schutzniveau" + description: "Transfer Impact Assessment ergibt Defizite im Datenschutzniveau" + severity: critical + + trigger_conditions: + - field: "contracts.tia.result" + operator: "equals" + value: "inadequate" + + required_controls: + - CTRL_TECHNICAL_SUPPLEMENTARY + - CTRL_ENCRYPTION_E2E + + escalation: + level: E2 + reason: "Zusätzliche technische Maßnahmen erforderlich um Transfer zu legitimieren" + auto_assign_to: "dpo" + + legal_refs: + - "EDPB Recommendations 01/2020" + - "DSGVO Art. 32" + + GAP_TIA_NOT_FEASIBLE: + name: "Transfer nicht möglich" + description: "TIA ergibt: angemessenes Schutzniveau nicht erreichbar" + severity: critical + + trigger_conditions: + - field: "contracts.tia.result" + operator: "equals" + value: "not_feasible" + + required_controls: [] + + escalation: + level: E3 + reason: "Transfer muss unterbleiben - kein angemessenes Schutzniveau erreichbar" + auto_assign_to: "dpo" + requires_board_decision: true + blocks_processing: true + + legal_refs: + - "DSGVO Art. 44" + - "EuGH Schrems II" + + GAP_LOCAL_HOSTING_NOT_VERIFIED: + name: "Lokales Hosting nicht verifiziert" + description: "Behauptung lokales Hosting, aber keine Verifizierung" + severity: medium + + trigger_conditions: + - field: "hosting.type" + operator: "equals" + value: "on_premises" + - field: "hosting.verified" + operator: "equals" + value: false + + required_controls: + - CTRL_HOSTING_VERIFICATION + + escalation: + level: E0 + reason: "Hosting-Konfiguration sollte dokumentiert werden" + auto_assign_to: null + + legal_refs: + - "DSGVO Art. 5 Abs. 2 (Rechenschaftspflicht)" + + # --------------------------------------------------------------------------- + # DATENMINIMIERUNG GAPS + # --------------------------------------------------------------------------- + + GAP_NO_TRAINING_CLAUSE: + name: "Keine Opt-Out-Klausel für KI-Training" + description: "Provider kann Daten für Modelltraining verwenden" + severity: high + + trigger_conditions: + - field: "provider.uses_data_for_training" + operator: "equals" + value: true + - field: "contracts.no_training_clause" + operator: "equals" + value: false + + required_controls: + - CTRL_NO_TRAINING_CLAUSE + + escalation: + level: E1 + reason: "Zweckbindung verletzt wenn Daten für Training verwendet werden" + auto_assign_to: "legal" + + legal_refs: + - "DSGVO Art. 5 Abs. 1 lit. b" + + GAP_RETENTION_UNKNOWN: + name: "Speicherdauer beim Provider unbekannt" + description: "Prompt/Response Retention Policy nicht dokumentiert" + severity: medium + + trigger_conditions: + - field: "provider.prompt_retention.known" + operator: "equals" + value: false + + required_controls: + - CTRL_RETENTION_POLICY + + escalation: + level: E0 + reason: "Speicherdauer muss dokumentiert werden" + auto_assign_to: null + + legal_refs: + - "DSGVO Art. 5 Abs. 1 lit. e" + - "DSGVO Art. 13 Abs. 2 lit. a" + + GAP_CHAT_LOGS_RAW: + name: "Chat-Logs im Klartext gespeichert" + description: "Benutzerfragen werden ohne Anonymisierung gespeichert" + severity: high + + trigger_conditions: + - field: "logs.store_user_questions" + operator: "equals" + value: true + - field: "logs.anonymization" + operator: "equals" + value: false + + required_controls: + - CTRL_PII_REDACTION_GATEWAY + - CTRL_RETENTION_POLICY + + escalation: + level: E1 + reason: "Klartext-Logs mit PII erfordern besondere Schutzmaßnahmen" + auto_assign_to: "security" + + legal_refs: + - "DSGVO Art. 5 Abs. 1 lit. c" + - "DSGVO Art. 32" + + GAP_NO_PII_FILTER: + name: "Kein PII-Filter vor LLM" + description: "Personenbezogene Daten werden ungefiltert an LLM gesendet" + severity: high + + trigger_conditions: + - field: "data.contains_pii" + operator: "equals" + value: true + - field: "technical.pii_filter.enabled" + operator: "equals" + value: false + + required_controls: + - CTRL_PII_REDACTION_GATEWAY + + escalation: + level: E1 + reason: "PII-Minimierung vor LLM-Verarbeitung erforderlich" + auto_assign_to: "security" + + legal_refs: + - "DSGVO Art. 5 Abs. 1 lit. c" + - "DSGVO Art. 25" + + # --------------------------------------------------------------------------- + # CCTV / VIDEO GAPS + # --------------------------------------------------------------------------- + + GAP_CCTV_PUBLIC_NO_SIGN: + name: "CCTV im öffentlichen Bereich ohne Hinweisschild" + description: "Videoüberwachung ohne transparente Information" + severity: critical + + trigger_conditions: + - field: "cctv.public_area" + operator: "equals" + value: true + - field: "cctv.signage_present" + operator: "equals" + value: false + + required_controls: + - CTRL_CCTV_SIGNAGE + + escalation: + level: E2 + reason: "Verstoß gegen Informationspflichten (Art. 13 DSGVO)" + auto_assign_to: "dpo" + + legal_refs: + - "DSGVO Art. 13" + - "EDPB Guidelines 3/2019" + + GAP_CCTV_FACES_STORED: + name: "Gesichtserkennung mit Speicherung" + description: "CCTV erfasst Gesichter und speichert diese" + severity: critical + + trigger_conditions: + - field: "cctv.contains_faces" + operator: "equals" + value: true + - field: "cctv.storage" + operator: "in" + value: ["local_7d", "local_30d", "cloud"] + + required_controls: + - CTRL_FACE_BLURRING + - CTRL_DSFA + - CTRL_CCTV_POLICY + + escalation: + level: E3 + reason: "Biometrische Daten erfordern DSFA und besondere Rechtsgrundlage" + auto_assign_to: "dpo" + requires_board_decision: true + + legal_refs: + - "DSGVO Art. 9" + - "DSGVO Art. 35" + - "EDPB Guidelines 3/2019" + + GAP_CCTV_LICENSE_PLATES: + name: "Kennzeichenerfassung ohne Rechtsgrundlage" + description: "CCTV erfasst KFZ-Kennzeichen" + severity: high + + trigger_conditions: + - field: "cctv.contains_license_plates" + operator: "equals" + value: true + - field: "cctv.license_plate_purpose" + operator: "equals" + value: "unknown" + + required_controls: + - CTRL_ANPR_BLURRING + - CTRL_CCTV_POLICY + + escalation: + level: E2 + reason: "Kennzeichenerfassung erfordert spezifische Rechtsgrundlage" + auto_assign_to: "dpo" + + legal_refs: + - "DSGVO Art. 6" + - "BDSG §4" + + GAP_CCTV_CLOUD_STORAGE: + name: "CCTV-Aufnahmen in Cloud gespeichert" + description: "Videoaufnahmen werden bei Cloud-Anbieter gespeichert" + severity: high + + trigger_conditions: + - field: "cctv.storage" + operator: "equals" + value: "cloud" + + required_controls: + - CTRL_AVV + - CTRL_ENCRYPTION_TRANSIT + - CTRL_ENCRYPTION_REST + + escalation: + level: E1 + reason: "Cloud-Speicherung von Videoaufnahmen erfordert zusätzliche Garantien" + auto_assign_to: "security" + + legal_refs: + - "DSGVO Art. 28" + - "DSGVO Art. 32" + + # --------------------------------------------------------------------------- + # AI ACT GAPS + # --------------------------------------------------------------------------- + + GAP_AIACT_HIGHRISK_NO_CONFORMITY: + name: "Hochrisiko-KI ohne Konformitätsbewertung" + description: "KI-System fällt unter Hochrisiko-Kategorie ohne CE-Kennzeichnung" + severity: critical + + trigger_conditions: + - field: "aiact.risk_category" + operator: "equals" + value: "high" + - field: "aiact.conformity_assessment" + operator: "equals" + value: false + + required_controls: + - CTRL_AI_CONFORMITY + - CTRL_AI_DOCUMENTATION + - CTRL_HITL_ENFORCED + + escalation: + level: E3 + reason: "Hochrisiko-KI erfordert Konformitätsbewertung gem. AI Act" + auto_assign_to: "dpo" + requires_board_decision: true + + legal_refs: + - "AI Act Art. 6" + - "AI Act Annex III" + + GAP_AIACT_NO_HITL: + name: "Hochrisiko-KI ohne Human Oversight" + description: "Kein menschlicher Eingriff bei automatisierten Entscheidungen" + severity: critical + + trigger_conditions: + - field: "outputs.decision_with_legal_effect" + operator: "equals" + value: true + - field: "processing.human_oversight" + operator: "equals" + value: false + + required_controls: + - CTRL_HITL_ENFORCED + + escalation: + level: E2 + reason: "Automatisierte Entscheidungen mit rechtlicher Wirkung erfordern menschliche Aufsicht" + auto_assign_to: "dpo" + + legal_refs: + - "DSGVO Art. 22" + - "AI Act Art. 14" + + GAP_AIACT_SYSTEMATIC_MONITORING: + name: "Systematische Überwachung ohne Transparenz" + description: "KI-System führt systematische Überwachung durch" + severity: critical + + trigger_conditions: + - field: "processing.systematic_monitoring" + operator: "equals" + value: true + - field: "transparency.monitoring_disclosed" + operator: "equals" + value: false + + required_controls: + - CTRL_DSFA + - CTRL_AI_TRANSPARENCY + + escalation: + level: E3 + reason: "Systematische Überwachung ist hochriskant und muss offengelegt werden" + auto_assign_to: "dpo" + requires_board_decision: true + + legal_refs: + - "DSGVO Art. 35 Abs. 3 lit. c" + - "AI Act Art. 5" + + # --------------------------------------------------------------------------- + # TRAINING / IMPROVEMENT GAPS + # --------------------------------------------------------------------------- + + GAP_TRAINING_NO_CONSENT: + name: "Nutzerdaten für Training ohne Einwilligung" + description: "Chat-Logs werden für Modellverbesserung genutzt ohne explizite Zustimmung" + severity: high + + trigger_conditions: + - field: "improvement.strategy" + operator: "in" + value: ["finetune", "curated_samples"] + - field: "improvement.user_consent" + operator: "equals" + value: false + + required_controls: + - CTRL_TRAINING_CONSENT + - CTRL_DATA_SAMPLING_POLICY + + escalation: + level: E2 + reason: "Training auf Nutzerdaten erfordert informierte Einwilligung" + auto_assign_to: "dpo" + + legal_refs: + - "DSGVO Art. 6 Abs. 1 lit. a" + - "DSGVO Art. 7" + + GAP_TRAINING_NO_ANONYMIZATION: + name: "Training ohne Anonymisierung" + description: "Trainingsdaten enthalten personenbezogene Daten" + severity: high + + trigger_conditions: + - field: "improvement.strategy" + operator: "in" + value: ["finetune", "curated_samples"] + - field: "improvement.anonymization" + operator: "equals" + value: false + + required_controls: + - CTRL_PII_REDACTION_GATEWAY + - CTRL_SYNTHETIC_DATA + + escalation: + level: E1 + reason: "Trainingsdaten sollten anonymisiert oder synthetisch sein" + auto_assign_to: "security" + + legal_refs: + - "DSGVO Art. 5 Abs. 1 lit. c" + - "DSGVO Art. 89" + + # --------------------------------------------------------------------------- + # GOVERNANCE GAPS + # --------------------------------------------------------------------------- + + GAP_NO_DSFA: + name: "Fehlende Datenschutz-Folgenabschätzung" + description: "Hohes Risiko für Betroffene ohne DSFA" + severity: critical + + trigger_conditions: + - field: "risk.dsfa_required" + operator: "equals" + value: true + - field: "governance.dsfa_completed" + operator: "equals" + value: false + + required_controls: + - CTRL_DSFA + + escalation: + level: E2 + reason: "DSFA ist gesetzlich vorgeschrieben bei hohem Risiko" + auto_assign_to: "dpo" + + legal_refs: + - "DSGVO Art. 35" + - "DSK Blacklist" + + GAP_NO_VVT_ENTRY: + name: "Kein Eintrag im Verarbeitungsverzeichnis" + description: "KI-Verarbeitung nicht im VVT dokumentiert" + severity: medium + + trigger_conditions: + - field: "governance.vvt_entry" + operator: "equals" + value: false + + required_controls: + - CTRL_VVT_ENTRY + + escalation: + level: E0 + reason: "VVT-Pflicht gem. Art. 30 DSGVO" + auto_assign_to: null + + legal_refs: + - "DSGVO Art. 30" + +# ============================================================================= +# ESCALATION LEVEL DEFINITIONS +# ============================================================================= + +escalation_levels: + E0: + name: "Self-Service" + description: "Keine manuelle Prüfung erforderlich" + sla_hours: null + requires_approval: false + + E1: + name: "Expert Review" + description: "Fachliche Prüfung durch Legal/Security" + sla_hours: 72 + requires_approval: true + + E2: + name: "DPO Review" + description: "Prüfung durch Datenschutzbeauftragten" + sla_hours: 48 + requires_approval: true + + E3: + name: "Advisory Board" + description: "Entscheidung durch Datenschutz-Gremium" + sla_hours: 120 + requires_approval: true + requires_board_decision: true + +# ============================================================================= +# ROLE ASSIGNMENTS +# ============================================================================= + +role_assignments: + dpo: + name: "Datenschutzbeauftragter" + can_approve: [E1, E2, E3] + notification_channels: [email, webhook] + + legal: + name: "Rechtsabteilung" + can_approve: [E1] + notification_channels: [email] + + security: + name: "IT-Sicherheit" + can_approve: [E1] + notification_channels: [email, webhook] + +# ============================================================================= +# GAP AGGREGATION RULES +# ============================================================================= + +aggregation_rules: + # Wenn mehrere Gaps vorliegen, wie wird das Gesamtrisiko berechnet? + + severity_order: [critical, high, medium, low] + + escalation_promotion: + # Mehrere high-severity Gaps → höhere Escalation + - condition: "count(severity=critical) >= 2" + promote_to: E3 + reason: "Mehrere kritische Gaps erfordern Gremiumsentscheidung" + + - condition: "count(severity=high) >= 3" + promote_to: E2 + reason: "Kumulation von Risiken erfordert DPO-Prüfung" + + control_deduplication: + # Wenn derselbe Control von mehreren Gaps gefordert wird + strategy: "unique" + # Nur einmal in der finalen Liste aufführen diff --git a/ai-compliance-sdk/policies/licensed_content_policy.yaml b/ai-compliance-sdk/policies/licensed_content_policy.yaml new file mode 100644 index 0000000..33b8156 --- /dev/null +++ b/ai-compliance-sdk/policies/licensed_content_policy.yaml @@ -0,0 +1,655 @@ +# ============================================================================= +# UCCA Licensed Content Policy v1.0 +# Lizenz- und Urheberrechts-Compliance fuer Standards/Normen +# ============================================================================= +# +# HINTERGRUND: +# - DIN Media (ehem. Beuth) verbietet AI/TDM-Nutzung ohne explizite Erlaubnis +# - AI-Lizenzmodell erst ab Q4/2025 geplant (Stand: Jan 2026) +# - Single-Workstation vs Network/Intranet vs Enterprise Lizenzen +# - Technische Schutzmassnahmen und Anti-Crawler in AGB +# +# QUELLEN: +# - DIN Media Support: "AI use currently not permitted" +# - DIN Media AGB: TDM-Vorbehalt nach §44b UrhG +# - Urheberrecht.de: Zitatrecht ist begrenzt +# +# GRUNDPRINZIP: +# - Default bei Unklarheit: DENY / Link-only +# - Volltext-RAG nur mit nachweislicher Erlaubnis +# - Training auf Normen: Grundsaetzlich verboten ohne AI-Lizenz +# +# ============================================================================= + +policy: + name: "Breakpilot Licensed Content Policy" + version: "1.0.0" + jurisdiction: "DE/EU" + basis: + - "UrhG (Urheberrechtsgesetz)" + - "§44b UrhG (TDM-Vorbehalt)" + - "DIN Media Nutzungsbedingungen" + default_mode: "LINK_ONLY" + default_deny: true + +# ============================================================================= +# FACTS SCHEMA - Licensed Content +# ============================================================================= + +facts_schema: + + licensed_content: + present: + type: boolean + default: false + description: | + Es werden lizenz-/urheberrechtlich eingeschraenkte Inhalte + (z.B. DIN Normen, VDI Richtlinien) verarbeitet. + simple_explanation: | + Haben Sie Dokumente wie DIN-Normen, ISO-Standards oder + VDI-Richtlinien, die Sie mit KI nutzen moechten? + + publisher: + type: enum + values: + - "DIN_MEDIA" # DIN / DIN Media (ehem. Beuth Verlag) + - "VDI" # Verein Deutscher Ingenieure + - "VDE" # VDE/DKE Normen + - "ISO" # Internationale Organisation fuer Normung + - "IEC" # International Electrotechnical Commission + - "DGUV" # Deutsche Gesetzliche Unfallversicherung + - "VDMA" # Verband Deutscher Maschinen- und Anlagenbau + - "OTHER" # Sonstige + - "UNKNOWN" # Unbekannt + default: "UNKNOWN" + description: "Quelle/Herausgeber der Standards/Regelwerke" + simple_explanation: | + Von welchem Verlag/Herausgeber stammen Ihre Normen? + DIN-Normen kommen von DIN Media (frueher Beuth Verlag). + + license_type: + type: enum + values: + - "SINGLE_WORKSTATION" # Einzelplatz-Lizenz + - "NETWORK_INTRANET" # Netzwerk/Intranet-Lizenz + - "ENTERPRISE" # Unternehmens-Flatrate + - "AI_LICENSE" # Explizite AI/TDM-Lizenz + - "AUSLEGESTELLE" # Oeffentliche Auslegestelle + - "UNKNOWN" # Nicht bekannt + default: "UNKNOWN" + description: "Lizenztyp laut Vertrag/Kaufbeleg" + simple_explanation: | + Welche Lizenz haben Sie fuer Ihre Normen erworben? + + EINZELPLATZ: Die Norm darf nur an einem Arbeitsplatz genutzt werden. + NETZWERK/INTRANET: Mehrere Mitarbeiter duerfen zugreifen. + ENTERPRISE: Unternehmensweit nutzbar. + AI-LIZENZ: Spezielle Erlaubnis fuer KI-Nutzung (sehr selten). + + ai_use_permitted: + type: enum + values: + - "YES" # Ja, schriftlich bestaetigt + - "NO" # Nein, explizit ausgeschlossen + - "UNKNOWN" # Unklar / keine Information + default: "UNKNOWN" + description: "Ist AI/TDM/LLM-Nutzung explizit erlaubt?" + simple_explanation: | + Duerfen Sie die Normen mit KI/LLM verarbeiten? + + WICHTIG: DIN Media verbietet aktuell die KI-Nutzung von Normen + ohne explizite Genehmigung! Ein AI-Lizenzmodell ist erst ab + Ende 2025 geplant. + + Wenn Sie "Unklar" waehlen, wird aus Sicherheitsgruenden + nur der Link-only oder Notes-only Modus freigeschaltet. + + proof_uploaded: + type: boolean + default: false + description: "Liegt ein Lizenz-/Rechte-Nachweis vor?" + simple_explanation: | + Haben Sie einen Nachweis hochgeladen, der die KI-Nutzung erlaubt? + + Das kann sein: + - Vertrag mit AI-Lizenz-Klausel + - Schriftliche Freigabe vom Verlag + - Enterprise-Lizenz mit TDM-Erlaubnis + + operation_mode: + type: enum + values: + - "LINK_ONLY" # Nur Verweise, keine Inhalte + - "NOTES_ONLY" # Nur kundeneigene Notizen/Paraphrasen + - "EXCERPT_ONLY" # Nur kurze Zitate (Zitatrecht) + - "FULLTEXT_RAG" # Volltext-RAG (nur mit Lizenz) + - "TRAINING" # Modell-Training (sehr restriktiv) + default: "LINK_ONLY" + description: "Wie soll die KI diese Inhalte nutzen?" + simple_explanation: | + Wie moechten Sie Breakpilot mit Ihren Normen nutzen? + + LINK-ONLY (empfohlen): + Breakpilot verweist auf relevante Abschnitte, zeigt aber keine + Normentexte an. Sie schauen selbst in der Norm nach. + Vorteil: Kein Lizenzrisiko. + + NOTES-ONLY: + Sie erstellen eigene Zusammenfassungen und Checklisten. + Diese (nicht die Originaltexte) werden durchsuchbar gemacht. + + VOLLTEXT-RAG (nur mit Lizenz!): + Die kompletten Normentexte werden indexiert und durchsuchbar. + NUR moeglich mit schriftlicher AI-Nutzungserlaubnis! + + distribution_scope: + type: enum + values: + - "SINGLE_USER" # Nur ein Nutzer + - "COMPANY_INTERNAL" # Nur intern im Unternehmen + - "SUBSIDIARIES" # Inkl. Tochtergesellschaften + - "EXTERNAL_CUSTOMERS" # Auch an Kunden/Externe + - "UNKNOWN" # Nicht bekannt + default: "UNKNOWN" + description: "Wer soll Zugriff auf die Ergebnisse haben?" + simple_explanation: | + Wer soll die KI-generierten Antworten sehen koennen? + + WICHTIG: Wenn Sie eine Einzelplatz-Lizenz haben, duerfen + Sie Inhalte nicht an Kollegen weitergeben! + + content_type: + type: enum + values: + - "NORM_FULLTEXT" # Kompletter Normentext + - "NORM_EXCERPT" # Auszuege/Kapitel + - "TOC_ONLY" # Nur Inhaltsverzeichnis + - "METADATA_ONLY" # Nur Metadaten (Titel, Nummer, Datum) + - "CUSTOMER_NOTES" # Kundeneigene Zusammenfassungen + - "CHECKLISTS" # Abgeleitete Checklisten + default: "METADATA_ONLY" + description: "Art der zu verarbeitenden Inhalte" + +# ============================================================================= +# CONTROLS - Normen-Lizenz-Compliance +# ============================================================================= + +controls: + + CTRL-LICENSE-PROOF: + id: CTRL-LICENSE-PROOF + title: "Lizenz-/Rechte-Nachweis einholen" + category: License_Compliance + description: | + Vor Nutzung urheberrechtlich geschuetzter Inhalte muss die + Berechtigung nachgewiesen werden. + when_applicable: | + - licensed_content.present = true + - licensed_content.proof_uploaded = false + - licensed_content.operation_mode in [FULLTEXT_RAG, TRAINING] + what_to_do: | + 1. Lizenzvertrag/Kaufbeleg pruefen + 2. AI/TDM-Klausel suchen (meist: nicht vorhanden!) + 3. Bei DIN Media: Explizite Anfrage stellen + 4. Schriftliche Freigabe einholen und hochladen + 5. Bei Ablehnung: Auf LINK_ONLY oder NOTES_ONLY wechseln + evidence_needed: + - "Lizenzvertrag (PDF)" + - "Schriftliche AI-Freigabe" + - "E-Mail-Korrespondenz mit Verlag" + effort: medium + legal_refs: + - "UrhG §44b (TDM-Vorbehalt)" + - "DIN Media AGB" + + CTRL-LINK-ONLY-MODE: + id: CTRL-LINK-ONLY-MODE + title: "Link-only / Evidence Navigator aktivieren" + category: License_Compliance + description: | + Sicherer Default-Modus: Keine Normen-Volltexte werden verarbeitet, + nur Verweise auf relevante Abschnitte. + when_applicable: | + - licensed_content.present = true + - licensed_content.ai_use_permitted in [NO, UNKNOWN] + what_to_do: | + 1. operation_mode auf LINK_ONLY setzen + 2. Nur Metadaten indexieren (Titel, Nummer, Ausgabe) + 3. Antworten als Checklisten + Verweise formulieren + 4. Keine Zitate oder Textpassagen ausgeben + evidence_needed: + - "System-Konfiguration: operation_mode=LINK_ONLY" + - "Stichproben-Audit der Antworten" + effort: low + legal_refs: + - "UrhG §15 (Verwertungsrechte)" + + CTRL-NOTES-ONLY-RAG: + id: CTRL-NOTES-ONLY-RAG + title: "Notes-only RAG (kundeneigene Paraphrasen)" + category: License_Compliance + description: | + Indexiert werden nur kundeneigene Notizen und Zusammenfassungen, + nicht die Originaltexte der Normen. + when_applicable: | + - licensed_content.present = true + - licensed_content.operation_mode = NOTES_ONLY + what_to_do: | + 1. UI-Flow fuer Notes-Erstellung bereitstellen + 2. Kunde formuliert eigene Zusammenfassungen + 3. KEIN Copy/Paste von Originaltexten erlauben + 4. Notes als separate Kollektion indexieren + 5. Provenance-Logging aktivieren + evidence_needed: + - "Notes-Provenance-Log" + - "Stichproben: keine Volltexte in Notes" + effort: medium + legal_refs: + - "UrhG §51 (Zitatrecht - sehr begrenzt)" + + CTRL-LICENSE-GATED-INGEST: + id: CTRL-LICENSE-GATED-INGEST + title: "License-gated Ingest (technischer Schutz)" + category: Technical + description: | + Hard Gate vor Chunking/Indexierung: Ohne erlaubten Modus + wird kein Volltext in den Index aufgenommen. + when_applicable: | + - licensed_content.present = true + what_to_do: | + 1. Ingest-Pipeline prueft license_policy.can_ingest(doc) + 2. Bei deny: Nur Metadaten/Referenz registrieren + 3. Audit-Log fuer alle Ingest-Entscheidungen + 4. Regelmaessige Pruefung der Deny-Events + evidence_needed: + - "Ingest-Audit-Logs" + - "Denied-Ingest-Reports" + effort: medium + legal_refs: + - "Technische Schutzmassnahmen" + + CTRL-OUTPUT-GUARD-QUOTES: + id: CTRL-OUTPUT-GUARD-QUOTES + title: "Output-Guard: Quote-Limits & Cite-only" + category: Technical + description: | + Antwortfilter zur Begrenzung von Zitaten und + Verhinderung unerlaubter Reproduktion. + when_applicable: | + - licensed_content.present = true + what_to_do: | + 1. Max. Zitatlänge konfigurieren (z.B. 100 Zeichen) + 2. Bei LINK_ONLY: Keine Zitate, nur Verweise + 3. Bei NOTES_ONLY: Nur Notes paraphrasieren + 4. Bei FULLTEXT_RAG: Kurze Zitate + Quellenangabe + 5. Copy-Schutz in UI (wo rechtlich gefordert) + evidence_needed: + - "Output-Guard-Konfiguration" + - "Stichproben-Tests" + effort: low + legal_refs: + - "UrhG §51 (Zitatrecht)" + + CTRL-NO-CRAWLING-DIN: + id: CTRL-NO-CRAWLING-DIN + title: "Crawler-/Scraper-Block fuer Normenverlage" + category: Technical + description: | + Automatisierte Abrufe von DIN Media und anderen Normenverlagen + sind in den AGB explizit untersagt. + when_applicable: | + - licensed_content.publisher in [DIN_MEDIA, VDI, VDE, ISO] + what_to_do: | + 1. Domain-Denylist konfigurieren + 2. Keine Crawler/Scraper auf Normenportale + 3. Nur manueller File-Import (wenn lizenziert) + 4. Link-only Verweise sind erlaubt + evidence_needed: + - "Domain-Denylist-Konfiguration" + - "Fetch-/Crawl-Logs" + effort: low + legal_refs: + - "DIN Media AGB - Anti-Crawler-Klausel" + - "UrhG §95a (Technische Schutzmassnahmen)" + + CTRL-TENANT-ISOLATION-STANDARDS: + id: CTRL-TENANT-ISOLATION-STANDARDS + title: "Tenant-Isolation fuer lizenzierte Inhalte" + category: Technical + description: | + Strikte Trennung lizenzierter Inhalte zwischen Mandanten, + kein unberechtigter Zugriff oder Export. + when_applicable: | + - licensed_content.present = true + - licensed_content.operation_mode in [FULLTEXT_RAG, NOTES_ONLY] + what_to_do: | + 1. Collection/Index pro Tenant isolieren + 2. Keine Cross-Tenant-Suche + 3. Export-Funktion einschraenken + 4. Audit-Logging fuer alle Zugriffe + evidence_needed: + - "Tenant-Isolation-Architektur" + - "Zugriffs-Audit-Logs" + effort: medium + legal_refs: + - "Lizenzvertraege (Netzwerk vs. Single)" + + CTRL-ONPREM-STANDARDS-VAULT: + id: CTRL-ONPREM-STANDARDS-VAULT + title: "On-Premises Standards Vault (Mac Studio)" + category: Technical + description: | + Lokale Speicherung und Verarbeitung lizenzierter Inhalte + auf kundeneigener Hardware (keine Cloud-Synchronisation). + when_applicable: | + - licensed_content.present = true + - licensed_content.operation_mode in [FULLTEXT_RAG] + - hosting.type = on_premises + what_to_do: | + 1. Lokaler Vector Store (keine Cloud) + 2. Lokale Embeddings + Inference + 3. "No Cloud Sync" fuer lizenzierte Inhalte + 4. Air-gapped Mode optional + 5. Audit-Logs lokal speichern + evidence_needed: + - "Deployment-Dokumentation" + - "No-Sync-Konfiguration" + effort: medium + legal_refs: + - "Einzelplatz-/Netzwerk-Lizenzbedingungen" + +# ============================================================================= +# GAP MAPPING - Licensed Content +# ============================================================================= + +gaps: + + GAP_LICENSE_UNKNOWN: + id: GAP_LICENSE_UNKNOWN + title: "Lizenzlage fuer Standards unklar" + description: | + Es werden Normen/Standards genutzt, aber die Lizenzlage + ist nicht geklaert. Default: Link-only Modus. + when: + all: + - licensed_content.present: true + - licensed_content.license_type: "UNKNOWN" + controls: + - CTRL-LICENSE-PROOF + - CTRL-LINK-ONLY-MODE + escalation_level: E2 + risk_score: 30 + message: | + Bitte klaeren Sie die Lizenzlage fuer Ihre Normen. + Bis dahin ist nur der Link-only Modus verfuegbar. + + GAP_AI_USE_NOT_PERMITTED: + id: GAP_AI_USE_NOT_PERMITTED + title: "AI/TDM/LLM Nutzung nicht erlaubt" + description: | + Die KI-Nutzung der Normen ist explizit nicht erlaubt oder unklar. + Volltext-RAG und Training sind blockiert. + when: + all: + - licensed_content.present: true + - licensed_content.ai_use_permitted: + in: ["NO", "UNKNOWN"] + - licensed_content.operation_mode: + in: ["FULLTEXT_RAG", "TRAINING", "EXCERPT_ONLY"] + controls: + - CTRL-LINK-ONLY-MODE + - CTRL-OUTPUT-GUARD-QUOTES + - CTRL-LICENSE-PROOF + escalation_level: E3 + risk_score: 50 + message: | + ACHTUNG: DIN Media und andere Normenverlage verbieten aktuell + die AI/TDM-Nutzung ohne explizite Genehmigung! + + Bitte wechseln Sie auf Link-only oder Notes-only Modus, + oder holen Sie eine schriftliche AI-Lizenz ein. + + GAP_FULLTEXT_WITHOUT_PROOF: + id: GAP_FULLTEXT_WITHOUT_PROOF + title: "Volltext-RAG ohne Lizenznachweis" + description: | + Volltext-RAG ist konfiguriert, aber kein Nachweis + der AI-Nutzungserlaubnis liegt vor. + when: + all: + - licensed_content.present: true + - licensed_content.operation_mode: "FULLTEXT_RAG" + - licensed_content.proof_uploaded: false + controls: + - CTRL-LICENSE-PROOF + - CTRL-LICENSE-GATED-INGEST + escalation_level: E3 + risk_score: 60 + message: | + Volltext-RAG erfordert einen Nachweis der AI-Nutzungserlaubnis. + Bitte laden Sie den Lizenzvertrag oder die schriftliche + Freigabe hoch. + + GAP_DISTRIBUTION_SCOPE_MISMATCH: + id: GAP_DISTRIBUTION_SCOPE_MISMATCH + title: "Verteilungsumfang passt nicht zur Lizenz" + description: | + Die geplante Nutzung (z.B. unternehmensweit) uebersteigt + den Lizenzumfang (z.B. Einzelplatz). + when: + all: + - licensed_content.present: true + - licensed_content.license_type: "SINGLE_WORKSTATION" + - licensed_content.distribution_scope: + in: ["COMPANY_INTERNAL", "SUBSIDIARIES", "EXTERNAL_CUSTOMERS"] + controls: + - CTRL-LICENSE-PROOF + - CTRL-LINK-ONLY-MODE + - CTRL-TENANT-ISOLATION-STANDARDS + escalation_level: E3 + risk_score: 50 + message: | + Ihre Einzelplatz-Lizenz erlaubt keine unternehmensweite Nutzung. + Bitte upgraden Sie auf eine Netzwerk-/Enterprise-Lizenz oder + beschraenken Sie die Nutzung auf einen Arbeitsplatz. + + GAP_TRAINING_ON_STANDARDS: + id: GAP_TRAINING_ON_STANDARDS + title: "Training auf Normen ohne AI-Lizenz" + description: | + Modell-Training mit Normeninhalten ist ohne explizite + AI-Lizenz nicht zulaessig. + when: + all: + - licensed_content.present: true + - licensed_content.operation_mode: "TRAINING" + - licensed_content.ai_use_permitted: + in: ["NO", "UNKNOWN"] + controls: + - CTRL-LICENSE-PROOF + escalation_level: E3 + risk_score: 80 + message: | + STOP: Training auf Normen ist ohne explizite AI-Lizenz verboten! + DIN Media hat dies ausdruecklich ausgeschlossen. + + Bitte aendern Sie den Modus auf Link-only oder Notes-only. + + GAP_DIN_MEDIA_WITHOUT_AI_LICENSE: + id: GAP_DIN_MEDIA_WITHOUT_AI_LICENSE + title: "DIN Media Normen ohne AI-Lizenz" + description: | + DIN Media (ehem. Beuth) hat explizit festgelegt, dass + AI-Nutzung aktuell nicht erlaubt ist (Stand: 2026). + when: + all: + - licensed_content.present: true + - licensed_content.publisher: "DIN_MEDIA" + - licensed_content.ai_use_permitted: + in: ["NO", "UNKNOWN"] + - licensed_content.operation_mode: + in: ["FULLTEXT_RAG", "TRAINING"] + controls: + - CTRL-LINK-ONLY-MODE + - CTRL-NO-CRAWLING-DIN + - CTRL-LICENSE-PROOF + escalation_level: E3 + risk_score: 70 + message: | + DIN Media untersagt die AI-Nutzung von Normen ohne + explizite Genehmigung. Ein AI-Lizenzmodell ist erst + ab Q4/2025 geplant. + + Verfuegbare Optionen: + 1. Link-only Modus (empfohlen) + 2. Notes-only Modus (eigene Zusammenfassungen) + 3. AI-Lizenz bei DIN Media anfragen + +# ============================================================================= +# STOP-LINES (Hard Deny) +# ============================================================================= + +stop_lines: + + STOP_DIN_FULLTEXT_AI_NOT_ALLOWED: + id: STOP_DIN_FULLTEXT_AI_NOT_ALLOWED + title: "DIN Media Volltext-RAG/Training ohne Erlaubnis" + description: | + Volltext-RAG oder Training auf DIN Media Standards ist + ohne explizite AI/TDM-Erlaubnis blockiert. + when: + all: + - licensed_content.present: true + - licensed_content.publisher: "DIN_MEDIA" + - licensed_content.operation_mode: + in: ["FULLTEXT_RAG", "TRAINING"] + - licensed_content.ai_use_permitted: + in: ["NO", "UNKNOWN"] + outcome: "NOT_ALLOWED_UNTIL_LICENSE_CLEARED" + feasibility: "NO" + escalation_level: E3 + message: | + BLOCKIERT: Volltext-RAG/Training auf DIN Media Standards + ist ohne explizite AI/TDM-Erlaubnis nicht zulaessig. + + Default: Link-only oder Notes-only bis Lizenz geklaert. + evidence_refs: + - "DIN Media Support: AI use currently not permitted" + - "DIN Media AGB: TDM-Vorbehalt nach §44b UrhG" + + STOP_TRAINING_WITHOUT_PROOF: + id: STOP_TRAINING_WITHOUT_PROOF + title: "Training auf Standards ohne Nachweis" + description: | + Modell-Training mit lizenzierten Inhalten ist ohne + nachweisliche Erlaubnis grundsaetzlich blockiert. + when: + all: + - licensed_content.present: true + - licensed_content.operation_mode: "TRAINING" + - licensed_content.proof_uploaded: false + outcome: "NOT_ALLOWED" + feasibility: "NO" + escalation_level: E3 + message: | + BLOCKIERT: Training auf lizenzierten Standards erfordert + einen Nachweis der expliziten AI/TDM-Erlaubnis. + +# ============================================================================= +# OPERATION MODES - Detaillierte Definition +# ============================================================================= + +operation_modes: + + LINK_ONLY: + id: LINK_ONLY + name: "Evidence Navigator" + description: "Nur Verweise und Checklisten, kein Volltext" + license_requirement: "Keine spezielle Lizenz erforderlich" + features: + - "Verweise auf Normenabschnitte" + - "Strukturierte Checklisten" + - "CE-Dokumentations-Templates" + - "Hazard-Log-Generierung" + restrictions: + - "Keine Normtexte in Index" + - "Keine Zitate in Antworten" + - "Nur Metadaten speicherbar" + risk_level: "MINIMAL" + + NOTES_ONLY: + id: NOTES_ONLY + name: "Customer Notes RAG" + description: "Nur kundeneigene Zusammenfassungen werden indexiert" + license_requirement: "Standard-Lizenz + eigene Paraphrasen" + features: + - "Indexierung kundeneigener Notes" + - "Suche in Zusammenfassungen" + - "Checklisten aus Notes generieren" + restrictions: + - "Kein Copy/Paste von Originaltexten" + - "Nur eigene Formulierungen" + - "Zitate nur im Zitatrecht-Rahmen" + risk_level: "LOW" + + EXCERPT_ONLY: + id: EXCERPT_ONLY + name: "Zitat-Modus" + description: "Kurze Zitate im Rahmen des Zitatrechts" + license_requirement: "Standard-Lizenz + Zitatrecht" + features: + - "Kurze Zitate mit Quellenangabe" + - "Max. 100-200 Zeichen pro Zitat" + restrictions: + - "Keine umfangreichen Auszuege" + - "Zitatrecht ist begrenzt!" + risk_level: "MEDIUM" + + FULLTEXT_RAG: + id: FULLTEXT_RAG + name: "Licensed Full-Text RAG" + description: "Volltext-Indexierung mit expliziter Lizenz" + license_requirement: "AI-Lizenz oder schriftliche Freigabe ERFORDERLICH" + features: + - "Volltext-Indexierung" + - "Semantische Suche" + - "Direkte Antworten mit Quellenangabe" + restrictions: + - "Nur mit Lizenznachweis" + - "Tenant-isoliert" + - "Kein Export erlaubt" + - "Copy-Schutz aktiv" + risk_level: "HIGH" + + TRAINING: + id: TRAINING + name: "Model Training" + description: "Training/Fine-Tuning mit Normeninhalten" + license_requirement: "Explizite AI-Training-Lizenz ERFORDERLICH" + features: + - "Fine-Tuning-Daten" + - "Instruction-Tuning" + restrictions: + - "Nur mit expliziter AI-Lizenz" + - "Grundsaetzlich verboten bei DIN Media (aktuell)" + risk_level: "CRITICAL" + +# ============================================================================= +# METADATA +# ============================================================================= + +metadata: + created_at: "2026-01-29" + created_by: "AI Compliance SDK" + last_updated: "2026-01-29" + + legal_sources: + - name: "DIN Media Wissensdatenbank" + url: "https://support.dinmedia.de/en/support/solutions/articles/80001170855" + note: "AI use currently not permitted; AI license model planned Q4/2025" + - name: "DIN Media AGB" + url: "https://www.dinmedia.de/en/general-terms-and-conditions" + note: "TDM-Vorbehalt, Anti-Crawler, technische Schutzmassnahmen" + - name: "Urheberrecht.de" + url: "https://www.urheberrecht.de/din-normen/" + note: "Zitatrecht ist begrenzt" diff --git a/ai-compliance-sdk/policies/obligations/ai_act_obligations.yaml b/ai-compliance-sdk/policies/obligations/ai_act_obligations.yaml new file mode 100644 index 0000000..47ef666 --- /dev/null +++ b/ai-compliance-sdk/policies/obligations/ai_act_obligations.yaml @@ -0,0 +1,430 @@ +# AI Act (EU Regulation 2024/1689) Obligations +# EU Artificial Intelligence Act + +regulation: ai_act +name: "AI Act (EU KI-Verordnung)" +description: "EU-Verordnung zur Festlegung harmonisierter Vorschriften fuer kuenstliche Intelligenz" + +obligations: + # Prohibited AI Practices (Art. 5) - applies to all + - id: "AIACT-OBL-001" + title: "Verbotene KI-Praktiken vermeiden" + description: | + Sicherstellung, dass keine verbotenen KI-Praktiken eingesetzt werden: + - Social Scoring durch oeffentliche Stellen + - Ausnutzung von Schwaechen (Alter, Behinderung) + - Unterschwellige Manipulation + - Biometrische Echtzeit-Fernidentifizierung (mit Ausnahmen) + - Emotionserkennung am Arbeitsplatz/in Bildung + - Biometrische Kategorisierung nach sensitiven Merkmalen + applies_when: "uses_ai" + legal_basis: + - norm: "Art. 5 AI Act" + article: "Verbotene Praktiken im KI-Bereich" + category: "Compliance" + responsible: "Geschaeftsfuehrung" + deadline: + type: "absolute" + date: "2025-02-02" + sanctions: + max_fine: "35 Mio. EUR oder 7% Jahresumsatz" + criminal_liability: false + evidence: + - "KI-Inventar mit Risikobewertung" + - "Dokumentierte Pruefung auf verbotene Praktiken" + priority: "kritisch" + + # High-Risk AI System Requirements (Art. 6-15) + - id: "AIACT-OBL-002" + title: "Risikomanagementsystem fuer Hochrisiko-KI" + description: | + Einrichtung eines Risikomanagementsystems fuer Hochrisiko-KI-Systeme: + - Ermittlung und Analyse bekannter und vorhersehbarer Risiken + - Schaetzung und Bewertung der Risiken + - Risikominderungsmassnahmen + - Kontinuierliche Ueberwachung und Aktualisierung + applies_when: "high_risk" + legal_basis: + - norm: "Art. 9 AI Act" + article: "Risikomanagementsystem" + category: "Governance" + responsible: "KI-Verantwortlicher" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + personal_liability: false + evidence: + - "Risikomanagement-Dokumentation" + - "Risikobewertungen pro KI-System" + - "Massnahmenplan" + priority: "kritisch" + iso27001_mapping: ["A.5.1.1", "A.8.2"] + + - id: "AIACT-OBL-003" + title: "Daten-Governance fuer Hochrisiko-KI" + description: | + Anforderungen an Trainings-, Validierungs- und Testdaten: + - Relevante Design-Entscheidungen + - Datenerhebung und Datenherkunft + - Vorverarbeitung (Annotation, Labelling, Bereinigung) + - Erkennung und Behebung von Verzerrungen (Bias) + - Identifizierung von Datenluecken + applies_when: "high_risk_provider" + legal_basis: + - norm: "Art. 10 AI Act" + article: "Daten und Daten-Governance" + category: "Technisch" + responsible: "KI-Verantwortlicher" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "Datensatzdokumentation" + - "Bias-Analyse-Berichte" + - "Datenqualitaetsnachweise" + priority: "hoch" + + - id: "AIACT-OBL-004" + title: "Technische Dokumentation erstellen" + description: | + Erstellung umfassender technischer Dokumentation vor Inverkehrbringen: + - Allgemeine Beschreibung des KI-Systems + - Design-Spezifikationen + - Entwicklungsprozess + - Leistungsmetriken + - Risikomanagement-Dokumentation + applies_when: "high_risk_provider" + legal_basis: + - norm: "Art. 11 AI Act" + article: "Technische Dokumentation" + category: "Governance" + responsible: "KI-Verantwortlicher" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "Technische Dokumentation nach Anhang IV" + - "Systemarchitektur-Dokumentation" + - "Algorithmus-Beschreibung" + priority: "hoch" + + - id: "AIACT-OBL-005" + title: "Protokollierungsfunktion implementieren" + description: | + Hochrisiko-KI-Systeme muessen automatische Protokolle (Logs) erstellen: + - Nutzungszeitraum + - Referenzdatenbank + - Eingabedaten + - Identitaet der verifizierenden Personen + applies_when: "high_risk" + legal_basis: + - norm: "Art. 12 AI Act" + article: "Aufzeichnungspflichten" + category: "Technisch" + responsible: "IT-Leitung" + deadline: + type: "relative" + duration: "Aufbewahrung mindestens 6 Monate" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "Log-System-Dokumentation" + - "Beispiel-Logs" + - "Aufbewahrungsrichtlinie" + priority: "hoch" + iso27001_mapping: ["A.12.4"] + + - id: "AIACT-OBL-006" + title: "Transparenz und Nutzerinformation" + description: | + Bereitstellung klarer Informationen fuer Betreiber (Deployer): + - Gebrauchsanweisungen + - Eigenschaften und Grenzen des Systems + - Leistungsniveau und Genauigkeit + - Vorhersehbare Fehlnutzungen + applies_when: "high_risk_provider" + legal_basis: + - norm: "Art. 13 AI Act" + article: "Transparenz und Information" + category: "Organisatorisch" + responsible: "KI-Verantwortlicher" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "Gebrauchsanweisung" + - "Leistungsdokumentation" + - "Warnhinweise" + priority: "hoch" + + - id: "AIACT-OBL-007" + title: "Menschliche Aufsicht sicherstellen" + description: | + Hochrisiko-KI muss menschliche Aufsicht ermoeglichen: + - Faehigkeiten und Grenzen verstehen + - Ueberwachung des Betriebs + - Interpretation der Ausgaben + - Eingreifen oder Abbrechen koennen + applies_when: "high_risk" + legal_basis: + - norm: "Art. 14 AI Act" + article: "Menschliche Aufsicht" + category: "Organisatorisch" + responsible: "KI-Verantwortlicher" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "Aufsichtskonzept" + - "Schulungsnachweise fuer Bediener" + - "Notfall-Abschaltprozedur" + priority: "kritisch" + + - id: "AIACT-OBL-008" + title: "Genauigkeit, Robustheit und Cybersicherheit" + description: | + Hochrisiko-KI muss waehrend des gesamten Lebenszyklus: + - Angemessene Genauigkeit aufweisen + - Robust gegen Fehler und Inkonsistenzen sein + - Cyberangriffe verhindern koennen (Adversarial Attacks) + applies_when: "high_risk" + legal_basis: + - norm: "Art. 15 AI Act" + article: "Genauigkeit, Robustheit und Cybersicherheit" + category: "Technisch" + responsible: "IT-Leitung" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "Genauigkeits-Metriken und Tests" + - "Robustheitstests" + - "Security-Assessment" + priority: "hoch" + iso27001_mapping: ["A.14.2", "A.18.2"] + + # Deployer Obligations (Art. 26) + - id: "AIACT-OBL-009" + title: "Betreiberpflichten fuer Hochrisiko-KI" + description: | + Betreiber (Deployer) von Hochrisiko-KI muessen: + - Geeignete technische und organisatorische Massnahmen treffen + - Eingabedaten auf Relevanz pruefen + - Betrieb ueberwachen + - Protokolle aufbewahren + - Betroffene Personen informieren + applies_when: "high_risk_deployer" + legal_basis: + - norm: "Art. 26 AI Act" + article: "Pflichten der Betreiber" + category: "Organisatorisch" + responsible: "KI-Verantwortlicher" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "Betriebskonzept" + - "Eingabedaten-Pruefung" + - "Monitoring-Dokumentation" + priority: "hoch" + + - id: "AIACT-OBL-010" + title: "Grundrechte-Folgenabschaetzung" + description: | + Betreiber von Hochrisiko-KI in sensiblen Bereichen muessen vor Einsatz eine + Grundrechte-Folgenabschaetzung durchfuehren (FRIA - Fundamental Rights Impact Assessment). + Dies gilt fuer oeffentliche Stellen und private Betreiber in kritischen Bereichen. + applies_when: "high_risk_deployer_fria" + legal_basis: + - norm: "Art. 27 AI Act" + article: "Grundrechte-Folgenabschaetzung" + category: "Governance" + responsible: "KI-Verantwortlicher" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "FRIA-Dokumentation" + - "Risikobewertung Grundrechte" + - "Abhilfemassnahmen" + priority: "kritisch" + + # Transparency Obligations for Limited Risk AI (Art. 50) + - id: "AIACT-OBL-011" + title: "Transparenzpflichten fuer KI-Interaktionen" + description: | + Bei KI-Systemen, die mit natuerlichen Personen interagieren: + - Kennzeichnung der KI-Interaktion + - Information, dass Inhalte KI-generiert sind + - Kennzeichnung von Deep Fakes + applies_when: "limited_risk" + legal_basis: + - norm: "Art. 50 AI Act" + article: "Transparenzpflichten" + category: "Organisatorisch" + responsible: "KI-Verantwortlicher" + deadline: + type: "absolute" + date: "2026-08-02" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "Kennzeichnungskonzept" + - "Nutzerhinweise" + - "Deep-Fake-Kennzeichnung" + priority: "hoch" + + # GPAI Obligations (Art. 53) + - id: "AIACT-OBL-012" + title: "GPAI-Modell Dokumentation" + description: | + Anbieter von GPAI-Modellen (General Purpose AI) muessen: + - Technische Dokumentation erstellen und aktualisieren + - Informationen fuer nachgelagerte Anbieter bereitstellen + - Urheberrechtsrichtlinie einhalten + - Trainingsdaten-Zusammenfassung veroeffentlichen + applies_when: "gpai_provider" + legal_basis: + - norm: "Art. 53 AI Act" + article: "Pflichten der Anbieter von GPAI-Modellen" + category: "Governance" + responsible: "KI-Verantwortlicher" + deadline: + type: "absolute" + date: "2025-08-02" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "GPAI-Dokumentation" + - "Trainingsdaten-Summary" + - "Urheberrechts-Policy" + priority: "hoch" + + - id: "AIACT-OBL-013" + title: "GPAI mit systemischem Risiko" + description: | + GPAI-Modelle mit systemischem Risiko (>10^25 FLOP Training) haben zusaetzliche Pflichten: + - Modellbewertung nach Protokollen + - Bewertung und Minderung systemischer Risiken + - Dokumentation von Vorfaellen + - Angemessene Cybersicherheit + applies_when: "gpai_systemic_risk" + legal_basis: + - norm: "Art. 55 AI Act" + article: "Pflichten bei systemischem Risiko" + category: "Technisch" + responsible: "KI-Verantwortlicher" + deadline: + type: "absolute" + date: "2025-08-02" + sanctions: + max_fine: "35 Mio. EUR oder 7% Jahresumsatz" + evidence: + - "Systemische Risikobewertung" + - "Red-Teaming-Berichte" + - "Incident-Dokumentation" + priority: "kritisch" + + # Registration (Art. 49, 60) + - id: "AIACT-OBL-014" + title: "EU-Datenbank-Registrierung" + description: | + Registrierung in der EU-Datenbank fuer Hochrisiko-KI-Systeme: + - Anbieter: Vor Inverkehrbringen + - Betreiber: Vor Inbetriebnahme (bei bestimmten Kategorien) + applies_when: "high_risk" + legal_basis: + - norm: "Art. 49 AI Act" + article: "Registrierung" + category: "Meldepflicht" + responsible: "KI-Verantwortlicher" + deadline: + type: "relative" + duration: "Vor Inverkehrbringen/Inbetriebnahme" + sanctions: + max_fine: "15 Mio. EUR oder 3% Jahresumsatz" + evidence: + - "Registrierungsbestaetigung" + - "EU-Datenbank-Eintrag" + priority: "hoch" + + # AI Literacy (Art. 4) + - id: "AIACT-OBL-015" + title: "KI-Kompetenz sicherstellen" + description: | + Anbieter und Betreiber muessen sicherstellen, dass Personal mit ausreichender + KI-Kompetenz ausgestattet ist. Dies umfasst Schulungen und Sensibilisierung + fuer Risiken und ethische Aspekte. + applies_when: "uses_ai" + legal_basis: + - norm: "Art. 4 AI Act" + article: "KI-Kompetenz" + category: "Schulung" + responsible: "Geschaeftsfuehrung" + deadline: + type: "absolute" + date: "2025-02-02" + sanctions: + max_fine: "7,5 Mio. EUR oder 1% Jahresumsatz" + evidence: + - "Schulungsnachweise" + - "Kompetenzmatrix" + - "Awareness-Programm" + priority: "mittel" + +controls: + - id: "AIACT-CTRL-001" + name: "KI-Inventar" + description: "Fuehrung eines vollstaendigen Inventars aller KI-Systeme" + category: "Governance" + what_to_do: "Erfassung aller KI-Systeme mit Risikoeinstufung, Zweck, Anbieter, Betreiber" + iso27001_mapping: ["A.8.1"] + priority: "kritisch" + + - id: "AIACT-CTRL-002" + name: "KI-Governance-Struktur" + description: "Etablierung einer KI-Governance mit klaren Verantwortlichkeiten" + category: "Governance" + what_to_do: "Benennung eines KI-Verantwortlichen, Einrichtung eines KI-Boards" + priority: "hoch" + + - id: "AIACT-CTRL-003" + name: "Bias-Testing und Fairness" + description: "Regelmaessige Pruefung auf Verzerrungen und Diskriminierung" + category: "Technisch" + what_to_do: "Implementierung von Bias-Detection, Fairness-Metriken, Datensatz-Audits" + priority: "hoch" + + - id: "AIACT-CTRL-004" + name: "Model Monitoring" + description: "Kontinuierliche Ueberwachung der KI-Modellleistung" + category: "Technisch" + what_to_do: "Drift-Detection, Performance-Monitoring, Anomalie-Erkennung" + priority: "hoch" + + - id: "AIACT-CTRL-005" + name: "KI-Risikobewertungs-Prozess" + description: "Etablierung eines strukturierten Prozesses zur Risikobewertung" + category: "Governance" + what_to_do: "Pre-Deployment Assessment, regelmaessige Re-Evaluation, Eskalationsprozess" + priority: "kritisch" + + - id: "AIACT-CTRL-006" + name: "Explainability-Framework" + description: "Implementierung von Erklaerbarkeit fuer KI-Entscheidungen" + category: "Technisch" + what_to_do: "SHAP/LIME Integration, Entscheidungsprotokollierung, Nutzererklaerungen" + priority: "mittel" + +incident_deadlines: + - phase: "Schwerwiegender Vorfall melden" + deadline: "unverzueglich" + content: | + Meldung schwerwiegender Vorfaelle bei Hochrisiko-KI-Systemen: + - Tod oder schwere Gesundheitsschaeden + - Schwerwiegende Grundrechtsverletzungen + - Schwere Schaeden an Eigentum oder Umwelt + recipient: "Zustaendige Marktaufsichtsbehoerde" + legal_basis: + - norm: "Art. 73 AI Act" + + - phase: "Fehlfunktion melden (Anbieter)" + deadline: "15 Tage" + content: | + Anbieter von Hochrisiko-KI melden Fehlfunktionen, die einen + schwerwiegenden Vorfall darstellen koennten. + recipient: "Marktaufsichtsbehoerde des Herkunftslandes" + legal_basis: + - norm: "Art. 73 Abs. 1 AI Act" diff --git a/ai-compliance-sdk/policies/obligations/dsgvo_obligations.yaml b/ai-compliance-sdk/policies/obligations/dsgvo_obligations.yaml new file mode 100644 index 0000000..4ba9e70 --- /dev/null +++ b/ai-compliance-sdk/policies/obligations/dsgvo_obligations.yaml @@ -0,0 +1,321 @@ +# DSGVO (Datenschutz-Grundverordnung) Obligations +# EU Verordnung 2016/679 + +regulation: dsgvo +name: "DSGVO (Datenschutz-Grundverordnung)" +description: "EU-Verordnung zum Schutz personenbezogener Daten" + +obligations: + - id: "DSGVO-OBL-001" + title: "Verarbeitungsverzeichnis fuehren" + description: | + Fuehrung eines Verzeichnisses aller Verarbeitungstaetigkeiten mit Angabe der + Zwecke, Kategorien betroffener Personen, Empfaenger, Uebermittlungen in + Drittlaender und Loeschfristen. + applies_when: "always" + legal_basis: + - norm: "Art. 30 DSGVO" + article: "Verzeichnis von Verarbeitungstaetigkeiten" + category: "Governance" + responsible: "Datenschutzbeauftragter" + sanctions: + max_fine: "10 Mio. EUR oder 2% Jahresumsatz" + evidence: + - "Verarbeitungsverzeichnis" + - "Regelmaessige Aktualisierung dokumentiert" + priority: "hoch" + iso27001_mapping: ["A.5.1.1"] + + - id: "DSGVO-OBL-002" + title: "Technische und organisatorische Massnahmen (TOMs)" + description: | + Implementierung geeigneter technischer und organisatorischer Massnahmen zum + Schutz personenbezogener Daten unter Beruecksichtigung des Stands der Technik, + der Implementierungskosten und der Art, des Umfangs, der Umstaende und der + Zwecke der Verarbeitung. + applies_when: "always" + legal_basis: + - norm: "Art. 32 DSGVO" + article: "Sicherheit der Verarbeitung" + category: "Technisch" + responsible: "IT-Leitung" + sanctions: + max_fine: "10 Mio. EUR oder 2% Jahresumsatz" + evidence: + - "TOM-Dokumentation" + - "Risikoanalyse" + - "Verschluesselungskonzept" + - "Zugriffskontroll-Dokumentation" + priority: "hoch" + iso27001_mapping: ["A.8", "A.10", "A.12", "A.13"] + how_to_implement: | + 1. Risikoanalyse fuer alle Verarbeitungen durchfuehren + 2. Geeignete TOMs je nach Risikoniveau auswaehlen + 3. Verschluesselung fuer Daten at rest und in transit + 4. Zugriffskontrolle nach Need-to-know-Prinzip + 5. Regelmaessige Ueberpruefung und Aktualisierung + + - id: "DSGVO-OBL-003" + title: "Datenschutz-Folgenabschaetzung (DSFA)" + description: | + Durchfuehrung einer Datenschutz-Folgenabschaetzung bei Verarbeitungsvorgaengen, + die voraussichtlich ein hohes Risiko fuer die Rechte und Freiheiten natuerlicher + Personen zur Folge haben, insbesondere bei neuen Technologien. + applies_when: "high_risk" + legal_basis: + - norm: "Art. 35 DSGVO" + article: "Datenschutz-Folgenabschaetzung" + category: "Governance" + responsible: "Datenschutzbeauftragter" + sanctions: + max_fine: "10 Mio. EUR oder 2% Jahresumsatz" + evidence: + - "DSFA-Dokumentation" + - "Risikobewertung" + - "Abhilfemassnahmen" + - "Stellungnahme DSB" + priority: "kritisch" + iso27001_mapping: ["A.5.1.1", "A.18.1"] + how_to_implement: | + 1. Pruefen ob DSFA erforderlich (Blacklist der Aufsichtsbehoerde) + 2. Systematische Beschreibung der Verarbeitung + 3. Bewertung der Notwendigkeit und Verhaeltnismaessigkeit + 4. Risiken fuer Rechte und Freiheiten bewerten + 5. Abhilfemassnahmen festlegen + 6. Bei hohem Restrisiko: Konsultation der Aufsichtsbehoerde + + - id: "DSGVO-OBL-004" + title: "Datenschutzbeauftragten benennen" + description: | + Benennung eines Datenschutzbeauftragten bei oeffentlichen Stellen, + systematischer Ueberwachung im grossen Umfang oder Verarbeitung + besonderer Kategorien im grossen Umfang. In Deutschland: ab 20 MA. + applies_when: "needs_dpo" + legal_basis: + - norm: "Art. 37 DSGVO" + article: "Benennung eines Datenschutzbeauftragten" + - norm: "§ 38 BDSG" + article: "Datenschutzbeauftragte nichtoeffentlicher Stellen" + category: "Governance" + responsible: "Geschaeftsfuehrung" + sanctions: + max_fine: "10 Mio. EUR oder 2% Jahresumsatz" + evidence: + - "DSB-Bestellung" + - "Meldung an Aufsichtsbehoerde" + - "Veroeffentlichung Kontaktdaten" + priority: "hoch" + + - id: "DSGVO-OBL-005" + title: "Auftragsverarbeitungsvertrag (AVV)" + description: | + Abschluss eines Auftragsverarbeitungsvertrags mit allen Auftragsverarbeitern, + der die Pflichten gemaess Art. 28 Abs. 3 DSGVO enthaelt. + applies_when: "uses_processors" + legal_basis: + - norm: "Art. 28 DSGVO" + article: "Auftragsverarbeiter" + category: "Organisatorisch" + responsible: "Datenschutzbeauftragter" + sanctions: + max_fine: "10 Mio. EUR oder 2% Jahresumsatz" + evidence: + - "AVV-Vertrag" + - "TOM-Nachweis des Auftragsverarbeiters" + - "Verzeichnis der Auftragsverarbeiter" + priority: "hoch" + + - id: "DSGVO-OBL-006" + title: "Informationspflichten erfuellen" + description: | + Information der betroffenen Personen ueber die Verarbeitung ihrer Daten + bei Erhebung (Art. 13) oder nachtraeglich (Art. 14). Mindestinhalt: + Identitaet Verantwortlicher, Zwecke, Rechtsgrundlage, Empfaenger, + Uebermittlung Drittland, Speicherdauer, Betroffenenrechte. + applies_when: "controller" + legal_basis: + - norm: "Art. 13 DSGVO" + article: "Informationspflicht bei Erhebung" + - norm: "Art. 14 DSGVO" + article: "Informationspflicht bei Dritterhebung" + category: "Organisatorisch" + responsible: "Datenschutzbeauftragter" + sanctions: + max_fine: "20 Mio. EUR oder 4% Jahresumsatz" + evidence: + - "Datenschutzerklaerung Website" + - "Cookie-Banner" + - "Informationsblaetter Mitarbeiter" + - "Kundeninformationen" + priority: "hoch" + + - id: "DSGVO-OBL-007" + title: "Betroffenenrechte umsetzen" + description: | + Einrichtung von Prozessen zur Bearbeitung von Betroffenenanfragen innerhalb + von 1 Monat: Auskunft (Art. 15), Berichtigung (Art. 16), Loeschung (Art. 17), + Einschraenkung (Art. 18), Datenuebertragbarkeit (Art. 20), Widerspruch (Art. 21). + applies_when: "controller" + legal_basis: + - norm: "Art. 15-21 DSGVO" + article: "Betroffenenrechte" + category: "Organisatorisch" + responsible: "Datenschutzbeauftragter" + deadline: + type: "relative" + duration: "1 Monat nach Anfrage" + sanctions: + max_fine: "20 Mio. EUR oder 4% Jahresumsatz" + evidence: + - "DSR-Prozess dokumentiert" + - "Anfrageformulare" + - "Bearbeitungsprotokolle" + priority: "kritisch" + + - id: "DSGVO-OBL-008" + title: "Einwilligungen dokumentieren" + description: | + Nachweis gueltiger Einwilligungen: freiwillig, informiert, spezifisch, + unmissverstaendlich, widerrufbar. Bei besonderen Kategorien: ausdruecklich. + applies_when: "controller" + legal_basis: + - norm: "Art. 7 DSGVO" + article: "Bedingungen fuer die Einwilligung" + - norm: "Art. 9 Abs. 2 lit. a DSGVO" + category: "Governance" + responsible: "Datenschutzbeauftragter" + sanctions: + max_fine: "20 Mio. EUR oder 4% Jahresumsatz" + evidence: + - "Consent-Management-System" + - "Einwilligungsprotokolle" + - "Widerrufsprozess dokumentiert" + priority: "hoch" + + - id: "DSGVO-OBL-009" + title: "Loeschkonzept umsetzen" + description: | + Implementierung eines Loeschkonzepts mit definierten Aufbewahrungsfristen + und automatisierten Loeschroutinen (Speicherbegrenzung). + applies_when: "always" + legal_basis: + - norm: "Art. 17 DSGVO" + article: "Recht auf Loeschung" + - norm: "Art. 5 Abs. 1 lit. e DSGVO" + article: "Speicherbegrenzung" + category: "Technisch" + responsible: "IT-Leitung" + sanctions: + max_fine: "20 Mio. EUR oder 4% Jahresumsatz" + evidence: + - "Loeschkonzept" + - "Aufbewahrungsfristen je Kategorie" + - "Loeschprotokolle" + priority: "hoch" + + - id: "DSGVO-OBL-010" + title: "Drittlandtransfer absichern" + description: | + Bei Uebermittlung in Drittlaender ohne Angemessenheitsbeschluss: + Standardvertragsklauseln (SCCs), BCRs oder andere Garantien. + Transfer Impact Assessment durchfuehren. + applies_when: "cross_border" + legal_basis: + - norm: "Art. 44-49 DSGVO" + article: "Uebermittlung in Drittlaender" + category: "Organisatorisch" + responsible: "Datenschutzbeauftragter" + sanctions: + max_fine: "20 Mio. EUR oder 4% Jahresumsatz" + evidence: + - "SCCs abgeschlossen" + - "Transfer Impact Assessment" + - "Dokumentation der Garantien" + priority: "kritisch" + + - id: "DSGVO-OBL-011" + title: "Meldeprozess Datenschutzverletzungen" + description: | + Etablierung eines Prozesses zur Erkennung, Bewertung und Meldung von + Datenschutzverletzungen innerhalb von 72 Stunden an die Aufsichtsbehoerde + und ggf. unverzueglich an betroffene Personen. + applies_when: "always" + legal_basis: + - norm: "Art. 33 DSGVO" + article: "Meldung an Aufsichtsbehoerde" + - norm: "Art. 34 DSGVO" + article: "Benachrichtigung Betroffener" + category: "Meldepflicht" + responsible: "Datenschutzbeauftragter" + sanctions: + max_fine: "10 Mio. EUR oder 2% Jahresumsatz" + evidence: + - "Breach-Notification-Prozess" + - "Meldevorlage" + - "Vorfallprotokoll" + priority: "kritisch" + +controls: + - id: "DSGVO-CTRL-001" + name: "Consent-Management-System" + description: "Implementierung eines Systems zur Verwaltung von Einwilligungen" + category: "Technisch" + what_to_do: "Implementierung einer Consent-Management-Plattform mit Protokollierung, Widerrufsmoeglichkeit und Nachweis" + iso27001_mapping: ["A.18.1"] + priority: "hoch" + + - id: "DSGVO-CTRL-002" + name: "Verschluesselung personenbezogener Daten" + description: "Verschluesselung ruhender und uebertragener Daten" + category: "Technisch" + what_to_do: "TLS 1.3 fuer Uebertragung, AES-256 fuer Speicherung, Key-Management implementieren" + iso27001_mapping: ["A.10.1"] + priority: "hoch" + + - id: "DSGVO-CTRL-003" + name: "Zugriffskontrolle" + description: "Need-to-know-Prinzip fuer Zugriff auf personenbezogene Daten" + category: "Organisatorisch" + what_to_do: "RBAC implementieren, regelmaessige Berechtigungspruefung, Protokollierung aller Zugriffe" + iso27001_mapping: ["A.9.1", "A.9.2", "A.9.4"] + priority: "hoch" + + - id: "DSGVO-CTRL-004" + name: "Pseudonymisierung/Anonymisierung" + description: "Anwendung von Pseudonymisierung wo moeglich, Anonymisierung fuer Analysen" + category: "Technisch" + what_to_do: "Pseudonymisierungsverfahren implementieren, Zuordnungstabellen getrennt speichern" + iso27001_mapping: ["A.8.2"] + priority: "mittel" + + - id: "DSGVO-CTRL-005" + name: "Datenschutz-Schulungen" + description: "Regelmaessige Schulung aller Mitarbeiter zu Datenschutzthemen" + category: "Organisatorisch" + what_to_do: "Jaehrliche Pflichtschulungen, Awareness-Kampagnen, dokumentierte Nachweise" + iso27001_mapping: ["A.7.2.2"] + priority: "mittel" + +incident_deadlines: + - phase: "Meldung an Aufsichtsbehoerde" + deadline: "72 Stunden" + content: | + Meldung bei Verletzung des Schutzes personenbezogener Daten, + es sei denn, die Verletzung fuehrt voraussichtlich nicht zu einem + Risiko fuer die Rechte und Freiheiten natuerlicher Personen. + Inhalt: Art der Verletzung, Kategorien/Anzahl Betroffener, + Kontakt DSB, Folgen, ergriffene Massnahmen. + recipient: "Zustaendige Datenschutz-Aufsichtsbehoerde" + legal_basis: + - norm: "Art. 33 DSGVO" + + - phase: "Benachrichtigung Betroffener" + deadline: "unverzueglich" + content: | + Wenn die Verletzung voraussichtlich ein hohes Risiko fuer die + Rechte und Freiheiten natuerlicher Personen zur Folge hat. + In klarer und einfacher Sprache: Art der Verletzung, Kontakt DSB, + wahrscheinliche Folgen, ergriffene Abhilfemassnahmen. + recipient: "Betroffene Personen" + legal_basis: + - norm: "Art. 34 DSGVO" diff --git a/ai-compliance-sdk/policies/obligations/nis2_obligations.yaml b/ai-compliance-sdk/policies/obligations/nis2_obligations.yaml new file mode 100644 index 0000000..9cff2fa --- /dev/null +++ b/ai-compliance-sdk/policies/obligations/nis2_obligations.yaml @@ -0,0 +1,556 @@ +# NIS2 Obligations & Controls +# Version: 1.0 +# Based on: EU Directive 2022/2555 (NIS2) and German BSIG-E (Draft) + +regulation: nis2 +name: "NIS2-Richtlinie / BSIG-E" +version: "1.0" +effective_date: "2024-10-18" +german_implementation: "BSIG-E (Entwurf)" + +# ============================================================================== +# Pflichten (Obligations) +# ============================================================================== + +obligations: + # --------------------------------------------------------------------------- + # Meldepflichten + # --------------------------------------------------------------------------- + - id: "NIS2-OBL-001" + title: "BSI-Registrierung" + description: | + Registrierung beim BSI über das Meldeportal innerhalb von 3 Monaten nach + Identifikation als betroffene Einrichtung. Anzugeben sind: + - Name und Anschrift der Einrichtung + - Kontaktdaten (E-Mail, Telefon) eines Ansprechpartners + - IP-Adressbereiche der Einrichtung + - Sektor und Tätigkeitsbereich + applies_when: "classification in ['wichtige_einrichtung', 'besonders_wichtige_einrichtung']" + legal_basis: + - norm: "§ 33 BSIG-E" + article: "Registrierungspflicht" + - norm: "Art. 3 Abs. 4 NIS2" + category: "Meldepflicht" + responsible: "Geschäftsführung" + deadline: + type: "absolute" + date: "2025-01-17" + sanctions: + max_fine: "500.000 EUR" + personal_liability: false + evidence: + - "Registrierungsbestätigung des BSI" + - "Dokumentierte Ansprechpartner mit Kontaktdaten" + - "Liste der registrierten IP-Bereiche" + priority: "critical" + how_to_implement: | + 1. BSI-Meldeportal aufrufen (wird noch eingerichtet) + 2. Unternehmensdaten eingeben + 3. Technische Ansprechpartner benennen + 4. IP-Bereiche dokumentieren und eintragen + 5. Bestätigung archivieren + + - id: "NIS2-OBL-002" + title: "Risikomanagement-Maßnahmen implementieren" + description: | + Umsetzung angemessener und verhältnismäßiger technischer, operativer und + organisatorischer Maßnahmen zur Beherrschung der Risiken für die Sicherheit + der Netz- und Informationssysteme. Die Maßnahmen müssen dem Stand der Technik + entsprechen und folgende Bereiche abdecken: + - Risikoanalyse und Sicherheitskonzepte + - Bewältigung von Sicherheitsvorfällen + - Betriebskontinuität und Krisenmanagement + - Sicherheit der Lieferkette + - Sicherheitsmaßnahmen bei Erwerb, Entwicklung und Wartung + - Bewertung der Wirksamkeit von Maßnahmen + - Cyberhygiene und Schulungen + - Kryptographie + - Personalsicherheit + - Zugangskontrollen + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 21 NIS2" + article: "Risikomanagementmaßnahmen im Bereich der Cybersicherheit" + - norm: "§ 30 BSIG-E" + article: "Risikomanagementmaßnahmen" + category: "Governance" + responsible: "CISO" + deadline: + type: "relative" + duration: "18 Monate nach Inkrafttreten des BSIG-E" + sanctions: + max_fine: "10 Mio. EUR oder 2% des weltweiten Jahresumsatzes" + personal_liability: true + evidence: + - "ISMS-Dokumentation" + - "Risikoanalyse und -bewertung" + - "Maßnahmenkatalog mit Umsetzungsstatus" + - "Sicherheitskonzept" + priority: "high" + iso27001_mapping: ["A.5", "A.6", "A.8"] + + - id: "NIS2-OBL-003" + title: "Geschäftsführungs-Verantwortung und Genehmigung" + description: | + Die Leitungsorgane (Geschäftsführung, Vorstand) müssen die + Risikomanagementmaßnahmen billigen und deren Umsetzung überwachen. + Bei Verstößen können sie persönlich haftbar gemacht werden. Die + Geschäftsführung ist verpflichtet: + - Risikomanagement-Maßnahmen zu genehmigen + - Umsetzung regelmäßig zu überprüfen + - Ausreichende Ressourcen bereitzustellen + - Cybersicherheit als strategisches Thema zu behandeln + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 20 NIS2" + article: "Governance" + - norm: "§ 38 BSIG-E" + article: "Billigung, Überwachung und Schulung" + category: "Governance" + responsible: "Geschäftsführung" + sanctions: + max_fine: "10 Mio. EUR oder 2% des weltweiten Jahresumsatzes" + personal_liability: true + evidence: + - "Vorstandsbeschluss zur Cybersicherheitsstrategie" + - "Dokumentierte Genehmigung der Risikomanagement-Maßnahmen" + - "Protokolle der regelmäßigen Reviews" + - "Nachweis der Ressourcenbereitstellung" + priority: "critical" + + - id: "NIS2-OBL-004" + title: "Cybersicherheits-Schulung der Geschäftsführung" + description: | + Mitglieder der Leitungsorgane müssen an regelmäßigen Schulungen teilnehmen, + um ausreichende Kenntnisse und Fähigkeiten zur Erkennung und Bewertung von + Cybersicherheitsrisiken und deren Auswirkungen zu erlangen. Die Schulungen + müssen folgende Themen abdecken: + - Aktuelle Bedrohungslage + - Grundlagen der Informationssicherheit + - Relevante gesetzliche Anforderungen (NIS2, DSGVO, etc.) + - Risikobasierter Ansatz + - Incident Response und Krisenmanagement + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 20 Abs. 2 NIS2" + - norm: "§ 38 Abs. 3 BSIG-E" + category: "Schulung" + responsible: "Geschäftsführung" + deadline: + type: "recurring" + interval: "jährlich" + evidence: + - "Schulungsnachweise/Zertifikate der Geschäftsführung" + - "Schulungsplan mit Themen und Terminen" + - "Teilnahmelisten" + priority: "high" + + - id: "NIS2-OBL-005" + title: "Incident-Response-Prozess und Meldepflichten" + description: | + Etablierung eines Prozesses zur Erkennung, Analyse, Eindämmung und Meldung + von erheblichen Sicherheitsvorfällen. Die Meldung muss in drei Phasen erfolgen: + + 1. Frühwarnung (24 Stunden): Unverzügliche Meldung, ob böswilliger Angriff + vermutet wird und ob grenzüberschreitende Auswirkungen möglich sind + 2. Vorfallmeldung (72 Stunden): Aktualisierung mit erster Bewertung, + Schweregrad, Auswirkungen und Kompromittierungsindikatoren + 3. Abschlussbericht (1 Monat): Ausführliche Beschreibung, Ursachenanalyse, + ergriffene Maßnahmen und grenzüberschreitende Auswirkungen + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 23 NIS2" + article: "Berichtspflichten" + - norm: "§ 32 BSIG-E" + article: "Meldepflichten" + category: "Meldepflicht" + responsible: "CISO" + evidence: + - "Incident-Response-Plan" + - "Meldeprozess-Dokumentation" + - "24/7-Erreichbarkeit der Ansprechpartner" + - "Kontaktdaten BSI und ggf. sektorale Behörden" + - "Vorlagen für Meldungen" + priority: "critical" + iso27001_mapping: ["A.16"] + + - id: "NIS2-OBL-006" + title: "Business Continuity Management" + description: | + Implementierung von Maßnahmen zur Aufrechterhaltung des Betriebs: + - Backup-Management und -Strategie + - Notfallwiederherstellung (Disaster Recovery) + - Krisenmanagement + - Notfallplanung + Regelmäßige Tests der BCM-Maßnahmen sind durchzuführen. + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 21 Abs. 2 lit. c NIS2" + - norm: "§ 30 Abs. 2 Nr. 3 BSIG-E" + category: "Technisch" + responsible: "CISO" + evidence: + - "BCM-Dokumentation" + - "Backup-Konzept mit RTO/RPO" + - "Disaster-Recovery-Plan" + - "Krisenmanagement-Handbuch" + - "Testprotokolle (mindestens jährlich)" + priority: "high" + iso27001_mapping: ["A.17"] + + - id: "NIS2-OBL-007" + title: "Lieferketten-Sicherheit" + description: | + Sicherstellung der Sicherheit in der Lieferkette, einschließlich: + - Bewertung der Sicherheitspraktiken von Lieferanten + - Vertragliche Sicherheitsanforderungen + - Regelmäßige Überprüfung der Lieferanten + - Berücksichtigung von Konzentrationsrisiken + - Dokumentation kritischer Lieferanten + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 21 Abs. 2 lit. d NIS2" + - norm: "§ 30 Abs. 2 Nr. 4 BSIG-E" + category: "Organisatorisch" + responsible: "CISO" + evidence: + - "Lieferanten-Risikobewertung" + - "Sicherheitsanforderungen in Verträgen" + - "Liste kritischer Lieferanten" + - "Audit-Berichte von Lieferanten" + priority: "medium" + iso27001_mapping: ["A.15"] + + - id: "NIS2-OBL-008" + title: "Schwachstellenmanagement" + description: | + Etablierung von Prozessen zum Umgang mit Schwachstellen: + - Regelmäßige Schwachstellen-Scans + - Priorisierung nach Risiko + - Zeitnahe Behebung (Patch-Management) + - Koordinierte Offenlegung von Schwachstellen + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 21 Abs. 2 lit. e NIS2" + - norm: "§ 30 Abs. 2 Nr. 5 BSIG-E" + category: "Technisch" + responsible: "CISO" + evidence: + - "Schwachstellen-Management-Prozess" + - "Patch-Management-Richtlinie" + - "Vulnerability-Scan-Berichte" + - "Statistiken zur Behebungszeit" + priority: "high" + iso27001_mapping: ["A.12.6"] + + - id: "NIS2-OBL-009" + title: "Zugangs- und Identitätsmanagement" + description: | + Implementierung von Konzepten für: + - Zugangskontrolle (Need-to-know-Prinzip) + - Management von Benutzerkonten und Berechtigungen + - Multi-Faktor-Authentifizierung (MFA) + - Sichere Authentifizierungsmethoden + - Regelmäßige Überprüfung von Berechtigungen + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 21 Abs. 2 lit. i NIS2" + - norm: "§ 30 Abs. 2 Nr. 9 BSIG-E" + category: "Technisch" + responsible: "IT-Leitung" + evidence: + - "Zugangskontroll-Richtlinie" + - "MFA-Implementierungsnachweis" + - "Identity-Management-Dokumentation" + - "Berechtigungsmatrix" + - "Review-Protokolle" + priority: "high" + iso27001_mapping: ["A.9"] + + - id: "NIS2-OBL-010" + title: "Kryptographie und Verschlüsselung" + description: | + Konzepte und Verfahren für den Einsatz von Kryptographie: + - Verschlüsselung von Daten in Ruhe und Transit + - Sichere Schlüsselverwaltung + - Verwendung aktueller kryptographischer Standards + - Regelmäßige Überprüfung der eingesetzten Verfahren + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 21 Abs. 2 lit. h NIS2" + - norm: "§ 30 Abs. 2 Nr. 8 BSIG-E" + category: "Technisch" + responsible: "CISO" + evidence: + - "Kryptographie-Richtlinie" + - "Verschlüsselungskonzept" + - "Key-Management-Dokumentation" + - "Liste eingesetzter Algorithmen" + priority: "medium" + iso27001_mapping: ["A.10"] + + - id: "NIS2-OBL-011" + title: "Personalsicherheit und Awareness" + description: | + Sicherstellung der Personalsicherheit: + - Hintergrundüberprüfungen für kritische Rollen + - Regelmäßige Sicherheitsschulungen für alle Mitarbeiter + - Awareness-Programme + - Klare Verantwortlichkeiten + - Prozesse bei Personalwechsel + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 21 Abs. 2 lit. g NIS2" + - norm: "Art. 21 Abs. 2 lit. j NIS2" + - norm: "§ 30 Abs. 2 Nr. 7 BSIG-E" + - norm: "§ 30 Abs. 2 Nr. 10 BSIG-E" + category: "Organisatorisch" + responsible: "Geschäftsführung" + evidence: + - "Personalsicherheits-Richtlinie" + - "Schulungskonzept und -nachweise" + - "Awareness-Materialien" + - "Onboarding/Offboarding-Prozesse" + priority: "medium" + iso27001_mapping: ["A.7"] + + - id: "NIS2-OBL-012" + title: "Regelmäßige Sicherheitsaudits (besonders wichtige Einrichtungen)" + description: | + Besonders wichtige Einrichtungen unterliegen regelmäßigen + Sicherheitsüberprüfungen durch das BSI. Diese können umfassen: + - Vor-Ort-Prüfungen + - Remote-Audits + - Dokumentenprüfungen + - Technische Prüfungen + Die Einrichtungen müssen auf Anforderung Nachweise vorlegen. + applies_when: "classification == 'besonders_wichtige_einrichtung'" + legal_basis: + - norm: "Art. 32 NIS2" + article: "Aufsichtsmaßnahmen für besonders wichtige Einrichtungen" + - norm: "§ 39 BSIG-E" + category: "Audit" + responsible: "CISO" + deadline: + type: "recurring" + interval: "alle 2 Jahre" + evidence: + - "Audit-Berichte" + - "Maßnahmenpläne aus Audits" + - "Nachweise der Umsetzung" + priority: "high" + + - id: "NIS2-OBL-013" + title: "Netzsegmentierung und Netzwerksicherheit" + description: | + Implementierung von Netzwerksicherheitsmaßnahmen: + - Segmentierung kritischer Systeme + - Firewalls und Zugangskontrolle + - Sichere Netzwerkarchitektur + - Überwachung des Netzwerkverkehrs + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 21 Abs. 2 lit. a NIS2" + - norm: "§ 30 Abs. 2 Nr. 1 BSIG-E" + category: "Technisch" + responsible: "IT-Leitung" + evidence: + - "Netzwerkarchitektur-Dokumentation" + - "Firewall-Regelwerke" + - "Segmentierungskonzept" + priority: "high" + iso27001_mapping: ["A.13.1"] + + - id: "NIS2-OBL-014" + title: "Security Monitoring und Protokollierung" + description: | + Kontinuierliche Überwachung der IT-Sicherheit: + - Sicherheitsrelevante Protokollierung + - SIEM oder vergleichbare Lösung + - Anomalie-Erkennung + - Regelmäßige Auswertung der Logs + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "Art. 21 Abs. 2 lit. b NIS2" + - norm: "§ 30 Abs. 2 Nr. 2 BSIG-E" + category: "Technisch" + responsible: "CISO" + evidence: + - "Log-Management-Konzept" + - "SIEM-Dokumentation" + - "Monitoring-Dashboards" + - "Incident-Berichte aus Monitoring" + priority: "high" + iso27001_mapping: ["A.12.4"] + +# ============================================================================== +# Controls (Maßnahmen) +# ============================================================================== + +controls: + - id: "NIS2-CTRL-001" + name: "ISMS implementieren" + description: "Implementierung eines Informationssicherheits-Managementsystems nach anerkanntem Standard" + category: "Governance" + what_to_do: "Aufbau eines ISMS nach ISO 27001 oder BSI IT-Grundschutz" + iso27001_mapping: ["4", "5", "6", "7"] + priority: "high" + + - id: "NIS2-CTRL-002" + name: "Netzwerksegmentierung" + description: "Segmentierung kritischer Netzwerkbereiche zur Reduzierung der Angriffsfläche" + category: "Technisch" + what_to_do: "Implementierung von VLANs, Firewalls und Mikrosegmentierung für kritische Systeme" + iso27001_mapping: ["A.13.1"] + priority: "high" + + - id: "NIS2-CTRL-003" + name: "Security Monitoring" + description: "Kontinuierliche Überwachung der IT-Sicherheit" + category: "Technisch" + what_to_do: "Implementierung von SIEM, Log-Management und Anomalie-Erkennung" + iso27001_mapping: ["A.12.4"] + priority: "high" + + - id: "NIS2-CTRL-004" + name: "Awareness-Programm" + description: "Regelmäßige Sicherheitsschulungen für alle Mitarbeiter" + category: "Organisatorisch" + what_to_do: "Durchführung von Phishing-Simulationen, E-Learning und Präsenzschulungen" + iso27001_mapping: ["A.7.2.2"] + priority: "medium" + + - id: "NIS2-CTRL-005" + name: "Multi-Faktor-Authentifizierung" + description: "MFA für alle administrativen Zugänge und kritischen Systeme" + category: "Technisch" + what_to_do: "Einführung von MFA für VPN, E-Mail, Admin-Zugänge und kritische Anwendungen" + iso27001_mapping: ["A.9.4"] + priority: "high" + + - id: "NIS2-CTRL-006" + name: "Backup & Recovery" + description: "Regelmäßige Backups und getestete Wiederherstellung" + category: "Technisch" + what_to_do: "Implementierung von 3-2-1 Backup-Strategie mit regelmäßigen Recovery-Tests" + iso27001_mapping: ["A.12.3"] + priority: "high" + + - id: "NIS2-CTRL-007" + name: "Vulnerability Management" + description: "Systematisches Schwachstellenmanagement" + category: "Technisch" + what_to_do: "Regelmäßige Scans, Priorisierung nach CVSS, zeitnahe Patches" + iso27001_mapping: ["A.12.6"] + priority: "high" + + - id: "NIS2-CTRL-008" + name: "Incident Response Team" + description: "Dediziertes Team für Sicherheitsvorfälle" + category: "Organisatorisch" + what_to_do: "Aufbau eines CSIRT/CERT mit klaren Rollen und Eskalationspfaden" + iso27001_mapping: ["A.16.1"] + priority: "high" + +# ============================================================================== +# Incident Deadlines (Meldefristen) +# ============================================================================== + +incident_deadlines: + - phase: "Frühwarnung" + deadline: "24 Stunden" + content: | + Unverzügliche Meldung erheblicher Sicherheitsvorfälle. Angabe: + - Ob böswilliger Angriff vermutet wird + - Ob grenzüberschreitende Auswirkungen möglich sind + recipient: "BSI" + legal_basis: + - norm: "§ 32 Abs. 1 BSIG-E" + + - phase: "Vorfallmeldung" + deadline: "72 Stunden" + content: | + Aktualisierung der Frühwarnung mit: + - Erste Bewertung des Vorfalls + - Schweregrad und Auswirkungen + - Kompromittierungsindikatoren (IoCs) + recipient: "BSI" + legal_basis: + - norm: "§ 32 Abs. 2 BSIG-E" + + - phase: "Abschlussbericht" + deadline: "1 Monat" + content: | + Ausführlicher Bericht mit: + - Ausführliche Beschreibung des Vorfalls + - Ursachenanalyse (Root Cause) + - Ergriffene Abhilfemaßnahmen + - Grenzüberschreitende Auswirkungen + recipient: "BSI" + legal_basis: + - norm: "§ 32 Abs. 3 BSIG-E" + +# ============================================================================== +# Sector Classification (NIS2 Annexes) +# ============================================================================== + +sectors: + annex_i: + name: "Sektoren mit hoher Kritikalität" + description: "Anhang I der NIS2-Richtlinie" + sectors: + - id: "energy" + name: "Energie" + subsectors: ["Elektrizität", "Fernwärme/-kälte", "Erdöl", "Erdgas", "Wasserstoff"] + - id: "transport" + name: "Verkehr" + subsectors: ["Luftverkehr", "Schienenverkehr", "Schifffahrt", "Straßenverkehr"] + - id: "banking_financial" + name: "Bankwesen" + subsectors: ["Kreditinstitute"] + - id: "financial_market" + name: "Finanzmarktinfrastrukturen" + subsectors: ["Betreiber von Handelsplätzen", "Zentrale Gegenparteien"] + - id: "health" + name: "Gesundheitswesen" + subsectors: ["Gesundheitsdienstleister", "EU-Referenzlaboratorien", "Arzneimittelhersteller", "Medizinproduktehersteller"] + - id: "drinking_water" + name: "Trinkwasser" + subsectors: ["Lieferanten und Verteiler von Trinkwasser"] + - id: "wastewater" + name: "Abwasser" + subsectors: ["Unternehmen, die kommunales Abwasser sammeln, entsorgen oder behandeln"] + - id: "digital_infrastructure" + name: "Digitale Infrastruktur" + subsectors: ["IXPs", "DNS-Dienste", "TLD-Namenregister", "Cloud-Computing", "Rechenzentren", "CDNs", "Vertrauensdienste", "Öffentliche Kommunikationsnetze"] + - id: "ict_service_mgmt" + name: "Verwaltung von IKT-Diensten (B2B)" + subsectors: ["Managed Service Provider", "Managed Security Service Provider"] + - id: "public_administration" + name: "Öffentliche Verwaltung" + subsectors: ["Zentralregierung", "Regionale Behörden"] + - id: "space" + name: "Weltraum" + subsectors: ["Betreiber von Bodeninfrastrukturen"] + + annex_ii: + name: "Sonstige kritische Sektoren" + description: "Anhang II der NIS2-Richtlinie" + sectors: + - id: "postal" + name: "Post- und Kurierdienste" + - id: "waste" + name: "Abfallbewirtschaftung" + - id: "chemicals" + name: "Herstellung, Produktion und Vertrieb von Chemikalien" + - id: "food" + name: "Produktion, Verarbeitung und Vertrieb von Lebensmitteln" + - id: "manufacturing" + name: "Verarbeitendes Gewerbe/Herstellung von Waren" + subsectors: ["Medizinprodukte", "DV-Geräte", "Elektrische Ausrüstungen", "Maschinenbau", "Kraftwagen", "Sonstiger Fahrzeugbau"] + - id: "digital_providers" + name: "Anbieter digitaler Dienste" + subsectors: ["Online-Marktplätze", "Online-Suchmaschinen", "Soziale Netzwerke"] + - id: "research" + name: "Forschung" + subsectors: ["Forschungseinrichtungen"] diff --git a/ai-compliance-sdk/policies/scc_legal_corpus.yaml b/ai-compliance-sdk/policies/scc_legal_corpus.yaml new file mode 100644 index 0000000..7a5e2f8 --- /dev/null +++ b/ai-compliance-sdk/policies/scc_legal_corpus.yaml @@ -0,0 +1,458 @@ +# ============================================================================= +# SCC Legal Corpus - Standardvertragsklauseln & Drittlandtransfers +# Für Legal RAG Integration +# Version: 1.0 +# Stand: Januar 2026 +# ============================================================================= + +version: "1.0" +jurisdiction: EU +last_updated: "2026-01-29" +description: "Rechtliche Informationen zu Standardvertragsklauseln und internationalen Datentransfers" + +# ============================================================================= +# GRUNDLAGEN +# ============================================================================= + +fundamentals: + + scc_definition: + id: SCC-DEF-001 + topic: "Was sind Standardvertragsklauseln?" + content: | + Standardvertragsklauseln (Standard Contractual Clauses - SCC) sind von der + EU-Kommission verabschiedete Musterverträge, die eine rechtliche Grundlage + für Datenübermittlungen personenbezogener Daten in Länder außerhalb des + Europäischen Wirtschaftsraums (EWR) schaffen. + + Sie gelten als Instrument nach Art. 46 Abs. 2 lit. c DSGVO, wenn für das + Empfängerland kein Angemessenheitsbeschluss der EU-Kommission besteht. + legal_refs: + - "DSGVO Art. 46 Abs. 2 lit. c" + - "EU-Kommission Durchführungsbeschluss (EU) 2021/914" + keywords: ["SCC", "Standardvertragsklauseln", "Drittlandtransfer", "Art. 46"] + + new_scc_2021: + id: SCC-DEF-002 + topic: "Neue SCC seit Juni 2021" + content: | + Die EU-Kommission hat im Juni 2021 modernisierte Standardvertragsklauseln + veröffentlicht, die alte Versionen seit dem 27. Dezember 2022 vollständig + abgelöst haben. Die neuen SCC sind modular aufgebaut und decken vier + verschiedene Transfer-Szenarien ab: + + - Modul 1: Controller zu Controller (C2C) + - Modul 2: Controller zu Processor (C2P) + - Modul 3: Processor zu Processor (P2P) + - Modul 4: Processor zu Controller (P2C) + + WICHTIG: Alte SCC-Versionen (von 2001, 2004, 2010) sind seit Ende 2022 + nicht mehr gültig für neue oder andauernde Transfers. + legal_refs: + - "EU-Kommission Durchführungsbeschluss (EU) 2021/914 vom 4. Juni 2021" + - "CNIL Guidance on SCC transition" + keywords: ["neue SCC", "2021", "Module", "C2C", "C2P", "P2P"] + + adequacy_decisions: + id: SCC-DEF-003 + topic: "Angemessenheitsbeschlüsse" + content: | + Für einige Drittländer hat die EU-Kommission Angemessenheitsbeschlüsse + erlassen, die ein dem EU-Recht vergleichbares Datenschutzniveau bestätigen. + In diesen Fällen sind KEINE SCC erforderlich. + + Länder mit Angemessenheitsbeschluss (Stand 2026): + - Andorra, Argentinien, Kanada (nur PIPEDA), Färöer-Inseln + - Guernsey, Israel, Isle of Man, Japan, Jersey + - Neuseeland, Schweiz, Südkorea, Uruguay + - Vereinigtes Königreich (UK) + - USA (nur für Unternehmen unter dem EU-US Data Privacy Framework) + + ACHTUNG: Der USA-Angemessenheitsbeschluss (Data Privacy Framework) gilt + NUR für US-Unternehmen, die beim DPF zertifiziert sind! + legal_refs: + - "DSGVO Art. 45" + - "EU-Kommission Angemessenheitsbeschlüsse" + - "EU-US Data Privacy Framework Beschluss vom 10. Juli 2023" + keywords: ["Angemessenheitsbeschluss", "adequacy", "Art. 45", "DPF"] + +# ============================================================================= +# WANN SIND SCC ERFORDERLICH? +# ============================================================================= + +requirements: + + when_scc_required: + id: SCC-REQ-001 + topic: "Wann sind SCC erforderlich?" + content: | + SCC sind erforderlich wenn ALLE folgenden Bedingungen erfüllt sind: + + 1. Es werden personenbezogene Daten übermittelt + 2. Der Empfänger befindet sich außerhalb des EWR + 3. Für das Empfängerland besteht KEIN Angemessenheitsbeschluss + (oder der Empfänger ist nicht unter einem solchen zertifiziert) + + WICHTIG: Als "Übermittlung" gilt auch: + - Remote-Zugriff auf EU-Daten von einem Drittland aus + - Support/Wartungszugriffe von Mitarbeitern im Drittland + - Backup-Speicherung in Drittland-Rechenzentren + - Nutzung von Cloud-Diensten mit Drittland-Verarbeitung + legal_refs: + - "DSGVO Art. 44" + - "DSGVO Art. 46" + keywords: ["Übermittlung", "Drittland", "Transfer"] + + when_scc_not_required: + id: SCC-REQ-002 + topic: "Wann sind SCC NICHT erforderlich?" + content: | + SCC sind NICHT erforderlich in folgenden Fällen: + + 1. Rein lokale Verarbeitung (On-Premises im EWR) + - Alle Daten bleiben im EWR + - Kein Zugriff von Drittländern + - Keine Cloud-Dienste mit Drittland-Verarbeitung + + 2. Angemessenheitsbeschluss vorhanden + - Empfängerland hat EU-Angemessenheitsbeschluss + - Bei USA: Empfänger ist DPF-zertifiziert + + 3. Keine personenbezogenen Daten + - Vollständig anonymisierte Daten (irreversibel!) + - Nur aggregierte Statistiken + + 4. Binding Corporate Rules (BCR) + - Konzerninterner Transfer mit genehmigten BCR + legal_refs: + - "DSGVO Art. 44" + - "DSGVO Art. 45" + - "ErwGr. 26 DSGVO (Anonymisierung)" + keywords: ["lokal", "On-Premises", "anonymisiert", "BCR"] + + local_hosting_scenario: + id: SCC-REQ-003 + topic: "Szenario: Lokales Hosting (Mac Studio)" + content: | + Bei lokalem Hosting auf kundeneigener Hardware (z.B. Mac Studio): + + KEINE SCC erforderlich wenn: + - Software/LLM läuft vollständig lokal + - Alle personenbezogenen Daten bleiben auf der lokalen Hardware + - Wartung/Support erfolgt OHNE Zugriff auf personenbezogene Daten + - Fehlerberichte/Logs enthalten KEINE personenbezogenen Daten + - Keine Cloud-Synchronisation mit Drittländern + + AVV WEITERHIN ERFORDERLICH wenn: + - Der Software-Anbieter als Auftragsverarbeiter agiert + - Im Rahmen von Wartung/Support Zugriff auf Daten möglich ist + + ACHTUNG: Sobald Support-Mitarbeiter von außerhalb des EWR auf + personenbezogene Daten zugreifen können, liegt ein Drittlandtransfer vor! + legal_refs: + - "DSGVO Art. 28 (AVV-Pflicht)" + - "DSGVO Art. 44ff (Drittlandtransfer)" + keywords: ["lokal", "On-Premises", "Mac Studio", "AVV"] + + cloud_hosting_scenario: + id: SCC-REQ-004 + topic: "Szenario: Cloud-Hosting" + content: | + Bei Cloud-Hosting (z.B. SysEleven, AWS, Azure, GCP): + + Prüfschema: + + 1. Wo befinden sich die Rechenzentren? + → EU-only: Grundsätzlich keine SCC nötig + → Weltweit/USA: Weitere Prüfung erforderlich + + 2. Wer hat Zugriff auf die Daten? + → Nur EU-Personal: Keine SCC + → Drittland-Support möglich: SCC erforderlich + + 3. Gibt es Unterauftragsverarbeiter im Drittland? + → Ja: SCC mit diesen erforderlich + → Nein: Dokumentation ausreichend + + 4. USA-Cloud-Provider mit EU-Rechenzentren: + → Prüfen ob DPF-zertifiziert + → Prüfen ob US-Behördenzugriff technisch möglich (FISA 702) + → Ggf. zusätzliche technische Maßnahmen (Verschlüsselung) + legal_refs: + - "DSGVO Art. 28 Abs. 2 (Unterauftragsverarbeiter)" + - "DSGVO Art. 44ff" + - "EuGH Schrems II (C-311/18)" + keywords: ["Cloud", "SysEleven", "AWS", "Azure", "GCP"] + +# ============================================================================= +# TRANSFER IMPACT ASSESSMENT (TIA) +# ============================================================================= + +tia: + + tia_requirement: + id: SCC-TIA-001 + topic: "Transfer Impact Assessment" + content: | + Nach dem Schrems II-Urteil des EuGH müssen Datenexporteure vor jedem + Drittlandtransfer ein Transfer Impact Assessment (TIA) durchführen. + + Das TIA muss bewerten: + + 1. Rechtslage im Empfängerland + - Behördenzugriffsrechte (z.B. FISA 702 in USA) + - Rechtsschutz für EU-Bürger + - Datenschutzaufsicht + + 2. Praktische Anwendung der Gesetze + - Werden Zugriffsrechte tatsächlich genutzt? + - Gibt es dokumentierte Fälle? + + 3. Vertragliche Garantien + - Reichen die SCC allein aus? + - Werden zusätzliche Schutzmaßnahmen benötigt? + + 4. Zusätzliche Schutzmaßnahmen + - Technische Maßnahmen (Verschlüsselung, Pseudonymisierung) + - Organisatorische Maßnahmen + - Vertragliche Maßnahmen + legal_refs: + - "EuGH Schrems II (C-311/18)" + - "EDPB Recommendations 01/2020" + - "EDPB Recommendations 02/2020" + keywords: ["TIA", "Transfer Impact Assessment", "Schrems II"] + + supplementary_measures: + id: SCC-TIA-002 + topic: "Ergänzende Schutzmaßnahmen" + content: | + Wenn das TIA ergibt, dass SCC allein kein angemessenes Schutzniveau + gewährleisten, sind ergänzende Maßnahmen erforderlich: + + TECHNISCHE MAßNAHMEN: + - Ende-zu-Ende-Verschlüsselung (Schlüssel nur beim Exporteur) + - Pseudonymisierung (Zuordnungstabelle im EWR) + - Split Processing (sensible Teile nur im EWR) + + VERTRAGLICHE MAßNAHMEN: + - Benachrichtigung bei Behördenanfragen + - Verpflichtung zur Anfechtung rechtswidriger Anfragen + - Verbot der Schlüsselherausgabe + + ORGANISATORISCHE MAßNAHMEN: + - Strikte Zugriffskontrollen + - Dokumentation aller Zugriffe + - Regelmäßige Audits + + WICHTIG: Wenn auch mit Zusatzmaßnahmen kein angemessenes Niveau + erreichbar ist, muss der Transfer unterbleiben! + legal_refs: + - "EDPB Recommendations 01/2020 on supplementary measures" + - "DSGVO Art. 32" + keywords: ["Verschlüsselung", "Pseudonymisierung", "Zusatzmaßnahmen"] + +# ============================================================================= +# AVV vs. SCC +# ============================================================================= + +avv_scc: + + avv_definition: + id: SCC-AVV-001 + topic: "AVV vs. SCC - Unterschiede" + content: | + AVV und SCC sind VERSCHIEDENE Instrumente mit UNTERSCHIEDLICHEN Zwecken: + + AUFTRAGSVERARBEITUNGSVERTRAG (AVV): + - Rechtsgrundlage: Art. 28 DSGVO + - Zweck: Regelung der Verarbeitung durch einen Auftragsverarbeiter + - Erforderlich: Bei JEDER Auftragsverarbeitung + - Unabhängig vom Ort: Auch bei EU-internen Verarbeitungen + + STANDARDVERTRAGSKLAUSELN (SCC): + - Rechtsgrundlage: Art. 46 Abs. 2 lit. c DSGVO + - Zweck: Absicherung von Drittlandtransfers + - Erforderlich: NUR bei Übermittlung in Drittländer ohne Angemessenheit + + KOMBINATION: + Bei Drittland-Auftragsverarbeitern sind BEIDE erforderlich: + - AVV für die Auftragsverarbeitung an sich + - SCC für den Drittlandtransfer + + Die neuen SCC (2021) können beides kombinieren, indem sie sowohl + Art. 28-Anforderungen als auch Art. 46-Anforderungen erfüllen. + legal_refs: + - "DSGVO Art. 28" + - "DSGVO Art. 46" + - "EU-Kommission Durchführungsbeschluss (EU) 2021/914" + keywords: ["AVV", "SCC", "Art. 28", "Art. 46"] + +# ============================================================================= +# PRAKTISCHE UMSETZUNG +# ============================================================================= + +implementation: + + scc_checklist: + id: SCC-IMP-001 + topic: "SCC-Implementierung: Checkliste" + content: | + Checkliste für die SCC-Implementierung: + + VOR DEM TRANSFER: + □ Prüfung ob Angemessenheitsbeschluss besteht + □ Identifikation des korrekten SCC-Moduls (C2C, C2P, P2P, P2C) + □ Durchführung des Transfer Impact Assessment (TIA) + □ Dokumentation der Rechtsgrundlage + + VERTRAGSABSCHLUSS: + □ Verwendung der aktuellen SCC-Version (2021) + □ Auswahl der relevanten Module + □ Ergänzung der erforderlichen Anhänge (Annex I, II) + □ Definition der technischen/organisatorischen Maßnahmen + □ Benennung der Kontaktpersonen + + NACH DEM TRANSFER: + □ Regelmäßige Überprüfung der Rechtslage im Drittland + □ Aktualisierung des TIA bei Änderungen + □ Dokumentation aller Transfers + □ Information der Aufsichtsbehörde auf Anfrage + legal_refs: + - "DSGVO Art. 46" + - "EDPB Recommendations 01/2020" + keywords: ["Checkliste", "Implementierung", "Dokumentation"] + + scc_modules: + id: SCC-IMP-002 + topic: "SCC-Module im Überblick" + content: | + Die neuen SCC (2021) umfassen vier Module: + + MODUL 1: Controller zu Controller (C2C) + - Anwendung: Zwei unabhängige Verantwortliche + - Beispiel: Unternehmensgruppe mit gemeinsamer Kundendatenbank + + MODUL 2: Controller zu Processor (C2P) + - Anwendung: Verantwortlicher beauftragt Auftragsverarbeiter im Drittland + - Beispiel: EU-Unternehmen nutzt US-Cloud-Provider + - HÄUFIGSTER ANWENDUNGSFALL + + MODUL 3: Processor zu Processor (P2P) + - Anwendung: EU-Auftragsverarbeiter nutzt Unterauftragsverarbeiter im Drittland + - Beispiel: EU-IT-Dienstleister nutzt US-Sub-Contractor + + MODUL 4: Processor zu Controller (P2C) + - Anwendung: Drittland-Auftragsverarbeiter gibt Daten an EU-Verantwortlichen zurück + - Beispiel: Rückübermittlung nach Datenanalyse im Drittland + - SELTEN + + Die Module können kombiniert werden (Docking Clause für nachträgliche Beitritte). + legal_refs: + - "EU-Kommission Durchführungsbeschluss (EU) 2021/914" + keywords: ["Module", "C2C", "C2P", "P2P", "P2C"] + +# ============================================================================= +# BSI C5 UND SCC +# ============================================================================= + +bsi_c5: + + bsi_c5_scc_relation: + id: SCC-BSI-001 + topic: "BSI C5 Zertifizierung und SCC" + content: | + Eine BSI C5 Zertifizierung ist KEIN Ersatz für SCC! + + BSI C5 belegt: + - Technische Sicherheit des Cloud-Dienstes + - Organisatorische Maßnahmen + - Compliance mit deutschen Sicherheitsstandards + + BSI C5 belegt NICHT: + - Rechtliche Grundlage für Drittlandtransfers + - Angemessenes Datenschutzniveau im Drittland + - Schutz vor Behördenzugriffen + + Konsequenz: + Auch ein BSI C5-zertifizierter Cloud-Provider benötigt SCC, wenn: + - Daten in Drittländer übermittelt werden + - Support/Zugriff aus Drittländern erfolgt + - Unterauftragsverarbeiter im Drittland eingesetzt werden + legal_refs: + - "BSI C5:2020" + - "DSGVO Art. 46" + keywords: ["BSI C5", "Zertifizierung", "Cloud"] + +# ============================================================================= +# ENTSCHEIDUNGSMATRIX +# ============================================================================= + +decision_matrix: + + transfer_decision: + id: SCC-DEC-001 + topic: "Entscheidungsmatrix: SCC erforderlich?" + content: | + ENTSCHEIDUNGSBAUM: + + 1. Werden personenbezogene Daten verarbeitet? + → Nein: Keine SCC erforderlich (DSGVO nicht anwendbar) + → Ja: Weiter zu 2. + + 2. Werden Daten außerhalb des EWR verarbeitet oder ist Zugriff möglich? + → Nein (rein lokale Verarbeitung im EWR): Keine SCC (nur AVV) + → Ja: Weiter zu 3. + + 3. Besteht ein Angemessenheitsbeschluss für das Drittland? + → Ja: Keine SCC erforderlich + → Nein: Weiter zu 4. + + 4. Ist der Empfänger DPF-zertifiziert (bei USA)? + → Ja: Keine SCC für diesen Empfänger + → Nein: SCC ERFORDERLICH + + 5. Bei SCC-Erforderlichkeit: + → TIA durchführen + → Ggf. ergänzende Maßnahmen implementieren + → Wenn angemessenes Niveau nicht erreichbar: Transfer NICHT zulässig + legal_refs: + - "DSGVO Art. 44-49" + keywords: ["Entscheidungsbaum", "Prüfschema"] + +# ============================================================================= +# KEYWORDS FÜR RAG RETRIEVAL +# ============================================================================= + +rag_keywords: + primary: + - "SCC" + - "Standardvertragsklauseln" + - "Standard Contractual Clauses" + - "Drittlandtransfer" + - "Drittland" + - "Art. 44" + - "Art. 46" + - "Transfer" + - "Datenübermittlung" + - "EU-Kommission" + - "TIA" + - "Transfer Impact Assessment" + - "Schrems II" + - "Angemessenheitsbeschluss" + - "adequacy" + + secondary: + - "USA" + - "Cloud" + - "AWS" + - "Azure" + - "GCP" + - "FISA" + - "DPF" + - "Data Privacy Framework" + - "BCR" + - "Binding Corporate Rules" + - "Auftragsverarbeitung" + - "AVV" + - "Unterauftragsverarbeiter" + - "Subprocessor" diff --git a/ai-compliance-sdk/policies/ucca_policy_v1.yaml b/ai-compliance-sdk/policies/ucca_policy_v1.yaml new file mode 100644 index 0000000..aadce89 --- /dev/null +++ b/ai-compliance-sdk/policies/ucca_policy_v1.yaml @@ -0,0 +1,1144 @@ +# ============================================================================= +# Breakpilot Use-Case Compliance & Feasibility Policy +# Version: 1.0 +# Jurisdiction: EU (DSGVO + AI Act) +# ============================================================================= +# +# GRUNDSATZ: +# - Diese Regeln sind DETERMINISTISCH und laufen OHNE LLM +# - LLM darf Hard-Blocks NICHT überschreiben +# - Bei Unsicherheit → Escalation an DSB +# +# SEVERITY LEVELS: +# INFO = Hinweis, kein Risiko +# WARN = Risiko, aber lösbar +# BLOCK = Nicht zulässig ohne grundlegende Änderung +# +# ============================================================================= + +policy: + name: Breakpilot Use-Case Compliance & Feasibility Policy + version: "1.0.0" + jurisdiction: EU + basis: + - GDPR + - AI_Act + - BDSG + default_feasibility: YES + default_risk_score: 0 + +# ============================================================================= +# Schwellenwerte +# ============================================================================= + +thresholds: + risk: + minimal: 0 # 0-19 + low: 20 # 20-39 + medium: 40 # 40-59 + high: 60 # 60-79 + unacceptable: 80 # 80+ + + escalation: + - art9_data + - minor_data_with_profiling + - fully_automated_decisions + - conflicting_evidence + +# ============================================================================= +# Architektur-Patterns (Lösungsvorschläge) +# ============================================================================= + +patterns: + P_RAG_ONLY: + id: P_RAG_ONLY + title: "RAG statt Training" + description: "Retrieval-Augmented Generation ohne Modell-Training" + benefit: "Daten bleiben in Vektordatenbank, fließen nicht ins Modell" + effort: low + risk_reduction: 20 + + P_PRE_ANON: + id: P_PRE_ANON + title: "Vor-Anonymisierung" + description: "Irreversible Anonymisierung vor Speicherung/Verarbeitung" + benefit: "Keine personenbezogenen Daten mehr → DSGVO nicht anwendbar" + effort: medium + risk_reduction: 30 + + P_PIXELIZATION: + id: P_PIXELIZATION + title: "Verpixelung/Unkenntlichmachung" + description: "Identifizierende Merkmale automatisch unkenntlich machen" + benefit: "Gesichter, Kennzeichen etc. nicht mehr erkennbar" + effort: medium + risk_reduction: 25 + + P_HITL_ENFORCED: + id: P_HITL_ENFORCED + title: "Human-in-the-Loop erzwingen" + description: "Technisch erzwungene menschliche Überprüfung" + benefit: "Keine vollautomatisierten Entscheidungen → Art. 22 nicht anwendbar" + effort: low + risk_reduction: 20 + + P_RETENTION_CAP: + id: P_RETENTION_CAP + title: "Kurze Aufbewahrung" + description: "Automatische Löschung nach max. 72 Stunden" + benefit: "Minimiert Risiko durch kurze Speicherdauer" + effort: low + risk_reduction: 10 + + P_NAMESPACE_ISOLATION: + id: P_NAMESPACE_ISOLATION + title: "Mandantentrennung" + description: "Strikte Trennung der Daten nach Mandanten" + benefit: "Kein Datenzugriff zwischen Mandanten möglich" + effort: medium + risk_reduction: 15 + + P_CONSENT_FLOW: + id: P_CONSENT_FLOW + title: "Einwilligungs-Workflow" + description: "Implementierung eines rechtskonformen Einwilligungsprozesses" + benefit: "Gültige Rechtsgrundlage nach Art. 6(1)(a) / Art. 9(2)(a)" + effort: medium + risk_reduction: 20 + + P_EU_HOSTING: + id: P_EU_HOSTING + title: "EU-Hosting" + description: "Wechsel zu einem Anbieter mit EU-Rechenzentren" + benefit: "Kein Drittlandtransfer → Art. 44ff nicht anwendbar" + effort: medium + risk_reduction: 15 + +# ============================================================================= +# Erforderliche Kontrollen +# ============================================================================= + +controls: + C_EXPLICIT_CONSENT: + id: C_EXPLICIT_CONSENT + title: "Ausdrückliche Einwilligung" + description: "Opt-in mit klarer Information über Verarbeitungszweck" + gdpr_ref: "Art. 6(1)(a), Art. 9(2)(a) DSGVO" + effort: medium + + C_PARENTAL_CONSENT: + id: C_PARENTAL_CONSENT + title: "Einwilligung der Erziehungsberechtigten" + description: "Bei Minderjährigen unter 16: Elterneinwilligung erforderlich" + gdpr_ref: "Art. 8 DSGVO" + effort: high + + C_DSFA: + id: C_DSFA + title: "Datenschutz-Folgenabschätzung" + description: "Formale DSFA nach Art. 35 DSGVO durchführen" + gdpr_ref: "Art. 35 DSGVO" + effort: high + + C_TRANSPARENCY: + id: C_TRANSPARENCY + title: "KI-Transparenz-Hinweis" + description: "Nutzer informieren dass sie mit KI interagieren" + gdpr_ref: "Art. 13/14 DSGVO, Art. 52 AI Act" + effort: low + + C_CONTESTATION: + id: C_CONTESTATION + title: "Anfechtungsrecht" + description: "Betroffene können automatisierte Entscheidungen anfechten" + gdpr_ref: "Art. 22(3) DSGVO" + effort: medium + + C_ACCESS_LOGGING: + id: C_ACCESS_LOGGING + title: "Zugriffs-Protokollierung" + description: "Alle Datenzugriffe revisionssicher protokollieren" + gdpr_ref: "Art. 5(2) DSGVO" + effort: low + + C_ENCRYPTION: + id: C_ENCRYPTION + title: "Verschlüsselung" + description: "Daten bei Übertragung und Speicherung verschlüsseln" + gdpr_ref: "Art. 32 DSGVO" + effort: low + + C_RETENTION_POLICY: + id: C_RETENTION_POLICY + title: "Löschkonzept" + description: "Definierte Aufbewahrungsfristen mit automatischer Löschung" + gdpr_ref: "Art. 5(1)(e) DSGVO" + effort: medium + + C_SCC: + id: C_SCC + title: "Standardvertragsklauseln" + description: "EU-SCC mit Drittland-Anbieter abschließen" + gdpr_ref: "Art. 46(2)(c) DSGVO" + effort: medium + + C_TIA: + id: C_TIA + title: "Transfer Impact Assessment" + description: "Bewertung der Risiken bei Drittlandtransfer" + gdpr_ref: "Schrems II Urteil" + effort: high + + C_SCC_NEW: + id: C_SCC_NEW + title: "Neue Standardvertragsklauseln (2021)" + description: "Verwendung der aktuellen SCC-Version seit Juni 2021" + gdpr_ref: "Art. 46(2)(c) DSGVO, EU 2021/914" + effort: medium + when_applicable: "Drittlandtransfer ohne Angemessenheitsbeschluss" + what_to_do: | + 1. Korrektes SCC-Modul wählen (C2C, C2P, P2P, P2C) + 2. Annex I (Datenexporteur/Importeur) ausfüllen + 3. Annex II (Technische Maßnahmen) dokumentieren + 4. Von beiden Parteien unterzeichnen lassen + evidence_needed: + - "Unterzeichnete SCC (PDF)" + - "Ausgefüllte Annexe I und II" + - "Dokumentation des gewählten Moduls" + + C_SCC_DPF_CHECK: + id: C_SCC_DPF_CHECK + title: "DPF-Zertifizierungsprüfung" + description: "Prüfung ob US-Anbieter unter Data Privacy Framework zertifiziert ist" + gdpr_ref: "EU-US Data Privacy Framework Beschluss 2023" + effort: low + when_applicable: "Übermittlung an US-Empfänger" + what_to_do: | + 1. DPF-Liste auf dataprivacyframework.gov prüfen + 2. Zertifizierungsstatus dokumentieren + 3. Bei Zertifizierung: SCC optional (aber empfohlen als Backup) + 4. Ohne Zertifizierung: SCC zwingend erforderlich + evidence_needed: + - "Screenshot DPF-Zertifizierungsstatus" + - "Dokumentation Prüfdatum" + + C_SUBPROCESSOR_SCC: + id: C_SUBPROCESSOR_SCC + title: "SCC für Unterauftragsverarbeiter" + description: "SCC-Kette zu Unterauftragsverarbeitern im Drittland" + gdpr_ref: "Art. 28(4), Art. 46 DSGVO" + effort: medium + when_applicable: "Subprozessoren im Drittland" + what_to_do: | + 1. Liste aller Subprozessoren mit Standorten einholen + 2. Für jeden Drittland-Subprozessor: SCC oder Angemessenheit prüfen + 3. Vertragliche Weitergabe der Pflichten sicherstellen + evidence_needed: + - "Subprocessor-Liste mit Standorten" + - "SCC-Nachweis für Drittland-Subprozessoren" + + C_TECHNICAL_SUPPLEMENTARY: + id: C_TECHNICAL_SUPPLEMENTARY + title: "Technische Zusatzmaßnahmen" + description: "Ergänzende technische Schutzmaßnahmen bei Drittlandtransfer" + gdpr_ref: "EDPB Recommendations 01/2020" + effort: high + when_applicable: "TIA zeigt unzureichendes Schutzniveau" + what_to_do: | + 1. Ende-zu-Ende-Verschlüsselung implementieren (Schlüssel nur bei Exporteur) + 2. Pseudonymisierung mit Mapping-Tabelle im EWR + 3. Logging aller Drittland-Zugriffe + evidence_needed: + - "Verschlüsselungskonzept" + - "Pseudonymisierungskonzept" + - "Zugriffsprotokollierung" + +# ============================================================================= +# REGELN - Kategorie A: Datenklassifikation +# ============================================================================= + +rules: + + # --------------------------------------------------------------------------- + # A. Datenklassifikation + # --------------------------------------------------------------------------- + + - id: R-A001 + category: "A. Datenklassifikation" + title: "Personenbezogene Daten vorhanden" + description: "Es werden personenbezogene Daten verarbeitet" + condition: + field: data_types.personal_data + operator: equals + value: true + effect: + risk_add: 10 + controls_add: [C_TRANSPARENCY] + severity: INFO + gdpr_ref: "Art. 4(1), Art. 6 DSGVO" + rationale: "Personenbezogene Daten erfordern Rechtsgrundlage nach Art. 6" + + - id: R-A002 + category: "A. Datenklassifikation" + title: "Besondere Kategorien (Art. 9)" + description: "Gesundheitsdaten, Religion, politische Meinung etc." + condition: + field: data_types.article_9_data + operator: equals + value: true + effect: + risk_add: 25 + feasibility: CONDITIONAL + controls_add: [C_EXPLICIT_CONSENT, C_DSFA, C_ENCRYPTION] + escalation: true + severity: WARN + gdpr_ref: "Art. 9 DSGVO" + rationale: "Besondere Kategorien sind grundsätzlich verboten, Ausnahmen nur nach Art. 9(2)" + + - id: R-A003 + category: "A. Datenklassifikation" + title: "Daten von Minderjährigen" + description: "Es werden Daten von Personen unter 18 Jahren verarbeitet" + condition: + field: data_types.minor_data + operator: equals + value: true + effect: + risk_add: 20 + training_allowed: false + controls_add: [C_PARENTAL_CONSENT, C_DSFA] + severity: WARN + gdpr_ref: "Art. 8 DSGVO, ErwGr. 38" + rationale: "Kinder verdienen besonderen Schutz" + + - id: R-A004 + category: "A. Datenklassifikation" + title: "KFZ-Kennzeichen" + description: "Fahrzeugkennzeichen werden verarbeitet" + condition: + field: data_types.license_plates + operator: equals + value: true + effect: + risk_add: 15 + controls_add: [C_RETENTION_POLICY] + suggested_patterns: [P_PIXELIZATION, P_PRE_ANON] + severity: WARN + gdpr_ref: "Art. 4(1) DSGVO" + rationale: "Kennzeichen ermöglichen Identifikation des Halters" + + - id: R-A005 + category: "A. Datenklassifikation" + title: "Biometrische Daten" + description: "Fingerabdrücke, Gesichtserkennung oder andere biometrische Merkmale" + condition: + field: data_types.biometric_data + operator: equals + value: true + effect: + risk_add: 30 + feasibility: CONDITIONAL + controls_add: [C_EXPLICIT_CONSENT, C_DSFA, C_ENCRYPTION, C_ACCESS_LOGGING] + suggested_patterns: [P_PIXELIZATION] + escalation: true + severity: WARN + gdpr_ref: "Art. 9(1), Art. 4(14) DSGVO" + rationale: "Biometrische Daten zur Identifikation sind besondere Kategorien" + + - id: R-A006 + category: "A. Datenklassifikation" + title: "Standortdaten" + description: "GPS, Bewegungsprofile oder Aufenthaltsorte" + condition: + field: data_types.location_data + operator: equals + value: true + effect: + risk_add: 15 + controls_add: [C_EXPLICIT_CONSENT, C_RETENTION_POLICY] + severity: WARN + gdpr_ref: "Art. 4(1) DSGVO, ErwGr. 75" + rationale: "Standortdaten ermöglichen detaillierte Bewegungsprofile" + + - id: R-A007 + category: "A. Datenklassifikation" + title: "Nur öffentliche/anonyme Daten" + description: "Keine personenbezogenen Daten" + condition: + field: data_types.public_data + operator: equals + value: true + effect: + risk_add: 0 + severity: INFO + gdpr_ref: "ErwGr. 26 DSGVO" + rationale: "Anonyme Daten fallen nicht unter die DSGVO" + + # --------------------------------------------------------------------------- + # B. Zweck & Rechtsgrundlage + # --------------------------------------------------------------------------- + + - id: R-B001 + category: "B. Zweck & Rechtsgrundlage" + title: "Kundenservice" + description: "Verarbeitung zum Zweck des Kundenservice" + condition: + field: purpose.customer_support + operator: equals + value: true + effect: + risk_add: 5 + legal_basis: "Art. 6(1)(b) DSGVO" + severity: INFO + rationale: "Kundenservice kann auf Vertragserfüllung gestützt werden" + + - id: R-B002 + category: "B. Zweck & Rechtsgrundlage" + title: "Bewertung/Scoring von Personen" + description: "Personen werden bewertet, eingestuft oder gerankt" + condition: + field: purpose.evaluation_scoring + operator: equals + value: true + effect: + risk_add: 20 + controls_add: [C_TRANSPARENCY, C_CONTESTATION, C_DSFA] + severity: WARN + gdpr_ref: "Art. 22, § 31 BDSG" + rationale: "Scoring erfordert besondere Transparenz und Anfechtbarkeit" + + - id: R-B003 + category: "B. Zweck & Rechtsgrundlage" + title: "Profiling" + description: "Automatisierte Analyse persönlicher Aspekte" + condition: + field: purpose.profiling + operator: equals + value: true + effect: + risk_add: 20 + controls_add: [C_TRANSPARENCY, C_DSFA] + severity: WARN + gdpr_ref: "Art. 4(4), Art. 22 DSGVO" + rationale: "Profiling erfordert erhöhte Transparenz" + + - id: R-B004 + category: "B. Zweck & Rechtsgrundlage" + title: "Marketing mit personenbezogenen Daten" + description: "Werbung unter Verwendung personenbezogener Daten" + condition: + all_of: + - field: purpose.marketing + operator: equals + value: true + - field: data_types.personal_data + operator: equals + value: true + effect: + risk_add: 10 + feasibility: CONDITIONAL + controls_add: [C_EXPLICIT_CONSENT] + severity: WARN + gdpr_ref: "Art. 6(1)(a), § 7 UWG" + rationale: "E-Mail-Marketing erfordert i.d.R. Einwilligung" + + # --------------------------------------------------------------------------- + # C. Automatisierung & Entscheidungen + # --------------------------------------------------------------------------- + + - id: R-C001 + category: "C. Automatisierung" + title: "Assistive KI-Nutzung" + description: "KI macht Vorschläge, Mensch entscheidet" + condition: + field: automation + operator: equals + value: assistive + effect: + risk_add: 0 + severity: INFO + rationale: "Human-in-the-loop garantiert → geringes Risiko" + + - id: R-C002 + category: "C. Automatisierung" + title: "Teilautomatisierung" + description: "KI trifft Vorentscheidungen, Mensch prüft" + condition: + field: automation + operator: equals + value: semi_automated + effect: + risk_add: 10 + controls_add: [C_TRANSPARENCY] + severity: INFO + rationale: "Human-on-the-loop reduziert Risiko" + + - id: R-C003 + category: "C. Automatisierung" + title: "Vollautomatisierte Verarbeitung" + description: "KI entscheidet ohne menschliche Prüfung" + condition: + field: automation + operator: equals + value: fully_automated + effect: + risk_add: 25 + art22_risk: true + controls_add: [C_CONTESTATION, C_TRANSPARENCY] + suggested_patterns: [P_HITL_ENFORCED] + severity: WARN + gdpr_ref: "Art. 22 DSGVO" + rationale: "Vollautomatisierte Entscheidungen sind nach Art. 22 grundsätzlich verboten" + + - id: R-C004 + category: "C. Automatisierung" + title: "Entscheidungen mit rechtlicher Wirkung" + description: "Automatisierte Entscheidungen mit rechtlichen Konsequenzen" + condition: + all_of: + - field: automation + operator: equals + value: fully_automated + - field: outputs.legal_effects + operator: equals + value: true + effect: + feasibility: NO + severity: BLOCK + gdpr_ref: "Art. 22(1) DSGVO" + rationale: "Vollautomatisierte Entscheidungen mit rechtlicher Wirkung sind verboten" + + - id: R-C005 + category: "C. Automatisierung" + title: "HR-Kontext mit vollautomatisiertem Scoring" + description: "Mitarbeiterbewertung durch vollautomatisierte KI" + condition: + all_of: + - field: domain + operator: in + value: [hr, recruiting] + - field: purpose.evaluation_scoring + operator: equals + value: true + - field: automation + operator: equals + value: fully_automated + effect: + feasibility: NO + severity: BLOCK + gdpr_ref: "Art. 22 DSGVO, § 26 BDSG" + rationale: "Vollautomatisiertes HR-Scoring ist unzulässig" + + # --------------------------------------------------------------------------- + # D. Training & Modell-Nutzung + # --------------------------------------------------------------------------- + + - id: R-D001 + category: "D. Training & Modell" + title: "RAG ohne Training" + description: "Nur Dokumentensuche, kein Modell-Training" + condition: + all_of: + - field: model_usage.rag + operator: equals + value: true + - field: model_usage.training + operator: equals + value: false + effect: + risk_add: 0 + suggested_patterns: [P_RAG_ONLY] + severity: INFO + rationale: "RAG ist datenschutzfreundlich - Daten fließen nicht ins Modell" + + - id: R-D002 + category: "D. Training & Modell" + title: "Training mit personenbezogenen Daten" + description: "KI-Training unter Verwendung personenbezogener Daten" + condition: + all_of: + - field: model_usage.training + operator: equals + value: true + - field: data_types.personal_data + operator: equals + value: true + effect: + risk_add: 25 + feasibility: CONDITIONAL + training_allowed: false + suggested_patterns: [P_RAG_ONLY, P_PRE_ANON] + controls_add: [C_EXPLICIT_CONSENT, C_DSFA] + severity: WARN + gdpr_ref: "Art. 5(1)(b), Art. 6 DSGVO" + rationale: "Training ist eigenständiger Verarbeitungszweck" + + - id: R-D003 + category: "D. Training & Modell" + title: "Training mit Daten Minderjähriger" + description: "KI-Training mit Daten von Kindern/Jugendlichen" + condition: + all_of: + - field: model_usage.training + operator: equals + value: true + - field: data_types.minor_data + operator: equals + value: true + effect: + feasibility: NO + severity: BLOCK + gdpr_ref: "Art. 8 DSGVO" + rationale: "Training mit Daten Minderjähriger ist nicht zulässig" + + - id: R-D004 + category: "D. Training & Modell" + title: "Fine-Tuning mit Kundengesprächen" + description: "Modell-Anpassung mit realen Kundeninteraktionen" + condition: + all_of: + - field: model_usage.finetune + operator: equals + value: true + - field: data_types.customer_data + operator: equals + value: true + effect: + risk_add: 15 + feasibility: CONDITIONAL + suggested_patterns: [P_PRE_ANON, P_RAG_ONLY] + controls_add: [C_EXPLICIT_CONSENT] + severity: WARN + rationale: "Fine-Tuning nur mit anonymisierten Daten oder Einwilligung" + + # --------------------------------------------------------------------------- + # E. Hosting & Drittlandtransfer + # --------------------------------------------------------------------------- + + - id: R-E001 + category: "E. Hosting" + title: "EU-Hosting" + description: "Daten werden in der EU/EWR verarbeitet" + condition: + field: hosting.region + operator: equals + value: eu + effect: + risk_add: 0 + severity: INFO + rationale: "EU-Hosting vermeidet Drittlandtransfer-Problematik" + + - id: R-E002 + category: "E. Hosting" + title: "Drittland-Hosting" + description: "Daten werden außerhalb der EU verarbeitet" + condition: + field: hosting.region + operator: equals + value: third_country + effect: + risk_add: 15 + controls_add: [C_SCC, C_TIA, C_ENCRYPTION] + suggested_patterns: [P_EU_HOSTING] + severity: WARN + gdpr_ref: "Art. 44ff DSGVO" + rationale: "Drittlandtransfer erfordert zusätzliche Garantien" + + - id: R-E003 + category: "E. Hosting" + title: "Drittland mit sensiblen Daten" + description: "Sensible Daten außerhalb der EU" + condition: + all_of: + - field: hosting.region + operator: equals + value: third_country + - any_of: + - field: data_types.article_9_data + operator: equals + value: true + - field: data_types.biometric_data + operator: equals + value: true + - field: data_types.minor_data + operator: equals + value: true + effect: + risk_add: 25 + feasibility: CONDITIONAL + escalation: true + suggested_patterns: [P_EU_HOSTING] + severity: WARN + gdpr_ref: "Art. 44, Art. 9 DSGVO" + rationale: "Sensible Daten im Drittland erfordern umfangreiche Schutzmaßnahmen" + + # --------------------------------------------------------------------------- + # E2. Standardvertragsklauseln (SCC) + # --------------------------------------------------------------------------- + + - id: R-E004 + category: "E. Transfer" + title: "SCC vorhanden bei Drittlandtransfer" + description: "Standardvertragsklauseln mit Drittland-Provider abgeschlossen" + condition: + all_of: + - field: provider.location + operator: in + value: [us, non_eu, third_country] + - field: contracts.scc.present + operator: equals + value: true + effect: + risk_add: 5 + controls_add: [C_TIA] + severity: INFO + gdpr_ref: "Art. 46(2)(c) DSGVO" + rationale: "SCC bieten rechtliche Grundlage, TIA zur Validierung erforderlich" + + - id: R-E005 + category: "E. Transfer" + title: "Alte SCC-Version verwendet" + description: "SCC vorhanden, aber nicht die aktuelle Version (2021)" + condition: + all_of: + - field: contracts.scc.present + operator: equals + value: true + - field: contracts.scc.version + operator: not_equals + value: new_scc_2021 + effect: + risk_add: 10 + controls_add: [C_SCC_NEW] + flags: ["SCC_VERSION_OUTDATED"] + severity: WARN + gdpr_ref: "EU 2021/914" + rationale: "Alte SCC-Versionen sind seit Ende 2022 nicht mehr gültig" + + - id: R-E006 + category: "E. Transfer" + title: "USA-Transfer mit DPF-Zertifizierung" + description: "US-Provider ist unter Data Privacy Framework zertifiziert" + condition: + all_of: + - field: provider.location + operator: equals + value: us + - field: provider.dpf_certified + operator: equals + value: true + effect: + risk_add: 0 + severity: INFO + gdpr_ref: "EU-US DPF Beschluss 2023" + rationale: "DPF-Zertifizierung ermöglicht Transfer ohne zusätzliche SCC" + + - id: R-E007 + category: "E. Transfer" + title: "USA-Transfer ohne DPF-Zertifizierung" + description: "US-Provider ist NICHT unter DPF zertifiziert" + condition: + all_of: + - field: provider.location + operator: equals + value: us + - field: provider.dpf_certified + operator: equals + value: false + effect: + risk_add: 15 + controls_add: [C_SCC, C_TIA, C_SCC_DPF_CHECK] + flags: ["US_NO_DPF"] + severity: WARN + gdpr_ref: "DSGVO Art. 44ff, Schrems II" + rationale: "Ohne DPF sind SCC + TIA zwingend erforderlich" + + - id: R-E008 + category: "E. Transfer" + title: "Lokales Hosting ohne Drittlandzugriff" + description: "Vollständig lokale Verarbeitung im EWR ohne externen Zugriff" + condition: + all_of: + - field: hosting.type + operator: equals + value: on_premises + - field: hosting.region + operator: equals + value: eu + - field: provider.remote_access + operator: equals + value: false + effect: + risk_add: 0 + flags: ["NO_TRANSFER_REQUIRED"] + severity: INFO + rationale: "Rein lokale Verarbeitung erfordert keine SCC" + + - id: R-E009 + category: "E. Transfer" + title: "Support-Zugriff aus Drittland" + description: "Provider-Support kann von Drittland aus auf Daten zugreifen" + condition: + all_of: + - field: provider.support_location + operator: in + value: [us, non_eu, third_country, global] + - field: data_types.personal_data + operator: equals + value: true + effect: + risk_add: 10 + controls_add: [C_SCC, C_TIA] + flags: ["SUPPORT_TRANSFER_RISK"] + severity: WARN + gdpr_ref: "Art. 44 DSGVO" + rationale: "Remote-Zugriff auf EU-Daten von Drittland = Transfer" + + - id: R-E010 + category: "E. Transfer" + title: "Unterauftragsverarbeiter im Drittland" + description: "Provider nutzt Subprozessoren außerhalb des EWR" + condition: + all_of: + - field: provider.subprocessors.third_country + operator: equals + value: true + effect: + risk_add: 10 + controls_add: [C_SUBPROCESSOR_SCC, C_TIA] + flags: ["SUBPROCESSOR_TRANSFER"] + severity: WARN + gdpr_ref: "Art. 28(4) DSGVO" + rationale: "SCC-Kette zu Drittland-Subprozessoren erforderlich" + + - id: R-E011 + category: "E. Transfer" + title: "TIA zeigt unzureichendes Schutzniveau" + description: "Transfer Impact Assessment ergibt Defizite" + condition: + all_of: + - field: contracts.tia.present + operator: equals + value: true + - field: contracts.tia.result + operator: equals + value: inadequate + effect: + risk_add: 20 + controls_add: [C_TECHNICAL_SUPPLEMENTARY] + flags: ["TIA_INADEQUATE"] + escalation: true + severity: WARN + gdpr_ref: "EDPB Recommendations 01/2020" + rationale: "Zusätzliche technische Maßnahmen erforderlich" + + - id: R-E012 + category: "E. Transfer" + title: "TIA zeigt Transfer nicht möglich" + description: "Transfer Impact Assessment ergibt: kein angemessenes Niveau erreichbar" + condition: + all_of: + - field: contracts.tia.present + operator: equals + value: true + - field: contracts.tia.result + operator: equals + value: not_feasible + effect: + feasibility: NO + flags: ["TRANSFER_BLOCKED"] + severity: BLOCK + gdpr_ref: "Art. 44 DSGVO" + rationale: "Transfer ohne angemessenes Schutzniveau ist unzulässig" + + # --------------------------------------------------------------------------- + # F. Domain-spezifische Regeln + # --------------------------------------------------------------------------- + + - id: R-F001 + category: "F. Domain-spezifisch" + title: "Bildung + automatisiertes Scoring" + description: "Automatisierte Bewertung von Schülern/Studenten" + condition: + all_of: + - field: domain + operator: in + value: [education, higher_education, vocational_training] + - field: purpose.evaluation_scoring + operator: equals + value: true + - field: automation + operator: equals + value: fully_automated + effect: + feasibility: NO + severity: BLOCK + rationale: "Vollautomatisiertes Scoring im Bildungsbereich nicht zulässig" + + - id: R-F002 + category: "F. Domain-spezifisch" + title: "Stadtwerke Chatbot (Standard)" + description: "Kundenservice-Chatbot für Versorgungsunternehmen" + condition: + all_of: + - field: domain + operator: equals + value: utilities + - field: purpose.customer_support + operator: equals + value: true + - field: model_usage.rag + operator: equals + value: true + - field: model_usage.training + operator: equals + value: false + effect: + feasibility: YES + risk_add: 0 + severity: INFO + rationale: "Standard-Anwendungsfall mit geringem Risiko" + + - id: R-F003 + category: "F. Domain-spezifisch" + title: "Parkhaus mit Kennzeichen-Training" + description: "KI-Training mit KFZ-Kennzeichen" + condition: + all_of: + - field: domain + operator: equals + value: real_estate + - field: data_types.license_plates + operator: equals + value: true + - field: model_usage.training + operator: equals + value: true + effect: + feasibility: NO + suggested_patterns: [P_PIXELIZATION] + severity: BLOCK + rationale: "Training mit Kennzeichen ohne Einwilligung nicht zulässig" + + - id: R-F004 + category: "F. Domain-spezifisch" + title: "Gesundheitswesen mit Art.9-Daten" + description: "KI im Gesundheitsbereich mit Patientendaten" + condition: + all_of: + - field: domain + operator: in + value: [healthcare, medical_devices, pharma, elderly_care] + - field: data_types.article_9_data + operator: equals + value: true + effect: + risk_add: 20 + feasibility: CONDITIONAL + controls_add: [C_DSFA, C_ENCRYPTION, C_ACCESS_LOGGING] + escalation: true + severity: WARN + gdpr_ref: "Art. 9(2)(h) DSGVO" + rationale: "Gesundheitsdaten nur mit besonderen Schutzmaßnahmen" + + # --------------------------------------------------------------------------- + # G. Aggregation & Ergebnis + # --------------------------------------------------------------------------- + + - id: R-G001 + category: "G. Aggregation" + title: "BLOCK-Regel greift" + description: "Mindestens eine blockierende Regel wurde ausgelöst" + condition: + aggregate: any_block + effect: + feasibility: NO + severity: BLOCK + rationale: "Hard-Block kann nicht überschrieben werden" + + - id: R-G002 + category: "G. Aggregation" + title: "Hoher Risikoscore" + description: "Aggregierter Risikoscore über Schwellenwert" + condition: + aggregate: risk_score_gte + value: 60 + effect: + feasibility: CONDITIONAL + controls_add: [C_DSFA] + escalation: true + severity: WARN + rationale: "Hohe Risiko-Aggregation erfordert DSFA" + + - id: R-G003 + category: "G. Aggregation" + title: "Nur INFO-Regeln" + description: "Keine WARN oder BLOCK Regeln ausgelöst" + condition: + aggregate: only_info + effect: + feasibility: YES + severity: INFO + rationale: "Geringes Risiko bei nur informativen Regeln" + +# ============================================================================= +# Problem-Lösungs-Mappings (für LLM-Prompt-Generierung) +# ============================================================================= + +problem_solutions: + + - problem_id: "license_plates_no_consent" + title: "KFZ-Kennzeichen ohne Rechtsgrundlage" + triggers: + - rule: R-A004 + without_control: C_EXPLICIT_CONSENT + solutions: + - id: "pixelize" + title: "Kennzeichen verpixeln" + pattern: P_PIXELIZATION + removes_problem: true + team_question: "Funktioniert das Projekt auch mit verpixelten (nicht lesbaren) Kennzeichen?" + - id: "consent" + title: "Einwilligung einholen" + control: C_EXPLICIT_CONSENT + removes_problem: true + team_question: "Können Sie die Einwilligung aller Fahrzeughalter sicherstellen?" + + - problem_id: "biometrics_no_consent" + title: "Biometrische Daten ohne Einwilligung" + triggers: + - rule: R-A005 + without_control: C_EXPLICIT_CONSENT + solutions: + - id: "anonymize_faces" + title: "Gesichter anonymisieren" + pattern: P_PIXELIZATION + removes_problem: true + team_question: "Funktioniert das Projekt auch ohne erkennbare Gesichter?" + - id: "explicit_consent" + title: "Ausdrückliche Einwilligung" + control: C_EXPLICIT_CONSENT + removes_problem: true + team_question: "Können Sie eine ausdrückliche Einwilligung aller Betroffenen sicherstellen?" + + - problem_id: "training_with_pii" + title: "KI-Training mit personenbezogenen Daten" + triggers: + - rule: R-D002 + solutions: + - id: "use_rag" + title: "RAG statt Training" + pattern: P_RAG_ONLY + removes_problem: true + team_question: "Reicht es, wenn die KI Ihre Dokumente durchsuchen kann statt daraus zu lernen?" + - id: "anonymize_first" + title: "Trainingsdaten anonymisieren" + pattern: P_PRE_ANON + removes_problem: true + team_question: "Können die Daten vor dem Training vollständig anonymisiert werden?" + + - problem_id: "fully_automated_decisions" + title: "Vollautomatisierte Entscheidungen" + triggers: + - rule: R-C003 + solutions: + - id: "add_hitl" + title: "Menschliche Überprüfung einführen" + pattern: P_HITL_ENFORCED + removes_problem: true + team_question: "Kann ein Mensch jede Entscheidung vor Wirksamkeit prüfen?" + + - problem_id: "third_country_no_scc" + title: "Drittlandtransfer ohne SCC" + triggers: + - rule: R-E002 + without_control: C_SCC + solutions: + - id: "add_scc" + title: "SCC mit Provider abschließen" + control: C_SCC_NEW + removes_problem: true + team_question: "Kann der Provider die neuen EU-Standardvertragsklauseln unterzeichnen?" + - id: "switch_eu" + title: "Zu EU-Anbieter wechseln" + pattern: P_EU_HOSTING + removes_problem: true + team_question: "Gibt es eine EU-Alternative für diesen Dienst?" + - id: "local_hosting" + title: "Lokales Hosting nutzen" + removes_problem: true + team_question: "Kann die Verarbeitung vollständig lokal im EWR erfolgen?" + + - problem_id: "us_provider_no_dpf" + title: "US-Provider ohne DPF-Zertifizierung" + triggers: + - rule: R-E007 + solutions: + - id: "check_dpf" + title: "DPF-Status prüfen" + control: C_SCC_DPF_CHECK + removes_problem: false + team_question: "Wurde geprüft ob der Provider mittlerweile DPF-zertifiziert ist?" + - id: "add_scc_tia" + title: "SCC + TIA implementieren" + controls: [C_SCC_NEW, C_TIA] + removes_problem: true + team_question: "Hat der Provider die neuen SCC unterzeichnet und wurde ein TIA durchgeführt?" + + - problem_id: "outdated_scc" + title: "Veraltete SCC-Version" + triggers: + - rule: R-E005 + solutions: + - id: "update_scc" + title: "SCC auf Version 2021 aktualisieren" + control: C_SCC_NEW + removes_problem: true + team_question: "Kann der bestehende SCC-Vertrag auf die neue Version aktualisiert werden?" + + - problem_id: "support_from_third_country" + title: "Support-Zugriff aus Drittland" + triggers: + - rule: R-E009 + solutions: + - id: "restrict_access" + title: "Zugriff auf EU-Support beschränken" + removes_problem: true + team_question: "Kann der Support auf EU-basiertes Personal beschränkt werden?" + - id: "add_scc_support" + title: "SCC für Support-Zugriffe" + control: C_SCC + removes_problem: true + team_question: "Können SCC den Support-Zugriff aus dem Drittland abdecken?" + + - problem_id: "tia_inadequate" + title: "TIA zeigt Defizite" + triggers: + - rule: R-E011 + solutions: + - id: "add_technical_measures" + title: "Technische Zusatzmaßnahmen implementieren" + control: C_TECHNICAL_SUPPLEMENTARY + removes_problem: true + team_question: "Können Ende-zu-Ende-Verschlüsselung und Pseudonymisierung implementiert werden?" + - id: "switch_provider" + title: "Provider wechseln" + pattern: P_EU_HOSTING + removes_problem: true + team_question: "Gibt es einen alternativen Provider mit besserem Schutzniveau?" + +# ============================================================================= +# Escalation-Triggers (wann DSB einschalten) +# ============================================================================= + +escalation_triggers: + - condition: "Art. 9 Daten vorhanden" + reason: "Besondere Kategorien erfordern DSB-Freigabe" + - condition: "Minderjährige + Profiling/Scoring" + reason: "Besonderer Schutz für Kinder" + - condition: "Vollautomatisierte Entscheidungen mit Rechtswirkung" + reason: "Art. 22 DSGVO Prüfung erforderlich" + - condition: "Widersprüchliche Legal-RAG Ergebnisse" + reason: "Juristische Klärung erforderlich" + - condition: "Risikoscore >= 80" + reason: "Sehr hohes aggregiertes Risiko" diff --git a/ai-compliance-sdk/policies/wizard_schema_v1.yaml b/ai-compliance-sdk/policies/wizard_schema_v1.yaml new file mode 100644 index 0000000..af0639b --- /dev/null +++ b/ai-compliance-sdk/policies/wizard_schema_v1.yaml @@ -0,0 +1,1396 @@ +# ============================================================================= +# UCCA Wizard Schema v1.1 +# Use-Case Compliance Wizard mit Transfer/SCC-Fragen +# ============================================================================= +# +# STRUKTUR: +# - Jeder Step enthält mehrere Felder +# - Felder haben `visible_if` für adaptive Subflows +# - `simple_explanation` für Einfach-Modus Nutzer +# - `legal_refs` für Legal RAG Referenz +# - `why_it_matters` erklärt Relevanz +# +# ============================================================================= + +version: "1.1" +name: "UCCA Compliance Wizard" +description: "Schritt-für-Schritt Prüfung für KI-Anwendungsfälle" +total_steps: 10 +default_mode: "simple" # simple | expert + +# ============================================================================= +# LEGAL ASSISTANT KONFIGURATION +# ============================================================================= +# Jeder Wizard-Step hat einen integrierten Legal Assistant Chat. +# Nutzer können Fragen stellen, um die Wizard-Fragen besser zu verstehen. +# +# Backend-Endpoint: POST /sdk/v1/ucca/wizard/ask +# LLM: Internes 32B Modell +# RAG: Legal Corpus (DSGVO, AI Act, SCC, etc.) +# ============================================================================= + +legal_assistant: + enabled: true + model: "internal-32b" # Internes LLM + max_tokens: 1024 + temperature: 0.3 # Niedrig für präzise rechtliche Antworten + + system_prompt: | + Du bist ein freundlicher Rechtsassistent, der Nutzern hilft, + datenschutzrechtliche Begriffe und Anforderungen zu verstehen. + + DEINE AUFGABE: + - Erkläre rechtliche Begriffe in einfacher, verständlicher Sprache + - Beantworte Fragen zum aktuellen Wizard-Schritt + - Hilf dem Nutzer, die richtigen Antworten im Wizard zu geben + - Verweise auf relevante Rechtsquellen (DSGVO-Artikel, etc.) + + WICHTIGE REGELN: + - Antworte IMMER auf Deutsch + - Verwende einfache Sprache, keine Juristensprache + - Gib konkrete Beispiele wenn möglich + - Bei Unsicherheit empfehle die Rücksprache mit einem DSB + - Du darfst KEINE Rechtsberatung geben, nur erklären + + ANTWORT-FORMAT: + - Kurz und prägnant (max. 3-4 Sätze für einfache Fragen) + - Strukturiert mit Aufzählungen bei komplexen Themen + - Immer mit Quellenangabe am Ende (z.B. "Siehe: DSGVO Art. 9") + + # Kontextuelle Prompts pro Step (optional, für bessere Antworten) + step_contexts: + 1: "Der Nutzer befindet sich im ersten Schritt und gibt grundlegende Informationen zum Unternehmen und KI-Vorhaben ein. Erkläre Begriffe wie NIS2, KRITIS, EU KMU-Definition, Konzernzugehörigkeit und wie Unternehmensgröße (Mitarbeiter, Umsatz) die Anwendbarkeit von Regulierungen beeinflusst." + 2: "Der Nutzer gibt an, welche Datenarten verarbeitet werden. Erkläre die Unterschiede zwischen personenbezogenen Daten, Art. 9 Daten, etc." + 3: "Der Nutzer gibt den Verarbeitungszweck an. Erkläre Begriffe wie Profiling, Scoring, systematische Überwachung." + 4: "Der Nutzer gibt Hosting-Informationen an. Erkläre Cloud vs. On-Premises, Drittlandtransfer." + 5: "Der Nutzer beantwortet Fragen zu SCC und TIA. Erkläre Standardvertragsklauseln, Transfer Impact Assessment, DPF." + 6: "Der Nutzer gibt KI-Modell-Informationen an. Erkläre RAG vs. Training/Finetuning." + 7: "Der Nutzer beantwortet Fragen zu Verträgen. Erkläre AVV, DSFA, Verarbeitungsverzeichnis." + 8: "Der Nutzer gibt Automatisierungsgrad an. Erkläre Human-in-the-Loop, Art. 22 DSGVO." + 9: "Der Nutzer beantwortet Fragen zu Standards und Normen. Erkläre DIN-Lizenzen, LINK_ONLY vs FULLTEXT_RAG." + 10: "Der Nutzer beantwortet Fragen zu Finanzregulierung. Erkläre DORA, MaRisk, BAIT, Modellvalidierung, algorithmischer Handel." + + # Beispiel-Fragen die der Nutzer stellen könnte (für UI-Hints) + example_questions: + - "Was sind personenbezogene Daten?" + - "Was ist der Unterschied zwischen AVV und SCC?" + - "Brauche ich ein TIA?" + - "Was bedeutet Profiling?" + - "Was ist Art. 9 DSGVO?" + - "Wann brauche ich eine DSFA?" + - "Was ist das Data Privacy Framework?" + - "Was ist DORA?" + - "Was ist MaRisk AT 4.3.5?" + - "Wann gilt BAIT fuer mich?" + - "Was ist eine kritische IKT-Dienstleistung?" + - "Brauche ich eine Modellvalidierung?" + - "Was sind DORA-Meldepflichten?" + # NIS2-spezifische Fragen + - "Was ist NIS2?" + - "Bin ich von NIS2 betroffen?" + - "Was ist der Unterschied zwischen wichtiger und besonders wichtiger Einrichtung?" + - "Wann muss ich mich beim BSI registrieren?" + - "Was sind NIS2-Meldepflichten?" + - "Was ist KRITIS?" + - "Zählt mein Unternehmen als KMU?" + - "Welche Pflichten habe ich als MSP unter NIS2?" + +# ============================================================================= +# STEP 1: Grundlegende Informationen +# ============================================================================= + +steps: + + - step_number: 1 + title: "Grundlegende Informationen" + description: "Allgemeine Angaben zu Ihrem KI-Vorhaben" + icon: "info" + + fields: + + - id: "use_case.title" + label: "Bezeichnung des Vorhabens" + type: "text" + required: true + placeholder: "z.B. Chatbot für Kundenservice" + simple_explanation: "Geben Sie einen kurzen Namen für Ihr KI-Projekt an." + + - id: "use_case.description" + label: "Beschreibung" + type: "textarea" + required: true + placeholder: "Beschreiben Sie kurz, was die KI tun soll..." + simple_explanation: "Erklären Sie in 2-3 Sätzen, was Ihre KI-Anwendung machen soll." + + - id: "domain" + label: "Branche/Bereich" + type: "select" + required: true + options: + # NIS2 Anhang I - Hohe Kritikalität + - value: "energy" + label: "Energie (Strom, Gas, Öl, Wasserstoff)" + - value: "utilities" + label: "Stadtwerke/Wasserversorgung" + - value: "transport" + label: "Verkehr (Luft, Schiene, Wasser, Straße)" + - value: "banking" + label: "Bankwesen/Kreditinstitute" + - value: "finance" + label: "Finanzmarkt/Investment" + - value: "healthcare" + label: "Gesundheitswesen" + - value: "digital_infrastructure" + label: "Digitale Infrastruktur (Cloud, Rechenzentrum, DNS)" + - value: "ict_services" + label: "IT-Dienstleister (MSP, MSSP)" + - value: "public_sector" + label: "Öffentliche Verwaltung" + # NIS2 Anhang II - Sonstige kritische Sektoren + - value: "postal" + label: "Post-/Kurierdienste" + - value: "chemicals" + label: "Chemie" + - value: "food" + label: "Lebensmittel" + - value: "manufacturing" + label: "Produktion/Industrie" + - value: "mechanical_engineering" + label: "Maschinen-/Anlagenbau" + - value: "automotive" + label: "Automotive/Fahrzeugbau" + - value: "research" + label: "Forschung" + # Sonstige + - value: "real_estate" + label: "Immobilien/Parkhaus" + - value: "education" + label: "Bildung" + - value: "retail" + label: "Einzelhandel" + - value: "insurance" + label: "Versicherung" + - value: "other" + label: "Sonstiges" + simple_explanation: "In welchem Sektor ist Ihr Unternehmen tätig? Dies beeinflusst, welche Regulierungen (NIS2, DORA, etc.) für Sie gelten." + why_it_matters: "Die NIS2-Richtlinie unterscheidet zwischen Sektoren hoher Kritikalität (Anhang I) und sonstigen kritischen Sektoren (Anhang II). Je nach Sektor gelten unterschiedliche Pflichten." + legal_refs: ["NIS2 Anhang I", "NIS2 Anhang II"] + + # ========================================================================= + # UNTERNEHMENSANGABEN (für Regulierungs-Bestimmung) + # ========================================================================= + + - id: "organization.employee_count" + label: "Anzahl Mitarbeiter" + type: "select" + required: true + options: + - value: "micro" + label: "Unter 10 Mitarbeiter" + - value: "small" + label: "10-49 Mitarbeiter" + - value: "medium" + label: "50-249 Mitarbeiter" + - value: "large" + label: "250+ Mitarbeiter" + simple_explanation: "Wie viele Mitarbeiter hat Ihr Unternehmen (Vollzeitäquivalente)? Bei Konzernzugehörigkeit zählt die Gesamtzahl." + why_it_matters: "Die Unternehmensgröße ist entscheidend für NIS2: Ab 50 Mitarbeitern oder 10 Mio. EUR Umsatz können Sie betroffen sein." + legal_refs: ["NIS2 Art. 2", "EU KMU-Definition"] + + - id: "organization.annual_revenue" + label: "Jahresumsatz" + type: "select" + required: true + options: + - value: "under_2m" + label: "Unter 2 Mio. EUR" + - value: "2m_10m" + label: "2-10 Mio. EUR" + - value: "10m_50m" + label: "10-50 Mio. EUR" + - value: "over_50m" + label: "Über 50 Mio. EUR" + simple_explanation: "Wie hoch ist der Jahresumsatz Ihres Unternehmens? Bei Konzernzugehörigkeit zählt der konsolidierte Umsatz." + why_it_matters: "Zusammen mit der Mitarbeiterzahl bestimmt der Umsatz Ihre Unternehmensgröße und damit die anwendbaren Regulierungen." + legal_refs: ["NIS2 Art. 2", "EU KMU-Definition"] + + - id: "organization.is_part_of_group" + label: "Gehört das Unternehmen zu einem Konzern?" + type: "boolean" + default: false + simple_explanation: "Ist Ihr Unternehmen Teil einer größeren Unternehmensgruppe? Dann zählen die Gesamtwerte des Konzerns." + why_it_matters: "Bei Konzernzugehörigkeit werden Mitarbeiter und Umsatz auf Gruppenebene betrachtet. Ein kleines Tochterunternehmen eines großen Konzerns kann daher trotzdem unter NIS2 fallen." + + - id: "sector.special_services" + label: "Erbringen Sie einen dieser speziellen digitalen Dienste?" + type: "multiselect" + required: false + visible_if: + field: "domain" + in: ["digital_infrastructure", "ict_services", "other"] + options: + - value: "dns" + label: "DNS-Dienste" + - value: "tld" + label: "TLD-Namenregister" + - value: "cloud" + label: "Cloud-Computing-Dienste" + - value: "datacenter" + label: "Rechenzentrumsdienste" + - value: "cdn" + label: "Content-Delivery-Network (CDN)" + - value: "msp" + label: "Managed Service Provider (MSP)" + - value: "mssp" + label: "Managed Security Service Provider (MSSP)" + - value: "trust_service" + label: "Vertrauensdienste (qualifiziert/nicht-qualifiziert)" + - value: "public_network" + label: "Öffentliche Kommunikationsnetze" + - value: "none" + label: "Keinen der genannten" + simple_explanation: "Anbieter dieser Dienste unterliegen NIS2 unabhängig von ihrer Größe." + why_it_matters: "Bestimmte digitale Dienste sind so kritisch, dass sie unabhängig von der Unternehmensgröße unter NIS2 fallen." + legal_refs: ["NIS2 Art. 2", "§ 28 BSIG-E"] + + - id: "sector.is_kritis" + label: "Sind Sie als KRITIS-Betreiber eingestuft?" + type: "boolean" + default: false + visible_if: + field: "domain" + in: ["energy", "utilities", "transport", "healthcare", "digital_infrastructure", "food"] + simple_explanation: "KRITIS-Betreiber sind Unternehmen, die kritische Infrastrukturen betreiben und bestimmte Schwellenwerte überschreiten (z.B. 500.000 versorgte Personen)." + why_it_matters: "KRITIS-Betreiber unterliegen NIS2 unabhängig von ihrer Unternehmensgröße und haben zusätzliche Pflichten." + legal_refs: ["BSI-KritisV", "§ 28 BSIG-E"] + +# ============================================================================= +# STEP 2: Datenarten +# ============================================================================= + + - step_number: 2 + title: "Welche Daten werden verarbeitet?" + description: "Art der Daten, die von der KI genutzt werden" + icon: "database" + + fields: + + - id: "data_types.personal_data" + label: "Personenbezogene Daten" + type: "boolean" + default: false + simple_explanation: | + Personenbezogene Daten sind alle Informationen, die sich auf eine + identifizierbare Person beziehen: Namen, E-Mail-Adressen, Telefonnummern, + aber auch IP-Adressen oder Kundennummern. + why_it_matters: "Die DSGVO gilt nur für personenbezogene Daten. Ohne solche Daten entfallen viele Anforderungen." + legal_refs: ["DSGVO Art. 4(1)"] + + - id: "data_types.article_9_data" + label: "Besonders sensible Daten (Art. 9 DSGVO)" + type: "boolean" + default: false + visible_if: + field: "data_types.personal_data" + equals: true + simple_explanation: | + Besonders geschützte Datenkategorien sind: + - Gesundheitsdaten (Diagnosen, Medikamente) + - Religiöse oder politische Überzeugungen + - Ethnische Herkunft + - Sexuelle Orientierung + - Gewerkschaftszugehörigkeit + - Genetische oder biometrische Daten zur Identifikation + why_it_matters: "Diese Daten sind grundsätzlich verboten zu verarbeiten, es sei denn, eine Ausnahme greift." + legal_refs: ["DSGVO Art. 9"] + + - id: "data_types.minor_data" + label: "Daten von Minderjährigen" + type: "boolean" + default: false + visible_if: + field: "data_types.personal_data" + equals: true + simple_explanation: | + Wenn Nutzer unter 18 Jahren sein können, gelten besondere Schutzvorschriften. + Bei unter 16-Jährigen ist meist die Einwilligung der Eltern erforderlich. + why_it_matters: "Kinder verdienen besonderen Schutz und können selbst keine wirksame Einwilligung erteilen." + legal_refs: ["DSGVO Art. 8", "ErwGr. 38"] + + - id: "data_types.biometric_data" + label: "Biometrische Daten (Gesichtserkennung, Fingerabdruck)" + type: "boolean" + default: false + visible_if: + field: "data_types.personal_data" + equals: true + simple_explanation: | + Biometrische Daten sind körperliche Merkmale, die eine Person eindeutig identifizieren: + - Gesichtserkennung / Fotos mit erkennbaren Gesichtern + - Fingerabdrücke + - Iris-Scans + - Stimmerkennung + why_it_matters: "Biometrische Daten zur Identifikation sind besondere Kategorien und erfordern Einwilligung." + legal_refs: ["DSGVO Art. 9(1)", "Art. 4(14)"] + + - id: "data_types.license_plates" + label: "KFZ-Kennzeichen" + type: "boolean" + default: false + simple_explanation: | + Nummernschilder ermöglichen die Identifikation des Fahrzeughalters und sind + daher personenbezogene Daten. + why_it_matters: "Kennzeichen-Erfassung (z.B. bei Parkhaus-Systemen) erfordert Rechtsgrundlage." + legal_refs: ["DSGVO Art. 4(1)"] + + - id: "data_types.location_data" + label: "Standortdaten / GPS" + type: "boolean" + default: false + simple_explanation: | + Standortdaten zeigen, wo sich eine Person aufhält oder aufgehalten hat. + Dazu gehören GPS-Koordinaten, Bewegungsprofile oder Check-in-Daten. + why_it_matters: "Standortdaten ermöglichen detaillierte Bewegungsprofile und sind besonders schützenswert." + legal_refs: ["DSGVO Art. 4(1)", "ErwGr. 75"] + +# ============================================================================= +# STEP 3: Verarbeitungszweck +# ============================================================================= + + - step_number: 3 + title: "Wofür wird die KI eingesetzt?" + description: "Zweck und Art der Verarbeitung" + icon: "target" + + fields: + + - id: "purpose.customer_support" + label: "Kundenservice / Support" + type: "boolean" + default: false + simple_explanation: "Die KI beantwortet Kundenanfragen oder unterstützt beim Support." + + - id: "purpose.evaluation_scoring" + label: "Bewertung / Scoring von Personen" + type: "boolean" + default: false + simple_explanation: | + Die KI bewertet, rankt oder stuft Personen ein, z.B.: + - Kreditwürdigkeit + - Bewerbungs-Screening + - Leistungsbewertung + why_it_matters: "Scoring erfordert besondere Transparenz und das Recht auf Anfechtung." + legal_refs: ["DSGVO Art. 22", "§31 BDSG"] + + - id: "purpose.profiling" + label: "Profiling (automatisierte Analyse persönlicher Aspekte)" + type: "boolean" + default: false + visible_if: + field: "data_types.personal_data" + equals: true + simple_explanation: | + Profiling bedeutet, dass die KI automatisch persönliche Merkmale analysiert: + - Vorlieben / Interessen + - Verhalten + - Zuverlässigkeit + - Gesundheit + - Wirtschaftliche Lage + why_it_matters: "Profiling kann zu automatisierten Entscheidungen führen und ist besonders reguliert." + legal_refs: ["DSGVO Art. 4(4)", "Art. 22"] + + - id: "processing.systematic_monitoring" + label: "Systematische Überwachung" + type: "boolean" + default: false + simple_explanation: | + Werden Personen systematisch beobachtet oder ihr Verhalten dauerhaft erfasst? + Beispiele: Videoüberwachung, Tracking, Verhaltensanalyse + why_it_matters: "Systematische Überwachung erfordert immer eine DSFA." + legal_refs: ["DSGVO Art. 35(3)(c)"] + + - id: "outputs.decision_with_legal_effect" + label: "Entscheidungen mit rechtlicher Wirkung" + type: "boolean" + default: false + simple_explanation: | + Hat die KI-Entscheidung direkte Auswirkungen auf Rechte oder Verträge? + Beispiele: + - Automatische Kreditablehnung + - Kündigungen + - Vertragsabschlüsse + - Behördenbescheide + why_it_matters: "Vollautomatisierte Entscheidungen mit Rechtswirkung sind nur mit Einwilligung oder Vertrag zulässig." + legal_refs: ["DSGVO Art. 22"] + +# ============================================================================= +# STEP 4: Hosting & Provider +# ============================================================================= + + - step_number: 4 + title: "Wo läuft die KI?" + description: "Hosting-Modell und Anbieter" + icon: "server" + + fields: + + - id: "hosting.type" + label: "Hosting-Modell" + type: "select" + required: true + options: + - value: "on_premises" + label: "Lokal / On-Premises (eigene Hardware)" + - value: "private_cloud" + label: "Private Cloud (dedizierte Infrastruktur)" + - value: "public_cloud" + label: "Public Cloud (z.B. AWS, Azure, GCP)" + - value: "hybrid" + label: "Hybrid (teils lokal, teils Cloud)" + - value: "saas" + label: "SaaS (Software as a Service)" + simple_explanation: | + Wo wird die KI-Software betrieben? + - LOKAL: Auf Ihrem eigenen Server/Computer (z.B. Mac Studio) + - CLOUD: Bei einem externen Anbieter (z.B. Amazon, Microsoft, Google) + - SaaS: Sie nutzen einen fertigen Online-Dienst + why_it_matters: "Bei lokalem Hosting bleiben Daten bei Ihnen. Bei Cloud-Diensten sind zusätzliche Verträge nötig." + + - id: "hosting.region" + label: "Standort der Verarbeitung" + type: "select" + required: true + options: + - value: "eu" + label: "EU / EWR (Deutschland, Österreich, etc.)" + - value: "eu_adequacy" + label: "Land mit Angemessenheitsbeschluss (UK, Schweiz, etc.)" + - value: "us" + label: "USA" + - value: "third_country" + label: "Anderes Drittland" + simple_explanation: | + Wo werden die Daten physisch verarbeitet und gespeichert? + + EU/EWR: Keine besonderen Anforderungen für Datenübermittlung. + + Angemessenheitsbeschluss: Die EU hat bestätigt, dass diese Länder + (z.B. UK, Schweiz, Japan) ein vergleichbares Datenschutzniveau haben. + + USA: Besondere Regeln gelten - siehe nächste Fragen. + + Andere Drittländer: Standardvertragsklauseln (SCC) erforderlich. + why_it_matters: "Bei Verarbeitung außerhalb der EU sind zusätzliche Garantien erforderlich (Art. 44ff DSGVO)." + legal_refs: ["DSGVO Art. 44", "Art. 45", "Art. 46"] + +# ============================================================================= +# STEP 5: Drittlandtransfer & SCC (NEUER STEP) +# ============================================================================= + + - step_number: 5 + title: "Internationaler Datentransfer" + description: "Standardvertragsklauseln und Drittlandübermittlung" + icon: "globe" + visible_if: + any_of: + - field: "hosting.region" + in: ["us", "third_country"] + - field: "provider.location" + in: ["us", "non_eu", "third_country"] + + intro_text: | + Da Daten außerhalb des EWR verarbeitet werden, müssen zusätzliche + Garantien für den Datenschutz geschaffen werden. Die wichtigsten + Instrumente sind Standardvertragsklauseln (SCC) und das + Transfer Impact Assessment (TIA). + + fields: + + - id: "provider.location" + label: "Hauptsitz des KI-Anbieters" + type: "select" + required: true + options: + - value: "eu" + label: "EU / EWR" + - value: "us" + label: "USA" + - value: "uk" + label: "Vereinigtes Königreich" + - value: "ch" + label: "Schweiz" + - value: "non_eu" + label: "Anderes Drittland" + simple_explanation: | + Wo hat der Anbieter der KI-Software seinen Hauptsitz? + + Dies ist wichtig, weil auch der Firmensitz über die anzuwendenden + Gesetze entscheidet. Ein US-Unternehmen kann z.B. von US-Behörden + zur Datenherausgabe verpflichtet werden. + + - id: "provider.dpf_certified" + label: "DPF-Zertifizierung (nur bei USA)" + type: "boolean" + default: false + visible_if: + field: "provider.location" + equals: "us" + simple_explanation: | + Das EU-US Data Privacy Framework (DPF) ist ein Abkommen zwischen + der EU und den USA. Unternehmen, die sich zertifizieren lassen, + bieten ein "angemessenes" Datenschutzniveau. + + Sie können prüfen ob ein US-Unternehmen zertifiziert ist unter: + https://www.dataprivacyframework.gov + + WENN ZERTIFIZIERT: Keine SCC erforderlich (aber empfohlen als Backup). + WENN NICHT ZERTIFIZIERT: SCC sind zwingend erforderlich! + why_it_matters: "DPF-Zertifizierung vereinfacht den USA-Transfer erheblich." + legal_refs: ["EU-US DPF Beschluss 2023"] + + - id: "contracts.scc.present" + label: "Standardvertragsklauseln (SCC) abgeschlossen" + type: "boolean" + default: false + visible_if: + field: "hosting.region" + in: ["us", "third_country"] + simple_explanation: | + STANDARDVERTRAGSKLAUSELN (SCC) sind von der EU genehmigte + Musterverträge, die sicherstellen sollen, dass Ihre Daten + auch außerhalb der EU geschützt werden. + + SCC sind PFLICHT wenn: + - Daten in ein Drittland übermittelt werden + - Kein Angemessenheitsbeschluss besteht + - Der US-Provider nicht DPF-zertifiziert ist + + Der Anbieter muss die SCC mit Ihnen unterzeichnen. + Fragen Sie nach dem "Data Processing Agreement" oder "DPA". + why_it_matters: "Ohne SCC ist die Datenübermittlung in Drittländer rechtswidrig." + legal_refs: ["DSGVO Art. 46(2)(c)", "EU 2021/914"] + + - id: "contracts.scc.version" + label: "Version der SCC" + type: "select" + visible_if: + field: "contracts.scc.present" + equals: true + options: + - value: "new_scc_2021" + label: "Neue SCC (Version Juni 2021)" + - value: "old_scc" + label: "Alte SCC (vor 2021)" + - value: "unknown" + label: "Nicht bekannt" + simple_explanation: | + Die EU hat im Juni 2021 neue Standardvertragsklauseln veröffentlicht. + Die alten Versionen sind seit Ende 2022 NICHT MEHR GÜLTIG! + + Wenn Sie sich nicht sicher sind, fragen Sie beim Anbieter nach: + "Verwenden Sie die neuen EU-Standardvertragsklauseln von 2021?" + why_it_matters: "Verträge mit alten SCC müssen aktualisiert werden." + legal_refs: ["EU 2021/914"] + + - id: "contracts.tia.present" + label: "Transfer Impact Assessment (TIA) durchgeführt" + type: "boolean" + default: false + visible_if: + field: "hosting.region" + in: ["us", "third_country"] + simple_explanation: | + EIN TRANSFER IMPACT ASSESSMENT (TIA) ist eine Risikoprüfung, + die Sie VOR einem Drittlandtransfer durchführen müssen. + + WAS WIRD GEPRÜFT: + 1. Kann das Drittland auf die Daten zugreifen? (z.B. Geheimdienste) + 2. Bietet das Land ausreichenden Rechtsschutz für EU-Bürger? + 3. Reichen die SCC allein aus, oder brauchen Sie Zusatzmaßnahmen? + + WARUM IST DAS WICHTIG: + Der Europäische Gerichtshof (Schrems II) hat entschieden, dass + SCC allein nicht ausreichen, wenn das Drittland die Daten + ungehindert abgreifen kann. Das TIA dokumentiert, dass Sie + diese Risiken geprüft und ggf. Maßnahmen ergriffen haben. + why_it_matters: "Das TIA ist seit dem Schrems II-Urteil Pflicht bei Drittlandtransfers." + legal_refs: ["EuGH Schrems II (C-311/18)", "EDPB Recommendations 01/2020"] + + - id: "contracts.tia.result" + label: "Ergebnis des TIA" + type: "select" + visible_if: + field: "contracts.tia.present" + equals: true + options: + - value: "adequate" + label: "Angemessenes Schutzniveau (Transfer OK)" + - value: "adequate_with_measures" + label: "OK mit zusätzlichen Maßnahmen" + - value: "inadequate" + label: "Defizite - Zusatzmaßnahmen erforderlich" + - value: "not_feasible" + label: "Angemessenes Niveau nicht erreichbar" + simple_explanation: | + Was hat das Transfer Impact Assessment ergeben? + + ANGEMESSEN: Die SCC und die Situation im Drittland bieten + ausreichenden Schutz. Der Transfer kann erfolgen. + + MIT ZUSATZMASSNAHMEN: Der Transfer ist möglich, aber Sie müssen + zusätzliche technische Maßnahmen ergreifen (z.B. Verschlüsselung). + + DEFIZITE: Es gibt Probleme, die behoben werden müssen. + + NICHT MÖGLICH: Der Transfer darf NICHT stattfinden, da kein + angemessenes Schutzniveau erreichbar ist. + why_it_matters: "Das TIA-Ergebnis bestimmt, ob und wie der Transfer erfolgen darf." + legal_refs: ["EDPB Recommendations 01/2020"] + + - id: "provider.support_location" + label: "Wo sitzt der Support des Anbieters?" + type: "select" + options: + - value: "eu" + label: "Nur EU / EWR" + - value: "us" + label: "USA" + - value: "global" + label: "Weltweit / Verschiedene Standorte" + - value: "unknown" + label: "Nicht bekannt" + simple_explanation: | + ACHTUNG: Auch wenn Ihre Daten in der EU gespeichert werden, + kann ein ZUGRIFF aus dem Drittland ein Transfer sein! + + Wenn Support-Mitarbeiter des Anbieters aus den USA oder anderen + Drittländern auf Ihre Daten zugreifen können (z.B. für Fehlerbehebung), + gilt das als Datenübermittlung in dieses Land. + why_it_matters: "Remote-Zugriff = Drittlandtransfer, auch bei EU-Hosting." + legal_refs: ["DSGVO Art. 44", "EDPB Guidelines on Data Transfers"] + + - id: "provider.subprocessors.known" + label: "Sind die Unterauftragsverarbeiter bekannt?" + type: "boolean" + default: false + simple_explanation: | + UNTERAUFTRAGSVERARBEITER (Subprocessors) sind Firmen, die der + Anbieter selbst beauftragt, um Teile der Dienstleistung zu erbringen. + + Beispiele: + - Cloud-Infrastruktur (AWS, Azure) + - Zahlungsabwicklung + - Analytics-Dienste + - CDN/Caching + + Sie haben ein Recht zu wissen, WER diese Firmen sind und WO sie sitzen. + Der Anbieter muss eine Liste bereitstellen. + why_it_matters: "Für jeden Subprocessor im Drittland brauchen Sie ebenfalls SCC." + legal_refs: ["DSGVO Art. 28(2)", "Art. 28(4)"] + + - id: "provider.subprocessors.third_country" + label: "Gibt es Unterauftragsverarbeiter im Drittland?" + type: "boolean" + default: false + visible_if: + field: "provider.subprocessors.known" + equals: true + simple_explanation: | + Nutzt der Anbieter Dienstleister außerhalb der EU/des EWR? + + Häufige Beispiele: + - US-Cloud-Provider (AWS, Google Cloud, Azure) + - US-Analytics (Mixpanel, Amplitude) + - US-Support-Tools (Zendesk, Intercom) + + Für JEDEN dieser Subprocessors muss die SCC-Kette durchgängig sein. + why_it_matters: "Die Verantwortung für die gesamte Verarbeitungskette liegt bei Ihnen." + +# ============================================================================= +# STEP 6: KI-Modell & Training +# ============================================================================= + + - step_number: 6 + title: "KI-Modell und Training" + description: "Wie wird das KI-Modell genutzt und verbessert?" + icon: "brain" + + fields: + + - id: "model_usage.rag" + label: "RAG (Retrieval-Augmented Generation)" + type: "boolean" + default: false + simple_explanation: | + Bei RAG durchsucht die KI Ihre Dokumente und verwendet gefundene + Textstellen für die Antwort. Das Modell selbst wird NICHT verändert. + + Vorteil: Ihre Daten fließen nicht ins KI-Modell, sondern bleiben + in einer durchsuchbaren Datenbank. + why_it_matters: "RAG ist datenschutzfreundlicher als Training, da Daten nicht ins Modell einfließen." + + - id: "model_usage.training" + label: "Training / Finetuning" + type: "boolean" + default: false + simple_explanation: | + Beim Training/Finetuning werden Ihre Daten verwendet, um das + KI-Modell selbst zu verbessern. Die Daten werden quasi "eingebaut". + + ACHTUNG: Einmal trainierte Daten können nicht mehr "vergessen" werden! + why_it_matters: "Training mit personenbezogenen Daten hat weitreichende Konsequenzen." + legal_refs: ["DSGVO Art. 17 (Recht auf Löschung)"] + + - id: "provider.uses_data_for_training" + label: "Nutzt der Anbieter Ihre Daten für eigenes Training?" + type: "boolean" + default: false + simple_explanation: | + Manche KI-Anbieter (z.B. OpenAI, Google) nutzen Kundendaten, + um ihre eigenen Modelle zu verbessern - es sei denn, Sie widersprechen. + + Prüfen Sie die Nutzungsbedingungen und fragen Sie nach einem + "Opt-Out" für Modelltraining! + why_it_matters: "Wenn Ihre Kundendaten im Modell des Anbieters landen, verlieren Sie die Kontrolle." + legal_refs: ["DSGVO Art. 5(1)(b) Zweckbindung"] + + - id: "contracts.no_training_clause" + label: "Vertragliche Opt-Out-Klausel für Training vorhanden?" + type: "boolean" + default: false + visible_if: + field: "provider.uses_data_for_training" + equals: true + simple_explanation: | + Haben Sie mit dem Anbieter vereinbart, dass Ihre Daten NICHT + für das Training seiner KI-Modelle verwendet werden dürfen? + + Dies sollte im Vertrag oder DPA explizit stehen. + +# ============================================================================= +# STEP 7: Verträge & Compliance +# ============================================================================= + + - step_number: 7 + title: "Verträge & Compliance" + description: "Vertragliche Absicherung" + icon: "file-contract" + + fields: + + - id: "contracts.avv.present" + label: "Auftragsverarbeitungsvertrag (AVV) abgeschlossen" + type: "boolean" + default: false + simple_explanation: | + Ein AUFTRAGSVERARBEITUNGSVERTRAG (AVV) ist ein Vertrag nach Art. 28 DSGVO, + der regelt, wie ein Dienstleister Ihre Daten verarbeiten darf. + + Der AVV ist PFLICHT, wenn: + - Ein externer Dienstleister Daten für Sie verarbeitet + - Sie einen Cloud-Service nutzen + - Sie einen KI-Provider beauftragen + + WICHTIG: AVV und SCC sind VERSCHIEDENE Dinge! + - AVV: Regelt WIE verarbeitet wird (immer erforderlich bei Auftragsverarbeitung) + - SCC: Regelt Drittlandtransfer (nur bei Nicht-EU erforderlich) + + Bei EU-Anbietern: Nur AVV + Bei Drittland-Anbietern: AVV UND SCC + why_it_matters: "Ohne AVV verstoßen Sie gegen die DSGVO - auch bei EU-Anbietern." + legal_refs: ["DSGVO Art. 28"] + + - id: "contracts.avv.complete" + label: "AVV enthält alle erforderlichen Klauseln" + type: "boolean" + default: false + visible_if: + field: "contracts.avv.present" + equals: true + simple_explanation: | + Ein vollständiger AVV muss mindestens enthalten: + - Gegenstand und Dauer der Verarbeitung + - Art und Zweck der Verarbeitung + - Art der personenbezogenen Daten + - Kategorien betroffener Personen + - Rechte und Pflichten des Verantwortlichen + - Weisungsbindung + - Vertraulichkeit + - Technische und organisatorische Maßnahmen + - Regelung zu Unterauftragsverarbeitern + - Unterstützung bei Betroffenenrechten + - Löschung/Rückgabe nach Auftragsende + legal_refs: ["DSGVO Art. 28(3)"] + + - id: "governance.dsfa_completed" + label: "Datenschutz-Folgenabschätzung (DSFA) durchgeführt" + type: "boolean" + default: false + simple_explanation: | + Eine DSFA ist eine ausführliche Risikoanalyse, die bei bestimmten + Verarbeitungen PFLICHT ist: + - Systematische Bewertung/Scoring von Personen + - Umfangreiche Verarbeitung besonderer Kategorien (Art. 9) + - Systematische Überwachung öffentlicher Bereiche + why_it_matters: "Bei hohem Risiko für Betroffene ist die DSFA gesetzlich vorgeschrieben." + legal_refs: ["DSGVO Art. 35"] + + - id: "governance.vvt_entry" + label: "Eintrag im Verarbeitungsverzeichnis" + type: "boolean" + default: false + simple_explanation: | + Das Verarbeitungsverzeichnis (VVT) ist die Dokumentation aller + Datenverarbeitungen in Ihrem Unternehmen. + + Jede neue KI-Anwendung sollte dort eingetragen werden. + legal_refs: ["DSGVO Art. 30"] + +# ============================================================================= +# STEP 8: Automatisierung & Human Oversight +# ============================================================================= + + - step_number: 8 + title: "Automatisierung & Kontrolle" + description: "Grad der Automatisierung und menschliche Aufsicht" + icon: "user-check" + + fields: + + - id: "automation" + label: "Grad der Automatisierung" + type: "select" + required: true + options: + - value: "support_only" + label: "Nur Unterstützung (Mensch entscheidet immer)" + - value: "semi_automated" + label: "Teilautomatisiert (Mensch prüft vor Wirksamkeit)" + - value: "fully_automated" + label: "Vollautomatisiert (keine menschliche Prüfung)" + simple_explanation: | + Wie viel entscheidet die KI alleine? + + NUR UNTERSTÜTZUNG: Die KI macht Vorschläge, aber ein Mensch + entscheidet und handelt. + + TEILAUTOMATISIERT: Die KI bereitet Entscheidungen vor, aber + ein Mensch prüft und gibt frei. + + VOLLAUTOMATISIERT: Die KI trifft Entscheidungen ohne + menschliche Prüfung (z.B. automatische Kreditablehnung). + why_it_matters: "Vollautomatisierte Entscheidungen mit Rechtswirkung sind besonders reguliert." + legal_refs: ["DSGVO Art. 22", "AI Act Art. 14"] + + - id: "processing.human_oversight" + label: "Ist menschliche Aufsicht technisch erzwungen?" + type: "boolean" + default: false + visible_if: + field: "automation" + in: ["semi_automated", "fully_automated"] + simple_explanation: | + Ist es TECHNISCH sichergestellt, dass ein Mensch kritische + Entscheidungen prüfen muss, bevor sie wirksam werden? + + Beispiel: Das System zeigt einen "Genehmigen"-Button, ohne den + die Entscheidung nicht umgesetzt wird. + why_it_matters: "Human-in-the-Loop kann Art. 22 DSGVO-Risiken vermeiden." + legal_refs: ["DSGVO Art. 22(3)", "AI Act Art. 14"] + +# ============================================================================= +# STEP 9: Standards & Normen (Lizenz-Compliance) +# ============================================================================= + + - step_number: 9 + title: "Standards & Normen" + description: "Nutzung von DIN/ISO/VDI Normen und Lizenz-Compliance" + icon: "book-open" + visible_if: + any_of: + - field: "domain" + in: ["mechanical_engineering", "automotive", "manufacturing"] + - field: "use_case.involves_standards" + equals: true + + intro_text: | + Bei der Nutzung von technischen Normen (DIN, ISO, VDI, etc.) + muessen neben Datenschutz auch URHEBERRECHTLICHE Aspekte + beachtet werden. + + WICHTIG: DIN Media (ehem. Beuth Verlag) untersagt aktuell die + KI-Nutzung von Normen ohne explizite Genehmigung! + + fields: + + - id: "licensed_content.present" + label: "Werden Normen/Standards verarbeitet?" + type: "boolean" + default: false + simple_explanation: | + Sollen DIN-Normen, ISO-Standards, VDI-Richtlinien oder + aehnliche technische Regelwerke mit KI verarbeitet werden? + + Beispiele: + - Risikobeurteilung nach DIN EN ISO 12100 + - CE-Kennzeichnung nach Maschinenrichtlinie + - Sicherheitsfunktionen nach DIN EN ISO 13849 + + ACHTUNG: Normen sind urheberrechtlich geschuetzt! + why_it_matters: "Die Nutzung von Normen mit KI erfordert besondere Lizenzen." + legal_refs: ["UrhG", "DIN Media Nutzungsbedingungen"] + + - id: "licensed_content.publisher" + label: "Herausgeber der Normen" + type: "select" + visible_if: + field: "licensed_content.present" + equals: true + options: + - value: "DIN_MEDIA" + label: "DIN / DIN Media (ehem. Beuth)" + - value: "VDI" + label: "VDI Richtlinien" + - value: "VDE" + label: "VDE/DKE Normen" + - value: "ISO" + label: "ISO (international)" + - value: "IEC" + label: "IEC (Elektrotechnik)" + - value: "DGUV" + label: "DGUV Vorschriften" + - value: "OTHER" + label: "Andere" + - value: "UNKNOWN" + label: "Nicht bekannt" + simple_explanation: | + Von welchem Verlag/Herausgeber stammen Ihre Normen? + + Die meisten deutschen DIN-Normen werden von DIN Media + (frueher Beuth Verlag) vertrieben. + + WICHTIG: Jeder Verlag hat eigene Lizenzbedingungen! + + - id: "licensed_content.license_type" + label: "Lizenztyp" + type: "select" + visible_if: + field: "licensed_content.present" + equals: true + options: + - value: "SINGLE_WORKSTATION" + label: "Einzelplatz/Workstation-Lizenz" + - value: "NETWORK_INTRANET" + label: "Netzwerk/Intranet-Lizenz" + - value: "ENTERPRISE" + label: "Enterprise/Unternehmens-Lizenz" + - value: "AI_LICENSE" + label: "AI-Lizenz (explizit fuer KI-Nutzung)" + - value: "UNKNOWN" + label: "Nicht bekannt" + simple_explanation: | + Welche Lizenz haben Sie fuer Ihre Normen erworben? + + EINZELPLATZ: Nur ein Mitarbeiter an einem PC darf die + Norm nutzen. KEINE Weitergabe erlaubt! + + NETZWERK/INTRANET: Mehrere Mitarbeiter duerfen im + Firmennetzwerk zugreifen. + + ENTERPRISE: Unternehmensweit nutzbar. + + AI-LIZENZ: Spezielle Erlaubnis fuer KI/LLM-Nutzung. + Diese gibt es bei DIN Media erst ab Q4/2025! + why_it_matters: "Die Lizenz bestimmt, was Sie technisch duerfen." + + - id: "licensed_content.ai_use_permitted" + label: "Ist KI/LLM-Nutzung erlaubt?" + type: "select" + visible_if: + field: "licensed_content.present" + equals: true + options: + - value: "YES" + label: "Ja (schriftlich bestaetigt)" + - value: "NO" + label: "Nein" + - value: "UNKNOWN" + label: "Nicht bekannt / Unklar" + simple_explanation: | + Haben Sie eine SCHRIFTLICHE Erlaubnis, die Normen mit + KI/LLM zu verarbeiten? + + WICHTIG: DIN Media hat aktuell (Stand 2026) festgelegt, + dass die KI-Nutzung von Normen NICHT erlaubt ist! + + Ein AI-Lizenzmodell ist erst ab Q4/2025 geplant. + + Wenn Sie "Unklar" waehlen, wird aus Sicherheitsgruenden + nur der Link-only oder Notes-only Modus freigeschaltet. + why_it_matters: "Ohne explizite Erlaubnis ist Volltext-RAG blockiert." + legal_refs: ["UrhG §44b (TDM-Vorbehalt)", "DIN Media AGB"] + + - id: "licensed_content.operation_mode" + label: "Wie soll Breakpilot die Normen nutzen?" + type: "select" + visible_if: + field: "licensed_content.present" + equals: true + options: + - value: "LINK_ONLY" + label: "Nur Verweise & Checklisten (kein Normentext)" + - value: "NOTES_ONLY" + label: "Nur eigene Notizen/Zusammenfassungen (RAG)" + - value: "FULLTEXT_RAG" + label: "Volltext-RAG (nur mit AI-Lizenz!)" + - value: "TRAINING" + label: "Modell-Training (nur mit expliziter Erlaubnis!)" + simple_explanation: | + LINK-ONLY (empfohlen, immer moeglich): + Breakpilot zeigt Ihnen Checklisten und verweist auf + relevante Normenabschnitte. Sie schauen selbst in der + Norm nach. KEIN Lizenzrisiko! + + NOTES-ONLY (meist moeglich): + Sie erstellen eigene Zusammenfassungen und Checklisten. + Diese werden durchsuchbar gemacht - nicht die Originaltexte. + + VOLLTEXT-RAG (nur mit AI-Lizenz!): + Die kompletten Normentexte werden indexiert. + ACHTUNG: Erfordert schriftliche AI-Nutzungserlaubnis! + + TRAINING (sehr restriktiv): + Normen zum Modell-Training verwenden. + Bei DIN Media aktuell VERBOTEN! + why_it_matters: "Der Modus bestimmt das Lizenzrisiko." + + - id: "licensed_content.proof_uploaded" + label: "Liegt ein AI-Lizenz-Nachweis vor?" + type: "boolean" + default: false + visible_if: + all_of: + - field: "licensed_content.present" + equals: true + - field: "licensed_content.operation_mode" + in: ["FULLTEXT_RAG", "TRAINING"] + simple_explanation: | + Haben Sie einen Nachweis, der die KI/LLM-Nutzung erlaubt? + + Das kann sein: + - Vertrag mit expliziter AI-Nutzungs-Klausel + - Schriftliche Freigabe vom Verlag + - AI-Lizenz von DIN Media (ab Q4/2025 verfuegbar) + + OHNE diesen Nachweis ist Volltext-RAG blockiert! + why_it_matters: "Ohne Nachweis kein Volltext-RAG moeglich." + + - id: "licensed_content.distribution_scope" + label: "Wer soll Zugriff auf die Ergebnisse haben?" + type: "select" + visible_if: + field: "licensed_content.present" + equals: true + options: + - value: "SINGLE_USER" + label: "Nur ich selbst" + - value: "COMPANY_INTERNAL" + label: "Unternehmensintern" + - value: "SUBSIDIARIES" + label: "Inkl. Tochtergesellschaften" + - value: "EXTERNAL_CUSTOMERS" + label: "Auch an Kunden/Externe" + - value: "UNKNOWN" + label: "Nicht bekannt" + simple_explanation: | + An wen moechten Sie KI-generierte Antworten weitergeben? + + WICHTIG: Einzelplatz-Lizenzen erlauben KEINE Weitergabe + an Kollegen! Wenn Sie unternehmensweit arbeiten moechten, + benoetigen Sie mindestens eine Netzwerk-Lizenz. + why_it_matters: "Der Verteilungsumfang muss zur Lizenz passen." + +# ============================================================================= +# STEP 10: Finanzregulierung (DORA, MaRisk, BAIT) +# ============================================================================= + + - step_number: 10 + title: "Finanzregulierung" + description: "DORA, MaRisk und BAIT Compliance fuer regulierte Finanzunternehmen" + icon: "building-bank" + visible_if: + any_of: + - field: "domain" + in: ["banking", "finance", "insurance", "investment", "payment_services"] + - field: "financial_entity.regulated" + equals: true + + fields: + + # --- Finanzunternehmen-Klassifikation --- + + - id: "financial_entity.type" + label: "Art des Finanzunternehmens" + type: "select" + required: true + options: + - value: "CREDIT_INSTITUTION" + label: "Kreditinstitut (Bank)" + - value: "PAYMENT_SERVICE_PROVIDER" + label: "Zahlungsdienstleister (PSD2)" + - value: "E_MONEY_INSTITUTION" + label: "E-Geld-Institut" + - value: "INVESTMENT_FIRM" + label: "Wertpapierfirma (MiFID II)" + - value: "INSURANCE_COMPANY" + label: "Versicherungsunternehmen" + - value: "CRYPTO_ASSET_PROVIDER" + label: "Krypto-Dienstleister (MiCA)" + - value: "OTHER_FINANCIAL" + label: "Sonstiges Finanzunternehmen" + - value: "NOT_REGULATED" + label: "Nicht reguliert" + simple_explanation: | + Welcher Art ist Ihr Unternehmen? + + KREDITINSTITUT: Klassische Bank mit BaFin-Lizenz + + ZAHLUNGSDIENSTLEISTER: Unternehmen das Zahlungen + abwickelt (z.B. PayPal, Klarna) + + WERTPAPIERFIRMA: Broker, Vermoegensverwaltung + + VERSICHERUNG: Lebens-, Sach- oder Krankenversicherung + + KRYPTO-DIENSTLEISTER: Handelsplatz, Wallet-Anbieter + why_it_matters: "Der Unternehmenstyp bestimmt die regulatorischen Anforderungen." + legal_refs: ["DORA Art. 2", "KWG", "ZAG", "VAG"] + + - id: "financial_entity.regulated" + label: "Unterliegt das Unternehmen der BaFin-Aufsicht?" + type: "boolean" + default: true + visible_if: + field: "financial_entity.type" + not_equals: "NOT_REGULATED" + simple_explanation: | + Wird Ihr Unternehmen von der BaFin (oder EZB) beaufsichtigt? + + JA wenn Sie: + - Eine BaFin-Lizenz besitzen + - Unter EZB-Aufsicht stehen (SSM) + - Einer regulierten Gruppe angehoeren + + NEIN wenn Sie: + - Ein reines FinTech ohne Lizenz sind + - Nur Dienstleister fuer Banken sind + why_it_matters: "Regulierte Unternehmen muessen DORA, MaRisk und BAIT einhalten." + legal_refs: ["KWG §1", "DORA Art. 2"] + + - id: "financial_entity.size_category" + label: "Groessenkategorie des Instituts" + type: "select" + visible_if: + field: "financial_entity.regulated" + equals: true + options: + - value: "SIGNIFICANT" + label: "Bedeutendes Institut (Significant Institution)" + - value: "LESS_SIGNIFICANT" + label: "Weniger bedeutendes Institut (LSI)" + - value: "SMALL" + label: "Kleines Institut" + simple_explanation: | + BEDEUTEND (SI): Direkte EZB-Aufsicht, Bilanzsumme > 30 Mrd. EUR + + WENIGER BEDEUTEND (LSI): BaFin-Aufsicht, nationale Bedeutung + + KLEIN: Vereinfachte Anforderungen moeglich + why_it_matters: "Groessere Institute haben strengere DORA-Anforderungen (z.B. TLPT)." + legal_refs: ["SSM-Rahmenverordnung", "DORA Art. 26"] + + # --- IKT-Dienste und Auslagerungen --- + + - id: "ict_service.is_critical" + label: "Handelt es sich um eine kritische IKT-Dienstleistung?" + type: "boolean" + default: false + visible_if: + field: "financial_entity.regulated" + equals: true + simple_explanation: | + Eine IKT-Dienstleistung gilt als KRITISCH (DORA Art. 3 Nr. 21) wenn: + + - Ihr Ausfall den Geschaeftsbetrieb erheblich beeintraechtigt + - Sie Kernbankfunktionen unterstuetzt + - Sie fuer die Kundenbetreuung essentiell ist + - Regulatorische Meldepflichten davon abhaengen + + Beispiele: Core Banking, Zahlungsverkehr, Risikoberechnung + why_it_matters: "Kritische IKT-Dienste unterliegen strengeren DORA-Anforderungen." + legal_refs: ["DORA Art. 3(21)", "DORA Art. 28-30"] + + - id: "ict_service.is_outsourced" + label: "Wird die KI-Anwendung ausgelagert (Cloud/Drittanbieter)?" + type: "boolean" + default: false + visible_if: + field: "financial_entity.regulated" + equals: true + simple_explanation: | + JA wenn: + - Die KI bei einem Cloud-Anbieter laeuft (AWS, Azure, GCP) + - Ein Drittanbieter die KI-Loesung bereitstellt + - Die Daten das eigene Rechenzentrum verlassen + + NEIN wenn: + - Vollstaendig On-Premises betrieben + - Nur interne Ressourcen genutzt werden + why_it_matters: "Auslagerungen erfordern Due Diligence und vertragliche Absicherung." + legal_refs: ["DORA Art. 28-30", "MaRisk AT 9"] + + - id: "ict_service.provider_location" + label: "Wo ist der IKT-Drittanbieter ansaessig?" + type: "select" + visible_if: + field: "ict_service.is_outsourced" + equals: true + options: + - value: "EU" + label: "EU / EWR" + - value: "ADEQUACY_DECISION" + label: "Land mit Angemessenheitsbeschluss" + - value: "THIRD_COUNTRY" + label: "Drittland (z.B. USA)" + simple_explanation: | + EU/EWR: Niedrigeres Risiko, DORA direkt anwendbar + + ANGEMESSENHEITSBESCHLUSS: z.B. Schweiz, UK, Japan + + DRITTLAND: Zusaetzliche Pruefung und Schutzmaßnahmen noetig + why_it_matters: "Drittland-Auslagerungen erfordern zusaetzliche DORA-Kontrollen." + legal_refs: ["DORA Art. 31", "DSGVO Art. 44ff"] + + - id: "ict_service.concentration_risk" + label: "Besteht ein Konzentrationsrisiko bei IKT-Anbietern?" + type: "boolean" + default: false + visible_if: + field: "ict_service.is_outsourced" + equals: true + simple_explanation: | + JA wenn: + - Mehrere kritische Dienste beim gleichen Anbieter liegen + - Keine Alternative bei Anbieter-Ausfall verfuegbar ist + - Der Markt von wenigen Anbietern dominiert wird (z.B. Cloud) + + Beispiel: AWS fuer Core Banking UND KI UND Backup + why_it_matters: "Konzentrationsrisiken koennen systemische Auswirkungen haben." + legal_refs: ["DORA Art. 29(2)"] + + # --- KI-spezifische Finanzfragen --- + + - id: "ai_application.affects_customer_decisions" + label: "Beeinflusst die KI Kundenentscheidungen?" + type: "boolean" + default: false + visible_if: + field: "financial_entity.regulated" + equals: true + simple_explanation: | + JA wenn die KI: + - Kreditentscheidungen unterstuetzt oder trifft + - Versicherungspraemien berechnet + - Kontoeroeffnungen bewertet + - Ablehnungsgruende generiert + + NEIN wenn die KI nur intern genutzt wird (z.B. Compliance-Checks) + why_it_matters: "Kundenentscheidungen per KI erfordern Erklaerbarkeit und Fairness." + legal_refs: ["Art. 22 DSGVO", "MaRisk AT 4.3.5"] + + - id: "ai_application.risk_assessment" + label: "Wird die KI fuer Risikobewertungen eingesetzt?" + type: "boolean" + default: false + visible_if: + field: "financial_entity.regulated" + equals: true + simple_explanation: | + JA wenn die KI: + - Kredit-Scoring durchfuehrt + - Fraud Detection macht + - Marktrisiken bewertet + - Bonitaetspruefungen unterstuetzt + + Diese Modelle unterliegen der MaRisk-Validierungspflicht! + why_it_matters: "Risikomodelle muessen nach MaRisk AT 4.3.5 validiert werden." + legal_refs: ["MaRisk AT 4.3.5", "EBA Guidelines on IRB"] + + - id: "ai_application.model_validation_done" + label: "Wurde eine Modellvalidierung nach MaRisk durchgefuehrt?" + type: "boolean" + default: false + visible_if: + field: "ai_application.risk_assessment" + equals: true + simple_explanation: | + Die Validierung nach MaRisk AT 4.3.5 umfasst: + - Konzeptionelle Pruefung (Ist das Modell geeignet?) + - Datenpruefung (Sind die Trainingsdaten valide?) + - Backtesting (Stimmen die Vorhersagen?) + - Unabhaengige Pruefung (2nd Line of Defense) + + OHNE Validierung: KEIN produktiver Einsatz erlaubt! + why_it_matters: "Nicht-validierte Risikomodelle duerfen nicht produktiv eingesetzt werden." + legal_refs: ["MaRisk AT 4.3.5"] + + - id: "ai_application.algorithmic_trading" + label: "Wird die KI fuer algorithmischen Handel eingesetzt?" + type: "boolean" + default: false + visible_if: + field: "financial_entity.type" + in: ["CREDIT_INSTITUTION", "INVESTMENT_FIRM"] + simple_explanation: | + JA wenn die KI: + - Automatisch Trades ausfuehrt + - Handelsstrategien optimiert + - Market Making betreibt + - High-Frequency-Trading unterstuetzt + + ACHTUNG: Algorithmischer Handel erfordert BaFin-Anzeige! + why_it_matters: "Algorithmischer Handel unterliegt besonderen MiFID II Anforderungen." + legal_refs: ["MiFID II Art. 17", "WpHG §80"] + + - id: "ai_application.aml_kyc" + label: "Wird die KI fuer AML/KYC eingesetzt?" + type: "boolean" + default: false + visible_if: + field: "financial_entity.regulated" + equals: true + simple_explanation: | + JA wenn die KI: + - Verdaechtige Transaktionen erkennt + - Kundenidentitaeten verifiziert + - Sanktionslisten-Screening macht + - PEP-Pruefungen durchfuehrt + + AML-Entscheidungen erfordern IMMER menschliche Pruefung! + why_it_matters: "KI fuer AML erfordert Human-in-the-Loop und regelmaessige Validierung." + legal_refs: ["GwG", "AMLA (EU)"] + +# ============================================================================= +# METADATA +# ============================================================================= + +metadata: + created_at: "2026-01-29" + created_by: "AI Compliance SDK" + last_updated: "2026-01-29" + supported_modes: + - simple + - expert + languages: + - de + - en diff --git a/developer-portal/.dockerignore b/developer-portal/.dockerignore new file mode 100644 index 0000000..e421be9 --- /dev/null +++ b/developer-portal/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.gitignore +README.md +*.log +.env.local +.env.*.local diff --git a/developer-portal/Dockerfile b/developer-portal/Dockerfile new file mode 100644 index 0000000..20267ee --- /dev/null +++ b/developer-portal/Dockerfile @@ -0,0 +1,45 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Set to production +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built assets +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Switch to non-root user +USER nextjs + +# Expose port +EXPOSE 3000 + +# Set hostname +ENV HOSTNAME="0.0.0.0" + +# Start the application +CMD ["node", "server.js"] diff --git a/developer-portal/app/api/export/page.tsx b/developer-portal/app/api/export/page.tsx new file mode 100644 index 0000000..368768d --- /dev/null +++ b/developer-portal/app/api/export/page.tsx @@ -0,0 +1,271 @@ +import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/DevPortalLayout' + +export default function ExportApiPage() { + return ( + +

Uebersicht

+

+ Die Export API ermoeglicht den Download aller Compliance-Daten in + verschiedenen Formaten fuer Audits, Dokumentation und Archivierung. +

+ +

Unterstuetzte Formate

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FormatBeschreibungUse Case
jsonKompletter State als JSONBackup, Migration, API-Integration
pdfFormatierter PDF-ReportAudits, Management-Reports
zipAlle Dokumente als ZIP-ArchivVollstaendige Dokumentation
+
+ +

GET /export

+

Exportiert den aktuellen State im gewuenschten Format.

+ +

Query-Parameter

+ + +

JSON Export

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=json&tenantId=your-tenant-id" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -o compliance-export.json`} + + +

Response

+ +{`{ + "exportedAt": "2026-02-04T12:00:00Z", + "version": "1.0.0", + "tenantId": "your-tenant-id", + "state": { + "currentPhase": 2, + "currentStep": "dsfa", + "completedSteps": [...], + "useCases": [...], + "risks": [...], + "controls": [...], + "dsfa": {...}, + "toms": [...], + "vvt": [...] + }, + "meta": { + "completionPercentage": 75, + "lastModified": "2026-02-04T11:55:00Z" + } +}`} + + +

PDF Export

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=pdf&tenantId=your-tenant-id§ions=dsfa,toms" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -o compliance-report.pdf`} + + +

PDF Inhalt

+

Das generierte PDF enthaelt:

+
    +
  • Deckblatt mit Tenant-Info und Exportdatum
  • +
  • Inhaltsverzeichnis
  • +
  • Executive Summary mit Fortschritt
  • +
  • Use Case Uebersicht
  • +
  • Risikoanalyse mit Matrix-Visualisierung
  • +
  • DSFA (falls generiert)
  • +
  • TOM-Katalog
  • +
  • VVT-Auszug
  • +
  • Checkpoint-Status
  • +
+ + + Das PDF folgt einem professionellen Audit-Layout mit Corporate Design. + Enterprise-Kunden koennen ein Custom-Logo und Farbschema konfigurieren. + + +

ZIP Export

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=zip&tenantId=your-tenant-id" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -o compliance-export.zip`} + + +

ZIP Struktur

+ +{`compliance-export/ +├── README.md +├── state.json # Kompletter State +├── summary.pdf # Executive Summary +├── use-cases/ +│ ├── uc-1-ki-analyse.json +│ └── uc-2-chatbot.json +├── risks/ +│ ├── risk-matrix.pdf +│ └── risks.json +├── documents/ +│ ├── dsfa.pdf +│ ├── dsfa.json +│ ├── toms.pdf +│ ├── toms.json +│ ├── vvt.pdf +│ └── vvt.json +├── checkpoints/ +│ └── checkpoint-status.json +└── audit-trail/ + └── changes.json`} + + +

SDK Integration

+ +{`import { useSDK, exportToPDF, exportToZIP, downloadExport } from '@breakpilot/compliance-sdk' + +// Option 1: Ueber den Hook +function ExportButton() { + const { exportState } = useSDK() + + const handlePDFExport = async () => { + const blob = await exportState('pdf') + downloadExport(blob, 'compliance-report.pdf') + } + + const handleZIPExport = async () => { + const blob = await exportState('zip') + downloadExport(blob, 'compliance-export.zip') + } + + const handleJSONExport = async () => { + const blob = await exportState('json') + downloadExport(blob, 'compliance-state.json') + } + + return ( +
+ + + +
+ ) +} + +// Option 2: Direkte Funktionen +async function exportManually(state: SDKState) { + // PDF generieren + const pdfBlob = await exportToPDF(state) + downloadExport(pdfBlob, \`compliance-\${Date.now()}.pdf\`) + + // ZIP generieren + const zipBlob = await exportToZIP(state) + downloadExport(zipBlob, \`compliance-\${Date.now()}.zip\`) +}`} +
+ +

Command Bar Integration

+

+ Exporte sind auch ueber die Command Bar verfuegbar: +

+ +{`Cmd+K → "pdf" → "Als PDF exportieren" +Cmd+K → "zip" → "Als ZIP exportieren" +Cmd+K → "json" → "Als JSON exportieren"`} + + +

Automatisierte Exports

+

+ Fuer regelmaessige Backups oder CI/CD-Integration: +

+ +{`# Taeglicher Backup-Export um 02:00 Uhr +0 2 * * * curl -X GET "https://api.breakpilot.io/sdk/v1/export?format=zip&tenantId=my-tenant" \\ + -H "Authorization: Bearer $API_KEY" \\ + -o "/backups/compliance-$(date +%Y%m%d).zip"`} + + + + ZIP-Exporte koennen bei umfangreichen States mehrere MB gross werden. + Die API hat ein Timeout von 60 Sekunden. Bei sehr grossen States + verwenden Sie den asynchronen Export-Endpoint (Enterprise). + + +

Fehlerbehandlung

+ +{`import { exportState } from '@breakpilot/compliance-sdk' + +try { + const blob = await exportState('pdf') + downloadExport(blob, 'report.pdf') +} catch (error) { + if (error.code === 'EMPTY_STATE') { + console.error('Keine Daten zum Exportieren vorhanden') + } else if (error.code === 'GENERATION_FAILED') { + console.error('PDF-Generierung fehlgeschlagen:', error.message) + } else if (error.code === 'TIMEOUT') { + console.error('Export-Timeout - versuchen Sie ZIP fuer grosse States') + } else { + console.error('Unbekannter Fehler:', error) + } +}`} + +
+ ) +} diff --git a/developer-portal/app/api/generate/page.tsx b/developer-portal/app/api/generate/page.tsx new file mode 100644 index 0000000..7c82bd5 --- /dev/null +++ b/developer-portal/app/api/generate/page.tsx @@ -0,0 +1,381 @@ +import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/DevPortalLayout' + +export default function GenerateApiPage() { + return ( + +

Uebersicht

+

+ Die Generation API nutzt LLM-Technologie (Claude) zur automatischen Erstellung + von Compliance-Dokumenten basierend auf Ihrem SDK-State: +

+
    +
  • DSFA - Datenschutz-Folgenabschaetzung
  • +
  • TOM - Technische und Organisatorische Massnahmen
  • +
  • VVT - Verarbeitungsverzeichnis nach Art. 30 DSGVO
  • +
+ + + Die Generierung verwendet Claude 3.5 Sonnet fuer optimale Qualitaet + bei deutschen Rechtstexten. RAG-Context wird automatisch einbezogen. + + +

POST /generate/dsfa

+

Generiert eine Datenschutz-Folgenabschaetzung basierend auf dem aktuellen State.

+ +

Request Body

+ + +

Request

+ +{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/dsfa" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "tenantId": "your-tenant-id", + "useCaseId": "uc-ki-kundenanalyse", + "includeRisks": true, + "includeControls": true, + "language": "de" + }'`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "dsfa": { + "id": "dsfa-2026-02-04-abc123", + "version": "1.0", + "status": "DRAFT", + "createdAt": "2026-02-04T12:00:00Z", + "useCase": { + "id": "uc-ki-kundenanalyse", + "name": "KI-gestuetzte Kundenanalyse", + "description": "Analyse von Kundenverhalten mittels ML..." + }, + "sections": { + "systematicDescription": { + "title": "1. Systematische Beschreibung", + "content": "Die geplante Verarbeitungstaetigkeit umfasst..." + }, + "necessityAssessment": { + "title": "2. Bewertung der Notwendigkeit", + "content": "Die Verarbeitung ist notwendig fuer..." + }, + "riskAssessment": { + "title": "3. Risikobewertung", + "risks": [ + { + "id": "risk-1", + "title": "Unbefugter Datenzugriff", + "severity": "HIGH", + "likelihood": 3, + "impact": 4, + "description": "...", + "mitigations": ["Verschluesselung", "Zugriffskontrolle"] + } + ] + }, + "mitigationMeasures": { + "title": "4. Abhilfemassnahmen", + "controls": [...] + }, + "stakeholderConsultation": { + "title": "5. Einbeziehung Betroffener", + "content": "..." + }, + "dpoOpinion": { + "title": "6. Stellungnahme des DSB", + "content": "Ausstehend - Freigabe erforderlich" + } + }, + "conclusion": { + "overallRisk": "MEDIUM", + "recommendation": "PROCEED_WITH_CONDITIONS", + "conditions": [ + "Implementierung der TOM-Empfehlungen", + "Regelmaessige Ueberpruefung" + ] + } + }, + "generationMeta": { + "model": "claude-3.5-sonnet", + "ragContextUsed": true, + "tokensUsed": 4250, + "durationMs": 8500 + } + } +}`} + + +

POST /generate/tom

+

Generiert technische und organisatorische Massnahmen.

+ +

Request Body

+ + +

Request

+ +{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/tom" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "tenantId": "your-tenant-id", + "categories": ["access_control", "encryption", "backup"], + "basedOnRisks": true + }'`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "toms": [ + { + "id": "tom-1", + "category": "access_control", + "categoryLabel": "Zugangskontrolle", + "title": "Multi-Faktor-Authentifizierung", + "description": "Implementierung von MFA fuer alle Systemzugaenge", + "technicalMeasures": [ + "TOTP-basierte 2FA", + "Hardware Security Keys (FIDO2)" + ], + "organizationalMeasures": [ + "Schulung der Mitarbeiter", + "Dokumentation der Zugaenge" + ], + "article32Reference": "Art. 32 Abs. 1 lit. b DSGVO", + "priority": "HIGH", + "implementationStatus": "PLANNED" + }, + { + "id": "tom-2", + "category": "encryption", + "categoryLabel": "Verschluesselung", + "title": "Transportverschluesselung", + "description": "TLS 1.3 fuer alle Datenuebert\\\\ragungen", + "technicalMeasures": [ + "TLS 1.3 mit PFS", + "HSTS Header" + ], + "organizationalMeasures": [ + "Zertifikatsmanagement", + "Regelmaessige Audits" + ], + "article32Reference": "Art. 32 Abs. 1 lit. a DSGVO", + "priority": "CRITICAL", + "implementationStatus": "IMPLEMENTED" + } + ], + "summary": { + "totalMeasures": 20, + "byCategory": { + "access_control": 5, + "encryption": 4, + "backup": 3, + "monitoring": 4, + "incident_response": 4 + }, + "implementationProgress": { + "implemented": 12, + "in_progress": 5, + "planned": 3 + } + } + } +}`} + + +

POST /generate/vvt

+

Generiert ein Verarbeitungsverzeichnis nach Art. 30 DSGVO.

+ +

Request Body

+ + +

Request

+ +{`curl -X POST "https://api.breakpilot.io/sdk/v1/generate/vvt" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "tenantId": "your-tenant-id", + "organizationInfo": { + "name": "Beispiel GmbH", + "address": "Musterstrasse 1, 10115 Berlin", + "dpoContact": "datenschutz@beispiel.de" + }, + "includeRetentionPolicies": true + }'`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "vvt": { + "id": "vvt-2026-02-04", + "version": "1.0", + "organization": { + "name": "Beispiel GmbH", + "address": "Musterstrasse 1, 10115 Berlin", + "dpoContact": "datenschutz@beispiel.de" + }, + "processingActivities": [ + { + "id": "pa-1", + "name": "Kundendatenverarbeitung", + "purpose": "Vertragserfuellung und Kundenservice", + "legalBasis": "Art. 6 Abs. 1 lit. b DSGVO", + "dataCategories": ["Kontaktdaten", "Vertragsdaten", "Zahlungsdaten"], + "dataSubjects": ["Kunden", "Interessenten"], + "recipients": ["Zahlungsdienstleister", "Versanddienstleister"], + "thirdCountryTransfers": { + "exists": false, + "countries": [], + "safeguards": null + }, + "retentionPeriod": "10 Jahre nach Vertragsende (HGB)", + "technicalMeasures": ["Verschluesselung", "Zugriffskontrolle"] + } + ], + "lastUpdated": "2026-02-04T12:00:00Z" + } + } +}`} + + +

SDK Integration

+ +{`import { getSDKBackendClient } from '@breakpilot/compliance-sdk' + +const client = getSDKBackendClient() + +// DSFA generieren +async function generateDSFA(useCaseId: string) { + const dsfa = await client.generateDSFA({ + useCaseId, + includeRisks: true, + includeControls: true, + }) + + console.log('DSFA generiert:', dsfa.id) + console.log('Gesamtrisiko:', dsfa.conclusion.overallRisk) + return dsfa +} + +// TOMs generieren +async function generateTOMs() { + const toms = await client.generateTOM({ + categories: ['access_control', 'encryption'], + basedOnRisks: true, + }) + + console.log(\`\${toms.length} TOMs generiert\`) + return toms +} + +// VVT generieren +async function generateVVT() { + const vvt = await client.generateVVT({ + organizationInfo: { + name: 'Beispiel GmbH', + address: 'Musterstrasse 1', + dpoContact: 'dpo@beispiel.de', + }, + }) + + console.log(\`VVT mit \${vvt.processingActivities.length} Verarbeitungen\`) + return vvt +}`} + + + + Die Dokumentengenerierung verbraucht LLM-Tokens. Durchschnittliche Kosten: + DSFA ~5.000 Tokens, TOMs ~3.000 Tokens, VVT ~4.000 Tokens. + Enterprise-Kunden haben unbegrenzte Generierungen. + +
+ ) +} diff --git a/developer-portal/app/api/page.tsx b/developer-portal/app/api/page.tsx new file mode 100644 index 0000000..2b660bf --- /dev/null +++ b/developer-portal/app/api/page.tsx @@ -0,0 +1,239 @@ +import Link from 'next/link' +import { DevPortalLayout, ApiEndpoint, InfoBox } from '@/components/DevPortalLayout' + +export default function ApiReferencePage() { + return ( + +

Base URL

+

+ Alle API-Endpunkte sind unter folgender Basis-URL erreichbar: +

+
+ https://api.breakpilot.io/sdk/v1 +
+

+ Für Self-Hosted-Installationen verwenden Sie Ihre eigene Domain. +

+ +

Authentifizierung

+

+ Alle API-Anfragen erfordern einen gültigen API Key im Header: +

+
+ Authorization: Bearer YOUR_API_KEY +
+ + + Die Tenant-ID wird aus dem API Key abgeleitet oder kann explizit + als Query-Parameter oder im Request-Body mitgegeben werden. + + +

API Endpoints

+ +

State Management

+

+ Verwalten Sie den SDK-State für Ihren Tenant. +

+ + + + + +

+ + → Vollständige State API Dokumentation + +

+ +

RAG Search

+

+ Durchsuchen Sie den Compliance-Korpus (DSGVO, AI Act, NIS2). +

+ + + + +

+ + → Vollständige RAG API Dokumentation + +

+ +

Document Generation

+

+ Generieren Sie Compliance-Dokumente automatisch. +

+ + + + + +

+ + → Vollständige Generation API Dokumentation + +

+ +

Export

+

+ Exportieren Sie den Compliance-Stand in verschiedenen Formaten. +

+ + + +

+ + → Vollständige Export API Dokumentation + +

+ +

Response Format

+

+ Alle Responses folgen einem einheitlichen Format: +

+ +

Erfolgreiche Response

+
+{`{ + "success": true, + "data": { ... }, + "meta": { + "version": 1, + "timestamp": "2026-02-04T12:00:00Z" + } +}`} +
+ +

Fehler Response

+
+{`{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Tenant ID is required", + "details": { ... } + } +}`} +
+ +

Error Codes

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HTTP StatusCodeBeschreibung
400VALIDATION_ERRORUngültige Request-Daten
401UNAUTHORIZEDFehlender oder ungültiger API Key
403FORBIDDENKeine Berechtigung für diese Ressource
404NOT_FOUNDRessource nicht gefunden
409CONFLICTVersions-Konflikt (Optimistic Locking)
429RATE_LIMITEDZu viele Anfragen
500INTERNAL_ERRORInterner Server-Fehler
+
+ +

Rate Limits

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
PlanRequests/MinuteRequests/Tag
Starter6010.000
Professional300100.000
EnterpriseUnbegrenztUnbegrenzt
+
+
+ ) +} diff --git a/developer-portal/app/api/rag/page.tsx b/developer-portal/app/api/rag/page.tsx new file mode 100644 index 0000000..2847e6c --- /dev/null +++ b/developer-portal/app/api/rag/page.tsx @@ -0,0 +1,248 @@ +import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/DevPortalLayout' + +export default function RAGApiPage() { + return ( + +

Uebersicht

+

+ Die RAG (Retrieval-Augmented Generation) API ermoeglicht semantische Suche + im Compliance-Korpus. Der Korpus enthaelt: +

+
    +
  • DSGVO (Datenschutz-Grundverordnung)
  • +
  • AI Act (EU KI-Verordnung)
  • +
  • NIS2 (Netzwerk- und Informationssicherheit)
  • +
  • ePrivacy-Verordnung
  • +
  • Bundesdatenschutzgesetz (BDSG)
  • +
+ + + Die Suche verwendet BGE-M3 Embeddings fuer praezise semantische Aehnlichkeit. + Die Vektoren werden in Qdrant gespeichert. + + +

GET /rag/search

+

Durchsucht den Legal Corpus semantisch.

+ +

Query-Parameter

+ + +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/rag/search?q=Einwilligung%20DSGVO&top_k=5" \\ + -H "Authorization: Bearer YOUR_API_KEY"`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "query": "Einwilligung DSGVO", + "results": [ + { + "id": "dsgvo-art-7", + "title": "Art. 7 DSGVO - Bedingungen fuer die Einwilligung", + "content": "Beruht die Verarbeitung auf einer Einwilligung, muss der Verantwortliche nachweisen koennen, dass die betroffene Person in die Verarbeitung ihrer personenbezogenen Daten eingewilligt hat...", + "corpus": "dsgvo", + "article": "Art. 7", + "score": 0.92, + "metadata": { + "chapter": "II", + "section": "Einwilligung", + "url": "https://dsgvo-gesetz.de/art-7-dsgvo/" + } + }, + { + "id": "dsgvo-art-6-1-a", + "title": "Art. 6 Abs. 1 lit. a DSGVO - Einwilligung als Rechtsgrundlage", + "content": "Die Verarbeitung ist nur rechtmaessig, wenn mindestens eine der nachstehenden Bedingungen erfuellt ist: a) Die betroffene Person hat ihre Einwilligung...", + "corpus": "dsgvo", + "article": "Art. 6", + "score": 0.88, + "metadata": { + "chapter": "II", + "section": "Rechtmaessigkeit", + "url": "https://dsgvo-gesetz.de/art-6-dsgvo/" + } + } + ], + "total_results": 2, + "search_time_ms": 45 + }, + "meta": { + "corpus_version": "2026-01", + "embedding_model": "bge-m3" + } +}`} + + +

GET /rag/status

+

Gibt Status-Informationen ueber das RAG-System zurueck.

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/rag/status" \\ + -H "Authorization: Bearer YOUR_API_KEY"`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "status": "healthy", + "corpus": { + "dsgvo": { + "documents": 99, + "chunks": 1250, + "last_updated": "2026-01-15T00:00:00Z" + }, + "ai_act": { + "documents": 89, + "chunks": 980, + "last_updated": "2026-01-20T00:00:00Z" + }, + "nis2": { + "documents": 46, + "chunks": 520, + "last_updated": "2026-01-10T00:00:00Z" + } + }, + "embedding_service": { + "status": "online", + "model": "bge-m3", + "dimension": 1024 + }, + "vector_db": { + "type": "qdrant", + "collections": 3, + "total_vectors": 2750 + } + } +}`} + + +

SDK Integration

+

+ Verwenden Sie den SDK-Client fuer einfache RAG-Suche: +

+ +{`import { getSDKBackendClient, isLegalQuery } from '@breakpilot/compliance-sdk' + +const client = getSDKBackendClient() + +// Pruefen ob die Query rechtliche Inhalte betrifft +if (isLegalQuery('Was ist eine Einwilligung?')) { + // RAG-Suche durchfuehren + const results = await client.search('Einwilligung DSGVO', 5) + + results.forEach(result => { + console.log(\`[\${result.corpus}] \${result.title}\`) + console.log(\`Score: \${result.score}\`) + console.log(\`URL: \${result.metadata.url}\`) + console.log('---') + }) +}`} + + +

Keyword-Erkennung

+

+ Die Funktion isLegalQuery erkennt automatisch rechtliche Anfragen: +

+ +{`import { isLegalQuery } from '@breakpilot/compliance-sdk' + +// Gibt true zurueck fuer: +isLegalQuery('DSGVO Art. 5') // true - Artikel-Referenz +isLegalQuery('Einwilligung') // true - DSGVO-Begriff +isLegalQuery('AI Act Hochrisiko') // true - AI Act Begriff +isLegalQuery('NIS2 Richtlinie') // true - NIS2 Referenz +isLegalQuery('personenbezogene Daten') // true - Datenschutz-Begriff + +// Gibt false zurueck fuer: +isLegalQuery('Wie ist das Wetter?') // false - Keine rechtliche Anfrage +isLegalQuery('Programmiere mir X') // false - Technische Anfrage`} + + +

Beispiel: Command Bar Integration

+ +{`import { useState } from 'react' +import { getSDKBackendClient, isLegalQuery } from '@breakpilot/compliance-sdk' + +function CommandBarSearch({ query }: { query: string }) { + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (query.length > 3 && isLegalQuery(query)) { + setLoading(true) + const client = getSDKBackendClient() + + client.search(query, 3).then(data => { + setResults(data) + setLoading(false) + }) + } + }, [query]) + + if (!isLegalQuery(query)) return null + + return ( +
+ {loading ? ( +

Suche im Legal Corpus...

+ ) : ( + results.map(result => ( +
+

{result.title}

+

{result.content.slice(0, 200)}...

+ + Volltext lesen + +
+ )) + )} +
+ ) +}`} +
+ + + Die RAG-Suche ist auf 100 Anfragen/Minute (Professional) bzw. + unbegrenzt (Enterprise) limitiert. Implementieren Sie Client-Side + Debouncing fuer Echtzeit-Suche. + +
+ ) +} diff --git a/developer-portal/app/api/state/page.tsx b/developer-portal/app/api/state/page.tsx new file mode 100644 index 0000000..f7f3188 --- /dev/null +++ b/developer-portal/app/api/state/page.tsx @@ -0,0 +1,266 @@ +import { DevPortalLayout, ApiEndpoint, CodeBlock, ParameterTable, InfoBox } from '@/components/DevPortalLayout' + +export default function StateApiPage() { + return ( + +

Übersicht

+

+ Die State API ermöglicht das Speichern und Abrufen des kompletten SDK-States. + Der State enthält alle Compliance-Daten: Use Cases, Risiken, Controls, + Checkpoints und mehr. +

+ + + Der State wird mit optimistischem Locking gespeichert. Bei jedem Speichern + wird die Version erhöht. Bei Konflikten erhalten Sie einen 409-Fehler. + + +

GET /state/{'{tenantId}'}

+

Lädt den aktuellen SDK-State für einen Tenant.

+ +

Request

+ +{`curl -X GET "https://api.breakpilot.io/sdk/v1/state/your-tenant-id" \\ + -H "Authorization: Bearer YOUR_API_KEY"`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "version": "1.0.0", + "lastModified": "2026-02-04T12:00:00Z", + "tenantId": "your-tenant-id", + "userId": "user-123", + "subscription": "PROFESSIONAL", + "currentPhase": 1, + "currentStep": "use-case-workshop", + "completedSteps": ["use-case-workshop", "screening"], + "checkpoints": { + "CP-UC": { + "checkpointId": "CP-UC", + "passed": true, + "validatedAt": "2026-02-01T10:00:00Z", + "validatedBy": "user-123", + "errors": [], + "warnings": [] + } + }, + "useCases": [ + { + "id": "uc-1", + "name": "KI-Kundenanalyse", + "description": "...", + "category": "Marketing", + "stepsCompleted": 5, + "assessmentResult": { + "riskLevel": "HIGH", + "dsfaRequired": true, + "aiActClassification": "LIMITED" + } + } + ], + "risks": [...], + "controls": [...], + "dsfa": {...}, + "toms": [...], + "vvt": [...] + }, + "meta": { + "version": 5, + "etag": "W/\\"abc123\\"" + } +}`} + + +

Response (404 Not Found)

+ +{`{ + "success": false, + "error": { + "code": "NOT_FOUND", + "message": "No state found for tenant your-tenant-id" + } +}`} + + +

POST /state

+

Speichert den SDK-State. Unterstützt Versionierung und optimistisches Locking.

+ +

Request Body

+ + +

Request

+ +{`curl -X POST "https://api.breakpilot.io/sdk/v1/state" \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -H "If-Match: W/\\"abc123\\"" \\ + -d '{ + "tenantId": "your-tenant-id", + "userId": "user-123", + "state": { + "currentPhase": 1, + "currentStep": "risks", + "useCases": [...], + "risks": [...] + } + }'`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "tenantId": "your-tenant-id", + "version": 6, + "updatedAt": "2026-02-04T12:05:00Z" + }, + "meta": { + "etag": "W/\\"def456\\"" + } +}`} + + +

Response (409 Conflict)

+ +{`{ + "success": false, + "error": { + "code": "CONFLICT", + "message": "Version conflict: expected 5, but current is 6", + "details": { + "expectedVersion": 5, + "currentVersion": 6 + } + } +}`} + + + + Bei einem 409-Fehler sollten Sie den State erneut laden, Ihre Änderungen + mergen und erneut speichern. + + +

DELETE /state/{'{tenantId}'}

+

Löscht den kompletten State für einen Tenant.

+ +

Request

+ +{`curl -X DELETE "https://api.breakpilot.io/sdk/v1/state/your-tenant-id" \\ + -H "Authorization: Bearer YOUR_API_KEY"`} + + +

Response (200 OK)

+ +{`{ + "success": true, + "data": { + "tenantId": "your-tenant-id", + "deleted": true + } +}`} + + +

State-Struktur

+

Der SDKState enthält alle Compliance-Daten:

+ + +{`interface SDKState { + // Metadata + version: string + lastModified: Date + + // Tenant & User + tenantId: string + userId: string + subscription: 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE' + + // Progress + currentPhase: 1 | 2 + currentStep: string + completedSteps: string[] + checkpoints: Record + + // Phase 1 Data + useCases: UseCaseAssessment[] + activeUseCase: string | null + screening: ScreeningResult | null + modules: ServiceModule[] + requirements: Requirement[] + controls: Control[] + evidence: Evidence[] + checklist: ChecklistItem[] + risks: Risk[] + + // Phase 2 Data + aiActClassification: AIActResult | null + obligations: Obligation[] + dsfa: DSFA | null + toms: TOM[] + retentionPolicies: RetentionPolicy[] + vvt: ProcessingActivity[] + documents: LegalDocument[] + cookieBanner: CookieBannerConfig | null + consents: ConsentRecord[] + dsrConfig: DSRConfig | null + escalationWorkflows: EscalationWorkflow[] + + // UI State + preferences: UserPreferences +}`} + + +

Beispiel: SDK Integration

+ +{`import { getSDKApiClient } from '@breakpilot/compliance-sdk' + +const client = getSDKApiClient('your-tenant-id') + +// State laden +const state = await client.getState() +console.log('Current step:', state.currentStep) +console.log('Use cases:', state.useCases.length) + +// State speichern +await client.saveState({ + ...state, + currentStep: 'risks', + risks: [...state.risks, newRisk], +})`} + +
+ ) +} diff --git a/developer-portal/app/changelog/page.tsx b/developer-portal/app/changelog/page.tsx new file mode 100644 index 0000000..7a1bb7e --- /dev/null +++ b/developer-portal/app/changelog/page.tsx @@ -0,0 +1,164 @@ +import { DevPortalLayout, InfoBox } from '@/components/DevPortalLayout' + +export default function ChangelogPage() { + return ( + +

Versionierung

+

+ Das SDK folgt Semantic Versioning (SemVer): + MAJOR.MINOR.PATCH +

+
    +
  • MAJOR: Breaking Changes
  • +
  • MINOR: Neue Features, abwaertskompatibel
  • +
  • PATCH: Bugfixes
  • +
+ + {/* Version 1.2.0 */} +
+
+ + v1.2.0 + + 2026-02-04 + Latest +
+ +

Neue Features

+
    +
  • Demo-Daten Seeding ueber API (nicht mehr hardcodiert)
  • +
  • Playwright E2E Tests fuer alle 19 SDK-Schritte
  • +
  • Command Bar RAG-Integration mit Live-Suche
  • +
  • Developer Portal mit API-Dokumentation
  • +
  • TOM-Katalog mit 20 vorkonfigurierten Massnahmen
  • +
  • VVT-Templates fuer gaengige Verarbeitungstaetigkeiten
  • +
+ +

Verbesserungen

+
    +
  • Performance-Optimierung beim State-Loading
  • +
  • Bessere TypeScript-Typen fuer alle Exports
  • +
  • Verbesserte Fehlerbehandlung bei API-Calls
  • +
+ +

Bugfixes

+
    +
  • Fix: Checkpoint-Validierung bei leeren Arrays
  • +
  • Fix: Multi-Tab-Sync bei Safari
  • +
  • Fix: Export-Dateiname mit Sonderzeichen
  • +
+
+ + {/* Version 1.1.0 */} +
+
+ + v1.1.0 + + 2026-01-20 +
+ +

Neue Features

+
    +
  • Backend-Sync mit PostgreSQL-Persistierung
  • +
  • SDK Backend (Go) mit RAG + LLM-Integration
  • +
  • Automatische DSFA-Generierung via Claude API
  • +
  • Export nach PDF, ZIP, JSON
  • +
+ +

Verbesserungen

+
    +
  • Offline-Support mit localStorage Fallback
  • +
  • Optimistic Locking fuer Konfliktbehandlung
  • +
  • BroadcastChannel fuer Multi-Tab-Sync
  • +
+
+ + {/* Version 1.0.0 */} +
+
+ + v1.0.0 + + 2026-01-01 +
+ +

Initial Release

+
    +
  • SDKProvider mit React Context
  • +
  • useSDK Hook mit vollstaendigem State-Zugriff
  • +
  • 19-Schritte Compliance-Workflow (Phase 1 + 2)
  • +
  • Checkpoint-Validierung
  • +
  • Risk Matrix mit Score-Berechnung
  • +
  • TypeScript-Support mit allen Types
  • +
  • Utility Functions fuer Navigation und Berechnung
  • +
+
+ + {/* Breaking Changes Notice */} + +

+ Bei Major-Version-Updates (z.B. 1.x → 2.x) koennen Breaking Changes auftreten. + Pruefen Sie die Migration Guides vor dem Upgrade. +

+

+ Das SDK speichert die State-Version im localStorage. Bei inkompatiblen + Aenderungen wird automatisch eine Migration durchgefuehrt. +

+
+ +

Geplante Features

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureVersionStatus
Multi-Tenant-Supportv1.3.0In Entwicklung
Workflow-Customizationv1.3.0Geplant
Audit-Trail Exportv1.4.0Geplant
White-Label Brandingv2.0.0Roadmap
+
+ +

Feedback & Issues

+

+ Fuer Bug-Reports und Feature-Requests nutzen Sie bitte: +

+
    +
  • + GitHub Issues:{' '} + github.com/breakpilot/compliance-sdk/issues +
  • +
  • + Support:{' '} + support@breakpilot.io +
  • +
+
+ ) +} diff --git a/developer-portal/app/development/byoeh/page.tsx b/developer-portal/app/development/byoeh/page.tsx new file mode 100644 index 0000000..3eb827e --- /dev/null +++ b/developer-portal/app/development/byoeh/page.tsx @@ -0,0 +1,769 @@ +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' + +export default function BYOEHDocsPage() { + return ( + + {/* ============================================================ */} + {/* 1. EINLEITUNG */} + {/* ============================================================ */} +

1. Was ist die Namespace-Technologie?

+

+ Unsere Namespace-Technologie (intern BYOEH -- Bring Your Own Expectation Horizon) + ist eine Privacy-First-Architektur, die es Geschaeftskunden ermoeglicht, sensible Daten + anonym und verschluesselt von KI-Services in der Cloud verarbeiten zu lassen -- ohne dass + personenbezogene Informationen jemals den Client verlassen. +

+
+ “Daten gehen pseudonymisiert und verschluesselt in die Cloud, werden dort + von KI verarbeitet, und kommen verarbeitet zurueck. Nur der Kunde kann die Ergebnisse + wieder den Originaldaten zuordnen -- denn nur sein System hat den Schluessel dafuer.” +
+

+ Das SDK loest ein grundlegendes Problem fuer Unternehmen: KI-gestuetzte + Datenverarbeitung ohne Datenschutzrisiko. Die Architektur basiert auf vier Bausteinen: +

+
    +
  1. + Pseudonymisierung: Personenbezogene Daten werden durch zufaellige + Tokens ersetzt. Nur der Kunde kennt die Zuordnung. +
  2. +
  3. + Client-seitige Verschluesselung: Alle Daten werden auf dem System + des Kunden verschluesselt, bevor sie die Infrastruktur verlassen. Der Cloud-Server + sieht nur verschluesselte Blobs. +
  4. +
  5. + Namespace-Isolation: Jeder Kunde erhaelt einen eigenen, vollstaendig + abgeschotteten Namespace. Kein Kunde kann auf Daten eines anderen zugreifen. +
  6. +
  7. + KI-Verarbeitung in der Cloud: Die KI arbeitet mit den pseudonymisierten + Daten und den vom Kunden bereitgestellten Referenzdokumenten. Ergebnisse gehen zurueck + an den Kunden zur lokalen Entschluesselung und Re-Identifizierung. +
  8. +
+ + + Breakpilot kann die Kundendaten nicht lesen. Der Server sieht nur + verschluesselte Blobs und einen Schluessel-Hash (nicht den Schluessel selbst). Die + Passphrase zum Entschluesseln existiert ausschliesslich auf dem System des Kunden + und wird niemals uebertragen. Selbst ein Angriff auf die Cloud-Infrastruktur wuerde keine + Klartextdaten preisgeben. + + + {/* ============================================================ */} + {/* 2. ANWENDUNGSFAELLE */} + {/* ============================================================ */} +

2. Typische Anwendungsfaelle

+

+ Die Namespace-Technologie ist ueberall einsetzbar, wo sensible Daten von einer KI + verarbeitet werden sollen, ohne den Datenschutz zu gefaehrden: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BrancheAnwendungsfallSensible Daten
BildungKI-gestuetzte KlausurkorrekturSchuelernamen, Noten, Leistungsdaten
GesundheitswesenMedizinische BefundanalysePatientennamen, Diagnosen, Befunde
RechtVertragsanalyse, Due DiligenceMandantendaten, Vertragsinhalte
PersonalwesenBewerbungsscreening, ZeugnisanalyseBewerberdaten, Gehaltsinformationen
FinanzwesenDokumentenpruefung, Compliance-ChecksKontodaten, Transaktionen, Identitaeten
+
+ + {/* ============================================================ */} + {/* 3. DER KOMPLETTE ABLAUF */} + {/* ============================================================ */} +

3. Der komplette Ablauf im Ueberblick

+

+ Der Prozess laesst sich in sieben Schritte unterteilen. Die gesamte + Pseudonymisierung und Verschluesselung geschieht auf dem System des Kunden, + bevor Daten in die Cloud gesendet werden: +

+ + +{`SCHRITT 1: DOKUMENTE ERFASSEN & PSEUDONYMISIEREN +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +SDK empfaengt Dokumente (PDF, Bild, Text) + → Personenbezogene Daten werden erkannt (Header, Namen, IDs) + → PII wird durch zufaellige Tokens ersetzt (doc_token, UUID4) + → Zuordnung "Token → Originalname" wird lokal gesichert + +SCHRITT 2: CLIENT-SEITIGE VERSCHLUESSELUNG +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Kunde konfiguriert eine Passphrase im SDK + → SDK leitet daraus einen 256-Bit-Schluessel ab (PBKDF2, 100k Runden) + → Dokumente werden mit AES-256-GCM verschluesselt + → Nur der Hash des Schluessels wird an den Server gesendet + → Passphrase und Schluessel verlassen NIEMALS das Kundensystem + +SCHRITT 3: IDENTITAETS-MAP SICHERN +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Die Zuordnung "Token → Originaldaten" wird verschluesselt gespeichert: + → Nur mit der Passphrase des Kunden rekonstruierbar + → Ohne Passphrase ist keine Re-Identifizierung moeglich + +SCHRITT 4: UPLOAD IN DEN KUNDEN-NAMESPACE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Verschluesselte Dateien gehen in den isolierten Namespace: + → Jeder Kunde hat eine eigene tenant_id + → Daten werden in MinIO (Storage) + Qdrant (Vektoren) gespeichert + → Server sieht: verschluesselter Blob + Schluessel-Hash + Salt + +SCHRITT 5: KI-VERARBEITUNG IN DER CLOUD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +KI verarbeitet die pseudonymisierten Daten: + → RAG-System durchsucht Referenzdokumente des Kunden + → KI generiert Ergebnisse basierend auf Kundenkontext + → Ergebnisse sind an den Namespace gebunden + +SCHRITT 6: ERGEBNISSE ZURUECK +━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +KI-Ergebnisse gehen an das Kundensystem: + → SDK entschluesselt die Ergebnisse mit der Passphrase + → Kunde sieht aufbereitete Ergebnisse im Klartext + +SCHRITT 7: RE-IDENTIFIZIERUNG & FINALISIERUNG +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Kunde ordnet Ergebnisse den Originaldaten zu: + → Identitaets-Map wird entschluesselt + → Tokens werden wieder den echten Datensaetzen zugeordnet + → Fertige Ergebnisse stehen im Originalsystem bereit`} + + + {/* ============================================================ */} + {/* 4. SDK-INTEGRATION */} + {/* ============================================================ */} +

4. SDK-Integration

+

+ Die Integration in bestehende Systeme erfolgt ueber unser SDK. Nachfolgend ein + vereinfachtes Beispiel, wie ein Kunde das SDK nutzt: +

+ + +{`import { BreakpilotSDK, NamespaceClient } from '@breakpilot/compliance-sdk' + +// 1. SDK initialisieren mit API-Key +const sdk = new BreakpilotSDK({ + apiKey: process.env.BREAKPILOT_API_KEY, + endpoint: 'https://api.breakpilot.de' +}) + +// 2. Namespace-Client erstellen (pro Mandant/Abteilung) +const namespace = sdk.createNamespace({ + tenantId: 'kunde-firma-abc', + passphrase: process.env.ENCRYPTION_PASSPHRASE // Bleibt lokal! +}) + +// 3. Dokument pseudonymisieren & verschluesselt hochladen +const result = await namespace.upload({ + file: documentBuffer, + metadata: { type: 'vertrag', category: 'due-diligence' }, + pseudonymize: true, // PII automatisch ersetzen + headerRedaction: true // Kopfbereich entfernen +}) +// result.docToken = "a7f3c2d1-4e9b-4a5f-8c7d-..." + +// 4. Referenzdokument hochladen (z.B. Pruefkriterien) +await namespace.uploadReference({ + file: referenceBuffer, + title: 'Pruefkriterien Vertrag Typ A' +}) + +// 5. KI-Verarbeitung anstossen +const analysis = await namespace.analyze({ + docToken: result.docToken, + prompt: 'Pruefe den Vertrag gegen die Referenzkriterien', + useRAG: true +}) + +// 6. Ergebnisse entschluesseln (passiert automatisch im SDK) +console.log(analysis.findings) // Klartext-Ergebnisse +console.log(analysis.score) // Bewertung + +// 7. Re-Identifizierung (Token → Originalname) +const identityMap = await namespace.getIdentityMap() +const originalName = identityMap[result.docToken]`} + + + + Die Passphrase verlässt niemals das System des Kunden. Das SDK verschluesselt + und entschluesselt ausschliesslich lokal. Breakpilot hat zu keinem + Zeitpunkt Zugriff auf Klartextdaten oder den Verschluesselungsschluessel. + + + {/* ============================================================ */} + {/* 5. PSEUDONYMISIERUNG */} + {/* ============================================================ */} +

5. Pseudonymisierung: Wie personenbezogene Daten entfernt werden

+

+ Pseudonymisierung bedeutet: personenbezogene Daten werden durch zufaellige + Tokens ersetzt, sodass ohne Zusatzinformation kein Rueckschluss auf die Person + moeglich ist. Das SDK bietet zwei Mechanismen: +

+ +

5.1 Der doc_token: Ein zufaelliger Identifikator

+

+ Jedes Dokument erhaelt einen doc_token -- einen 128-Bit-Zufallscode im + UUID4-Format (z.B. a7f3c2d1-4e9b-4a5f-8c7d-6b2e1f0a9d3c). Dieser Token: +

+
    +
  • Ist kryptographisch zufaellig -- es gibt keinen Zusammenhang zwischen + Token und Originaldatensatz
  • +
  • Kann nicht zurueckgerechnet werden -- auch mit Kenntnis des Algorithmus + ist kein Rueckschluss moeglich
  • +
  • Dient als eindeutiger Schluessel, um Ergebnisse spaeter dem + Originaldokument zuzuordnen
  • +
+ +

5.2 Header-Redaction: PII wird entfernt

+

+ Bei Dokumenten mit erkennbarem Kopfbereich (Namen, Adressen, IDs) kann das SDK diesen + Bereich automatisch entfernen. Die Entfernung ist permanent: + Die Originaldaten werden nicht an den Server uebermittelt. +

+ +
+ + + + + + + + + + + + + + + + + + + + +
MethodeWie es funktioniertWann verwenden
Einfache RedactionDefinierter Bereich des Dokuments wird entferntStandardisierte Formulare mit festem Layout
Smarte RedactionOpenCV/NER erkennt Textbereiche mit PII und entfernt gezieltFreitext-Dokumente, variable Layouts
+
+ +

5.3 Die Identitaets-Map: Nur der Kunde kennt die Zuordnung

+

+ Die Zuordnung doc_token → Originaldaten wird als verschluesselte Tabelle + gespeichert. Das Datenmodell sieht vereinfacht so aus: +

+ + +{`NamespaceSession +├── tenant_id = "kunde-firma-abc" ← Pflichtfeld (Isolation) +├── encrypted_identity_map = [verschluesselte Bytes] ← Nur mit Passphrase lesbar +├── identity_map_iv = "a3f2c1..." ← Initialisierungsvektor (fuer AES) +│ +└── PseudonymizedDocument (pro Dokument) + ├── doc_token = "a7f3c2d1-..." ← Zufaelliger Token (Primary Key) + ├── session_id = [Referenz] + └── (Kein Name, keine personenbezogenen Daten)`} + + + + Die Pseudonymisierung erfuellt die Definition der DSGVO: Personenbezogene Daten + koennen ohne Hinzuziehung zusaetzlicher Informationen + (der verschluesselten Identitaets-Map + der Passphrase des Kunden) nicht mehr einer + bestimmten Person zugeordnet werden. + + + {/* ============================================================ */} + {/* 6. VERSCHLUESSELUNG */} + {/* ============================================================ */} +

6. Ende-zu-Ende-Verschluesselung

+

+ Die Verschluesselung ist das Herzstueck des Datenschutzes. Sie findet vollstaendig + auf dem System des Kunden statt -- der Cloud-Server bekommt nur verschluesselte + Daten zu sehen. +

+ +

6.1 Der Verschluesselungsvorgang

+ + +{`┌─────────────────────────────────────────────────────────────────┐ +│ System des Kunden (SDK) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Kunde konfiguriert Passphrase im SDK │ +│ │ ↑ │ +│ │ │ Passphrase bleibt hier -- wird NIE gesendet │ +│ ▼ │ +│ 2. Schluessel-Ableitung: │ +│ PBKDF2-SHA256(Passphrase, zufaelliger Salt, 100.000 Runden) │ +│ │ │ +│ │ → Ergebnis: 256-Bit-Schluessel (32 Bytes) │ +│ │ → 100.000 Runden machen Brute-Force unpraktikabel │ +│ ▼ │ +│ 3. Verschluesselung: │ +│ AES-256-GCM(Schluessel, zufaelliger IV, Dokument) │ +│ │ │ +│ │ → AES-256: Militaerstandard, 2^256 moegliche Schluessel │ +│ │ → GCM: Garantiert Integritaet (Manipulation erkennbar) │ +│ ▼ │ +│ 4. Schluessel-Hash: │ +│ SHA-256(abgeleiteter Schluessel) → Hash fuer Verifikation │ +│ │ │ +│ │ → Server speichert nur diesen Hash │ +│ │ → Damit kann geprueft werden ob die Passphrase stimmt │ +│ │ → Vom Hash kann der Schluessel NICHT zurueckberechnet │ +│ │ werden │ +│ ▼ │ +│ 5. Upload: Nur diese Daten gehen an den Cloud-Server: │ +│ • Verschluesselter Blob (unlesbar ohne Schluessel) │ +│ • Salt (zufaellige Bytes, harmlos) │ +│ • IV (Initialisierungsvektor, harmlos) │ +│ • Schluessel-Hash (zur Verifikation, nicht umkehrbar) │ +│ │ +│ Was NICHT an den Server geht: │ +│ ✗ Passphrase │ +│ ✗ Abgeleiteter Schluessel │ +│ ✗ Unverschluesselter Klartext │ +└─────────────────────────────────────────────────────────────────┘`} + + +

6.2 Sicherheitsgarantien

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AngriffsszenarioWas der Angreifer siehtErgebnis
Cloud-Server wird gehacktVerschluesselte Blobs + HashesKeine lesbaren Dokumente
Datenbank wird geleaktencrypted_identity_map (verschluesselt)Keine personenbezogenen Daten
Netzwerkverkehr abgefangenVerschluesselte Daten (TLS + AES)Doppelt verschluesselt
Betreiber (Breakpilot) will mitlesenVerschluesselte Blobs, kein SchluesselOperator Blindness
Anderer Kunde versucht ZugriffNichts (Tenant-Isolation)Namespace blockiert
+
+ + {/* ============================================================ */} + {/* 7. NAMESPACE / TENANT-ISOLATION */} + {/* ============================================================ */} +

7. Namespace-Isolation: Jeder Kunde hat seinen eigenen Bereich

+

+ Ein Namespace (auch “Tenant” genannt) ist ein vollstaendig + abgeschotteter Bereich im System. Man kann es sich wie separate Tresorraeume + in einer Bank vorstellen: Jeder Kunde hat seinen eigenen Raum, und kein Schluessel + passt in einen anderen. +

+ +

7.1 Wie die Isolation funktioniert

+

+ Jeder Kunde erhaelt eine eindeutige tenant_id. Diese ID wird + bei jeder einzelnen Datenbankabfrage als Pflichtfilter verwendet: +

+ + +{`Kunde A (tenant_id: "firma-alpha-001") +├── Dokument 1 (verschluesselt) +├── Dokument 2 (verschluesselt) +└── Referenz: Pruefkriterien 2025 + +Kunde B (tenant_id: "firma-beta-002") +├── Dokument 1 (verschluesselt) +└── Referenz: Compliance-Vorgaben 2025 + +Suchanfrage von Kunde A: + "Welche Klauseln weichen von den Referenzkriterien ab?" + → Suche NUR in tenant_id = "firma-alpha-001" + → Kunde B's Daten sind UNSICHTBAR + +Jede Qdrant-Query hat diesen Pflichtfilter: + must_conditions = [ + FieldCondition(key="tenant_id", match="firma-alpha-001") + ] + +Es gibt KEINE Abfrage ohne tenant_id-Filter.`} + + +

7.2 Drei Ebenen der Isolation

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
EbeneSystemIsolation
DateisystemMinIO (S3-Storage)Eigener Bucket/Pfad pro Kunde: /tenant-id/doc-id/encrypted.bin
VektordatenbankQdrantPflichtfilter tenant_id bei jeder Suche
Metadaten-DBPostgreSQLJede Tabelle hat tenant_id als Pflichtfeld
+
+ + + Auf allen Vektoren in Qdrant ist das Flag training_allowed: false gesetzt. + Kundeninhalte werden ausschliesslich fuer RAG-Suchen innerhalb des + Kunden-Namespace verwendet und niemals zum Trainieren eines KI-Modells + eingesetzt. + + + {/* ============================================================ */} + {/* 8. RAG-PIPELINE */} + {/* ============================================================ */} +

8. RAG-Pipeline: KI-Verarbeitung mit Kundenkontext

+

+ Die KI nutzt die vom Kunden hochgeladenen Referenzdokumente als Wissensbasis. + Dieser Prozess heisst RAG (Retrieval Augmented Generation): + Die KI “liest” zuerst die relevanten Referenzen und generiert dann + kontextbezogene Ergebnisse. +

+ +

8.1 Indexierung der Referenzdokumente

+ +{`Referenzdokument (verschluesselt auf Server) + | + v +┌────────────────────────────────────┐ +│ 1. Passphrase-Verifikation │ ← SDK sendet Schluessel-Hash +│ Hash pruefen │ Server vergleicht mit gespeichertem Hash +└──────────┬─────────────────────────┘ + | + v +┌────────────────────────────────────┐ +│ 2. Entschluesselung │ ← Temporaer im Arbeitsspeicher +│ AES-256-GCM Decrypt │ (wird nach Verarbeitung geloescht) +└──────────┬─────────────────────────┘ + | + v +┌────────────────────────────────────┐ +│ 3. Text-Extraktion │ ← PDF → Klartext +│ Tabellen, Listen, Absaetze │ +└──────────┬─────────────────────────┘ + | + v +┌────────────────────────────────────┐ +│ 4. Chunking │ ← Text in ~1.000-Zeichen-Abschnitte +│ Ueberlappung: 200 Zeichen │ (mit Ueberlappung fuer Kontexterhalt) +└──────────┬─────────────────────────┘ + | + v +┌────────────────────────────────────┐ +│ 5. Embedding │ ← Jeder Abschnitt wird in einen +│ Text → 1.536 Zahlen │ Bedeutungsvektor umgewandelt +└──────────┬─────────────────────────┘ + | + v +┌────────────────────────────────────┐ +│ 6. Re-Encryption │ ← Jeder Chunk wird ERNEUT verschluesselt +│ AES-256-GCM pro Chunk │ bevor er gespeichert wird +└──────────┬─────────────────────────┘ + | + v +┌────────────────────────────────────┐ +│ 7. Qdrant-Indexierung │ ← Vektor + verschluesselter Chunk +│ tenant_id: "firma-alpha-001" │ werden mit Tenant-Filter gespeichert +│ training_allowed: false │ +└────────────────────────────────────┘`} + + +

8.2 Wie die KI eine Anfrage bearbeitet (RAG-Query)

+
    +
  1. + Anfrage formulieren: Das SDK sendet eine Suchanfrage mit dem + zu verarbeitenden Dokument und den gewuenschten Kriterien. +
  2. +
  3. + Semantische Suche: Die Anfrage wird in einen Vektor umgewandelt und + gegen die Referenz-Vektoren in Qdrant gesucht -- nur im Namespace des Kunden. +
  4. +
  5. + Entschluesselung: Die gefundenen Chunks werden mit der Passphrase + des Kunden entschluesselt. +
  6. +
  7. + KI-Antwort: Die entschluesselten Referenzpassagen werden als Kontext + an die KI uebergeben, die daraus ein Ergebnis generiert. +
  8. +
+ + {/* ============================================================ */} + {/* 9. KEY SHARING */} + {/* ============================================================ */} +

9. Key Sharing: Zusammenarbeit ermoeglichen

+

+ In vielen Geschaeftsprozessen muessen mehrere Personen oder Abteilungen auf die gleichen + Daten zugreifen -- z.B. fuer Vier-Augen-Prinzip, Qualitaetskontrolle oder externe Audits. + Das Key-Sharing-System ermoeglicht es dem Eigentuemer, seinen Namespace sicher mit + anderen zu teilen. +

+ +

9.1 Einladungs-Workflow

+ +{`Eigentuemer Server Eingeladener + │ │ │ + │ 1. Einladung senden │ │ + │ (E-Mail + Rolle + Scope) │ │ + │─────────────────────────────────▶ │ + │ │ │ + │ │ 2. Einladung erstellt │ + │ │ (14 Tage gueltig) │ + │ │ │ + │ │ 3. Benachrichtigung ──────▶│ + │ │ │ + │ │ 4. Einladung annehmen + │ │◀─────────────────────────────│ + │ │ │ + │ │ 5. Key-Share erstellt │ + │ │ (verschluesselte │ + │ │ Passphrase) │ + │ │ │ + │ │ 6. Eingeladener kann ──────▶│ + │ │ jetzt Daten im │ + │ │ Namespace abfragen │ + │ │ │ + │ 7. Zugriff widerrufen │ │ + │ (jederzeit moeglich) │ │ + │─────────────────────────────────▶ │ + │ │ Share deaktiviert │`} + + +

9.2 Rollen beim Key-Sharing

+
+ + + + + + + + + + + + + +
RolleTypischer NutzerRechte
OwnerProjektverantwortlicherVollzugriff, kann teilen & widerrufen
ReviewerQualitaetssicherungLesen, RAG-Queries, eigene Anmerkungen
AuditorExterner PrueferNur Lesen (Aufsichtsfunktion)
+
+ + {/* ============================================================ */} + {/* 10. AUDIT-TRAIL */} + {/* ============================================================ */} +

10. Audit-Trail: Vollstaendige Nachvollziehbarkeit

+

+ Jede Aktion im Namespace wird revisionssicher im Audit-Log gespeichert. + Das ist essenziell fuer Compliance-Anforderungen und externe Audits. +

+ +
+ + + + + + + + + + + + + + + + + +
EventWas protokolliert wird
uploadDokument hochgeladen (Dateigroesse, Metadaten, Zeitstempel)
indexReferenzdokument indexiert (Anzahl Chunks, Dauer)
rag_queryRAG-Suchanfrage ausgefuehrt (Query-Hash, Anzahl Ergebnisse)
analyzeKI-Verarbeitung gestartet (Dokument-Token, Modell, Dauer)
shareNamespace mit anderem Nutzer geteilt (Empfaenger, Rolle)
revoke_shareZugriff widerrufen (wer, wann)
decryptErgebnis entschluesselt (durch wen, Zeitstempel)
deleteDokument geloescht (Soft Delete, bleibt in Logs)
+
+ + {/* ============================================================ */} + {/* 11. API-ENDPUNKTE */} + {/* ============================================================ */} +

11. API-Endpunkte (SDK-Referenz)

+

+ Die folgenden Endpunkte sind ueber das SDK oder direkt via REST ansprechbar. + Authentifizierung erfolgt ueber API-Key + JWT-Token. +

+ +

11.1 Namespace-Verwaltung

+
+ + + + + + + + + + + + + + +
MethodeEndpunktBeschreibung
POST/api/v1/namespace/uploadVerschluesseltes Dokument hochladen
GET/api/v1/namespace/documentsEigene Dokumente auflisten
GET/api/v1/namespace/documents/{'{id}'}Einzelnes Dokument abrufen
DELETE/api/v1/namespace/documents/{'{id}'}Dokument loeschen (Soft Delete)
+
+ +

11.2 Referenzdokumente & RAG

+
+ + + + + + + + + + + + + + +
MethodeEndpunktBeschreibung
POST/api/v1/namespace/references/uploadReferenzdokument hochladen
POST/api/v1/namespace/references/{'{id}'}/indexReferenz fuer RAG indexieren
POST/api/v1/namespace/rag-queryRAG-Suchanfrage ausfuehren
POST/api/v1/namespace/analyzeKI-Verarbeitung anstossen
+
+ +

11.3 Key Sharing

+
+ + + + + + + + + + + + + + +
MethodeEndpunktBeschreibung
POST/api/v1/namespace/shareNamespace mit anderem Nutzer teilen
GET/api/v1/namespace/sharesAktive Shares auflisten
DELETE/api/v1/namespace/shares/{'{shareId}'}Zugriff widerrufen
GET/api/v1/namespace/shared-with-meMit mir geteilte Namespaces
+
+ + {/* ============================================================ */} + {/* 12. ZUSAMMENFASSUNG */} + {/* ============================================================ */} +

12. Zusammenfassung: Compliance-Garantien

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
GarantieWie umgesetztRegelwerk
Keine PII verlaesst das KundensystemHeader-Redaction + verschluesselte Identity-MapDSGVO Art. 4 Nr. 5
Betreiber kann nicht mitlesenClient-seitige AES-256-GCM VerschluesselungDSGVO Art. 32
Kein Zugriff durch andere KundenTenant-Isolation (Namespace) auf allen 3 EbenenDSGVO Art. 25
Kein KI-Training mit Kundendatentraining_allowed: false auf allen VektorenAI Act Art. 10
Alles nachvollziehbarVollstaendiger Audit-Trail aller AktionenDSGVO Art. 5 Abs. 2
Kunde behaelt volle KontrolleJederzeitiger Widerruf, Loeschung, DatenexportDSGVO Art. 17, 20
+
+ + + Die Namespace-Technologie ermoeglicht KI-gestuetzte Datenverarbeitung in der Cloud, bei der + keine personenbezogenen Daten das Kundensystem verlassen, alle Daten + Ende-zu-Ende verschluesselt sind, jeder Kunde seinen + eigenen abgeschotteten Namespace hat, und ein + vollstaendiger Audit-Trail jede Aktion dokumentiert. + +
+ ) +} diff --git a/developer-portal/app/development/docs/page.tsx b/developer-portal/app/development/docs/page.tsx new file mode 100644 index 0000000..44bd4f8 --- /dev/null +++ b/developer-portal/app/development/docs/page.tsx @@ -0,0 +1,891 @@ +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' + +export default function ComplianceServiceDocsPage() { + return ( + + {/* ============================================================ */} + {/* 1. EINLEITUNG */} + {/* ============================================================ */} +

1. Was ist der Compliance Hub?

+

+ Der BreakPilot Compliance Hub ist ein System, das Organisationen dabei + unterstuetzt, gesetzliche Vorschriften einzuhalten. Er beantwortet die zentrale Frage: +

+
+ “Duerfen wir das, was wir vorhaben, ueberhaupt so machen -- und wenn ja, welche + Auflagen muessen wir dafuer erfuellen?” +
+

+ Konkret geht es um EU- und deutsche Gesetze, die fuer den Umgang mit Daten und + kuenstlicher Intelligenz relevant sind: die DSGVO, den AI Act, + die NIS2-Richtlinie und viele weitere Regelwerke. Das System hat vier + Hauptaufgaben: +

+
    +
  1. + Wissen bereitstellen: Hunderte Rechtstexte sind eingelesen und + durchsuchbar -- nicht nur per Stichwort, sondern nach Bedeutung (semantische Suche). +
  2. +
  3. + Bewerten: Wenn ein Nutzer einen geplanten KI-Anwendungsfall beschreibt, + bewertet das System automatisch, ob er zulaessig ist, welches Risiko besteht und welche + Massnahmen noetig sind. +
  4. +
  5. + Dokumentieren: Das System erzeugt die Dokumente, die Aufsichtsbehoerden + verlangen: Datenschutz-Folgenabschaetzungen (DSFA), technisch-organisatorische Massnahmen + (TOM), Verarbeitungsverzeichnisse (VVT) und mehr. +
  6. +
  7. + Nachweisen: Jede Bewertung, jede Entscheidung und jeder Zugriff wird + revisionssicher protokolliert -- als Nachweis gegenueber Pruefer und Behoerden. +
  8. +
+ + + Die KI ist nicht die Entscheidungsinstanz. Alle + Compliance-Entscheidungen (zulaessig / bedingt zulaessig / nicht zulaessig) trifft ein + deterministisches Regelwerk. Das LLM (Sprachmodell) wird ausschliesslich dafuer verwendet, + Ergebnisse verstaendlich zu erklaeren -- niemals um sie zu treffen. + + + {/* ============================================================ */} + {/* 2. ARCHITEKTUR-UEBERSICHT */} + {/* ============================================================ */} +

2. Architektur im Ueberblick

+

+ Das System besteht aus mehreren Bausteinen, die jeweils eine klar abgegrenzte Aufgabe haben. + Man kann es sich wie ein Buero vorstellen: +

+ +
+ + + + + + + + + + + + + + + + + + + + +
BausteinAnalogieTechnologieAufgabe
API-GatewayEmpfang / RezeptionGo (Gin)Nimmt alle Anfragen entgegen, prueft Identitaet und leitet weiter
Compliance Engine (UCCA)SachbearbeiterGoBewertet Anwendungsfaelle gegen 45+ Regeln und berechnet Risikoscore
RAG ServiceRechtsbibliothekPython (FastAPI)Durchsucht Gesetze semantisch und beantwortet Rechtsfragen
Legal CorpusGesetzesbuecher im RegalYAML/JSON + QdrantEnthaelt alle Rechtstexte als durchsuchbare Wissensbasis
Policy EngineRegelbuch des SachbearbeitersYAML-Dateien45+ auditierbare Pruefregeln in maschinenlesbarer Form
Eskalations-SystemChef-UnterschriftGo + PostgreSQLLeitet kritische Faelle an menschliche Pruefer weiter
Admin DashboardSchreibtischNext.jsBenutzeroberflaeche fuer alle Funktionen
PostgreSQLAktenschrankSQL-DatenbankSpeichert Assessments, Eskalationen, Controls, Audit-Trail
QdrantSuchindex der BibliothekVektordatenbankErmoeglicht semantische Suche ueber Rechtstexte
+
+ +

Wie die Bausteine zusammenspielen

+ +{`Benutzer (Browser) + | + v +┌─────────────────────────────┐ +│ API-Gateway (Port 8080) │ ← Authentifizierung, Rate-Limiting, Tenant-Isolation +│ "Wer bist du? Darfst du?" │ +└──────────┬──────────────────┘ + | + ┌─────┼──────────────────────────────┐ + v v v +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Compliance │ │ RAG Service │ │ Security │ +│ Engine │ │ (Bibliothek)│ │ Scanner │ +│ (Bewertung) │ │ │ │ │ +└──────┬───┬──┘ └──────┬───────┘ └──────────────┘ + | | | + | | ┌──────┴───────┐ + | | │ Qdrant │ ← Vektordatenbank mit allen Rechtstexten + | | │ (Suchindex) │ + | | └──────────────┘ + | | + | └──────────────────────┐ + v v +┌──────────────┐ ┌──────────────┐ +│ PostgreSQL │ │ Eskalation │ +│ (Speicher) │ │ (E0-E3) │ +└──────────────┘ └──────────────┘`} + + + {/* ============================================================ */} + {/* 3. DER KATALOGMANAGER / LEGAL CORPUS */} + {/* ============================================================ */} +

3. Der Katalogmanager: Die Wissensbasis

+

+ Das Herzstueck des Systems ist seine Wissensbasis -- eine Sammlung aller + relevanten Rechtstexte, die das System kennt und durchsuchen kann. Wir nennen das den + Legal Corpus (wörtlich: “Rechtlicher Koerper”). +

+ +

3.1 Welche Dokumente sind enthalten?

+

+ Der Legal Corpus ist in zwei Hauptbereiche gegliedert: EU-Recht und + deutsches Recht. +

+ +

EU-Verordnungen und -Richtlinien

+
+ + + + + + + + + + + + + + + + + + + +
RegelwerkAbkuerzungArtikelGueltig seitThema
Datenschutz-GrundverordnungDSGVO9925.05.2018Schutz personenbezogener Daten
KI-VerordnungAI Act11301.08.2024Regulierung kuenstlicher Intelligenz
Netz- und InformationssicherheitNIS24618.10.2024Cybersicherheit kritischer Infrastrukturen
ePrivacy-VerordnungePrivacy--in ArbeitVertraulichkeit elektronischer Kommunikation
Cyber Resilience ActCRA--2024Cybersicherheit von Produkten mit digitalen Elementen
Data ActDA--2024Zugang und Nutzung von Daten
Digital Markets ActDMA--2023Regulierung digitaler Gatekeeper
+
+ +

Deutsches Recht

+
+ + + + + + + + + + + + + + +
GesetzAbkuerzungThema
Telekommunikation-Digitale-Dienste-Datenschutz-GesetzTDDDGDatenschutz bei Telekommunikation und digitalen Diensten
BundesdatenschutzgesetzBDSGNationale Ergaenzung zur DSGVO
IT-SicherheitsgesetzIT-SiGIT-Sicherheit kritischer Infrastrukturen
BSI-KritisVKritisVBSI-Verordnung fuer kritische Infrastrukturen
+
+ +

Standards und Normen

+
+ + + + + + + + + + + + + + +
StandardThema
ISO 27001Informationssicherheits-Managementsystem (ISMS)
SOC2Trust Service Criteria (Sicherheit, Verfuegbarkeit, Vertraulichkeit)
BSI GrundschutzIT-Grundschutz des BSI
BSI TR-03161Technische Richtlinie fuer Anforderungen an Anwendungen im Gesundheitswesen
SCC (Standard Contractual Clauses)Standardvertragsklauseln fuer Drittlandtransfers
+
+ +

3.2 Wie werden Rechtstexte gespeichert?

+

+ Jeder Rechtstext durchlaeuft eine Verarbeitungspipeline, bevor er im + System durchsuchbar ist. Der Vorgang laesst sich mit dem Erstellen eines + Bibliothekskatalogs vergleichen: +

+
    +
  1. + Erfassung (Ingestion): Der Rechtstext wird als Dokument (PDF, Markdown + oder Klartext) in das System geladen. Fuer jede Verordnung gibt es eine + metadata.json-Datei, die beschreibt, um welches Gesetz es sich handelt, + wie viele Artikel es hat und welche Schluesselbegriffe relevant sind. +
  2. +
  3. + Zerkleinerung (Chunking): Lange Gesetzestexte werden in kleinere + Abschnitte von ca. 512 Zeichen zerlegt. Dabei ueberlappen sich die Abschnitte um + 50 Zeichen, damit kein Kontext verloren geht. Stellen Sie sich vor, Sie zerschneiden + einen langen Brief in Absaetze, wobei jeder Absatz die letzten zwei Zeilen des + vorherigen enthaelt. +
  4. +
  5. + Vektorisierung (Embedding): Jeder Textabschnitt wird vom + Embedding-Modell BGE-M3 in einen Vektor umgewandelt -- eine + Liste von 1.024 Zahlen, die die Bedeutung des Textes repraesentieren. Texte + mit aehnlicher Bedeutung haben aehnliche Vektoren, unabhaengig von der Wortwahl. +
  6. +
  7. + Indexierung: Die Vektoren werden in der Vektordatenbank + Qdrant gespeichert. Zusammen mit jedem Vektor werden Metadaten + hinterlegt: zu welchem Gesetz der Text gehoert, welcher Artikel es ist und welcher + Paragraph. +
  8. +
+ + +{`Rechtstext (z.B. DSGVO Art. 32) + | + v +┌────────────────────────┐ +│ 1. Einlesen │ ← PDF/Markdown/Klartext + metadata.json +│ Metadaten zuordnen │ +└──────────┬─────────────┘ + | + v +┌────────────────────────┐ +│ 2. Chunking │ ← Text in 512-Zeichen-Abschnitte zerlegen +│ Ueberlappung: 50 Zch. │ (mit 50 Zeichen Ueberlappung) +└──────────┬─────────────┘ + | + v +┌────────────────────────┐ +│ 3. Embedding │ ← BGE-M3 wandelt Text in 1024 Zahlen um +│ Text → Vektor │ (Bedeutungs-Repraesentation) +└──────────┬─────────────┘ + | + v +┌────────────────────────┐ +│ 4. Qdrant speichern │ ← Vektor + Metadaten werden indexiert +│ Sofort durchsuchbar │ (~2.274 Chunks insgesamt) +└────────────────────────┘`} + + + + Der Legal Corpus enthaelt derzeit ca. 2.274 Textabschnitte aus ueber + 400 Gesetzesartikeln. Darunter 99 DSGVO-Artikel, 85 AI-Act-Artikel, 46 NIS2-Artikel, + 86 BDSG-Paragraphen sowie zahlreiche Artikel aus TDDDG, CRA, Data Act und weiteren + Regelwerken. + + +

3.3 Wie funktioniert die semantische Suche?

+

+ Klassische Suchmaschinen suchen nach Woertern. Wenn Sie “Einwilligung” + eingeben, finden sie nur Texte, die genau dieses Wort enthalten. Unsere semantische Suche + funktioniert anders: Sie sucht nach Bedeutung. +

+

+ Beispiel: Wenn Sie fragen “Wann muss ich den Nutzer um Erlaubnis + bitten?”, findet das System Art. 7 DSGVO (Bedingungen fuer die Einwilligung), obwohl + Ihre Frage das Wort “Einwilligung” gar nicht enthaelt. Das funktioniert, weil + die Bedeutungsvektoren von “um Erlaubnis bitten” und “Einwilligung” + sehr aehnlich sind. +

+

Der Suchvorgang im Detail:

+
    +
  1. Ihre Suchanfrage wird vom gleichen Modell (BGE-M3) in einen Vektor umgewandelt.
  2. +
  3. Qdrant vergleicht diesen Vektor mit allen gespeicherten Vektoren (Kosinus-Aehnlichkeit).
  4. +
  5. Die aehnlichsten Textabschnitte werden zurueckgegeben, sortiert nach Relevanz (Score 0-1).
  6. +
  7. Optional kann nach bestimmten Gesetzen gefiltert werden (nur DSGVO, nur AI Act, etc.).
  8. +
+ +

3.4 Der KI-Rechtsassistent (Legal Q&A)

+

+ Ueber die reine Suche hinaus kann das System auch Fragen beantworten. + Dabei wird die semantische Suche mit einem Sprachmodell kombiniert: +

+
    +
  1. Suche: Das System findet die 5 relevantesten Gesetzesabschnitte zur Frage.
  2. +
  3. Kontext-Erstellung: Diese Abschnitte werden zusammen mit der Frage an das Sprachmodell (Qwen 2.5 32B) uebergeben.
  4. +
  5. Antwort-Generierung: Das Modell formuliert eine verstaendliche Antwort auf Deutsch und zitiert die verwendeten Rechtsquellen.
  6. +
  7. Quellenangabe: Jede Antwort enthaelt exakte Zitate mit Artikelangaben, damit die Aussagen nachpruefbar sind.
  8. +
+ + + Der Rechtsassistent gibt keine Rechtsberatung. Er hilft, relevante + Gesetzespassagen zu finden und verstaendlich zusammenzufassen. Die Antworten enthalten + immer einen Confidence-Score (0-1), der angibt, wie sicher sich das System ist. Bei + niedrigem Score wird explizit auf die Unsicherheit hingewiesen. + + + {/* ============================================================ */} + {/* 4. DIE COMPLIANCE ENGINE (UCCA) */} + {/* ============================================================ */} +

4. Die Compliance Engine: Wie Bewertungen funktionieren

+

+ Das Kernmodul des Compliance Hub ist die UCCA Engine (Unified Compliance + Control Assessment). Sie bewertet, ob ein geplanter KI-Anwendungsfall zulaessig ist. +

+ +

4.1 Der Fragebogen (Use Case Intake)

+

+ Alles beginnt mit einem strukturierten Fragebogen. Der Nutzer beschreibt seinen geplanten + Anwendungsfall, indem er Fragen zu folgenden Bereichen beantwortet: +

+
+ + + + + + + + + + + + + + + + + + +
BereichTypische FragenWarum relevant?
DatentypenWerden personenbezogene Daten verarbeitet? Besondere Kategorien (Art. 9)?Art. 9-Daten (Gesundheit, Religion, etc.) erfordern besondere Schutzmassnahmen
VerarbeitungszweckWird Profiling betrieben? Scoring? Automatisierte Entscheidungen?Art. 22 DSGVO schuetzt vor vollautomatischen Entscheidungen
ModellnutzungWird das Modell nur genutzt (Inference) oder mit Nutzerdaten trainiert (Fine-Tuning)?Training mit personenbezogenen Daten erfordert besondere Rechtsgrundlage
AutomatisierungsgradAssistenzsystem, teil- oder vollautomatisch?Vollautomatische Systeme unterliegen strengeren Auflagen
DatenspeicherungWie lange werden Daten gespeichert? Wo?DSGVO Art. 5: Speicherbegrenzung / Zweckbindung
Hosting-StandortEU, USA, oder anderswo?Drittlandtransfers erfordern zusaetzliche Garantien (SCC, DPF)
BrancheGesundheit, Finanzen, Bildung, Automotive, ...?Bestimmte Branchen unterliegen zusaetzlichen Regulierungen
Menschliche AufsichtGibt es einen Human-in-the-Loop?AI Act fordert menschliche Aufsicht fuer Hochrisiko-KI
+
+ +

4.2 Die Pruefregeln (Policy Engine)

+

+ Die Antworten des Fragebogens werden gegen ein Regelwerk von ueber 45 Regeln + geprueft. Jede Regel ist in einer YAML-Datei definiert und hat folgende Struktur: +

+
    +
  • Bedingung: Wann greift die Regel? (z.B. “Art. 9-Daten werden verarbeitet”)
  • +
  • Schweregrad: INFO (Hinweis), WARN (Risiko, aber loesbar) oder BLOCK (grundsaetzlich nicht zulaessig)
  • +
  • Auswirkung: Was passiert, wenn die Regel greift? (Risikoerhoehung, zusaetzliche Controls, Eskalation)
  • +
  • Gesetzesreferenz: Auf welchen Artikel bezieht sich die Regel?
  • +
+ +

Die Regeln sind in 10 Kategorien organisiert:

+
+ + + + + + + + + + + + + + + + + + + + + +
KategorieRegel-IDsPrueftBeispiel
A. DatenklassifikationR-001 bis R-006Welche Daten werden verarbeitet?R-001: Werden personenbezogene Daten verarbeitet? → +10 Risiko
B. Zweck & KontextR-010 bis R-013Warum und wie werden Daten genutzt?R-011: Profiling? → DSFA empfohlen
C. AutomatisierungR-020 bis R-025Wie stark ist die Automatisierung?R-023: Vollautomatisch? → Art. 22 Risiko
D. Training vs. NutzungR-030 bis R-035Wird das Modell trainiert?R-035: Training + Art. 9-Daten? → BLOCK
E. SpeicherungR-040 bis R-042Wie lange werden Daten gespeichert?R-041: Unbegrenzte Speicherung? → WARN
F. HostingR-050 bis R-052Wo werden Daten gehostet?R-051: Hosting in USA? → SCC/DPF pruefen
G. TransparenzR-060 bis R-062Werden Nutzer informiert?R-060: Keine Offenlegung? → AI Act Verstoss
H. BranchenspezifischR-070 bis R-074Gelten Sonderregeln fuer die Branche?R-070: Gesundheitsbranche? → zusaetzliche Anforderungen
I. AggregationR-090 bis R-092Meta-Regeln ueber andere RegelnR-090: Zu viele WARN-Regeln? → Gesamtrisiko erhoeht
J. ErklaerungR-100Warum hat das System so entschieden?Automatisch generierte Begruendung
+
+ + + Die Regeln sind bewusst in YAML-Dateien definiert und nicht im Programmcode versteckt. + Das hat zwei Vorteile: (1) Sie sind fuer Nicht-Programmierer lesbar und damit + auditierbar, d.h. ein Datenschutzbeauftragter oder Wirtschaftspruefer kann + pruefen, ob die Regeln korrekt sind. (2) Sie koennen versioniert werden -- + wenn sich ein Gesetz aendert, wird die Regelaenderung im Versionsverlauf sichtbar. + + +

4.3 Das Ergebnis: Die Compliance-Bewertung

+

+ Nach der Pruefung aller Regeln erhaelt der Nutzer eine strukturierte Bewertung: +

+
+ + + + + + + + + + + + + + + + + + + + +
ErgebnisBeschreibung
Machbarkeit + YES + CONDITIONAL + NO +
Risikoscore0-100 Punkte. Je hoeher, desto mehr Massnahmen sind erforderlich.
RisikostufeMINIMAL / LOW / MEDIUM / HIGH / UNACCEPTABLE
Ausgeloeste RegelnListe aller Regeln, die angeschlagen haben, mit Schweregrad und Gesetzesreferenz
Erforderliche ControlsKonkrete Massnahmen, die umgesetzt werden muessen (z.B. Verschluesselung, Einwilligung einholen)
Empfohlene ArchitekturTechnische Muster, die eingesetzt werden sollten (z.B. On-Premise statt Cloud)
Verbotene MusterTechnische Ansaetze, die vermieden werden muessen
DSFA erforderlich?Ob eine Datenschutz-Folgenabschaetzung nach Art. 35 DSGVO durchgefuehrt werden muss
+
+ + +{`Anwendungsfall: "Chatbot fuer Kundenservice mit Zugriff auf Bestellhistorie" + +Machbarkeit: CONDITIONAL (bedingt zulaessig) +Risikoscore: 35/100 (LOW) +Risikostufe: LOW + +Ausgeloeste Regeln: + R-001 WARN Personenbezogene Daten werden verarbeitet (Art. 6 DSGVO) + R-010 INFO Verarbeitungszweck: Kundenservice (Art. 5 DSGVO) + R-020 INFO Assistenzsystem (nicht vollautomatisch) (Art. 22 DSGVO) + R-060 WARN Nutzer muessen ueber KI-Nutzung informiert werden (AI Act Art. 52) + +Erforderliche Controls: + C_EXPLICIT_CONSENT Einwilligung fuer Chatbot-Nutzung einholen + C_TRANSPARENCY Hinweis "Sie sprechen mit einer KI" + C_DATA_MINIMIZATION Nur notwendige Bestelldaten abrufen + +DSFA erforderlich: Nein (Risikoscore unter 40) +Eskalation: E0 (keine manuelle Pruefung noetig)`} + + + {/* ============================================================ */} + {/* 5. DAS ESKALATIONS-SYSTEM */} + {/* ============================================================ */} +

5. Das Eskalations-System: Wann Menschen entscheiden

+

+ Nicht jede Bewertung ist eindeutig. Fuer heikle Faelle gibt es ein abgestuftes + Eskalations-System, das sicherstellt, dass die richtigen Menschen die endgueltige + Entscheidung treffen. +

+ +
+ + + + + + + + + + + + + + + + +
StufeWann?Wer prueft?Frist (SLA)Beispiel
E0Nur INFO-Regeln, Risiko < 20Niemand (automatisch freigegeben)--Spam-Filter ohne personenbezogene Daten
E1WARN-Regeln, Risiko 20-39Teamleiter24 StundenChatbot mit Kundendaten (unser Beispiel oben)
E2Art. 9-Daten ODER Risiko 40-59 ODER DSFA empfohlenDatenschutzbeauftragter (DSB)8 StundenKI-System, das Gesundheitsdaten verarbeitet
E3BLOCK-Regel ODER Risiko ≥ 60 ODER Art. 22-RisikoDSB + Rechtsabteilung4 StundenVollautomatische Kreditentscheidung
+
+ +

+ Zuweisung: Die Zuweisung erfolgt automatisch an den Pruefer mit der + geringsten aktuellen Arbeitslast (Workload-basiertes Round-Robin). Jeder Pruefer hat eine + konfigurierbare Obergrenze fuer gleichzeitige Reviews (z.B. 10 fuer Teamleiter, 5 fuer DSB, + 3 fuer Rechtsabteilung). +

+

+ Entscheidung: Der Pruefer kann den Anwendungsfall freigeben, + ablehnen, mit Auflagen freigeben oder weiter eskalieren. + Jede Entscheidung wird mit Begruendung im Audit-Trail gespeichert. +

+ + {/* ============================================================ */} + {/* 6. CONTROLS, EVIDENCE & RISIKEN */} + {/* ============================================================ */} +

6. Controls, Nachweise und Risiken

+ +

6.1 Was sind Controls?

+

+ Ein Control ist eine konkrete Massnahme, die eine Organisation umsetzt, + um ein Compliance-Risiko zu beherrschen. Es gibt drei Arten: +

+
    +
  • Technische Controls: Verschluesselung, Zugangskontrollen, Firewalls, Pseudonymisierung
  • +
  • Organisatorische Controls: Schulungen, Richtlinien, Verantwortlichkeiten, Audits
  • +
  • Physische Controls: Zutrittskontrolle zu Serverraeumen, Schliesssysteme
  • +
+

+ Der Compliance Hub verwaltet einen Katalog von ueber 100 vordefinierten Controls, + die in 9 Domaenen organisiert sind: +

+
+
+ {[ + { code: 'AC', name: 'Zugriffsmanagement', desc: 'Wer darf was?' }, + { code: 'DP', name: 'Datenschutz', desc: 'Schutz personenbezogener Daten' }, + { code: 'NS', name: 'Netzwerksicherheit', desc: 'Sichere Kommunikation' }, + { code: 'IR', name: 'Incident Response', desc: 'Reaktion auf Sicherheitsvorfaelle' }, + { code: 'BC', name: 'Business Continuity', desc: 'Geschaeftskontinuitaet' }, + { code: 'VM', name: 'Vendor Management', desc: 'Dienstleister-Steuerung' }, + { code: 'AM', name: 'Asset Management', desc: 'Verwaltung von IT-Werten' }, + { code: 'CR', name: 'Kryptographie', desc: 'Verschluesselung & Schluessel' }, + { code: 'PS', name: 'Physische Sicherheit', desc: 'Gebaeude & Hardware' }, + ].map(d => ( +
+
{d.code}
+
{d.name}
+
{d.desc}
+
+ ))} +
+
+ +

6.2 Wie Controls mit Gesetzen verknuepft sind

+

+ Jeder Control ist mit einem oder mehreren Gesetzesartikeln verknuepft. Diese + Mappings machen sichtbar, warum eine Massnahme erforderlich ist: +

+ + +{`Control: AC-01 (Zugriffskontrolle) +├── DSGVO Art. 32 → "Sicherheit der Verarbeitung" +├── NIS2 Art. 21 → "Massnahmen zum Management von Cyberrisiken" +├── ISO 27001 A.9 → "Zugangskontrolle" +└── BSI Grundschutz → "ORP.4 Identitaets- und Berechtigungsmanagement" + +Control: DP-03 (Datenverschluesselung) +├── DSGVO Art. 32 → "Verschluesselung personenbezogener Daten" +├── DSGVO Art. 34 → "Benachrichtigung ueber Datenverletzung" (Ausnahme bei Verschluesselung) +└── NIS2 Art. 21 → "Einsatz von Kryptographie"`} + + +

6.3 Evidence (Nachweise)

+

+ Ein Control allein genuegt nicht -- man muss auch nachweisen, dass er + umgesetzt wurde. Das System verwaltet verschiedene Nachweis-Typen: +

+
    +
  • Zertifikate: ISO 27001-Zertifikat, SOC2-Report
  • +
  • Richtlinien: Interne Datenschutzrichtlinie, Passwort-Policy
  • +
  • Audit-Berichte: Ergebnisse interner oder externer Pruefungen
  • +
  • Screenshots / Konfigurationen: Nachweis technischer Umsetzung
  • +
+

+ Jeder Nachweis hat ein Ablaufdatum. Das System warnt automatisch, + wenn Nachweise bald ablaufen (z.B. ein ISO-Zertifikat, das in 3 Monaten erneuert werden muss). +

+ +

6.4 Risikobewertung

+

+ Risiken werden in einer 5x5-Risikomatrix dargestellt. Die beiden Achsen sind: +

+
    +
  • Eintrittswahrscheinlichkeit: Wie wahrscheinlich ist es, dass das Risiko eintritt?
  • +
  • Auswirkung: Wie schwerwiegend waeren die Folgen?
  • +
+

+ Aus der Kombination ergibt sich die Risikostufe: Minimal, Low, + Medium, High oder Critical. Fuer jedes identifizierte Risiko + wird dokumentiert, welche Controls es abmildern und wer dafuer verantwortlich ist. +

+ + {/* ============================================================ */} + {/* 7. OBLIGATIONS FRAMEWORK */} + {/* ============================================================ */} +

7. Pflichten-Ableitung: Welche Gesetze gelten fuer mich?

+

+ Nicht jedes Gesetz gilt fuer jede Organisation. Das Obligations Framework + ermittelt automatisch, welche konkreten Pflichten sich aus der Situation einer Organisation + ergeben. Dafuer werden “Fakten” ueber die Organisation gesammelt und gegen die + Anwendbarkeitsbedingungen der einzelnen Gesetze geprueft. +

+ +

Beispiel: NIS2-Anwendbarkeit

+ +{`Ist Ihr Unternehmen in einem der NIS2-Sektoren taetig? +(Energie, Transport, Banken, Gesundheit, Wasser, Digitale Infrastruktur, ...) + │ + ├── Nein → NIS2 gilt NICHT fuer Sie + │ + └── Ja → Wie gross ist Ihr Unternehmen? + │ + ├── >= 250 Mitarbeiter ODER >= 50 Mio. EUR Umsatz + │ → ESSENTIAL ENTITY (wesentliche Einrichtung) + │ → Volle NIS2-Pflichten, strenge Aufsicht + │ → Bussgelder bis 10 Mio. EUR oder 2% Jahresumsatz + │ + ├── >= 50 Mitarbeiter ODER >= 10 Mio. EUR Umsatz + │ → IMPORTANT ENTITY (wichtige Einrichtung) + │ → NIS2-Pflichten, reaktive Aufsicht + │ → Bussgelder bis 7 Mio. EUR oder 1,4% Jahresumsatz + │ + └── Kleiner → NIS2 gilt grundsaetzlich NICHT + (Ausnahmen fuer bestimmte Sektoren moeglich)`} + + +

+ Aehnliche Entscheidungsbaeume existieren fuer DSGVO (Verarbeitung personenbezogener Daten?), + AI Act (KI-System im Einsatz? Welche Risikokategorie?) und alle anderen Regelwerke. + Das System leitet daraus konkrete Pflichten ab -- z.B. “Meldepflicht bei + Sicherheitsvorfaellen innerhalb von 72 Stunden” oder “Ernennung eines + Datenschutzbeauftragten”. +

+ + {/* ============================================================ */} + {/* 8. DSGVO-MODULE */} + {/* ============================================================ */} +

8. DSGVO-Compliance-Module im Detail

+

+ Fuer die Einhaltung der DSGVO bietet der Compliance Hub spezialisierte Module: +

+ +

8.1 Consent Management (Einwilligungsverwaltung)

+

+ Verwaltet die Einwilligung von Nutzern gemaess Art. 6/7 DSGVO. Jede Einwilligung wird + protokolliert: wer hat wann, auf welchem Kanal, fuer welchen Zweck zugestimmt (oder + abgelehnt)? Einwilligungen koennen jederzeit widerrufen werden, der Widerruf wird ebenfalls + dokumentiert. +

+

+ Zwecke: Essential (funktionsnotwendig), Functional, Analytics, Marketing, + Personalization, Third-Party. +

+ +

8.2 DSR Management (Betroffenenrechte)

+

+ Verwaltet Antraege betroffener Personen nach Art. 15-21 DSGVO: Auskunft, Berichtigung, + Loeschung, Datenportabilitaet, Einschraenkung und Widerspruch. Das System ueberwacht die + 30-Tage-Frist (Art. 12) und eskaliert automatisch, wenn Fristen drohen + zu verstreichen. +

+ +

8.3 VVT (Verzeichnis von Verarbeitungstaetigkeiten)

+

+ Dokumentiert alle Datenverarbeitungen gemaess Art. 30 DSGVO: Welche Daten werden fuer + welchen Zweck, auf welcher Rechtsgrundlage, wie lange und von wem verarbeitet? Jede + Verarbeitungstaetigkeit wird mit ihren Datenkategorien, Empfaengern und + Loeschfristen erfasst. +

+ +

8.4 DSFA (Datenschutz-Folgenabschaetzung)

+

+ Wenn eine Datenverarbeitung voraussichtlich ein hohes Risiko fuer die Rechte natuerlicher + Personen mit sich bringt, ist eine DSFA nach Art. 35 DSGVO Pflicht. Das System unterstuetzt + den Prozess: Risiken identifizieren, bewerten, Gegenmassnahmen definieren und das Ergebnis + dokumentieren. +

+ +

8.5 TOM (Technisch-Organisatorische Massnahmen)

+

+ Dokumentiert die Schutzmassnahmen nach Art. 32 DSGVO. Fuer jede Massnahme wird erfasst: + Kategorie (z.B. Verschluesselung, Zugriffskontrolle), Status (implementiert / in + Bearbeitung / geplant), Verantwortlicher und Nachweise. +

+ +

8.6 Loeschkonzept

+

+ Verwaltet Aufbewahrungsfristen und automatische Loeschung gemaess Art. 5/17 DSGVO. + Fuer jede Datenkategorie wird definiert: wie lange darf sie gespeichert werden, wann muss + sie geloescht werden und wie (z.B. Ueberschreiben, Schluesselloeschung bei verschluesselten + Daten). +

+ + {/* ============================================================ */} + {/* 9. MULTI-TENANCY & ZUGRIFFSKONTROLLE */} + {/* ============================================================ */} +

9. Multi-Tenancy und Zugriffskontrolle

+

+ Das System ist mandantenfaehig (Multi-Tenant): Mehrere Organisationen + koennen es gleichzeitig nutzen, ohne dass sie gegenseitig auf ihre Daten zugreifen koennen. + Jede Anfrage enthaelt eine Tenant-ID, und die Datenbank-Abfragen filtern automatisch nach + dieser ID. +

+ +

9.1 Rollenbasierte Zugriffskontrolle (RBAC)

+

+ Innerhalb eines Mandanten gibt es verschiedene Rollen mit unterschiedlichen Berechtigungen: +

+
+ + + + + + + + + + + + + + +
RolleDarf
MitarbeiterAnwendungsfaelle einreichen, eigene Bewertungen einsehen
TeamleiterE1-Eskalationen pruefen, Team-Assessments einsehen
DSB (Datenschutzbeauftragter)E2/E3-Eskalationen pruefen, alle Assessments einsehen, Policies aendern
RechtsabteilungE3-Eskalationen pruefen, Grundsatzentscheidungen
AdministratorSystem konfigurieren, Nutzer verwalten, LLM-Policies festlegen
+
+ +

9.2 PII-Erkennung und -Schutz

+

+ Bevor Texte an ein Sprachmodell gesendet werden, durchlaufen sie eine automatische + PII-Erkennung (Personally Identifiable Information). Das System erkennt + ueber 20 Arten personenbezogener Daten: +

+
    +
  • E-Mail-Adressen, Telefonnummern, Postanschriften
  • +
  • Sozialversicherungsnummern, Kreditkartennummern
  • +
  • Personennamen, IP-Adressen
  • +
  • und weitere...
  • +
+

+ Je nach Konfiguration werden erkannte PII-Daten geschwuerzt (durch + Platzhalter ersetzt), maskiert (nur Anfang/Ende sichtbar) oder nur im + Audit-Log markiert. +

+ + {/* ============================================================ */} + {/* 10. LLM-NUTZUNG */} + {/* ============================================================ */} +

10. Wie das System KI nutzt (und wie nicht)

+

+ Der Compliance Hub setzt kuenstliche Intelligenz gezielt und kontrolliert ein. Es gibt + eine klare Trennung zwischen dem, was die KI tut, und dem, was sie nicht tun darf: +

+ +
+ + + + + + + + + + + + + + + + + + +
AufgabeEntschieden vonRolle der KI
Machbarkeit (YES/CONDITIONAL/NO)Deterministische RegelnKeine
Risikoscore berechnenRegelbasierte BerechnungKeine
Eskalation ausloesenSchwellenwerte + RegellogikKeine
Controls zuordnenRegel-zu-Control-MappingKeine
Ergebnis erklaeren--LLM + RAG-Kontext
Verbesserungsvorschlaege--LLM
Rechtsfragen beantworten--LLM + RAG (Rechtskorpus)
Dokumente generieren (DSFA, TOM, VVT)--LLM + Vorlagen
+
+ +

LLM-Provider und Fallback

+

+ Das System unterstuetzt mehrere KI-Anbieter mit automatischem Fallback: +

+
    +
  1. Primaer: Ollama (lokal) -- Qwen 2.5 32B bzw. Mistral, laeuft direkt auf dem Server. Keine Daten verlassen das lokale Netzwerk.
  2. +
  3. Fallback: Anthropic Claude -- Wird nur aktiviert, wenn das lokale Modell nicht verfuegbar ist.
  4. +
+

+ Jeder LLM-Aufruf wird im Audit-Trail protokolliert: Prompt-Hash (SHA-256), verwendetes + Modell, Antwortzeit und ob PII erkannt wurde. +

+ + {/* ============================================================ */} + {/* 11. AUDIT-TRAIL */} + {/* ============================================================ */} +

11. Audit-Trail: Alles wird protokolliert

+

+ Saemtliche Aktionen im System werden revisionssicher protokolliert: +

+
    +
  • Jede Compliance-Bewertung mit allen Ein- und Ausgaben
  • +
  • Jede Eskalationsentscheidung mit Begruendung
  • +
  • Jeder LLM-Aufruf (wer hat was wann gefragt, welches Modell wurde verwendet)
  • +
  • Jede Aenderung an Controls, Evidence und Policies
  • +
  • Jeder Login und Daten-Export
  • +
+

+ Der Audit-Trail kann als PDF, CSV oder JSON exportiert werden und dient als + Nachweis gegenueber Aufsichtsbehoerden, Wirtschaftspruefern und internen Revisoren. +

+ + + Der Use-Case-Text (die Beschreibung des Anwendungsfalls) wird + nur mit Einwilligung des Nutzers gespeichert. Standardmaessig wird nur + ein SHA-256-Hash des Textes gespeichert -- damit kann nachgewiesen werden, dass + ein bestimmter Text bewertet wurde, ohne den Text selbst preiszugeben. + + + {/* ============================================================ */} + {/* 12. SECURITY SCANNER */} + {/* ============================================================ */} +

12. Security Scanner: Technische Sicherheitspruefung

+

+ Ergaenzend zur rechtlichen Compliance prueft der Security Scanner die + technische Sicherheit: +

+
    +
  • Container-Scanning (Trivy): Prueft Docker-Images auf bekannte Schwachstellen (CVEs)
  • +
  • Statische Code-Analyse (Semgrep): Sucht im Quellcode nach Sicherheitsluecken (SQL Injection, XSS, etc.)
  • +
  • Secret Detection (Gitleaks): Findet versehentlich eingecheckte Passwoerter, API-Keys und Tokens
  • +
  • SBOM-Generierung: Erstellt eine Software Bill of Materials -- eine vollstaendige Liste aller verwendeten Bibliotheken und deren Lizenzen
  • +
+

+ Gefundene Schwachstellen werden nach Schweregrad (Critical, High, Medium, Low) klassifiziert + und koennen direkt im System nachverfolgt und behoben werden. +

+ + {/* ============================================================ */} + {/* 13. ZUSAMMENFASSUNG */} + {/* ============================================================ */} +

13. Zusammenfassung: Der komplette Datenfluss

+

+ Hier ist der gesamte Prozess von Anfang bis Ende: +

+ + +{`SCHRITT 1: FAKTEN SAMMELN +━━━━━━━━━━━━━━━━━━━━━━━━ +Nutzer fuellt Fragebogen aus: + → Welche Daten? Welcher Zweck? Welche Branche? Wo gehostet? + +SCHRITT 2: ANWENDBARKEIT PRUEFEN +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Obligations Framework ermittelt: + → DSGVO betroffen? → Ja (personenbezogene Daten) + → AI Act betroffen? → Ja (KI-System) + → NIS2 betroffen? → Nein (< 50 Mitarbeiter, kein KRITIS-Sektor) + +SCHRITT 3: REGELN PRUEFEN +━━━━━━━━━━━━━━━━━━━━━━━━ +Policy Engine wertet 45+ Regeln aus: + → R-001 (WARN): Personenbezogene Daten +10 Risiko + → R-020 (INFO): Assistenzsystem +0 Risiko + → R-060 (WARN): KI-Transparenz fehlt +15 Risiko + → ... + → Gesamt-Risikoscore: 35/100 (LOW) + → Machbarkeit: CONDITIONAL + +SCHRITT 4: CONTROLS ZUORDNEN +━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Jede ausgeloeste Regel triggert Controls: + → C_EXPLICIT_CONSENT: Einwilligung einholen + → C_TRANSPARENCY: KI-Nutzung offenlegen + → C_DATA_MINIMIZATION: Datenminimierung + +SCHRITT 5: ESKALATION (bei Bedarf) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Score 35 → Stufe E1 → Teamleiter wird benachrichtigt + → SLA: 24 Stunden fuer Pruefung + → Entscheidung: Freigabe mit Auflagen + +SCHRITT 6: ERKLAERUNG GENERIEREN +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +LLM + RAG erstellen verstaendliche Erklaerung: + → Suche relevante Gesetzesartikel (Qdrant) + → Generiere Erklaerungstext (Qwen 2.5) + → Fuege Zitate und Quellen hinzu + +SCHRITT 7: DOKUMENTATION +━━━━━━━━━━━━━━━━━━━━━━━ +System erzeugt erforderliche Dokumente: + → DSFA (falls empfohlen) + → TOM-Dokumentation + → VVT-Eintrag + → Compliance-Report (PDF/ZIP/JSON) + +SCHRITT 8: MONITORING +━━━━━━━━━━━━━━━━━━━━ +Laufende Ueberwachung: + → Controls werden regelmaessig geprueft + → Nachweise werden auf Ablauf ueberwacht + → Gesetzesaenderungen fliessen in den Corpus ein`} + + + + Der Compliance Hub nimmt die Beschreibung eines KI-Vorhabens entgegen, prueft es gegen + ueber 45 deterministische Regeln und 400+ Gesetzesartikel, berechnet ein Risiko, ordnet + Massnahmen zu, eskaliert bei Bedarf an menschliche Pruefer und dokumentiert alles + revisionssicher -- wobei die KI nur fuer Erklaerungen und Zusammenfassungen eingesetzt wird, + niemals fuer die eigentliche Compliance-Entscheidung. + +
+ ) +} diff --git a/developer-portal/app/getting-started/page.tsx b/developer-portal/app/getting-started/page.tsx new file mode 100644 index 0000000..2be74c7 --- /dev/null +++ b/developer-portal/app/getting-started/page.tsx @@ -0,0 +1,203 @@ +import Link from 'next/link' +import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/DevPortalLayout' + +export default function GettingStartedPage() { + return ( + +

1. Installation

+

+ Installieren Sie das SDK über Ihren bevorzugten Paketmanager: +

+ +{`npm install @breakpilot/compliance-sdk +# oder +yarn add @breakpilot/compliance-sdk +# oder +pnpm add @breakpilot/compliance-sdk`} + + +

2. API Key erhalten

+

+ Nach dem Abo-Abschluss erhalten Sie Ihren API Key im{' '} + + Einstellungsbereich + . +

+ + + Speichern Sie den API Key niemals im Frontend-Code. Verwenden Sie + Umgebungsvariablen auf dem Server. + + +

3. Provider einrichten

+

+ Wrappen Sie Ihre App mit dem SDKProvider: +

+ +{`import { SDKProvider } from '@breakpilot/compliance-sdk' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + ) +}`} + + +

Provider Props

+ + +

4. SDK verwenden

+

+ Nutzen Sie den useSDK Hook in Ihren Komponenten: +

+ +{`'use client' + +import { useSDK } from '@breakpilot/compliance-sdk' + +export function ComplianceDashboard() { + const { + state, + completionPercentage, + goToStep, + currentStep, + } = useSDK() + + return ( +
+

+ Compliance Fortschritt: {completionPercentage}% +

+ +
+

Aktueller Schritt: {currentStep?.name}

+

Phase: {state.currentPhase}

+

Use Cases: {state.useCases.length}

+
+ +
+ + +
+
+ ) +}`} +
+ +

5. Erste Schritte im Workflow

+

+ Das SDK führt Sie durch einen 19-Schritte-Workflow in 2 Phasen: +

+ +
+
+
+

Phase 1: Assessment

+
    +
  1. Use Case Workshop
  2. +
  3. System Screening
  4. +
  5. Compliance Modules
  6. +
  7. Requirements
  8. +
  9. Controls
  10. +
  11. Evidence
  12. +
  13. Audit Checklist
  14. +
  15. Risk Matrix
  16. +
+
+
+

Phase 2: Dokumentation

+
    +
  1. AI Act Klassifizierung
  2. +
  3. Pflichtenübersicht
  4. +
  5. DSFA
  6. +
  7. TOMs
  8. +
  9. Löschfristen
  10. +
  11. VVT
  12. +
  13. Rechtliche Vorlagen
  14. +
  15. Cookie Banner
  16. +
  17. Einwilligungen
  18. +
  19. DSR Portal
  20. +
  21. Escalations
  22. +
+
+
+
+ +

6. Nächste Schritte

+
    +
  • + + SDK Konfiguration + + {' '}- Alle Konfigurationsoptionen +
  • +
  • + + State API + + {' '}- Verstehen Sie das State Management +
  • +
  • + + Phase 1 Guide + + {' '}- Kompletter Workflow für das Assessment +
  • +
+
+ ) +} diff --git a/developer-portal/app/globals.css b/developer-portal/app/globals.css new file mode 100644 index 0000000..0857e21 --- /dev/null +++ b/developer-portal/app/globals.css @@ -0,0 +1,35 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* Smooth transitions */ +* { + transition-property: background-color, border-color, color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Focus styles */ +*:focus-visible { + outline: 2px solid #0ea5e9; + outline-offset: 2px; +} diff --git a/developer-portal/app/guides/page.tsx b/developer-portal/app/guides/page.tsx new file mode 100644 index 0000000..be27d41 --- /dev/null +++ b/developer-portal/app/guides/page.tsx @@ -0,0 +1,227 @@ +import Link from 'next/link' +import { DevPortalLayout, InfoBox } from '@/components/DevPortalLayout' + +export default function GuidesPage() { + return ( + +

Workflow-Guides

+

+ Das AI Compliance SDK fuehrt durch einen strukturierten 19-Schritte-Workflow + in zwei Phasen. Diese Guides erklaeren jeden Schritt im Detail. +

+ +
+ +
+
+ 1 +
+
+

Phase 1: Assessment

+

8 Schritte

+
+
+

+ Use Case Workshop, System Screening, Module-Auswahl, Requirements, + Controls, Evidence, Checkliste, Risk Matrix. +

+ + + +
+
+ 2 +
+
+

Phase 2: Dokumentation

+

11 Schritte

+
+
+

+ AI Act Klassifizierung, Pflichten, DSFA, TOMs, Loeschfristen, + VVT, Rechtliche Vorlagen, Cookie Banner, DSR Portal. +

+ +
+ +

Workflow-Uebersicht

+
+
+

Phase 1: Assessment (8 Schritte)

+
    +
  1. + 01 +

    Use Case Workshop

    +
  2. +
  3. + 02 +

    System Screening

    +
  4. +
  5. + 03 +

    Compliance Modules

    +
  6. +
  7. + 04 +

    Requirements

    +
  8. +
  9. + 05 +

    Controls

    +
  10. +
  11. + 06 +

    Evidence

    +
  12. +
  13. + 07 +

    Audit Checklist

    +
  14. +
  15. + 08 +

    Risk Matrix

    +
  16. +
+
+ +
+

Phase 2: Dokumentation (11 Schritte)

+
    +
  1. + 09 +

    AI Act Klassifizierung

    +
  2. +
  3. + 10 +

    Pflichtenuebersicht

    +
  4. +
  5. + 11 +

    DSFA

    +
  6. +
  7. + 12 +

    TOMs

    +
  8. +
  9. + 13 +

    Loeschfristen

    +
  10. +
  11. + 14 +

    VVT

    +
  12. +
  13. + 15 +

    Rechtliche Vorlagen

    +
  14. +
  15. + 16 +

    Cookie Banner

    +
  16. +
  17. + 17 +

    Einwilligungen

    +
  18. +
  19. + 18 +

    DSR Portal

    +
  20. +
  21. + 19 +

    Escalations

    +
  22. +
+
+
+ +

Checkpoints

+

+ Das SDK validiert den Fortschritt an definierten Checkpoints: +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CheckpointNach SchrittValidierung
CP-UCUse Case WorkshopMind. 1 Use Case angelegt
CP-SCREENSystem ScreeningScreening abgeschlossen
CP-CTRLControlsAlle Requirements haben Controls
CP-RISKRisk MatrixAlle Risiken bewertet
CP-DSFADSFADSFA generiert (falls erforderlich)
CP-TOMTOMsMind. 10 TOMs definiert
CP-VVTVVTVVT vollstaendig
+
+ + + Nicht bestandene Checkpoints blockieren den Fortschritt zu spaetere Schritte. + Verwenden Sie validateCheckpoint() um den Status zu pruefen. + + +

Best Practices

+
    +
  • + Speichern Sie regelmaessig: Der State wird automatisch + im localStorage gespeichert, aber aktivieren Sie Backend-Sync fuer + persistente Speicherung. +
  • +
  • + Nutzen Sie die Command Bar: Cmd+K oeffnet schnelle + Navigation, Export und RAG-Suche. +
  • +
  • + Arbeiten Sie Use-Case-zentriert: Bearbeiten Sie + einen Use Case vollstaendig, bevor Sie zum naechsten wechseln. +
  • +
  • + Validieren Sie Checkpoints: Pruefen Sie vor dem + Phasenwechsel, ob alle Checkpoints bestanden sind. +
  • +
+
+ ) +} diff --git a/developer-portal/app/guides/phase1/page.tsx b/developer-portal/app/guides/phase1/page.tsx new file mode 100644 index 0000000..653b9d8 --- /dev/null +++ b/developer-portal/app/guides/phase1/page.tsx @@ -0,0 +1,391 @@ +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' + +export default function Phase1GuidePage() { + return ( + +

Uebersicht Phase 1

+

+ Phase 1 umfasst die Erfassung und Bewertung Ihrer KI-Anwendungsfaelle. + Am Ende haben Sie eine vollstaendige Risikoanalyse und wissen, welche + Compliance-Dokumente Sie benoetigen. +

+ +
+

Phase 1 Schritte

+
    +
  1. Use Case Workshop
  2. +
  3. System Screening
  4. +
  5. Compliance Modules
  6. +
  7. Requirements
  8. +
  9. Controls
  10. +
  11. Evidence
  12. +
  13. Audit Checklist
  14. +
  15. Risk Matrix
  16. +
+
+ +

Schritt 1: Use Case Workshop

+

+ Erfassen Sie alle KI-Anwendungsfaelle in Ihrem Unternehmen. +

+ +

Code-Beispiel

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function UseCaseForm() { + const { updateUseCase, state } = useSDK() + + const handleCreateUseCase = async () => { + await updateUseCase({ + id: \`uc-\${Date.now()}\`, + name: 'KI-gestuetzte Kundenanalyse', + description: 'Analyse von Kundenverhalten mittels ML', + category: 'Marketing', + department: 'Marketing & Sales', + dataTypes: ['Kundendaten', 'Verhaltensdaten', 'Transaktionen'], + aiCapabilities: ['Profiling', 'Vorhersage'], + stepsCompleted: 0, + }) + } + + return ( +
+

Use Cases: {state.useCases.length}

+ + + {state.useCases.map(uc => ( +
+

{uc.name}

+

{uc.description}

+
+ ))} +
+ ) +}`} +
+ + + Nach dem Use Case Workshop muss mindestens ein Use Case angelegt sein, + um zum naechsten Schritt zu gelangen. + + +

Schritt 2: System Screening

+

+ Das Screening bewertet jeden Use Case hinsichtlich Datenschutz und AI Act. +

+ +

Code-Beispiel

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function ScreeningView() { + const { state, dispatch } = useSDK() + + const completeScreening = (useCaseId: string, result: ScreeningResult) => { + dispatch({ + type: 'UPDATE_USE_CASE', + payload: { + id: useCaseId, + screeningResult: result, + // Ergebnis bestimmt weitere Pflichten + assessmentResult: { + riskLevel: result.aiActRisk, + dsfaRequired: result.dsfaRequired, + aiActClassification: result.aiActClassification, + }, + }, + }) + } + + // Screening-Fragen beantworten + const screeningQuestions = [ + 'Werden personenbezogene Daten verarbeitet?', + 'Erfolgt automatisierte Entscheidungsfindung?', + 'Werden besondere Datenkategorien verarbeitet?', + 'Erfolgt Profiling?', + 'Werden Daten in Drittlaender uebermittelt?', + ] + + return ( +
+ {screeningQuestions.map((question, i) => ( + + ))} +
+ ) +}`} +
+ +

Schritt 3: Compliance Modules

+

+ Basierend auf dem Screening werden relevante Compliance-Module aktiviert. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
ModulAktiviert wenn
DSGVO BasisImmer (personenbezogene Daten)
DSFAHohes Risiko, Profiling, Art. 9 Daten
AI ActKI-basierte Entscheidungen
NIS2Kritische Infrastruktur
+
+ +

Schritt 4: Requirements

+

+ Fuer jedes aktivierte Modul werden spezifische Anforderungen generiert. +

+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function RequirementsView() { + const { state } = useSDK() + + // Requirements nach Modul gruppieren + const byModule = state.requirements.reduce((acc, req) => { + const module = req.module || 'general' + if (!acc[module]) acc[module] = [] + acc[module].push(req) + return acc + }, {}) + + return ( +
+ {Object.entries(byModule).map(([module, reqs]) => ( +
+

{module}

+
    + {reqs.map(req => ( +
  • + {req.title} +

    {req.description}

    + Status: {req.status} +
  • + ))} +
+
+ ))} +
+ ) +}`} +
+ +

Schritt 5: Controls

+

+ Definieren Sie Kontrollen fuer jede Anforderung. +

+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function ControlsView() { + const { updateControl, state } = useSDK() + + const addControl = (requirementId: string) => { + updateControl({ + id: \`ctrl-\${Date.now()}\`, + requirementId, + title: 'Zugriffskontrolle implementieren', + description: 'Role-based access control fuer alle Datenzugaenge', + type: 'TECHNICAL', + status: 'PLANNED', + implementationDate: null, + owner: 'IT-Abteilung', + }) + } + + return ( +
+

Controls: {state.controls.length}

+ + {state.requirements.map(req => ( +
+

{req.title}

+

Controls: {state.controls.filter(c => c.requirementId === req.id).length}

+ +
+ ))} +
+ ) +}`} +
+ + + Jede Requirement muss mindestens ein Control haben, bevor Sie + zur Evidence-Phase uebergehen koennen. + + +

Schritt 6: Evidence

+

+ Dokumentieren Sie Nachweise fuer implementierte Controls. +

+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function EvidenceUpload({ controlId }: { controlId: string }) { + const { dispatch } = useSDK() + + const addEvidence = (file: File) => { + dispatch({ + type: 'ADD_EVIDENCE', + payload: { + id: \`ev-\${Date.now()}\`, + controlId, + title: file.name, + type: 'DOCUMENT', + uploadedAt: new Date().toISOString(), + fileType: file.type, + // In Produktion: Upload zu Storage + }, + }) + } + + return ( + e.target.files?.[0] && addEvidence(e.target.files[0])} + /> + ) +}`} + + +

Schritt 7: Audit Checklist

+

+ Die Checkliste fasst alle Compliance-Punkte zusammen. +

+ +

Schritt 8: Risk Matrix

+

+ Bewerten Sie alle identifizierten Risiken nach Likelihood und Impact. +

+ + +{`import { useSDK, calculateRiskScore, getRiskSeverityFromScore } from '@breakpilot/compliance-sdk' + +function RiskMatrix() { + const { addRisk, state } = useSDK() + + const createRisk = () => { + const likelihood = 3 // 1-5 + const impact = 4 // 1-5 + const score = calculateRiskScore(likelihood, impact) // 12 + const severity = getRiskSeverityFromScore(score) // 'HIGH' + + addRisk({ + id: \`risk-\${Date.now()}\`, + title: 'Unbefugter Datenzugriff', + description: 'Risiko durch unzureichende Zugriffskontrolle', + likelihood, + impact, + inherentScore: score, + severity, + category: 'Security', + mitigations: [], + residualScore: null, + }) + } + + return ( +
+

Risiken: {state.risks.length}

+ + {/* 5x5 Matrix Visualisierung */} +
+ {[5,4,3,2,1].map(likelihood => ( + [1,2,3,4,5].map(impact => { + const score = likelihood * impact + const risksHere = state.risks.filter( + r => r.likelihood === likelihood && r.impact === impact + ) + return ( +
= 15 ? 'bg-red-500' : score >= 8 ? 'bg-yellow-500' : 'bg-green-500'}\`} + > + {risksHere.length > 0 && ( + {risksHere.length} + )} +
+ ) + }) + ))} +
+ + +
+ ) +}`} +
+ + + Nach erfolgreicher Bewertung aller Risiken koennen Sie zu Phase 2 + uebergehen. Der Checkpoint CP-RISK validiert, dass alle Risiken + eine Severity-Bewertung haben. + + +

Navigation nach Phase 2

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function PhaseTransition() { + const { validateCheckpoint, goToStep, phase1Completion } = useSDK() + + const handleContinueToPhase2 = async () => { + // Alle Phase-1-Checkpoints pruefen + const cpRisk = await validateCheckpoint('CP-RISK') + + if (cpRisk.passed) { + goToStep('ai-act-classification') // Erster Schritt Phase 2 + } else { + console.error('Checkpoint nicht bestanden:', cpRisk.errors) + } + } + + return ( +
+

Phase 1 Fortschritt: {phase1Completion}%

+ + {phase1Completion === 100 && ( + + )} +
+ ) +}`} +
+
+ ) +} diff --git a/developer-portal/app/guides/phase2/page.tsx b/developer-portal/app/guides/phase2/page.tsx new file mode 100644 index 0000000..8f3c3fd --- /dev/null +++ b/developer-portal/app/guides/phase2/page.tsx @@ -0,0 +1,377 @@ +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' + +export default function Phase2GuidePage() { + return ( + +

Uebersicht Phase 2

+

+ Phase 2 generiert alle erforderlichen Compliance-Dokumente basierend + auf dem Assessment aus Phase 1. Die Dokumente koennen exportiert und + fuer Audits verwendet werden. +

+ +
+

Phase 2 Schritte

+
    +
  1. AI Act Klassifizierung
  2. +
  3. Pflichtenuebersicht
  4. +
  5. DSFA (Datenschutz-Folgenabschaetzung)
  6. +
  7. TOMs (Technische/Organisatorische Massnahmen)
  8. +
  9. Loeschfristen
  10. +
  11. VVT (Verarbeitungsverzeichnis)
  12. +
  13. Rechtliche Vorlagen
  14. +
  15. Cookie Banner
  16. +
  17. Einwilligungen
  18. +
  19. DSR Portal
  20. +
  21. Escalations
  22. +
+
+ +

Schritt 9: AI Act Klassifizierung

+

+ Klassifizieren Sie jeden Use Case nach dem EU AI Act Risikosystem. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RisikostufeBeschreibungPflichten
VerbotenSocial Scoring, Manipulative KINicht zulaessig
HochrisikoBiometrie, Medizin, kritische InfrastrukturUmfangreiche Dokumentation, Konformitaetsbewertung
BegrenztChatbots, EmpfehlungssystemeTransparenzpflichten
MinimalSpam-Filter, SpieleFreiwillige Verhaltenskodizes
+
+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' +import type { AIActRiskCategory } from '@breakpilot/compliance-sdk' + +function AIActClassification() { + const { state, dispatch } = useSDK() + + const classifyUseCase = (useCaseId: string, classification: AIActRiskCategory) => { + dispatch({ + type: 'UPDATE_USE_CASE', + payload: { + id: useCaseId, + assessmentResult: { + ...state.useCases.find(uc => uc.id === useCaseId)?.assessmentResult, + aiActClassification: classification, + }, + }, + }) + + // Wenn Hochrisiko, zusaetzliche Pflichten aktivieren + if (classification === 'HIGH_RISK') { + dispatch({ + type: 'SET_AI_ACT_RESULT', + payload: { + classification, + conformityRequired: true, + documentationRequired: true, + humanOversightRequired: true, + }, + }) + } + } + + return ( +
+ {state.useCases.map(uc => ( +
+

{uc.name}

+ +
+ ))} +
+ ) +}`} +
+ +

Schritt 10: Pflichtenuebersicht

+

+ Basierend auf der Klassifizierung werden alle anwendbaren Pflichten angezeigt. +

+ +

Schritt 11: DSFA

+

+ Die Datenschutz-Folgenabschaetzung wird automatisch generiert. +

+ + +{`import { useSDK, getSDKBackendClient } from '@breakpilot/compliance-sdk' + +function DSFAGeneration() { + const { state, dispatch } = useSDK() + const [generating, setGenerating] = useState(false) + + const generateDSFA = async () => { + setGenerating(true) + + const client = getSDKBackendClient() + const dsfa = await client.generateDSFA({ + useCases: state.useCases, + risks: state.risks, + controls: state.controls, + }) + + dispatch({ + type: 'SET_DSFA', + payload: dsfa, + }) + + setGenerating(false) + } + + // DSFA nur anzeigen wenn erforderlich + const dsfaRequired = state.useCases.some( + uc => uc.assessmentResult?.dsfaRequired + ) + + if (!dsfaRequired) { + return

Keine DSFA erforderlich fuer die aktuellen Use Cases.

+ } + + return ( +
+ {state.dsfa ? ( +
+

DSFA generiert

+

Status: {state.dsfa.status}

+

Gesamtrisiko: {state.dsfa.conclusion?.overallRisk}

+ + {/* DSFA-Sektionen anzeigen */} + {Object.entries(state.dsfa.sections || {}).map(([key, section]) => ( +
+

{section.title}

+

{section.content}

+
+ ))} +
+ ) : ( + + )} +
+ ) +}`} +
+ + + Wenn eine DSFA erforderlich ist (basierend auf Screening), muss diese + generiert werden, bevor Sie fortfahren koennen. + + +

Schritt 12: TOMs

+

+ Technische und Organisatorische Massnahmen nach Art. 32 DSGVO. +

+ + +{`import { useSDK, getSDKBackendClient } from '@breakpilot/compliance-sdk' + +function TOMsView() { + const { state, dispatch } = useSDK() + + const generateTOMs = async () => { + const client = getSDKBackendClient() + const toms = await client.generateTOM({ + risks: state.risks, + controls: state.controls, + }) + + dispatch({ + type: 'SET_TOMS', + payload: toms, + }) + } + + const tomCategories = [ + { id: 'access_control', label: 'Zugangskontrolle' }, + { id: 'access_rights', label: 'Zugriffskontrolle' }, + { id: 'transfer_control', label: 'Weitergabekontrolle' }, + { id: 'input_control', label: 'Eingabekontrolle' }, + { id: 'availability', label: 'Verfuegbarkeitskontrolle' }, + { id: 'separation', label: 'Trennungsgebot' }, + ] + + return ( +
+

TOMs: {state.toms.length}

+ + {tomCategories.map(cat => { + const tomsInCategory = state.toms.filter(t => t.category === cat.id) + return ( +
+

{cat.label} ({tomsInCategory.length})

+
    + {tomsInCategory.map(tom => ( +
  • + {tom.title} +

    {tom.description}

    + Status: {tom.implementationStatus} +
  • + ))} +
+
+ ) + })} + + +
+ ) +}`} +
+ +

Schritt 13: Loeschfristen

+

+ Definieren Sie Aufbewahrungsfristen fuer verschiedene Datenkategorien. +

+ +

Schritt 14: VVT

+

+ Das Verarbeitungsverzeichnis nach Art. 30 DSGVO. +

+ + +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function VVTView() { + const { state, dispatch } = useSDK() + + const addProcessingActivity = () => { + dispatch({ + type: 'ADD_PROCESSING_ACTIVITY', + payload: { + id: \`pa-\${Date.now()}\`, + name: 'Kundendatenverarbeitung', + purpose: 'Vertragserfuellung', + legalBasis: 'Art. 6 Abs. 1 lit. b DSGVO', + dataCategories: ['Kontaktdaten', 'Vertragsdaten'], + dataSubjects: ['Kunden'], + recipients: [], + retentionPeriod: '10 Jahre', + technicalMeasures: ['Verschluesselung', 'Zugriffskontrolle'], + }, + }) + } + + return ( +
+

Verarbeitungstaetigkeiten: {state.vvt.length}

+ + {state.vvt.map(activity => ( +
+

{activity.name}

+

Zweck: {activity.purpose}

+

Rechtsgrundlage: {activity.legalBasis}

+

Datenkategorien: {activity.dataCategories.join(', ')}

+

Betroffene: {activity.dataSubjects.join(', ')}

+

Loeschfrist: {activity.retentionPeriod}

+
+ ))} + + +
+ ) +}`} +
+ +

Schritt 15-19: Weitere Dokumentation

+

+ Die verbleibenden Schritte umfassen: +

+
    +
  • Rechtliche Vorlagen: AGB, Datenschutzerklaerung, etc.
  • +
  • Cookie Banner: Konfiguration fuer Cookie-Consent
  • +
  • Einwilligungen: Consent-Management fuer Betroffene
  • +
  • DSR Portal: Data Subject Request Handling
  • +
  • Escalations: Eskalationspfade fuer Datenschutzvorfaelle
  • +
+ +

Export der Dokumentation

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function ExportAll() { + const { exportState, completionPercentage } = useSDK() + + const handleExport = async (format: 'pdf' | 'zip' | 'json') => { + const blob = await exportState(format) + + // Download ausloesen + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = \`compliance-export.\${format === 'json' ? 'json' : format}\` + a.click() + URL.revokeObjectURL(url) + } + + return ( +
+

Compliance Fortschritt: {completionPercentage}%

+ +
+ + + +
+
+ ) +}`} +
+ + + Nach Abschluss aller 19 Schritte haben Sie eine vollstaendige + Compliance-Dokumentation, die Sie fuer Audits und regulatorische + Anforderungen verwenden koennen. + +
+ ) +} diff --git a/developer-portal/app/layout.tsx b/developer-portal/app/layout.tsx new file mode 100644 index 0000000..57f1735 --- /dev/null +++ b/developer-portal/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'BreakPilot Developer Portal', + description: 'SDK-Dokumentation und API-Referenz fuer BreakPilot AI Compliance SDK', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/developer-portal/app/page.tsx b/developer-portal/app/page.tsx new file mode 100644 index 0000000..0e98f81 --- /dev/null +++ b/developer-portal/app/page.tsx @@ -0,0 +1,188 @@ +import Link from 'next/link' +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' +import { Zap, Code, Terminal, Book, ArrowRight } from 'lucide-react' + +export default function DevelopersPage() { + return ( + + {/* Quick Links */} +
+ +
+
+ +
+

Quick Start

+
+

+ Starten Sie in 5 Minuten mit dem AI Compliance SDK +

+ + Jetzt starten + + + + +
+
+ +
+

API Reference

+
+

+ Vollständige API-Dokumentation aller Endpoints +

+ + API erkunden + + + + +
+
+ +
+

SDK Documentation

+
+

+ TypeScript SDK für React und Next.js +

+ + Dokumentation lesen + + + + +
+
+ +
+

Guides

+
+

+ Schritt-für-Schritt-Anleitungen und Best Practices +

+ + Guides ansehen + + +
+ + {/* Installation */} +

Installation

+ +{`npm install @breakpilot/compliance-sdk +# oder +yarn add @breakpilot/compliance-sdk +# oder +pnpm add @breakpilot/compliance-sdk`} + + + {/* Quick Example */} +

Schnellstart-Beispiel

+ +{`import { SDKProvider, useSDK } from '@breakpilot/compliance-sdk' + +function App() { + return ( + + + + ) +} + +function ComplianceDashboard() { + const { state, goToStep, completionPercentage } = useSDK() + + return ( +
+

Compliance Status: {completionPercentage}%

+

Aktueller Schritt: {state.currentStep}

+ +
+ ) +}`} +
+ + +
    +
  • Node.js 18 oder höher
  • +
  • React 18 oder höher
  • +
  • Breakpilot API Key (erhältlich nach Abo-Abschluss)
  • +
+
+ + {/* Features */} +

Hauptfunktionen

+
+
+

19-Schritt-Workflow

+

+ Geführter Compliance-Prozess von Use Case bis DSR-Portal +

+
+
+

RAG-basierte Suche

+

+ Durchsuchen Sie DSGVO, AI Act, NIS2 mit semantischer Suche +

+
+
+

Dokumentengenerierung

+

+ Automatische Erstellung von DSFA, TOMs, VVT +

+
+
+

Export

+

+ PDF, JSON, ZIP-Export für Audits und Dokumentation +

+
+
+ + {/* Next Steps */} +

Nächste Schritte

+
    +
  1. + + Quick Start Guide + + {' '}- Erste Integration in 5 Minuten +
  2. +
  3. + + State API + + {' '}- Verstehen Sie das State Management +
  4. +
  5. + + Phase 1 Workflow + + {' '}- Durchlaufen Sie den Compliance-Prozess +
  6. +
+
+ ) +} diff --git a/developer-portal/app/sdk/configuration/page.tsx b/developer-portal/app/sdk/configuration/page.tsx new file mode 100644 index 0000000..47d392d --- /dev/null +++ b/developer-portal/app/sdk/configuration/page.tsx @@ -0,0 +1,256 @@ +import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/DevPortalLayout' + +export default function SDKConfigurationPage() { + return ( + +

SDKProvider Props

+

+ Der SDKProvider akzeptiert folgende Konfigurationsoptionen: +

+ void', + required: false, + description: 'Callback fuer Fehlerbehandlung', + }, + { + name: 'onStateChange', + type: '(state: SDKState) => void', + required: false, + description: 'Callback bei State-Aenderungen', + }, + ]} + /> + +

Vollstaendiges Beispiel

+ +{`'use client' + +import { SDKProvider } from '@breakpilot/compliance-sdk' +import { useRouter } from 'next/navigation' + +export default function SDKLayout({ children }: { children: React.ReactNode }) { + const router = useRouter() + + return ( + { + console.error('SDK Error:', error) + // Optional: Sentry oder anderes Error-Tracking + }} + onStateChange={(state) => { + console.log('State changed:', state.currentStep) + // Optional: Analytics-Events + }} + > + {children} + + ) +}`} + + +

Synchronisations-Strategien

+ +

1. Nur localStorage (Offline-Only)

+ +{` + {children} +`} + +

+ Ideal fuer: Lokale Entwicklung, Demos, Privacy-fokussierte Installationen. + Daten werden nur im Browser gespeichert. +

+ +

2. Backend-Sync mit Fallback

+ +{` + {children} +`} + +

+ Empfohlen fuer: Produktionsumgebungen. Daten werden mit dem Backend + synchronisiert, localStorage dient als Fallback bei Netzwerkproblemen. +

+ +

3. Nur Backend (kein lokaler Cache)

+ +{` + {children} +`} + +

+ Ideal fuer: Strenge Compliance-Anforderungen, Multi-User-Szenarien. + Daten werden nur im Backend gespeichert. +

+ + + Im Backend-Only Modus ist eine aktive Internetverbindung erforderlich. + Bei Netzwerkproblemen koennen Daten verloren gehen. + + +

API URL Konfiguration

+ +

Cloud-Version (Standard)

+

Keine zusaetzliche Konfiguration erforderlich:

+ +{` + {/* Nutzt automatisch https://api.breakpilot.io/sdk/v1 */} +`} + + +

Self-Hosted

+ +{` + {children} +`} + + +

Lokale Entwicklung

+ +{` + {children} +`} + + +

Feature Flags

+

+ Das SDK unterstuetzt Feature Flags ueber Subscription-Levels: +

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function MyComponent() { + const { state } = useSDK() + + // Subscription-basierte Features + const isEnterprise = state.subscription === 'ENTERPRISE' + const isProfessional = ['PROFESSIONAL', 'ENTERPRISE'].includes(state.subscription) + + // Feature-Gates + const canExportPDF = isProfessional + const canUseRAG = isProfessional + const canCustomizeDSFA = isEnterprise + const canUseAPI = isProfessional + + return ( +
+ {canExportPDF && } + {canUseRAG && } +
+ ) +}`} +
+ +

Logging & Debugging

+

+ Aktivieren Sie detailliertes Logging fuer die Entwicklung: +

+ +{`// In Ihrer .env.local +NEXT_PUBLIC_SDK_DEBUG=true + +// Oder programmatisch + { + if (process.env.NODE_ENV === 'development') { + console.log('[SDK] State Update:', { + phase: state.currentPhase, + step: state.currentStep, + useCases: state.useCases.length, + risks: state.risks.length, + }) + } + }} +> + {children} +`} + + + + Der SDK-State ist im React DevTools unter dem SDKProvider-Context sichtbar. + Installieren Sie die React Developer Tools Browser-Extension fuer einfaches Debugging. + +
+ ) +} diff --git a/developer-portal/app/sdk/consent/api-reference/page.tsx b/developer-portal/app/sdk/consent/api-reference/page.tsx new file mode 100644 index 0000000..89599c0 --- /dev/null +++ b/developer-portal/app/sdk/consent/api-reference/page.tsx @@ -0,0 +1,482 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +function CodeBlock({ code }: { code: string }) { + return ( +
+
+ +
+
+        {code}
+      
+
+ ) +} + +function MethodCard({ + name, + signature, + description, + params, + returns, + example, +}: { + name: string + signature: string + description: string + params?: { name: string; type: string; description: string }[] + returns?: string + example?: string +}) { + return ( +
+
+ {name} +
+
+
+ {signature} +
+

{description}

+ + {params && params.length > 0 && ( +
+

Parameter

+ + + {params.map((param) => ( + + + + + + ))} + +
+ {param.name} + + {param.type} + {param.description}
+
+ )} + + {returns && ( +
+

Rueckgabe

+ {returns} +
+ )} + + {example && ( +
+

Beispiel

+ +
+ )} +
+
+ ) +} + +export default function APIReferencePage() { + return ( +
+ + +
+
+

API Referenz

+

+ Vollstaendige Dokumentation aller Methoden und Konfigurationsoptionen des Consent SDK. +

+ + {/* ConsentManager */} +
+

ConsentManager

+

+ Die zentrale Klasse fuer das Consent Management. Verwaltet Einwilligungen, Script-Blocking und Events. +

+ + {/* Constructor */} +
+ + + + + + + + + { + await consent.acceptAll(); +});`} + /> + + { + await consent.rejectAll(); +});`} + /> + + { + await consent.revokeAll(); + location.reload(); +});`} + /> + + { + console.log('Consent geaendert:', state); +}); + +// Spaeter: Listener entfernen +unsubscribe();`} + /> + + + + +
+
+ + {/* Configuration */} +
+

Konfiguration

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Option + + Typ + + Default + + Beschreibung +
+ apiEndpoint + + string + erforderlichURL des Consent-Backends
+ siteId + + string + erforderlichEindeutige Site-ID
+ debug + + boolean + falseAktiviert Debug-Logging
+ language + + string + 'de'Sprache fuer UI-Texte
+ consent.rememberDays + + number + 365Gueltigkeitsdauer in Tagen
+ consent.recheckAfterDays + + number + 180Erneute Abfrage nach X Tagen
+
+
+ + {/* Events */} +
+

Events

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Event + + Daten + + Beschreibung +
+ init + + ConsentState | null + SDK initialisiert
+ change + + ConsentState + Consent geaendert
+ accept_all + + ConsentState + Alle akzeptiert
+ reject_all + + ConsentState + Alle abgelehnt
+ banner_show + + undefined + Banner angezeigt
+ banner_hide + + undefined + Banner versteckt
+ error + + Error + Fehler aufgetreten
+
+
+ + {/* Types */} +
+

TypeScript Types

+ ; + vendors: Record; + timestamp: string; + version: string; + consentId?: string; + expiresAt?: string; +} + +// Konfiguration +interface ConsentConfig { + apiEndpoint: string; + siteId: string; + debug?: boolean; + language?: string; + fallbackLanguage?: string; + ui?: ConsentUIConfig; + consent?: ConsentBehaviorConfig; + onConsentChange?: (state: ConsentState) => void; + onBannerShow?: () => void; + onBannerHide?: () => void; + onError?: (error: Error) => void; +}`} + /> +
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/frameworks/angular/page.tsx b/developer-portal/app/sdk/consent/frameworks/angular/page.tsx new file mode 100644 index 0000000..431d64c --- /dev/null +++ b/developer-portal/app/sdk/consent/frameworks/angular/page.tsx @@ -0,0 +1,281 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function AngularIntegrationPage() { + return ( +
+ + +
+
+
+
+ A +
+

Angular Integration

+
+

+ Service und Module fuer Angular 14+ Projekte. +

+ + {/* Installation */} +
+

Installation

+ +
+ + {/* Module Setup */} +
+

Module Setup

+ +
+ + {/* Standalone Setup */} +
+

Standalone Setup (Angular 15+)

+ +
+ + {/* Service Usage */} +
+

Service Usage

+ + +
+ \`, +}) +export class AnalyticsComponent implements OnInit { + hasAnalyticsConsent$ = this.consentService.hasConsent$('analytics'); + + constructor(private consentService: ConsentService) {} + + async loadAnalytics() { + if (await this.consentService.hasConsent('analytics')) { + // Load analytics + } + } +}`} + /> + + + {/* Cookie Banner */} +
+

Cookie Banner Component

+ +
+

+ Wir verwenden Cookies um Ihr Erlebnis zu verbessern. +

+
+ + + +
+
+
+ \`, +}) +export class CookieBannerComponent { + isBannerVisible$ = this.consentService.isBannerVisible$; + + constructor(private consentService: ConsentService) {} + + async acceptAll() { + await this.consentService.acceptAll(); + } + + async rejectAll() { + await this.consentService.rejectAll(); + } + + showSettings() { + this.consentService.showSettings(); + } +}`} + /> + + + {/* Directive */} +
+

ConsentGate Directive

+ + + + +
+ +
+ +
+

Bitte stimmen Sie Statistik-Cookies zu.

+ +
+
`} + /> +
+ + {/* Service API */} +
+

Service API

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Property/Method + + Typ + + Beschreibung +
consent$Observable<ConsentState>Observable des aktuellen Consent
hasConsent$()Observable<boolean>Reaktive Consent-Pruefung
hasConsent()Promise<boolean>Async Consent-Pruefung
isBannerVisible$Observable<boolean>Banner-Sichtbarkeit
acceptAll()Promise<void>Akzeptiert alle
rejectAll()Promise<void>Lehnt alle ab
setConsent()Promise<void>Setzt spezifische Kategorien
+
+
+ + + + ) +} diff --git a/developer-portal/app/sdk/consent/frameworks/page.tsx b/developer-portal/app/sdk/consent/frameworks/page.tsx new file mode 100644 index 0000000..f2df7a7 --- /dev/null +++ b/developer-portal/app/sdk/consent/frameworks/page.tsx @@ -0,0 +1,98 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { ChevronRight } from 'lucide-react' + +const frameworks = [ + { + name: 'React', + href: '/sdk/consent/frameworks/react', + logo: '/logos/react.svg', + description: 'Hooks und Provider fuer React 17+ und Next.js', + features: ['ConsentProvider', 'useConsent Hook', 'ConsentGate Component'], + color: 'bg-cyan-500', + }, + { + name: 'Vue 3', + href: '/sdk/consent/frameworks/vue', + logo: '/logos/vue.svg', + description: 'Composables und Plugin fuer Vue 3 und Nuxt', + features: ['Vue Plugin', 'useConsent Composable', 'ConsentGate Component'], + color: 'bg-emerald-500', + }, + { + name: 'Angular', + href: '/sdk/consent/frameworks/angular', + logo: '/logos/angular.svg', + description: 'Service und Module fuer Angular 14+', + features: ['ConsentService', 'ConsentModule', 'Dependency Injection'], + color: 'bg-red-500', + }, +] + +export default function FrameworksPage() { + return ( +
+ + +
+
+

Framework Integration

+

+ Das Consent SDK bietet native Integrationen fuer alle gaengigen Frontend-Frameworks. +

+ +
+ {frameworks.map((framework) => ( + +
+
+ {framework.name[0]} +
+
+
+

+ {framework.name} +

+ +
+

{framework.description}

+
+ {framework.features.map((feature) => ( + + {feature} + + ))} +
+
+
+ + ))} +
+ + {/* Vanilla JS Note */} +
+

Vanilla JavaScript

+

+ Sie koennen das SDK auch ohne Framework verwenden. Importieren Sie einfach den ConsentManager direkt + aus dem Hauptpaket. Siehe{' '} + + Installation + {' '} + fuer Details. +

+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/frameworks/react/page.tsx b/developer-portal/app/sdk/consent/frameworks/react/page.tsx new file mode 100644 index 0000000..4b44249 --- /dev/null +++ b/developer-portal/app/sdk/consent/frameworks/react/page.tsx @@ -0,0 +1,277 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function ReactIntegrationPage() { + return ( +
+ + +
+
+
+
+ R +
+

React Integration

+
+

+ Hooks und Provider fuer React 17+ und Next.js Projekte. +

+ + {/* Installation */} +
+

Installation

+ +
+ + {/* Provider Setup */} +
+

Provider Setup

+

+ Umschliessen Sie Ihre App mit dem ConsentProvider: +

+ + + + {children} + + + + ); +}`} + /> +
+ + {/* useConsent Hook */} +
+

useConsent Hook

+

+ Verwenden Sie den Hook in jeder Komponente: +

+ + ); +}`} + /> +
+ + {/* ConsentGate */} +
+

ConsentGate Component

+

+ Zeigt Inhalte nur wenn Consent vorhanden ist: +

+ +

Video erfordert Ihre Zustimmung.

+ +
+ } + > + `} + /> + + + {/* Requirements */} +
+

Systemvoraussetzungen

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Anforderung + + Minimum +
Node.js>= 18.0.0
React (optional)>= 17.0.0
Vue (optional)>= 3.0.0
TypeScript (optional)>= 4.7.0
+
+
+ + {/* Browser Support */} +
+

Browser-Unterstuetzung

+ + Das SDK unterstuetzt alle modernen Browser mit ES2017+ Unterstuetzung. + Fuer aeltere Browser wird ein automatischer Fallback fuer Crypto-Funktionen bereitgestellt. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Browser + + Minimum Version +
Chrome>= 60
Firefox>= 55
Safari>= 11
Edge>= 79 (Chromium)
+
+
+ + {/* Next Steps */} +
+

Naechste Schritte

+ +
+
+ + + ) +} diff --git a/developer-portal/app/sdk/consent/mobile/android/page.tsx b/developer-portal/app/sdk/consent/mobile/android/page.tsx new file mode 100644 index 0000000..04f240d --- /dev/null +++ b/developer-portal/app/sdk/consent/mobile/android/page.tsx @@ -0,0 +1,269 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check, Smartphone } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function AndroidSDKPage() { + return ( +
+ + +
+
+
+
+ +
+

Android SDK (Kotlin)

+
+

+ Native Kotlin SDK fuer Android API 26+ mit Jetpack Compose Unterstuetzung. +

+ + {/* Requirements */} +
+

Systemvoraussetzungen

+
+ + + + + + + + + + + + + + + +
Kotlin Version1.9+
Min SDKAPI 26 (Android 8.0)
Compile SDK34+
+
+
+ + {/* Installation */} +
+

Installation

+ +
+ + {/* Basic Setup */} +
+

Grundlegende Einrichtung

+ +
+ + {/* Jetpack Compose */} +
+

Jetpack Compose Integration

+ +
+ + {/* Traditional Android */} +
+

View-basierte Integration

+ + updateUI(state) + } + } + + // Banner anzeigen wenn noetig + if (ConsentManager.needsConsent()) { + ConsentManager.showBanner(this) + } + } + + private fun updateUI(state: ConsentState?) { + if (state?.hasConsent(ConsentCategory.ANALYTICS) == true) { + loadAnalytics() + } + } + + fun onAcceptAllClick(view: View) { + lifecycleScope.launch { + ConsentManager.acceptAll() + } + } +}`} + /> +
+ + {/* API Reference */} +
+

API Referenz

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodeBeschreibung
configure()SDK konfigurieren
initialize()SDK initialisieren (suspend)
hasConsent()Consent fuer Kategorie pruefen
consentFlowFlow fuer reaktive Updates
acceptAll()Alle akzeptieren (suspend)
rejectAll()Alle ablehnen (suspend)
setConsent()Kategorien setzen (suspend)
showBanner()Banner als DialogFragment
+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/mobile/flutter/page.tsx b/developer-portal/app/sdk/consent/mobile/flutter/page.tsx new file mode 100644 index 0000000..1f713ad --- /dev/null +++ b/developer-portal/app/sdk/consent/mobile/flutter/page.tsx @@ -0,0 +1,313 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check, Smartphone } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function FlutterSDKPage() { + return ( +
+ + +
+
+
+
+ +
+

Flutter SDK

+
+

+ Cross-Platform SDK fuer Flutter 3.16+ mit iOS, Android und Web Support. +

+ + {/* Requirements */} +
+

Systemvoraussetzungen

+
+ + + + + + + + + + + + + + + +
Dart Version3.0+
Flutter Version3.16+
PlattformeniOS, Android, Web
+
+
+ + {/* Installation */} +
+

Installation

+ +
+ +
+
+ + {/* Basic Setup */} +
+

Grundlegende Einrichtung

+ +
+ + {/* Widget Usage */} +
+

Widget Integration

+ ( + stream: ConsentManager.instance.consentStream, + builder: (context, snapshot) { + final consent = snapshot.data; + + if (consent?.hasConsent(ConsentCategory.analytics) ?? false) { + return const AnalyticsWidget(); + } + + return const SizedBox.shrink(); + }, + ), + + // ConsentGate Widget + ConsentGate( + category: ConsentCategory.marketing, + fallback: const Center( + child: Text('Marketing-Zustimmung erforderlich'), + ), + child: const MarketingWidget(), + ), + + // Buttons + ElevatedButton( + onPressed: () => ConsentManager.instance.acceptAll(), + child: const Text('Alle akzeptieren'), + ), + + ElevatedButton( + onPressed: () => ConsentManager.instance.rejectAll(), + child: const Text('Alle ablehnen'), + ), + + TextButton( + onPressed: () => ConsentManager.instance.showSettings(context), + child: const Text('Einstellungen'), + ), + ], + ), + ); + } +}`} + /> +
+ + {/* Custom Banner */} +
+

Custom Cookie Banner

+ ( + stream: ConsentManager.instance.isBannerVisibleStream, + builder: (context, snapshot) { + if (!(snapshot.data ?? false)) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + ), + ], + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Wir verwenden Cookies um Ihr Erlebnis zu verbessern.', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => ConsentManager.instance.rejectAll(), + child: const Text('Ablehnen'), + ), + TextButton( + onPressed: () => ConsentManager.instance.showSettings(context), + child: const Text('Einstellungen'), + ), + ElevatedButton( + onPressed: () => ConsentManager.instance.acceptAll(), + child: const Text('Alle akzeptieren'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +}`} + /> +
+ + {/* API Reference */} +
+

API Referenz

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Methode/PropertyBeschreibung
initialize()SDK initialisieren (Future)
hasConsent()Consent pruefen
consentStreamStream fuer Consent-Updates
isBannerVisibleStreamStream fuer Banner-Sichtbarkeit
acceptAll()Alle akzeptieren (Future)
rejectAll()Alle ablehnen (Future)
setConsent()Kategorien setzen (Future)
showSettings()Einstellungs-Dialog oeffnen
+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/mobile/ios/page.tsx b/developer-portal/app/sdk/consent/mobile/ios/page.tsx new file mode 100644 index 0000000..62db91f --- /dev/null +++ b/developer-portal/app/sdk/consent/mobile/ios/page.tsx @@ -0,0 +1,283 @@ +'use client' + +import React, { useState } from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Copy, Check, Apple } from 'lucide-react' + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + ) +} + +function CodeBlock({ code, filename }: { code: string; filename?: string }) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+
+ +
+
+          {code}
+        
+
+
+ ) +} + +export default function iOSSDKPage() { + return ( +
+ + +
+
+
+
+ +
+

iOS SDK (Swift)

+
+

+ Native Swift SDK fuer iOS 15+ und iPadOS mit SwiftUI-Unterstuetzung. +

+ + {/* Requirements */} +
+

Systemvoraussetzungen

+
+ + + + + + + + + + + + + + + +
Swift Version5.9+
iOS Deployment TargetiOS 15.0+
Xcode Version15.0+
+
+
+ + {/* Installation */} +
+

Installation

+

Swift Package Manager

+ +

+ Oder in Xcode: File → Add Package Dependencies → URL eingeben +

+
+ + {/* Basic Usage */} +
+

Grundlegende Verwendung

+ Bool { + + // Consent Manager konfigurieren + ConsentManager.shared.configure( + apiEndpoint: "https://api.example.com/consent", + siteId: "my-ios-app" + ) + + // Initialisieren + Task { + await ConsentManager.shared.initialize() + } + + return true + } +}`} + /> +
+ + {/* SwiftUI Integration */} +
+

SwiftUI Integration

+ +
+ + {/* UIKit Integration */} +
+

UIKit Integration

+ () + + override func viewDidLoad() { + super.viewDidLoad() + + // Reaktiv auf Consent-Aenderungen reagieren + ConsentManager.shared.$consent + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.updateUI(consent: state) + } + .store(in: &cancellables) + } + + private func updateUI(consent: ConsentState?) { + if consent?.hasConsent(.analytics) == true { + loadAnalytics() + } + } + + @IBAction func acceptAllTapped(_ sender: UIButton) { + Task { + await ConsentManager.shared.acceptAll() + } + } +}`} + /> +
+ + {/* Consent Categories */} +
+

Consent-Kategorien

+ +
+ + {/* API Reference */} +
+

API Referenz

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodeBeschreibung
configure()SDK konfigurieren
initialize()SDK initialisieren (async)
hasConsent(_:)Consent fuer Kategorie pruefen
acceptAll()Alle Kategorien akzeptieren (async)
rejectAll()Alle ablehnen (async)
setConsent(_:)Spezifische Kategorien setzen (async)
showBanner()Banner anzeigen
exportConsent()Consent-Daten exportieren (DSGVO)
+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/mobile/page.tsx b/developer-portal/app/sdk/consent/mobile/page.tsx new file mode 100644 index 0000000..6173669 --- /dev/null +++ b/developer-portal/app/sdk/consent/mobile/page.tsx @@ -0,0 +1,95 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { ChevronRight, Apple, Smartphone } from 'lucide-react' + +const platforms = [ + { + name: 'iOS (Swift)', + href: '/sdk/consent/mobile/ios', + description: 'Native Swift SDK fuer iOS 15+ und iPadOS', + features: ['Swift 5.9+', 'iOS 15.0+', 'SwiftUI Support', 'Combine Integration'], + color: 'bg-gray-900', + icon: Apple, + }, + { + name: 'Android (Kotlin)', + href: '/sdk/consent/mobile/android', + description: 'Native Kotlin SDK fuer Android API 26+', + features: ['Kotlin 1.9+', 'API 26+', 'Jetpack Compose', 'Coroutines'], + color: 'bg-green-600', + icon: Smartphone, + }, + { + name: 'Flutter', + href: '/sdk/consent/mobile/flutter', + description: 'Cross-Platform SDK fuer Flutter 3.16+', + features: ['Dart 3.0+', 'Flutter 3.16+', 'iOS & Android', 'Web Support'], + color: 'bg-blue-500', + icon: Smartphone, + }, +] + +export default function MobileSDKsPage() { + return ( +
+ + +
+
+

Mobile SDKs

+

+ Native SDKs fuer iOS, Android und Flutter mit vollstaendiger DSGVO-Konformitaet. +

+ +
+ {platforms.map((platform) => ( + +
+
+ +
+
+
+

+ {platform.name} +

+ +
+

{platform.description}

+
+ {platform.features.map((feature) => ( + + {feature} + + ))} +
+
+
+ + ))} +
+ + {/* Cross-Platform Note */} +
+

Cross-Platform Konsistenz

+

+ Alle Mobile SDKs bieten dieselbe API-Oberflaeche wie das Web SDK. + Consent-Daten werden ueber die API synchronisiert, sodass Benutzer auf allen Geraeten + denselben Consent-Status haben. +

+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/page.tsx b/developer-portal/app/sdk/consent/page.tsx new file mode 100644 index 0000000..3d0caff --- /dev/null +++ b/developer-portal/app/sdk/consent/page.tsx @@ -0,0 +1,262 @@ +'use client' + +import React, { useState } from 'react' +import Link from 'next/link' +import { + Shield, Code, Download, Smartphone, FileCode, Lock, + ChevronRight, Copy, Check, Zap, Globe, Layers, + BookOpen, Terminal +} from 'lucide-react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' + +type Framework = 'npm' | 'yarn' | 'pnpm' + +const installCommands: Record = { + npm: 'npm install @breakpilot/consent-sdk', + yarn: 'yarn add @breakpilot/consent-sdk', + pnpm: 'pnpm add @breakpilot/consent-sdk', +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +export default function ConsentSDKHubPage() { + const [selectedPM, setSelectedPM] = useState('npm') + + const quickLinks = [ + { + title: 'Installation', + description: 'SDK in wenigen Minuten einrichten', + href: '/sdk/consent/installation', + icon: Download, + color: 'bg-blue-500', + }, + { + title: 'API Referenz', + description: 'Vollstaendige API-Dokumentation', + href: '/sdk/consent/api-reference', + icon: FileCode, + color: 'bg-purple-500', + }, + { + title: 'Frameworks', + description: 'React, Vue, Angular Integration', + href: '/sdk/consent/frameworks', + icon: Layers, + color: 'bg-green-500', + }, + { + title: 'Mobile SDKs', + description: 'iOS, Android, Flutter', + href: '/sdk/consent/mobile', + icon: Smartphone, + color: 'bg-orange-500', + }, + { + title: 'Sicherheit', + description: 'Best Practices & Compliance', + href: '/sdk/consent/security', + icon: Lock, + color: 'bg-red-500', + }, + ] + + const features = [ + { + title: 'DSGVO & TTDSG Konform', + description: 'Vollstaendige Unterstuetzung fuer EU-Datenschutzverordnungen mit revisionssicherer Consent-Speicherung.', + icon: Shield, + }, + { + title: 'Google Consent Mode v2', + description: 'Native Integration mit automatischer Synchronisation zu Google Analytics und Ads.', + icon: Globe, + }, + { + title: 'Script Blocking', + description: 'Automatisches Blockieren von Third-Party Scripts bis zur Einwilligung.', + icon: Code, + }, + { + title: 'Multi-Platform', + description: 'Unterstuetzung fuer Web, PWA, iOS, Android und Flutter aus einer Codebasis.', + icon: Smartphone, + }, + ] + + return ( +
+ + +
+
+ {/* Header */} +
+
+
+ +
+
+

Consent SDK

+
+ + v1.0.0 + + DSGVO/TTDSG Compliant +
+
+
+

+ Das Consent SDK ermoeglicht DSGVO-konforme Einwilligungsverwaltung fuer Web, PWA und Mobile Apps. + Mit nativer Unterstuetzung fuer React, Vue, Angular und Mobile Platforms. +

+
+ + {/* Quick Install */} +
+
+

Schnellinstallation

+
+ {(['npm', 'yarn', 'pnpm'] as const).map((pm) => ( + + ))} +
+
+
+ + $ {installCommands[selectedPM]} + + +
+
+ + {/* Quick Links */} +
+

Dokumentation

+
+ {quickLinks.map((link) => ( + +
+
+ +
+
+

+ {link.title} + +

+

{link.description}

+
+
+ + ))} +
+
+ + {/* Quick Start Code */} +
+
+

Schnellstart

+
+
+
+{`import { ConsentManager } from '@breakpilot/consent-sdk';
+
+// Manager initialisieren
+const consent = new ConsentManager({
+  apiEndpoint: 'https://api.example.com/consent',
+  siteId: 'your-site-id',
+});
+
+// SDK starten
+await consent.init();
+
+// Consent pruefen
+if (consent.hasConsent('analytics')) {
+  // Analytics laden
+}
+
+// Events abonnieren
+consent.on('change', (state) => {
+  console.log('Consent geaendert:', state);
+});`}
+              
+
+
+ + {/* Features */} +
+

Features

+
+ {features.map((feature) => ( +
+
+
+ +
+
+

{feature.title}

+

{feature.description}

+
+
+
+ ))} +
+
+ + {/* Compliance Notice */} +
+
+ +
+

DSGVO & TTDSG Compliance

+

+ Das Consent SDK erfuellt alle Anforderungen der DSGVO (Art. 6, 7, 13, 14, 17, 20) und des TTDSG (§ 25). + Alle Einwilligungen werden revisionssicher gespeichert und koennen jederzeit exportiert werden. +

+
+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/consent/security/page.tsx b/developer-portal/app/sdk/consent/security/page.tsx new file mode 100644 index 0000000..9ca2bec --- /dev/null +++ b/developer-portal/app/sdk/consent/security/page.tsx @@ -0,0 +1,290 @@ +'use client' + +import React from 'react' +import { SDKDocsSidebar } from '@/components/SDKDocsSidebar' +import { Shield, Lock, Eye, Database, Key, AlertTriangle, CheckCircle } from 'lucide-react' + +function SecurityCard({ + title, + description, + icon: Icon, + items, +}: { + title: string + description: string + icon: React.ComponentType<{ className?: string }> + items: string[] +}) { + return ( +
+
+
+ +
+
+

{title}

+

{description}

+
    + {items.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+
+ ) +} + +export default function SecurityPage() { + return ( +
+ + +
+
+

Sicherheit & Compliance

+

+ Best Practices fuer sichere Implementierung und DSGVO-konforme Nutzung des Consent SDK. +

+ + {/* Security Features */} +
+

Sicherheits-Features

+
+ + + + + + + +
+
+ + {/* DSGVO Compliance */} +
+

DSGVO Compliance

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ DSGVO Artikel + + Anforderung + + SDK-Unterstuetzung +
Art. 6Rechtmaessigkeit der Verarbeitung + + Vollstaendig + +
Art. 7Bedingungen fuer Einwilligung + + Vollstaendig + +
Art. 13/14Informationspflichten + + Vollstaendig + +
Art. 17Recht auf Loeschung + + Vollstaendig + +
Art. 20Datenportabilitaet + + Vollstaendig + +
+
+
+ + {/* TTDSG Compliance */} +
+

TTDSG Compliance

+
+
+
+ +
+
+

§ 25 TTDSG - Schutz der Privatsphaere

+

+ Das SDK erfuellt alle Anforderungen des § 25 TTDSG (Telemediengesetz): +

+
    +
  • + + + Einwilligung vor Speicherung: Cookies und localStorage werden erst nach + Einwilligung gesetzt (ausser technisch notwendige). + +
  • +
  • + + + Informierte Einwilligung: Klare Kategorisierung und Beschreibung + aller Cookies und Tracker. + +
  • +
  • + + + Widerrufsrecht: Jederzeit widerrufbare Einwilligung mit einem Klick. + +
  • +
+
+
+
+
+ + {/* Best Practices */} +
+

Best Practices

+ +
+
+

+ + Empfohlen +

+
    +
  • • HTTPS fuer alle API-Aufrufe verwenden
  • +
  • • Consent-Banner vor dem Laden von Third-Party Scripts anzeigen
  • +
  • • Alle Kategorien klar und verstaendlich beschreiben
  • +
  • • Ablehnen-Button gleichwertig zum Akzeptieren-Button darstellen
  • +
  • • Consent-Aenderungen serverseitig protokollieren
  • +
  • • Regelmaessige Ueberpruefung der Consent-Gultigkeit (recheckAfterDays)
  • +
+
+ +
+

+ + Vermeiden +

+
    +
  • • Dark Patterns (versteckte Ablehnen-Buttons)
  • +
  • • Pre-checked Consent-Optionen
  • +
  • • Tracking vor Einwilligung
  • +
  • • Cookie-Walls ohne echte Alternative
  • +
  • • Unklare oder irrefuehrende Kategoriebezeichnungen
  • +
+
+
+
+ + {/* Audit Trail */} +
+

Audit Trail

+
+

+ Das SDK speichert fuer jeden Consent-Vorgang revisionssichere Daten: +

+
+
+{`{
+  "consentId": "consent_abc123...",
+  "timestamp": "2024-01-15T10:30:00.000Z",
+  "categories": {
+    "essential": true,
+    "analytics": true,
+    "marketing": false
+  },
+  "metadata": {
+    "userAgent": "Mozilla/5.0...",
+    "language": "de-DE",
+    "platform": "web",
+    "screenResolution": "1920x1080"
+  },
+  "signature": "sha256=...",
+  "version": "1.0.0"
+}`}
+                
+
+

+ Diese Daten werden sowohl lokal als auch auf dem Server gespeichert und koennen + jederzeit fuer Audits exportiert werden. +

+
+
+
+
+
+ ) +} diff --git a/developer-portal/app/sdk/installation/page.tsx b/developer-portal/app/sdk/installation/page.tsx new file mode 100644 index 0000000..df7771f --- /dev/null +++ b/developer-portal/app/sdk/installation/page.tsx @@ -0,0 +1,186 @@ +import { DevPortalLayout, CodeBlock, InfoBox, ParameterTable } from '@/components/DevPortalLayout' + +export default function SDKInstallationPage() { + return ( + +

Voraussetzungen

+
    +
  • Node.js 18 oder hoeher
  • +
  • React 18+ / Next.js 14+
  • +
  • TypeScript 5.0+ (empfohlen)
  • +
+ +

Installation

+

+ Installieren Sie das SDK ueber Ihren bevorzugten Paketmanager: +

+ +{`npm install @breakpilot/compliance-sdk`} + + +{`yarn add @breakpilot/compliance-sdk`} + + +{`pnpm add @breakpilot/compliance-sdk`} + + +

Peer Dependencies

+

+ Das SDK hat folgende Peer Dependencies, die automatisch installiert werden sollten: +

+ +{`{ + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } +}`} + + +

Zusaetzliche Pakete (optional)

+

+ Fuer erweiterte Funktionen koennen Sie folgende Pakete installieren: +

+ + +

TypeScript Konfiguration

+

+ Das SDK ist vollstaendig in TypeScript geschrieben. Stellen Sie sicher, + dass Ihre tsconfig.json folgende Optionen enthaelt: +

+ +{`{ + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +}`} + + +

Next.js Integration

+

+ Fuer Next.js 14+ mit App Router, fuegen Sie den Provider in Ihr Root-Layout ein: +

+ +{`import { SDKProvider } from '@breakpilot/compliance-sdk' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + ) +}`} + + + + Der SDKProvider ist ein Client-Component. Wenn Sie Server Components + verwenden, wrappen Sie nur die Teile der App, die das SDK benoetigen. + + +

Umgebungsvariablen

+

+ Erstellen Sie eine .env.local Datei mit folgenden Variablen: +

+ +{`# Pflicht +NEXT_PUBLIC_TENANT_ID=your-tenant-id + +# Optional (fuer Backend-Sync) +BREAKPILOT_API_KEY=sk_live_... + +# Optional (fuer Self-Hosted) +NEXT_PUBLIC_SDK_API_URL=https://your-server.com/sdk/v1`} + + + + Der API Key sollte niemals im Frontend-Code oder in NEXT_PUBLIC_ Variablen + erscheinen. Verwenden Sie Server-Side API Routes fuer authentifizierte Anfragen. + + +

Verifizierung

+

+ Testen Sie die Installation mit einer einfachen Komponente: +

+ +{`'use client' + +import { useSDK } from '@breakpilot/compliance-sdk' + +export default function TestPage() { + const { state, completionPercentage } = useSDK() + + return ( +
+

SDK Test

+

Fortschritt: {completionPercentage}%

+

Aktuelle Phase: {state.currentPhase}

+

Use Cases: {state.useCases.length}

+
+ ) +}`} +
+ +

Fehlerbehebung

+ +

Error: useSDK must be used within SDKProvider

+

+ Stellen Sie sicher, dass der SDKProvider das gesamte Layout umschliesst + und dass Sie {'\'use client\''} in Client-Komponenten verwenden. +

+ +

Error: Module not found

+

+ Loeschen Sie node_modules und package-lock.json, dann reinstallieren: +

+ +{`rm -rf node_modules package-lock.json +npm install`} + + +

TypeScript Errors

+

+ Stellen Sie sicher, dass TypeScript 5.0+ installiert ist: +

+ +{`npm install typescript@latest`} + +
+ ) +} diff --git a/developer-portal/app/sdk/page.tsx b/developer-portal/app/sdk/page.tsx new file mode 100644 index 0000000..5df3e16 --- /dev/null +++ b/developer-portal/app/sdk/page.tsx @@ -0,0 +1,281 @@ +import Link from 'next/link' +import { DevPortalLayout, CodeBlock, InfoBox } from '@/components/DevPortalLayout' + +export default function SDKOverviewPage() { + return ( + +

Übersicht

+

+ Das AI Compliance SDK ist ein TypeScript-Paket für die Integration des + Compliance-Workflows in React und Next.js Anwendungen. Es bietet: +

+
    +
  • React Context Provider für State Management
  • +
  • Hooks für einfachen Zugriff auf Compliance-Daten
  • +
  • Automatische Synchronisation mit dem Backend
  • +
  • Offline-Support mit localStorage Fallback
  • +
  • Export-Funktionen (PDF, JSON, ZIP)
  • +
+ +

Kernkomponenten

+ +

SDKProvider

+

+ Der Provider wrappet Ihre App und stellt den SDK-Kontext bereit: +

+ +{`import { SDKProvider } from '@breakpilot/compliance-sdk' + +export default function Layout({ children }) { + return ( + + {children} + + ) +}`} + + +

useSDK Hook

+

+ Der Haupt-Hook für den Zugriff auf alle SDK-Funktionen: +

+ +{`import { useSDK } from '@breakpilot/compliance-sdk' + +function MyComponent() { + const { + // State + state, + dispatch, + + // Navigation + currentStep, + goToStep, + goToNextStep, + goToPreviousStep, + canGoNext, + canGoPrevious, + + // Progress + completionPercentage, + phase1Completion, + phase2Completion, + + // Checkpoints + validateCheckpoint, + overrideCheckpoint, + getCheckpointStatus, + + // Data Updates + updateUseCase, + addRisk, + updateControl, + + // Persistence + saveState, + loadState, + + // Demo Data + seedDemoData, + clearDemoData, + isDemoDataLoaded, + + // Sync + syncState, + forceSyncToServer, + isOnline, + + // Export + exportState, + + // Command Bar + isCommandBarOpen, + setCommandBarOpen, + } = useSDK() + + return ( +
+

Progress: {completionPercentage}%

+ +
+ ) +}`} +
+ +

Types

+

+ Das SDK exportiert alle TypeScript-Types für volle Typsicherheit: +

+ +{`import type { + // Core Types + SDKState, + SDKAction, + SDKStep, + SDKPhase, + + // Use Cases + UseCaseAssessment, + AssessmentResult, + + // Risk Management + Risk, + RiskSeverity, + RiskMitigation, + + // Controls & Evidence + Control, + Evidence, + Requirement, + + // Checkpoints + Checkpoint, + CheckpointStatus, + ValidationError, + + // DSFA + DSFA, + DSFASection, + DSFAApproval, + + // TOMs & VVT + TOM, + ProcessingActivity, + RetentionPolicy, + + // AI Act + AIActResult, + AIActRiskCategory, +} from '@breakpilot/compliance-sdk'`} + + +

Utility Functions

+

+ Hilfreiche Funktionen für die Arbeit mit dem SDK: +

+ +{`import { + // Step Navigation + getStepById, + getStepByUrl, + getNextStep, + getPreviousStep, + getStepsForPhase, + + // Risk Calculation + calculateRiskScore, + getRiskSeverityFromScore, + calculateResidualRisk, + + // Progress + getCompletionPercentage, + getPhaseCompletionPercentage, +} from '@breakpilot/compliance-sdk' + +// Beispiel: Risk Score berechnen +const inherentRisk = calculateRiskScore(4, 5) // likelihood * impact = 20 +const severity = getRiskSeverityFromScore(20) // 'CRITICAL'`} + + +

API Client

+

+ Für direkten API-Zugriff ohne React Context: +

+ +{`import { + getSDKApiClient, + SDKApiClient, +} from '@breakpilot/compliance-sdk' + +const client = getSDKApiClient('your-tenant-id') + +// State laden +const state = await client.getState() + +// State speichern +await client.saveState(updatedState) + +// Checkpoint validieren +const result = await client.validateCheckpoint('CP-UC', state) + +// Export +const blob = await client.exportState('pdf')`} + + +

RAG & LLM Client

+

+ Zugriff auf die RAG-Suche und Dokumentengenerierung: +

+ +{`import { + getSDKBackendClient, + isLegalQuery, +} from '@breakpilot/compliance-sdk' + +const client = getSDKBackendClient() + +// RAG-Suche +const results = await client.search('DSGVO Art. 5', 5) +console.log(results) // SearchResult[] + +// Dokumentengenerierung +const dsfa = await client.generateDSFA(context) +const toms = await client.generateTOM(context) +const vvt = await client.generateVVT(context) + +// Prüfen ob eine Query rechtliche Inhalte betrifft +if (isLegalQuery('Einwilligung DSGVO')) { + // RAG-Suche durchführen +}`} + + +

Export

+

+ Exportieren Sie Compliance-Daten in verschiedenen Formaten: +

+ +{`import { exportToPDF, exportToZIP, downloadExport } from '@breakpilot/compliance-sdk' + +// PDF Export +const pdfBlob = await exportToPDF(state) +downloadExport(pdfBlob, 'compliance-report.pdf') + +// ZIP Export (alle Dokumente) +const zipBlob = await exportToZIP(state) +downloadExport(zipBlob, 'compliance-export.zip') + +// Über den Hook +const { exportState } = useSDK() +const blob = await exportState('pdf') // 'json' | 'pdf' | 'zip'`} + + + +
    +
  • + + Installation Guide + +
  • +
  • + + Konfigurationsoptionen + +
  • +
  • + + Phase 1 Workflow Guide + +
  • +
+
+
+ ) +} diff --git a/developer-portal/components/DevPortalLayout.tsx b/developer-portal/components/DevPortalLayout.tsx new file mode 100644 index 0000000..c1ec019 --- /dev/null +++ b/developer-portal/components/DevPortalLayout.tsx @@ -0,0 +1,322 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { Book, Code, FileText, HelpCircle, Zap, Terminal, Database, Shield, ChevronRight, Clock, BookOpen } from 'lucide-react' + +interface NavItem { + title: string + href: string + icon?: React.ReactNode + items?: NavItem[] +} + +const navigation: NavItem[] = [ + { + title: 'Getting Started', + href: '/getting-started', + icon: , + items: [ + { title: 'Quick Start', href: '/getting-started' }, + ], + }, + { + title: 'SDK Documentation', + href: '/sdk', + icon: , + items: [ + { title: 'Overview', href: '/sdk' }, + { title: 'Installation', href: '/sdk/installation' }, + { title: 'Configuration', href: '/sdk/configuration' }, + ], + }, + { + title: 'Consent SDK', + href: '/sdk/consent', + icon: , + items: [ + { title: 'Uebersicht', href: '/sdk/consent' }, + { title: 'Installation', href: '/sdk/consent/installation' }, + { title: 'API Referenz', href: '/sdk/consent/api-reference' }, + { title: 'Frameworks', href: '/sdk/consent/frameworks' }, + { title: 'Mobile SDKs', href: '/sdk/consent/mobile' }, + { title: 'Sicherheit', href: '/sdk/consent/security' }, + ], + }, + { + title: 'API Reference', + href: '/api', + icon: , + items: [ + { title: 'Overview', href: '/api' }, + { title: 'State API', href: '/api/state' }, + { title: 'RAG Search API', href: '/api/rag' }, + { title: 'Generation API', href: '/api/generate' }, + { title: 'Export API', href: '/api/export' }, + ], + }, + { + title: 'Guides', + href: '/guides', + icon: , + items: [ + { title: 'Overview', href: '/guides' }, + { title: 'Phase 1: Assessment', href: '/guides/phase1' }, + { title: 'Phase 2: Dokumentation', href: '/guides/phase2' }, + ], + }, + { + title: 'Systemdokumentation', + href: '/development/docs', + icon: , + items: [ + { title: 'Compliance Service', href: '/development/docs' }, + { title: 'Klausur-Namespace (BYOEH)', href: '/development/byoeh' }, + ], + }, + { + title: 'Changelog', + href: '/changelog', + icon: , + items: [ + { title: 'Versionshistorie', href: '/changelog' }, + ], + }, +] + +interface DevPortalLayoutProps { + children: React.ReactNode + title?: string + description?: string +} + +export function DevPortalLayout({ children, title, description }: DevPortalLayoutProps) { + const pathname = usePathname() + + return ( +
+ {/* Header */} +
+
+
+
+ +
+ +
+ Developer Portal + + | + AI Compliance SDK +
+
+ + SDK Dashboard + + + GitHub + +
+
+
+
+ +
+ {/* Sidebar */} + + + {/* Main Content */} +
+
+ {(title || description) && ( +
+ {title && ( +

{title}

+ )} + {description && ( +

{description}

+ )} +
+ )} +
+ {children} +
+
+
+
+
+ ) +} + +// Re-usable components for documentation +export function ApiEndpoint({ + method, + path, + description, +}: { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' + path: string + description: string +}) { + const methodColors = { + GET: 'bg-green-100 text-green-800', + POST: 'bg-blue-100 text-blue-800', + PUT: 'bg-yellow-100 text-yellow-800', + DELETE: 'bg-red-100 text-red-800', + } + + return ( +
+
+ + {method} + + {path} +
+

{description}

+
+ ) +} + +export function CodeBlock({ + language, + children, + filename, +}: { + language: string + children: string + filename?: string +}) { + return ( +
+ {filename && ( +
+ {filename} +
+ )} +
+        {children}
+      
+
+ ) +} + +export function ParameterTable({ + parameters, +}: { + parameters: Array<{ + name: string + type: string + required?: boolean + description: string + }> +}) { + return ( +
+ + + + + + + + + + + {parameters.map((param) => ( + + + + + + + ))} + +
ParameterTypeRequiredDescription
+ {param.name} + + {param.type} + + {param.required ? ( + Yes + ) : ( + No + )} + {param.description}
+
+ ) +} + +export function InfoBox({ + type = 'info', + title, + children, +}: { + type?: 'info' | 'warning' | 'success' | 'error' + title?: string + children: React.ReactNode +}) { + const styles = { + info: 'bg-blue-50 border-blue-200 text-blue-800', + warning: 'bg-yellow-50 border-yellow-200 text-yellow-800', + success: 'bg-green-50 border-green-200 text-green-800', + error: 'bg-red-50 border-red-200 text-red-800', + } + + const icons = { + info: , + warning: , + success: , + error: , + } + + return ( +
+
+
{icons[type]}
+
+ {title &&

{title}

} +
{children}
+
+
+
+ ) +} diff --git a/developer-portal/components/SDKDocsSidebar.tsx b/developer-portal/components/SDKDocsSidebar.tsx new file mode 100644 index 0000000..ca8fbf3 --- /dev/null +++ b/developer-portal/components/SDKDocsSidebar.tsx @@ -0,0 +1,165 @@ +'use client' + +import React from 'react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { + Shield, Download, FileCode, Layers, Smartphone, Lock, + ChevronDown, ChevronRight, Home, BookOpen, + Code2 +} from 'lucide-react' + +interface NavItem { + title: string + href: string + icon?: React.ReactNode + children?: NavItem[] +} + +const navigation: NavItem[] = [ + { + title: 'Uebersicht', + href: '/sdk/consent', + icon: , + }, + { + title: 'Installation', + href: '/sdk/consent/installation', + icon: , + }, + { + title: 'API Referenz', + href: '/sdk/consent/api-reference', + icon: , + }, + { + title: 'Frameworks', + href: '/sdk/consent/frameworks', + icon: , + children: [ + { title: 'React', href: '/sdk/consent/frameworks/react' }, + { title: 'Vue', href: '/sdk/consent/frameworks/vue' }, + { title: 'Angular', href: '/sdk/consent/frameworks/angular' }, + ], + }, + { + title: 'Mobile SDKs', + href: '/sdk/consent/mobile', + icon: , + children: [ + { title: 'iOS (Swift)', href: '/sdk/consent/mobile/ios' }, + { title: 'Android (Kotlin)', href: '/sdk/consent/mobile/android' }, + { title: 'Flutter', href: '/sdk/consent/mobile/flutter' }, + ], + }, + { + title: 'Sicherheit', + href: '/sdk/consent/security', + icon: , + }, +] + +function NavLink({ item, depth = 0 }: { item: NavItem; depth?: number }) { + const pathname = usePathname() + const isActive = pathname === item.href + const isParentActive = item.children?.some((child) => pathname === child.href) + const [isOpen, setIsOpen] = React.useState(isActive || isParentActive) + + const hasChildren = item.children && item.children.length > 0 + + return ( +
+
+ + {item.icon && {item.icon}} + {item.title} + + {hasChildren && ( + + )} +
+ {hasChildren && isOpen && ( +
+ {item.children?.map((child) => ( + + ))} +
+ )} +
+ ) +} + +export function SDKDocsSidebar() { + return ( + + ) +} + +export default SDKDocsSidebar diff --git a/developer-portal/next.config.js b/developer-portal/next.config.js new file mode 100644 index 0000000..f0eab76 --- /dev/null +++ b/developer-portal/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + reactStrictMode: true, + typescript: { + ignoreBuildErrors: true, + }, +} + +module.exports = nextConfig diff --git a/developer-portal/package.json b/developer-portal/package.json new file mode 100644 index 0000000..9bf7020 --- /dev/null +++ b/developer-portal/package.json @@ -0,0 +1,25 @@ +{ + "name": "breakpilot-developer-portal", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3006", + "build": "next build", + "start": "next start -p 3006" + }, + "dependencies": { + "lucide-react": "^0.468.0", + "next": "^15.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/react": "^18.3.16", + "@types/react-dom": "^18.3.5", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.16", + "typescript": "^5.7.2" + } +} diff --git a/developer-portal/postcss.config.mjs b/developer-portal/postcss.config.mjs new file mode 100644 index 0000000..d0c615b --- /dev/null +++ b/developer-portal/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +export default config diff --git a/developer-portal/tailwind.config.ts b/developer-portal/tailwind.config.ts new file mode 100644 index 0000000..527c226 --- /dev/null +++ b/developer-portal/tailwind.config.ts @@ -0,0 +1,18 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + './components/**/*.{js,ts,jsx,tsx,mdx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + }, + }, + plugins: [], +} + +export default config diff --git a/developer-portal/tsconfig.json b/developer-portal/tsconfig.json new file mode 100644 index 0000000..d81d4ee --- /dev/null +++ b/developer-portal/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + }, + "target": "ES2017" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/docker-compose-new-infra.yml b/docker-compose-new-infra.yml new file mode 100644 index 0000000..f392b2f --- /dev/null +++ b/docker-compose-new-infra.yml @@ -0,0 +1,1049 @@ +services: + # PostgreSQL Database with PostGIS Extension + # PostGIS is required for geo-service (OSM/Terrain features) + postgres: + image: postgis/postgis:16-3.4-alpine + container_name: breakpilot-pwa-postgres + ports: + - "5432:5432" + environment: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_db + volumes: + - breakpilot_pwa_data:/var/lib/postgresql/data + - ./geo-service/scripts/init_postgis.sql:/docker-entrypoint-initdb.d/10-init-postgis.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_db"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Valkey - Session Cache (Redis-Fork, BSD-3) + # Redis-compatible, 100% Open Source + # ============================================ + valkey: + image: valkey/valkey:8-alpine + container_name: breakpilot-pwa-valkey + ports: + - "6379:6379" + volumes: + - valkey_data:/data + command: valkey-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Go Consent Service + consent-service: + build: + context: ./consent-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-consent-service + ports: + - "8081:8081" + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-your-refresh-secret-key-change-in-production} + - PORT=8081 + - ENVIRONMENT=${ENVIRONMENT:-development} + - ALLOWED_ORIGINS=http://localhost:8000,http://backend:8000 + # Valkey Session Cache + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379} + - SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-24} + # E-Mail Konfiguration (Mailpit für Entwicklung) + - SMTP_HOST=${SMTP_HOST:-mailpit} + - SMTP_PORT=${SMTP_PORT:-1025} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-BreakPilot} + - SMTP_FROM_ADDR=${SMTP_FROM_ADDR:-noreply@breakpilot.app} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8000} + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + mailpit: + condition: service_started + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Python Backend + backend: + build: + context: ./backend + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-backend + expose: + - "8000" + environment: + - CONSENT_SERVICE_URL=http://consent-service:8081 + - KLAUSUR_SERVICE_URL=http://klausur-service:8086 + - TROCR_SERVICE_URL=${TROCR_SERVICE_URL:-http://host.docker.internal:18084} + - CAMUNDA_URL=http://camunda:8080/engine-rest + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - ENVIRONMENT=${ENVIRONMENT:-development} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - DEBUG=${DEBUG:-false} + # Alerts Agent (Google Alerts Monitoring) + - ALERTS_AGENT_ENABLED=${ALERTS_AGENT_ENABLED:-true} + # HashiCorp Vault - Secrets Management + - VAULT_ADDR=${VAULT_ADDR:-http://vault:8200} + - VAULT_TOKEN=${VAULT_TOKEN:-breakpilot-dev-token} + - VAULT_SECRETS_PATH=${VAULT_SECRETS_PATH:-breakpilot} + - USE_VAULT_SECRETS=${USE_VAULT_SECRETS:-true} + # Keycloak Authentication (optional - wenn nicht gesetzt wird lokales JWT verwendet) + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} + - KEYCLOAK_VERIFY_SSL=${KEYCLOAK_VERIFY_SSL:-true} + # Valkey Session Cache + - VALKEY_URL=${VALKEY_URL:-redis://valkey:6379} + - SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-24} + # vast.ai GPU Infrastructure + - VAST_API_KEY=${VAST_API_KEY:-} + - VAST_INSTANCE_ID=${VAST_INSTANCE_ID:-} + - CONTROL_API_KEY=${CONTROL_API_KEY:-} + - VAST_AUTO_SHUTDOWN=${VAST_AUTO_SHUTDOWN:-true} + - VAST_AUTO_SHUTDOWN_MINUTES=${VAST_AUTO_SHUTDOWN_MINUTES:-30} + # vLLM Backend + - VLLM_BASE_URL=${VLLM_BASE_URL:-} + - VLLM_ENABLED=${VLLM_ENABLED:-false} + # Ollama Backend (lokal auf Mac Mini - DSGVO-konform) + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_ENABLED=${OLLAMA_ENABLED:-true} + - OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + - OLLAMA_VISION_MODEL=${OLLAMA_VISION_MODEL:-qwen2.5vl:32b} + - OLLAMA_CORRECTION_MODEL=${OLLAMA_CORRECTION_MODEL:-qwen2.5:14b} + - OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT:-180} + # Breakpilot Drive Game API + - GAME_USE_DATABASE=${GAME_USE_DATABASE:-true} + - GAME_REQUIRE_AUTH=${GAME_REQUIRE_AUTH:-false} + - GAME_REQUIRE_BILLING=${GAME_REQUIRE_BILLING:-false} + - GAME_LLM_MODEL=${GAME_LLM_MODEL:-} + # Compliance LLM Provider Configuration + # Options: "anthropic" (cloud) or "self_hosted" (Ollama/local) + - COMPLIANCE_LLM_PROVIDER=${COMPLIANCE_LLM_PROVIDER:-self_hosted} + - SELF_HOSTED_LLM_URL=${SELF_HOSTED_LLM_URL:-http://host.docker.internal:11434} + - SELF_HOSTED_LLM_MODEL=${SELF_HOSTED_LLM_MODEL:-llama3.1:70b} + - COMPLIANCE_LLM_MAX_TOKENS=${COMPLIANCE_LLM_MAX_TOKENS:-4096} + - COMPLIANCE_LLM_TEMPERATURE=${COMPLIANCE_LLM_TEMPERATURE:-0.3} + - COMPLIANCE_LLM_TIMEOUT=${COMPLIANCE_LLM_TIMEOUT:-120} + # E-Mail Konfiguration (Mailpit fuer Entwicklung) + - SMTP_HOST=${SMTP_HOST:-mailpit} + - SMTP_PORT=${SMTP_PORT:-1025} + - SMTP_USERNAME=${SMTP_USERNAME:-} + - SMTP_PASSWORD=${SMTP_PASSWORD:-} + - SMTP_FROM_NAME=${SMTP_FROM_NAME:-BreakPilot} + - SMTP_FROM_ADDR=${SMTP_FROM_ADDR:-noreply@breakpilot.app} + extra_hosts: + - "host.docker.internal:host-gateway" + - "mac-mini:192.168.178.163" + volumes: + # Mount Docker socket for container monitoring (Mac Mini Control) + - /var/run/docker.sock:/var/run/docker.sock:ro + # Mount SBOM files for Security Dashboard + - ./docs/sbom:/app/docs/sbom:ro + # Mount Projekt-Verzeichnis fuer Test-Dashboard (echte Test-Discovery) + - /Users/benjaminadmin/Projekte/breakpilot-pwa:/app/project:ro + depends_on: + - consent-service + - valkey + - mailpit + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Mailpit - Lokaler E-Mail-Server für Entwicklung + # Web UI: http://localhost:8025 + # SMTP: localhost:1025 + mailpit: + image: axllent/mailpit:latest + container_name: breakpilot-pwa-mailpit + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP Server + environment: + - MP_SMTP_AUTH_ACCEPT_ANY=true + - MP_SMTP_AUTH_ALLOW_INSECURE=true + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Go School Service (Klausuren, Noten, Zeugnisse) + school-service: + build: + context: ./school-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-school-service + ports: + - "8084:8084" + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - PORT=8084 + - ENVIRONMENT=${ENVIRONMENT:-development} + - ALLOWED_ORIGINS=http://localhost:8000,http://backend:8000 + - LLM_GATEWAY_URL=http://backend:8000/llm + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Embedding Service (ML-heavy operations) + # Handles: embeddings, re-ranking, PDF extraction + # Separated for faster klausur-service builds + embedding-service: + build: + context: ./klausur-service/embedding-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-embedding-service + ports: + - "8087:8087" + environment: + - EMBEDDING_BACKEND=${EMBEDDING_BACKEND:-local} + - LOCAL_EMBEDDING_MODEL=${LOCAL_EMBEDDING_MODEL:-BAAI/bge-m3} + - LOCAL_RERANKER_MODEL=${LOCAL_RERANKER_MODEL:-BAAI/bge-reranker-v2-m3} + - PDF_EXTRACTION_BACKEND=${PDF_EXTRACTION_BACKEND:-auto} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - COHERE_API_KEY=${COHERE_API_KEY:-} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + volumes: + - embedding_models:/root/.cache/huggingface + deploy: + resources: + limits: + memory: 4G + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8087/health').raise_for_status()"] + interval: 30s + timeout: 10s + start_period: 120s + retries: 3 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Klausur-Service (Abitur/Vorabitur Klausurkorrektur) + # React + FastAPI Microservice + # Web UI: http://localhost:8086 + klausur-service: + build: + context: ./klausur-service + dockerfile: Dockerfile + platform: linux/arm64 # Native ARM64 - PaddlePaddle 3.3.0 unterstützt ARM64 + container_name: breakpilot-pwa-klausur-service + expose: + - "8086" + environment: + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - BACKEND_URL=http://backend:8000 + - SCHOOL_SERVICE_URL=http://school-service:8084 + - ENVIRONMENT=${ENVIRONMENT:-development} + # PostgreSQL for OCR Labeling & Metrics + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db + # Embedding Service (ML operations) + - EMBEDDING_SERVICE_URL=http://embedding-service:8087 + # BYOEH Configuration + - QDRANT_URL=http://qdrant:6333 + - BYOEH_ENCRYPTION_ENABLED=true + - BYOEH_CHUNK_SIZE=1000 + - BYOEH_CHUNK_OVERLAP=200 + # MinIO Configuration (RAG Document Storage) + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-rag + - MINIO_SECURE=false + # Ollama LLM Configuration + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_ENABLED=${OLLAMA_ENABLED:-true} + - OLLAMA_DEFAULT_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + - OLLAMA_VISION_MODEL=${OLLAMA_VISION_MODEL:-qwen2.5vl:32b} + - OLLAMA_CORRECTION_MODEL=${OLLAMA_CORRECTION_MODEL:-qwen2.5:14b} + # PaddleOCR Service (x86_64 via Rosetta) + - PADDLEOCR_SERVICE_URL=http://paddleocr-service:8095 + # HashiCorp Vault - Anthropic API Key for Loesung E + - VAULT_ADDR=http://vault:8200 + - VAULT_TOKEN=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + volumes: + - klausur_uploads:/app/uploads + - eh_uploads:/app/eh-uploads + - ocr_labeling:/app/ocr-labeling + - paddle_models:/root/.paddlex # Persist PaddleOCR models across restarts + - ./docs:/app/docs # NIBIS Abitur-Dateien + depends_on: + - backend + - school-service + - embedding-service + - postgres + - qdrant + - minio + - paddleocr-service + - vault + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8086/health"] + interval: 30s + timeout: 30s + retries: 3 + start_period: 10s + restart: unless-stopped + + # ============================================ + # PaddleOCR Service - x86_64 via Rosetta + # Runs in emulation to avoid ARM64 crashes + # ============================================ + paddleocr-service: + build: + context: ./paddleocr-service + dockerfile: Dockerfile + platform: linux/amd64 # Force x86_64 emulation via Rosetta + container_name: breakpilot-pwa-paddleocr + expose: + - "8095" + environment: + - PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True + volumes: + - paddleocr_models:/root/.paddlex # Cache PaddleX models + - paddleocr_models:/root/.paddleocr # Cache PaddleOCR 3.x models + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8095/health"] + interval: 30s + timeout: 60s + retries: 5 + start_period: 180s # Models need time to load in emulation + restart: unless-stopped + + # Qdrant Vector Database (BYOEH - Erwartungshorizont RAG) + # REST API: http://localhost:6333 + # gRPC: localhost:6334 + qdrant: + image: qdrant/qdrant:v1.12.1 + container_name: breakpilot-pwa-qdrant + ports: + - "6333:6333" + - "6334:6334" + volumes: + - qdrant_data:/qdrant/storage + environment: + - QDRANT__SERVICE__GRPC_PORT=6334 + healthcheck: + test: ["CMD-SHELL", "bash -c ' /dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 3 + + # DSMS Gateway - REST API für DSMS + dsms-gateway: + build: + context: ./dsms-gateway + dockerfile: Dockerfile + container_name: breakpilot-pwa-dsms-gateway + ports: + - "8082:8082" + environment: + - IPFS_API_URL=http://dsms-node:5001 + - IPFS_GATEWAY_URL=http://dsms-node:8080 + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + depends_on: + dsms-node: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # ============================================ + # Jitsi Meet - Videokonferenzen für Schulungen + # Web UI: http://localhost:8443 + # ============================================ + + # Jitsi Web Frontend + jitsi-web: + image: jitsi/web:stable-9823 + container_name: breakpilot-pwa-jitsi-web + expose: + - "80" + environment: + - ENABLE_XMPP_WEBSOCKET=1 + - ENABLE_COLIBRI_WEBSOCKET=1 + - XMPP_DOMAIN=meet.jitsi + - XMPP_BOSH_URL_BASE=http://jitsi-xmpp:5280 + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - TZ=Europe/Berlin + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - JICOFO_AUTH_USER=focus + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - ENABLE_GUESTS=${JITSI_ENABLE_GUESTS:-1} + - ENABLE_RECORDING=${JITSI_ENABLE_RECORDING:-1} + - ENABLE_LIVESTREAMING=0 + - DISABLE_HTTPS=1 + # Branding + - APP_NAME=BreakPilot Meet + - NATIVE_APP_NAME=BreakPilot Meet + - PROVIDER_NAME=BreakPilot + volumes: + - jitsi_web_config:/config + - jitsi_web_crontabs:/var/spool/cron/crontabs + - jitsi_transcripts:/usr/share/jitsi-meet/transcripts + networks: + breakpilot-pwa-network: + aliases: + - meet.jitsi + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # Prosody XMPP Server + jitsi-xmpp: + image: jitsi/prosody:stable-9823 + container_name: breakpilot-pwa-jitsi-xmpp + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_GUEST_DOMAIN=guest.meet.jitsi + - XMPP_RECORDER_DOMAIN=recorder.meet.jitsi + - XMPP_CROSS_DOMAIN=true + - TZ=Europe/Berlin + - JICOFO_AUTH_USER=focus + - JICOFO_AUTH_PASSWORD=${JITSI_JICOFO_AUTH_PASSWORD:-jicofo_secret_123} + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${JITSI_JVB_AUTH_PASSWORD:-jvb_secret_123} + - JIGASI_XMPP_USER=jigasi + - JIGASI_XMPP_PASSWORD=${JITSI_JIGASI_XMPP_PASSWORD:-jigasi_secret_123} + - JIBRI_XMPP_USER=jibri + - JIBRI_XMPP_PASSWORD=${JITSI_JIBRI_XMPP_PASSWORD:-jibri_secret_123} + - JIBRI_RECORDER_USER=recorder + - JIBRI_RECORDER_PASSWORD=${JITSI_JIBRI_RECORDER_PASSWORD:-recorder_secret_123} + - LOG_LEVEL=info + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - ENABLE_GUESTS=${JITSI_ENABLE_GUESTS:-1} + volumes: + - jitsi_prosody_config:/config + - jitsi_prosody_plugins:/prosody-plugins-custom + networks: + breakpilot-pwa-network: + aliases: + - xmpp.meet.jitsi + restart: unless-stopped + + # Jicofo - Jitsi Conference Focus + jitsi-jicofo: + image: jitsi/jicofo:stable-9823 + container_name: breakpilot-pwa-jitsi-jicofo + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - JICOFO_AUTH_USER=focus + - JICOFO_AUTH_PASSWORD=${JITSI_JICOFO_AUTH_PASSWORD:-jicofo_secret_123} + - TZ=Europe/Berlin + - ENABLE_AUTH=${JITSI_ENABLE_AUTH:-0} + - AUTH_TYPE=internal + - ENABLE_AUTO_OWNER=${JITSI_ENABLE_AUTO_OWNER:-1} + volumes: + - jitsi_jicofo_config:/config + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # JVB - Jitsi Videobridge (WebRTC SFU) + jitsi-jvb: + image: jitsi/jvb:stable-9823 + container_name: breakpilot-pwa-jitsi-jvb + ports: + - "10000:10000/udp" # Video/Audio RTP + - "8080:8080" # Colibri REST API (internal) + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - JVB_AUTH_USER=jvb + - JVB_AUTH_PASSWORD=${JITSI_JVB_AUTH_PASSWORD:-jvb_secret_123} + - JVB_PORT=10000 + - JVB_STUN_SERVERS=meet-jit-si-turnrelay.jitsi.net:443 + - TZ=Europe/Berlin + - PUBLIC_URL=${JITSI_PUBLIC_URL:-https://macmini} + - COLIBRI_REST_ENABLED=true + - ENABLE_COLIBRI_WEBSOCKET=1 + volumes: + - jitsi_jvb_config:/config + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + + # ============================================ + # Jibri - Jitsi Recording Service + # Recordings werden zu MinIO hochgeladen + # ============================================ + jibri: + build: + context: ./docker/jibri + dockerfile: Dockerfile + container_name: breakpilot-pwa-jibri + environment: + - XMPP_DOMAIN=meet.jitsi + - XMPP_AUTH_DOMAIN=auth.meet.jitsi + - XMPP_INTERNAL_MUC_DOMAIN=internal-muc.meet.jitsi + - XMPP_RECORDER_DOMAIN=recorder.meet.jitsi + - XMPP_SERVER=jitsi-xmpp + - XMPP_MUC_DOMAIN=muc.meet.jitsi + - JIBRI_XMPP_USER=jibri + - JIBRI_XMPP_PASSWORD=${JITSI_JIBRI_XMPP_PASSWORD:-jibri_secret_123} + - JIBRI_RECORDER_USER=recorder + - JIBRI_RECORDER_PASSWORD=${JITSI_JIBRI_RECORDER_PASSWORD:-recorder_secret_123} + - JIBRI_BREWERY_MUC=jibribrewery + - JIBRI_RECORDING_DIR=/recordings + - JIBRI_FINALIZE_SCRIPT=/config/finalize.sh + - TZ=Europe/Berlin + # X11 Display Konfiguration (Xvfb) + - DISPLAY=:0 + - RESOLUTION=1920x1080x24 + # Optional: VNC fuer Debugging (Port 5900) + # - VNC_PASSWORD=debug123 + # MinIO Upload Konfiguration + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD} + - MINIO_BUCKET=breakpilot-recordings + # Backend Webhook (wird nach Upload aufgerufen) + - BACKEND_WEBHOOK_URL=http://backend:8000/api/recordings/webhook + volumes: + - jibri_recordings:/recordings + - /dev/shm:/dev/shm + shm_size: '2gb' + cap_add: + - SYS_ADMIN + - NET_BIND_SERVICE + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + - jitsi-xmpp + - minio + profiles: + - recording + + # ============================================ + # Transcription Worker - Whisper + pyannote + # Verarbeitet Recordings asynchron + # ============================================ + transcription-worker: + build: + context: ./backend + dockerfile: Dockerfile.worker + container_name: breakpilot-pwa-transcription-worker + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db?sslmode=disable + - REDIS_URL=redis://valkey:6379/1 + - WHISPER_MODEL=${WHISPER_MODEL:-large-v3} + - WHISPER_DEVICE=${WHISPER_DEVICE:-cpu} + - WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-int8} + # pyannote.audio Token (HuggingFace) + - PYANNOTE_AUTH_TOKEN=${PYANNOTE_AUTH_TOKEN:-} + # MinIO Storage + - MINIO_ENDPOINT=${MINIO_ENDPOINT:-minio:9000} + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-recordings + - MINIO_SECURE=false + - TZ=Europe/Berlin + volumes: + - transcription_models:/root/.cache/huggingface + - transcription_temp:/tmp/transcriptions + deploy: + resources: + limits: + memory: 8G + reservations: + memory: 4G + networks: + - breakpilot-pwa-network + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + minio: + condition: service_started + profiles: + - recording + + # ============================================ + # Camunda 7 - BPMN Workflow Engine + # Web UI: http://localhost:8089/camunda + # REST API: http://localhost:8089/engine-rest + # License: Apache 2.0 (kommerziell nutzbar) + # ============================================ + camunda: + image: camunda/camunda-bpm-platform:7.21.0 + container_name: breakpilot-pwa-camunda + ports: + - "8089:8080" + environment: + - DB_DRIVER=org.postgresql.Driver + - DB_URL=jdbc:postgresql://postgres:5432/breakpilot_db + - DB_USERNAME=breakpilot + - DB_PASSWORD=${POSTGRES_PASSWORD:-breakpilot123} + - DB_VALIDATE_ON_BORROW=true + - WAIT_FOR=postgres:5432 + - CAMUNDA_BPM_ADMIN_USER_ID=admin + - CAMUNDA_BPM_ADMIN_USER_PASSWORD=${CAMUNDA_ADMIN_PASSWORD:-admin123} + depends_on: + postgres: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/camunda/api/engine || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + restart: unless-stopped + profiles: + - bpmn + + # ============================================ + # GeoEdu Service - Self-Hosted OSM + Terrain + # DSGVO-konforme Erdkunde-Lernplattform + # Web UI: http://localhost:8088 + # ============================================ + geo-service: + build: + context: ./geo-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-geo-service + ports: + - "8088:8088" + environment: + - PORT=8088 + - ENVIRONMENT=${ENVIRONMENT:-development} + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key} + # PostgreSQL (PostGIS fuer OSM-Daten) + - DATABASE_URL=postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db + # MinIO (AOI Bundles, generierte Assets) + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-breakpilot} + - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-breakpilot123} + - MINIO_BUCKET=breakpilot-geo + - MINIO_SECURE=false + # Ollama (Lernstationen generieren) + - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + - OLLAMA_MODEL=${OLLAMA_DEFAULT_MODEL:-qwen2.5:14b} + # Tile Server Config + - TILE_CACHE_DIR=/app/cache/tiles + - DEM_CACHE_DIR=/app/cache/dem + - MAX_AOI_SIZE_KM2=4 + volumes: + - geo_osm_data:/app/data/osm + - geo_dem_data:/app/data/dem + - geo_tile_cache:/app/cache/tiles + - geo_aoi_bundles:/app/bundles + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_started + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8088/health"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + restart: unless-stopped + + # ============================================ + # Voice Service - PersonaPlex + TaskOrchestrator + # Voice-First Interface fuer Breakpilot + # DSGVO-konform: Keine Audio-Persistenz + # Web UI: http://localhost:8091 + # ============================================ + voice-service: + build: + context: ./voice-service + dockerfile: Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-voice-service + # Port 8091 wird von nginx HTTPS bereitgestellt (wss://macmini:8091) + expose: + - "8091" + environment: + - PORT=8091 + - DATABASE_URL=postgresql+asyncpg://breakpilot:breakpilot123@postgres:5432/breakpilot_db + - VALKEY_URL=redis://valkey:6379/2 + - PERSONAPLEX_ENABLED=${PERSONAPLEX_ENABLED:-false} + - PERSONAPLEX_WS_URL=${PERSONAPLEX_WS_URL:-ws://host.docker.internal:8998} + - ORCHESTRATOR_ENABLED=true + - FALLBACK_LLM_PROVIDER=${FALLBACK_LLM_PROVIDER:-ollama} + - OLLAMA_BASE_URL=http://host.docker.internal:11434 + - OLLAMA_VOICE_MODEL=qwen2.5:32b + - BQAS_JUDGE_MODEL=qwen2.5:14b + - KLAUSUR_SERVICE_URL=http://klausur-service:8086 + - ENCRYPTION_ENABLED=true + - AUDIO_PERSISTENCE=false + - AUDIO_SAMPLE_RATE=24000 + - ENVIRONMENT=${ENVIRONMENT:-development} + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + volumes: + - voice_session_data:/app/data/sessions + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8091/health"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + restart: unless-stopped + + # ============================================ + # MkDocs Documentation + # Web UI: http://localhost:8009 + # Material Theme with German language support + # ============================================ + docs: + build: + context: . + dockerfile: docs-src/Dockerfile + platform: linux/arm64 # Mac Mini Apple Silicon + container_name: breakpilot-pwa-docs + ports: + - "8009:80" + networks: + - breakpilot-pwa-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + breakpilot_pwa_data: + # Embedding Service Model Cache + embedding_models: + # Valkey Session Cache + valkey_data: + dsms_data: + klausur_uploads: + eh_uploads: + ocr_labeling: + # PaddleOCR Model Cache (persist across container restarts) + paddle_models: + # PaddleOCR Service Model Cache (x86_64 emulation) + paddleocr_models: + qdrant_data: + # Jitsi Volumes + jitsi_web_config: + jitsi_web_crontabs: + jitsi_transcripts: + jitsi_prosody_config: + jitsi_prosody_plugins: + jitsi_jicofo_config: + jitsi_jvb_config: + # Jibri Recording Volumes + jibri_recordings: + # Transcription Worker Volumes + transcription_models: + transcription_temp: + # GeoEdu Service Volumes + geo_osm_data: + geo_dem_data: + geo_tile_cache: + geo_aoi_bundles: + # Voice Service Volumes (transient sessions only) + voice_session_data: + +networks: + breakpilot-pwa-network: + driver: bridge diff --git a/dsms-gateway/Dockerfile b/dsms-gateway/Dockerfile new file mode 100644 index 0000000..5ac37dc --- /dev/null +++ b/dsms-gateway/Dockerfile @@ -0,0 +1,32 @@ +# DSMS Gateway - REST API für dezentrales Speichersystem +FROM python:3.11-slim + +LABEL maintainer="BreakPilot " +LABEL description="DSMS Gateway - REST API wrapper for IPFS" + +WORKDIR /app + +# Install curl for healthcheck and dependencies +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY main.py . + +# Environment variables +ENV IPFS_API_URL=http://dsms-node:5001 +ENV IPFS_GATEWAY_URL=http://dsms-node:8080 +ENV PORT=8082 + +# Expose port +EXPOSE 8082 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8082/health || exit 1 + +# Run application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8082"] diff --git a/dsms-gateway/main.py b/dsms-gateway/main.py new file mode 100644 index 0000000..0a2a390 --- /dev/null +++ b/dsms-gateway/main.py @@ -0,0 +1,467 @@ +""" +DSMS Gateway - REST API für dezentrales Speichersystem +Bietet eine vereinfachte API über IPFS für BreakPilot +""" + +import os +import json +import httpx +import hashlib +from datetime import datetime +from typing import Optional +from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +import io + +app = FastAPI( + title="DSMS Gateway", + description="Dezentrales Daten Speicher System Gateway für BreakPilot", + version="1.0.0" +) + +# CORS Configuration +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8000", "http://backend:8000", "*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Configuration +IPFS_API_URL = os.getenv("IPFS_API_URL", "http://dsms-node:5001") +IPFS_GATEWAY_URL = os.getenv("IPFS_GATEWAY_URL", "http://dsms-node:8080") +JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") + + +# Models +class DocumentMetadata(BaseModel): + """Metadaten für gespeicherte Dokumente""" + document_type: str # 'legal_document', 'consent_record', 'audit_log' + document_id: Optional[str] = None + version: Optional[str] = None + language: Optional[str] = "de" + created_at: Optional[str] = None + checksum: Optional[str] = None + encrypted: bool = False + + +class StoredDocument(BaseModel): + """Antwort nach erfolgreichem Speichern""" + cid: str # Content Identifier (IPFS Hash) + size: int + metadata: DocumentMetadata + gateway_url: str + timestamp: str + + +class DocumentList(BaseModel): + """Liste der gespeicherten Dokumente""" + documents: list + total: int + + +# Helper Functions +async def verify_token(authorization: Optional[str] = Header(None)) -> dict: + """Verifiziert JWT Token (vereinfacht für MVP)""" + if not authorization: + raise HTTPException(status_code=401, detail="Authorization header fehlt") + + # In Produktion: JWT validieren + # Für MVP: Einfache Token-Prüfung + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Ungültiges Token-Format") + + return {"valid": True} + + +async def ipfs_add(content: bytes, pin: bool = True) -> dict: + """Fügt Inhalt zu IPFS hinzu""" + async with httpx.AsyncClient(timeout=60.0) as client: + files = {"file": ("document", content)} + params = {"pin": str(pin).lower()} + + response = await client.post( + f"{IPFS_API_URL}/api/v0/add", + files=files, + params=params + ) + + if response.status_code != 200: + raise HTTPException( + status_code=502, + detail=f"IPFS Fehler: {response.text}" + ) + + return response.json() + + +async def ipfs_cat(cid: str) -> bytes: + """Liest Inhalt von IPFS""" + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/cat", + params={"arg": cid} + ) + + if response.status_code != 200: + raise HTTPException( + status_code=404, + detail=f"Dokument nicht gefunden: {cid}" + ) + + return response.content + + +async def ipfs_pin_ls() -> list: + """Listet alle gepinnten Objekte""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/pin/ls", + params={"type": "recursive"} + ) + + if response.status_code != 200: + return [] + + data = response.json() + return list(data.get("Keys", {}).keys()) + + +# API Endpoints +@app.get("/health") +async def health_check(): + """Health Check für DSMS Gateway""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post(f"{IPFS_API_URL}/api/v0/id") + ipfs_status = response.status_code == 200 + except Exception: + ipfs_status = False + + return { + "status": "healthy" if ipfs_status else "degraded", + "ipfs_connected": ipfs_status, + "timestamp": datetime.utcnow().isoformat() + } + + +@app.post("/api/v1/documents", response_model=StoredDocument) +async def store_document( + file: UploadFile = File(...), + document_type: str = "legal_document", + document_id: Optional[str] = None, + version: Optional[str] = None, + language: str = "de", + _auth: dict = Depends(verify_token) +): + """ + Speichert ein Dokument im DSMS. + + - **file**: Das zu speichernde Dokument + - **document_type**: Typ des Dokuments (legal_document, consent_record, audit_log) + - **document_id**: Optionale ID des Dokuments + - **version**: Optionale Versionsnummer + - **language**: Sprache (default: de) + """ + content = await file.read() + + # Checksum berechnen + checksum = hashlib.sha256(content).hexdigest() + + # Metadaten erstellen + metadata = DocumentMetadata( + document_type=document_type, + document_id=document_id, + version=version, + language=language, + created_at=datetime.utcnow().isoformat(), + checksum=checksum, + encrypted=False + ) + + # Dokument mit Metadaten als JSON verpacken + package = { + "metadata": metadata.model_dump(), + "content_base64": content.hex(), # Hex-encodiert für JSON + "filename": file.filename + } + + package_bytes = json.dumps(package).encode() + + # Zu IPFS hinzufügen + result = await ipfs_add(package_bytes) + + cid = result.get("Hash") + size = int(result.get("Size", 0)) + + return StoredDocument( + cid=cid, + size=size, + metadata=metadata, + gateway_url=f"{IPFS_GATEWAY_URL}/ipfs/{cid}", + timestamp=datetime.utcnow().isoformat() + ) + + +@app.get("/api/v1/documents/{cid}") +async def get_document( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Ruft ein Dokument aus dem DSMS ab. + + - **cid**: Content Identifier (IPFS Hash) + """ + content = await ipfs_cat(cid) + + try: + package = json.loads(content) + metadata = package.get("metadata", {}) + original_content = bytes.fromhex(package.get("content_base64", "")) + filename = package.get("filename", "document") + + return StreamingResponse( + io.BytesIO(original_content), + media_type="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "X-DSMS-Document-Type": metadata.get("document_type", "unknown"), + "X-DSMS-Checksum": metadata.get("checksum", ""), + "X-DSMS-Created-At": metadata.get("created_at", "") + } + ) + except json.JSONDecodeError: + # Wenn es kein DSMS-Paket ist, gib rohen Inhalt zurück + return StreamingResponse( + io.BytesIO(content), + media_type="application/octet-stream" + ) + + +@app.get("/api/v1/documents/{cid}/metadata") +async def get_document_metadata( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Ruft nur die Metadaten eines Dokuments ab. + + - **cid**: Content Identifier (IPFS Hash) + """ + content = await ipfs_cat(cid) + + try: + package = json.loads(content) + return { + "cid": cid, + "metadata": package.get("metadata", {}), + "filename": package.get("filename"), + "size": len(bytes.fromhex(package.get("content_base64", ""))) + } + except json.JSONDecodeError: + return { + "cid": cid, + "metadata": {}, + "raw_size": len(content) + } + + +@app.get("/api/v1/documents", response_model=DocumentList) +async def list_documents( + _auth: dict = Depends(verify_token) +): + """ + Listet alle gespeicherten Dokumente auf. + """ + cids = await ipfs_pin_ls() + + documents = [] + for cid in cids[:100]: # Limit auf 100 für Performance + try: + content = await ipfs_cat(cid) + package = json.loads(content) + documents.append({ + "cid": cid, + "metadata": package.get("metadata", {}), + "filename": package.get("filename") + }) + except Exception: + # Überspringe nicht-DSMS Objekte + continue + + return DocumentList( + documents=documents, + total=len(documents) + ) + + +@app.delete("/api/v1/documents/{cid}") +async def unpin_document( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Entfernt ein Dokument aus dem lokalen Pin-Set. + Das Dokument bleibt im Netzwerk, wird aber bei GC entfernt. + + - **cid**: Content Identifier (IPFS Hash) + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/pin/rm", + params={"arg": cid} + ) + + if response.status_code != 200: + raise HTTPException( + status_code=404, + detail=f"Konnte Pin nicht entfernen: {cid}" + ) + + return { + "status": "unpinned", + "cid": cid, + "message": "Dokument wird bei nächster Garbage Collection entfernt" + } + + +@app.post("/api/v1/legal-documents/archive") +async def archive_legal_document( + document_id: str, + version: str, + content: str, + language: str = "de", + _auth: dict = Depends(verify_token) +): + """ + Archiviert eine rechtliche Dokumentversion dauerhaft. + Speziell für AGB, Datenschutzerklärung, etc. + + - **document_id**: ID des Legal Documents + - **version**: Versionsnummer + - **content**: HTML/Markdown Inhalt + - **language**: Sprache + """ + # Checksum berechnen + content_bytes = content.encode('utf-8') + checksum = hashlib.sha256(content_bytes).hexdigest() + + # Metadaten + metadata = { + "document_type": "legal_document", + "document_id": document_id, + "version": version, + "language": language, + "created_at": datetime.utcnow().isoformat(), + "checksum": checksum, + "content_type": "text/html" + } + + # Paket erstellen + package = { + "metadata": metadata, + "content": content, + "archived_at": datetime.utcnow().isoformat() + } + + package_bytes = json.dumps(package, ensure_ascii=False).encode('utf-8') + + # Zu IPFS hinzufügen + result = await ipfs_add(package_bytes) + + cid = result.get("Hash") + + return { + "cid": cid, + "document_id": document_id, + "version": version, + "checksum": checksum, + "archived_at": datetime.utcnow().isoformat(), + "verification_url": f"{IPFS_GATEWAY_URL}/ipfs/{cid}" + } + + +@app.get("/api/v1/verify/{cid}") +async def verify_document(cid: str): + """ + Verifiziert die Integrität eines Dokuments. + Öffentlich zugänglich für Audit-Zwecke. + + - **cid**: Content Identifier (IPFS Hash) + """ + try: + content = await ipfs_cat(cid) + package = json.loads(content) + + # Checksum verifizieren + stored_checksum = package.get("metadata", {}).get("checksum") + + if "content_base64" in package: + original_content = bytes.fromhex(package["content_base64"]) + calculated_checksum = hashlib.sha256(original_content).hexdigest() + elif "content" in package: + calculated_checksum = hashlib.sha256( + package["content"].encode('utf-8') + ).hexdigest() + else: + calculated_checksum = None + + integrity_valid = ( + stored_checksum == calculated_checksum + if stored_checksum and calculated_checksum + else None + ) + + return { + "cid": cid, + "exists": True, + "integrity_valid": integrity_valid, + "metadata": package.get("metadata", {}), + "stored_checksum": stored_checksum, + "calculated_checksum": calculated_checksum, + "verified_at": datetime.utcnow().isoformat() + } + except Exception as e: + return { + "cid": cid, + "exists": False, + "error": str(e), + "verified_at": datetime.utcnow().isoformat() + } + + +@app.get("/api/v1/node/info") +async def get_node_info(): + """ + Gibt Informationen über den DSMS Node zurück. + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Node ID + id_response = await client.post(f"{IPFS_API_URL}/api/v0/id") + node_info = id_response.json() if id_response.status_code == 200 else {} + + # Repo Stats + stat_response = await client.post(f"{IPFS_API_URL}/api/v0/repo/stat") + repo_stats = stat_response.json() if stat_response.status_code == 200 else {} + + return { + "node_id": node_info.get("ID"), + "protocol_version": node_info.get("ProtocolVersion"), + "agent_version": node_info.get("AgentVersion"), + "repo_size": repo_stats.get("RepoSize"), + "storage_max": repo_stats.get("StorageMax"), + "num_objects": repo_stats.get("NumObjects"), + "addresses": node_info.get("Addresses", [])[:5] # Erste 5 + } + except Exception as e: + return {"error": str(e)} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8082) diff --git a/dsms-gateway/requirements.txt b/dsms-gateway/requirements.txt new file mode 100644 index 0000000..0c13cca --- /dev/null +++ b/dsms-gateway/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.104.0 +uvicorn>=0.24.0 +httpx>=0.25.0 +pydantic>=2.5.0 +python-multipart>=0.0.6 + +# Testing +pytest>=7.4.0 +pytest-asyncio>=0.21.0 diff --git a/dsms-gateway/test_main.py b/dsms-gateway/test_main.py new file mode 100644 index 0000000..8a40705 --- /dev/null +++ b/dsms-gateway/test_main.py @@ -0,0 +1,612 @@ +""" +Unit Tests für DSMS Gateway +Tests für alle API-Endpoints und Hilfsfunktionen +""" + +import pytest +import hashlib +import json +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.testclient import TestClient +from httpx import Response + +# Import der App +from main import app, DocumentMetadata, StoredDocument, DocumentList + + +# Test Client +client = TestClient(app) + + +# ==================== Fixtures ==================== + +@pytest.fixture +def valid_auth_header(): + """Gültiger Authorization Header für Tests""" + return {"Authorization": "Bearer test-token-12345"} + + +@pytest.fixture +def sample_document_metadata(): + """Beispiel-Metadaten für Tests""" + return DocumentMetadata( + document_type="legal_document", + document_id="doc-123", + version="1.0", + language="de", + created_at="2024-01-01T00:00:00", + checksum="abc123", + encrypted=False + ) + + +@pytest.fixture +def mock_ipfs_response(): + """Mock-Antwort von IPFS add""" + return { + "Hash": "QmTest1234567890abcdef", + "Size": "1024" + } + + +# ==================== Health Check Tests ==================== + +class TestHealthCheck: + """Tests für den Health Check Endpoint""" + + def test_health_check_ipfs_connected(self): + """Test: Health Check wenn IPFS verbunden ist""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock(status_code=200) + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert "status" in data + assert "ipfs_connected" in data + assert "timestamp" in data + + def test_health_check_ipfs_disconnected(self): + """Test: Health Check wenn IPFS nicht erreichbar""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.side_effect = Exception("Connection failed") + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "degraded" + assert data["ipfs_connected"] is False + + +# ==================== Authorization Tests ==================== + +class TestAuthorization: + """Tests für die Autorisierung""" + + def test_documents_endpoint_without_auth_returns_401(self): + """Test: Dokument-Endpoint ohne Auth gibt 401 zurück""" + response = client.get("/api/v1/documents") + assert response.status_code == 401 + + def test_documents_endpoint_with_invalid_token_returns_401(self): + """Test: Ungültiges Token-Format gibt 401 zurück""" + response = client.get( + "/api/v1/documents", + headers={"Authorization": "InvalidFormat"} + ) + assert response.status_code == 401 + + def test_documents_endpoint_with_valid_token_format(self, valid_auth_header): + """Test: Gültiges Token-Format wird akzeptiert""" + with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + mock_pin_ls.return_value = [] + + response = client.get( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 200 + + +# ==================== Document Storage Tests ==================== + +class TestDocumentStorage: + """Tests für das Speichern von Dokumenten""" + + def test_store_document_success(self, valid_auth_header, mock_ipfs_response): + """Test: Dokument erfolgreich speichern""" + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + test_content = b"Test document content" + + response = client.post( + "/api/v1/documents", + headers=valid_auth_header, + files={"file": ("test.txt", test_content, "text/plain")}, + data={ + "document_type": "legal_document", + "document_id": "doc-123", + "version": "1.0", + "language": "de" + } + ) + + assert response.status_code == 200 + data = response.json() + assert "cid" in data + assert data["cid"] == "QmTest1234567890abcdef" + assert "metadata" in data + assert "gateway_url" in data + + def test_store_document_calculates_checksum(self, valid_auth_header, mock_ipfs_response): + """Test: Checksum wird korrekt berechnet""" + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + test_content = b"Test content for checksum" + expected_checksum = hashlib.sha256(test_content).hexdigest() + + response = client.post( + "/api/v1/documents", + headers=valid_auth_header, + files={"file": ("test.txt", test_content, "text/plain")} + ) + + assert response.status_code == 200 + data = response.json() + assert data["metadata"]["checksum"] == expected_checksum + + def test_store_document_without_file_returns_422(self, valid_auth_header): + """Test: Fehlende Datei gibt 422 zurück""" + response = client.post( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 422 + + +# ==================== Document Retrieval Tests ==================== + +class TestDocumentRetrieval: + """Tests für das Abrufen von Dokumenten""" + + def test_get_document_success(self, valid_auth_header): + """Test: Dokument erfolgreich abrufen""" + test_content = b"Original content" + package = { + "metadata": { + "document_type": "legal_document", + "checksum": hashlib.sha256(test_content).hexdigest() + }, + "content_base64": test_content.hex(), + "filename": "test.txt" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get( + "/api/v1/documents/QmTestCid123", + headers=valid_auth_header + ) + + assert response.status_code == 200 + assert response.content == test_content + + def test_get_document_not_found(self, valid_auth_header): + """Test: Nicht existierendes Dokument gibt 404 zurück""" + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + from fastapi import HTTPException + mock_cat.side_effect = HTTPException(status_code=404, detail="Not found") + + response = client.get( + "/api/v1/documents/QmNonExistent", + headers=valid_auth_header + ) + + assert response.status_code == 404 + + def test_get_document_metadata_success(self, valid_auth_header): + """Test: Dokument-Metadaten abrufen""" + test_content = b"Content" + package = { + "metadata": { + "document_type": "legal_document", + "document_id": "doc-123", + "version": "1.0" + }, + "content_base64": test_content.hex(), + "filename": "test.txt" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get( + "/api/v1/documents/QmTestCid123/metadata", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["cid"] == "QmTestCid123" + assert data["metadata"]["document_type"] == "legal_document" + + +# ==================== Document List Tests ==================== + +class TestDocumentList: + """Tests für das Auflisten von Dokumenten""" + + def test_list_documents_empty(self, valid_auth_header): + """Test: Leere Dokumentenliste""" + with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + mock_pin_ls.return_value = [] + + response = client.get( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["documents"] == [] + assert data["total"] == 0 + + def test_list_documents_with_items(self, valid_auth_header): + """Test: Dokumentenliste mit Einträgen""" + package = { + "metadata": {"document_type": "legal_document"}, + "content_base64": "68656c6c6f", + "filename": "test.txt" + } + + with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + mock_pin_ls.return_value = ["QmCid1", "QmCid2"] + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + + +# ==================== Document Deletion Tests ==================== + +class TestDocumentDeletion: + """Tests für das Löschen von Dokumenten""" + + def test_unpin_document_success(self, valid_auth_header): + """Test: Dokument erfolgreich unpinnen""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock(status_code=200) + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.delete( + "/api/v1/documents/QmTestCid123", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "unpinned" + assert data["cid"] == "QmTestCid123" + + def test_unpin_document_not_found(self, valid_auth_header): + """Test: Nicht existierendes Dokument unpinnen""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock(status_code=404) + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.delete( + "/api/v1/documents/QmNonExistent", + headers=valid_auth_header + ) + + assert response.status_code == 404 + + +# ==================== Legal Document Archive Tests ==================== + +class TestLegalDocumentArchive: + """Tests für die Legal Document Archivierung""" + + def test_archive_legal_document_success(self, valid_auth_header, mock_ipfs_response): + """Test: Legal Document erfolgreich archivieren""" + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + response = client.post( + "/api/v1/legal-documents/archive", + headers=valid_auth_header, + params={ + "document_id": "privacy-policy", + "version": "2.0", + "content": "

Datenschutzerklärung

", + "language": "de" + } + ) + + assert response.status_code == 200 + data = response.json() + assert "cid" in data + assert data["document_id"] == "privacy-policy" + assert data["version"] == "2.0" + assert "checksum" in data + assert "archived_at" in data + + def test_archive_legal_document_calculates_checksum(self, valid_auth_header, mock_ipfs_response): + """Test: Checksum für HTML-Inhalt korrekt berechnet""" + content = "

Test Content

" + expected_checksum = hashlib.sha256(content.encode('utf-8')).hexdigest() + + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + response = client.post( + "/api/v1/legal-documents/archive", + headers=valid_auth_header, + params={ + "document_id": "terms", + "version": "1.0", + "content": content + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["checksum"] == expected_checksum + + +# ==================== Document Verification Tests ==================== + +class TestDocumentVerification: + """Tests für die Dokumenten-Verifizierung""" + + def test_verify_document_integrity_valid(self): + """Test: Dokument mit gültiger Integrität""" + content = "Test content" + checksum = hashlib.sha256(content.encode('utf-8')).hexdigest() + + package = { + "metadata": { + "document_type": "legal_document", + "checksum": checksum + }, + "content": content + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get("/api/v1/verify/QmTestCid123") + + assert response.status_code == 200 + data = response.json() + assert data["exists"] is True + assert data["integrity_valid"] is True + assert data["stored_checksum"] == checksum + assert data["calculated_checksum"] == checksum + + def test_verify_document_integrity_invalid(self): + """Test: Dokument mit ungültiger Integrität (manipuliert)""" + package = { + "metadata": { + "document_type": "legal_document", + "checksum": "fake_checksum_12345" + }, + "content": "Actual content" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get("/api/v1/verify/QmTestCid123") + + assert response.status_code == 200 + data = response.json() + assert data["exists"] is True + assert data["integrity_valid"] is False + + def test_verify_document_not_found(self): + """Test: Nicht existierendes Dokument verifizieren""" + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.side_effect = Exception("Not found") + + response = client.get("/api/v1/verify/QmNonExistent") + + assert response.status_code == 200 + data = response.json() + assert data["exists"] is False + assert "error" in data + + def test_verify_document_public_access(self): + """Test: Verifizierung ist öffentlich zugänglich (keine Auth)""" + package = { + "metadata": {"checksum": "abc"}, + "content": "test" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + # Kein Authorization Header! + response = client.get("/api/v1/verify/QmTestCid123") + + assert response.status_code == 200 + + +# ==================== Node Info Tests ==================== + +class TestNodeInfo: + """Tests für Node-Informationen""" + + def test_get_node_info_success(self): + """Test: Node-Informationen abrufen""" + id_response = { + "ID": "QmNodeId12345", + "ProtocolVersion": "ipfs/0.1.0", + "AgentVersion": "kubo/0.24.0", + "Addresses": ["/ip4/127.0.0.1/tcp/4001"] + } + stat_response = { + "RepoSize": 1048576, + "StorageMax": 10737418240, + "NumObjects": 42 + } + + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + + async def mock_post(url, **kwargs): + mock_resp = MagicMock() + if "id" in url: + mock_resp.status_code = 200 + mock_resp.json.return_value = id_response + elif "stat" in url: + mock_resp.status_code = 200 + mock_resp.json.return_value = stat_response + return mock_resp + + mock_instance.post = mock_post + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.get("/api/v1/node/info") + + assert response.status_code == 200 + data = response.json() + assert data["node_id"] == "QmNodeId12345" + assert data["num_objects"] == 42 + + def test_get_node_info_public_access(self): + """Test: Node-Info ist öffentlich zugänglich""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock( + status_code=200, + json=lambda: {} + ) + mock_client.return_value.__aenter__.return_value = mock_instance + + # Kein Authorization Header! + response = client.get("/api/v1/node/info") + + assert response.status_code == 200 + + +# ==================== Model Tests ==================== + +class TestModels: + """Tests für Pydantic Models""" + + def test_document_metadata_defaults(self): + """Test: DocumentMetadata Default-Werte""" + metadata = DocumentMetadata(document_type="test") + + assert metadata.document_type == "test" + assert metadata.document_id is None + assert metadata.version is None + assert metadata.language == "de" + assert metadata.encrypted is False + + def test_document_metadata_all_fields(self): + """Test: DocumentMetadata mit allen Feldern""" + metadata = DocumentMetadata( + document_type="legal_document", + document_id="doc-123", + version="1.0", + language="en", + created_at="2024-01-01T00:00:00", + checksum="abc123", + encrypted=True + ) + + assert metadata.document_type == "legal_document" + assert metadata.document_id == "doc-123" + assert metadata.version == "1.0" + assert metadata.language == "en" + assert metadata.encrypted is True + + def test_stored_document_model(self, sample_document_metadata): + """Test: StoredDocument Model""" + stored = StoredDocument( + cid="QmTest123", + size=1024, + metadata=sample_document_metadata, + gateway_url="http://localhost:8080/ipfs/QmTest123", + timestamp="2024-01-01T00:00:00" + ) + + assert stored.cid == "QmTest123" + assert stored.size == 1024 + assert stored.metadata.document_type == "legal_document" + + def test_document_list_model(self): + """Test: DocumentList Model""" + doc_list = DocumentList( + documents=[{"cid": "Qm1"}, {"cid": "Qm2"}], + total=2 + ) + + assert doc_list.total == 2 + assert len(doc_list.documents) == 2 + + +# ==================== Integration Tests ==================== + +class TestIntegration: + """Integration Tests (erfordern laufenden IPFS Node)""" + + @pytest.mark.skip(reason="Erfordert laufenden IPFS Node") + def test_full_document_lifecycle(self, valid_auth_header): + """Integration Test: Vollständiger Dokument-Lebenszyklus""" + # 1. Dokument speichern + response = client.post( + "/api/v1/documents", + headers=valid_auth_header, + files={"file": ("test.txt", b"Test content", "text/plain")} + ) + assert response.status_code == 200 + cid = response.json()["cid"] + + # 2. Dokument abrufen + response = client.get( + f"/api/v1/documents/{cid}", + headers=valid_auth_header + ) + assert response.status_code == 200 + + # 3. Verifizieren + response = client.get(f"/api/v1/verify/{cid}") + assert response.status_code == 200 + assert response.json()["integrity_valid"] is True + + # 4. Unpinnen + response = client.delete( + f"/api/v1/documents/{cid}", + headers=valid_auth_header + ) + assert response.status_code == 200 + + +# ==================== Run Tests ==================== + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/dsms-node/Dockerfile b/dsms-node/Dockerfile new file mode 100644 index 0000000..310076d --- /dev/null +++ b/dsms-node/Dockerfile @@ -0,0 +1,32 @@ +# DSMS Node - Dezentrales Daten Speicher System +# Basiert auf IPFS für BreakPilot PWA + +FROM ipfs/kubo:v0.24.0 + +LABEL maintainer="BreakPilot " +LABEL description="DSMS Node for BreakPilot - Decentralized Storage System" + +# Environment variables +ENV IPFS_PATH=/data/ipfs +ENV IPFS_PROFILE=server + +# Expose ports +# 4001 - Swarm (P2P) +# 5001 - API +# 8080 - Gateway +EXPOSE 4001 +EXPOSE 5001 +EXPOSE 8080 + +# Copy initialization script with correct permissions for ipfs user +USER root +COPY init-dsms.sh /container-init.d/001-init-dsms.sh +RUN chmod 755 /container-init.d/001-init-dsms.sh && chown 1000:users /container-init.d/001-init-dsms.sh +USER ipfs + +# Health check - use ipfs id which works for standalone node +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD ipfs id > /dev/null 2>&1 || exit 1 + +# Default command +CMD ["daemon", "--migrate=true", "--enable-gc"] diff --git a/dsms-node/init-dsms.sh b/dsms-node/init-dsms.sh new file mode 100644 index 0000000..5f85875 --- /dev/null +++ b/dsms-node/init-dsms.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# DSMS Node Initialization Script +# Creates a private IPFS network for BreakPilot + +set -e + +echo "=== DSMS Node Initialization ===" + +# Generate swarm key for private network if not exists +if [ ! -f "$IPFS_PATH/swarm.key" ]; then + echo "Generating private network swarm key..." + + # Use predefined swarm key for BreakPilot private network + # In production, this should be securely generated and shared between nodes + cat > "$IPFS_PATH/swarm.key" << 'EOF' +/key/swarm/psk/1.0.0/ +/base16/ +b3c7e8f4a9d2e1c5f8b7a6d4c3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4 +EOF + + echo "Swarm key created for private network" +fi + +# Configure IPFS for private network +echo "Configuring IPFS for DSMS private network..." + +# Remove default bootstrap nodes (we want a private network) +ipfs bootstrap rm --all 2>/dev/null || true + +# Configure API to listen on all interfaces (for Docker) +ipfs config Addresses.API /ip4/0.0.0.0/tcp/5001 + +# Configure Gateway +ipfs config Addresses.Gateway /ip4/0.0.0.0/tcp/8080 + +# Enable CORS for BreakPilot +ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["http://localhost:8000", "http://backend:8000", "*"]' +ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["GET", "POST", "PUT", "DELETE"]' +ipfs config --json API.HTTPHeaders.Access-Control-Allow-Headers '["Authorization", "Content-Type", "X-Requested-With"]' + +# Configure for server profile (less aggressive DHT) +ipfs config Routing.Type dht +ipfs config --json Swarm.ConnMgr.LowWater 50 +ipfs config --json Swarm.ConnMgr.HighWater 200 +ipfs config --json Swarm.ConnMgr.GracePeriod '"60s"' + +# Enable garbage collection +ipfs config --json Datastore.GCPeriod '"1h"' +ipfs config --json Datastore.StorageMax '"10GB"' + +# Configure for BreakPilot metadata tagging +ipfs config --json Experimental.FilestoreEnabled true + +echo "=== DSMS Node Configuration Complete ===" +echo "Private Network Key: $(cat $IPFS_PATH/swarm.key | tail -1 | head -c 16)..." +echo "API: http://0.0.0.0:5001" +echo "Gateway: http://0.0.0.0:8080"