Harden module runtime: audited listener failures and DI-first resolver

This commit is contained in:
2026-03-25 10:02:03 +01:00
parent a721ec0043
commit cb5f7037b2
12 changed files with 314 additions and 79 deletions

View File

@@ -4,6 +4,7 @@ namespace MintyPHP\App\Container\Registrars;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\App\Module\ModuleClassResolver;
use MintyPHP\App\Module\ModuleEventDispatcher;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Http\CookieStoreInterface;
@@ -116,7 +117,8 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
$container->set(AccessPolicyFactory::class, static fn (AppContainer $c): AccessPolicyFactory => new AccessPolicyFactory(
$c->get(PermissionService::class),
$c->get(TenantScopeService::class),
$c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null
$c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null,
static fn (string $class): ?object => ModuleClassResolver::resolve($c, $class)
));
$container->set(UserGatewayFactory::class, static fn (AppContainer $c): UserGatewayFactory => new UserGatewayFactory(
$c->get(AccessServicesFactory::class),

View File

@@ -0,0 +1,50 @@
<?php
namespace MintyPHP\App\Module;
use MintyPHP\App\AppContainer;
/**
* Resolve module-declared classes from DI first and only fall back to direct
* instantiation when the class is not container-bound and has no required
* constructor arguments.
*/
final class ModuleClassResolver
{
public static function resolve(AppContainer $container, string $class): ?object
{
$class = trim($class);
if ($class === '') {
return null;
}
if ($container->has($class)) {
try {
$resolved = $container->get($class);
return is_object($resolved) ? $resolved : null;
} catch (\Throwable) {
return null;
}
}
if (!class_exists($class)) {
return null;
}
try {
$reflection = new \ReflectionClass($class);
if (!$reflection->isInstantiable()) {
return null;
}
$constructor = $reflection->getConstructor();
if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) {
return null;
}
return $reflection->newInstance();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -4,6 +4,8 @@ namespace MintyPHP\App\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Http\RequestContext;
use MintyPHP\Service\Audit\SystemAuditService;
/**
* Fire-and-forget event dispatcher for module lifecycle events.
@@ -19,7 +21,8 @@ final class ModuleEventDispatcher
*/
public function __construct(
private readonly array $listenerMap,
private readonly AppContainer $container
private readonly AppContainer $container,
private readonly ?SystemAuditService $systemAuditService = null
) {
}
@@ -31,19 +34,42 @@ final class ModuleEventDispatcher
$listeners = $this->listenerMap[$event] ?? [];
foreach ($listeners as $descriptor) {
$class = '';
$method = '';
try {
$class = $descriptor['class'];
$method = $descriptor['method'];
$listener = $this->container->get($class);
$listener = ModuleClassResolver::resolve($this->container, $class);
if (!$listener instanceof EventListener) {
$listener = new $class();
continue;
}
$listener->{$method}($event, $payload);
} catch (\Throwable) {
// Swallow — one failing listener must not break the chain.
} catch (\Throwable $exception) {
$this->recordListenerFailure($event, $class, $method, $exception);
}
}
}
private function recordListenerFailure(
string $event,
string $listenerClass,
string $listenerMethod,
\Throwable $exception
): void {
if ($this->systemAuditService === null) {
return;
}
$this->systemAuditService->record('module.listener.failed', 'failed', [
'metadata' => [
'module_event' => $event,
'listener_class' => $listenerClass,
'listener_method' => $listenerMethod,
'exception_class' => $exception::class,
'request_id' => RequestContext::id(),
],
]);
}
}

View File

@@ -12,6 +12,7 @@ use MintyPHP\App\Container\Registrars\SettingsRegistrar;
use MintyPHP\App\Container\Registrars\UserRegistrar;
use MintyPHP\App\Module\ModuleEventDispatcher;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Service\Audit\SystemAuditService;
$container = new AppContainer();
@@ -46,7 +47,8 @@ $moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules);
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
$container->set(ModuleEventDispatcher::class, static fn () => new ModuleEventDispatcher(
$moduleRegistry->getEventListeners(),
$container
$container,
$container->has(SystemAuditService::class) ? $container->get(SystemAuditService::class) : null
));
// Disallow overriding existing core bindings from module registrars.