API Restriction · 03 — ลำดับ deploy เรื่องที่ต้องตัดสิน และหลักฐานที่ยังขาด
ทำอะไรก่อนหลัง กลไก observe mode ที่มีอยู่แล้วเกี่ยวตรงไหน 4 เรื่องที่ต้องเคาะก่อนเริ่ม และสิ่งที่ยังไม่ได้ verify
อัปเดต: 2026-09-08
⬅️ 00 — ที่มาและสภาพปัจจุบัน · 01 — role ตาม app · 02 — spec
03. ลำดับ deploy เรื่องที่ต้องตัดสิน และหลักฐานที่ยังขาด
ลำดับ
sequenceDiagram
autonumber
participant P as Backend_Package
participant S as service ที่ adopt
participant F as FE ของแต่ละ app
participant C as config
P->>P: ทำข้อ 1 และ spec ข้อ 2 สวิตช์ปิดอยู่
P->>S: publish แล้ว service bump version
Note over S: พฤติกรรมยังเหมือนเดิมทุกอย่าง
F->>F: ส่ง header AppId ทุก call
C->>S: เปิดสวิตช์ข้อ 1 ทีละ service
Note over S: จุดนี้ 401 ถ้าไม่ระบุ app
S->>S: แปะ RequirePermission ทีละเส้น ผ่านช่วงซ้อมก่อน
สลับไม่ได้ — เปิดสวิตช์ก่อน FE ส่ง header = 401 ทุก app พร้อมกัน
ทีละขั้น
Backend_Packagerelease — งานข้อ 1 + P1 ถึง P6 ของ 02 · ยังไม่มีอะไรทำงาน เพราะสวิตช์ปิดและไม่มี endpoint ไหนแปะ attribute · 🔴 merge เข้าdevelopmentไม่ publish อัตโนมัติ ต้องdotnet nuget pushด้วยมือBackend_UserServicebump แล้วทำ U1 ถึง U5 · blob เริ่มมีapiPermissions- FE ทุก app ส่ง header AppId ครบทุก call
- เปิดสวิตช์ข้อ 1 ทีละ service ผ่าน config ไม่ใช่ผ่านการ deploy โค้ดใหม่
- force refresh blob หรือรอ TTL 86400 วินาที — ข้ามขั้นนี้ = ทุกเส้นที่แปะ
[RequirePermission]จะ 403 พร้อมกัน เพราะapiPermissionsเป็น null - แปะ
[RequirePermission]เส้นแรก พร้อม[RequireAuthorizationObserve]เปิด observe modeLogOnly+OptIn· อ่าน log ว่าใครจะโดน แก้ข้อมูลจนไม่มีใครโดนผิด แล้วสลับEnforce - ทำซ้ำขั้น 6 กับเส้นถัดไป
Gate เพิ่มก่อนขั้น 4-6: U4/U4a ต้อง enforce จริงก่อนเปิด API เปลี่ยน grants; ต้องตกลง app-context threat model, endpoint coverage/HTTP contract, company scope และ revocation SLA พร้อมทดสอบ failure paths ของไฟล์ 01-02 การรอ TTL เป็นเพียงทาง migrate blob รุ่นเก่า ไม่ใช่วิธีรับรอง revoke หรืออนุญาตให้ข้อมูลเก่ากลับมามีผล
ขั้น 6 ต้องคง policy เดิมเป็น hard gate แล้ว shadow เฉพาะ permission ใหม่ หลังผ่าน acceptance gates ให้เปลี่ยน permission เป็น Enforce ขณะยังคง policy เดิม แล้วจึงถอด policy เดิมอย่างควบคุม; role ใหม่ที่ไม่มีสิทธิ์ตาม policy เดิมจะยังถูก deny ในช่วงเปลี่ยนผ่านนี้ ต้องระบุเป็น migration behavior ไม่เปิดทางด้วยการ bypass policy เดิม Rollback ห้ามปิด app-scoped enforcement หรือเปิด LogOnly จน grant ที่ถอนแล้วกลับมาใช้ได้ ให้คืน known-good policy หรือปิด endpoint ที่เสี่ยงชั่วคราว
กลไก E (observe mode) เกี่ยวตรงไหน
E คือ ช่วงซ้อมก่อนบังคับ — เปิด LogOnly แล้วมันจะบันทึกว่า ใครจะโดน 403 โดยยังปล่อยผ่านไปก่อน
มีจริงและ merge แล้วที่ Backend_Package@10.30.0 (src/Middleware/AuthorizationObserve/) · UserService ต่อไว้แล้วที่ SecurityServiceExtensions.cs:56 โดย appsettings.json:240-243 ตั้ง Enabled = false
🔴 ทิศของ E กลับด้านกับกลไก security ตัวอื่น — LogOnly แปลว่า ปล่อยผ่าน ไม่ใช่ปฏิเสธ ⇒ เปิด LogOnly คู่กับ Coverage=RequireAll บน service ที่มี [Authorize(Roles=…)] ใช้งานอยู่ = ถอดด่านของเส้นเหล่านั้นทั้ง service · ต้องเริ่มที่ OptIn เสมอ ซึ่งเป็นค่า default อยู่แล้ว ห้ามเปลี่ยนเพื่อความสะดวก
🔴 E ครอบการปฏิเสธจาก [RequirePermission] ไม่ได้ ต้องแก้ก่อน — AuthorizationObserveResultHandler.cs:124-126 ตัดสินว่าจะสังเกตหรือไม่ จากการ มีอยู่ ของ RolesAuthorizationRequirement ในนโยบายเท่านั้น
- เส้นที่แปะแค่
[RequirePermission]โดยไม่มี[Authorize(Roles=…)]⇒ ปฏิเสธจริงตั้งแต่วันแรก ไม่มีช่วงซ้อม วันเปิดเป็นหน้าผา - ถ้าแปะทั้งคู่ และตัวตรวจ permission ไม่เรียก
context.Fail()⇒LogOnlyจะ ปล่อยการปฏิเสธจาก permission ผ่านไปด้วย = fail-open
ทางแก้: อ่าน authorizeResult.AuthorizationFailure.FailedRequirements แล้วบังคับว่า requirement ที่ fail ทุกตัวต้องเป็นชนิดที่กลไกนี้รับผิดชอบ (งาน P6 ใน 02) — ต้องอยู่ใน release เดียวกับที่ปล่อย [RequirePermission] หรือก่อนหน้า
🔴 และ E มองไม่เห็น policy ที่เขียนด้วย RequireClaim ซึ่งเป็นแบบที่ repo นี้แนะนำเอง — AmloAdminAccess สร้างด้วย policy.RequireClaim(ClaimTypes.Role, amloAdminRoles) (AuthenticationExtensions.cs:153) ซึ่งได้ ClaimsAuthorizationRequirement ไม่ใช่ RolesAuthorizationRequirement ⇒ isRoleOnlyDenial เป็น false เสมอ การปฏิเสธที่ AdminAmloController จึงถูกส่งต่อให้ปฏิเสธจริงทุกโหมด และได้แค่ log LogNotRoleOnlyDenial (:129) ที่บอกว่า “ไม่ได้สังเกตให้” ไม่ใช่ log ที่บอกว่าใครจะโดน
ที่ต้องระวังคือ comment ที่ AuthenticationExtensions.cs:143-148 อธิบายไว้เองว่า จงใจเลือก RequireClaim แทน RequireRole เพราะ RequireRole วิ่งผ่าน IsInRole ซึ่งขึ้นกับ RoleClaimType ⇒ policy ที่เขียนตามแนวทางที่ repo แนะนำ จะมองไม่เห็นด้วย E ทั้งหมด
ข้อแก้ไข: การที่ E ไม่ bypass ClaimsAuthorizationRequirement ของ AmloAdminAccess เป็นพฤติกรรมที่ต้องรักษาใน rollout นี้ ไม่ใช่ข้อบกพร่องที่ต้องขยาย bypass ให้ครอบทุก claim การซ้อม permission ใหม่ทำได้เมื่อ hard gate เดิมผ่านแล้ว; เมื่อ hard gate เดิมไม่ผ่านให้ deny ตามเดิม แม้ permission ใหม่ผ่านก็ตาม สามารถ log ผลของทั้งสองแยกกันได้โดยไม่เปลี่ยนผล authorization
4 เรื่องที่ต้องเคาะก่อนเริ่ม
1. AmloAdminAccess ถอดได้ต่อเมื่อ [RequirePermission] ผ่านช่วง Enforce แล้วเท่านั้น — AdminAmloController.cs:27 วันนี้ใช้ [Authorize(Policy = "AmloAdminAccess")] ซึ่งอ่านรายชื่อ role จาก config · เป้าหมายปลายทางคือถอดออกแล้วใช้ [RequirePermission] แทน แต่ ระหว่างช่วงซ้อม (LogOnly) ห้ามถอด — แปะทั้งคู่ไว้ก่อน (AND ต้องผ่านทั้งสอง) เพื่อให้ AmloAdminAccess เดิมยังเป็นด่านจริงตลอดช่วงที่ [RequirePermission] ยังไม่ผ่านการซ้อม ต่อเมื่อ log ยืนยันว่าไม่มีใครโดนผิดและสลับเป็น Enforce แล้วเท่านั้นจึงถอด AmloAdminAccess ได้ · ถอดก่อนช่วงนี้ = มีหน้าต่างที่ endpoint ปลดล็อกบริษัทไม่มีด่านจริงเลย
2. ใครผูกสิทธิ์ข้าม app ได้ — ตาราง RoleApiPermission ไม่มี FK ⇒ ไม่มีอะไรบล็อกทางเทคนิค ต้องกำหนดเองว่าใครทำได้ · ถ้าเปิดให้ admin ของทุก app ⇒ admin ของ FX ผูกสิทธิ์ที่ UserService เป็นเจ้าของให้ role ตัวเองได้ = ยกสิทธิ์ให้ตัวเอง · ข้อเสนอ: เฉพาะ platform-admin ซึ่งพึ่งเรื่องที่ 3 และ U4a ใน 02 — ถ้า AssignShellRoleAsync/AssignCompanyRoleAsync ยังไม่ gate caller privilege การจำกัดว่าใครเรียก RoleApiPermission API ได้ก็ไม่มีความหมาย เพราะ user คนไหนก็ assign privileged role ให้ตัวเองตรง ๆ ได้อยู่ดี โดยไม่ต้องแตะ API ที่ถูกจำกัดเลย
3. admin API ที่คุมสิทธิ์ทั้งระบบ มีแค่ [Authorize] เปล่า — PermissionController.cs:23 และ RolePermissionController.cs:24 ⇒ ใครก็ตามที่ login เข้าระบบได้ เรียก API เหล่านี้แก้สิทธิ์ของทุก role ได้ · การย้ายการตัดสินใจเรื่องสิทธิ์ไปอยู่ในข้อมูล ก่อนปิดช่องนี้ = ย้ายไปไว้ในที่ที่ป้องกันน้อยกว่าเดิม ต้องปิดก่อน ไม่ใช่หลัง
3a. assign role เองก็ไม่มีด่านเหมือนกัน และอันตรายกว่าข้อ 3 — UserRoleController.cs:32 มี [Authorize] เปล่าที่ระดับ class โดยไม่มีอะไรเพิ่มบน AssignShellRoleAsync (:114) AssignCompanyRoleAsync (:70) RemoveShellRoleAsync (:132) RemoveCompanyRoleAsync (:89) และ RoleController.cs:23 ก็เหมือนกัน · AssignShellRoleHandler ตรวจว่า user มีจริง role มีจริงและ active ไม่ซ้ำ แต่ไม่ตรวจว่า caller มีสิทธิ์ assign role นั้นไหมเลย ⇒ ใครก็ตามที่ login ได้ ยกตัวเองเป็น role ที่ active อยู่ในระบบได้ทันที โดยไม่ต้องแตะ RoleApiPermission เลยสักแถว · ถ้าไม่ปิดพร้อมข้อ 3 ด่านที่กำลังสร้างทั้งหมดไร้ความหมาย (งาน U4a ใน 02)
4. /users/me เปลี่ยนพฤติกรรม — วันนี้ไม่มี [Authorize] เลย (UserController.cs:127-132, class ก็ไม่มี, ApiControllerBase.cs:35 มีแค่ [ApiController]) doc comment เขียนเองว่า “Returns 200 with empty data if unauthenticated” · การเติม [Authorize] ทำให้ caller ที่ไม่ได้ login ได้ 401 แทน 200 = breaking change บนเส้นที่ทุก app เรียก ต้องยืนยันว่าไม่มี FE ตัวไหนพึ่งพฤติกรรมเดิม
5. AdminAmloController เป็น company-scoped operation แต่ permission model นี้เป็น global ต่อ role — role model ของสิทธิ์ API (ข้อ 2 ของ 02) ไม่มีมิติ company เลย ส่วน AMLO unblock ผูกกับ companyId ในทุก endpoint · ถ้า role หนึ่งได้ amlo:company:unblock role นั้นปลดล็อกทุกบริษัทได้ ไม่ใช่แค่บริษัทที่ user เกี่ยวข้อง — เดิม AmloAdminAccess ก็เป็น global อยู่แล้วจึงไม่ regress แต่การย้ายมาที่ RoleApiPermission ทำให้เพิ่ม role ใหม่ที่ตั้งใจจะจำกัดแค่บางบริษัทไม่ได้ ⇒ ต้องเคาะว่ายอมรับ global-only ต่อไป หรือต้องเพิ่มมิติ company ก่อนใช้กับ endpoint ที่ scope เป็นบริษัท
เกณฑ์ปิดข้อ 5: ต้องแยก target resource ออกจาก assignment scope ชัดเจน role ที่ assign ในบริษัท A ไม่ได้รับสิทธิ์บริษัท B เพียงเพราะชื่อ permission ตรงกัน แม้ global template role จะใช้ซ้ำหลายบริษัทได้ ก็ห้ามทิ้ง CompanyId ของ assignment; rollout แบบ global-only ต้องรับเฉพาะ assignment ที่ยืนยันว่า global และมีอนุมัติ global access ของ operation นั้น มิฉะนั้น block endpoint ไว้ก่อน
เรื่องรอง: POST /user-cache/refresh/{userId} เป็น anonymous (UserCacheController.cs:19 บอกว่าตั้งใจ เพื่อไม่ให้ login-time warming พัง) — พอ blob เริ่มถือสิทธิ์ API ควรทบทวนว่ายังปล่อย anonymous ได้ไหม
หลักฐานที่ยังขาด
| # | เรื่อง | บล็อกอะไร |
|---|---|---|
| 1 | FE ทำอะไรเมื่อเจอ 401 จากข้อ 1 — ถ้า interceptor refresh วนจะเป็นลูป | ขั้นที่ 4 |
| 2 | ServiceIdentity:ServiceName ตั้งครบทุก service ทุก env หรือยัง | ขั้นที่ 6 |
| 3 | SupApp_util_lib 10.30.0 อยู่บน feed แล้วหรือยัง — merge เข้า development ไม่ publish ให้ | ขั้นที่ 1 |
| 4 | FX เรียกเข้ามาทางไหนบ้าง — ถ้ามี server-to-server สมมติฐานเรื่อง header ของข้อ 1 ต้องคิดใหม่ | ทั้งแผน |
| 5 | AppId (GUID) จริงของแต่ละ app | ขั้นที่ 3 |
ข้อ 6 ไม่บล็อก แต่ตอบว่า convention ที่ตั้งไว้ยังจริงไหม — มี user ที่ถือหลาย role ใน app เดียวหรือยัง:
SELECT ur."UserId", r."AppId", COUNT(*)
FROM "UserRoles" ur
JOIN "Roles" r ON r."Id" = ur."RoleId"
GROUP BY 1, 2
HAVING COUNT(*) > 1;
ข้อ 7 เป็น rollout blocker: ต้องมี numeric revocation SLA ที่อนุมัติ, authority/revision/freshness contract, backend-owned durable refresh และผลทดสอบว่า stale grant ใช้ต่อเกิน SLA ไม่ได้ การแจ้ง error ให้ admin UI หรือเห็น refresh สำเร็จอย่างเดียวไม่พอ ต้องพิสูจน์ race, retry, outage และ partial fan-out ตามไฟล์ 02 ด้วย
Security acceptance gates เพิ่มเติม 08/09/2026
A1 - สิทธิ์จัดการ: authenticated user ปกติและ app-admin ที่ไม่มี grant-management privilege ต้อง assign/remove privileged role, เปลี่ยน role หรือผูก permission ข้าม app ไม่ได้ ไม่มี DB mutation/cache refresh ที่ให้สิทธิ์เกิดขึ้นจากคำขอที่ถูก deny; platform-admin ที่ได้รับอนุญาตต้องทำงานได้ตามขอบเขตที่กำหนด
A2 - Policy migration: ทดสอบ old policy ผ่าน/ไม่ผ่าน เทียบกับ new permission ผ่าน/ไม่ผ่านครบทั้ง 4 คู่ใน LogOnly และ Enforce เมื่อยังคง old policy; LogOnly อนุญาตเฉพาะ old-pass/new-deny และ old-pass/new-pass ส่วน Enforce อนุญาตเฉพาะทั้งคู่ผ่าน ทุกกรณีต้องผ่าน hard prerequisites อยู่ก่อนแล้ว ต้องทดสอบ explicit Fail(), company/freshness denial และ empty/unknown FailedRequirements ว่าไม่ถูก bypass
A3 - App context: header หาย/ซ้ำ/ผิดรูปแบบ, app ที่ไม่เป็นสมาชิก, ชื่อ role ซ้ำข้าม app และ user ที่ถือหลาย app ต้องให้ผลตาม HTTP/threat-model contract; เปลี่ยนจาก FX ไป Platform ที่ user มีสิทธิ์อยู่แล้วต้องไม่ถูกอ้างว่าเป็นการพิสูจน์ app ต้นทาง และ phase 1 อย่างเดียวต้องไม่ถูกอ้างว่าบังคับ endpoint ownership ครบแล้ว
A4 - Fail-closed: JWT ที่ยัง valid และมี privileged role ต้องไม่เข้า protected endpoint เมื่อไม่มี OID/user info, schema mismatch, ข้อมูลเสีย, dependency outage, freshness พิสูจน์ไม่ได้, permissions เป็น null หรือ ServiceIdentity ไม่ถูกต้อง ทั้ง LogOnly/Enforce ต้องตรวจ side effect ไม่เกิด ไม่ใช่ดู status code อย่างเดียว ส่วนข้อมูลที่ครบและสดแต่มี permission name/owner ไม่ตรง หรือเป็นรายการว่างที่ยืนยันแล้ว คือ ordinary absent grant: ห้าม Succeed permission requirement แต่ LogOnly อาจผ่านตาม A2 เมื่อด่านเดิมผ่าน ไม่ใช่ hard failure แบบ dependency outage
A5 - Revoke: ทดสอบปิด admin UI หลังส่งคำขอ, crash หลัง DB commit, Redis outage, role ขนาดใหญ่, delayed HTTP warm ก่อน revoke, stale write หลัง cache eviction และ retry สลับลำดับ ต้องมี recovery ที่ไม่ฟื้น grant เก่า ไม่รายงาน revoke complete ก่อนมีผล และ deny request ที่ใช้สิทธิ์เก่าหลังครบ SLA ที่อนุมัติ รวมการ remove/disable role และเปลี่ยน membership
A6 - Company/resource scope: user ที่เป็น admin ในบริษัท A แต่เป็น user ธรรมดาหรือไม่เป็นสมาชิกในบริษัท B ต้องอ่าน/แก้ resource ของ B ไม่ได้ด้วย grant ของ A; explicit global assignment ให้ข้ามบริษัทได้เฉพาะ operation ที่อนุมัติ global access ไว้ ต้องทดสอบหลาย role ภายใน app เดียวโดยไม่ทำ scope หาย
A7 - Release evidence: verify config parity ทุก env, header forwarding/CORS และ FE error handling; health/bootstrap/S2S ใช้ policy ที่ประกาศไว้ ไม่กลายเป็น bypass ของ protected endpoint ต้องมีหลักฐาน negative tests และ rollback ที่ไม่ลด security ก่อนเปลี่ยน Enforce เอกสารนี้เพิ่มเกณฑ์ทดสอบเท่านั้น ยังไม่ได้รัน backend/runtime acceptance tests เหล่านี้
ต้นทุน
- ทุกอย่างที่แตะ
Backend_Package⇒ publish ด้วยมือ แล้ว ~13-14 service ต้อง bump และ redeploy เอง · service ที่ยังไม่เปิดสวิตช์และไม่แปะ attribute ไม่พัง - ตารางใหม่
RoleApiPermission= migration 🔴 ต้อง author บน branchMIGRATIONตามกฎของ repo - Backend_Iac ต้องตรวจ/เติม config parity ของ header name, enforcement switch และ ServiceIdentity ตามไฟล์ 01-02 ทุก env ที่กระทบ รวมวิธี rollout config จริง ห้ามถือว่าปรับ config แล้วมีผลบน service ทันทีหากยังต้อง build image/redeploy