195 lines
5.7 KiB
PHP
195 lines
5.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use CodeIgniter\Database\BaseConnection;
|
|
use CodeIgniter\HTTP\RequestInterface;
|
|
use Config\Database;
|
|
use Config\Services;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Audit trail untuk aksi admin (API `/api/admin/*` dan keamanan).
|
|
*/
|
|
class AdminAuditService
|
|
{
|
|
private const MAX_PAYLOAD_CHARS = 65535;
|
|
|
|
private BaseConnection $db;
|
|
|
|
public function __construct(?BaseConnection $db = null)
|
|
{
|
|
$this->db = $db ?? Database::connect();
|
|
}
|
|
|
|
/**
|
|
* Catat satu baris audit. `payload` disimpan sebagai JSON (disederhanakan & dibatasi panjang).
|
|
* Konteks sesi (auth_source, grup Ion) diambil dari session saat dipanggil dari request web.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
public function log(string $action, array $payload = []): void
|
|
{
|
|
try {
|
|
if (! $this->db->tableExists('admin_activity_logs')) {
|
|
log_message('debug', 'AdminAuditService: tabel admin_activity_logs belum ada — jalankan migrasi.');
|
|
|
|
return;
|
|
}
|
|
|
|
$req = Services::request();
|
|
$sess = session();
|
|
|
|
$outcome = (string) ($payload['__outcome'] ?? 'success');
|
|
$body = $payload;
|
|
unset($body['__outcome']);
|
|
|
|
$row = [
|
|
'admin_user' => $this->resolveAdminUserLabel($sess, $body),
|
|
'action' => $this->truncate($action, 128),
|
|
'endpoint' => $this->truncate($req->getUri()->getPath(), 512),
|
|
'payload' => $this->encodePayload($this->enrichPayload($req, $sess, $body)),
|
|
'ip_address' => $this->truncate($req->getIPAddress(), 45),
|
|
'user_agent' => $this->truncate($req->getUserAgent()->__toString(), 512),
|
|
'auth_source' => $this->truncate((string) ($sess->get('admin_auth_source') ?? ''), 32) ?: null,
|
|
'roles_json' => $this->encodeRolesJson($sess),
|
|
'outcome' => $this->truncate($outcome, 32),
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
];
|
|
|
|
$this->db->table('admin_activity_logs')->insert($row);
|
|
} catch (Throwable $e) {
|
|
log_message('error', 'AdminAuditService::log gagal: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $sessPayload
|
|
*/
|
|
private function resolveAdminUserLabel($sess, array $sessPayload): string
|
|
{
|
|
$u = $sess->get('admin_username');
|
|
if (is_string($u) && $u !== '') {
|
|
return $this->truncate($u, 191);
|
|
}
|
|
|
|
$actor = $sessPayload['actor'] ?? null;
|
|
if (is_array($actor)) {
|
|
$nip = (string) ($actor['nip'] ?? '');
|
|
$id = (string) ($actor['id_pegawai'] ?? '');
|
|
if ($nip !== '') {
|
|
return $this->truncate('pegawai:' . $nip, 191);
|
|
}
|
|
if ($id !== '') {
|
|
return $this->truncate('pegawai:id:' . $id, 191);
|
|
}
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function enrichPayload(RequestInterface $req, $sess, array $payload): array
|
|
{
|
|
$out = $this->sanitizeForStorage($payload);
|
|
|
|
$out['_http'] = [
|
|
'method' => $req->getMethod(),
|
|
'uri' => (string) $req->getUri(),
|
|
];
|
|
|
|
$out['_session_panel'] = [
|
|
'has_admin_token' => is_string($sess->get('admin_mobile_token')) && $sess->get('admin_mobile_token') !== '',
|
|
'auth_source' => $sess->get('admin_auth_source'),
|
|
'ion_groups' => $sess->get('admin_ion_groups'),
|
|
];
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function sanitizeForStorage(array $data): array
|
|
{
|
|
$redactKeys = ['password', 'token', 'admin_mobile_token'];
|
|
|
|
return $this->stripSensitiveRecursive($data, $redactKeys);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @param list<string> $redactKeys
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function stripSensitiveRecursive(array $data, array $redactKeys): array
|
|
{
|
|
$out = [];
|
|
foreach ($data as $k => $v) {
|
|
$key = (string) $k;
|
|
if (in_array(strtolower($key), $redactKeys, true)) {
|
|
$out[$key] = '[redacted]';
|
|
|
|
continue;
|
|
}
|
|
if (is_array($v)) {
|
|
$out[$key] = $this->stripSensitiveRecursive($v, $redactKeys);
|
|
} elseif (is_scalar($v) || $v === null) {
|
|
$out[$key] = $v;
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function encodePayload(array $payload): ?string
|
|
{
|
|
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
|
if ($json === false) {
|
|
return null;
|
|
}
|
|
|
|
if (strlen($json) > self::MAX_PAYLOAD_CHARS) {
|
|
$json = substr($json, 0, self::MAX_PAYLOAD_CHARS - 40) . '…[truncated]';
|
|
}
|
|
|
|
return $json;
|
|
}
|
|
|
|
private function encodeRolesJson($sess): ?string
|
|
{
|
|
$g = $sess->get('admin_ion_groups');
|
|
if (! is_array($g)) {
|
|
return null;
|
|
}
|
|
|
|
$enc = json_encode(array_values($g), JSON_UNESCAPED_UNICODE);
|
|
if ($enc === false) {
|
|
return null;
|
|
}
|
|
|
|
return strlen($enc) > 16000 ? substr($enc, 0, 16000) : $enc;
|
|
}
|
|
|
|
private function truncate(string $s, int $max): string
|
|
{
|
|
if (strlen($s) <= $max) {
|
|
return $s;
|
|
}
|
|
|
|
return substr($s, 0, max(0, $max - 3)) . '...';
|
|
}
|
|
}
|