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,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