GeoNetAgent, LDAPWeb, server-audit, server-connection
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

418 lines
13 KiB

<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Support\ApiActor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Throwable;
class AuditLogService
{
public function __construct(
private readonly ClientContextService $clientContext
) {
}
public function isAvailable(): bool
{
try {
return Schema::connection('audit')->hasTable('audit_logs');
} catch (Throwable) {
return false;
}
}
public function log(
string $action,
string $actorType = 'web',
?string $actorUsername = null,
?string $actorClient = null,
?string $resource = null,
?Request $request = null,
?int $statusCode = null,
array $metadata = []
): void {
if (! $this->isAvailable()) {
return;
}
try {
$ipInfo = $request ? $this->clientIpInfo($request) : ['ip' => null, 'scope' => 'unknown'];
$metadata = $this->enrichMetadata($request, $metadata, $ipInfo['scope']);
if ($request?->header('X-Forwarded-For')) {
$metadata['forwarded_for'] = $request->header('X-Forwarded-For');
}
AuditLog::query()->create([
'occurred_at' => now(),
'actor_type' => $actorType,
'actor_username' => $actorUsername,
'actor_client' => $actorClient,
'action' => $action,
'resource' => $resource,
'method' => $request?->method(),
'path' => $request ? $request->path() : null,
'status_code' => $statusCode,
'ip_address' => $ipInfo['ip'],
'user_agent' => $request?->userAgent(),
'metadata' => $metadata,
]);
} catch (Throwable) {
// Audit must not break main flow.
}
}
public function logWeb(string $action, ?string $resource = null, ?Request $request = null, array $metadata = []): void
{
$this->log(
action: $action,
actorType: 'web',
actorUsername: session('ldap_username'),
resource: $resource,
request: $request ?? request(),
metadata: $metadata
);
}
public function logPublic(string $action, ?string $resource = null, ?Request $request = null, array $metadata = []): void
{
$this->log(
action: $action,
actorType: 'web',
actorUsername: $metadata['username'] ?? null,
resource: $resource,
request: $request ?? request(),
metadata: $metadata
);
}
public function crudActionFromRoute(Request $request): string
{
$routeName = $request->route()?->getName() ?? 'web.unknown';
return $routeName;
}
public function crudVerb(string $method): string
{
return match (strtoupper($method)) {
'POST' => 'create',
'PUT', 'PATCH' => 'update',
'DELETE' => 'delete',
'GET' => 'read',
default => strtolower($method),
};
}
public function resourceFromRequest(Request $request): ?string
{
$params = $request->route()?->parameters() ?? [];
$parts = [];
foreach ($params as $param) {
if (is_object($param)) {
if (method_exists($param, 'getRouteKey')) {
$parts[] = (string) $param->getRouteKey();
} elseif (isset($param->name)) {
$parts[] = (string) $param->name;
} else {
$parts[] = class_basename($param);
}
} else {
$parts[] = (string) $param;
}
}
return $parts !== [] ? implode('/', $parts) : null;
}
public function clientIpInfo(Request $request): array
{
$ip = $request->ip();
return [
'ip' => $ip,
'scope' => $this->classifyIp($ip),
];
}
public function classifyIp(?string $ip): string
{
if ($ip === null || $ip === '') {
return 'unknown';
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
if (str_starts_with($ip, '127.') || $ip === '0.0.0.0') {
return 'localhost';
}
if (preg_match('/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/', $ip)) {
return 'private';
}
return 'public';
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$lower = strtolower($ip);
if ($lower === '::1') {
return 'localhost';
}
if (str_starts_with($lower, 'fe80:') || str_starts_with($lower, 'fc') || str_starts_with($lower, 'fd')) {
return 'private';
}
return 'public';
}
return 'unknown';
}
public function ipScopeLabel(string $scope): string
{
return match ($scope) {
'public' => 'Publik',
'private' => 'Lokal (LAN)',
'localhost' => 'Localhost',
default => 'Tidak diketahui',
};
}
public function logApi(Request $request, ApiActor $actor, int $statusCode): void
{
if (! in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
return;
}
$routeName = $request->route()?->getName() ?? 'api.request';
$this->log(
action: $routeName,
actorType: 'api',
actorUsername: $actor->type === 'ldap' ? $actor->identifier : null,
actorClient: $actor->identifier,
resource: $this->resourceFromRequest($request),
request: $request,
statusCode: $statusCode,
metadata: [
'crud' => $this->crudVerb($request->method()),
'auth_type' => $actor->type,
'scopes' => $actor->scopes,
'is_admin' => $actor->isAdmin,
'route' => $routeName,
]
);
}
public function listRecent(
int $limit = 300,
?string $search = null,
?string $actorType = null,
?string $module = null,
?string $ipScope = null
): array {
if (! $this->isAvailable()) {
return ['rows' => [], 'error' => 'Audit database is not available.'];
}
$query = AuditLog::query()->orderByDesc('occurred_at')->limit($limit);
if ($search !== null && $search !== '') {
$needle = '%' . $search . '%';
$query->where(function ($q) use ($needle) {
$q->where('actor_username', 'like', $needle)
->orWhere('actor_client', 'like', $needle)
->orWhere('action', 'like', $needle)
->orWhere('resource', 'like', $needle)
->orWhere('path', 'like', $needle)
->orWhere('ip_address', 'like', $needle);
});
}
if ($actorType !== null && $actorType !== '') {
$query->where('actor_type', $actorType);
}
if ($module !== null && $module !== '') {
$query->where(function ($q) use ($module) {
$q->where('action', 'like', $module.'%')
->orWhere('action', 'like', '%.'.$module.'.%')
->orWhere('action', 'like', $module.'.%');
});
}
if ($ipScope !== null && $ipScope !== '') {
$query->where('metadata->ip_scope', $ipScope);
}
return [
'rows' => $query->get()->all(),
'error' => null,
];
}
public function enrichMetadata(?Request $request, array $metadata, ?string $ipScope = null): array
{
if ($ipScope !== null) {
$metadata['ip_scope'] = $ipScope;
} elseif ($request !== null) {
$metadata['ip_scope'] = $this->clientIpInfo($request)['scope'];
}
if ($request !== null) {
$client = $this->clientContext->fromSession($request);
if ($client === [] && $request->userAgent()) {
$client = $this->clientContext->parseUserAgent($request->userAgent());
}
if ($client !== []) {
$metadata['client'] = $client;
}
}
return $metadata;
}
public function logPageView(
Request $request,
string $pageVisitId,
string $path,
?string $title,
?string $referrer,
array $clientContext
): void {
$this->log(
action: 'web.ui.view',
actorType: 'web',
actorUsername: session('ldap_username'),
resource: $path,
request: $request,
metadata: $this->enrichMetadata($request, [
'page_visit_id' => $pageVisitId,
'page_title' => $title,
'referrer' => $referrer ? mb_substr($referrer, 0, 500) : null,
'client' => $clientContext,
'event' => 'page_view',
])
);
}
public function logPageDuration(
Request $request,
string $pageVisitId,
string $path,
int $durationMs,
array $clientContext
): void {
$durationSeconds = (int) round($durationMs / 1000);
$this->log(
action: 'web.ui.duration',
actorType: 'web',
actorUsername: session('ldap_username'),
resource: $path,
request: $request,
metadata: $this->enrichMetadata($request, [
'page_visit_id' => $pageVisitId,
'duration_ms' => $durationMs,
'duration_seconds' => $durationSeconds,
'client' => $clientContext,
'event' => 'page_duration',
])
);
}
/**
* @return array{page_views: list<array{path: string, count: int}>, page_durations: list<array{path: string, visits: int, avg_seconds: float, total_seconds: int}>}
*/
public function uiAccessStats(int $days = 7, int $limit = 15): array
{
if (! $this->isAvailable()) {
return ['page_views' => [], 'page_durations' => []];
}
$since = now()->subDays($days);
$views = AuditLog::query()
->where('action', 'web.ui.view')
->where('occurred_at', '>=', $since)
->get(['resource', 'path']);
$viewCounts = [];
foreach ($views as $row) {
$path = $row->resource ?? $row->path ?? '/';
$viewCounts[$path] = ($viewCounts[$path] ?? 0) + 1;
}
arsort($viewCounts);
$pageViews = [];
foreach (array_slice($viewCounts, 0, $limit, true) as $path => $count) {
$pageViews[] = ['path' => $path, 'count' => $count];
}
$durations = AuditLog::query()
->where('action', 'web.ui.duration')
->where('occurred_at', '>=', $since)
->get(['resource', 'path', 'metadata']);
$durationStats = [];
foreach ($durations as $row) {
$path = $row->resource ?? $row->path ?? '/';
$seconds = (int) ($row->metadata['duration_seconds'] ?? 0);
if (! isset($durationStats[$path])) {
$durationStats[$path] = ['total' => 0, 'count' => 0];
}
$durationStats[$path]['total'] += $seconds;
$durationStats[$path]['count']++;
}
$pageDurations = [];
foreach ($durationStats as $path => $stat) {
$pageDurations[] = [
'path' => $path,
'visits' => $stat['count'],
'avg_seconds' => $stat['count'] > 0 ? round($stat['total'] / $stat['count'], 1) : 0,
'total_seconds' => $stat['total'],
];
}
usort($pageDurations, fn ($a, $b) => $b['total_seconds'] <=> $a['total_seconds']);
return [
'page_views' => $pageViews,
'page_durations' => array_slice($pageDurations, 0, $limit),
];
}
public function listForUser(string $username, int $limit = 50): array
{
if (! $this->isAvailable()) {
return ['rows' => [], 'error' => 'Audit database is not available.'];
}
$needle = '%' . $username . '%';
$rows = AuditLog::query()
->where(function ($q) use ($username, $needle) {
$q->where('actor_username', $username)
->orWhere('resource', $username)
->orWhere('resource', 'like', $needle)
->orWhere('metadata->username', $username);
})
->orderByDesc('occurred_at')
->limit($limit)
->get()
->all();
return ['rows' => $rows, 'error' => null];
}
}