Private Docs

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
AC2apply ครบ 6 ข้อใน UserService + HostApp และใช้งานได้เส้นที่เปิดต้องทำงาน และเส้นที่เหลือต้องไม่เปลี่ยน
AC3เอกสารต้องพอให้ dev อ่านแล้ว apply เองได้พิสูจน์ไปแล้วรอบหนึ่งด้วย ConsentService
AC4apply ตามเอกสาร ห้ามออกนอกเอกสาร · ไม่กระทบ businessConsentService = ตัวพิสูจน์ 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 ไม่ถูกแตะเลย:

serviceendpointแปะอะไรพิสูจน์ข้อ
UserServicePOST /api/user-service/v1/client-keys/echo
ClientKeysController.cs:57-58,69-70
[EncryptedWholeBody] · [EncryptedWholeBodyResponse] · [RequireClientSignature] · [RequireRequestLog]1.1 · 1.3 · 1.6
ConsentServiceGET /api/consent-service/v1/notice
ConsentController.cs:32,40-42
[RequireClientSignature] · [RequireAuthorizationObserve] · [EncryptedWholeBodyResponse]1.1 · 1.3 · 1.5
ConsentServiceGET /api/consent-service/v1/user
ConsentController.cs:86,95
[RequireRequestLog] · [EncryptedField] บน UserConsentResponseDto.Identifier1.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.2response ของ /v1/user มี identifier เป็น ciphertext บนสาย และ FE ถอดออกมาอ่านได้ค่าโผล่เป็น plaintext บน network tab
1.3request/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. ลำดับก่อนกด — สลับไม่ได้

  1. merge PR ให้ครบก่อน แล้ว deploy — ไม่มี auto-publish ตอน merge, feed กับ development เป็นคนละเรื่อง
  2. สร้าง Service Bus topic ก่อนเปิด RequestLog:Enabled ที่ไหนก็ตามprovision-servicebus-topics.ps1 ต่อ namespace ของทุก env · MaxDeliveryCount และ requiresSession ตั้งได้เฉพาะตอนสร้าง แก้ทีหลังต้องลบสร้างใหม่ · เปิด flag ก่อนมี topic = ข้อความหายเงียบ ไม่ error
  3. เติม RequestLog__ServiceBus__ConnectionString ลง Key Vault + CSI SecretProviderClass ของ service/env ที่จะเปิด — ค่าเป็นความลับ ห้ามลง appsettings
  4. เติม config ลง Backend_Iac/config/{service}/{env}/appsettings.json ครบทุก env ก่อนเปิด flag ราย env: FieldEncryption:Payload:WholeBody · AuthorizationObserve · RequestLog · และ {Section}:Coverage ถ้าจะใช้โหมด RequireAll — ขาด env ไหน env นั้นพังตอน deploy
  5. เปิดทีละข้อ ทีละโหมด Enabled=falseLogOnlyEnforce · อย่าเปิดพร้อมกันหลายข้อ เพราะเวลามีอะไรพังจะแยกไม่ออกว่าตัวไหน
  6. หลังเปิดแต่ละข้อ ยิงเส้นที่ไม่ได้แปะ 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-sdk 1.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 ยังไม่ verifyX-Response-Public-Key และ header ลายเซ็นเป็นของใหม่ที่ shell ไม่เคยส่ง · Access-Control-Allow-Headers ของ APIM จะยอมหรือไม่ ตรวจจากรีโปไม่ได้ ต้องรู้ตอนเปิดจอจริง

7. ของที่ยังค้าง ต้องตัดสินก่อนเริ่ม

เรื่องทำไมต้องตัดสิน
ข้อ 4 กับ service ที่ไม่มี DBคู่มือเคยเขียนว่าครอบ “ค่าใน Redis” ด้วย แต่โค้ดจริงลงได้ผ่าน EF อย่างเดียว
ข้อ 5 จะทดสอบที่ service ไหนConsent ทำไม่ได้ · ต้องเลือก service ที่มี role ใช้จริงอยู่แล้ว
route + 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