| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406 |
- <?php
- declare(strict_types=1);
- namespace App\App;
- use App\Application\Admin\AllowlistController;
- use App\Application\Admin\AppSettingsController;
- use App\Application\Admin\AuditController;
- use App\Application\Admin\CategoriesController;
- use App\Application\Admin\ConfigController;
- use App\Application\Admin\ConsumersController;
- use App\Application\Admin\IpsController;
- use App\Application\Admin\JobsAdminController;
- use App\Application\Admin\MaintenanceController;
- use App\Application\Admin\ManualBlocksController;
- use App\Application\Admin\MeController;
- use App\Application\Admin\PoliciesController;
- use App\Application\Admin\ReportersController;
- use App\Application\Admin\StatsController;
- use App\Application\Admin\TokensController;
- use App\Application\Admin\UsersController;
- use App\Application\Auth\AuthController;
- use App\Application\Internal\JobsController;
- use App\Application\Public\BlocklistController;
- use App\Application\Public\DocsController;
- use App\Application\Public\ReportController;
- use App\Domain\Auth\Role;
- use App\Infrastructure\Http\JsonErrorHandler;
- use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
- use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
- use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
- use App\Infrastructure\Http\Middleware\InternalTokenMiddleware;
- use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
- use App\Infrastructure\Http\Middleware\RbacMiddleware;
- use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
- use Psr\Container\ContainerInterface;
- use Psr\Http\Message\ResponseFactoryInterface;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Http\Message\ServerRequestInterface;
- use Psr\Log\LoggerInterface;
- use Slim\App;
- use Slim\Factory\AppFactory as SlimAppFactory;
- use Slim\Routing\RouteCollectorProxy;
- /**
- * Builds the configured Slim app.
- *
- * Slim middleware is LIFO. To get "TokenAuth → Impersonation → Rbac" we
- * add them in reverse on each route group.
- *
- * Route groups in M04:
- * - Public /api/v1/report RateLimit(ip) → TokenAuth → AuditContext → RateLimit(token) → controller (kind check inside)
- * - Admin /api/v1/admin/* RateLimit(ip) → TokenAuth → Impersonation → AuditContext → RateLimit(token) → Rbac(role)
- * - Auth /api/v1/auth/* RateLimit(ip) → TokenAuth → AuditContext → RateLimit(token) → controller (kind=service inside)
- *
- * The two RateLimit positions (SEC_REVIEW F27, F29) draw on independent
- * `ip:` and `token:` buckets so an invalid-bearer-token flood is
- * throttled before TokenAuthenticationMiddleware ever queries the DB,
- * and an authenticated Viewer cannot drive pathological admin-side
- * queries (F30 IP-search full-table scan, F31 deep-offset audit,
- * F32 N+1 enrichment) without hitting the post-auth token bucket.
- */
- final class AppFactory
- {
- /**
- * @return App<ContainerInterface|null>
- */
- public static function build(ContainerInterface $container): App
- {
- SlimAppFactory::setContainer($container);
- $app = SlimAppFactory::create();
- $app->addRoutingMiddleware();
- $app->addBodyParsingMiddleware();
- /** @var array{app_env: string} $settings */
- $settings = $container->get('settings');
- $isDev = $settings['app_env'] === 'development';
- /** @var LoggerInterface $logger */
- $logger = $container->get(LoggerInterface::class);
- $errorMiddleware = $app->addErrorMiddleware($isDev, true, true, $logger);
- /** @var JsonErrorHandler $handler */
- $handler = $container->get(JsonErrorHandler::class);
- $errorMiddleware->setDefaultErrorHandler($handler);
- /** @var ResponseFactoryInterface $rf */
- $rf = $container->get(ResponseFactoryInterface::class);
- /** @var TokenAuthenticationMiddleware $tokenAuth */
- $tokenAuth = $container->get(TokenAuthenticationMiddleware::class);
- /** @var ImpersonationMiddleware $impersonation */
- $impersonation = $container->get(ImpersonationMiddleware::class);
- /** @var AuditContextMiddleware $auditContext */
- $auditContext = $container->get(AuditContextMiddleware::class);
- /** @var RateLimitMiddleware $rateLimit */
- $rateLimit = $container->get(RateLimitMiddleware::class);
- /** @var InternalNetworkMiddleware $internalNetwork */
- $internalNetwork = $container->get(InternalNetworkMiddleware::class);
- /** @var InternalTokenMiddleware $internalToken */
- $internalToken = $container->get(InternalTokenMiddleware::class);
- // Public docs (no auth, no rate limit). The viewer is HTML-only;
- // the spec is the YAML file shipped with the api image.
- /** @var DocsController $docs */
- $docs = $container->get(DocsController::class);
- $app->get('/api/v1/openapi.yaml', [$docs, 'spec']);
- $app->get('/api/docs', [$docs, 'viewer']);
- $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response) use ($container): ResponseInterface {
- /** @var array{driver: string} $dbSettings */
- $dbSettings = $container->get('settings.db');
- $dbConnected = false;
- try {
- /** @var \Doctrine\DBAL\Connection $conn */
- $conn = $container->get(\Doctrine\DBAL\Connection::class);
- $conn->executeQuery('SELECT 1')->fetchOne();
- $dbConnected = true;
- } catch (\Throwable) {
- $dbConnected = false;
- }
- /** @var array{enabled: bool, provider: string, country_db: string, asn_db: string, maxmind_license_key: string, ipinfo_token: string} $geoip */
- $geoip = $container->get('settings.geoip');
- $providerConfigured = match ($geoip['provider']) {
- 'maxmind' => $geoip['maxmind_license_key'] !== '',
- 'ipinfo' => $geoip['ipinfo_token'] !== '',
- default => true,
- };
- $countryPresent = is_file($geoip['country_db']) && is_readable($geoip['country_db']);
- $asnPresent = is_file($geoip['asn_db']) && is_readable($geoip['asn_db']);
- $countryMtime = $countryPresent
- ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['country_db']))
- : null;
- $asnMtime = $asnPresent
- ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['asn_db']))
- : null;
- $response->getBody()->write((string) json_encode([
- 'status' => 'ok',
- 'db' => [
- 'connected' => $dbConnected,
- 'driver' => $dbSettings['driver'],
- ],
- 'geoip' => [
- 'provider' => $geoip['provider'],
- 'provider_configured' => $providerConfigured,
- 'country_db_present' => $countryPresent,
- 'asn_db_present' => $asnPresent,
- 'country_db_modified' => $countryMtime,
- 'asn_db_modified' => $asnMtime,
- ],
- ]));
- return $response->withHeader('Content-Type', 'application/json');
- });
- // Auth API: service-token-only. No impersonation — these endpoints
- // exist to *produce* user_ids the ui can later impersonate. The
- // controller enforces kind=service on each call. Audit context is
- // attached so user.created / user.role_changed audit rows
- // (SEC_REVIEW F5) carry source IP and request id. Rate limit is
- // attached so a leaked service token cannot brute-force-enumerate
- // users via GET /users/{id} (SEC_REVIEW F14, capping the
- // enumeration speed flagged separately by F17).
- $app->group('/api/v1/auth', function (RouteCollectorProxy $auth) use ($container): void {
- /** @var AuthController $controller */
- $controller = $container->get(AuthController::class);
- $auth->post('/users/upsert-oidc', [$controller, 'upsertOidc']);
- $auth->post('/users/upsert-local', [$controller, 'upsertLocal']);
- $auth->get('/users/{id}', function (
- ServerRequestInterface $request,
- ResponseInterface $response,
- array $args,
- ) use ($controller): ResponseInterface {
- /** @var array{id: string} $args */
- return $controller->getUser($request, $response, $args['id']);
- });
- })
- ->add($rateLimit)
- ->add($auditContext)
- ->add($tokenAuth)
- ->add($rateLimit);
- // Public API: ingest endpoint. Auth → rate limit → controller. The
- // controller rejects non-reporter kinds itself (uniform 401 per
- // SPEC).
- $app->group('/api/v1', function (RouteCollectorProxy $public) use ($container): void {
- /** @var ReportController $report */
- $report = $container->get(ReportController::class);
- $public->post('/report', $report);
- /** @var BlocklistController $blocklist */
- $blocklist = $container->get(BlocklistController::class);
- $public->get('/blocklist', $blocklist);
- })
- ->add($rateLimit)
- ->add($auditContext)
- ->add($tokenAuth)
- ->add($rateLimit);
- // Admin API: token auth → impersonation → role check.
- $app->group('/api/v1/admin', function (RouteCollectorProxy $admin) use ($container, $rf): void {
- /** @var MeController $me */
- $me = $container->get(MeController::class);
- $admin->get('/me', $me)
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- /** @var ReportersController $reporters */
- $reporters = $container->get(ReportersController::class);
- $admin->group('/reporters', function (RouteCollectorProxy $r) use ($reporters): void {
- $r->get('', [$reporters, 'list']);
- $r->post('', [$reporters, 'create']);
- $r->get('/{id}', [$reporters, 'show']);
- $r->patch('/{id}', [$reporters, 'update']);
- $r->delete('/{id}', [$reporters, 'delete']);
- })->add(RbacMiddleware::require($rf, Role::Admin));
- /** @var ConsumersController $consumers */
- $consumers = $container->get(ConsumersController::class);
- $admin->group('/consumers', function (RouteCollectorProxy $r) use ($consumers): void {
- $r->get('', [$consumers, 'list']);
- $r->post('', [$consumers, 'create']);
- $r->get('/{id}', [$consumers, 'show']);
- $r->patch('/{id}', [$consumers, 'update']);
- $r->delete('/{id}', [$consumers, 'delete']);
- })->add(RbacMiddleware::require($rf, Role::Admin));
- /** @var TokensController $tokens */
- $tokens = $container->get(TokensController::class);
- $admin->group('/tokens', function (RouteCollectorProxy $r) use ($tokens): void {
- $r->get('', [$tokens, 'list']);
- $r->post('', [$tokens, 'create']);
- $r->delete('/{id}', [$tokens, 'delete']);
- $r->delete('/{id}/purge', [$tokens, 'purge']);
- })->add(RbacMiddleware::require($rf, Role::Admin));
- // Manual blocks: list/show require Viewer, create/delete require Operator.
- // Per-route middleware lets us split read vs write at one URL group.
- /** @var ManualBlocksController $manualBlocks */
- $manualBlocks = $container->get(ManualBlocksController::class);
- $admin->get('/manual-blocks', [$manualBlocks, 'list'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->get('/manual-blocks/{id}', [$manualBlocks, 'show'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->post('/manual-blocks', [$manualBlocks, 'create'])
- ->add(RbacMiddleware::require($rf, Role::Operator));
- $admin->delete('/manual-blocks/{id}', [$manualBlocks, 'delete'])
- ->add(RbacMiddleware::require($rf, Role::Operator));
- /** @var AllowlistController $allowlist */
- $allowlist = $container->get(AllowlistController::class);
- $admin->get('/allowlist', [$allowlist, 'list'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->get('/allowlist/{id}', [$allowlist, 'show'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->post('/allowlist', [$allowlist, 'create'])
- ->add(RbacMiddleware::require($rf, Role::Operator));
- $admin->delete('/allowlist/{id}', [$allowlist, 'delete'])
- ->add(RbacMiddleware::require($rf, Role::Operator));
- // IPs: list, detail, stats — all Viewer (read-only this milestone).
- /** @var IpsController $ips */
- $ips = $container->get(IpsController::class);
- $admin->get('/ips', [$ips, 'list'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- // /ips/countries must come BEFORE the {ip} route or it'd be
- // matched as an IP.
- $admin->get('/ips/countries', [$ips, 'countries'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- // SEC_REVIEW F43: pin to an IP-shaped charset rather than `.+`.
- // 0-9, a-f/A-F (IPv6 hex), `.` (IPv4), `:` (IPv6), and `%`
- // (so a UI `rawurlencode('2001:db8::1')` form like
- // `2001%3Adb8%3A%3A1` still matches before the controller's
- // `rawurldecode`). Anything else — `/`, `..`, `?`, spaces,
- // dashes — fails to match the route and 404s before the
- // handler can use the param as a filename / log key /
- // downstream URL component.
- $admin->get('/ips/{ip:[0-9a-fA-F.:%]+}', [$ips, 'show'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- /** @var StatsController $stats */
- $stats = $container->get(StatsController::class);
- $admin->get('/stats/dashboard', [$stats, 'dashboard'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- // Categories: list/show = Viewer; write = Admin (per SPEC §M10.1).
- /** @var CategoriesController $categories */
- $categories = $container->get(CategoriesController::class);
- $admin->get('/categories', [$categories, 'list'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->get('/categories/{id}', [$categories, 'show'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->post('/categories', [$categories, 'create'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- $admin->patch('/categories/{id}', [$categories, 'update'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- $admin->delete('/categories/{id}', [$categories, 'delete'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- // Users: Admin only — list + show + PATCH disable/enable.
- /** @var UsersController $users */
- $users = $container->get(UsersController::class);
- $admin->group('/users', function (RouteCollectorProxy $r) use ($users): void {
- $r->get('', [$users, 'list']);
- $r->get('/{id}', [$users, 'show']);
- $r->patch('/{id}', [$users, 'update']);
- })->add(RbacMiddleware::require($rf, Role::Admin));
- // Audit log: Viewer.
- /** @var AuditController $audit */
- $audit = $container->get(AuditController::class);
- $admin->get('/audit-log', [$audit, 'list'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- // Jobs admin (Viewer for status, Admin for trigger).
- /** @var JobsAdminController $jobsAdmin */
- $jobsAdmin = $container->get(JobsAdminController::class);
- $admin->get('/jobs/status', [$jobsAdmin, 'status'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->post('/jobs/trigger/{name}', [$jobsAdmin, 'trigger'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- // Effective config (secrets masked) — Admin only.
- /** @var ConfigController $config */
- $config = $container->get(ConfigController::class);
- $admin->get('/config', [$config, 'show'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- // Runtime feature flags (audit-emission toggles). Admin only.
- /** @var AppSettingsController $appSettings */
- $appSettings = $container->get(AppSettingsController::class);
- $admin->get('/app-settings', [$appSettings, 'show'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- $admin->patch('/app-settings', [$appSettings, 'update'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- // Demo / maintenance — Admin only. Both wipe and seed are
- // destructive in different directions; the UI guards each with a
- // confirmation modal and the purge body must include the literal
- // "PURGE" string.
- /** @var MaintenanceController $maintenance */
- $maintenance = $container->get(MaintenanceController::class);
- $admin->post('/maintenance/purge', [$maintenance, 'purge'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- $admin->post('/maintenance/seed-demo', [$maintenance, 'seedDemo'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- // Policies: list/show/preview = Viewer; write = Admin.
- /** @var PoliciesController $policies */
- $policies = $container->get(PoliciesController::class);
- $admin->get('/policies', [$policies, 'list'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->get('/policies/{id}', [$policies, 'show'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->get('/policies/{id}/preview', [$policies, 'preview'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->get('/policies/{id}/score-distribution', [$policies, 'scoreDistribution'])
- ->add(RbacMiddleware::require($rf, Role::Viewer));
- $admin->post('/policies', [$policies, 'create'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- $admin->patch('/policies/{id}', [$policies, 'update'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- $admin->delete('/policies/{id}', [$policies, 'delete'])
- ->add(RbacMiddleware::require($rf, Role::Admin));
- })
- ->add($rateLimit)
- ->add($auditContext)
- ->add($impersonation)
- ->add($tokenAuth)
- ->add($rateLimit);
- // Internal jobs API: scheduler-only. Network gate (404 outside
- // RFC1918) → token gate (401) → controller. Order matters:
- // network rejection must not leak through token-attempt logs.
- $app->group('/internal/jobs', function (RouteCollectorProxy $internal) use ($container): void {
- /** @var JobsController $jobs */
- $jobs = $container->get(JobsController::class);
- $internal->post('/recompute-scores', [$jobs, 'recomputeScores']);
- $internal->post('/cleanup-audit', [$jobs, 'cleanupAudit']);
- $internal->post('/cleanup-expired-manual-blocks', [$jobs, 'cleanupExpiredManualBlocks']);
- $internal->post('/enrich-pending', [$jobs, 'enrichPending']);
- $internal->post('/tick', [$jobs, 'tick']);
- $internal->post('/refresh-geoip', [$jobs, 'refreshGeoip']);
- $internal->get('/status', [$jobs, 'status']);
- })
- ->add($internalToken)
- ->add($internalNetwork);
- $app->map(
- ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
- '/{routes:.+}',
- function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
- $response->getBody()->write((string) json_encode(['error' => 'not_found']));
- return $response
- ->withHeader('Content-Type', 'application/json')
- ->withStatus(404);
- }
- );
- return $app;
- }
- }
|