H5 — validating RLS under a least-privilege DB role
The platform's tenant isolation is Postgres Row-Level Security. RLS is bypassed for SUPERUSER / BYPASSRLS roles, so while the app connects as the bootstrap superuser (nis2) the policies are decorative — isolation rests on the app-layer organization_id filters alone. This runbook switches the app + worker to a NOSUPERUSER NOBYPASSRLS role so the policies actually apply, and validates it.
Status of the H5 stack when you run this
- Done & safe to validate now: manual scans (PR #155) and report generation (PR #158) set the worker's RLS org context before their tenant reads.
- WIP — expect breakage until the later chunks land (tracked on #140): scheduled scans (the beat sweep isn't org-looped yet) and API-key auth (the
api_keyslookup isn't RLS-exempt yet). Validate the manual scan + report path first; don't cut scheduled/API-key traffic over until those land.
1. Provision the role
Fresh deploy: automatic — docker-compose.{dev,prod}.yml mount infra/docker/initdb into the postgres container and the 01-create-app-role.sh wrapper runs infra/docker/initdb/sql/01-create-app-role.sql into the postgres container's /docker-entrypoint-initdb.d/ (runs once on first volume init).
Existing deploy (volume already initialised): run it once as the superuser —
psql "$SUPERUSER_DATABASE_URL" -v app_pw="$NIS2_APP_PASSWORD" \
-f infra/docker/initdb/sql/01-create-app-role.sqlConfirm the role is least-privilege:
SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'nis2_app';
-- expect: f | f2. Point the app + worker at the role (keep the superuser for migrations)
DDL (migrations) still needs the superuser — nis2_app has DML only. Keep two URLs:
# .env
SUPERUSER_DATABASE_URL=postgresql+asyncpg://nis2:<su_pw>@db:5432/nis2 # migrations only
DATABASE_URL=postgresql+asyncpg://nis2_app:<app_pw>@db:5432/nis2 # api + workerRun migrations as the superuser, then start the app on the app role:
DATABASE_URL="$SUPERUSER_DATABASE_URL" alembic upgrade head
docker compose -f infra/docker/docker-compose.prod.yml up -d # api/worker/beat read DATABASE_URLThe worker boot guard (assert_db_role_rls_safe) and the API lifespan should now start without the "rolsuper/rolbypassrls — RLS bypassed" warning. If you see Refusing to start: ... SUPERUSER/BYPASSRLS app role, the app is still on the superuser URL.
One-time superuser setup. The RLS policies and the two
SECURITY DEFINERaudit helpers (purge_old_audit_logs,pseudonymize_user_audit_logs) are created bysetup_row_level_securityduring a superuser/migration boot — run that once before switching the app role. When the app later boots asnis2_app, those CREATE/REVOKE/GRANT statements no-op (insufficient privilege, caught) because the objects already exist.
3. Acceptance test
The point is to prove isolation holds even with the app-layer filters removed — i.e. the database itself refuses cross-tenant reads.
Manual scan persists (part 1). Trigger a scan in org A from the UI/API. It must reach
completedwith findings. If findings come back empty / the scan stalls inrunning,set_rls_org_contextisn't taking effect — check the worker connects asnis2_appandCELERY_WORKER=1is set (NullPool).Report generates (part 2). Download a report for that scan → non-empty.
Cross-tenant read is refused at the DB. As
nis2_appin psql, simulate org A's context and confirm org B's rows are invisible:sqlSELECT set_config('app.current_org_id', '<ORG_A_UUID>', false); SELECT count(*) FROM scans; -- only org A's scans SELECT count(*) FROM scans WHERE organization_id = '<ORG_B_UUID>'; -- expect 00 rows for org B is the proof RLS is doing the work, not just the API.
4. Rollback
Point DATABASE_URL back at the superuser URL and restart. Nothing in the schema changed, so this is instant and lossless.
Status — VALIDATED (2026-06-27)
The full switch is proven on the local stack under nis2_app: the E2E live suite passes 53/53, plus register / login / scans / reports / API-key auth / GDPR erasure end-to-end. The per-org cross-tenant sweeps (#163 / #170 / #171), the api_keys RLS exemption (#169 / #174), M2 audit append-only (#172), and the bootstrap-write contexts (#174) have all landed — so nothing in the acceptance test above is "expected to break" anymore. The only step that still needs the superuser is the one-time schema + policy + SECURITY-DEFINER-function setup noted in §2.