AppFactory.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\App;
  4. use App\Application\Admin\AllowlistController;
  5. use App\Application\Admin\AuditController;
  6. use App\Application\Admin\CategoriesController;
  7. use App\Application\Admin\ConfigController;
  8. use App\Application\Admin\ConsumersController;
  9. use App\Application\Admin\IpsController;
  10. use App\Application\Admin\JobsAdminController;
  11. use App\Application\Admin\MaintenanceController;
  12. use App\Application\Admin\ManualBlocksController;
  13. use App\Application\Admin\MeController;
  14. use App\Application\Admin\PoliciesController;
  15. use App\Application\Admin\ReportersController;
  16. use App\Application\Admin\StatsController;
  17. use App\Application\Admin\TokensController;
  18. use App\Application\Auth\AuthController;
  19. use App\Application\Internal\JobsController;
  20. use App\Application\Public\BlocklistController;
  21. use App\Application\Public\DocsController;
  22. use App\Application\Public\ReportController;
  23. use App\Domain\Auth\Role;
  24. use App\Infrastructure\Http\JsonErrorHandler;
  25. use App\Infrastructure\Http\Middleware\AuditContextMiddleware;
  26. use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
  27. use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
  28. use App\Infrastructure\Http\Middleware\InternalTokenMiddleware;
  29. use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
  30. use App\Infrastructure\Http\Middleware\RbacMiddleware;
  31. use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
  32. use Psr\Container\ContainerInterface;
  33. use Psr\Http\Message\ResponseFactoryInterface;
  34. use Psr\Http\Message\ResponseInterface;
  35. use Psr\Http\Message\ServerRequestInterface;
  36. use Psr\Log\LoggerInterface;
  37. use Slim\App;
  38. use Slim\Factory\AppFactory as SlimAppFactory;
  39. use Slim\Routing\RouteCollectorProxy;
  40. /**
  41. * Builds the configured Slim app.
  42. *
  43. * Slim middleware is LIFO. To get "TokenAuth → Impersonation → Rbac" we
  44. * add them in reverse on each route group.
  45. *
  46. * Route groups in M04:
  47. * - Public /api/v1/report TokenAuth → RateLimit → controller (kind check inside)
  48. * - Admin /api/v1/admin/{reporters,consumers,tokens} TokenAuth → Impersonation → Rbac(Admin)
  49. * - Admin /api/v1/admin/me TokenAuth → Impersonation → Rbac(Viewer)
  50. * - Auth /api/v1/auth/* TokenAuth (controller checks kind=service)
  51. */
  52. final class AppFactory
  53. {
  54. /**
  55. * @return App<ContainerInterface|null>
  56. */
  57. public static function build(ContainerInterface $container): App
  58. {
  59. SlimAppFactory::setContainer($container);
  60. $app = SlimAppFactory::create();
  61. $app->addRoutingMiddleware();
  62. $app->addBodyParsingMiddleware();
  63. /** @var array{app_env: string} $settings */
  64. $settings = $container->get('settings');
  65. $isDev = $settings['app_env'] === 'development';
  66. /** @var LoggerInterface $logger */
  67. $logger = $container->get(LoggerInterface::class);
  68. $errorMiddleware = $app->addErrorMiddleware($isDev, true, true, $logger);
  69. /** @var JsonErrorHandler $handler */
  70. $handler = $container->get(JsonErrorHandler::class);
  71. $errorMiddleware->setDefaultErrorHandler($handler);
  72. /** @var ResponseFactoryInterface $rf */
  73. $rf = $container->get(ResponseFactoryInterface::class);
  74. /** @var TokenAuthenticationMiddleware $tokenAuth */
  75. $tokenAuth = $container->get(TokenAuthenticationMiddleware::class);
  76. /** @var ImpersonationMiddleware $impersonation */
  77. $impersonation = $container->get(ImpersonationMiddleware::class);
  78. /** @var AuditContextMiddleware $auditContext */
  79. $auditContext = $container->get(AuditContextMiddleware::class);
  80. /** @var RateLimitMiddleware $rateLimit */
  81. $rateLimit = $container->get(RateLimitMiddleware::class);
  82. /** @var InternalNetworkMiddleware $internalNetwork */
  83. $internalNetwork = $container->get(InternalNetworkMiddleware::class);
  84. /** @var InternalTokenMiddleware $internalToken */
  85. $internalToken = $container->get(InternalTokenMiddleware::class);
  86. // Public docs (no auth, no rate limit). The viewer is HTML-only;
  87. // the spec is the YAML file shipped with the api image.
  88. /** @var DocsController $docs */
  89. $docs = $container->get(DocsController::class);
  90. $app->get('/api/v1/openapi.yaml', [$docs, 'spec']);
  91. $app->get('/api/docs', [$docs, 'viewer']);
  92. $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response) use ($container): ResponseInterface {
  93. /** @var array{driver: string} $dbSettings */
  94. $dbSettings = $container->get('settings.db');
  95. $dbConnected = false;
  96. try {
  97. /** @var \Doctrine\DBAL\Connection $conn */
  98. $conn = $container->get(\Doctrine\DBAL\Connection::class);
  99. $conn->executeQuery('SELECT 1')->fetchOne();
  100. $dbConnected = true;
  101. } catch (\Throwable) {
  102. $dbConnected = false;
  103. }
  104. /** @var array{enabled: bool, provider: string, country_db: string, asn_db: string, maxmind_license_key: string, ipinfo_token: string} $geoip */
  105. $geoip = $container->get('settings.geoip');
  106. $providerConfigured = match ($geoip['provider']) {
  107. 'maxmind' => $geoip['maxmind_license_key'] !== '',
  108. 'ipinfo' => $geoip['ipinfo_token'] !== '',
  109. default => true,
  110. };
  111. $countryPresent = is_file($geoip['country_db']) && is_readable($geoip['country_db']);
  112. $asnPresent = is_file($geoip['asn_db']) && is_readable($geoip['asn_db']);
  113. $countryMtime = $countryPresent
  114. ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['country_db']))
  115. : null;
  116. $asnMtime = $asnPresent
  117. ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['asn_db']))
  118. : null;
  119. $response->getBody()->write((string) json_encode([
  120. 'status' => 'ok',
  121. 'db' => [
  122. 'connected' => $dbConnected,
  123. 'driver' => $dbSettings['driver'],
  124. ],
  125. 'geoip' => [
  126. 'provider' => $geoip['provider'],
  127. 'provider_configured' => $providerConfigured,
  128. 'country_db_present' => $countryPresent,
  129. 'asn_db_present' => $asnPresent,
  130. 'country_db_modified' => $countryMtime,
  131. 'asn_db_modified' => $asnMtime,
  132. ],
  133. ]));
  134. return $response->withHeader('Content-Type', 'application/json');
  135. });
  136. // Auth API: service-token-only. No impersonation — these endpoints
  137. // exist to *produce* user_ids the ui can later impersonate. The
  138. // controller enforces kind=service on each call.
  139. $app->group('/api/v1/auth', function (RouteCollectorProxy $auth) use ($container): void {
  140. /** @var AuthController $controller */
  141. $controller = $container->get(AuthController::class);
  142. $auth->post('/users/upsert-oidc', [$controller, 'upsertOidc']);
  143. $auth->post('/users/upsert-local', [$controller, 'upsertLocal']);
  144. $auth->get('/users/{id}', function (
  145. ServerRequestInterface $request,
  146. ResponseInterface $response,
  147. array $args,
  148. ) use ($controller): ResponseInterface {
  149. /** @var array{id: string} $args */
  150. return $controller->getUser($request, $response, $args['id']);
  151. });
  152. })->add($tokenAuth);
  153. // Public API: ingest endpoint. Auth → rate limit → controller. The
  154. // controller rejects non-reporter kinds itself (uniform 401 per
  155. // SPEC).
  156. $app->group('/api/v1', function (RouteCollectorProxy $public) use ($container): void {
  157. /** @var ReportController $report */
  158. $report = $container->get(ReportController::class);
  159. $public->post('/report', $report);
  160. /** @var BlocklistController $blocklist */
  161. $blocklist = $container->get(BlocklistController::class);
  162. $public->get('/blocklist', $blocklist);
  163. })
  164. ->add($rateLimit)
  165. ->add($tokenAuth);
  166. // Admin API: token auth → impersonation → role check.
  167. $app->group('/api/v1/admin', function (RouteCollectorProxy $admin) use ($container, $rf): void {
  168. /** @var MeController $me */
  169. $me = $container->get(MeController::class);
  170. $admin->get('/me', $me)
  171. ->add(RbacMiddleware::require($rf, Role::Viewer));
  172. /** @var ReportersController $reporters */
  173. $reporters = $container->get(ReportersController::class);
  174. $admin->group('/reporters', function (RouteCollectorProxy $r) use ($reporters): void {
  175. $r->get('', [$reporters, 'list']);
  176. $r->post('', [$reporters, 'create']);
  177. $r->get('/{id}', [$reporters, 'show']);
  178. $r->patch('/{id}', [$reporters, 'update']);
  179. $r->delete('/{id}', [$reporters, 'delete']);
  180. })->add(RbacMiddleware::require($rf, Role::Admin));
  181. /** @var ConsumersController $consumers */
  182. $consumers = $container->get(ConsumersController::class);
  183. $admin->group('/consumers', function (RouteCollectorProxy $r) use ($consumers): void {
  184. $r->get('', [$consumers, 'list']);
  185. $r->post('', [$consumers, 'create']);
  186. $r->get('/{id}', [$consumers, 'show']);
  187. $r->patch('/{id}', [$consumers, 'update']);
  188. $r->delete('/{id}', [$consumers, 'delete']);
  189. })->add(RbacMiddleware::require($rf, Role::Admin));
  190. /** @var TokensController $tokens */
  191. $tokens = $container->get(TokensController::class);
  192. $admin->group('/tokens', function (RouteCollectorProxy $r) use ($tokens): void {
  193. $r->get('', [$tokens, 'list']);
  194. $r->post('', [$tokens, 'create']);
  195. $r->delete('/{id}', [$tokens, 'delete']);
  196. })->add(RbacMiddleware::require($rf, Role::Admin));
  197. // Manual blocks: list/show require Viewer, create/delete require Operator.
  198. // Per-route middleware lets us split read vs write at one URL group.
  199. /** @var ManualBlocksController $manualBlocks */
  200. $manualBlocks = $container->get(ManualBlocksController::class);
  201. $admin->get('/manual-blocks', [$manualBlocks, 'list'])
  202. ->add(RbacMiddleware::require($rf, Role::Viewer));
  203. $admin->get('/manual-blocks/{id}', [$manualBlocks, 'show'])
  204. ->add(RbacMiddleware::require($rf, Role::Viewer));
  205. $admin->post('/manual-blocks', [$manualBlocks, 'create'])
  206. ->add(RbacMiddleware::require($rf, Role::Operator));
  207. $admin->delete('/manual-blocks/{id}', [$manualBlocks, 'delete'])
  208. ->add(RbacMiddleware::require($rf, Role::Operator));
  209. /** @var AllowlistController $allowlist */
  210. $allowlist = $container->get(AllowlistController::class);
  211. $admin->get('/allowlist', [$allowlist, 'list'])
  212. ->add(RbacMiddleware::require($rf, Role::Viewer));
  213. $admin->get('/allowlist/{id}', [$allowlist, 'show'])
  214. ->add(RbacMiddleware::require($rf, Role::Viewer));
  215. $admin->post('/allowlist', [$allowlist, 'create'])
  216. ->add(RbacMiddleware::require($rf, Role::Operator));
  217. $admin->delete('/allowlist/{id}', [$allowlist, 'delete'])
  218. ->add(RbacMiddleware::require($rf, Role::Operator));
  219. // IPs: list, detail, stats — all Viewer (read-only this milestone).
  220. /** @var IpsController $ips */
  221. $ips = $container->get(IpsController::class);
  222. $admin->get('/ips', [$ips, 'list'])
  223. ->add(RbacMiddleware::require($rf, Role::Viewer));
  224. // /ips/countries must come BEFORE /ips/{ip:.+} or it'd be
  225. // matched as an IP.
  226. $admin->get('/ips/countries', [$ips, 'countries'])
  227. ->add(RbacMiddleware::require($rf, Role::Viewer));
  228. $admin->get('/ips/{ip:.+}', [$ips, 'show'])
  229. ->add(RbacMiddleware::require($rf, Role::Viewer));
  230. /** @var StatsController $stats */
  231. $stats = $container->get(StatsController::class);
  232. $admin->get('/stats/dashboard', [$stats, 'dashboard'])
  233. ->add(RbacMiddleware::require($rf, Role::Viewer));
  234. // Categories: list/show = Viewer; write = Admin (per SPEC §M10.1).
  235. /** @var CategoriesController $categories */
  236. $categories = $container->get(CategoriesController::class);
  237. $admin->get('/categories', [$categories, 'list'])
  238. ->add(RbacMiddleware::require($rf, Role::Viewer));
  239. $admin->get('/categories/{id}', [$categories, 'show'])
  240. ->add(RbacMiddleware::require($rf, Role::Viewer));
  241. $admin->post('/categories', [$categories, 'create'])
  242. ->add(RbacMiddleware::require($rf, Role::Admin));
  243. $admin->patch('/categories/{id}', [$categories, 'update'])
  244. ->add(RbacMiddleware::require($rf, Role::Admin));
  245. $admin->delete('/categories/{id}', [$categories, 'delete'])
  246. ->add(RbacMiddleware::require($rf, Role::Admin));
  247. // Audit log: Viewer.
  248. /** @var AuditController $audit */
  249. $audit = $container->get(AuditController::class);
  250. $admin->get('/audit-log', [$audit, 'list'])
  251. ->add(RbacMiddleware::require($rf, Role::Viewer));
  252. // Jobs admin (Viewer for status, Admin for trigger).
  253. /** @var JobsAdminController $jobsAdmin */
  254. $jobsAdmin = $container->get(JobsAdminController::class);
  255. $admin->get('/jobs/status', [$jobsAdmin, 'status'])
  256. ->add(RbacMiddleware::require($rf, Role::Viewer));
  257. $admin->post('/jobs/trigger/{name}', [$jobsAdmin, 'trigger'])
  258. ->add(RbacMiddleware::require($rf, Role::Admin));
  259. // Effective config (secrets masked) — Admin only.
  260. /** @var ConfigController $config */
  261. $config = $container->get(ConfigController::class);
  262. $admin->get('/config', [$config, 'show'])
  263. ->add(RbacMiddleware::require($rf, Role::Admin));
  264. // Demo / maintenance — Admin only. Both wipe and seed are
  265. // destructive in different directions; the UI guards each with a
  266. // confirmation modal and the purge body must include the literal
  267. // "PURGE" string.
  268. /** @var MaintenanceController $maintenance */
  269. $maintenance = $container->get(MaintenanceController::class);
  270. $admin->post('/maintenance/purge', [$maintenance, 'purge'])
  271. ->add(RbacMiddleware::require($rf, Role::Admin));
  272. $admin->post('/maintenance/seed-demo', [$maintenance, 'seedDemo'])
  273. ->add(RbacMiddleware::require($rf, Role::Admin));
  274. // Policies: list/show/preview = Viewer; write = Admin.
  275. /** @var PoliciesController $policies */
  276. $policies = $container->get(PoliciesController::class);
  277. $admin->get('/policies', [$policies, 'list'])
  278. ->add(RbacMiddleware::require($rf, Role::Viewer));
  279. $admin->get('/policies/{id}', [$policies, 'show'])
  280. ->add(RbacMiddleware::require($rf, Role::Viewer));
  281. $admin->get('/policies/{id}/preview', [$policies, 'preview'])
  282. ->add(RbacMiddleware::require($rf, Role::Viewer));
  283. $admin->post('/policies', [$policies, 'create'])
  284. ->add(RbacMiddleware::require($rf, Role::Admin));
  285. $admin->patch('/policies/{id}', [$policies, 'update'])
  286. ->add(RbacMiddleware::require($rf, Role::Admin));
  287. $admin->delete('/policies/{id}', [$policies, 'delete'])
  288. ->add(RbacMiddleware::require($rf, Role::Admin));
  289. })
  290. ->add($auditContext)
  291. ->add($impersonation)
  292. ->add($tokenAuth);
  293. // Internal jobs API: scheduler-only. Network gate (404 outside
  294. // RFC1918) → token gate (401) → controller. Order matters:
  295. // network rejection must not leak through token-attempt logs.
  296. $app->group('/internal/jobs', function (RouteCollectorProxy $internal) use ($container): void {
  297. /** @var JobsController $jobs */
  298. $jobs = $container->get(JobsController::class);
  299. $internal->post('/recompute-scores', [$jobs, 'recomputeScores']);
  300. $internal->post('/cleanup-audit', [$jobs, 'cleanupAudit']);
  301. $internal->post('/cleanup-expired-manual-blocks', [$jobs, 'cleanupExpiredManualBlocks']);
  302. $internal->post('/enrich-pending', [$jobs, 'enrichPending']);
  303. $internal->post('/tick', [$jobs, 'tick']);
  304. $internal->post('/refresh-geoip', [$jobs, 'refreshGeoip']);
  305. $internal->get('/status', [$jobs, 'status']);
  306. })
  307. ->add($internalToken)
  308. ->add($internalNetwork);
  309. $app->map(
  310. ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  311. '/{routes:.+}',
  312. function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
  313. $response->getBody()->write((string) json_encode(['error' => 'not_found']));
  314. return $response
  315. ->withHeader('Content-Type', 'application/json')
  316. ->withStatus(404);
  317. }
  318. );
  319. return $app;
  320. }
  321. }