API Restriction · 02 — dynamic role (spec)
spec ล้วน — data model ตารางใหม่ รูปร่าง blob สัญญาตอนตรวจสิทธิ์ เกณฑ์ตั้งชื่อ และงานที่ต้องทำแยกตาม repo
อัปเดต: 2026-09-08
⬅️ 00 — ปัญหาจริงมีข้อเดียว · 🔗 ต้องทำก่อน 01 — role ที่ผูกกับ app ที่เรียกจริง
02. dynamic role — spec
ตรวจกับ Backend_UserService@25b1923 และ Backend_Package@10.30.0 แล้ว ยกเว้นที่กำกับ ยังไม่ verify
มติ
- endpoint ประกาศชื่อสิทธิ์ผ่าน attribute ในโค้ด —
[RequirePermission("amlo:company:unblock")] - แยกจากตาราง
Permissionsของหน้าบ้าน ใช้ตารางใหม่RoleApiPermissionที่เก็บชื่อเป็น string ไม่มี FK - แต่ละ service เป็นเจ้าของรายการสิทธิ์ของตัวเอง UserService เก็บแค่ว่า role ไหนได้ชื่อไหน
- ตอนตรวจ เทียบทั้งชื่อและเจ้าของ โดยเจ้าของฝั่ง service มาจาก
ServiceIdentity:ServiceNameที่มีอยู่แล้ว
Data model
ตารางใหม่ RoleApiPermission (UserService)
| คอลัมน์ | ชนิด | หมายเหตุ |
|---|---|---|
Id | uuid | |
RoleId | uuid | FK → Roles |
PermissionName | varchar(150) | string เปล่า ไม่มี FK |
OwnerServiceName | varchar(100) | ค่าเดียวกับ ServiceIdentity:ServiceName ของ service เจ้าของ |
| audit fields | ตาม IAuditableEntity |
unique (RoleId, PermissionName, OwnerServiceName)
🔴 ไม่มี FK ไปตาราง permission ใด ๆ โดยตั้งใจ — UserService จึงไม่ต้องรู้ล่วงหน้าว่า service ไหนมี endpoint อะไร · service ใหม่เกิดขึ้น = 0 แถวที่ต้องสร้างไว้ก่อน
รายการสิทธิ์ของแต่ละ service
service เป็นคนรู้ว่าตัวเองมีสิทธิ์อะไร เพราะมันอยู่ในโค้ดของตัวเองอยู่แล้ว (attribute) · Backend_Package เพิ่ม endpoint กลางหนึ่งเส้นที่อ่าน attribute ทั้ง assembly ด้วย reflection แล้วคืนรายการออกมา — admin UI เรียกเส้นนี้เพื่อเอาไปให้คนเลือก แล้วเขียนผลลัพธ์กลับมาที่ RoleApiPermission
⇒ ไม่มีใครต้อง maintain รายการสิทธิ์ด้วยมือ และไม่มีทางที่รายการจะไม่ตรงกับโค้ด
ผลข้างเคียงที่ต้องรับ: แถวที่ชี้ไปยังชื่อที่ไม่มี service ไหนประกาศแล้ว (endpoint ถูกลบ) จะกลายเป็น string ลอย ไม่ error · admin UI ต้องเตือน และตัวตรวจตอน runtime ไม่สนใจมัน
ข้อกำหนด catalog: UI ไม่ใช่ security boundary ของการผูกสิทธิ์ API ฝั่ง server ต้องตรวจผู้มีสิทธิ์จัดการ grants, target RoleId/AppId และคู่ ownerService/permission ที่อนุญาตให้ผูกจาก catalog ของ service ที่เชื่อถือได้ ห้ามรับ URL catalog จาก caller แล้ว fetch ตามทันที; endpoint อ่าน catalog ต้องมี authorization และการเลิกใช้ชื่อสิทธิ์ต้องไม่ทำให้ grant เก่ากลับมามีผลโดยอัตโนมัติเมื่อมีการนำชื่อเดิมไปใช้กับ operation คนละความหมาย
UserInfoForRedis — field ใหม่ใต้ UserAppInfo
{
"apps": [
{
"appId": "f2222222-…",
"roles": [{ "id": "r004", "name": "FxCompliance" }],
"apiPermissions": [
{ "name": "amlo:company:unblock", "ownerService": "UserService" }
]
}
],
"schemaVersion": 1
}
- อยู่ ใต้แต่ละ app ไม่กองรวม — ถ้ากองรวม user จะได้สิทธิ์จาก role ฝั่ง Platform ตอนเรียกผ่าน FX
ownerServiceติดไปทุกตัวschemaVersionยังเป็น1🔴 ห้ามแตะ — mismatch = ไม่ inject claim เลยจนกว่า TTL 86400 วินาทีจะหมด (RedisUserInfoMiddleware.cs:275-282)
Company scope: blob ตัวอย่างนี้ยังใช้กับ company-scoped assignments ไม่ได้ ห้าม union สิทธิ์ของ role ที่ผูกบริษัท A เข้า apiPermissions ระดับ app แล้วใช้กับบริษัท B ต้องรักษา CompanyId ของ assignment และตรวจ target resource/company ฝั่ง server หรือจำกัด rollout ไว้ที่ explicit global assignments เท่านั้น; การมี companyId ใน route ไม่ได้แปลว่า caller ได้สิทธิ์ข้ามทุกบริษัท ส่วน AMLO global access ต้องได้รับการยืนยันเป็นราย permission ไม่อนุมานจากชื่อ role
ข้อมูลจำลอง
ใช้เส้นจริง AdminAmloController (ปลดล็อกบริษัท) เจ้าของคือ UserService
ในโค้ด เขียนครั้งเดียวตอนสร้าง endpoint:
[RequirePermission("amlo:company:unblock")] public async Task<IActionResult> UnblockCompany(...) { }
[RequirePermission("amlo:company:read")] public async Task<IActionResult> GetBlockedCompanies(...) { }
[Authorize] public async Task<IActionResult> GetUserDetailAsync(...) { }
RoleApiPermission วันนี้:
| RoleId | Role | PermissionName | OwnerServiceName |
|---|---|---|---|
r001 | Platform · AmloAdmin | amlo:company:unblock | UserService |
r001 | Platform · AmloAdmin | amlo:company:read | UserService |
r002 | Platform · AmloOperator | amlo:company:unblock | UserService |
r002 | Platform · AmloOperator | amlo:company:read | UserService |
r003 | Platform · ComplianceLead | amlo:company:unblock | UserService |
| role | POST …/unblock | GET …/blocked-companies | GET /users/me |
|---|---|---|---|
Platform · AmloAdmin | ✅ | ✅ | ✅ |
Platform · AmloOperator | ✅ | ✅ | ✅ |
Platform · ComplianceLead | ✅ | ❌ | ✅ |
FX · FxCompliance | ❌ | ❌ | ✅ |
FX · FxUser | ❌ | ❌ | ✅ |
พรุ่งนี้ BU ขอให้ FxCompliance ปลดล็อกได้ — insert 1 แถว:
| RoleId | Role | PermissionName | OwnerServiceName |
|---|---|---|---|
r004 | FX · FxCompliance | amlo:company:unblock | UserService |
| role | POST …/unblock | GET …/blocked-companies | GET /users/me |
|---|---|---|---|
Platform · AmloAdmin | ✅ | ✅ | ✅ |
Platform · AmloOperator | ✅ | ✅ | ✅ |
Platform · ComplianceLead | ✅ | ❌ | ✅ |
FX · FxCompliance | ✅ ← เปลี่ยน | ❌ | ✅ |
FX · FxUser | ❌ | ❌ | ✅ |
ไม่แก้โค้ด ไม่ deploy · role ของ FX ผูกกับสิทธิ์ที่ UserService เป็นเจ้าของได้ทันที เพราะไม่มี FK ที่จะไปบล็อก
ตอน request เข้ามา
sequenceDiagram
autonumber
participant FE as หน้าจอ FX
participant MW as middleware กลาง
participant H as PermissionHandler
participant EP as endpoint ปลดล็อกบริษัท
FE->>MW: request + header AppId f2222222
MW->>MW: หา app ในบล็อกของ user ไม่เจอ ให้ 401
MW->>H: ส่ง UserInfo และ app ที่เลือกไว้แล้ว
H->>H: เส้นนี้ต้องการ amlo company unblock
H->>H: เทียบชื่อ และเทียบ ownerService กับ ServiceIdentity ของ service นี้
alt ตรงทั้งคู่
H->>EP: เข้า handler
EP-->>FE: 200
else ไม่ตรง
H-->>FE: 403
end
PermissionRequirement(name)
app = app ที่ middleware ข้อ 1 เลือกไว้แล้ว (อ่านจาก HttpContext ห้ามอ่าน header ซ้ำ)
ok = app.ApiPermissions.Any(p => p.Name == name
&& p.OwnerService == options.ServiceName)
ok == true → context.Succeed(requirement)
ok == false → ไม่ทำอะไร ปล่อยให้ requirement ไม่ผ่านเอง
ข้อแก้ไข PermissionHandler: ไม่เรียก context.Fail() เฉพาะกรณีตรวจ app context/freshness สำเร็จแล้วแต่ไม่มี permission ที่กำลังซ้อมเท่านั้น; authentication, app resolution, freshness, company/resource scope และ explicit security veto ต้องยังเป็น hard denial เสมอ ห้ามเลี่ยง context.Fail() เพียงเพื่อให้ LogOnly ปล่อย request ที่พิสูจน์สิทธิ์ไม่ได้
| สถานการณ์ | ผล |
|---|---|
GetUserInfo() เป็น null | ไม่ผ่าน |
| ไม่มี app ที่เลือก | ไม่ถึงตรงนี้ — 401 ที่ middleware ของข้อ 1 |
apiPermissions เป็น null (blob เก่า) | ไม่ผ่าน ⇒ ที่มาของลำดับ deploy |
ชื่อตรงแต่ ownerService ไม่ตรง | ไม่ผ่าน |
ServiceIdentity:ServiceName ว่าง | ไม่ผ่านทุกกรณี + log error ตอน startup |
Policy contract: dynamic policy ต้อง RequireAuthenticatedUser() โดยชัดเจนและรักษา named/default/fallback policies เดิม; permission ที่ชื่อไม่รู้จักหรือข้อมูล null ต้องไม่กลายเป็น allow Policy ต้องอ่านเฉพาะ app context ที่ middleware ตรวจแล้วและต้องใช้ทั้ง owner service กับ permission name ไม่ใช้ role name หรือ claims จาก identity อื่นแทน สำหรับ invalid ServiceIdentity ต้องปฏิเสธตั้งแต่ startup/readiness เมื่อเปิด enforcement และห้ามถูก observe bypass
ตอน admin เพิ่ม role
sequenceDiagram
autonumber
participant A as admin UI
participant SV as service เจ้าของ endpoint
participant API as UserService
participant R as Redis UserInfo
participant U as user ที่ถือ role ใหม่
A->>SV: ขอรายการสิทธิ์ของ service นี้
SV-->>A: รายชื่อจาก attribute ในโค้ด
A->>API: ผูก role D เข้ากับสิทธิ์ที่เลือก
API->>API: insert RoleApiPermission
A->>API: refresh cache ของ user ที่กระทบ
API->>R: เขียน blob ใหม่
U->>U: request ถัดไป ผ่านแล้ว
การเขียน cache ใหม่ — ส่วนที่ทำให้คำว่า dynamic เป็นจริง
blob ใน Redis เป็น cache อายุ 86400 วินาที (RedisUserInfoOptions.CacheTtlSeconds ค่าเริ่มต้น) ⇒ แก้แถวใน RoleApiPermission เฉย ๆ ไม่มีผลจนกว่า cache จะเปลี่ยน
มีของให้ใช้อยู่แล้ววันนี้: IUserCacheRefreshService.RefreshAsync(userId) และ POST /user-cache/refresh/{userId} (UserCacheController.cs:68) — เขียน blob ของ user คนเดียวใหม่ ไม่ต้องรอ TTL
กติกาที่ต้องเขียนให้ครบ:
| กรณี | ต้องทำอะไร |
|---|---|
| ผูกสิทธิ์เพิ่ม | เรียก refresh ของ user ทุกคนที่ถือ role นั้น |
| ถอนสิทธิ์ | เรียก refresh และต้องรู้ผล — ดูข้อถัดไป |
| refresh ล้มเหลว | รายงานกลับไปที่ผู้เรียกว่ายังไม่มีผลจริง พร้อมทางให้ retry |
🔴 การถอนสิทธิ์ต้องถือว่า refresh เป็นส่วนหนึ่งของงาน ไม่ใช่ fire-and-forget — ถ้า RefreshAsync ล้มเหลวหลังลบแถวสำเร็จ blob เดิมยังถือสิทธิ์เก่าไปอีกไม่เกิน 86400 วินาที และคนที่กดถอนเห็นว่าสำเร็จ ⇒ ต้อง surface ความล้มเหลวกลับไปที่ admin UI ไม่ใช่กลืนไว้
จำนวน user ที่ต้อง refresh โตตามขนาดของ role แต่ไม่ใช่เหตุผลให้ยอมรับ revoke ช้าถึง TTL โดยอัตโนมัติ ต้องกำหนด revocation SLA เป็นตัวเลข, owner ของ fan-out และ freshness authority ก่อน rollout; ถ้ายังไม่มีข้อยุติให้ถือว่า rollout ถูก block ไม่ใช่ยอมรับ 24 ชั่วโมงไปก่อน
ข้อแก้ไข flow ข้างบน: backend ต้องเป็นผู้รับผิดชอบ refresh/invalidation หลังเปลี่ยนสิทธิ์ ไม่พึ่งลูกศรที่ admin UI เรียก refresh แยก บันทึก mutation, authorization revision และ durable refresh work ใน DB transaction เดียวกันได้ เช่น transactional outbox; DB commit กับ Redis write ไม่ใช่ atomic transaction เดียวกัน ต้องมี retry แบบ idempotent, progress ของ affected users และสถานะ pending/failed ที่ไม่รายงานว่า revoke มีผลครบแล้วเมื่อยังยืนยันไม่ได้
Revision/freshness contract: snapshot ต้องผูกกับ monotonic authorization revision ที่อ่านสอดคล้องกับ grants จาก source of truth; ทุก writer รวม HTTP fallback warming และ retry ต้องไม่เขียน revision เก่าทับใหม่ และ permission request ต้องตรวจ revision/freshness กับ authority ที่เชื่อถือได้ภายใน SLA ที่อนุมัติ การ compare เฉพาะ blob ที่ไม่มี authority ตรวจ revoke ยังไม่พอ ถ้า cache หาย writer เก่าต้องไม่สามารถฟื้น grant ที่ถอนแล้วได้; เมื่อยืนยันความสดไม่ได้ให้ deny แม้ LogOnly และอย่านำ schemaVersion มาใช้แทน authorization revision
ขอบเขต invalidation ต้องรวมการผูก/ถอน RoleApiPermission, assign/remove UserRole, disable/delete role, เปลี่ยน app/company membership และสถานะ user ที่มีผลต่อสิทธิ์ ไม่ใช่เฉพาะ API ใหม่ ต้องทดสอบ delayed warm ที่เริ่มก่อน revoke, worker crash หลัง DB commit, out-of-order retry และ partial fan-out; หาก cache ยังอยู่ใน freshness window ที่อนุมัติ ต้องรายงาน revoke เป็น pending จนกว่าจะรับประกันว่า grant เก่าใช้ไม่ได้ตาม SLA
เกณฑ์ตั้งชื่อ
{resource}:{action} · ยาวรวมไม่เกิน 150
| ส่วน | กฎ | ตัวอย่าง |
|---|---|---|
resource | สิ่งที่ถูกกระทำ ภาษาธุรกิจ ไม่ใช่ route · มีลำดับชั้นได้ | company · amlo:company · onboarding:document |
action | กริยาจากชุดปิด | read create update delete approve reject export unblock |
ห้ามใส่ในชื่อ: ชื่อ service (มีคอลัมน์ OwnerServiceName แล้ว) · HTTP method · เวอร์ชัน · route — ทั้งหมดเปลี่ยนได้โดยความหมายไม่เปลี่ยน ⇒ ชื่อจะต้องเปลี่ยนตาม แล้วต้องกลับไปแก้โค้ด
🔴 1 ชื่อครอบได้หลาย endpoint — GET /companies, /companies/{id}, /companies/search ใช้ company:read ตัวเดียวกัน ⇒ 400 endpoint ยุบเหลือไม่กี่สิบชื่อ · ไม่ใช้ wildcard เพราะ endpoint ใหม่ที่ขึ้นต้นด้วย prefix เดียวกันจะเรียกได้ทันทีโดยไม่มีใครอนุมัติ · “เลือกทั้งกลุ่ม” เป็นเรื่องของ UI ที่ขยายเป็นแถวจริงตอน save
งานต่อ repo
Backend_Package
| # | งาน |
|---|---|
| P1 | RequirePermissionAttribute |
| P2 | PermissionRequirement + AuthorizationHandler ตามสัญญาข้างบน |
| P3 | IAuthorizationPolicyProvider แปลงชื่อเป็น policy แบบ dynamic |
| P4 | endpoint กลางคืนรายการสิทธิ์ของ service นั้น อ่านจาก attribute ด้วย reflection |
| P5 | ApiPermissions ใต้ UserAppInfo แบบ optional 🔴 ห้ามแตะ CurrentSchemaVersion |
| P6 | แก้ AuthorizationObserveResultHandler ให้ตรวจ FailedRequirements และซ้อมเฉพาะ pending PermissionRequirement ของ endpoint ที่ OptIn โดยชัดเจน; ห้ามเพิ่ม ClaimsAuthorizationRequirement หรือ RolesAuthorizationRequirement ทั้งชนิดเข้า bypass allow-list ของ rollout นี้ ต้องคง AmloAdminAccess และ hard prerequisites ทุกตัวไว้ |
P6: วันนี้ :124-126 ตัดสินจากการ มีอยู่ ของ RolesAuthorizationRequirement ⇒ เส้นที่แปะแค่ [RequirePermission] จะปฏิเสธจริงตั้งแต่วันแรก ไม่มีช่วงซ้อม · และถ้าแปะคู่กับ [Authorize(Roles=…)] การปฏิเสธจาก permission จะถูก LogOnly ปล่อยผ่าน ซึ่งเป็น fail-open
สัญญา P6 ใหม่: LogOnly เรียก endpoint ต่อได้เมื่อ caller authenticated, hard prerequisites ผ่าน, ไม่มี explicit Fail() และ FailedRequirements เป็นชุดไม่ว่างที่ทุกตัวคือ pending PermissionRequirement ที่เลือกซ้อมบน endpoint นี้เท่านั้น ถ้า old role/claim policy, company scope, freshness หรือ requirement อื่น fail ให้ส่งต่อผล deny เดิม การบันทึกผลของ ClaimsAuthorizationRequirement ทำได้ แต่การ observe/log ไม่ใช่การอนุญาตให้ bypass มัน
Backend_UserService
| # | งาน |
|---|---|
| U1 | entity + EF config + migration ของ RoleApiPermission 🔴 ต้อง author บน branch MIGRATION |
| U2 | API ผูก/ถอน/อ่าน RoleApiPermission (คู่ขนานกับ RolePermissionController ไม่ใช่แก้ของเดิม) |
| U3 | UsersMapper.ToUserInfoForRedis เติม apiPermissions ใต้แต่ละ app + เพิ่ม include ใน GetUserByIdWithCacheDetailsAsync |
| U4 | ปิดช่อง PermissionController.cs:23 และ RolePermissionController.cs:24 ที่มีแค่ [Authorize] เปล่า และให้ API ใหม่ในข้อ U2 ถูก gate ตั้งแต่แรก |
| U4a | 🔴 gate การ assign/remove/update role ด้วย ไม่ใช่แค่ U4 — UserRoleController.cs:32 (AssignShellRoleAsync, AssignCompanyRoleAsync, RemoveShellRoleAsync, RemoveCompanyRoleAsync) และ RoleController.cs:23 (UpdateRoles) มีแค่ [Authorize] เปล่าเหมือนกัน — AssignShellRoleHandler.cs:36 ตรวจแค่ user/role มีอยู่จริงและยัง active ไม่ตรวจว่า caller มีสิทธิ์ assign role นั้นไหม ⇒ user คนไหนก็ตามที่ login ได้ ยกสิทธิ์ตัวเองเป็น role ระดับสูงสุดได้ทันทีโดยไม่ต้องแตะ RoleApiPermission เลย — ถ้าไม่ปิดพร้อมกับ U4 ทั้งงานนี้ไร้ความหมาย |
| U5 | เติม [Authorize] ให้ /users/me |
U4/U4a เป็น prerequisite ก่อนเพิ่ม grants: ต้องตรวจ caller จาก trusted app-scoped identity, target user/role/app/company และขอบเขตการมอบสิทธิ์จริงฝั่ง server ห้ามใช้ชื่อ "Admin" ที่ไม่ผูก app หรือรับ admin flag จาก request; policy สำหรับผู้จัดการ grants ต้องไม่ถูก LogOnly หรือ API ที่ user ปกติแก้ได้ยกระดับกลับมาเอง ต้องมีวิธี bootstrap/recovery ของ platform-admin ที่จำกัดสิทธิ์และมี audit ก่อน deploy
งาน UserService เพิ่มเติม: implementation ต้องมี backend-owned refresh/invalidation ตาม revision contract, กรอง role/assignment/user ที่ถูกปิดหรือถอนแล้ว, รักษา company scope และ audit การเปลี่ยน grants พร้อมผล revoke โดยไม่บันทึก token/session/PII การใช้ RefreshAsync ราย user ที่มีอยู่เป็นเพียงส่วนหนึ่งของงาน ไม่ใช่หลักฐานว่ารองรับข้อกำหนดเหล่านี้แล้ว
Backend_Iac
มีงาน config parity ตามไฟล์ 01: header name และ enforcement switch ต้องครบทุก env ที่ deploy พร้อมตรวจ ServiceIdentity:ServiceName และการส่ง header/CORS ที่ boundary จริง ค่าที่มีอยู่แล้วไม่ต้องเปลี่ยนโดยไม่มีเหตุ แต่ยังสรุปว่า "ไม่มีงาน" ไม่ได้จนกว่าจะ verify ทั้งชุด
ลำดับ deploy · เรื่องที่ต้องตัดสิน · หลักฐานที่ยังขาด → 03