A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
66 KiB
66 KiB
Mail-RBAC Developer Specification
Version: 1.0.0 Datum: 2026-01-10 Status: Entwicklungsspezifikation Autor: BreakPilot Development Team
1. Executive Summary
Dieses Dokument spezifiziert die technische Implementierung eines DSGVO-konformen Mail-Systems mit rollenbasierter Zugriffskontrolle (RBAC) und Mitarbeiter-Anonymisierungsfunktion.
1.1 Projektziele
| Ziel | Beschreibung | Priorität |
|---|---|---|
| Rollenbasierte E-Mail | Funktionale Mailboxen statt personengebundener Adressen | P0 |
| DSGVO-Anonymisierung | Vollständige Anonymisierung bei Mitarbeiter-Ausscheiden | P0 |
| Audit-Trail | Lückenlose Nachverfolgbarkeit aller E-Mail-Aktionen | P0 |
| Kalender-Integration | CalDAV mit Jitsi-Meeting-Links | P1 |
| Groupware-UI | Webmail-Interface für Mitarbeiter | P1 |
1.2 Nicht-Ziele (Out of Scope)
- Exchange/Outlook-Protokoll-Kompatibilität
- Mobile Push-Notifications (Phase 2)
- Externe E-Mail-Domain-Routing (Phase 2)
2. DSGVO-Konformität
2.1 Rechtliche Grundlagen
| Artikel | Anforderung | Umsetzung |
|---|---|---|
| Art. 5 DSGVO | Datenminimierung | Nur notwendige Metadaten speichern |
| Art. 17 DSGVO | Recht auf Löschung | Anonymisierungs-Workflow |
| Art. 20 DSGVO | Datenportabilität | MBOX/EML Export |
| Art. 30 DSGVO | Verarbeitungsverzeichnis | Automatische Protokollierung |
| Art. 32 DSGVO | Sicherheit | Verschlüsselung at-rest & in-transit |
2.2 Datenschutz-Prinzipien
┌─────────────────────────────────────────────────────────────┐
│ DATENSCHUTZ-BY-DESIGN │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. TRENNUNG: Person ≠ Rolle ≠ Mailbox │
│ ├── Person: Max Mustermann (anonymisierbar) │
│ ├── Rolle: klassenlehrer (persistent) │
│ └── Mailbox: klassenlehrer.5a@... (rollengebunden) │
│ │
│ 2. MINIMIERUNG: Nur speichern was nötig │
│ ├── E-Mail-Inhalte: Verschlüsselt │
│ ├── Metadaten: Nur Subject-Hash, keine Namen │
│ └── Audit: Rollenbasiert, nicht personenbasiert │
│ │
│ 3. LÖSCHBARKEIT: Jederzeit anonymisierbar │
│ ├── Personendaten: Pseudonymisierung │
│ ├── E-Mail-Archive: Header-Anonymisierung │
│ └── Audit-Trail: Bleibt für Nachvollziehbarkeit │
│ │
└─────────────────────────────────────────────────────────────┘
2.3 Aufbewahrungsfristen
| Datentyp | Frist | Rechtsgrundlage |
|---|---|---|
| E-Mail-Inhalte | 10 Jahre | § 147 AO (Geschäftskorrespondenz) |
| Audit-Logs | 10 Jahre | § 257 HGB |
| Personenbezogene Daten | Bis Löschungsantrag | Art. 17 DSGVO |
| Anonymisierte Daten | Unbegrenzt | Keine personenbezogenen Daten |
3. Systemarchitektur
3.1 Komponenten-Übersicht
┌──────────────────────────────────────────────────────────────────────┐
│ BREAKPILOT MAIL-RBAC │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ PRESENTATION LAYER │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Admin UI │ │ Webmail UI │ │ Calendar UI │ │ │
│ │ │ (Next.js) │ │ (SOGo) │ │ (SOGo) │ │ │
│ │ │ :3000 │ │ :20000 │ │ :20000 │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │
│ └─────────┼─────────────────┼─────────────────┼──────────────┘ │
│ │ │ │ │
│ ┌─────────┴─────────────────┴─────────────────┴──────────────┐ │
│ │ APPLICATION LAYER │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ RBAC-MAIL-BRIDGE (Python) │ │ │
│ │ │ Port: 8087 │ │ │
│ │ ├─────────────────────────────────────────────────────┤ │ │
│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │
│ │ │ │ Mailbox │ │ Anonymizer │ │ Audit │ │ │ │
│ │ │ │ Manager │ │ Service │ │ Logger │ │ │ │
│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │
│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │
│ │ │ │ RBAC │ │ Calendar │ │ Jitsi │ │ │ │
│ │ │ │ Sync │ │ Bridge │ │ Integrator │ │ │ │
│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────────┼────────────────────────────┐ │ │
│ │ │ │ │ │ │
│ │ │ ┌──────────────┐ ┌───┴────────┐ ┌─────────────┐ │ │ │
│ │ │ │ Stalwart │ │ Existing │ │ MinIO │ │ │ │
│ │ │ │ Mail Server │ │ RBAC API │ │ Storage │ │ │ │
│ │ │ │ :25/:143/:993│ │ :8000 │ │ :9000 │ │ │ │
│ │ │ └──────────────┘ └────────────┘ └─────────────┘ │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ DATA LAYER │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ PostgreSQL │ │ Stalwart │ │ MinIO │ │ │
│ │ │ (RBAC Data) │ │ (Mail Data) │ │ (Attachments)│ │ │
│ │ │ :5432 │ │ Internal │ │ :9000 │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
3.2 Datenfluss
┌─────────────────────────────────────────────────────────────────┐
│ E-MAIL SENDEN (OUTBOUND) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Lehrer klickt "Senden" in Webmail │
│ │ │
│ ▼ │
│ 2. SOGo → Stalwart SMTP (Port 25) │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ From: klassenlehrer.5a@schule.bp.app │ │
│ │ │ (Rollenbasiert, nicht personenbasiert)│ │
│ │ └──────────────────────────────────────┘ │
│ ▼ │
│ 3. Stalwart → RBAC-Mail-Bridge (Milter Hook) │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ - Validiere Absender-Berechtigung │ │
│ │ │ - Logge Audit-Event │ │
│ │ │ - Füge X-BP-Role Header hinzu │ │
│ │ └──────────────────────────────────────┘ │
│ ▼ │
│ 4. Stalwart → Internet (MX Lookup) │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ E-MAIL EMPFANGEN (INBOUND) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Internet → Stalwart SMTP (Port 25) │
│ │ │
│ ▼ │
│ 2. Stalwart → RBAC-Mail-Bridge (Milter Hook) │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ - Identifiziere Ziel-Mailbox │ │
│ │ │ - Lookup aktuelle Rollenzuweisung │ │
│ │ │ - Logge Audit-Event │ │
│ │ └──────────────────────────────────────┘ │
│ ▼ │
│ 3. Stalwart → Mailbox Store (IMAP) │
│ │ │
│ ▼ │
│ 4. SOGo zeigt E-Mail an (für zugewiesene Person) │
│ │
└─────────────────────────────────────────────────────────────────┘
4. Datenmodell
4.1 Entity-Relationship-Diagramm
┌─────────────────────────────────────────────────────────────────────┐
│ MAIL-RBAC DATENMODELL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────┐ ┌───────────────────┐ │
│ │ users │ │ functional_ │ │
│ │ (bestehend) │ │ mailboxes │ │
│ ├───────────────┤ ├───────────────────┤ │
│ │ id (PK) │ │ id (PK) │ │
│ │ email │ │ role_key (FK) │──────┐ │
│ │ name │ │ email_address │ │ │
│ │ is_active │ │ display_name │ │ │
│ │ anonymized_at │ │ tenant_id (FK) │ │ │
│ └───────┬───────┘ │ is_active │ │ │
│ │ └─────────┬─────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────┘ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────────┐ ┌───────────────────┐ │
│ │ mailbox_ │ │ roles │ │
│ │ assignments │ │ (bestehend) │ │
│ ├───────────────────┤ ├───────────────────┤ │
│ │ id (PK) │ │ role_key (PK) │ │
│ │ mailbox_id (FK) │───────────────│ display_name │ │
│ │ user_id (FK) │ │ category │ │
│ │ valid_from │ └───────────────────┘ │
│ │ valid_to │ │
│ │ assigned_by (FK) │ │
│ │ revoked_at │ │
│ └─────────┬─────────┘ │
│ │ │
│ │ ┌────────────────────────────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ │ │
│ ┌───────────────────┐ ┌───────────────────┐ │ │
│ │ email_audit_ │ │ anonymization_ │ │ │
│ │ trail │ │ log │ │ │
│ ├───────────────────┤ ├───────────────────┤ │ │
│ │ id (PK) │ │ id (PK) │ │ │
│ │ mailbox_id (FK) │ │ entity_type │ │ │
│ │ direction │ │ entity_id │ │ │
│ │ subject_hash │ │ anonymization_type│ │ │
│ │ timestamp │ │ fields_affected │ │ │
│ │ external_domain │ │ reason │ │ │
│ │ role_key │ │ performed_by (FK) │◄────────┘ │
│ └───────────────────┘ │ performed_at │ │
│ │ legal_basis │ │
│ └───────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
4.2 SQL Schema
-- ============================================================
-- MAIL-RBAC SCHEMA
-- Version: 1.0.0
-- ============================================================
-- Erweiterung der bestehenden users-Tabelle
ALTER TABLE users ADD COLUMN IF NOT EXISTS
anonymized_at TIMESTAMP;
ALTER TABLE users ADD COLUMN IF NOT EXISTS
anonymization_token VARCHAR(64);
-- Funktionale Mailboxen (rollengebunden)
CREATE TABLE functional_mailboxes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Rollen-Verknüpfung
role_key VARCHAR(100) NOT NULL,
email_address VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(255) NOT NULL,
-- Tenant/Schule
tenant_id UUID NOT NULL REFERENCES tenants(id),
resource_type VARCHAR(50) DEFAULT 'class',
resource_id VARCHAR(100),
-- Stalwart Mailbox ID (nach Erstellung)
stalwart_mailbox_id VARCHAR(255),
-- Status
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
created_by UUID REFERENCES users(id),
-- Indizes
CONSTRAINT fk_tenant FOREIGN KEY (tenant_id)
REFERENCES tenants(id) ON DELETE CASCADE
);
CREATE INDEX idx_fm_role ON functional_mailboxes(role_key);
CREATE INDEX idx_fm_tenant ON functional_mailboxes(tenant_id);
CREATE INDEX idx_fm_email ON functional_mailboxes(email_address);
-- Mailbox-Zuweisungen (Person ↔ Funktionale Mailbox)
CREATE TABLE mailbox_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Verknüpfungen
mailbox_id UUID NOT NULL REFERENCES functional_mailboxes(id),
user_id UUID NOT NULL REFERENCES users(id),
-- Gültigkeitszeitraum
valid_from TIMESTAMP DEFAULT NOW(),
valid_to TIMESTAMP,
-- Audit
assigned_by UUID REFERENCES users(id),
assigned_at TIMESTAMP DEFAULT NOW(),
revoked_by UUID REFERENCES users(id),
revoked_at TIMESTAMP,
revocation_reason VARCHAR(255),
-- Constraints
CONSTRAINT unique_active_mailbox_assignment
EXCLUDE USING gist (
mailbox_id WITH =,
tsrange(valid_from, COALESCE(valid_to, 'infinity'::timestamp)) WITH &&
) WHERE (revoked_at IS NULL)
);
CREATE INDEX idx_ma_mailbox ON mailbox_assignments(mailbox_id);
CREATE INDEX idx_ma_user ON mailbox_assignments(user_id);
CREATE INDEX idx_ma_active ON mailbox_assignments(revoked_at) WHERE revoked_at IS NULL;
-- E-Mail Audit Trail (DSGVO-konform, ohne personenbezogene Daten)
CREATE TABLE email_audit_trail (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Mailbox-Referenz (nicht Person!)
mailbox_id UUID REFERENCES functional_mailboxes(id),
-- E-Mail-Metadaten (anonymisiert)
direction VARCHAR(10) NOT NULL CHECK (direction IN ('inbound', 'outbound')),
message_id_hash VARCHAR(64), -- SHA-256 der Message-ID
subject_hash VARCHAR(64), -- SHA-256 des Betreffs
timestamp TIMESTAMP NOT NULL,
-- Externe Partei (nur Domain, nicht volle Adresse)
external_party_domain VARCHAR(255),
-- Rolle zum Zeitpunkt (für Nachvollziehbarkeit)
role_key VARCHAR(100) NOT NULL,
-- Keine personenbezogenen Daten!
-- Die Person ist nur über mailbox_assignments nachvollziehbar
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_eat_mailbox ON email_audit_trail(mailbox_id);
CREATE INDEX idx_eat_timestamp ON email_audit_trail(timestamp);
CREATE INDEX idx_eat_role ON email_audit_trail(role_key);
-- Anonymisierungsprotokoll
CREATE TABLE anonymization_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Was wurde anonymisiert
entity_type VARCHAR(50) NOT NULL CHECK (entity_type IN (
'user', 'email_account', 'email_content', 'calendar_event'
)),
entity_id UUID NOT NULL,
-- Wie
anonymization_type VARCHAR(50) NOT NULL CHECK (anonymization_type IN (
'pseudonymization', 'deletion', 'header_anonymization'
)),
fields_affected JSONB NOT NULL,
-- Warum
reason VARCHAR(100) NOT NULL CHECK (reason IN (
'employee_departure', 'dsgvo_request', 'retention_expiry', 'manual'
)),
-- Audit
performed_by UUID NOT NULL REFERENCES users(id),
performed_at TIMESTAMP DEFAULT NOW(),
-- Rechtliche Dokumentation
legal_basis VARCHAR(255),
retention_period_days INTEGER,
confirmation_token VARCHAR(64)
);
CREATE INDEX idx_al_entity ON anonymization_log(entity_type, entity_id);
CREATE INDEX idx_al_performed ON anonymization_log(performed_at);
-- Kalender-Events mit Jitsi-Integration
CREATE TABLE calendar_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Event-Daten
title VARCHAR(255) NOT NULL,
description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
location VARCHAR(255),
-- Jitsi-Integration
jitsi_room_id VARCHAR(100),
jitsi_url TEXT,
-- Organisator (Mailbox, nicht Person)
organizer_mailbox_id UUID REFERENCES functional_mailboxes(id),
-- CalDAV-Synchronisation
caldav_uid VARCHAR(255) UNIQUE,
caldav_etag VARCHAR(100),
-- Status
is_cancelled BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_ce_organizer ON calendar_events(organizer_mailbox_id);
CREATE INDEX idx_ce_time ON calendar_events(start_time, end_time);
-- Kalender-Teilnehmer
CREATE TABLE calendar_attendees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
mailbox_id UUID REFERENCES functional_mailboxes(id),
external_email VARCHAR(255),
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN (
'pending', 'accepted', 'declined', 'tentative'
)),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_ca_event ON calendar_attendees(event_id);
5. API-Spezifikation
5.1 REST API Endpoints
# ============================================================
# MAIL-RBAC API SPECIFICATION
# OpenAPI 3.0
# ============================================================
openapi: 3.0.3
info:
title: BreakPilot Mail-RBAC API
version: 1.0.0
description: DSGVO-konformes Mail-System mit Rollen-Integration
servers:
- url: http://localhost:8087/api/v1
description: Development
paths:
# ==================== MAILBOXEN ====================
/mailboxes:
get:
summary: Liste aller funktionalen Mailboxen
tags: [Mailboxes]
parameters:
- name: tenant_id
in: query
schema:
type: string
format: uuid
- name: role_key
in: query
schema:
type: string
responses:
200:
description: Mailbox-Liste
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/FunctionalMailbox'
post:
summary: Erstellt eine neue funktionale Mailbox
tags: [Mailboxes]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateMailboxRequest'
responses:
201:
description: Mailbox erstellt
content:
application/json:
schema:
$ref: '#/components/schemas/FunctionalMailbox'
/mailboxes/{mailbox_id}:
get:
summary: Details einer Mailbox
tags: [Mailboxes]
parameters:
- name: mailbox_id
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
description: Mailbox-Details
content:
application/json:
schema:
$ref: '#/components/schemas/FunctionalMailboxDetail'
/mailboxes/{mailbox_id}/assign:
post:
summary: Weist Mailbox einem Benutzer zu
tags: [Mailboxes]
parameters:
- name: mailbox_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AssignMailboxRequest'
responses:
201:
description: Zuweisung erstellt
content:
application/json:
schema:
$ref: '#/components/schemas/MailboxAssignment'
/mailboxes/{mailbox_id}/revoke:
post:
summary: Widerruft Mailbox-Zuweisung
tags: [Mailboxes]
parameters:
- name: mailbox_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
user_id:
type: string
format: uuid
reason:
type: string
responses:
200:
description: Zuweisung widerrufen
# ==================== ANONYMISIERUNG ====================
/users/{user_id}/anonymize:
post:
summary: Anonymisiert einen Benutzer vollständig
tags: [Anonymization]
description: |
Führt folgende Schritte aus:
1. Widerruft alle Mailbox-Zuweisungen
2. Anonymisiert Benutzerdaten
3. Anonymisiert E-Mail-Header
4. Erstellt Audit-Log
parameters:
- name: user_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AnonymizationRequest'
responses:
200:
description: Anonymisierung erfolgreich
content:
application/json:
schema:
$ref: '#/components/schemas/AnonymizationResult'
/users/{user_id}/anonymize/preview:
get:
summary: Vorschau der Anonymisierung
tags: [Anonymization]
description: Zeigt was anonymisiert werden würde
parameters:
- name: user_id
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
description: Anonymisierungs-Vorschau
content:
application/json:
schema:
$ref: '#/components/schemas/AnonymizationPreview'
# ==================== AUDIT ====================
/audit/logs:
get:
summary: Audit-Log abrufen
tags: [Audit]
parameters:
- name: mailbox_id
in: query
schema:
type: string
format: uuid
- name: from
in: query
schema:
type: string
format: date-time
- name: to
in: query
schema:
type: string
format: date-time
responses:
200:
description: Audit-Einträge
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/AuditLogEntry'
/audit/anonymizations:
get:
summary: Anonymisierungsprotokoll
tags: [Audit]
responses:
200:
description: Anonymisierungen
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/AnonymizationLogEntry'
/audit/export:
get:
summary: DSGVO-Export
tags: [Audit]
parameters:
- name: user_id
in: query
required: true
schema:
type: string
format: uuid
responses:
200:
description: DSGVO-Datenexport
content:
application/json:
schema:
$ref: '#/components/schemas/GDPRExport'
# ==================== KALENDER ====================
/calendar/events:
post:
summary: Erstellt einen Kalender-Eintrag mit optionalem Jitsi-Meeting
tags: [Calendar]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateCalendarEventRequest'
responses:
201:
description: Event erstellt
content:
application/json:
schema:
$ref: '#/components/schemas/CalendarEvent'
components:
schemas:
FunctionalMailbox:
type: object
properties:
id:
type: string
format: uuid
role_key:
type: string
example: "klassenlehrer"
email_address:
type: string
format: email
example: "klassenlehrer.5a@schule.breakpilot.app"
display_name:
type: string
example: "Klassenlehrer 5a"
is_active:
type: boolean
current_assignee:
$ref: '#/components/schemas/UserSummary'
CreateMailboxRequest:
type: object
required:
- role_key
- email_address
- display_name
- tenant_id
properties:
role_key:
type: string
email_address:
type: string
format: email
display_name:
type: string
tenant_id:
type: string
format: uuid
resource_type:
type: string
enum: [class, department, function]
resource_id:
type: string
AnonymizationRequest:
type: object
required:
- reason
- confirmed
properties:
reason:
type: string
enum: [employee_departure, dsgvo_request, manual]
confirmed:
type: boolean
description: Muss true sein für Durchführung
preserve_audit_trail:
type: boolean
default: true
delete_email_content:
type: boolean
default: false
AnonymizationResult:
type: object
properties:
success:
type: boolean
anonymization_id:
type: string
format: uuid
affected_mailboxes:
type: integer
affected_emails:
type: integer
audit_log_entry_id:
type: string
format: uuid
5.2 Internal Events (Message Queue)
# Für zukünftige Skalierung: Event-basierte Kommunikation
class MailRBACEvent:
"""Base Event für Mail-RBAC System"""
event_type: str
timestamp: datetime
correlation_id: str
class MailboxAssignedEvent(MailRBACEvent):
"""Wird ausgelöst wenn eine Mailbox zugewiesen wird"""
event_type = "mailbox.assigned"
mailbox_id: str
user_id: str
role_key: str
class MailboxRevokedEvent(MailRBACEvent):
"""Wird ausgelöst wenn eine Mailbox-Zuweisung widerrufen wird"""
event_type = "mailbox.revoked"
mailbox_id: str
user_id: str
reason: str
class UserAnonymizedEvent(MailRBACEvent):
"""Wird ausgelöst wenn ein Benutzer anonymisiert wird"""
event_type = "user.anonymized"
user_id: str
anonymization_id: str
affected_mailboxes: List[str]
class EmailSentEvent(MailRBACEvent):
"""Wird ausgelöst wenn eine E-Mail gesendet wird"""
event_type = "email.sent"
mailbox_id: str
message_id_hash: str
external_domain: str
class CalendarEventCreatedEvent(MailRBACEvent):
"""Wird ausgelöst wenn ein Kalender-Event erstellt wird"""
event_type = "calendar.created"
event_id: str
organizer_mailbox_id: str
has_jitsi: bool
6. Implementierungsdetails
6.1 Anonymisierungs-Service
# rbac_mail_bridge/services/anonymizer.py
from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional
import hashlib
import secrets
@dataclass
class AnonymizationResult:
success: bool
anonymization_id: str
affected_mailboxes: int
affected_emails: int
audit_log_id: str
errors: List[str]
class EmployeeAnonymizer:
"""
DSGVO-konforme Anonymisierung von Mitarbeiterdaten.
Prinzipien:
1. Keine personenbezogenen Daten in Audit-Logs
2. Rollenbasierte Historie bleibt erhalten
3. Verschlüsselung der Original-Daten für Auskunftsrechte
"""
def __init__(
self,
db: AsyncSession,
mail_server: StalwartClient,
encryption_service: EncryptionService
):
self.db = db
self.mail_server = mail_server
self.encryption = encryption_service
async def anonymize(
self,
user_id: str,
reason: str,
performed_by: str,
options: AnonymizationOptions
) -> AnonymizationResult:
"""
Führt vollständige Anonymisierung durch.
Steps:
1. Validation
2. Mailbox-Zuweisungen widerrufen
3. Benutzerdaten pseudonymisieren
4. E-Mail-Header anonymisieren (optional)
5. Audit-Log erstellen
6. Event publizieren
"""
anonymization_id = str(uuid.uuid4())
errors = []
async with self.db.begin():
# 1. Validierung
user = await self._get_user(user_id)
if not user:
raise UserNotFoundError(user_id)
if user.anonymized_at:
raise AlreadyAnonymizedError(user_id)
# 2. Mailbox-Zuweisungen widerrufen
assignments = await self._get_active_assignments(user_id)
for assignment in assignments:
await self._revoke_assignment(
assignment.id,
performed_by,
reason="employee_anonymization"
)
# 3. Benutzerdaten pseudonymisieren
pseudonym = self._generate_pseudonym()
# Original-Daten verschlüsseln (für DSGVO Art. 15 Auskunftsrecht)
encrypted_original = await self.encryption.encrypt({
"name": user.name,
"email": user.email,
"encrypted_at": datetime.utcnow().isoformat()
})
await self.db.execute("""
UPDATE users SET
name = :pseudonym,
email = :anon_email,
anonymized_at = NOW(),
anonymization_token = :token,
original_data_encrypted = :encrypted
WHERE id = :user_id
""", {
"pseudonym": f"Ehemaliger Mitarbeiter ({pseudonym})",
"anon_email": f"anon_{pseudonym}@deleted.local",
"token": secrets.token_hex(32),
"encrypted": encrypted_original,
"user_id": user_id
})
# 4. E-Mail-Header anonymisieren (falls gewünscht)
affected_emails = 0
if options.anonymize_email_headers:
affected_emails = await self._anonymize_email_headers(
user_id, pseudonym
)
# 5. Audit-Log
audit_log_id = await self._create_audit_log(
entity_type="user",
entity_id=user_id,
anonymization_type="pseudonymization",
fields_affected={
"name": True,
"email": True,
"mailbox_assignments": len(assignments),
"email_headers": affected_emails
},
reason=reason,
performed_by=performed_by,
legal_basis="Art. 17 DSGVO"
)
# 6. Event publizieren (für andere Services)
await self._publish_event(UserAnonymizedEvent(
user_id=user_id,
anonymization_id=anonymization_id,
affected_mailboxes=[a.mailbox_id for a in assignments]
))
return AnonymizationResult(
success=True,
anonymization_id=anonymization_id,
affected_mailboxes=len(assignments),
affected_emails=affected_emails,
audit_log_id=audit_log_id,
errors=errors
)
def _generate_pseudonym(self) -> str:
"""Generiert ein eindeutiges Pseudonym."""
return hashlib.sha256(
secrets.token_bytes(32)
).hexdigest()[:12]
async def _anonymize_email_headers(
self,
user_id: str,
pseudonym: str
) -> int:
"""
Anonymisiert E-Mail-Header in Stalwart.
Ersetzt:
- From: Max Mustermann → Ehemaliger Mitarbeiter
- Reply-To: max.mustermann@... → anon_xxx@deleted.local
Behält:
- Funktionale Absender-Adressen (klassenlehrer.5a@...)
"""
# Stalwart API Call für Header-Manipulation
return await self.mail_server.anonymize_headers(
user_id=user_id,
replacement_name=f"Ehemaliger Mitarbeiter ({pseudonym})"
)
6.2 Stalwart Integration
# rbac_mail_bridge/integrations/stalwart.py
class StalwartClient:
"""
Client für Stalwart Mail Server API.
Dokumentation: https://stalw.art/docs/api/
"""
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url
self.api_key = api_key
self.session = httpx.AsyncClient(
base_url=base_url,
headers={"Authorization": f"Bearer {api_key}"}
)
async def create_mailbox(
self,
email: str,
display_name: str,
quota_mb: int = 1024
) -> str:
"""Erstellt eine neue Mailbox in Stalwart."""
response = await self.session.post(
"/api/v1/accounts",
json={
"email": email,
"name": display_name,
"quota": quota_mb * 1024 * 1024,
"type": "individual"
}
)
response.raise_for_status()
return response.json()["id"]
async def update_mailbox_access(
self,
mailbox_id: str,
user_email: str,
access_type: str # "full", "send_as", "read_only"
):
"""
Aktualisiert Zugriffsrechte auf eine Mailbox.
Wird verwendet wenn:
- Neue Zuweisung erstellt wird
- Zuweisung widerrufen wird
"""
response = await self.session.put(
f"/api/v1/accounts/{mailbox_id}/access",
json={
"user": user_email,
"access": access_type
}
)
response.raise_for_status()
async def anonymize_headers(
self,
user_id: str,
replacement_name: str
) -> int:
"""
Anonymisiert E-Mail-Header für einen Benutzer.
Returns: Anzahl der betroffenen E-Mails
"""
# Stalwart unterstützt dies möglicherweise nicht nativ
# Alternative: Sieve-Filter oder Post-Processing
pass
6.3 SOGo Integration
# rbac_mail_bridge/integrations/sogo.py
class SOGoClient:
"""
Client für SOGo Groupware API.
SOGo nutzt CalDAV/CardDAV und hat eine eigene REST API.
"""
def __init__(self, base_url: str, admin_user: str, admin_pass: str):
self.base_url = base_url
self.auth = (admin_user, admin_pass)
async def create_calendar_event(
self,
calendar_id: str,
event: CalendarEvent
) -> str:
"""Erstellt einen Kalender-Eintrag."""
ical_data = self._to_ical(event)
response = await httpx.put(
f"{self.base_url}/SOGo/dav/{calendar_id}/Calendar/{event.uid}.ics",
content=ical_data,
headers={"Content-Type": "text/calendar"},
auth=self.auth
)
response.raise_for_status()
return event.uid
def _to_ical(self, event: CalendarEvent) -> str:
"""Konvertiert Event zu iCalendar Format."""
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BreakPilot//Mail-RBAC//DE",
"BEGIN:VEVENT",
f"UID:{event.uid}",
f"DTSTART:{event.start_time.strftime('%Y%m%dT%H%M%SZ')}",
f"DTEND:{event.end_time.strftime('%Y%m%dT%H%M%SZ')}",
f"SUMMARY:{event.title}",
]
if event.description:
lines.append(f"DESCRIPTION:{event.description}")
if event.jitsi_url:
lines.append(f"LOCATION:{event.jitsi_url}")
lines.append(f"X-JITSI-URL:{event.jitsi_url}")
lines.extend([
"END:VEVENT",
"END:VCALENDAR"
])
return "\r\n".join(lines)
7. Frontend-Integration
7.1 Admin-Seite: Mail-Management
// website/app/admin/mail-management/page.tsx
'use client'
import { useState, useEffect } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
type TabType = 'mailboxes' | 'assignments' | 'anonymization' | 'audit'
interface FunctionalMailbox {
id: string
role_key: string
email_address: string
display_name: string
is_active: boolean
current_assignee?: {
id: string
name: string
email: string
}
}
export default function MailManagementPage() {
const [activeTab, setActiveTab] = useState<TabType>('mailboxes')
const [mailboxes, setMailboxes] = useState<FunctionalMailbox[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchMailboxes()
}, [])
const fetchMailboxes = async () => {
try {
const res = await fetch('/api/admin/mail-rbac/mailboxes')
const data = await res.json()
setMailboxes(data)
} finally {
setLoading(false)
}
}
return (
<AdminLayout
title="Mail-Management"
description="Rollenbasierte E-Mail-Verwaltung mit DSGVO-Anonymisierung"
>
{/* Tabs */}
<div className="border-b border-slate-200 mb-6">
<nav className="flex gap-6">
{[
{ id: 'mailboxes', label: 'Funktionale Mailboxen', icon: '📧' },
{ id: 'assignments', label: 'Zuweisungen', icon: '👤' },
{ id: 'anonymization', label: 'Anonymisierung', icon: '🔒' },
{ id: 'audit', label: 'Audit-Log', icon: '📋' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.icon} {tab.label}
</button>
))}
</nav>
</div>
{/* Mailboxes Tab */}
{activeTab === 'mailboxes' && (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-primary-600">
{mailboxes.length}
</div>
<div className="text-sm text-slate-500">Funktionale Mailboxen</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-green-600">
{mailboxes.filter(m => m.current_assignee).length}
</div>
<div className="text-sm text-slate-500">Zugewiesen</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-yellow-600">
{mailboxes.filter(m => !m.current_assignee).length}
</div>
<div className="text-sm text-slate-500">Nicht zugewiesen</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-3xl font-bold text-slate-600">
{new Set(mailboxes.map(m => m.role_key)).size}
</div>
<div className="text-sm text-slate-500">Verschiedene Rollen</div>
</div>
</div>
{/* Mailbox Liste */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b border-slate-200 flex justify-between items-center">
<h2 className="text-lg font-semibold">Funktionale Mailboxen</h2>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700">
+ Neue Mailbox
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-600">
E-Mail-Adresse
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-600">
Rolle
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-600">
Aktuell zugewiesen
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-600">
Status
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-slate-600">
Aktionen
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{mailboxes.map((mailbox) => (
<tr key={mailbox.id} className="hover:bg-slate-50">
<td className="py-3 px-4">
<code className="text-sm bg-slate-100 px-2 py-1 rounded">
{mailbox.email_address}
</code>
</td>
<td className="py-3 px-4">
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
{mailbox.role_key}
</span>
</td>
<td className="py-3 px-4">
{mailbox.current_assignee ? (
<div>
<div className="font-medium">
{mailbox.current_assignee.name}
</div>
<div className="text-xs text-slate-500">
{mailbox.current_assignee.email}
</div>
</div>
) : (
<span className="text-slate-400 italic">
Nicht zugewiesen
</span>
)}
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 text-xs rounded-full ${
mailbox.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{mailbox.is_active ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td className="py-3 px-4">
<div className="flex gap-2">
<button className="text-sm text-blue-600 hover:underline">
Bearbeiten
</button>
<button className="text-sm text-green-600 hover:underline">
Zuweisen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Anonymization Tab */}
{activeTab === 'anonymization' && (
<div className="space-y-6">
{/* Warning Banner */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex gap-3">
<span className="text-2xl">⚠️</span>
<div>
<h3 className="font-semibold text-amber-800">
Anonymisierung ist irreversibel
</h3>
<p className="text-sm text-amber-700">
Die Anonymisierung kann nicht rückgängig gemacht werden.
Stellen Sie sicher, dass alle Aufbewahrungsfristen eingehalten werden.
</p>
</div>
</div>
</div>
{/* Anonymization Form */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">
Mitarbeiter anonymisieren
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Mitarbeiter auswählen
</label>
<select className="w-full border border-slate-300 rounded-lg px-3 py-2">
<option>-- Bitte wählen --</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Grund der Anonymisierung
</label>
<select className="w-full border border-slate-300 rounded-lg px-3 py-2">
<option value="employee_departure">Mitarbeiter-Ausscheiden</option>
<option value="dsgvo_request">DSGVO-Löschungsantrag</option>
<option value="manual">Manuell (mit Begründung)</option>
</select>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2">
<input type="checkbox" className="rounded" defaultChecked />
<span className="text-sm">Audit-Trail beibehalten</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" className="rounded" />
<span className="text-sm">E-Mail-Inhalte löschen</span>
</label>
</div>
<div className="pt-4 border-t border-slate-200">
<button className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
Anonymisierung durchführen
</button>
</div>
</div>
</div>
</div>
)}
</AdminLayout>
)
}
8. Docker-Konfiguration
8.1 Docker Compose Erweiterung
# docker-compose.mail.yml
# Verwendet mit: docker compose -f docker-compose.yml -f docker-compose.mail.yml up
version: '3.8'
services:
# Stalwart Mail Server
stalwart:
image: stalwartlabs/mail-server:v0.9
container_name: breakpilot-mail-stalwart
hostname: mail.breakpilot.local
ports:
- "25:25" # SMTP
- "143:143" # IMAP
- "465:465" # SMTPS
- "993:993" # IMAPS
- "4190:4190" # ManageSieve
- "8787:8080" # Admin API
volumes:
- stalwart-data:/opt/stalwart-mail/data
- ./config/stalwart/config.toml:/opt/stalwart-mail/etc/config.toml:ro
environment:
- STALWART_HOSTNAME=mail.breakpilot.local
networks:
- breakpilot-pwa-network
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
# SOGo Groupware
sogo:
image: sogo/sogo:5.10
container_name: breakpilot-mail-sogo
ports:
- "20000:20000"
volumes:
- ./config/sogo/sogo.conf:/etc/sogo/sogo.conf:ro
environment:
- MYSQL_HOST=postgres # SOGo unterstützt auch PostgreSQL
- SOGO_HOSTNAME=groupware.breakpilot.local
depends_on:
- stalwart
- postgres
networks:
- breakpilot-pwa-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:20000/SOGo/"]
interval: 30s
timeout: 10s
retries: 3
# RBAC-Mail-Bridge
rbac-mail-bridge:
build:
context: ./rbac-mail-bridge
dockerfile: Dockerfile
container_name: breakpilot-mail-rbac
ports:
- "8087:8087"
environment:
- DATABASE_URL=${DATABASE_URL}
- STALWART_API_URL=http://stalwart:8080
- STALWART_API_KEY=${STALWART_API_KEY}
- SOGO_URL=http://sogo:20000
- JITSI_URL=${JITSI_URL:-http://localhost:8443}
- ENCRYPTION_KEY=${MAIL_ENCRYPTION_KEY}
depends_on:
- postgres
- stalwart
- sogo
networks:
- breakpilot-pwa-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8087/health"]
interval: 30s
timeout: 10s
retries: 3
volumes:
stalwart-data:
driver: local
8.2 Stalwart Konfiguration
# config/stalwart/config.toml
[server]
hostname = "mail.breakpilot.local"
[store]
db.type = "postgresql"
db.url = "postgresql://breakpilot:breakpilot123@postgres:5432/breakpilot_mail"
[authentication]
mechanisms = ["PLAIN", "LOGIN"]
directory.type = "internal"
[imap]
bind = ["0.0.0.0:143"]
[smtp]
bind = ["0.0.0.0:25"]
[api]
bind = ["0.0.0.0:8080"]
key = "${STALWART_API_KEY}"
9. Sicherheit
9.1 Verschlüsselung
# rbac_mail_bridge/services/encryption.py
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
import os
class EncryptionService:
"""
AES-256 Verschlüsselung für Original-Daten.
Verwendet für:
- Verschlüsselung der Original-Benutzerdaten bei Anonymisierung
- DSGVO Art. 15 Auskunftsrecht (Entschlüsselung nur bei Berechtigung)
"""
def __init__(self, master_key: str):
self.master_key = master_key.encode()
def _derive_key(self, salt: bytes) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
return base64.urlsafe_b64encode(kdf.derive(self.master_key))
async def encrypt(self, data: dict) -> bytes:
"""Verschlüsselt Daten mit AES-256."""
salt = os.urandom(16)
key = self._derive_key(salt)
f = Fernet(key)
plaintext = json.dumps(data).encode()
ciphertext = f.encrypt(plaintext)
# Salt + Ciphertext kombinieren
return salt + ciphertext
async def decrypt(self, encrypted: bytes, audit_reason: str) -> dict:
"""
Entschlüsselt Daten.
WICHTIG: Jede Entschlüsselung wird geloggt!
"""
salt = encrypted[:16]
ciphertext = encrypted[16:]
key = self._derive_key(salt)
f = Fernet(key)
plaintext = f.decrypt(ciphertext)
# Audit-Log
await self._log_decryption(audit_reason)
return json.loads(plaintext.decode())
9.2 Zugriffskontrolle
# Berechtigungen für Mail-RBAC
MAIL_RBAC_PERMISSIONS = {
"mail:mailbox:create": "Kann funktionale Mailboxen erstellen",
"mail:mailbox:assign": "Kann Mailboxen Benutzern zuweisen",
"mail:mailbox:revoke": "Kann Mailbox-Zuweisungen widerrufen",
"mail:user:anonymize": "Kann Benutzer anonymisieren",
"mail:audit:view": "Kann Audit-Logs einsehen",
"mail:audit:export": "Kann DSGVO-Export durchführen",
}
# Rollen-Mapping
ROLE_PERMISSIONS = {
"schul_admin": [
"mail:mailbox:create",
"mail:mailbox:assign",
"mail:mailbox:revoke",
],
"data_protection_officer": [
"mail:user:anonymize",
"mail:audit:view",
"mail:audit:export",
],
"schulleitung": [
"mail:mailbox:assign",
"mail:mailbox:revoke",
"mail:audit:view",
],
}
10. Testing
10.1 Unit Tests
# rbac_mail_bridge/tests/test_anonymizer.py
import pytest
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
from services.anonymizer import EmployeeAnonymizer, AnonymizationResult
@pytest.fixture
def anonymizer():
db = AsyncMock()
mail_server = AsyncMock()
encryption = AsyncMock()
return EmployeeAnonymizer(db, mail_server, encryption)
class TestAnonymizer:
@pytest.mark.asyncio
async def test_anonymize_user_success(self, anonymizer):
# Arrange
anonymizer._get_user = AsyncMock(return_value=MagicMock(
id="user-123",
name="Max Mustermann",
email="max@test.de",
anonymized_at=None
))
anonymizer._get_active_assignments = AsyncMock(return_value=[
MagicMock(id="assign-1", mailbox_id="mb-1"),
MagicMock(id="assign-2", mailbox_id="mb-2"),
])
anonymizer._revoke_assignment = AsyncMock()
anonymizer._create_audit_log = AsyncMock(return_value="audit-123")
anonymizer._publish_event = AsyncMock()
# Act
result = await anonymizer.anonymize(
user_id="user-123",
reason="employee_departure",
performed_by="admin-1",
options=MagicMock(anonymize_email_headers=False)
)
# Assert
assert result.success is True
assert result.affected_mailboxes == 2
assert anonymizer._revoke_assignment.call_count == 2
assert anonymizer._create_audit_log.called
@pytest.mark.asyncio
async def test_anonymize_already_anonymized_raises(self, anonymizer):
# Arrange
anonymizer._get_user = AsyncMock(return_value=MagicMock(
anonymized_at=datetime.utcnow()
))
# Act & Assert
with pytest.raises(AlreadyAnonymizedError):
await anonymizer.anonymize(
user_id="user-123",
reason="employee_departure",
performed_by="admin-1",
options=MagicMock()
)
@pytest.mark.asyncio
async def test_anonymize_user_not_found_raises(self, anonymizer):
# Arrange
anonymizer._get_user = AsyncMock(return_value=None)
# Act & Assert
with pytest.raises(UserNotFoundError):
await anonymizer.anonymize(
user_id="nonexistent",
reason="employee_departure",
performed_by="admin-1",
options=MagicMock()
)
10.2 Integration Tests
# rbac_mail_bridge/tests/test_integration.py
import pytest
from httpx import AsyncClient
@pytest.mark.integration
class TestMailRBACIntegration:
@pytest.mark.asyncio
async def test_create_and_assign_mailbox(self, client: AsyncClient):
# 1. Mailbox erstellen
create_res = await client.post(
"/api/v1/mailboxes",
json={
"role_key": "klassenlehrer",
"email_address": "klassenlehrer.test@school.bp.app",
"display_name": "Klassenlehrer Test",
"tenant_id": "tenant-123"
}
)
assert create_res.status_code == 201
mailbox_id = create_res.json()["id"]
# 2. Mailbox zuweisen
assign_res = await client.post(
f"/api/v1/mailboxes/{mailbox_id}/assign",
json={
"user_id": "user-456",
"valid_from": "2026-01-10T00:00:00Z"
}
)
assert assign_res.status_code == 201
# 3. Verifizieren
get_res = await client.get(f"/api/v1/mailboxes/{mailbox_id}")
assert get_res.status_code == 200
assert get_res.json()["current_assignee"]["id"] == "user-456"
11. Deployment Checklist
11.1 Vor dem Deployment
- PostgreSQL Schema migriert
- Stalwart API Key generiert und in .env
- MAIL_ENCRYPTION_KEY generiert (32 Bytes, Base64)
- DNS Records konfiguriert (MX, SPF, DKIM, DMARC)
- SSL-Zertifikate für Mail-Domain
- Firewall-Regeln für Ports 25, 143, 465, 993
11.2 Nach dem Deployment
- Health-Checks für alle Services grün
- Test-E-Mail senden/empfangen
- Admin-UI erreichbar
- Audit-Logging funktioniert
- Backup konfiguriert
12. Roadmap
Phase 1: MVP (4-6 Wochen)
- Architektur-Dokumentation
- Datenbank-Schema
- RBAC-Mail-Bridge Backend
- Stalwart Integration
- Admin-UI (Basis)
Phase 2: Groupware (2-3 Wochen)
- SOGo Integration
- CalDAV/CardDAV
- Jitsi-Meeting aus Kalender
Phase 3: Anonymisierung (2-3 Wochen)
- Anonymisierungs-Service
- E-Mail-Header-Anonymisierung
- DSGVO-Export
Phase 4: Polish (1-2 Wochen)
- Admin-UI vollständig
- Dokumentation
- Produktions-Hardening
Anhang A: Glossar
| Begriff | Beschreibung |
|---|---|
| Funktionale Mailbox | Rollengebundene E-Mail-Adresse (z.B. klassenlehrer.5a@...) |
| Personenbezogene Mailbox | An eine Person gebundene E-Mail-Adresse |
| Anonymisierung | Unwiderrufliche Entfernung personenbezogener Daten |
| Pseudonymisierung | Ersetzung durch Pseudonym (reversibel mit Schlüssel) |
| Audit-Trail | Lückenlose Protokollierung aller Aktionen |
| RBAC | Role-Based Access Control |
| CalDAV | Calendar Distributed Authoring and Versioning |