1
0

AppFactory.php 20 KB

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