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 $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 $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 $payload * * @return array */ 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 $data * * @return array */ private function sanitizeForStorage(array $data): array { $redactKeys = ['password', 'token', 'admin_mobile_token']; return $this->stripSensitiveRecursive($data, $redactKeys); } /** * @param array $data * @param list $redactKeys * * @return array */ 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 $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)) . '...'; } }