API Restriction · 05 — goal และภาพรวม
เป้าหมายของงาน API restriction ทั้งชุด เล่าด้วย sequence diagram — วันนี้เป็นยังไง ปลายทางเป็นยังไง ตอนถอนสิทธิ์เกิดอะไร Redis ล่มแล้วรอดยังไง พร้อมรายการงานที่เหลือและมติที่เคาะแล้ว
อัปเดต: 2026-09-09
ชุดเดียวกัน: 00 ที่มา · 01 app context · 02 spec · 03 rollout · 04 implementation
ไฟล์นี้เป็นภาพรวมสำหรับดูให้เห็นทั้งงาน · รายละเอียดที่ลงมือได้อยู่ในไฟล์ 04
1. เป้าหมายพูดเป็นประโยคเดียว
ย้ายการตัดสินใจว่าใครเรียก API เส้นไหนได้ จากโค้ดที่กระจายอยู่ตาม controller ไปอยู่ในข้อมูลที่แก้ได้โดยไม่ต้อง deploy — และระหว่างทางต้องปิดสองรูที่เปิดอยู่วันนี้
รูที่หนึ่ง: role ปนกันข้าม app · รูที่สอง: ไม่มีกลไก permission เลย มีแต่ role check ที่เขียนมือทีละที่
2. วันนี้เป็นยังไง
สมชายอยู่ 2 app — เป็น Admin ใน app A, Member ใน app B
sequenceDiagram
actor ส as สมชาย (หน้าจอ app B)
participant API as Backend service
participant R as Redis
participant DB as Postgres
ส->>API: GET /api/reports (token)
API->>R: GET user:info:{oid}
R-->>API: blob — Admin(app A) + Member(app B)
Note over API: enrich context.User ด้วย role ทั้งหมด<br/>ไม่แยกว่ามาจาก app ไหน
API->>API: [Authorize(Roles="Admin")] ผ่าน ✅
API->>DB: อ่านข้อมูล
API-->>ส: 200 — ทั้งที่ใน app B เขาเป็นแค่ Member
ปัญหาเพิ่มอีกชั้น — endpoint ที่แจก role เอง (UserRoleController) วันนี้เป็น [Authorize] เปล่า ⇒ ใครที่ login ได้ ยกตัวเองเป็น role ที่ active อยู่ในระบบได้ทันที
3. ปลายทางเป็นยังไง
sequenceDiagram
actor ส as สมชาย (หน้าจอ app B)
participant MW as RedisUserInfoMiddleware
participant R as Redis
participant AUTHZ as UseAuthorization
participant C as Controller
ส->>MW: GET /api/reports<br/>token + X-App-Id: appB
Note over MW,R: อ่าน 2 key แยกกัน — blob กับ authority<br/>ยิงทั้งคู่ก่อนแล้วค่อย await (ไม่ใช่ MGET)
MW->>R: GET user:info:{oid}
MW->>R: GET user:authrev:{oid}
R-->>MW: blob (revision=7)
R-->>MW: authrev = 7
Note over MW: ด่าน 0-4 ผ่าน (login แล้ว · มี oid ·<br/>header เป็น GUID · อ่าน blob ออก)
alt ด่าน 5 ผ่าน — revision ในblob ไม่ต่ำกว่า authority
Note over MW: blob revision 7 · authrev 7<br/>blob สดพอ ไปต่อ
MW->>MW: ด่าน 6-7 · สมชายอยู่ใน app B<br/>และมี role ใน app นั้น
MW->>MW: ด่าน 8 · apiPermissions ไม่เป็น null
MW->>MW: ด่าน 9 · ถอด role เดิมทิ้งทั้งหมด<br/>ใส่เฉพาะ role ใน app B
Note over MW: context.User = Member เท่านั้น<br/>Admin ของ app A หายไปแล้ว
MW->>AUTHZ: next()
AUTHZ->>AUTHZ: [RequirePermission("reports:read")]<br/>หาใน blob.apiPermissions ของ app B
alt มีสิทธิ์
AUTHZ->>C: เข้า handler
C-->>ส: 200
else ไม่มีสิทธิ์
AUTHZ-->>ส: 403
end
else ด่าน 5 ไม่ผ่าน — blob เก่ากว่า authority
Note over MW: blob revision 7 · authrev 8<br/>มีคนเปลี่ยนสิทธิ์ไปแล้ว blob เชื่อไม่ได้
MW-->>ส: 503 · ไม่เรียก next ไม่แตะ controller
else ด่าน 5 ไม่ผ่าน — blob ไม่มี revision เลย
Note over MW: blob เขียนโดย writer รุ่นเก่า<br/>พิสูจน์ความสดไม่ได้
MW-->>ส: 503
end
เทียบให้เห็นภาพก่อน — บัตรผ่านกับกระดานหน้าป้อมยาม
นึกถึงอาคารที่มีป้อมยาม
- blob = บัตรผ่านที่พิมพ์ออกมาแล้วถือไว้ บนบัตรพิมพ์ไว้ว่า “ออกในรอบที่ 7”
- authrev = กระดานหน้าป้อมยาม ที่เขียนตัวเลขรอบปัจจุบันไว้ ตอนนี้เขียนว่า 7
ยามไม่ได้จำหน้าใคร เขาแค่เทียบเลขบนบัตรกับเลขบนกระดาน
| สถานการณ์ | ยามทำอะไร |
|---|---|
| บัตรเขียน 7 กระดานเขียน 7 | ผ่าน |
| มีการถอนสิทธิ์ ยามขีดกระดานเป็น 8 | บัตรทุกใบที่เขียน 7 ใช้ไม่ได้ทันที — ไม่ต้องวิ่งตามเก็บบัตรคืนทีละใบ |
| บัตรไม่มีเลขรอบพิมพ์ไว้เลย | ไม่รู้ว่าออกรอบไหน ⇒ ไม่ให้ผ่าน |
นี่คือหัวใจ — การขีดเลขบนกระดานหนึ่งครั้ง ทำให้บัตรเก่าทุกใบตายพร้อมกัน · ถ้าไม่มีกระดาน ทางเดียวคือรอบัตรหมดอายุเอง ซึ่งของเราคือ 24 ชั่วโมง
การพิมพ์บัตรใบใหม่ให้คนที่ยังมีสิทธิ์ = ทำให้เขากลับเข้าอาคารได้เร็วขึ้น ไม่ใช่สิ่งที่ทำให้บัตรเก่าใช้ไม่ได้ · ถ้าเครื่องพิมพ์บัตรพัง คนที่ยังมีสิทธิ์เข้าไม่ได้ชั่วคราว แต่คนที่ถูกถอนสิทธิ์ก็ยังเข้าไม่ได้อยู่ดี
จังหวะของ revision — ใครเขียนตอนไหน
| เลข | อยู่ที่ | ใครเขียน | เขียนตอนไหน |
|---|---|---|---|
authrev | key แยก user:authrev:{oid} ใน Redis · ต้นทางคือคอลัมน์ใน Postgres | worker ของ U6 | ทันทีที่ transaction ที่เปลี่ยนสิทธิ์ commit |
revision ในblob | อยู่ในตัว blob | ใครก็ตามที่เขียน blob (worker, self-warm, HTTP fallback) | ตอนประกอบ blob — stamp ค่า authority ที่อ่านได้ ณ ตอนนั้น |
กฎเดียวที่ด่าน 5 บังคับ: revision ในblob ต้องไม่ต่ำกว่า authrev
- เท่ากัน = blob ถูกเขียนหลังการเปลี่ยนสิทธิ์ครั้งล่าสุด ⇒ ใช้ได้
- ต่ำกว่า = มีการเปลี่ยนสิทธิ์เกิดขึ้นหลังจาก blob ถูกเขียน ⇒ ปฏิเสธทันที ไม่รอ TTL ไม่ re-fetch
- ไม่มีเลย = พิสูจน์ไม่ได้ ⇒ ปฏิเสธ
🔴 ทำไมถึงได้ 30 วินาที เพราะตัวที่บังคับคือ authrev = กระดานหน้าป้อม ซึ่งขยับพร้อม commit ไม่ใช่ตัว blob ที่ต้องรอ worker · ต่อให้ worker ช้าหรือ crash สิทธิ์เก่าก็ใช้ไม่ได้ตั้งแต่วินาทีที่ commit ผ่าน — สิ่งที่ worker ช้าทำให้เสียคือ ความเร็ว (503 ชั่วคราวจนกว่า blob ใหม่จะมา) ไม่ใช่ ความปลอดภัย
⚠️ ราคาของกฎนี้: ช่วงระหว่าง commit กับ blob ใหม่ถูกเขียน สมชายจะได้ 503 · ยิ่ง worker ช้า ช่วงนี้ยิ่งยาว — เป็นเหตุผลที่ U6 ต้องมี outbox ที่ retry ได้ ไม่ใช่ fire-and-forget
สิ่งที่เปลี่ยนไปจากข้อ 2
| วันนี้ | ปลายทาง | |
|---|---|---|
| request บอก app ไหม | ไม่บอก | บังคับส่ง header |
| role ที่ principal ถือ | ทุก app รวมกัน | เฉพาะ app ที่เลือก |
| ตัดสินสิทธิ์จากอะไร | role ที่เขียน hardcode ใน attribute | permission ที่อยู่ใน DB |
| แก้สิทธิ์ต้อง deploy ไหม | ต้อง | ไม่ต้อง |
4. ถอนสิทธิ์แล้วต้องมีผลใน 30 วินาที
cache มีอายุ 24 ชั่วโมง ⇒ ถ้าไม่มีอะไรมาบังคับ สิทธิ์ที่ถอนแล้วจะยังใช้ได้ถึงพรุ่งนี้ · revision คือตัวบังคับ
sequenceDiagram
actor A as Admin
participant US as UserService
participant DB as Postgres
participant W as Invalidation worker
participant R as Redis
actor ส as สมชาย
A->>US: ถอน role Admin ของสมชาย
rect rgba(200,230,255,0.35)
Note over US,DB: transaction เดียวกัน
US->>DB: ลบแถว UserRole
US->>DB: revision ของสมชาย 7 → 8
US->>DB: enqueue งาน invalidate (outbox)
end
US-->>A: 200 (ยังไม่บอกว่า revoke มีผลครบแล้ว)
W->>DB: หยิบงานจาก outbox
W->>R: SET user:authrev:{oid} = 8
W->>R: เขียน blob ใหม่ stamp revision=8
Note over ส,R: ถ้าสมชายยิง request ก่อน worker ทำเสร็จ
ส->>R: GET blob + authrev
R-->>ส: blob(revision=7) + authrev=8
Note over ส: 7 < 8 ⇒ blob เก่ากว่าความจริง ⇒ 503<br/>สิทธิ์เก่าใช้ไม่ได้ทันทีที่ revision ขยับ
🔴 จุดสำคัญ — authrev ขึ้นเป็น 8 ตั้งแต่ตอน commit ⇒ ต่อให้ blob ยังไม่ถูกเขียนใหม่ สิทธิ์เก่าก็ใช้ไม่ได้แล้ว · การเขียน blob ใหม่เป็นแค่การคืนความเร็ว ไม่ใช่ตัวบังคับความปลอดภัย
5. Redis ล่มแล้วรอดยังไง
blob กับ authority อยู่ใน Redis ทั้งคู่ ⇒ Redis ล่ม = หายพร้อมกัน · ทางออกไม่ใช่ตอบ 503 ทั้งระบบ
แต่ต้องแยกสองกรณี เพราะ service ที่รับ request อาจไม่ใช่ UserService
กรณีที่ 1 — service อื่นถูกเรียก (ประมาณ 11 service)
sequenceDiagram
actor ส as สมชาย
participant MW as ReportService<br/>RedisUserInfoMiddleware
participant R as Redis
participant US as UserService
participant DB as Postgres
ส->>MW: GET /api/reports + X-App-Id
MW->>R: GET blob + authrev
R--xMW: ล่ม / ไม่ตอบ
MW->>US: HTTP fallback · X-API-Key
Note over US: endpoint นี้ต้องมี SkipAppContext<br/>ไม่งั้นโดนด่าน 0 ตอบ 401
rect rgba(210,245,215,0.4)
Note over US,DB: อ่าน grants กับ revision<br/>ใน snapshot เดียวกัน
US->>DB: SELECT
DB-->>US: grants + revision 8
end
US-->>MW: blob stamp revision 8
Note over MW: blob มาจากแหล่งความจริงโดยตรง<br/>สดโดยนิยาม ⇒ ข้ามการเทียบ authority
MW-->>ส: 200
กรณีที่ 2 — UserService ถูกเรียกเอง
sequenceDiagram
actor ส as สมชาย
participant MW as UserService<br/>RedisUserInfoMiddleware
participant R as Redis
participant SVC as UserService<br/>โค้ดของตัวเอง
participant DB as Postgres
ส->>MW: GET /users/me + X-App-Id
MW->>R: GET blob + authrev
R--xMW: ล่ม / ไม่ตอบ
alt วันนี้ — HTTP fallback ชี้กลับมาที่ตัวเอง
MW->>MW: ยิง HTTP หา UserService ซึ่งคือตัวเอง
Note over MW: request รอบสองเข้า middleware ตัวเดิม<br/>Redis ก็ยังล่ม
MW-->>ส: 503 ช่วยตัวเองไม่ได้
else ที่ต้องทำ — อ่าน DB ในโปรเซสตัวเอง
MW->>SVC: เรียกโค้ดอ่าน user ตรง ๆ ไม่ผ่าน HTTP
SVC->>DB: SELECT
DB-->>SVC: grants + revision 8
SVC-->>MW: blob stamp revision 8
MW-->>ส: 200
end
🔴 UserService เป็น service เดียวที่ช่วยตัวเองไม่ได้ด้วยเส้น fallback เพราะ fallback คือการยิง HTTP ไปหา UserService ซึ่งก็คือตัวมันเอง — request รอบสองก็เจอ Redis ล่มเหมือนเดิม · ต้องมีเส้นอ่าน DB ในโปรเซสตัวเองแยกต่างหาก ยังไม่มีในโค้ดวันนี้
สองอย่างที่ต้องมี ไม่งั้นเรื่องนี้ไม่จริงทั้งหัวข้อ
| # | ต้องทำ | ถ้าไม่ทำจะเป็นยังไง |
|---|---|---|
| 1 | endpoint ที่ service อื่นใช้ fallback ต้องแปะ [SkipAppContext] | request นั้นมาด้วย X-API-Key ไม่ได้มาด้วย token ของผู้ใช้ ⇒ ด่าน 0 ตอบ 401 ⇒ เส้น fallback ตายทั้งเส้นทันทีที่เปิด enforcement |
| 2 | UserService ต้องมีเส้นอ่าน DB ในโปรเซสตัวเอง เมื่อ Redis ล่ม | UserService ยิง HTTP หาตัวเอง วนแล้วก็ 503 อยู่ดี |
ทำไมไม่ fail-open เพราะข้อมูลมาจาก Postgres ไม่ใช่จาก cache ที่พิสูจน์ความสดไม่ได้ · ต่อจากการเปรียบเทียบข้อ 3: กระดานหน้าป้อมหาย แต่ยามเดินไปเปิดทะเบียนตัวจริงในห้องเอกสารได้ ไม่ใช่ปล่อยคนเข้ามั่ว
🔴 ราคาที่ยังไม่ได้ออกแบบ ระหว่าง Redis ล่ม ทุก request ของทุก service วิ่งเข้า UserService หนึ่ง hop ⇒ ต้องมี timeout + circuit breaker ที่เส้น fallback ไม่งั้น Redis ล่มจะลาก UserService ล่มตาม · และ UserService เองก็รับโหลดของตัวเองเพิ่มจากข้อ 2 ด้วย
6. ตอนนี้อยู่ตรงไหน
flowchart LR
subgraph done ["เสร็จแล้ว"]
S1["ขั้น 1<br/>Package 10.31.0"]
S2["ขั้น 2<br/>publish ขึ้น feed"]
end
subgraph next ["ถัดไป"]
S0a["ขั้น 0a<br/>ปิด UNDECIDED"]
S3["ขั้น 3<br/>UserService U1-U9"]
end
subgraph later ["หลังจากนั้น"]
S4["ขั้น 4-4a<br/>FE + re-warm"]
S5["ขั้น 5<br/>เปิด EnforceAppContext"]
S6["ขั้น 6-9<br/>แปะ permission ทีละเส้น"]
end
S1 --> S2 --> S0a --> S3 --> S4 --> S5 --> S6
ขั้น 1-2 จบแล้ว — Backend_Package 10.31.0 merge ที่ 2655c26 และขึ้น feed แล้ว · ฝั่งอ่านครบ แต่ยังเปิดใช้ไม่ได้เพราะไม่มีใครเขียนข้อมูล
7. งานที่เหลือ
UserService — 9 ชิ้น
| # | งาน | หมายเหตุ |
|---|---|---|
| U1 | ตาราง RoleApiPermission + entity + migration | migration ต้อง author บน branch MIGRATION · เพิ่มคอลัมน์ revision ต่อ user ด้วย |
| U2 | controller ผูก / ถอน / อ่าน grant | controller ใหม่ ไม่แก้ RolePermissionController เดิม |
| U3 | mapper เติม apiPermissions ลง blob | ต้องกรอง role ที่ disable, assignment ที่ถอนแล้ว, user ที่ปิด |
| U4 | gate PermissionController + RolePermissionController + controller ของ U2 | = G1 |
| U4a | gate UserRoleController + RoleController + ตรวจสิทธิ์ caller ใน handler | = G2 · รูที่เปิดอยู่วันนี้ |
| U5 | เติม [Authorize] ให้ /users/me | |
| U6 | invalidation — outbox + worker + compare-and-set | ข้อ 4 ข้างบนคือรูปของงานนี้ |
| U7 | audit ของการเปลี่ยน grant ทุกชนิด | ห้ามบันทึก token / session / PII |
| U8 | ตั้งชื่อ permission | ต้องเสร็จก่อน U4 · เป็นเอกสาร ไม่ใช่โค้ด |
| U9 | seed แถวตั้งต้น | ไม่งั้นวันแรก 503 ทั้งกระดาน |
ที่อื่น
Backend_Iac— เติม config 3 key ให้ครบทุก envBackend_Package— เวอร์ชันถัดไป ต้องรองรับเคส “blob มาจาก fallback ⇒ ข้ามการเทียบ authority” (ข้อ 5)Backend_UserService— แปะ[SkipAppContext]ที่ endpoint ปลายทางของ fallback · และเพิ่มเส้นอ่าน DB ในโปรเซสตัวเองสำหรับตอน Redis ล่ม (ข้อ 5 กรณีที่ 2)- FE — ส่ง header + ยืนยันว่า interceptor ไม่ refresh token บน 400/403/503
- test gate A1-A7 ยังไม่ได้รันสักตัว
8. มติที่เคาะแล้ว และที่ยังค้าง
| เรื่อง | สถานะ |
|---|---|
| revocation SLA | 30 วินาที |
| company scope | ไม่มี — โมเดลครอบเฉพาะ user role · endpoint ที่เป็น company-scoped (AdminAmloController) ใช้โมเดลนี้ไม่ได้ ต้องอยู่กับ policy เดิม |
OwnerServiceName | เก็บคอลัมน์ไว้ ค่าเป็นชื่อ service เปล่าไม่มี prefix env |
| freshness authority | Postgres เป็นตัวจริง Redis เป็น cache · Redis ล่ม ⇒ fallback ไม่ใช่ 503 |
| platform-admin bootstrap | ผูก permission ให้ role admin ที่ Owner ระบุ — AdminMaker (7 คน) · AdminApprover (3) · FX-AdminPortal (9) · FX-Admin (0) · RM-Admin (0) · ไม่ต้อง seed OID รายคน · Global Admin ปล่อยไว้ก่อน ยังไม่ใช้งานจริง |
| app context threat model | ยังไม่ปิด — ต้องบันทึกเป็นมติก่อนขั้น 5 |
วิธี seed ที่เลือก
ผูกแบบหยาบก่อน ละเอียดทีหลัง — role ฝั่ง user ผูกทุกเส้นยกเว้นเส้นของ admin · role ฝั่ง admin ผูกทุกเส้นไปก่อนแล้วค่อยถอด
🔴 ผลข้างเคียงที่ต้องรู้ตัว seed แบบนี้ทำให้ระบบ ไม่ได้แคบลงกว่าวันนี้ ในวันแรก มันปิดแค่ช่องที่ไม่มี gate เลย · การหดสิทธิ์จริงเกิดตอนถอดสิทธิ์ของ admin ซึ่งต้องเป็นงานที่มีกำหนด ไม่ใช่ปล่อยค้าง
🔴 Employee กับ Customer ไม่ใช่ระดับเดียวกัน — ต้องตัดสินก่อน U9
FX-Admin / FX-AdminPortal / RM-Admin เป็น RoleType = Customer คือ admin ฝั่งลูกค้า · ถ้าใช้กฎ “admin ผูกทุกเส้น” ตรง ๆ กับกลุ่มนี้ จะให้ admin ฝั่งลูกค้าเข้าถึง endpoint จัดการ role/permission ของ platform ซึ่งวันนี้เข้าไม่ได้ ⇒ ขยายสิทธิ์ ไม่ใช่คงสถานะเดิม
⇒ ต้องแยก seed สองชุด: endpoint จัดการ platform ผูกเฉพาะ role Employee (AdminMaker, AdminApprover) · endpoint ของ app ฝั่งลูกค้าจึงผูกกับ role Customer ได้
สิ่งที่ยังพิสูจน์ไม่ได้
ทุก role ในกลุ่ม admin บน dev มี LastLoginAt เป็น null — ยังไม่เคยมีใครใน role เหล่านี้ login เลย ⇒ ยังยืนยันไม่ได้ว่า bootstrap ใช้ได้จริง ต้องให้คนใดคนหนึ่งเข้าระบบก่อนเปิด enforcement
FX-Admin และ RM-Admin ไม่มีคนถือเลยบน dev (0 คน) — ต้องนับซ้ำบน UAT/PROD ก่อน promote
9. ลำดับที่สลับไม่ได้
- ปิด UNDECIDED ที่เหลือ ก่อน เขียน U1 — เขียน schema จากร่างที่ยังไม่ตัดสิน = migrate ผิดแล้วต้องแก้ซ้ำ
- U8 (ตั้งชื่อ) ก่อน U4 — แปะ
[RequirePermission("...")]ไม่ได้ถ้ายังไม่มีชื่อ - U1-U3 ก่อน U4/U4a — ตามมติ Owner ที่จะรอสิทธิ์จาก DB แทนการใช้ allow-list จาก config
- ขั้น 4a (re-warm) ก่อน ขั้น 5 — blob รุ่นเก่าไม่มี revision ⇒ เปิดก่อนจะ 503 ทั้งที่ยังไม่ได้แปะ permission
- ขั้น 6 (
LogOnly) ก่อน ขั้น 7 (Enforce) ก่อน ขั้น 8 (ถอด policy เดิม)
⚠️ การเปิดสวิตช์และการ rollback ต้อง redeploy ไม่ใช่ toggle config