feat(quick-260909-dgj): Anwendungsrolle tessera_app ohne RLS-Umgehungsrecht (Task 1/4)
- Migration 20260909130000_rls_app_role legt tessera_app mit NOSUPERUSER NOBYPASSRLS wiederholbar an bzw. konvergiert eine vorhandene Rolle darauf - Kein Kennwort im SQL, Datenbankname und Eigentuemer dynamisch gebildet - 7 Tests in rls-app-role.spec.ts (rot vor der Migration, jetzt gruen) - Rolle wird von niemandem benutzt — WINDOWS #18 bleibt offen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
-- WINDOWS #18 — Anwendungsrolle ohne RLS-Umgehungsrecht (T-DGJ-01, T-DGJ-04,
|
||||||
|
-- T-DGJ-05). Erste von zwei Migrationen; die zweite (20260909140000) ergaenzt
|
||||||
|
-- die restlichen Policies.
|
||||||
|
--
|
||||||
|
-- Befund (gemessen am 2026-09-09, siehe docker-compose.yml:33/:76): Die API
|
||||||
|
-- verbindet als Rolle "tessera". Diese Rolle entsteht aus POSTGRES_USER und
|
||||||
|
-- ist damit Superuser des Postgres-Clusters (auf alpha gemessen:
|
||||||
|
-- rolsuper = t, rolbypassrls = t). PostgreSQL wendet Row-Level Security auf
|
||||||
|
-- Superuser-Rollen und Rollen ohne NOBYPASSRLS grundsaetzlich nicht an;
|
||||||
|
-- FORCE ROW LEVEL SECURITY aendert daran nichts, weil es nur den
|
||||||
|
-- Tabelleneigentuemer erfasst, nicht Rollen mit Umgehungsrecht. Die sieben
|
||||||
|
-- vorhandenen Policies
|
||||||
|
-- (User, Group, GroupMembership, LdapConfig, LdapFieldMapping, ModuleGrant,
|
||||||
|
-- PasswordResetToken) sind unter der Rolle "tessera" deshalb ohne Wirkung.
|
||||||
|
--
|
||||||
|
-- Diese Migration allein stellt noch nichts um — sie legt lediglich die
|
||||||
|
-- Rolle "tessera_app" an und konvergiert sie bei Wiederholung auf
|
||||||
|
-- NOSUPERUSER/NOBYPASSRLS. Niemand verbindet mit ihr, solange
|
||||||
|
-- DATABASE_URL nicht umgestellt wird. Der volle Ablauf inklusive
|
||||||
|
-- Kennwortvergabe steht in docs/mandantentrennung-datenbankrolle.md — das
|
||||||
|
-- Kennwort wird bewusst NICHT hier gesetzt, sondern vom Betreiber von Hand,
|
||||||
|
-- weil es sonst im Klartext in dieser versionierten Datei laenden wuerde.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
can_manage_roles boolean;
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tessera_app') THEN
|
||||||
|
-- Rolle existiert noch nicht: current_user braucht die Berechtigung,
|
||||||
|
-- Rollen anzulegen. Lautes Scheitern mit Anleitung ist hier richtig —
|
||||||
|
-- ein stilles Ueberspringen wuerde einen Betreiber im Glauben lassen,
|
||||||
|
-- die Rolle existiere bereits.
|
||||||
|
SELECT rolsuper OR rolcreaterole INTO can_manage_roles
|
||||||
|
FROM pg_roles WHERE rolname = current_user;
|
||||||
|
|
||||||
|
IF NOT can_manage_roles THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'tessera_app existiert nicht und current_user (%) darf keine Rollen anlegen. '
|
||||||
|
'Einmalig als Datenbank-Superuser ausfuehren: '
|
||||||
|
'CREATE ROLE tessera_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; '
|
||||||
|
'Siehe docs/mandantentrennung-datenbankrolle.md.', current_user;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
CREATE ROLE tessera_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
|
||||||
|
ELSE
|
||||||
|
-- Rolle existiert bereits: auf denselben Stand konvergieren. Nur ein
|
||||||
|
-- Superuser darf SUPERUSER/NOBYPASSRLS setzen oder entziehen — ist
|
||||||
|
-- current_user keiner, pruefen, ob die Merkmale bereits beide falsch
|
||||||
|
-- sind, statt das ALTER zu versuchen.
|
||||||
|
SELECT rolsuper INTO can_manage_roles FROM pg_roles WHERE rolname = current_user;
|
||||||
|
|
||||||
|
IF can_manage_roles THEN
|
||||||
|
ALTER ROLE tessera_app WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
|
||||||
|
ELSE
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_roles
|
||||||
|
WHERE rolname = 'tessera_app' AND (rolsuper OR rolbypassrls)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'tessera_app traegt noch rolsuper oder rolbypassrls, und current_user (%) '
|
||||||
|
'ist kein Superuser, um das zu korrigieren. Einmalig als '
|
||||||
|
'Datenbank-Superuser ausfuehren: '
|
||||||
|
'ALTER ROLE tessera_app WITH NOSUPERUSER NOBYPASSRLS; '
|
||||||
|
'Siehe docs/mandantentrennung-datenbankrolle.md.', current_user;
|
||||||
|
END IF;
|
||||||
|
-- rolsuper und rolbypassrls sind bereits beide falsch — reiner Durchlauf.
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Rechte, alle idempotent (ein wiederholtes GRANT ist in PostgreSQL
|
||||||
|
-- folgenlos). Datenbankname und Eigentuemer werden dynamisch gebildet,
|
||||||
|
-- damit die Migration auch gegen eine anders benannte Installation bzw.
|
||||||
|
-- eine andere migrierende Rolle laeuft.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('GRANT CONNECT ON DATABASE %I TO tessera_app', current_database());
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA public TO tessera_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tessera_app;
|
||||||
|
-- Das Schema nutzt derzeit keine Sequenzen (kein einziges autoincrement
|
||||||
|
-- nachgezaehlt) — die Vergabe kostet nichts und verhindert einen spaeteren
|
||||||
|
-- Stolperstein, sollte eine kuenftige Migration eine Sequenz einfuehren.
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO tessera_app;
|
||||||
|
|
||||||
|
-- Vorgaberechte, damit kuenftige Migrationen (angewendet von current_user,
|
||||||
|
-- also der migrierenden Rolle — nicht fest "tessera" eingetragen, weil die
|
||||||
|
-- auf einer anderen Installation anders heissen kann) nicht jedes Mal
|
||||||
|
-- nachziehen muessen.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tessera_app',
|
||||||
|
current_user
|
||||||
|
);
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO tessera_app',
|
||||||
|
current_user
|
||||||
|
);
|
||||||
|
END
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die neue Rollen-Migration (Task 1, WINDOWS #18) rein textuell gegen
|
||||||
|
* die generierte migration.sql — keine Datenbank noetig. Baut die
|
||||||
|
* Hilfsfunktion aus apps/api/src/groups/migration-sql.spec.ts bewusst nach,
|
||||||
|
* statt sie zu importieren: die vorhandene ist in ihrer Datei privat, und
|
||||||
|
* eine Kopie von zwoelf Zeilen ist billiger als eine neue Abhaengigkeit
|
||||||
|
* zwischen zwei Testdateien.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
|
||||||
|
|
||||||
|
function readMigrationSql(suffix: string): string {
|
||||||
|
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
|
||||||
|
.map((entry) => entry.name);
|
||||||
|
|
||||||
|
if (dirs.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rls_app_role migration.sql (WINDOWS #18, T-DGJ-01/T-DGJ-04/T-DGJ-05)', () => {
|
||||||
|
const sql = readMigrationSql('_rls_app_role');
|
||||||
|
|
||||||
|
it('nennt die Rolle tessera_app', () => {
|
||||||
|
expect(sql).toContain('tessera_app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entzieht ausdruecklich beide Umgehungswege (NOSUPERUSER, NOBYPASSRLS) — BYPASSRLS kommt nie ohne vorangestelltes NO vor', () => {
|
||||||
|
expect(sql).toContain('NOSUPERUSER');
|
||||||
|
expect(sql).toContain('NOBYPASSRLS');
|
||||||
|
|
||||||
|
const bypassOccurrences = sql.match(/BYPASSRLS/g) ?? [];
|
||||||
|
for (const _ of bypassOccurrences) {
|
||||||
|
// jedes Vorkommen von BYPASSRLS muss Teil von NOBYPASSRLS sein
|
||||||
|
}
|
||||||
|
const bareBypassrls = sql.match(/(?<!NO)BYPASSRLS/g) ?? [];
|
||||||
|
expect(bareBypassrls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist wiederholbar — Existenzpruefung ueber pg_roles, DO $$ und IF NOT EXISTS', () => {
|
||||||
|
expect(sql).toContain('pg_roles');
|
||||||
|
expect(sql).toContain('DO $$');
|
||||||
|
expect(sql).toMatch(/IF NOT EXISTS/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bildet den Datenbanknamen dynamisch ueber current_database(), nicht fest verdrahtet', () => {
|
||||||
|
expect(sql).toContain('current_database()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt die Vorgaberechte fuer current_user, nicht fuer einen fest verdrahteten Rollennamen', () => {
|
||||||
|
expect(sql).toContain('FOR ROLE');
|
||||||
|
expect(sql).toContain('current_user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enthaelt kein Kennwort — kein PASSWORD gefolgt von einem Hochkomma', () => {
|
||||||
|
expect(sql).not.toMatch(/PASSWORD\s*'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erteilt alle vier Datenzugriffsarten (SELECT, INSERT, UPDATE, DELETE)', () => {
|
||||||
|
for (const verb of ['SELECT', 'INSERT', 'UPDATE', 'DELETE']) {
|
||||||
|
expect(sql).toContain(verb);
|
||||||
|
}
|
||||||
|
expect(sql).toMatch(/GRANT[^;]*SELECT[^;]*INSERT[^;]*UPDATE[^;]*DELETE/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user