# EEG-Abrechnungs-Assistent – Lizenzsystem

Vollständige technische und operative Dokumentation des Lizenz-, Auth-, Rate-Limiting-
und DSGVO-Subsystems.

---

## 1. Überblick

Das Lizenzsystem ist ein Flask-basierter Single-Process-Dienst mit SQLite als
Datenablage. Es steuert:

- **Account-Lifecycle** (Anlegen, Token-Ladungen, Sperren, DSGVO-Löschen)
- **Session-basierte Auth** für Endkunden (App-Nutzer) und Admins (Admin-Panel)
- **Atomare Token-Abbuchung** pro erfolgreichem Download
- **Rate-Limiting** auf mehreren Ebenen (Login, API-Endpoints, Admin-Login)
- **Audit-Trail** aller Admin-Aktionen
- **DSGVO-Funktionen** (Art. 15 Auskunft, Art. 17 Löschung)
- **Automatische Backups** der SQLite-DB

Das System ist bewusst als eine einzelne Flask-App mit einer Datei
(`licensing.py`) plus Integrationspunkten in `app.py` gehalten – kein Redis, kein
externer Job-Runner. Rate-Limits leben im Prozess-RAM, Sessions werden in der DB
persistiert.

---

## 2. Dateien im Überblick

| Pfad | Rolle |
|---|---|
| `v1_upload/licensing.py` | Kern: DB-Schema, Session-Handling, Token-Management, Backups, DSGVO-Helper |
| `v1_upload/app.py` | Flask-App: API-Routen, Rate-Limiter, Admin-Routen, Integration |
| `v1_upload/templates/admin.html` | Admin-Dashboard (Tailwind, Vanilla-JS) |
| `v1_upload/templates/index.html` | Kunden-UI inkl. DSGVO-Cookie-Banner |
| `v1_upload/templates/datenschutz.html` | DSGVO-Seite (Art. 6/13/15/16/17/18/20/21) |
| `v1_upload/tests/test_phase5.py` | Atomare Abbuchung + Replay-Schutz |
| `v1_upload/tests/test_phase6.py` | Admin-Web-UI, DB-Session-Auth, Audit-Log |
| `v1_upload/tests/test_phase7.py` | Rate-Limits, DSGVO-Export/Löschen, Banner |
| `v1_upload/tests/run_all_tests.py` | Master-Runner über alle Phasen |
| `v1_upload/data/licensing.db` | SQLite-Hauptdatei (wird automatisch erzeugt) |
| `v1_upload/data/backups/` | Rotierende DB-Backups |

---

## 3. DB-Schema

Alle Tabellen leben in `data/licensing.db`. Schema wird idempotent in
`licensing.init_db()` angelegt.

### 3.1 `accounts`

| Spalte | Typ | Zweck |
|---|---|---|
| `uid` | TEXT PK | Kundenkennung, Format `EEGA-XXXX-XXXX-XXXX` |
| `customer_name` | TEXT | Anzeigename; nach DSGVO-Löschung `[gelöscht]` |
| `email` | TEXT | Kontaktadresse; nach DSGVO-Löschung `NULL` |
| `password_hash` | TEXT | PBKDF2-SHA256, 200k Iterationen |
| `salt` | TEXT | Per-Account-Salt (hex) |
| `status` | TEXT | `active`, `disabled`, `suspended` |
| `token_balance` | INTEGER | Aktuelles Guthaben |
| `tokens_granted_total` | INTEGER | Summe aller Ladungen (monoton steigend) |
| `created_at`, `updated_at` | TEXT (ISO) | Zeitstempel |
| `last_login_at` | TEXT (ISO) | Letzter erfolgreicher Login |
| `fingerprint_hash_bound` | TEXT | Optionaler Hardware-Fingerprint-Bind |

### 3.2 `sessions`

Kundenseitige Sessions, pro Login ein Eintrag.

| Spalte | Typ | Zweck |
|---|---|---|
| `session_id` | TEXT PK | 256-bit zufällig (hex) |
| `uid` | TEXT | FK → `accounts.uid` |
| `created_at`, `expires_at` | TEXT (ISO) | Lifetime-Kontrolle |
| `ip`, `user_agent` | TEXT | Forensik |
| `fingerprint_hash` | TEXT | Hardware-Bind aus Login |
| `status` | TEXT | `active`, `consumed`, `revoked` |

Sessions gelten genau **einen Download lang**. Nach erfolgreicher Abbuchung wird
`status = 'consumed'` gesetzt → Replay blockiert.

### 3.3 `usage_log`

Vollständiger Audit-Trail aller Token-Events.

| Spalte | Typ | Zweck |
|---|---|---|
| `id` | INTEGER PK | – |
| `uid` | TEXT | Betroffener Account |
| `session_id` | TEXT | Verknüpfte Session (falls vorhanden) |
| `ts` | TEXT (ISO) | Zeitpunkt |
| `event_type` | TEXT | `login`, `process`, `download_ok`, `download_fail`, `admin_topup`, `admin_deduct`, … |
| `tokens_consumed` | INTEGER | Abbuchung (positiv) |
| `remaining_after` | INTEGER | Stand nach Buchung |
| `fingerprint_hash` | TEXT | Fingerprint der Anfrage |
| `fingerprint_match` | INTEGER | 0/1 ob Bind passt |
| `periode`, `modules` | TEXT | Kontext der Abrechnung |

### 3.4 `admin_users`

| Spalte | Typ | Zweck |
|---|---|---|
| `username` | TEXT PK | Login-Name |
| `password_hash`, `salt` | TEXT | siehe oben |
| `created_at`, `last_login_at` | TEXT (ISO) | – |

### 3.5 `admin_audit`

Write-once-Log aller Admin-Aktionen.

| Spalte | Typ | Zweck |
|---|---|---|
| `id` | INTEGER PK | – |
| `ts` | TEXT (ISO) | Zeitpunkt |
| `admin_user` | TEXT | Ausführender Admin |
| `action` | TEXT | `create_account`, `topup`, `deduct`, `suspend`, `activate`, `delete_account`, `dsgvo_export`, … |
| `target_uid` | TEXT | Betroffener Account |
| `details` | TEXT | JSON-Blob mit Zusatzinfos |

### 3.6 `failed_logins`

Für DB-gestütztes Slow-Burst-Limit beim Kunden-Login (persistiert über
Prozessneustart hinweg).

| Spalte | Typ |
|---|---|
| `ip` | TEXT |
| `ts` | TEXT (ISO) |

---

## 4. Login- und Download-Flow

### 4.1 Kunden-Login (`POST /api/v1/login`)

1. **Rate-Check**: In-Memory Sliding-Window (20 req/60s per IP) +
   DB-`failed_logins`-Fenster.
2. `accounts`-Zeile holen, Passwort gegen `password_hash` prüfen.
3. Bei Fehler: Eintrag in `failed_logins`, `usage_log(event_type='login_fail')`.
4. Bei Erfolg: Session anlegen (TTL 30 min, Status `active`), `last_login_at`
   setzen, `usage_log(event_type='login')` schreiben, Session-ID + Account-View
   zurückgeben.

Response-View enthält `uid_masked` (z.B. `EEGA-07A6-…-AEAF`), niemals die volle
UID im JSON.

### 4.2 `POST /api/v1/process`

Berechnet das Dokument **ohne** Token abzubuchen. Dient als Preview für den
Kunden. Rate-Limit: 20 req/min pro UID.

### 4.3 `POST /api/v1/download`

Der einzige Endpoint, der wirklich Tokens konsumiert:

1. Rate-Limit: 10 req/min pro UID.
2. Session laden (`active`), sonst 401.
3. Fingerprint gegen `fingerprint_hash_bound` prüfen (Warnung bei Mismatch,
   aber kein Abbruch).
4. **Atomare Transaktion**:
   ```sql
   BEGIN IMMEDIATE;
   UPDATE accounts SET token_balance = token_balance - :cost
       WHERE uid = :uid AND token_balance >= :cost;
   -- rowcount == 0 ⇒ ROLLBACK + "insufficient_tokens"
   UPDATE sessions SET status = 'consumed' WHERE session_id = :sid AND status = 'active';
   INSERT INTO usage_log (...) VALUES (...);
   COMMIT;
   ```
5. Bei Erfolg: Datei streamen, Session ist verbraucht (kein Replay möglich).

Der zweite Download mit derselben Session-ID scheitert mit 401 – das wird in
`test_phase5.py` Scenario 6 explizit verifiziert.

---

## 5. Rate-Limiter

Implementiert in `app.py` als In-Memory Sliding-Window:

```python
_RATE_BUCKETS: dict[str, deque[float]]
_rate_limit(key, max_calls, window_s) -> bool  # True == blockiert
```

### Aktive Limits

| Endpoint | Key | Limit | Fenster |
|---|---|---|---|
| `POST /api/v1/login` | `login_ip:<ip>` | 20 | 60 s |
| `POST /api/v1/process` | `process:<uid_masked>` | 20 | 60 s |
| `POST /api/v1/download` | `download:<uid_masked>` | 10 | 60 s |
| `POST /admin/api/login` | `admin_login_ip:<ip>` | 15 | 300 s |

Bei Block wird HTTP 429 mit `Retry-After: 60` zurückgegeben.

### Reverse-Proxy-Setup

IP-Ermittlung respektiert `X-Forwarded-For`. Wenn hinter Traefik/Nginx/Caddy
deployt, **muss** der Proxy den Header setzen und der Flask-Prozess darf keine
anderen Quellen vertrauen. In `test_phase7.py` Scenario 13 wird bewiesen, dass
unterschiedliche `X-Forwarded-For`-IPs getrennte Buckets bekommen.

### Grenzen

- **Prozess-lokal**: Bei horizontaler Skalierung (mehrere Gunicorn-Worker) hat
  jeder Worker seinen eigenen Counter → effektives Limit = limit × workers.
  Für Produktionseinsatz mit >1 Worker auf Redis-backed Limiter umstellen.
- **RAM-Wachstum**: Alte Buckets werden beim nächsten Zugriff auf denselben Key
  bereinigt, aber langlebige, ungenutzte Keys bleiben bis zum Neustart liegen.

---

## 6. Admin-Panel

### 6.1 Auth

- `POST /admin/api/login` prüft Passwort, setzt Cookie `admin_sid`
  (HttpOnly, SameSite=Strict, Path=`/admin`, TTL 8 h).
- Session-Store: In-Memory-Dict `_ADMIN_SESSIONS` in `licensing.py`.
- `POST /admin/api/logout` revoziert.
- Alle `/admin/api/*`-Routen (außer `/login`) erfordern das Cookie; bei
  Fehlen/Ungültig → 401.

### 6.2 Routen

| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/admin` | Dashboard-HTML |
| POST | `/admin/api/login` | Login |
| POST | `/admin/api/logout` | Logout |
| GET | `/admin/api/accounts` | Liste (paginiert) |
| POST | `/admin/api/accounts` | Neuer Account |
| POST | `/admin/api/accounts/<uid>/topup` | Tokens aufladen |
| POST | `/admin/api/accounts/<uid>/deduct` | Tokens abbuchen |
| POST | `/admin/api/accounts/<uid>/status` | `active`/`suspended`/`disabled` |
| GET | `/admin/api/accounts/<uid>/export` | **DSGVO Art. 15** |
| POST | `/admin/api/accounts/<uid>/delete` | **DSGVO Art. 17** |
| GET | `/admin/api/audit` | Letzte Admin-Aktionen |

### 6.3 Bedienung

Das Dashboard listet alle Accounts mit Buchungsstand. Pro Zeile:
Aufladen, Abbuchen, Sperren/Freigeben, **Export** und **DSGVO-Löschen**.

DSGVO-Löschen ist bewusst doppelt abgesichert: Admin muss die UID wörtlich
nochmal ins Prompt-Feld eintippen, bevor die Aktion losgeht.

---

## 7. DSGVO-Prozesse

### 7.1 Art. 15 – Auskunft (Export)

`GET /admin/api/accounts/<uid>/export` liefert JSON:

```json
{
  "uid": "EEGA-07A6-D7B5-AEAF",
  "account": { "customer_name": "...", "email": "...", "status": "active",
               "token_balance": 100, "created_at": "...", ... },
  "usage_log": [
    { "ts": "...", "event_type": "login", "tokens_consumed": 0,
      "remaining_after": 100, ... },
    ...
  ]
}
```

Die Aktion landet im `admin_audit` mit Action `dsgvo_export`.

### 7.2 Art. 17 – Löschung

`POST /admin/api/accounts/<uid>/delete` ruft `licensing.delete_account(uid, admin)`:

1. `accounts.customer_name` → `[gelöscht]`
2. `accounts.email` → `NULL`
3. `accounts.password_hash`, `salt` → leerer String (Login dauerhaft unmöglich)
4. `accounts.status` → `disabled`
5. Alle Zeilen aus `usage_log` für diese UID werden **gelöscht**.
6. Alle aktiven Sessions werden revoziert.
7. `admin_audit`-Eintrag `delete_account` mit Count der gelöschten Log-Zeilen.

Die UID selbst bleibt als technischer Platzhalter erhalten – das ist bewusst,
weil `admin_audit` darauf referenziert (Nachvollziehbarkeit der Löschung).

### 7.3 Weitere Betroffenenrechte

`GET /datenschutz` liefert die Datenschutzerklärung gemäß Art. 6, 13, 15–21.
Der Cookie-Banner auf `/` speichert die Bestätigung unter
`localStorage['eeg_dsgvo_ack_v1']`. Kein Tracking, kein Profiling, keine
Third-Party-Cookies – nur dieser eine Consent-Key.

---

## 8. Backups

`licensing.start_backup_thread()` startet beim App-Start einen Daemon-Thread:

- Alle **60 Minuten** wird `data/licensing.db` mit der SQLite-`BACKUP`-API
  nach `data/backups/licensing_YYYYMMDD_HHMMSS.db` kopiert.
- Die letzten **48 Backups** werden behalten, ältere automatisch gelöscht.
- Ein initiales Backup läuft direkt beim Start (falls DB vorhanden).

### Restore

```bash
systemctl stop eeg-abrechnung           # bzw. gunicorn/uwsgi
cp data/licensing.db data/licensing.db.pre-restore
cp data/backups/licensing_20260417_120000.db data/licensing.db
systemctl start eeg-abrechnung
```

---

## 9. CLI-Hilfen (in `licensing.py`)

Alle Funktionen sind direkt importierbar, es existieren keine separaten
Subcommands. Beispiel-Aufrufe:

```python
import licensing
licensing.init_db()
licensing.create_admin("admin", "langes-passwort")
uid = licensing.create_account("Kunde GmbH", "k@x.tld", "kundenpw", tokens=500)
licensing.topup(uid, 200, admin_user="admin")
licensing.set_status(uid, "suspended", admin_user="admin")
licensing.export_account_data(uid)
licensing.delete_account(uid, admin_user="admin")
```

---

## 10. Tests

### 10.1 Überblick

| Phase | Datei | Szenarien | Fokus |
|---|---|---|---|
| 5 | `tests/test_phase5.py` | 6 | Atomare Abbuchung, Race-Conditions, Replay-Schutz |
| 6 | `tests/test_phase6.py` | 14 | Admin-UI, Cookie-Auth, Audit, CSRF-artige Checks |
| 7 | `tests/test_phase7.py` | 13 | Rate-Limits pro Endpoint, DSGVO-Export/Löschung, Banner, X-Forwarded-For |

### 10.2 Master-Runner

```bash
cd v1_upload
python tests/run_all_tests.py                 # alle Phasen
python tests/run_all_tests.py --only 7        # nur Phase 7
python tests/run_all_tests.py --skip 5        # Phase 5 auslassen
python tests/run_all_tests.py --verbose       # vollständige Ausgabe
```

Rückgabewerte: `0` = alles grün, `2` = mindestens eine Phase rot.

### 10.3 Test-Idempotenz

Jeder Testlauf erzeugt eine **frische** DB in `/tmp/…` und eine isolierte
Flask-Test-Client-Instanz. Parallellauf mit echten Daten findet nicht statt.
Der Runner setzt `PYTHONDONTWRITEBYTECODE=1` und einen eigenen
`PYTHONPYCACHEPREFIX` pro Phase – so sind keine `__pycache__`-Überreste
zwischen Läufen ein Thema.

---

## 11. Deployment-Hinweise

### 11.1 Gunicorn hinter Reverse-Proxy

```bash
gunicorn -w 1 -b 127.0.0.1:8000 \
    --access-logfile - --error-logfile - \
    --forwarded-allow-ips 127.0.0.1 \
    app:app
```

- **`-w 1`** ist absichtlich. Rate-Limits sind prozess-lokal; mehrere Worker
  vervielfachen das effektive Limit. Bei höheren Traffic-Zahlen erst Redis-
  backed Limiter einziehen, dann hochskalieren.
- `--forwarded-allow-ips` schützt davor, dass beliebige Clients gefälschte
  `X-Forwarded-For` setzen können.

### 11.2 Nginx-Snippet

```
location / {
    proxy_pass http://127.0.0.1:8000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}
```

### 11.3 Dateiberechtigungen

`data/licensing.db` und `data/backups/` brauchen RW-Zugriff für den App-User,
sonst für niemanden. Empfehlung: eigener Systemuser `eeg`, `chown -R eeg:eeg
data/`, `chmod 700 data/`.

---

## 12. Troubleshooting

| Symptom | Wahrscheinliche Ursache | Abhilfe |
|---|---|---|
| Login liefert 429 sofort | IP vom DB-Slow-Limiter gesperrt | `DELETE FROM failed_logins WHERE ip = '…'` |
| Admin-Cookie wird nicht akzeptiert | Uhrzeit driftet (TTL-Vergleich) | NTP einrichten |
| `insufficient_tokens` obwohl Guthaben vorhanden | Concurrent-Download, zweiter verliert Race | OK so, App soll Retry-Logik im Client nicht aktivieren |
| `import licensing` zeigt alte Funktionen | `__pycache__` steht | `find . -name __pycache__ -exec rm -rf {} +` |
| `/admin` 500 auf Render | `admin.html` beschädigt | In Git-Diff prüfen, rollbacken |
| Test schlägt mit "Login-Burst" fehl | Vorheriger Test hat denselben IP-Bucket benutzt | `X-Forwarded-For` pro Test eindeutig setzen |

---

## 13. Changelog

| Phase | Inhalt |
|---|---|
| 1 | Grund-Schema, Account-Anlage, Passwort-Hashing |
| 2 | Sessions, Kunden-Login-API |
| 3 | `/process` und `/download` mit Token-Abbuchung |
| 4 | Admin-CLI und erste Admin-API |
| 5 | Atomare DB-Transaktion + Session-Consumed-Flag (Replay-Schutz) |
| 6 | Admin-Web-UI mit Tailwind, DB-Session-Cookies, vollständiger Audit-Trail |
| 7 | Rate-Limits (Login/Process/Download/Admin), DSGVO-Export + Löschung, Banner, `/datenschutz` |
| 8 | Test-Bündel in `tests/`, Master-Runner, diese Dokumentation |

---

## 14. Kontakt / Wartung

Alle App-interne Texte sind in Deutsch; Fehlermeldungen zum Admin kommen als
loglines im Format
`Admin <user>: <action> für <uid> (<details>)`.

Für Notfälle: DB ist ein einzelnes SQLite-File, lässt sich mit jedem SQLite-
Browser direkt inspizieren. Backups liegen in `data/backups/`.
