Zum Inhalt springen

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

FeldTypPflichtHinweise
iduuidjaPK
tenant_iduuidjaFK → AA_TENANT. Konsistenz mit entity.tenant_id per Trigger
entity_iduuidneinFK → DDM_ENTITY ON DELETE RESTRICT. NULLable: erlaubt entkoppelte Anhänge (z. B. nur an Email/Job/Case gebunden, ohne fachliche Entität)
logical_keytextneinoptionaler Slot je Entity, z. B. vertragsscan, datenblatt, passbild. Format ^[a-z][a-z0-9_]*$. Null = “namenloser” Append-Upload
version_nointegerjadefault 1. Zählt monoton je (entity_id, logical_key) hoch. Append-only — kein Replace
is_currentbooleanjadefault true. Kennzeichnet die jüngste aktive Version eines Slots; ältere werden beim Hochzählen automatisch auf false gesetzt (Service-Layer-Pflicht)
filenametextjaOriginal-Filename inkl. Extension, Anzeigename
mimetextjaMIME-Type wie vom Client gemeldet, validiert gegen Tenant-Whitelist
size_bytesbigintjaGröße der Datei. >= 0
sha256textjahex-[a-f0-9]{64} Hash. Service prüft beim Upload-Commit gegen S3-ETag/Multipart-Hash
storage_backendtextjaz. B. minio, s3, r2. Erlaubt Multi-Backend-Migration ohne URL-Umbau
storage_uritextjaunveränderlich nach Anlage; Format s3://<bucket>/<tenant>/<uuid>
virus_scan_statustextjapending, clean, infected, skipped, failed. Default pending. Download blockiert bis clean (oder skipped falls Tenant-Konfig kein Scan)
virus_scan_attimestamptzneinZeitpunkt des letzten Scans
virus_scan_enginetextneinz. B. clamav-1.3.0, defender-cloud
metadatajsonbjadefault '{}' (Quellsystem-Hinweise, fachliche Tags, Original-Upload-Source)
created_at / created_byWer hochgeladen hat
deleted_at / deleted_bySoft-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_id o. ä. — 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 >= 0
  • attachment_sha256_format_chk: sha256 ~ '^[a-f0-9]{64}$'
  • attachment_version_positive_chk: version_no >= 1
  • attachment_metadata_is_object_chk
  • attachment_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 mit is_current=true:
    CREATE UNIQUE INDEX uq_attachment_slot_current
    ON mdm.DDM_ATTACHMENT (tenant_id, entity_id, logical_key)
    WHERE logical_key IS NOT NULL
    AND deleted_at IS NULL
    AND is_current = true;
  • Versions-Eindeutigkeit (partial UNIQUE): pro (tenant, entity, logical_key) ist version_no eindeutig (verhindert Race-Beim-Append):
    CREATE UNIQUE INDEX uq_attachment_slot_version
    ON 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_key haben keine Slot-Constraints — jeder Upload ist eine eigenständige Datei (nützlich für „mehrere Fotos je Entity” o. ä.).

Indizes

  • ix_attachment_entity partial: (tenant_id, entity_id, created_at DESC) WHERE deleted_at IS NULL — alle Anhänge einer Entity in Reihenfolge.
  • ix_attachment_storage_uri partial: (storage_uri) WHERE deleted_at IS NULL — Storage-Cleanup-/Konsistenz-Prüfungen.
  • ix_attachment_pending_scan partial: (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_idlogical_keyversion_nois_currentfilenamecreated_at
c-1001vertragsscan1falsevertrag_2024.pdf2024-03-12
c-1001vertragsscan2falsevertrag_2025.pdf2025-01-08
c-1001vertragsscan3truevertrag_2026.pdf2026-04-27

Service-Operation append_attachment(entity_id, logical_key, file):

  1. SELECT max(version_no) FROM DDM_ATTACHMENT WHERE entity_id=? AND logical_key=? AND deleted_at IS NULLn.
  2. UPDATE DDM_ATTACHMENT SET is_current=false WHERE entity_id=? AND logical_key=? AND is_current=true.
  3. INSERT … VALUES (…, version_no = n+1, is_current=true, virus_scan_status='pending', …).
  4. Audit action='attachment_uploaded', Outbox-Event entity.<type>.attachment_added.
  5. Job job_kind='virus_scan' mit payload.attachment_id enqueuen.

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_at entfällt — Tabelle hat kein updated_at (is_current-Flip ist bewusster Service-Schritt, nicht Daten-Update).
  • trg_validate_attachment_tenant: erzwingt Tenant-Konsistenz attachment.tenant_id = entity.tenant_id (analog zu validate_external_id_tenant). Greift nur, wenn entity_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 via POST /attachments:commit mit sha256 + 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. Audit action='restore'. Storage-Object muss noch existieren (sonst Fehler — Cleanup-Job hat es eventuell schon entfernt).

Permission-Modell V1

Erbt von der gebundenen Entity:

  • read auf Entity ⇒ read auf alle Anhänge der Entity (Listing + Download).
  • update auf Entity ⇒ create/update/soft_delete auf Anhänge.
  • hard_delete ist 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 hat virus_scan_status gesetzt (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).

Verwandte Dokumente