Merge PR #309: QA-02 (Archive): Prüfgate Retention & WORM
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s

This commit is contained in:
patrick committed 2026-10-19 02:18:22 +02:00
commit ed9a2a9a02
3 files changed
+192

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
# QA-02 – Prüfprotokoll: Prüfgate Retention & WORM
Stand: 2026-10-16, Testhost 192.168.1.204 (echte `TEST_TENANT_DSN`-PostgreSQL-Testdatenbank, kein
Mocking). Voraussetzung RET-03 (Löschworkflow & Legal Hold) und RET-04 (WORM-Speicher-Garantie) –
beide Fertig, siehe `RET-03-PRUEFPROTOKOLL.md`/`RET-04-PRUEFPROTOKOLL.md`. Dieses Gate wiederholt
deren Pflichtprüfungen unter Prüfgate-Bedingungen und bindet sie zusätzlich an den in QA-01
definierten Referenzdatensatz (`internal/referenceset`).
## Umsetzung dieses Gates
- `archive/internal/deletionworkflow/deletionworkflow_test.go`: neuer Test
`TestPruefgate02_LegalHoldResistsDeletionForReferenceSetObjects` – durchläuft alle drei
`referenceset.RetentionObjects()` (`dms_document`/`steuerlich_10j`,
`mail_message`/`handelsrechtlich_6j`, `audit_event`/`sofort_loeschbar`), setzt je Objekt eine
Aufbewahrungssperre, prüft, dass `ReleaseExpired` das gesperrte, fällige Referenzobjekt NICHT
freigibt, und dass `Destroy` mit `ErrOnLegalHold` scheitert.
- `archive/internal/wormstore/wormstore_test.go`: neuer Test
`TestPruefgate02_OverwriteAndTamperDetectionForReferenceSetPayloads` – schreibt je
Größenklasse (`SizeSmall`/`SizeMedium`/`SizeLarge`) die deterministischen
`referenceset.WormPayload`-Nutzdaten, vergleicht die Prüfsumme mit
`referenceset.WormPayloadChecksum`, prüft den Überschreibversuch über die Go-API
(`ErrAlreadyExists`) und den direkten Schreibzugriff am Dateisystem.
- Kein Produktivcode in `deletionworkflow`/`wormstore` geändert – nur Testdateien, RET-03/RET-04
bleiben unverändert (Pflichttestgate aus `TESTSTRATEGIE-ARCHIVE.md` Abschnitt 3 bleibt erfüllt).
## Prüfungen vor der Abnahme
| # | Prüfung | Zielwert | Messwert | Bewertung |
|---|---|---|---|---|
| 1 | Löschversuch an gesperrtem Objekt schlägt fehl (RET-03, Referenzdatensatz) | `Destroy` liefert `ErrOnLegalHold` für jedes der drei Referenzobjekte, Status bleibt unverändert, `ReleaseExpired` gibt das gesperrte Objekt trotz Fälligkeit nicht frei | `go test ./internal/deletionworkflow/... -v -count=1` gegen echte `TEST_TENANT_DSN`-DB, 2026-10-16: **7/7 PASS**, davon `TestDestroy_ObjectWithActiveHoldResistsDeletion` (Alt-Fixture) und `TestPruefgate02_LegalHoldResistsDeletionForReferenceSetObjects` (Referenzdatensatz, alle 3 Objekttypen) beide grün; zusätzlich `TestDestructionLog_IsItselfLogged`/`TestDestructionLog_IsImmutable`-Familie unverändert grün | **bestanden** |
| 2 | Überschreibversuch am WORM-Speicher schlägt fehl (RET-04, Referenzdatensatz) | Zweiter `Put` auf denselben Schlüssel liefert `ErrAlreadyExists`; direkter Schreibzugriff am Dateisystem scheitert; Prüfsumme der gelesenen Daten entspricht `referenceset.WormPayloadChecksum` | **Teilweise bestanden, Teilergebnis offen** – siehe Restrisiko 1. Automatisierter Store-Testlauf (`TEST_WORMSTORE_LOCK_DIR` auf ZFS-Wurzel des Testhosts) übersprang 7 Tests (inkl. des neuen `TestPruefgate02_OverwriteAndTamperDetectionForReferenceSetPayloads`) real mit der Meldung "chattr +i wird ... nicht durchgesetzt" – reproduziert exakt den in `RET-11-PRUEFPROTOKOLL.md` für Host 131 dokumentierten Befund, jetzt auch für Host 204 bestätigt. Als Ersatznachweis für die ursprüngliche RET-04-Garantie (chmod 0400, ohne chattr) wurde **manuell** als echter Betriebsnutzer geprüft: Verzeichnis `nexarch:nexarch`-Eigentümer (realistische Produktionslage), Datei von `nexarch` geschrieben und anschließend `chmod 0400` gesetzt; `sudo -u nexarch` Schreibversuch auf die Datei → **„Permission denied", Exit 1**, Inhalt unverändert. Reflection-Test `TestStore_OnlyDestroyCanRemoveObjects` (kein Lösch-Pfad außer `Destroy`) lief real und **PASS** | **teilweise bestanden** (Schreibschutz real nachgewiesen, Storage-Object-Lock-Ebene auf diesem Host nicht prüfbar) |
| 3 | Offene Restrisiken sind schriftlich benannt | Vollständige, ehrliche Liste | 3 Punkte identifiziert und dokumentiert (unten) | **bestanden** |
## Build/Test-Ergebnis (192.168.1.204)
```
cd archive && go build ./... -> clean
cd archive && go vet ./... -> clean
golangci-lint -> auf Host 204 nicht installiert (anders als 131 in frueheren
Protokollen; keine Installation vorgenommen, siehe Vorgabe
"keine Toolchain-Installationen auf 204"); go vet als
verfuegbare statische Pruefstufe genutzt
go test ./internal/deletionworkflow/... -v -count=1
-> 7/7 PASS (TEST_TENANT_DSN, echte Postgres-Testdatenbank)
go test ./internal/wormstore/... -v -count=1
-> 1 PASS (TestStore_OnlyDestroyCanRemoveObjects), 7 SKIP (chattr +i auf
ZFS/unprivilegiertem LXC-Host strukturell nicht durchsetzbar)
```
Manueller Zusatznachweis (nicht Teil der Go-Testsuite, siehe Prüfung 2): Verzeichnis-/Datei-Setup
und `sudo -u nexarch`-Schreibversuch, durchgeführt 2026-10-16 ca. 12:11 UTC, Testartefakte danach
entfernt (`/tmp/qa02-worm-manual2`).
## Offene Restrisiken (Akzeptanzkriterium/Pflichtprüfung 3)
1. **Storage-Object-Lock (RET-11, `chattr +i`) auf keinem bekannten Host dieses Projekts real
nachweisbar – potenziell auch in Produktion nicht funktionsfähig.** `wormstore.Store.Put` ist
seit RET-11 fail-closed: schlägt das Setzen von `chattr +i` fehl, wird die bereits geschriebene
Datei wieder entfernt und `Put` insgesamt mit Fehler beendet. Sowohl Host 131
(`RET-11-PRUEFPROTOKOLL.md`) als auch – in diesem Gate neu bestätigt – Host 204 sind
unprivilegierte Proxmox-LXC-Container auf ZFS-Root, auf denen `chattr +i` strukturell mit
`EPERM` scheitert. `docs/STORAGE-KONZEPT.md` Abschnitt 10 benennt "Stufe 1 (Einzel-LXC)" mit
"lokales Dateisystem oder ZFS" ausdrücklich als Ziel-Produktionsarchitektur – also exakt dieselbe
Kombination, an der `chattr +i` auf beiden bisher geprüften Hosts scheitert. **Ungeklärt, ob
`wormstore.Store.Put` in der aktuellen, dokumentierten Zielarchitektur überhaupt jemals
erfolgreich schreiben kann.** Das ist kein neuer Befund dieses Gates (RET-11 selbst stufte seine
Prüfungen 1/2 bereits als "offen" ein), aber die erneute Bestätigung auf einem zweiten,
unabhängigen Host erhöht das Gewicht dieses Risikos von "Testumgebungs-Lücke" zu
"möglicherweise produktionsrelevant". **Empfehlung:** eigenes Folgeticket, das (a) `Put` entweder
mit einem privilegierten Container/VM mit ext4/xfs/btrfs-Backing real verifiziert, oder (b) einen
Fallback-Modus für Hosts ohne `CAP_LINUX_IMMUTABLE`-Durchsetzung definiert, statt stillschweigend
jeden Schreibvorgang scheitern zu lassen.
2. **Referenzdatensatz-Bindung deckt nur RET-03/RET-04 ab, nicht RET-06/RET-07.** Die
Ausgangslage dieses Tickets nennt RET-06 (Fristen-Konfigurationsoberfläche) und RET-07
(Fristablauf-Benachrichtigungen) als bereits fertiggestellt, QA-02s Akzeptanzkriterien verlangen
aber ausdrücklich nur den Nachweis für RET-03/RET-04 – dieses Gate prüft RET-06/RET-07 bewusst
nicht erneut (kein Scope-Drift). Ein eigenes Prüfgate für RET-06/RET-07 existiert nach aktuellem
Stand nicht.
3. **`golangci-lint` auf Host 204 nicht verfügbar.** Frühere Archive-Prüfprotokolle (RET-03, RET-04,
RET-11) liefen auf Host 131 inklusive `golangci-lint run ./...`. Auf 204 steht das Werkzeug nicht
zur Verfügung und wurde gemäß Vorgabe (keine Toolchain-Installation auf 204) nicht nachinstalliert
– `go build`/`go vet` liefen stattdessen clean, ersetzen aber nicht den vollen Lint-Umfang.
## Gesamtergebnis
**Teilweise bestanden.** RET-03 (Löschworkflow & Legal Hold) ist vollständig und real gegen den
Referenzdatensatz nachgewiesen (Prüfung 1: bestanden). RET-04 (WORM-Speicher) ist in seinem
ursprünglichen, eigenen Geltungsbereich (Anwendungs-API ohne Lösch-Pfad, Schreibschutz gegen den
realen Betriebsnutzer `nexarch`) real bestätigt, die seit RET-11 zusätzliche
Storage-Object-Lock-Ebene bleibt auf diesem Host technisch nicht prüfbar (Prüfung 2: teilweise
bestanden). Drei Restrisiken benannt, keines davon ist ein neu eingeführter Fehler dieses Gates –
Restrisiko 1 verdient wegen der Übereinstimmung mit der dokumentierten Zielarchitektur
(`STORAGE-KONZEPT.md`) besondere Priorität vor einer produktiven Freigabe der WORM-Funktion.
## Unterschriften
- **Umsetzung:** Claude (Agent), 2026-10-16 — alle Prüfungen durchgeführt, Protokoll erstellt.
- **Gegenzeichnung:** aussteht (wie bei früheren QA-Gates wird das Gegenlesen im Pull Request
nachgetragen, sobald eine zweite Person verfügbar ist; blockiert nicht das technische
Inkrafttreten dieses Protokolls).
@@ -8,6 +8,8 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/referenceset"
)
func setupTest(t *testing.T) *pgxpool.Pool {
@@ -267,3 +269,45 @@ func TestDestroy_RequiresPriorRelease(t *testing.T) {
t.Fatalf("erwartet ErrNotReleased, habe: %v", err)
}
}
// TestPruefgate02_LegalHoldResistsDeletionForReferenceSetObjects ist QA-02
// Pflichtpruefung 1, ausgefuehrt gegen den Referenzdatensatz
// (referenceset.RetentionObjects) statt ad-hoc-Fixtures.
func TestPruefgate02_LegalHoldResistsDeletionForReferenceSetObjects(t *testing.T) {
pool := setupTest(t)
ctx := context.Background()
for _, class := range []string{"steuerlich_10j", "handelsrechtlich_6j", "sofort_loeschbar"} {
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_rules (retention_class, duration) VALUES ($1, '1 second')`, class); err != nil {
t.Fatal(err)
}
}
past := time.Now().UTC().Add(-24 * time.Hour)
for _, obj := range referenceset.RetentionObjects() {
var objID string
if err := pool.QueryRow(ctx, `INSERT INTO retention_objects (object_type, object_reference) VALUES ($1, $2) RETURNING id`, obj.ObjectType, obj.ObjectReference).Scan(&objID); err != nil {
t.Fatalf("%s: objekt anlegen: %v", obj.ObjectType, err)
}
if _, err := pool.Exec(ctx, `INSERT INTO retention_class_assignments (retention_object_id, retention_class, assigned_at) VALUES ($1, $2, $3)`, objID, obj.RetentionClass, past); err != nil {
t.Fatalf("%s: klassenzuordnung: %v", obj.ObjectType, err)
}
if err := SetLegalHold(ctx, pool, objID, "qa-02 pruefgate referenzdatensatz", "qa02@acme.example"); err != nil {
t.Fatalf("%s: sperre setzen: %v", obj.ObjectType, err)
}
released, err := ReleaseExpired(ctx, pool, time.Now().UTC())
if err != nil {
t.Fatalf("%s: releaseexpired: %v", obj.ObjectType, err)
}
for _, r := range released {
if r == objID {
t.Fatalf("%s: gesperrtes, faelliges referenzobjekt wurde trotzdem freigegeben", obj.ObjectType)
}
}
if err := Destroy(ctx, pool, objID, "qa02-worker"); !errors.Is(err, ErrOnLegalHold) {
t.Fatalf("%s: erwartet ErrOnLegalHold, habe: %v", obj.ObjectType, err)
}
}
}
@@ -1,8 +1,10 @@
package wormstore
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
@@ -10,6 +12,8 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/referenceset"
)
// wormLockDir liefert ein Basisverzeichnis, in dem chattr +i tatsächlich
@@ -256,3 +260,47 @@ func TestStore_OnlyDestroyCanRemoveObjects(t *testing.T) {
}
}
}
// TestPruefgate02_OverwriteAndTamperDetectionForReferenceSetPayloads ist
// QA-02 Pflichtpruefung 2, ausgefuehrt gegen den Referenzdatensatz
// (referenceset.WormPayload/WormPayloadChecksum) statt ad-hoc-Testbytes,
// ueber alle drei Groessenklassen.
func TestPruefgate02_OverwriteAndTamperDetectionForReferenceSetPayloads(t *testing.T) {
store, _ := setupTest(t)
ctx := context.Background()
for _, size := range []referenceset.WormPayloadSize{referenceset.SizeSmall, referenceset.SizeMedium, referenceset.SizeLarge} {
key := fmt.Sprintf("wormtest_refset_%d", size)
payload := referenceset.WormPayload(size)
wantSum := referenceset.WormPayloadChecksum(size)
sum, written, err := store.Put(ctx, key, bytes.NewReader(payload), time.Now().Add(time.Hour), "test_tenant", "qa02-pruefgate")
if err != nil {
t.Fatalf("groessenklasse %d: put: %v", size, err)
}
if sum != wantSum {
t.Fatalf("groessenklasse %d: pruefsumme weicht vom referenzdatensatz ab: habe %q, will %q", size, sum, wantSum)
}
if written != int64(size) {
t.Fatalf("groessenklasse %d: groesse weicht ab: habe %d, will %d", size, written, int64(size))
}
if _, _, err := store.Put(ctx, key, bytes.NewReader([]byte("manipulierter ueberschreibversuch")), time.Now().Add(time.Hour), "test_tenant", "qa02-pruefgate"); !errors.Is(err, ErrAlreadyExists) {
t.Fatalf("groessenklasse %d: erwartet ErrAlreadyExists beim ueberschreibversuch, habe: %v", size, err)
}
full := store.path(key)
if f, openErr := os.OpenFile(full, os.O_WRONLY, 0); openErr == nil {
_ = f.Close()
t.Fatalf("groessenklasse %d: direkter schreibzugriff haette an der storage-object-lock scheitern muessen", size)
}
data, err := store.GetVerified(ctx, key)
if err != nil {
t.Fatalf("groessenklasse %d: getverified: %v", size, err)
}
if !bytes.Equal(data, payload) {
t.Fatalf("groessenklasse %d: gelesener inhalt weicht vom referenzdatensatz ab", size)
}
}
}