Anhänge / Verhalten
Status: Entwurf · Spec-Kandidat: ja
Zweck
Beschreibt Upload-, Download-, Scan- und Cleanup-Verhalten für Datei-Anhänge (DDM_ATTACHMENT). Schnittstellen-Details (REST + Pre-Signed-URLs) liegen in Anhänge-API.
Storage-Backend
S3-kompatibler Object-Store. V1-Default: MinIO self-hosted via Coolify; gleicher Adapter spricht später AWS S3 / Cloudflare R2 / Backblaze B2 ohne Codeänderung.
Bucket-Pfadstruktur: s3://<bucket>/<tenant_key>/<attachment_id> (kein Original-Filename im Pfad — vermeidet PII in URIs, vermeidet Path-Traversal).
Tenant-Konfiguration in AA_TENANT.metadata.attachments:
{ "attachments": { "storage": { "backend": "minio", "endpoint": "https://minio.acme.example.com", "bucket": "mdm-acme", "region": "us-east-1", "credentials_secret_ref": "env:MINIO_ACME_CREDS" }, "max_size_mb": 100, "mime_whitelist": null, "presigned_ttl_seconds": 300, "virus_scan": "clamav" }}mime_whitelist=null ⇒ alle MIME-Types erlaubt. Liste z. B. ["application/pdf", "image/png", "image/jpeg", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"]. Plattform-Default-Limits liegen in der Service-Konfig (außerhalb DB) — Tenant-Override gewinnt.
virus_scan: null ⇒ Anhänge starten direkt mit virus_scan_status='skipped'. Für regulierte Daten nicht empfohlen.
Upload-Flow (Pre-Signed-URL, gesetzt)
Zwei-Phasen-Upload, kein Byte-Strom durch die MDM-API:
Phase 1 — Initiate
POST /attachments:initiate{ "tenant_id": "...", // implizit aus Auth-Kontext "entity_id": "...", // optional "logical_key": "vertragsscan", // optional "filename": "vertrag_2026.pdf", "mime": "application/pdf", "size_bytes": 1248192}→ 200 OK{ "upload_session_id": "...", "presigned_put_url": "https://minio.../mdm-acme/...?X-Amz-...", "expires_at": "2026-04-27T13:05:00Z", "storage_uri": "s3://mdm-acme/acme/<uuid>"}Service:
- Permission-Check:
updateauf der Entity (odermanage_metadatafür entkoppelte Anhänge). - Validierung:
mimegegen Tenant-Whitelist,size_bytes≤ Tenant-Limit,logical_keygegen Format-Regex. - Reservierungseintrag in einer Hilfstabelle
mdm.AA_UPLOAD_SESSION(siehe DDL):id,attachment_uuid(vorberechnet),tenant_id,entity_id,logical_key,filename,mime,size_bytes,expires_at,committed_at. Verhindert Drift zwischen erwarteter Datei und tatsächlich geladener. - Pre-Signed-PUT-URL generieren mit Tenant-TTL (Default 5 min).
- Antwort an Client.
Phase 2 — Direkter Upload zum Storage
Client PUTtet die Bytes direkt zur Pre-Signed-URL. MDM ist nicht beteiligt. S3 antwortet mit ETag.
Phase 3 — Commit
POST /attachments:commit{ "upload_session_id": "...", "sha256": "9f86d0...", "size_bytes": 1248192 // optional, Server validiert gegen Phase 1}→ 201 Created{ "attachment": { ... full DDM_ATTACHMENT row ... } }Service:
- Session lookup.
expires_at < now()→410 Gone.committed_at IS NOT NULL→409(idempotent rejection). - HEAD-Request gegen Storage, prüft Existenz und ETag/Size. Diff zu Session →
409. - Ggf. SHA-256 server-side recompute (oder vertraue Client + Multipart-Hash). Dokumentiert: V1 vertraut Client-
sha256und prüft nur Größe — Mismatch ist V2+ via Stream-Hash beim Download verifizierbar. - In einer Transaktion:
INSERT INTO DDM_ATTACHMENT (id=session.attachment_uuid, …, virus_scan_status='pending', is_current=true, version_no = …).- Bei
logical_key: Vorgängeris_currentauffalse, neuerversion_no = max+1. UPDATE AA_UPLOAD_SESSION SET committed_at = now().INSERT INTO AA_AUDIT_LOG (action='attachment_uploaded', …).INSERT INTO AA_OUTBOX_EVENT (event_type='entity.<kind>.attachment_added', …)(fallsentity_idgesetzt).INSERT INTO AA_JOB (job_kind='virus_scan', payload={"attachment_id":…}).
- Antwort mit der frischen
DDM_ATTACHMENT-Zeile.
Abbruch / Garbage Collection
- Sessions mit
expires_at < now() - 24hundcommitted_at IS NULLwerden vom Cleanup-Job (job_kind='cleanup_jobs') gelöscht. Storage-Objekte werden — sofern sie hochgeladen wurden, aber nie committet — über Bucket-Lifecycle-Policy (S3-Native:Expiration: 1dauf einempending/-Prefix) entfernt. Dokumentation in Backup und Restore.
Virus-Scan-Job (gesetzt)
Neue Job-Art: mdm.job_kind wird um virus_scan erweitert.
Worker-Schritte für job_kind='virus_scan':
payload.attachment_idlesen,DDM_ATTACHMENTselektieren.- Wenn Tenant
virus_scan: null⇒ direktvirus_scan_status='skipped'. Auditaction='attachment_scanned'. Fertig. - Storage-Stream zum Scanner (ClamAV, Defender Cloud, …).
- Ergebnis:
clean→virus_scan_status='clean',virus_scan_at,virus_scan_engine.infected→virus_scan_status='infected', Datei bleibt vorhanden (forensische Sicherung), Soft-Delete der Zeile durch Service. Auditattachment_scannedplus zusätzlichattachment_deletedmitreason='virus_detected'. Outbox-Eventattachment.infectedfür Steward-Alerting.- Scanner-Fehler / Timeout →
virus_scan_status='failed', Job in DLQ nachmax_attempts.
- Audit
action='attachment_scanned'mitpayload.virus_scan_statusundpayload.virus_scan_engine.
Download wird in GET /attachments/{id} durch MDM erst nach clean/skipped freigegeben. Pending oder failed → 423 Locked mit Hinweis-Body.
Download-Flow
GET /attachments/{id}→ 302 FoundLocation: https://minio.../mdm-acme/...?X-Amz-...Server:
- Permission-Check:
readauf Entity (odermanage_metadatafür entkoppelte Anhänge). virus_scan_statusGate (siehe oben).deleted_at IS NULLGate; Soft-deleted ⇒404.- Pre-Signed-GET-URL generieren (Default-TTL aus Tenant-Konfig, 5 min). Optional
Content-Disposition: attachment; filename=<filename>als signed-Header. - Audit
action='read'mitentity_name='DDM_ATTACHMENT',entity_id=…(nur bei Zugriffslogging-Pflicht je Tenant — sonst Aggregat-Metrik, sonst läuft Audit-Volumen über).
Direct-Stream-Variante (GET /attachments/{id}/content) ist optional für Clients, die keine 302-Redirects verfolgen können. Streamt durch MDM — nicht für große Dateien empfohlen.
Soft-Delete + Cleanup
DELETE /attachments/{id}setztdeleted_at,deleted_by. Storage-Object bleibt unverändert.- Tenant-Retention (
AA_TENANT.metadata.attachments.retention_days, Default 90) entscheidet, wann der Cleanup-Job das Storage-Object endgültig löscht. - Cleanup-Job (
job_kind='cleanup_jobs', Cron-getriebener Enqueue alle 24 h):- Findet
DDM_ATTACHMENTmitdeleted_at < now() - retention_days. - Löscht Storage-Object via Adapter; bei Erfolg
DELETE FROM DDM_ATTACHMENT WHERE id=…. - Audit
action='attachment_hard_deleted'mitmetadata.storage_uri(forensisch belegt, dass Datei gelöscht wurde).
- Findet
Hard-Delete (Admin)
POST /attachments/{id}:purge (manage_metadata-only):
- Storage-Object löschen.
DELETE FROM DDM_ATTACHMENT.- Audit
attachment_hard_deletedmit Begründung.
Pre-Bedingung: keine aktiven Email-Jobs referenzieren attachment_id (Service-Layer-Check). Verstoß ⇒ 409.
Restore
POST /attachments/{id}:restore setzt deleted_at=NULL. Pre-Bedingung: Storage-Object existiert noch (kein Cleanup gelaufen). Andernfalls 410 Gone.
Beziehung zu Email-Anhängen
Email-Job-Payload referenziert Anhänge per FK:
"attachments": [ { "kind": "attachment_id", "attachment_id": "<uuid>" }, { "kind": "inline_base64", "filename": "...", "mime": "...", "content_base64": "..." }]Service-Pflichten beim Email-Enqueue:
- Für jedes
kind='attachment_id': Anhang muss existieren,deleted_at IS NULL,virus_scan_status='clean'(oderskippedper Tenant-Konfig). - Permission-Check: aufrufendes Subjekt darf den Anhang lesen (Erbung von Entity).
- Permission-Re-Check zur Versandzeit im Worker — falls inzwischen Soft-Delete oder Permissions weg, wird der Job mit
failed+DLQ markiert (kein verspäteter Versand eines gelöschten Vertrags). - Worker erzeugt Provider-spezifischen Anhang aus dem Storage-Stream zur Versandzeit.
AA_EMAIL_LOG.attachment_count reflektiert die Zahl der erfolgreich angefügten Anhänge (inline + per FK aufgelöst).