# 03. Multi-Tenant Authentication dan Engine Idempotensitas

## 1. Multi-Tenant Authentication Architecture

Aplikasi PEWE dirancang dari awal sebagai layanan **Multi-Tenant terisolasi**. Setiap aplikasi internal yang terhubung ke PEWE (misalnya `TSJL` untuk Tasjil, `MOYA` untuk PPDB/Moya) diidentifikasi secara unik di dalam tabel `tenant`.

### Kredensial Tenant
- **`tenant_id`**: Kode string 4 karakter (misal: `TSJL`, `MOYA`).
- **`api_key`**: Public key unik tenant (misal: `pk_2456c27ed13c9c03952a74a9c2c06421`).
- **`api_secret`**: Secret key rahasia untuk menandatangani signature HMAC (misal: `sk_82e4c42e6ce2685af87ab8dbbdc00ea3c5eccd...`).
- **`callback_url`**: Endpoint HTTP di aplikasi tenant yang siap menerima notifikasi webhook status pembayaran dari PEWE.

### Strategi Isolasi & Keamanan Multi-Tenant
1. **Isolasi Logis (Logical Tenant Isolation)**:
   - Setiap transaksi pembayaran (`payment_request`), kunci idempotensi (`idempotency_keys`), dan antrean webhook (`webhook_deliveries`) diikat secara ketat dengan kolom `tenant_id`.
   - Kueri database pada API PEWE secara otomatis membatasi akses data hanya pada `tenant_id` yang terverifikasi melalui autentikasi header.
2. **Pengelolaan Kredensial & Rotasi Key**:
   - `api_key` bersifat publik dan digunakan untuk pengidentifikasian tenant.
   - `api_secret` disimpan secara aman dan **TIDAK BOLEH** dikirimkan langsung dalam payload HTTP request. `api_secret` hanya digunakan sebagai kunci simetris dalam fungsi komputasi hash HMAC-SHA256.
   - Rotasi `api_secret` dapat dilakukan secara mandiri melalui dashboard admin PEWE tanpa perlu mengubah `tenant_id`.
3. **Kontrol Aktivasi Status Tenant (`is_active`)**:
   - Setiap tenant memiliki flag `is_active` (`1` = aktif, `0` = dinonaktifkan). Jika tenant dinonaktifkan, seluruh permintaan API dari tenant tersebut akan ditolak secara instan oleh middleware autentikasi dengan status HTTP `401 Unauthorized`.

---

## 2. Middleware `TenantAuth` (`App\Middleware\TenantAuth`) & Keamanan HMAC-SHA256

Setiap request API ke endpoint tenant (seperti `POST /api/v1/payment-requests`) diwajibkan membawa Header Keamanan berikut:

```http
Content-Type: application/json
X-Tenant-Key: pk_2456c27ed13c9c03952a74a9c2c06421
X-Timestamp: 2026-07-24T08:30:00Z
X-Signature: 5f8a92b... (HMAC-SHA256 Hex)
X-Idempotency-Key: 7b88e1a2-94bc-4e51-8d2a-1153fa89b012
```

### 2.1 Formula Perhitungan Signature (Tenant ➔ PEWE)

Setiap request HTTP wajib ditandatangani menggunakan **HMAC-SHA256** dengan `api_secret` tenant:

```text
1. BodyHash     = SHA256(RAW_JSON_BODY)  // Hex-encoded string 64 karakter (atau SHA256 string kosong jika tidak ada body)

2. StringToSign = HTTP_METHOD + "\n" + REQUEST_PATH + "\n" + X-Timestamp + "\n" + BodyHash

3. X-Signature  = HMAC-SHA256(API_SECRET, StringToSign)
```

#### Contoh Perhitungan StringToSign:
```text
POST
/api/v1/payment-requests
2026-07-24T08:30:00Z
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

### Diagram Alur Otentikasi HMAC-SHA256 (Mermaid Sequence)

```mermaid
sequenceDiagram
    autonumber
    participant TenantApp as Aplikasi Tenant
    participant Middleware as TenantAuth Middleware
    participant Database as Database (Tabel tenant)
    participant Controller as Api Controller

    TenantApp->>Middleware: HTTP Request (Headers: X-Tenant-Key, X-Timestamp, X-Signature)
    
    alt Missing Headers
        Middleware-->>TenantApp: HTTP 401 Unauthorized (Missing authentication headers)
    end

    Note over Middleware: Pengecekan Toleransi Waktu (Clock Skew)<br/>|time() - timestamp| <= 300 detik
    alt Timestamp Expired
        Middleware-->>TenantApp: HTTP 401 Unauthorized (Timestamp is expired)
    end

    Middleware->>Database: Query Tenant (api_key = X-Tenant-Key AND is_active = 1)
    Database-->>Middleware: Tenant Entity / Null

    alt Tenant Not Found / Inactive
        Middleware-->>TenantApp: HTTP 401 Unauthorized (Invalid API key)
    end

    Note over Middleware: Hitung Expected Signature:<br/>StringToSign = METHOD + \n + PATH + \n + TIMESTAMP + \n + SHA256(BODY)<br/>ExpectedSig = HMAC-SHA256(api_secret, StringToSign)

    Note over Middleware: Bandingkan via hash_equals(ExpectedSig, X-Signature)
    alt Signature Mismatch
        Middleware-->>TenantApp: HTTP 401 Unauthorized (Signature mismatch)
    end

    Note over Middleware: Attach Tenant Entity ke Request Attribute ('tenant')
    Middleware->>Controller: Forward to Next Middleware / Handler Action
```

### Implementasi Kode PHP Middleware `TenantAuth`

```php
namespace App\Middleware;

use App\Entity\Tenant;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use function hash;
use function hash_equals;
use function hash_hmac;
use function json_encode;

final class TenantAuth implements MiddlewareInterface
{
    public function __construct(
        private ResponseFactoryInterface $responseFactory,
    ) {
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $tenantKey = $request->getHeaderLine('X-Tenant-Key');
        $timestamp = $request->getHeaderLine('X-Timestamp');
        $signature = $request->getHeaderLine('X-Signature');

        // 1. Validasi Kehadiran Header Autentikasi
        if (empty($tenantKey) || empty($timestamp) || empty($signature)) {
            return $this->errorResponse(401, 'UNAUTHORIZED', 'Missing authentication headers');
        }

        // 2. Proteksi Serangan Replay Attack via Tolerance Window (+/- 300 Detik)
        $diff = abs(time() - strtotime($timestamp));
        if ($diff > 300) {
            return $this->errorResponse(401, 'TIMESTAMP_EXPIRED', 'Timestamp is expired or too far in the future');
        }

        // 3. Pengecekan Ketersediaan & Status Aktif Tenant
        /** @var Tenant|null $tenant */
        $tenant = Tenant::query()->where(['api_key' => $tenantKey, 'is_active' => 1])->one();
        if (!$tenant) {
            return $this->errorResponse(401, 'TENANT_UNAUTHORIZED', 'Invalid API key');
        }

        // 4. Perhitungan Signature HMAC-SHA256 dari Request Payload
        $body = (string) $request->getBody();
        $expectedSignature = $this->generateSignature(
            $request->getMethod(),
            $request->getUri()->getPath(),
            $body,
            $timestamp,
            $tenant->api_secret
        );

        // 5. Perbandingan String Aman Karakter (Constant-Time String Comparison untuk Mencegah Timing Attack)
        if (!hash_equals($expectedSignature, $signature)) {
            return $this->errorResponse(401, 'INVALID_SIGNATURE', 'Signature mismatch');
        }

        // Embed Objek Tenant ke dalam Request Attribute untuk Digunakan oleh Controller Action
        $request = $request->withAttribute('tenant', $tenant);

        return $handler->handle($request);
    }

    private function generateSignature(string $method, string $path, string $body, string $timestamp, string $apiSecret): string
    {
        $bodyHash = hash('sha256', $body);
        $stringToSign = strtoupper($method) . "\n"
            . $path . "\n"
            . $timestamp . "\n"
            . $bodyHash;
        return hash_hmac('sha256', $stringToSign, $apiSecret);
    }

    private function errorResponse(int $status, string $code, string $message): ResponseInterface
    {
        $response = $this->responseFactory->createResponse($status);
        $response->getBody()->write(json_encode([
            'success' => false,
            'error' => [
                'code' => $code,
                'message' => $message
            ]
        ]));
        return $response->withHeader('Content-Type', 'application/json');
    }
}
```

### 2.2 Verifikasi Webhook Inbound (PEWE ➔ Tenant)

Saat PEWE mengirimkan notifikasi callback pelunasan ke `callback_url` Tenant, PEWE menyertakan header `X-Signature`. PEWE menghitung signature webhook langsung menggunakan HMAC-SHA256 dari **Raw JSON Body** callback dengan `api_secret` tenant.

Tenant **wajib memvalidasi signature** sebelum memperbarui status pembayaran di database lokalnya.

---

## 3. Engine Idempotensitas (`idempotency_keys`)

Dalam sistem transaksi keuangan, kegagalan koneksi jaringan atau retry otomatis dari client dapat menyebabkan pengiriman request ganda. Tanpa mekanisme keamanan, hal ini dapat mengakibatkan **double creation Virtual Account** atau **double charging**.

PEWE menyediakan **Engine Idempotensitas** terpusat menggunakan header HTTP `X-Idempotency-Key` (atau `Idempotency-Key`).

### Diagram Keputusan Engine Idempotensitas (Mermaid Flowchart)

```mermaid
flowchart TD
    A[Terima Request POST /payment-requests] --> B{Punya Header X-Idempotency-Key?}
    B -- Tidak --> C[Proses Transaksi Biasa tanpa Caching Idempotensi]
    B -- Ya --> D[Query Tabel idempotency_keys WHERE tenant_id & idempotency_key]
    D --> E{Record Ditemukan?}
    
    E -- Belum Ada --> F[Hitung SHA256 Request Body -> request_hash]
    F --> G[Eksekusi Pembayaran ke Vendor Gateway]
    G --> H[Simpan Request Hash, Response Body JSON & HTTP Status ke idempotency_keys]
    H --> I[Kembalikan HTTP Response Baru ke Client]
    
    E -- Sudah Ada --> J{request_hash Identik?}
    J -- Sama (Retry Request) --> K[Ambil response_body & http_status dari Cache Database]
    K --> L[TIDAK MEMANGGIL VENDOR - Return Cached Response Instan]
    
    J -- Beda Payload (Payload Conflict) --> M[Return HTTP 409 Conflict Error JSON]
```

### Mekanisme Kerja Idempotensitas:

1. **Client Header**: Aplikasi tenant menyertakan UUID / string unik di header request (`X-Idempotency-Key`).

2. **Request Hashing**: System menghitung hash SHA-256 dari JSON Request Body yang diterima (`request_hash`).

3. **Pengecekan Tabel `idempotency_keys`**:
   - Sistem melakukan query ke database dengan kombinasi `(tenant_id, idempotency_key)`:

   #### Skenario A: Key Belum Pernah Digunakan (Request Baru)
   - Transaksi diproses biasa ke Vendor Payment Gateway.
   - Hasil response JSON dan status HTTP (misal `201 Created`) disimpan ke dalam tabel `idempotency_keys`.
   - Client menerima response transaksi.

   #### Skenario B: Key Pernah Digunakan & Hash Request Identik (Retry Request)
   - Sistem mendeteksi `request_hash` sama persis dengan transaksi sebelumnya.
   - **TIDAK MEMANGGIL VENDOR**: PEWE langsung mengambil `response_body` dari tabel `idempotency_keys` dan mengembalikan response persis seperti saat pertama kali dibuat.

   #### Skenario C: Key Pernah Digunakan tetapi Hash Request Berbeda (Konflik Payload)
   - Jika `idempotency_key` sama digunakan untuk payload yang berbeda, PEWE menolak request dan mengembalikan error **HTTP 409 Conflict**:
     ```json
     {
       "error": "IdempotencyKeyConflict",
       "message": "Idempotency key reused with a different request payload."
     }
     ```

### Kebijakan Retensi & Masa Berlaku Kunci (TTL Management)
- Setiap Kunci Idempotensi yang tersimpan memiliki atribut `expired_at` yang dihitung selama **24 Jam** sejak pertama kali dibuat (`created_at + 86400`).
- Setelah melewati masa kadaluarsa, record kunci idempotensi akan dibersihkan secara berkala oleh command otomatis CLI:
  ```bash
  php ./yii idempotency/clean
  ```

---

## 4. Pipeline Middleware Rest API (`ApiFormatter` & `ApiLogger`)

Rantai pemrosesan request API pada PEWE diatur dalam pipeline PSR-15 Middleware:

```php
// config/web/middleware.php
return [
    App\Middleware\ApiLogger::class,     // 1. Mencatat log waktu & URL request
    App\Middleware\TenantAuth::class,    // 2. Memverifikasi identitas & HMAC tenant
    App\Middleware\ApiFormatter::class,  // 3. Menangani penataan format response & exception
];
```

### Rincian Implementasi PSR-15 Middleware Pipeline

#### 1. `App\Middleware\ApiLogger`
Middleware pertama yang menangkap seluruh payload HTTP request masuk dan response keluar:
- Mencatat metode HTTP, URI, timestamp, header, dan body request/response.
- Mengirimkan rekaman log ke PSR-3 `LoggerInterface` dan menuliskan salinan debug mentah pada file `runtime/logs/api_debug.log`.

```php
namespace App\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;

final class ApiLogger implements MiddlewareInterface
{
    public function __construct(
        private LoggerInterface $logger
    ) {
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $uri = (string)$request->getUri();
        $method = $request->getMethod();
        
        $requestBody = (string)$request->getBody();
        if ($request->getBody()->isSeekable()) {
            $request->getBody()->rewind();
        }

        $logReqData = [
            'time' => date('Y-m-d H:i:s'),
            'type' => 'API Request',
            'method' => $method,
            'uri' => $uri,
            'body' => $requestBody,
            'headers' => $request->getHeaders()
        ];
        
        $this->logger->info("API Request [{$method} {$uri}]", $logReqData);

        // Eksekusi Rantai Middleware Berikutnya
        $response = $handler->handle($request);

        $responseBody = (string)$response->getBody();
        if ($response->getBody()->isSeekable()) {
            $response->getBody()->rewind();
        }

        $logData = [
            'time' => date('Y-m-d H:i:s'),
            'type' => 'API Response',
            'method' => $method,
            'uri' => $uri,
            'status' => $response->getStatusCode(),
            'body' => $responseBody,
            'headers' => $response->getHeaders()
        ];
        
        $this->logger->info("API Response [{$method} {$uri}]", $logData);

        return $response;
    }
}
```

#### 2. `App\Middleware\ApiFormatter`
Middleware pembungkus respons akhir yang mencegat objek `Yiisoft\DataResponse\DataResponse`:
- Jika status HTTP bernilai `2xx`, data dibungkus dalam atribut `{"success": true, "data": ...}`.
- Jika status HTTP bernilai `4xx` atau `5xx`, data dibungkus dalam atribut `{"success": false, "error": ...}`.

```php
namespace App\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Yiisoft\DataResponse\DataResponse;

final class ApiFormatter implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);
        
        if ($response instanceof DataResponse) {
            $data = $response->getData();
            $statusCode = $response->getStatusCode();
            
            if ($statusCode >= 200 && $statusCode < 300) {
                $formattedData = [
                    'success' => true,
                    'data' => $data
                ];
            } else {
                $formattedData = [
                    'success' => false,
                    'error' => $data
                ];
            }
            
            $json = json_encode($formattedData);
            
            $stream = new \HttpSoft\Message\Stream('php://temp', 'wb+');
            $stream->write($json);
            $stream->rewind();
            
            return (new \HttpSoft\Message\Response($statusCode))
                ->withHeader('Content-Type', 'application/json')
                ->withBody($stream);
        }
        
        return $response;
    }
}
```

---

### Format Response Standar

#### Response Sukses (HTTP 201 / 200)
```json
{
  "success": true,
  "data": {
    "payment_request_id": "23",
    "external_id": "TJjZXrRFSMf9Rca",
    "status": "pending",
    "amount": 50000.00,
    "fee": 0.00,
    "total": 50000.00,
    "channel": "xendit_va_bri",
    "payment_method": "va",
    "va_number": "262159999713152",
    "qr_string": null,
    "payment_url": null,
    "expired_at": 1784933324
  }
}
```

#### Response Error Standar (HTTP 400 / 401 / 404 / 409 / 500)
```json
{
  "success": false,
  "error": {
    "code": "INVALID_CHANNEL",
    "message": "Channel xendit_va_bca is not active or supported."
  }
}
```
