API Restriction · 04 — implementation
ไฟล์ที่ dev เปิดแล้วลงมือได้ — ข้อขัดแย้งที่ตัดสินแล้ว งานต่อ repo พร้อมชื่อ type จริง test charter ต่อ gate และรายการที่ยังตัดสินไม่ได้ซึ่งบล็อก rollout
อัปเดต: 2026-09-08
อ่านก่อน: 00 ที่มา · 01 app context · 02 spec · 03 rollout
04. implementation
ไฟล์นี้รวมข้อกำหนดจาก 00-03 ทั้งฉบับเดิมและที่ security review เพิ่มเมื่อ 08/09/2026 ให้เป็นชุดเดียวที่ลงมือได้ · ตรวจกับ Backend_UserService@25b1923 และ Backend_Package@10.30.0
สิ่งที่ไฟล์นี้ไม่ใช่ — ไม่ใช่หลักฐานว่าอะไรถูก implement แล้ว ทุกบรรทัดคือสิ่งที่ยังต้องเขียน · รายการ UNDECIDED ท้ายไฟล์ บล็อก rollout ไม่ใช่ของค้างที่ค่อยตามเก็บ
1. ข้อขัดแย้งระหว่างเอกสาร ที่ตัดสินแล้วในไฟล์นี้
เอกสาร 00-03 มีจุดที่เนื้อความเดิมกับข้อกำหนดของ security review ขัดกัน · implementation ยึดตามนี้ โดยข้อกำหนดที่แก้ในไฟล์นี้มี precedence สำหรับงาน implementation; มติที่ยังเป็น UNDECIDED ไม่ถือว่าได้รับอนุมัติแล้ว
1.1 HTTP status — ยึดสัญญาใหม่ ไม่ใช่ “401 ทุกกรณี”
เนื้อความเดิมใน 01 และ diagram ใน 03 บอกว่าทุกกรณีที่ระบุ app ไม่ได้ ⇒ 401 · security review แย้งว่าต้องแยกตามสาเหตุ ไฟล์นี้ยึดตามสัญญาใหม่
| สาเหตุ | status | เหตุผล |
|---|---|---|
| authentication ไม่ผ่าน | 401 | ไม่มี identity ให้ตัดสิน |
| header ขาด / ส่งซ้ำหลายค่า / ไม่ใช่ GUID | 400 | คำขอผิดรูปแบบ ไม่ใช่เรื่องสิทธิ์ |
| authenticated แต่ไม่ได้เป็นสมาชิก app นั้น / ไม่มี role ใน app นั้น | 403 | ตัวตนถูก แต่ไม่มีสิทธิ์ |
| ตรวจสิทธิ์ไม่ได้เพราะ dependency ล้ม (Redis + HTTP fallback ตายทั้งคู่ / freshness ยืนยันไม่ได้) | 503 | ไม่ใช่ความผิดของ caller และต้องไม่ทำให้ FE คิดว่า token เสีย |
ใครตอบรหัสไหน — middleware เป็นคนตอบ 400 / 401 / 403 / 503 ทั้งหมดใน §3.1 เพราะมันหยุด request ก่อนถึง authorization · handler ของ permission (§3.3) ทำงานหลัง middleware ปล่อยผ่านแล้วเท่านั้น ⇒ กรณีที่ handler เจอ ApiPermissions == null หรือ context หาย แปลว่า middleware พลาด ให้ Fail() เป็นตาข่ายชั้นสอง (ออกมาเป็น 403) และ log ระดับ error เพราะเป็นสัญญาณว่าลำดับ pipeline ผิด ไม่ใช่เส้นทางปกติ
🔴 เหตุผลที่ต้องแยก ไม่ใช่แค่ความสวยงาม — FE interceptor ส่วนใหญ่ผูก 401 กับการ refresh token · ตอบ 401 ตอน Redis ล่ม จะทำให้ FE ทั้งระบบเข้า refresh loop พร้อมกันตอนที่ backend กำลังแย่อยู่แล้ว
⚠️ ข้อนี้กลับมติที่เคยตกลงไว้ (“ไม่เจอ/ไม่ส่ง header ⇒ 401”) — เจตนาเดิมคือ fail-closed ซึ่งยังคงอยู่ครบ ไม่มีกรณีไหนที่ปล่อยผ่าน เปลี่ยนแค่รหัสที่ตอบกลับ · ถ้า Owner ยืนยัน 401 ทุกกรณี ให้กลับข้อนี้ก่อนเริ่มเขียน และต้องแก้ FE interceptor ก่อนเปิดสวิตช์
1.2 ขอบเขตของ P6 — ไม่ขยาย bypass ให้ ClaimsAuthorizationRequirement
ผมเคยเสนอ (commit cf2a91c) ให้ P6 นับ ClaimsAuthorizationRequirement เป็นชนิดที่สังเกตได้ เพื่อให้ AmloAdminAccess มีช่วงซ้อม · security review แย้งว่า ห้าม และข้อแย้งนั้นถูก ไฟล์นี้ถอนข้อเสนอเดิมของผม
AmloAdminAccess เป็น hard gate ที่ต้องคงไว้ตลอด rollout การทำให้ observe mode ปล่อยผ่านการปฏิเสธของมันได้ = เปิดช่องที่ endpoint ปลดล็อกบริษัทไม่มีด่านจริงระหว่างช่วงซ้อม ซึ่งแย่กว่าการไม่มีช่วงซ้อม
ผลที่ต้องยอมรับและเขียนไว้ให้ชัด:
AdminAmloControllerไม่มีช่วงซ้อมสำหรับด่านเดิม — ซ้อมได้เฉพาะ permission ใหม่ ในกรณีที่ด่านเดิมผ่านอยู่แล้ว (คู่ old-pass/new-deny ของ A2)- role ใหม่ที่ยังไม่อยู่ใน
AmloAdmin:AllowedRolesจะยังถูกปฏิเสธตลอดช่วงเปลี่ยนผ่าน แม้ผูกRoleApiPermissionให้แล้ว — นี่คือ migration behavior ที่ตั้งใจ ไม่ใช่บั๊ก ห้ามแก้ด้วยการ bypass ด่านเดิม - ถอด
AmloAdminAccessได้หลังสลับEnforceและยืนยันผลแล้วเท่านั้น
1.3 การเปิดสวิตช์ไม่ใช่ config toggle — ต้อง redeploy
เนื้อความใน 03 และร่างแรกของไฟล์นี้เขียนว่าเปิดสวิตช์ “ผ่าน config ไม่ใช่ผ่านการ deploy โค้ดใหม่” · ไม่จริงกับระบบนี้
CI ทำ overlay ทับ appsettings.{ENV}.json ด้วยไฟล์จาก Backend_Iac แบบ full-file copy ตอน build image ⇒ แก้ค่าใน IaC แล้วต้อง build + redeploy ถึงจะมีผลบน cluster
ผลที่ต้องยอมรับ:
- ขั้นที่ 5 (เปิด
EnforceAppContext) = redeploy ต่อ service ไม่ใช่ toggle - rollback ด้วยการปิดสวิตช์ก็ช้าเท่ากัน ⇒ ทางที่เร็วจริงเมื่อมีปัญหาคือ ปิด endpoint ที่มีปัญหา ไม่ใช่หวังพึ่งสวิตช์
- ยิ่งย้ำว่าห้าม rollback ด้วยการเปิด
LogOnly(ดูข้อ 7)
1.4 fail-closed ต้อง short-circuit ในตัว middleware ไม่ใช่ฝากไว้ปลายทาง
RedisUserInfoMiddleware.InvokeAsync (:83-91) เรียก _next(context) เสมอ และ :351-353 ใส่ Redis identity ไว้หน้า JWT identity โดย เก็บ JWT identity ไว้ด้วย ⇒ ถ้า enrichment ล้มเหลว JWT identity ยังอยู่ และ role claim ที่ติดมากับ JWT ยังทำให้ [Authorize(Roles=…)] ผ่านได้
⇒ การบังคับ app context ต้องเป็นการหยุด request ในตัว middleware เอง เมื่อสวิตช์เปิด ไม่ใช่การหวังว่า endpoint ปลายทางจะปฏิเสธให้
- สวิตช์ ปิด → พฤติกรรมเดิมทุกอย่าง เรียก
_nextเสมอ ไม่มีอะไรเปลี่ยน - สวิตช์ เปิด และ endpoint อยู่ในขอบเขต → ตรวจ app context ให้จบก่อน · ไม่ผ่าน = เขียน response แล้ว ไม่เรียก
_next - 🔴 ห้ามใช้ role จาก JWT identity แทน app context ที่ตรวจไม่สำเร็จ ไม่ว่ากรณีใด
success path ก็ต้อง isolate role ด้วย — ClaimsPrincipal.IsInRole() ตรวจทุก identity ไม่ใช่เฉพาะตัวแรก · ทดลอง principal ที่ Redis identity มี Member แต่ JWT identity มี PlatformAdmin ได้ IsInRole("PlatformAdmin") == true แม้เลือก app ที่ไม่มี role นี้แล้ว ⇒ แค่เปลี่ยน AllRoles เป็น role ของ app ที่เลือกยังไม่พอ ต้องกัน role จาก identity อื่นในข้อ 3.1 และพิสูจน์ด้วย A3
2. เงื่อนไขที่ต้องเสร็จก่อนเริ่มเขียนโค้ดของ feature นี้
ทั้ง 4 ข้อนี้ไม่ใช่ส่วนหนึ่งของ feature แต่ถ้าไม่เสร็จ feature นี้ไม่มีความหมาย
| # | เงื่อนไข | ทำไมต้องก่อน |
|---|---|---|
| G1 | gate PermissionController.cs:23 และ RolePermissionController.cs:24 (วันนี้เป็น [Authorize] เปล่า) | ย้ายการตัดสินใจเรื่องสิทธิ์ไปอยู่ในข้อมูล ก่อนปิดช่องนี้ = ย้ายไปไว้ในที่ที่ป้องกันน้อยกว่าเดิม |
| G2 | gate UserRoleController.cs:32 (AssignShellRoleAsync:114 · AssignCompanyRoleAsync:70 · RemoveShellRoleAsync:132 · RemoveCompanyRoleAsync:89) และ RoleController.cs:23 · handler ต้องตรวจสิทธิ์ของ caller ไม่ใช่แค่ตรวจว่า user/role มีจริง (AssignShellRoleHandler วันนี้ตรวจแค่ user มีจริง · role มีจริงและ active · ไม่ซ้ำ) | ใครที่ login ได้ ยกตัวเองเป็น role ที่ active อยู่ในระบบได้ทันที โดยไม่ต้องแตะตารางใหม่เลยสักแถว |
| G3 | ตัดสินและ implement platform-admin bootstrap — ใครถือ role ที่ผ่าน G1/G2 ในวันแรก และมาได้ยังไง | G1/G2 gate ด้วย privileged role · ถ้าไม่มีใครถือ ระบบล็อกตัวเอง · ถ้าแจกมั่ว gate ไร้ความหมาย → UNDECIDED-3 |
| G4 | ตัวเลข revocation SLA ที่อนุมัติแล้ว | ทุกอย่างในไฟล์ 02 เรื่อง freshness วัดกับตัวเลขนี้ · ไม่มีตัวเลข = ทดสอบไม่ได้ = rollout ถูกบล็อก → UNDECIDED-2 |
🔴 G1 กับ G2 ต้องเสร็จ พร้อมกัน — ปิดแค่ G1 ยังยกสิทธิ์ตัวเองผ่าน G2 ได้ · ปิดแค่ G2 ยังผูก permission ให้ role ที่ตัวเองถืออยู่ผ่าน G1 ได้
3. Backend_Package
3.1 app context (ปัญหาที่ 1)
ไฟล์ที่แตะ: src/Middleware/RedisUserInfo/
| สิ่งที่เพิ่ม | รายละเอียด |
|---|---|
RedisUserInfoOptions.AppContextHeaderName | string · default "X-App-Id" (ยืนยันชื่อจริงกับ FE ก่อน) |
RedisUserInfoOptions.EnforceAppContext | bool · default false |
AppContextResult | record เก็บ AppId · Roles · ApiPermissions · Revision พร้อมหลักฐาน freshness ตาม UNDECIDED-1 · ต้องรักษา assignment/company scope ตาม UNDECIDED-4 ไม่ใช่ flatten เป็นชื่อ role อย่างเดียว |
HttpContext.Items["SupApp.AppContext"] | ที่เก็บ context ที่ตรวจแล้ว · policy handler อ่านจากที่นี่เท่านั้น |
HttpContextExtensions.GetAppContext() | ตัวอ่านฝั่ง consumer |
ลำดับใน InvokeAsync เมื่อ EnforceAppContext = true และ endpoint อยู่ในขอบเขต:
1. ไม่ authenticated → 401 ไม่เรียก _next
2. header ขาด / มีมากกว่า 1 ค่า / ไม่ใช่ GUID → 400 ไม่เรียก _next
3. อ่าน user info ไม่ได้ทั้ง Redis และ HTTP fallback → 503 ไม่เรียก _next
4. schemaVersion ไม่ตรง / JSON เสีย / decrypt ล้ม → 503 ไม่เรียก _next
5. revision/freshness หายหรือยืนยันไม่ได้ → 503 ไม่เรียก _next
6. AppId ที่ส่งมาไม่อยู่ใน apps ของ user → 403 ไม่เรียก _next
7. อยู่ใน apps แต่ Roles ว่าง → 403 ไม่เรียก _next
8. ApiPermissions ของ app ที่เลือกเป็น null → 503 ไม่เรียก _next
9. ผ่านหมด → เก็บ AppContext ลง HttpContext.Items
→ expose role เฉพาะ app/scope ที่ตรวจแล้ว
→ เรียก _next
ApiPermissions == [] คือ snapshot ที่ครบแต่ไม่มี grant ไม่ใช่ข้อมูลเสีย ⇒ ผ่าน middleware ได้ แล้วให้ permission handler ตัดสิน ordinary absent grant · null คือ snapshot ยังไม่พร้อม ⇒ ตอบ 503 ที่ middleware; ถ้าหลุดถึง handler จึงเป็น defensive Fail()/403 ตามข้อ 1.1 · สัญญานี้ทำให้ต้อง re-warm ก่อนขั้น 5 ไม่ใช่เฉพาะก่อนแปะ permission
🔴 ข้อ 7 ตรวจได้แค่ “ว่างหรือไม่ว่าง” — UserRoleInfo (UserInfoForRedis.cs:190-205) มีแค่ Id / Name / Description / RoleType ไม่มี field บอกว่า active ⇒ role ที่ถูก disable ต้องถูกกรองทิ้ง ที่ต้นทางตอนเขียน blob (งาน U3) ไม่ใช่ที่ middleware · ถ้า U3 ไม่กรอง ด่านนี้จะปล่อย role ที่ปิดไปแล้วผ่าน
🔴 ข้อ 0 ที่ขาดไปจากลำดับ: ไม่มี OID ใน principal → 503 ไม่เรียก _next — เป็นคนละเคสกับ “ไม่ authenticated” และวันนี้ GetOid คืน null ได้โดยไม่มีใคร handle
🔴 request ที่ไม่ match route ใด ๆ (GetEndpoint() เป็น null) ⇒ ถือว่าอยู่ในขอบเขต ไม่ใช่ยกเว้น — ไม่งั้น 404 กลายเป็นช่องข้ามการตรวจ
ข้อสมมติเรื่องลำดับ pipeline ที่ต้องยืนยันต่อ service — การอ่าน endpoint metadata ได้ ต้องมี routing ทำงานก่อน UseRedisUserInfo() · ตรวจแล้วใน UserService: UseAuthentication() (PipelineExtensions.cs:63) → UseRedisUserInfo() (:103) → UseAuthorization() (:106) และไม่มี UseRouting() คั่นหลังจากนั้น ⇒ GetEndpoint() มีค่า · service อื่นต้องตรวจซ้ำก่อนเปิดสวิตช์ ถ้า service ไหนเรียก UseRouting() หลัง UseRedisUserInfo() การยกเว้นจะพังเงียบ
self-warm อยู่ก่อนด่านนี้ด้วย — PipelineExtensions.cs:67-100 เรียก KeyExistsAsync / WarmByOidAsync ก่อน UseRedisUserInfo() โดยไม่มี local dependency-error handling · ถ้า throw ตรงนั้น request ไม่ถึง middleware ที่จะตอบ 503 ⇒ service ต้องจัดการ failure ของ self-warm ให้เข้าผลลัพธ์เดียวกัน ไม่ปล่อยผ่าน protected endpoint และไม่กลืน error เป็น success · A4 ต้องทดสอบผ่าน pipeline จริงของ UserService รวม Redis outage ไม่ใช่ทดสอบ package middleware แยกตัวเดียว
🔴 ข้อ 2 ต้องนับจำนวนค่าใน header จริง ๆ — Request.Headers[name] เป็น StringValues ที่รับหลายค่าได้ · .ToString() จะเอามาต่อกันด้วย comma แล้ว Guid.TryParse จะ fail กลายเป็น 400 พอดี แต่ให้เขียนเช็ค .Count != 1 ให้ชัด อย่าพึ่งผลข้างเคียง
🔴 คง authentication และ non-role claims แต่ห้ามคง role ที่ข้าม app ใน authorization principal — เมื่อสวิตช์เปิด ให้สร้าง request-local principal ที่ Redis identity อยู่ก่อน JWT identity เพื่อให้ FindFirstValue(NameIdentifier) ยังคืน internal userId · clone identity เดิมโดยไม่ expose role claims เดิมที่ IsInRole หรือ role-claim policy อ่านได้ แล้วเติมเฉพาะ role ของ app/scope ที่ตรวจแล้ว; ต้องตรวจทั้ง RoleClaimType และ claim type ที่ policy เดิมใช้งานจริง ไม่ลบทิ้งเฉพาะ ClaimTypes.Role แบบเดา
ด่านเดิมที่ตรวจ non-role claims ยังต้องทำงานเหมือนเดิม · ถ้ามี authentication scheme/claims transformation ที่สร้าง principal ใหม่หลัง middleware ต้อง isolate ที่ principal สุดท้ายด้วย หรือ block rollout ของ endpoint นั้น · เมื่อสวิตช์ปิด พฤติกรรมเดิมไม่เปลี่ยน · ห้าม expose company-scoped role เป็น app-wide role บน endpoint ที่ยังไม่มี company gate
3.2 ขอบเขต endpoint — ประกาศจากฝั่ง server เท่านั้น
EnforceAppContext = true ไม่ได้แปลว่าทุก endpoint ต้องมี app context — เส้นที่ไม่ควรมีต้องประกาศไว้ที่ตัว endpoint
| สิ่งที่เพิ่ม | รายละเอียด |
|---|---|
SkipAppContextAttribute | ระดับ method เท่านั้น สำหรับ controller action |
ISkipAppContextMetadata | marker ที่ endpoint ซึ่งไม่มี method ให้แปะ ใช้ผ่าน .WithMetadata(...) |
🔴 attribute อย่างเดียวไม่พอ และข้อนี้ทำให้ pod ตายได้จริง — endpoint ที่ต้องยกเว้นหลายตัว ไม่มี method ให้แปะ attribute:
| endpoint | ประกาศที่ | ยกเว้นยังไง |
|---|---|---|
/health | PipelineExtensions.cs:152 MapHealthChecks | .WithMetadata(...) |
| OpenAPI endpoint | :143 MapOpenApi | .WithMetadata(...) |
| Swagger JSON / UI | UseSwagger() / UseSwaggerUI() ก่อน MapOpenApi | เป็น middleware ไม่ใช่ mapped endpoint; .WithMetadata(...) บน MapOpenApi ไม่ครอบ สองเส้นนี้ |
| static assets | :161 MapStaticAssets | .WithMetadata(...) |
| request ที่ไม่ match route | ไม่มี endpoint | ไม่ยกเว้น — ถือว่าอยู่ในขอบเขต |
Swagger ต้องจัด pipeline แยกให้ชัด — ปิดเมื่อไม่จำเป็น หรือวาง Swagger middleware ใน server-configured branch ก่อน app-context enforcement พร้อม access policy ของเอกสารตาม environment · ห้ามแก้ด้วยการยกเว้น GetEndpoint() == null ทั้งหมด หรือยกเว้นทุก path ที่ขึ้นต้นด้วย /swagger แล้วปล่อยผ่านไปยัง protected action · A7 ต้องยิง Swagger JSON/UI จริง ไม่ใช่ทดสอบแค่ MapOpenApi
⚠️ ถ้าลืม /health — probe เป็น anonymous ⇒ เข้าเงื่อนไขข้อ 1 ของ §3.1 ได้ 401 ⇒ readiness fail ทำให้ pod ถูกถอนจาก Service endpoints และหยุดรับ traffic · readiness fail อย่างเดียวไม่ restart pod; ถ้า liveness/startup probe ใช้เส้นเดียวกันและ fail ถึง threshold จึงเกิด container restart · ต้องตรวจ probe ของ service จริงก่อนเปิดสวิตช์
🔴 ห้ามแปะระดับ class บน controller ที่มีทั้งเส้น anonymous และเส้น protected ปนกัน — UserController เป็นตัวอย่างจริง มี GetActiveUserIds (:61-68) ที่เป็น [AllowAnonymous] สำหรับ NotificationService ปนอยู่กับเส้นอื่น ⇒ แปะระดับ class = ปลดทั้ง controller
🔴 caller ต้องไม่มีทางทำให้ตัวเองถูกยกเว้นได้ — การยกเว้นตัดสินจาก endpoint metadata ฝั่ง server เท่านั้น ห้ามมีเงื่อนไขแบบ “ถ้าไม่ส่ง header ก็ข้ามการตรวจ”
กลุ่มที่ต้องจัดประเภทก่อนประกาศยกเว้น (ไล่ให้ครบก่อนเปิดสวิตช์ในแต่ละ service): login / bootstrap · health และ readiness probe · Swagger และ static · เส้น [AllowAnonymous] ที่มีอยู่แล้ว · เส้น server-to-server · ไม่ใช่ให้ยกเว้นทุกเส้นในกลุ่มโดยอัตโนมัติ โดยเฉพาะ [AllowAnonymous] เดิมที่เป็นช่องโหว่ต้องแก้ auth ก่อน; SkipAppContext ข้ามเฉพาะ user app context ไม่ข้าม S2S authentication/authorization
🔴 UserCacheController ทั้ง controller เป็น [AllowAnonymous] ระดับ class (:23) route จริงคือ api/user-service/v{version:apiVersion}/cache/users ⇒ ครอบ ทั้ง POST warm/{oid} (:40) · POST refresh/{userId} (:68) · DELETE {userId} (:56) และ comment ที่ :15-21 เขียนเองว่า [RequireApiKey] ถูก comment ทิ้ง เหลือ network isolation อย่างเดียว
⇒ ทั้งสามเส้นเปลี่ยน cache state ได้ รวม warm/{oid} · การเข้าถึงจาก Internet ยังต้องตรวจ network/APIM จริง ไม่อนุมานจาก attribute อย่างเดียว แต่ใน application ไม่มี caller gate ตามที่กล่าวข้างบน · ก่อนขั้นที่ 3 ต้องมี authenticated S2S policy และสิทธิ์แยกตาม operation ที่คง login-time warming ของ Sentinel พร้อมปฏิเสธ caller ที่ไม่มีสิทธิ์บน warm/refresh/delete; การแปะ [Authorize] ทับ class-level [AllowAnonymous] ไม่ได้แก้ปัญหา ต้องถอน anonymous metadata จากเส้นที่ต้อง protected ด้วย
3.3 permission (ปัญหาที่ 2)
ไฟล์ที่แตะ: src/Authorization/ (โฟลเดอร์ใหม่) และ src/Extension/
| # | type | หน้าที่ |
|---|---|---|
| P1 | RequirePermissionAttribute | AttributeUsage(Method) · รับชื่อ permission หนึ่งค่า |
| P2 | PermissionRequirement | IAuthorizationRequirement ถือชื่อ permission |
| P2 | PermissionAuthorizationHandler | AuthorizationHandler<PermissionRequirement> |
| P3 | PermissionPolicyProvider | IAuthorizationPolicyProvider สร้าง policy จากชื่อแบบ dynamic |
| P4 | PermissionOptions.OwnerServiceName | ดูข้อ 3.3.1 — ใช้ ServiceIdentity:ServiceName ตรง ๆ ไม่ได้ |
| P5 | UserAppInfo.ApiPermissions | field ใหม่แบบ optional 🔴 ห้ามแตะ CurrentSchemaVersion (ยังเป็น 1) |
| P6 | แก้ AuthorizationObserveResultHandler | ดูข้อ 3.5 |
3.3.1 🔴 ServiceIdentity:ServiceName มี prefix ของ env ⇒ ใช้เป็น owner ตรง ๆ ไม่ได้
ค่าจริงใน Backend_Iac ไม่ใช่ชื่อ service เปล่า:
| env | ค่า |
|---|---|
| dev | DEV_UserService (config/user-service/dev/appsettings.json:364) |
| sit | SIT_UserService (sit/appsettings.json:129) |
| uat | UAT_UserService (uat/appsettings.json:89) |
service อื่นก็เหมือนกัน (DEV_Centralized · DEV_Log)
⇒ ถ้า OwnerServiceName ในตาราง RoleApiPermission เก็บค่านี้ตรง ๆ grant ที่สร้างบน dev จะไม่ match บน uat/prod ผลคือ deny ทั้งกระดาน โดย log ไม่ได้บอกสาเหตุ
ต้องเคาะก่อนเริ่ม U1 → UNDECIDED-6 · ทางเลือก: normalize ด้วยการตัด prefix {ENV}_ ที่ตอนอ่าน config (เปราะ ถ้ามีชื่อ service ที่มี _ อยู่แล้ว) · หรือเพิ่ม key ใหม่ ServiceIdentity:OwnerName ที่ไม่มี prefix (สวนกับที่ตั้งใจว่าไม่เพิ่ม key แต่ชัดเจนกว่า) · หรือเก็บ owner เป็น GUID แทนชื่อ
🔴 P5 — ห้าม bump CurrentSchemaVersion เด็ดขาด UserInfoForRedis.cs:16 · ถ้า bump ตอนเพิ่ม field ใหม่ blob ทุกอันที่ยังไม่ถูกเขียนใหม่จะ mismatch ⇒ ตามลำดับข้อ 4 ของ §3.1 คือ 503 ทั้งฝูง จนกว่าจะครบ 86400 วินาที · repo นี้เพิ่ม field แบบ optional มาแล้ว 4 ครั้ง (cardId · passportNo · custCode · amloStatus) โดยไม่ bump และมี comment กำกับห้ามไว้ทุกจุด — ทำตามนั้น
PermissionPolicyProvider — ข้อบังคับ:
- policy ที่สร้างต้องเรียก
RequireAuthenticatedUser()ชัดเจน ไม่พึ่ง default policy - ต้อง fallback ไป
DefaultAuthorizationPolicyProviderสำหรับชื่อ policy ที่ไม่ใช่ของเรา ·GetDefaultPolicyAsyncและGetFallbackPolicyAsyncต้องคืนของเดิม ไม่ทับ - ชื่อ permission ที่ไม่รู้จัก ต้องไม่กลายเป็น allow — สร้าง requirement ตามชื่อนั้นตามปกติ แล้วปล่อยให้ไม่ผ่านเพราะไม่มีใครมี
PermissionAuthorizationHandler — สัญญา:
ctx = HttpContext.Items["SupApp.AppContext"]
ctx == null → context.Fail() [hard]
options.OwnerServiceName ว่างหรือไม่ถูกต้อง → context.Fail() [hard]
ctx.ApiPermissions == null → context.Fail() [hard]
freshness หรือ company/resource scope ไม่ผ่าน → context.Fail() [hard]
ctx มีอยู่ ข้อมูลครบและสด
และมี (Name == requirement.Name
&& OwnerService == options.OwnerServiceName)
→ context.Succeed(requirement)
แต่ไม่มีคู่ที่ตรง → ไม่ทำอะไร [ordinary absent grant]
🔴 เส้นแบ่งระหว่าง Fail() กับ “ไม่ทำอะไร” คือหัวใจของความปลอดภัยของช่วงซ้อม
- “ไม่ทำอะไร” ใช้ได้เฉพาะกรณีเดียว: ตรวจทุกอย่างผ่านหมดแล้ว ข้อมูลครบและสด แต่ ไม่มีสิทธิ์ตัวนั้น — นี่คือ absent grant ธรรมดา ซึ่งเป็นสิ่งเดียวที่ควรถูกซ้อม
Fail()ใช้กับทุกกรณีที่ พิสูจน์สิทธิ์ไม่ได้ — ไม่มี app context, ServiceIdentity ผิด, permission เป็น null, freshness ยืนยันไม่ได้, company scope ไม่ผ่าน ·Fail()ทำให้ observe mode ปล่อยผ่านไม่ได้ ซึ่งเป็นผลที่ต้องการ- ห้ามเลี่ยงการเรียก
Fail()เพียงเพื่อให้LogOnlyปล่อย request ที่พิสูจน์สิทธิ์ไม่ได้
PermissionOptions.OwnerServiceName ว่างหรือผิด — ต้อง validate canonical owner ที่ resolve ตาม UNDECIDED-6 ตั้งแต่ startup ก่อนเปิด enforcement ไม่ใช่ไป fail ทีละ request · ชื่อ property ใน handler, catalog และ row ต้องตรงกัน; ServiceIdentity:ServiceName เดิมเป็นเพียง candidate source ไม่ใช่ canonical owner ที่ตกลงแล้ว
company gate ต้องทำงานก่อน side effect และก่อน observe bypass — authorization middleware ยังไม่มี model-bound body/resource ให้โดยอัตโนมัติ; ถ้า target company หาได้หลัง load resource ต้องมี resource authorization ที่ enforce จริงก่อนอ่านข้อมูลออกหรือแก้ DB · P6 ห้ามตีความว่า company gate ผ่านเพียงเพราะยังไม่มีใครเรียก Fail() และห้าม approve company-scoped rollout จน UNDECIDED-4 ระบุ owning check กับ test ที่พิสูจน์ได้
3.4 catalog endpoint
endpoint กลางที่คืนรายชื่อ permission ที่ service นั้นประกาศไว้ อ่านจาก RequirePermissionAttribute ทั้ง assembly ด้วย reflection ตอน startup ครั้งเดียว แล้ว cache ไว้ใน memory
- ต้องมี authorization — ไม่ใช่ endpoint สาธารณะ · รายชื่อ permission ทั้ง service คือแผนผังของด่าน
- admin UI ห้ามเป็น security boundary — UI ดึง catalog มาให้คนเลือกได้ แต่ฝั่ง server ตอนบันทึกต้องตรวจเองว่าคู่
(ownerService, permissionName)ที่ส่งมาอยู่ใน catalog ของ service ที่เชื่อถือได้จริง - 🔴 ห้ามรับ URL ของ catalog จาก caller แล้ว fetch ตาม — รายชื่อ service ที่เชื่อถือได้ต้องมาจาก config ฝั่ง server
เลิกใช้ชื่อ permission — ถ้าชื่อหนึ่งถูกเลิกใช้แล้วมีคนเอาชื่อเดิมไปใช้กับ operation คนละความหมาย grant เก่าที่ค้างอยู่จะกลับมามีผลกับของใหม่ทันที ⇒ ชื่อที่เคยใช้แล้ว ห้ามนำกลับมาใช้ซ้ำกับความหมายอื่น และการ deprecate ต้องมีขั้นตอนล้าง grant ที่ค้าง
3.5 P6 — AuthorizationObserveResultHandler
สัญญาใหม่ — LogOnly จะเรียก _next ต่อได้ ก็ต่อเมื่อครบทุกข้อ:
- caller authenticated
- hard prerequisite ผ่านหมด (app context, freshness, company scope)
- ไม่มี
context.Fail()ถูกเรียก (AuthorizationFailure.FailCalled == false) AuthorizationFailure.FailedRequirementsไม่ว่าง และ ทุกตัว เป็นPermissionRequirement- endpoint นั้น opt-in ช่วงซ้อมไว้จริง (
RequireAuthorizationObserveAttribute)
ถ้าข้อใดไม่ครบ → ส่งต่อให้ handler เดิมปฏิเสธตามปกติ
🔴 ห้ามเพิ่ม ClaimsAuthorizationRequirement หรือ RolesAuthorizationRequirement เข้า allow-list — ผลคือ policy เดิมทุกตัว รวม AmloAdminAccess ยังเป็นด่านจริงตลอด rollout · การ log ผลของ requirement เดิมทำได้และควรทำ แต่ log ไม่ใช่การอนุญาต
⚠️ FailedRequirements ว่าง (เกิดได้เมื่อ policy ไม่ผ่านโดยไม่มี requirement ตัวไหน fail ชัดเจน) ⇒ ข้อ 4 ไม่ผ่าน ⇒ ปฏิเสธจริง · เขียน test ครอบเคสนี้
4. Backend_UserService
4.1 ตารางใหม่ RoleApiPermission
| คอลัมน์ | ชนิด | หมายเหตุ |
|---|---|---|
Id | uuid | |
RoleId | uuid | FK → Roles |
PermissionName | varchar(150) | string เปล่า ไม่มี FK |
OwnerServiceName | varchar(100) | canonical owner เดียวกับ PermissionOptions.OwnerServiceName ตาม UNDECIDED-6; ห้ามใช้ env-prefixed service name ตรง ๆ |
CompanyId | uuid? | ร่างสำหรับทางเลือก company-scoped ตาม UNDECIDED-4; ต้องตัดสิน scope model ก่อน U1 ไม่บังคับเพิ่มคอลัมน์ที่ยังไม่รู้ semantics |
| audit | ตาม IAuditableEntity |
ถ้าเลือก model ที่มี CompanyId: uniqueness ต้องครอบ global grant ที่ CompanyId IS NULL ด้วย · PostgreSQL unique (RoleId, PermissionName, OwnerServiceName, CompanyId) แบบ default ยอมให้ global grant ซ้ำ เพราะ NULL ถือว่าต่างกัน
ให้ใช้ unique index แยกสองชุด: (RoleId, PermissionName, OwnerServiceName) เมื่อ CompanyId IS NULL และ (RoleId, PermissionName, OwnerServiceName, CompanyId) เมื่อ CompanyId IS NOT NULL · ถ้า entity ใช้ soft delete ต้องระบุ active-row predicate และ re-grant semantics ให้ตรงกันทั้งสองชุด · ต้องมี DB integration test สำหรับ concurrent duplicate grant, revoke และ re-grant; ไม่ใช้ EF InMemory พิสูจน์ constraint นี้ (PostgreSQL: Unique Constraints)
🔴 migration ต้อง author บน branch MIGRATION ตามกฎของ repo — port เฉพาะ entity/EF config จาก feature branch ไปที่ MIGRATION แล้ว dotnet ef migrations add บนนั้น ห้าม cherry-pick migration กลับ · verify dotnet ef migrations has-pending-model-changes = none แล้ว merge MIGRATION กลับเข้า feature branch
4.2 งานที่เหลือ
| # | งาน | ไฟล์ |
|---|---|---|
| U1 | entity + EF config + migration | UserService04.Domain/Entities/Authorization/ · UserService02.Infrastructure/Persistence/Configurations/ |
| U2 | API ผูก / ถอน / อ่าน RoleApiPermission — controller ใหม่ ไม่แก้ RolePermissionController เดิม | UserService01.API/Controllers/v1/ |
| U3 | UsersMapper.ToUserInfoForRedis เติม apiPermissions ใต้แต่ละ app + เพิ่ม include ใน GetUserByIdWithCacheDetailsAsync | UserService03.Application/Features/Users/UsersMapper.cs:240-312 |
| U4 | gate PermissionController + RolePermissionController + controller ใหม่ของ U2 (= G1) | |
| U4a | gate UserRoleController + RoleController + ตรวจสิทธิ์ caller ใน handler (= G2) | |
| U5 | เติม [Authorize] ให้ /users/me | UserController.cs:127-132 |
| U6 | invalidation ฝั่ง backend ตามข้อ 4.3 | |
| U7 | audit ของการเปลี่ยน grant ทุกชนิด พร้อมผลของ revoke — ห้ามบันทึก token / session / PII |
U3 ต้องกรองด้วย — role ที่ถูก disable หรือลบ · assignment ที่ถอนแล้ว · user ที่ถูกปิด ต้องไม่หลุดเข้า blob
4.3 invalidation — backend เป็นเจ้าของ ไม่ใช่ admin UI
🔴 admin UI ต้องไม่มีหน้าที่เรียก refresh — flow ที่ให้ UI ยิง 2 ครั้ง (บันทึก แล้วค่อย refresh) พังทันทีที่ UI ถูกปิด, เน็ตหลุด, หรือ request ที่สองล้มเหลว · คนกดเห็นว่าสำเร็จ ทั้งที่สิทธิ์เก่ายังใช้ได้อยู่
รูปแบบที่ต้องใช้: บันทึกการเปลี่ยน grant, authorization revision และ งาน invalidation ที่ durable ลง DB ใน transaction เดียวกัน (transactional outbox) แล้วให้ worker เก็บงานไปทำ · snapshot ต้องอ่าน grants กับ revision อย่างสอดคล้องกัน ห้ามได้ grants เก่าคู่กับ revision ใหม่
- DB commit กับ Redis write ไม่ใช่ transaction เดียวกัน ⇒ worker ต้อง retry ได้แบบ idempotent
- ต้องมีสถานะ
pending/failedต่องาน และ ห้ามรายงานว่า revoke มีผลครบแล้ว จนกว่าจะยืนยันได้จริง - ต้องเห็น progress ของ fan-out ได้ — role ที่มีคนถือหลักพัน คือเขียน blob ใหม่หลักพันครั้ง
writer ของ blob มีมากกว่าที่คิด — ต้องคุมทุกทาง:
| writer | ที่ | พฤติกรรมวันนี้ |
|---|---|---|
UserCacheRefreshService.RefreshAsync | :52-75 | read DB → write Redis ตรง ๆ ไม่เทียบอะไร |
| self-warm middleware ใน UserService | PipelineExtensions.cs:67-100 → WarmByOidAsync | เหมือนกัน |
HTTP fallback ใน RedisUserInfoMiddleware | ตอน cache miss | เหมือนกัน |
UserCacheController (anonymous) | :40 :56 :68 | เรียกจากภายนอกได้ |
ตารางนี้เป็น entry points ไม่ใช่ writer อิสระสี่ตัว — controller/self-warm เรียก refresh service ร่วมกัน ส่วน DELETE เป็น eviction ไม่ใช่ read-then-write · ต้องคุม mutation ทุกทาง รวม eviction: ห้ามลบ revision authority/high-water mark ไปพร้อม blob จน delayed writer เก่ากลับมาเขียนได้
เหตุการณ์ที่ต้อง invalidate — ไม่ใช่แค่ตารางใหม่:
ผูก/ถอน RoleApiPermission · assign/remove UserRole · disable หรือลบ role · เปลี่ยน membership ของ app หรือ company · เปลี่ยนสถานะ user
เคสที่ต้องออกแบบเผื่อตั้งแต่แรก:
| เคส | สิ่งที่ต้องไม่เกิด |
|---|---|
| HTTP fallback warm ที่เริ่มอ่าน DB ก่อน revoke แล้วเขียน blob ทีหลัง | blob เก่าทับของใหม่ ⇒ grant ที่ถอนแล้วกลับมา |
| worker crash หลัง DB commit | งาน invalidation หาย |
| retry มาไม่เรียงลำดับ | revision เก่าทับใหม่ |
| cache ถูก evict แล้ว writer เก่ามาเขียน | grant ที่ถอนแล้วฟื้นคืน |
ทั้ง 4 เคสแก้ด้วยกลไกเดียวกันคือ authorization revision ซึ่ง ยังออกแบบไม่เสร็จ → UNDECIDED-1
5. FE
| เรื่อง | ข้อกำหนด |
|---|---|
| header | ส่ง GUID ของ app ที่ user เลือกอยู่ค่าเดียว เฉพาะ call ไป SuperApp API ใน trusted allowlist ที่ต้องใช้ app context; ไม่บังคับ bootstrap/public/S2S และไม่แนบไป third-party origin |
400 | บั๊กของ FE เอง — อย่า retry อย่า refresh token |
401 | ทางเดิม (ไป login) |
403 | แสดงว่าไม่มีสิทธิ์ — ห้าม refresh token |
503 | ระบบมีปัญหาชั่วคราว — แสดง error, bounded retry/backoff ได้เฉพาะ operation ที่ retry-safe หรือมี server-side idempotency; ห้าม retry mutation อัตโนมัติเพียงเพราะเห็น 503 และ ห้าม refresh token |
🔴 ต้องยืนยันพฤติกรรม interceptor ปัจจุบันก่อนเปิดสวิตช์ — ถ้าวันนี้ interceptor ผูก refresh ไว้กับทุก error หรือกับ 401 แบบไม่มีเงื่อนไข การเปิดสวิตช์จะสร้าง refresh loop ทั้งระบบ (หลักฐานที่ยังขาดข้อ 1 ในไฟล์ 03 · ยังไม่ verify)
CORS / header forwarding — header ใหม่ต้องผ่านทุก hop จริง (browser → APIM → service) ตรวจที่ boundary จริง ไม่ใช่แค่ local
6. test charter
A1-A7 ในไฟล์ 03 บอกว่าต้องพิสูจน์อะไร · ตารางนี้บอกว่าพิสูจน์ยังไงและที่ไหน
| gate | ชนิด | ที่ | สิ่งที่ assert |
|---|---|---|---|
| A1 สิทธิ์จัดการ | integration | Backend_UserService test | user ธรรมดาและ app-admin ที่ไม่มีสิทธิ์จัดการ ยิง assign/remove role, เปลี่ยน role, ผูก permission ข้าม app → ถูก deny และ query DB ยืนยันว่า ไม่มีแถวเปลี่ยน และไม่มี refresh ถูก enqueue · platform-admin ทำได้ตามขอบเขต |
| A2 policy migration | integration | Backend_UserService test | ครบ 4 คู่ (old pass/fail × new pass/fail) ทั้ง LogOnly และ Enforce โดยยังคง policy เดิม · LogOnly ผ่านได้เฉพาะ old-pass · Enforce ผ่านได้เฉพาะผ่านทั้งคู่ · แยกเคส explicit Fail(), company denial, FailedRequirements ว่าง · ก่อนถอด policy เดิม ต้องทดสอบ final policy เดี่ยว: old-fail/new-pass ผ่านได้เฉพาะ access expansion ที่อนุมัติไว้ ไม่ใช่ถอด claim/tenant/company gate ไปด้วยโดยไม่ตั้งใจ |
| A3 app context | integration | Backend_Package test + service test | header หาย → 400 · ส่ง 2 ค่า → 400 · ไม่ใช่ GUID → 400 · app ที่ไม่ได้เป็นสมาชิก → 403 · selected app มีแค่ Member แต่ JWT/identity อื่นมี Admin ต้องไม่ผ่าน role/role-claim policy · user หลาย app สลับ context ได้ตามที่ตั้งใจ · company role ไม่หลุดเป็น app-wide role · internal NameIdentifier และ non-role hard gates ไม่เปลี่ยน · ทดสอบ principal หลังเลือก authentication scheme จริง |
| A4 fail-closed | integration | Backend_Package test | JWT ที่ valid และมี privileged role ต้อง ไม่เข้า endpoint เมื่อ: ไม่มี OID · schema mismatch · JSON เสีย · Redis+HTTP ตายทั้งคู่ · freshness ยืนยันไม่ได้ · apiPermissions เป็น null · ServiceIdentity ผิด · assert ว่า handler ปลายทางไม่ถูกเรียก ไม่ใช่ดูแค่ status |
| A5 revoke | integration | Backend_UserService test | ปิด UI กลางคัน · crash หลัง DB commit · Redis ล่ม · role ใหญ่ · warm ที่เริ่มก่อน revoke · retry สลับลำดับ · stale write หลัง evict → ทุกเคส grant เก่าต้องใช้ไม่ได้ภายใน SLA และ ห้ามรายงาน revoke complete ก่อนมีผล · ครอบ remove/disable role และเปลี่ยน membership ด้วย |
| A6 company scope | integration | Backend_UserService test | admin ของบริษัท A ที่เป็น user ธรรมดาหรือไม่เป็นสมาชิกในบริษัท B ต้องอ่าน/แก้ resource ของ B ไม่ได้ · global assignment ข้ามบริษัทได้เฉพาะ operation ที่อนุมัติไว้ · user หลาย role ใน app เดียวต้องไม่ทำ scope หาย |
| A7 release evidence | manual + pipeline | config parity ทุก env · header forwarding และ CORS/preflight ที่ boundary จริง · FE error handling · health/bootstrap/S2S ใช้ policy ที่ประกาศไว้และไม่กลายเป็น bypass · Swagger JSON/UI และ MapOpenApi ทดสอบแยก · negative test มีหลักฐาน · rollback ไม่ลด security |
🔴 A4 และ A1 ห้าม assert แค่ status code — ต้องพิสูจน์ว่า side effect ไม่เกิด · request ที่ถูก deny แล้วยังเขียน DB หรือ enqueue refresh คือ fail แม้จะตอบ 403 ถูกต้อง
ยังไม่มีการรัน test เหล่านี้ — ตารางนี้เป็นเกณฑ์ ไม่ใช่ผล
release-blocking cases เพิ่มเติม: A1 ต้อง deny caller ที่ไม่มีสิทธิ์บน cache warm/refresh/delete · A4 ต้องแยก ApiPermissions == null (503 ก่อน endpoint) กับ [] (ordinary absent grant), legacy blob ไม่มี revision, และ startup reject เมื่อ endpoint มี anonymous/skip metadata ขัดกับ permission · A5 ต้องครอบ snapshot grants/revision ไม่สอดคล้องกัน, delayed write นานกว่า SLA และ retry ที่พยายามต่ออายุ snapshot เดิม · U1 ต้องผ่าน DB uniqueness test ตามข้อ 4.1
7. ลำดับ deploy พร้อม gate
| ขั้น | ทำอะไร | gate ที่ต้องผ่านก่อนขั้นถัดไป |
|---|---|---|
| 0 | G1 + G2 + G3 (bootstrap) + G4 (SLA) | A1 ผ่าน |
| 0a | ปิด UNDECIDED-1/4/6 ก่อน freeze P5/U1/U3 และปิดแบบ S2S cache gate ก่อนขั้น 3 | ยืนยัน revision/freshness wire contract, company scope และ canonical owner; ห้ามเขียน schema จริงจากร่างที่ยังไม่ตัดสิน |
| 1 | Backend_Package release — ทุกอย่างในข้อ 3 · สวิตช์ default ปิด | build + unit test เขียว · A3 · A4 ผ่าน |
| 2 | publish ขึ้น feed ด้วยมือ (dotnet nuget push) — merge เข้า development ไม่ publish ให้ | ยืนยันว่า feed มีเวอร์ชันจริง หลังล้าง local cache |
| 3 | Backend_UserService bump + U1-U7 · Backend_Iac เติม config ทุก env | A5 · A6 ผ่าน |
| 4 | FE ทุก app ส่ง header ครบเฉพาะ API ในขอบเขต | ยืนยัน interceptor ไม่ refresh บน 400/403/503 และไม่ retry mutation ที่ไม่ปลอดภัย |
| 4a | re-warm user ที่อยู่ใน rollout cohort ก่อนเปิด enforcement | writer ทุก instance รวม fallback ใช้ contract ใหม่; ทุก snapshot ใน cohort มี revision/freshness และ apiPermissions ที่ไม่เป็น null; lazy warm ต้องผ่าน validation เดียวกันก่อนให้ใช้งาน |
| 5 | เปิด EnforceAppContext ทีละ service ผ่าน config | เฝ้า 400/403/503 ต่อ service · A7 ผ่าน |
| 6 | แปะ [RequirePermission] เส้นแรก โดยคง policy เดิมไว้ + [RequireAuthorizationObserve] + LogOnly OptIn | อ่าน log ให้ครบรอบธุรกิจ · A2 ผ่าน |
| 7 | สลับเป็น Enforce โดยยังคง policy เดิม | ยืนยันไม่มีใครถูก deny ผิด |
| 8 | ถอดเฉพาะส่วนของ policy เดิมที่ permission ใหม่แทนได้จริง | A2 final-policy tests ผ่าน พร้อมอนุมัติ access expansion; tenant/authentication/company hard gates ที่ permission ไม่ได้แทนต้องคงไว้ |
| 9 | ทำซ้ำ 6-8 กับเส้นถัดไป |
🔴 สลับลำดับไม่ได้ — ขั้น 0a ต้องมาก่อน freeze schema/wire contract · ขั้น 4 และ 4a ต้องมาก่อนขั้น 5 · ขั้น 6 ต้องมาก่อนขั้น 7 · ขั้น 7 และ A2 final-policy tests ต้องมาก่อนขั้น 8
🔴 ทำไมขั้น 4a ห้ามข้าม — blob รุ่นเก่าไม่มี authorization revision/permission completeness ที่ middleware ต้องตรวจ ⇒ เปิดขั้น 5 ก่อนจะตอบ 503 แม้ endpoint ยังไม่ได้แปะ [RequirePermission] · การสุ่มดู blob หรือรอ TTL อย่างเดียวไม่พิสูจน์ coverage และไม่กัน old writer เขียนกลับระหว่าง rolling deployment · ต้องยืนยัน writer compatibility และ readiness ของ cohort ด้วยข้อมูลนับได้ โดยไม่ log blob/PII
⚠️ ขั้น 5 และ rollback ต้อง redeploy ไม่ใช่ toggle (ดูข้อ 1.3)
⚠️ การรอ TTL หมดอายุไม่ใช่ gate — เป็นวิธี migrate blob รุ่นเก่าเท่านั้น ไม่ใช่หลักฐานว่า revoke มีผล และไม่อนุญาตให้ข้อมูลเก่ากลับมามีผล
rollback
| ทำได้ | ห้ามทำ |
|---|---|
| คืน policy เดิมที่รู้ว่าดี | ปิด EnforceAppContext เพื่อแก้ปัญหา |
| ปิด endpoint ที่มีปัญหาชั่วคราว | เปิด LogOnly เพื่อให้คนที่ถูก deny ผ่านไปก่อน |
🔴 ทั้งสองอย่างในคอลัมน์ขวาทำให้ grant ที่ถอนไปแล้วกลับมาใช้ได้ — rollback ต้องไม่ลดระดับความปลอดภัยลงจากก่อนเริ่ม
8. UNDECIDED — บล็อก rollout ทั้งหมด
ทุกข้อในนี้ยังต้องมีมติ/แบบ/ตัวเลขที่อนุมัติ · UNDECIDED-1/4/6 บล็อกการ freeze schema และ wire contract ตั้งแต่ขั้น 0a ไม่ใช่เพิ่งบล็อกตอน rollout; ข้อ 2/3 อยู่ใน G4/G3 ก่อนขั้น 0 · ห้ามเริ่มขั้นที่ 5 จนกว่าจะปิดครบรวมข้อ 5
UNDECIDED-1 · authorization revision และ freshness authority
ข้อกำหนดที่มี: snapshot ต้องผูกกับ revision ที่เดินหน้าอย่างเดียว · writer ทุกตัวรวม HTTP fallback ห้ามเขียนทับด้วย revision ที่เก่ากว่า · handler ต้องตรวจความสดกับ authority ที่เชื่อถือได้ภายใน SLA · ถ้า cache หาย writer เก่าต้องฟื้น grant เก่าไม่ได้
คำถามที่ต้องตอบก่อนเขียนโค้ด:
- revision เก็บที่ไหน — ต่อ user, ต่อ role, หรือ global · เหตุการณ์ที่ทำให้เพิ่มมีอย่างน้อย 5 ชนิด (ดูข้อ 4.3) ไม่ได้อยู่ในตารางเดียว
- authority ที่ handler เอาไว้เทียบคืออะไร — ถ้าเทียบกับตัวเลขใน blob เอง แปลว่าเชื่อ cache ตรวจสอบตัวเอง ซึ่งไม่ตอบโจทย์ · ถ้าต้องยิงถาม UserService ทุก request จะเสียประโยชน์ทั้งหมดของการมี cache
- HTTP fallback warm ใน middleware กันเขียนทับยังไง — วันนี้เขียนตรงโดยไม่เทียบอะไร
- เมื่อยืนยันความสดไม่ได้ ⇒ deny (ตามข้อกำหนด) — ทำให้ Redis ล่ม = ทั้งระบบ 503 · ยอมรับได้ไหม
ทางเลือกที่ต้องพิสูจน์ ไม่ใช่ข้อยุติ — bounded staleness อาจลดการอ่าน authority ทุก request ได้ แต่ ตั้ง CacheTtlSeconds <= SLA อย่างเดียวไม่พอ: writer อ่านก่อน revoke แล้วค้างนานกว่า SLA ก่อน SET จะเริ่ม TTL ใหม่ให้สิทธิ์เก่า; CachedAtUtc ที่ตั้งตอนเขียนไม่ใช่หลักฐานว่า DB snapshot สด
ถ้าจะใช้ทางนี้ ต้องผูก expiry แบบ absolute กับเวลาที่อ่าน snapshot ที่เชื่อถือได้, รวม read/replication lag กับ clock skew ใน SLA budget, ห้าม retry/warm ต่ออายุ snapshot เดิม และปฏิเสธ snapshot ที่หมดอายุแล้วก่อนเขียนหรือใช้ · revision ต้องอ่านพร้อม grants อย่างสอดคล้องกัน และ high-water mark/authority ต้องอยู่รอดแม้ blob evict · ยังคงต้องพิสูจน์ stale-write prevention ตามสัญญา 02 ไม่ถือว่า TTL มาแทนให้แล้ว
TTL ไม่ทำให้ Redis outage หายไป — Redis ล่มอาจตอบ 503 เว้นแต่ fallback ได้ข้อมูลที่ยืนยัน freshness ตาม contract เดียวกันจริง · security reviewer ต้องอนุมัติ authority, failure behavior และผล A5 ของแบบที่เลือกก่อนปิดข้อนี้
⇒ สถานะ: รอเคาะ ไม่ใช่ ไม่มีทางออก
UNDECIDED-6 · owner ของสิทธิ์ จะใช้ค่าอะไร
ServiceIdentity:ServiceName มี prefix ของ env (DEV_UserService / SIT_ / UAT_) ⇒ ใช้เป็น OwnerServiceName ตรง ๆ ไม่ได้ เพราะ grant จะไม่ข้าม env · รายละเอียดและทางเลือก 3 ทางอยู่ที่ข้อ 3.3.1 · ต้องเคาะก่อน U1 เพราะเป็นคอลัมน์ในตารางที่ต้อง migrate
UNDECIDED-2 · revocation SLA
ต้องเป็น ตัวเลข ที่อนุมัติแล้ว เช่น “grant ที่ถอนต้องใช้ไม่ได้ภายใน N วินาที นับจาก API ตอบสำเร็จ” · TTL 86400 วินาที ไม่ใช่ SLA เป็นแค่อายุ cache · ไม่มีตัวเลข = A5 เขียน test ไม่ได้ = rollout ถูกบล็อก
UNDECIDED-3 · platform-admin bootstrap
G1/G2 gate ด้วย privileged role · ใครถือ role นั้นในวันแรก และมาได้ยังไง · ทางเลือกที่ต้องเลือก: seed ใน migration ด้วย OID ที่ระบุชื่อ · endpoint ครั้งเดียวที่ปิดทิ้งหลังใช้ · ผูกกับ group ใน employee tenant ที่มีอยู่ · ทุกทางต้องมี audit และต้องกู้คืนได้ถ้าคนสุดท้ายที่ถือ role หลุด
UNDECIDED-4 · company scope
AdminAmloController เป็น company-scoped ทุกเส้น แต่ model นี้เป็น global ต่อ role · ต้องเคาะข้อใดข้อหนึ่ง:
- (ก) global-only ไปก่อน — รับเฉพาะ assignment ที่ยืนยันว่าเป็น global และ operation นั้นได้รับอนุมัติ global access แล้ว · endpoint อื่นที่ scope เป็นบริษัท ถูกบล็อกไว้ก่อน
- (ข) เพิ่มมิติ company — ตัดสินก่อน U1 ว่า grant เป็น role capability ที่รับ scope จาก
UserRole.CompanyIdหรือมีRoleApiPermission.CompanyIdเพิ่มด้วย · ถ้ามีทั้งสองต้องใช้ scope intersection ห้ามให้ global grant ขยาย company-bound assignment · ต้องรักษา scope ตลอด mapper/blob/context จนถึง target resource และกำหนด query filtering ของ list endpoint ไม่ใช่ตรวจเฉพาะ companyId ใน route
🔴 ห้าม union สิทธิ์ที่ผูกบริษัท A ขึ้นไปเป็น apiPermissions ระดับ app ไม่ว่าเลือกทางไหน
UNDECIDED-5 · app context threat model
app context เป็น ตัวเลือก context ไม่ใช่การพิสูจน์ที่มา — user ที่ถือ role ถูกต้องทั้ง Platform และ FX ส่ง AppId ของ Platform มาจากหน้าจอ FX ได้ และ server จะยอม
ต้องเคาะว่า ธุรกิจยอมรับข้อนี้ไหม · ถ้าไม่ยอมรับ ต้องผูก AppId กับ session ตั้งแต่ login ซึ่งเป็นงานคนละชุดที่เอกสารทั้ง 4 ไฟล์ไม่ได้ออกแบบไว้ · ถ้ายอมรับ ต้องบันทึกเป็นมติ ไม่ใช่ปล่อยให้คนอ่านเข้าใจผิดว่าเป็นการแยก trust ระหว่าง app
9. กับดักตอนเขียนโค้ด
| # | กับดัก | ผลถ้าพลาด |
|---|---|---|
| 1 | RequirePermissionAttribute ต้อง derive AuthorizeAttribute และเซ็ต Policy | ถ้าไม่ derive IAuthorizationPolicyProvider จะไม่ถูกเรียกเลย ⇒ attribute ไม่มีผลใด ๆ และ ไม่มี error |
| 2 | UserAppInfo.AppId เป็น string? (UserInfoForRedis.cs:174-175) และ UsersMapper.cs:246 เขียนด้วย .ToString() | เทียบเป็น string จะพลาดเรื่องตัวพิมพ์และรูปแบบ GUID ⇒ parse เป็น Guid แล้วเทียบ ทั้งสองฝั่ง |
| 3 | effective endpoint metadata มี [RequirePermission] คู่กับ ISkipAppContextMetadata หรือ IAllowAnonymous | skip ทำให้ context หาย; anonymous ข้าม authorization ได้แม้มี AuthorizeAttribute · startup guard ต้อง reject ทั้งสองกรณี โดยตรวจ metadata ที่รวมจาก controller/action/conventions ไม่ใช่ดูเฉพาะ attribute บน method |
| 4 | Coverage ของ observe mode | 🔴 ห้ามเปลี่ยนเป็น RequireAll ไม่ว่ากรณีใด — จะถอดด่านของเส้นที่มี role gate อยู่แล้วทั้ง service |
| 5 | ชื่อ permission | {resource}:{action} · action จากชุดปิด · ห้าม wildcard — endpoint ใหม่ที่ขึ้นต้นด้วย prefix เดียวกันจะเรียกได้ทันทีโดยไม่มีใครอนุมัติ · ห้ามใส่ชื่อ service / HTTP method / version / route ในชื่อ |
| 6 | error code | ต้องคงที่และ document ไว้ · FE ต้องแยกสาเหตุได้โดยไม่ต้องอ่านข้อความ |
| 7 | ชื่อ permission ที่เลิกใช้ | ห้ามนำกลับมาใช้กับความหมายอื่น · grant เก่าที่ค้างจะกลับมามีผลกับ operation ใหม่ทันที |
10. หลักฐานที่ยังต้องเก็บ
| # | เรื่อง | บล็อกขั้นที่ |
|---|---|---|
| 1 | FE interceptor ทำอะไรกับ 400 / 401 / 403 / 503 วันนี้ | 4 |
| 2 | ServiceIdentity:ServiceName ตั้งครบทุก service ทุก env หรือยัง | 5 |
| 3 | SupApp_util_lib 10.30.0 อยู่บน feed แล้วหรือยัง | 2 |
| 4 | FX เรียกเข้ามาทางไหนบ้าง — ถ้ามี server-to-server สมมติฐานเรื่อง header ต้องคิดใหม่ทั้งหมด | ทั้งแผน |
| 5 | AppId (GUID) จริงของแต่ละ app | 4 |
| 6 | มี user ที่ถือหลาย role ใน app เดียวจริงไหม (ไม่บล็อก แต่ตอบว่า convention ยังจริงอยู่ไหม) | — |
| 7 | มี FE ตัวไหนพึ่งพฤติกรรม /users/me ที่คืน 200 พร้อม body ว่างตอนไม่ login หรือเปล่า — โค้ดจริงคืน ApiOk(new { }) (UserController.cs:132-133) · งาน U5 ทำให้กลายเป็น 401 | 3 |
| 8 | service อื่นนอกจาก UserService เรียก UseRouting() หลัง UseRedisUserInfo() หรือเปล่า — ถ้ามี การยกเว้น endpoint จะพังเงียบ | 5 |
ทุกข้อยังเป็น ยังไม่ verify