MuninMunin
Logg innKom i gang gratis
Home/Journal/Ett databaseskjema for fem apper, ikke fem skjemaer.
Engineering · 6 min read

Ett databaseskjema for fem apper, ikke fem skjemaer.

Hvorfor vi la CRM, KB, CMS, utgående kommunikasjon, samtaler — og nå analyse — bak én Postgres, og hvordan row-level security bærer lasten. Tittelen sier fem fordi analyse kom etter at den ble skrevet.

A shallow cobalt blue moulded tray on a dark desk at night holding five identical small dark anodised blocks in one neat row.
Fem deler, ett brett. Ikke fem brett.

De fleste B2B-plattformer vokser gjennom oppkjøp: hver modul kommer med sitt eget skjema, sin egen autentiseringsmodell, sin egen forestilling om hva en «kontakt» er. Etter noen år spiser integrasjonslaget utviklingsteamet.

Munin tok det motsatte veddemålet. CRM, KB, CMS, utgående kommunikasjon og samtaler deler alle én Postgres-database, én identitetsmodell, én tenancy-kolonne og étt sett med org_id-avgrensede RLS-policyer. Dette innlegget handler om hvordan det faktisk er å bygge på det, og hvor det biter.

Hvorfor legge fem apper bak étt Postgres-skjema?

Fordi kundeposten er én post. En kontakt i CRM-et er den samme kontakten supportavdelingen snakker med, og den samme kontakten en kampanje retter seg mot. Munin kjører hver modul på én Postgres-database med nøyaktig én contacts-tabell, én org_id-kolonne og étt sett med row-level security-policyer. Å dele det opp i fem rader i fem skjemaer er ingen arkitektur — det er et synkroniseringsprosjekt som venter på å skje.

Dette er databasehalvdelen av et argument jeg har lagt fram andre steder: hvis appene skal eksponere seg som verktøy snarere enn som sider, bør de være enige om hva de beskriver. En agent som må forene tre versjoner av samme kunde, gjør nettopp det integrasjonsarbeidet vi prøvde å fjerne.

Så det gjør vi ikke. Det finnes nøyaktig én contacts-tabell, nøyaktig én org_id på den, nøyaktig étt sett RLS-policyer. CRM-modulen leser den. Samtalemodulen leser den. Modulen for utgående kommunikasjon leser den. De ser alle den samme raden, med de samme tillatelsene. Det er også grunnen til at CRM-et kan fylle seg selv ut fra supportsamtaler — agenten som leser en lukket tråd, ser på raden en utgående kampanje vil rette seg mot, ikke en kopi av den.

Hvordan bærer row-level security fler-leietakerskap?

RLS er kontrakten, ikke sikkerhetsnettet. Hver eneste spørring går gjennom Postgres' RLS-motor, så applikasjonskoden filtrerer aldri på org_id — den kan ikke, fordi spørringene ikke inneholder det. Applikasjonens eneste jobb er å sette leietakerkonteksten på sesjonen. Databasens jobb er å nekte å gjøre noe annet.

RLS har fått dårlig rykte fordi de fleste teamene som prøver det, skrur det på i etterkant. Behandlet som kontrakten fra første migrering fjerner det en hel kategori feil: applikasjonskoden kan ikke omgå grensen selv ved et uhell, fordi databasen selv håndhever policyen.

sqlformen på det
-- leietakergrensen, é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 hver indeks leder med den samme kolonnen
CREATE INDEX contacts_org_email_idx ON contacts (org_id, email);

-- applikasjonens hele bidrag til tenancy:
--   SET LOCAL munin.org_id = '...'
-- etter det er en glemt WHERE-klausul en feil, ikke et brudd.

Hvorfor endrer en tilstandsløs protokoll hvor tenancy bor?

Oppdatert august 2026: Ordet som gjør jobben i den kodesnutten, er LOCAL. Leietakerkonteksten etableres per forespørsel, fra den autentiserte kalleren, og rives ned med transaksjonen — den er aldri noe serveren husker om deg mellom kall. Da vi tok det valget, leste det som alminnelig hygiene. Det modnet til en bærende beslutning 28. juli 2026, da MCP-spesifikasjonen 2026-07-28 landet: SEP-2575 fjernet initialize/initialized-håndtrykket, og SEP-2567 fjernet Mcp-Session-Id-headeren sammen med sesjonen på protokollnivå den bar. Protokollversjon, klientinfo og klientkapasiteter reiser nå i _meta på hver forespørsel i stedet for å utveksles én gang ved tilkobling — endringsloggen er kortversjonen.

Den praktiske konsekvensen er verdt å si rett ut, for en protokoll uten egen sesjon skyver servere mot å gi modellen eksplisitte id-er å tre gjennom mellom kall. En post-id som lekker ut av én leietakers samtale og spilles av mot en annen, er en spørring som returnerer null rader. Ikke fordi applikasjonen fanget det — fordi databasen ikke kan bli stilt spørsmålet. En id i Munin er et navn, ikke en nøkkel.

Brakk den sjette modulen veddemålet?

Nei, og dette er det klareste beviset jeg har på at det lønte seg. Analyse kom etter at dette innlegget ble skrevet — polymorfe hendelser for sidevisninger og søk — og det havnet på den samme databasen, den samme org_id, de samme policyene, uten nytt skjema og uten synkroniseringsjobb. Å legge til en modul kostet en tabell og et sett verktøy, ikke et kvartal. Det er også det som gjør en sidevisning og en kontakt sammenkoblbare i én enkelt spørring, som er hele mekanismen bak å spørre hvilken artikkel som lukket avtalen.

Det gjør tittelen feil med én. Det er seks moduler nå, ikke fem. Jeg lar heller kvitteringen ligge enn å omnummerere den i stillhet.

Hva er ulempene med étt delt skjema?

Tre reelle, og ingen av dem er hypotetiske. Migreringer er globale, så en endring i kontaktskjemaet forplanter seg gjennom hver modul og må koordineres. Feilsøking i RLS-policyer er en ferdighet i seg selv — når en spørring ikke returnerer noe, sjekk sesjonens GUC før du sjekker noe annet. Og RLS-predikater havner i hver spørringsplan, så du betaler for org_id-ledede indekser.

Hver av dem har en form det er verdt å kjenne før du forplikter deg:

  1. Globale migreringer. Det finnes ikke noe som heter å sende en kontaktskjemaendring til én modul. Hver modul som leser tabellen, må være klar i samme utrulling, så koordineringskostnaden skalerer med antall moduler snarere enn med størrelsen på endringen.
  2. GUC-først feilsøking. RLS' feilmodus er stillhet — en korrekt spørring som returnerer null rader fordi sesjonskonteksten aldri ble satt. Det ser ut som manglende data snarere enn en tilgangsfeil, og det sender folk på jakt på feil sted i en time.
  3. Indeksdisiplin. En indeks som ikke leder med org_id, er en indeks policy-predikatet ikke kan bruke. Vi har skrevet om flere av dem enn vi hadde planlagt, og regelen er nå kjedelig: tenancy-kolonnen først, alltid.

Ingen av disse er dealbreakers, og hver av dem er en kostnad du betaler én gang i bytte mot en kostnad du ellers ville betalt kontinuerlig. Fem skjemaer betyr fem migreringer, fem utrullingsrekkefølger, fem sett med legitimasjon, og en synkroniseringsjobb mellom hvert par som må holde seg korrekt for alltid. Ett skjema betyr at en migrering er én hendelse med én tilbakerulling, at en spørring som kobler en samtale til en kontakt til en kampanje bare er en spørring, og at å legge til en sjette modul kostet en tabell og et sett verktøy snarere enn et kvartal. Indeksarbeidet er reelt, og det tar slutt. Integrasjonsarbeidet det erstatter, ville aldri gjort det.

Ville vi tatt det samme valget igjen?

Hele veien. Synkroniseringsproblemene vi ikke har, er verdt migreringskoordineringen vi har.

Det finnes en annen gevinst jeg ikke forutsa da vi startet, nemlig at étt skjema med én tenancy-kolonne gjør utgangen billig. Hver modul får et symmetrisk eksport-/importpar, og en agent kan gå gjennom alle sammen for å flytte en hel leietaker et annet sted — som er den delen av forretningsmodellen jeg ville forsvart hardest. Det kan du ikke tilby troverdig oppe på fem skjemaer. Du kan knapt tilby det i det hele tatt.

Ofte stilte spørsmål

Kan Postgres row-level security håndtere fler-leietakerskap alene? Ja, hvis det er kontrakten snarere enn en ettertanke. En policy på hver leietakeravgrenset tabell, sjekket med både USING og WITH CHECK, betyr at både lesing og skriving begrenses av databasen. Applikasjonen legger aldri org_id i en WHERE-klausul, så den kan aldri glemme det.

Bør jeg bruke én database per leietaker eller row-level security? Database per leietaker gir deg den hardeste isolasjonen og den verste migreringshistorien — hver skjemaendring er en flateoperasjon. RLS i én database gir deg én migrering og legger byrden på policy-korrekthet og indeksdesign. Vi valgte RLS fordi vi leverer moduler ofte og ville at det å legge til én skulle koste en tabell, ikke et kvartal.

Hvordan setter du leietakerkonteksten for RLS i Postgres? SET LOCAL munin.org_id = '...' ved starten av transaksjonen, avledet fra den autentiserte kalleren, revet ned når transaksjonen avsluttes. LOCAL er det viktige ordet: konteksten overlever aldri forespørselen, så en tilkobling som leveres tilbake til poolen, bærer ingenting med seg.

Gjør row-level security spørringer tregere? Det legger et predikat til hver plan, noe som er reelt, men håndterbart. Løsningen er å la hver indeks lede med tenancy-kolonnen — (org_id, email) snarere enn (email) — slik at predikatet tilfredsstilles av indeksen i stedet for av et filter i etterkant. Budsjetter mer indeksarbeid enn du venter.

Hvorfor endrer en tilstandsløs protokoll som MCP tenancy-designet? Fordi det ikke finnes noen sesjon på protokollnivå å henge leietakeren på. MCP-revisjonen 2026-07-28 fjernet håndtrykket (SEP-2575) og Mcp-Session-Id-headeren (SEP-2567), så en forespørsel bærer sin egen legitimasjon og kan lande på hvilken som helst serverinstans. Kontekst per forespørsel er ingen finesse i den verdenen; det er det eneste stedet leietakergrensen kan bo.

Kan du eksportere én enkelt leietaker ut av et delt skjema? Ja — det er gevinsten vi ikke planla. Hver Munin-modul eksponerer et sammenkoblet eksport- og importpar, alle avgrenset av den samme org_id, så en agent kan gå gjennom seks moduler og flytte en hel leietaker til en annen instans. Med fem skjemaer og fem forestillinger om en kontakt er den samme operasjonen et migreringsprosjekt.

Hvis du tar det samme valget

  • Én contacts-tabell delt av hver modul slår fem tabeller og en synkroniseringsjobb — kunden er én person.
  • Behandle RLS som kontrakten, ikke sikkerhetsnettet. Hvis applikasjonen aldri skriver org_id inn i en WHERE-klausul, kan den aldri glemme det.
  • Sett leietakerkonteksten per forespørsel, ikke per sesjon. Siden MCP-revisjonen 2026-07-28 droppet håndtrykket og sesjonsheaderen, finnes det ingen protokollsesjon igjen å legge den i.
  • La hver indeks lede med tenancy-kolonnen. RLS-predikater havner i hver spørringsplan, så betal for dem én gang i indeksen.
  • Å legge til den sjette modulen kostet én tabell og étt sett verktøy — intet nytt skjema, ingen synkroniseringsjobb, ingen migrering mellom systemer.
  • Den uplanlagte gevinsten er portabilitet. Ett skjema er det som gjør en eksport av en hel leietaker til noe en agent kan gjøre på en ettermiddag.

Policyene, indeksene og eksportverktøyene er alle lesbare i repoet, og dokumentasjonen dekker verktøyene hver modul eksponerer.

Kunden er én person. Posten bør også være det.

Kjell Rune Monsø, gründer.