Prove POC Security on Cloud — แผนพิสูจน์บน dev ว่า 6 ข้อทำงานจริง
หน้าตาของการพิสูจน์บน cloud: 5 repo ที่ deploy, เส้น API ที่เปิดจริงแค่ 3 เส้น, sequence diagram ของทุก flow ที่จะถูกทดสอบ, เกณฑ์ผ่าน/ไม่ผ่านต่อข้อที่วัดจากของที่มองเห็นได้จริง, ลำดับก่อนกดที่สลับไม่ได้ และสิ่งที่รอบนี้ยังไม่พิสูจน์
อัปเดต: 2026-09-04
Prove POC Security on Cloud
เอกสารนี้ตอบคำถามเดียว: “เอาขึ้น dev แล้วเราจะเห็นอะไร ถึงเรียกได้ว่ากลไกทั้ง 6 ข้อทำงานจริง”
ไม่ใช่คู่มือติดตั้ง (อันนั้นคือ Security Uplift — ติดตั้งใน service ของคุณ) และไม่ใช่รายงานผล — เป็นแผนพิสูจน์ที่เขียนก่อนกด เพื่อให้ตกลงกันไว้ล่วงหน้าว่าอะไรคือผ่าน อะไรคือไม่ผ่าน
1. เป้าหมายที่ตกลงกันไว้
โปรแกรมนี้มี 6 กลไก: 1.1 Body Signature · 1.2 Encryption Field · 1.3 Encryption Payload (ทั้งก้อน) · 1.4 At-Rest Encryption · 1.5 API Restriction · 1.6 Monitor Log
เงื่อนไขที่ผูกไว้ตั้งแต่ต้น และเป็นตัวกำหนดหน้าตาของการพิสูจน์รอบนี้:
| # | เงื่อนไข | ผลต่อการพิสูจน์ |
|---|---|---|
| AC1 | ทุกอย่างอยู่ใน lib กลาง ทั้ง FE และ BE · dev ไม่ต้องรู้ข้างใน | สิ่งที่ deploy ต้องเป็นเวอร์ชันจาก feed ไม่ใช่โค้ด copy |
| AC2 | apply ครบ 6 ข้อใน UserService + HostApp และใช้งานได้ | เส้นที่เปิดต้องทำงาน และเส้นที่เหลือต้องไม่เปลี่ยน |
| AC3 | เอกสารต้องพอให้ dev อ่านแล้ว apply เองได้ | พิสูจน์ไปแล้วรอบหนึ่งด้วย ConsentService |
| AC4 | apply ตามเอกสาร ห้ามออกนอกเอกสาร · ไม่กระทบ business | ConsentService = ตัวพิสูจน์ AC4 |
| 🔴 | ห้ามเป็น interceptor ที่คุมทุก API — เส้นปกติต้องไม่กระทบ | ทุกกลไก opt-in ราย endpoint · เส้นที่ไม่ได้แปะ attribute คือหลักฐานหลักของรอบนี้ |
2. ขอบเขต — เล็กโดยตั้งใจ
5 repo ที่ deploy:
| repo | บทบาทในการพิสูจน์ | มาจากไหน |
|---|---|---|
| HostApp (FE) | ต้นทาง — เซ็น เข้ารหัส และถอดของที่ตอบกลับ | เป้าหมายตั้งต้น (AC2) |
| UserService (BE) | service ที่ apply ครบทั้ง 6 ข้อ | เป้าหมายตั้งต้น (AC2) |
| Sentinel Gateway | ลงทะเบียนกุญแจ + แจก JWKS | เป้าหมายตั้งต้น |
| LogService | ปลายทางของข้อ 1.6 (consumer + ตาราง RequestLogs) | เป้าหมายตั้งต้น |
| ConsentService | ตัวพิสูจน์ AC4 — apply ตามเอกสารล้วน ๆ และเป็นเจ้าของ 2 ใน 3 เส้นที่จะทดสอบ | เพิ่มเข้ามาทีหลังตอนทดสอบว่าเอกสารพอไหม |
เป้าหมายตอนตั้งไว้พูดถึง 4 repo · ConsentService เข้ามาเพราะ AC4 สั่งให้เอาเอกสารไป apply จริงกับ service ที่ไม่ได้อยู่ในแผนเดิม — และเมื่อ 2 ใน 3 เส้นที่จะทดสอบอยู่ที่นั่น มันจึงต้องขึ้น dev ด้วย ไม่ใช่แค่ merge ทิ้งไว้
เส้น API ที่เปิดจริง = 3 เส้น ทุกเส้นที่เหลือของทุก service ไม่ถูกแตะเลย:
| service | endpoint | แปะอะไร | พิสูจน์ข้อ |
|---|---|---|---|
| UserService | POST /api/user-service/v1/client-keys/echoClientKeysController.cs:57-58,69-70 | [EncryptedWholeBody] · [EncryptedWholeBodyResponse] · [RequireClientSignature] · [RequireRequestLog] | 1.1 · 1.3 · 1.6 |
| ConsentService | GET /api/consent-service/v1/noticeConsentController.cs:32,40-42 | [RequireClientSignature] · [RequireAuthorizationObserve] · [EncryptedWholeBodyResponse] | 1.1 · 1.3 · 1.5 |
| ConsentService | GET /api/consent-service/v1/userConsentController.cs:86,95 | [RequireRequestLog] · [EncryptedField] บน UserConsentResponseDto.Identifier | 1.2 · 1.6 |
echo ถูกเลือกเพราะสะท้อน body กลับ ไม่มี business logic · notice เป็น read-only ที่มี prefetch cache อยู่แล้ว พังก็ไม่เสียข้อมูล · ต้องมีเส้นที่สองที่ Consent เพราะ request log ข้ามการเก็บ body ของ action ที่แปะ [EncryptedWholeBodyResponse] โดยตั้งใจ (RequestLogEnvelopeMiddleware.cs:303) และ [EncryptedField] อยู่ร่วม action กับ whole-body ไม่ได้
ของที่จะอยู่บน cluster: SupApp_util_lib 10.28.0 (อยู่บน feed แล้ว) · @exim/auth-sdk 1.10.0 (อยู่บน feed แล้ว)
3. หน้าตาของ flow ที่จะถูกทดสอบ
3.1 ลงทะเบียนกุญแจ — เกิดครั้งเดียวต่อ browser ต่อ user
กลไก 1.1 และ 1.3 ใช้กุญแจคู่ที่ browser สร้างเอง ทุกอย่างหลังจากนี้ล้มหมดถ้าขั้นนี้ไม่ผ่าน
sequenceDiagram
autonumber
participant B as Browser (HostApp)
participant SDK as "@exim/auth-sdk 1.10.0"
participant APIM as APIM facade
participant SG as Sentinel Gateway
participant R as Redis
B->>SDK: หลัง login สำเร็จ
SDK->>SDK: generate ECDSA P-256 (sign) + EC (response decrypt)<br/>เก็บ private key แบบ non-extractable ใน IndexedDB
SDK->>APIM: POST /sentinel/client-keys (cookie .Exim.Auth*)
APIM->>SG: resolve-session → แนบ Bearer แล้ว forward
SG->>R: SET {KeyRegistryPrefix}:client-key:{oid} = public JWK
Note over SG,R: prefix = "SuperAppPlatform"<br/>(appsettings.json:45 · เหมือนกันทุก service)
R-->>SG: OK
SG-->>B: 200 (ลงทะเบียนแล้ว)
จุดที่เคยพังมาก่อน: lib ก่อน 10.27.0 ไม่อ่าน ClientSignature:KeyRegistryPrefix แล้วตกไปใช้ ServiceIdentity:ServiceName ⇒ กุญแจถูกเขียนใต้ชื่อ service ของ Sentinel แต่ UserService ไปหาใต้ชื่อตัวเอง = SIG_KEY_NOT_FOUND ทุกครั้ง · 10.28.0 อ่าน prefix ก่อนแล้วค่อย fallback
3.2 เส้นที่เปิด — signed + encrypted ทั้งขาไปขากลับ
sequenceDiagram
autonumber
participant B as Browser (HostApp)
participant APIM as APIM facade
participant MW as UserService middleware (lib 10.28.0)
participant A as ClientKeysController.Echo
participant R as Redis (key registry)
B->>B: สร้าง JWE ของ body ทั้งก้อน → {"data":"<JWE>"}
B->>B: เซ็น payload ด้วย private key → header signature
B->>APIM: POST /api/user-service/v1/client-keys/echo (cookie)
APIM->>APIM: cookie → resolve-session → Authorization: Bearer
APIM->>MW: forward
MW->>MW: endpoint มี [RequireClientSignature]? → ใช่
MW->>R: หา public key ของ oid
R-->>MW: JWK
MW->>MW: verify signature · เช็ค replay
MW->>MW: endpoint มี [EncryptedWholeBody]? → ใช่ → ถอด JWE เป็น body จริง
MW->>A: model binding ได้ body ที่ถอดแล้ว
A-->>MW: response object
MW->>MW: [EncryptedWholeBodyResponse] → seal ด้วย ECDH-ES+A256KW / A256GCM
MW-->>B: {"data":"<JWE>"}
B->>B: decryptResponseJwe → ได้ body จริง
ขาตอบใช้ ECDH-ES+A256KW + A256GCM (ephemeral key ใหม่ทุก response) ส่วนขาเข้าใช้ RSA-OAEP-256 — ไม่ใช่ความไม่สม่ำเสมอ แต่เป็นมติที่ freeze ไว้ เพราะ browser เก็บ EC key คู่ที่สองไว้อยู่แล้ว การใช้ RSA ขาตอบจะบังคับให้ browser generate RSA-3072 บน request path
3.3 เส้นที่ ไม่ได้ เปิด — นี่คือหลักฐานสำคัญที่สุด
sequenceDiagram
autonumber
participant B as Browser
participant MW as middleware ชุดเดียวกัน
participant A as action ที่ไม่ได้แปะ attribute
B->>MW: GET /api/user-service/v1/<เส้นปกติ>
MW->>MW: [RequireClientSignature]? → ไม่มี → ผ่าน
MW->>MW: [EncryptedWholeBody]? → ไม่มี → ผ่าน
MW->>MW: [RequireRequestLog]? → ไม่มี → ไม่เก็บอะไรเลย
MW->>A: ส่งต่อ body เดิม ไม่ถูกแตะ
A-->>B: 200 เหมือนก่อน deploy ทุกประการ
middleware ทั้ง 6 ตัวอยู่ใน pipeline ของ ทุก request จริง — แต่ตัดสินจาก endpoint metadata ⇒ เส้นที่ไม่ได้แปะ attribute ไม่ถูกแตะ · ค่าที่ทำให้เป็นแบบนี้คือ {Section}:Coverage = OptIn ซึ่งเป็น default ของ 10.28.0
3.4 ข้อ 1.6 — เส้นทางของ request log
sequenceDiagram
autonumber
participant MW as RequestLog middleware (lib)
participant Q as in-memory channel
participant S as RequestLogSenderService
participant BL as Blob storage
participant SB as Service Bus topic
participant C as LogService consumer
participant PG as PostgreSQL RequestLogs
MW->>MW: endpoint มี [RequireRequestLog]? → ใช่
MW->>MW: mask ค่าที่อ่อนไหวจาก [EncryptedField] + ExtraSensitiveJsonPaths
MW->>Q: enqueue (คิวเต็ม = ทิ้งแล้วนับ ไม่หน่วง request)
Q->>S: drain (background)
alt body ใหญ่กว่า MaxInlineBodyBytes
S->>BL: upload bytes ที่ mask แล้ว
BL-->>S: blobUri
end
S->>SB: publish RequestLogMessage (JSON, PascalCase, enum เป็น string)
SB->>C: deliver
C->>C: validate — RouteTemplate ว่าง / EventId ว่าง / mode ไม่รู้จัก → dead-letter
C->>PG: insert แถว
🔴 เก็บทั้งขาเข้าและขาออก แต่ขาออกมีเงื่อนไขที่ตัดทิ้ง — ShouldRetainResponse (RequestLogEnvelopeMiddleware.cs:288-306) ไม่เก็บ body ขาออกเมื่อ: content-type ไม่ใช่ JSON · endpoint ไม่อยู่ใน scope · endpoint แปะ [EncryptedWholeBodyResponse] · route อยู่ใน ExcludedRouteTemplates · แถวยังถูกสร้าง (route/method/status/duration/ขนาด) แต่ไม่มีไบต์ของ response
⇒ ในแผนนี้ echo และ /v1/notice จะไม่มี response body ให้ investigate เพราะทั้งคู่แปะ [EncryptedWholeBodyResponse] · เส้นเดียวที่พิสูจน์ “อ่านย้อนได้ว่า response จริงคืออะไร” ได้คือ /v1/user (field-level) · ถ้า Owner ต้องการหลักฐานขาออกมากกว่านี้ ต้องเพิ่ม endpoint ที่ไม่ได้เข้ารหัสทั้ง body เข้ามาในชุดทดสอบ
RequestLogMessage ถูกประกาศ 2 ที่ โดยตั้งใจ — lib ถือฝั่งส่ง (Abstractions/RequestLog/) และ LogService ถือฝั่งรับ (Log04.Domain/Messages/) · เทียบ field ต่อ field แล้ว ตรงกันครบ 15/15 ทั้งชื่อ ชนิด nullability, JSON naming เป็น PascalCase ทั้งคู่, enum เป็น string ทั้งคู่ · การพิสูจน์บน cloud รอบนี้คือครั้งแรกที่ transport จะได้ยิงใส่ topic จริง — ที่ผ่านมามีแต่การเทียบ shape
4. เกณฑ์ผ่าน / ไม่ผ่าน
วัดจากของที่มองเห็นได้จริงเท่านั้น — ไม่นับ “ไม่มี error ใน log”
| ข้อ | ผ่านเมื่อ | ไม่ผ่านเมื่อ |
|---|---|---|
| 1.1 | ยิง echo โดยไม่มี signature header ตอน Mode=Enforce → ถูกปฏิเสธ · ยิงด้วย signature ถูกต้อง → 200 · ยิงซ้ำด้วย signature เดิม → ถูกปฏิเสธ (replay) | ยิงไม่มี signature แล้วได้ 200 = ยังอยู่ LogOnly หรือ Coverage ไม่ได้ตั้ง |
| 1.2 | response ของ /v1/user มี identifier เป็น ciphertext บนสาย และ FE ถอดออกมาอ่านได้ | ค่าโผล่เป็น plaintext บน network tab |
| 1.3 | request/response ของ echo บน network tab เป็น {"data":"<JWE>"} ทั้งคู่ และ round-trip ได้ค่าเดิม | body เป็น plaintext = attribute ไม่ทำงานหรือ flag ยังปิด |
| 1.4 | คอลัมน์ที่ mark ใน DB ของ UserService เป็น v1:{kid}:{base64} เมื่ออ่านด้วย SQL ตรง แต่ API คืนค่าปกติ | อ่าน SQL แล้วเห็น plaintext |
| 1.5 | /v1/notice ที่ถูกปฏิเสธด้วย role → มี log บันทึก method/path/oid/roles ที่ endpoint ต้องการ vs ที่ caller มี | ไม่มี log = handler ไม่ถูกเรียก |
| 1.6 | ยิง /v1/user แล้วมีแถวใหม่ใน RequestLogs ที่ อ่านย้อนได้ทั้ง body ขาเข้าและขาออก และค่าอ่อนไหวเป็น *** | ไม่มีแถว = topic ไม่มี หรือ transport ไม่ถูก register · มีแถวแต่ค่าอ่อนไหวไม่ถูก mask = ปัญหาที่หนักกว่าไม่เก็บเลย · มีแถวแต่ ResponseBody ว่าง = ไปชนเงื่อนไขใดเงื่อนไขหนึ่งของ ShouldRetainResponse |
| 🔴 ทุกข้อ | เส้นที่ไม่ได้แปะ attribute ตอบเหมือนก่อน deploy ทุกประการ (status, body, latency) | เส้นปกติเปลี่ยนพฤติกรรม = ผิดข้อบังคับหลัก ต้อง rollback ไม่ใช่แก้หน้างาน |
5. ลำดับก่อนกด — สลับไม่ได้
- merge PR ให้ครบก่อน แล้ว deploy — ไม่มี auto-publish ตอน merge, feed กับ
developmentเป็นคนละเรื่อง - สร้าง Service Bus topic ก่อนเปิด
RequestLog:Enabledที่ไหนก็ตาม —provision-servicebus-topics.ps1ต่อ namespace ของทุก env ·MaxDeliveryCountและrequiresSessionตั้งได้เฉพาะตอนสร้าง แก้ทีหลังต้องลบสร้างใหม่ · เปิด flag ก่อนมี topic = ข้อความหายเงียบ ไม่ error - เติม
RequestLog__ServiceBus__ConnectionStringลง Key Vault + CSISecretProviderClassของ service/env ที่จะเปิด — ค่าเป็นความลับ ห้ามลง appsettings - เติม config ลง
Backend_Iac/config/{service}/{env}/appsettings.jsonครบทุก env ก่อนเปิด flag ราย env:FieldEncryption:Payload:WholeBody·AuthorizationObserve·RequestLog· และ{Section}:Coverageถ้าจะใช้โหมดRequireAll— ขาด env ไหน env นั้นพังตอน deploy - เปิดทีละข้อ ทีละโหมด
Enabled=false→LogOnly→Enforce· อย่าเปิดพร้อมกันหลายข้อ เพราะเวลามีอะไรพังจะแยกไม่ออกว่าตัวไหน - หลังเปิดแต่ละข้อ ยิงเส้นที่ไม่ได้แปะ attribute ซ้ำทุกครั้ง — นั่นคือด่านที่จับว่ากลไกรั่วออกนอกขอบเขต
🔴 ข้อ 1.5 ทิศกลับด้านจากที่คนคาด: Enabled=false = ระบบทำงานปกติ 403 ตามเดิม · LogOnly = ปล่อย denial ผ่าน แล้วบันทึกไว้ ⇒ เปิด LogOnly บน service ที่ role ทำงานจริงอยู่แล้ว เท่ากับถอดด่าน authorization ชั่วคราว · ก่อนเปิดต้องรู้ก่อนว่ามีใครพึ่ง role นั้นอยู่
6. สิ่งที่รอบนี้ ไม่ พิสูจน์
เขียนไว้เพื่อไม่ให้ผลลัพธ์ถูกอ่านเกินจริง
- ไม่ใช่การ rollout — 3 เส้นใน 2 service ไม่ได้แปลว่าอีกสิบกว่า service พร้อม
- ข้อ 1.4 พิสูจน์ได้ที่ UserService เท่านั้น — ConsentService ไม่มี EF เลย (ไม่มี
DbContext, ไม่มีMigrations) และกลไกนี้ติดตั้งผ่านApplyAtRestEncryption(modelBuilder)⇒ service ที่ไม่มี DB ยังไม่มีทางทำข้อ 4 เลย รอ Owner ตัดสินว่า Redis อยู่ในขอบเขตไหม - ข้อ 1.5 พิสูจน์ที่ ConsentService ไม่ได้ — repo นั้นมี
[Authorize]ศูนย์ตัว การเติม role เข้าไปคือการเปลี่ยน business - ไม่พิสูจน์ performance — ยังไม่มีตัวเลข request/วัน, ขนาด body p50/p99, tier ของ Service Bus หรือ IOPS ของ PostgreSQL
- ไม่พิสูจน์ PDPA — ตาราง
RequestLogsเป็น append-only + archive ลง blob ⇒ การลบข้อมูลรายบุคคลยังทำไม่ได้ - ฝั่ง HostApp ต่อสายครบแล้ว แต่ยังไม่เคยยิงของจริง —
PAYLOAD_ENCRYPTION_CONFIGถูก provide ที่app.config.ts:249(jwksUrl=`${baseDomain}/${servicePaths.sentinel}/security/jwks`ตรงกับSecurityControllerของ Sentinel) ·routesมี 3 entry ตรงกับ 3 เส้นในตารางข้อ 2 ·signedUrlPrefixesจากลิสต์ว่างเป็น 3 เส้นเดียวกัน ·payloadEncryptionInterceptor(:218) และbodySignatureInterceptor(:221) ถูก register แล้ว · เทสต์ยืนยันว่า 7 URL ที่แอปยิงจริงทุกวันนี้ไม่ match ทั้งsignedUrlPrefixesและ route prefix ไหนเลย · สิ่งที่ยังไม่มีคือหลักฐานจากเบราว์เซอร์จริงผ่าน gateway - 🔴
payloadDecryptionInterceptor(app.config.ts:129) จะ scope ตัวเองก็ต่อเมื่ออยู่บน@exim/auth-sdk1.11.0 — บน 1.10.0 มันไม่มี allowlist เดินทั้ง body ของ ทุก response · 1.11.0 ทำให้มันอ่านroutesชุดเดียวกับข้างบน (payload-decryption.interceptor.ts:37-48) แต่ยังไม่ merge และยังไม่ publish ⇒ ห้าม deploy HostApp ก่อน 1.11.0 ขึ้น feed ไม่งั้นข้อบังคับ “ห้ามคุมทุก API” ยังไม่ผ่านฝั่ง FE - ⚠️
stripPathPrefixes(app.config.ts:139) ยังไม่ verify กับ APIM policy จริง — ลายเซ็นต้องครอบ path ที่ service อ่าน ไม่ใช่ path ที่เบราว์เซอร์ยิง ค่าปัจจุบันสมมติว่า gateway ตัด segment แรกพอดี · ถ้าผิด อาการคือSIG_INVALIDทุกใบเฉพาะตอนผ่าน gateway (ยิงตรง service จะผ่าน) แก้ที่บรรทัดเดียวนี้ - ⚠️ CORS ยังไม่ verify —
X-Response-Public-Keyและ header ลายเซ็นเป็นของใหม่ที่ shell ไม่เคยส่ง ·Access-Control-Allow-Headersของ APIM จะยอมหรือไม่ ตรวจจากรีโปไม่ได้ ต้องรู้ตอนเปิดจอจริง
7. ของที่ยังค้าง ต้องตัดสินก่อนเริ่ม
| เรื่อง | ทำไมต้องตัดสิน |
|---|---|
| ข้อ 4 กับ service ที่ไม่มี DB | คู่มือเคยเขียนว่าครอบ “ค่าใน Redis” ด้วย แต่โค้ดจริงลงได้ผ่าน EF อย่างเดียว |
| ข้อ 5 จะทดสอบที่ service ไหน | Consent ทำไม่ได้ · ต้องเลือก service ที่มี role ใช้จริงอยู่แล้ว |
jwksUrl ของ HostApp | ปิดแล้ว — jwksUrl หาได้จาก Sentinel เอง (SecurityController.cs:25,30) และ route คือ 3 เส้นในตารางข้อ 2 |
ClientSignatureLocalHttpTest เส้นพิสูจน์ Enforce ตาย | มันลงทะเบียนกุญแจผ่าน endpoint ที่ UserService เลิกให้บริการตอนย้ายไป Sentinel · ถูก env-gate ไว้จึง fail เงียบและถูกรายงานว่าผ่านมาตลอด — ต้องตัดสินว่า local run จะเอากุญแจที่ลงทะเบียนแล้วมาจากไหน |
20260903051912 เคยถูก apply ลง dev DB หรือยัง | ยังไม่ verify — ถ้าเคย migration 20260903070030 จะพังด้วย relation already exists ตอน deploy |