App Context Gate — คุมว่า user คนนี้กำลังทำงานในนาม app ไหน
อธิบาย feature app-context ใน SupApp_util_lib 10.31.0 แบบไล่ตาม request จริง — ปัญหาที่แก้, ด่านทั้ง 10, Revision, ทำไมต้องตัด role สองรอบ, checklist สำหรับ service ที่จะเอาไปใช้, และทำไมยังเปิดใช้ไม่ได้
อัปเดต: 2026-09-09
1. ปัญหาที่ feature นี้แก้
user คนหนึ่งอยู่ได้หลาย app พร้อมกัน และแต่ละ app ให้ role คนละแบบ
แต่ token ที่ user ถือมามีแค่ใบเดียว และมันหอบ role ของ ทุก app มารวมกันในนั้น
ผลคือ user ที่เป็น Admin ใน app A จะผ่านด่าน Admin ของ app B ไปด้วย ทั้งที่ใน app B เขาเป็นแค่ Member
ไม่มีใครตั้งใจให้เป็นแบบนั้น แต่ระบบไม่มีอะไรกันไว้
feature นี้บังคับสองอย่าง:
- ทุก request ต้องบอกมาว่า “ฉันกำลังทำงานในนาม app ไหน” (ส่งมาใน header)
- ระบบจะ ตัด role ของ app อื่นทิ้ง ก่อนส่งต่อให้ controller
2. ตาม request หนึ่งอันจากต้นจนจบ
สมมติ สมชาย:
| app | role ที่สมชายถือ |
|---|---|
app A (aaaa…) | Admin |
app B (bbbb…) | Member |
สมชายเปิดหน้าจอของ app B แล้วกดปุ่ม หน้าจอยิง:
GET /api/reports
Authorization: Bearer <token>
X-App-Id: bbbbbbbb-.... ← app B
สิ่งที่เกิดขึ้นตามลำดับ
ขั้น 1 — UseAuthentication ทำงาน
framework ตรวจ token แล้วสร้าง context.User ขึ้นมา ตอนนี้หน้าตาแบบนี้:
identity #1 (จาก JWT)
oid = 2020...
CustomRoles = Admin ← role ของ app A ติดมาด้วย
CustomRoles = Member
tid = tenant-123
ขั้น 2 — RedisUserInfoMiddleware ทำงาน (อยู่หลัง UseAuthentication ก่อน UseAuthorization)
มันอ่าน X-App-Id ได้ app B แล้วไปดึงข้อมูลสมชายจาก Redis มาดูว่า:
- สมชายอยู่ใน app B จริงไหม → จริง
- ใน app B เขามี role อะไร →
Memberอย่างเดียว
ขั้น 3 — ตัด role ทิ้งแล้วสร้าง context.User ใหม่
identity #1 (ใหม่ ชื่อ "AppContext")
NameIdentifier = 1111-2222-... ← internal Users.Id ของเรา ไม่ใช่ oid ของ Entra
Role = Member ← เฉพาะ role ใน app B
identity #2 (ของเดิม แต่ role ถูกถอนออกหมด)
oid = 2020...
tid = tenant-123 ← claim ที่ไม่ใช่ role อยู่ครบ
Admin หายไปแล้ว ⇒ ถ้า controller เช็ค User.IsInRole("Admin") จะได้ false ซึ่งถูกต้อง
ขั้น 4 — ฝาก context ไว้ให้ controller อ่าน
var ctx = HttpContext.GetAppContext();
// ctx.AppId = app B
// ctx.UserId = 1111-2222-...
// ctx.Roles = ["Member"]
var info = HttpContext.GetUserInfo();
// info.Apps = [app B] ← เหลือ app เดียว ไม่ใช่ทั้งสอง
// info.AllRoles = ["Member"]
// info.Email = ... ← ข้อมูลระดับ user อยู่ครบ
ข้อมูลที่ GetUserInfo() คืน ถูกตัดให้เหลือเฉพาะ app ที่เลือก ด้วย
เพราะ handler ส่วนใหญ่อ่านจากตรงนี้ ไม่ได้อ่านจาก principal — ถ้าปล่อยให้คืนทุก app
การตัด role ที่ทำมาทั้งหมดก็ไม่มีความหมาย
Companies ไม่ถูกตัด เพราะเป็นของ user ไม่ใช่ของ app
ขั้น 5 — UseAuthorization ทำงาน แล้ว request ถึง controller
flowchart LR
A["token<br/>Admin + Member"] --> B[UseAuthentication]
B --> C["context.User<br/>Admin, Member"]
C --> D[RedisUserInfoMiddleware]
D --> E["context.User<br/>Member เท่านั้น"]
E --> F[UseAuthorization]
F --> G[Controller]
ทำไมต้องถอน ไม่ใช่แค่เพิ่ม
จุดที่คนพลาดบ่อยที่สุด: การเพิ่ม identity ใหม่ที่มีแค่ Member ไม่ได้ปิดอะไรเลย
IsInRole ไล่ค้น ทุก identity บน principal ⇒ Admin ที่ยังนั่งอยู่บน identity เดิมก็ยังตอบ true
ต้องลบ claim นั้นออกจริง ๆ เท่านั้น
3. ถ้าไม่ผ่านด่านจะได้อะไรกลับ
ด่านเรียงตามลำดับ ตกด่านไหนตอบทันที ไม่ทำต่อ
| ด่าน | ตกเมื่อ | ตอบ | code |
|---|---|---|---|
| 0 | ยังไม่ login | 401 | APP_CONTEXT_UNAUTHENTICATED |
| 1 | login แล้วแต่หา oid ในtoken ไม่เจอ | 503 | APP_CONTEXT_UNAVAILABLE |
| 2 | header ขาด / ส่งซ้ำ / ไม่ใช่ GUID | 400 | APP_CONTEXT_BAD_REQUEST |
| 3 | ดึงข้อมูล user ไม่ได้เลย (Redis + fallback ล่มทั้งคู่) | 503 | APP_CONTEXT_UNAVAILABLE |
| 4 | ได้ข้อมูลมาแต่อ่านไม่ออก (schema/JSON/decrypt) | 503 | APP_CONTEXT_UNAVAILABLE |
| 5 | ยืนยันไม่ได้ว่าข้อมูลใหม่พอ (ดูข้อ 4) | 503 | APP_CONTEXT_UNAVAILABLE |
| 6 | app ที่ขอ ไม่ใช่ app ของ user คนนี้ | 403 | APP_CONTEXT_FORBIDDEN |
| 7 | อยู่ใน app จริง แต่ไม่มี role เลย | 403 | APP_CONTEXT_FORBIDDEN |
| 8 | ข้อมูล permission เป็น null | 503 | APP_CONTEXT_UNAVAILABLE |
| 9 | ถอน role ออกไม่สำเร็จ | 503 | APP_CONTEXT_UNAVAILABLE |
body ที่ตอบกลับมีแค่ 5 field เท่านั้น: type title status code traceId
ไม่มี app id, ชื่อ role, oid, internal user id หรือเศษข้อมูลใด ๆ — ของพวกนั้นอยู่ใน log อย่างเดียว
ด่าน 6 กับด่าน 7 ตอบกลับ เหมือนกันเป๊ะ (ต่างแค่ traceId) ตั้งใจ เพราะถ้าแยก
คนที่ยังไม่ได้ยืนยันตัวตนจะเดาได้ว่า app id ไหนมีอยู่จริงในระบบบ้าง
4. Revision — กันสิทธิ์ที่ถอนแล้วยังใช้ได้
เล่าเป็นเรื่อง:
- 10:00 admin ถอน role
Adminของสมชายออกจาก DB - ข้อมูลสมชายที่ cache ไว้ใน Redis มีอายุ 24 ชั่วโมง
- ถ้าการล้าง cache พลาด (Redis เขียนไม่ติด / process ตายกลางทาง) สมชายยังเป็น
Adminได้ถึง 10:00 พรุ่งนี้ - SLA ที่ตกลงกันไว้คือ 30 วินาที
Revision คือตัวจับกรณีนี้ วิธีคือแยกความจริงเป็นสองที่:
| เก็บที่ | คืออะไร |
|---|---|
| ในตัว cache เอง | เลข revision ณ ตอนที่ cache ก้อนนี้ถูกเขียน |
| key แยกอีกอันหนึ่ง | เลขจริงล่าสุด — บวกขึ้นทุกครั้งที่สิทธิ์ของ user คนนั้นเปลี่ยน |
middleware อ่านทั้งสองมาเทียบกัน
- เลขใน cache น้อยกว่า เลขจริง ⇒ cache ก้อนนี้เกิดก่อนการถอนสิทธิ์ ⇒ ใช้ไม่ได้ ⇒ 503
- เลขใน cache หายไป ⇒ พิสูจน์ไม่ได้ ⇒ 503 เหมือนกัน
5. ทำไมต้องตัด role สองรอบ
ขั้น 3 ในข้อ 2 ตัด role ไปแล้วรอบหนึ่ง สำหรับ endpoint ส่วนใหญ่แค่นั้นจบ
แต่ ถ้า endpoint นั้นมี policy ที่ระบุ authentication scheme ไว้ framework จะ authenticate ใหม่อีกรอบ
ก่อนตรวจ policy แล้วเขียนทับ context.User ด้วยผลลัพธ์ใหม่
flowchart TD
A["ขั้น 3 จบ<br/>context.User = Member"] --> B[UseAuthorization]
B --> C{policy ระบุ scheme ไหม?}
C -- ไม่ --> D["ไม่มีอะไรเปลี่ยน<br/>Member — ถูกต้อง"]
C -- ใช่ --> E["framework authenticate ใหม่<br/>เขียนทับ context.User"]
E --> F["context.User = Admin + Member<br/>🔴 role ที่ตัดไปแล้ว กลับมา"]
F --> G[AppContextPolicyEvaluator<br/>ตัดใหม่]
G --> H["context.User = Member<br/>ถูกต้อง"]
โค้ดของ framework ที่เป็นต้นเหตุ:
foreach (var scheme in policy.AuthenticationSchemes)
{
var result = await context.AuthenticateAsync(scheme);
if (result != null && result.Succeeded)
newPrincipal = SecurityHelper.MergeUserPrincipal(newPrincipal, result.Principal);
}
if (newPrincipal != null)
context.User = newPrincipal; // ← ของที่เราตัดไว้ หายตรงนี้
จุดตาย: MergeUserPrincipal เริ่มจาก null ไม่ได้เริ่มจาก context.User
⇒ มันไม่ได้ merge กับของเดิม แต่ ทับทิ้งทั้งก้อน ด้วย principal ที่มี role ครบทุก app เหมือนเดิม
⇒ Admin กลับมา ⇒ สมชายผ่านด่าน Admin ของ app B ได้
AppContextPolicyEvaluator คือตัวซ่อม มันดูว่า context.User ถูกเปลี่ยนไปหรือเปล่า ถ้าถูกเปลี่ยนก็ซ่อม
การเขียนทับนี้ทำให้ เส้นปิด (EnforceAppContext=false) เสียหายด้วย — identity ที่ RedisUserInfo แนบไว้
ถือ internal Users.Id อยู่ใน NameIdentifier แล้วมันหายไปพร้อมกัน ⇒ handler อ่านได้ Entra subject แทน
ตัวซ่อมเอา identity นั้นใส่กลับ แต่ ไม่ตัด role เพราะเส้นปิดไม่เคยตัด role อยู่แล้ว
ไม่ใช่เรื่องสมมติ — Backend_WorkflowService/src/WorkFlow01.API/Base/ApiBaseController.cs:9
แปะ [Authorize(AuthenticationSchemes = "Bearer")] ไว้บน base controller ⇒ ทุก controller ที่สืบทอดโดนหมด
(เป็นที่เดียวที่เจอ จาก 10 service ที่ grep)
| เจอสถานการณ์นี้ | ทำอะไร |
|---|---|
| ไม่มี app context และไม่มี identity ของ RedisUserInfo | ไม่ทำอะไร |
| ไม่มี app context แต่ identity ของ RedisUserInfo (เส้นปิด) ถูกทับ | ใส่กลับไปไว้หน้าสุด — ไม่ตัด role เพราะเส้นปิดไม่เคยตัด |
context.User ยังเป็นก้อนเดิม | ไม่ทำอะไร — ตัดซ้ำจะถอน Member ทิ้งไปด้วย |
context.User ถูกเขียนทับ | ตัด role ใหม่ |
| ตัดใหม่ไม่สำเร็จ | คืน principal เปล่า ⇒ 401 — ไม่มีทางผ่าน |
6. Checklist สำหรับ service ที่จะเอาไปใช้
ตอนลงทะเบียน (Program.cs)
builder.Services.AddRedisUserInfo(builder.Configuration.GetSection("RedisUserInfo"));
builder.Services.AddApiPermissions(o => o.OwnerServiceName = "UserService");
ตอนต่อ pipeline — ลำดับสลับไม่ได้
app.UseAuthentication();
app.UseRedisUserInfo(); // ← ต้องอยู่ตรงนี้เท่านั้น
app.UseAuthorization();
app.MapControllers();
app.ValidateApiPermissionEndpoints(); // ← ลืมแล้ว startup พัง
app.Run();
ValidateApiPermissionEndpoints() เช็คว่าไม่มี endpoint ไหนแปะ [RequirePermission]
พร้อมกับ AllowAnonymous / [SkipAppContext] / scheme — สามอย่างนี้ทำให้ permission ไม่ทำงานจริง
ทั้งที่โค้ดยังอ่านเหมือนถูกป้องกันอยู่ ถ้าเจอมันจะบอกมาทุกตัวในข้อความเดียว
ตอนอ่านค่าใน controller
var ctx = HttpContext.GetAppContext(); // app ที่เลือก + role ใน app นั้น
var info = HttpContext.GetUserInfo(); // ข้อมูล user ทั้งก้อน
endpoint ที่ไม่มีคนเรียก (health probe, OpenAPI, static file) แปะ [SkipAppContext]
endpoint ที่แปะ marker นี้ยัง ได้ internal user id ตามปกติ (NameIdentifier)
แต่จะ ไม่มี role เลย และ GetAppContext() / GetUserInfo() คืน null
เหตุผล: marker แปลว่า “route นี้ไม่มี app” ไม่ได้แปลว่า “route นี้ไม่มีคนเรียก” — endpoint ที่บันทึกว่าใครเรียก ต้องได้ id ที่ถูก ส่วน role ไม่ใส่เพราะไม่ได้เลือก app ⇒ ไม่มีชุด role ไหนที่ถูกต้อง
ขั้นนี้ ไม่มีทาง deny ถ้า Redis ล่มก็ปล่อยผ่านโดยไม่มี identity — probe ที่ตอบ 503 เพราะ Redis ล่ม แย่กว่า probe ที่ไม่มี identity · caller ที่ยังไม่ login ไม่มี oid ⇒ ไม่ยิง Redis เลย
config 3 ตัว
| key | default | ผล |
|---|---|---|
RedisUserInfo:EnforceAppContext | false | true = เปิดด่านทั้งหมด |
RedisUserInfo:AppContextHeaderName | X-App-Id | ชื่อ header |
ApiPermissions:OwnerServiceName | ไม่มี | ชื่อ service เจ้าของ permission |
7. ทำไมยังเปิดใช้ไม่ได้
ยังไม่มีใครเขียนข้อมูลสองอย่างที่ด่านต้องใช้ — ข้อมูล permission (ด่าน 8) และ เลข revision (ด่าน 5)
เปิด EnforceAppContext=true วันนี้ ⇒ ทุก request ตกด่าน 5 หรือ 8 ⇒ 503 หมด
รุ่น 10.31.0 คือ ฝั่งอ่านล้วน ๆ ครบแล้ว แต่ยังไม่มีข้อมูลให้อ่าน ฝั่งเขียนเป็นงานของ UserService
8. ภาคผนวก — เหตุผลเบื้องหลัง
ไม่ต้องอ่านตอนแรก อ่านตอนสงสัยว่า “ทำไมทำแบบนี้”
ทำไม 401 ใช้แค่ด่าน 0
FE interceptor ผูก 401 ไว้กับ “ไป refresh token” ถ้าตอบ 401 ตอน Redis ล่ม client ทุกตัวจะเข้า refresh loop พร้อมกันในวินาทีที่ backend รับไม่ไหวที่สุด ด่านที่เหลือจึงเป็น 503
ทำไม 503 ทุกสาเหตุใช้ code เดียว
แยกให้ละเอียด = บอก caller ที่ยังไม่ยืนยันตัวตนว่าตอนนี้ Redis ล่ม / schema เปลี่ยน / ข้อมูลเก่า
ไม่มีเหตุผลที่ caller ต้องรู้ ส่วนคนที่ต้อง debug ดูจาก log ซึ่งมี check= บอกด่านชัดเจน
ทำไมเลขด่านเป็น enum ไม่ใช่ตัวเลขลอย
เดิมฟังก์ชันที่เขียน response รับ HTTP status กับเลขด่านเป็น คนละ argument มี call site 13 จุด และไม่มีอะไรผูกสองค่านั้นเข้าด้วยกัน
เขียน checkId: 6 คู่กับ Status503ServiceUnavailable ก็ compile ผ่าน แล้วได้ log ที่ขัดกับสิ่งที่ตอบไปจริง
ซ้ำร้ายกว่านั้น การแปลง status เป็น code ถูกทำอีกรอบข้างในฟังก์ชัน = mapping สองชุดที่เพี้ยนจากกันได้
ตอนนี้ AppContextDenial ค่าเดียวตัดสินทั้งสามอย่าง และค่าตัวเลขของมัน คือ เลข check= ใน log
ทำไม key ใน HttpContext.Items เป็น Type ไม่ใช่ string
internal static readonly object UserInfoItemKey = typeof(UserInfoForRedis);
internal static readonly object AppContextItemKey = typeof(AppContextResult);
internal const string LegacyUserInfoItemKey = "UserInfoForRedis"; // เขียนคู่ไว้ 1 release
HttpContext.Items เป็น dictionary ตัวเดียว ที่ middleware ทุกตัวในแอปเขียนร่วมกัน
ตั้งชื่อ "AppContext" เฉย ๆ แล้วมีคนอื่นใช้ชื่อซ้ำ ค่าจะทับกันโดยไม่มี error
key จึงต้องไม่ชนกับโค้ดที่ lib ไม่เคยเห็น วิธีปกติคือใส่ prefix แต่ prefix ที่อ่านรู้เรื่องย่อมมีชื่อ product อยู่
และ lib ตัวนี้ไม่ได้ผูกกับ product นี้ตลอดไป — ต้องมาเปลี่ยน runtime contract เพราะเปลี่ยนแบรนด์ ไม่คุ้ม
Items รับ key เป็น object อยู่แล้ว ⇒ ใช้ Type ไปเลย ชนไม่ได้โดยโครงสร้าง และไม่มีชื่ออะไรให้ล้าสมัย
แต่ลบ string เก่าทิ้งเลยไม่ได้ เพราะมี consumer ที่เซ็ต Items ด้วย literal เอง ไม่ได้ผ่าน package —
Backend_NotificationService/tests/.../DiagnosticsControllerTests.cs:15 ประกาศ "UserInfoForRedis" ของตัวเอง
ถ้าลบทิ้ง GetUserInfo() จะเริ่มคืน null โดยไม่มี error อะไรบอก
จึงเขียนสอง key ชี้ object เดียวกัน และ GetUserInfo() อ่านได้ทั้งสอง ลบ key เก่าเมื่อไม่มีใครอ่านแล้ว
ส่วน AppContextResult ไม่มี key เก่า เพราะยังไม่มีใครใช้
ทั้งหมดเป็น internal ฝั่ง consumer ใช้ GetAppContext() / GetUserInfo() ไม่ต้องรู้ชื่อ key
กับดัก: RequireRole มองไม่เห็น role ฝั่ง Redis
identity จาก JWT ใช้ RoleClaimType = "CustomRoles" แต่ identity ที่ RedisUserInfo แนบเข้าไปใช้ ClaimTypes.Role
⇒ policy ที่เขียนด้วย .RequireRole(...) มองไม่เห็น role ที่มาจาก Redis ต้องใช้ .RequireClaim(...) แทน
ทำไมอ่าน Redis ทีละ key ไม่ใช่ multi-key ครั้งเดียว
สอง key นี้ไม่มี hash tag ร่วมกัน บน Redis แบบ cluster มันตกคนละ slot แล้ว multi-key read จะ fail
key ของ cache มีมาก่อน feature นี้ ใช้งานจริงอยู่ทุก env แล้ว จะเปลี่ยนชื่อให้ไปแชร์ hash tag ไม่ได้ — เปลี่ยนแล้ว cache ทั้ง fleet กลายเป็น miss พร้อมกัน
จึงยิงแยกสองครั้ง ยิงทั้งคู่ก่อนแล้วค่อย await ⇒ StackExchange.Redis pipeline ให้เอง ไม่เสีย round trip เพิ่ม และการอ่านแบบไม่ atomic ปลอดภัยทั้งสองลำดับ: cache เก่าเทียบเลขใหม่ = deny (ถูก), cache ใหม่เทียบเลขเก่า = ผ่าน (ถูกเหมือนกัน)
ทำไม ValidateApiPermissionEndpoints() ต้องให้ consumer เรียกเอง
เวอร์ชันแรกเช็คจาก hosted service ของตัวเอง ผิด และพิสูจน์แล้วว่าผิดบน host จริง
framework ย้ายรายการ endpoint เข้าที่ที่ hosted service อ่านได้ หลัง hosted service ของ user เริ่มไปแล้ว
⇒ ตอนเช็คมันเห็นรายการว่าง ไม่เจออะไร แล้วปล่อยให้ endpoint ที่ AllowAnonymous + [RequirePermission]
ตอบ 200 จริง (probe วัดได้ DIEndpointsBeforeStart=0, DIEndpointsAfterStart=3)
ย้ายไป ApplicationStarted จะแย่กว่า — callback นั้นรันใน try/catch ของ host
exception จะถูก log เฉย ๆ แล้ว process ยังรับ request ต่อ
ตอนนี้ PermissionStartupGuard เหลือหน้าที่เดียว: ถ้า consumer ลืมเรียก ให้ startup พัง
การเช็ค scheme ที่ guard ทำ เป็น ชั้นแรกจากสองชั้น — มันจับได้เฉพาะ endpoint ที่แปะ [RequirePermission]
ส่วน endpoint ที่ gate ด้วย role ล้วนซึ่ง guard มองไม่เห็น ปิดด้วยตัวซ่อมในข้อ 5 ตอน runtime
ลำดับการ register ที่พลาดแล้วเงียบ
ถ้า service จะ register IPolicyEvaluator ของตัวเอง ต้อง register ก่อน AddRedisUserInfo
register ทีหลัง = ตัวสุดท้ายชนะ ⇒ ตัวซ่อมในข้อ 5 หลุดไปทั้งตัวโดยไม่มี error วันนี้ยังไม่มี service ไหนทำ
[RequirePermission] ทำงานยังไง
RequirePermissionAttribute สืบทอด AuthorizeAttribute และ เซ็ต Policy เอง ถ้าไม่ทำ
policy provider จะไม่ถูกเรียกเลย และ attribute จะไม่ทำอะไรโดยไม่มี error
handler fail closed ทุกอย่างที่พิสูจน์ไม่ได้ — ไม่มี app context, ข้อมูล permission เป็น null,
resolve ชื่อ service ไม่ได้, revision พิสูจน์ไม่ได้, หรือมี exception — ทั้งหมด Fail() ซึ่ง observe mode ปล่อยผ่านไม่ได้
มีกรณีเดียวที่มันไม่ทำอะไรเลย: ทุกอย่างครบถ้วนสดใหม่ แล้วแค่ไม่มีสิทธิ์ที่ตรง — นั่นคือสถานะเดียวที่ควรถูก stage
OwnerServiceName แยกเป็น key ของตัวเอง ไม่ derive จาก ServiceIdentity:ServiceName
เพราะค่านั้นมี prefix ของ environment (DEV_UserService) ⇒ สิทธิ์ที่บันทึกบน env หนึ่งจะไม่ match บนอีก env
และการถอด prefix ออกคือ pattern ที่เคยทำให้เกิด incident บน platform นี้มาแล้ว
Observe mode — เส้นไหน stage ได้บ้าง
AuthorizationObserve:Mode=LogOnly = บาง denial แค่ log แล้วปล่อยผ่าน ไม่ block จริง
flowchart TD
A[ถูกปฏิเสธ] --> B{handler เรียก Fail เองไหม?}
B -- ใช่ --> Z[block เสมอ]
B -- ไม่ --> C{รู้ไหมว่าอะไร fail?}
C -- ไม่รู้ --> Z2[block — เดาไม่ได้ก็ไม่ปล่อย]
C -- รู้ --> D{endpoint กำลังย้ายไป permission?}
D -- ใช่ --> E{ทุกอย่างที่ fail<br/>เป็น permission?}
D -- ไม่ --> F{ทุกอย่างที่ fail<br/>เป็น role?}
E -- ใช่ --> S[log แล้วปล่อยผ่าน]
E -- ไม่ --> Z3[block]
F -- ใช่ --> S
F -- ไม่ --> Z4[block]
เปลี่ยนจากเดิมและเปลี่ยนผลลัพธ์จริง: เดิมโค้ดถามว่า policy มีอะไรอยู่ข้างใน ไม่ใช่ถามว่า อะไร fail
policy ที่รวม role กับ claim ไว้ด้วยกัน โดยส่วน role ผ่านแต่ส่วน claim fail จึงถูกจัดเป็น “role fail” แล้วปล่อยผ่าน
⚠️ service ที่รัน LogOnly อยู่ จะเห็น request แบบนั้นถูก block ทั้งที่เมื่อก่อนผ่าน ตั้งใจให้รัดขึ้น
ที่ต้องแยกสองเส้น เพราะ endpoint ที่กำลังย้ายไป permission มักยังมี role gate ติดอยู่ด้วย ใช้เงื่อนไขเดียวแล้ว role ที่ fail บน endpoint พวกนั้นจะถูกปล่อยผ่านไปด้วย = รั่ว ส่วน role ล้วนบน endpoint ทั่วไปยังต้องปล่อยผ่านต่อ ไม่งั้น probe ของ security uplift ข้อ 1.5 ที่รันบน dev จะถูกปิดไปเงียบ ๆ
ทำไม CurrentSchemaVersion ยัง = 1 และห้ามขึ้น
field ใหม่สองตัว (revision, apiPermissions) เป็น nullable ที่ ไม่มีค่า default
⇒ cache ที่เขียนโดยของเก่าจะอ่านได้เป็น null ⇒ ด่านแยกออกว่า “ยังไม่พร้อม” ต่างจาก “พร้อมแล้วแต่ไม่มีสิทธิ์”
ถ้าใส่ค่า default ด่านนี้จะปลดอาวุธตัวเองเงียบ ๆ และถ้าขึ้น schema version cache ทุกก้อนในระบบจะ mismatch แล้วทั้ง fleet ตอบ 503 จนกว่า TTL 24 ชั่วโมงจะหมุนครบ
ตอนเพิ่ม cardId passportNo custCode amloStatus ก็ไม่ได้ขึ้น version เหมือนกัน
request ที่ไม่ตรง route ไหนเลย
อยู่ในขอบเขต ไม่ได้ถูกยกเว้น — ไม่มี endpoint = ไม่มี metadata = ไม่มีเครื่องหมายยกเว้น
การยกเว้นตัดสินจาก endpoint metadata ฝั่ง server เท่านั้น ไม่มีเงื่อนไขไหนที่ caller ทำเองแล้วหลุดออกจากขอบเขตได้ รวมถึงการไม่ส่ง header
9. รายการที่ค้างในตัว lib
ไม่เหลือแล้ว ณ db2ad07 ห้าข้อที่เคยค้างปิดครบ:
| เคยค้าง | ปิดที่ |
|---|---|
GetUserInfo() คืนทุก app ทุก role แม้ผ่าน gate | 9b018f2 |
[SkipAppContext] ไม่ได้ internal user id | d123ca6 |
เส้น EnforceAppContext=false เสีย NameIdentifier บน endpoint ที่ผูก scheme | 0d8cf76 |
AddApiPermissions ทิ้ง policy provider ของ consumer | ca92c28 |
UserInfoItemKey ไม่มี prefix — จบที่ใช้ Type เป็น key แทน string | 3f0c2b4 + db2ad07 |
ที่ยังเหลือ ไม่ใช่งานของ lib: ฝั่งเขียนข้อมูล permission และ revision เป็นงานของ UserService
ตราบใดที่ยังไม่มี EnforceAppContext ก็เปิดไม่ได้ (ข้อ 7)
มีอีกหนึ่งอย่างที่ตั้งใจทิ้งไว้: LegacyUserInfoItemKey ต้องถูกลบเมื่อไม่มี consumer ไหนอ่านชื่อเก่าแล้ว
อ่านต่อ
- SupApp_util_lib — ดึง Role/UserInfo ผ่าน Central Library — RedisUserInfo ฝั่งที่ใช้อยู่วันนี้
CHANGELOG.md10.31.0 ในBackend_Package— รายการเปลี่ยนแปลงฉบับเต็ม