In mijn profielenschema stond een policy als deze, geschreven met de Drizzle-helper voor Neon:
crudPolicy({
role: authenticatedRole,
read: authUid(table.userId),
modify: false,
})
Drie regels. Ze zeggen dat een geauthenticeerde gebruiker alleen zijn eigen rijen ziet en die niet mag wijzigen. Daar valt moeilijk iets tegenin te brengen. Anderhalf jaar later verwijderde ik die drie regels, en die van de feedbacktabel, in dezelfde commit. Maanden later herhaalde ik de verwijdering in een ander project dat hetzelfde schema had geërfd.
Het was geen gril. Het was dat de policy en de code hetzelfde zeiden, en als twee plekken hetzelfde zeggen gaat er uiteindelijk één liegen.
De belofte van RLS en waarom ik erin trapte
De belofte is goed: de permissie woont in de database, dus het maakt niet uit wie er bevraagt. Schrijf ik morgen een onderhoudsscript, of open ik een nieuw endpoint, of vergeet iemand een where, dan komt de rij er niet uit. Dat is echte defense in depth, geen versiering.
Ik bouwde alles daaromheen. Twee verbindingen:
export const getRlsDb = async (user: StackUser): Promise<DrizzleDb> => {
const authToken = (await user?.getAuthJson())?.accessToken;
const url = process.env.NEXT_PUBLIC_DATABASE_AUTHENTICATED_URL;
const sql = neon(url, { authToken });
return drizzle(sql);
};
Een "geauthenticeerde" verbinding die de JWT van de gebruiker meedraagt en de policies respecteert, en een admin-verbinding met de normale DATABASE_URL die eroverheen gaat. Ik ging zelfs zover dat ik een map met RLS-verbindingen per gebruikers-id cachede, tot ik doorhad dat ik verbindingen bewaarde die vastzaten aan een token dat verloopt, en het liet bij elke keer een nieuwe maken.
Waar het begon te piepen met Drizzle en server actions
Het probleem is niet dat RLS faalt. Het is dat het in een app met Next en server actions bijna nooit de laag is die beslist.
Elke server action begint al met het resolven van de sessie. Je hebt de gebruiker al voordat je de database aanraakt. En dan schrijf je dit:
const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));
Die where is redundant met de policy. Maar je kunt hem niet weghalen, want haal je hem weg dan liegt de code: het lijkt alsof hij alle feedback ophaalt en hangt af van een onzichtbare policy in een ander bestand. Dus eindig je met het filter in de code en in de database, plus de twijfel welke van de twee nu eigenlijk werkt.
Het tweede is de dagelijkse frictie:
- Twee verbindingen. Elke nieuwe functie begint met de vraag of hij via RLS of via admin gaat. En alles wat administratie is, betaalwebhooks, crons of migraties gaat via admin. Oftewel: via het pad dat niets controleert.
- De fouten leggen niets uit. Als de policy je blokkeert, zegt hij niet "je mist de claim"; hij geeft nul rijen terug. Een
selectdebuggen die een lege array teruggeeft terwijl de rij bestaat, kost meer dan het lijkt. - Het token reist waar je het niet wilt. De geauthenticeerde URL belandt in een variabele met prefix
NEXT_PUBLIC_, en dan moet je al twee keer nadenken over wat je blootstelt. - De policies wonen in de snapshot. Een policy wijzigen is een migratie genereren en toepassen. Een check in code wijzigen is één regel veranderen.
Op een gegeven moment sloeg ik Drizzle zelfs helemaal over en liet ik de feedback en de profielen via PostgREST praten, juist om echt op RLS te kunnen leunen. Daar werd de prijs duidelijk: ik verloor de typering, moest de kolomnamen met de hand in snake_case uitschrijven (user_id, created_at) en elke aanroep eindigde in hetzelfde ritueel.
const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];
Een as Feedback[] aan het eind. Oftewel: erop vertrouwen dat de database teruggeeft wat ik zeg. Dat ging twee weken later terug naar Drizzle.
Dezelfde afloop in het tweede project
Toen ik het volgende project startte vanaf dezelfde boilerplate, zaten de policies erin. Ze hielden het uit tot ik de feedbacktabel serieus aanraakte. De commit waarin ik ze weghaalde deed tegelijk iets anders: feedback.description hernoemen naar feedback.content. Een dom detail maar het vertelt het hele verhaal: ik was het datamodel aan het vormgeven, en de policies waren wat me hinderde bij het verschuiven. In die commit verdwenen de import van drizzle-orm/neon en de vier aanroepen naar crudPolicy. De tabellen hielden het enige over wat echt elke dag gebruikt wordt:
(table) => [index("idx_feedback_user_id").on(table.userId)]
Een index op user_id. Dát raadpleegt Postgres bij elk request.
Waar mijn permissielogica vandaag woont
In de middleware en in de laag met server actions. Toen ik de managed auth verliet en mijn eigen sessie-middleware schreef, kwam de plek waar beslist wordt wie wat ziet op één enkel punt te liggen: de sessie wordt geresolved, de rol wordt gecontroleerd, en vanaf daar worden alle queries geschreven met de userId al in de hand. Eén verbinding, die van admin, en geen parallel bevoorrecht pad.
Op papier is het minder veilig. Een query die de where vergeet geeft rijen van iemand anders terug. Ik compenseer dat doordat die queries allemaal in één map met acties wonen, niet verspreid door de app, en doordat de functiesignatuur je dwingt de gebruiker mee te geven.
Wanneer ik RLS wél weer zou gebruiken
Als de database meer dan één client heeft. Is er naast mijn applicatie ook een publieke PostgREST, een client die direct verbindt, of analisten met leesrechten, dan is de policy de enige plek waar de regel voor iedereen geldt. Daar betalen de kosten van twee waarheden onderhouden zichzelf terug.
Ook bij harde multi-tenancy, waar de fout niet "je ziet andermans feedback" is maar "je ziet de gegevens van een ander bedrijf". Daar wil ik het vangnet eronder, ook al filtert de code al.
Voor een applicatie met één client, met server actions en de gebruiker geresolved vóór de eerste query, is RLS een tweede kopie van een regel die je al hebt opgeschreven. En een regel die twee keer is opgeschreven is niet twee keer zo veilig: hij is twee keer zo makkelijk verouderd te laten raken.
