# LabaBersih v2 — Security Implementation Plan

> Dibuat: 5 April 2026
> Audit source: code review endpoint.ex, router.ex, plugs, config, schemas
> Prinsip: Security BUKAN fitur terpisah — security = bagian dari setiap fitur.
> Part by part. Setiap part: implement → test → verify → commit.

---

## 0. AUDIT RESULTS (5 April 2026)

### Yang SUDAH Ada (jangan bikin ulang)

| Layer | Status | Detail |
|---|---|---|
| Password hashing | ✅ Solid | Bcrypt, min 8 char, `redact: true` |
| CSRF protection | ✅ Ada | `protect_from_forgery` di browser pipeline |
| SQL injection | ✅ Protected | Ecto parameterized queries |
| XSS prevention | ✅ Protected | LiveView server-rendered + auto-escape |
| HTTPS/TLS | ✅ Enforced | `force_ssl: true` + HSTS |
| Multi-tenant | ✅ Enforced | `org_id` di setiap query, RequireOrg plug |
| Rate limiting login | ✅ Ada | Hammer: 5 attempt / 60s per email |
| Rate limiting reset | ✅ Ada | Hammer: 3 attempt / 300s per email |
| Webhook HMAC | ✅ Ada | ScaleV + TikTok + Google — constant-time comparison |
| Sentry error tracking | ✅ Ada | Configured di production |
| Sobelow | ✅ Ada | Static analysis tool |
| Password reset tokens | ✅ Solid | SHA256 hash, 1-hour expiry, cleanup after use |
| File upload basic | ✅ Ada | .xlsx/.xls only, 10MB limit |
| CSP header | ✅ Partial | Ada tapi pakai `unsafe-inline` |

### Yang BELUM Ada (harus dikerjakan)

| # | Gap | Risk Level | Kapan |
|---|---|---|---|
| S1 | **Token/API key encryption at rest** | **CRITICAL** | SEBELUM integrasi |
| S2 | Security headers (X-Frame, X-Content-Type, Referrer) | MEDIUM | SEBELUM integrasi |
| S3 | XLSX/file upload hardening (MIME, sanitize) | MEDIUM | SEBELUM integrasi |
| S4 | Rate limiting registration + webhook + global | MEDIUM | SEBELUM integrasi |
| S5 | mix_audit dependency scanning | MEDIUM | SEBELUM integrasi |
| S6 | Password complexity + audit trail | MEDIUM | SEBELUM cutover |
| S7 | 2FA/MFA | MEDIUM-HIGH | SEBELUM cutover |
| S8 | Session management (timeout, force logout, active sessions) | MEDIUM | SEBELUM cutover |
| S9 | Webhook replay protection (timestamp + dedup) | LOW | BISA setelah integrasi |
| S10 | Cookie hardening (explicit Secure, HttpOnly) | LOW | SEBELUM cutover |

---

## 1. TIMELINE: Kapan Security Dikerjakan

### Jawaban: SPLIT — bukan sebelum ATAU sesudah, tapi DI ANTARA.

```
Current state
  │
  ▼
Part S-PRE: Security Foundation (SEBELUM Part G integrasi)
  │  S1: Token encryption at rest (CRITICAL)
  │  S2: Security headers
  │  S3: File upload hardening
  │  S4: Rate limiting expansion
  │  S5: mix_audit + CI pipeline
  │
  ▼
Part F: Staging Pipeline ─── sudah ada di gap-closure plan
Part G: Integrasi Platform ─── token yang disimpan SUDAH encrypted
  │
  ▼
Part S-POST: Security Hardening (SEBELUM Part J cutover)
  │  S6: Password complexity + login audit
  │  S8: Session management
  │  S9: Webhook replay protection
  │  S10: Cookie hardening
  │
  │  (S7: 2FA → Phase 2, setelah cutover, opsional)
  │
  ▼
Part J: Data Migration + Cutover
```

### Kenapa Split?

**S-PRE harus sebelum integrasi karena:**
- S1: Integrasi MENYIMPAN token TikTok/Mengantar di DB. Kalau encryption belum ada, token masuk plaintext. Retrofit = harus re-encrypt semua existing tokens.
- S3: Integrasi buka upload flow baru (settlement XLSX). Validation harus ready.
- S4: Webhook endpoint akan dipanggil external platform. Rate limit harus ada.

**S-POST bisa setelah integrasi karena:**
- S7 (2FA): Gak blocking integrasi. Tapi WAJIB sebelum cutover (user real masuk).
- S8 (Session): Enhancement, bukan blocker.
- S9 (Replay protection): Webhook HMAC sudah ada, replay = low risk tambahan.

---

## 2. PART S-PRE: Security Foundation (~2 session)

### S1: Token Encryption at Rest (CRITICAL)

**Problem:** `integration_configs.config` simpan semua token plaintext:
```
%{
  "access_token" => "tok_abc123...",      ← plaintext
  "refresh_token" => "ref_xyz789...",     ← plaintext
  "api_key" => "API-menga-123...",        ← plaintext
  "webhook_secret" => "whsec_..."         ← plaintext
}
```
DB breach = akses ke SEMUA connected platform (TikTok Shop, Mengantar, ScaleV, Google Ads).

**Solusi: Cloak library — application-level encryption.**

**Step S1.1: Add dependency**
```elixir
# mix.exs
{:cloak, "~> 1.1"},
{:cloak_ecto, "~> 1.3"}
```

**Step S1.2: Configure vault**
```elixir
# lib/lababersih/vault.ex
defmodule Lababersih.Vault do
  use Cloak.Vault, otp_app: :lababersih
end

# config/runtime.exs
config :lababersih, Lababersih.Vault,
  ciphers: [
    default: {Cloak.Ciphers.AES.GCM,
      tag: "AES.GCM.V1",
      key: Base.decode64!(System.fetch_env!("CLOAK_KEY")),
      iv_length: 12}
  ]
```

**Step S1.3: Create encrypted types**
```elixir
# lib/lababersih/encrypted/binary.ex
defmodule Lababersih.Encrypted.Binary do
  use Cloak.Ecto.Binary, vault: Lababersih.Vault
end

# lib/lababersih/encrypted/map.ex
defmodule Lababersih.Encrypted.Map do
  use Cloak.Ecto.Map, vault: Lababersih.Vault
end
```

**Step S1.4: Migration — convert config column**
```elixir
# Approach: add encrypted column, migrate data, drop old column
alter table(:integration_configs) do
  add :config_encrypted, :binary  # Cloak stores as binary
end

# Data migration di application layer (mix task):
# 1. Read all integration_configs
# 2. Per row: encrypt config map → write to config_encrypted
# 3. Verify all rows migrated
# 4. Separate migration: drop old config column, rename config_encrypted → config
```

**Step S1.5: Update IntegrationConfig schema**
```elixir
schema "integration_configs" do
  field :config, Lababersih.Encrypted.Map  # was :map, now encrypted
  # ...
end
```

**Step S1.6: Generate + store CLOAK_KEY**
```bash
# Generate key (32 bytes for AES-256)
mix cloak.gen.key

# Store di Fly.io
fly secrets set CLOAK_KEY="base64_encoded_key_here"
```

**Step S1.7: Key rotation support**
```elixir
# Cloak supports multiple keys. Old key = decrypt only, new key = encrypt.
# Saat rotate: Oban job decrypt-re-encrypt semua rows.
config :lababersih, Lababersih.Vault,
  ciphers: [
    default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V2", key: new_key},
    retired: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", key: old_key}
  ]
```

**Test:**
```
- [ ] Simpan integration config → DB column = binary (bukan readable text)
- [ ] Baca integration config → decrypted map returned
- [ ] DB dump → token TIDAK visible
- [ ] Key rotation → old data masih bisa di-decrypt, new data pakai key baru
```

**Deliverable:** Semua sensitive data encrypted at rest. DB breach ≠ token compromise.

---

### S2: Security Headers

**File:** `lib/lababersih_web/plugs/security_headers.ex` (NEW)

```elixir
defmodule LababersihWeb.Plugs.SecurityHeaders do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    conn
    |> put_resp_header("x-frame-options", "DENY")
    |> put_resp_header("x-content-type-options", "nosniff")
    |> put_resp_header("referrer-policy", "strict-origin-when-cross-origin")
    |> put_resp_header("permissions-policy", "camera=(), microphone=(), geolocation=()")
    |> put_resp_header("x-permitted-cross-domain-policies", "none")
  end
end
```

**Update endpoint.ex:**
```elixir
plug LababersihWeb.Plugs.SecurityHeaders
plug LababersihWeb.Router
```

**Update CSP — tighten `unsafe-inline`:**
```elixir
# Kalau bisa hilangkan unsafe-inline, ganti dengan nonce:
# script-src 'self' 'nonce-{random}'
# Tapi LiveView butuh inline script untuk hooks.
# Compromise: keep unsafe-inline untuk style (Tailwind), tighten script.
# Evaluasi: test tanpa unsafe-inline dulu, kalau break → keep minimal.
```

**Test:**
```
- [ ] Response headers include X-Frame-Options: DENY
- [ ] Response headers include X-Content-Type-Options: nosniff
- [ ] Response headers include Referrer-Policy
- [ ] Response headers include Permissions-Policy
- [ ] Page masih render normal (gak break karena CSP)
```

---

### S3: File Upload Hardening

**File:** `lib/lababersih_web/plugs/upload_validator.ex` (NEW)

```elixir
defmodule LababersihWeb.UploadValidator do
  @moduledoc "Validate uploaded files beyond extension check"

  @max_size 10 * 1024 * 1024  # 10MB
  @allowed_extensions ~w(.xlsx .xls)
  @allowed_mimes ~w(
    application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
    application/vnd.ms-excel
    application/octet-stream
  )

  # XLSX magic bytes: PK (ZIP format)
  @xlsx_magic <<0x50, 0x4B, 0x03, 0x04>>

  def validate(upload) do
    with :ok <- validate_extension(upload),
         :ok <- validate_size(upload),
         :ok <- validate_magic_bytes(upload) do
      {:ok, upload}
    end
  end

  defp validate_extension(%{client_name: name}) do
    ext = Path.extname(name) |> String.downcase()
    if ext in @allowed_extensions, do: :ok, else: {:error, :invalid_extension}
  end

  defp validate_size(%{client_size: size}) when size <= @max_size, do: :ok
  defp validate_size(_), do: {:error, :file_too_large}

  defp validate_magic_bytes(%{path: path}) do
    case File.read(path) do
      {:ok, <<@xlsx_magic, _rest::binary>>} -> :ok
      {:ok, _} -> {:error, :invalid_file_format}
      {:error, _} -> {:error, :file_read_error}
    end
  end
end
```

**Update semua upload LiveView (order import, RTS import, settlement):**
```elixir
# Sebelum proses file:
case UploadValidator.validate(upload_entry) do
  {:ok, _} -> proceed_with_parsing(...)
  {:error, :invalid_file_format} -> {:noreply, put_flash(socket, :error, "File bukan XLSX yang valid")}
  {:error, :file_too_large} -> {:noreply, put_flash(socket, :error, "File terlalu besar (maks 10MB)")}
end
```

**Test:**
```
- [ ] Upload .xlsx valid → accepted
- [ ] Upload .txt renamed to .xlsx → rejected (magic bytes check)
- [ ] Upload > 10MB → rejected
- [ ] Upload .exe → rejected
```

---

### S4: Rate Limiting Expansion

**Update router.ex — tambah rate limit di endpoint yang belum:**

```elixir
# Registration
plug Hammer.Plug, [
  rate_limit: {"register", 60_000, 3},  # 3 per menit per IP
  by: {:conn, &LababersihWeb.RateLimitHelper.by_ip/1}
] when action == :create

# Webhook endpoints — per IP
plug Hammer.Plug, [
  rate_limit: {"webhook", 60_000, 120},  # 120 per menit per IP
  by: {:conn, &LababersihWeb.RateLimitHelper.by_ip/1}
]

# File upload — per user
plug Hammer.Plug, [
  rate_limit: {"upload", 60_000, 10},  # 10 upload per menit
  by: {:conn, &LababersihWeb.RateLimitHelper.by_user/1}
]

# Global fallback — per IP
plug Hammer.Plug, [
  rate_limit: {"global", 60_000, 300},  # 300 req per menit per IP
  by: {:conn, &LababersihWeb.RateLimitHelper.by_ip/1}
]
```

**File baru:** `lib/lababersih_web/rate_limit_helper.ex`
```elixir
defmodule LababersihWeb.RateLimitHelper do
  def by_ip(conn) do
    conn.remote_ip |> :inet.ntoa() |> to_string()
  end

  def by_user(conn) do
    case Guardian.Plug.current_resource(conn) do
      %{id: user_id} -> "user:#{user_id}"
      _ -> by_ip(conn)
    end
  end
end
```

**Test:**
```
- [ ] Register 4x dalam 1 menit → ke-4 ditolak (429)
- [ ] Webhook 121x dalam 1 menit → ditolak
- [ ] Upload 11x dalam 1 menit → ditolak
- [ ] Normal usage → gak kena rate limit
```

---

### S5: Dependency Audit

**Step S5.1: Add mix_audit**
```elixir
# mix.exs
{:mix_audit, "~> 2.1", only: :dev, runtime: false}
```

**Step S5.2: Run audit**
```bash
mix deps.audit    # Check Hex advisories
mix sobelow       # Static analysis
```

**Step S5.3: Add to CI (GitHub Actions)**
```yaml
# .github/workflows/ci.yml — tambah step:
- name: Security audit
  run: |
    mix deps.audit
    mix sobelow --config
```

**Step S5.4: Fix findings**
- Setiap advisory → evaluate → fix atau document exception

**Test:**
```
- [ ] mix deps.audit → 0 known vulnerabilities (atau documented exceptions)
- [ ] mix sobelow → 0 high severity (atau documented exceptions)
```

---

## 3. PART S-POST: Security Hardening (~2 session)

### S6: Password Policy + Login Audit

**Step S6.1: Password complexity**
```elixir
# Di User changeset:
defp validate_password_complexity(changeset) do
  changeset
  |> validate_length(:password, min: 8, max: 72)
  |> validate_format(:password, ~r/[a-z]/, message: "harus mengandung huruf kecil")
  |> validate_format(:password, ~r/[A-Z]/, message: "harus mengandung huruf besar")
  |> validate_format(:password, ~r/[0-9]/, message: "harus mengandung angka")
  |> validate_format(:password, ~r/[!@#$%^&*()_+\-=\[\]{}|;':",.<>\/?]/, message: "harus mengandung simbol")
end
```

**Step S6.2: Login audit trail**
```elixir
# Migration: NEW table
create table(:login_attempts, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :email, :string, null: false
  add :success, :boolean, null: false
  add :ip_address, :string
  add :user_agent, :string
  add :failure_reason, :string  # "invalid_password", "account_locked", "rate_limited"
  timestamps(type: :utc_datetime)
end
create index(:login_attempts, [:email])
create index(:login_attempts, [:inserted_at])
```

**Step S6.3: Account lockout**
```elixir
# Di login flow:
# 10 failed attempts dalam 1 jam → lock account 30 menit
# User bisa unlock via password reset email
```

**Test:**
```
- [ ] Password "abc" → rejected (too short)
- [ ] Password "abcdefgh" → rejected (no uppercase/number)
- [ ] Password "Abcdef1g" → accepted
- [ ] Login gagal → record di login_attempts
- [ ] 10 gagal → account locked
- [ ] Password reset → account unlocked
```

---

### S7: Two-Factor Authentication (2FA)

**Approach: TOTP (Time-based One-Time Password) — Google Authenticator compatible.**

**Step S7.1: Add dependency**
```elixir
{:nimble_totp, "~> 1.0"}
```

**Step S7.2: Migration**
```elixir
alter table(:users) do
  add :totp_secret, :binary          # encrypted via Cloak
  add :totp_enabled, :boolean, default: false
  add :totp_backup_codes, {:array, :string}, default: []  # hashed
  add :totp_confirmed_at, :utc_datetime
end
```

**Step S7.3: Flow**
```
SETUP:
1. User buka Settings → Keamanan → Aktifkan 2FA
2. Generate TOTP secret → tampilkan QR code
3. User scan dengan Google Authenticator / Authy
4. User masukkan 6-digit code → verify → enable 2FA
5. Tampilkan 8 backup codes (1x lihat, simpan di tempat aman)

LOGIN:
1. Email + password → verified
2. Kalau 2FA enabled → redirect ke halaman "Masukkan kode 2FA"
3. User masukkan 6-digit code dari app
4. Verify → login success
5. Opsi: "Ingat perangkat ini 30 hari" (device cookie)

RECOVERY:
1. Gak punya akses ke app → pakai backup code
2. Backup code 1x pakai (burned after use)
3. Semua backup code habis → harus contact admin (manual verify)
```

**Step S7.4: Context functions**
```elixir
# Di Accounts context:
generate_totp_secret(user)
  # Return: %{secret: binary, uri: "otpauth://totp/LabaBersih:user@email?secret=...&issuer=LabaBersih"}

verify_totp(user, code)
  # NimbleTOTP.valid?(secret, code, since: 30) — 30 second window

enable_totp(user, code)
  # Verify code → set totp_enabled = true, totp_confirmed_at = now()
  # Generate 8 backup codes → hash dan simpan

disable_totp(user, code)
  # Verify code → set totp_enabled = false, clear secret

verify_backup_code(user, code)
  # Check against hashed backup codes → burn used code

generate_backup_codes(user)
  # 8 random codes, hash each, store array
```

**Step S7.5: UI**
```
Settings → Keamanan:
  - Status 2FA: Aktif / Nonaktif
  - [Aktifkan 2FA] → QR code + input verify
  - [Nonaktifkan 2FA] → input code confirm
  - [Regenerate Backup Codes] → show 8 codes

Login page (kalau 2FA aktif):
  - Input 6 digit
  - "Pakai backup code"
  - "Ingat perangkat ini"
```

**Test:**
```
- [ ] Generate secret → valid otpauth URI
- [ ] verify_totp dengan kode benar → true
- [ ] verify_totp dengan kode salah → false
- [ ] enable_totp → totp_enabled = true
- [ ] Login tanpa 2FA code saat enabled → redirect ke 2FA page
- [ ] Login dengan 2FA code → success
- [ ] Backup code → 1x pakai, burned after
- [ ] Disable 2FA → login normal lagi
```

---

### S8: Session Management

**Step S8.1: Session timeout**
```elixir
# config/config.exs
config :lababersih, :session,
  max_age: 24 * 60 * 60,        # 24 jam absolute
  idle_timeout: 5 * 60 * 60     # 5 jam idle → logout

# Implement: track last_active_at di session
# On each request: update last_active_at
# If now - last_active_at > idle_timeout → invalidate
```

**Step S8.2: Active sessions tracking**
```elixir
# Migration: NEW table
create table(:user_sessions, primary_key: false) do
  add :id, :binary_id, primary_key: true
  add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
  add :token_hash, :string, null: false  # hash of JWT
  add :ip_address, :string
  add :user_agent, :string
  add :device_name, :string              # parsed from user_agent
  add :last_active_at, :utc_datetime
  add :expires_at, :utc_datetime
  timestamps(type: :utc_datetime)
end
```

**Step S8.3: Force logout**
```elixir
# Di Accounts context:
list_active_sessions(user_id)
revoke_session(session_id)
revoke_all_sessions(user_id)  # "Logout dari semua perangkat"
```

**Step S8.4: UI di Settings → Keamanan**
```
Sesi Aktif:
  Chrome di macOS — Jakarta (IP: 103.xxx) — Aktif sekarang
  Safari di iPhone — Jakarta — 2 jam lalu
  [Logout] per session
  [Logout Semua Perangkat]
```

**Test:**
```
- [ ] Session idle > 4 jam → auto logout
- [ ] Revoke session → token invalid, redirect ke login
- [ ] Revoke all → semua device logout
- [ ] List sessions → tampil device + IP + last active
```

---

### S9: Webhook Replay Protection

**Update webhook controllers:**

```elixir
defp verify_timestamp(timestamp_header) do
  case Integer.parse(timestamp_header) do
    {ts, _} ->
      now = System.system_time(:second)
      # Tolerate 5 menit clock skew
      if abs(now - ts) <= 300, do: :ok, else: {:error, :timestamp_expired}
    _ ->
      {:error, :invalid_timestamp}
  end
end

defp check_idempotency(event_id) do
  # Check di ETS/cache — kalau event_id sudah pernah diproses, skip
  case Cachex.get(:webhook_events, event_id) do
    {:ok, nil} ->
      Cachex.put(:webhook_events, event_id, true, ttl: :timer.hours(24))
      :ok
    {:ok, _} ->
      {:error, :duplicate_event}
  end
end
```

---

### S10: Cookie Hardening

**Update endpoint.ex:**
```elixir
@session_options [
  store: :cookie,
  key: "_lababersih_key",
  signing_salt: "eI/mtJwj",
  encryption_salt: "encrypted_cookie_salt",  # ADD: encrypt cookie content
  same_site: "Lax",
  secure: true,                               # ADD: HTTPS only
  http_only: true,                            # ADD: no JS access
  max_age: 24 * 60 * 60                       # ADD: 24 hour expiry
]
```

---

## 4. UPDATED GAP CLOSURE TIMELINE

```
SEBELUM (current state):
  Part A-E done (337 tests)

BARU:
  Part S-PRE: Security Foundation ← INSERT DI SINI (2 session)
    Session 1: S1 (token encryption) + S2 (security headers)
    Session 2: S3 (upload hardening) + S4 (rate limiting) + S5 (mix_audit)

LANJUT (existing plan):
  Part F: Staging Pipeline (2 session)
  Part G: Integrasi Platform (3 session) ← token yang disimpan SUDAH encrypted
  Part H: Infrastructure (1 session)

BARU:
  Part S-POST: Security Hardening ← INSERT SEBELUM CUTOVER (2 session)
    Session 1: S6 (password policy + audit) + S10 (cookie hardening)
    Session 2: S7 (2FA) + S8 (session management)
    S9 (replay protection) bisa bareng session mana saja

LANJUT:
  Part I: Marketing (1 session)
  Part J: Data Migration (1 session) ← user real masuk, security SUDAH complete
```

### Updated Total Timeline

```
Part A-E:      Done ✅ (337 tests)
Part S-PRE:    2 session (BARU)
Part F:        2 session
Part G:        3 session
Part H:        1 session
Part S-POST:   2 session (BARU)
Part I:        1 session
Part J:        1 session
─────────────────────────
Total remaining: 12 session (+4 security dari original 8)
```

---

## 5. DEPENDENCY MAP

```
S1 (token encryption) ──→ G (integrasi) ──→ S9 (replay protection)
S2 (headers)          ──→ independent
S3 (upload)           ──→ F (staging XLSX), G (settlement XLSX)
S4 (rate limiting)    ──→ G (webhook endpoints)
S5 (mix_audit)        ──→ independent
S6 (password)         ──→ J (cutover — user real login)
S7 (2FA)              ──→ J (cutover)
S8 (session)          ──→ J (cutover)
S10 (cookie)          ──→ J (cutover)
```

---

## 6. MONITORING & MAINTENANCE

### Setelah Semua Implemented

**Recurring (monthly):**
- `mix deps.audit` — cek vulnerability baru
- `mix sobelow` — re-scan setelah code changes
- Review login_attempts — pattern anomali?
- Review rate limit hits — legitimate atau attack?

**Recurring (quarterly):**
- Key rotation (CLOAK_KEY, GUARDIAN_SECRET_KEY)
- Review + prune active sessions
- Update dependencies (security patches)
- Review CSP — tighten kalau bisa

**Incident response:**
- DB breach → immediate key rotation + revoke semua integration tokens + notify affected platforms
- Credential compromise → force password reset + revoke sessions + check login audit
- Webhook abuse → block IP + review rate limits + check data integrity

---

## 7. ANTI-PATTERN

1. ❌ JANGAN simpan secret/token plaintext di DB — SELALU encrypt (Cloak)
2. ❌ JANGAN trust file extension saja — check magic bytes
3. ❌ JANGAN compare signature dengan `==` — pakai constant-time comparison
4. ❌ JANGAN log sensitive data (token, password, NPWP)
5. ❌ JANGAN hardcode secret di source code — SELALU dari env
6. ❌ JANGAN skip rate limiting di webhook — external caller = untrusted
7. ❌ JANGAN store backup codes plaintext — hash setiap code
8. ❌ JANGAN implement crypto sendiri — pakai library proven (Cloak, NimbleTOTP, bcrypt)
9. ❌ JANGAN assume HTTPS = cukup — encrypt at rest juga
10. ❌ JANGAN skip security karena "user masih dikit" — retrofit lebih mahal dari build right

---

## 8. KEPUTUSAN UNTUK HAFISH

| # | Keputusan | Opsi | Rekomendasi |
|---|---|---|---|
| 1 | 2FA wajib atau opsional? | Opsional, Phase 2 (setelah cutover) | **DECIDED** |
| 2 | Session timeout berapa lama? | 5 jam idle | **DECIDED** |
| 3 | Password complexity seberapa ketat? | Min 8 + upper + lower + angka + symbol | **DECIDED** |
| 4 | Login audit retention berapa lama? | 90 hari | **DECIDED** |
| 5 | Key rotation frequency? | Per 6 bulan + saat incident | **DECIDED** |
| 6 | Rate limit webhook berapa? | 120 per menit (sesuai ScaleV v1) | **DECIDED** |
