policy ใบเดียวที่ระบุ FOR SELECT · UPDATE ได้ 00000 และแก้ไปศูนย์แถว
บทที่ 3 วัดการเขียนข้ามผู้เช่าที่ถูกปฏิเสธ และการปฏิเสธนั้นส่งรหัสออกมาให้เห็น คนที่อ่าน log เจอรหัสนั้น ก็รู้ทันทีว่ามีคำสั่งที่ไม่ผ่าน
บทนี้เปลี่ยน policy ไปหนึ่งคำ แล้วการปฏิเสธเปลี่ยนเสียง
update ที่ตั้งใจจะแก้สามแถว กลับมาด้วยรหัส 00000 และ 0 แถวที่ถูกกระทำ
ซึ่งเป็นค่าเดียวกับที่คำสั่งซึ่งไม่มีอะไรให้ทำ กลับมาด้วย
คอร์สนี้อยู่ในซีรีส์คลินิกเดียวกับ คอร์สคัดกรองผู้เรียก และ คอร์ส CORS กับ CSRF แต่หกบทแรกไม่มีแอปมาเกี่ยวข้อง มีแต่ตาราง policy และ role
ตัวเลขทุกตัวในบทนี้คุณรันเองได้ สั่ง scripts/clinic-rls/up.sh ซึ่งยก container
ชื่อ kaen-rls-pg ขึ้นที่ port 55438 แล้วสั่ง python3 scripts/clinic-rls/spike.py
เก็บกวาดด้วย docker rm -f kaen-rls-pg
policy ใบเดียว ที่บอกไว้ว่าตัวเองใช้กับคำสั่งไหน
หัวข้อที่มีชื่อว่า “policy ใบเดียว ที่บอกไว้ว่าตัวเองใช้กับคำสั่งไหน”DDL ก้อนนี้ต่างจากก้อนของบทที่ 1 ที่ชื่อตาราง ชื่อ policy และอีกคำเดียวบนบรรทัดสุดท้าย
create table t_selonly ( id bigserial primary key, tenant_id text not null, patient text not null, archived boolean not null default false);insert into t_selonly (tenant_id, patient, archived) values ('clinic-a','Ann',false),('clinic-a','Ben',false),('clinic-a','Cid',true), ('clinic-b','Dan',false),('clinic-b','Eve',false),('clinic-b','Fay',true);grant select, insert, update, delete on t_selonly to kaen_rls_app, kaen_rls_bypass;grant usage, select on sequence t_selonly_id_seq to kaen_rls_app, kaen_rls_bypass;alter table t_selonly enable row level security;create policy p_sel on t_selonly for select using (tenant_id = current_setting('app.tenant', true));บรรทัดสุดท้ายคือ CREATE POLICYCREATE POLICYคำสั่งที่ผูกนิพจน์หนึ่งเข้ากับตารางหนึ่ง · ตาราง t_app ของ probe มี policy ผูกอยู่ 1 ใบ (appPolicyCount) และเขียนถูกทุกตัวอักษร แต่จำนวน policy ที่ผูกอยู่ไม่ใช่หลักฐานว่ามันเคยถูกเรียกใช้ ที่ระบุ for select แทน for all
นิพจน์ใน USINGUSING (…)นิพจน์ที่ policy ใช้คัดว่าแถวใดมองเห็นหรือแตะได้ · เมื่อ policy ไม่มี WITH CHECK นิพจน์นี้ทำหน้าที่ตรวจแถวหลังการเขียนให้เองด้วย · UPDATE ที่ย้ายแถวข้ามผู้เช่าบนตาราง t_update ใต้ USING ล้วน ได้ 42501 และยอดของสองผู้เช่าค้างที่ 3 กับ 3 เป็นนิพจน์เดียวกับที่บทที่ 1 ใช้ทุกตัวอักษร
สิ่งที่เปลี่ยนคือขอบเขตของใบนี้ ไม่ใช่เนื้อในของมัน
ผู้อ่านในทรงนี้เป็น role kaen_rls_app ซึ่งไม่ใช่เจ้าของตาราง
ตารางจึงไม่ต้องการ force row level security เพื่อให้ policy ทำงาน
grant สี่สิทธิ์บนบรรทัดที่เก้ายืนยันว่า role ใบนี้มีสิทธิ์สั่งครบทั้งสี่คำสั่ง
สามคำสั่งเขียน ใต้ policy ที่ไม่ได้พูดถึงมันสักคำ
หัวข้อที่มีชื่อว่า “สามคำสั่งเขียน ใต้ policy ที่ไม่ได้พูดถึงมันสักคำ”คำสั่งอ่านได้สามแถว แล้วคำสั่งเขียนสามคำสั่งกลับมาด้วยค่าที่ไม่เหมือนกัน
set app.tenant = 'clinic-a';select 'KAEN|selOnlyVisible|'||count(*) from t_selonly where true;select 'KAEN|selOnlyUpdate|'||public.kaen_try('update t_selonly set patient = patient || ''!'' where tenant_id=''clinic-a''');select 'KAEN|selOnlyDelete|'||public.kaen_try('delete from t_selonly where tenant_id=''clinic-a''');select 'KAEN|selOnlyInsert|'||public.kaen_try('insert into t_selonly (tenant_id, patient) values (''clinic-a'',''Gil'')');public.kaen_try เป็นตัวช่วยของ probe ตัวเดียว มันสั่งคำสั่งที่รับมา
แล้วคืนสตริงสองช่อง ช่องแรกเป็นรหัส SQLSTATESQLSTATEรหัสห้าหลักที่ PostgreSQL ให้กับผลของคำสั่งหนึ่ง · คอร์สนี้ยกเฉพาะรหัส ไม่ยกข้อความผิดพลาด เพราะข้อความเป็นร้อยแก้วที่เปลี่ยนข้าม major ส่วนรหัสไม่เปลี่ยน · 00000 แปลว่าคำสั่งสำเร็จ ซึ่งเกิดคู่กับจำนวนแถวที่ถูกกระทำเท่าใดก็ได้ และช่องที่สองเป็นจำนวนแถวที่ถูกกระทำ
เส้นทางที่ยกข้อผิดพลาดคืน -1 ในช่องที่สอง ซึ่งเป็นหมุดที่แปลว่าไม่มีจำนวนแถวให้รายงาน
| คีย์ | คำสั่งที่ probe สั่ง | ค่าที่วัดได้ |
|---|---|---|
selOnlyVisible | select count(*) | 3 |
selOnlyUpdate | update แถวของ clinic-a | 00000 0 |
selOnlyDelete | delete แถวของ clinic-a | 00000 0 |
selOnlyInsert | insert แถวของ clinic-a | 42501 -1 |
สามแถวที่ selOnlyVisible นับได้ คือแถวที่ policy ใบนี้ยอมให้เห็น
คำสั่ง update กับ delete ชี้ไปที่แถวชุดเดียวกันด้วยเงื่อนไขตัวเดียวกัน
แล้วทั้งคู่กลับมาด้วย 00000 และ 0
ส่วน selOnlyInsert กลับมาด้วยรหัส 42501 insufficient_privilegeSQLSTATE 42501 (insufficient_privilege)รหัสที่ RLS ใช้ปฏิเสธการเขียนแบบส่งเสียงออกมา · วัดได้ในห้าทรง คือ UPDATE บนตาราง t_update ที่ย้ายแถวข้ามผู้เช่า ทั้งใต้ USING ล้วนและใต้ WITH CHECK (true) · INSERT ข้ามผู้เช่าบนตาราง t_insert ใต้ USING ล้วน · ทรงเดียวกันที่ผ่านแล้วแต่เติม RETURNING เข้าไป · และ INSERT บนตาราง t_selonly ใต้ policy ที่ระบุ FOR SELECT อย่างเดียว · ตรงข้ามกับ UPDATE และ DELETE บนตารางใบหลังนั้น ซึ่งคืน 00000 และ 0 แถวโดยไม่ส่งเสียง
คำสั่งเดียวที่ส่งเสียงออกมา คือคำสั่งที่ไม่มีแถวเดิมให้อ้างถึงเลย
ตารางหลังจบ · หกแถว และไม่มีเครื่องหมายอัศเจรีย์สักตัว
หัวข้อที่มีชื่อว่า “ตารางหลังจบ · หกแถว และไม่มีเครื่องหมายอัศเจรีย์สักตัว”ศูนย์แถวที่
updateรายงาน เป็นรายงานที่ตรง และเจ้าของตารางยืนยันมันได้จากอีกฝั่ง
คำสั่ง update ในทรงข้างบนต่อเครื่องหมาย ! ท้ายชื่อของทุกแถวที่มันแก้ได้
ร่องรอยของมันจึงนับได้ และ delete ที่ลบสำเร็จจะทำให้จำนวนแถวทั้งตารางลดลง
probe เปลี่ยนไปอ่านด้วย role ที่เป็นเจ้าของตาราง ซึ่งบทที่ 1 วัดแล้วว่าอยู่นอก policy ของตัวเอง
select 'KAEN|selOnlyRowsAfter|'||count(*) from t_selonly where true;select 'KAEN|selOnlyBangs|'||count(*) from t_selonly where patient like '%!';selOnlyRowsAfter = 6 และ selOnlyBangs = 0
หกคือจำนวนแถวตั้งต้นทั้งตาราง ไม่มีแถวไหนหายไป และศูนย์แปลว่าไม่มีแถวไหนได้เครื่องหมายนั้นติดมา สองค่านี้ปิดความเป็นไปได้ที่ว่า คำสั่งทำงานสำเร็จแล้วรายงานผิด
แต่ข้อที่ต้องอ่านให้ครบคือข้อกลับกัน ศูนย์แถวไม่ได้แปลว่าคำสั่งไม่ได้ถูกส่งไป คำสั่งเดินทางไปถึงฐานข้อมูล ถูกวางแผน และถูกสั่ง สิ่งที่เป็นศูนย์คือจำนวนแถวที่มันได้รับอนุญาตให้แตะ
สองเสียงของการปฏิเสธ บนตารางใบเดียวกัน
หัวข้อที่มีชื่อว่า “สองเสียงของการปฏิเสธ บนตารางใบเดียวกัน”ทรงเดียวกันให้การปฏิเสธสองแบบ และแบบที่ระบบเฝ้าง่ายกว่า คือแบบที่ดังกว่า
| คำสั่ง | คีย์ | ค่าที่วัดได้ | สิ่งที่ผู้เรียกเห็น |
|---|---|---|---|
insert | selOnlyInsert | 42501 -1 | ข้อผิดพลาด พร้อมรหัสที่จับได้ |
update | selOnlyUpdate | 00000 0 | สำเร็จ กระทบศูนย์แถว |
delete | selOnlyDelete | 00000 0 | สำเร็จ กระทบศูนย์แถว |
สองแถวล่างคือรูปเดียวกับที่คำสั่งซึ่งเงื่อนไข where ไม่ตรงกับแถวไหนเลย กลับมาด้วย
แอปที่เขียนไว้ว่า “สำเร็จก็ไปต่อ” จึงไปต่อ และ log ไม่มีบรรทัดให้ค้น
เอกสารของ PostgreSQL 18 ระบุว่า เมื่อเปิด row-level security แล้วไม่มี policy ใบไหน ใช้ได้กับคำสั่งนั้น ค่าปริยายคือปฏิเสธ ข้อนี้เป็นข้ออ้างเรื่องกลไก จึงอ้างเอกสาร ส่วนตัวเลขสี่ตัวข้างบนเป็นสิ่งที่ probe วัด
การปฏิเสธสองแบบนี้ไม่ได้ต่างกันที่ความรุนแรง มันต่างกันที่คำสั่งมีแถวเดิมให้คัดหรือไม่
update กับ delete คัดแถวเดิมได้ศูนย์แถว แล้วจบตามปกติ
ส่วน insert ไม่มีแถวเดิมให้คัด แถวใหม่ของมันจึงต้องถูกตัดสิน และคำตอบคือปฏิเสธ
เปิด RLS โดยไม่มี policy สักใบ
หัวข้อที่มีชื่อว่า “เปิด RLS โดยไม่มี policy สักใบ”ครึ่งหลังของบทย้ายไปตาราง
t_combineซึ่งเริ่มจากทรงที่ว่างที่สุดที่เป็นไปได้
ตาราง t_combine หว่านด้วยเมล็ดชุดเดียวกับ t_selonly คือหกแถว สามแถวต่อผู้เช่า
และเปิด row-level security ไว้แล้ว ต่างกันที่ตอนนี้ยังไม่มีใครสั่ง create policy เลยสักใบ
select 'KAEN|combineZeroPolicies|'||count(*) from t_combine where true;combineZeroPolicies = 0
ตารางมีหกแถวอยู่ครบ role ใบนี้มีสิทธิ์ select และไม่มี policy ใบไหนปฏิเสธมัน
เพราะไม่มี policy สักใบ ค่าที่ได้คือศูนย์
ตารางที่ไม่มี policy จึงไม่ใช่ตารางที่เปิดกว้าง มันคือตารางที่ปิดสนิท
สำหรับทุกคนที่ไม่ใช่เจ้าของตาราง ไม่ใช่ superuser และไม่ถือธง BYPASSRLS
สามข้อยกเว้นนั้นคือสิ่งที่บทที่ 1 วัดไว้แล้ว
permissive สองใบ รวมกันด้วย OR
หัวข้อที่มีชื่อว่า “permissive สองใบ รวมกันด้วย OR”ใบที่สองเพิ่มแถวเข้ามา ไม่ได้ตัดแถวออก
create policy p_a on t_combine for select using (tenant_id = 'clinic-a');create policy p_b on t_combine for select using (tenant_id = 'clinic-b');select 'KAEN|combineTwoPermissive|'||count(*) from t_combine where true;combineTwoPermissive = 6
นิพจน์สองใบนี้ตัดกันไม่ได้ แถวหนึ่งเป็นของ clinic-a หรือของ clinic-b อย่างใดอย่างหนึ่ง
ถ้าเงื่อนไขสองใบถูกรวมด้วย AND ค่าที่ได้ต้องเป็นศูนย์ ค่าที่วัดได้คือหก
ซึ่งเท่ากับทั้งตาราง
policy ชนิดปริยายเรียกว่า permissive policypermissive policypolicy ชนิดปริยาย ซึ่งหลายใบรวมกันด้วย OR · บนตาราง t_combine สองใบแบบนี้ให้ 6 แถว ส่วนนิพจน์คู่เดียวกันที่ใบที่สองถูกประกาศเป็น restrictive ให้ 0 แถว · ชนิดของ policy จึงพลิกผลได้ทั้งที่นิพจน์ไม่เปลี่ยน และหลายใบรวมกันด้วย OR ใบที่เพิ่มเข้ามาจึงขยายสิ่งที่มองเห็นได้เสมอ ไม่มีทางลดมันลง
นิพจน์คู่เดิม ใบที่สองเปลี่ยนชนิด
หัวข้อที่มีชื่อว่า “นิพจน์คู่เดิม ใบที่สองเปลี่ยนชนิด”คำที่เพิ่มเข้ามาคือ
as restrictiveและไม่มีตัวอักษรไหนในนิพจน์ถูกแตะ
drop policy p_b on t_combine;create policy p_b on t_combine as restrictive for select using (tenant_id = 'clinic-b');select 'KAEN|combinePermissiveRestrictive|'||count(*) from t_combine where true;combinePermissiveRestrictive = 0
หัวข้อก่อนหน้าให้ 6 จากนิพจน์คู่นี้ หัวข้อนี้ให้ 0 จากนิพจน์คู่เดียวกัน สิ่งที่เปลี่ยนคือชนิดของใบที่สอง restrictive policyrestrictive policypolicy ที่รวมกับใบอื่นด้วย AND · บนตาราง t_combine permissive ของผู้เช่าบวก restrictive ที่เขียนว่า not archived ให้ 2 แถว · ส่วน restrictive ใบเดียวโดดๆ บนตารางใบเดียวกัน ให้ 0 แถว เพราะไม่มี permissive ใบไหนอนุญาตอะไรไว้ก่อน รวมกับใบอื่นด้วย AND และเงื่อนไขสองข้อนี้เป็นจริงพร้อมกันไม่ได้
ชนิดของ policy ไม่ได้อยู่ในนิพจน์ มันอยู่ในคำสั่งที่สร้างใบนั้น คนที่อ่านนิพจน์อย่างเดียวเพื่อตัดสินว่าตารางเปิดกว้างแค่ไหน อ่านข้อมูลไม่ครบ
restrictive ที่ทำงานเป็นตัวตัด และ restrictive ที่โดดเดี่ยว
หัวข้อที่มีชื่อว่า “restrictive ที่ทำงานเป็นตัวตัด และ restrictive ที่โดดเดี่ยว”ใบเดิมสองใบ ถอดออกไปหนึ่งใบ แล้วสิ่งที่เหลือไม่ได้อนุญาตอะไรเลย
drop policy p_a on t_combine;drop policy p_b on t_combine;create policy p_t on t_combine for select using (tenant_id = current_setting('app.tenant', true));create policy p_r on t_combine as restrictive for select using (not archived);set app.tenant = 'clinic-a';select 'KAEN|combineTenantAndNotArchived|'||count(*) from t_combine where true;combineTenantAndNotArchived = 2
ใบ p_t เป็น permissive และอนุญาตเฉพาะแถวของผู้เช่าที่ app.tenant ชี้ไว้
ใบ p_r เป็น restrictive และตัดแถวที่ archived เป็นจริงออก ค่าที่วัดได้คือ 2
นี่คือทรงที่ restrictive ทำในสิ่งที่คนคาดหวังจากมัน
drop policy p_t on t_combine;set app.tenant = 'clinic-a';select 'KAEN|combineRestrictiveAlone|'||count(*) from t_combine where true;combineRestrictiveAlone = 0
ใบที่เหลืออยู่คือ p_r ซึ่งเขียนว่า not archived และเมล็ดตั้งต้นมีแถวที่ผ่านเงื่อนไขนั้นอยู่
ถึงอย่างนั้นค่าที่วัดได้ก็เป็นศูนย์ probe อ่านจาก pg_policy ยืนยันสองค่าไว้ในรอบเดียวกัน
คือ combinePolicyCount = 1 และ combinePermissiveLeft = 0
restrictive ใบเดียวไม่เคยอนุญาตอะไร มันตัดจากสิ่งที่ permissive อนุญาตไว้ก่อน เมื่อไม่มี permissive สักใบ สิ่งที่ถูกอนุญาตไว้ก่อนคือเซตว่าง และการตัดเซตว่างด้วยเงื่อนไขใดก็ตาม ยังได้เซตว่าง
แผนที่ของบท
หัวข้อที่มีชื่อว่า “แผนที่ของบท”ครึ่งแรกวัดเสียงของการปฏิเสธ ครึ่งหลังวัดว่าใบไหนอนุญาตและใบไหนตัด
flowchart TB
S1["ทรงที่ ⑥ · t_selonly · policy ใบเดียว ระบุ FOR SELECT"] --> S2{"คำสั่งมีแถวเดิมให้คัดหรือไม่"}
S2 -->|"update และ delete · มีแถวเดิม"| S3["00000 กระทบ 0 แถว · selOnlyUpdate และ selOnlyDelete"]
S2 -->|"insert · ไม่มีแถวเดิม"| S4["42501 · selOnlyInsert"]
S3 --> S5["เจ้าของนับหลังจบ · 6 แถว และ 0 แถวที่ถูกแก้ · selOnlyRowsAfter และ selOnlyBangs"]
S4 --> S5
C1["ทรงที่ ⑫ · t_combine · หกแถวเท่ากัน"] --> C2["ไม่มี policy สักใบ · 0 แถว · combineZeroPolicies"]
C1 --> C3["permissive สองใบ รวมด้วย OR · 6 แถว · combineTwoPermissive"]
C1 --> C4["นิพจน์คู่เดิม ใบที่สองเป็น restrictive รวมด้วย AND · 0 แถว · combinePermissiveRestrictive"]
C1 --> C5["permissive ผู้เช่า บวก restrictive not archived · 2 แถว · combineTenantAndNotArchived"]
C1 --> C6["restrictive ใบเดียว ไม่มี permissive เหลือ · 0 แถว · combineRestrictiveAlone"]
คำบรรยายภาพ: สองทรงที่บทนี้วัด ทรงที่ ⑥ แยกการปฏิเสธออกเป็นสองเสียงตามว่าคำสั่ง มีแถวเดิมให้คัดหรือไม่ ส่วนทรงที่ ⑫ ใช้ตารางหกแถวใบเดียวกับ policy ห้าชุด แล้วได้จำนวนแถวห้าค่า ซึ่งมีเพียงสามค่าที่ต่างกัน
ที่วัดได้ กับที่เอกสารกำหนด
หัวข้อที่มีชื่อว่า “ที่วัดได้ กับที่เอกสารกำหนด”ตัวเลขทั้งบทมาจาก SQL ที่รันจริง ส่วนกฎว่าชนิดไหนรวมด้วยตัวไหน มาจากเอกสาร
spike.py รันชุดข้ออ้างทั้งชุดสามรอบต่อการเรียกหนึ่งครั้ง แล้วเทียบผลทีละคีย์
รอบที่รันเมื่อ 2026-09-06 ได้ 52 ข้ออ้างตรงทั้งหมด 98 ค่าที่วัดได้
และ 0 ค่าที่ขยับระหว่างรอบ
สิ่งที่บทนี้ ไม่ได้ พิสูจน์คือกลไกข้างใน บทนี้ไม่ได้แสดงว่าตัวตัดสิน ประกอบนิพจน์ของสองชนิดเข้าด้วยกันด้วยลำดับใด ข้ออ้างแบบนั้นอ้างเอกสารของ PostgreSQL 18 และเอกสารเป็นคนละหลักฐานกับ probe
ผลทั้งหมดในบทนี้วัดบน PostgreSQL 18.6 บน aarch64-unknown-linux-musl
ใน container ของคอร์สนี้เอง major อื่นเป็นคนละการวัด และบทนี้ไม่ได้อ้างถึงมัน
หน่วยของหลักฐานในบทนี้มีสี่หน่วย คือจำนวนแถวที่ผู้อ่านนับได้ จำนวนแถวที่คำสั่งกระทำ จำนวน policy ที่ผูกกับตาราง และรหัส SQLSTATE ทั้งคอร์สไม่มีหน่วยเวลาสักตัว
คำถามที่บทนี้ทิ้งไว้
หัวข้อที่มีชื่อว่า “คำถามที่บทนี้ทิ้งไว้”รู้แล้วว่า policy ปฏิเสธด้วยเสียงไหนบ้าง ยังไม่รู้ว่ามีทางไหนที่มันไม่ได้ถูกเรียกเลย
ทุกตัวเลขในบทนี้มาจากคำสั่งที่ยิงตรงเข้าตาราง t_selonly และ t_combine
เมื่อคำสั่งชี้ตรงไปที่ตาราง policy ที่ผูกกับตารางนั้นจะได้ตัดสินเสมอ
ไม่ว่าคำตอบของมันจะเป็นอนุญาต หรือเป็นการปฏิเสธเสียงใดเสียงหนึ่งในสองเสียง
แต่แอปจริงไม่ได้ชี้ตรงเข้าตารางเสมอไป มันเรียกผ่าน view และเรียกผ่านฟังก์ชัน คำถามคือทางอ้อมเหล่านั้นเดินผ่าน policy ใบเดิม หรือเดินอ้อมมันไป
บทที่ 5 วัดสี่ทางเข้าบนตารางใบเดียวกัน แล้วเทียบจำนวนแถวที่แต่ละทางคืนกลับมา ทางที่ให้ค่าต่างจากการอ่านตรงๆ คือทางที่ policy มองไม่เห็น
-
PostgreSQL 18 — Row Security Policies (เข้าถึง 2026-09-06) — ที่มาของกฎว่าค่าปริยายคือปฏิเสธเมื่อไม่มี policy ใบไหนใช้ได้กับคำสั่งนั้น และกฎว่า permissive รวมกันด้วย OR ส่วน restrictive รวมด้วย AND
-
PostgreSQL 18 —
CREATE POLICY(เข้าถึง 2026-09-06) — ความหมายของAS PERMISSIVEAS RESTRICTIVEและFOR SELECT -
PostgreSQL 18 — PostgreSQL Error Codes (เข้าถึง 2026-09-06) — ที่มาของรหัส
42501และ00000ในตารางของบทนี้ -
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/clinic-rls/spike.pyซึ่งสร้างฐานข้อมูลกับ role ขึ้นใหม่ทุกรอบ แล้วรื้อทิ้งเมื่อจบ · ต้องสั่งscripts/clinic-rls/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55438
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3ตาราง `t_selonly` มี policy ใบเดียวที่ระบุ `for select` app role ที่ตั้ง `app.tenant` เป็น `clinic-a` สั่ง `update` แถวของผู้เช่านั้น ได้อะไรกลับมา