Compare commits
69 Commits
11cf9083fa
...
v0.40.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3b171f721 | ||
|
|
b0d6b00045 | ||
|
|
fe28278615 | ||
|
|
0fe29f8c80 | ||
|
|
eccf02050b | ||
|
|
8a8295dacd | ||
|
|
690660b655 | ||
|
|
f729e5ae48 | ||
|
|
9e2c7f92ff | ||
|
|
4c60603055 | ||
|
|
bc99858c94 | ||
|
|
3aecbb446c | ||
|
|
08c7721165 | ||
|
|
59e1bcc8f7 | ||
|
|
2e459f1dd1 | ||
|
|
d77b19f61f | ||
|
|
dbeaa45644 | ||
|
|
4358dbaa0d | ||
|
|
7397f70068 | ||
|
|
2736e2c0db | ||
|
|
9d2cb79424 | ||
|
|
4dd9bd88cb | ||
|
|
96ac5d6b59 | ||
|
|
29961f5657 | ||
|
|
3ec57f2786 | ||
|
|
6884f28109 | ||
|
|
b91a58ccba | ||
|
|
eb99891e38 | ||
|
|
36af1bf288 | ||
|
|
5f6502bb41 | ||
|
|
22f748d66c | ||
|
|
d52b325424 | ||
|
|
cff8c9eadf | ||
|
|
aad517ce23 | ||
|
|
d1e80dd19f | ||
|
|
e968cf9761 | ||
|
|
b1b121bb23 | ||
|
|
db9c5b7482 | ||
|
|
344a33805a | ||
|
|
b4d4ee8d3c | ||
|
|
6fd7831784 | ||
|
|
5813e6209c | ||
|
|
790ab20651 | ||
|
|
ae59e745c0 | ||
|
|
8e5d06aafb | ||
|
|
835ad9f0a7 | ||
|
|
c9d71b0d54 | ||
|
|
6df1d2961b | ||
|
|
77de1627c5 | ||
|
|
374f4ade31 | ||
|
|
34b1d8d2a6 | ||
|
|
8f638df387 | ||
|
|
5156fe62da | ||
|
|
2a16dc2200 | ||
|
|
f5ad08cebd | ||
|
|
72005fc326 | ||
|
|
e0218337e7 | ||
|
|
31caa8f66a | ||
|
|
db373e51ce | ||
|
|
917b51299b | ||
|
|
d54636f57a | ||
|
|
15bf277bee | ||
|
|
da91c3e2c1 | ||
|
|
bec34b5988 | ||
|
|
8b08b39725 | ||
|
|
e929e5bf26 | ||
|
|
667343fa81 | ||
|
|
e9e386df85 | ||
|
|
bfe52c73eb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Build output
|
||||
dist/
|
||||
dossiers/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
92
cmd/deklarix/main.go
Normal file
92
cmd/deklarix/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/mail"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
log.Fatal("DATABASE_URL is required")
|
||||
}
|
||||
|
||||
if err := store.Migrate(databaseURL); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
// Migrationen laufen immer über DATABASE_URL (braucht DDL-Rechte,
|
||||
// z. B. CREATE TABLE/ALTER TABLE). Die laufende Anwendung verbindet
|
||||
// sich dagegen möglichst über die eingeschränkte Row-Level-Security-
|
||||
// Rolle "deklarix_app" (siehe Migration 0021) — DATABASE_URL_APP,
|
||||
// falls gesetzt. Ohne DATABASE_URL_APP fällt sie auf DATABASE_URL
|
||||
// zurück (bisheriges Verhalten, Postgres-Superuser umgeht RLS-
|
||||
// Policies dann vollständig — kein Fehler, aber auch keine
|
||||
// zusätzliche Isolation auf DB-Ebene, siehe CLAUDE.md).
|
||||
appDatabaseURL := os.Getenv("DATABASE_URL_APP")
|
||||
if appDatabaseURL == "" {
|
||||
appDatabaseURL = databaseURL
|
||||
}
|
||||
db, err := store.Open(context.Background(), appDatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("open store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rulesDir := os.Getenv("RULES_DIR")
|
||||
if rulesDir == "" {
|
||||
rulesDir = "rules"
|
||||
}
|
||||
rulesFS := os.DirFS(rulesDir)
|
||||
// Beim Start laden und validieren, auch wenn die Auswertungslogik
|
||||
// (Phase 3 der Baureihenfolge) sie noch nicht konsumiert — ein
|
||||
// kaputtes Regelwerk soll den Dienst nicht erst beim ersten Antrag
|
||||
// zum Absturz bringen, sondern gar nicht erst starten lassen.
|
||||
datenklasse, err := rules.LoadDatenklasse(rulesFS, "datenklasse.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("load datenklasse-regelwerk from %s: %v", rulesDir, err)
|
||||
}
|
||||
einstufung, err := rules.LoadEinstufung(rulesFS, "kivo_einstufung.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("load ki-vo-einstufung-regelwerk from %s: %v", rulesDir, err)
|
||||
}
|
||||
anforderungen, err := rules.LoadAnforderungen(rulesFS, "anforderungen.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("load anforderungsprofil-regelwerk from %s: %v", rulesDir, err)
|
||||
}
|
||||
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
||||
|
||||
mailer := mail.NewSMTPMailer(mail.Config{
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: os.Getenv("SMTP_PORT"),
|
||||
User: os.Getenv("SMTP_USER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: os.Getenv("SMTP_FROM"),
|
||||
})
|
||||
|
||||
server, err := web.NewServer(db, web.Regelwerk{
|
||||
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||
}, mailer)
|
||||
if err != nil {
|
||||
log.Fatalf("web server: %v", err)
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
addr := ":" + port
|
||||
|
||||
log.Printf("Deklarix server starting on %s", addr)
|
||||
if err := http.ListenAndServe(addr, server); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
addr := ":" + port
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"ok":true}`)
|
||||
})
|
||||
|
||||
log.Printf("Deklarix server starting on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
21
go.mod
21
go.mod
@@ -1,3 +1,24 @@
|
||||
module github.com/netcell-it/deklarix
|
||||
|
||||
go 1.26.6
|
||||
|
||||
require (
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
|
||||
github.com/go-pdf/fpdf v0.9.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
100
go.sum
Normal file
100
go.sum
Normal file
@@ -0,0 +1,100 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
|
||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
56
internal/auth/auth.go
Normal file
56
internal/auth/auth.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Package auth enthält die reine Logik für Anmeldung: Passwort-Hashing,
|
||||
// Session-Token-Erzeugung. Es fasst keine Datenbank an — das macht
|
||||
// internal/store (account, app_user, session), damit Persistenz an
|
||||
// einer Stelle im Projekt gebündelt bleibt.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// MinPasswordLength ist die einzige Passwort-Regel, die wir prüfen —
|
||||
// keine erzwungenen Sonderzeichen/Ziffern-Vorgaben, die Nutzer nur zu
|
||||
// vorhersehbaren Mustern verleiten.
|
||||
const MinPasswordLength = 8
|
||||
|
||||
// SessionDuration ist die Gültigkeitsdauer einer neu erstellten Sitzung.
|
||||
const SessionDuration = 30 * 24 * time.Hour
|
||||
|
||||
// HashPassword hasht ein Klartext-Passwort für die Speicherung. Lehnt
|
||||
// zu kurze Passwörter ab, statt sie stillschweigend zu hashen.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < MinPasswordLength {
|
||||
return "", fmt.Errorf("auth: passwort muss mindestens %d Zeichen haben", MinPasswordLength)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: passwort hashen: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword prüft ein Klartext-Passwort gegen einen gespeicherten
|
||||
// Hash. Ein Fehler bedeutet: falsches Passwort oder ungültiger Hash —
|
||||
// beides führt zu "Anmeldung abgelehnt", nie zu einem Unterschied, den
|
||||
// ein Angreifer für User-Enumeration nutzen könnte.
|
||||
func VerifyPassword(hash, password string) error {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return fmt.Errorf("auth: passwort ungültig: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSessionToken erzeugt ein kryptographisch zufälliges Session-Token
|
||||
// (32 Byte, hex-kodiert).
|
||||
func NewSessionToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("auth: token generieren: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
50
internal/auth/auth_test.go
Normal file
50
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
func TestHashPasswordRejectsShortPassword(t *testing.T) {
|
||||
if _, err := auth.HashPassword("kurz"); err == nil {
|
||||
t.Fatal("expected error for a too-short password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAndVerifyRoundTrip(t *testing.T) {
|
||||
hash, err := auth.HashPassword("ein-langes-passwort")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if err := auth.VerifyPassword(hash, "ein-langes-passwort"); err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPasswordRejectsWrongPassword(t *testing.T) {
|
||||
hash, err := auth.HashPassword("ein-langes-passwort")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if err := auth.VerifyPassword(hash, "falsches-passwort"); err == nil {
|
||||
t.Fatal("expected error for a wrong password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionTokenIsRandomAndCorrectLength(t *testing.T) {
|
||||
a, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionToken: %v", err)
|
||||
}
|
||||
b, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionToken: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("expected two distinct tokens, got the same value twice")
|
||||
}
|
||||
if len(a) != 64 {
|
||||
t.Fatalf("token length = %d, want 64 (hex of 32 random bytes)", len(a))
|
||||
}
|
||||
}
|
||||
78
internal/mail/mail.go
Normal file
78
internal/mail/mail.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Package mail versendet E-Mails (aktuell: Passwort-Zurücksetzen-Links)
|
||||
// über einen konfigurierten SMTP-Server. Reine Versandlogik, kein
|
||||
// Template-Rendering — der Aufrufer (internal/web) baut Betreff/Text
|
||||
// selbst zusammen, damit dieses Paket unabhängig vom Web-Layer bleibt.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
// Mailer ist die Schnittstelle, die internal/web nutzt — austauschbar
|
||||
// gegen eine Test-Doppel (siehe FakeMailer), damit Tests keinen echten
|
||||
// SMTP-Server brauchen.
|
||||
type Mailer interface {
|
||||
Send(to, subject, body string) error
|
||||
}
|
||||
|
||||
// Config sind die SMTP-Zugangsdaten, aus Umgebungsvariablen gelesen
|
||||
// (siehe cmd/deklarix/main.go) — kein neues Geheimnis im Code.
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
// SMTPMailer versendet über einen echten SMTP-Server (STARTTLS, falls
|
||||
// vom Server angeboten — das deckt den Standard-Submission-Port 587 ab;
|
||||
// implizites TLS auf Port 465 wird von net/smtp nicht unterstützt).
|
||||
type SMTPMailer struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func NewSMTPMailer(cfg Config) *SMTPMailer {
|
||||
return &SMTPMailer{cfg: cfg}
|
||||
}
|
||||
|
||||
// Send verschickt eine reine Text-E-Mail. Liefert einen Fehler, wenn
|
||||
// kein SMTP-Host konfiguriert ist — bewusst kein stiller No-op, ein
|
||||
// fehlgeschlagener Versand (z. B. Passwort-Reset) muss sichtbar
|
||||
// scheitern statt eine E-Mail vorzutäuschen, die nie ankommt.
|
||||
func (m *SMTPMailer) Send(to, subject, body string) error {
|
||||
if m.cfg.Host == "" {
|
||||
return fmt.Errorf("mail: SMTP nicht konfiguriert (SMTP_HOST fehlt)")
|
||||
}
|
||||
addr := m.cfg.Host + ":" + m.cfg.Port
|
||||
msg := fmt.Sprintf(
|
||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||
m.cfg.From, to, subject, body,
|
||||
)
|
||||
|
||||
var auth smtp.Auth
|
||||
if m.cfg.User != "" {
|
||||
auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
|
||||
}
|
||||
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg)); err != nil {
|
||||
return fmt.Errorf("mail: senden an %s: %w", to, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SentMail protokolliert einen Versand für Tests.
|
||||
type SentMail struct {
|
||||
To, Subject, Body string
|
||||
}
|
||||
|
||||
// FakeMailer ist das Test-Doppel für Mailer — sammelt gesendete Mails
|
||||
// statt sie zu verschicken.
|
||||
type FakeMailer struct {
|
||||
Sent []SentMail
|
||||
}
|
||||
|
||||
func (f *FakeMailer) Send(to, subject, body string) error {
|
||||
f.Sent = append(f.Sent, SentMail{To: to, Subject: subject, Body: body})
|
||||
return nil
|
||||
}
|
||||
317
internal/rules/evaluate.go
Normal file
317
internal/rules/evaluate.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Antworten ist die im Antrag gespeicherte Fragebogen-Antwort-Menge —
|
||||
// dieselbe Struktur, die internal/web/antrag_handlers.go erzeugt
|
||||
// (antwortenFromForm): ein flaches JSON-Objekt mit denselben
|
||||
// Fakten-Schlüsseln wie in den Regelwerk-YAML-Dateien (b1..b7, c1..c5,
|
||||
// c2_folge, c3_art).
|
||||
type Antworten map[string]any
|
||||
|
||||
// ParseAntworten liest antrag.antworten (rohes JSON aus der Datenbank).
|
||||
func ParseAntworten(raw []byte) (Antworten, error) {
|
||||
if len(raw) == 0 {
|
||||
return Antworten{}, nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("rules: antworten parsen: %w", err)
|
||||
}
|
||||
return Antworten(m), nil
|
||||
}
|
||||
|
||||
// istJaOderUnsicher prüft eine B-Frage. "Unsicher zählt wie Ja" ist eine
|
||||
// Auswertungsregel (siehe CLAUDE.md, Fragebogen-Abschnitt B) — deshalb
|
||||
// hier und nicht schon beim Speichern des Antrags angewendet.
|
||||
func (a Antworten) istJaOderUnsicher(key string) bool {
|
||||
v, _ := a[key].(string)
|
||||
return v == "ja" || v == "unsicher"
|
||||
}
|
||||
|
||||
// DatenklasseErgebnis ist die abgeleitete Datenklasse mit Herleitung —
|
||||
// "Jede Anforderung/Ableitung trägt ihre Herleitung, sonst ist das
|
||||
// Ergebnis im Audit wertlos" (siehe CLAUDE.md).
|
||||
type DatenklasseErgebnis struct {
|
||||
ID string
|
||||
Herleitung string
|
||||
}
|
||||
|
||||
// EvaluateDatenklasse leitet die Datenklasse aus den B-Antworten ab.
|
||||
// "Höchste zutreffende Stufe gewinnt" (höherer Rang gewinnt). Trifft
|
||||
// keine Stufe zu (z. B. wenn versehentlich auch B7 mit "nein"
|
||||
// beantwortet wurde, obwohl keine andere Kategorie zutrifft — ein
|
||||
// eigentlich widersprüchlicher Fragebogen-Zustand), wird konservativ
|
||||
// "intern" angenommen statt "oeffentlich": im Zweifel mehr Schutz, nicht
|
||||
// weniger. Siehe rules/OPEN.md, Punkt 5.
|
||||
func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) DatenklasseErgebnis {
|
||||
const fallback = "intern"
|
||||
var gewinner *DatenklasseStufe
|
||||
var treffer []string
|
||||
for i := range regelwerk.Stufen {
|
||||
st := ®elwerk.Stufen[i]
|
||||
var stTreffer []string
|
||||
for _, ausloeser := range st.Ausloeser {
|
||||
if antworten.istJaOderUnsicher(ausloeser) {
|
||||
stTreffer = append(stTreffer, ausloeser)
|
||||
}
|
||||
}
|
||||
if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) {
|
||||
gewinner = st
|
||||
treffer = stTreffer
|
||||
}
|
||||
}
|
||||
if gewinner == nil {
|
||||
return DatenklasseErgebnis{ID: fallback, Herleitung: "keine Kategorie aus Abschnitt B traf eindeutig zu (konservativer Standardwert)"}
|
||||
}
|
||||
return DatenklasseErgebnis{ID: gewinner.ID, Herleitung: "ausgelöst durch " + strings.Join(treffer, ", ")}
|
||||
}
|
||||
|
||||
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
|
||||
// den Antworten passen (UND-Verknüpfung innerhalb der Variante).
|
||||
func variantePasst(variante EinstufungVariante, antworten Antworten) bool {
|
||||
for key, want := range variante {
|
||||
got, ok := antworten[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch wantVal := want.(type) {
|
||||
case bool:
|
||||
gotBool, ok := got.(bool)
|
||||
if !ok || gotBool != wantVal {
|
||||
return false
|
||||
}
|
||||
case string:
|
||||
gotStr, ok := got.(string)
|
||||
if !ok || gotStr != wantVal {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// EinstufungErgebnis ist die abgeleitete KI-VO-Einstufung mit Herleitung.
|
||||
type EinstufungErgebnis struct {
|
||||
ID string
|
||||
Quelle string // z. B. "Art. 5 KI-VO", leer wenn die Stufe keine Quelle nennt
|
||||
Herleitung string
|
||||
}
|
||||
|
||||
// formatVariante liefert eine deterministische, lesbare Darstellung
|
||||
// einer zutreffenden Variante, z. B. "c2=true, c2_folge=kreditwuerdigkeit".
|
||||
func formatVariante(v EinstufungVariante) string {
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", k, v[k]))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// EvaluateEinstufung leitet die KI-VO-Einstufung aus den C-Antworten ab.
|
||||
// Prüfreihenfolge: die erste zutreffende Stufe gewinnt (siehe
|
||||
// Bewertungslogik, K.-o.-Prüfung) — deshalb steht "verboten" in
|
||||
// rules/kivo_einstufung.yaml an erster Stelle. Liefert einen Fehler nur,
|
||||
// wenn das Regelwerk selbst keine Auffangregel definiert (sollte durch
|
||||
// LoadEinstufung bereits verhindert sein).
|
||||
func EvaluateEinstufung(regelwerk EinstufungRegelwerk, antworten Antworten) (EinstufungErgebnis, error) {
|
||||
for _, st := range regelwerk.Stufen {
|
||||
if len(st.Varianten) == 0 {
|
||||
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: "Auffangregel (keine speziellere Stufe traf zu)"}, nil
|
||||
}
|
||||
for _, variante := range st.Varianten {
|
||||
if variantePasst(variante, antworten) {
|
||||
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: formatVariante(variante)}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return EinstufungErgebnis{}, fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
|
||||
}
|
||||
|
||||
// IstVerboten prüft die K.-o.-Bedingung (Art. 5 KI-VO): bei "verboten"
|
||||
// erfolgt sofortige Ablehnung, keine Werkzeugsuche.
|
||||
func IstVerboten(einstufungID string) bool {
|
||||
return einstufungID == "verboten"
|
||||
}
|
||||
|
||||
// AnforderungErgebnis ist eine abgeleitete Anforderung mit Herleitung.
|
||||
type AnforderungErgebnis struct {
|
||||
ID string
|
||||
Beschreibung string
|
||||
Herleitung string // z. B. "aus Datenklasse personenbezogen"
|
||||
}
|
||||
|
||||
// DeriveAnforderungen leitet aus Datenklasse und Einstufung die Menge
|
||||
// der Anforderungen ab, die ein Werkzeug erfüllen muss.
|
||||
func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []AnforderungErgebnis {
|
||||
var out []AnforderungErgebnis
|
||||
for _, a := range regelwerk.Anforderungen {
|
||||
var gruende []string
|
||||
if containsString(a.AusDatenklassen, datenklasseID) {
|
||||
gruende = append(gruende, "Datenklasse "+datenklasseID)
|
||||
}
|
||||
if containsString(a.AusEinstufungen, einstufungID) {
|
||||
gruende = append(gruende, "Einstufung "+einstufungID)
|
||||
}
|
||||
if len(gruende) > 0 {
|
||||
out = append(out, AnforderungErgebnis{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: "aus " + strings.Join(gruende, " und ")})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsString(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WerkzeugEigenschaften sind die für die harte Filterung relevanten
|
||||
// Felder eines Katalogeintrags — bewusst ein eigener, schlanker Typ
|
||||
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
|
||||
// unabhängig von internal/store und für sich allein testbar.
|
||||
type WerkzeugEigenschaften struct {
|
||||
AVVVerfuegbar bool
|
||||
// Verarbeitungslaender nennt die tatsächlichen Länder statt eines
|
||||
// groben "EU"/"USA"-Eimers (siehe CLAUDE.md, Werkzeugkatalog) — die
|
||||
// eu_verarbeitung-Anforderung gilt nur als erfüllt, wenn ALLE
|
||||
// genannten Länder EU/EWR-Mitgliedstaaten sind.
|
||||
Verarbeitungslaender []string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
// AufbewahrungTage ist nil, wenn der Anbieter keine Aufbewahrungsdauer
|
||||
// beziffert — das erfüllt eine gesetzte Löschfrist-Anforderung NICHT
|
||||
// (fail closed), analog zur leeren Länderliste bei eu_verarbeitung.
|
||||
AufbewahrungTage *int
|
||||
}
|
||||
|
||||
// euEwrLaender sind die Staaten, für die eine Verarbeitung nicht als
|
||||
// Drittlandtransfer gilt (Art. 44 ff. DSGVO) — die 27 EU-Mitgliedstaaten
|
||||
// plus die drei über den EWR-Vertrag gleichgestellten Staaten. Die USA
|
||||
// gehören bewusst NICHT dazu — sie sind ein Drittland wie jedes andere,
|
||||
// unabhängig vom EU-US Data Privacy Framework (das mildert die
|
||||
// Transfer-Grundlage, macht die USA aber nicht zum EU/EWR-Gebiet).
|
||||
var euEwrLaender = map[string]bool{
|
||||
"Belgien": true, "Bulgarien": true, "Dänemark": true, "Deutschland": true,
|
||||
"Estland": true, "Finnland": true, "Frankreich": true, "Griechenland": true,
|
||||
"Irland": true, "Italien": true, "Kroatien": true, "Lettland": true,
|
||||
"Litauen": true, "Luxemburg": true, "Malta": true, "Niederlande": true,
|
||||
"Österreich": true, "Polen": true, "Portugal": true, "Rumänien": true,
|
||||
"Schweden": true, "Slowakei": true, "Slowenien": true, "Spanien": true,
|
||||
"Tschechien": true, "Ungarn": true, "Zypern": true,
|
||||
"Island": true, "Liechtenstein": true, "Norwegen": true,
|
||||
}
|
||||
|
||||
// alleLaenderInEUEWR prüft, ob jedes genannte Land EU/EWR ist. Eine
|
||||
// leere Liste (kein Land benannt) gilt als NICHT erfüllt — "wissen wir
|
||||
// nicht" darf nie stillschweigend als "ist okay" durchgehen.
|
||||
func alleLaenderInEUEWR(laender []string) bool {
|
||||
if len(laender) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, l := range laender {
|
||||
if !euEwrLaender[l] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
||||
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
||||
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
||||
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
||||
// Werkzeug selbst.
|
||||
//
|
||||
// loeschfristMaxTage ist die vom Mandanten für die aktuelle Datenklasse
|
||||
// konfigurierte Frist (siehe store.LoeschfristEinstellung) — nil
|
||||
// bedeutet "für diesen Mandanten/diese Datenklasse nicht konfiguriert"
|
||||
// und wird NICHT hart gefiltert (Rückwärtskompatibilität, siehe
|
||||
// rules/OPEN.md, Punkt 4); ist eine Frist gesetzt, erfüllt ein Werkzeug
|
||||
// ohne bezifferte Aufbewahrungsdauer sie NICHT (fail closed).
|
||||
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften, loeschfristMaxTage *int) bool {
|
||||
switch anforderungID {
|
||||
case "avv_erforderlich":
|
||||
return w.AVVVerfuegbar
|
||||
case "eu_verarbeitung":
|
||||
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
||||
case "kein_training_auf_eingabe":
|
||||
return w.TrainingStandard
|
||||
case "loeschfrist_max_tage":
|
||||
if loeschfristMaxTage == nil {
|
||||
return true
|
||||
}
|
||||
if w.AufbewahrungTage == nil {
|
||||
return false
|
||||
}
|
||||
return *w.AufbewahrungTage <= *loeschfristMaxTage
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// AusschlussGrund hält fest, warum ein Werkzeug aussortiert wurde —
|
||||
// auch aussortierte Werkzeuge werden im Ergebnis gezeigt (siehe
|
||||
// Bewertungslogik), nie stillschweigend weggelassen.
|
||||
type AusschlussGrund struct {
|
||||
WerkzeugID string
|
||||
NichtErfuellt []string
|
||||
}
|
||||
|
||||
// WerkzeugKandidat ist ein Katalogeintrag im harten Filter.
|
||||
type WerkzeugKandidat struct {
|
||||
ID string
|
||||
Eigenschaften WerkzeugEigenschaften
|
||||
}
|
||||
|
||||
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
||||
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
||||
// Werkzeug fest, welche Anforderungen fehlten. loeschfristMaxTage siehe
|
||||
// ErfuelltAnforderung.
|
||||
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string, loeschfristMaxTage *int) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||
for _, k := range kandidaten {
|
||||
var fehlend []string
|
||||
for _, reqID := range anforderungIDs {
|
||||
if !ErfuelltAnforderung(reqID, k.Eigenschaften, loeschfristMaxTage) {
|
||||
fehlend = append(fehlend, reqID)
|
||||
}
|
||||
}
|
||||
if len(fehlend) == 0 {
|
||||
zulaessig = append(zulaessig, k.ID)
|
||||
} else {
|
||||
ausgeschlossen = append(ausgeschlossen, AusschlussGrund{WerkzeugID: k.ID, NichtErfuellt: fehlend})
|
||||
}
|
||||
}
|
||||
return zulaessig, ausgeschlossen
|
||||
}
|
||||
|
||||
// String liefert eine lesbare Begründung, z. B. für die
|
||||
// Ergebnisdarstellung.
|
||||
func (a AusschlussGrund) String() string {
|
||||
return fmt.Sprintf("%s: erfüllt nicht %s", a.WerkzeugID, strings.Join(a.NichtErfuellt, ", "))
|
||||
}
|
||||
|
||||
// RegelwerkVersion liefert eine reproduzierbare Kennung des geladenen
|
||||
// Regelwerk-Stands — wird mit jeder Bewertung eingefroren, damit im
|
||||
// Audit nachvollziehbar bleibt, mit welcher Regelwerk-Version ein
|
||||
// Vorschlag erzeugt wurde (analog zu store.CurrentKatalogVersion für
|
||||
// den Werkzeugkatalog).
|
||||
func RegelwerkVersion(dk DatenklasseRegelwerk, ei EinstufungRegelwerk, an AnforderungsRegelwerk) string {
|
||||
return fmt.Sprintf("dk%d.ei%d.an%d", dk.Version, ei.Version, an.Version)
|
||||
}
|
||||
247
internal/rules/evaluate_test.go
Normal file
247
internal/rules/evaluate_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func loadRealRegelwerke(t *testing.T) (rules.DatenklasseRegelwerk, rules.EinstufungRegelwerk, rules.AnforderungsRegelwerk) {
|
||||
t.Helper()
|
||||
fsys := realRulesFS(t)
|
||||
dk, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDatenklasse: %v", err)
|
||||
}
|
||||
ei, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEinstufung: %v", err)
|
||||
}
|
||||
an, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAnforderungen: %v", err)
|
||||
}
|
||||
return dk, ei, an
|
||||
}
|
||||
|
||||
func TestEvaluateDatenklasseOnlyOeffentlich(t *testing.T) {
|
||||
dk, _, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "ja"}
|
||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||
if got.ID != "oeffentlich" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want oeffentlich", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDatenklasseHighestWins(t *testing.T) {
|
||||
dk, _, _ := loadRealRegelwerke(t)
|
||||
// b1 (personenbezogen) UND b2 (besondere_kategorie) beide ja ->
|
||||
// besondere_kategorie hat den hoeheren Rang und muss gewinnen.
|
||||
antworten := rules.Antworten{"b1": "ja", "b2": "ja", "b7": "nein"}
|
||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||
if got.ID != "besondere_kategorie" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want besondere_kategorie", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDatenklasseUnsicherZaehltWieJa(t *testing.T) {
|
||||
dk, _, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"b3": "unsicher"}
|
||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||
if got.ID != "berufsgeheimnis" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateDatenklasseFallsBackToInternWhenNothingMatches(t *testing.T) {
|
||||
dk, _, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "nein"}
|
||||
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||
if got.ID != "intern" {
|
||||
t.Fatalf("EvaluateDatenklasse.ID = %q, want intern (konservativer Fallback)", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEinstufungVerboten(t *testing.T) {
|
||||
_, ei, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"c3": true, "c3_art": "social_scoring"}
|
||||
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got.ID != "verboten" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want verboten", got.ID)
|
||||
}
|
||||
if !rules.IstVerboten(got.ID) {
|
||||
t.Error("IstVerboten sollte true liefern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEinstufungHochrisiko(t *testing.T) {
|
||||
_, ei, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"c2": true, "c2_folge": "kreditwuerdigkeit", "c3": false}
|
||||
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got.ID != "hochrisiko" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want hochrisiko", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEinstufungTransparenzpflicht(t *testing.T) {
|
||||
_, ei, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"c1": true, "c2": false, "c3": false, "c5": false}
|
||||
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got.ID != "transparenzpflicht" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want transparenzpflicht", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEinstufungMinimalFallback(t *testing.T) {
|
||||
_, ei, _ := loadRealRegelwerke(t)
|
||||
antworten := rules.Antworten{"c1": false, "c2": false, "c3": false, "c4": false, "c5": true}
|
||||
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||
if err != nil {
|
||||
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||
}
|
||||
if got.ID != "minimal" {
|
||||
t.Fatalf("EvaluateEinstufung.ID = %q, want minimal", got.ID)
|
||||
}
|
||||
if rules.IstVerboten(got.ID) {
|
||||
t.Error("IstVerboten sollte fuer minimal false liefern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAnforderungenPersonenbezogen(t *testing.T) {
|
||||
_, _, an := loadRealRegelwerke(t)
|
||||
got := rules.DeriveAnforderungen(an, "personenbezogen", "minimal")
|
||||
want := map[string]bool{"avv_erforderlich": true, "kein_training_auf_eingabe": true, "loeschfrist_max_tage": true}
|
||||
gotSet := map[string]bool{}
|
||||
for _, a := range got {
|
||||
gotSet[a.ID] = true
|
||||
if a.Herleitung == "" {
|
||||
t.Errorf("Anforderung %q hat keine Herleitung", a.ID)
|
||||
}
|
||||
}
|
||||
for id := range want {
|
||||
if !gotSet[id] {
|
||||
t.Errorf("expected Anforderung %q for personenbezogen/minimal, got %v", id, got)
|
||||
}
|
||||
}
|
||||
if gotSet["eu_verarbeitung"] {
|
||||
t.Error("eu_verarbeitung sollte fuer personenbezogen (ohne berufsgeheimnis/besondere_kategorie) nicht ausgeloest werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
||||
_, _, an := loadRealRegelwerke(t)
|
||||
got := rules.DeriveAnforderungen(an, "oeffentlich", "hochrisiko")
|
||||
gotSet := map[string]bool{}
|
||||
for _, a := range got {
|
||||
gotSet[a.ID] = true
|
||||
}
|
||||
if !gotSet["menschliche_aufsicht"] || !gotSet["dsfa_erforderlich"] {
|
||||
t.Errorf("expected menschliche_aufsicht und dsfa_erforderlich fuer hochrisiko, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungAVV(t *testing.T) {
|
||||
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt ohne AVV")
|
||||
}
|
||||
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}, nil) {
|
||||
t.Error("erwartet: erfuellt mit AVV")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
|
||||
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}, nil) {
|
||||
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
|
||||
}
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt bei USA - die USA sind ein Drittland wie jedes andere")
|
||||
}
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland", "USA"}}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
|
||||
}
|
||||
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}, nil) {
|
||||
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungLoeschfrist(t *testing.T) {
|
||||
dreissig := 30
|
||||
neunzig := 90
|
||||
// Keine Frist konfiguriert -> nicht hart gefiltert (Rückwärtskompatibilität).
|
||||
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, nil) {
|
||||
t.Error("erwartet: erfuellt ohne konfigurierte Frist")
|
||||
}
|
||||
// Frist gesetzt, Werkzeug bleibt innerhalb -> erfuellt.
|
||||
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &dreissig}, &neunzig) {
|
||||
t.Error("erwartet: erfuellt, wenn Aufbewahrung <= Frist")
|
||||
}
|
||||
// Frist gesetzt, Werkzeug überschreitet -> nicht erfuellt.
|
||||
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &neunzig}, &dreissig) {
|
||||
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrung > Frist")
|
||||
}
|
||||
// Frist gesetzt, Werkzeug beziffert Aufbewahrung nicht -> fail closed.
|
||||
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, &dreissig) {
|
||||
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrungsdauer unbekannt ist ('wissen wir nicht' darf nicht als 'ist okay' durchgehen)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}, nil) {
|
||||
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||
kandidaten := []rules.WerkzeugKandidat{
|
||||
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||
}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"}, nil)
|
||||
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
||||
}
|
||||
if len(ausgeschlossen) != 1 || ausgeschlossen[0].WerkzeugID != "ohne-avv" {
|
||||
t.Fatalf("ausgeschlossen = %v, want genau ohne-avv", ausgeschlossen)
|
||||
}
|
||||
if len(ausgeschlossen[0].NichtErfuellt) != 1 || ausgeschlossen[0].NichtErfuellt[0] != "avv_erforderlich" {
|
||||
t.Fatalf("NichtErfuellt = %v, want [avv_erforderlich]", ausgeschlossen[0].NichtErfuellt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
||||
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil, nil)
|
||||
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
||||
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAntwortenRoundTrip(t *testing.T) {
|
||||
raw := []byte(`{"b1":"ja","c2":true,"c2_folge":"bildung"}`)
|
||||
a, err := rules.ParseAntworten(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAntworten: %v", err)
|
||||
}
|
||||
if a["b1"] != "ja" || a["c2_folge"] != "bildung" {
|
||||
t.Fatalf("ParseAntworten = %+v, unerwartete Werte", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAntwortenEmpty(t *testing.T) {
|
||||
a, err := rules.ParseAntworten(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAntworten: %v", err)
|
||||
}
|
||||
if len(a) != 0 {
|
||||
t.Fatalf("ParseAntworten(nil) = %+v, want empty", a)
|
||||
}
|
||||
}
|
||||
111
internal/rules/loader.go
Normal file
111
internal/rules/loader.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LoadDatenklasse lädt und validiert die Datenklasse-Ableitungstabelle.
|
||||
func LoadDatenklasse(fsys fs.FS, path string) (DatenklasseRegelwerk, error) {
|
||||
var rw DatenklasseRegelwerk
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse lesen: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse parsen: %w", err)
|
||||
}
|
||||
if rw.Version < 1 {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: version fehlt oder ungültig")
|
||||
}
|
||||
if len(rw.Stufen) == 0 {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: keine stufen definiert")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
seenRaenge := map[int]string{}
|
||||
for _, st := range rw.Stufen {
|
||||
if st.ID == "" {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe ohne id")
|
||||
}
|
||||
if seenIDs[st.ID] {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: doppelte stufe-id %q", st.ID)
|
||||
}
|
||||
seenIDs[st.ID] = true
|
||||
if other, ok := seenRaenge[st.Rang]; ok {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufen %q und %q teilen sich rang %d — \"höchste Stufe gewinnt\" braucht eindeutige Ränge", st.ID, other, st.Rang)
|
||||
}
|
||||
seenRaenge[st.Rang] = st.ID
|
||||
if len(st.Ausloeser) == 0 {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe %q hat keine ausloeser", st.ID)
|
||||
}
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
// LoadEinstufung lädt und validiert die KI-VO-Einstufungstabelle.
|
||||
func LoadEinstufung(fsys fs.FS, path string) (EinstufungRegelwerk, error) {
|
||||
var rw EinstufungRegelwerk
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung lesen: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung parsen: %w", err)
|
||||
}
|
||||
if rw.Version < 1 {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: version fehlt oder ungültig")
|
||||
}
|
||||
if len(rw.Stufen) == 0 {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: keine stufen definiert")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for i, st := range rw.Stufen {
|
||||
if st.ID == "" {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe ohne id")
|
||||
}
|
||||
if seenIDs[st.ID] {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: doppelte stufe-id %q", st.ID)
|
||||
}
|
||||
seenIDs[st.ID] = true
|
||||
// Die letzte Stufe darf eine Auffangregel ohne Bedingungen sein
|
||||
// (z. B. "minimal"), alle davor brauchen mindestens eine Variante.
|
||||
if len(st.Varianten) == 0 && i != len(rw.Stufen)-1 {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe %q hat keine varianten (nur die letzte Stufe darf eine Auffangregel sein)", st.ID)
|
||||
}
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
// LoadAnforderungen lädt und validiert die Anforderungsprofil-Tabelle.
|
||||
func LoadAnforderungen(fsys fs.FS, path string) (AnforderungsRegelwerk, error) {
|
||||
var rw AnforderungsRegelwerk
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen lesen: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen parsen: %w", err)
|
||||
}
|
||||
if rw.Version < 1 {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: version fehlt oder ungültig")
|
||||
}
|
||||
if len(rw.Anforderungen) == 0 {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: keine anforderungen definiert")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for _, a := range rw.Anforderungen {
|
||||
if a.ID == "" {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung ohne id")
|
||||
}
|
||||
if seenIDs[a.ID] {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: doppelte anforderung-id %q", a.ID)
|
||||
}
|
||||
seenIDs[a.ID] = true
|
||||
if len(a.AusDatenklassen) == 0 && len(a.AusEinstufungen) == 0 {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung %q hat weder aus_datenklassen noch aus_einstufungen", a.ID)
|
||||
}
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
132
internal/rules/loader_test.go
Normal file
132
internal/rules/loader_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func realRulesFS(t *testing.T) fs.FS {
|
||||
t.Helper()
|
||||
return os.DirFS("../../rules")
|
||||
}
|
||||
|
||||
func TestLoadDatenklasseRealFile(t *testing.T) {
|
||||
rw, err := rules.LoadDatenklasse(realRulesFS(t), "datenklasse.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDatenklasse: %v", err)
|
||||
}
|
||||
if rw.Version != 1 {
|
||||
t.Fatalf("Version = %d, want 1", rw.Version)
|
||||
}
|
||||
if len(rw.Stufen) != 6 {
|
||||
t.Fatalf("expected 6 Stufen, got %d", len(rw.Stufen))
|
||||
}
|
||||
byID := map[string]rules.DatenklasseStufe{}
|
||||
for _, st := range rw.Stufen {
|
||||
byID[st.ID] = st
|
||||
}
|
||||
for _, id := range []string{"oeffentlich", "intern", "auftragsdaten", "personenbezogen", "berufsgeheimnis", "besondere_kategorie"} {
|
||||
if _, ok := byID[id]; !ok {
|
||||
t.Errorf("expected Stufe %q to be defined", id)
|
||||
}
|
||||
}
|
||||
if byID["besondere_kategorie"].Rang <= byID["personenbezogen"].Rang {
|
||||
t.Error("expected besondere_kategorie to outrank personenbezogen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatenklasseRejectsDuplicateRang(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
|
||||
version: 1
|
||||
stufen:
|
||||
- {id: a, rang: 1, ausloeser: [b1]}
|
||||
- {id: b, rang: 1, ausloeser: [b2]}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
|
||||
t.Fatal("expected an error for duplicate rang values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatenklasseRejectsMissingVersion(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
|
||||
stufen:
|
||||
- {id: a, rang: 1, ausloeser: [b1]}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
|
||||
t.Fatal("expected an error for a missing version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEinstufungRealFile(t *testing.T) {
|
||||
rw, err := rules.LoadEinstufung(realRulesFS(t), "kivo_einstufung.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEinstufung: %v", err)
|
||||
}
|
||||
if len(rw.Stufen) != 4 {
|
||||
t.Fatalf("expected 4 Stufen, got %d", len(rw.Stufen))
|
||||
}
|
||||
if rw.Stufen[0].ID != "verboten" {
|
||||
t.Fatalf("expected 'verboten' to be checked first (K.-o.-Prüfung), got %q", rw.Stufen[0].ID)
|
||||
}
|
||||
if rw.Stufen[len(rw.Stufen)-1].ID != "minimal" {
|
||||
t.Fatalf("expected 'minimal' as the last (catch-all) Stufe, got %q", rw.Stufen[len(rw.Stufen)-1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEinstufungRejectsNonLastStufeWithoutVarianten(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"kivo_einstufung.yaml": &fstest.MapFile{Data: []byte(`
|
||||
version: 1
|
||||
stufen:
|
||||
- {id: verboten, varianten: []}
|
||||
- {id: minimal, varianten: []}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml"); err == nil {
|
||||
t.Fatal("expected an error when a non-last Stufe has no Varianten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAnforderungenRealFile(t *testing.T) {
|
||||
rw, err := rules.LoadAnforderungen(realRulesFS(t), "anforderungen.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAnforderungen: %v", err)
|
||||
}
|
||||
wantIDs := []string{
|
||||
"avv_erforderlich", "eu_verarbeitung", "kein_training_auf_eingabe",
|
||||
"loeschfrist_max_tage", "menschliche_aufsicht", "kennzeichnungspflicht", "dsfa_erforderlich",
|
||||
}
|
||||
if len(rw.Anforderungen) != len(wantIDs) {
|
||||
t.Fatalf("expected %d Anforderungen, got %d", len(wantIDs), len(rw.Anforderungen))
|
||||
}
|
||||
byID := map[string]bool{}
|
||||
for _, a := range rw.Anforderungen {
|
||||
byID[a.ID] = true
|
||||
}
|
||||
for _, id := range wantIDs {
|
||||
if !byID[id] {
|
||||
t.Errorf("expected Anforderung %q to be defined", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAnforderungenRejectsAnforderungWithoutTrigger(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"anforderungen.yaml": &fstest.MapFile{Data: []byte(`
|
||||
version: 1
|
||||
anforderungen:
|
||||
- {id: x}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml"); err == nil {
|
||||
t.Fatal("expected an error for an Anforderung without any trigger")
|
||||
}
|
||||
}
|
||||
68
internal/rules/regelwerk.go
Normal file
68
internal/rules/regelwerk.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package rules lädt das Bewertungsregelwerk (Datenklasse-Ableitung,
|
||||
// KI-VO-Einstufung, Anforderungsprofil) aus versionierten YAML-Dateien.
|
||||
// Regelbasiert, nicht ML — die Regeln liegen als Daten vor, nicht im
|
||||
// Code (siehe CLAUDE.md). Dieses Paket lädt und validiert nur die
|
||||
// Struktur; die eigentliche Auswertung gegen Fragebogen-Antworten ist
|
||||
// bewusst noch nicht Teil von Phase 1 (siehe rules/OPEN.md und die
|
||||
// Baureihenfolge in CLAUDE.md — "Ableitungen und harte Filter" ist ein
|
||||
// eigener, späterer Schritt, sobald der Fragebogen aus Phase 2 die
|
||||
// exakten Fakten-Feldnamen festlegt).
|
||||
package rules
|
||||
|
||||
// DatenklasseStufe ist eine mögliche Datenklasse mit ihren Auslösern aus
|
||||
// Fragebogen-Abschnitt B. Rang bestimmt, welche Stufe gewinnt, wenn
|
||||
// mehrere Auslöser gleichzeitig zutreffen ("höchste zutreffende Stufe
|
||||
// gewinnt") — höherer Rang gewinnt.
|
||||
type DatenklasseStufe struct {
|
||||
ID string `yaml:"id"`
|
||||
Rang int `yaml:"rang"`
|
||||
Ausloeser []string `yaml:"ausloeser"`
|
||||
Folge string `yaml:"folge"`
|
||||
}
|
||||
|
||||
// DatenklasseRegelwerk ist die vollständige Ableitungstabelle für
|
||||
// Fragebogen-Abschnitt B.
|
||||
type DatenklasseRegelwerk struct {
|
||||
Version int `yaml:"version"`
|
||||
Stufen []DatenklasseStufe `yaml:"stufen"`
|
||||
}
|
||||
|
||||
// EinstufungVariante ist eine mögliche Kombination von Fragebogen-Fakten
|
||||
// (Abschnitt C), die zu einer KI-VO-Einstufung führt — mehrere Varianten
|
||||
// sind ODER-verknüpft, die Felder innerhalb einer Variante UND-verknüpft.
|
||||
// Bewusst als freie Schlüssel-Werte-Paare statt fester Go-Felder: welche
|
||||
// Fakten-Schlüssel es gibt, legt der Fragebogen aus Phase 2 fest, nicht
|
||||
// dieses Paket.
|
||||
type EinstufungVariante map[string]any
|
||||
|
||||
// EinstufungStufe ist eine mögliche KI-VO-Einstufung.
|
||||
type EinstufungStufe struct {
|
||||
ID string `yaml:"id"`
|
||||
Quelle string `yaml:"quelle,omitempty"`
|
||||
Varianten []EinstufungVariante `yaml:"varianten"`
|
||||
}
|
||||
|
||||
// EinstufungRegelwerk ist die vollständige Ableitungstabelle für
|
||||
// Fragebogen-Abschnitt C. Die Reihenfolge der Stufen ist Prüfreihenfolge
|
||||
// (erste zutreffende Stufe gewinnt) — siehe Bewertungslogik, Schritt 1
|
||||
// (K.-o.-Prüfung: "verboten" muss zuerst geprüft werden).
|
||||
type EinstufungRegelwerk struct {
|
||||
Version int `yaml:"version"`
|
||||
Stufen []EinstufungStufe `yaml:"stufen"`
|
||||
}
|
||||
|
||||
// AnforderungsRegel leitet eine einzelne Anforderung aus Datenklasse
|
||||
// und/oder KI-VO-Einstufung ab (z. B. avv_erforderlich).
|
||||
type AnforderungsRegel struct {
|
||||
ID string `yaml:"id"`
|
||||
AusDatenklassen []string `yaml:"aus_datenklassen,omitempty"`
|
||||
AusEinstufungen []string `yaml:"aus_einstufungen,omitempty"`
|
||||
Beschreibung string `yaml:"beschreibung,omitempty"`
|
||||
}
|
||||
|
||||
// AnforderungsRegelwerk ist die vollständige Mapping-Tabelle
|
||||
// Datenklasse/Einstufung -> Anforderungsprofil.
|
||||
type AnforderungsRegelwerk struct {
|
||||
Version int `yaml:"version"`
|
||||
Anforderungen []AnforderungsRegel `yaml:"anforderungen"`
|
||||
}
|
||||
85
internal/store/abteilung.go
Normal file
85
internal/store/abteilung.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Abteilung ist Stammdatum für den Fragebogen (Feld A.abteilung).
|
||||
type Abteilung struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
||||
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
||||
var a Abteilung
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
||||
RETURNING id, account_id, name, created_at
|
||||
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return Abteilung{}, fmt.Errorf("store: create abteilung: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
|
||||
// alphabetisch — als Auswahlliste für den Fragebogen.
|
||||
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM abteilung
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Abteilung
|
||||
for rows.Next() {
|
||||
var a Abteilung
|
||||
if err := rows.Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan abteilung: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
||||
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
||||
var a Abteilung
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Abteilung{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Abteilung{}, fmt.Errorf("store: get abteilung: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
|
||||
// angelegt).
|
||||
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete abteilung: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
62
internal/store/abteilung_test.go
Normal file
62
internal/store/abteilung_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAbteilungCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
a, err := s.CreateAbteilung(ctx, accID, "IT")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAbteilung: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAbteilung(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAbteilung: %v", err)
|
||||
}
|
||||
if got.Name != "IT" || got.AccountID != accID {
|
||||
t.Fatalf("GetAbteilung = %+v, unerwartete Werte", got)
|
||||
}
|
||||
|
||||
list, err := s.ListAbteilungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAbteilungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != a.ID {
|
||||
t.Fatalf("ListAbteilungenForAccount = %+v, want exactly the created Abteilung", list)
|
||||
}
|
||||
|
||||
if err := s.DeleteAbteilung(ctx, a.ID); err != nil {
|
||||
t.Fatalf("DeleteAbteilung: %v", err)
|
||||
}
|
||||
if _, err := s.GetAbteilung(ctx, a.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbteilungIsolatedPerAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateAbteilung(ctx, accA, "Marketing"); err != nil {
|
||||
t.Fatalf("CreateAbteilung: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListAbteilungenForAccount(ctx, accB)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAbteilungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no Abteilungen for Mandant B, got %+v", list)
|
||||
}
|
||||
}
|
||||
126
internal/store/account.go
Normal file
126
internal/store/account.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
|
||||
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
|
||||
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
|
||||
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
|
||||
type Account struct {
|
||||
ID string
|
||||
Name string
|
||||
EinladungToken string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const accountColumns = `id, name, einladung_token, created_at`
|
||||
|
||||
func scanAccount(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Account, error) {
|
||||
var a Account
|
||||
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
||||
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO account (name) VALUES ($1)
|
||||
RETURNING `+accountColumns,
|
||||
name,
|
||||
)
|
||||
a, err := scanAccount(row)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||
// Betreiber-gestützten Anlage korrigieren).
|
||||
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: update account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: get account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAccountByEinladungToken liest den Mandanten zu einem
|
||||
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
|
||||
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
|
||||
// oder bereits erneuertem (damit ungültig gewordenem) Token.
|
||||
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
|
||||
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
|
||||
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
|
||||
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: regenerate einladung token: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
|
||||
// Admin-Bereich (Accounts-Verwaltung).
|
||||
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
a, err := scanAccount(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan account: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
138
internal/store/admin_test.go
Normal file
138
internal/store/admin_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser mit role=betreiber: %v", err)
|
||||
}
|
||||
if u.Role != "betreiber" {
|
||||
t.Fatalf("Role = %q, want betreiber", u.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppUserRoleRejectsUnknownRole(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "unbekannt@example.com", "hash", "kanzlei"); err == nil {
|
||||
t.Fatal("expected the old role 'kanzlei' to be rejected after the product pivot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccounts(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := s.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
after, err := s.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
if len(after) != len(before)+1 {
|
||||
t.Fatalf("expected exactly one more account, got %d -> %d", len(before), len(after))
|
||||
}
|
||||
found := false
|
||||
for _, a := range after {
|
||||
if a.ID == acc.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the newly created account in ListAccounts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsersForAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
otherAccID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "verantwortlicher"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListUsersForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsersForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected exactly 2 users for this account, got %d: %+v", len(list), list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogCreateAndList(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "manuell geprüft")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuditEntry: %v", err)
|
||||
}
|
||||
if entry.ActorUserID != admin.ID {
|
||||
t.Fatalf("ActorUserID = %q, want %q", entry.ActorUserID, admin.ID)
|
||||
}
|
||||
|
||||
list, err := s.ListAuditLog(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAuditLog: %v", err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Fatal("expected at least one audit entry")
|
||||
}
|
||||
if list[0].ID != entry.ID {
|
||||
t.Fatalf("expected the newest entry first, got %+v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuditEntry: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE audit_log SET action = 'geaendert' WHERE id = $1`, entry.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on audit_log to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM audit_log WHERE id = $1`, entry.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on audit_log to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
162
internal/store/antrag.go
Normal file
162
internal/store/antrag.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Antrag ist ein Vorhaben aus dem Fragebogen (Abschnitt A) mit den
|
||||
// vollständigen Antworten aus B/C/D als JSON — der Fragebogen ist
|
||||
// adaptiv (Folgefragen hängen von vorherigen Antworten ab), ein starres
|
||||
// Spaltenschema könnte das nicht abbilden. Antworten ist bewusst
|
||||
// []byte (rohes JSON), nicht ein aufgelöster Go-Typ — die Struktur der
|
||||
// Antworten ist Sache von internal/rules (Stufe 2), store speichert nur.
|
||||
type Antrag struct {
|
||||
ID string
|
||||
AccountID string
|
||||
ErstellerUserID string
|
||||
AbteilungID *string
|
||||
Titel string
|
||||
Beschreibung string
|
||||
Ergebnis string
|
||||
Haeufigkeit string
|
||||
Antworten []byte
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const antragColumns = `id, account_id, ersteller_user_id, abteilung_id, titel, beschreibung,
|
||||
ergebnis, haeufigkeit, antworten, status, created_at, updated_at`
|
||||
|
||||
func scanAntrag(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Antrag, error) {
|
||||
var a Antrag
|
||||
err := row.Scan(
|
||||
&a.ID, &a.AccountID, &a.ErstellerUserID, &a.AbteilungID, &a.Titel, &a.Beschreibung,
|
||||
&a.Ergebnis, &a.Haeufigkeit, &a.Antworten, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
|
||||
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+antragColumns,
|
||||
accountID, erstellerUserID, abteilungID, titel,
|
||||
)
|
||||
a, err := scanAntrag(row)
|
||||
if err != nil {
|
||||
return Antrag{}, fmt.Errorf("store: create antrag: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
|
||||
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
||||
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
|
||||
a, err := scanAntrag(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Antrag{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Antrag{}, fmt.Errorf("store: get antrag: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// UpdateAntragFelder aktualisiert die Fragebogen-Felder eines Antrags —
|
||||
// solange er im Entwurf ist, kann der Fragebogen adaptiv weiter
|
||||
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
|
||||
// Status hier nicht).
|
||||
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
UPDATE antrag SET
|
||||
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+antragColumns,
|
||||
id, titel, beschreibung, ergebnis, haeufigkeit, antworten,
|
||||
)
|
||||
a, err := scanAntrag(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Antrag{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Antrag{}, fmt.Errorf("store: update antrag felder: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// SetAntragStatus setzt den Status eines Antrags (entwurf -> eingereicht
|
||||
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
|
||||
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
||||
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set antrag status: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAntraegeForUser liefert alle Anträge, die ein bestimmter Nutzer
|
||||
// gestellt hat, neueste zuerst — für Ebene 2 ("eigene Anträge
|
||||
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
|
||||
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
||||
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
|
||||
`, erstellerUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Antrag
|
||||
for rows.Next() {
|
||||
a, err := scanAntrag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan antrag: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||
// zuerst.
|
||||
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Antrag
|
||||
for rows.Next() {
|
||||
a, err := scanAntrag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan antrag: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
133
internal/store/antrag_test.go
Normal file
133
internal/store/antrag_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testUserID(t *testing.T, s *store.Store, accountID string) string {
|
||||
t.Helper()
|
||||
u, err := s.CreateUser(context.Background(), accountID, "mitarbeiter-"+accountID+"@example.com", "hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func TestAntragCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
abt, err := s.CreateAbteilung(ctx, accID, "Vertrieb")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAbteilung: %v", err)
|
||||
}
|
||||
|
||||
a, err := s.CreateAntrag(ctx, accID, userID, &abt.ID, "Angebotstexte generieren")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
if a.Status != "entwurf" {
|
||||
t.Fatalf("Status = %q, want entwurf", a.Status)
|
||||
}
|
||||
if a.AbteilungID == nil || *a.AbteilungID != abt.ID {
|
||||
t.Fatalf("AbteilungID = %v, want %q", a.AbteilungID, abt.ID)
|
||||
}
|
||||
|
||||
got, err := s.GetAntrag(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag: %v", err)
|
||||
}
|
||||
if got.Titel != "Angebotstexte generieren" {
|
||||
t.Fatalf("Titel = %q, unerwartet", got.Titel)
|
||||
}
|
||||
|
||||
updated, err := s.UpdateAntragFelder(ctx, a.ID, "Angebotstexte generieren", "KI schreibt Angebotstexte", "Fertiger Text zur Freigabe", "gelegentlich", []byte(`{"b1":"nein"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAntragFelder: %v", err)
|
||||
}
|
||||
if updated.Beschreibung != "KI schreibt Angebotstexte" || updated.Haeufigkeit != "gelegentlich" {
|
||||
t.Fatalf("UpdateAntragFelder = %+v, unerwartete Werte", updated)
|
||||
}
|
||||
|
||||
if err := s.SetAntragStatus(ctx, a.ID, "eingereicht"); err != nil {
|
||||
t.Fatalf("SetAntragStatus: %v", err)
|
||||
}
|
||||
got, err = s.GetAntrag(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag nach Statuswechsel: %v", err)
|
||||
}
|
||||
if got.Status != "eingereicht" {
|
||||
t.Fatalf("Status nach SetAntragStatus = %q, want eingereicht", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAntragNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.GetAntrag(context.Background(), "00000000-0000-0000-0000-000000000000")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAntragStatusNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
err := s.SetAntragStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "eingereicht")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAntraegeForUserOnlyReturnsOwnAntraege(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accID)
|
||||
userB, err := s.CreateUser(ctx, accID, "zweiter-mitarbeiter-"+accID+"@example.com", "hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CreateAntrag(ctx, accID, userA, nil, "Antrag von A"); err != nil {
|
||||
t.Fatalf("CreateAntrag (A): %v", err)
|
||||
}
|
||||
if _, err := s.CreateAntrag(ctx, accID, userB.ID, nil, "Antrag von B"); err != nil {
|
||||
t.Fatalf("CreateAntrag (B): %v", err)
|
||||
}
|
||||
|
||||
listA, err := s.ListAntraegeForUser(ctx, userA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAntraegeForUser: %v", err)
|
||||
}
|
||||
if len(listA) != 1 || listA[0].Titel != "Antrag von A" {
|
||||
t.Fatalf("ListAntraegeForUser(A) = %+v, want exactly Antrag von A", listA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAntraegeForAccountIsolatesTenants(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accA)
|
||||
userB := testUserID(t, s, accB)
|
||||
|
||||
if _, err := s.CreateAntrag(ctx, accA, userA, nil, "Antrag A"); err != nil {
|
||||
t.Fatalf("CreateAntrag (A): %v", err)
|
||||
}
|
||||
if _, err := s.CreateAntrag(ctx, accB, userB, nil, "Antrag B"); err != nil {
|
||||
t.Fatalf("CreateAntrag (B): %v", err)
|
||||
}
|
||||
|
||||
listA, err := s.ListAntraegeForAccount(ctx, accA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAntraegeForAccount (A): %v", err)
|
||||
}
|
||||
if len(listA) != 1 || listA[0].Titel != "Antrag A" {
|
||||
t.Fatalf("ListAntraegeForAccount (A) = %+v, want exactly Antrag A", listA)
|
||||
}
|
||||
}
|
||||
61
internal/store/auditlog.go
Normal file
61
internal/store/auditlog.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only:
|
||||
// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein
|
||||
// Nachweis mehr, aus demselben Grund wie bei finding/extraction.
|
||||
type AuditEntry struct {
|
||||
ID string
|
||||
ActorUserID string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Details string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
||||
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
||||
var e AuditEntry
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
||||
`, actorUserID, action, targetType, targetID, details).Scan(
|
||||
&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
||||
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
||||
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []AuditEntry
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan audit entry: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
280
internal/store/auth_test.go
Normal file
280
internal/store/auth_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAccountCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAccount(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if got.Name != "Beispiel Agentur GmbH" {
|
||||
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
|
||||
}
|
||||
if got.EinladungToken == "" {
|
||||
t.Error("expected a newly created account to have a non-empty EinladungToken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
acc, err := s.CreateAccount(ctx, "Alter Name GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAccount: %v", err)
|
||||
}
|
||||
if updated.Name != "Neuer Name GmbH" {
|
||||
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
|
||||
}
|
||||
|
||||
got, err := s.GetAccount(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if got.Name != "Neuer Name GmbH" {
|
||||
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccountNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountByEinladungToken(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountByEinladungToken: %v", err)
|
||||
}
|
||||
if got.ID != acc.ID {
|
||||
t.Fatalf("GetAccountByEinladungToken returned a different account")
|
||||
}
|
||||
|
||||
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateEinladungToken(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
alterToken := acc.EinladungToken
|
||||
|
||||
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
|
||||
t.Fatalf("RegenerateEinladungToken: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("expected the old token to be invalid, got err = %v", err)
|
||||
}
|
||||
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
|
||||
}
|
||||
if got.ID != acc.ID {
|
||||
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
byEmail, err := s.GetUserByEmail(ctx, "team@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if byEmail.ID != user.ID {
|
||||
t.Fatalf("GetUserByEmail returned a different user than CreateUser")
|
||||
}
|
||||
|
||||
byID, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
||||
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
||||
}
|
||||
if !byID.Active {
|
||||
t.Error("expected a newly created user to be Active by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserActive(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
user, err := s.CreateUser(ctx, accID, "deaktivierbar@example.com", "bcrypt-hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetUserActive(ctx, user.ID, false); err != nil {
|
||||
t.Fatalf("SetUserActive (false): %v", err)
|
||||
}
|
||||
deaktiviert, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if deaktiviert.Active {
|
||||
t.Fatal("expected the user to be inactive")
|
||||
}
|
||||
|
||||
if err := s.SetUserActive(ctx, user.ID, true); err != nil {
|
||||
t.Fatalf("SetUserActive (true): %v", err)
|
||||
}
|
||||
reaktiviert, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if !reaktiviert.Active {
|
||||
t.Fatal("expected the user to be active again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserActiveNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
err := s.SetUserActive(context.Background(), "00000000-0000-0000-0000-000000000000", false)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByEmailNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.GetUserByEmail(context.Background(), "nichtvorhanden@example.com")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEmailIsUnique(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser (1): %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "mitarbeiter"); err == nil {
|
||||
t.Fatal("expected error for a duplicate email, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "verantwortlicher")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||
sess, err := s.CreateSession(ctx, "test-token-123", user.ID, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetSession(ctx, sess.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if got.UserID != user.ID {
|
||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||
}
|
||||
if got.ImpersonatedByUserID != nil {
|
||||
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
|
||||
}
|
||||
|
||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
}
|
||||
if _, err := s.GetSession(ctx, sess.Token); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSessionNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.GetSession(context.Background(), "unbekanntes-token")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateImpersonatedSession(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
kundenAcc := testAccountID(t, s)
|
||||
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser (Kunde): %v", err)
|
||||
}
|
||||
betreiberAcc := testAccountID(t, s)
|
||||
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser (Betreiber): %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateImpersonatedSession: %v", err)
|
||||
}
|
||||
if sess.UserID != kundenNutzer.ID {
|
||||
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
|
||||
}
|
||||
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
|
||||
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
|
||||
}
|
||||
|
||||
got, err := s.GetSession(ctx, sess.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
|
||||
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
|
||||
}
|
||||
}
|
||||
139
internal/store/bewertung.go
Normal file
139
internal/store/bewertung.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// BewertungAnforderung ist eine abgeleitete Anforderung mit Herleitung,
|
||||
// wie sie in bewertung.anforderungen (JSONB) gespeichert wird.
|
||||
type BewertungAnforderung struct {
|
||||
ID string `json:"id"`
|
||||
Beschreibung string `json:"beschreibung"`
|
||||
Herleitung string `json:"herleitung"`
|
||||
}
|
||||
|
||||
// BewertungAusschluss hält fest, warum ein Werkzeug für eine Bewertung
|
||||
// ausgeschlossen wurde.
|
||||
type BewertungAusschluss struct {
|
||||
WerkzeugID string `json:"werkzeug_id"`
|
||||
NichtErfuellt []string `json:"nicht_erfuellt"`
|
||||
}
|
||||
|
||||
// Bewertung ist der berechnete Vorschlag für einen Antrag. Append-only
|
||||
// — siehe Migration.
|
||||
type Bewertung struct {
|
||||
ID string
|
||||
AntragID string
|
||||
Datenklasse string
|
||||
DatenklasseHerleitung string
|
||||
Einstufung string
|
||||
EinstufungHerleitung string
|
||||
Verboten bool
|
||||
Anforderungen []BewertungAnforderung
|
||||
ZulaessigeWerkzeuge []string
|
||||
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||
RegelwerkVersion string
|
||||
KatalogVersion string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const bewertungColumns = `id, antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version, created_at`
|
||||
|
||||
func scanBewertung(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Bewertung, error) {
|
||||
var b Bewertung
|
||||
var anforderungenRaw, ausschlussRaw []byte
|
||||
err := row.Scan(
|
||||
&b.ID, &b.AntragID, &b.Datenklasse, &b.DatenklasseHerleitung, &b.Einstufung, &b.EinstufungHerleitung,
|
||||
&b.Verboten, &anforderungenRaw, &b.ZulaessigeWerkzeuge, &ausschlussRaw, &b.RegelwerkVersion, &b.KatalogVersion, &b.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Bewertung{}, err
|
||||
}
|
||||
if err := json.Unmarshal(anforderungenRaw, &b.Anforderungen); err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: anforderungen unmarshal: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(ausschlussRaw, &b.AusgeschlosseneWerkzeuge); err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge unmarshal: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// BewertungInput bündelt die Felder einer neuen Bewertung — bei elf
|
||||
// Werten lesbarer als eine positionale Parameterliste (gleiches Muster
|
||||
// wie WerkzeugInput).
|
||||
type BewertungInput struct {
|
||||
AntragID string
|
||||
Datenklasse string
|
||||
DatenklasseHerleitung string
|
||||
Einstufung string
|
||||
EinstufungHerleitung string
|
||||
Verboten bool
|
||||
Anforderungen []BewertungAnforderung
|
||||
ZulaessigeWerkzeuge []string
|
||||
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||
RegelwerkVersion string
|
||||
KatalogVersion string
|
||||
}
|
||||
|
||||
// CreateBewertung speichert eine berechnete Bewertung für einen Antrag.
|
||||
func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertung, error) {
|
||||
if in.ZulaessigeWerkzeuge == nil {
|
||||
in.ZulaessigeWerkzeuge = []string{}
|
||||
}
|
||||
if in.Anforderungen == nil {
|
||||
in.Anforderungen = []BewertungAnforderung{}
|
||||
}
|
||||
if in.AusgeschlosseneWerkzeuge == nil {
|
||||
in.AusgeschlosseneWerkzeuge = []BewertungAusschluss{}
|
||||
}
|
||||
anforderungenJSON, err := json.Marshal(in.Anforderungen)
|
||||
if err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: anforderungen marshal: %w", err)
|
||||
}
|
||||
ausschlussJSON, err := json.Marshal(in.AusgeschlosseneWerkzeuge)
|
||||
if err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
||||
}
|
||||
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO bewertung (
|
||||
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING `+bewertungColumns,
|
||||
in.AntragID, in.Datenklasse, in.DatenklasseHerleitung, in.Einstufung, in.EinstufungHerleitung,
|
||||
in.Verboten, anforderungenJSON, in.ZulaessigeWerkzeuge, ausschlussJSON, in.RegelwerkVersion, in.KatalogVersion,
|
||||
)
|
||||
b, err := scanBewertung(row)
|
||||
if err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: create bewertung: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// GetLatestBewertungForAntrag liefert die zuletzt berechnete Bewertung
|
||||
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
|
||||
// existiert.
|
||||
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT `+bewertungColumns+`
|
||||
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`, antragID)
|
||||
b, err := scanBewertung(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Bewertung{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Bewertung{}, fmt.Errorf("store: get latest bewertung: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
126
internal/store/bewertung_test.go
Normal file
126
internal/store/bewertung_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testBewertungInput(antragID string) store.BewertungInput {
|
||||
return store.BewertungInput{
|
||||
AntragID: antragID, Datenklasse: "personenbezogen", DatenklasseHerleitung: "ausgelöst durch b1",
|
||||
Einstufung: "minimal", EinstufungHerleitung: "Auffangregel", Verboten: false,
|
||||
Anforderungen: []store.BewertungAnforderung{
|
||||
{ID: "avv_erforderlich", Beschreibung: "AVV nötig", Herleitung: "aus Datenklasse personenbezogen"},
|
||||
},
|
||||
ZulaessigeWerkzeuge: []string{"werkzeug-a"},
|
||||
AusgeschlosseneWerkzeuge: []store.BewertungAusschluss{
|
||||
{WerkzeugID: "werkzeug-b", NichtErfuellt: []string{"avv_erforderlich"}},
|
||||
},
|
||||
RegelwerkVersion: "dk1.ei1.an1", KatalogVersion: "3-1735300000",
|
||||
}
|
||||
}
|
||||
|
||||
func TestBewertungCreateAndGetLatest(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Testantrag")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
|
||||
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBewertung: %v", err)
|
||||
}
|
||||
if b.Datenklasse != "personenbezogen" || len(b.Anforderungen) != 1 {
|
||||
t.Fatalf("CreateBewertung = %+v, unerwartete Werte", b)
|
||||
}
|
||||
if b.Anforderungen[0].ID != "avv_erforderlich" {
|
||||
t.Fatalf("Anforderungen[0] = %+v, unerwartet", b.Anforderungen[0])
|
||||
}
|
||||
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-b" {
|
||||
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, unerwartet", b.AusgeschlosseneWerkzeuge)
|
||||
}
|
||||
|
||||
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if got.ID != b.ID {
|
||||
t.Fatalf("GetLatestBewertungForAntrag returned a different row than CreateBewertung")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestBewertungForAntragNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Bewertung")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBewertungReturnsNewestWhenMultiple(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Mehrfach bewertet")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID)); err != nil {
|
||||
t.Fatalf("CreateBewertung (1): %v", err)
|
||||
}
|
||||
second := testBewertungInput(antrag.ID)
|
||||
second.Datenklasse = "besondere_kategorie"
|
||||
newest, err := s.CreateBewertung(ctx, second)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBewertung (2): %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if got.ID != newest.ID || got.Datenklasse != "besondere_kategorie" {
|
||||
t.Fatalf("expected the newest bewertung, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBewertungIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Append-only Test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBewertung: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE bewertung SET datenklasse = 'geaendert' WHERE id = $1`, b.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on bewertung to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM bewertung WHERE id = $1`, b.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on bewertung to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
116
internal/store/email_vorlage.go
Normal file
116
internal/store/email_vorlage.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
|
||||
// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
|
||||
// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
|
||||
// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type EmailVorlage struct {
|
||||
ID string
|
||||
AccountID *string
|
||||
Typ string
|
||||
Betreff string
|
||||
Text string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
|
||||
// accountID nil schreibt den plattformweiten Standard (nur für den
|
||||
// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
|
||||
//
|
||||
// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
|
||||
// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
|
||||
// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
|
||||
// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
|
||||
// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
|
||||
// Standards eine neue Zeile statt die bestehende zu aktualisieren).
|
||||
func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
|
||||
var v EmailVorlage
|
||||
var err error
|
||||
if accountID == nil {
|
||||
err = s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||
VALUES (NULL, $1, $2, $3)
|
||||
ON CONFLICT (typ) WHERE account_id IS NULL
|
||||
DO UPDATE SET betreff = $2, text = $3, updated_at = now()
|
||||
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||
`, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
} else {
|
||||
err = s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
|
||||
DO UPDATE SET betreff = $3, text = $4, updated_at = now()
|
||||
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||
`, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
|
||||
// accountID nil sucht den plattformweiten Standard.
|
||||
func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
|
||||
var v EmailVorlage
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||
WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return EmailVorlage{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
|
||||
// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
|
||||
// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
|
||||
// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
|
||||
// Plattform-Standard wird per Migration angelegt).
|
||||
func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
|
||||
var v EmailVorlage
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||
WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
|
||||
ORDER BY account_id NULLS LAST
|
||||
LIMIT 1
|
||||
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return EmailVorlage{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
|
||||
// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
|
||||
// wieder auf den plattformweiten Standard zurück), beim Betreiber
|
||||
// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
|
||||
// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
|
||||
func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `
|
||||
DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||
`, accountID, typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete email vorlage: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
117
internal/store/email_vorlage_test.go
Normal file
117
internal/store/email_vorlage_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestEmailVorlagePlattformStandardExistiertNachMigration(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
v, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("GetEmailVorlage (Plattform): %v", err)
|
||||
}
|
||||
if v.AccountID != nil {
|
||||
t.Fatalf("AccountID = %v, want nil (Plattform-Standard)", v.AccountID)
|
||||
}
|
||||
if v.Betreff == "" || v.Text == "" {
|
||||
t.Fatalf("Plattform-Standard unvollständig: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEmailVorlageFaelltAufPlattformStandardZurueck(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
// Ohne eigene Übersteuerung liefert Resolve den Plattform-Standard.
|
||||
resolved, err := s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEmailVorlage (kein eigener Override): %v", err)
|
||||
}
|
||||
if resolved.AccountID != nil {
|
||||
t.Fatalf("AccountID = %v, want nil (geerbt vom Plattform-Standard)", resolved.AccountID)
|
||||
}
|
||||
|
||||
// Eigene Übersteuerung anlegen — Resolve muss jetzt die eigene liefern.
|
||||
eigene, err := s.UpsertEmailVorlage(ctx, &accID, "passwort_zuruecksetzen", "Eigener Betreff", "Eigener Text {{link}}")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertEmailVorlage: %v", err)
|
||||
}
|
||||
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEmailVorlage (mit Override): %v", err)
|
||||
}
|
||||
if resolved.ID != eigene.ID || resolved.Betreff != "Eigener Betreff" {
|
||||
t.Fatalf("resolved = %+v, want eigene Vorlage", resolved)
|
||||
}
|
||||
|
||||
// Zurücksetzen entfernt die Übersteuerung, Resolve fällt wieder zurück.
|
||||
if err := s.DeleteEmailVorlage(ctx, &accID, "passwort_zuruecksetzen"); err != nil {
|
||||
t.Fatalf("DeleteEmailVorlage: %v", err)
|
||||
}
|
||||
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEmailVorlage (nach Reset): %v", err)
|
||||
}
|
||||
if resolved.AccountID != nil {
|
||||
t.Fatalf("AccountID nach Reset = %v, want nil (wieder Plattform-Standard)", resolved.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertPlattformStandardAktualisiertStattZuDuplizieren(t *testing.T) {
|
||||
// Regressionstest: SQL behandelt NULL nie als gleich zu NULL, ein
|
||||
// naiver UNIQUE(account_id, typ)-Constraint hätte beliebig viele
|
||||
// Plattform-Standard-Zeilen je typ zugelassen — jedes erneute
|
||||
// Speichern hätte eine neue Zeile erzeugt statt die bestehende zu
|
||||
// aktualisieren (genau das ist beim Live-Verifizieren passiert).
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Erster Betreff", "Erster Text {{link}}")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertEmailVorlage (1): %v", err)
|
||||
}
|
||||
second, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Zweiter Betreff", "Zweiter Text {{link}}")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertEmailVorlage (2): %v", err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Fatalf("zweites Upsert erzeugte eine neue Zeile (ID %s statt %s) statt zu aktualisieren", second.ID, first.ID)
|
||||
}
|
||||
|
||||
got, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("GetEmailVorlage: %v", err)
|
||||
}
|
||||
if got.Betreff != "Zweiter Betreff" {
|
||||
t.Fatalf("Betreff = %q, want %q (aktueller Stand)", got.Betreff, "Zweiter Betreff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailVorlageZweierMandantenIsoliert(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
|
||||
if _, err := s.UpsertEmailVorlage(ctx, &accA, "passwort_zuruecksetzen", "Vorlage A", "Text A {{link}}"); err != nil {
|
||||
t.Fatalf("UpsertEmailVorlage A: %v", err)
|
||||
}
|
||||
|
||||
resolvedB, err := s.ResolveEmailVorlage(ctx, accB, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEmailVorlage B: %v", err)
|
||||
}
|
||||
if resolvedB.Betreff == "Vorlage A" {
|
||||
t.Fatal("Mandant B sieht die Vorlage von Mandant A — Isolation defekt")
|
||||
}
|
||||
|
||||
if _, err := s.GetEmailVorlage(ctx, &accB, "passwort_zuruecksetzen"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("GetEmailVorlage B (kein eigener Override) err=%v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
146
internal/store/entscheidung.go
Normal file
146
internal/store/entscheidung.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Entscheidung ist die Entscheidung eines/einer Verantwortlichen über
|
||||
// einen Antrag, auf Basis einer Bewertung (Vorschlag). Append-only —
|
||||
// siehe Migration. WerkzeugSnapshot ist der eingefrorene, vollständige
|
||||
// Werkzeug-Datensatz zum Entscheidungszeitpunkt (nil, wenn kein
|
||||
// Werkzeug gewählt wurde, z. B. bei "abgelehnt") — ein späterer
|
||||
// Katalog-Wandel darf nicht rückwirkend verändern, worauf die
|
||||
// Entscheidung beruhte.
|
||||
type Entscheidung struct {
|
||||
ID string
|
||||
AntragID string
|
||||
BewertungID string
|
||||
EntscheiderUserID string
|
||||
Entscheidung string
|
||||
WerkzeugID *string
|
||||
WerkzeugSnapshot *Werkzeug
|
||||
Begruendung string
|
||||
GueltigBis *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const entscheidungColumns = `id, antrag_id, bewertung_id, entscheider_user_id, entscheidung,
|
||||
werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis, created_at`
|
||||
|
||||
func scanEntscheidung(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Entscheidung, error) {
|
||||
var e Entscheidung
|
||||
var snapshotRaw []byte
|
||||
err := row.Scan(
|
||||
&e.ID, &e.AntragID, &e.BewertungID, &e.EntscheiderUserID, &e.Entscheidung,
|
||||
&e.WerkzeugID, &snapshotRaw, &e.Begruendung, &e.GueltigBis, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Entscheidung{}, err
|
||||
}
|
||||
if snapshotRaw != nil {
|
||||
var w Werkzeug
|
||||
if err := json.Unmarshal(snapshotRaw, &w); err != nil {
|
||||
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot unmarshal: %w", err)
|
||||
}
|
||||
e.WerkzeugSnapshot = &w
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// EntscheidungInput bündelt die Felder einer neuen Entscheidung.
|
||||
type EntscheidungInput struct {
|
||||
AntragID string
|
||||
BewertungID string
|
||||
EntscheiderUserID string
|
||||
Entscheidung string
|
||||
WerkzeugID *string
|
||||
WerkzeugSnapshot *Werkzeug
|
||||
Begruendung string
|
||||
GueltigBis *time.Time
|
||||
}
|
||||
|
||||
// CreateEntscheidung speichert eine Entscheidung über einen Antrag.
|
||||
func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (Entscheidung, error) {
|
||||
var snapshotJSON []byte
|
||||
if in.WerkzeugSnapshot != nil {
|
||||
var err error
|
||||
snapshotJSON, err = json.Marshal(in.WerkzeugSnapshot)
|
||||
if err != nil {
|
||||
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot marshal: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO entscheidung (
|
||||
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING `+entscheidungColumns,
|
||||
in.AntragID, in.BewertungID, in.EntscheiderUserID, in.Entscheidung, in.WerkzeugID, snapshotJSON, in.Begruendung, in.GueltigBis,
|
||||
)
|
||||
e, err := scanEntscheidung(row)
|
||||
if err != nil {
|
||||
return Entscheidung{}, fmt.Errorf("store: create entscheidung: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// GetLatestEntscheidungForAntrag liefert die zuletzt getroffene
|
||||
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
|
||||
// Entscheidung existiert.
|
||||
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT `+entscheidungColumns+`
|
||||
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||
`, antragID)
|
||||
e, err := scanEntscheidung(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Entscheidung{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Entscheidung{}, fmt.Errorf("store: get latest entscheidung: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListAktiveGenehmigungenForAccount liefert alle Genehmigungen
|
||||
// ("genehmigt"/"genehmigt_mit_auflagen") eines Mandanten — Grundlage
|
||||
// für die Wiedervorlage (Schritt 7): abgelaufene/bald ablaufende
|
||||
// Genehmigungen und solche, deren zugrunde liegendes Werkzeug sich seit
|
||||
// der Entscheidung im Katalog geändert hat, muss der/die Verantwortliche
|
||||
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
|
||||
// zum Mandanten läuft über den zugehörigen antrag.
|
||||
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
|
||||
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
|
||||
FROM entscheidung e
|
||||
JOIN antrag a ON a.id = e.antrag_id
|
||||
WHERE a.account_id = $1 AND e.entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen')
|
||||
ORDER BY e.created_at DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Entscheidung
|
||||
for rows.Next() {
|
||||
e, err := scanEntscheidung(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan entscheidung: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
153
internal/store/entscheidung_test.go
Normal file
153
internal/store/entscheidung_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testAntragMitBewertung(t *testing.T, s *store.Store, accountID, userID string) (store.Antrag, store.Bewertung) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
antrag, err := s.CreateAntrag(ctx, accountID, userID, nil, "Testantrag")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBewertung: %v", err)
|
||||
}
|
||||
return antrag, b
|
||||
}
|
||||
|
||||
func TestEntscheidungCreateAndGetLatest(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "ChatGPT Enterprise"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
|
||||
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
|
||||
Entscheidung: "genehmigt", WerkzeugID: &w.ID, WerkzeugSnapshot: &w,
|
||||
Begruendung: "", GueltigBis: &gueltigBis,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
if e.Entscheidung != "genehmigt" || e.WerkzeugSnapshot == nil || e.WerkzeugSnapshot.Name != "ChatGPT Enterprise" {
|
||||
t.Fatalf("CreateEntscheidung = %+v, unerwartete Werte", e)
|
||||
}
|
||||
|
||||
got, err := s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestEntscheidungForAntrag: %v", err)
|
||||
}
|
||||
if got.ID != e.ID {
|
||||
t.Fatalf("GetLatestEntscheidungForAntrag returned a different row than CreateEntscheidung")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntscheidungOhneWerkzeugHatKeinenSnapshot(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||
|
||||
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
|
||||
Entscheidung: "abgelehnt", Begruendung: "Kein geeignetes Werkzeug im Katalog",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
if e.WerkzeugID != nil || e.WerkzeugSnapshot != nil {
|
||||
t.Fatalf("expected no werkzeug/snapshot for an abgelehnt Entscheidung, got %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLatestEntscheidungForAntragNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Entscheidung")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntscheidungIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, Entscheidung: "rueckfrage",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE entscheidung SET entscheidung = 'genehmigt' WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on entscheidung to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM entscheidung WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAktiveGenehmigungenForAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
antragGenehmigt, b1 := testAntragMitBewertung(t, s, accID, userID)
|
||||
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antragGenehmigt.ID, BewertungID: b1.ID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateEntscheidung (genehmigt): %v", err)
|
||||
}
|
||||
|
||||
antragAbgelehnt, b2 := testAntragMitBewertung(t, s, accID, userID)
|
||||
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antragAbgelehnt.ID, BewertungID: b2.ID, EntscheiderUserID: userID, Entscheidung: "abgelehnt",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateEntscheidung (abgelehnt): %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListAktiveGenehmigungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].AntragID != antragGenehmigt.ID {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount = %+v, want exactly the genehmigt entscheidung", list)
|
||||
}
|
||||
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListAktiveGenehmigungenForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount (other): %v", err)
|
||||
}
|
||||
if len(otherList) != 0 {
|
||||
t.Fatalf("expected no genehmigungen for a different tenant, got %+v", otherList)
|
||||
}
|
||||
}
|
||||
388
internal/store/freigabe.go
Normal file
388
internal/store/freigabe.go
Normal file
@@ -0,0 +1,388 @@
|
||||
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
|
||||
// und CLAUDE.md. GenehmigerRolle ist eine reine Freigabe-Funktion
|
||||
// (z. B. "Datenschutzbeauftragter", "Geschäftsführung"), unabhängig von
|
||||
// app_user.role (Zugriffskontrolle) — eine Person kann beides zugleich
|
||||
// sein oder keines von beidem.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type GenehmigerRolle struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
Beschreibung string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
|
||||
// Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des
|
||||
// Admins (z. B. "Prüft den Antrag aus Sicherheitssicht") — bindet keine
|
||||
// Bedingung, das bleibt Sache von freigabe_regel.
|
||||
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
|
||||
RETURNING id, account_id, name, beschreibung, created_at
|
||||
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
if err != nil {
|
||||
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||
var g GenehmigerRolle
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
|
||||
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GenehmigerRolle{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return GenehmigerRolle{}, fmt.Errorf("store: get genehmiger rolle: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||
// Mandanten.
|
||||
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list genehmiger rollen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GenehmigerRolle
|
||||
for rows.Next() {
|
||||
var g GenehmigerRolle
|
||||
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteGenehmigerRolle entfernt eine Genehmiger-Rolle. Zuordnungen
|
||||
// (nutzer_genehmiger_rolle) und darauf verweisende Freigabe-Regeln
|
||||
// hängen per Fremdschlüssel daran — der Aufrufer muss sie vorher
|
||||
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
|
||||
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
||||
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.db(ctx).Exec(ctx, `
|
||||
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
||||
`, userID, genehmigerRolleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: add nutzer genehmiger rolle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||
_, err := s.db(ctx).Exec(ctx, `
|
||||
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
|
||||
`, userID, genehmigerRolleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: remove nutzer genehmiger rolle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
||||
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
||||
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT gr.id, gr.account_id, gr.name, gr.created_at
|
||||
FROM genehmiger_rolle gr
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
|
||||
WHERE ngr.app_user_id = $1
|
||||
ORDER BY gr.name
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list genehmiger rollen for user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GenehmigerRolle
|
||||
for rows.Next() {
|
||||
var g GenehmigerRolle
|
||||
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GenehmigerMitglied ist ein Nutzer, der eine bestimmte Genehmiger-Rolle
|
||||
// innehat — für die Anzeige "wer hat diese Rolle" auf der Verwaltungsseite.
|
||||
type GenehmigerMitglied struct {
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||
// bestimmten Genehmiger-Rolle.
|
||||
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT u.id, u.email
|
||||
FROM app_user u
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
|
||||
WHERE ngr.genehmiger_rolle_id = $1
|
||||
ORDER BY u.email
|
||||
`, genehmigerRolleID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list nutzer for genehmiger rolle: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []GenehmigerMitglied
|
||||
for rows.Next() {
|
||||
var m GenehmigerMitglied
|
||||
if err := rows.Scan(&m.UserID, &m.Email); err != nil {
|
||||
return nil, fmt.Errorf("store: scan genehmiger mitglied: %w", err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// FreigabeRegel: "wenn Bedingung X zutrifft, ist zusätzlich eine
|
||||
// Freigabe durch GenehmigerRolleID nötig." BedingungTyp ist eine von
|
||||
// "anforderung"/"einstufung"/"datenklasse", BedingungWert die jeweilige
|
||||
// ID aus dem Regelwerk (z. B. "dsfa_erforderlich", "hochrisiko").
|
||||
type FreigabeRegel struct {
|
||||
ID string
|
||||
AccountID string
|
||||
BedingungTyp string
|
||||
BedingungWert string
|
||||
GenehmigerRolleID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
`, accountID, bedingungTyp, bedingungWert, genehmigerRolleID).Scan(
|
||||
&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return FreigabeRegel{}, fmt.Errorf("store: create freigabe regel: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list freigabe regeln: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []FreigabeRegel
|
||||
for rows.Next() {
|
||||
var f FreigabeRegel
|
||||
if err := rows.Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan freigabe regel: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete freigabe regel: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFreigabeRegel liest eine einzelne Freigabe-Regel — genutzt, um
|
||||
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
|
||||
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
||||
var f FreigabeRegel
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||
FROM freigabe_regel WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return FreigabeRegel{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return FreigabeRegel{}, fmt.Errorf("store: get freigabe regel: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Freigabeschritt ist eine einzelne, durch eine FreigabeRegel
|
||||
// ausgelöste Freigabe-Aufgabe für einen konkreten Antrag. Nicht
|
||||
// append-only — ein Schritt geht von "ausstehend" in genau einen
|
||||
// Endzustand über, keine Historie mehrerer Entscheidungen zum selben
|
||||
// Schritt.
|
||||
type Freigabeschritt struct {
|
||||
ID string
|
||||
AntragID string
|
||||
GenehmigerRolleID string
|
||||
Status string // ausstehend/genehmigt/abgelehnt
|
||||
EntschiedenVon *string
|
||||
EntschiedenAm *time.Time
|
||||
Kommentar string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
`, antragID, genehmigerRolleID).Scan(
|
||||
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Freigabeschritt{}, fmt.Errorf("store: create freigabeschritt: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE id = $1
|
||||
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Freigabeschritt{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Freigabeschritt{}, fmt.Errorf("store: get freigabeschritt: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
|
||||
`, antragID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list freigabeschritte for antrag: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Freigabeschritt
|
||||
for rows.Next() {
|
||||
var f Freigabeschritt
|
||||
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAusstehendeFreigabeschritteForUser liefert alle offenen
|
||||
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
||||
// innehat — Grundlage für "Meine Freigaben".
|
||||
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
|
||||
FROM freigabeschritt f
|
||||
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
|
||||
WHERE ngr.app_user_id = $1 AND f.status = 'ausstehend'
|
||||
ORDER BY f.created_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list ausstehende freigabeschritte: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Freigabeschritt
|
||||
for rows.Next() {
|
||||
var f Freigabeschritt
|
||||
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EntscheideFreigabeschritt trägt die Entscheidung einer Person zu
|
||||
// einem einzelnen Freigabeschritt ein. Nur von "ausstehend" aus
|
||||
// erlaubt — der Aufrufer prüft das vorher (ErrNotFound bei bereits
|
||||
// entschiedenen Schritten wäre irreführend, deshalb hier ein
|
||||
// generischer Fehler statt eines Sentinels).
|
||||
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||
var f Freigabeschritt
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
||||
WHERE id = $1 AND status = 'ausstehend'
|
||||
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||
`, id, status, entschiedenVon, kommentar).Scan(
|
||||
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Freigabeschritt{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Freigabeschritt{}, fmt.Errorf("store: entscheide freigabeschritt: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// KaskadiereAblehnung lehnt alle noch ausstehenden Freigabeschritte
|
||||
// eines Antrags automatisch ab, nachdem ein anderer Schritt desselben
|
||||
// Antrags bereits abgelehnt wurde — eine einzelne Ablehnung kippt den
|
||||
// gesamten Antrag, die übrigen offenen Freigaben werden dadurch
|
||||
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
|
||||
// hängen zu bleiben.
|
||||
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||
_, err := s.db(ctx).Exec(ctx, `
|
||||
UPDATE freigabeschritt
|
||||
SET status = 'abgelehnt', entschieden_am = now(),
|
||||
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
||||
WHERE antrag_id = $1 AND status = 'ausstehend' AND id != $2
|
||||
`, antragID, ausloesenderSchrittID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: kaskadiere ablehnung: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
174
internal/store/freigabe_test.go
Normal file
174
internal/store/freigabe_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
if g.AccountID != accID || g.Name != "Datenschutzbeauftragter" {
|
||||
t.Fatalf("GenehmigerRolle = %+v, unerwartete Werte", g)
|
||||
}
|
||||
|
||||
got, err := s.GetGenehmigerRolle(ctx, g.ID)
|
||||
if err != nil || got.Name != "Datenschutzbeauftragter" {
|
||||
t.Fatalf("GetGenehmigerRolle = %+v, err=%v", got, err)
|
||||
}
|
||||
|
||||
liste, err := s.ListGenehmigerRollenForAccount(ctx, accID)
|
||||
if err != nil || len(liste) != 1 {
|
||||
t.Fatalf("ListGenehmigerRollenForAccount = %+v, err=%v", liste, err)
|
||||
}
|
||||
|
||||
user := testUserID(t, s, accID)
|
||||
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
// Erneutes Hinzufügen derselben Zuordnung ist ein No-op (ON CONFLICT),
|
||||
// kein Fehler.
|
||||
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle (erneut): %v", err)
|
||||
}
|
||||
|
||||
rollenDesNutzers, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||
if err != nil || len(rollenDesNutzers) != 1 || rollenDesNutzers[0].ID != g.ID {
|
||||
t.Fatalf("ListGenehmigerRollenForUser = %+v, err=%v", rollenDesNutzers, err)
|
||||
}
|
||||
|
||||
mitglieder, err := s.ListNutzerForGenehmigerRolle(ctx, g.ID)
|
||||
if err != nil || len(mitglieder) != 1 || mitglieder[0].UserID != user {
|
||||
t.Fatalf("ListNutzerForGenehmigerRolle = %+v, err=%v", mitglieder, err)
|
||||
}
|
||||
|
||||
if err := s.RemoveNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||
t.Fatalf("RemoveNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
rollenNachEntfernen, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||
if err != nil || len(rollenNachEntfernen) != 0 {
|
||||
t.Fatalf("ListGenehmigerRollenForUser nach Entfernen = %+v, err=%v", rollenNachEntfernen, err)
|
||||
}
|
||||
|
||||
if err := s.DeleteGenehmigerRolle(ctx, g.ID); err != nil {
|
||||
t.Fatalf("DeleteGenehmigerRolle: %v", err)
|
||||
}
|
||||
if _, err := s.GetGenehmigerRolle(ctx, g.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreigabeRegelCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||
}
|
||||
|
||||
regel, err := s.CreateFreigabeRegel(ctx, accID, "einstufung", "hochrisiko", g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFreigabeRegel: %v", err)
|
||||
}
|
||||
if regel.BedingungTyp != "einstufung" || regel.BedingungWert != "hochrisiko" {
|
||||
t.Fatalf("FreigabeRegel = %+v, unerwartete Werte", regel)
|
||||
}
|
||||
|
||||
liste, err := s.ListFreigabeRegelnForAccount(ctx, accID)
|
||||
if err != nil || len(liste) != 1 {
|
||||
t.Fatalf("ListFreigabeRegelnForAccount = %+v, err=%v", liste, err)
|
||||
}
|
||||
|
||||
got, err := s.GetFreigabeRegel(ctx, regel.ID)
|
||||
if err != nil || got.ID != regel.ID {
|
||||
t.Fatalf("GetFreigabeRegel = %+v, err=%v", got, err)
|
||||
}
|
||||
|
||||
if err := s.DeleteFreigabeRegel(ctx, regel.ID); err != nil {
|
||||
t.Fatalf("DeleteFreigabeRegel: %v", err)
|
||||
}
|
||||
if _, err := s.GetFreigabeRegel(ctx, regel.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
user := testUserID(t, s, accID)
|
||||
antrag, err := s.CreateAntrag(ctx, accID, user, nil, "Test-Antrag für Freigabeschritte")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
antragID := antrag.ID
|
||||
|
||||
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||
|
||||
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||
}
|
||||
if schrittDSB.Status != "ausstehend" {
|
||||
t.Fatalf("Status = %q, want ausstehend", schrittDSB.Status)
|
||||
}
|
||||
schrittGF, err := s.CreateFreigabeschritt(ctx, antragID, gf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||
}
|
||||
|
||||
alle, err := s.ListFreigabeschritteForAntrag(ctx, antragID)
|
||||
if err != nil || len(alle) != 2 {
|
||||
t.Fatalf("ListFreigabeschritteForAntrag = %+v, err=%v", alle, err)
|
||||
}
|
||||
|
||||
ausstehend, err := s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAusstehendeFreigabeschritteForUser: %v", err)
|
||||
}
|
||||
if len(ausstehend) != 0 {
|
||||
t.Fatalf("erwartet 0 ausstehende Freigaben ohne Genehmiger-Rollen-Zuordnung, got %d", len(ausstehend))
|
||||
}
|
||||
if err := s.AddNutzerGenehmigerRolle(ctx, user, dsb.ID); err != nil {
|
||||
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||
}
|
||||
ausstehend, err = s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||
if err != nil || len(ausstehend) != 1 || ausstehend[0].ID != schrittDSB.ID {
|
||||
t.Fatalf("ListAusstehendeFreigabeschritteForUser = %+v, err=%v", ausstehend, err)
|
||||
}
|
||||
|
||||
entschieden, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "abgelehnt", user, "nicht ausreichend")
|
||||
if err != nil {
|
||||
t.Fatalf("EntscheideFreigabeschritt: %v", err)
|
||||
}
|
||||
if entschieden.Status != "abgelehnt" || entschieden.EntschiedenVon == nil || *entschieden.EntschiedenVon != user {
|
||||
t.Fatalf("Freigabeschritt nach Entscheidung = %+v, unerwartete Werte", entschieden)
|
||||
}
|
||||
|
||||
// Ein bereits entschiedener Schritt kann nicht erneut entschieden werden.
|
||||
if _, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "genehmigt", user, ""); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("erneute Entscheidung err = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
if err := s.KaskadiereAblehnung(ctx, antragID, schrittDSB.ID); err != nil {
|
||||
t.Fatalf("KaskadiereAblehnung: %v", err)
|
||||
}
|
||||
nachKaskade, err := s.GetFreigabeschritt(ctx, schrittGF.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFreigabeschritt: %v", err)
|
||||
}
|
||||
if nachKaskade.Status != "abgelehnt" {
|
||||
t.Fatalf("Status nach Kaskade = %q, want abgelehnt", nachKaskade.Status)
|
||||
}
|
||||
}
|
||||
69
internal/store/loeschfrist.go
Normal file
69
internal/store/loeschfrist.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Löschfristen je Datenklasse (Migration 0020) — schließt die in
|
||||
// rules/OPEN.md, Punkt 4 dokumentierte Lücke: loeschfrist_max_tage war
|
||||
// bisher nur als Anforderung "vorhanden", ohne Tageswerte, und wurde
|
||||
// deshalb nicht hart gefiltert. Die konkreten Tageswerte sind KEINE
|
||||
// gesetzliche Vorgabe (die DSGVO nennt keine festen Fristen, nur den
|
||||
// Grundsatz "so lange wie für den Zweck nötig", Art. 5 Abs. 1 lit. e) —
|
||||
// deshalb pro Mandant einstellbar statt hartkodiert, vom
|
||||
// Datenschutzbeauftragten hinterlegt. Bei Firmenanlage werden
|
||||
// risikogestaffelte Vorschlagswerte vorbelegt (siehe
|
||||
// internal/web/loeschfrist_handlers.go), frei editierbar.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoeschfristEinstellung struct {
|
||||
ID string
|
||||
AccountID string
|
||||
DatenklasseID string
|
||||
MaxTage int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertLoeschfristEinstellung legt eine Einstellung an oder
|
||||
// aktualisiert sie — ein Mandant hat höchstens eine Frist je
|
||||
// Datenklasse (UNIQUE-Constraint).
|
||||
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
|
||||
var e LoeschfristEinstellung
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (account_id, datenklasse_id)
|
||||
DO UPDATE SET max_tage = $3, updated_at = now()
|
||||
RETURNING id, account_id, datenklasse_id, max_tage, updated_at
|
||||
`, accountID, datenklasseID, maxTage).Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt)
|
||||
if err != nil {
|
||||
return LoeschfristEinstellung{}, fmt.Errorf("store: upsert loeschfrist einstellung: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
|
||||
// Fristen eines Mandanten.
|
||||
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, datenklasse_id, max_tage, updated_at
|
||||
FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []LoeschfristEinstellung
|
||||
for rows.Next() {
|
||||
var e LoeschfristEinstellung
|
||||
if err := rows.Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan loeschfrist einstellung: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
41
internal/store/loeschfrist_test.go
Normal file
41
internal/store/loeschfrist_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoeschfristEinstellungUpsert(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
e, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 90)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||
}
|
||||
if e.MaxTage != 90 {
|
||||
t.Fatalf("MaxTage = %d, want 90", e.MaxTage)
|
||||
}
|
||||
|
||||
// Erneutes Upsert für dieselbe Datenklasse überschreibt statt zu duplizieren.
|
||||
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 30); err != nil {
|
||||
t.Fatalf("UpsertLoeschfristEinstellung (Update): %v", err)
|
||||
}
|
||||
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "besondere_kategorie", 14); err != nil {
|
||||
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||
}
|
||||
|
||||
liste, err := s.ListLoeschfristEinstellungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListLoeschfristEinstellungenForAccount: %v", err)
|
||||
}
|
||||
if len(liste) != 2 {
|
||||
t.Fatalf("got %d Einstellungen, want 2 (Upsert darf nicht duplizieren)", len(liste))
|
||||
}
|
||||
for _, e := range liste {
|
||||
if e.DatenklasseID == "personenbezogen" && e.MaxTage != 30 {
|
||||
t.Fatalf("personenbezogen MaxTage = %d, want 30 nach Update", e.MaxTage)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
internal/store/migrate.go
Normal file
48
internal/store/migrate.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Migrate wendet alle ausstehenden Migrationen aus migrations/ an.
|
||||
// Die Migrationen sind im Binary eingebettet (go:embed), damit Deklarix
|
||||
// weiterhin als einzelnes Binary lauffähig bleibt.
|
||||
func Migrate(databaseURL string) error {
|
||||
db, err := sql.Open("pgx", databaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: open db for migration: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: migration driver: %w", err)
|
||||
}
|
||||
|
||||
source, err := iofs.New(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: migration source: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "pgx5", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: migrate init: %w", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("store: migrate up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
internal/store/migrations/0001_init.down.sql
Normal file
11
internal/store/migrations/0001_init.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
DROP TRIGGER IF EXISTS evidence_package_append_only ON evidence_package;
|
||||
DROP TRIGGER IF EXISTS finding_append_only ON finding;
|
||||
DROP TRIGGER IF EXISTS extraction_append_only ON extraction;
|
||||
DROP FUNCTION IF EXISTS forbid_update_delete();
|
||||
|
||||
DROP TABLE IF EXISTS participant;
|
||||
DROP TABLE IF EXISTS evidence_package;
|
||||
DROP TABLE IF EXISTS finding;
|
||||
DROP TABLE IF EXISTS extraction;
|
||||
DROP TABLE IF EXISTS asset;
|
||||
DROP TABLE IF EXISTS submission;
|
||||
89
internal/store/migrations/0001_init.up.sql
Normal file
89
internal/store/migrations/0001_init.up.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE submission (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
|
||||
post_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
|
||||
caption TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE asset (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
|
||||
path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Stufe 1: striktes JSON aus der Claude-Extraktion. Append-only, siehe Trigger unten.
|
||||
CREATE TABLE extraction (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
payload JSONB NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Stufe 2: Ergebnis pro Regel. Append-only, Korrektur = neue Zeile.
|
||||
-- supersedes zeigt auf die alte Zeile, die diese Zeile ersetzt (gesetzt
|
||||
-- beim INSERT der Korrektur, nie per UPDATE — der Trigger würde das
|
||||
-- verbieten). "Aktuell gültig" = Zeilen, auf die kein supersedes zeigt,
|
||||
-- siehe Index unten für den Anti-Join.
|
||||
CREATE TABLE finding (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
extraction_id UUID REFERENCES extraction (id),
|
||||
rule_id TEXT NOT NULL,
|
||||
rule_version INTEGER NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
|
||||
message TEXT NOT NULL,
|
||||
supersedes UUID REFERENCES finding (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
|
||||
|
||||
-- Dossier + Beweiskette (Hash, RFC-3161-Token). Append-only.
|
||||
CREATE TABLE evidence_package (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
dossier_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
timestamp_token BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE participant (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||
name TEXT NOT NULL,
|
||||
vorgegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
freigegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
|
||||
CREATE FUNCTION forbid_update_delete() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'append-only table: % on % is not allowed', TG_OP, TG_TABLE_NAME;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER extraction_append_only
|
||||
BEFORE UPDATE OR DELETE ON extraction
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TRIGGER finding_append_only
|
||||
BEFORE UPDATE OR DELETE ON finding
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TRIGGER evidence_package_append_only
|
||||
BEFORE UPDATE OR DELETE ON evidence_package
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE finding DROP COLUMN title;
|
||||
ALTER TABLE finding DROP COLUMN fix;
|
||||
ALTER TABLE finding DROP COLUMN sources;
|
||||
ALTER TABLE finding ADD COLUMN message TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE finding ALTER COLUMN message DROP DEFAULT;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- finding.message war ein einzelnes Freitextfeld. Fürs Dossier brauchen
|
||||
-- wir Titel, Korrekturvorschlag und Fundstellen aber strukturiert und
|
||||
-- getrennt (siehe internal/dossier), sonst müsste die Anzeige Text
|
||||
-- wieder auseinanderparsen, den wir selbst zusammengefügt haben.
|
||||
ALTER TABLE finding DROP COLUMN message;
|
||||
ALTER TABLE finding ADD COLUMN title TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE finding ADD COLUMN fix TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE finding ADD COLUMN sources TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE finding ALTER COLUMN title DROP DEFAULT;
|
||||
ALTER TABLE finding ALTER COLUMN fix DROP DEFAULT;
|
||||
ALTER TABLE finding ALTER COLUMN sources DROP DEFAULT;
|
||||
4
internal/store/migrations/0003_auth.down.sql
Normal file
4
internal/store/migrations/0003_auth.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE submission DROP COLUMN account_id;
|
||||
DROP TABLE session;
|
||||
DROP TABLE app_user;
|
||||
DROP TABLE account;
|
||||
33
internal/store/migrations/0003_auth.up.sql
Normal file
33
internal/store/migrations/0003_auth.up.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Mandantentrennung: ein account ist der Mandant (Creator, Agentur,
|
||||
-- Marke oder Kanzlei als eigene Organisation), app_user ist ein Login
|
||||
-- innerhalb eines accounts. session ist bewusst eine eigene Tabelle
|
||||
-- statt signierter, zustandsloser Tokens — ein Logout muss eine
|
||||
-- Sitzung wirklich beenden können, nicht nur clientseitig "vergessen"
|
||||
-- werden.
|
||||
CREATE TABLE account (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- "app_user" statt "user", da user ein reserviertes Wort in SQL ist.
|
||||
CREATE TABLE app_user (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE session (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Jeder Beitrag gehört ab jetzt zu genau einem Mandanten. NOT NULL ohne
|
||||
-- Default ist hier sicher, weil noch keine Submission-Zeilen aus der
|
||||
-- Zeit vor Auth existieren (kein Kunde live).
|
||||
ALTER TABLE submission ADD COLUMN account_id UUID NOT NULL REFERENCES account (id);
|
||||
6
internal/store/migrations/0004_admin.down.sql
Normal file
6
internal/store/migrations/0004_admin.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
DROP TRIGGER audit_log_append_only ON audit_log;
|
||||
DROP TABLE audit_log;
|
||||
ALTER TABLE account DROP COLUMN verified;
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei'));
|
||||
36
internal/store/migrations/0004_admin.up.sql
Normal file
36
internal/store/migrations/0004_admin.up.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- "admin" ist eine fünfte app_user-Rolle: Betreiber-Personal (Netcell-IT),
|
||||
-- nicht an einen Mandanten-Geschäftszweck (creator/agentur/marke/kanzlei)
|
||||
-- gebunden, sondern zuständig für die Plattform selbst (Accounts,
|
||||
-- Kanzlei-Verzeichnis, Audit-Log). Bewusst KEIN eigenes account_role-Feld
|
||||
-- getrennt von app_user.role — ein Admin-Login ist genauso ein app_user
|
||||
-- wie jeder andere, nur mit einer anderen Rolle. Es gibt bewusst keine
|
||||
-- Selbstregistrierung für "admin" über /register (siehe internal/web) —
|
||||
-- der erste Admin wird per SQL angelegt (siehe CLAUDE.md).
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
|
||||
|
||||
-- verified markiert eine Kanzlei-Account als für das öffentliche,
|
||||
-- kostenlose Kanzlei-Verzeichnis freigegeben (siehe CLAUDE.md, § 49b
|
||||
-- Abs. 3 BRAO: keine Sachvorteile, kein Routing — das Verzeichnis ist
|
||||
-- eine reine Auflistung, keine Vermittlung). Nur für Accounts mit
|
||||
-- mindestens einem Nutzer der Rolle "kanzlei" sinnvoll; das erzwingt die
|
||||
-- Anwendungsschicht, nicht die Datenbank.
|
||||
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Audit-Log für Admin-Aktionen: append-only aus demselben Grund wie
|
||||
-- finding/extraction/evidence_package — ein Protokoll, das man ändern
|
||||
-- kann, ist kein Nachweis mehr.
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER audit_log_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
@@ -0,0 +1 @@
|
||||
DROP TRIGGER asset_append_only ON asset;
|
||||
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- asset war in 0001 ohne append-only-Trigger angelegt, weil bis jetzt
|
||||
-- nichts Assets tatsächlich schrieb. Ein hochgeladenes Standbild ist
|
||||
-- Teil der Beweiskette (SHA-256, siehe CLAUDE.md) genau wie extraction/
|
||||
-- finding/evidence_package — es nachträglich austauschen zu können,
|
||||
-- würde denselben Grund unterlaufen, aus dem diese Tabellen append-only
|
||||
-- sind.
|
||||
CREATE TRIGGER asset_append_only
|
||||
BEFORE UPDATE OR DELETE ON asset
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE platform_connection;
|
||||
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
|
||||
-- OAuth verbinden (siehe internal/socialconnect), damit die
|
||||
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
|
||||
-- abrufen kann statt ihn manuell hochzuladen. Anders als
|
||||
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
|
||||
-- und werden erneuert, eine Verbindung kann getrennt und neu
|
||||
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
|
||||
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
|
||||
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
|
||||
-- ON CONFLICT in UpsertPlatformConnection).
|
||||
CREATE TABLE platform_connection (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||
platform_user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, platform)
|
||||
);
|
||||
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE asset DROP COLUMN purpose;
|
||||
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
|
||||
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
|
||||
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
|
||||
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
|
||||
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
|
||||
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
|
||||
-- angelegten Zeilen unverändert.
|
||||
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
|
||||
CHECK (purpose IN ('initial', 'insights'));
|
||||
104
internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql
Normal file
104
internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql
Normal file
@@ -0,0 +1,104 @@
|
||||
-- Best-effort-Rückbau auf die Schema-Form des alten Werberecht-Produkts
|
||||
-- (Stand nach Migration 0007) — reine Struktur, keine Daten. Ein
|
||||
-- kompletter Produktwechsel wird in der Praxis nicht zurückgerollt,
|
||||
-- diese Datei existiert nur, damit "migrate down" nicht bricht.
|
||||
|
||||
DROP TABLE IF EXISTS antrag;
|
||||
DROP TABLE IF EXISTS werkzeug_sperre;
|
||||
DROP TABLE IF EXISTS werkzeug;
|
||||
DROP TABLE IF EXISTS abteilung;
|
||||
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
|
||||
|
||||
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE submission (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
|
||||
post_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
|
||||
caption TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
account_id UUID NOT NULL REFERENCES account (id)
|
||||
);
|
||||
|
||||
CREATE TABLE asset (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
|
||||
path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
purpose TEXT NOT NULL DEFAULT 'initial' CHECK (purpose IN ('initial', 'insights'))
|
||||
);
|
||||
CREATE TRIGGER asset_append_only
|
||||
BEFORE UPDATE OR DELETE ON asset
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE extraction (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
payload JSONB NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TRIGGER extraction_append_only
|
||||
BEFORE UPDATE OR DELETE ON extraction
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE finding (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
extraction_id UUID REFERENCES extraction (id),
|
||||
rule_id TEXT NOT NULL,
|
||||
rule_version INTEGER NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
|
||||
supersedes UUID REFERENCES finding (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
title TEXT NOT NULL,
|
||||
fix TEXT NOT NULL,
|
||||
sources TEXT[] NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
|
||||
CREATE TRIGGER finding_append_only
|
||||
BEFORE UPDATE OR DELETE ON finding
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE evidence_package (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
dossier_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
timestamp_token BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TRIGGER evidence_package_append_only
|
||||
BEFORE UPDATE OR DELETE ON evidence_package
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE participant (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||
name TEXT NOT NULL,
|
||||
vorgegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
freigegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE platform_connection (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||
platform_user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, platform)
|
||||
);
|
||||
117
internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql
Normal file
117
internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql
Normal file
@@ -0,0 +1,117 @@
|
||||
-- Produktwechsel: Deklarix war eine Kennzeichnungsprüfung für
|
||||
-- Werbe-Content (UWG/MStV), wird jetzt eine KI-Antragsprüfung
|
||||
-- (Fragebogen -> Datenklasse/KI-VO-Einstufung -> Werkzeug-Katalog ->
|
||||
-- Entscheidung). Alles, was ausschließlich für das alte Werberecht-
|
||||
-- Produkt existierte, wird entfernt. account/app_user/session/
|
||||
-- audit_log bleiben — Mandantentrennung, Login und
|
||||
-- Protokollierungsprinzip sind produktunabhängig.
|
||||
|
||||
DROP TABLE IF EXISTS platform_connection;
|
||||
DROP TABLE IF EXISTS asset;
|
||||
DROP TABLE IF EXISTS participant;
|
||||
DROP TABLE IF EXISTS evidence_package;
|
||||
DROP TABLE IF EXISTS finding;
|
||||
DROP TABLE IF EXISTS extraction;
|
||||
DROP TABLE IF EXISTS submission;
|
||||
|
||||
-- "verified"/Kanzlei-Verzeichnis gab es nur für die alte Berufsrecht-
|
||||
-- Sonderrolle "kanzlei".
|
||||
ALTER TABLE account DROP COLUMN IF EXISTS verified;
|
||||
|
||||
-- Fünf Rollen (Ebenen 2-5 der Frontend-Spezifikation):
|
||||
-- mitarbeiter Ebene 2 — stellt Anträge, sieht nur eigene
|
||||
-- verantwortlicher Ebene 3 — KI-Verantwortlicher, volles Entscheidungsrecht
|
||||
-- pruefer Ebene 3 — identische Sicht wie verantwortlicher, aber
|
||||
-- ohne Entscheidungsrecht (reine Prüfsicht)
|
||||
-- admin Ebene 4 — Mandanten-Verwaltung (Nutzer, Abteilungen,
|
||||
-- Anmeldeverfahren, eigene Werkzeug-Freigaben) — bezogen
|
||||
-- auf GENAU EINEN Mandanten, nicht plattformweit
|
||||
-- betreiber Ebene 5 — Netcell-IT-Personal, plattformweit
|
||||
-- (Werkzeugkatalog, Regelwerk, Mandantenverwaltung);
|
||||
-- entspricht der alten "admin"-Rolle vor dieser Migration
|
||||
--
|
||||
-- Die alte CHECK-Constraint muss zuerst weg, sonst lehnt sie die
|
||||
-- Datenmigration unten (die neue Rollenwerte wie "betreiber" schreibt)
|
||||
-- sofort ab — die alte Constraint kennt diese Werte ja noch nicht.
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
|
||||
-- Bestehende Zeilen auf gültige neue Werte umstellen, bevor die neue
|
||||
-- Constraint das erzwingt — sonst schlägt sie auf jeder Installation
|
||||
-- mit echten Nutzern fehl. Die alte Rolle "admin" war der Plattform-
|
||||
-- Betreiber (Netcell-IT) — wird zu "betreiber", nicht zum neuen,
|
||||
-- mandantenbezogenen "admin". Alle anderen alten Rollen (creator/agentur/
|
||||
-- marke/kanzlei) waren gewöhnliche Mandanten-Logins ohne Sonderrechte —
|
||||
-- werden zu "mitarbeiter", der schlankesten neuen Rolle.
|
||||
UPDATE app_user SET role = 'betreiber' WHERE role = 'admin';
|
||||
UPDATE app_user SET role = 'mitarbeiter' WHERE role IN ('creator', 'agentur', 'marke', 'kanzlei');
|
||||
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('mitarbeiter', 'verantwortlicher', 'pruefer', 'admin', 'betreiber'));
|
||||
|
||||
-- Stammdaten: Abteilungen zur Auswahl im Fragebogen (Feld A.abteilung).
|
||||
CREATE TABLE abteilung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, name)
|
||||
);
|
||||
|
||||
-- Werkzeugkatalog: der eigentliche Wert des Produkts. account_id NULL
|
||||
-- markiert einen zentral gepflegten, für alle Mandanten identischen
|
||||
-- Katalogeintrag; ein gesetzter account_id ist eine mandantenspezifische
|
||||
-- Ergänzung (siehe Spezifikation "jeder Mandant kann zusätzlich eigene
|
||||
-- Einträge ... führen"). letzte_pruefung und quelle sind Pflicht — jede
|
||||
-- Zusicherung im Katalog muss belegbar sein, siehe CLAUDE.md.
|
||||
CREATE TABLE werkzeug (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES account (id),
|
||||
name TEXT NOT NULL,
|
||||
anbieter TEXT NOT NULL,
|
||||
verarbeitungsort TEXT NOT NULL CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem')),
|
||||
avv_verfuegbar BOOLEAN NOT NULL DEFAULT false,
|
||||
avv_url TEXT NOT NULL DEFAULT '',
|
||||
training_opt_out BOOLEAN NOT NULL DEFAULT false,
|
||||
training_standard BOOLEAN NOT NULL DEFAULT false,
|
||||
aufbewahrung_tage INTEGER NOT NULL DEFAULT 0,
|
||||
zertifizierungen TEXT[] NOT NULL DEFAULT '{}',
|
||||
geeignete_zwecke TEXT[] NOT NULL DEFAULT '{}',
|
||||
einschraenkungen TEXT[] NOT NULL DEFAULT '{}',
|
||||
letzte_pruefung TIMESTAMPTZ NOT NULL,
|
||||
quelle TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Ein Mandant kann einen (auch zentralen) Katalogeintrag für sich
|
||||
-- sperren, ohne den zentralen Katalog selbst zu verändern.
|
||||
CREATE TABLE werkzeug_sperre (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
werkzeug_id UUID NOT NULL REFERENCES werkzeug (id),
|
||||
grund TEXT NOT NULL,
|
||||
gesperrt_am TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, werkzeug_id)
|
||||
);
|
||||
|
||||
-- Antrag: das Vorhaben aus Fragebogen-Abschnitt A, plus die vollständigen
|
||||
-- Antworten aus B/C/D als JSON (adaptiver Fragebogen — welche Folgefragen
|
||||
-- beantwortet wurden, hängt von vorherigen Antworten ab, ein starres
|
||||
-- Spaltenschema würde das nicht abbilden). status ist eine normale
|
||||
-- Zustandsänderung (wie submission es früher war), kein Beweis-Eintrag.
|
||||
CREATE TABLE antrag (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
ersteller_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
abteilung_id UUID REFERENCES abteilung (id),
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT NOT NULL DEFAULT '',
|
||||
ergebnis TEXT NOT NULL DEFAULT '',
|
||||
haeufigkeit TEXT NOT NULL DEFAULT 'einmalig'
|
||||
CHECK (haeufigkeit IN ('einmalig', 'gelegentlich', 'taeglich', 'automatisiert')),
|
||||
antworten JSONB NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'entwurf'
|
||||
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
1
internal/store/migrations/0009_bewertung.down.sql
Normal file
1
internal/store/migrations/0009_bewertung.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE bewertung;
|
||||
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Bewertung: der berechnete Vorschlag für einen Antrag (Datenklasse,
|
||||
-- KI-VO-Einstufung, Anforderungsprofil, zulässige/ausgeschlossene
|
||||
-- Werkzeuge mit Herleitung). Append-only wie finding/extraction im
|
||||
-- alten Produkt — eine neue Bewertung (z. B. nach Änderung der
|
||||
-- Antworten oder des Katalogs) ergänzt die alte, ersetzt sie nicht;
|
||||
-- die Historie bleibt für den Audit erhalten. regelwerk_version und
|
||||
-- katalog_version werden zum Berechnungszeitpunkt eingefroren.
|
||||
CREATE TABLE bewertung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
datenklasse TEXT NOT NULL,
|
||||
datenklasse_herleitung TEXT NOT NULL,
|
||||
einstufung TEXT NOT NULL,
|
||||
einstufung_herleitung TEXT NOT NULL,
|
||||
verboten BOOLEAN NOT NULL DEFAULT false,
|
||||
anforderungen JSONB NOT NULL DEFAULT '[]',
|
||||
zulaessige_werkzeuge TEXT[] NOT NULL DEFAULT '{}',
|
||||
ausgeschlossene_werkzeuge JSONB NOT NULL DEFAULT '[]',
|
||||
regelwerk_version TEXT NOT NULL,
|
||||
katalog_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER bewertung_append_only
|
||||
BEFORE UPDATE OR DELETE ON bewertung
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE entscheidung;
|
||||
25
internal/store/migrations/0010_entscheidung.up.sql
Normal file
25
internal/store/migrations/0010_entscheidung.up.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Entscheidung: der Mensch entscheidet, das System bereitet nur vor
|
||||
-- (siehe CLAUDE.md, Grundregel). Eine Entscheidung bezieht sich auf
|
||||
-- genau eine Bewertung (den Vorschlag, auf dessen Basis entschieden
|
||||
-- wurde) und friert optional ein gewähltes Werkzeug als vollständigen
|
||||
-- Datensatz ein (werkzeug_snapshot) — ein späterer Katalog-Wandel darf
|
||||
-- nicht rückwirkend verändern, worauf eine Entscheidung beruhte.
|
||||
-- Append-only wie bewertung/audit_log: eine Entscheidung wird nicht
|
||||
-- korrigiert, sondern durch eine neue Entscheidung auf Basis einer
|
||||
-- neuen Bewertung ersetzt (Neubewertung + erneute Entscheidung).
|
||||
CREATE TABLE entscheidung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
bewertung_id UUID NOT NULL REFERENCES bewertung (id),
|
||||
entscheider_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
entscheidung TEXT NOT NULL CHECK (entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen', 'abgelehnt', 'rueckfrage')),
|
||||
werkzeug_id UUID REFERENCES werkzeug (id),
|
||||
werkzeug_snapshot JSONB,
|
||||
begruendung TEXT NOT NULL DEFAULT '',
|
||||
gueltig_bis TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER entscheidung_append_only
|
||||
BEFORE UPDATE OR DELETE ON entscheidung
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE registereintrag;
|
||||
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Registereintrag: wird automatisch bei jeder Genehmigung erzeugt
|
||||
-- (genehmigt / genehmigt_mit_auflagen), siehe CLAUDE.md, Entscheidung/
|
||||
-- Register/Wiedervorlage. Bewusst denormalisiert (eigene Textspalten
|
||||
-- statt Joins über bewertung/entscheidung/werkzeug) — ein Registereintrag
|
||||
-- ist ein für sich lesbarer, exportierbarer Nachweis, der nicht von
|
||||
-- einem späteren Katalog- oder Abteilungs-Wandel abhängen darf.
|
||||
-- Append-only wie bewertung/entscheidung.
|
||||
CREATE TABLE registereintrag (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
entscheidung_id UUID NOT NULL REFERENCES entscheidung (id),
|
||||
zweck TEXT NOT NULL,
|
||||
abteilung TEXT NOT NULL DEFAULT '',
|
||||
werkzeug TEXT NOT NULL,
|
||||
datenklasse TEXT NOT NULL,
|
||||
einstufung TEXT NOT NULL,
|
||||
auflagen TEXT[] NOT NULL DEFAULT '{}',
|
||||
verantwortlicher TEXT NOT NULL,
|
||||
entschieden_am TIMESTAMPTZ NOT NULL,
|
||||
gueltig_bis TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER registereintrag_append_only
|
||||
BEFORE UPDATE OR DELETE ON registereintrag
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE app_user DROP COLUMN active;
|
||||
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Nutzer können nicht gelöscht werden (app_user wird von antrag,
|
||||
-- session, bewertung [über antrag], entscheidung, audit_log,
|
||||
-- registereintrag [über antrag] per Foreign Key referenziert — ein
|
||||
-- Hard-Delete würde die Historie zerstören). Stattdessen: deaktivieren.
|
||||
-- Ein deaktivierter Nutzer kann sich nicht mehr anmelden, bleibt aber
|
||||
-- als Akteur in Anträgen/Entscheidungen/Audit-Log nachvollziehbar.
|
||||
ALTER TABLE app_user ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE account DROP COLUMN einladung_token;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
|
||||
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
|
||||
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
|
||||
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
|
||||
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
|
||||
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
|
||||
-- dass store.CreateAccount etwas ändern muss.
|
||||
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE session DROP COLUMN impersonated_by_user_id;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
|
||||
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
|
||||
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
|
||||
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
|
||||
-- — die authenticate-Middleware liest das, um in der Nav einen
|
||||
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
|
||||
-- er/sie gerade im Kontext eines fremden Kontos handelt.
|
||||
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);
|
||||
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal file
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE werkzeug DROP COLUMN subprozessoren;
|
||||
|
||||
UPDATE werkzeug SET aufbewahrung_tage = 0 WHERE aufbewahrung_tage IS NULL;
|
||||
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET DEFAULT 0;
|
||||
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET NOT NULL;
|
||||
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal file
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- aufbewahrung_tage war bisher NOT NULL DEFAULT 0 — das machte "0" doppeldeutig:
|
||||
-- mal eine echte, belegte Zusicherung (z. B. DeepL: sofortige Löschung),
|
||||
-- mal ein Platzhalter für "vom Anbieter nicht beziffert". NULL trennt das
|
||||
-- sauber: "unbekannt" statt "angeblich sofort gelöscht".
|
||||
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP NOT NULL;
|
||||
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP DEFAULT;
|
||||
|
||||
-- Subprozessoren (z. B. Anthropic als Unterauftragsverarbeiter bei
|
||||
-- Microsoft 365 Copilot, AWS bei DeepL) verändern die EU/USA-Einstufung
|
||||
-- eines Werkzeugs materiell — bisher nur als Freitext in
|
||||
-- "einschraenkungen" erfasst, jetzt als eigenes, durchsuchbares Feld.
|
||||
ALTER TABLE werkzeug ADD COLUMN subprozessoren TEXT[] NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE werkzeug ADD COLUMN verarbeitungsort TEXT;
|
||||
|
||||
-- Ein Rückbau von Freitext-Ländern auf den alten Vier-Werte-Eimer ist
|
||||
-- verlustbehaftet (welches Land genau "gemischt" war, geht verloren) —
|
||||
-- bestmögliche Näherung statt Datenverlust durch NULL.
|
||||
UPDATE werkzeug SET verarbeitungsort =
|
||||
CASE
|
||||
WHEN verarbeitungslaender = ARRAY['On-Premise (selbst gehostet)'] THEN 'on-prem'
|
||||
WHEN verarbeitungslaender = ARRAY['USA'] THEN 'USA'
|
||||
WHEN array_length(verarbeitungslaender, 1) IS NULL OR array_length(verarbeitungslaender, 1) = 0 THEN 'gemischt'
|
||||
WHEN 'USA' = ANY(verarbeitungslaender) THEN 'gemischt'
|
||||
ELSE 'EU'
|
||||
END;
|
||||
|
||||
ALTER TABLE werkzeug ALTER COLUMN verarbeitungsort SET NOT NULL;
|
||||
ALTER TABLE werkzeug ADD CONSTRAINT werkzeug_verarbeitungsort_check
|
||||
CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem'));
|
||||
|
||||
ALTER TABLE werkzeug DROP COLUMN verarbeitungslaender;
|
||||
ALTER TABLE werkzeug DROP COLUMN dpf_zertifiziert;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- "EU"/"USA"/"gemischt"/"on-prem" als grobe Eimer verschleierten, dass
|
||||
-- auch die USA bereits ein Drittland sind (DSGVO Art. 44 ff. kennt nur
|
||||
-- EU/EWR vs. Drittland) und dass "gemischt" nichts über die tatsächlich
|
||||
-- beteiligten Länder aussagt (z. B. Irland+USA bei Microsoft 365
|
||||
-- Copilot vs. Frankreich bei Mistral) — beides macht einen Unterschied
|
||||
-- für die Einschätzung, nicht nur für die Anzeige. Ersetzt durch eine
|
||||
-- Liste tatsächlicher Länder (oder einer Region, wenn der Anbieter
|
||||
-- selbst keinen einzelnen Staat benennt, z. B. "EU-Region" bei
|
||||
-- OpenAI/Microsoft).
|
||||
ALTER TABLE werkzeug ADD COLUMN verarbeitungslaender TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
ALTER TABLE werkzeug DROP CONSTRAINT werkzeug_verarbeitungsort_check;
|
||||
|
||||
UPDATE werkzeug SET verarbeitungslaender =
|
||||
CASE verarbeitungsort
|
||||
WHEN 'EU' THEN ARRAY['EU-Region (Land nicht migriert, bitte prüfen)']
|
||||
WHEN 'USA' THEN ARRAY['USA']
|
||||
WHEN 'on-prem' THEN ARRAY['On-Premise (selbst gehostet)']
|
||||
ELSE ARRAY['nicht spezifiziert (bitte prüfen)']
|
||||
END;
|
||||
|
||||
ALTER TABLE werkzeug DROP COLUMN verarbeitungsort;
|
||||
|
||||
-- Ob ein US-Anbieter unter dem EU-US Data Privacy Framework zertifiziert
|
||||
-- ist (schmalere, für Schrems-Nachfolgeverfahren anfällige Rechtsgrundlage)
|
||||
-- oder sich rein auf Standardvertragsklauseln stützt, stand bisher nur als
|
||||
-- Fließtext in "einschraenkungen" — jetzt strukturiert und damit künftig
|
||||
-- filterbar.
|
||||
ALTER TABLE werkzeug ADD COLUMN dpf_zertifiziert BOOLEAN NOT NULL DEFAULT false;
|
||||
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||
UPDATE antrag SET status = 'eingereicht' WHERE status = 'wartet_auf_freigabe';
|
||||
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden'));
|
||||
|
||||
DROP TABLE freigabeschritt;
|
||||
DROP TABLE freigabe_regel;
|
||||
DROP TABLE nutzer_genehmiger_rolle;
|
||||
DROP TABLE genehmiger_rolle;
|
||||
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Konfigurierbarer Mehrfach-Freigabe-Workflow (2026-08-31): manche
|
||||
-- Anträge brauchen zusätzlich zur normalen Fachebene-Entscheidung eine
|
||||
-- Freigabe durch bestimmte Personen (z. B. Datenschutzbeauftragte bei
|
||||
-- dsfa_erforderlich, Geschäftsführung bei hochrisiko). Bewusst additiv
|
||||
-- zur bestehenden app_user.role (Zugriffskontrolle) — eine
|
||||
-- Genehmiger-Rolle ist eine reine Freigabe-Funktion, keine Berechtigung,
|
||||
-- und eine Person kann mehrere davon gleichzeitig innehaben.
|
||||
|
||||
-- Stammdaten wie abteilung, pro Mandant frei benennbar.
|
||||
CREATE TABLE genehmiger_rolle (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, name)
|
||||
);
|
||||
|
||||
-- Welche Personen eine Genehmiger-Rolle innehaben (n:m).
|
||||
CREATE TABLE nutzer_genehmiger_rolle (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (app_user_id, genehmiger_rolle_id)
|
||||
);
|
||||
|
||||
-- "Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
|
||||
-- Genehmiger-Rolle Y nötig." Bedingung ist bewusst eine der bereits vom
|
||||
-- Regelwerk abgeleiteten Größen (Anforderungs-ID, Einstufungs-ID,
|
||||
-- Datenklasse-ID) — kein freier Regel-Editor, keine beliebige Logik.
|
||||
CREATE TABLE freigabe_regel (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
bedingung_typ TEXT NOT NULL CHECK (bedingung_typ IN ('anforderung', 'einstufung', 'datenklasse')),
|
||||
bedingung_wert TEXT NOT NULL,
|
||||
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Pro Antrag ein Eintrag je durch eine Regel ausgelöster nötiger
|
||||
-- Freigabe. Nicht append-only (wie werkzeug) — ein Freigabeschritt ist
|
||||
-- eine einzelne Aufgabe, die von ausstehend in einen Endzustand
|
||||
-- übergeht, keine Historie mehrerer Entscheidungen zum selben Schritt.
|
||||
CREATE TABLE freigabeschritt (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||
status TEXT NOT NULL DEFAULT 'ausstehend' CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')),
|
||||
entschieden_von UUID REFERENCES app_user (id),
|
||||
entschieden_am TIMESTAMPTZ,
|
||||
kommentar TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Ein Antrag, der auf zusätzliche Freigaben wartet, ist weder "nur
|
||||
-- eingereicht" noch schon "entschieden" — eigener Zwischenzustand.
|
||||
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||
CHECK (status IN ('entwurf', 'eingereicht', 'wartet_auf_freigabe', 'entschieden'));
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE password_reset_token;
|
||||
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE password_reset_token (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES app_user(id),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX password_reset_token_user_id_idx ON password_reset_token(user_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE loeschfrist_einstellung;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE loeschfrist_einstellung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account(id),
|
||||
datenklasse_id TEXT NOT NULL,
|
||||
max_tage INTEGER NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, datenklasse_id)
|
||||
);
|
||||
58
internal/store/migrations/0021_row_level_security.down.sql
Normal file
58
internal/store/migrations/0021_row_level_security.down.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
ALTER TABLE antrag NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE antrag DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON antrag;
|
||||
|
||||
ALTER TABLE registereintrag NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE registereintrag DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON registereintrag;
|
||||
|
||||
ALTER TABLE abteilung NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE abteilung DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON abteilung;
|
||||
|
||||
ALTER TABLE werkzeug_sperre NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE werkzeug_sperre DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON werkzeug_sperre;
|
||||
|
||||
ALTER TABLE genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE genehmiger_rolle DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON genehmiger_rolle;
|
||||
|
||||
ALTER TABLE freigabe_regel NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE freigabe_regel DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON freigabe_regel;
|
||||
|
||||
ALTER TABLE loeschfrist_einstellung NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE loeschfrist_einstellung DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON loeschfrist_einstellung;
|
||||
|
||||
ALTER TABLE werkzeug NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE werkzeug DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON werkzeug;
|
||||
|
||||
ALTER TABLE bewertung NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE bewertung DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON bewertung;
|
||||
|
||||
ALTER TABLE entscheidung NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE entscheidung DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON entscheidung;
|
||||
|
||||
ALTER TABLE freigabeschritt NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE freigabeschritt DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON freigabeschritt;
|
||||
|
||||
ALTER TABLE nutzer_genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE nutzer_genehmiger_rolle DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON nutzer_genehmiger_rolle;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
|
||||
EXECUTE 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM deklarix_app';
|
||||
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM deklarix_app';
|
||||
EXECUTE 'REVOKE USAGE ON SCHEMA public FROM deklarix_app';
|
||||
DROP ROLE deklarix_app;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
187
internal/store/migrations/0021_row_level_security.up.sql
Normal file
187
internal/store/migrations/0021_row_level_security.up.sql
Normal file
@@ -0,0 +1,187 @@
|
||||
-- Mandantenisolation auf Datenbankebene (Postgres Row-Level Security).
|
||||
--
|
||||
-- WICHTIG, per Incident am 2026-09-01 gelernt: RLS-Policies wirken NIE
|
||||
-- bei Postgres-Superusern, und NIE beim Tabellenbesitzer ohne FORCE ROW
|
||||
-- LEVEL SECURITY. Welcher Fall zutrifft, hängt von der Umgebung ab:
|
||||
-- - Produktion verbindet als eigene, nicht-privilegierte Rolle (z. B.
|
||||
-- "deklarix"), die zugleich Eigentümerin der Tabellen ist (sie hat
|
||||
-- sie über die Migrationen selbst angelegt) — für sie reicht FORCE
|
||||
-- ROW LEVEL SECURITY völlig aus, keine weitere Rolle nötig.
|
||||
-- - Manche Entwicklungs-/Testumgebungen verbinden dagegen als
|
||||
-- echter Postgres-Superuser (z. B. lokales Docker-Postgres mit
|
||||
-- "postgres") — für den wirkt FORCE nicht (Superuser sind davon
|
||||
-- laut Postgres-Dokumentation ausdrücklich ausgenommen). Dort kann
|
||||
-- zusätzlich eine eingeschränkte Rolle "deklarix_app" angelegt
|
||||
-- werden, für die die Policies unabhängig von FORCE gelten.
|
||||
--
|
||||
-- Diese Migration deckt BEIDE Fälle ab, ohne bei fehlendem CREATEROLE
|
||||
-- fehlzuschlagen (das brachte den Dienst am 2026-09-01 für ~3 Minuten
|
||||
-- zum Absturz, siehe CLAUDE.md) — das Anlegen von "deklarix_app" ist
|
||||
-- rein optional und wird übersprungen, wenn die aktuelle Rolle dafür
|
||||
-- keine Berechtigung hat.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND rolcreaterole) THEN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
|
||||
CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN;
|
||||
END IF;
|
||||
EXECUTE 'GRANT USAGE ON SCHEMA public TO deklarix_app';
|
||||
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO deklarix_app';
|
||||
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO deklarix_app';
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle % hat kein CREATEROLE — deklarix_app wird übersprungen, FORCE ROW LEVEL SECURITY schützt stattdessen direkt die bestehende (Tabellenbesitzer-)Rolle.', current_user;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Login-Fähigkeit + Passwort für deklarix_app (falls angelegt) werden
|
||||
-- einmalig manuell je Umgebung gesetzt, NIE in einer versionierten
|
||||
-- Migration (Klartext-Secret gehört nicht ins Repo):
|
||||
-- ALTER ROLE deklarix_app WITH LOGIN PASSWORD '<generiertes Secret>';
|
||||
-- Danach optional DATABASE_URL_APP in der jeweiligen deklarix.env
|
||||
-- eintragen. Für Umgebungen, in denen die Anwendung bereits als
|
||||
-- Tabellenbesitzer (nicht-Superuser) verbindet, ist das NICHT nötig —
|
||||
-- FORCE ROW LEVEL SECURITY unten reicht dort aus.
|
||||
|
||||
-- ─── Tabellen MIT direkter account_id-Spalte ───────────────────────────
|
||||
-- Eine einzelne Policy (FOR ALL) pro Tabelle deckt SELECT/UPDATE/DELETE
|
||||
-- (USING) und INSERT/UPDATE (WITH CHECK) ab. NULLIF(..., '')::uuid
|
||||
-- verhindert einen harten Cast-Fehler, wenn app.account_id nie gesetzt
|
||||
-- oder auf '' steht (z. B. vor der eigentlichen Anmeldung) — die
|
||||
-- Bedingung wird dann einfach UNKNOWN/false statt eines SQL-Fehlers.
|
||||
CREATE POLICY tenant_isolation ON antrag FOR ALL USING (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
);
|
||||
ALTER TABLE antrag ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE antrag FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
);
|
||||
ALTER TABLE registereintrag ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE registereintrag FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON abteilung FOR ALL USING (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
);
|
||||
ALTER TABLE abteilung ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE abteilung FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
);
|
||||
ALTER TABLE werkzeug_sperre ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE werkzeug_sperre FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
);
|
||||
ALTER TABLE genehmiger_rolle ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE genehmiger_rolle FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
);
|
||||
ALTER TABLE freigabe_regel ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE freigabe_regel FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
);
|
||||
ALTER TABLE loeschfrist_einstellung ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE loeschfrist_einstellung FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- ─── werkzeug: account_id NULLABLE (NULL = zentraler Katalog) ──────────
|
||||
-- Lesen: jeder sieht zentrale (NULL) Einträge plus die eigenen. NUR der
|
||||
-- Betreiber darf einen zentralen (NULL) Eintrag anlegen/ändern, ein
|
||||
-- Mandant nur seine eigenen — sonst könnte ein Mandant über einen
|
||||
-- vergessenen Anwendungscheck einen zentralen Katalogeintrag verändern.
|
||||
CREATE POLICY tenant_isolation ON werkzeug FOR ALL USING (
|
||||
account_id IS NULL
|
||||
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
|
||||
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
);
|
||||
ALTER TABLE werkzeug ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE werkzeug FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- ─── Tabellen OHNE eigene account_id, über Fremdschlüssel abgeleitet ───
|
||||
-- antrag/genehmiger_rolle sind selbst schon RLS-geschützt (s. o.) — eine
|
||||
-- Unterabfrage gegen sie erbt in derselben Sitzung automatisch dieselbe
|
||||
-- Mandantengrenze, ohne die Bedingung hier zu duplizieren.
|
||||
CREATE POLICY tenant_isolation ON bewertung FOR ALL USING (
|
||||
antrag_id IN (SELECT id FROM antrag)
|
||||
) WITH CHECK (
|
||||
antrag_id IN (SELECT id FROM antrag)
|
||||
);
|
||||
ALTER TABLE bewertung ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE bewertung FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING (
|
||||
antrag_id IN (SELECT id FROM antrag)
|
||||
) WITH CHECK (
|
||||
antrag_id IN (SELECT id FROM antrag)
|
||||
);
|
||||
ALTER TABLE entscheidung ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE entscheidung FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING (
|
||||
antrag_id IN (SELECT id FROM antrag)
|
||||
) WITH CHECK (
|
||||
antrag_id IN (SELECT id FROM antrag)
|
||||
);
|
||||
ALTER TABLE freigabeschritt ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE freigabeschritt FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING (
|
||||
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
|
||||
) WITH CHECK (
|
||||
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
|
||||
);
|
||||
ALTER TABLE nutzer_genehmiger_rolle ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE nutzer_genehmiger_rolle FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- ─── Bewusst OHNE RLS ───────────────────────────────────────────────────
|
||||
-- account: hat keine account_id-Spalte (ist selbst der Mandant) und
|
||||
-- muss bei der Registrierung uneingeschränkt INSERT erlauben, bevor
|
||||
-- die neue ID überhaupt bekannt ist.
|
||||
-- app_user: Login/Passwort-Zurücksetzen suchen per E-Mail über ALLE
|
||||
-- Mandanten hinweg (die Ziel-account_id ist zu diesem Zeitpunkt noch
|
||||
-- nicht bekannt) — kein Datenleck, da E-Mail-Adressen exakt und nicht
|
||||
-- in Bulk abgefragt werden, kein sequentiell erratbarer Schlüssel.
|
||||
-- session, password_reset_token: werden ausschließlich über einen
|
||||
-- kryptographisch zufälligen, praktisch unerratbaren Token gesucht,
|
||||
-- nicht über eine sequentielle ID — dieselbe Begründung wie app_user.
|
||||
-- audit_log: plattformweites Protokoll, wird ausschließlich vom
|
||||
-- Betreiber (Ebene 5, sieht ohnehin alle Mandanten) gelesen, hat keine
|
||||
-- eigene account_id-Spalte.
|
||||
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal file
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE email_vorlage NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE email_vorlage DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON email_vorlage;
|
||||
DROP TABLE email_vorlage;
|
||||
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal file
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- Editierbare E-Mail-Vorlagen, zweistufig wie der Werkzeugkatalog:
|
||||
-- account_id NULL = plattformweiter Standard (nur vom Betreiber
|
||||
-- editierbar, z. B. künftige E-Mails, die die Plattform selbst an
|
||||
-- Mandanten-Admins schickt), account_id gesetzt = mandantenspezifische
|
||||
-- Übersteuerung (vom Mandanten-Admin editierbar, z. B. eigener Ton/
|
||||
-- Branding für eine E-Mail, die an die eigenen Mitarbeiter geht).
|
||||
-- "typ" identifiziert, welche vom System versendete E-Mail gemeint ist
|
||||
-- (aktuell nur "passwort_zuruecksetzen" — die einzige E-Mail, die das
|
||||
-- System bisher verschickt, siehe internal/mail). Text darf Platzhalter
|
||||
-- wie "{{link}}" enthalten, die beim Versand ersetzt werden (siehe
|
||||
-- internal/web/email_vorlage_handlers.go).
|
||||
CREATE TABLE email_vorlage (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES account(id),
|
||||
typ TEXT NOT NULL,
|
||||
betreff TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ACHTUNG: ein einfaches UNIQUE (account_id, typ) würde NICHT reichen —
|
||||
-- SQL behandelt NULL nie als gleich zu NULL, ein normaler UNIQUE-
|
||||
-- Constraint hätte also beliebig viele Plattform-Standard-Zeilen
|
||||
-- (account_id IS NULL) je typ zugelassen. Zwei partielle Unique-Indizes
|
||||
-- statt eines gemeinsamen Constraints, dafür braucht UpsertEmailVorlage
|
||||
-- zwei unterschiedliche ON-CONFLICT-Ziele (siehe internal/store/email_vorlage.go).
|
||||
CREATE UNIQUE INDEX email_vorlage_plattform_uidx ON email_vorlage (typ) WHERE account_id IS NULL;
|
||||
CREATE UNIQUE INDEX email_vorlage_mandant_uidx ON email_vorlage (account_id, typ) WHERE account_id IS NOT NULL;
|
||||
|
||||
-- Plattformweiter Standard für die einzige aktuell existierende
|
||||
-- E-Mail — ohne diese Zeile gäbe es nichts, worauf ResolveEmailVorlage
|
||||
-- zurückfallen könnte, solange ein Mandant keine eigene Vorlage hat.
|
||||
INSERT INTO email_vorlage (account_id, typ, betreff, text) VALUES (
|
||||
NULL,
|
||||
'passwort_zuruecksetzen',
|
||||
'Deklarix — Passwort zurücksetzen',
|
||||
'Hallo,' || E'\n\n' ||
|
||||
'über diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):' || E'\n' ||
|
||||
'{{link}}' || E'\n\n' ||
|
||||
'Falls du das nicht angefordert hast, ignoriere diese E-Mail.'
|
||||
);
|
||||
|
||||
-- Wie werkzeug (account_id NULLABLE): NULL-Zeilen sind für alle lesbar,
|
||||
-- aber nur vom Betreiber schreibbar; ein Mandant darf nur seine eigene
|
||||
-- account_id-Zeile anlegen/ändern.
|
||||
CREATE POLICY tenant_isolation ON email_vorlage FOR ALL USING (
|
||||
account_id IS NULL
|
||||
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
|
||||
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
);
|
||||
ALTER TABLE email_vorlage ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE email_vorlage FORCE ROW LEVEL SECURITY;
|
||||
77
internal/store/password_reset.go
Normal file
77
internal/store/password_reset.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Passwort-Zurücksetzen-Tokens (Migration 0019). Ein Token ist einmal
|
||||
// verwendbar (used_at) und läuft ab (expires_at) — GetValidPasswordResetToken
|
||||
// liefert ErrNotFound für "nicht gefunden", "abgelaufen" und "schon
|
||||
// verwendet" gleichermaßen, damit ein Angreifer über die Fehlermeldung
|
||||
// nichts über den Zustand eines geratenen Tokens lernt.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// PasswordResetTokenDuration ist die Gültigkeitsdauer eines frisch
|
||||
// erzeugten Zurücksetzen-Links.
|
||||
const PasswordResetTokenDuration = 1 * time.Hour
|
||||
|
||||
type PasswordResetToken struct {
|
||||
ID string
|
||||
UserID string
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreatePasswordResetToken legt einen neuen Zurücksetzen-Token für
|
||||
// einen Nutzer an. token muss bereits kryptographisch zufällig erzeugt
|
||||
// sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst.
|
||||
func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) {
|
||||
var t PasswordResetToken
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO password_reset_token (user_id, token, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, user_id, token, expires_at, used_at, created_at
|
||||
`, userID, token, time.Now().Add(PasswordResetTokenDuration)).Scan(
|
||||
&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return PasswordResetToken{}, fmt.Errorf("store: create password reset token: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetValidPasswordResetToken liest einen Token nur, wenn er existiert,
|
||||
// noch nicht abgelaufen und noch nicht verwendet ist.
|
||||
func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) {
|
||||
var t PasswordResetToken
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, user_id, token, expires_at, used_at, created_at
|
||||
FROM password_reset_token
|
||||
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
|
||||
`, token).Scan(&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PasswordResetToken{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return PasswordResetToken{}, fmt.Errorf("store: get valid password reset token: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe
|
||||
// Link kein zweites Mal ein Passwort setzen kann.
|
||||
func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: mark password reset token used: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
internal/store/password_reset_test.go
Normal file
58
internal/store/password_reset_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestPasswordResetTokenLifecycle(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
tok, err := s.CreatePasswordResetToken(ctx, userID, "test-token-123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePasswordResetToken: %v", err)
|
||||
}
|
||||
if tok.UserID != userID || tok.Token != "test-token-123" {
|
||||
t.Fatalf("PasswordResetToken = %+v, unerwartete Werte", tok)
|
||||
}
|
||||
|
||||
got, err := s.GetValidPasswordResetToken(ctx, "test-token-123")
|
||||
if err != nil || got.ID != tok.ID {
|
||||
t.Fatalf("GetValidPasswordResetToken = %+v, err=%v", got, err)
|
||||
}
|
||||
|
||||
if err := s.MarkPasswordResetTokenUsed(ctx, tok.ID); err != nil {
|
||||
t.Fatalf("MarkPasswordResetTokenUsed: %v", err)
|
||||
}
|
||||
|
||||
// Ein verbrauchter Token gilt nicht mehr als gültig.
|
||||
if _, err := s.GetValidPasswordResetToken(ctx, "test-token-123"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Verbrauch = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Ein unbekannter Token ist von Anfang an ErrNotFound.
|
||||
if _, err := s.GetValidPasswordResetToken(ctx, "existiert-nicht"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err für unbekannten Token = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserPassword(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
if err := s.SetUserPassword(ctx, userID, "neuer-hash"); err != nil {
|
||||
t.Fatalf("SetUserPassword: %v", err)
|
||||
}
|
||||
got, err := s.GetUser(ctx, userID)
|
||||
if err != nil || got.PasswordHash != "neuer-hash" {
|
||||
t.Fatalf("GetUser nach SetUserPassword = %+v, err=%v", got, err)
|
||||
}
|
||||
}
|
||||
106
internal/store/registereintrag.go
Normal file
106
internal/store/registereintrag.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registereintrag wird automatisch bei jeder Genehmigung erzeugt
|
||||
// (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage). Append-only
|
||||
// — siehe Migration. Bewusst denormalisiert: eigene Textspalten statt
|
||||
// Joins über bewertung/entscheidung/werkzeug, damit ein einmal
|
||||
// erzeugter Eintrag nicht von einem späteren Katalog- oder Abteilungs-
|
||||
// Wandel abhängt.
|
||||
type Registereintrag struct {
|
||||
ID string
|
||||
AccountID string
|
||||
AntragID string
|
||||
EntscheidungID string
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen []string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm time.Time
|
||||
GueltigBis *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const registereintragColumns = `id, account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis, created_at`
|
||||
|
||||
func scanRegistereintrag(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Registereintrag, error) {
|
||||
var e Registereintrag
|
||||
err := row.Scan(
|
||||
&e.ID, &e.AccountID, &e.AntragID, &e.EntscheidungID, &e.Zweck, &e.Abteilung, &e.Werkzeug,
|
||||
&e.Datenklasse, &e.Einstufung, &e.Auflagen, &e.Verantwortlicher, &e.EntschiedenAm, &e.GueltigBis, &e.CreatedAt,
|
||||
)
|
||||
return e, err
|
||||
}
|
||||
|
||||
// RegistereintragInput bündelt die Felder eines neuen Registereintrags.
|
||||
type RegistereintragInput struct {
|
||||
AccountID string
|
||||
AntragID string
|
||||
EntscheidungID string
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen []string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm time.Time
|
||||
GueltigBis *time.Time
|
||||
}
|
||||
|
||||
// CreateRegistereintrag speichert einen Registereintrag.
|
||||
func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInput) (Registereintrag, error) {
|
||||
if in.Auflagen == nil {
|
||||
in.Auflagen = []string{}
|
||||
}
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO registereintrag (
|
||||
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING `+registereintragColumns,
|
||||
in.AccountID, in.AntragID, in.EntscheidungID, in.Zweck, in.Abteilung, in.Werkzeug,
|
||||
in.Datenklasse, in.Einstufung, in.Auflagen, in.Verantwortlicher, in.EntschiedenAm, in.GueltigBis,
|
||||
)
|
||||
e, err := scanRegistereintrag(row)
|
||||
if err != nil {
|
||||
return Registereintrag{}, fmt.Errorf("store: create registereintrag: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
|
||||
// Mandanten, neueste zuerst.
|
||||
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Registereintrag
|
||||
for rows.Next() {
|
||||
e, err := scanRegistereintrag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan registereintrag: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
100
internal/store/registereintrag_test.go
Normal file
100
internal/store/registereintrag_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testRegistereintragInput(accountID, antragID, entscheidungID string) store.RegistereintragInput {
|
||||
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
return store.RegistereintragInput{
|
||||
AccountID: accountID, AntragID: antragID, EntscheidungID: entscheidungID,
|
||||
Zweck: "Angebotstexte generieren", Abteilung: "Vertrieb", Werkzeug: "ChatGPT Enterprise",
|
||||
Datenklasse: "personenbezogen", Einstufung: "minimal",
|
||||
Auflagen: []string{"AVV erforderlich", "kein Training auf Eingaben"},
|
||||
Verantwortlicher: "verantwortlicher@example.com", EntschiedenAm: time.Now().Truncate(time.Millisecond),
|
||||
GueltigBis: &gueltigBis,
|
||||
}
|
||||
}
|
||||
|
||||
func testEntscheidungID(t *testing.T, s *store.Store, antragID, bewertungID, userID string) string {
|
||||
t.Helper()
|
||||
e, err := s.CreateEntscheidung(context.Background(), store.EntscheidungInput{
|
||||
AntragID: antragID, BewertungID: bewertungID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
return e.ID
|
||||
}
|
||||
|
||||
func TestRegistereintragCreateAndList(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||
|
||||
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||
}
|
||||
if e.Werkzeug != "ChatGPT Enterprise" || len(e.Auflagen) != 2 {
|
||||
t.Fatalf("CreateRegistereintrag = %+v, unerwartete Werte", e)
|
||||
}
|
||||
|
||||
list, err := s.ListRegistereintraegeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistereintraegeForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != e.ID {
|
||||
t.Fatalf("ListRegistereintraegeForAccount = %+v, want exactly one entry", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistereintragIsolatesTenants(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accA)
|
||||
antragA, bewertungA := testAntragMitBewertung(t, s, accA, userA)
|
||||
entscheidungA := testEntscheidungID(t, s, antragA.ID, bewertungA.ID, userA)
|
||||
if _, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accA, antragA.ID, entscheidungA)); err != nil {
|
||||
t.Fatalf("CreateRegistereintrag (A): %v", err)
|
||||
}
|
||||
|
||||
accB := testAccountID(t, s)
|
||||
list, err := s.ListRegistereintraegeForAccount(ctx, accB)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistereintraegeForAccount (B): %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no registereintraege for a different tenant, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistereintragIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE registereintrag SET werkzeug = 'geaendert' WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on registereintrag to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM registereintrag WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on registereintrag to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
94
internal/store/session.go
Normal file
94
internal/store/session.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
||||
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||
// eine Session wird immer über ihren Token nachgeschlagen.
|
||||
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
|
||||
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
|
||||
// statt durch den regulären Login des Nutzers selbst.
|
||||
type Session struct {
|
||||
Token string
|
||||
UserID string
|
||||
ImpersonatedByUserID *string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
|
||||
|
||||
func scanSession(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Session, error) {
|
||||
var sess Session
|
||||
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||
return sess, err
|
||||
}
|
||||
|
||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO session (token, user_id, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING `+sessionColumns,
|
||||
token, userID, expiresAt,
|
||||
)
|
||||
sess, err := scanSession(row)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
|
||||
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
|
||||
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
|
||||
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
||||
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
||||
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+sessionColumns,
|
||||
token, userID, impersonatedByUserID, expiresAt,
|
||||
)
|
||||
sess, err := scanSession(row)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: create impersonated session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
||||
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||
sess, err := scanSession(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("store: get session: %w", err)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// DeleteSession beendet eine Sitzung (Logout).
|
||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
if _, err := s.db(ctx).Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
||||
return fmt.Errorf("store: delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
internal/store/store.go
Normal file
31
internal/store/store.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store hält den Verbindungspool zur Postgres-Datenbank.
|
||||
type Store struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Open baut den Verbindungspool auf und prüft ihn mit einem Ping.
|
||||
func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("store: ping: %w", err)
|
||||
}
|
||||
return &Store{Pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close gibt den Verbindungspool frei.
|
||||
func (s *Store) Close() {
|
||||
s.Pool.Close()
|
||||
}
|
||||
70
internal/store/store_test.go
Normal file
70
internal/store/store_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// Diese Tests brauchen eine laufende Postgres-Instanz und werden ohne
|
||||
// DATABASE_URL übersprungen, statt eine Verbindung vorzutäuschen.
|
||||
func testDatabaseURL(t *testing.T) string {
|
||||
t.Helper()
|
||||
url := os.Getenv("DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("DATABASE_URL nicht gesetzt, überspringe Store-Integrationstest")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func openTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
url := testDatabaseURL(t)
|
||||
if err := store.Migrate(url); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
s, err := store.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
||||
func testAccountID(t *testing.T, s *store.Store) string {
|
||||
t.Helper()
|
||||
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
func TestMigrateAndOpen(t *testing.T) {
|
||||
url := testDatabaseURL(t)
|
||||
|
||||
if err := store.Migrate(url); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
|
||||
s, err := store.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
var tableCount int
|
||||
err = s.Pool.QueryRow(context.Background(), `
|
||||
SELECT count(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = ANY($1)
|
||||
`, []string{"account", "app_user", "session", "audit_log", "abteilung", "werkzeug", "werkzeug_sperre", "antrag"}).Scan(&tableCount)
|
||||
if err != nil {
|
||||
t.Fatalf("query tables: %v", err)
|
||||
}
|
||||
if tableCount != 8 {
|
||||
t.Fatalf("expected 8 tables, got %d", tableCount)
|
||||
}
|
||||
}
|
||||
104
internal/store/tenant_scope.go
Normal file
104
internal/store/tenant_scope.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Row-Level-Security-Unterstützung. Isolation zwischen Mandanten lief
|
||||
// bisher ausschließlich anwendungsseitig (Handler vergleichen AccountID,
|
||||
// siehe CLAUDE.md) — das ist die einzige Stelle, die einen vergessenen
|
||||
// WHERE-account_id-Filter in einer neuen Store-Methode nicht auffängt.
|
||||
// WithTenantScope öffnet für die Dauer eines Requests eine Transaktion
|
||||
// und setzt zwei Postgres-Sitzungsvariablen (SET LOCAL, per set_config
|
||||
// mit Parameterbindung statt String-Interpolation — SQL-Injection-frei
|
||||
// und automatisch auf die Transaktion begrenzt, kein manuelles Zurück-
|
||||
// setzen nötig):
|
||||
//
|
||||
// - app.account_id — der Mandant, für den dieser Request angemeldet ist
|
||||
// - app.is_betreiber — "true" für Ebene-5-Zugriff (sieht alle Mandanten)
|
||||
//
|
||||
// Migration 0021 aktiviert FORCE ROW LEVEL SECURITY auf den Tabellen mit
|
||||
// echten Mandanten-Geschäftsdaten und legt Policies an, die genau diese
|
||||
// beiden Variablen auswerten. Bewusst NICHT auf account/app_user/
|
||||
// session/password_reset_token (siehe Migration 0021 für die Begründung
|
||||
// — diese vier brauchen unmandantierte Lookups, z. B. Login per E-Mail).
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// pgxIface wird sowohl von *pgxpool.Pool als auch von pgx.Tx erfüllt —
|
||||
// db(ctx) kann so transparent zwischen "kein Tenant-Kontext" (Pool,
|
||||
// z. B. in Store-Tests ohne WithTenantScope) und "innerhalb eines
|
||||
// Requests" (Tx mit gesetzten Sitzungsvariablen) wählen, ohne dass jede
|
||||
// einzelne Store-Methode das selbst unterscheiden müsste.
|
||||
type pgxIface interface {
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||
}
|
||||
|
||||
type tenantTxKey struct{}
|
||||
|
||||
// db liefert die für ctx passende Ausführungsschnittstelle: die aktive
|
||||
// Transaktion, falls WithTenantScope sie gesetzt hat, sonst den Pool
|
||||
// direkt (z. B. für Store-Tests, die ohne Tenant-Kontext laufen — diese
|
||||
// Tabellen sind dann nicht durch RLS geschützt, was für White-Box-Tests
|
||||
// der Store-Logik selbst unkritisch ist, siehe dedizierte RLS-Tests in
|
||||
// tenant_scope_test.go für den tatsächlichen Isolationsnachweis).
|
||||
func (s *Store) db(ctx context.Context) pgxIface {
|
||||
if tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx); ok {
|
||||
return tx
|
||||
}
|
||||
return s.Pool
|
||||
}
|
||||
|
||||
// WithTenantScope führt fn in einer Transaktion aus, die die Sitzungs-
|
||||
// variablen für RLS setzt. accountID kann leer sein (z. B. während der
|
||||
// Registrierung, bevor der neue Account existiert) — SetTenantScope
|
||||
// erlaubt, die Variable mitten in derselben Transaktion nachträglich zu
|
||||
// setzen, sobald die ID bekannt ist.
|
||||
func (s *Store) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error {
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: begin tenant scope: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck // no-op nach erfolgreichem Commit
|
||||
|
||||
scopedCtx := context.WithValue(ctx, tenantTxKey{}, tx)
|
||||
if err := setTenantSessionVars(scopedCtx, tx, accountID, isBetreiber); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(scopedCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("store: commit tenant scope: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTenantScope aktualisiert die Sitzungsvariablen innerhalb einer
|
||||
// bereits laufenden WithTenantScope-Transaktion — nötig, wenn eine neue
|
||||
// Firma erst mitten im Request entsteht (die account_id ist vorher
|
||||
// nicht bekannt, siehe handleRegister/handleBetreiberAccountCreate).
|
||||
// Ruft man es außerhalb von WithTenantScope auf, ist es ein No-op ohne
|
||||
// Effekt (kein Tx im Context) — daher immer den Rückgabewert prüfen,
|
||||
// falls das je außerhalb eines Handlers genutzt wird.
|
||||
func (s *Store) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error {
|
||||
tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx)
|
||||
if !ok {
|
||||
return fmt.Errorf("store: SetTenantScope außerhalb von WithTenantScope aufgerufen")
|
||||
}
|
||||
return setTenantSessionVars(ctx, tx, accountID, isBetreiber)
|
||||
}
|
||||
|
||||
func setTenantSessionVars(ctx context.Context, tx pgx.Tx, accountID string, isBetreiber bool) error {
|
||||
betreiberFlag := "false"
|
||||
if isBetreiber {
|
||||
betreiberFlag = "true"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT set_config('app.account_id', $1, true), set_config('app.is_betreiber', $2, true)`, accountID, betreiberFlag); err != nil {
|
||||
return fmt.Errorf("store: set tenant scope: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
152
internal/store/tenant_scope_test.go
Normal file
152
internal/store/tenant_scope_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Beweis, dass Row-Level-Security tatsächlich greift — nicht nur, dass
|
||||
// die Store-Methoden mit einer Transaktion statt dem Pool funktionieren
|
||||
// (das würde auch mit einer Superuser-Verbindung "bestehen", ohne dass
|
||||
// RLS irgendetwas tut, siehe CLAUDE.md/Migration 0021: Superuser
|
||||
// umgehen RLS-Policies immer). Diese Tests laufen deshalb NICHT gegen
|
||||
// DATABASE_URL (Superuser, für Migrationen), sondern gegen
|
||||
// DATABASE_URL_APP — die eingeschränkte Rolle "deklarix_app", für die
|
||||
// die Policies tatsächlich wirken. Ohne DATABASE_URL_APP werden sie
|
||||
// übersprungen (die Rolle existiert erst nach manuellem Passwort-Setup,
|
||||
// siehe Migration 0021 und CLAUDE.md).
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// openRestrictedTestStore öffnet eine zweite Verbindung über
|
||||
// DATABASE_URL_APP (die eingeschränkte Rolle) — Migrationen und
|
||||
// Fixture-Aufbau laufen weiterhin über die normale, privilegierte
|
||||
// openTestStore-Verbindung.
|
||||
func openRestrictedTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
url := os.Getenv("DATABASE_URL_APP")
|
||||
if url == "" {
|
||||
t.Skip("DATABASE_URL_APP nicht gesetzt, überspringe RLS-Test (siehe Migration 0021)")
|
||||
}
|
||||
s, err := store.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Open (restricted): %v", err)
|
||||
}
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestRLSAbteilungIsoliertZwischenMandanten(t *testing.T) {
|
||||
privileged := openTestStore(t)
|
||||
restricted := openRestrictedTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
accA := testAccountID(t, privileged)
|
||||
accB := testAccountID(t, privileged)
|
||||
abtA, err := privileged.CreateAbteilung(ctx, accA, "Abteilung-A")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAbteilung A: %v", err)
|
||||
}
|
||||
if _, err := privileged.CreateAbteilung(ctx, accB, "Abteilung-B"); err != nil {
|
||||
t.Fatalf("CreateAbteilung B: %v", err)
|
||||
}
|
||||
|
||||
// Mit Tenant-Kontext A: nur die eigene Abteilung ist sichtbar, auch
|
||||
// wenn wir explizit nach IDs von B fragen würden — hier geprüft über
|
||||
// die Listen-Methode, die auf accA gefiltert nach RLS zusätzlich
|
||||
// noch mal (redundant) accountID=accA übergibt; der Beweis liegt in
|
||||
// TestRLSVerweigertFremdenAccountAuchBeiFalscherAccountID unten, wo
|
||||
// die Anwendungsschicht bewusst "falsch" fragt.
|
||||
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
|
||||
liste, err := restricted.ListAbteilungenForAccount(scoped, accA)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(liste) != 1 || liste[0].Name != "Abteilung-A" {
|
||||
t.Fatalf("liste = %+v, want genau [Abteilung-A]", liste)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithTenantScope A: %v", err)
|
||||
}
|
||||
|
||||
// Der eigentliche RLS-Beweis: im Tenant-Kontext A nach ListAbteilungenForAccount(B)
|
||||
// fragen — ein Programmierfehler, der die AccountID nicht prüft, wäre
|
||||
// ohne RLS ein echtes Datenleck. Mit RLS liefert die DB trotzdem 0 Zeilen,
|
||||
// weil die Sitzungsvariable (Kontext A) nicht zu den B-Zeilen passt.
|
||||
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
|
||||
liste, err := restricted.ListAbteilungenForAccount(scoped, accB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(liste) != 0 {
|
||||
t.Fatalf("RLS-LECK: Kontext A sieht %d Zeilen von Account B, want 0", len(liste))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithTenantScope A->B: %v", err)
|
||||
}
|
||||
|
||||
// GetAbteilung(abtA.ID) im Kontext B muss ErrNotFound liefern — RLS
|
||||
// versteckt die fremde Zeile, unabhängig davon, ob die Anwendung die
|
||||
// AccountID selbst vergleicht.
|
||||
err = restricted.WithTenantScope(ctx, accB, false, func(scoped context.Context) error {
|
||||
_, err := restricted.GetAbteilung(scoped, abtA.ID)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("RLS-LECK: GetAbteilung(A) im Kontext B err=%v, want ErrNotFound", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithTenantScope B: %v", err)
|
||||
}
|
||||
|
||||
// Betreiber-Kontext sieht beide.
|
||||
err = restricted.WithTenantScope(ctx, "", true, func(scoped context.Context) error {
|
||||
listeA, err := restricted.ListAbteilungenForAccount(scoped, accA)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listeB, err := restricted.ListAbteilungenForAccount(scoped, accB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(listeA) != 1 || len(listeB) != 1 {
|
||||
t.Fatalf("Betreiber-Kontext: listeA=%+v listeB=%+v, want je 1", listeA, listeB)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithTenantScope Betreiber: %v", err)
|
||||
}
|
||||
|
||||
// Ohne jeden Tenant-Kontext (direkter Pool-Zugriff, keine Transaktion,
|
||||
// keine Sitzungsvariable gesetzt): fail closed, 0 Zeilen — nicht "alle".
|
||||
liste, err := restricted.ListAbteilungenForAccount(ctx, accA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAbteilungenForAccount ohne Kontext: %v", err)
|
||||
}
|
||||
if len(liste) != 0 {
|
||||
t.Fatalf("RLS-LECK: ohne Tenant-Kontext sichtbar: %+v, want 0 Zeilen (fail closed)", liste)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRLSVerhindertInsertFuerFremdenAccount(t *testing.T) {
|
||||
privileged := openTestStore(t)
|
||||
restricted := openRestrictedTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
accA := testAccountID(t, privileged)
|
||||
accB := testAccountID(t, privileged)
|
||||
|
||||
err := restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
|
||||
_, err := restricted.CreateAbteilung(scoped, accB, "Boesartig-Eingeschleust")
|
||||
return err
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("RLS-LECK: INSERT für fremde account_id im Kontext A wurde nicht abgelehnt")
|
||||
}
|
||||
}
|
||||
138
internal/store/user.go
Normal file
138
internal/store/user.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat —
|
||||
// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit
|
||||
// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei
|
||||
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||
|
||||
// User ist ein Login innerhalb eines Account (Mandanten). Nutzer werden
|
||||
// nicht gelöscht (app_user wird von antrag/session/entscheidung/
|
||||
// audit_log per Foreign Key referenziert — ein Hard-Delete würde die
|
||||
// Historie zerstören), sondern über Active deaktiviert.
|
||||
type User struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const userColumns = `id, account_id, email, password_hash, role, active, created_at`
|
||||
|
||||
func scanUser(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (User, error) {
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.Active, &u.CreatedAt)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
||||
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||
// speichert nur, es hasht nicht selbst.
|
||||
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+userColumns,
|
||||
accountID, email, passwordHash, role,
|
||||
)
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: get user by email: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// GetUser liest einen Nutzer anhand seiner ID.
|
||||
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: get user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
||||
// Admin-Bereich (Account-Detailansicht).
|
||||
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan user: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetUserPassword ersetzt den Passwort-Hash eines Nutzers (Passwort-
|
||||
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
|
||||
// CreateUser.
|
||||
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set user password: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
||||
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
||||
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
||||
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set user active: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
322
internal/store/werkzeug.go
Normal file
322
internal/store/werkzeug.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Werkzeug ist ein Eintrag im KI-Werkzeugkatalog — der eigentliche Wert
|
||||
// des Produkts (siehe CLAUDE.md). AccountID nil markiert einen zentral
|
||||
// gepflegten, für alle Mandanten identischen Katalogeintrag; ein
|
||||
// gesetzter AccountID ist eine mandantenspezifische Ergänzung.
|
||||
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
|
||||
// muss belegbar sein.
|
||||
type Werkzeug struct {
|
||||
ID string
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter string
|
||||
// Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn
|
||||
// der Anbieter selbst keinen einzelnen Staat zusichert, eine Region
|
||||
// wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die
|
||||
// USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere,
|
||||
// "gemischt" verschleierte, welche Länder konkret beteiligt sind.
|
||||
Verarbeitungslaender []string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
// DPFZertifiziert: EU-US Data Privacy Framework — schmalere,
|
||||
// gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor,
|
||||
// Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte
|
||||
// Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von
|
||||
// widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den
|
||||
// übrigen Booleans hier.
|
||||
DPFZertifiziert bool
|
||||
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
|
||||
Zertifizierungen []string
|
||||
Subprozessoren []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
Quelle string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
||||
// Create/Update — bei 15 Feldern lesbarer als eine positionale
|
||||
// Parameterliste.
|
||||
type WerkzeugInput struct {
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungslaender []string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
DPFZertifiziert bool
|
||||
AufbewahrungTage *int
|
||||
Zertifizierungen []string
|
||||
Subprozessoren []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
Quelle string
|
||||
}
|
||||
|
||||
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||
|
||||
func scanWerkzeug(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Werkzeug, error) {
|
||||
var w Werkzeug
|
||||
err := row.Scan(
|
||||
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
|
||||
&w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
|
||||
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||
)
|
||||
return w, err
|
||||
}
|
||||
|
||||
// normalizeWerkzeugSlices ersetzt nil-Slices durch leere Slices — die
|
||||
// Spalten sind TEXT[] NOT NULL, ein nil-Slice (z. B. wenn eine
|
||||
// Einschränkung optional ist) käme sonst als SQL-NULL an und würde mit
|
||||
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
||||
// Fall wie früher bei finding.sources).
|
||||
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||
if in.Verarbeitungslaender == nil {
|
||||
in.Verarbeitungslaender = []string{}
|
||||
}
|
||||
if in.Zertifizierungen == nil {
|
||||
in.Zertifizierungen = []string{}
|
||||
}
|
||||
if in.GeeigneteZwecke == nil {
|
||||
in.GeeigneteZwecke = []string{}
|
||||
}
|
||||
if in.Einschraenkungen == nil {
|
||||
in.Einschraenkungen = []string{}
|
||||
}
|
||||
if in.Subprozessoren == nil {
|
||||
in.Subprozessoren = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO werkzeug (
|
||||
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING `+werkzeugColumns,
|
||||
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
if err != nil {
|
||||
return Werkzeug{}, fmt.Errorf("store: create werkzeug: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// UpdateWerkzeug ersetzt die Felder eines bestehenden Katalogeintrags
|
||||
// (z. B. bei einer erneuten Prüfung der Zusicherungen). Werkzeug ist
|
||||
// bewusst nicht append-only — anders als ein Beweisstück ist der
|
||||
// Katalog ein gepflegter, sich änderender Datenbestand; eine Entscheidung
|
||||
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
|
||||
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.db(ctx).QueryRow(ctx, `
|
||||
UPDATE werkzeug SET
|
||||
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
|
||||
zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
|
||||
letzte_pruefung = $15, quelle = $16, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+werkzeugColumns,
|
||||
id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Werkzeug{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Werkzeug{}, fmt.Errorf("store: update werkzeug: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
||||
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
||||
row := s.db(ctx).QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
|
||||
w, err := scanWerkzeug(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Werkzeug{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Werkzeug{}, fmt.Errorf("store: get werkzeug: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
||||
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete werkzeug: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWerkzeugeForAccount liefert den für einen Mandanten sichtbaren
|
||||
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
||||
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
||||
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT `+werkzeugColumns+` FROM werkzeug w
|
||||
WHERE (w.account_id IS NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
|
||||
)) OR w.account_id = $1
|
||||
ORDER BY w.name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Werkzeug
|
||||
for rows.Next() {
|
||||
w, err := scanWerkzeug(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListZentraleWerkzeuge liefert den vollständigen zentralen Katalog
|
||||
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
||||
// Mandanten-Sperrungen.
|
||||
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Werkzeug
|
||||
for rows.Next() {
|
||||
w, err := scanWerkzeug(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CurrentKatalogVersion liefert eine reproduzierbare Kennung des
|
||||
// aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird
|
||||
// in jeder Bewertung/Entscheidung eingefroren, damit im Audit
|
||||
// nachvollziehbar bleibt, mit welchem Katalogstand ein Vorschlag
|
||||
// erzeugt wurde.
|
||||
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||
var count int
|
||||
var lastUpdate time.Time
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
|
||||
`).Scan(&count, &lastUpdate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: current katalog version: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", count, lastUpdate.Unix()), nil
|
||||
}
|
||||
|
||||
// WerkzeugSperre ist die Sperrung eines (auch zentralen) Katalogeintrags
|
||||
// durch einen einzelnen Mandanten — der zentrale Katalog selbst bleibt
|
||||
// dabei unverändert.
|
||||
type WerkzeugSperre struct {
|
||||
ID string
|
||||
AccountID string
|
||||
WerkzeugID string
|
||||
Grund string
|
||||
GesperrtAm time.Time
|
||||
}
|
||||
|
||||
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
||||
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
||||
var sp WerkzeugSperre
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
||||
`, accountID, werkzeugID, grund).Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm)
|
||||
if err != nil {
|
||||
return WerkzeugSperre{}, fmt.Errorf("store: create werkzeug sperre: %w", err)
|
||||
}
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
||||
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `
|
||||
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
|
||||
`, accountID, werkzeugID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete werkzeug sperre: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
||||
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
|
||||
rows, err := s.db(ctx).Query(ctx, `
|
||||
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
|
||||
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []WerkzeugSperre
|
||||
for rows.Next() {
|
||||
var sp WerkzeugSperre
|
||||
if err := rows.Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm); err != nil {
|
||||
return nil, fmt.Errorf("store: scan werkzeug sperre: %w", err)
|
||||
}
|
||||
out = append(out, sp)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
240
internal/store/werkzeug_test.go
Normal file
240
internal/store/werkzeug_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||
return store.WerkzeugInput{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: "Beispiel-Anbieter GmbH",
|
||||
Verarbeitungslaender: []string{"Deutschland"},
|
||||
AVVVerfuegbar: true,
|
||||
AVVURL: "https://beispiel.example/avv",
|
||||
TrainingOptOut: true,
|
||||
TrainingStandard: true,
|
||||
AufbewahrungTage: intPtr(30),
|
||||
Zertifizierungen: []string{"ISO 27001"},
|
||||
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
|
||||
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||
Einschraenkungen: nil,
|
||||
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
||||
Quelle: "https://beispiel.example/beleg",
|
||||
}
|
||||
}
|
||||
|
||||
func TestWerkzeugCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentrales Werkzeug"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
if w.AccountID != nil {
|
||||
t.Fatalf("expected AccountID nil fuer zentralen Katalogeintrag, got %v", w.AccountID)
|
||||
}
|
||||
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
||||
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
||||
}
|
||||
if len(w.Subprozessoren) != 1 || w.Subprozessoren[0] != "Beispiel-Subprozessor Inc." {
|
||||
t.Fatalf("Subprozessoren = %v, want [Beispiel-Subprozessor Inc.]", w.Subprozessoren)
|
||||
}
|
||||
if w.AufbewahrungTage == nil || *w.AufbewahrungTage != 30 {
|
||||
t.Fatalf("AufbewahrungTage = %v, want 30", w.AufbewahrungTage)
|
||||
}
|
||||
|
||||
unbekannt, err := s.CreateWerkzeug(ctx, func() store.WerkzeugInput {
|
||||
in := testWerkzeugInput(nil, "Werkzeug ohne Aufbewahrungsangabe")
|
||||
in.AufbewahrungTage = nil
|
||||
return in
|
||||
}())
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (ohne Aufbewahrungsangabe): %v", err)
|
||||
}
|
||||
if unbekannt.AufbewahrungTage != nil {
|
||||
t.Fatalf("AufbewahrungTage = %v, want nil (unbekannt)", *unbekannt.AufbewahrungTage)
|
||||
}
|
||||
|
||||
got, err := s.GetWerkzeug(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWerkzeug: %v", err)
|
||||
}
|
||||
if got.Name != "Zentrales Werkzeug" {
|
||||
t.Fatalf("Name = %q, want Zentrales Werkzeug", got.Name)
|
||||
}
|
||||
|
||||
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
||||
updateInput.AufbewahrungTage = intPtr(14)
|
||||
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateWerkzeug: %v", err)
|
||||
}
|
||||
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage == nil || *updated.AufbewahrungTage != 14 {
|
||||
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
||||
}
|
||||
|
||||
if err := s.DeleteWerkzeug(ctx, w.ID); err != nil {
|
||||
t.Fatalf("DeleteWerkzeug: %v", err)
|
||||
}
|
||||
if _, err := s.GetWerkzeug(ctx, w.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWerkzeugNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.UpdateWerkzeug(context.Background(), "00000000-0000-0000-0000-000000000000", testWerkzeugInput(nil, "x"))
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWerkzeugeForAccountIncludesCentralAndOwnEntries(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral A"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (zentral): %v", err)
|
||||
}
|
||||
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes Werkzeug"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
|
||||
}
|
||||
|
||||
// Anderer Mandant darf das eigene Werkzeug nicht sehen.
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
|
||||
}
|
||||
for _, w := range otherList {
|
||||
if w.ID == eigenes.ID {
|
||||
t.Fatal("expected the other account's own werkzeug to stay isolated")
|
||||
}
|
||||
}
|
||||
|
||||
list, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount: %v", err)
|
||||
}
|
||||
byID := map[string]bool{}
|
||||
for _, w := range list {
|
||||
byID[w.ID] = true
|
||||
}
|
||||
if !byID[zentral.ID] || !byID[eigenes.ID] {
|
||||
t.Fatalf("expected both the central and the own werkzeug, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWerkzeugSperreHidesCentralEntry(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zu sperrendes Werkzeug"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CreateWerkzeugSperre(ctx, accID, zentral.ID, "vom Mandanten intern verboten"); err != nil {
|
||||
t.Fatalf("CreateWerkzeugSperre: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount: %v", err)
|
||||
}
|
||||
for _, w := range list {
|
||||
if w.ID == zentral.ID {
|
||||
t.Fatal("expected the sperred central werkzeug to be hidden for this account")
|
||||
}
|
||||
}
|
||||
|
||||
// Der zentrale Katalog selbst bleibt fuer andere Mandanten sichtbar.
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, w := range otherList {
|
||||
if w.ID == zentral.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the central werkzeug to remain visible for a different account")
|
||||
}
|
||||
|
||||
if err := s.DeleteWerkzeugSperre(ctx, accID, zentral.ID); err != nil {
|
||||
t.Fatalf("DeleteWerkzeugSperre: %v", err)
|
||||
}
|
||||
listAfter, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount nach Entsperren: %v", err)
|
||||
}
|
||||
found = false
|
||||
for _, w := range listAfter {
|
||||
if w.ID == zentral.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the werkzeug to be visible again after DeleteWerkzeugSperre")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentKatalogVersionChangesOnCreate(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := s.CurrentKatalogVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentKatalogVersion: %v", err)
|
||||
}
|
||||
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Version-Test-Werkzeug")); err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
after, err := s.CurrentKatalogVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentKatalogVersion (2): %v", err)
|
||||
}
|
||||
if before == after {
|
||||
t.Fatalf("expected CurrentKatalogVersion to change after adding a werkzeug, got %q both times", before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListZentraleWerkzeugeExcludesMandantenEigene(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral B")); err != nil {
|
||||
t.Fatalf("CreateWerkzeug (zentral): %v", err)
|
||||
}
|
||||
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes B"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListZentraleWerkzeuge(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListZentraleWerkzeuge: %v", err)
|
||||
}
|
||||
for _, w := range list {
|
||||
if w.ID == eigenes.ID {
|
||||
t.Fatal("expected ListZentraleWerkzeuge to exclude mandantenspezifische Eintraege")
|
||||
}
|
||||
}
|
||||
}
|
||||
96
internal/web/abteilung_handlers_test.go
Normal file
96
internal/web/abteilung_handlers_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminCanCreateAndDeleteAbteilung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/abteilungen", url.Values{"name": {"Vertrieb"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/abteilungen")
|
||||
if !strings.Contains(list.Body.String(), "Vertrieb") {
|
||||
t.Errorf("expected Vertrieb in the Abteilungsliste, got: %s", list.Body.String())
|
||||
}
|
||||
|
||||
var abtID string
|
||||
for accID, abts := range fs.abteilungen {
|
||||
_ = accID
|
||||
for _, a := range abts {
|
||||
if a.Name == "Vertrieb" {
|
||||
abtID = a.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if abtID == "" {
|
||||
t.Fatal("expected Vertrieb to exist in fakeStore")
|
||||
}
|
||||
|
||||
delResp := postForm(t, s, adminCookie, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||
if delResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, body: %s", delResp.Code, delResp.Body.String())
|
||||
}
|
||||
if _, err := fs.GetAbteilung(context.Background(), abtID); err == nil {
|
||||
t.Error("expected the Abteilung to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeueAntragFormZeigtAngelegteAbteilung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
postForm(t, s, adminCookie, "/verwaltung/abteilungen", url.Values{"name": {"Marketing"}})
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/antraege/neu")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Marketing") {
|
||||
t.Errorf("expected the newly created Abteilung in the Fragebogen, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessAbteilungsverwaltung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/verwaltung/abteilungen")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotDeleteForeignAbteilung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||
postForm(t, s, adminA, "/verwaltung/abteilungen", url.Values{"name": {"Nur A"}})
|
||||
var abtID string
|
||||
for _, abts := range fs.abteilungen {
|
||||
for _, a := range abts {
|
||||
if a.Name == "Nur A" {
|
||||
abtID = a.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminB, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign account's Abteilung", resp.Code)
|
||||
}
|
||||
if _, err := fs.GetAbteilung(context.Background(), abtID); err != nil {
|
||||
t.Error("expected the foreign Abteilung to still exist")
|
||||
}
|
||||
}
|
||||
72
internal/web/admin_fachebene_test.go
Normal file
72
internal/web/admin_fachebene_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
|
||||
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
|
||||
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
|
||||
// und kann entscheiden, genau wie ein verantwortlicher.
|
||||
func TestAdminSiehtPosteingang(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/faelle")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||
t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminKannEntscheiden(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag: %v", err)
|
||||
}
|
||||
if antrag.Status != "entschieden" {
|
||||
t.Errorf("Status = %q, want entschieden", antrag.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
|
||||
resp := getWithCookie(t, s, adminCookie, path)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
230
internal/web/admin_handlers.go
Normal file
230
internal/web/admin_handlers.go
Normal file
@@ -0,0 +1,230 @@
|
||||
// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen und Abteilungen für
|
||||
// GENAU EINEN Mandanten, siehe CLAUDE.md, Fünf Ebenen. Anmeldeverfahren,
|
||||
// Werkzeug-Freigaben/-Sperrungen und Rechnungsdaten sind weiterhin
|
||||
// spätere Ausbaustufen. Es gibt noch keinen Einladungslink/CSV-Import/
|
||||
// SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier direkt ein
|
||||
// Initialpasswort, wie schon bei der Firma-Registrierung.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
||||
// Mandanten vergeben darf — "betreiber" ist Ebene 5 (Plattform) und
|
||||
// kann von keinem Mandanten-Admin vergeben werden.
|
||||
var mandantenRollen = []string{"mitarbeiter", "verantwortlicher", "pruefer", "admin"}
|
||||
|
||||
func istMandantenRolle(role string) bool {
|
||||
for _, r := range mandantenRollen {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type nutzerListItem struct {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
Active bool
|
||||
IstSelbst bool
|
||||
}
|
||||
|
||||
type nutzerListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Nutzer []nutzerListItem
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// handleNutzerListe zeigt alle Logins des eigenen Mandanten.
|
||||
func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) {
|
||||
self := currentUser(r)
|
||||
users, err := s.store.ListUsersForAccount(r.Context(), self.AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []store.User
|
||||
for _, u := range users {
|
||||
if matchesQuery(q, u.Email, u.Role) {
|
||||
gefiltert = append(gefiltert, u)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||
for _, u := range paginate(gefiltert, pag) {
|
||||
data.Nutzer = append(data.Nutzer, nutzerListItem{
|
||||
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNutzerDeaktivieren sperrt einen Login des eigenen Mandanten —
|
||||
// nicht löschen, siehe store.User (Fremdschlüssel auf antrag/entscheidung/
|
||||
// audit_log würden das verhindern und die Historie zerstören). Ein Admin
|
||||
// kann sich nicht selbst deaktivieren, sonst könnte ein Mandant sich
|
||||
// versehentlich komplett aussperren.
|
||||
func (s *Server) handleNutzerDeaktivieren(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
self := currentUser(r)
|
||||
if id == self.ID {
|
||||
http.Error(w, "Der eigene Zugang kann nicht deaktiviert werden", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
target, err := s.store.GetUser(r.Context(), id)
|
||||
if err != nil || target.AccountID != self.AccountID {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserActive(r.Context(), id, false); err != nil {
|
||||
http.Error(w, "Nutzer konnte nicht deaktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleNutzerAktivieren(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target, err := s.store.GetUser(r.Context(), id)
|
||||
if err != nil || target.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserActive(r.Context(), id, true); err != nil {
|
||||
http.Error(w, "Nutzer konnte nicht aktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type nutzerNeuData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Rollen []string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleNutzerNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNutzerCreate legt einen weiteren Login im eigenen Mandanten an.
|
||||
// Es gibt keine Einladungsmail (siehe CLAUDE.md, Onboarding — noch
|
||||
// nicht gebaut) — der Admin setzt das Initialpasswort direkt und gibt
|
||||
// es dem/der neuen Nutzer:in auf einem anderen Weg weiter.
|
||||
func (s *Server) handleNutzerCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
role := r.FormValue("role")
|
||||
if email == "" || password == "" || !istMandantenRolle(role) {
|
||||
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "E-Mail, Passwort und eine gültige Rolle sind Pflicht"}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.store.CreateUser(r.Context(), currentUser(r).AccountID, email, passwordHash, role); err != nil {
|
||||
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type abteilungListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type abteilungListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Abteilungen []abteilungListItem
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleAbteilungListe zeigt alle Abteilungen des eigenen Mandanten
|
||||
// mit einem Formular zum Anlegen weiterer — bewusst eine Seite statt
|
||||
// Liste+Formular getrennt, da Abteilungen reine Stammdaten ohne
|
||||
// weitere Felder sind (nur `name`).
|
||||
func (s *Server) handleAbteilungListe(w http.ResponseWriter, r *http.Request) {
|
||||
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := abteilungListeData{Title: "Abteilungen", Nav: navFor(r)}
|
||||
for _, a := range abteilungen {
|
||||
data.Abteilungen = append(data.Abteilungen, abteilungListItem{ID: a.ID, Name: a.Name})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "abteilung-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAbteilungCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAbteilung(r.Context(), currentUser(r).AccountID, name); err != nil {
|
||||
http.Error(w, "Abteilung konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleAbteilungDelete entfernt eine Abteilung des eigenen Mandanten.
|
||||
// Fremde Abteilungen (anderer Mandant) sind über die ID nicht löschbar
|
||||
// — GetAbteilung prüft AccountID gegen den angemeldeten Mandanten,
|
||||
// bevor gelöscht wird.
|
||||
func (s *Server) handleAbteilungDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
abt, err := s.store.GetAbteilung(r.Context(), id)
|
||||
if err != nil || abt.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Abteilung nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteAbteilung(r.Context(), id); err != nil {
|
||||
http.Error(w, "Abteilung konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther)
|
||||
}
|
||||
75
internal/web/admin_handlers_test.go
Normal file
75
internal/web/admin_handlers_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMitarbeiterCannotAccessNutzerverwaltung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter on /verwaltung/nutzer", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanCreateUserWithMandantenRolle(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"}, "role": {"verantwortlicher"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
u, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if u.Role != "verantwortlicher" {
|
||||
t.Errorf("Role = %q, want verantwortlicher", u.Role)
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/nutzer")
|
||||
if !strings.Contains(list.Body.String(), "neu@example.com") {
|
||||
t.Errorf("expected the new user in the Nutzerliste, got: %s", list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotCreateBetreiberUser(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"versuch@example.com"}, "password": {"ein-langes-passwort"}, "role": {"betreiber"},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (Formular mit Fehler) for role betreiber", resp.Code)
|
||||
}
|
||||
if _, err := fs.GetUserByEmail(context.Background(), "versuch@example.com"); err == nil {
|
||||
t.Error("expected no user to be created for an invalid role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCreateUserRejectsDuplicateEmail(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"admin@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (Formular mit Fehler) for duplicate email", resp.Code)
|
||||
}
|
||||
}
|
||||
486
internal/web/antrag_handlers.go
Normal file
486
internal/web/antrag_handlers.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten-
|
||||
// Schlüsseln in rules/datenklasse.yaml (b1..b7) und
|
||||
// rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3
|
||||
// (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten,
|
||||
// ohne sie erst umbenennen zu müssen.
|
||||
var datenfragen = []struct {
|
||||
Key string
|
||||
Label string
|
||||
}{
|
||||
{"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"},
|
||||
{"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"},
|
||||
{"b3", "Daten von Patienten, Mandanten oder Versicherten"},
|
||||
{"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"},
|
||||
{"b5", "Daten, die uns ein Kunde anvertraut hat"},
|
||||
{"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"},
|
||||
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
||||
}
|
||||
|
||||
var c2FolgeLabels = map[string]string{
|
||||
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
|
||||
"kreditwuerdigkeit": "Kreditwürdigkeit",
|
||||
"leistungsbewilligung": "Leistungsbewilligung",
|
||||
"bildung": "Zugang zu Bildung",
|
||||
"gesundheit": "medizinische Beurteilung",
|
||||
}
|
||||
|
||||
var c3ArtLabels = map[string]string{
|
||||
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
|
||||
"social_scoring": "Social Scoring",
|
||||
"biometrische_kategorisierung": "Biometrische Kategorisierung",
|
||||
}
|
||||
|
||||
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
|
||||
// siehe antwortenAnzeige.
|
||||
type antwortZeile struct {
|
||||
Frage string
|
||||
Antwort string
|
||||
}
|
||||
|
||||
func antwortLabel(v string) string {
|
||||
switch v {
|
||||
case "ja":
|
||||
return "Ja"
|
||||
case "nein":
|
||||
return "Nein"
|
||||
case "unsicher":
|
||||
return "Unsicher"
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func jaNein(v bool) string {
|
||||
if v {
|
||||
return "Ja"
|
||||
}
|
||||
return "Nein"
|
||||
}
|
||||
|
||||
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
|
||||
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
|
||||
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
|
||||
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
|
||||
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
|
||||
// Antworten, auf denen sie beruht — im Audit ist das nicht
|
||||
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
|
||||
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
|
||||
var out []antwortZeile
|
||||
for _, f := range datenfragen {
|
||||
v, _ := antworten[f.Key].(string)
|
||||
if v == "" {
|
||||
v = "nein"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
|
||||
}
|
||||
|
||||
c1, _ := antworten["c1"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
|
||||
|
||||
c2, _ := antworten["c2"].(bool)
|
||||
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
|
||||
if c2 {
|
||||
if folge, _ := antworten["c2_folge"].(string); folge != "" {
|
||||
if label, ok := c2FolgeLabels[folge]; ok {
|
||||
c2Zeile.Antwort += " — " + label
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, c2Zeile)
|
||||
|
||||
c3, _ := antworten["c3"].(bool)
|
||||
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
|
||||
if c3 {
|
||||
if art, _ := antworten["c3_art"].(string); art != "" {
|
||||
if label, ok := c3ArtLabels[art]; ok {
|
||||
c3Zeile.Antwort += " — " + label
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, c3Zeile)
|
||||
|
||||
c4, _ := antworten["c4"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
|
||||
|
||||
c5, _ := antworten["c5"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
|
||||
|
||||
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
|
||||
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
|
||||
}
|
||||
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
|
||||
label := "Privater Zugang"
|
||||
if zugang == "firma" {
|
||||
label = "Firmenkonto"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
|
||||
}
|
||||
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
|
||||
label := "Privatgerät"
|
||||
if geraet == "firma" {
|
||||
label = "Firmengerät"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type antragFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Abteilungen []store.Abteilung
|
||||
Datenfragen []struct {
|
||||
Key string
|
||||
Label string
|
||||
}
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen").
|
||||
// Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten —
|
||||
// jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als
|
||||
// fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive
|
||||
// Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet,
|
||||
// ohne zusätzlichen Server-Roundtrip.
|
||||
func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r).Role == "betreiber" {
|
||||
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen}
|
||||
if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// antworten ist die JSON-Struktur, die in antrag.antworten gespeichert
|
||||
// wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln.
|
||||
// "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT
|
||||
// zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja")
|
||||
// ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der
|
||||
// Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert
|
||||
// erhalten.
|
||||
func antwortenFromForm(r *http.Request) []byte {
|
||||
m := map[string]any{}
|
||||
for _, f := range datenfragen {
|
||||
v := r.FormValue(f.Key)
|
||||
if v == "" {
|
||||
v = "nein"
|
||||
}
|
||||
m[f.Key] = v
|
||||
}
|
||||
m["c1"] = r.FormValue("c1") == "ja"
|
||||
m["c2"] = r.FormValue("c2") == "ja"
|
||||
m["c2_folge"] = r.FormValue("c2_folge")
|
||||
m["c3"] = r.FormValue("c3") == "ja"
|
||||
m["c3_art"] = r.FormValue("c3_art")
|
||||
m["c4"] = r.FormValue("c4") == "ja"
|
||||
m["c5"] = r.FormValue("c5") == "ja"
|
||||
m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext")
|
||||
m["d_zugang"] = r.FormValue("d_zugang")
|
||||
m["d_geraet"] = r.FormValue("d_geraet")
|
||||
|
||||
data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern
|
||||
return data
|
||||
}
|
||||
|
||||
// handleAntragCreate legt einen Antrag an und reicht ihn direkt ein
|
||||
// (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine
|
||||
// einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht
|
||||
// vorgesehen.
|
||||
func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r).Role == "betreiber" {
|
||||
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
titel := r.FormValue("titel")
|
||||
beschreibung := r.FormValue("beschreibung")
|
||||
ergebnis := r.FormValue("ergebnis")
|
||||
haeufigkeit := r.FormValue("haeufigkeit")
|
||||
if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" {
|
||||
http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var abteilungID *string
|
||||
if v := r.FormValue("abteilung_id"); v != "" {
|
||||
abteilungID = &v
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
user := currentUser(r)
|
||||
|
||||
antworten := antwortenFromForm(r)
|
||||
|
||||
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
|
||||
if err != nil {
|
||||
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antworten); err != nil {
|
||||
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil {
|
||||
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.bewerteAntrag(ctx, antrag.ID, user.AccountID, antworten); err != nil {
|
||||
http.Error(w, "Bewertung konnte nicht berechnet werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// bewerteAntrag berechnet die Bewertung für einen eingereichten Antrag
|
||||
// (Schritt 3: Ableitungen und harte Filter) und speichert sie. Bei
|
||||
// "verboten" erfolgt die K.-o.-Prüfung — keine Werkzeugsuche, siehe
|
||||
// CLAUDE.md, Bewertungslogik.
|
||||
func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, antwortenJSON []byte) error {
|
||||
antworten, err := rules.ParseAntworten(antwortenJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
datenklasse := rules.EvaluateDatenklasse(s.regelwerk.Datenklasse, antworten)
|
||||
einstufung, err := rules.EvaluateEinstufung(s.regelwerk.Einstufung, antworten)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verboten := rules.IstVerboten(einstufung.ID)
|
||||
|
||||
in := store.BewertungInput{
|
||||
AntragID: antragID, Datenklasse: datenklasse.ID, DatenklasseHerleitung: datenklasse.Herleitung,
|
||||
Einstufung: einstufung.ID, EinstufungHerleitung: einstufungHerleitungText(einstufung), Verboten: verboten,
|
||||
KatalogVersion: "n/a (K.-o.-Prüfung, keine Werkzeugsuche)",
|
||||
}
|
||||
|
||||
if !verboten {
|
||||
anforderungen := rules.DeriveAnforderungen(s.regelwerk.Anforderungen, datenklasse.ID, einstufung.ID)
|
||||
anforderungIDs := make([]string, len(anforderungen))
|
||||
for i, a := range anforderungen {
|
||||
in.Anforderungen = append(in.Anforderungen, store.BewertungAnforderung{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||
anforderungIDs[i] = a.ID
|
||||
}
|
||||
|
||||
werkzeuge, err := s.store.ListWerkzeugeForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||
for i, w := range werkzeuge {
|
||||
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
||||
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||
}}
|
||||
}
|
||||
|
||||
var loeschfristMaxTage *int
|
||||
loeschfristen, err := s.store.ListLoeschfristEinstellungenForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range loeschfristen {
|
||||
if l.DatenklasseID == datenklasse.ID {
|
||||
maxTage := l.MaxTage
|
||||
loeschfristMaxTage = &maxTage
|
||||
}
|
||||
}
|
||||
|
||||
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs, loeschfristMaxTage)
|
||||
in.ZulaessigeWerkzeuge = zulaessig
|
||||
for _, a := range ausgeschlossen {
|
||||
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
||||
}
|
||||
|
||||
katalogVersion, err := s.store.CurrentKatalogVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.KatalogVersion = katalogVersion
|
||||
}
|
||||
in.RegelwerkVersion = rules.RegelwerkVersion(s.regelwerk.Datenklasse, s.regelwerk.Einstufung, s.regelwerk.Anforderungen)
|
||||
|
||||
_, err = s.store.CreateBewertung(ctx, in)
|
||||
return err
|
||||
}
|
||||
|
||||
// einstufungHerleitungText fügt die Quelle (z. B. "Art. 5 KI-VO") mit
|
||||
// an, falls das Regelwerk eine nennt.
|
||||
func einstufungHerleitungText(e rules.EinstufungErgebnis) string {
|
||||
if e.Quelle == "" {
|
||||
return e.Herleitung
|
||||
}
|
||||
return e.Herleitung + " (" + e.Quelle + ")"
|
||||
}
|
||||
|
||||
type antragListItem struct {
|
||||
ID string
|
||||
Titel string
|
||||
Status string
|
||||
Haeufigkeit string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type antragListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Antraege []antragListItem
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
||||
// Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten.
|
||||
func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r).Role == "betreiber" {
|
||||
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []store.Antrag
|
||||
for _, a := range list {
|
||||
if matchesQuery(q, a.Titel, a.Status, a.Haeufigkeit) {
|
||||
gefiltert = append(gefiltert, a)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := antragListData{Title: "Meine Anträge", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||
for _, a := range paginate(gefiltert, pag) {
|
||||
data.Antraege = append(data.Antraege, antragListItem{
|
||||
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type anforderungView struct {
|
||||
Beschreibung string
|
||||
Herleitung string
|
||||
}
|
||||
|
||||
type werkzeugAusschlussView struct {
|
||||
Name string
|
||||
NichtErfuellt []string
|
||||
}
|
||||
|
||||
type bewertungView struct {
|
||||
Datenklasse string
|
||||
DatenklasseHerleitung string
|
||||
Einstufung string
|
||||
EinstufungHerleitung string
|
||||
Verboten bool
|
||||
Anforderungen []anforderungView
|
||||
ZulaessigeWerkzeuge []string // Namen, nicht IDs
|
||||
AusgeschlosseneWerkzeuge []werkzeugAusschlussView
|
||||
RegelwerkVersion string
|
||||
KatalogVersion string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type antragDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Titel string
|
||||
Beschreibung string
|
||||
Ergebnis string
|
||||
Haeufigkeit string
|
||||
Status string
|
||||
CreatedAt string
|
||||
Antworten []antwortZeile
|
||||
Bewertung *bewertungView
|
||||
}
|
||||
|
||||
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
|
||||
// Ein Antrag eines fremden Nutzers wird wie ein nicht existierender
|
||||
// behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe
|
||||
// Muster wie schon beim alten Produkt für fremde Mandanten.
|
||||
func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
||||
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
||||
if err != nil || antrag.ErstellerUserID != currentUser(r).ID {
|
||||
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var antworten map[string]any
|
||||
if len(antrag.Antworten) > 0 {
|
||||
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||
}
|
||||
|
||||
data := antragDetailData{
|
||||
Title: "Antrag", Nav: navFor(r), Titel: antrag.Titel,
|
||||
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
|
||||
}
|
||||
|
||||
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||
if err == nil {
|
||||
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// toBewertungView löst die in der Bewertung nur als ID gespeicherten
|
||||
// Werkzeuge zu Anzeigenamen auf. Ein Werkzeug, das seit der Berechnung
|
||||
// gelöscht wurde, wird mit seiner ID statt eines Namens angezeigt,
|
||||
// statt den ganzen Antrag unanzeigbar zu machen.
|
||||
func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewertungView {
|
||||
v := &bewertungView{
|
||||
Datenklasse: b.Datenklasse, DatenklasseHerleitung: b.DatenklasseHerleitung,
|
||||
Einstufung: b.Einstufung, EinstufungHerleitung: b.EinstufungHerleitung, Verboten: b.Verboten,
|
||||
RegelwerkVersion: b.RegelwerkVersion, KatalogVersion: b.KatalogVersion,
|
||||
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
for _, a := range b.Anforderungen {
|
||||
v.Anforderungen = append(v.Anforderungen, anforderungView{Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||
}
|
||||
for _, id := range b.ZulaessigeWerkzeuge {
|
||||
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
||||
}
|
||||
for _, a := range b.AusgeschlosseneWerkzeuge {
|
||||
v.AusgeschlosseneWerkzeuge = append(v.AusgeschlosseneWerkzeuge, werkzeugAusschlussView{
|
||||
Name: s.werkzeugName(ctx, a.WerkzeugID), NichtErfuellt: a.NichtErfuellt,
|
||||
})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) werkzeugName(ctx context.Context, id string) string {
|
||||
w, err := s.store.GetWerkzeug(ctx, id)
|
||||
if err != nil {
|
||||
return id
|
||||
}
|
||||
return w.Name
|
||||
}
|
||||
307
internal/web/antrag_handlers_test.go
Normal file
307
internal/web/antrag_handlers_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func fullAntragForm() url.Values {
|
||||
return url.Values{
|
||||
"titel": {"Angebotstexte generieren"}, "beschreibung": {"KI schreibt Angebotstexte"},
|
||||
"ergebnis": {"Fertiger Text zur Freigabe"}, "haeufigkeit": {"gelegentlich"},
|
||||
"b1": {"ja"}, "b2": {"nein"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
|
||||
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
|
||||
"c4": {"nein"}, "c5": {"ja"},
|
||||
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragNewFormRendersFragebogen(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/antraege/neu")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
for _, want := range []string{`name="titel"`, `name="b1"`, `name="c2_folge"`, `name="d_zugang"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("expected %q in the Fragebogen, got: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
|
||||
s, fs, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
loc := resp.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/antraege/") {
|
||||
t.Fatalf("Location = %q, want a redirect to the new antrag", loc)
|
||||
}
|
||||
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag: %v", err)
|
||||
}
|
||||
if antrag.Status != "eingereicht" {
|
||||
t.Fatalf("Status = %q, want eingereicht", antrag.Status)
|
||||
}
|
||||
var antworten map[string]any
|
||||
if err := json.Unmarshal(antrag.Antworten, &antworten); err != nil {
|
||||
t.Fatalf("Antworten unmarshal: %v", err)
|
||||
}
|
||||
if antworten["b1"] != "ja" {
|
||||
t.Errorf("b1 = %v, want ja", antworten["b1"])
|
||||
}
|
||||
if antworten["c5"] != true {
|
||||
t.Errorf("c5 = %v, want true", antworten["c5"])
|
||||
}
|
||||
|
||||
detailResp := getWithCookie(t, s, cookie, loc)
|
||||
if detailResp.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
||||
}
|
||||
body := detailResp.Body.String()
|
||||
if !strings.Contains(body, "Angebotstexte generieren") {
|
||||
t.Errorf("expected the antrag title on the detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragCreateRejectsMissingFields(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", url.Values{"titel": {"Nur Titel"}})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 for missing required fields", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragListShowsOnlyOwnAntraege(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
if resp := postForm(t, s, cookieA, "/antraege", fullAntragForm()); resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create (A) status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
listA := getWithCookie(t, s, cookieA, "/antraege")
|
||||
if !strings.Contains(listA.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected Mandant A to see their own antrag, got: %s", listA.Body.String())
|
||||
}
|
||||
listB := getWithCookie(t, s, cookieB, "/antraege")
|
||||
if strings.Contains(listB.Body.String(), "Angebotstexte generieren") {
|
||||
t.Errorf("expected Mandant B to see no antrag, got: %s", listB.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragDetailRejectsForeignUser(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
createResp := postForm(t, s, cookieA, "/antraege", fullAntragForm())
|
||||
antragURL := createResp.Header().Get("Location")
|
||||
|
||||
resp := getWithCookie(t, s, cookieB, antragURL)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign user's antrag", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// fullAntragForm liefert b1=ja (Datenklasse personenbezogen) und
|
||||
// c1..c3=nein (keine der verboten/hochrisiko/transparenzpflicht-
|
||||
// Varianten trifft zu) — die Einstufung fällt damit auf die
|
||||
// Auffangregel "minimal" zurück (siehe rules/kivo_einstufung.yaml).
|
||||
func TestAntragCreateComputesBewertung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if b.Datenklasse != "personenbezogen" {
|
||||
t.Errorf("Datenklasse = %q, want personenbezogen", b.Datenklasse)
|
||||
}
|
||||
if b.Einstufung != "minimal" {
|
||||
t.Errorf("Einstufung = %q, want minimal", b.Einstufung)
|
||||
}
|
||||
if b.Verboten {
|
||||
t.Error("Verboten = true, want false")
|
||||
}
|
||||
|
||||
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||
if !strings.Contains(detail.Body.String(), "Das System entscheidet nicht") {
|
||||
t.Errorf("expected the mandatory disclaimer on the detail page, got: %s", detail.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAntragCreateVerbotenSkipsWerkzeugSearch prüft die K.-o.-Prüfung:
|
||||
// c3=ja mit c3_art=social_scoring löst "verboten" aus (Art. 5 KI-VO) —
|
||||
// dann darf keine Werkzeugsuche stattfinden (siehe CLAUDE.md,
|
||||
// Bewertungslogik).
|
||||
func TestAntragCreateVerbotenSkipsWerkzeugSearch(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
form := fullAntragForm()
|
||||
form.Set("c3", "ja")
|
||||
form.Set("c3_art", "social_scoring")
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", form)
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if !b.Verboten || b.Einstufung != "verboten" {
|
||||
t.Fatalf("Bewertung = %+v, want Verboten=true, Einstufung=verboten", b)
|
||||
}
|
||||
if len(b.ZulaessigeWerkzeuge) != 0 || len(b.AusgeschlosseneWerkzeuge) != 0 {
|
||||
t.Errorf("expected no werkzeug search for a verboten Bewertung, got %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAntragCreateFiltersWerkzeugeByAnforderungen prüft den harten
|
||||
// Filter: personenbezogen verlangt avv_erforderlich — ein Werkzeug ohne
|
||||
// AVV wird ausgeschlossen und im Ergebnis mit Begründung genannt statt
|
||||
// stillschweigend wegzufallen, ein Werkzeug mit AVV bleibt zulässig.
|
||||
func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{
|
||||
ID: "werkzeug-ok", Name: "ChatGPT Enterprise",
|
||||
AVVVerfuegbar: true, TrainingStandard: true,
|
||||
}
|
||||
fs.werkzeuge["werkzeug-ohne-avv"] = store.Werkzeug{
|
||||
ID: "werkzeug-ohne-avv", Name: "Freies KI-Tool",
|
||||
AVVVerfuegbar: false, TrainingStandard: true,
|
||||
}
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "werkzeug-ok" {
|
||||
t.Errorf("ZulaessigeWerkzeuge = %v, want [werkzeug-ok]", b.ZulaessigeWerkzeuge)
|
||||
}
|
||||
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-ohne-avv" {
|
||||
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want [werkzeug-ohne-avv]", b.AusgeschlosseneWerkzeuge)
|
||||
}
|
||||
if got := b.AusgeschlosseneWerkzeuge[0].NichtErfuellt; len(got) != 1 || got[0] != "avv_erforderlich" {
|
||||
t.Errorf("NichtErfuellt = %v, want [avv_erforderlich]", got)
|
||||
}
|
||||
|
||||
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||
body := detail.Body.String()
|
||||
if !strings.Contains(body, "ChatGPT Enterprise") || !strings.Contains(body, "Freies KI-Tool") {
|
||||
t.Errorf("expected both werkzeuge (by name) on the detail page, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntragCreateFiltertNachLoeschfrist(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||
|
||||
sess := fs.sessions[cookie.Value]
|
||||
accountID := fs.users[sess.UserID].AccountID
|
||||
// fullAntragForm loest Datenklasse "personenbezogen" aus (b1=ja).
|
||||
fs.loeschfristen[accountID] = map[string]int{"personenbezogen": 30}
|
||||
|
||||
fs.werkzeuge["kurze-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "kurze-aufbewahrung", Name: "Kurz-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(14),
|
||||
}
|
||||
fs.werkzeuge["lange-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "lange-aufbewahrung", Name: "Lang-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(90),
|
||||
}
|
||||
fs.werkzeuge["unbekannte-aufbewahrung"] = store.Werkzeug{
|
||||
ID: "unbekannte-aufbewahrung", Name: "Unbekannt-Tool",
|
||||
AVVVerfuegbar: true, TrainingStandard: true,
|
||||
}
|
||||
|
||||
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
antragID = id
|
||||
}
|
||||
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||
}
|
||||
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "kurze-aufbewahrung" {
|
||||
t.Fatalf("ZulaessigeWerkzeuge = %v, want [kurze-aufbewahrung]", b.ZulaessigeWerkzeuge)
|
||||
}
|
||||
if len(b.AusgeschlosseneWerkzeuge) != 2 {
|
||||
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want 2 ausgeschlossene", b.AusgeschlosseneWerkzeuge)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
for _, path := range []string{"/antraege", "/antraege/neu"} {
|
||||
resp := getWithCookie(t, s, betreiberCookie, path)
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Errorf("GET %s status = %d, want 303 redirect for a betreiber", path, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
198
internal/web/auth_handlers.go
Normal file
198
internal/web/auth_handlers.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type authPageData struct {
|
||||
Title string
|
||||
Error string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
||||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren"})
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := authPageData{Title: "Anmelden"}
|
||||
if r.URL.Query().Get("reset") == "1" {
|
||||
data.Message = "Passwort erfolgreich geändert. Bitte melde dich mit dem neuen Passwort an."
|
||||
}
|
||||
s.renderAuthPage(w, "login", data)
|
||||
}
|
||||
|
||||
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
||||
// Spezifikation): legt einen neuen Mandanten (Account) und den ersten
|
||||
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
|
||||
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
|
||||
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
|
||||
// Mitarbeiter über dieses Formular — die kommen über den
|
||||
// Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
|
||||
// Import/Mandantenlink (weiterhin nicht gebaut).
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
|
||||
return
|
||||
}
|
||||
|
||||
accountName := r.FormValue("account_name")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if accountName == "" || email == "" || password == "" {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Die account_id ist zu Beginn noch nicht bekannt (die Firma entsteht
|
||||
// ja gerade erst) — WithTenantScope startet die RLS-Transaktion
|
||||
// zunächst ohne Mandant, SetTenantScope trägt die neue ID nach,
|
||||
// sobald CreateAccount sie liefert. Ohne das würden die anschließenden
|
||||
// Schreibzugriffe auf genehmiger_rolle/loeschfrist_einstellung (beide
|
||||
// RLS-geschützt) von der Policy abgelehnt.
|
||||
var errMsg string
|
||||
err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error {
|
||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||
if err != nil {
|
||||
errMsg = "Konto konnte nicht angelegt werden"
|
||||
return err
|
||||
}
|
||||
if err := s.store.SetTenantScope(ctx, acc.ID, false); err != nil {
|
||||
errMsg = "Interner Fehler"
|
||||
return err
|
||||
}
|
||||
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
||||
if err != nil {
|
||||
errMsg = "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"
|
||||
return err
|
||||
}
|
||||
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||
errMsg = "Genehmiger-Rollen konnten nicht angelegt werden"
|
||||
return err
|
||||
}
|
||||
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||
errMsg = "Löschfristen konnten nicht angelegt werden"
|
||||
return err
|
||||
}
|
||||
// r.WithContext(ctx): startSession muss in DERSELBEN Transaktion
|
||||
// laufen wie CreateUser darüber — sonst sieht die Session-Zeile
|
||||
// (andere Verbindung über s.Pool, da r.Context() sonst ohne Tx
|
||||
// wäre) den gerade erst angelegten, noch nicht committeten
|
||||
// Nutzer nicht und die Fremdschlüsselprüfung schlägt fehl.
|
||||
if err := s.startSession(w, r.WithContext(ctx), user.ID); err != nil {
|
||||
errMsg = "Sitzung konnte nicht gestartet werden"
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if errMsg == "" {
|
||||
errMsg = "Registrierung fehlgeschlagen"
|
||||
}
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: errMsg})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "ungültiges Formular"})
|
||||
return
|
||||
}
|
||||
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
|
||||
ctx := r.Context()
|
||||
user, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
// Absichtlich dieselbe Meldung wie bei falschem Passwort — sonst
|
||||
// verrät die Fehlermeldung, ob eine E-Mail-Adresse existiert.
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||
return
|
||||
}
|
||||
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||
return
|
||||
}
|
||||
// Erst NACH erfolgreicher Passwortprüfung unterscheiden — sonst würde
|
||||
// diese Meldung einem Angreifer ohne gültiges Passwort verraten, dass
|
||||
// die E-Mail-Adresse zu einem (deaktivierten) Konto gehört.
|
||||
if !user.Active {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Dieses Konto ist deaktiviert"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
return
|
||||
}
|
||||
// Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der
|
||||
// Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch
|
||||
// getrennt vom Mandantenbereich).
|
||||
if user.Role == "betreiber" {
|
||||
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
_ = s.store.DeleteSession(r.Context(), cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
|
||||
})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// startSession erzeugt eine neue Sitzung und setzt das Session-Cookie.
|
||||
// Secure ist an genau dann, wenn die Anfrage selbst über TLS kam — ein
|
||||
// fest verdrahtetes Secure=true würde lokale Entwicklung ohne TLS
|
||||
// (http://localhost) brechen, da Browser ein Secure-Cookie über Klartext-
|
||||
// HTTP schlicht nicht speichern.
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
setSessionCookie(w, r, token, expiresAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
|
||||
// startSession (regulärer Login) und handleBetreiberLoginAls
|
||||
// (Support-Login).
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: expiresAt,
|
||||
})
|
||||
}
|
||||
98
internal/web/betreiber_account_crud_test.go
Normal file
98
internal/web/betreiber_account_crud_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
|
||||
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
user, err := fs.GetUserByEmail(context.Background(), "admin@neue-firma.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
t.Errorf("Role = %q, want admin", user.Role)
|
||||
}
|
||||
acc, err := fs.GetAccount(context.Background(), user.AccountID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if acc.Name != "Neue Firma GmbH" {
|
||||
t.Errorf("Name = %q, want Neue Firma GmbH", acc.Name)
|
||||
}
|
||||
|
||||
// Der neue Login funktioniert tatsächlich.
|
||||
loginResp := postForm(t, s, nil, "/login", url.Values{
|
||||
"email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if loginResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberFirmaAnlegenRejectsMissingFields(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{"account_name": {"Nur Name"}})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Pflicht") {
|
||||
t.Errorf("expected a validation error, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberKannFirmaUmbenennen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
acc, err := fs.CreateAccount(context.Background(), "Alter Name")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID+"/umbenennen", url.Values{"name": {"Neuer Name"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
got, err := fs.GetAccount(context.Background(), acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if got.Name != "Neuer Name" {
|
||||
t.Errorf("Name = %q, want Neuer Name", got.Name)
|
||||
}
|
||||
|
||||
detail := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID)
|
||||
if !strings.Contains(detail.Body.String(), "Neuer Name") {
|
||||
t.Errorf("expected the new name on the detail page, got: %s", detail.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotCreateFirma(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/betreiber/accounts/neu")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
329
internal/web/betreiber_handlers.go
Normal file
329
internal/web/betreiber_handlers.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
type betreiberDashboardData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
AccountCount int
|
||||
WerkzeugCount int
|
||||
RecentAuditCount int
|
||||
}
|
||||
|
||||
// handleBetreiberDashboard zeigt eine kurze Übersicht als Einstieg in
|
||||
// den Plattform-Bereich (Ebene 5 — nur Betreiber, technisch getrennt
|
||||
// vom Mandantenbereich).
|
||||
func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
accounts, err := s.store.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
werkzeuge, err := s.store.ListZentraleWerkzeuge(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auditLog, err := s.store.ListAuditLog(ctx, 5)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := betreiberDashboardData{
|
||||
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts),
|
||||
WerkzeugCount: len(werkzeuge), RecentAuditCount: len(auditLog),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberAccountListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type betreiberAccountListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Accounts []betreiberAccountListItem
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
||||
func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Request) {
|
||||
accounts, err := s.store.ListAccounts(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []store.Account
|
||||
for _, a := range accounts {
|
||||
if matchesQuery(q, a.Name) {
|
||||
gefiltert = append(gefiltert, a)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||
for _, a := range paginate(gefiltert, pag) {
|
||||
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
||||
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-accounts", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberAccountNeuData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleBetreiberAccountNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r)}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberAccountCreate legt eine Firma samt erstem admin-Login
|
||||
// an — dasselbe Ergebnis wie die öffentliche Firma-Registrierung
|
||||
// (handleRegister), nur vom Betreiber ausgelöst statt von der Firma
|
||||
// selbst (z. B. bei vertriebsunterstütztem Onboarding oder zum Anlegen
|
||||
// eines Testkontos). Der Betreiber setzt das Initialpasswort direkt,
|
||||
// wie überall sonst im Produkt ohne E-Mail-Infrastruktur.
|
||||
func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountName := r.FormValue("account_name")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if accountName == "" || email == "" || password == "" {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||
if err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin"); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Genehmiger-Rollen konnten nicht angelegt werden: " + err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
|
||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Löschfristen konnten nicht angelegt werden: " + err.Error()}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/betreiber/accounts/"+acc.ID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleBetreiberAccountRename benennt eine Firma um (z. B. Tippfehler
|
||||
// korrigieren) — kein sonstiges Bearbeiten, Löschen bleibt bewusst
|
||||
// ungebaut (siehe CLAUDE.md, Offene Punkte: hängt an der noch nicht
|
||||
// getroffenen Abrechnungs-/Freischaltungs-Architektur, und ein
|
||||
// Hard-Delete würde gegen die Fremdschlüssel aus antrag/app_user/
|
||||
// audit_log laufen).
|
||||
func (s *Server) handleBetreiberAccountRename(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if _, err := s.store.UpdateAccount(r.Context(), id, name); err != nil {
|
||||
http.Error(w, "Firma konnte nicht umbenannt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/accounts/"+id, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type betreiberUserView struct {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type betreiberAccountDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
AccountID string
|
||||
Name string
|
||||
Users []betreiberUserView
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// handleBetreiberAccountDetail zeigt einen Mandanten mit seinen Logins.
|
||||
func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := betreiberAccountDetailData{
|
||||
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name,
|
||||
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
for _, u := range users {
|
||||
data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberAuditEntryView struct {
|
||||
CreatedAt string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Details string
|
||||
}
|
||||
|
||||
type betreiberAuditLogData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Entries []betreiberAuditEntryView
|
||||
Suche string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// auditLogFetchLimit begrenzt, wie viele der neuesten Einträge aus der
|
||||
// DB geholt werden, bevor im Go-Code gefiltert/paginiert wird — echte
|
||||
// DB-seitige Offset-Paginierung wäre für ein potenziell sehr groß
|
||||
// werdendes Audit-Log der sauberere Ansatz, ist hier aber (noch) nicht
|
||||
// umgesetzt (siehe CLAUDE.md, Offene Punkte).
|
||||
const auditLogFetchLimit = 1000
|
||||
|
||||
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
||||
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.store.ListAuditLog(r.Context(), auditLogFetchLimit)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
var gefiltert []store.AuditEntry
|
||||
for _, e := range entries {
|
||||
if matchesQuery(q, e.Action, e.TargetType, e.Details) {
|
||||
gefiltert = append(gefiltert, e)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||
for _, e := range paginate(gefiltert, pag) {
|
||||
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
||||
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
||||
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-audit-log", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten
|
||||
// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen.
|
||||
// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per
|
||||
// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die
|
||||
// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet
|
||||
// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die
|
||||
// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum
|
||||
// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log
|
||||
// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die
|
||||
// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID —
|
||||
// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis
|
||||
// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass
|
||||
// gerade im Kontext eines fremden Kontos gehandelt wird.
|
||||
func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) {
|
||||
betreiber := currentUser(r)
|
||||
ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID"))
|
||||
if err != nil || ziel.AccountID != r.PathValue("accountID") {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !ziel.Active {
|
||||
http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||
if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil {
|
||||
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, token, expiresAt)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
449
internal/web/betreiber_werkzeug_handlers.go
Normal file
449
internal/web/betreiber_werkzeug_handlers.go
Normal file
@@ -0,0 +1,449 @@
|
||||
// Ebene 5 (Plattform) — Pflege des zentralen Werkzeugkatalogs
|
||||
// (`werkzeug.account_id IS NULL`). Mandantenspezifische Ergänzungen
|
||||
// (`account_id` gesetzt) sind NICHT Teil dieser Seiten — die bleiben
|
||||
// Sache des jeweiligen Mandanten (Ebene 4, "eigene Werkzeug-
|
||||
// Freigaben/-Sperrungen", noch nicht gebaut), siehe CLAUDE.md.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// werkzeugFilter bündelt die Filterkriterien für die Katalog-Listen
|
||||
// (Betreiber wie Mandant) — ein einziges GET-Formular setzt "q"
|
||||
// (Freitext über Name/Anbieter/Länder) und optional "zweck".
|
||||
type werkzeugFilter struct {
|
||||
Suche string
|
||||
Zweck string
|
||||
}
|
||||
|
||||
func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter {
|
||||
return werkzeugFilter{
|
||||
Suche: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
Zweck: r.URL.Query().Get("zweck"),
|
||||
}
|
||||
}
|
||||
|
||||
// Aktiv meldet, ob mindestens ein Filter gesetzt ist — steuert, ob ein
|
||||
// "Zurücksetzen"-Link angezeigt wird. Exportiert, weil html/template
|
||||
// nur exportierte Methoden per Reflection aufrufen kann.
|
||||
func (f werkzeugFilter) Aktiv() bool {
|
||||
return f.Suche != "" || f.Zweck != ""
|
||||
}
|
||||
|
||||
func (f werkzeugFilter) matches(wz store.Werkzeug) bool {
|
||||
if f.Zweck != "" {
|
||||
gefunden := false
|
||||
for _, z := range wz.GeeigneteZwecke {
|
||||
if z == f.Zweck {
|
||||
gefunden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !gefunden {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Suche != "" {
|
||||
q := strings.ToLower(f.Suche)
|
||||
heuhaufen := strings.ToLower(wz.Name + " " + wz.Anbieter + " " + strings.Join(wz.Verarbeitungslaender, " "))
|
||||
if !strings.Contains(heuhaufen, q) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
|
||||
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
|
||||
// nur für Verarbeitungsländer statt Zwecke.
|
||||
func istGueltigesLand(v string) bool {
|
||||
for _, l := range weltLaenderliste {
|
||||
if l == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
|
||||
// (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld
|
||||
// Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den
|
||||
// künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am
|
||||
// 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste
|
||||
// v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert)
|
||||
// — die ursprünglichen sechs deckten nur klassische Wissensarbeit ab,
|
||||
// nicht Datenanalyse/Video/Audio/Kundenservice/Design/Automatisierung.
|
||||
var gueltigeZwecke = []string{
|
||||
"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche",
|
||||
"Datenanalyse", "Video", "Audio/Sprache", "Kundenservice/Chatbot", "Präsentation/Design", "Automatisierung/Agenten",
|
||||
}
|
||||
|
||||
func istGueltigerZweck(v string) bool {
|
||||
for _, z := range gueltigeZwecke {
|
||||
if z == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen,
|
||||
// geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne
|
||||
// führende/folgende Leerzeichen und ohne leere Einträge.
|
||||
func splitListe(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type werkzeugListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungslaender string
|
||||
DPFZertifiziert bool
|
||||
AVVVerfuegbar bool
|
||||
TrainingStandard bool
|
||||
Aufbewahrung string
|
||||
Zertifizierungen string
|
||||
GeeigneteZwecke string
|
||||
LetztePruefung string
|
||||
PruefStatusLabel string
|
||||
PruefStatusCSS string
|
||||
}
|
||||
|
||||
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
|
||||
// keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer
|
||||
// sofortigen Löschung) verwechselt wird.
|
||||
func formatAufbewahrung(tage *int) string {
|
||||
if tage == nil {
|
||||
return "unbekannt"
|
||||
}
|
||||
if *tage == 0 {
|
||||
return "0 Tage (sofort)"
|
||||
}
|
||||
return strconv.Itoa(*tage) + " Tage"
|
||||
}
|
||||
|
||||
type werkzeugListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []werkzeugListItem
|
||||
Filter werkzeugFilter
|
||||
ZweckeOptionen []string
|
||||
Pagination pagination
|
||||
}
|
||||
|
||||
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
|
||||
// Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe
|
||||
// CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
|
||||
// belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt
|
||||
// 30 Tage davor (dasselbe Vorwarnfenster wie die Wiedervorlage,
|
||||
// wiedervorlageSchwelle) — "bald fällig" soll rechtzeitig auffallen,
|
||||
// nicht erst wenn die Frist schon gerissen ist.
|
||||
const (
|
||||
werkzeugPruefungBaldFaelligNach = 150 * 24 * time.Hour
|
||||
werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// pruefStatus liefert eine Ampel-Kennzeichnung für das Alter einer
|
||||
// letzten Werkzeug-Prüfung: aktuell (grün) / bald fällig (gelb) /
|
||||
// abgelaufen (rot) — die CSS-Klassen sind dieselben wie für die
|
||||
// Risiko-Ampel anderswo im Produkt (status-niedrig/-mittel/-hoch).
|
||||
func pruefStatus(letztePruefung time.Time) (label, css string) {
|
||||
alter := time.Since(letztePruefung)
|
||||
switch {
|
||||
case alter > werkzeugPruefungVeraltetNach:
|
||||
return "abgelaufen", "status-hoch"
|
||||
case alter > werkzeugPruefungBaldFaelligNach:
|
||||
return "bald fällig", "status-mittel"
|
||||
default:
|
||||
return "aktuell", "status-niedrig"
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
||||
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||
werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filter := werkzeugFilterFromRequest(r)
|
||||
var gefiltert []store.Werkzeug
|
||||
for _, wz := range werkzeuge {
|
||||
if filter.matches(wz) {
|
||||
gefiltert = append(gefiltert, wz)
|
||||
}
|
||||
}
|
||||
pag := paginationFromRequest(r, len(gefiltert))
|
||||
seite := paginate(gefiltert, pag)
|
||||
|
||||
data := werkzeugListeData{
|
||||
Title: "Werkzeugkatalog", Nav: navFor(r),
|
||||
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
|
||||
}
|
||||
for _, wz := range seite {
|
||||
label, css := pruefStatus(wz.LetztePruefung)
|
||||
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||
DPFZertifiziert: wz.DPFZertifiziert,
|
||||
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
|
||||
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler
|
||||
// Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten-
|
||||
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
|
||||
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
|
||||
type werkzeugFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
ActionBase string
|
||||
ID string // leer bei "neu"
|
||||
Name string
|
||||
Anbieter string
|
||||
LaenderOptionen []string
|
||||
LaenderAusgewaehlt map[string]bool
|
||||
DPFZertifiziert bool
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
|
||||
Zertifizierungen string
|
||||
Subprozessoren string
|
||||
ZweckeOptionen []string
|
||||
ZweckeAusgewaehlt map[string]bool
|
||||
Einschraenkungen string
|
||||
LetztePruefung string
|
||||
Quelle string
|
||||
Error string
|
||||
}
|
||||
|
||||
// auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
|
||||
// gespeicherten oder erneut eingereichten Liste von Zwecken.
|
||||
func auswahlSet(ausgewaehlt []string) map[string]bool {
|
||||
m := make(map[string]bool, len(ausgewaehlt))
|
||||
for _, z := range ausgewaehlt {
|
||||
m[z] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// werkzeugFormDataFromWerkzeug baut die Formular-Vorbelegung zum
|
||||
// Bearbeiten eines bestehenden Katalogeintrags — gemeinsam für Betreiber-
|
||||
// und Mandanten-Formular, damit beide bei neuen Feldern nicht auseinanderlaufen.
|
||||
func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, actionBase string) werkzeugFormData {
|
||||
aufbewahrung := ""
|
||||
if wz.AufbewahrungTage != nil {
|
||||
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
|
||||
}
|
||||
return werkzeugFormData{
|
||||
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID,
|
||||
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert,
|
||||
Name: wz.Name, Anbieter: wz.Anbieter,
|
||||
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
||||
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung,
|
||||
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
|
||||
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke),
|
||||
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
||||
Quelle: wz.Quelle,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := werkzeugFormData{
|
||||
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
|
||||
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
|
||||
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
|
||||
// accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt
|
||||
// für einen mandantenspezifischen Eintrag (Ebene 4).
|
||||
func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) {
|
||||
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
|
||||
if err != nil {
|
||||
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
||||
}
|
||||
var verarbeitungslaender []string
|
||||
for _, l := range r.Form["verarbeitungslaender"] {
|
||||
if istGueltigesLand(l) {
|
||||
verarbeitungslaender = append(verarbeitungslaender, l)
|
||||
}
|
||||
}
|
||||
if len(verarbeitungslaender) == 0 {
|
||||
return store.WerkzeugInput{}, "mindestens ein Verarbeitungsland ist Pflicht"
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
anbieter := r.FormValue("anbieter")
|
||||
quelle := r.FormValue("quelle")
|
||||
if name == "" || anbieter == "" || quelle == "" {
|
||||
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
|
||||
}
|
||||
var aufbewahrungTage *int
|
||||
if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" {
|
||||
wert, err := strconv.Atoi(rohwert)
|
||||
if err != nil || wert < 0 {
|
||||
return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)"
|
||||
}
|
||||
aufbewahrungTage = &wert
|
||||
}
|
||||
|
||||
var geeigneteZwecke []string
|
||||
for _, z := range r.Form["geeignete_zwecke"] {
|
||||
if istGueltigerZweck(z) {
|
||||
geeigneteZwecke = append(geeigneteZwecke, z)
|
||||
}
|
||||
}
|
||||
|
||||
return store.WerkzeugInput{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: anbieter,
|
||||
Verarbeitungslaender: verarbeitungslaender,
|
||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
||||
AVVURL: r.FormValue("avv_url"),
|
||||
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
||||
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||
AufbewahrungTage: aufbewahrungTage,
|
||||
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
|
||||
GeeigneteZwecke: geeigneteZwecke,
|
||||
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||
LetztePruefung: letztePruefung,
|
||||
Quelle: quelle,
|
||||
}, ""
|
||||
}
|
||||
|
||||
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
|
||||
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
|
||||
// statt das Formular zu leeren.
|
||||
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
||||
return werkzeugFormData{
|
||||
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id,
|
||||
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(r.Form["verarbeitungslaender"]),
|
||||
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"),
|
||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
||||
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
|
||||
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
|
||||
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
|
||||
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]),
|
||||
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
||||
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugCreate legt einen zentralen Katalogeintrag an.
|
||||
func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugEditForm zeigt einen zentralen Katalogeintrag
|
||||
// zum Bearbeiten. Ein mandantenspezifischer Eintrag (account_id
|
||||
// gesetzt) ist hier bewusst nicht erreichbar — das ist Sache des
|
||||
// jeweiligen Mandanten, nicht der Plattform.
|
||||
func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||
if err != nil || wz.AccountID != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugUpdate speichert Änderungen an einem zentralen
|
||||
// Katalogeintrag. Werkzeug ist nicht append-only (siehe store.UpdateWerkzeug)
|
||||
// — eine bereits getroffene Entscheidung hält ihren eigenen, zum
|
||||
// Entscheidungszeitpunkt eingefrorenen Snapshot (entscheidung.werkzeug_snapshot).
|
||||
func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugDelete entfernt einen zentralen Katalogeintrag.
|
||||
func (s *Server) handleBetreiberWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
188
internal/web/betreiber_werkzeug_handlers_test.go
Normal file
188
internal/web/betreiber_werkzeug_handlers_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func werkzeugForm() url.Values {
|
||||
return url.Values{
|
||||
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
|
||||
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
||||
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
||||
"einschraenkungen": {""}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberCanCreateZentralesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var w store.Werkzeug
|
||||
for _, wz := range fs.werkzeuge {
|
||||
w = wz
|
||||
}
|
||||
if w.Name != "ChatGPT Enterprise" || w.AccountID != nil {
|
||||
t.Fatalf("Werkzeug = %+v, unerwartete Werte", w)
|
||||
}
|
||||
if len(w.Zertifizierungen) != 2 {
|
||||
t.Errorf("Zertifizierungen = %v, want 2 Einträge", w.Zertifizierungen)
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the new werkzeug in the Katalogliste, got: %s", list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberWerkzeugCreateRequiresLetztePruefungUndQuelle(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
form := werkzeugForm()
|
||||
form.Set("letzte_pruefung", "")
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", form)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (Formular mit Fehler) ohne letzte_pruefung", resp.Code)
|
||||
}
|
||||
if len(fs.werkzeuge) != 0 {
|
||||
t.Errorf("expected no werkzeug to be created, got %d", len(fs.werkzeuge))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberCanUpdateAndDeleteZentralesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||
var id string
|
||||
for wid := range fs.werkzeuge {
|
||||
id = wid
|
||||
}
|
||||
|
||||
editResp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id)
|
||||
if editResp.Code != http.StatusOK || !strings.Contains(editResp.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Fatalf("edit form status = %d, body: %s", editResp.Code, editResp.Body.String())
|
||||
}
|
||||
|
||||
updateForm := werkzeugForm()
|
||||
updateForm.Set("name", "ChatGPT Enterprise (aktualisiert)")
|
||||
updateResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id, updateForm)
|
||||
if updateResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||
}
|
||||
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (aktualisiert)" {
|
||||
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||
}
|
||||
|
||||
deleteResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||
}
|
||||
if _, ok := fs.werkzeuge[id]; ok {
|
||||
t.Error("expected the werkzeug to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBetreiberCannotEditMandantenEigenesWerkzeug: ein mandantenspezifischer
|
||||
// Katalogeintrag (account_id gesetzt) ist über die Betreiber-Seiten
|
||||
// nicht erreichbar — das bleibt Sache des jeweiligen Mandanten.
|
||||
func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
acc, err := fs.CreateAccount(context.Background(), "Mandant mit eigenem Werkzeug")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
eigenesID := "eigenes-werkzeug"
|
||||
fs.werkzeuge[eigenesID] = store.Werkzeug{ID: eigenesID, AccountID: &acc.ID, Name: "Mandanten-eigenes Tool"}
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+eigenesID)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberWerkzeugListeFiltertNachSucheUndZweck(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
fs.werkzeuge["a"] = store.Werkzeug{
|
||||
ID: "a", Name: "ChatGPT Enterprise", Anbieter: "OpenAI",
|
||||
Verarbeitungslaender: []string{"USA"}, GeeigneteZwecke: []string{"Textgenerierung"},
|
||||
LetztePruefung: time.Now(),
|
||||
}
|
||||
fs.werkzeuge["b"] = store.Werkzeug{
|
||||
ID: "b", Name: "DeepL Pro", Anbieter: "DeepL SE",
|
||||
Verarbeitungslaender: []string{"Deutschland"}, GeeigneteZwecke: []string{"Übersetzung"},
|
||||
LetztePruefung: time.Now(),
|
||||
}
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=deepl")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.Code)
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || !strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||
t.Fatalf("q=deepl sollte nur DeepL Pro zeigen, body: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?zweck=Textgenerierung")
|
||||
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||
t.Fatalf("zweck=Textgenerierung sollte nur ChatGPT Enterprise zeigen, body: %s", resp.Body.String())
|
||||
}
|
||||
|
||||
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=nichts-passt")
|
||||
if !strings.Contains(resp.Body.String(), "Keine Treffer") {
|
||||
t.Fatalf("erwartet 'Keine Treffer' bei erfolgloser Suche, body: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberWerkzeugListePaginiert(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
for i := 0; i < 25; i++ {
|
||||
id := fmt.Sprintf("wz-%02d", i)
|
||||
fs.werkzeuge[id] = store.Werkzeug{
|
||||
ID: id, Name: fmt.Sprintf("Werkzeug %02d", i), Anbieter: "Testanbieter",
|
||||
LetztePruefung: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.Code)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Seite 1 von 2") {
|
||||
t.Fatalf("erwartet 'Seite 1 von 2' bei 25 Einträgen (20 pro Seite), body: %s", resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Weiter") || strings.Contains(resp.Body.String(), "Zurück") {
|
||||
t.Fatalf("Seite 1 sollte einen Weiter-, aber keinen Zurück-Button zeigen")
|
||||
}
|
||||
|
||||
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?page=2")
|
||||
if !strings.Contains(resp.Body.String(), "Seite 2 von 2") {
|
||||
t.Fatalf("erwartet 'Seite 2 von 2', body: %s", resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Weiter") || !strings.Contains(resp.Body.String(), "Zurück") {
|
||||
t.Fatalf("Seite 2 sollte einen Zurück-, aber keinen Weiter-Button zeigen")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user