API Restriction · 01 — role ที่ผูกกับ app ที่เรียกจริง
แผนที่จะทำ: FE ส่ง AppId มาใน header, backend เอาไปหาใน RedisUserInfo ว่า user คนนี้มี role อะไรใน app นั้น แล้ว map แค่นั้น หาไม่เจอคือ 401
อัปเดต: 2026-09-07
⬅️ ที่มาของงานนี้ → 00 — ปัญหา มติที่เสนอ และตัวเลือกที่ตกรอบ
01. role ที่ผูกกับ app ที่เรียกจริง
🔵 หน้านี้คือแผนที่จะทำ ยังไม่มีโค้ดสักบรรทัด — สิ่งที่เขียนไว้ข้างล่างเป็นพฤติกรรมที่ตั้งใจให้เป็นหลังทำเสร็จ ไม่ใช่พฤติกรรมของระบบวันนี้ ยกเว้นหัวข้อ “สิ่งที่เกิดขึ้นทุกวันนี้” ที่ตรวจกับ Backend_Package@10.30.0 แล้ว
สิ่งที่เกิดขึ้นทุกวันนี้
RedisUserInfoMiddleware.cs:341 ยัด role เข้า ClaimsPrincipal ด้วยบรรทัดเดียว:
claims.AddRange(userInfo.AllRoles.Select(r => new Claim(ClaimTypes.Role, r)));
และ AllRoles (UserInfoForRedis.cs:95-100) คือ Apps.SelectMany(a => a.Roles).Select(r => r.Name).Distinct() — ยุบข้าม app ทั้งหมดด้วยชื่อ role ⇒ user ที่เป็น Admin ใน FX เดินเข้า endpoint ของ Platform ที่เขียน [Authorize(Roles = "Admin")] ได้ทันที
กติกาที่จะใช้ — มีข้อเดียว
FE ส่ง GUID ของ app ที่ user เลือกอยู่มาใน header · backend เอา GUID นั้นไปหาใน UserInfoForRedis.Apps ที่ดึงมาจาก Redis อยู่แล้ว · เจอ ⇒ map role ของ app นั้นเข้า claim · ไม่เจอ หรือเจอแต่ไม่มี role ⇒ ตอบ 401
เหตุผลที่ตอบ 401 ไม่ใช่ปล่อยผ่านแบบไม่มีสิทธิ์: เป็นไปไม่ได้ที่ user จะเรียกเข้ามาในนามของ app ที่ตัวเองไม่มี role อยู่เลย ⇒ สถานะนี้ไม่ใช่ “ไม่มีสิทธิ์ทำ operation นี้” แต่คือ “คำขอนี้ไม่สมเหตุสมผลตั้งแต่ต้น” — ไม่ส่ง header มาเลยก็เข้ากติกาเดียวกัน เพราะไม่รู้ว่า app ไหน ก็ไม่มี role ให้ map
ทำไมกติกานี้ปลอดภัย ทั้งที่ header เชื่อไม่ได้
ตรวจ Backend_Iac/src/apim/policies/sentinel-facade-inbound-v6.xml แล้ว — exists-action="delete" มีอยู่จุดเดียวคือ Authorization ที่ :274 ไม่มีการลบ header ของ client เลยสักตัว ⇒ ใครจะยัด GUID อะไรมาก็ได้ รวมถึงกรณีที่โดน XSS แล้วมี script ยัด header ปลอมเข้ามา
แต่ ตัวที่ตัดสินไม่ใช่ header — ตัวที่ตัดสินคือ blob ใน Redis ซึ่ง client แตะไม่ถึง header ทำได้อย่างเดียวคือ เลือกจากบรรดา app ที่ user คนนั้นมี role อยู่แล้ว ⇒ ผลลัพธ์แย่ที่สุดของ GUID ปลอมคือ 401 ไม่มีทางที่มันจะพา role ที่ user ไม่มีเข้ามาได้
map role ทุกตัวของ app นั้น ไม่ใช่ตัวแรก
ข้อสมมติ “1 APP = 1 USER = 1 ROLE” schema ไม่ได้บังคับ — unique index จริงคือ (UserId, RoleId, CompanyId) ที่ UserRoleConfiguration.cs:51-53 และ UserAppInfo.Roles (UserInfoForRedis.cs:183-184) เป็น list
เขียนเป็น foreach ตั้งแต่แรกไม่ได้แพงกว่าเขียน Roles[0] เลย และหลีกเลี่ยงบั๊กแบบเดียวกับที่ GetUserShellPermissionsHandler.cs:30-31 มีอยู่ตอนนี้ คือหยิบ Roles[0] แล้วตัวที่สองหายเงียบ ไม่ error ไม่ log ส่วนเรื่องจะเติม unique constraint (UserId, AppId) ไหม เป็นมติแยกที่ตัดสินทีหลังได้
ขนาดของงาน — เล็กกว่าที่คิด
ข้อมูลที่ต้องใช้ อยู่ใน Redis blob อยู่แล้วครบ — Apps (UserInfoForRedis.cs:91-92) มี appId และ roles ส่วน AllRoles เป็น [JsonIgnore] computed property ไม่ได้อยู่ใน blob จริง
⇒ แก้ที่ middleware ล้วน ๆ ไม่ต้องแก้ฝั่งที่เขียน blob ไม่ต้องแตะ UsersMapper ไม่ต้องแตะ schema
🔴 ห้าม bump CurrentSchemaVersion — RedisUserInfoMiddleware.cs:275-282 ถ้า version ไม่ตรงจะ LogWarning แล้ว return เปล่า ไม่ inject claim ใด ๆ และ ไม่ fallback ไป HTTP ⇒ ทุก request ของ user คนนั้นไม่มี identity เลยจนกว่า TTL 86400 วินาทีจะหมด งานนี้ไม่ต้อง bump อยู่แล้วเพราะไม่ได้เพิ่ม field แต่เขียนกำกับไว้กันคนถัดไปเผลอ
ลำดับตอน deploy
กติกา 401 ทำให้ การ bump lib เฉย ๆ จะ 401 ทุก app ที่ FE ยังไม่ส่ง header ⇒ ต้องมีสวิตช์ใน config และ default ต้องเป็น “ยังไม่บังคับ” (พฤติกรรมเดิม) เพื่อให้ service ที่ bump version ตามน้ำไม่เปลี่ยนพฤติกรรมอะไรเลย
ลำดับ: lib ขึ้นโดยสวิตช์ปิด → FE ส่ง header ครบ → เปิดสวิตช์ทีละ service ผ่าน config ไม่ใช่ผ่านการ deploy โค้ดใหม่
ยังไม่ verify: FE ปัจจุบันทำอะไรเมื่อเจอ 401 — ถ้า interceptor เด้งไปหน้า login การ 401 session ที่โดนยัด header ปลอมก็เป็นผลที่ต้องการพอดี แต่ถ้ามัน retry หรือ refresh วนจะกลายเป็นลูป ต้องดูโค้ด FE ก่อนเปิดสวิตช์
ต้นทุนที่ต้องนับ
- แก้ที่
Backend_Package⇒ ต้องdotnet nuget pushด้วยมือ (merge เข้าdevelopmentไม่ publish ให้เอง) แล้ว ~13-14 service ต้อง bump version และ redeploy เอง - ชื่อ header กับสวิตช์เปิด/ปิด เป็น config ⇒ ต้องเติมค่าใน
Backend_Iacครบทุก env ที่ service นั้น deploy แก้ใน service repo อย่างเดียวไม่มีผลบน cluster และ env ที่ขาด key จะพังตอน deploy
สิ่งที่งานนี้ยังไม่แก้
ชื่อ role ไม่ซ้ำกันข้าม app อยู่แล้วในทางปฏิบัติ และถ้าจะซ้ำ การแยกก็ควรแยกด้วย AppId ไม่ใช่ด้วยการตั้งชื่อให้ต่างกัน — จุดที่การแยกด้วย AppId เกิดขึ้นจริงคือ 02 — permission model ซึ่ง resolve permission เทียบ AppId ของ service เจ้าของ endpoint เอง
ที่ต้องรู้ไว้เฉย ๆ: หลัง map แล้ว role claim เป็น string เปล่า ๆ ไม่ได้พก AppId ติดไปด้วย ⇒ [Authorize(Roles = "Admin")] ยังตัดสินจากชื่ออย่างเดียว และ RoleConfiguration.cs:67-68 unique แค่ (AppId, Name) ไม่ได้ห้ามชื่อซ้ำข้าม app ⇒ ข้อนี้พึ่ง convention ไม่ได้พึ่ง constraint จนกว่าจะถึงข้อ 2 (ยังไม่ verify ว่าวันนี้มี row ที่ชื่อซ้ำข้าม AppId จริงไหม อยู่ในรายการหลักฐานที่ต้องเก็บใน 03)
อีกเรื่องที่ยังไม่ตอบ: blob ไม่ได้เก็บ company scope ของ role — UsersMapper.cs:242-257 ทำ GroupBy(AppId) โดยไม่เก็บ UserRole.CompanyId ทั้งที่ unique index มี CompanyId อยู่ด้วย ⇒ พอพูดเรื่อง “เลือก app” จะโดนถามต่อทันทีว่า “แล้ว company ล่ะ”