API Restriction · 00 — ที่มา สภาพปัจจุบัน และทางที่เลือก
อ่านไฟล์เดียวแล้วเข้าใจว่าระบบวันนี้ทำงานยังไง ปัญหาคืออะไร และเราจะแก้ทางไหน — จุดตั้งต้นของเอกสารชุดนี้
อัปเดต: 2026-09-08
00. ที่มา สภาพปัจจุบัน และทางที่เลือก
เอกสารชุดนี้มี 4 ไฟล์ อ่านตามลำดับ:
| เนื้อหา | |
|---|---|
| 00 (ไฟล์นี้) | ระบบวันนี้เป็นยังไง ปัญหาคืออะไร เลือกทางไหน |
| 01 | ปัญหาที่ 1 — role ที่ผูกกับ app ที่เรียกจริง |
| 02 | ปัญหาที่ 2 — dynamic role · spec ล้วน |
| 03 | ลำดับ deploy · เรื่องที่ยังต้องตัดสิน · หลักฐานที่ยังขาด |
| 04 | implementation — ลงมือจากไฟล์นี้ · ข้อขัดแย้งที่ตัดสินแล้ว · test charter · รายการที่บล็อก rollout |
| 05 | ภาพรวมทั้งชุดด้วย sequence diagram |
| 06 | สถานะและ checklist งานที่เหลือ — ไฟล์ที่ยึดถือได้ อ่านก่อนเริ่มงานต่อ |
ทุกข้อในไฟล์นี้ตรวจกับโค้ดจริงแล้วที่ Backend_UserService@25b1923 และ Backend_Package@10.30.0 ยกเว้นที่กำกับ ยังไม่ verify
Security review เพิ่มเติม 08/09/2026: ข้อความสีแดงเป็นข้อแก้ไขหรือข้อกำหนดเพิ่มเติมของ spec ไม่ใช่การยืนยันว่า implementation หรือระบบที่ deploy แล้วทำงานตามนี้; ส่วนที่ยังต้องตัดสินต้องปิดก่อน rollout ตามไฟล์ 03
ระบบเราเป็นแบบไหน
SuperApp เป็น platform ที่ app อื่นมาเสียบ — วันนี้มี Platform (ตัวหลัก) กับ FX และจะมีเพิ่มอีก · app เหล่านี้ไม่ได้แค่มีหน้าจอของตัวเอง แต่เรียก API ของ service กลางด้วย เช่น FX เรียก User Service ที่อยู่ฝั่ง Platform
ฝั่งหลังบ้านมี service ประมาณ 14 ตัว แต่ละตัวเป็น .NET แยก repo และใช้ library กลางตัวเดียวกันชื่อ SupApp_util_lib (repo Backend_Package)
request หนึ่งเดินทางยังไง
sequenceDiagram
autonumber
participant FE as หน้าจอ
participant APIM as APIM facade
participant SG as Sentinel Gateway
participant MW as middleware กลางใน lib
participant R as Redis
participant EP as endpoint ของ service
FE->>APIM: request พร้อม cookie
APIM->>SG: เอา cookie ไปแลก
SG-->>APIM: access token ที่ Entra เซ็น
APIM->>MW: แนบ Authorization Bearer แล้วส่งต่อ
MW->>MW: validate JWT ได้ OID ของ user
MW->>R: อ่าน user info ด้วย OID
R-->>MW: ข้อมูล user พร้อม role ของทุก app
MW->>EP: ใส่ role เข้า ClaimsPrincipal แล้วส่งต่อ
EP->>EP: Authorize ตัดสินจาก claim ที่ได้
จุดที่เอกสารชุดนี้สนใจคือ ขั้นที่ 7-8 — ตอนที่ middleware กลางเอา role จาก Redis ใส่เข้า ClaimsPrincipal
ข้อมูลวันนี้หน้าตาเป็นยังไง
ในฐานข้อมูล (UserService):
| ตาราง | เก็บอะไร | ข้อจำกัด |
|---|---|---|
Application | app ที่มาเสียบ | |
Roles | role ผูกกับ app | unique (AppId, Name) — ชื่อซ้ำข้าม app ได้ |
UserRoles | user ถือ role อะไร | unique (UserId, RoleId, CompanyId) |
Permissions | สิทธิ์ผูกกับ app | unique (AppId, Name) · Name = "{resource}:{action}" |
RolePermission | role ได้สิทธิ์อะไร | FK ไปทั้ง Roles และ Permissions |
Menu / MenuPermission | เมนูผูกกับสิทธิ์ |
🔴 Permissions วันนี้ใช้คุมหน้าจอ ไม่ได้คุม API — มันถูกส่งให้ FE ผ่าน shell เพื่อบอกว่าเมนูไหนควรโชว์ ไม่มีจุดไหนในระบบที่เอามันมาตัดสินว่า request ผ่านหรือไม่ผ่าน
ใน Redis — blob เดียวต่อ user เขียนโดย UserService อ่านโดย middleware กลาง:
{
"userId": "…", "azureObjectId": "…",
"apps": [
{ "appId": "a1111111-…", "roles": [{ "id": "r001", "name": "AmloAdmin" }] },
{ "appId": "f2222222-…", "roles": [{ "id": "r004", "name": "FxCompliance" }] }
],
"schemaVersion": 1
}
blob แยก role ตาม app ไว้แล้ว — ข้อมูลถูกต้องตั้งแต่ต้นทาง
blob ตัวนี้เป็น cache ไม่ใช่แหล่งข้อมูลจริง
จุดที่ต้องเข้าใจก่อนคุยเรื่องอื่น: สิ่งที่ middleware อ่านตอน request เข้ามา ไม่ใช่ฐานข้อมูล แต่เป็นสำเนาที่ UserService เขียนไว้ล่วงหน้า
sequenceDiagram
autonumber
participant DB as ฐานข้อมูล UserService
participant W as UserService ตอนเขียน cache
participant R as Redis
participant MW as middleware กลาง
W->>DB: อ่าน user role app company
W->>R: เขียน blob ทับ พร้อมตั้งอายุ
Note over R: อยู่ได้ 86400 วินาที แล้วหายไปเอง
MW->>R: อ่าน blob ตอนมี request
alt เจอ
R-->>MW: ใช้ค่านั้นเลย
else ไม่เจอ
MW->>W: ถามตรงผ่าน HTTP
W-->>MW: ข้อมูลล่าสุด
MW->>R: เขียนกลับเข้า cache
end
สิ่งที่ตามมาจากการเป็น cache — สำคัญกับทุกอย่างที่คุยกันต่อจากนี้:
- แก้สิทธิ์ในฐานข้อมูลแล้วไม่มีผลทันที — ต้องรอ blob หมดอายุ หรือมีคนสั่งเขียนใหม่ · ค่าเริ่มต้นของอายุคือ 86400 วินาที (24 ชั่วโมง)
- การถอนสิทธิ์คือฝั่งที่อันตราย — เพิ่มสิทธิ์แล้วยังไม่มีผล คนแค่บ่นว่ากดไม่ได้ · แต่ ถอนสิทธิ์แล้วยังไม่มีผล แปลว่าคนที่ถูกถอนยังใช้สิทธิ์เดิมได้ต่อ จนกว่า cache จะเปลี่ยน
- มีทางสั่งเขียนใหม่ราย user ได้ ไม่ต้องรอครบ 24 ชั่วโมง ⇒ “เปลี่ยนแล้วมีผลทันที” เป็นไปได้จริง แต่ ต้องสั่งเอง ไม่ได้เกิดขึ้นเอง
- ถ้าคำสั่งเขียนใหม่ล้มเหลว จะเงียบ — คนที่กดถอนสิทธิ์เห็นว่าสำเร็จ ทั้งที่ของจริงยังไม่เปลี่ยน
ข้อ 3 กับ 4 คือเหตุผลที่ 02 กำหนดว่าการเขียน cache ใหม่ต้องเป็นส่วนหนึ่งของการแก้สิทธิ์ ไม่ใช่งานที่ยิงทิ้งแล้วไม่สนใจผล
คำว่า "เปลี่ยนแล้วมีผลทันที" ต้องหมายถึง request ที่ตรวจ authorization หลังยืนยัน revoke แล้วไม่สามารถใช้ grant เดิมได้ ไม่ใช่แค่ refresh สำเร็จหนึ่งครั้ง: request ที่อ่าน DB ก่อน revoke อาจเขียน blob เก่าทับภายหลังได้ ต้องมี revision/freshness check และป้องกัน stale write ตามไฟล์ 02; TTL 24 ชั่วโมงเป็นอายุ cache ไม่ใช่ revocation SLA ที่ยอมรับแล้ว และไม่ย้อนยกเลิก operation ที่ได้รับอนุญาตก่อน revoke
ปัญหาที่ 1 — middleware ไม่รู้ว่า request มาจาก app ไหน
blob แยก role ตาม app ไว้ให้แล้ว แต่ middleware ยุบทิ้ง
RedisUserInfoMiddleware.cs:341:
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() — รวม role ของทุก app เข้าด้วยกันด้วยชื่อ
sequenceDiagram
autonumber
participant U as user ที่เป็น Admin ใน FX เท่านั้น
participant MW as middleware กลาง
participant EP as endpoint ของ Platform
U->>MW: เรียก endpoint ของ Platform
MW->>MW: AllRoles ยุบทุก app ได้คำว่า Admin
MW->>EP: claim Role Admin
EP->>EP: Authorize Roles Admin ผ่าน
EP-->>U: 200 ทั้งที่ไม่มีสิทธิ์ใน Platform เลย
ทางแก้ที่ตกลงกันแล้ว — FE ส่ง GUID ของ app ที่ user เลือกอยู่มาใน header · backend เอาไปหาใน apps ของ blob · เจอ ⇒ ใส่ role ของ app นั้น · ไม่เจอ หรือไม่มี role หรือไม่ส่ง header มา ⇒ 401 เพราะเป็นไปไม่ได้ที่จะเรียกในนาม app ที่ตัวเองไม่มี role อยู่เลย
รายละเอียด → 01
ปัญหาที่ 2 — รายชื่อ role ที่เรียกได้ อยู่ในโค้ด
API 1 เส้น วันนี้เรียกได้ 3 role · อนาคต 4 · แล้ว 5
[Authorize(Roles = "A, B, C")] // เพิ่ม D = แก้บรรทัดนี้ = deploy ใหม่
ทุกครั้งที่รายชื่อเปลี่ยน ต้องแก้โค้ดแล้ว deploy · ยิ่ง service เพิ่ม endpoint เพิ่ม ยิ่งหนัก
สิ่งที่ต้องการคือ: เพิ่ม role ได้จากภายนอก โดยไม่แก้โค้ด ไม่ deploy
⇒ รายชื่อ role ต้องออกจากโค้ดไปอยู่ในข้อมูล และบน endpoint เหลือ ชื่อที่ไม่มีวันเปลี่ยน แทน
permission = ชื่อของ endpoint ที่ไม่เปลี่ยน · role = ของที่เปลี่ยน
รายละเอียด → 02
ปัญหาที่ 1 กับ 2 ต่อกันยังไง
ข้อ 1 ตอบว่า request นี้มาจาก app ไหน · ข้อ 2 ตอบว่า สิทธิ์ของ app นั้นพอเรียกเส้นนี้ไหม
ขอบเขตที่พิสูจน์ได้: ข้อ 1 รู้เพียงว่า caller เลือก app context ใดจากสิทธิ์ที่มี ไม่ได้พิสูจน์ app ต้นทาง; ข้อ 2 ต้องเทียบ app context, permission name และ owner service ร่วมกัน การมี ownerService ตรงอย่างเดียวไม่ได้ป้องกันการเปลี่ยน AppId ไปยังอีก app ที่ user มีสิทธิ์อยู่แล้ว การแยก client/session ข้าม app เป็นข้อกำหนดอีกชุดที่ต้องยืนยันก่อน rollout
ข้อ 2 ตั้งอยู่บนข้อ 1 — ถ้าข้อ 1 ยังยุบ role ข้าม app ชุดสิทธิ์ที่ข้อ 2 เอามาเทียบก็จะเป็นของทุก app รวมกัน แล้วปัญหาเดิมจะย้ายลงไปอีกชั้นเดียวเท่านั้น ⇒ ต้องทำข้อ 1 ก่อนเสมอ
ทางที่ไม่เลือก และเหตุผล
ตัดสินที่ APIM ด้วย allow-list ว่า app ไหนเรียก path ไหนได้ — policy ของ APIM ยัง paste เข้า Portal ด้วยมือ และตัวไฟล์ sentinel-facade-inbound-v6.xml:17-18 เตือนตัวเองว่าของที่รันจริงมี drift จากไฟล์ใน repo · Backend_Iac ยังไม่มี overlay ของ prod ⇒ ไม่มีที่ให้ลงค่า · และตัดสินได้แค่ระดับ app มองไม่เห็น role
ใช้ claim จาก token (appid / azp / aud) — เป็นทางที่ดีที่สุดถ้าทำได้ เพราะ Entra เซ็นมาให้ ไม่ต้องเชื่อ header จาก client แต่ SentinelGateway01.API/Config/tenants.json มี ClientId อยู่ 2 ตัวและแบ่งตาม tenant (customer/CIAM กับ employee/workforce) ไม่ได้แบ่งตาม app ⇒ แยก FX ออกจาก Platform ไม่ได้ · ข้อนี้พลิกทันทีถ้า FX จด client registration ของตัวเองใน Entra
เอาสิทธิ์ API ไปใส่ตาราง Permissions ที่มีอยู่ — ตารางนั้น unique ที่ (AppId, Name) และ join กับ Menu ⇒ ถูกออกแบบให้ app เป็นเจ้าของหน้าจอของตัวเอง ไม่ใช่ให้ UserService เป็นเจ้าของ endpoint ของอีก 40+ repo · พอ service เพิ่มขึ้นเรื่อย ๆ จะไม่รอด ⇒ 02 จึงใช้ตารางแยก
ข้อสมมติที่ต้องแก้ก่อนคุยต่อ
[Authorize] ทำ “มี role ใดใน N role ก็พอ” ได้อยู่แล้ว — [Authorize(Roles = "A, B")] คือ OR ไม่ใช่ AND · policy.RequireRole("User", "Admin") ใช้จริงที่ AuthenticationExtensions.cs:135 · ที่ทำไม่ได้คือ AND ซึ่งไม่ใช่โจทย์เรา ⇒ ปัญหาที่ 2 ไม่ได้เกิดจากข้อจำกัดของ framework แต่เกิดจากการที่ รายชื่ออยู่ในโค้ด
“1 APP = 1 USER = 1 ROLE” ฐานข้อมูลไม่ได้บังคับ — unique index จริงคือ (UserId, RoleId, CompanyId) (UserRoleConfiguration.cs:51-53) และ GetUserShellPermissionsHandler.cs:30-31 หยิบ Roles[0] ⇒ role ตัวที่สองหายเงียบอยู่แล้ววันนี้ · แผนไม่ต้องรอแก้ข้อนี้ แค่เขียนให้รองรับหลาย role ตั้งแต่แรก
ชื่อ role ไม่ซ้ำข้าม app ในทางปฏิบัติ ตามที่ทีมยืนยัน แต่ RoleConfiguration.cs:67-68 unique แค่ (AppId, Name) ⇒ ฐานข้อมูลไม่ได้ห้าม · การแยกด้วย AppId จริง ๆ เกิดที่ข้อ 2 ซึ่งเทียบเจ้าของสิทธิ์ ไม่ใช่เทียบชื่อ role