Append-only per Trigger (nicht nur GRANT/REVOKE): audit_events_prevent_mutation() wirft bei jedem UPDATE/DELETE auf audit_events eine Exception, unabhaengig von der verbindenden Rolle (Akzeptanzkriterium 1). internal/audit/four_eyes.go: Vier-Augen-Prinzip fuer sicherheitskritische Entscheidungen (Loeschbestaetigung, Rechtevergabe), 1:1 nach archivdms- Vorbild. Request erzeugt einen Klartext-Code (wird ausserhalb des Systems an eine ZWEITE Person uebermittelt) und speichert nur dessen SHA-256-Hash. Confirm sperrt die Zeile mit FOR UPDATE (Akzeptanzkriterium 2 — serialisiert zwei gleichzeitige Bestaetigungsversuche, verhindert doppelte Ausfuehrung), weist eine Bestaetigung durch dieselbe Person wie die anfordernde ab (ErrSameActor, echtes Vier-Augen-Prinzip statt nur Code-Pruefung), und vergleicht den Code timing-safe (Akzeptanzkriterium 3). internal/audit/timingsafe.go: timingSafeEqual als projektweite Referenz- implementierung (subtle.ConstantTimeCompare) fuer sicherheitsrelevante Vergleiche — andere Module (z.B. Archive CMP-06 Freigabelinks) uebernehmen dasselbe Muster laut IAM-02-Konvention. Nebenbei behoben: AUD-01s eigener Test nutzte einen festen Tenant-Slug mit DELETE-basiertem Cleanup — seit dem neuen Append-only-Trigger schlaegt dieses Cleanup lautlos fehl, wodurch Zeilen sich ueber Testlaeufe hinweg summierten und die Zaehl-Assertion brach. Auf eindeutigen Slug pro Lauf umgestellt (direkte, notwendige Folge dieser Kachel, keine Umgestaltung von AUD-01 selbst). Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Direkter UPDATE/DELETE-Versuch von der Datenbank abgewiesen — TestAppendOnly_RejectsUpdateAndDelete: beide Operationen scheitern, Eintrag bleibt unveraendert erhalten. PASS. 2. Vier-Augen-Prinzip mit FOR-UPDATE-Lock race-frei unter parallelen Anfragen — TestFourEyes_ConcurrentConfirmIsRaceFree: zwei gleichzeitige Bestaetigungsversuche fuer denselben Vorgang, genau einer erfolgreich, der andere ErrAlreadyDecided. PASS. 3. Timing-safe Vergleich per Laufzeitmessung stichprobenartig verifiziert — TestTimingSafeEqual_NoEarlyExitTiming: Mismatch am Anfang (603µs) vs. am Ende (574µs) ueber 20000 Iterationen, kein Hinweis auf Short-Circuit- Vergleich (Ratio innerhalb Faktor 3 Toleranz). PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
121 lines
3.5 KiB
Go
121 lines
3.5 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var (
|
|
ErrConfirmationNotFound = errors.New("audit: bestaetigungsvorgang nicht gefunden")
|
|
ErrAlreadyDecided = errors.New("audit: bestaetigungsvorgang wurde bereits entschieden")
|
|
ErrSameActor = errors.New("audit: bestaetigung muss von einer anderen person als der anfordernden erfolgen")
|
|
ErrInvalidCode = errors.New("audit: bestaetigungscode ungueltig")
|
|
)
|
|
|
|
type ConfirmationStatus string
|
|
|
|
const (
|
|
StatusPending ConfirmationStatus = "pending"
|
|
StatusConfirmed ConfirmationStatus = "confirmed"
|
|
)
|
|
|
|
// FourEyes implementiert das Vier-Augen-Prinzip fuer sicherheitskritische
|
|
// Entscheidungen (Akzeptanzkriterium 2) nach dem archivdms-Vorbild:
|
|
// FOR-UPDATE-Lock gegen Race-Bedingungen bei paralleler Bestaetigung,
|
|
// Timing-safe Vergleich des Bestaetigungscodes (Akzeptanzkriterium 3).
|
|
type FourEyes struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewFourEyes(pool *pgxpool.Pool) *FourEyes {
|
|
return &FourEyes{pool: pool}
|
|
}
|
|
|
|
// Request legt einen neuen, zu bestaetigenden Vorgang an (z.B. Loeschbestaetigung,
|
|
// Rechtevergabe) und liefert einen einmaligen Klartext-Code, der ausserhalb
|
|
// dieses Systems (z.B. per E-Mail) an eine ZWEITE Person uebermittelt wird —
|
|
// niemals der anfordernden Person selbst.
|
|
func (f *FourEyes) Request(ctx context.Context, action, target, requestedBy string) (id, code string, err error) {
|
|
code, err = generateCode()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("bestaetigungscode erzeugen: %w", err)
|
|
}
|
|
hash := hashCode(code)
|
|
|
|
err = f.pool.QueryRow(ctx, `
|
|
INSERT INTO security_confirmations (action, target, requested_by, code_hash, status)
|
|
VALUES ($1, $2, $3, $4, 'pending')
|
|
RETURNING id
|
|
`, action, target, requestedBy, hash).Scan(&id)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("bestaetigungsvorgang anlegen: %w", err)
|
|
}
|
|
return id, code, nil
|
|
}
|
|
|
|
// Confirm bestaetigt einen Vorgang. confirmedBy MUSS sich von der
|
|
// anfordernden Person unterscheiden (echtes Vier-Augen-Prinzip). Der Zugriff
|
|
// auf die Zeile erfolgt mit FOR UPDATE, damit zwei gleichzeitige
|
|
// Bestaetigungsversuche serialisiert werden und niemals beide durchgehen
|
|
// (Akzeptanzkriterium 2 / Pruefung 2).
|
|
func (f *FourEyes) Confirm(ctx context.Context, id, confirmedBy, code string) error {
|
|
tx, err := f.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("transaktion starten: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var requestedBy, status string
|
|
var codeHash []byte
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT requested_by, status, code_hash FROM security_confirmations
|
|
WHERE id = $1 FOR UPDATE
|
|
`, id).Scan(&requestedBy, &status, &codeHash)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrConfirmationNotFound
|
|
}
|
|
return fmt.Errorf("bestaetigungsvorgang lesen: %w", err)
|
|
}
|
|
|
|
if status != string(StatusPending) {
|
|
return ErrAlreadyDecided
|
|
}
|
|
if confirmedBy == requestedBy {
|
|
return ErrSameActor
|
|
}
|
|
if !timingSafeEqual(hashCode(code), codeHash) {
|
|
return ErrInvalidCode
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE security_confirmations
|
|
SET status = 'confirmed', confirmed_by = $2, confirmed_at = now()
|
|
WHERE id = $1
|
|
`, id, confirmedBy); err != nil {
|
|
return fmt.Errorf("bestaetigung speichern: %w", err)
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func generateCode() (string, error) {
|
|
buf := make([]byte, 16)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
func hashCode(code string) []byte {
|
|
sum := sha256.Sum256([]byte(code))
|
|
return sum[:]
|
|
}
|