feat(legal): Zustimmungs-Nachweise mit IP, Gerät/Browser & Hash (v1.7.8)

Belastbarer Einwilligungsnachweis:
- Schema/Migration: legal_acceptances + ipAddress, userAgent (contentHash bestand bereits)
- Erfassung bei Registrierung und jeder Zustimmung (/api/legal/accept)
- Admin /admin/legal: neue Spalten IP + Gerät/Browser + Hash; CSV-Export um ipAddress,
  userAgent, contentHash erweitert
- Datenschutzerklärung + Dateninventar: IP/User-Agent als dokumentierter Beweiszweck ergänzt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-23 17:55:57 +02:00
parent 5cdf0e63c9
commit fa1e911ecd
10 changed files with 27 additions and 8 deletions

View File

@@ -9,7 +9,7 @@ Abgeleitet aus dem tatsächlichen Code-Stand. Offene Fristen/Standorte als Platz
| Passwort (bcrypt-Hash) | Authentifizierung | Benutzer | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Kontolöschung | mit Konto | Einweg-Hash (bcrypt cost 12) | | Passwort (bcrypt-Hash) | Authentifizierung | Benutzer | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Kontolöschung | mit Konto | Einweg-Hash (bcrypt cost 12) |
| Einsatz-/Inhaltsdaten (Pläne, Journale, Zeichnungen, Koordinaten) | Kernfunktion | Org-Mitglieder, ggf. Dritte | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Löschung durch Org | Projekt-/Orglöschung; Backups | Mandantentrennung, Zugriffskontrolle | | Einsatz-/Inhaltsdaten (Pläne, Journale, Zeichnungen, Koordinaten) | Kernfunktion | Org-Mitglieder, ggf. Dritte | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Löschung durch Org | Projekt-/Orglöschung; Backups | Mandantentrennung, Zugriffskontrolle |
| Hochgeladene Dateien (Logos, Planbilder, Symbole) | Darstellung | Org | Upload | MinIO | — | `[SERVERSTANDORT]` | bis Löschung | mit Projekt/Org | Zugriffsschutz, Validierung | | Hochgeladene Dateien (Logos, Planbilder, Symbole) | Darstellung | Org | Upload | MinIO | — | `[SERVERSTANDORT]` | bis Löschung | mit Projekt/Org | Zugriffsschutz, Validierung |
| Zustimmungsnachweise (Doc-Typ/Version/Zeitpunkt/Kontext) | Nachweis Einwilligung/Vertrag | Benutzer | System | PostgreSQL | — | `[SERVERSTANDORT]` | Aufbewahrung zu Nachweiszwecken `[FRIST]` | revisionssicher, nicht überschrieben | minimale Daten (keine roh-IP) | | Zustimmungsnachweise (Doc-Typ/Version/Zeitpunkt/Kontext, IP, User-Agent, Hash) | Nachweis Einwilligung/Vertrag (Beweiszweck) | Benutzer | System | PostgreSQL (Schweiz, Eigenbetrieb) | — | nein | Aufbewahrung zu Nachweiszwecken `[FRIST]` | revisionssicher, nicht überschrieben | Zugriffskontrolle; IP/User-Agent bewusst zu Beweiszwecken erfasst |
| Protokoll-/Sicherheitsdaten | Betrieb, Sicherheit | Benutzer | System | Server-Logs | — | `[SERVERSTANDORT]` | `[FRIST]` | rotierend | Minimierung sensibler Inhalte | | Protokoll-/Sicherheitsdaten | Betrieb, Sicherheit | Benutzer | System | Server-Logs | — | `[SERVERSTANDORT]` | `[FRIST]` | rotierend | Minimierung sensibler Inhalte |
| Session-Cookie `auth-token` | Anmeldung | Benutzer | System | Client-Cookie | — | — | 24 h / 14 Tage | Ablauf/Logout | httpOnly, secure (prod), sameSite=lax | | Session-Cookie `auth-token` | Anmeldung | Benutzer | System | Client-Cookie | — | — | 24 h / 14 Tage | Ablauf/Logout | httpOnly, secure (prod), sameSite=lax |
| E-Mail-Versand (Verifizierung, Reset, Kontakt, Einladung) | Kommunikation | Benutzer/Empfänger | System/Eingabe | SMTP-Anbieter | E-Mail-Anbieter | `[ABHÄNGIG]` | beim Anbieter | — | TLS, nur bei Bedarf | | E-Mail-Versand (Verifizierung, Reset, Kontakt, Einladung) | Kommunikation | Benutzer/Empfänger | System/Eingabe | SMTP-Anbieter | E-Mail-Anbieter | `[ABHÄNGIG]` | beim Anbieter | — | TLS, nur bei Bedarf |

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.7.7", "version": "1.7.8",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -183,6 +183,8 @@ async function migrate() {
)`, )`,
`CREATE INDEX IF NOT EXISTS legal_acceptances_user_idx ON legal_acceptances("userId")`, `CREATE INDEX IF NOT EXISTS legal_acceptances_user_idx ON legal_acceptances("userId")`,
`CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`, `CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`,
`ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "ipAddress" TEXT`,
`ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "userAgent" TEXT`,
] ]
for (const sql of tableMigrations) { for (const sql of tableMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ } try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }

View File

@@ -173,6 +173,9 @@ model LegalAcceptance {
locale String? locale String?
// REGISTRATION | LOGIN_RECONSENT | ORGANIZATION_CREATION | ADMIN_ROLE_ACCEPTANCE | DONATION // REGISTRATION | LOGIN_RECONSENT | ORGANIZATION_CREATION | ADMIN_ROLE_ACCEPTANCE | DONATION
context String context String
// Nachweisdaten (zu Beweiszwecken der Einwilligung): IP + Gerät/Browser zum Zeitpunkt der Zustimmung.
ipAddress String?
userAgent String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -8,7 +8,7 @@ import { useAuth } from '@/components/providers/auth-provider'
interface Doc { id: string; type: string; version: string; title: string; url: string | null; isActive: boolean; publishedAt: string; contentHash: string; acceptanceCount: number } interface Doc { id: string; type: string; version: string; title: string; url: string | null; isActive: boolean; publishedAt: string; contentHash: string; acceptanceCount: number }
interface Miss { type: string; version: string; missing: number } interface Miss { type: string; version: string; missing: number }
interface Acc { id: string; email: string; name: string; documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null } interface Acc { id: string; email: string; name: string; documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null; ipAddress: string | null; userAgent: string | null; contentHash: string | null }
export default function AdminLegalPage() { export default function AdminLegalPage() {
const { user, loading, isServerAdmin } = useAuth() const { user, loading, isServerAdmin } = useAuth()
@@ -137,6 +137,8 @@ export default function AdminLegalPage() {
<th className="px-3 py-2">Benutzer</th> <th className="px-3 py-2">Benutzer</th>
<th className="px-3 py-2">Dokument</th> <th className="px-3 py-2">Dokument</th>
<th className="px-3 py-2">Kontext</th> <th className="px-3 py-2">Kontext</th>
<th className="px-3 py-2">IP</th>
<th className="px-3 py-2">Gerät / Browser</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -144,8 +146,10 @@ export default function AdminLegalPage() {
<tr key={a.id} className="border-t border-border"> <tr key={a.id} className="border-t border-border">
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">{new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })}</td> <td className="px-3 py-2 text-muted-foreground whitespace-nowrap">{new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })}</td>
<td className="px-3 py-2"><span className="text-foreground">{a.name}</span><br /><span className="text-xs text-muted-foreground">{a.email}</span></td> <td className="px-3 py-2"><span className="text-foreground">{a.name}</span><br /><span className="text-xs text-muted-foreground">{a.email}</span></td>
<td className="px-3 py-2">{a.documentType} <span className="text-muted-foreground">v{a.documentVersion}</span></td> <td className="px-3 py-2">{a.documentType} <span className="text-muted-foreground">v{a.documentVersion}</span>{a.contentHash && <><br /><span className="font-mono text-[10px] text-muted-foreground">{a.contentHash.slice(0, 12)}</span></>}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{a.context}</td> <td className="px-3 py-2 text-xs text-muted-foreground">{a.context}</td>
<td className="px-3 py-2 text-xs text-muted-foreground font-mono whitespace-nowrap">{a.ipAddress || '—'}</td>
<td className="px-3 py-2 text-[10px] text-muted-foreground max-w-[200px] truncate" title={a.userAgent || ''}>{a.userAgent || '—'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -29,7 +29,7 @@ export async function GET(req: NextRequest) {
where: userFilter, where: userFilter,
orderBy: { acceptedAt: 'desc' }, orderBy: { acceptedAt: 'desc' },
take: 2000, take: 2000,
select: { id: true, userId: true, organizationId: true, documentType: true, documentVersion: true, context: true, acceptedAt: true }, select: { id: true, userId: true, organizationId: true, documentType: true, documentVersion: true, context: true, acceptedAt: true, contentHash: true, ipAddress: true, userAgent: true },
}) })
// Nur die notwendigen Nachweisdaten anreichern (E-Mail/Name) — keine überflüssigen Personendaten. // Nur die notwendigen Nachweisdaten anreichern (E-Mail/Name) — keine überflüssigen Personendaten.
@@ -43,11 +43,11 @@ export async function GET(req: NextRequest) {
})) }))
if (format === 'csv') { if (format === 'csv') {
const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', 'organizationId'] const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', 'ipAddress', 'userAgent', 'contentHash', 'organizationId']
const esc = (v: any) => `"${String(v ?? '').replace(/"/g, '""')}"` const esc = (v: any) => `"${String(v ?? '').replace(/"/g, '""')}"`
const lines = [header.join(',')] const lines = [header.join(',')]
for (const r of rows) { for (const r of rows) {
lines.push([new Date(r.acceptedAt).toISOString(), r.email, r.name, r.documentType, r.documentVersion, r.context, r.organizationId || ''].map(esc).join(',')) lines.push([new Date(r.acceptedAt).toISOString(), r.email, r.name, r.documentType, r.documentVersion, r.context, r.ipAddress || '', r.userAgent || '', r.contentHash || '', r.organizationId || ''].map(esc).join(','))
} }
return new NextResponse(lines.join('\r\n'), { return new NextResponse(lines.join('\r\n'), {
headers: { headers: {

View File

@@ -119,6 +119,8 @@ export async function POST(req: NextRequest) {
context: 'REGISTRATION', context: 'REGISTRATION',
types: ['TERMS', 'PRIVACY'], types: ['TERMS', 'PRIVACY'],
locale: 'de-CH', locale: 'de-CH',
ipAddress: getClientIp(req),
userAgent: req.headers.get('user-agent'),
}) })
} catch (e) { } catch (e) {
console.warn('[register] Zustimmungsprotokollierung fehlgeschlagen (nicht blockierend):', e) console.warn('[register] Zustimmungsprotokollierung fehlgeschlagen (nicht blockierend):', e)

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getClientIp } from '@/lib/rate-limit'
import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal' import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal'
const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT'] const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT']
@@ -38,6 +39,8 @@ export async function POST(req: NextRequest) {
context, context,
types, types,
locale: 'de-CH', locale: 'de-CH',
ipAddress: getClientIp(req),
userAgent: req.headers.get('user-agent'),
}) })
const isOrgAdmin = user.role === 'TENANT_ADMIN' const isOrgAdmin = user.role === 'TENANT_ADMIN'

View File

@@ -35,7 +35,7 @@ export default function DatenschutzPage() {
<li><strong>Registrierungs-/Kontodaten:</strong> Name, E-Mail-Adresse, Organisationsname, Rolle. Das Passwort wird mit einem geeigneten Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.</li> <li><strong>Registrierungs-/Kontodaten:</strong> Name, E-Mail-Adresse, Organisationsname, Rolle. Das Passwort wird mit einem geeigneten Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.</li>
<li><strong>Einsatz-/Inhaltsdaten:</strong> Lagepläne, Journaleinträge, Zeichnungen, Koordinaten, Symbole, hochgeladene Dateien (Logos, Planbilder, Symbole) sowie zugehörige Projekt- und Organisationsdaten.</li> <li><strong>Einsatz-/Inhaltsdaten:</strong> Lagepläne, Journaleinträge, Zeichnungen, Koordinaten, Symbole, hochgeladene Dateien (Logos, Planbilder, Symbole) sowie zugehörige Projekt- und Organisationsdaten.</li>
<li><strong>Protokoll-/Sicherheitsdaten:</strong> technische Zugriffs- und Fehlerprotokolle (z.B. Zeitpunkt, ungefähre technische Angaben) zur Gewährleistung von Betrieb und Sicherheit.</li> <li><strong>Protokoll-/Sicherheitsdaten:</strong> technische Zugriffs- und Fehlerprotokolle (z.B. Zeitpunkt, ungefähre technische Angaben) zur Gewährleistung von Betrieb und Sicherheit.</li>
<li><strong>Zustimmungsnachweise:</strong> welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde.</li> <li><strong>Zustimmungsnachweise:</strong> welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde zu Beweiszwecken zusammen mit IP-Adresse und Gerät/Browser (User-Agent) sowie dem Dokument-Prüfwert (Hash).</li>
<li><strong>Unterstützungsbeiträge (optional):</strong> bei freiwilligen Beiträgen der Betrag sowie freiwillig angegebener Name/Nachricht (Zahlungsabwicklung siehe Ziffer 9).</li> <li><strong>Unterstützungsbeiträge (optional):</strong> bei freiwilligen Beiträgen der Betrag sowie freiwillig angegebener Name/Nachricht (Zahlungsabwicklung siehe Ziffer 9).</li>
</ul> </ul>
</section> </section>

View File

@@ -67,6 +67,9 @@ interface RecordInput {
locale?: string | null locale?: string | null
/** Dokumenttypen, die zugestimmt werden. Es wird jeweils die AKTIVE Version protokolliert. */ /** Dokumenttypen, die zugestimmt werden. Es wird jeweils die AKTIVE Version protokolliert. */
types: LegalDocumentType[] types: LegalDocumentType[]
/** Nachweisdaten (Beweiszweck): IP + Gerät/Browser zum Zeitpunkt der Zustimmung. */
ipAddress?: string | null
userAgent?: string | null
} }
/** /**
@@ -88,6 +91,8 @@ export async function recordAcceptances(input: RecordInput): Promise<number> {
contentHash: d.contentHash, contentHash: d.contentHash,
context: input.context, context: input.context,
locale: input.locale ?? 'de-CH', locale: input.locale ?? 'de-CH',
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ? String(input.userAgent).slice(0, 400) : null,
})) }))
if (rows.length === 0) return 0 if (rows.length === 0) return 0
const res = await (prisma as any).legalAcceptance.createMany({ data: rows, skipDuplicates: true }) const res = await (prisma as any).legalAcceptance.createMany({ data: rows, skipDuplicates: true })