2026-03-04 15:56:58 +01:00
|
|
|
<?php
|
|
|
|
|
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
namespace MintyPHP\Module\Audit\Service;
|
2026-03-04 15:56:58 +01:00
|
|
|
|
|
|
|
|
use MintyPHP\Http\RequestContext;
|
|
|
|
|
|
|
|
|
|
class SystemAuditRedactionService
|
|
|
|
|
{
|
|
|
|
|
private const MAX_STRING_LENGTH = 500;
|
|
|
|
|
private const MAX_ARRAY_ITEMS = 50;
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
// email/to_email are PII, not credentials, but still redacted to avoid leaking addresses in audit logs.
|
2026-03-04 15:56:58 +01:00
|
|
|
/** @var array<int, string> */
|
|
|
|
|
private const SENSITIVE_KEYS = [
|
|
|
|
|
'password',
|
|
|
|
|
'password2',
|
|
|
|
|
'secret',
|
|
|
|
|
'token',
|
|
|
|
|
'access_token',
|
|
|
|
|
'refresh_token',
|
|
|
|
|
'id_token',
|
|
|
|
|
'authorization',
|
|
|
|
|
'api_key',
|
|
|
|
|
'client_secret',
|
|
|
|
|
'smtp_password',
|
|
|
|
|
'microsoft_shared_client_secret',
|
|
|
|
|
'email',
|
|
|
|
|
'to_email',
|
|
|
|
|
];
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
// Only fields on this list show actual before/after values in change sets.
|
|
|
|
|
// Everything else is recorded as '[REDACTED]' — callers must explicitly opt in to expose change values.
|
2026-03-04 15:56:58 +01:00
|
|
|
/** @var array<int, string> */
|
|
|
|
|
private const CHANGE_VALUE_ALLOWLIST = [
|
|
|
|
|
'active',
|
|
|
|
|
'status',
|
|
|
|
|
'default_tenant_id',
|
|
|
|
|
'default_role_id',
|
|
|
|
|
'default_department_id',
|
|
|
|
|
'app_locale',
|
|
|
|
|
'app_theme',
|
|
|
|
|
'app_theme_user',
|
|
|
|
|
'app_registration',
|
|
|
|
|
'app_primary_color',
|
|
|
|
|
'api_token_default_ttl_days',
|
|
|
|
|
'api_token_max_ttl_days',
|
|
|
|
|
'user_inactivity_deactivate_days',
|
|
|
|
|
'user_inactivity_delete_days',
|
|
|
|
|
'system_audit_enabled',
|
|
|
|
|
'system_audit_retention_days',
|
2026-03-06 00:44:52 +01:00
|
|
|
'frontend_telemetry_enabled',
|
|
|
|
|
'frontend_telemetry_sample_rate',
|
|
|
|
|
'frontend_telemetry_allowed_events',
|
2026-03-04 15:56:58 +01:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
public function redactMetadata(array $metadata): array
|
|
|
|
|
{
|
|
|
|
|
return $this->redactArray($metadata);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{changed_fields: list<string>, changes: array<string, array{before:mixed, after:mixed}>}
|
|
|
|
|
*/
|
|
|
|
|
public function buildChangeSet(array $before, array $after): array
|
|
|
|
|
{
|
|
|
|
|
$changedFields = [];
|
|
|
|
|
$changes = [];
|
|
|
|
|
|
|
|
|
|
$allFields = array_values(array_unique(array_merge(array_keys($before), array_keys($after))));
|
|
|
|
|
sort($allFields, SORT_STRING);
|
|
|
|
|
|
|
|
|
|
foreach ($allFields as $field) {
|
|
|
|
|
if (!is_string($field) || trim($field) === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$beforeValue = $before[$field] ?? null;
|
|
|
|
|
$afterValue = $after[$field] ?? null;
|
|
|
|
|
|
|
|
|
|
if ($this->valuesEqual($beforeValue, $afterValue)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$changedFields[] = $field;
|
|
|
|
|
if ($this->isAllowlistedChangeField($field)) {
|
|
|
|
|
$changes[$field] = [
|
|
|
|
|
'before' => $this->normalizeValue($beforeValue),
|
|
|
|
|
'after' => $this->normalizeValue($afterValue),
|
|
|
|
|
];
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$changes[$field] = [
|
|
|
|
|
'before' => '[REDACTED]',
|
|
|
|
|
'after' => '[REDACTED]',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'changed_fields' => $changedFields,
|
|
|
|
|
'changes' => $changes,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function hashValue(string $value): string
|
|
|
|
|
{
|
|
|
|
|
return RequestContext::hashValue($value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function redactArray(array $data): array
|
|
|
|
|
{
|
|
|
|
|
$result = [];
|
|
|
|
|
$index = 0;
|
|
|
|
|
|
|
|
|
|
foreach ($data as $rawKey => $value) {
|
|
|
|
|
if ($index >= self::MAX_ARRAY_ITEMS) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$key = trim((string) $rawKey);
|
|
|
|
|
if ($key === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($this->isSensitiveKey($key)) {
|
|
|
|
|
$result[$key] = '[REDACTED]';
|
|
|
|
|
$index++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (is_array($value)) {
|
|
|
|
|
$result[$key] = $this->redactArray($value);
|
|
|
|
|
$index++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$result[$key] = $this->normalizeValue($value);
|
|
|
|
|
$index++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function normalizeValue(mixed $value): mixed
|
|
|
|
|
{
|
|
|
|
|
if (is_null($value) || is_bool($value) || is_int($value) || is_float($value)) {
|
|
|
|
|
return $value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (is_array($value)) {
|
|
|
|
|
return $this->redactArray($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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
// Use json_encode for arrays to get consistent comparison regardless of type coercion.
|
2026-03-04 15:56:58 +01:00
|
|
|
private function valuesEqual(mixed $left, mixed $right): bool
|
|
|
|
|
{
|
|
|
|
|
if (is_array($left) || is_array($right)) {
|
|
|
|
|
return json_encode($left) === json_encode($right);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (string) $left === (string) $right;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function isSensitiveKey(string $key): bool
|
|
|
|
|
{
|
|
|
|
|
$key = strtolower(trim($key));
|
|
|
|
|
if ($key === '') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (in_array($key, self::SENSITIVE_KEYS, true)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
// Heuristic fallback catches new sensitive keys that weren't explicitly listed.
|
2026-03-04 15:56:58 +01:00
|
|
|
return str_contains($key, 'password')
|
|
|
|
|
|| str_contains($key, 'secret')
|
|
|
|
|
|| str_contains($key, 'token')
|
|
|
|
|
|| str_contains($key, 'authorization')
|
|
|
|
|
|| str_ends_with($key, '_key');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function isAllowlistedChangeField(string $field): bool
|
|
|
|
|
{
|
|
|
|
|
return in_array(strtolower(trim($field)), self::CHANGE_VALUE_ALLOWLIST, true);
|
|
|
|
|
}
|
|
|
|
|
}
|