Private Docs

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 — ใครเขียนตอนไหน

เลขอยู่ที่ใครเขียนเขียนตอนไหน
authrevkey แยก user:authrev:{oid} ใน Redis · ต้นทางคือคอลัมน์ใน Postgresworker ของ 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 ใน attributepermission ที่อยู่ใน 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 ในโปรเซสตัวเองแยกต่างหาก ยังไม่มีในโค้ดวันนี้

สองอย่างที่ต้องมี ไม่งั้นเรื่องนี้ไม่จริงทั้งหัวข้อ

#ต้องทำถ้าไม่ทำจะเป็นยังไง
1endpoint ที่ service อื่นใช้ fallback ต้องแปะ [SkipAppContext]request นั้นมาด้วย X-API-Key ไม่ได้มาด้วย token ของผู้ใช้ ⇒ ด่าน 0 ตอบ 401 ⇒ เส้น fallback ตายทั้งเส้นทันทีที่เปิด enforcement
2UserService ต้องมีเส้นอ่าน 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 + migrationmigration ต้อง author บน branch MIGRATION · เพิ่มคอลัมน์ revision ต่อ user ด้วย
U2controller ผูก / ถอน / อ่าน grantcontroller ใหม่ ไม่แก้ RolePermissionController เดิม
U3mapper เติม apiPermissions ลง blobต้องกรอง role ที่ disable, assignment ที่ถอนแล้ว, user ที่ปิด
U4gate PermissionController + RolePermissionController + controller ของ U2= G1
U4agate UserRoleController + RoleController + ตรวจสิทธิ์ caller ใน handler= G2 · รูที่เปิดอยู่วันนี้
U5เติม [Authorize] ให้ /users/me
U6invalidation — outbox + worker + compare-and-setข้อ 4 ข้างบนคือรูปของงานนี้
U7audit ของการเปลี่ยน grant ทุกชนิดห้ามบันทึก token / session / PII
U8ตั้งชื่อ permissionต้องเสร็จก่อน U4 · เป็นเอกสาร ไม่ใช่โค้ด
U9seed แถวตั้งต้นไม่งั้นวันแรก 503 ทั้งกระดาน

ที่อื่น

  • Backend_Iac — เติม config 3 key ให้ครบทุก env
  • Backend_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 SLA30 วินาที
company scopeไม่มี — โมเดลครอบเฉพาะ user role · endpoint ที่เป็น company-scoped (AdminAmloController) ใช้โมเดลนี้ไม่ได้ ต้องอยู่กับ policy เดิม
OwnerServiceNameเก็บคอลัมน์ไว้ ค่าเป็นชื่อ service เปล่าไม่มี prefix env
freshness authorityPostgres เป็นตัวจริง 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. ลำดับที่สลับไม่ได้

  1. ปิด UNDECIDED ที่เหลือ ก่อน เขียน U1 — เขียน schema จากร่างที่ยังไม่ตัดสิน = migrate ผิดแล้วต้องแก้ซ้ำ
  2. U8 (ตั้งชื่อ) ก่อน U4 — แปะ [RequirePermission("...")] ไม่ได้ถ้ายังไม่มีชื่อ
  3. U1-U3 ก่อน U4/U4a — ตามมติ Owner ที่จะรอสิทธิ์จาก DB แทนการใช้ allow-list จาก config
  4. ขั้น 4a (re-warm) ก่อน ขั้น 5 — blob รุ่นเก่าไม่มี revision ⇒ เปิดก่อนจะ 503 ทั้งที่ยังไม่ได้แปะ permission
  5. ขั้น 6 (LogOnly) ก่อน ขั้น 7 (Enforce) ก่อน ขั้น 8 (ถอด policy เดิม)

⚠️ การเปิดสวิตช์และการ rollback ต้อง redeploy ไม่ใช่ toggle config