บทที่ 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 historyinput/output ของ workflow และ activity, signal, update, ผลลัพธ์ได้
Visibility storeSearch Attributesไม่ได้ — เก็บเป็น plain เสมอ
Memoข้อมูลแนบได้
Workflow IDชื่อที่เราตั้งเองไม่ได้
Log ของ workerสิ่งที่เรา log เองอยู่ที่เราคุม

3 จุดที่มักหลุดโดยไม่ตั้งใจ

  1. Workflow ID — topup-0812345678 เอาเบอร์โทรมาเป็น ID คือการเปิดเผยข้อมูลส่วนบุคคล ใช้ transaction ID ที่ไม่สื่อความหมายแทน
  2. Search Attributes — ไม่ผ่าน codec เด็ดขาด อย่าใส่ชื่อ, เบอร์, เลขบัญชี
  3. 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 keymTLS
ตั้งค่าง่ายกว่าต้องจัดการ 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 มีให้ต้องตัดสินโดย
ข้อมูลอ่อนไหวใน historyPayload 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