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:
2026-09-09 09:59:21 +02:00
parent 8cb2d43f88
commit b3375ae016
2 changed files with 177 additions and 0 deletions
+74
View File
@@ -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/);
});
});