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:

ModusIdentitätsquelleTageslimitBucket
anonkein API-Key und keine sicher auflösbare bestehende Kontositzung10bestehender pseudonymer Hash-Bucket der Request-IP
sessionbestehende serverseitig validierte Sitzung eines noch vorhandenen users.id10pseudonymer HMAC-SHA-256-Account-Bucket
keygültiger API-Keyapi_keys.daily_limitbestehender 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

  1. Ein mitgesendeter Authorization: Bearer …- oder X-API-Key-Wert wird vor jeder Cookie-/Sessionentscheidung geprüft. Ein ungültiger oder widerrufener Key antwortet mit 401 unauthorized; es gibt keinen Fallback auf Sitzung oder anonymen Zugang.
  2. 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 in users existiert.
  3. Fehler bei der Sitzungsauflösung, unvollständige Sitzungen und bereits gelöschte Nutzer fallen auf anon und 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.
  4. Erst nach dieser Entscheidung wird exakt der gewählte Bucket atomar erhöht. X-RateLimit-Limit und X-RateLimit-Remaining beschreiben diesen Bucket; X-Auth-Mode lautet entsprechend anon, session oder key.

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 429 erhalten.
  • Report-Provenienz und Abuse-Schutz bleiben getrennt; insbesondere bleibt reports.ip_hash ein 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.