Repository navigation
Enrich log records with request context parameters #641
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,280 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types = 1); | ||
|
|
||
| /** | ||
| * Copyright 2026 SURFnet bv | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| namespace Surfnet\StepupMiddleware\ApiBundle\Monolog\Processor; | ||
|
|
||
| use Monolog\LogRecord; | ||
| use Stringable; | ||
| use Symfony\Component\HttpFoundation\Request; | ||
| use Symfony\Component\HttpFoundation\RequestStack; | ||
| use function array_filter; | ||
| use function in_array; | ||
| use function is_array; | ||
| use function is_scalar; | ||
| use function is_string; | ||
| use function json_decode; | ||
| use function trim; | ||
|
|
||
| class RequestContextProcessor | ||
| { | ||
| private const string CACHE_ATTRIBUTE = '_surfnet_request_context_processor_cached'; | ||
|
|
||
| private const array SECOND_FACTOR_ROUTES = [ | ||
| 'unverified_second_factor', | ||
| 'verified_second_factor', | ||
| 'verified_second_factor_can_skip_prove_posession', | ||
| 'vetted_second_factor', | ||
| ]; | ||
|
|
||
| public function __construct(private readonly RequestStack $requestStack) | ||
| { | ||
| } | ||
|
|
||
| public function __invoke(LogRecord $record): LogRecord | ||
| { | ||
| $request = $this->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<string, string> | ||
| */ | ||
| private function findRequestContext(Request $request): array | ||
| { | ||
| if ($request->attributes->has(self::CACHE_ATTRIBUTE)) { | ||
| /** @var array<string, string> */ | ||
| return $request->attributes->get(self::CACHE_ATTRIBUTE); | ||
| } | ||
|
|
||
| $context = $this->extractRequestContext($request); | ||
| $request->attributes->set(self::CACHE_ATTRIBUTE, $context); | ||
|
|
||
| return $context; | ||
| } | ||
|
|
||
| /** | ||
| * @return array<string, string> | ||
| */ | ||
| 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']), | ||
|
Comment on lines
+86
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. identity_id and name_id are documented personal data (docs/personal-data.md:39-48), but nothing here ties into the repo's Forgettable/SensitiveData mechanism. These will sit unredacted in logs indefinitely. Please confirm this is acceptable with whoever owns privacy/compliance, or hash/pseudonymize before logging. |
||
| 'institution' => $this->extractInstitution($sources, $jsonData), | ||
| ]; | ||
|
|
||
| return array_filter($fields, static fn (?string $value): bool => $value !== null); | ||
| } | ||
|
|
||
| /** | ||
| * @return array<array-key, mixed>|null | ||
| */ | ||
| private function parseJsonContent(string $content): ?array | ||
| { | ||
| if ($content === '') { | ||
| return null; | ||
| } | ||
|
|
||
| $decoded = json_decode($content, true); | ||
|
|
||
| return is_array($decoded) ? $decoded : null; | ||
| } | ||
|
|
||
| /** | ||
| * @param array<array-key, mixed>|null $jsonData | ||
| * @return list<array<array-key, mixed>> | ||
| */ | ||
| 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<array<array-key, mixed>> $sources | ||
| * @param array<array-key, mixed>|null $jsonData | ||
|
Comment on lines
+121
to
+150
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Query/body/JSON values are logged as is when there's no route match, with no validation. A caller can inject arbitrary values into identity_id, institution, etc. Restrict trusted fields to server controlled sources, or document these as best effort hints only. |
||
| */ | ||
| 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<string> $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<array-key, mixed>|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<array<array-key, mixed>> $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<array<array-key, mixed>> $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'], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. recoveryTokenIdId / recovery_token_id_id key variants aren't explained or tested. Typo, or covering a real payload quirk? |
||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @param list<array<array-key, mixed>> $sources | ||
| * @param array<array-key, mixed>|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<array-key, mixed>|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<array<array-key, mixed>> $sources | ||
| * @param list<string> $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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 } | ||
|
Comment on lines
+140
to
+145
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SecurityContextProcessor is scoped to the request channel; this processor isn't, so it runs on every Monolog channel. Intentional? If not, add the same channel restriction. |
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mutates $record->extra directly instead of $record->with(extra: ...) like SecurityContextProcessor. Works today, but inconsistent. Consider aligning.