DDM_ATTACHMENT
Status: Entwurf · Spec-Kandidat: ja
Zweck
Datei-Anhänge zu Stammdatensätzen: Vertragsscan, Zertifikat, Produktbild, Datenblatt-PDF, Email-Anhang. Bytes liegen in einem S3-kompatiblen Object-Store, Postgres hält ausschließlich Metadaten + Storage-Pointer + Virus-Scan-Status.
Diese Tabelle ist die einzige autorisierte Datei-Quelle des MDM. Email-Anhänge referenzieren Anhänge per FK (attachment_id), externe Systeme laden über Pre-Signed-URLs hoch/herunter.
Felder
| Feld | Typ | Pflicht | Hinweise |
|---|---|---|---|
id | uuid | ja | PK |
tenant_id | uuid | ja | FK → AA_TENANT. Konsistenz mit entity.tenant_id per Trigger |
entity_id | uuid | nein | FK → DDM_ENTITY ON DELETE RESTRICT. NULLable: erlaubt entkoppelte Anhänge (z. B. nur an Email/Job/Case gebunden, ohne fachliche Entität) |
logical_key | text | nein | optionaler Slot je Entity, z. B. vertragsscan, datenblatt, passbild. Format ^[a-z][a-z0-9_]*$. Null = “namenloser” Append-Upload |
version_no | integer | ja | default 1. Zählt monoton je (entity_id, logical_key) hoch. Append-only — kein Replace |
is_current | boolean | ja | default true. Kennzeichnet die jüngste aktive Version eines Slots; ältere werden beim Hochzählen automatisch auf false gesetzt (Service-Layer-Pflicht) |
filename | text | ja | Original-Filename inkl. Extension, Anzeigename |
mime | text | ja | MIME-Type wie vom Client gemeldet, validiert gegen Tenant-Whitelist |
size_bytes | bigint | ja | Größe der Datei. >= 0 |
sha256 | text | ja | hex-[a-f0-9]{64} Hash. Service prüft beim Upload-Commit gegen S3-ETag/Multipart-Hash |
storage_backend | text | ja | z. B. minio, s3, r2. Erlaubt Multi-Backend-Migration ohne URL-Umbau |
storage_uri | text | ja | unveränderlich nach Anlage; Format s3://<bucket>/<tenant>/<uuid> |
virus_scan_status | text | ja | pending, clean, infected, skipped, failed. Default pending. Download blockiert bis clean (oder skipped falls Tenant-Konfig kein Scan) |
virus_scan_at | timestamptz | nein | Zeitpunkt des letzten Scans |
virus_scan_engine | text | nein | z. B. clamav-1.3.0, defender-cloud |
metadata | jsonb | ja | default '{}' (Quellsystem-Hinweise, fachliche Tags, Original-Upload-Source) |
created_at / created_by | – | – | Wer hochgeladen hat |
deleted_at / deleted_by | – | – | Soft-Delete; Storage-Object wird durch Cleanup-Job entfernt |
Bewusst weggelassen V1:
retention_until,legal_hold— gehört zur systemweiten Governance (OP-36).classification(public/internal/confidential/restricted) — ebenfalls OP-36.replaced_by_attachment_ido. ä. — Versionen liegen über(logical_key, version_no)zusammen, kein FK nötig.
Constraints
attachment_logical_key_format_chk:logical_key IS NULL OR logical_key ~ '^[a-z][a-z0-9_]*$'attachment_size_nonneg_chk:size_bytes >= 0attachment_sha256_format_chk:sha256 ~ '^[a-f0-9]{64}$'attachment_version_positive_chk:version_no >= 1attachment_metadata_is_object_chkattachment_virus_scan_status_chk:virus_scan_status IN ('pending','clean','infected','skipped','failed')attachment_storage_backend_format_chk:storage_backend ~ '^[a-z][a-z0-9_]*$'- Slot-Eindeutigkeit (partial UNIQUE): pro
(tenant, entity, logical_key)gibt es höchstens eine aktive Version mitis_current=true:CREATE UNIQUE INDEX uq_attachment_slot_currentON mdm.DDM_ATTACHMENT (tenant_id, entity_id, logical_key)WHERE logical_key IS NOT NULLAND deleted_at IS NULLAND is_current = true; - Versions-Eindeutigkeit (partial UNIQUE): pro
(tenant, entity, logical_key)istversion_noeindeutig (verhindert Race-Beim-Append):CREATE UNIQUE INDEX uq_attachment_slot_versionON mdm.DDM_ATTACHMENT (tenant_id, entity_id, logical_key, version_no)WHERE logical_key IS NOT NULL AND deleted_at IS NULL; - Anhänge ohne
logical_keyhaben keine Slot-Constraints — jeder Upload ist eine eigenständige Datei (nützlich für „mehrere Fotos je Entity” o. ä.).
Indizes
ix_attachment_entitypartial:(tenant_id, entity_id, created_at DESC)WHERE deleted_at IS NULL— alle Anhänge einer Entity in Reihenfolge.ix_attachment_storage_uripartial:(storage_uri)WHERE deleted_at IS NULL— Storage-Cleanup-/Konsistenz-Prüfungen.ix_attachment_pending_scanpartial:(virus_scan_status)WHERE virus_scan_status='pending'— Worker-Hot-Path.
Versionierung (Append-Only)
Beispiel: Entity c-1001 hat zweimal einen neuen Vertragsscan bekommen.
entity_id | logical_key | version_no | is_current | filename | created_at |
|---|---|---|---|---|---|
| c-1001 | vertragsscan | 1 | false | vertrag_2024.pdf | 2024-03-12 |
| c-1001 | vertragsscan | 2 | false | vertrag_2025.pdf | 2025-01-08 |
| c-1001 | vertragsscan | 3 | true | vertrag_2026.pdf | 2026-04-27 |
Service-Operation append_attachment(entity_id, logical_key, file):
SELECT max(version_no) FROM DDM_ATTACHMENT WHERE entity_id=? AND logical_key=? AND deleted_at IS NULL→n.UPDATE DDM_ATTACHMENT SET is_current=false WHERE entity_id=? AND logical_key=? AND is_current=true.INSERT … VALUES (…, version_no = n+1, is_current=true, virus_scan_status='pending', …).- Audit
action='attachment_uploaded', Outbox-Evententity.<type>.attachment_added. - Job
job_kind='virus_scan'mitpayload.attachment_idenqueuen.
Für Anhänge ohne logical_key: nur Schritt 3 + Audit + Scan-Job; is_current bleibt true und ist nicht aussagekräftig (Reporting blendet logical_key IS NULL-Zeilen für Slot-Sichten aus).
Trigger
trg_attachment_set_updated_atentfällt — Tabelle hat keinupdated_at(is_current-Flip ist bewusster Service-Schritt, nicht Daten-Update).trg_validate_attachment_tenant: erzwingt Tenant-Konsistenzattachment.tenant_id = entity.tenant_id(analog zuvalidate_external_id_tenant). Greift nur, wennentity_id IS NOT NULL.
Lebenszyklus
- Upload: zwei Schritte. (1) Client holt Pre-Signed-URL via
POST /attachments:initiate, lädt direkt zu Storage. (2) Client meldet Erfolg viaPOST /attachments:commitmitsha256+size_bytes+mime— MDM legt die Zeile an. Detail: Anhänge-Verhalten, Anhänge-API. - Download:
GET /attachments/{id}→ MDM prüft Permission +virus_scan_status='clean'→ Redirect auf Pre-Signed-Download-URL (kurzlebig, Default 5 min). - Soft-Delete:
deleted_at = now(), Storage-Object bleibt erhalten. Cleanup-Job (job_kind='cleanup_jobs') entfernt Bytes nach Tenant-Retention-Fenster. - Hard-Delete: nur
manage_metadata-Rolle. Löscht Zeile + Storage-Object atomar (in zwei Schritten mit Rollback-Pfad — Detail im Behavior-Doc). - Restore: setzt
deleted_at = NULL. Auditaction='restore'. Storage-Object muss noch existieren (sonst Fehler — Cleanup-Job hat es eventuell schon entfernt).
Permission-Modell V1
Erbt von der gebundenen Entity:
readauf Entity ⇒readauf alle Anhänge der Entity (Listing + Download).updateauf Entity ⇒create/update/soft_deleteauf Anhänge.hard_deleteist eigene Permission (manage_metadata).
Anhänge ohne entity_id: V1-Default ist manage_metadata-only — nur Admins/Stewards. Feinere Permission-Modelle (eigener acl_scope='attachment', Klassifikation public/internal/confidential/restricted) sind OP-36 und nicht V1.
Audit-Aktionen
mdm.audit_action wird erweitert:
attachment_uploaded— neue Zeile angelegt (vor Scan).attachment_scanned— Worker hatvirus_scan_statusgesetzt (clean/infected/failed).attachment_deleted— Soft-Delete.attachment_restored— Soft-Delete rückgängig gemacht.attachment_hard_deleted— Zeile + Storage-Object entfernt.
metadata.storage_uri und metadata.sha256 werden im Audit mitgeführt (forensisch wichtig).