Private Docs

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 ขาด / ส่งซ้ำหลายค่า / ไม่ใช่ GUID400คำขอผิดรูปแบบ ไม่ใช่เรื่องสิทธิ์
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 นี้ไม่มีความหมาย

#เงื่อนไขทำไมต้องก่อน
G1gate PermissionController.cs:23 และ RolePermissionController.cs:24 (วันนี้เป็น [Authorize] เปล่า)ย้ายการตัดสินใจเรื่องสิทธิ์ไปอยู่ในข้อมูล ก่อนปิดช่องนี้ = ย้ายไปไว้ในที่ที่ป้องกันน้อยกว่าเดิม
G2gate 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.AppContextHeaderNamestring · default "X-App-Id" (ยืนยันชื่อจริงกับ FE ก่อน)
RedisUserInfoOptions.EnforceAppContextbool · default false
AppContextResultrecord เก็บ 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 ใน principal503 ไม่เรียก _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
ISkipAppContextMetadatamarker ที่ endpoint ซึ่งไม่มี method ให้แปะ ใช้ผ่าน .WithMetadata(...)

🔴 attribute อย่างเดียวไม่พอ และข้อนี้ทำให้ pod ตายได้จริง — endpoint ที่ต้องยกเว้นหลายตัว ไม่มี method ให้แปะ attribute:

endpointประกาศที่ยกเว้นยังไง
/healthPipelineExtensions.cs:152 MapHealthChecks.WithMetadata(...)
OpenAPI endpoint:143 MapOpenApi.WithMetadata(...)
Swagger JSON / UIUseSwagger() / 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หน้าที่
P1RequirePermissionAttributeAttributeUsage(Method) · รับชื่อ permission หนึ่งค่า
P2PermissionRequirementIAuthorizationRequirement ถือชื่อ permission
P2PermissionAuthorizationHandlerAuthorizationHandler<PermissionRequirement>
P3PermissionPolicyProviderIAuthorizationPolicyProvider สร้าง policy จากชื่อแบบ dynamic
P4PermissionOptions.OwnerServiceNameดูข้อ 3.3.1 — ใช้ ServiceIdentity:ServiceName ตรง ๆ ไม่ได้
P5UserAppInfo.ApiPermissionsfield ใหม่แบบ optional 🔴 ห้ามแตะ CurrentSchemaVersion (ยังเป็น 1)
P6แก้ AuthorizationObserveResultHandlerดูข้อ 3.5

3.3.1 🔴 ServiceIdentity:ServiceName มี prefix ของ env ⇒ ใช้เป็น owner ตรง ๆ ไม่ได้

ค่าจริงใน Backend_Iac ไม่ใช่ชื่อ service เปล่า:

envค่า
devDEV_UserService (config/user-service/dev/appsettings.json:364)
sitSIT_UserService (sit/appsettings.json:129)
uatUAT_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 ต่อได้ ก็ต่อเมื่อครบทุกข้อ:

  1. caller authenticated
  2. hard prerequisite ผ่านหมด (app context, freshness, company scope)
  3. ไม่มี context.Fail() ถูกเรียก (AuthorizationFailure.FailCalled == false)
  4. AuthorizationFailure.FailedRequirements ไม่ว่าง และ ทุกตัว เป็น PermissionRequirement
  5. 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

คอลัมน์ชนิดหมายเหตุ
Iduuid
RoleIduuidFK → Roles
PermissionNamevarchar(150)string เปล่า ไม่มี FK
OwnerServiceNamevarchar(100)canonical owner เดียวกับ PermissionOptions.OwnerServiceName ตาม UNDECIDED-6; ห้ามใช้ env-prefixed service name ตรง ๆ
CompanyIduuid?ร่างสำหรับทางเลือก 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 งานที่เหลือ

#งานไฟล์
U1entity + EF config + migrationUserService04.Domain/Entities/Authorization/ · UserService02.Infrastructure/Persistence/Configurations/
U2API ผูก / ถอน / อ่าน RoleApiPermissioncontroller ใหม่ ไม่แก้ RolePermissionController เดิมUserService01.API/Controllers/v1/
U3UsersMapper.ToUserInfoForRedis เติม apiPermissions ใต้แต่ละ app + เพิ่ม include ใน GetUserByIdWithCacheDetailsAsyncUserService03.Application/Features/Users/UsersMapper.cs:240-312
U4gate PermissionController + RolePermissionController + controller ใหม่ของ U2 (= G1)
U4agate UserRoleController + RoleController + ตรวจสิทธิ์ caller ใน handler (= G2)
U5เติม [Authorize] ให้ /users/meUserController.cs:127-132
U6invalidation ฝั่ง backend ตามข้อ 4.3
U7audit ของการเปลี่ยน 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-75read DB → write Redis ตรง ๆ ไม่เทียบอะไร
self-warm middleware ใน UserServicePipelineExtensions.cs:67-100WarmByOidAsyncเหมือนกัน
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 สิทธิ์จัดการintegrationBackend_UserService testuser ธรรมดาและ app-admin ที่ไม่มีสิทธิ์จัดการ ยิง assign/remove role, เปลี่ยน role, ผูก permission ข้าม app → ถูก deny และ query DB ยืนยันว่า ไม่มีแถวเปลี่ยน และไม่มี refresh ถูก enqueue · platform-admin ทำได้ตามขอบเขต
A2 policy migrationintegrationBackend_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 contextintegrationBackend_Package test + service testheader หาย → 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-closedintegrationBackend_Package testJWT ที่ valid และมี privileged role ต้อง ไม่เข้า endpoint เมื่อ: ไม่มี OID · schema mismatch · JSON เสีย · Redis+HTTP ตายทั้งคู่ · freshness ยืนยันไม่ได้ · apiPermissions เป็น null · ServiceIdentity ผิด · assert ว่า handler ปลายทางไม่ถูกเรียก ไม่ใช่ดูแค่ status
A5 revokeintegrationBackend_UserService testปิด UI กลางคัน · crash หลัง DB commit · Redis ล่ม · role ใหญ่ · warm ที่เริ่มก่อน revoke · retry สลับลำดับ · stale write หลัง evict → ทุกเคส grant เก่าต้องใช้ไม่ได้ภายใน SLA และ ห้ามรายงาน revoke complete ก่อนมีผล · ครอบ remove/disable role และเปลี่ยน membership ด้วย
A6 company scopeintegrationBackend_UserService testadmin ของบริษัท A ที่เป็น user ธรรมดาหรือไม่เป็นสมาชิกในบริษัท B ต้องอ่าน/แก้ resource ของ B ไม่ได้ · global assignment ข้ามบริษัทได้เฉพาะ operation ที่อนุมัติไว้ · user หลาย role ใน app เดียวต้องไม่ทำ scope หาย
A7 release evidencemanual + pipelineconfig 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 ที่ต้องผ่านก่อนขั้นถัดไป
0G1 + 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 จริงจากร่างที่ยังไม่ตัดสิน
1Backend_Package release — ทุกอย่างในข้อ 3 · สวิตช์ default ปิดbuild + unit test เขียว · A3 · A4 ผ่าน
2publish ขึ้น feed ด้วยมือ (dotnet nuget push) — merge เข้า development ไม่ publish ให้ยืนยันว่า feed มีเวอร์ชันจริง หลังล้าง local cache
3Backend_UserService bump + U1-U7 · Backend_Iac เติม config ทุก envA5 · A6 ผ่าน
4FE ทุก app ส่ง header ครบเฉพาะ API ในขอบเขตยืนยัน interceptor ไม่ refresh บน 400/403/503 และไม่ retry mutation ที่ไม่ปลอดภัย
4are-warm user ที่อยู่ใน rollout cohort ก่อนเปิด enforcementwriter ทุก 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 เก่าไม่ได้

คำถามที่ต้องตอบก่อนเขียนโค้ด:

  1. revision เก็บที่ไหน — ต่อ user, ต่อ role, หรือ global · เหตุการณ์ที่ทำให้เพิ่มมีอย่างน้อย 5 ชนิด (ดูข้อ 4.3) ไม่ได้อยู่ในตารางเดียว
  2. authority ที่ handler เอาไว้เทียบคืออะไร — ถ้าเทียบกับตัวเลขใน blob เอง แปลว่าเชื่อ cache ตรวจสอบตัวเอง ซึ่งไม่ตอบโจทย์ · ถ้าต้องยิงถาม UserService ทุก request จะเสียประโยชน์ทั้งหมดของการมี cache
  3. HTTP fallback warm ใน middleware กันเขียนทับยังไง — วันนี้เขียนตรงโดยไม่เทียบอะไร
  4. เมื่อยืนยันความสดไม่ได้ ⇒ 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. กับดักตอนเขียนโค้ด

#กับดักผลถ้าพลาด
1RequirePermissionAttribute ต้อง derive AuthorizeAttribute และเซ็ต Policyถ้าไม่ derive IAuthorizationPolicyProvider จะไม่ถูกเรียกเลย ⇒ attribute ไม่มีผลใด ๆ และ ไม่มี error
2UserAppInfo.AppId เป็น string? (UserInfoForRedis.cs:174-175) และ UsersMapper.cs:246 เขียนด้วย .ToString()เทียบเป็น string จะพลาดเรื่องตัวพิมพ์และรูปแบบ GUID ⇒ parse เป็น Guid แล้วเทียบ ทั้งสองฝั่ง
3effective endpoint metadata มี [RequirePermission] คู่กับ ISkipAppContextMetadata หรือ IAllowAnonymousskip ทำให้ context หาย; anonymous ข้าม authorization ได้แม้มี AuthorizeAttribute · startup guard ต้อง reject ทั้งสองกรณี โดยตรวจ metadata ที่รวมจาก controller/action/conventions ไม่ใช่ดูเฉพาะ attribute บน method
4Coverage ของ observe mode🔴 ห้ามเปลี่ยนเป็น RequireAll ไม่ว่ากรณีใด — จะถอดด่านของเส้นที่มี role gate อยู่แล้วทั้ง service
5ชื่อ permission{resource}:{action} · action จากชุดปิด · ห้าม wildcard — endpoint ใหม่ที่ขึ้นต้นด้วย prefix เดียวกันจะเรียกได้ทันทีโดยไม่มีใครอนุมัติ · ห้ามใส่ชื่อ service / HTTP method / version / route ในชื่อ
6error codeต้องคงที่และ document ไว้ · FE ต้องแยกสาเหตุได้โดยไม่ต้องอ่านข้อความ
7ชื่อ permission ที่เลิกใช้ห้ามนำกลับมาใช้กับความหมายอื่น · grant เก่าที่ค้างจะกลับมามีผลกับ operation ใหม่ทันที

10. หลักฐานที่ยังต้องเก็บ

#เรื่องบล็อกขั้นที่
1FE interceptor ทำอะไรกับ 400 / 401 / 403 / 503 วันนี้4
2ServiceIdentity:ServiceName ตั้งครบทุก service ทุก env หรือยัง5
3SupApp_util_lib 10.30.0 อยู่บน feed แล้วหรือยัง2
4FX เรียกเข้ามาทางไหนบ้าง — ถ้ามี server-to-server สมมติฐานเรื่อง header ต้องคิดใหม่ทั้งหมดทั้งแผน
5AppId (GUID) จริงของแต่ละ app4
6มี user ที่ถือหลาย role ใน app เดียวจริงไหม (ไม่บล็อก แต่ตอบว่า convention ยังจริงอยู่ไหม)
7มี FE ตัวไหนพึ่งพฤติกรรม /users/me ที่คืน 200 พร้อม body ว่างตอนไม่ login หรือเปล่า — โค้ดจริงคืน ApiOk(new { }) (UserController.cs:132-133) · งาน U5 ทำให้กลายเป็น 4013
8service อื่นนอกจาก UserService เรียก UseRouting() หลัง UseRedisUserInfo() หรือเปล่า — ถ้ามี การยกเว้น endpoint จะพังเงียบ5

ทุกข้อยังเป็น ยังไม่ verify