clqms-be/app/Services/AuditService.php

347 lines
10 KiB
PHP
Raw Normal View History

<?php
namespace App\Services;
use App\Libraries\ValueSet;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\HTTP\IncomingRequest;
use Config\Services;
use DateTime;
use DateTimeZone;
class AuditService
{
private const TABLE_MAP = [
'logpatient' => 'logpatient',
'patient' => 'logpatient',
'visit' => 'logpatient',
'logorder' => 'logorder',
'order' => 'logorder',
'specimen' => 'logorder',
'result' => 'logorder',
'logmaster' => 'logmaster',
'master' => 'logmaster',
'config' => 'logmaster',
'valueset' => 'logmaster',
'logsystem' => 'logsystem',
'system' => 'logsystem',
'auth' => 'logsystem',
'job' => 'logsystem',
];
private const DEFAULT_APP_ID = 'clqms-api';
private const ENTITY_VERSION_DEFAULT = 1;
private static ?BaseConnection $db = null;
private static $session = null;
private static ?IncomingRequest $request = null;
private static ?array $eventIdCache = null;
private static ?string $cachedRequestId = null;
public static function logData(
string $eventId,
string $activityId,
string $entityType,
string $entityId,
string $tableName,
?string $fldName = null,
$previousValue = null,
$newValue = null,
?string $reason = null,
?array $context = null,
array $options = []
): void {
$sourceTable = $tableName ?: $entityType;
$logTable = self::resolveLogTable($sourceTable);
if ($logTable === null) {
log_message('warning', "AuditService cannot resolve log table for {$sourceTable}");
return;
}
$record = self::buildRecord(
$logTable,
self::normalizeEventId($eventId),
strtoupper($activityId),
$entityType,
$entityId,
$sourceTable,
$fldName,
$previousValue,
$newValue,
$reason,
$context,
$options
);
if ($record === null) {
return;
}
try {
self::getDb()->table($logTable)->insert($record);
} catch (\Throwable $e) {
log_message('error', "AuditService failed to insert into {$logTable}: {$e->getMessage()}");
}
}
private static function buildRecord(
string $logTable,
string $eventId,
string $activityId,
string $entityType,
string $entityId,
string $tblName,
?string $fldName,
$previousValue,
$newValue,
?string $reason,
?array $context,
array $options
): ?array {
$contextJson = self::buildContext($context, $options, $entityType);
if ($contextJson === null) {
return null;
}
return [
'TblName' => $tblName,
'RecID' => (string) $entityId,
'FldName' => $fldName,
'FldValuePrev' => self::serializeValue($previousValue),
'FldValueNew' => self::serializeValue($newValue),
'UserID' => self::resolveUserId($options),
'SiteID' => self::resolveSiteId($options),
'DIDType' => $options['did_type'] ?? null,
'DID' => $options['did'] ?? null,
'MachineID' => $options['machine_id'] ?? gethostname(),
'SessionID' => self::resolveSessionId($options),
'AppID' => $options['app_id'] ?? self::DEFAULT_APP_ID,
'ProcessID' => $options['process_id'] ?? null,
'WebPageID' => $options['web_page_id'] ?? self::resolveRoute($options),
'EventID' => $eventId,
'ActivityID' => $activityId,
'Reason' => $reason,
'LogDate' => self::nowWithMillis(),
'Context' => $contextJson,
'IpAddress' => self::resolveIpAddress(),
];
}
private static function serializeValue($value): ?string
{
if ($value === null) {
return null;
}
if (is_scalar($value)) {
return (string) $value;
}
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $json !== false ? $json : null;
}
private static function buildContext(?array $context, array $options, string $entityType): ?string
{
$route = $options['route'] ?? self::resolveRoute($options);
$payload = array_merge(
[
'request_id' => $options['request_id'] ?? self::resolveRequestId(),
'route' => $route,
'timestamp_utc' => $options['timestamp_utc'] ?? self::timestampUtc(),
'entity_type' => $options['entity_type'] ?? $entityType,
'entity_version' => $options['entity_version'] ?? self::ENTITY_VERSION_DEFAULT,
],
$context ?? []
);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return $json !== false ? $json : null;
}
private static function resolveLogTable(?string $source): ?string
{
if ($source === null) {
return null;
}
$key = strtolower(trim($source));
return self::TABLE_MAP[$key] ?? null;
}
private static function resolveUserId(array $options): string
{
return $options['user_id'] ?? self::getSessionValue('user_id') ?? 'SYSTEM';
}
private static function resolveSiteId(array $options): string
{
return $options['site_id'] ?? self::getSessionValue('site_id') ?? 'GLOBAL';
}
private static function resolveSessionId(array $options): string
{
if (!empty($options['session_id'])) {
return $options['session_id'];
}
$session = self::getSession();
if ($session !== null && method_exists($session, 'getId')) {
$id = $session->getId();
if (!empty($id)) {
return $id;
}
}
if (session_status() === PHP_SESSION_ACTIVE) {
$id = session_id();
if (!empty($id)) {
return $id;
}
}
return self::generateUniqueId('sess');
}
private static function resolveRoute(array $options): string
{
if (!empty($options['route'])) {
return $options['route'];
}
$request = self::getRequest();
if ($request !== null) {
return trim(sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath()));
}
return 'cli';
}
private static function resolveIpAddress(): ?string
{
$request = self::getRequest();
if ($request !== null) {
return $request->getIPAddress();
}
return $_SERVER['REMOTE_ADDR'] ?? null;
}
private static function normalizeEventId(string $eventId): string
{
$normalized = strtoupper(trim($eventId));
if (empty($normalized)) {
log_message('warning', 'AuditService received empty EventID');
return $eventId;
}
if (!self::isKnownEvent($normalized)) {
log_message('warning', "AuditService unknown EventID: {$normalized}");
}
return $normalized;
}
private static function isKnownEvent(string $eventId): bool
{
if (self::$eventIdCache === null) {
$raw = ValueSet::getRaw('event_id') ?? [];
self::$eventIdCache = array_filter(array_map(fn ($item) => $item['key'] ?? null, $raw));
}
return in_array($eventId, self::$eventIdCache, true);
}
private static function resolveRequestId(): string
{
if (self::$cachedRequestId !== null) {
return self::$cachedRequestId;
}
$request = self::getRequest();
if ($request !== null) {
$value = $request->getHeaderLine('X-Request-ID');
if (!empty($value)) {
self::$cachedRequestId = $value;
return $value;
}
}
foreach (['HTTP_X_REQUEST_ID', 'REQUEST_ID'] as $header) {
if (!empty($_SERVER[$header])) {
self::$cachedRequestId = $_SERVER[$header];
return self::$cachedRequestId;
}
}
return self::$cachedRequestId = self::generateUniqueId('req');
}
private static function nowWithMillis(): string
{
$dt = new DateTime('now', new DateTimeZone('UTC'));
return $dt->format('Y-m-d H:i:s.v');
}
private static function timestampUtc(): string
{
$dt = new DateTime('now', new DateTimeZone('UTC'));
return $dt->format('Y-m-d\TH:i:s.v\Z');
}
private static function getSessionValue(string $key): ?string
{
$session = self::getSession();
if ($session === null) {
return null;
}
if (!method_exists($session, 'get')) {
return null;
}
$value = $session->get($key);
return $value !== null ? (string) $value : null;
}
private static function getSession(): ?object
{
if (self::$session !== null) {
return self::$session;
}
try {
return self::$session = Services::session();
} catch (\Throwable $e) {
return self::$session = null;
}
}
private static function getRequest(): ?IncomingRequest
{
if (self::$request !== null) {
return self::$request;
}
try {
return self::$request = Services::request();
} catch (\Throwable $e) {
return self::$request = null;
}
}
private static function getDb(): BaseConnection
{
return self::$db ??= \Config\Database::connect();
}
private static function generateUniqueId(string $prefix): string
{
try {
return $prefix . '_' . bin2hex(random_bytes(8));
} catch (\Throwable $e) {
return uniqid("{$prefix}_", true);
}
}
}