diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Monolog/Processor/RequestContextProcessor.php b/src/Surfnet/StepupMiddleware/ApiBundle/Monolog/Processor/RequestContextProcessor.php new file mode 100644 index 00000000..5a5e0573 --- /dev/null +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Monolog/Processor/RequestContextProcessor.php @@ -0,0 +1,280 @@ +requestStack->getCurrentRequest(); + + if (!$request instanceof Request) { + return $record; + } + + $context = $this->findRequestContext($request); + + foreach ($context as $key => $value) { + $record->extra[$key] = $value; + } + + return $record; + } + + /** + * @return array + */ + private function findRequestContext(Request $request): array + { + if ($request->attributes->has(self::CACHE_ATTRIBUTE)) { + /** @var array */ + return $request->attributes->get(self::CACHE_ATTRIBUTE); + } + + $context = $this->extractRequestContext($request); + $request->attributes->set(self::CACHE_ATTRIBUTE, $context); + + return $context; + } + + /** + * @return array + */ + private function extractRequestContext(Request $request): array + { + $jsonData = $this->parseJsonContent($request->getContent()); + $sources = $this->collectSources($request, $jsonData); + + $fields = [ + 'identity_id' => $this->extractIdentityId($request, $sources, $jsonData), + 'collab_person_id' => $this->extractFromSources($sources, ['collabPersonId', 'collab_person_id']), + 'second_factor_id' => $this->extractSecondFactorId($request, $sources), + 'recovery_token_id' => $this->extractRecoveryTokenId($request, $sources), + 'name_id' => $this->extractFromSources($sources, ['nameId', 'name_id', 'NameID']), + 'institution' => $this->extractInstitution($sources, $jsonData), + ]; + + return array_filter($fields, static fn (?string $value): bool => $value !== null); + } + + /** + * @return array|null + */ + private function parseJsonContent(string $content): ?array + { + if ($content === '') { + return null; + } + + $decoded = json_decode($content, true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array|null $jsonData + * @return list> + */ + private function collectSources(Request $request, ?array $jsonData): array + { + $sources = [ + $request->attributes->all(), + $request->query->all(), + $request->request->all(), + ]; + + if ($jsonData === null) { + return $sources; + } + + $sources[] = $jsonData; + + $command = $jsonData['command'] ?? null; + if (is_array($command) && isset($command['payload']) && is_array($command['payload'])) { + $sources[] = $command['payload']; + } + + $payload = $jsonData['payload'] ?? null; + if (is_array($payload)) { + $sources[] = $payload; + } + + return $sources; + } + + /** + * @param list> $sources + * @param array|null $jsonData + */ + private function extractIdentityId(Request $request, array $sources, ?array $jsonData): ?string + { + return $this->extractRouteId($request, ['identity']) + ?? $this->extractFromSources($sources, ['identityId', 'identity_id']) + ?? $this->extractIdentityCommandId($jsonData); + } + + /** + * @param list $allowedRoutes + */ + private function extractRouteId(Request $request, array $allowedRoutes): ?string + { + $route = $request->attributes->get('_route'); + if (is_string($route) && in_array($route, $allowedRoutes, true)) { + return $this->stringify($request->attributes->get('id')); + } + + return null; + } + + /** + * @param array|null $jsonData + */ + private function extractIdentityCommandId(?array $jsonData): ?string + { + if ($jsonData === null) { + return null; + } + + $command = $jsonData['command'] ?? null; + if (!is_array($command)) { + return null; + } + + $commandName = $command['name'] ?? null; + if (in_array($commandName, ['Identity:CreateIdentity', 'Identity:UpdateIdentity'], true)) { + $payload = $command['payload'] ?? null; + if (is_array($payload)) { + return $this->stringify($payload['id'] ?? null); + } + } + + return null; + } + + /** + * @param list> $sources + */ + private function extractSecondFactorId(Request $request, array $sources): ?string + { + return $this->extractRouteId($request, self::SECOND_FACTOR_ROUTES) + ?? $this->extractFromSources( + $sources, + ['secondFactorId', 'second_factor_id', 'authoringSecondFactorId', 'authoring_second_factor_id'], + ); + } + + /** + * @param list> $sources + */ + private function extractRecoveryTokenId(Request $request, array $sources): ?string + { + return $this->extractRouteId($request, ['recovery_token']) + ?? $this->extractFromSources( + $sources, + ['recoveryTokenIdId', 'recoveryTokenId', 'recovery_token_id', 'recovery_token_id_id'], + ); + } + + /** + * @param list> $sources + * @param array|null $jsonData + */ + private function extractInstitution(array $sources, ?array $jsonData): ?string + { + return $this->extractFromSources( + $sources, + ['institution', 'institutionName', 'institution_name', 'raInstitution', 'ra_institution'], + ) ?? $this->extractMetaInstitution($jsonData); + } + + /** + * @param array|null $jsonData + */ + private function extractMetaInstitution(?array $jsonData): ?string + { + if ($jsonData === null) { + return null; + } + + $meta = $jsonData['meta'] ?? null; + if (!is_array($meta)) { + return null; + } + + return $this->extractFromSources([$meta], ['actor_institution', 'actorInstitution', 'institution']); + } + + /** + * @param list> $sources + * @param list $keys + */ + private function extractFromSources(array $sources, array $keys): ?string + { + foreach ($sources as $source) { + foreach ($keys as $key) { + if (isset($source[$key])) { + $val = $this->stringify($source[$key]); + if ($val !== null) { + return $val; + } + } + } + } + + return null; + } + + private function stringify(mixed $value): ?string + { + if (is_scalar($value) || $value instanceof Stringable) { + $trimmed = trim((string) $value); + + return $trimmed !== '' ? $trimmed : null; + } + + return null; + } +} diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Resources/config/services.yml b/src/Surfnet/StepupMiddleware/ApiBundle/Resources/config/services.yml index 2cdb2765..7042c4fe 100644 --- a/src/Surfnet/StepupMiddleware/ApiBundle/Resources/config/services.yml +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Resources/config/services.yml @@ -136,6 +136,16 @@ services: tags: - { name: kernel.event_listener, event: kernel.exception } + # Monolog Processors + surfnet_stepup_middleware_api.monolog.request_context_processor: + class: Surfnet\StepupMiddleware\ApiBundle\Monolog\Processor\RequestContextProcessor + arguments: + - "@request_stack" + tags: + - { name: monolog.processor } + Surfnet\StepupMiddleware\ApiBundle\Monolog\Processor\RequestContextProcessor: + alias: surfnet_stepup_middleware_api.monolog.request_context_processor + # Security Entry Point surfnet_stepup_middleware_api.security.json_basic_auth_entry_point: class: Surfnet\StepupMiddleware\ApiBundle\Security\Http\EntryPoint\JsonBasicAuthenticationEntryPoint diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Monolog/Processor/RequestContextProcessorTest.php b/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Monolog/Processor/RequestContextProcessorTest.php new file mode 100644 index 00000000..7cc9f283 --- /dev/null +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Tests/Monolog/Processor/RequestContextProcessorTest.php @@ -0,0 +1,501 @@ + $extra + */ + private function createLogRecord(array $extra = []): LogRecord + { + return new LogRecord( + datetime: new DateTimeImmutable(), + channel: 'app', + level: Level::Info, + message: 'Test log message', + context: [], + extra: $extra, + ); + } + + #[Test] + #[Group('api-bundle')] + public function it_returns_record_unmodified_when_no_request_is_active(): void + { + $requestStack = new RequestStack(); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(['existing' => 'data']); + $processed = $processor($record); + + $this->assertSame(['existing' => 'data'], $processed->extra); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_identity_id_from_route_attributes(): void + { + $request = new Request(); + $request->attributes->set('identityId', 'abc-123-route'); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-route', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_snake_case_identity_id_from_route_attributes(): void + { + $request = new Request(); + $request->attributes->set('identity_id', 'abc-123-snake-route'); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-snake-route', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_identity_id_from_identity_route_with_id_attribute(): void + { + $request = new Request(); + $request->attributes->set('_route', 'identity'); + $request->attributes->set('id', 'abc-123-identity-get'); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-identity-get', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_identity_id_from_query_parameters(): void + { + $request = new Request(query: ['identityId' => 'abc-123-query']); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-query', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_snake_case_identity_id_from_query_parameters(): void + { + $request = new Request(query: ['identity_id' => 'abc-123-snake-query']); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-snake-query', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_identity_id_from_request_body_post_parameters(): void + { + $request = new Request(request: ['identityId' => 'abc-123-body']); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-body', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_identity_id_from_command_json_payload(): void + { + $payload = [ + 'meta' => ['actor_id' => 'actor-uuid'], + 'command' => [ + 'name' => 'Identity:VetSecondFactor', + 'uuid' => 'command-uuid', + 'payload' => [ + 'identityId' => 'abc-123-command-payload', + ], + ], + ]; + + $request = new Request( + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode($payload, JSON_THROW_ON_ERROR), + ); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-command-payload', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_snake_case_identity_id_from_command_json_payload(): void + { + $payload = [ + 'meta' => ['actor_id' => 'actor-uuid'], + 'command' => [ + 'name' => 'Identity:VetSecondFactor', + 'uuid' => 'command-uuid', + 'payload' => [ + 'identity_id' => 'abc-123-command-snake-payload', + ], + ], + ]; + + $request = new Request( + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode($payload, JSON_THROW_ON_ERROR), + ); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-command-snake-payload', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_id_from_create_identity_command_json_payload(): void + { + $payload = [ + 'meta' => ['actor_id' => 'actor-uuid'], + 'command' => [ + 'name' => 'Identity:CreateIdentity', + 'uuid' => 'command-uuid', + 'payload' => [ + 'id' => 'abc-123-created-identity', + ], + ], + ]; + + $request = new Request( + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode($payload, JSON_THROW_ON_ERROR), + ); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-created-identity', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_identity_id_from_root_json_payload(): void + { + $payload = [ + 'identityId' => 'abc-123-root-json', + ]; + + $request = new Request( + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode($payload, JSON_THROW_ON_ERROR), + ); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('identity_id', $processed->extra); + $this->assertSame('abc-123-root-json', $processed->extra['identity_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_collab_person_id_from_route_and_query(): void + { + $request = new Request(); + $request->attributes->set('_route', 'deprovision'); + $request->attributes->set('collabPersonId', 'urn:collab:person:surfnet.nl:jdoe'); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('collab_person_id', $processed->extra); + $this->assertSame('urn:collab:person:surfnet.nl:jdoe', $processed->extra['collab_person_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_second_factor_id_from_route_id_query_and_payload(): void + { + $request = new Request(); + $request->attributes->set('_route', 'verified_second_factor'); + $request->attributes->set('id', 'sf-uuid-123'); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('second_factor_id', $processed->extra); + $this->assertSame('sf-uuid-123', $processed->extra['second_factor_id']); + + $payload = [ + 'command' => [ + 'name' => 'Identity:VetSecondFactor', + 'uuid' => 'cmd-1', + 'payload' => [ + 'secondFactorId' => 'sf-payload-456', + ], + ], + ]; + $request2 = new Request( + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode($payload, JSON_THROW_ON_ERROR), + ); + $requestStack2 = new RequestStack(); + $requestStack2->push($request2); + $processor2 = new RequestContextProcessor($requestStack2); + $processed2 = $processor2($this->createLogRecord()); + $this->assertSame('sf-payload-456', $processed2->extra['second_factor_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_recovery_token_id_from_route_id_and_recovery_token_id_id(): void + { + $request = new Request(); + $request->attributes->set('_route', 'recovery_token'); + $request->attributes->set('id', 'recovery-token-uuid-1'); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('recovery_token_id', $processed->extra); + $this->assertSame('recovery-token-uuid-1', $processed->extra['recovery_token_id']); + + $request2 = new Request(query: ['recoveryTokenIdId' => 'recovery-token-uuid-2']); + $requestStack2 = new RequestStack(); + $requestStack2->push($request2); + $processor2 = new RequestContextProcessor($requestStack2); + $processed2 = $processor2($this->createLogRecord()); + $this->assertSame('recovery-token-uuid-2', $processed2->extra['recovery_token_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_name_id_from_route_query_and_payload(): void + { + $request = new Request(); + $request->attributes->set('_route', 'identity_sraa_get'); + $request->attributes->set('nameId', 'urn:collab:person:surfnet.nl:nameid1'); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('name_id', $processed->extra); + $this->assertSame('urn:collab:person:surfnet.nl:nameid1', $processed->extra['name_id']); + + $request2 = new Request(query: ['NameID' => 'query-name-id']); + $requestStack2 = new RequestStack(); + $requestStack2->push($request2); + $processor2 = new RequestContextProcessor($requestStack2); + $processed2 = $processor2($this->createLogRecord()); + $this->assertSame('query-name-id', $processed2->extra['name_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_institution_from_query_route_and_meta(): void + { + $request = new Request(query: ['institution' => 'SURFnet']); + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayHasKey('institution', $processed->extra); + $this->assertSame('SURFnet', $processed->extra['institution']); + + $payload = [ + 'meta' => [ + 'actor_institution' => 'Institution-From-Meta', + ], + 'command' => [ + 'name' => 'Identity:SomeCommand', + 'uuid' => 'cmd-1', + 'payload' => [], + ], + ]; + $request2 = new Request( + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode($payload, JSON_THROW_ON_ERROR), + ); + $requestStack2 = new RequestStack(); + $requestStack2->push($request2); + $processor2 = new RequestContextProcessor($requestStack2); + $processed2 = $processor2($this->createLogRecord()); + $this->assertSame('Institution-From-Meta', $processed2->extra['institution']); + } + + #[Test] + #[Group('api-bundle')] + public function it_extracts_multiple_context_fields_simultaneously(): void + { + $payload = [ + 'meta' => [ + 'actor_institution' => 'SURFnet', + ], + 'command' => [ + 'name' => 'Identity:CreateIdentity', + 'uuid' => 'cmd-uuid', + 'payload' => [ + 'id' => 'identity-uuid-1', + 'name_id' => 'name-id-1', + 'institution' => 'SURFnet-Payload', + ], + ], + ]; + + $request = new Request( + query: ['secondFactorId' => 'sf-uuid-9'], + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode($payload, JSON_THROW_ON_ERROR), + ); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertSame('identity-uuid-1', $processed->extra['identity_id']); + $this->assertSame('name-id-1', $processed->extra['name_id']); + $this->assertSame('SURFnet-Payload', $processed->extra['institution']); + $this->assertSame('sf-uuid-9', $processed->extra['second_factor_id']); + } + + #[Test] + #[Group('api-bundle')] + public function it_does_not_modify_record_when_no_context_is_present(): void + { + $request = new Request(query: ['unrelated' => 'value']); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(['existing' => 'val']); + $processed = $processor($record); + + $this->assertSame(['existing' => 'val'], $processed->extra); + } + + #[Test] + #[Group('api-bundle')] + public function it_ignores_empty_identity_id(): void + { + $request = new Request(query: ['identityId' => ' ']); + + $requestStack = new RequestStack(); + $requestStack->push($request); + $processor = new RequestContextProcessor($requestStack); + + $record = $this->createLogRecord(); + $processed = $processor($record); + + $this->assertArrayNotHasKey('identity_id', $processed->extra); + } +}