บทที่ 12 · Part 3 — Production & Advanced
Security & Compliance
เข้ารหัส payload ด้วย Codec, การเชื่อมต่อแบบ mTLS/API key, Namespace isolation และมุมมอง PDPA/PCI-DSS
Event History เก็บ ทุก input และ output ของ workflow กับ activity ไว้ถาวรตลอด retention และเปิดดูได้จาก Web UI ถ้าใครสักคนเผลอส่งเลขบัตรเข้าไปเป็น argument มันจะนอนอยู่ในนั้นเป็นเดือนโดยที่ไม่มีใครรู้
บทนี้ว่าด้วยการปิดช่องเหล่านั้น
จบบทนี้คุณจะ
- รู้ว่าข้อมูลของเราไปอยู่ที่ไหนบ้างในระบบ Temporal
- เข้ารหัส payload ด้วย Codec และเปิดดูผ่าน Web UI ได้อย่างปลอดภัย
- ต่อกับ Temporal Cloud ด้วย mTLS หรือ API key
- ออกแบบ namespace ให้แยกขอบเขตความเสียหาย
- รู้ว่าประเด็นไหนต้องยกให้ทีม compliance ตัดสิน ไม่ใช่ตัดสินในโค้ด
[!IMPORTANT] ขอบเขตของบทนี้ บทนี้อธิบาย กลไกทางเทคนิค ที่ Temporal มีให้ ไม่ใช่คำแนะนำทางกฎหมาย และการใช้เครื่องมือเหล่านี้ไม่ได้ทำให้ระบบ "ผ่าน PDPA" หรือ "ผ่าน PCI-DSS" โดยอัตโนมัติ การประเมินว่าระบบสอดคล้องกับข้อกำหนดหรือไม่ ต้องผ่านทีม compliance และผู้ตรวจสอบ ที่มีอำนาจขององค์กรเสมอ
ข้อมูลของเราไปอยู่ที่ไหนบ้าง
| ที่เก็บ | เก็บอะไร | เข้ารหัสด้วย codec ได้ไหม |
|---|---|---|
| Event history | input/output ของ workflow และ activity, signal, update, ผลลัพธ์ | ได้ |
| Visibility store | Search Attributes | ไม่ได้ — เก็บเป็น plain เสมอ |
| Memo | ข้อมูลแนบ | ได้ |
| Workflow ID | ชื่อที่เราตั้งเอง | ไม่ได้ |
| Log ของ worker | สิ่งที่เรา log เอง | อยู่ที่เราคุม |
3 จุดที่มักหลุดโดยไม่ตั้งใจ
- Workflow ID —
topup-0812345678เอาเบอร์โทรมาเป็น ID คือการเปิดเผยข้อมูลส่วนบุคคล ใช้ transaction ID ที่ไม่สื่อความหมายแทน - Search Attributes — ไม่ผ่าน codec เด็ดขาด อย่าใส่ชื่อ, เบอร์, เลขบัญชี
- Error message —
fmt.Errorf("charge failed for card 4111111111111111")จะถูกบันทึกลง history ทั้งข้อความ
Payload Codec — เข้ารหัส history
Codec ทำงานเป็นชั้นห่อ data converter: encode ก่อนส่งออกไป server, decode ตอนรับกลับมา Server จึงเห็นแค่ byte ที่อ่านไม่ออก
const encodingEncrypted = "binary/encrypted"
type Codec struct {
keyID string
aead cipher.AEAD // AES-256-GCM
}
func (c *Codec) Encode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) {
result := make([]*commonpb.Payload, len(payloads))
for i, p := range payloads {
plain, err := p.Marshal()
if err != nil {
return payloads, err
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return payloads, err
}
sealed := c.aead.Seal(nonce, nonce, plain, nil)
result[i] = &commonpb.Payload{
Metadata: map[string][]byte{
converter.MetadataEncoding: []byte(encodingEncrypted),
// เก็บ key ID ไว้ด้วย เพื่อให้ rotate key แล้วยังถอดของเก่าได้
"encryption-key-id": []byte(c.keyID),
},
Data: sealed,
}
}
return result, nil
}
func (c *Codec) Decode(payloads []*commonpb.Payload) ([]*commonpb.Payload, error) {
result := make([]*commonpb.Payload, len(payloads))
for i, p := range payloads {
if string(p.Metadata[converter.MetadataEncoding]) != encodingEncrypted {
result[i] = p // ไม่ได้เข้ารหัสไว้ ปล่อยผ่าน
continue
}
aead, err := c.aeadForKey(string(p.Metadata["encryption-key-id"]))
if err != nil {
return payloads, err
}
nonceSize := aead.NonceSize()
if len(p.Data) < nonceSize {
return payloads, fmt.Errorf("ciphertext too short")
}
plain, err := aead.Open(nil, p.Data[:nonceSize], p.Data[nonceSize:], nil)
if err != nil {
return payloads, err
}
result[i] = &commonpb.Payload{}
if err := result[i].Unmarshal(plain); err != nil {
return payloads, err
}
}
return result, nil
}
ต่อเข้ากับ client — ต้องตั้งค่าเดียวกันทั้งฝั่ง client และ worker ไม่งั้นถอดรหัสไม่ออก
dataConverter := converter.NewCodecDataConverter(
converter.GetDefaultDataConverter(),
codec,
)
c, err := client.Dial(client.Options{
HostPort: os.Getenv("TEMPORAL_ADDRESS"),
Namespace: "wallet-prod",
DataConverter: dataConverter,
})
กุญแจต้องมาจาก KMS ไม่ใช่จากไฟล์ config
อย่า hardcode key และอย่าใส่ใน environment variable แบบ plain ให้ดึงจาก KMS/HSM ตามมาตรฐานที่องค์กรใช้อยู่ และวางแผน key rotation ไว้ตั้งแต่แรก โดยเก็บ key ID ไว้ใน metadata ของ payload อย่างในตัวอย่าง ไม่งั้นพอ rotate key แล้ว history เก่าจะถอดไม่ออกตลอดกาล
[!WARNING] Codec ที่เรียก KMS ทุกครั้งจะชน deadlock detector
โค้ดใน workflow มีเพดานเวลาประมวลผลต่อ 1 workflow task
ถ้า converter ไปเรียก network ทุกครั้งจะโดน deadlock detection
ทางแก้: cache data key ไว้ในหน่วยความจำ (envelope encryption) และถ้ายังจำเป็นต้องเรียกจริง
ให้ห่อด้วย workflow.DataConverterWithoutDeadlockDetection
Codec Server — ให้ ops อ่าน history ได้
พอเข้ารหัสแล้ว Web UI จะแสดงเป็น byte อ่านไม่ออก ซึ่งทำให้ ops ทำงานไม่ได้
ทางแก้คือ Codec Server: HTTP service ในเครือข่ายเราที่มี endpoint /encode และ /decode
Web UI จะเรียกไปถอดรหัสให้ตอนแสดงผล — ตัว key ไม่เคยออกจากเครือข่ายเรา
func main() {
handler := converter.NewPayloadCodecHTTPHandler(codec)
mux := http.NewServeMux()
mux.Handle("/", withAuth(handler)) // ต้องมีการยืนยันตัวตน ห้ามเปิดโล่ง
_ = http.ListenAndServe("127.0.0.1:8081", mux)
}
Codec Server คือประตูถอดรหัส history ทั้งระบบ
ใครเข้าถึงได้ = อ่านข้อมูลธุรกรรมได้ทั้งหมด ต้องมี:
- การยืนยันตัวตนผูกกับ identity ของผู้ใช้จริง ไม่ใช่ shared secret
- Authorization ตามสิทธิ์ที่ควรเห็น (แยก namespace, แยกทีม)
- Audit log ว่าใครถอดรหัสอะไรเมื่อไร — ข้อนี้มักถูกลืม แต่ผู้ตรวจสอบจะถามหา
- ไม่เปิดออกอินเทอร์เน็ต
ความปลอดภัยของการเชื่อมต่อ
Temporal Cloud ด้วย API key
c, err := client.Dial(client.Options{
HostPort: "<namespace>.<account>.tmprl.cloud:7233",
Namespace: "wallet-prod.<account>",
Credentials: client.NewAPIKeyStaticCredentials(os.Getenv("TEMPORAL_API_KEY")),
ConnectionOptions: client.ConnectionOptions{
TLS: &tls.Config{MinVersion: tls.VersionTLS12},
},
})
Temporal Cloud ด้วย mTLS
cert, err := tls.LoadX509KeyPair(
os.Getenv("TEMPORAL_TLS_CERT"),
os.Getenv("TEMPORAL_TLS_KEY"),
)
if err != nil {
return err
}
c, err := client.Dial(client.Options{
HostPort: "<namespace>.<account>.tmprl.cloud:7233",
Namespace: "wallet-prod.<account>",
ConnectionOptions: client.ConnectionOptions{
TLS: &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
},
},
})
| API key | mTLS | |
|---|---|---|
| ตั้งค่า | ง่ายกว่า | ต้องจัดการ CA และ certificate |
| หมดอายุ | ต้อง rotate ตามรอบ | certificate หมดอายุตามที่ออกไว้ |
| เหมาะกับ | งาน CI, script, บริการที่หมุนเวียนบ่อย | worker ที่อยู่ยาวใน production |
| ความเสี่ยงหลัก | key รั่วแล้วใช้ได้ทันที | private key รั่ว |
เรื่องที่ต้องทำเสมอไม่ว่าจะใช้แบบไหน
- ห้าม commit key, certificate หรือ private key ลง repo เด็ดขาด
- ใช้ secret manager ที่องค์กรอนุมัติ ไม่ใช่ไฟล์
.envที่แชร์กัน - แยก credential ระหว่าง environment — key ของ production ห้ามอยู่บนเครื่อง developer
- ตั้ง alert เมื่อ certificate ใกล้หมดอายุ certificate หมดอายุ = worker หยุดทั้งระบบ
Namespace isolation
Namespace เป็นขอบเขตแยกที่แข็งแรงที่สุดใน Temporal — workflow ข้าม namespace มองไม่เห็นกัน มี retention แยก และให้สิทธิ์แยกได้
temporal operator namespace create \
--namespace wallet-prod \
--retention 30d
temporal operator namespace describe --namespace wallet-prod
แนวทางที่ใช้กันในระบบการเงิน:
| แยกตาม | เหตุผล |
|---|---|
Environment (dev / staging / prod) | จำเป็นเสมอ — กันเทสไปโดนข้อมูลจริง |
| ระดับความอ่อนไหวของข้อมูล | flow ที่แตะข้อมูลบัตรแยกจาก flow ทั่วไป ลดขอบเขตการตรวจสอบ |
| ทีมที่ดูแล | จำกัดสิทธิ์ให้เห็นเฉพาะของตัวเอง |
Retention คือกลไกลบข้อมูลเดียวที่มีจริง
Event history ถูกออกแบบให้ แก้ไขไม่ได้ ลบทีละรายการไม่ได้ มันจะหายไปเมื่อพ้น retention period ของ namespace เท่านั้น
ถ้ามีข้อกำหนดเรื่องการลบข้อมูลส่วนบุคคลตามคำขอ (PDPA มาตรา 33 เป็นต้นไป) วิธีที่ทำได้จริงคือ อย่าเก็บข้อมูลส่วนบุคคลใน history ตั้งแต่แรก — เก็บใน datastore ที่ลบได้ แล้วส่งแค่ reference เข้า workflow เรื่องนี้ต้องออกแบบร่วมกับทีม compliance ก่อนเขียนโค้ด ไม่ใช่แก้ทีหลัง
แนวปฏิบัติในการออกแบบ payload
หลักการเดียวที่ต้องจำ: workflow ควรรู้จักข้อมูลอ่อนไหวให้น้อยที่สุด
// อย่าทำ — ข้อมูลบัตรเข้าไปอยู่ใน history ตลอด retention
type TopUpRequest struct {
CardNumber string
CVV string
HolderName string
NationalID string
}
// ทำแบบนี้ — workflow เห็นแค่ token ที่ใช้แทนกันได้
type TopUpRequest struct {
TransactionID string // idempotency key
WalletID string // ตัวระบุภายใน ไม่สื่อตัวตน
PaymentToken string // token จาก payment vault ใช้ได้ครั้งเดียว
AmountSatang int64
CardLast4 string // เท่าที่จำเป็นต่อการแสดงผลและ support
}
Activity เป็นที่เดียวที่ควรแตะข้อมูลจริง เพราะเนื้อในของ activity ไม่ถูกบันทึกลง history มีแต่ input กับ output เท่านั้น — ดึงข้อมูลจริงจาก vault ในนั้น ใช้ แล้วคืนเฉพาะผลลัพธ์ที่ปลอดภัย
func (a *Activities) ChargeCard(ctx context.Context, req TopUpRequest) (string, error) {
// ดึงข้อมูลจริงในนี้ — ไม่ไหลออกไปเป็น input/output ของ workflow
card, err := a.Vault.Resolve(ctx, req.PaymentToken)
if err != nil {
return "", err
}
defer card.Zeroize()
ref, err := a.Bank.Charge(ctx, card, req.AmountSatang, req.TransactionID)
if err != nil {
// ระวัง error จากระบบปลายทางที่อาจแนบข้อมูลกลับมา — อย่าส่งต่อดิบๆ
return "", fmt.Errorf("charge failed: %w", sanitize(err))
}
return ref, nil // คืนแค่ reference
}
ตารางเชื่อมโยงกับข้อกำหนด
ตารางนี้ช่วยจัดระเบียบการคุยกับทีม compliance — ไม่ใช่ข้อสรุปว่าทำแล้วผ่าน
| ประเด็น | กลไกที่ Temporal มีให้ | ต้องตัดสินโดย |
|---|---|---|
| ข้อมูลอ่อนไหวใน history | Payload Codec | ทีม compliance ว่าระดับการเข้ารหัสพอไหม |
| ข้อมูลบัตรในระบบ | ไม่เก็บเลย ใช้ token จาก vault | ผู้ประเมิน PCI-DSS เรื่องขอบเขต |
| การลบข้อมูลตามคำขอ | Namespace retention เท่านั้น | ทีม compliance เรื่อง data model |
| ใครเข้าถึงข้อมูลได้ | Namespace + สิทธิ์ + Codec Server | เจ้าของระบบและ security |
| ร่องรอยการตรวจสอบ | Event history เป็น audit log โดยธรรมชาติ | ผู้ตรวจสอบว่าครบตามที่ต้องการไหม |
| ข้อมูลอยู่ที่ไหน | เลือก region ของ namespace | ทีม compliance และสัญญากับผู้ให้บริการ |
จุดแข็งที่มักถูกมองข้าม
Event history เป็น audit trail ที่แก้ไขไม่ได้และครบถ้วนโดยอัตโนมัติ — ทุกการตัดสินใจ ทุก retry ทุกการอนุมัติจาก ops พร้อม timestamp ระบบทั่วไปต้องเขียน audit log เพิ่มเอง (แล้วมักเขียนไม่ครบ) ตรงนี้เป็นข้อได้เปรียบที่ควรหยิบไปคุยตอนตรวจสอบ
Checklist ด้านความปลอดภัย
- Payload Codec เปิดใช้บนทุก environment ที่มีข้อมูลจริง และตั้งค่าตรงกันทั้ง client/worker
- กุญแจมาจาก KMS มีแผน rotation และ payload เก็บ key ID ไว้ถอดของเก่าได้
- Codec Server มี authentication, authorization และ audit log และไม่เปิดออกอินเทอร์เน็ต
- Workflow ID ไม่มีข้อมูลส่วนบุคคล
- Search Attributes ไม่มีข้อมูลส่วนบุคคล (ย้ำ: ไม่ผ่าน codec)
- ข้อความ error ถูก sanitize ก่อนคืนออกจาก activity
- ไม่มีข้อมูลบัตรเต็มใน input/output ของ workflow — ใช้ token เท่านั้น
- แยก namespace ระหว่าง environment เป็นอย่างน้อย
- Credential ไม่อยู่ใน repo และแยกตาม environment
- มี alert เมื่อ certificate ใกล้หมดอายุ
- Retention ของแต่ละ namespace ถูกตั้งตามที่ตกลงกับทีม compliance