Files
bij/app/Services/AdminAuditService.php
2026-04-21 05:59:39 +07:00

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)) . '...';
}
}