Ét databaseskema til fem apps, ikke fem skemaer.
Hvorfor vi lagde CRM, KB, CMS, udgående kommunikation, samtaler — og nu analyse — bag én Postgres, og hvordan row level security bærer lasten. Titlen siger fem, fordi analysen kom, efter den blev skrevet.

De fleste B2B-platforme vokser gennem opkøb: hvert modul kommer med sit eget skema, sin egen autentificeringsmodel, sin egen forestilling om, hvad en »kontakt« er. Efter nogle år æder integrationslaget udviklingsteamet.
Munin tog det modsatte væddemål. CRM, KB, CMS, udgående kommunikation og samtaler deler alle én Postgres-database, én identitetsmodel, én tenancy-kolonne og ét sæt org_id-afgrænsede RLS-politikker. Dette indlæg handler om, hvordan det faktisk er at bygge på det, og hvor det bider.
Hvorfor lægge fem apps bag ét Postgres-skema?
Fordi kundeposten er én post. En kontakt i CRM'et er den samme kontakt, helpdesken taler med, og den samme kontakt, en kampagne retter sig mod. Munin kører hvert modul på én Postgres-database med præcis én contacts-tabel, én org_id-kolonne og ét sæt row level security-politikker. At dele det op i fem rækker i fem skemaer er ingen arkitektur — det er et synkroniseringsprojekt, der venter på at ske.
Dette er databasehalvdelen af et argument, jeg har fremført andetsteds: hvis apps'ene skal eksponere sig som værktøjer snarere end som sider, bør de være enige om, hvad de beskriver. En agent, der skal forene tre versioner af samme kunde, laver netop det integrationsarbejde, vi prøvede at fjerne.
Så det gør vi ikke. Der findes præcis én contacts-tabel, præcis én org_id på den, præcis ét sæt RLS-politikker. CRM-modulet læser den. Samtalemodulet læser den. Modulet for udgående kommunikation læser den. De ser alle den samme række med de samme rettigheder. Det er også grunden til, at CRM'et kan udfylde sig selv fra supportsamtaler — agenten, der læser en lukket tråd, kigger på den række, en udgående kampagne vil rette sig mod, ikke på en kopi af den.
Hvordan bærer row level security multi-tenancy?
RLS er kontrakten, ikke sikkerhedsnettet. Hver eneste forespørgsel passerer Postgres' RLS-motor, så applikationskoden filtrerer aldrig på org_id — den kan ikke, fordi forespørgslerne ikke indeholder det. Applikationens eneste opgave er at sætte lejerkonteksten på sessionen. Databasens opgave er at nægte at gøre noget andet.
RLS har fået et dårligt ry, fordi de fleste team, der prøver det, skruer det på bagefter. Behandlet som kontrakten fra første migrering fjerner det en hel kategori af fejl: applikationskoden kan ikke omgå grænsen, selv ved et uheld, fordi databasen selv håndhæver politikken.
-- lejergrænsen, én gang, i databasen
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY contacts_tenant ON contacts
USING (org_id = current_setting('munin.org_id')::text)
WITH CHECK (org_id = current_setting('munin.org_id')::text);
-- og hvert indeks leder med den samme kolonne
CREATE INDEX contacts_org_email_idx ON contacts (org_id, email);
-- applikationens samlede bidrag til tenancy:
-- SET LOCAL munin.org_id = '...'
-- derefter er en glemt WHERE-klausul en fejl, ikke et brud.Hvorfor ændrer en tilstandsløs protokol, hvor tenancy bor?
Opdateret august 2026: Ordet, der gør arbejdet i det kodestykke, er LOCAL. Lejerkonteksten etableres per forespørgsel, ud fra den autentificerede kalder, og rives ned med transaktionen — den er aldrig noget, serveren husker om dig mellem kald. Da vi traf det valg, læste det som almindelig hygiejne. Det modnede til en bærende beslutning den 28. juli 2026, da MCP-specifikationen 2026-07-28 landede: SEP-2575 fjernede initialize/initialized-håndtrykket, og SEP-2567 fjernede Mcp-Session-Id-headeren sammen med den session på protokolniveau, den bar. Protokolversion, klientinfo og klientkapabiliteter rejser nu i _meta på hver forespørgsel i stedet for at blive udvekslet én gang ved forbindelsen — ændringsloggen er kortversionen.
Den praktiske konsekvens er værd at sige lige ud, for en protokol uden sin egen session skubber servere mod at give modellen eksplicitte id'er at træde igennem mellem kald. Et post-id, der siver ud af én lejers samtale og afspilles mod en anden, er en forespørgsel, der returnerer nul rækker. Ikke fordi applikationen fangede det — fordi databasen ikke kan få spørgsmålet stillet. Et id i Munin er et navn, ikke en nøgle.
Brød det sjette modul væddemålet?
Nej, og det er det klareste bevis, jeg har for, at det betalte sig. Analysen landede, efter dette indlæg blev skrevet — polymorfe hændelser for sidevisninger og søgninger — og den kom på den samme database, den samme org_id, de samme politikker, uden nyt skema og uden synkroniseringsjob. At tilføje et modul kostede en tabel og et sæt værktøjer, ikke et kvartal. Det er også det, der gør en sidevisning og en kontakt kan sammenkædes i én enkelt forespørgsel, hvilket er hele mekanismen bag at spørge, hvilken artikel der lukkede aftalen.
Hvilket gør titlen forkert med én. Der er seks moduler nu, ikke fem. Jeg lader hellere kvitteringen ligge end nummererer om i stilhed.
Hvad er ulemperne ved ét delt skema?
Tre reelle, og ingen af dem er hypotetiske. Migreringer er globale, så en ændring i kontaktskemaet forplanter sig gennem hvert modul og skal koordineres. Fejlfinding i RLS-politikker er et håndværk i sig selv — når en forespørgsel ikke returnerer noget, tjek sessionens GUC, før du tjekker noget andet. Og RLS-prædikater lander i hver forespørgselsplan, så du betaler for org_id-ledede indekser.
Hver af dem har en form, det er værd at kende, før du binder dig:
- Globale migreringer. Der findes ikke noget, der hedder at sende en kontaktskemaændring til ét modul. Hvert modul, der læser tabellen, skal være klar i samme udrulning, så koordineringsomkostningen skalerer med antallet af moduler snarere end med ændringens størrelse.
- GUC-først-fejlfinding. RLS' fejltilstand er stilhed — en korrekt forespørgsel, der returnerer nul rækker, fordi sessionskonteksten aldrig blev sat. Det ligner manglende data snarere end en rettighedsfejl, hvilket sender folk på jagt det forkerte sted i en time.
- Indeksdisciplin. Et indeks, der ikke leder med
org_id, er et indeks, politikprædikatet ikke kan bruge. Vi har omskrevet flere af dem, end vi havde planlagt, og reglen er nu kedelig: tenancy-kolonnen først, altid.
Ingen af disse er showstoppere, og hver af dem er en omkostning, du betaler én gang i bytte for en omkostning, du ellers ville betale løbende. Fem skemaer betyder fem migreringer, fem udrulningsrækkefølger, fem sæt legitimationsoplysninger og et synkroniseringsjob mellem hvert par, der skal forblive korrekt for altid. Ét skema betyder, at en migrering er én begivenhed med én tilbagerulning, at en forespørgsel, der sammenkæder en samtale med en kontakt med en kampagne, bare er en forespørgsel, og at tilføje et sjette modul kostede en tabel og et sæt værktøjer snarere end et kvartal. Indeksarbejdet er reelt, og det slutter. Integrationsarbejdet, det erstatter, ville aldrig gøre det.
Ville vi træffe det samme valg igen?
Det hele. De synkroniseringsproblemer, vi ikke har, er den migreringskoordinering værd, vi har.
Der er et andet udbytte, jeg ikke forudsagde, da vi startede, nemlig at ét skema med én tenancy-kolonne gør udgangen billig. Hvert modul får et symmetrisk eksport-/importpar, og en agent kan gå igennem dem alle for at flytte en hel lejer et andet sted hen — hvilket er den del af forretningsmodellen, jeg ville forsvare hårdest. Det kan du ikke tilbyde troværdigt oven på fem skemaer. Du kan dårligt nok tilbyde det overhovedet.
Ofte stillede spørgsmål
Kan Postgres row level security håndtere multi-tenancy alene?
Ja, hvis det er kontrakten snarere end en eftertanke. En politik på hver lejerafgrænset tabel, kontrolleret med både USING og WITH CHECK, betyder, at både læsninger og skrivninger begrænses af databasen. Applikationen sætter aldrig org_id i en WHERE-klausul, så den kan aldrig glemme det.
Bør jeg bruge én database per lejer eller row level security? Database per lejer giver dig den hårdeste isolation og den værste migreringshistorie — hver skemaændring er en flådeoperation. RLS i én database giver dig én migrering og lægger byrden på politikkorrekthed og indeksdesign. Vi valgte RLS, fordi vi sender moduler ofte og ville have, at det at tilføje et skulle koste en tabel, ikke et kvartal.
Hvordan sætter du lejerkonteksten for RLS i Postgres?
SET LOCAL munin.org_id = '...' i starten af transaktionen, udledt af den autentificerede kalder, revet ned når transaktionen slutter. LOCAL er det vigtige ord: konteksten overlever aldrig forespørgslen, så en forbindelse, der leveres tilbage til puljen, bærer intet med sig.
Gør row level security forespørgsler langsommere?
Det tilføjer et prædikat til hver plan, hvilket er reelt, men håndterbart. Løsningen er at lade hvert indeks lede med tenancy-kolonnen — (org_id, email) snarere end (email) — så prædikatet opfyldes af indekset i stedet for af et filter bagefter. Budgetér mere indeksarbejde, end du forventer.
Hvorfor ændrer en tilstandsløs protokol som MCP tenancy-designet?
Fordi der ikke findes nogen session på protokolniveau at hænge lejeren på. MCP-revisionen 2026-07-28 fjernede håndtrykket (SEP-2575) og Mcp-Session-Id-headeren (SEP-2567), så en forespørgsel bærer sine egne legitimationsoplysninger og kan lande på en hvilken som helst serverinstans. Kontekst per forespørgsel er ingen finesse i den verden; det er det eneste sted, lejergrænsen kan bo.
Kan man eksportere en enkelt lejer ud af et delt skema?
Ja — det er det udbytte, vi ikke planlagde. Hvert Munin-modul eksponerer et matchet eksport- og importpar, alle afgrænset af den samme org_id, så en agent kan gå igennem seks moduler og flytte en hel lejer til en anden instans. Med fem skemaer og fem forestillinger om en kontakt er den samme operation et migreringsprojekt.
Hvis du træffer det samme valg
- Én
contacts-tabel delt af hvert modul slår fem tabeller og et synkroniseringsjob — kunden er én person. - Behandl RLS som kontrakten, ikke sikkerhedsnettet. Hvis applikationen aldrig skriver
org_idi en WHERE-klausul, kan den aldrig glemme det. - Sæt lejerkonteksten per forespørgsel, ikke per session. Siden MCP-revisionen
2026-07-28droppede håndtrykket og sessionsheaderen, er der ingen protokolsession tilbage at lægge den i. - Lad hvert indeks lede med tenancy-kolonnen. RLS-prædikater lander i hver forespørgselsplan, så betal for dem én gang i indekset.
- At tilføje det sjette modul kostede én tabel og ét sæt værktøjer — intet nyt skema, intet synkroniseringsjob, ingen migrering mellem systemer.
- Det uplanlagte udbytte er portabilitet. Ét skema er det, der gør en eksport af en hel lejer til noget, en agent kan klare på en eftermiddag.
Politikkerne, indekserne og eksportværktøjerne kan alle læses i repoet, og dokumentationen dækker de værktøjer, hvert modul eksponerer.
Kunden er én person. Posten bør også være det.