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

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:

text
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 production

Polanya 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.

bash
export VAULT_ADDR=https://vault.example.comvault loginvault secrets list | grep static-secretvault auth list | grep approle
  • VAULT_ADDR memberi tahu CLI server mana yang dituju.

  • vault login meminta token admin (atau metode login yang biasa kamu pakai).

  • vault secrets list menampilkan semua mount secret. Anggap mount itu seperti lemari. Di Vault saya sudah ada lemari static-secret/ bertipe KV versi 2.

  • vault auth list memastikan metode login approle/ 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):

bash
vault secrets enable -path=static-secret kv-v2vault auth enable approle

Cek 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.

bash
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)"done

Beberapa hal yang sengaja saya lakukan di sini:

  • openssl rand -hex 32 menghasilkan 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 teks openssl rand -hex 32, bukan hasilnya.

  • Dev dan production dapat nilai berbeda, karena openssl dipanggil ulang tiap putaran. Jadi token JWT dari dev nggak akan pernah valid di production.

  • Nama field-nya secret_key untuk keduanya. Template Vault Agent di Bagian 4 akan membaca field ini.

Cek hasil: lihat metadata-nya saja, bukan nilainya.

bash
vault kv metadata get -mount=static-secret secure-cicd-go/development/secrets/jwtvault kv list -mount=static-secret secure-cicd-go/production/secrets   # → aes, jwt

metadata 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.

vault/policies/secure-cicd-go-development.hcl hcl
path "static-secret/data/secure-cicd-go/development/*" {  capabilities = ["read"]}
vault/policies/secure-cicd-go-production.hcl hcl
path "static-secret/data/secure-cicd-go/production/*" {  capabilities = ["read"]}

Tulis ke Vault:

bash
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.hcl

Least privilege, baris per baris

Policy-nya cuma tiga baris, tapi tiap bagian ada alasannya:

Bagian

Kenapa begitu

secure-cicd-go/development/*

Hanya folder environment-nya sendiri. Nggak ada akses ke production, apalagi ke aplikasi lain

capabilities = ["read"]

Hanya baca. Nggak bisa create, update, delete, atau list. Aplikasi nggak punya alasan untuk mengubah secret-nya sendiri

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

static-secret/data/<path>

Isi secret (nilai secret_key)

static-secret/metadata/<path>

Metadata: daftar versi, waktu dibuat, dan dipakai juga untuk list

static-secret/delete/<path>, undelete/, destroy/

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:

bash
vault policy read secure-cicd-go-developmentvault policy read secure-cicd-go-production

Keduanya 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.

bash
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=0done

Opsi

Artinya

token_policies

Token hasil login hanya membawa policy environment-nya sendiri

token_ttl=1h, token_max_ttl=4h

Token Vault berumur pendek. Vault Agent memperpanjang atau login ulang otomatis, jadi aplikasinya nggak terganggu

secret_id_ttl=0

secret_id nggak kedaluwarsa

secret_id_num_uses=0

secret_id bisa dipakai login berkali-kali

Dua baris terakhir itu sengaja. Itu accepted risk, dan saya bahas di bawah supaya nggak terkesan disembunyikan.

Cek hasil:

bash
vault read auth/approle/role/secure-cicd-go-development

Pastikan 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.

bash
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-id

Ulangi 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_id itu username. Vault membuatnya sekali, saat role dibuat, dan nilainya nggak berubah. Jadi kita cukup membaca yang sudah ada, pakai vault read.

  • secret_id itu password. Ia belum ada sama sekali sampai kita minta. Setiap kali diminta, Vault membuat yang baru. Panggilan kedua menghasilkan secret_id yang berbeda, dan yang lama tetap berlaku sampai dicabut. Membuat sesuatu berarti menulis, jadi pakai vault write.

Bagian

Artinya

vault read

Ambil data yang sudah ada, seperti GET

vault write

Kirim permintaan yang membuat atau mengubah sesuatu, seperti POST

-f

force: kirim write tanpa data apa pun. Tanpa -f, Vault menolak write yang kosong

-field=role_id / -field=secret_id

Cetak hanya nilai itu. Tanpa -field, ikut tampil tabel berisi secret_id_accessor, secret_id_ttl, dan lain-lain

auth/approle/role/<nama>/role-id

Alamat "username" milik role itu

auth/approle/role/<nama>/secret-id

"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).

bash
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_TOKEN

Baris per baris:

  • vault write auth/approle/login adalah login AppRole. Hasilnya token dev, disimpan di DEV_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

read

Tunggu input dari keyboard, simpan ke variabel

ROLE_ID / SECRET_ID

Nama variabel tujuan, dipakai lagi dengan "$ROLE_ID"

-r

raw: karakter \ nggak diolah, nilai disimpan persis seperti ditempel

-s

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:

  1. Perintah yang diketik tersimpan di history shell (~/.zsh_history). Siapa pun yang membuka history bisa melihat secret-nya.

  2. Argumen perintah bisa terlihat di daftar proses (ps) selama perintah berjalan.

  3. 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):

text
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 denied

Perhatikan 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

secret_id AppRole statis di env Dokploy

Supaya setup tetap sederhana

Policy hanya read folder environment sendiri. Token Vault TTL pendek (1 jam, maks 4 jam). Bisa dicabut kapan saja lewat secret-id-accessor/destroy. Opsional: secret_id_bound_cidrs ke IP VPS

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_id nggak 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, cabut secret_id dan 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 method approle/.

  • Empat secret acak: jwt dan aes untuk development dan production, dengan nilai berbeda, field secret_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 denied di production dan di path aplikasi lain.

  • Satu accepted risk yang tertulis: secret_id statis, 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.

Artikel terkait