On Premise

Konsolidasi JWT via Proxy

Runbook aktivasi dan rollback RESOLVE_JWT_VIA_PROXY & PROXY_RESOLVE_JWT — dua mode verifikasi identitas, kontrak header X-User-*/X-Tenant-*, cara menyetel dan merotasi GATEWAY_SHARED_SECRET, serta troubleshooting gejala ke penyebab.

A. Ringkasan & Definition of Done

Sejak nginx/default.conf.docker mengarahkan seluruh /api/v1/* ke proxy-service (bukan lagi langsung ke tiap service), ada dua lapis flag terpisah yang menentukan apakah verifikasi JWT benar-benar dipindahkan ke proxy, atau topologi baru ini cuma menambah satu loncatan jaringan tanpa mengubah perilaku auth:

  • PROXY_RESOLVE_JWT (dibaca proxy-service) — menyalakan resolusi identitas di jalur preset.
  • RESOLVE_JWT_VIA_PROXY (dibaca tiap backend service) — menyalakan kepercayaan pada header X-User-*/X-Tenant-* yang dikirim proxy, alih-alih verifikasi JWT sendiri.

Default kedua flag ini false di semua service. Selama masih default, topologi baru (nginx → proxy → service) tidak mengubah perilaku auth sedikit pun — proxy hanya meneruskan request apa adanya dan tiap service tetap memverifikasi token sendiri, persis seperti sebelum ada proxy. Bagian paling berbahaya dari fitur ini bukan flag-nya menyala salah, tapi salah konfigurasi di sini tidak memunculkan error — aplikasi tetap jalan, halaman tetap terbuka, log tetap bersih; yang berubah cuma siapa yang bisa menyamar jadi siapa. Karena itu setiap langkah di halaman ini punya verifikasi eksplisit — jangan simpulkan "berhasil" dari "tidak ada yang rusak".

DoD aktivasi dianggap selesai bila:

  • Urutan aktivasi §F dijalankan berurutan, tiap tahap diverifikasi sebelum lanjut ke tahap berikutnya.
  • Log startup proxy mencetak Gateway identity headers: ON dan tiap service yang sudah dipindah mencetak mode 2 (lihat §E.3).
  • Probe akses langsung ke port service (bukan lewat proxy) ditolak 401/403 — lihat §E.3.4.

B. Dua mode & arti flag

PROXY_RESOLVE_JWT / PROXY_RESOLVE_JWT_ENFORCE — sisi proxy-service

KombinasiPerilaku jalur preset (config/services.json)
PROXY_RESOLVE_JWT=false (default)Pass-through murni — proxy tidak menyentuh identitas sama sekali, sama seperti sebelum flag ini ada.
PROXY_RESOLVE_JWT=true, PROXY_RESOLVE_JWT_ENFORCE=falseProxy mulai me-resolve JWT dan meng-inject header X-User-*/X-Tenant-* untuk entry yang ber-resolve_jwt: true. Request yang gagal resolve (token tidak ada/tidak valid) tetap diteruskan tanpa header identitas — service di baliknya masih memverifikasi token sendiri. Ini periode overlap yang disengaja untuk memantau public_routes yang belum lengkap.
PROXY_RESOLVE_JWT_ENFORCE=trueBaru sekarang proxy menolak (401) request preset yang gagal resolve. Nyalakan hanya setelah log kegagalan resolve sepi minimal satu siklus pemakaian penuh.

RESOLVE_JWT_VIA_PROXY — sisi tiap backend service

Dipakai identik di sembilan service Go (authentication, tenant-management, migration, integration, bpm, report, notification, compro, generate-test) dan diadaptasi ke Java/Spring di camunda.

RESOLVE_JWT_VIA_PROXYGATEWAY_SHARED_SECRETMode aktif (service Go)
false (default)apa punMode 1 — service mendekode & memverifikasi JWT sendiri, jalur lama, byte-identik dengan sebelum task ini.
truekosongTetap Mode 1. Flag diabaikan, service mencetak RESOLVE_JWT_VIA_PROXY_IGNORED di log. "Setengah terkonfigurasi" sengaja dianggap MATI, bukan hidup tanpa autentikasi.
trueterisiMode 2 — service berhenti verifikasi JWT, membaca identitas dari header X-User-*/X-Tenant-*, hanya dipercaya kalau X-Gateway-Token cocok GATEWAY_SHARED_SECRET (dibandingkan constant-time).

Camunda TIDAK punya pengaman "flag + secret" yang sama. Di service Go, RESOLVE_JWT_VIA_PROXY=true dengan secret kosong otomatis jatuh ke Mode 1 (aman). Di camunda, RESOLVE_JWT_VIA_PROXY=true sudah cukup untuk masuk cabang Mode 2 — kalau X-Gateway-Token lalu tidak valid/tidak ada (termasuk karena secret kosong), camunda langsung menjawab 401 untuk semua request, tanpa fallback. Jangan nyalakan RESOLVE_JWT_VIA_PROXY di camunda sebelum GATEWAY_SHARED_SECRET benar-benar terisi dan sama persis dengan proxy-service. Detail gejala ada di §H.

Dalam Mode 2, kalau kredensial gateway valid tapi header identitas kosong (mis. request service-to-service, atau lewat jalur preset yang belum resolve_jwt), service turun lagi ke jalur lama dan memverifikasi token sendiri — bukan ditolak. Ini "mode dual" yang membuat Mode 2 tidak memutus trafik internal.

C. Diagram alur request

Sebelum — nginx proxy_pass langsung ke tiap container service:

Sesudah — setiap location ^~ /api/v1/<slug>/ (kecuali /api/v1/proxy/ milik proxy sendiri) diarahkan ke proxy-service:3011, yang lalu meneruskannya ke backend asli lewat PresetForwarder (config/services.json):

Port tiap service (3001–3012) memakai expose: di docker-compose.yml, bukan ports: — sejak sebelum task ini, tapi sekarang inilah syarat mutlak Mode 2 aman: kalau salah satu berubah jadi ports: saat debugging, service itu bisa dijangkau langsung dari host sambil melewati proxy sepenuhnya. Jangan lupa kembalikan ke expose: sebelum merge.

Path yang SENGAJA TIDAK lewat proxy (tidak diubah, tetap langsung ke service-nya):

PathAlasan
sso.* (Keycloak)Browser perlu redirect OIDC langsung ke Keycloak, bukan API JWT yang proxy verifikasi.
/camunda/, /engine-rest/Dipakai worker BPMN internal juga, bukan hanya browser.
/ws/, /collaboration/WebSocket long-lived; proxy-service tidak dirancang jadi WS passthrough di jalur ini.
/mailpit/, MinIO, micrositeTooling internal/infra, di luar cakupan konsolidasi JWT.
/ (app frontend)SPA statis, tidak butuh resolusi identitas server-side.

Panggilan service-to-service di dalam network (bpm-service memanggil notification:3005, worker Camunda memanggil camunda:8080/engine-rest/...) tidak lewat nginx sama sekali, jadi tidak terpengaruh oleh perubahan topologi ini.

D. Kontrak header X-User-* / X-Tenant-*

Sisi pengirim: proxy-service/middleware/identity_headers.go. Nama header harus sama persis di semua service consumer — ini kontrak lintas repo, bukan konvensi per service.

Header ini adalah pernyataan kepercayaan, bukan data. Begitu service masuk Mode 2, ia berhenti memverifikasi JWT dan mempercayai isi header — upstream yang menerima X-User-Id: 1 bertindak sebagai user 1. Dua hal harus sama-sama berlaku, tidak ada yang opsional: (1) header dari client harus di-strip sebelum sampai ke handler (proxy melakukan ini selalu, tanpa syarat, bahkan saat GATEWAY_SHARED_SECRET kosong), dan (2) upstream harus bisa membedakan request dari proxy vs seseorang yang menjangkau port service langsung — itulah peran X-Gateway-Token.

HeaderTipe dataWajib dikirim?Keterangan
X-User-Idstring berisi integer (users.id)SelaluNumerik, bukan UUID. Salah isi UUID di sini → query WHERE user_id=? cocok 0 baris tanpa error apa pun.
X-User-Uuidstring UUID (sub Keycloak)SelaluNilai berbeda dari X-User-Id — jangan tertukar.
X-User-UsernamestringSelalupreferred_username claim. Satu-satunya header identitas yang dibaca camunda.
X-User-EmailstringSelalu
X-User-NamestringSelaluname claim (display name).
X-User-Public-Namestring (kosong bila NULL)Selaluusers.public_name.
X-User-Is-Adminstring literal "true"/"false"Selalu, eksplisitDikirim eksplisit termasuk saat false — header yang absen dibaca downstream sebagai false, yang diam-diam mencabut hak admin.
X-User-Is-Blockedstring literal "true"/"false"Selalu, eksplisitAlasan sama arah sebaliknya: absen dibaca sebagai false (tidak diblokir).
X-User-Realm-Rolesstring, dipisah koma (role1,role2)Selalu (boleh kosong)realm_access.roles.
X-User-Resource-Accessstring JSON ringkas {"client":["role"]}, "{}" bila kosongSelaluHeader absen dibaca sebagai "tidak ada role apa pun" — arah aman (menolak), tapi tetap jawaban yang salah.
X-Tenant-Idstring berisi integer (tenants.id)Hanya saat resolusi tenant suksesTidak pernah dikirim sebagai 0. Kalau tenant gagal di-resolve (slug tak dikenal / keanggotaan dicabut), header ini tidak ada sama sekali — bukan 0. Consumer yang hanya cek keberadaan header (bukan nilainya) harus memperlakukan absen sebagai "tidak ada tenant", bukan tenant 0.
X-Tenant-Uuidstring UUIDSama seperti X-Tenant-Idtenants.uuid.
X-Tenant-RolestringSama seperti X-Tenant-Idtenant_user.role — disebut workspace_role/user_role di beberapa service dengan penamaan lokal berbeda.
X-Gateway-Tokenstring = GATEWAY_SHARED_SECRETSetiap request yang diteruskan proxyKredensial gateway — dibandingkan constant-time, bukan ==, di semua consumer. Lihat §E.

X-Tenant-Slug bukan bagian kontrak ini — itu selektor tenant milik microsite untuk route PDF struktur organisasi, sengaja dikecualikan dari strip set karena sudah dipakai duluan sebelum kontrak ini ada. Kalau menemukan header ini di kode, jangan disamakan dengan X-Tenant-* di atas.

Dua field tambahan yang TIDAK ada di kontrak proxy, tapi tetap wajib jalan di report-service: camunda_tenant_uuid dan act_env. Keduanya tetap di-resolve lokal di report-service dari header x-act-env + tabel tenant_environment_mapping, di kedua mode — lihat gejala tenant bocor di §H.

E. Gateway shared secret

E.1 Mekanisme

Proxy mengirim X-Gateway-Token: <GATEWAY_SHARED_SECRET> pada setiap request yang diteruskannya. Backend memvalidasi dengan perbandingan constant-time (crypto/subtle.ConstantTimeCompare di Go, MessageDigest.isEqual di camunda) — bukan == biasa, supaya waktu respons tidak bocor informasi tentang seberapa cocok tebakan penyerang. Secret kosong selalu dijawab tidak valid lebih dulu, sebelum membandingkan apa pun.

Strip header identitas dari request masuk (X-User-*, X-Tenant-*, X-Gateway-Token) berjalan selalu, tanpa syarat — bahkan saat GATEWAY_SHARED_SECRET kosong dan tidak ada injeksi sama sekali. Mengosongkan secret mematikan injeksi, bukan strip; tidak ada konfigurasi yang mengirim header identitas tanpa token gateway.

E.2 Membuat & menyetel

openssl rand -hex 32

Syarat: sama persis di proxy-service dan di setiap service upstream. Jangan pakai ulang nilai dari secret lain yang masa pakainya berbeda (mis. TENANT_SETTINGS_ENCRYPTION_KEY).

Satu nilai di .env root, dibaca oleh proxy dan seluruh service lewat variabel yang sama:

# .env
GATEWAY_SHARED_SECRET=<hasil openssl rand -hex 32>

Kosongkan untuk development lokal kecuali sedang menguji jalur ini — kosong berarti injeksi header identitas OFF, strip tetap jalan.

Satu Secret dipakai bersama oleh proxy dan seluruh service, di-mount lewat envFrom + secretRef (bukan env: literal per-deployment) — supaya "sama persis" adalah properti struktural, bukan sesuatu yang harus diingat saat menyunting belasan file:

kubectl -n alurkerja create secret generic alurkerja-gateway \
  --from-literal=GATEWAY_SHARED_SECRET="$(openssl rand -hex 32)"
envFrom:
  - secretRef:
      name: alurkerja-gateway

E.3 Verifikasi

E.3.1 — Mode mana yang berjalan. Startup proxy mencetak satu baris tanpa membocorkan nilai secret:

Gateway identity headers: ON - X-User-*/X-Tenant-* diinjeksi + X-Gateway-Token; ...

atau

Gateway identity headers: OFF (GATEWAY_SHARED_SECRET unset) - ...

E.3.2 — Semua replica/service memakai nilai yang sama. Bandingkan hash-nya, jangan nilainya langsung:

docker compose exec proxy sh -c 'printf "%s" "$GATEWAY_SHARED_SECRET" | sha256sum | cut -c1-16'
docker compose exec auth  sh -c 'printf "%s" "$GATEWAY_SHARED_SECRET" | sha256sum | cut -c1-16'

Semua harus identik. Satu yang berbeda menghasilkan kegagalan acak — sebagian request 401, retry sering berhasil — pola yang sangat sulit didiagnosis dari laporan user.

E.3.3 — Header palsu tidak menembus. Kirim identitas palsu lewat proxy, periksa apa yang benar-benar diterima upstream (harus identitas asli pemilik token, bukan nilai yang dikirim di request):

curl -s https://<domain>/api/v1/proxy/<slug>/<path> \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-User-Id: 1" -H "X-User-Is-Admin: true" -H "X-Gateway-Token: tebakan"

E.3.4 — Akses langsung ke port service ditolak. Dari dalam network, lewati proxy sepenuhnya:

docker compose exec proxy sh -c \
  'curl -si http://auth:3001/api/v1/authentication/<path> -H "X-User-Id: 1" -H "X-User-Is-Admin: true"'

Harus 401/403. Kalau 200, service itu belum memasang middleware verifikasi X-Gateway-Tokenjangan aktifkan Mode 2 di service tersebut sampai probe ini ditolak.

E.4 Rotasi

X-Gateway-Token dibandingkan nilai persis — tidak ada periode di mana dua nilai berlaku bersamaan secara otomatis. Rotasi wajib urutan ini:

  1. Tambahkan secret baru sebagai nilai kedua yang diterima di sisi upstream (service menerima nilai lama atau baru).
  2. Rollout ulang seluruh service upstream. Verifikasi semua sudah menerima keduanya.
  3. Ganti GATEWAY_SHARED_SECRET di proxy ke nilai baru. Rollout proxy.
  4. Verifikasi E.3.1–E.3.3.
  5. Hapus nilai lama dari daftar yang diterima upstream. Rollout ulang.

Melompati langkah 1–2 dan langsung mengganti nilai di proxy berarti setiap request tertolak upstream sampai rollout upstream selesai — outage penuh, bukan degradasi. Kalau sisi upstream belum mendukung dua nilai sekaligus, rotasi harus dilakukan dalam jendela maintenance dengan proxy dan seluruh service di-rollout bersamaan.

F. Urutan aktivasi yang aman

Membalik urutan ini menyebabkan outage (trafik nyasar sebelum proxy siap) atau lubang auth (flag service menyala sebelum proxy benar-benar satu-satunya jalan masuk).

  1. Deploy proxy-service dengan GATEWAY_SHARED_SECRET sudah terisi tapi PROXY_RESOLVE_JWT=false dan PROXY_RESOLVE_JWT_ENFORCE=false. Tunggu healthy.
  2. Pindahkan trafik: deploy nginx/default.conf.docker yang mengarahkan /api/v1/* ke proxy:3011.
  3. Verifikasi Mode 1 (default, semua flag false): dari log proxy, pastikan tiap /api/v1/<slug>/... benar-benar tercatat lewat proxy DAN responsnya identik dengan sebelum migrasi (login, CRUD dasar tiap service). Ini wajib hijau dulu — kalau ada endpoint 404/502 di sini, ada path yang belum disisir di nginx atau salah map di services.local.json.
  4. Baru setelah §3 hijau: PROXY_RESOLVE_JWT=true, PROXY_RESOLVE_JWT_ENFORCE tetap false. Proxy mulai injeksi header, service masih verifikasi sendiri — overlap aman.
  5. Baca log sampai sepi. Setiap kegagalan resolve di jalur preset mencetak satu baris preset: identity not resolved for <method> <path> ... — diteruskan tanpa header identitas. Pesan no Authorization header biasanya berarti route itu memang publik di upstream tapi belum ada di public_routes — tambahkan polanya. Pesan invalid token/not a Bearer token berarti client memang mengirim kredensial salah — tidak ada yang perlu diubah di proxy.
  6. PROXY_RESOLVE_JWT_ENFORCE=true — hanya setelah langkah 5 sepi minimal satu siklus pemakaian penuh (termasuk akhir pekan/jam sepi; beberapa route publik hanya dipakai lewat link email).
  7. RESOLVE_JWT_VIA_PROXY=true satu service per satu, dipantau dari log 401 tak terduga sebelum lanjut ke service berikutnya.

Menyalakan PROXY_RESOLVE_JWT_ENFORCE=true bersamaan dengan PROXY_RESOLVE_JWT=true (melompati langkah 5) berarti daftar public_routes yang belum lengkap langsung jadi 401 di produksi. Gejalanya tidak terbaca sebagai masalah kredensial: form start publik tidak render, panel MFE addon kosong, link unduh di email laporan gagal.

G. Rollback

Rollback per lapis, dari yang paling baru dinyalakan — kebalikan urutan aktivasi di §F:

  1. Mode 2 menyala dan bermasalah (401 massal / data user kosong / route publik ikut ditolak): set RESOLVE_JWT_VIA_PROXY=false di service yang bermasalah, restart service itu saja. Tidak perlu sentuh nginx atau proxy.
  2. PROXY_RESOLVE_JWT_ENFORCE=true menolak request yang seharusnya publik: kembalikan ke false — pemulihan seketika, restart proxy saja.
  3. Topologi nginx→proxy sendiri bermasalah (proxy down, salah map, latensi tambahan tidak diterima): kembalikan nginx/default.conf.docker ke revisi sebelum migrasi (proxy_pass langsung ke tiap service). Service tidak perlu direstart — mereka tidak tahu apakah trafik datang dari nginx langsung atau lewat proxy.
  4. Jangan pernah ubah expose: jadi ports: di docker-compose.yml yang di-commit untuk keperluan debug — kalau perlu akses langsung sesaat, pakai docker compose exec <service> sh atau port-forward manual yang tidak masuk git.

H. Troubleshooting

GejalaKemungkinan penyebabTindakan
401 muncul setelah flag proxy dinyalakan, sebagian request, acak, retry sering berhasilGATEWAY_SHARED_SECRET tidak seragam antar replica proxy/service§E.3.2 — bandingkan hash di semua proses, bukan nilainya
401 untuk SEMUA request ke satu service setelah RESOLVE_JWT_VIA_PROXY=true dinyalakanSecret di service itu kosong/beda, atau service belum di-rollout setelah rotasi. Khusus camunda: kombinasi ini tidak fallback ke jalur lama sama sekali (lihat §B) — pastikan GATEWAY_SHARED_SECRET terisi sebelum menyalakan flag di camunda§E.3.2 untuk service tersebut
Data user terlihat kosong/null padahal token validGATEWAY_SHARED_SECRET kosong di proxy → injeksi header mati sama sekali; atau UUID mendarat di slot numerik X-User-Id§E.3.1; bandingkan X-User-Id (numerik) vs X-User-Uuid (UUID) di log upstream
X-Tenant-Id kosong padahal user punya tenantResolusi tenant di proxy gagal — slug tidak dikenal atau keanggotaan dicabut. Ingat: tenant header tidak pernah dikirim sebagai 0, hanya tidak ada sama sekaliCek log proxy: Tenant context not resolved for user ...
Admin terlihat sebagai user biasaX-User-Is-Admin tidak sampai ke upstream — header absen dibaca false§E.3.3, pastikan header ini selalu dikirim eksplisit
Route yang tadinya publik (tanpa auth) jadi ikut kena 401Route belum terdaftar di public_routes (config/services.json) dan PROXY_RESOLVE_JWT_ENFORCE=true sudah dinyalakanSet PROXY_RESOLVE_JWT_ENFORCE=false dulu (pulih seketika, satu rollout), baru tambahkan pattern route ke public_routes dan rollout ulang — lihat §F langkah 5–6
Proses BPMN (camunda) berhenti/gagal — start form, REST API worker, web UI Camunda semuanya terpengaruh/camunda/ dan /engine-rest/ sengaja tidak lewat proxy (lihat §C). Kalau RESOLVE_JWT_VIA_PROXY=true dinyalakan di camunda mengikuti pola service lain tanpa menyadari ini, X-Gateway-Token tidak pernah sampai ke camunda → setiap request ditolak 401 tanpa fallbackMatikan RESOLVE_JWT_VIA_PROXY di camunda (§G langkah 1). Camunda tidak perlu dan tidak boleh dipindah ke Mode 2 mengikuti alur service lain — trafiknya memang tidak lewat proxy
Laporan (report-service) menampilkan angka tenant/environment lain — tanpa error, tanpa 4xx, tanpa log anehcamunda_tenant_uuid/act_env bukan bagian kontrak header proxy — keduanya harus tetap di-resolve lokal di report-service dari x-act-env + tabel tenant_environment_mapping, di kedua mode. Kalau resolusi ini terlewat saat Mode 2 aktif, service diam-diam fallback ke tenant_uuid aplikasi biasa dan mengembalikan laporan LENGKAP dari environment/tenant yang salahVerifikasi x-act-env dan tenant_environment_mapping tetap diproses di report-service setelah RESOLVE_JWT_VIA_PROXY=true; ini satu-satunya gejala di halaman ini yang tidak punya sinyal apa pun selain angka yang keliru — jangan andalkan monitoring error rate untuk menangkapnya
Upstream tidak melihat X-User-* di jalur preset padahal token validPROXY_RESOLVE_JWT masih false, atau entry-nya di services.json tidak ber-resolve_jwt: trueCek baris startup proxy Preset identity resolution: ... + entry base_path-nya
Struktur organisasi microsite merender tenant yang salahX-Tenant-Slug (bukan bagian kontrak identitas ini, lihat §D) hilang atau salahBukan disebabkan strip header identitas — cek stripExemptHeaders di proxy-service/middleware/identity_headers.go

I. Referensi

  • Kontrak header & desain: proxy-service/README.md, proxy-service/middleware/identity_headers.go.
  • Runbook rotasi & verifikasi lengkap (mencakup jalur Kubernetes): proxy-service/docs/GATEWAY_IDENTITY_HEADERS_RUNBOOK.md.
  • Topologi nginx & urutan deploy/rollback sumber halaman ini: release-alurkerja-local/docs/PROXY_TOPOLOGY_RUNBOOK.md.
  • Epic & task asal: Collabs #746#138963 s.d. #138968.