Private Docs

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

มติ

  1. endpoint ประกาศชื่อสิทธิ์ผ่าน attribute ในโค้ด — [RequirePermission("amlo:company:unblock")]
  2. แยกจากตาราง Permissions ของหน้าบ้าน ใช้ตารางใหม่ RoleApiPermission ที่เก็บชื่อเป็น string ไม่มี FK
  3. แต่ละ service เป็นเจ้าของรายการสิทธิ์ของตัวเอง UserService เก็บแค่ว่า role ไหนได้ชื่อไหน
  4. ตอนตรวจ เทียบทั้งชื่อและเจ้าของ โดยเจ้าของฝั่ง service มาจาก ServiceIdentity:ServiceName ที่มีอยู่แล้ว

Data model

ตารางใหม่ RoleApiPermission (UserService)

คอลัมน์ชนิดหมายเหตุ
Iduuid
RoleIduuidFK → Roles
PermissionNamevarchar(150)string เปล่า ไม่มี FK
OwnerServiceNamevarchar(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
}
  1. อยู่ ใต้แต่ละ app ไม่กองรวม — ถ้ากองรวม user จะได้สิทธิ์จาก role ฝั่ง Platform ตอนเรียกผ่าน FX
  2. ownerService ติดไปทุกตัว
  3. 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 วันนี้:

RoleIdRolePermissionNameOwnerServiceName
r001Platform · AmloAdminamlo:company:unblockUserService
r001Platform · AmloAdminamlo:company:readUserService
r002Platform · AmloOperatoramlo:company:unblockUserService
r002Platform · AmloOperatoramlo:company:readUserService
r003Platform · ComplianceLeadamlo:company:unblockUserService
rolePOST …/unblockGET …/blocked-companiesGET /users/me
Platform · AmloAdmin
Platform · AmloOperator
Platform · ComplianceLead
FX · FxCompliance
FX · FxUser

พรุ่งนี้ BU ขอให้ FxCompliance ปลดล็อกได้ — insert 1 แถว:

RoleIdRolePermissionNameOwnerServiceName
r004FX · FxComplianceamlo:company:unblockUserService
rolePOST …/unblockGET …/blocked-companiesGET /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 ชื่อครอบได้หลาย endpointGET /companies, /companies/{id}, /companies/search ใช้ company:read ตัวเดียวกัน ⇒ 400 endpoint ยุบเหลือไม่กี่สิบชื่อ · ไม่ใช้ wildcard เพราะ endpoint ใหม่ที่ขึ้นต้นด้วย prefix เดียวกันจะเรียกได้ทันทีโดยไม่มีใครอนุมัติ · “เลือกทั้งกลุ่ม” เป็นเรื่องของ UI ที่ขยายเป็นแถวจริงตอน save

งานต่อ repo

Backend_Package

#งาน
P1RequirePermissionAttribute
P2PermissionRequirement + AuthorizationHandler ตามสัญญาข้างบน
P3IAuthorizationPolicyProvider แปลงชื่อเป็น policy แบบ dynamic
P4endpoint กลางคืนรายการสิทธิ์ของ service นั้น อ่านจาก attribute ด้วย reflection
P5ApiPermissions ใต้ 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

#งาน
U1entity + EF config + migration ของ RoleApiPermission 🔴 ต้อง author บน branch MIGRATION
U2API ผูก/ถอน/อ่าน RoleApiPermission (คู่ขนานกับ RolePermissionController ไม่ใช่แก้ของเดิม)
U3UsersMapper.ToUserInfoForRedis เติม apiPermissions ใต้แต่ละ app + เพิ่ม include ใน GetUserByIdWithCacheDetailsAsync
U4ปิดช่อง PermissionController.cs:23 และ RolePermissionController.cs:24 ที่มีแค่ [Authorize] เปล่า และให้ API ใหม่ในข้อ U2 ถูก gate ตั้งแต่แรก
U4a🔴 gate การ assign/remove/update role ด้วย ไม่ใช่แค่ U4UserRoleController.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