Ga naar inhoud
Deel 2: Het fundament 8 min leestijd

Les 07

Beveiliging via architectuur

Regel het een keer. In het fundament.

Snelle quiz: op hoeveel plekken in je codebase controleer je of een gebruiker toegang heeft tot een specifiek record?

Als je moet tellen, heb je een probleem. Elk endpoint. Elke query. Elke service method die tenant data aanraakt. Je vertrouwt erop dat elke developer eraan denkt de check toe te voegen. Elke keer. In elk bestand.

Eén gemiste check en gebruiker A ziet de data van gebruiker B. Dat is geen bug. Dat is een krantenkop.

De architecturale aanpak

Stop met vertrouwen op developers die security checks onthouden. Maak het onmogelijk om ze te vergeten. Dwing beveiliging af op databaseniveau. Niet op applicatieniveau.

Row-Level Security (RLS)

PostgreSQL's Row-Level Security filtert automatisch elke query. De database zelf garandeert dat gebruikers alleen hun eigen data zien. Ook als je applicatiecode een bug bevat.

-- Schakel RLS in op de tabel
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Maak een policy: gebruikers zien alleen projecten van hun workspace
CREATE POLICY workspace_isolation ON projects
  FOR ALL TO authenticated_users
  USING (
    workspace_id = current_setting('app.current_workspace_id')::uuid
  );

Elke query krijgt dit filter. SELECT, INSERT, UPDATE, DELETE. Per ongeluk projecten van een andere workspace opvragen? Kan niet. De database geeft ze gewoon niet terug.

Session-based context

Bij elk geauthenticeerd request zet middleware de sessiecontext. Voordat er ook maar een query draait:

# In je middleware:
await session.execute(
    text(f"SET app.current_workspace_id = '{workspace_id}'")
)
await session.execute(
    text(f"SET app.current_user_id = '{user_id}'")
)
await session.execute(text("SET ROLE authenticated_users"))

De fail-safe standaard

Sessievariabelen niet gezet? In een goed systeem krijg je niets terug. Helemaal niets. Dat is de fail-safe standaard. Onduidelijke context? Geen data.

CASE
    WHEN current_setting('app.current_workspace_id', true) = ''
    THEN false  -- Geen context? Geen data.
    ELSE workspace_id = current_setting('app.current_workspace_id')::uuid
END

Applicatie-level checks werken andersom. Daar is de standaard: geef alles terug tenzij iets het tegenhoudt. Database-level beveiliging draait dat om. Geef niets terug tenzij alles klopt.

Drie verdedigingslagen

  1. Authentication in middleware: wie ben je? (JWT verificatie)
  2. Authorization via policies: wat mag je doen? (Role-based access)
  3. Isolatie op databaseniveau: welke data mag je zien? (RLS policies)

Laag 1 en 2 hebben bugs? Laag 3 vangt het op. Data lekt niet tussen tenants. Dat is defense in depth. Eén faalpunt brengt het systeem niet ten val.

Veelvoorkomende valkuilen

  • UUID casting op lege strings: PostgreSQL evalueert alle delen van AND-condities. Is app.current_workspace_id leeg? Dan crasht de cast naar UUID. Gebruik altijd CASE statements. Eerst checken, dan casten.
  • Sessie niet resetten: Na elk request moet je role en sessievariabelen resetten. Doe je dat niet? Dan kan connection pooling context lekken tussen requests.
  • Testen als superuser: Je tests moeten echt van role wisselen en sessievariabelen zetten. Superuser omzeilt RLS volledig. Dan test je niet wat productie doet.