[{"data":1,"prerenderedAt":1474},["ShallowReactive",2],{"post-de-supabase-rls-multi-tenant":3},{"post":4,"translated":317,"related":1462,"translations":1469},{"id":5,"title":6,"body":7,"cover":1438,"date":1439,"description":1440,"extension":1441,"key":1442,"keywords":1443,"meta":1449,"navigation":317,"path":1450,"projects":1451,"seo":1453,"slug":1442,"stem":1454,"tags":1455,"updated":1459,"__hash__":1460,"minutes":259,"words":1461},"blog/blog/de/supabase-rls-multi-tenant.md","Supabase RLS für Multi-Tenant-Apps: ein Header, zwei Regeln",{"type":8,"value":9,"toc":1428},"minimark",[10,19,24,59,68,77,81,88,95,99,106,165,175,574,577,600,603,607,614,818,835,850,854,857,1008,1013,1037,1048,1065,1069,1085,1088,1308,1311,1348,1359,1366,1370,1384,1388,1407,1410,1424],[11,12,13,14,18],"p",{},"In MoneyKoi ist ein Haushaltsbuch ein Mandant: Ein Haushalt teilt sich ein Buch, und eine Person kann in mehreren Büchern Mitglied sein. Für diese Multi-Tenant-Struktur nutze ich Supabase RLS (Row-Level-Security) mit einem Pre-Request-Hook: Vor jeder Anfrage prüft er den Header ",[15,16,17],"code",{},"X-Tenant-Id"," gegen die Mitgliedschaften und merkt sich das aktive Buch. Gelesen wird dann nur im aktiven Buch, geschrieben gegen das Buch der jeweiligen Zeile. So bleiben Listen schnell, und offline gesammelte Buchungen gehen auch durch, wenn inzwischen ein anderes Buch offen ist.",[20,21,23],"h2",{"id":22},"ein-buch-ist-ein-mandant","Ein Buch ist ein Mandant",[11,25,26,27,30,31,34,35,38,39,42,43,46,47,50,51,54,55,58],{},"Das Datenmodell ist schlicht. Es gibt ",[15,28,29],{},"tenant"," (das Buch), ",[15,32,33],{},"profile"," (die Person, global) und ",[15,36,37],{},"tenant_member"," mit einer Rolle: ",[15,40,41],{},"owner",", ",[15,44,45],{},"admin"," oder ",[15,48,49],{},"member",". Jede Datentabelle – Konten, Kategorien, Buchungen, Belege – hat eine Spalte ",[15,52,53],{},"tenant_id",". Bei der Registrierung legt ein Trigger auf ",[15,56,57],{},"auth.users"," das Profil, ein persönliches Buch und die Owner-Mitgliedschaft an.",[11,60,61,62,67],{},"Eine Person kann bis zu drei eigene Bücher haben und per Link in weitere eingeladen werden (",[63,64,66],"a",{"href":65},"/projects/moneykoi","zum Projekt","). Am Handy und im Browser wechselt man zwischen ihnen mit einem Tipp.",[11,69,70],{},[71,72],"img",{"alt":73,"height":74,"src":75,"width":76},"Buchauswahl in MoneyKoi im Demo-Modus: ein privates Buch, ausgewählt, und ein geteiltes Buch, in dem die Person Owner ist",711,"/images/blog/supabase-rls-multi-tenant/books.webp",320,[20,78,80],{"id":79},"warum-die-tenant-id-nicht-im-jwt-steht","Warum die Tenant-ID nicht im JWT steht",[11,82,83,84,87],{},"Ein verbreiteter Weg in Supabase ist, die Tenant-ID als Claim ins JWT zu legen und in den Policies ",[15,85,86],{},"auth.jwt()"," zu lesen. Bei einem Mandanten pro Person passt das. Bei mehreren Büchern pro Person bräuchte jeder Wechsel ein neues Token, und das Token kennt immer nur ein Buch.",[11,89,90,91,94],{},"MoneyKoi legt beim Registrieren zwar das eigene Buch in ",[15,92,93],{},"app_metadata"," ab, aber welches Buch gerade offen ist, schickt der Client bei jedem Request als Header mit. Der Header ist ein Wunsch, kein Beweis. Ob er gilt, entscheidet der Server.",[20,96,98],{"id":97},"der-pre-request-hook","Der Pre-Request-Hook",[11,100,101,102,105],{},"PostgREST kann vor jeder Anfrage eine Funktion ausführen. Registriert wird sie an der Rolle ",[15,103,104],{},"authenticator",":",[107,108,113],"pre",{"className":109,"code":110,"language":111,"meta":112,"style":112},"language-sql shiki shiki-themes github-dark-default","ALTER ROLE authenticator SET pgrst.db_pre_request TO 'public.db_pre_request';\nNOTIFY pgrst, 'reload config';\n","sql","",[15,114,115,154],{"__ignoreMap":112},[116,117,120,124,127,131,134,138,141,144,147,151],"span",{"class":118,"line":119},"line",1,[116,121,123],{"class":122},"suJrU","ALTER",[116,125,126],{"class":122}," ROLE",[116,128,130],{"class":129},"sZEs4"," authenticator ",[116,132,133],{"class":122},"SET",[116,135,137],{"class":136},"sFSAA"," pgrst",[116,139,140],{"class":129},".",[116,142,143],{"class":136},"db_pre_request",[116,145,146],{"class":122}," TO",[116,148,150],{"class":149},"s9uIt"," 'public.db_pre_request'",[116,152,153],{"class":129},";\n",[116,155,157,160,163],{"class":118,"line":156},2,[116,158,159],{"class":129},"NOTIFY pgrst, ",[116,161,162],{"class":149},"'reload config'",[116,164,153],{"class":129},[11,166,167,168,171,172,174],{},"Die Funktion ermittelt das Profil aus ",[15,169,170],{},"auth.uid()",", liest den Header und prüft ihn gegen ",[15,173,37],{},". Ist der Header leer oder ist die Person nicht (mehr) Mitglied, fällt sie auf das zuletzt gewählte Buch zurück und danach auf das eigene. Gekürzt:",[107,176,178],{"className":109,"code":177,"language":111,"meta":112,"style":112},"CREATE OR REPLACE FUNCTION public.db_pre_request()\n  RETURNS void LANGUAGE plpgsql STABLE SECURITY DEFINER\n  SET search_path = public\nAS $$\nDECLARE\n  v_profile uuid;\n  v_wanted  uuid;\n  v_active  uuid;\nBEGIN\n  SELECT id INTO v_profile FROM public.profile WHERE user_id = auth.uid();\n\n  BEGIN\n    v_wanted := NULLIF(current_setting('request.headers', true)::json ->> 'x-tenant-id', '')::uuid;\n  EXCEPTION WHEN others THEN\n    v_wanted := NULL;  -- not a uuid\n  END;\n\n  -- Honour the header only for a live membership.\n  SELECT tm.tenant_id INTO v_active\n  FROM public.tenant_member tm\n  WHERE tm.profile_id = v_profile AND tm.tenant_id = v_wanted AND tm.deleted_at IS NULL;\n\n  -- Fallbacks omitted: the last chosen book, then the user's own.\n\n  PERFORM set_config('request.tenant_id', COALESCE(v_active::text, ''), false);\n  PERFORM set_config('request.profile_id', COALESCE(v_profile::text, ''), false);\nEND;\n$$;\n",[15,179,180,195,215,230,239,245,251,257,263,269,312,319,325,362,377,394,402,407,413,431,446,494,499,505,510,537,560,568],{"__ignoreMap":112},[116,181,182,185,188,192],{"class":118,"line":119},[116,183,184],{"class":122},"CREATE OR REPLACE",[116,186,187],{"class":122}," FUNCTION",[116,189,191],{"class":190},"sc3cj"," public",[116,193,194],{"class":129},".db_pre_request()\n",[116,196,197,200,203,206,209,212],{"class":118,"line":156},[116,198,199],{"class":122},"  RETURNS",[116,201,202],{"class":129}," void ",[116,204,205],{"class":122},"LANGUAGE",[116,207,208],{"class":129}," plpgsql STABLE ",[116,210,211],{"class":122},"SECURITY",[116,213,214],{"class":129}," DEFINER\n",[116,216,218,221,224,227],{"class":118,"line":217},3,[116,219,220],{"class":122},"  SET",[116,222,223],{"class":129}," search_path ",[116,225,226],{"class":122},"=",[116,228,229],{"class":129}," public\n",[116,231,233,236],{"class":118,"line":232},4,[116,234,235],{"class":122},"AS",[116,237,238],{"class":129}," $$\n",[116,240,242],{"class":118,"line":241},5,[116,243,244],{"class":122},"DECLARE\n",[116,246,248],{"class":118,"line":247},6,[116,249,250],{"class":129},"  v_profile uuid;\n",[116,252,254],{"class":118,"line":253},7,[116,255,256],{"class":129},"  v_wanted  uuid;\n",[116,258,260],{"class":118,"line":259},8,[116,261,262],{"class":129},"  v_active  uuid;\n",[116,264,266],{"class":118,"line":265},9,[116,267,268],{"class":122},"BEGIN\n",[116,270,272,275,278,281,284,287,289,291,293,296,299,301,304,306,309],{"class":118,"line":271},10,[116,273,274],{"class":122},"  SELECT",[116,276,277],{"class":129}," id ",[116,279,280],{"class":122},"INTO",[116,282,283],{"class":129}," v_profile ",[116,285,286],{"class":122},"FROM",[116,288,191],{"class":136},[116,290,140],{"class":129},[116,292,33],{"class":136},[116,294,295],{"class":122}," WHERE",[116,297,298],{"class":129}," user_id ",[116,300,226],{"class":122},[116,302,303],{"class":136}," auth",[116,305,140],{"class":129},[116,307,308],{"class":136},"uid",[116,310,311],{"class":129},"();\n",[116,313,315],{"class":118,"line":314},11,[116,316,318],{"emptyLinePlaceholder":317},true,"\n",[116,320,322],{"class":118,"line":321},12,[116,323,324],{"class":122},"  BEGIN\n",[116,326,328,331,333,336,339,342,345,348,351,354,356,359],{"class":118,"line":327},13,[116,329,330],{"class":129},"    v_wanted :",[116,332,226],{"class":122},[116,334,335],{"class":136}," NULLIF",[116,337,338],{"class":129},"(current_setting(",[116,340,341],{"class":149},"'request.headers'",[116,343,344],{"class":129},", true)::",[116,346,347],{"class":122},"json",[116,349,350],{"class":122}," ->>",[116,352,353],{"class":149}," 'x-tenant-id'",[116,355,42],{"class":129},[116,357,358],{"class":149},"''",[116,360,361],{"class":129},")::uuid;\n",[116,363,365,368,371,374],{"class":118,"line":364},14,[116,366,367],{"class":129},"  EXCEPTION ",[116,369,370],{"class":122},"WHEN",[116,372,373],{"class":129}," others ",[116,375,376],{"class":122},"THEN\n",[116,378,380,382,384,387,390],{"class":118,"line":379},15,[116,381,330],{"class":129},[116,383,226],{"class":122},[116,385,386],{"class":122}," NULL",[116,388,389],{"class":129},";  ",[116,391,393],{"class":392},"sH3jZ","-- not a uuid\n",[116,395,397,400],{"class":118,"line":396},16,[116,398,399],{"class":122},"  END",[116,401,153],{"class":129},[116,403,405],{"class":118,"line":404},17,[116,406,318],{"emptyLinePlaceholder":317},[116,408,410],{"class":118,"line":409},18,[116,411,412],{"class":392},"  -- Honour the header only for a live membership.\n",[116,414,416,418,421,423,425,428],{"class":118,"line":415},19,[116,417,274],{"class":122},[116,419,420],{"class":136}," tm",[116,422,140],{"class":129},[116,424,53],{"class":136},[116,426,427],{"class":122}," INTO",[116,429,430],{"class":129}," v_active\n",[116,432,434,437,439,441,443],{"class":118,"line":433},20,[116,435,436],{"class":122},"  FROM",[116,438,191],{"class":136},[116,440,140],{"class":129},[116,442,37],{"class":136},[116,444,445],{"class":129}," tm\n",[116,447,449,452,454,456,459,462,464,467,469,471,473,475,478,480,482,484,487,490,492],{"class":118,"line":448},21,[116,450,451],{"class":122},"  WHERE",[116,453,420],{"class":136},[116,455,140],{"class":129},[116,457,458],{"class":136},"profile_id",[116,460,461],{"class":122}," =",[116,463,283],{"class":129},[116,465,466],{"class":122},"AND",[116,468,420],{"class":136},[116,470,140],{"class":129},[116,472,53],{"class":136},[116,474,461],{"class":122},[116,476,477],{"class":129}," v_wanted ",[116,479,466],{"class":122},[116,481,420],{"class":136},[116,483,140],{"class":129},[116,485,486],{"class":136},"deleted_at",[116,488,489],{"class":122}," IS",[116,491,386],{"class":122},[116,493,153],{"class":129},[116,495,497],{"class":118,"line":496},22,[116,498,318],{"emptyLinePlaceholder":317},[116,500,502],{"class":118,"line":501},23,[116,503,504],{"class":392},"  -- Fallbacks omitted: the last chosen book, then the user's own.\n",[116,506,508],{"class":118,"line":507},24,[116,509,318],{"emptyLinePlaceholder":317},[116,511,513,516,519,521,524,527,530,532,534],{"class":118,"line":512},25,[116,514,515],{"class":129},"  PERFORM set_config(",[116,517,518],{"class":149},"'request.tenant_id'",[116,520,42],{"class":129},[116,522,523],{"class":136},"COALESCE",[116,525,526],{"class":129},"(v_active::",[116,528,529],{"class":122},"text",[116,531,42],{"class":129},[116,533,358],{"class":149},[116,535,536],{"class":129},"), false);\n",[116,538,540,542,545,547,549,552,554,556,558],{"class":118,"line":539},26,[116,541,515],{"class":129},[116,543,544],{"class":149},"'request.profile_id'",[116,546,42],{"class":129},[116,548,523],{"class":136},[116,550,551],{"class":129},"(v_profile::",[116,553,529],{"class":122},[116,555,42],{"class":129},[116,557,358],{"class":149},[116,559,536],{"class":129},[116,561,563,566],{"class":118,"line":562},27,[116,564,565],{"class":122},"END",[116,567,153],{"class":129},[116,569,571],{"class":118,"line":570},28,[116,572,573],{"class":129},"$$;\n",[11,575,576],{},"Drei Details daran:",[578,579,580,584,593],"ul",{},[581,582,583],"li",{},"Ein ungültiger Header führt zu keinem Fehler, sondern zum Fallback. Die Antwort sagt damit auch nichts darüber, ob es das fremde Buch gibt.",[581,585,586,589,590,140],{},[15,587,588],{},"set_config(…, false)"," gilt für die ganze Sitzung, nicht nur für die Transaktion. Der Hook läuft vor jeder Anfrage und überschreibt die Werte, ein alter Wert bleibt also nicht stehen. Sauberer wäre ",[15,591,592],{},"true",[581,594,595,596,599],{},"Die echte Funktion setzt zusätzlich die Rolle im aktiven Buch (",[15,597,598],{},"request.role",").",[11,601,602],{},"Weil das Buch bei jedem Request mitkommt, stimmen Server und App auch dann überein, wenn der Wechsel offline passiert ist und der Server nie davon erfahren hat.",[20,604,606],{"id":605},"hilfsfunktionen-in-einem-eigenen-schema","Hilfsfunktionen in einem eigenen Schema",[11,608,609,610,613],{},"Die Policies lesen diese Werte über kleine Funktionen im Schema ",[15,611,612],{},"internal",", das nicht über die API erreichbar ist:",[107,615,617],{"className":109,"code":616,"language":111,"meta":112,"style":112},"CREATE FUNCTION internal.current_tenant() RETURNS uuid\n  LANGUAGE sql STABLE SET search_path = ''\nAS $$ SELECT NULLIF(current_setting('request.tenant_id', true), '')::uuid $$;\n\nCREATE FUNCTION internal.is_member_of(p_tenant uuid) RETURNS boolean\n  LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''\nAS $$\n  SELECT EXISTS (\n    SELECT 1 FROM public.tenant_member tm\n    WHERE tm.tenant_id = p_tenant\n      AND tm.profile_id = internal.current_profile()\n      AND tm.deleted_at IS NULL)\n$$;\n",[15,618,619,638,658,682,686,702,723,729,739,758,774,797,814],{"__ignoreMap":112},[116,620,621,624,626,629,632,635],{"class":118,"line":119},[116,622,623],{"class":122},"CREATE",[116,625,187],{"class":122},[116,627,628],{"class":190}," internal",[116,630,631],{"class":129},".current_tenant() ",[116,633,634],{"class":122},"RETURNS",[116,636,637],{"class":129}," uuid\n",[116,639,640,643,646,649,651,653,655],{"class":118,"line":156},[116,641,642],{"class":122},"  LANGUAGE",[116,644,645],{"class":122}," sql",[116,647,648],{"class":129}," STABLE ",[116,650,133],{"class":122},[116,652,223],{"class":129},[116,654,226],{"class":122},[116,656,657],{"class":149}," ''\n",[116,659,660,662,665,668,670,672,674,677,679],{"class":118,"line":217},[116,661,235],{"class":122},[116,663,664],{"class":129}," $$ ",[116,666,667],{"class":122},"SELECT",[116,669,335],{"class":136},[116,671,338],{"class":129},[116,673,518],{"class":149},[116,675,676],{"class":129},", true), ",[116,678,358],{"class":149},[116,680,681],{"class":129},")::uuid $$;\n",[116,683,684],{"class":118,"line":232},[116,685,318],{"emptyLinePlaceholder":317},[116,687,688,690,692,694,697,699],{"class":118,"line":241},[116,689,623],{"class":122},[116,691,187],{"class":122},[116,693,628],{"class":190},[116,695,696],{"class":129},".is_member_of(p_tenant uuid) ",[116,698,634],{"class":122},[116,700,701],{"class":122}," boolean\n",[116,703,704,706,708,710,712,715,717,719,721],{"class":118,"line":247},[116,705,642],{"class":122},[116,707,645],{"class":122},[116,709,648],{"class":129},[116,711,211],{"class":122},[116,713,714],{"class":129}," DEFINER ",[116,716,133],{"class":122},[116,718,223],{"class":129},[116,720,226],{"class":122},[116,722,657],{"class":149},[116,724,725,727],{"class":118,"line":253},[116,726,235],{"class":122},[116,728,238],{"class":129},[116,730,731,733,736],{"class":118,"line":259},[116,732,274],{"class":122},[116,734,735],{"class":122}," EXISTS",[116,737,738],{"class":129}," (\n",[116,740,741,744,747,750,752,754,756],{"class":118,"line":265},[116,742,743],{"class":122},"    SELECT",[116,745,746],{"class":136}," 1",[116,748,749],{"class":122}," FROM",[116,751,191],{"class":136},[116,753,140],{"class":129},[116,755,37],{"class":136},[116,757,445],{"class":129},[116,759,760,763,765,767,769,771],{"class":118,"line":271},[116,761,762],{"class":122},"    WHERE",[116,764,420],{"class":136},[116,766,140],{"class":129},[116,768,53],{"class":136},[116,770,461],{"class":122},[116,772,773],{"class":129}," p_tenant\n",[116,775,776,779,781,783,785,787,789,791,794],{"class":118,"line":314},[116,777,778],{"class":122},"      AND",[116,780,420],{"class":136},[116,782,140],{"class":129},[116,784,458],{"class":136},[116,786,461],{"class":122},[116,788,628],{"class":136},[116,790,140],{"class":129},[116,792,793],{"class":136},"current_profile",[116,795,796],{"class":129},"()\n",[116,798,799,801,803,805,807,809,811],{"class":118,"line":321},[116,800,778],{"class":122},[116,802,420],{"class":136},[116,804,140],{"class":129},[116,806,486],{"class":136},[116,808,489],{"class":122},[116,810,386],{"class":122},[116,812,813],{"class":129},")\n",[116,815,816],{"class":118,"line":327},[116,817,573],{"class":129},[11,819,820,823,824,827,828,830,831,834],{},[15,821,822],{},"is_member_of"," läuft als ",[15,825,826],{},"SECURITY DEFINER",", damit die Abfrage auf ",[15,829,37],{}," nicht selbst wieder durch die RLS dieser Tabelle muss. Das vermeidet Rekursion und spart Zeit. ",[15,832,833],{},"SET search_path = ''"," heißt, dass alles voll qualifiziert sein muss und kein anderes Schema eine gleichnamige Funktion unterschieben kann. Bei Funktionen, auf denen die ganze Mandantentrennung hängt, ist das Pflicht.",[11,836,837,838,841,842,845,846,849],{},"Die echte ",[15,839,840],{},"current_tenant()"," fällt nach ",[15,843,844],{},"request.tenant_id"," noch auf ",[15,847,848],{},"app_metadata.tenant_id"," im JWT zurück. Das wird bei Realtime wichtig.",[20,851,853],{"id":852},"supabase-rls-asymmetrisch-lesen-im-aktiven-buch-schreiben-im-buch-der-zeile","Supabase RLS asymmetrisch: Lesen im aktiven Buch, Schreiben im Buch der Zeile",[11,855,856],{},"Jetzt der Kern. Die Policies für Buchungen sahen zunächst so aus:",[107,858,860],{"className":109,"code":859,"language":111,"meta":112,"style":112},"CREATE POLICY booking_select ON public.booking FOR SELECT\n  USING (internal.is_current_tenant(tenant_id));\n\nCREATE POLICY booking_insert ON public.booking FOR INSERT\n  WITH CHECK (internal.can_write_booking(tenant_id));\n\nCREATE POLICY booking_update ON public.booking FOR UPDATE\n  USING (internal.can_write_booking(tenant_id))\n  WITH CHECK (internal.can_write_booking(tenant_id));\n",[15,861,862,888,906,910,932,951,955,977,992],{"__ignoreMap":112},[116,863,864,866,869,872,875,877,879,882,885],{"class":118,"line":119},[116,865,623],{"class":122},[116,867,868],{"class":122}," POLICY",[116,870,871],{"class":129}," booking_select ",[116,873,874],{"class":122},"ON",[116,876,191],{"class":136},[116,878,140],{"class":129},[116,880,881],{"class":136},"booking",[116,883,884],{"class":122}," FOR",[116,886,887],{"class":122}," SELECT\n",[116,889,890,893,896,898,900,903],{"class":118,"line":156},[116,891,892],{"class":122},"  USING",[116,894,895],{"class":129}," (",[116,897,612],{"class":136},[116,899,140],{"class":129},[116,901,902],{"class":136},"is_current_tenant",[116,904,905],{"class":129},"(tenant_id));\n",[116,907,908],{"class":118,"line":217},[116,909,318],{"emptyLinePlaceholder":317},[116,911,912,914,916,919,921,923,925,927,929],{"class":118,"line":232},[116,913,623],{"class":122},[116,915,868],{"class":122},[116,917,918],{"class":129}," booking_insert ",[116,920,874],{"class":122},[116,922,191],{"class":136},[116,924,140],{"class":129},[116,926,881],{"class":136},[116,928,884],{"class":122},[116,930,931],{"class":122}," INSERT\n",[116,933,934,937,940,942,944,946,949],{"class":118,"line":241},[116,935,936],{"class":122},"  WITH",[116,938,939],{"class":122}," CHECK",[116,941,895],{"class":129},[116,943,612],{"class":136},[116,945,140],{"class":129},[116,947,948],{"class":136},"can_write_booking",[116,950,905],{"class":129},[116,952,953],{"class":118,"line":247},[116,954,318],{"emptyLinePlaceholder":317},[116,956,957,959,961,964,966,968,970,972,974],{"class":118,"line":253},[116,958,623],{"class":122},[116,960,868],{"class":122},[116,962,963],{"class":129}," booking_update ",[116,965,874],{"class":122},[116,967,191],{"class":136},[116,969,140],{"class":129},[116,971,881],{"class":136},[116,973,884],{"class":122},[116,975,976],{"class":122}," UPDATE\n",[116,978,979,981,983,985,987,989],{"class":118,"line":259},[116,980,892],{"class":122},[116,982,895],{"class":129},[116,984,612],{"class":136},[116,986,140],{"class":129},[116,988,948],{"class":136},[116,990,991],{"class":129},"(tenant_id))\n",[116,993,994,996,998,1000,1002,1004,1006],{"class":118,"line":265},[116,995,936],{"class":122},[116,997,939],{"class":122},[116,999,895],{"class":129},[116,1001,612],{"class":136},[116,1003,140],{"class":129},[116,1005,948],{"class":136},[116,1007,905],{"class":129},[11,1009,1010,1012],{},[15,1011,948],{}," prüft die Mitgliedschaft im Buch der Zeile, nicht im aktiven Buch. Warum zwei verschiedene Regeln?",[578,1014,1015,1026],{},[581,1016,1017,1021,1022,1025],{},[1018,1019,1020],"strong",{},"Lesen eng."," Listen, Dashboards und Summen arbeiten immer in einem Buch. ",[15,1023,1024],{},"tenant_id = \u003Cein Wert>"," passt direkt auf einen Index, und auch eine Abfrage ohne Filter liefert nur das offene Buch.",[581,1027,1028,1031,1032,1036],{},[1018,1029,1030],{},"Schreiben gegen die Zeile."," Die App schreibt offline in eine Outbox (",[63,1033,1035],{"href":1034},"/blog/offline-first-flutter-supabase","wie die funktioniert","). Wer im Buch B drei Buchungen erfasst, in Buch A wechselt und erst dann wieder Netz hat, hätte mit der Regel „nur im aktiven Buch“ drei abgelehnte Buchungen. Mit der Mitgliedschaftsregel gehen sie durch.",[11,1038,1039,1040,1043,1044,1047],{},"Dazu kommt die Rolle. Alltägliches – Buchungen, Belege, Tags, Einkaufsliste – darf jedes Mitglied schreiben. Die Struktur des Buchs – Konten, Kategorien, Sparziele, Regeln – nur Owner und Admin. Dafür gibt es eine zweite Funktion, ",[15,1041,1042],{},"can_write",", die ",[15,1045,1046],{},"role_in(tenant_id) IN ('owner', 'admin')"," prüft.",[11,1049,1050,1051,1054,1055,1061,1062,1064],{},"Ganz ohne den passenden Header geht es trotzdem nicht. Postgres wendet bei ",[15,1052,1053],{},"RETURNING"," auch die SELECT-Policy auf die zurückgegebenen Zeilen an (",[63,1056,1060],{"href":1057,"rel":1058},"https://www.postgresql.org/docs/current/sql-createpolicy.html",[1059],"nofollow","CREATE POLICY in der Postgres-Doku","), und die App braucht die Zeile aus der Antwort, weil darin die Zeitstempel des Servers stehen. Deshalb trägt jedes Outbox-Element die ID seines Buchs und wird mit genau diesem Buch als ",[15,1063,17],{}," gesendet. Die Schreibregel entscheidet, ob geschrieben werden darf; der Header sorgt dafür, dass die Antwort lesbar ist.",[20,1066,1068],{"id":1067},"realtime-kennt-keinen-header","Realtime kennt keinen Header",[11,1070,1071,1072,1075,1076,1078,1079,1081,1082,1084],{},"Der Hook läuft vor jeder PostgREST-Anfrage, aber nicht für Realtime. Supabase prüft ",[15,1073,1074],{},"postgres_changes","-Events gegen die SELECT-Policy, nur mit dem JWT des Abonnenten. Ohne ",[15,1077,844],{}," fiel ",[15,1080,840],{}," auf ",[15,1083,848],{}," zurück, also immer auf das eigene Buch. Mitglieder eines geteilten Buchs bekamen deshalb keine Events dafür, und ihr Handy holte die Änderungen erst beim nächsten Pull. Aufgefallen ist mir das im Oktober 2026.",[11,1086,1087],{},"Die Lösung ist eine Funktion, die je nach Weg etwas anderes liefert:",[107,1089,1091],{"className":109,"code":1090,"language":111,"meta":112,"style":112},"CREATE FUNCTION internal.readable_tenant_ids() RETURNS uuid[]\n  LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, internal, pg_temp\nAS $$\n  SELECT CASE\n    -- REST: the hook set an active book\n    WHEN NULLIF(current_setting('request.tenant_id', true), '') IS NOT NULL\n      THEN ARRAY[internal.current_tenant()]\n    -- Realtime: every book the caller belongs to\n    ELSE ARRAY(SELECT tm.tenant_id FROM public.tenant_member tm\n               WHERE tm.profile_id = internal.current_profile()\n                 AND tm.deleted_at IS NULL)\n  END\n$$;\n\nCREATE POLICY booking_select ON public.booking FOR SELECT\n  USING (tenant_id = ANY ((SELECT internal.readable_tenant_ids())::uuid[]));\n",[15,1092,1093,1109,1130,1136,1143,1148,1169,1180,1185,1213,1234,1251,1256,1260,1264,1284],{"__ignoreMap":112},[116,1094,1095,1097,1099,1101,1104,1106],{"class":118,"line":119},[116,1096,623],{"class":122},[116,1098,187],{"class":122},[116,1100,628],{"class":190},[116,1102,1103],{"class":129},".readable_tenant_ids() ",[116,1105,634],{"class":122},[116,1107,1108],{"class":129}," uuid[]\n",[116,1110,1111,1113,1115,1117,1119,1121,1123,1125,1127],{"class":118,"line":156},[116,1112,642],{"class":122},[116,1114,645],{"class":122},[116,1116,648],{"class":129},[116,1118,211],{"class":122},[116,1120,714],{"class":129},[116,1122,133],{"class":122},[116,1124,223],{"class":129},[116,1126,226],{"class":122},[116,1128,1129],{"class":129}," public, internal, pg_temp\n",[116,1131,1132,1134],{"class":118,"line":217},[116,1133,235],{"class":122},[116,1135,238],{"class":129},[116,1137,1138,1140],{"class":118,"line":232},[116,1139,274],{"class":122},[116,1141,1142],{"class":122}," CASE\n",[116,1144,1145],{"class":118,"line":241},[116,1146,1147],{"class":392},"    -- REST: the hook set an active book\n",[116,1149,1150,1153,1155,1157,1159,1161,1163,1166],{"class":118,"line":247},[116,1151,1152],{"class":122},"    WHEN",[116,1154,335],{"class":136},[116,1156,338],{"class":129},[116,1158,518],{"class":149},[116,1160,676],{"class":129},[116,1162,358],{"class":149},[116,1164,1165],{"class":129},") ",[116,1167,1168],{"class":122},"IS NOT NULL\n",[116,1170,1171,1174,1177],{"class":118,"line":253},[116,1172,1173],{"class":122},"      THEN",[116,1175,1176],{"class":122}," ARRAY",[116,1178,1179],{"class":129},"[internal.current_tenant()]\n",[116,1181,1182],{"class":118,"line":259},[116,1183,1184],{"class":392},"    -- Realtime: every book the caller belongs to\n",[116,1186,1187,1190,1192,1195,1197,1199,1201,1203,1205,1207,1209,1211],{"class":118,"line":265},[116,1188,1189],{"class":122},"    ELSE",[116,1191,1176],{"class":122},[116,1193,1194],{"class":129},"(",[116,1196,667],{"class":122},[116,1198,420],{"class":136},[116,1200,140],{"class":129},[116,1202,53],{"class":136},[116,1204,749],{"class":122},[116,1206,191],{"class":136},[116,1208,140],{"class":129},[116,1210,37],{"class":136},[116,1212,445],{"class":129},[116,1214,1215,1218,1220,1222,1224,1226,1228,1230,1232],{"class":118,"line":271},[116,1216,1217],{"class":122},"               WHERE",[116,1219,420],{"class":136},[116,1221,140],{"class":129},[116,1223,458],{"class":136},[116,1225,461],{"class":122},[116,1227,628],{"class":136},[116,1229,140],{"class":129},[116,1231,793],{"class":136},[116,1233,796],{"class":129},[116,1235,1236,1239,1241,1243,1245,1247,1249],{"class":118,"line":314},[116,1237,1238],{"class":122},"                 AND",[116,1240,420],{"class":136},[116,1242,140],{"class":129},[116,1244,486],{"class":136},[116,1246,489],{"class":122},[116,1248,386],{"class":122},[116,1250,813],{"class":129},[116,1252,1253],{"class":118,"line":321},[116,1254,1255],{"class":122},"  END\n",[116,1257,1258],{"class":118,"line":327},[116,1259,573],{"class":129},[116,1261,1262],{"class":118,"line":364},[116,1263,318],{"emptyLinePlaceholder":317},[116,1265,1266,1268,1270,1272,1274,1276,1278,1280,1282],{"class":118,"line":379},[116,1267,623],{"class":122},[116,1269,868],{"class":122},[116,1271,871],{"class":129},[116,1273,874],{"class":122},[116,1275,191],{"class":136},[116,1277,140],{"class":129},[116,1279,881],{"class":136},[116,1281,884],{"class":122},[116,1283,887],{"class":122},[116,1285,1286,1288,1291,1293,1296,1298,1300,1302,1305],{"class":118,"line":396},[116,1287,892],{"class":122},[116,1289,1290],{"class":129}," (tenant_id ",[116,1292,226],{"class":122},[116,1294,1295],{"class":129}," ANY ((",[116,1297,667],{"class":122},[116,1299,628],{"class":136},[116,1301,140],{"class":129},[116,1303,1304],{"class":136},"readable_tenant_ids",[116,1306,1307],{"class":129},"())::uuid[]));\n",[11,1309,1310],{},"Drei Details:",[578,1312,1313,1320,1337],{},[581,1314,1315,1316,1319],{},"Die Funktion steckt in ",[15,1317,1318],{},"(SELECT …)",", damit Postgres sie einmal pro Statement auswertet statt einmal pro Zeile.",[581,1321,1322,1323,1326,1327,1330,1331,1334,1335,140],{},"Der Cast auf ",[15,1324,1325],{},"uuid[]"," ist keine Zierde. Ohne ihn ist ",[15,1328,1329],{},"= ANY (SELECT …)"," die Subquery-Form und vergleicht ",[15,1332,1333],{},"uuid"," mit ",[15,1336,1325],{},[581,1338,1339,1340,1343,1344,1347],{},"Das Ergebnis ist ein Array, kein ",[15,1341,1342],{},"OR",". Eine Variante mit ",[15,1345,1346],{},"OR internal.is_member_of(tenant_id)"," wäre kürzer, nimmt aber die Tenant-Bedingung aus dem Index. Jede REST-Abfrage ohne eigenen Tenant-Filter – im Web sind das die meisten – würde dann alle Bücher sequenziell lesen.",[11,1349,1350,1351,1354,1355,1358],{},"Der Preis dafür: Ein ",[15,1352,1353],{},"ORDER BY"," auf eine andere Spalte, etwa das Datum, kann nicht mehr geordnet über den Index ",[15,1356,1357],{},"(tenant_id, date)"," laufen und sortiert die Zeilen des Buchs stattdessen.",[11,1360,1361,1362,1365],{},"REST bleibt damit auf den Header beschränkt, Realtime sieht alle Bücher, in denen die Person Mitglied ist. Welches Buch die App gerade hört, legt sie beim Abonnieren mit dem Filter ",[15,1363,1364],{},"tenant_id=eq.\u003Caktives Buch>"," fest.",[20,1367,1369],{"id":1368},"grenzen","Grenzen",[578,1371,1372,1378],{},[581,1373,1374,1377],{},[1018,1375,1376],{},"Die Policies teste ich manuell."," Für heikle Migrationen gibt es SQL-Skripte, die ich gegen die lokale Datenbank laufen lasse, aber keine automatischen RLS-Tests im CI.",[581,1379,1380,1383],{},[1018,1381,1382],{},"Jede neue Tabelle braucht dieselbe Sorgfalt."," Eine Tabelle ohne RLS ist über die API offen. Die Regel im Projekt: keine Tabelle ohne Policies, und jede neue Realtime-Tabelle bekommt die Array-Policy, sonst hören Mitglieder geteilter Bücher nichts von ihr.",[20,1385,1387],{"id":1386},"zum-mitnehmen","Zum Mitnehmen",[578,1389,1390,1393,1396,1404],{},[581,1391,1392],{},"Der aktive Mandant kommt als Request-Header und wird serverseitig gegen die Mitgliedschaft geprüft. Der Client wünscht, der Server entscheidet.",[581,1394,1395],{},"Lesen auf den aktiven Mandanten beschränken, Schreiben gegen den Mandanten der Zeile prüfen, sobald Schreibvorgänge verspätet ankommen können.",[581,1397,1398,1399,1401,1402,140],{},"RLS-Hilfsfunktionen gehören in ein Schema außerhalb der API, mit ",[15,1400,833],{}," und, wo nötig, ",[15,1403,826],{},[581,1405,1406],{},"Realtime wertet Policies ohne Pre-Request-Hook aus. Jede Policy muss auch aus dieser Sicht stimmen.",[1408,1409],"hr",{},[11,1411,1412,1413,1418,1419,140],{},"MoneyKoi gibt es ",[63,1414,1417],{"href":1415,"rel":1416},"https://play.google.com/store/apps/details?id=com.psyren.moneykoi",[1059],"als Android-App bei Google Play",". Mehr Infos auf ",[63,1420,1423],{"href":1421,"rel":1422},"https://www.moneykoi.com",[1059],"moneykoi.com",[1425,1426,1427],"style",{},"html pre.shiki code .suJrU, html code.shiki .suJrU{--shiki-default:#FF7B72}html pre.shiki code .sZEs4, html code.shiki .sZEs4{--shiki-default:#E6EDF3}html pre.shiki code .sFSAA, html code.shiki .sFSAA{--shiki-default:#79C0FF}html pre.shiki code .s9uIt, html code.shiki .s9uIt{--shiki-default:#A5D6FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sc3cj, html code.shiki .sc3cj{--shiki-default:#D2A8FF}html pre.shiki code .sH3jZ, html code.shiki .sH3jZ{--shiki-default:#8B949E}",{"title":112,"searchDepth":156,"depth":156,"links":1429},[1430,1431,1432,1433,1434,1435,1436,1437],{"id":22,"depth":156,"text":23},{"id":79,"depth":156,"text":80},{"id":97,"depth":156,"text":98},{"id":605,"depth":156,"text":606},{"id":852,"depth":156,"text":853},{"id":1067,"depth":156,"text":1068},{"id":1368,"depth":156,"text":1369},{"id":1386,"depth":156,"text":1387},"/images/blog/supabase-rls-multi-tenant/cover.de.webp","2026-10-31T00:00:00.000Z","Supabase RLS für Multi-Tenant-Apps: wie MoneyKoi das aktive Buch per Header und Pre-Request-Hook prüft und Lesen und Schreiben bewusst verschieden absichert.","md","supabase-rls-multi-tenant",[1444,1445,1446,1447,1448],"Supabase RLS Multi-Tenant","Supabase Row Level Security","Supabase Mandantenfähigkeit","PostgREST db_pre_request","Supabase Tenant Header",{},"/blog/de/supabase-rls-multi-tenant",[1452],"moneykoi",{"title":6,"description":1440},"blog/de/supabase-rls-multi-tenant",[1456,1457,1458],"Supabase","PostgreSQL","Security",null,"BX-4R-CubEFpPU98nfLaFHI2lN_aTipVqHiwGBUAHSA",1508,[1463,1466],{"slug":1464,"title":1465,"minutes":265},"offline-first-flutter-supabase","Offline-First mit Flutter und Supabase: Outbox statt Ladebalken",{"slug":1467,"title":1468,"minutes":271},"rksv-qr-code-auslesen","RKSV-QR-Code auslesen: Kassabons am Handy mit ML Kit und Parsern",[1470,1472],{"code":1471,"slug":1442},"de",{"code":1473,"slug":1442},"en",1793537120196]