- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks - Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists - Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services - Add Microsoft OIDC SSO, API token management, and user lifecycle features - Add swagger-ui vendor integration and OpenAPI spec - Add production Docker setup and bin/ scripts - Update composer dependencies, config, templates, and frontend assets throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
221 lines
6.8 KiB
PHP
221 lines
6.8 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\Audit;
|
|
|
|
use MintyPHP\Http\ApiAuth;
|
|
use MintyPHP\Repository\Audit\ApiAuditLogRepository;
|
|
use MintyPHP\Repository\Support\RepoQuery;
|
|
|
|
class ApiAuditService
|
|
{
|
|
private const RETENTION_DAYS = 90;
|
|
private const MAX_USER_AGENT_LENGTH = 255;
|
|
private const MAX_STRING_LENGTH = 500;
|
|
private const MAX_ARRAY_ITEMS = 30;
|
|
|
|
private static ?array $context = null;
|
|
|
|
public static function startRequestContext(): void
|
|
{
|
|
if (self::$context !== null) {
|
|
return;
|
|
}
|
|
|
|
$method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
|
|
if ($method === 'OPTIONS') {
|
|
self::$context = ['active' => false, 'finished' => true];
|
|
return;
|
|
}
|
|
|
|
$path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
|
|
$path = is_string($path) ? trim($path) : '';
|
|
if ($path === '') {
|
|
self::$context = ['active' => false, 'finished' => true];
|
|
return;
|
|
}
|
|
|
|
self::$context = [
|
|
'active' => true,
|
|
'finished' => false,
|
|
'started_at' => microtime(true),
|
|
'request_id' => RepoQuery::uuidV4(),
|
|
'method' => substr($method !== '' ? $method : 'GET', 0, 8),
|
|
'path' => substr($path, 0, 255),
|
|
'query_json' => self::buildRedactedQueryJson($_GET),
|
|
];
|
|
}
|
|
|
|
public static function finish(int $statusCode, ?string $errorCode = null): void
|
|
{
|
|
if (!is_array(self::$context)) {
|
|
return;
|
|
}
|
|
if (!empty(self::$context['finished'])) {
|
|
return;
|
|
}
|
|
self::$context['finished'] = true;
|
|
|
|
if (empty(self::$context['active'])) {
|
|
return;
|
|
}
|
|
|
|
$durationMs = null;
|
|
if (isset(self::$context['started_at'])) {
|
|
$durationMs = (int) round((microtime(true) - (float) self::$context['started_at']) * 1000);
|
|
if ($durationMs < 0) {
|
|
$durationMs = 0;
|
|
}
|
|
}
|
|
|
|
$statusCode = ($statusCode >= 100 && $statusCode <= 999) ? $statusCode : 500;
|
|
$normalizedErrorCode = trim((string) ($errorCode ?? ''));
|
|
if ($normalizedErrorCode === '') {
|
|
$normalizedErrorCode = null;
|
|
} elseif (strlen($normalizedErrorCode) > 100) {
|
|
$normalizedErrorCode = substr($normalizedErrorCode, 0, 100);
|
|
}
|
|
|
|
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
|
if ($ip === '') {
|
|
$ip = null;
|
|
} elseif (strlen($ip) > 45) {
|
|
$ip = substr($ip, 0, 45);
|
|
}
|
|
|
|
$userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
|
if ($userAgent === '') {
|
|
$userAgent = null;
|
|
} elseif (strlen($userAgent) > self::MAX_USER_AGENT_LENGTH) {
|
|
$userAgent = substr($userAgent, 0, self::MAX_USER_AGENT_LENGTH);
|
|
}
|
|
|
|
$userId = ApiAuth::isAuthenticated() ? ApiAuth::userId() : 0;
|
|
$tenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tenantId() ?? 0) : 0;
|
|
$apiTokenId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::tokenId() ?? 0) : 0;
|
|
$tokenTenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::scopedTenantId() ?? 0) : 0;
|
|
|
|
$payload = [
|
|
'request_id' => (string) (self::$context['request_id'] ?? RepoQuery::uuidV4()),
|
|
'method' => (string) (self::$context['method'] ?? ''),
|
|
'path' => (string) (self::$context['path'] ?? ''),
|
|
'query_json' => self::$context['query_json'] ?? null,
|
|
'status_code' => $statusCode,
|
|
'duration_ms' => $durationMs,
|
|
'error_code' => $normalizedErrorCode,
|
|
'user_id' => $userId > 0 ? $userId : null,
|
|
'tenant_id' => $tenantId > 0 ? $tenantId : null,
|
|
'api_token_id' => $apiTokenId > 0 ? $apiTokenId : null,
|
|
'token_tenant_id' => $tokenTenantId > 0 ? $tokenTenantId : null,
|
|
'ip' => $ip,
|
|
'user_agent' => $userAgent,
|
|
];
|
|
|
|
try {
|
|
ApiAuditLogRepository::create($payload);
|
|
} catch (\Throwable $exception) {
|
|
// Never break API responses because of audit logging failures.
|
|
}
|
|
}
|
|
|
|
public static function listPaged(array $filters): array
|
|
{
|
|
return ApiAuditLogRepository::listPaged($filters);
|
|
}
|
|
|
|
public static function find(int $id): ?array
|
|
{
|
|
return ApiAuditLogRepository::find($id);
|
|
}
|
|
|
|
public static function purgeExpired(): int
|
|
{
|
|
return ApiAuditLogRepository::purgeOlderThanDays(self::RETENTION_DAYS);
|
|
}
|
|
|
|
private static function buildRedactedQueryJson(array $query): ?string
|
|
{
|
|
if (!$query) {
|
|
return null;
|
|
}
|
|
$redacted = self::redactArray($query);
|
|
if ($redacted === []) {
|
|
return null;
|
|
}
|
|
$encoded = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
return is_string($encoded) ? $encoded : null;
|
|
}
|
|
|
|
private static function redactArray(array $data): array
|
|
{
|
|
$result = [];
|
|
$i = 0;
|
|
foreach ($data as $rawKey => $value) {
|
|
if ($i >= self::MAX_ARRAY_ITEMS) {
|
|
break;
|
|
}
|
|
$key = trim((string) $rawKey);
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
|
|
if (self::isSensitiveKey($key)) {
|
|
$result[$key] = '[REDACTED]';
|
|
} else {
|
|
$result[$key] = self::normalizeValue($value);
|
|
}
|
|
$i++;
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
private static function normalizeValue(mixed $value): mixed
|
|
{
|
|
if (is_array($value)) {
|
|
return self::redactArray($value);
|
|
}
|
|
|
|
if (is_bool($value) || is_int($value) || is_float($value) || $value === null) {
|
|
return $value;
|
|
}
|
|
|
|
$string = trim((string) $value);
|
|
if ($string === '') {
|
|
return '';
|
|
}
|
|
if (strlen($string) > self::MAX_STRING_LENGTH) {
|
|
return substr($string, 0, self::MAX_STRING_LENGTH);
|
|
}
|
|
return $string;
|
|
}
|
|
|
|
private static function isSensitiveKey(string $key): bool
|
|
{
|
|
$key = strtolower(trim($key));
|
|
if ($key === '') {
|
|
return false;
|
|
}
|
|
|
|
if (in_array($key, [
|
|
'token',
|
|
'access_token',
|
|
'refresh_token',
|
|
'id_token',
|
|
'secret',
|
|
'password',
|
|
'authorization',
|
|
'api_key',
|
|
'client_secret',
|
|
'key',
|
|
], true)) {
|
|
return true;
|
|
}
|
|
|
|
return str_contains($key, 'token')
|
|
|| str_contains($key, 'secret')
|
|
|| str_contains($key, 'password')
|
|
|| str_contains($key, 'authorization')
|
|
|| str_ends_with($key, '_key');
|
|
}
|
|
|
|
}
|