Talk to PostgreSQL directly, and let the pooled connection forget
Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.
Was dabei ersatzlos verschwindet:
- fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
Am direkten Zugang ist eine Abfrage eine Abfrage.
- sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
abgesichert, sondern weg.
- lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.
Nebenbei besser geworden, weil der direkte Zugang es erlaubt:
- Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
Lesestand statt in fünf unabhängigen Anfragen.
- Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
eingebetteten Ressource mit !inner — eine Person mit mehreren
Zuordnungen über die Zeit erschien dort mehrfach.
- Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
- Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
- Namen an Ereigniszeilen kommen aus einem Join statt aus einem
Nachschlag, der ausserhalb der Transaktion lag.
Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.
Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.
Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
import "server-only";
|
||||
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "./types";
|
||||
|
||||
// Service-role client: bypasses RLS entirely. Server-only — never import this
|
||||
// from a Client Component or anything bundled for the browser. The
|
||||
// "server-only" import makes an accidental client-side import a build error
|
||||
// instead of a runtime one.
|
||||
export function createAdminClient() {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!serviceRoleKey) {
|
||||
throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
|
||||
}
|
||||
|
||||
return createSupabaseClient<Database>(supabaseUrl, serviceRoleKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Database } from "./types";
|
||||
|
||||
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
||||
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
||||
// re-establish that the caller is an active HR user itself. RLS is still the
|
||||
// real boundary (an unauthorized session simply reads nothing); this exists
|
||||
// so those routes answer 401/403 instead of handing back an empty workbook.
|
||||
export async function requireHrUser(supabase: SupabaseClient<Database>): Promise<NextResponse | null> {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
||||
if (profile?.role !== "hr" || profile.is_active !== true) {
|
||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// PostgREST's .or() filter syntax treats "," "(" and ")" as structural
|
||||
// delimiters between conditions. A raw user-supplied search term containing
|
||||
// them (e.g. from a search box or ?q= param) can break out of the intended
|
||||
// column conditions and append arbitrary extra filters to the query. Strip
|
||||
// them before interpolating — harmless for real name/title searches, which
|
||||
// never legitimately contain them.
|
||||
export function sanitizeIlikeTerm(term: string): string {
|
||||
return term.replace(/[,()]/g, "");
|
||||
}
|
||||
|
||||
// PostgREST caps every response at db.max_rows (1000, see
|
||||
// supabase/config.toml) and does so *silently* — a query over ~800 employees
|
||||
// or the employee_history log just stops returning rows, and a report or
|
||||
// export built from it is quietly wrong rather than failing. Anything that
|
||||
// aggregates a whole table has to page explicitly; anything that renders a
|
||||
// bounded list (an employee page, the audit log) uses .range() directly and
|
||||
// does not need this.
|
||||
const PAGE_SIZE = 1000;
|
||||
|
||||
type PagedQuery<Row> = {
|
||||
range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>;
|
||||
};
|
||||
|
||||
export async function fetchAllRows<Row>(buildQuery: () => PagedQuery<Row>): Promise<Row[]> {
|
||||
const rows: Row[] = [];
|
||||
for (let page = 0; ; page++) {
|
||||
const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
|
||||
if (error || !data) break;
|
||||
rows.push(...data);
|
||||
if (data.length < PAGE_SIZE) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
Reference in New Issue
Block a user