| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- <?php
- declare(strict_types=1);
- namespace App\App;
- use App\Application\Admin\AllowlistController;
- use App\Application\Admin\ConsumersController;
- use App\Application\Admin\IpsController;
- 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\Auth\AuthController;
- use App\Application\Internal\JobsController;
- use App\Application\Public\BlocklistController;
- use App\Application\Public\ReportController;
- use App\Domain\Auth\Role;
- use App\Infrastructure\Http\JsonErrorHandler;
- 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 TokenAuth → RateLimit → controller (kind check inside)
- * - Admin /api/v1/admin/{reporters,consumers,tokens} TokenAuth → Impersonation → Rbac(Admin)
- * - Admin /api/v1/admin/me TokenAuth → Impersonation → Rbac(Viewer)
- * - Auth /api/v1/auth/* TokenAuth (controller checks kind=service)
- */
- 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 RateLimitMiddleware $rateLimit */
- $rateLimit = $container->get(RateLimitMiddleware::class);
- /** @var InternalNetworkMiddleware $internalNetwork */
- $internalNetwork = $container->get(InternalNetworkMiddleware::class);
- /** @var InternalTokenMiddleware $internalToken */
- $internalToken = $container->get(InternalTokenMiddleware::class);
- $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
- $response->getBody()->write((string) json_encode(['status' => 'ok']));
- 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.
- $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($tokenAuth);
- // 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($tokenAuth);
- // 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']);
- })->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));
- $admin->get('/ips/{ip:.+}', [$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));
- // 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->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($impersonation)
- ->add($tokenAuth);
- // 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('/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;
- }
- }
|