//BAGIAN 2 DARI 10
Bagian 2: Vault, Secret, Policy, dan AppRole per Environment
· 12 menit baca
English//TUTORIAL · 2 DARI 10 — Secure CI/CD Pipeline dengan Go, GitHub Actions, Vault, dan Dokploy
- 01Bagian 1: Kenapa Saya Bikin Secure CI/CD Pipeline, dan Persiapan Tools-nya
- 02Bagian 2: Vault, Secret, Policy, dan AppRole per Environment
- 03Bagian 3: Aplikasi Go Kecil yang Membaca Secret dari File dan Menolak Jalan Kalau Config Salah
- 04Bagian 4: Vault Agent Sidecar dan Docker Compose, Secret Masuk Tanpa App Tahu Vault Ada
- 05Bagian 5: Repo GitHub dan Workflow PR sebagai Security Gate
- 06Bagian 6: Ruleset yang Memblokir Merge, dan Lima Demo PR yang Harus Gagal
- 07Bagian 7: Menyiapkan Environment Dev dan Production di Dokploy
- 08Bagian 8: Workflow Deploy, Build Sekali, Scan, SBOM, GHCR, lalu Promosi ke Production
- 09Bagian 9: Proses Exception untuk Temuan Security yang Belum Bisa Diperbaiki
- 10Bagian 10: CI Mengambil Token Dokploy dari Vault lewat GitHub OIDC, dan Penutup Seri
Di Bagian 1 kita sudah punya gambaran besar dan tools di laptop. Sekarang kita mulai dari fondasi yang paling menentukan: di mana secret disimpan, dan siapa yang boleh membacanya.
Semua bagian lain di seri ini bergantung ke sini. Aplikasi Go di Bagian 3 membaca secret dari file. Vault Agent di Bagian 4 yang membuat file itu. Dan Vault Agent cuma bisa membaca apa yang diizinkan oleh policy yang kita tulis hari ini.
Di akhir bagian ini kamu akan punya empat secret (dua per environment), dua policy, dua AppRole, dan yang paling penting: bukti bahwa kredensial dev ditolak saat mencoba membaca secret production atau secret aplikasi lain.
Perhatian
Jangan simpan nilai secret di mana pun
Selama mengikuti bagian ini, jangan tulis nilai secret, token, atau secret_id di catatan, file, chat, atau repo. Nilainya cukup lewat terminal, lalu langsung ditempel ke tempat tujuannya.
Gambaran isi Vault
Sebelum mengetik perintah, ini bentuk akhirnya:
static-secret/ ← mount KV v2└── secure-cicd-go/ ├── development/secrets/ │ ├── jwt (secret_key) │ └── aes (secret_key) └── production/secrets/ ├── jwt (secret_key) └── aes (secret_key)policy secure-cicd-go-development → read secure-cicd-go/development/*policy secure-cicd-go-production → read secure-cicd-go/production/*approle secure-cicd-go-development → token membawa policy developmentapprole secure-cicd-go-production → token membawa policy productionPolanya sederhana: satu environment, satu folder, satu policy, satu AppRole. Nggak ada yang dipakai bersama. Kalau kredensial dev bocor, yang bisa dibaca orang itu cuma secret dev. Production aman.
Login dan cek mount
Saya pakai Vault yang sudah ada dan saya punya akses admin di sana. Langkah ini memang butuh token admin, karena kita akan membuat policy dan role.
export VAULT_ADDR=https://vault.example.comvault loginvault secrets list | grep static-secretvault auth list | grep approleVAULT_ADDRmemberi tahu CLI server mana yang dituju.vault loginmeminta token admin (atau metode login yang biasa kamu pakai).vault secrets listmenampilkan semua mount secret. Anggap mount itu seperti lemari. Di Vault saya sudah ada lemaristatic-secret/bertipe KV versi 2.vault auth listmemastikan metode loginapprole/sudah aktif.
Kalau di Vault kamu belum ada keduanya, aktifkan dulu. Ini perintah yang sama dengan yang dipakai skrip Vault lokal di repo (scripts/local-vault.sh, kita pakai di Bagian 4):
vault secrets enable -path=static-secret kv-v2vault auth enable approleCek hasil: kedua grep di atas mencetak satu baris, static-secret/ dengan tipe kv dan approle/ dengan tipe approle.
Membuat empat secret
Aplikasinya nanti butuh dua secret: kunci untuk menandatangani JWT dan kunci AES-256. Masing-masing environment punya nilainya sendiri.
for ENV in development production; do vault kv put -mount=static-secret secure-cicd-go/$ENV/secrets/jwt secret_key="$(openssl rand -hex 32)" vault kv put -mount=static-secret secure-cicd-go/$ENV/secrets/aes secret_key="$(openssl rand -hex 32)"doneBeberapa hal yang sengaja saya lakukan di sini:
openssl rand -hex 32menghasilkan 32 byte acak dalam bentuk 64 karakter hex. Untuk AES, ini pas jadi kunci AES-256: aplikasinya nanti men-decode hex itu jadi 32 byte.Nilainya dibuat di dalam
$(...), jadi saya nggak pernah mengetik atau melihatnya. Yang masuk history shell cuma teksopenssl rand -hex 32, bukan hasilnya.Dev dan production dapat nilai berbeda, karena
openssldipanggil ulang tiap putaran. Jadi token JWT dari dev nggak akan pernah valid di production.Nama field-nya
secret_keyuntuk keduanya. Template Vault Agent di Bagian 4 akan membaca field ini.
Cek hasil: lihat metadata-nya saja, bukan nilainya.
vault kv metadata get -mount=static-secret secure-cicd-go/development/secrets/jwtvault kv list -mount=static-secret secure-cicd-go/production/secrets # → aes, jwtmetadata get menampilkan versi dan waktu dibuat, tanpa isi. Kebiasaan kecil ini berguna: kalau kamu sedang screen-sharing, nggak ada nilai yang ikut tampil.
Policy per environment
Policy di Vault itu daftar "pintu" yang boleh dibuka oleh sebuah token. Kalau sebuah path nggak tertulis di policy, aksesnya ditolak. Default-nya deny.
Dua file policy ini disimpan di repo, di folder vault/policies/. Alasannya: perubahan hak akses itu sama pentingnya dengan perubahan kode, jadi harus lewat PR dan di-review. Di dunia nyata biasanya ada repo platform tersendiri. Untuk proyek ini saya taruh di repo yang sama supaya mudah dilihat.
path "static-secret/data/secure-cicd-go/development/*" { capabilities = ["read"]}path "static-secret/data/secure-cicd-go/production/*" { capabilities = ["read"]}Tulis ke Vault:
vault policy write secure-cicd-go-development vault/policies/secure-cicd-go-development.hclvault policy write secure-cicd-go-production vault/policies/secure-cicd-go-production.hclLeast privilege, baris per baris
Policy-nya cuma tiga baris, tapi tiap bagian ada alasannya:
Bagian | Kenapa begitu |
|---|---|
| Hanya folder environment-nya sendiri. Nggak ada akses ke production, apalagi ke aplikasi lain |
| Hanya baca. Nggak bisa |
Satu policy per environment | Kalau suatu saat production butuh secret tambahan, policy dev nggak ikut berubah |
Inilah yang disebut least privilege: beri hak sekecil mungkin yang masih cukup untuk bekerja. Vault Agent cuma perlu membaca dua secret, jadi itulah satu-satunya yang ia dapat.
Kenapa ada data/ di path
Ini yang sempat bikin saya bingung waktu pertama pakai KV v2. Perhatikan: di perintah vault kv put tadi, path-nya secure-cicd-go/development/secrets/jwt. Tapi di policy, path-nya static-secret/data/secure-cicd-go/development/*. Dari mana data/ itu?
KV versi 2 menyimpan secret dengan versi. Karena itu satu secret sebenarnya punya beberapa alamat API, tergantung apa yang mau kamu lakukan:
Path API | Isinya |
|---|---|
| Isi secret (nilai |
| Metadata: daftar versi, waktu dibuat, dan dipakai juga untuk |
| Mengelola versi |
Perintah vault kv ... dengan -mount= menyisipkan data/ atau metadata/ secara otomatis, jadi kamu nggak melihatnya. Tapi policy bekerja di level path API yang asli, jadi data/ wajib ditulis. Kalau lupa, policy-nya terlihat benar tapi semua pembacaan ditolak 403.
Konsekuensi lain yang menarik: karena policy kita hanya memberi data/, token dev bahkan nggak bisa vault kv list folder dev-nya sendiri (itu butuh metadata/). Nggak masalah. Vault Agent nggak perlu melihat daftar isi folder. Ia tahu persis dua path yang mau dibaca.
Cek hasil:
vault policy read secure-cicd-go-developmentvault policy read secure-cicd-go-productionKeduanya harus mencetak isi file yang sama persis dengan yang ada di repo.
AppRole per environment
Sekarang kita butuh cara supaya Vault Agent bisa login. Untuk mesin (bukan manusia), Vault punya metode AppRole. Analoginya login biasa: ada role_id (seperti username) dan secret_id (seperti password). Hasil login adalah token Vault yang membawa policy tertentu.
for ENV in development production; do vault write auth/approle/role/secure-cicd-go-$ENV \ token_policies="secure-cicd-go-$ENV" \ token_ttl=1h token_max_ttl=4h \ secret_id_ttl=0 secret_id_num_uses=0doneOpsi | Artinya |
|---|---|
| Token hasil login hanya membawa policy environment-nya sendiri |
| Token Vault berumur pendek. Vault Agent memperpanjang atau login ulang otomatis, jadi aplikasinya nggak terganggu |
|
|
|
|
Dua baris terakhir itu sengaja. Itu accepted risk, dan saya bahas di bawah supaya nggak terkesan disembunyikan.
Cek hasil:
vault read auth/approle/role/secure-cicd-go-developmentPastikan token_policies berisi [secure-cicd-go-development] saja, dan token_ttl 1h.
Mengambil role_id dan secret_id
Dua nilai ini nantinya ditempel ke environment Dokploy di Bagian 7. Untuk sekarang kita butuh untuk uji isolasi.
vault read -field=role_id auth/approle/role/secure-cicd-go-development/role-idvault write -f -field=secret_id auth/approle/role/secure-cicd-go-development/secret-idUlangi dengan production kalau perlu. Dan sekali lagi: jangan disimpan di file atau catatan.
Kenapa role_id pakai read, tapi secret_id pakai write?
Kembali ke analogi username dan password:
role_iditu username. Vault membuatnya sekali, saat role dibuat, dan nilainya nggak berubah. Jadi kita cukup membaca yang sudah ada, pakaivault read.secret_iditu password. Ia belum ada sama sekali sampai kita minta. Setiap kali diminta, Vault membuat yang baru. Panggilan kedua menghasilkansecret_idyang berbeda, dan yang lama tetap berlaku sampai dicabut. Membuat sesuatu berarti menulis, jadi pakaivault write.
Bagian | Artinya |
|---|---|
| Ambil data yang sudah ada, seperti |
| Kirim permintaan yang membuat atau mengubah sesuatu, seperti |
| force: kirim |
| Cetak hanya nilai itu. Tanpa |
| Alamat "username" milik role itu |
| "Pabrik password" milik role itu |
Tips
Bersihkan secret_id yang nggak terpakai
Kalau kamu menjalankan vault write ... secret-id tiga kali, ada tiga secret_id aktif. Yang nggak dipakai sebaiknya dicabut. Lihat daftarnya dengan vault list auth/approle/role/<nama>/secret-id. Yang muncul adalah accessor, semacam nomor seri, bukan secret_id-nya, jadi aman ditampilkan. Cabut dengan vault write auth/approle/role/<nama>/secret-id-accessor/destroy secret_id_accessor=<accessor>. Di Bagian 7, sebelum menempel kredensial ke Dokploy, saya mencabut semua sisa uji dari bagian ini dan membuat satu yang baru.
Uji isolasi: bukti least privilege
Ini langkah yang wajib. Policy yang kelihatan benar di atas kertas belum tentu benar di Vault. Jadi kita login sebagai dev, lalu mencoba tiga hal: membaca secret dev (harus boleh), membaca secret production (harus ditolak), dan membaca path aplikasi lain di Vault yang sama (harus ditolak).
read -r ROLE_ID # tempel role_id dev, Enterread -rs SECRET_ID # tempel secret_id dev (tidak tampil), EnterDEV_TOKEN=$(vault write -field=token auth/approle/login role_id="$ROLE_ID" secret_id="$SECRET_ID") VAULT_TOKEN=$DEV_TOKEN vault kv get -mount=static-secret -field=secret_key secure-cicd-go/development/secrets/jwt >/dev/null && echo "dev: BOLEH (benar)"VAULT_TOKEN=$DEV_TOKEN vault kv get -mount=static-secret secure-cicd-go/production/secrets/jwt # harus: permission deniedVAULT_TOKEN=$DEV_TOKEN vault kv list -mount=static-secret aplikasi-lain/ # harus: permission denied vault token revoke "$DEV_TOKEN"; unset ROLE_ID SECRET_ID DEV_TOKENBaris per baris:
vault write auth/approle/loginadalah login AppRole. Hasilnya token dev, disimpan diDEV_TOKEN.VAULT_TOKEN=$DEV_TOKEN vault ...menjalankan satu perintah dengan token dev, tanpa mengganti login admin kamu.Baris dev dibuang ke
/dev/null, jadi nilainya nggak tampil. Kita cuma butuh tahu berhasil atau nggak.aplikasi-lain/ganti dengan path aplikasi lain yang benar-benar ada di Vault kamu. Uji ini lebih bermakna kalau path-nya memang berisi sesuatu.Di akhir, token dev dicabut dan variabelnya dihapus.
Kenapa read -r dan read -rs?
read adalah perintah bawaan shell untuk meminta input dari keyboard lalu menyimpannya ke variabel. Setelah Enter, terminal menunggu kamu menempel nilainya, lalu Enter lagi.
Bagian | Artinya |
|---|---|
| Tunggu input dari keyboard, simpan ke variabel |
| Nama variabel tujuan, dipakai lagi dengan |
| raw: karakter |
| silent: yang kamu tempel nggak tampil di layar, seperti mengetik password |
Kenapa nggak langsung ditulis di perintah, misalnya vault write auth/approle/login secret_id=abc123? Ada tiga alasan:
Perintah yang diketik tersimpan di history shell (
~/.zsh_history). Siapa pun yang membuka history bisa melihat secret-nya.Argumen perintah bisa terlihat di daftar proses (
ps) selama perintah berjalan.Bisa terekam saat screen-sharing, screenshot, atau rekaman layar.
Dengan read, yang masuk history hanya teks read -rs SECRET_ID, bukan nilainya. role_id cukup -r tanpa -s karena ia seperti username, bukan rahasia utama, jadi boleh terlihat untuk memastikan tempelannya benar. secret_id pakai -s karena itu password.
unset di akhir menghapus variabel dari memori shell, supaya nggak tertinggal di terminal yang mungkin masih terbuka berjam-jam.
Cek hasil: baris dev mencetak dev: BOLEH (benar). Dua baris lainnya gagal dengan 403. Kira-kira seperti ini (dipangkas):
dev: BOLEH (benar)Error reading static-secret/data/secure-cicd-go/production/secrets/jwt: Error making API request.Code: 403. Errors:* 1 error occurred: * permission deniedError listing static-secret/metadata/aplikasi-lain: Error making API request.Code: 403. Errors:* 1 error occurred: * permission deniedPerhatikan path di pesan error: CLI sudah menyisipkan data/ dan metadata/ sendiri. Ini cara gampang untuk melihat path API yang sebenarnya dicek policy.
Hasil ini saya screenshot untuk README, karena inilah bukti untuk salah satu anti-goal di Bagian 1: AppRole demo nggak bisa membaca secret milik aplikasi lain.
Catatan
Uji yang sama nanti diulang dari sisi container di Bagian 4: kredensial dev dipakai dengan compose production, Vault Agent mendapat permission denied, dan aplikasinya nggak pernah start.
Accepted risk: secret_id yang statis
Saya membuat secret_id yang nggak kedaluwarsa dan bisa dipakai berulang, lalu nanti menyimpannya di environment Dokploy. Itu jelas bukan pola terkuat. Pola yang lebih ketat misalnya response wrapping, atau secret_id sekali pakai yang dikirim oleh orkestrator. Tapi itu butuh komponen tambahan yang nggak ada di setup Dokploy sederhana.
Jadi saya terima risikonya, dan menuliskannya secara terbuka di README:
Item | Kenapa diterima | Mitigasi |
|---|---|---|
| Supaya setup tetap sederhana | Policy hanya |
Ada dua hal yang perlu kamu tahu tentang risiko ini:
Siapa pun yang punya admin Dokploy atau root di VPS bisa membaca
secret_id. Itu inti dari "statis".Mencabut
secret_idnggak menghentikan aplikasi yang sedang jalan, karena secret-nya sudah ada di memori aplikasi. Yang terblokir adalah start atau restart berikutnya. Kalau mau memutus akses segera, cabutsecret_iddan rotasi secret-nya, lalu redeploy.
Yang penting buat saya: risikonya diketahui, dibatasi oleh policy yang sempit, dan ditulis. Bukan didiamkan.
Catatan
Untuk CI, saya nggak mau mengulang pola ini. Di Bagian 10, job deploy di GitHub Actions login ke Vault pakai OIDC, jadi GitHub nggak menyimpan kredensial Vault sama sekali. Policy-nya juga ada di vault/policies/ (secure-cicd-go-ci-*.hcl), dan masing-masing hanya boleh membaca satu path.
Rangkuman
Yang sekarang sudah ada di Vault:
Mount KV v2
static-secret/dan auth methodapprole/.Empat secret acak:
jwtdanaesuntuk development dan production, dengan nilai berbeda, fieldsecret_key.Dua policy read-only, satu per environment, disimpan juga di
vault/policies/supaya ikut di-review lewat PR.Dua AppRole dengan token berumur pendek, masing-masing hanya membawa policy environment-nya.
Bukti uji isolasi: kredensial dev boleh membaca dev, tapi mendapat
403 permission denieddi production dan di path aplikasi lain.Satu accepted risk yang tertulis:
secret_idstatis, dengan mitigasinya.
Di Bagian 3, kita bikin "pasien" untuk pipeline ini: aplikasi Go kecil yang memakai dua secret tadi untuk JWT dan AES-256-GCM, membacanya dari file, dan langsung berhenti dengan error jelas kalau ada yang nggak beres.