ADR-0057: Kontogebundene Report-Quota getrennt vom IP-Bucket
- Status: akzeptiert; produktiv verifiziert
- Datum: 2026-08-25
- Owner: Andreas
- Issue: #312
- Produktionsnachweis: 26. August 2026
- Adressiert Risiken: geteilte Büro-/Gemeinde-IP, vermischte Authentitäten, Cookie-Fallback bei ungültigem API-Key, personenbezogene Rate-Limit-Kennungen und unvollständige Kontolöschung
Kontext
Der öffentliche Reportpfad begrenzte bisher alle Aufrufe ohne API-Key auf zehn Reports pro Tag und pseudonymem IP-Bucket. Das gilt auch dann, wenn der Browser bereits eine gültige LandNutzen-Kontositzung besitzt. Mehrere berechtigte Konten hinter derselben Büro-, Gemeinde-, Meetingraum- oder Abnahme-IP teilten damit einen Zähler. Ein ausgeschöpfter anonymer Bucket konnte einen korrekt angemeldeten Nutzer blockieren.
API-Keys besitzen bereits einen eigenständigen, in der Datenbank hinterlegten Tageswert. Diese Maschinenidentität darf weder mit einer Browser-Sitzung noch mit dem anonymen Fallback vermischt werden. Gleichzeitig darf ein Fehler beim Auflösen einer Sitzung den Missbrauchsschutz nicht umgehen.
Entscheidung
Drei Identitäten, drei getrennte Quota-Buckets
Für GET und POST /api/v1/report gelten genau drei Auth-Modi:
| Modus | Identitätsquelle | Tageslimit | Bucket |
|---|---|---|---|
anon | kein API-Key und keine sicher auflösbare bestehende Kontositzung | 10 | bestehender pseudonymer Hash-Bucket der Request-IP |
session | bestehende serverseitig validierte Sitzung eines noch vorhandenen users.id | 10 | pseudonymer HMAC-SHA-256-Account-Bucket |
key | gültiger API-Key | api_keys.daily_limit | bestehender API-Key-Bucket |
Der Account-Bucket wird als
account:v1:<HMAC-SHA-256(AUTH_SECRET, report:account:v1:<users.id>)>
gebildet. Weder die numerische User-ID noch die E-Mail-Adresse wird in
rate_limits gespeichert. Zwei Konten hinter derselben IP beeinflussen
einander nicht; ein Konto erhält unabhängig von wechselnden IPs keinen zweiten
Tageszähler. Der nicht personenbezogene Prefix trennt Konto-Buckets eindeutig
von anonymen IP- und API-Key-Buckets.
Die historisch benannte Spalte rate_limits.ip_hash enthält damit einen
generischen pseudonymen Bucket-Schlüssel. Sie wird für diesen Slice nicht
umbenannt. Der in reports.ip_hash gespeicherte Wert bleibt davon strikt
getrennt: Er ist weiterhin der pseudonyme Hash der tatsächlichen Request-IP
und wird nicht durch den Account-Bucket ersetzt.
Auth-Reihenfolge und Fallback
- Ein mitgesendeter
Authorization: Bearer …- oderX-API-Key-Wert wird vor jeder Cookie-/Sessionentscheidung geprüft. Ein ungültiger oder widerrufener Key antwortet mit401 unauthorized; es gibt keinen Fallback auf Sitzung oder anonymen Zugang. - Nur wenn kein API-Key angeboten wurde, darf der bestehende serverseitige
Auth.js-Sitzungspfad aufgelöst werden. Eine gültige Sitzung zählt nur dann
als
session, wenn der zugehörige Nutzer noch inusersexistiert. - Fehler bei der Sitzungsauflösung, unvollständige Sitzungen und bereits
gelöschte Nutzer fallen auf
anonund dessen Request-IP-Bucket zurück. Der Aufruf geht wegen einer Sessionstörung niemals direkt an der Bucket-Prüfung vorbei. Die bestehende, davon getrennte Fail-open-Policy bei einem Ausfall des Rate-Limit-Datastores wird durch diesen Slice nicht verändert. - Erst nach dieser Entscheidung wird exakt der gewählte Bucket atomar erhöht.
X-RateLimit-LimitundX-RateLimit-Remainingbeschreiben diesen Bucket;X-Auth-Modelautet entsprechendanon,sessionoderkey.
Der Session-Modus ist ausschließlich die Wiederverwendung der vorhandenen gleich-originären Browser-Kontositzung. Er führt kein neues headless Cookie-Auth-Verfahren ein. Maschinen- und Drittclients verwenden weiterhin API-Keys; die öffentliche CORS-Schnittstelle bleibt ohne Credentials.
Löschung und Aufbewahrung
Migration 0051 führt die interne Lifecycle-Tabelle
account_rate_limit_buckets(bucket, user_id) ein. rate_limits speichert
weiterhin keine rohe User-ID, verweist für Kontozeilen aber über
account_bucket mit ON DELETE CASCADE auf diese Zuordnung. Jede bei einer
späteren AUTH_SECRET-Rotation entstehende HMAC-Generation bleibt dadurch an
das Konto gebunden und vollständig löschbar.
deleteUserAccount entfernt die zugeordneten Tageszeilen explizit für den
Ergebniszähler. Der anschließende users-Delete cascadiert zusätzlich alle
Mappings und jeden parallel entstandenen Rest atomar. Der Konto-Quota-Upsert
sperrt die users-Zeile im selben SQL-Statement; deshalb läuft er entweder vor
der Löschung und wird mit entfernt oder sieht keinen User mehr und fällt auf
den anonymen IP-Pfad zurück. Anonyme IP- und API-Key-Buckets anderer
Identitäten bleiben unberührt.
Dieselbe Migration ergänzt den zuvor nur app-seitig geschützten
reports.user_id um REFERENCES users(id) ON DELETE CASCADE. Ein Session-POST,
der seinen Owner vor einem langen Reportbau aufgelöst hat, kann damit nach
einer parallelen Kontolöschung keinen dauerhaften Waisenreport mit
expires_at = NULL mehr anlegen: Der Insert scheitert am FK oder wird vom
bereits wartenden User-Delete im selben Datenbank-Lebenszyklus cascadiert.
Der bestehende wöchentliche ratelimit-prune-Cron löscht allgemein Zeilen,
deren Tag älter als 90 Tage ist. Das ist eine technische
Wachstumsbegrenzung, keine garantierte maximale Compliance-Frist; ein
verspäteter oder fehlgeschlagener Lauf kann die tatsächliche Aufbewahrung
verlängern. Die explizite Account-Löschung wartet nicht auf diesen Cron.
Konsequenzen
- Ein ausgeschöpfter anonymer IP-Zähler blockiert eine korrekt aufgelöste Kontositzung nicht mehr. Zwei Konten hinter derselben IP besitzen getrennte Quotas.
- Angemeldete UI-Nutzer erhalten ebenfalls zehn Reports pro Tag. Dieser Slice führt keine Rollen-, Tarif- oder Organisationsstaffel ein.
- API-Key-Limits und deren Auditpfad bleiben unverändert.
- Eine defekte oder gelöschte Sitzung gewinnt keine Berechtigung: Sie nutzt den
anonymen IP-Pfad und kann dort auch
429erhalten. - Report-Provenienz und Abuse-Schutz bleiben getrennt; insbesondere bleibt
reports.ip_hashein Request-IP-Pseudonym. - Die API-Antworten bleiben
Cache-Control: private, no-store.
Verifikation und Produktionsgrenze
Automatisierte Tests müssen mindestens anonymen Zugriff, gültige Sitzung, zwei Nutzer hinter einer IP, denselben Nutzer über verschiedene IPs, gültigen API-Key, ungültigen beziehungsweise widerrufenen API-Key, Sitzungsfehler, gelöschten Nutzer, Header und Account-Löschung abdecken. Lokale Tests und ein Vercel-Preview belegen Code- und Deployment-Vertrag, aber noch nicht allein den Produktionszustand.
Nach dem Merge sind auf dem exakten Produktionscommit sichere Readbacks ohne
personenbezogene Schreibdaten erforderlich: eine adresslose Anfrage muss den
Transport- und Headervertrag erreichen, ein absichtlich ungültiger Bearer-Key
muss vor Session und Quota mit 401 enden. Die positive Trennung zweier realer
Kontositzungen wird nicht allein für einen Techniknachweis mit künstlichen
Produktionskonten oder Kundenadressen erzwungen.
Der Produktionsnachweis vom 26. August 2026 bestätigt Migration, validierte Cascades, Preview, öffentlichen Produktionsdeploy und beide sicheren Negativpfade auf dem exakten Merge-Commit. Damit ist der technische Vertrag produktiv verifiziert; eine reale Mehrkonten-Nutzung bleibt davon getrennte Stakeholder- beziehungsweise Betriebsbeobachtung.