Files
breadcrumb-the-shire/modules/audit/lib/Module/Audit/Service/SystemAuditRedactionService.php
fs 149d4515de refactor(settings): remove global appearance settings — tenant is sole source
Removes the global app_theme, app_theme_user and app_primary_color settings
from the admin/settings area and the underlying service/cache/API/i18n/docs
layers. The tenant columns (tenants.primary_color, default_theme,
allow_user_theme) become the single source of truth for appearance.
Branding helpers are tenant-only and fall back to a hardcoded 'light' theme
with no brand color when no tenant context is available (login, error,
pre-session pages). The APP_THEME env var is removed.

Scope:
- UI: Appearance tab gone from /admin/settings; tenant edit form drops the
  global-fallback color preview.
- Service: SettingsAppGateway no longer exposes setting-backed accessors
  for theme/user-theme/primary-color. Theme catalog utilities (listThemes,
  isAllowedTheme, normalizeTheme, resolveDefaultTheme) stay and are used
  across Tenant / Auth / User flows; resolveDefaultTheme now hardcodes
  'light' with a catalog fallback (no global/env lookup).
- AdminSettingsService: buildPageData, sanitization, audit snapshot, apply
  step and cache payload are all stripped of the three keys. Dead helper
  applyAppPrimaryColor + normalizePrimaryColor removed.
- Cache: SettingCacheService HOT_PATH_KEYS drops the three keys.
- API: /settings (GET/PUT) and /settings/public (GET) no longer expose
  appearance fields; OpenAPI schemas pruned accordingly.
- Audit redaction list shortened.
- DB: db/init/init.sql seed rows removed. No migration for existing
  installs — legacy rows stay inert.
- i18n + docs: setting.app_theme, setting.app_theme_user,
  setting.app_primary_color removed from de+en locales; reference and
  explanation docs updated.
- Tests: covering tests for the removed methods pruned; ThemeResolutionTest
  rewritten for tenant-only semantics. One unrelated PHP-CS-Fixer issue in
  UserRegistrar.php cleaned up to keep QG-006 green.

Workflow: .agents/runs/SETTINGS-APPEARANCE-TENANT-ONLY/ (gitignored).
Gates: QG-001..003, QG-006, QG-008 all pass (1985 tests, 28764 assertions).
Reviews: code, security, acceptance all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:40:15 +02:00

197 lines
5.4 KiB
PHP

<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Http\RequestContext;
class SystemAuditRedactionService
{
private const MAX_STRING_LENGTH = 500;
private const MAX_ARRAY_ITEMS = 50;
// email/to_email are PII, not credentials, but still redacted to avoid leaking addresses in audit logs.
/** @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',
];
// 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.
/** @var array<int, string> */
private const CHANGE_VALUE_ALLOWLIST = [
'active',
'status',
'default_tenant_id',
'default_role_id',
'default_department_id',
'app_locale',
'app_registration',
'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',
'frontend_telemetry_enabled',
'frontend_telemetry_sample_rate',
'frontend_telemetry_allowed_events',
];
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;
}
// Use json_encode for arrays to get consistent comparison regardless of type coercion.
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;
}
// Heuristic fallback catches new sensitive keys that weren't explicitly listed.
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);
}
}