Vorher: resolve_approval_request pruefte nur Mandant + pending — NICHT
Ablaufdatum, NICHT den vorgesehenen Genehmiger, und ueberschrieb
approver_id mit dem tatsaechlichen Entscheider (Zuordnung verloren).
Astra-Repro: Eine abgelaufene Anfrage konnte von einem anderen
Entscheider genehmigt werden; konkurrierende Entscheidungen waren
moeglich.
Fix:
- Migration 0146: neue Spalte resolved_by (Zuordnung vs. Entscheider
getrennt — approver_id bleibt die ZUORDNUNG)
- resolve_approval_request komplett ueberarbeitet:
* Ablauf-Check: expires_at vorbei -> Status expired + 410
* Genehmiger-Check: approver_id match ODER approver_group-Mitgliedschaft;
unassigned = jeder mit approvals:approve; System-Admin als
dokumentierter Ops-Override; falscher Entscheider -> 403
* Atomarer Statusuebergang: UPDATE ... WHERE status=pending —
konkurrierende Entscheidung -> 409
* approver_id wird NIE ueberschrieben; resolved_by dokumentiert den
Entscheider
- ApprovalDecisionError mit HTTP-Status-Codes; approve/reject-Routen
fangen sie sauber ab (404/409/410/403 statt Flat-404)
- ApprovalResponse + Mapper um resolved_by ergaenzt
Abnahme (Astra): Falscher Entscheider, abgelaufene Anfrage und doppelte
Entscheidung werden abgewiesen — erfuellt (6 Tests).
Hinweis: workflows.py approve/reject-Aufrufer waren bereits kaputt
(F12, S2-Welle: approval[id] auf ORM-Objekt) und werden dort gefixt.
Tests: test_s1_security_guards.py 18/18 (6 neue F11-Tests). ruff clean.
Damit ist S1 — ALLE 11 Sicherheits-Findings der Astra-Welle 1 gefixt.
Vorher: prestart.sh fuehrte bei JEDEM Container-Start
GRANT DELETE ON ALL TABLES fuer crm_api/crm_auth/crm_worker aus — und
hob damit Migration 0100 auf, die DELETE auf 12 sensiblen Tabellen
(audit_log, api_tokens, password_reset_tokens, tenants, ...)
gezielt entzogen hatte. Der Blanket-Grant war ein BUG-030-Workaround
(User-DELETE 500), der den Schutz seit jedem Start zerstoerte.
Fix:
- Migration 0145 (0145_delete_grants_converged): deterministischer
Sollzustand — REVOKE DELETE auf geschuetzten Tabellen von beiden
Runtime-Rollen (audit_log, api_tokens, password_reset_tokens,
plugin_allowlist, plugin_migrations, tenants,
tenant_plugin_activation); GRANT DELETE auf legitime Runtime-Loeschungen
(users, user_tenants, sessions, plugins, notification_types) NUR fuer
crm_api; crm_worker erhaelt kein DELETE auf geschuetzten Tabellen.
- prestart.sh: Blanket-GRANT-Block entfernt, durch dokumentierenden
Verweis auf 0145 ersetzt.
- audit.py Retention-Route: Delete laeuft ueber Migrations-Session-Factory
(Table-Owner) statt Request-DB — Runtime-Rollen koennen Auditdaten
schreiben aber NIEMALS loeschen (Astra-Abnahme). Gleiches Muster wie
Plugin-Uninstall.
Abnahme (Astra): API und Worker koennen Auditdaten schreiben, aber nicht
loeschen — erfuellt (audit_log DELETE von crm_api/crm_worker entzogen,
Retention als dokumentierte Wartungsoperation ueber Owner-Session).
Verifikation: Migration-Syntax OK, ruff clean, alembic heads = genau 0145,
prestart bash -n OK, test_audit_architecture_fixes + test_user_service
30/30 (Logout-Session-Delete, User-DELETE, Audit-Pfade alle intakt).
Bekannte Grenze (ehrlich): Kuenftige Plugin-Tabellen brauchen ihre
DELETE-Rechte in der jeweiligen Migration statt im Boot-Skript —
sync_plugin_schema.py vergibt KEINE GRANTs (verifiziert), deshalb ist
das Default-Privilege-Problem in S2 (F18 Schema-Verantwortung)
adressiert.
Vorher: _is_url_safe blockierte nur IP-Literale und 5 feste Hostnamen.
Interne Servicenamen (postgres, redis, ...) und externe Domains mit
privater DNS-Aufloesung passierten ungeprueft (Astra-Repro:
http://postgres:5432/ wurde akzeptiert).
Fix: Der Hostname wird per socket.getaddrinfo aufgeloest und ALLE
aufgeloesten IPs muessen oeffentlich sein (private/loopback/link-local/
reserved/multicast/unspecified → blockiert). DNS-Fehler ist fail-closed
(nicht verifizierbar = blockiert). Blocking-DNS ist hier vertretbar —
Workflow-Steps sind Background-Jobs. Redirects bleiben deaktiviert
(follow_redirects=False, war bereits korrekt).
Abnahme (Astra): Interne Servicenamen, private DNS-Ziele und
DNS-Wechsel werden abgefangen — erfuellt (Tests mit getaddrinfo-Mocks:
postgres->172.18.0.2 blockiert, evil-corp.example->10.0.0.5 blockiert,
DNS-Fehler blockiert).
Tests: test_phase_g_workflows.py SSRF 11/11 (3 neue F15-Tests +
Positivfall auf aufladbaren Host umgestellt, unresolvable Hostnamen
jetzt fail-closed). ruff clean.
Vorher: POST /api/v1/backups/{id}/restore war ueber automation:admin
eines Mandanten erreichbar — der Restore bearbeitet aber die GESAMTE
geteilte Datenbank ohne Mandantenfilter. Ein Tenant-Admin haette den
Zustand aller Mandanten ueberschreiben koennen.
Fix: Restore-Route auf require_admin umgestellt (echter System-Admin:
is_system_admin oder *:* via RBAC). Listen/Erstellen/Loeschen von
Backups bleibt mandantenbezogen auf automation:admin.
Abnahme (Astra): Ein Tenant-Admin kann keinen Gesamtrestore ausloesen —
erfuellt (Route-Introspektions-Tests pinnen die Verdrahtung).
Tests: test_s1_security_guards.py 12/12 (2 neue F23-Tests: restore nutzt
require_admin, restore nutzt NICHT require_permission).
Vorher: require_active_plugin hing nicht an einer Auth-Dependency —
FastAPI konnte die Plugin-Pruefung VOR der Authentisierung ausfuehren.
Der Mandant wurde aus dem DB-Kontext gelesen (current_setting), der zu
diesem Zeitpunkt oft fehlt → stiller Return = Plugin aktiv. Der Code
trug sogar ein TODO: Fix in production. Astra-Repro: Endpunkt
antwortete HTTP 200 ohne Mandantenkontext.
Fix:
- _check haengt an get_current_user_or_bearer (Cookie- UND Bearer-Auth)
→ FastAPI aufloesungsbedingt immer authentifiziert vor dem Gate
- Mandant kommt aus dem authentifizierten User-Kontext, nie aus
current_setting
- Fehlender Mandanten-Kontext → 403 plugin_gate_no_tenant (fail-closed,
war: stiller Durchlass)
- Public-Routen (is_public) umgehen das Gate weiterhin korrekt
Nebenwirkung positiv: Bearer-Clients (External-API, MCP) laufen nicht
mehr gegen den Cookie-Zwang des Gates.
Verifikation: Syntax OK, ruff clean, test_s1_security_guards +
test_auth 21/21. Der Gate-Order-Beweis ist ein
Integrationstest-Verhalten (HTTP) — Plugin-Inactive-Faelle werden
bereits durch die permission_system_live-Suite abgedeckt.
Vorher: require_permission machte bei passendem Token-Scope ein
early-return — die User-Rechte wurden NIE geprueft. Ein Token mit
mail:write erlaubte mail:write selbst dann, wenn der Benutzer die
Berechtigung nie hatte oder sie entzogen bekam (Astra-Repro isoliert
bestaetigt).
Fix: Nach bestandenem Scope-Check in den normalen User-Rechte-Check
fallen. Effektives Recht = User-Rechte UND Token-Scope. Rechteentzug
wirkt sofort auf bestehende Tokens. System-Admin-Bypass unveraendert.
Tests: tests/test_s1_security_guards.py 7/7 (neue Suite):
Scope-ohne-User-Recht 403, beide-present pass, Scope-fehlt-User-hat 403
insufficient_scope, Deny-Revocation wirkt, Session-Pfad unveraendert,
*:*-Scope umgeht nicht, Admin-Bypass bleibt. ruff clean.
Vorher: Ein Mandanten-Admin (users:write) konnte die globale User.email
und das Passwort JEDES Mitglieds seines Mandanten aendern. User ist
aber mandantenuebergreifend — derselbe Datensatz traegt Passwort und
Systemadmin-Flag; der Passwort-Reset nutzt die veraenderbare Adresse.
Ein Admin aus Mandant A konnte so die globale Reset-Adresse eines
gemeinsamen Benutzers umlenken (Astra-Repro: globale Feldaenderung
isoliert reproduziert).
Fix (routes/users.py update_user):
- email/new_password fuer FREMDE User -> 403 global_identity_forbidden
(nur Selbstservice oder echter System-Admin)
- is_active fuer MEHRMANDANTEN-User durch Tenant-Admin -> 403
multi_tenant_status_forbidden (Deaktivierung waere global sperrend;
Single-Mandanten-Mitglieder duerfen wie bisher deaktiviert werden)
- is_system_admin-Eskalationscheck unberuehrt (war schon korrekt)
Abnahme (Astra): Ein Tenant-Verwalter kann weder die globale E-Mail-
Adresse noch den globalen Aktivstatus eines gemeinsamen Benutzers
veraendern — erfuellt.
Tests: test_user_service.py 13/13 (5 neue F02-Tests: fremde E-Mail 403,
fremdes Passwort 403, Mehrmandanten-Deaktivierung 403, Name-Aenderung
bleibt 200, Selbstservice bleibt 200). ruff clean.
Vorher: _execute_tool (agent_loop.py) und der KI-Chat-Loop
(stream_chat_comm) führten JEDES im Registry registrierte Tool aus, wenn
das LLM dessen Namen lieferte — ohne Abgleich mit der angebotenen Liste,
ohne required_permission-Check. Reproduktion (Astra): Nur audit_allowed
angeboten, Modell nannte audit_restricted (system:admin) → Handler lief.
Fix (fail-closed, an ALLEN Ausfuehrungspfaden):
- _check_tool_access: (1) Allowlist — nur Tools die dem LLM angeboten
wurden duerfen laufen; (2) required_permission gegen die AKTUELLEN
User-Rechte (deny-first, Rechteentzug wirkt sofort, ohne Kontext =
Ablehnung). Guard vor dry-run/approval/execute-Pfaden.
- stream_chat_comm: gleicher Allowlist-Guard vor execute_tool_call.
- run_react_loop/agent_runner/agent_stream/agent_routes reichen
user_permissions durch (perm_ctx bzw. Session-User).
- check_permission: Session-Kontexte tragen denied_permissions statt
denied — beide Keys werden gelesen, Deny-Liste wird nie mehr ignoriert.
Tests: test_agent_loop.py 18/18 (7 neue F01-Tests nach Astra-Abnahme:
nicht angeboten → Handler null; fehlende Permission → abgewiesen;
Fail-closed ohne Kontext; Deny-Liste session-shape; Rechteentzug
mitten im Lauf wirkt auf naechste Aktion; dry-run guardet auch).
ruff clean. Pre-existing-Beweis: permission_system_live-Failures
reproduzieren sich ohne diesen Patch identisch (Plugin-Aktivierung in
ephemeraler Test-DB, bekanntes Vorbestands-Finding).
Die CSRF-Middleware verlangte Origin+X-CSRF-Token auf allen unsafe
Requests — auch auf Bearer-authentifizierten API-Calls (External-Agent-
API, MCP, Integrationen). Externe Systeme senden nie Origin/CSRF,
dadurch war /api/v1/external/agent/* faktisch unbrauchbar (403).
Fix: Authorization: Bearer-Header-Requests skippen die CSRF-Pruefung.
Bearer ist CSRF-immun per Design: Browser haengen den Authorization-
Header niemals automatisch an, Cross-Site-Requests koennen ihn nicht
schmuggeln. Session-Cookie-Requests (SPA) laufen unverändert durch die
volle Origin+Double-Submit-Pruefung.
Regression: pytest test_auth.py 11/11, ruff clean
Produktionsbug: Plugin ai_assistant war migration_failed/inactive, weil
Migration 0003 ALTER TABLE ai_chat_sessions ausfuehrte — die Tabelle wurde
von Alembic 0137 (2026-08-21, Umstieg auf comm-Tabellen) gedroppt. Bei jedem
Container-Start crashte die Migration und deaktivierte das Plugin
(KI-Chat und /api/v1/ai/* lieferten 403).
Fix:
- 0003: ai_chat_sessions-Statements entfernt, nur ai_chat_folders behalten
- 0001: Ghost-Tabellen ai_chat_sessions/ai_chat_messages entfernt
(frische Installs duerfen sie nicht rekreieren — Schema-Drift)
- 0002: ai_chat_attachments + toter folder_id-ALTER entfernt,
nur ai_chat_folders behalten
Runner skipt getrackte Migrationen per Dateiname (kein Hash-Check),
Prod-Risiko null; 0003 laeuft beim naechsten Start sauber durch und
aktiviert das Plugin wieder.
scripts/generate_component_map.py scannt alle builtin-Manifeste + system_miniapps.py
und erzeugt frontend/src/generated/pluginComponents.generated.ts (37 Komponenten).
PluginLoader (STATIC_COMPONENT_MAP) und MiniAppHost (widgetRegistry) nutzen die
generierte Map — ein Plugin meldet seine Komponenten nur noch im Manifest,
keine zentrale Frontend-Datei muss angefasst werden.
Garantien: Generator failt hart bei Ghost-Komponenten (bewiesen: exit 1),
erkennt default- vs. named-exports, deterministische Ausgabe, --check-Modus
fuer CI. Kontakts DedupMergePage-Pfad-Alias auf echte Datei korrigiert.
Verifikation: tsc exit 0; production build exit 0; Ghost-Fail-Hard exit 1;
Dashboard+MiniAppWindow 17/17; pluginStore 18/18; keine Restreferenzen auf
STATIC_COMPONENT_MAP/widgetRegistry.
Der vorherige Patch hatte permissions=["system:read"] versehentlich in die
PluginRouteDef-kwargs gesetzt statt auf Manifest-Ebene — der Key blieb
dadurch unregistriert (live bewiesen: nur 11/12 Keys im Produktionskatalog
sichtbar). Korrigiert; Test f9 prueft jetzt die ECHTEN Manifeste statt
manueller Registrierung, so haette der Fehler ab sofort gefangen werden
muessen.
Verifikation: tests/test_audit_architecture_fixes.py 17/17,
manifest.permissions=['system:read'], is_core=False, routes=1.
- ContactsPlugin-Manifest: menu_items + page_routes ergänzt
(/contacts, /contacts/:id, /contacts/dedup — contacts:read-Gate)
- FrontendMenuItem/FrontendPageRoute-Imports in plugin.py ergänzt
- Sidebar.tsx: /contacts aus singleItems entfernt (nur noch dashboard +
system-dashboard als non-plugin items) — contacts kommt jetzt via
getAllMenuItems() aus dem Plugin-Manifest
- Funktionserhalt bewiesen: routePermissions-Tests 6/6 passed
(die durch die Manifest-Änderung betroffen sein könnten)
fixes#358 (P10-Teil)
Der 78-Zeilen-Duplikat-Export (app/services/export_service.py, CSV-only,
mit type/search-Filter und Sensitive-Data-Safety-Net) wandert in den
ContactsContract: ie_fetch_rows() erweitert um contact_type/search-Filter
und das Original export_service.py CSV-Profil (17 Spalten inkl.
displayname, code, email_1/2, phone_1/2, website, mailing_*, vat_code,
tags) mit Sensitive-Data-Safety-Net.
contacts/routes.py /export nutzt jetzt den Contract statt export_service.
app/services/export_service.py geloescht.
Funktionserhalt bewiesen: 15/15 tests/test_performance.py passed
(inkl. der 2 vorherigen Failures, die durch das Original-Profil behoben
wurden: assert 'firstname' == Header, search=Mueller in surname).
fixes#359 (export_service-Konsolidierung)