Auf dem Dashboard standen 102 offene Tickets. In der Datenbank waren es 79. Kein rotes Warnsymbol, keine kaputte Abfrage, keine Zeitüberschreitung — nur eine Zahl, die rund ein Drittel zu hoch war und dabei vollkommen plausibel aussah. Genau das ist der unangenehme Teil an Reporting: Ein falsches Dashboard sieht nicht falsch aus. Es sieht aus wie ein Dashboard.

Ich wollte Auswertungen für mein Ticketsystem, ohne dem BI-Tool die Datenbank zu überlassen. Also lief die Anbindung von Anfang an über eine enge Grenze: ein separates Schema, Views nur mit den Spalten, die für Kennzahlen nötig sind, ein Lesekonto, das an die Basistabellen nicht herankommt. Titel und Nachrichteninhalt bleiben draußen. Das hat funktioniert. Was nicht funktioniert hat, war die Annahme, dass „keine Inhalte" automatisch „richtige Zahlen" bedeutet.

Das Problem

Zammad ist das Ticketsystem, Metabase die Auswertungsschicht darüber. Gebraucht wurden Durchlaufzeiten, Schließvorgänge, offene Tickets, ein paar Verteilungen — nichts, wofür man Rohdaten mit Tickettexten braucht.

Der erste Aufbau war bewusst konservativ: eigenes Schema, Views im Besitz eines Accounts ohne Login-Recht, ein Lesekonto, das nur dieses Schema sieht. Die Views projizieren einzelne Spalten, nicht select *. Was nicht in der View steht, ist für das Reporting nicht existent — auch nicht versehentlich.

Die Zahlen der ersten Dashboards wurden gegen die Rohdaten geprüft, nicht nur gegen sich selbst. Dabei fiel die Offen-Zählung auf. Sie war nicht ein bisschen daneben, sondern deutlich: 102 statt 79.

Das Bemerkenswerte daran: Die Query war nicht kaputt. Sie tat genau das, was in ihr stand. Nur stellte sie die falsche Frage.

Die Diagnose

Erster Reflex bei einer falschen Kennzahl ist die Visualisierung: Filter, Zeitraum, falsche Karte. Hier war es keiner dieser Fälle. Die Gegenprobe lief direkt per psql gegen die View. Ergebnis: 102. Das Dashboard gab korrekt wieder, was die View lieferte. Der Fehler saß eine Ebene tiefer.

Der zweite Reflex: an der Read-only-Grenze zweifeln. Also der Negativtest. Das Lesekonto versucht, direkt auf die Basistabelle zuzugreifen:

select 1 from public.tickets;
-- ERROR: permission denied for schema public

Das ist die Antwort, die man haben will. Das Konto kommt nicht an die Rohdaten, auch nicht lesend. Die Grenze hielt. Sie hielt nur nicht die Bedeutung der Daten auseinander.

Der eigentliche Befund kam aus der View-Definition. v_tickets führt keinen Status. Der Zustand eines Tickets wurde aus der History abgeleitet — aus den Zeilen, die Zustandswechsel protokollieren. Das ist naheliegend, weil die History die Wahrheit über Ereignisse ist. Es ist nur keine Wahrheit über den aktuellen Zustand, wenn es keine Ereignisse gibt.

Tickets ohne jede Status-History wurden in dieser Ableitung als new behandelt. Unter den Tickets ohne History waren 23 bereits geschlossene. Die Zählung „offen" enthielt sie also mit. 23 zu viel, mitten in einer Grundgesamtheit von 79 echten offenen Tickets — daher 102. Die Abweichung war kein Rundungsfehler, sondern eine systematische Fehlklassifikation.

Der Fix war eine additive View auf die Tabelle, die den tatsächlichen Zustand kennt: v_ticket_state(id, state_id). Kein CREATE OR REPLACE, keine Änderung an bestehenden Views, kein Neustart des Ticket-Stacks. Nur eine zusätzliche Sicht auf zwei Spalten.

Und dann kam der zweite Fehler, der nichts mit SQL-Logik zu tun hat. Die View ließ sich nicht erzeugen:

CREATE VIEW bi_read.v_ticket_state AS SELECT id, state_id FROM public.tickets;
-- ERROR: permission denied for table tickets

Der View-Owner durfte die Tabelle sehen, aber nicht diese Spalte. Auf public.tickets liegen spaltenweise Grants. state_id war nicht freigegeben. Ein GRANT SELECT ON public.tickets hätte das gelöst — und viel zu viel geöffnet. Nötig war ein einziges zusätzliches Spaltenrecht:

GRANT SELECT (state_id) ON public.tickets TO bi_owner;

Danach lieferte die View den echten Zustand, und die Offen-Zählung lag bei 79. Dieselbe Zahl wie in der Gegenprobe per psql. Die Fächer-Summe der Altersverteilung ergab denselben Wert wie die Gesamtzahl — ein Test, der sich nicht selbst bestätigt, sondern zwei getrennte Wege vergleicht.

Ein dritter Punkt fiel bei den Vorperioden auf. Die Daten beginnen am 07.09.2026, der abgeleitete Vergleichsbeginn ist der 08.09.2026. Ein Vergleich „dieser Monat gegen letzter Monat" wäre strukturell schief, weil die Vorperiode teilweise vor dem Datenbeginn liegt. Der Guard ist konservativ: Laufende Perioden werden auf denselben relativen Zeitpunkt gekürzt, und ein Prozentvergleich entfällt, wenn die Vorperiode vor dem angenommenen Datenbeginn beginnt. Dann steht nur der Wert da, ohne +x %. „Erster Ereignistag plus ein Tag" ist dabei eine implementierte Heuristik, kein Beweis für lückenlose Daten davor oder danach. Sie verhindert einen offensichtlich unfairen Vergleich und ersetzt keine Datenqualitätsprüfung.

Die Ursache

Die Ursache ist eine Vermischung von zwei Quellen der Wahrheit: Die History beschreibt Ereignisse, der aktuelle Status ist ein Zustand. Wer den Zustand aus Ereignissen ableitet, bekommt für jedes Ticket mit Ereignissen das richtige Ergebnis — und für jedes ohne Ereignisse eine stille Annahme. Diese Annahme war new. Sie hätte auch closed sein können. Geprüft hat sie niemand, weil sie in der Abfrage unsichtbar ist.

Dazu kommt ein Denkfehler: Ich hatte drei verschiedene Grenzen als eine behandelt.

Die Netzwerkgrenze ist nicht die Berechtigungsgrenze. HAProxy verbindet Metabase mit dem vorhandenen Datenbanknetz, ohne einen Port am Host zu veröffentlichen. Der Forwarder sitzt in einem internen Netz, das nur Metabase und ihn verbindet, und in dem bestehenden Compose-Netz der Datenbank. Kein Port nach außen, kein Zugriff aus dem LAN. Aber HAProxy ist ein TCP-Proxy und garantiert nichts über Rechte. Die Read-only-Eigenschaft kommt aus den Grants der Datenbankrolle, nicht aus der Netzwerkarchitektur. Wer das verwechselt, hat eine Firewall und nennt sie Berechtigungssystem.

Die Verbindungsgrenze ist nicht die Datenbankgrenze. Metabase bringt einen Verbindungspool mit, dessen Standard bei 15 liegt. Die Leserolle hat CONNECTION LIMIT 10, der Pool ist auf 8 gesetzt. Das ist eine bewusste Begrenzung mit Headroom, kein behobener Ausfall. Ein Verbindungslimit-Ausfall ist hier nicht belegt; die Einstellung ist vorbeugend, damit das Reporting die Produktionsdatenbank nicht mit Verbindungen zustellt, nur weil viele Karten gleichzeitig laden.

Die Kontogrenze ist nicht die Spaltengrenze. Ein Lesekonto mit SELECT auf ein Schema kann trotzdem an einer einzelnen Spalte scheitern, wenn die Rechte spaltenweise vergeben sind. Genau das passiert oben. Der Fehler ist gut, weil er laut ist. Gefährlich wäre die Variante, in der die View ohne Fehler entsteht und eine Spalte still fehlt.

Die vierte Ursache ist die unangenehmste: Plausibilität ist kein Test. 102 offene Tickets bei einem Ticketsystem dieser Größe sind keine auffällige Zahl. Es gab keinen Grund, sie anzuzweifeln — außer der Gegenprobe gegen die Rohdaten.

Der Fix

Der Fix hat vier Teile, und nur einer davon ist SQL-Logik.

Datenbankgrenze. Ein eigenes Schema, ein View-Owner ohne Login, ein Lesekonto mit SELECT ausschließlich auf diesem Schema. Die Views geben nur die benötigten Spalten frei. title und der Nachrichteninhalt sind nicht dabei.

Der View-Owner hat NOLOGIN. Er besitzt die Views, damit sie mit seinen Rechten auf die Basistabellen zugreifen können, aber niemand kann sich als er anmelden. Das Lesekonto sieht die Views, nicht die Tabellen dahinter. Der Negativtest bleibt Teil des Aufbaus:

-- als Lesekonto:
select 1 from public.tickets;    -- permission denied for schema public
select count(*) from bi_read.v_tickets;  -- erlaubt

Aktueller Zustand. Die additive View v_ticket_state(id, state_id) liefert den tatsächlichen Status. Die Karten für offene Tickets leiten den Zustand jetzt daraus ab, nicht mehr aus der jüngsten History-Zeile. Der Zyklusbeginn für das Alter kommt weiterhin aus der History — das ist korrekt, weil Wiedereröffnungen echte Ereignisse sind. Nur der Zustand selbst kommt aus der Quelle, die ihn führt.

Dafür war das zusätzliche Spaltenrecht auf state_id nötig. Für den Rückbau gilt: erst die abhängigen Karten zurückspielen, dann die View löschen, dann das Spaltenrecht entziehen, dann den Schema-Sync anstoßen — sonst zeigt Metabase eine Tabelle, die es nicht mehr gibt und eine Freigabe ohne Verwender bleibt stehen.

Netzwerkgrenze. Der Forwarder ist ein eigener Container mit read_only-Dateisystem, ohne Host-Port, in einem internen Netz. Metabase spricht ihn unter einem festen Alias an, er spricht die Datenbank im bestehenden Compose-Netz an. Metabase hängt damit nicht direkt am Datenbanknetz. Der Preis ist eine Abhängigkeit: Solange der Forwarder im Datenbanknetz hängt, lässt sich dieses Netz nicht entfernen. Vor Wartungsarbeiten am Ticket-Stack wird er deshalb zuerst gestoppt.

Verbindungen. Pool 8, Rollenlimit 10. Die Differenz ist Headroom für andere Verwender derselben Rolle und für Verbindungen, die beim Schließen kurz nachhängen. Das ist keine Garantie gegen Engpässe, sondern die Entscheidung, die Enge nicht dem Zufall zu überlassen.

Dazu kommt der Vorperioden-Guard. Laufende Perioden werden fair gekürzt, Prozentwerte unterdrückt, wenn die Vorperiode vor dem angenommenen Datenbeginn liegt. Die Daten beginnen am 07.09.2026, der abgeleitete Vergleichsbeginn ist der 08.09.2026. Die Heuristik ist dokumentiert und nicht als Beweis getarnt.

Metabase selbst läuft auf einem eigenen kleinen Host, srv-scarif, getrennt vom Ticketsystem auf srv-rebellion und vom Forwarder auf srv-coruscant. Für diese Größenordnung reicht ein sparsamer Mini-PC; wer Reporting und Datenbank nicht auf derselben Maschine haben will, kommt mit einem Mini-PC N100 Beelink S12 Pro hin. Wer es günstiger will, nimmt einen gebrauchten Lenovo ThinkCentre M720q Tiny (i5-8400T, 16 GB, generalüberholt); für größere Reporting-Lasten und mehrere Datenquellen ist ein Minisforum MS-01 (i5-12600H, Barebone ohne RAM/SSD) die obere Klasse. Eine große Maschine ersetzt keine saubere Rechtevergabe.

Was noch offen ist. Der AD-Login funktioniert, aber nicht vollständig. Der Bind-Account konnte memberOf außerhalb der IT-OU nicht lesen. Gruppenmitgliedschaft allein genügte deshalb nicht — der Login-Filter prüfte genau dieses Attribut und sah es nicht. Das ist ein Delegationsproblem im Verzeichnis, kein Metabase-Fehler. Dokumentierte echte Erstlogins fehlen noch; solange die nicht stattgefunden haben, ist „Login funktioniert" eine Aussage über die Konfiguration, nicht über den Betrieb.

Und der historische Zustand war LDAP ohne TLS auf Port 389. Das war eine Übergangslösung, ausdrücklich keine empfohlene Zielkonfiguration: Bind-Passwort und Nutzerpasswort gehen dabei im Klartext über das Netz. Die Zielkonfiguration ist LDAPS mit gültigem Zertifikat.

Was du mitnimmst

Die technischen Bausteine sind unspektakulär: getrenntes Schema, nicht anmeldbarer View-Owner, eingeschränktes Lesekonto, getrennte Docker-Netze, bewusst begrenzter Pool. Zusammen ergeben sie eine Grenze, die man begründen kann.

Die eigentliche Lehre liegt woanders. Eine Kennzahl ist erst dann eine Kennzahl, wenn sie gegen die Rohdaten geprüft wurde. Nicht gegen eine zweite Karte im selben Dashboard, nicht gegen die eigene Vorwoche, sondern gegen die Quelle. 102 statt 79 fällt nur auf, wenn jemand nachzählt.

Der zweite Punkt: Grenzen sind nicht austauschbar. Ein Proxy macht kein Read-only. Ein Pool-Limit behebt kein Rollenlimit. Ein Schemarecht öffnet keine Spalte. Und eine aus Ereignissen abgeleitete Zustandsanzeige ist keine Zustandsanzeige, sobald ein Datensatz ohne Ereignisse existiert.

Der dritte Punkt ist der unbequemste: Fehlende Daten sehen aus wie normale Daten. Ein Ticket ohne History ist für eine History-Abfrage ein Ticket ohne Zustand — und die Abfrage füllt die Lücke mit einer Annahme, die niemand sieht. Deshalb steht in der View jetzt die Spalte, die den Zustand tatsächlich führt, statt der Umweg über das Ereignisprotokoll.

Am Ende bleibt eine Zahl, die stimmt, und ein Dashboard, dem man wieder trauen kann. Nicht weil es besser aussieht, sondern weil die Gegenprobe passt. Der Rest ist Verwaltung eines Imperiums: Irgendjemand muss die Rechte vergeben, und es ist besser, es steht in der Doku, bevor der nächste Container dazukommt.