Zum Inhalt springen

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:

  1. Permission-Check: update auf der Entity (oder manage_metadata für entkoppelte Anhänge).
  2. Validierung: mime gegen Tenant-Whitelist, size_bytes ≤ Tenant-Limit, logical_key gegen Format-Regex.
  3. 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.
  4. Pre-Signed-PUT-URL generieren mit Tenant-TTL (Default 5 min).
  5. 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:

  1. Session lookup. expires_at < now()410 Gone. committed_at IS NOT NULL409 (idempotent rejection).
  2. HEAD-Request gegen Storage, prüft Existenz und ETag/Size. Diff zu Session → 409.
  3. Ggf. SHA-256 server-side recompute (oder vertraue Client + Multipart-Hash). Dokumentiert: V1 vertraut Client-sha256 und prüft nur Größe — Mismatch ist V2+ via Stream-Hash beim Download verifizierbar.
  4. In einer Transaktion:
    • INSERT INTO DDM_ATTACHMENT (id=session.attachment_uuid, …, virus_scan_status='pending', is_current=true, version_no = …).
    • Bei logical_key: Vorgänger is_current auf false, neuer version_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', …) (falls entity_id gesetzt).
    • INSERT INTO AA_JOB (job_kind='virus_scan', payload={"attachment_id":…}).
  5. Antwort mit der frischen DDM_ATTACHMENT-Zeile.

Abbruch / Garbage Collection

  • Sessions mit expires_at < now() - 24h und committed_at IS NULL werden 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: 1d auf einem pending/-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':

  1. payload.attachment_id lesen, DDM_ATTACHMENT selektieren.
  2. Wenn Tenant virus_scan: null ⇒ direkt virus_scan_status='skipped'. Audit action='attachment_scanned'. Fertig.
  3. Storage-Stream zum Scanner (ClamAV, Defender Cloud, …).
  4. Ergebnis:
    • cleanvirus_scan_status='clean', virus_scan_at, virus_scan_engine.
    • infectedvirus_scan_status='infected', Datei bleibt vorhanden (forensische Sicherung), Soft-Delete der Zeile durch Service. Audit attachment_scanned plus zusätzlich attachment_deleted mit reason='virus_detected'. Outbox-Event attachment.infected für Steward-Alerting.
    • Scanner-Fehler / Timeout → virus_scan_status='failed', Job in DLQ nach max_attempts.
  5. Audit action='attachment_scanned' mit payload.virus_scan_status und payload.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 Found
Location: https://minio.../mdm-acme/...?X-Amz-...

Server:

  1. Permission-Check: read auf Entity (oder manage_metadata für entkoppelte Anhänge).
  2. virus_scan_status Gate (siehe oben).
  3. deleted_at IS NULL Gate; Soft-deleted ⇒ 404.
  4. Pre-Signed-GET-URL generieren (Default-TTL aus Tenant-Konfig, 5 min). Optional Content-Disposition: attachment; filename=<filename> als signed-Header.
  5. Audit action='read' mit entity_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} setzt deleted_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_ATTACHMENT mit deleted_at < now() - retention_days.
    • Löscht Storage-Object via Adapter; bei Erfolg DELETE FROM DDM_ATTACHMENT WHERE id=….
    • Audit action='attachment_hard_deleted' mit metadata.storage_uri (forensisch belegt, dass Datei gelöscht wurde).

Hard-Delete (Admin)

POST /attachments/{id}:purge (manage_metadata-only):

  1. Storage-Object löschen.
  2. DELETE FROM DDM_ATTACHMENT.
  3. Audit attachment_hard_deleted mit 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:

  1. Für jedes kind='attachment_id': Anhang muss existieren, deleted_at IS NULL, virus_scan_status='clean' (oder skipped per Tenant-Konfig).
  2. Permission-Check: aufrufendes Subjekt darf den Anhang lesen (Erbung von Entity).
  3. 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).
  4. 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).

Verwandte Dokumente