Skip to content

Commit 723df89

Browse files
committed
feat: add correlation ID infrastructure for SAML flow log tracing
Introduces three new components to address issue #1971: - CorrelationId: shared mutable DI service (get/set) that acts as a per-request holder for the active correlation ID - CorrelationIdRepository: Symfony service backed by the session with three operations: mint(requestId) — generate a random ID for an SP request (idempotent) link(target, src) — copy the ID to an IdP request ID resolve(requestId) — push the stored ID into CorrelationId Safely no-ops when no session is available (CLI, unit tests). - CorrelationIdProcessor: Monolog processor that stamps correlation_id on every log record from the shared CorrelationId service DI wiring: services.yml registers CorrelationId and CorrelationIdRepository (with @request_stack); logging.yml registers the Monolog processor.
1 parent ea48823 commit 723df89

8 files changed

Lines changed: 583 additions & 0 deletions

File tree

config/services/logging.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ services:
3030
tags:
3131
- { name: monolog.processor }
3232

33+
OpenConext\EngineBlock\Logger\Processor\CorrelationIdProcessor:
34+
arguments:
35+
- '@OpenConext\EngineBlock\Request\CorrelationId'
36+
tags:
37+
- { name: monolog.processor }
38+
3339
OpenConext\EngineBlock\Logger\Processor\SessionIdProcessor:
3440
tags:
3541
- { name: monolog.processor }

config/services/services.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ services:
5656
- '@OpenConext\EngineBlock\Request\UniqidGenerator'
5757
public: true
5858

59+
OpenConext\EngineBlock\Request\CorrelationId:
60+
public: true
61+
62+
OpenConext\EngineBlock\Request\CorrelationIdRepository:
63+
public: true
64+
arguments:
65+
- '@OpenConext\EngineBlock\Request\CorrelationId'
66+
- '@request_stack'
67+
5968
OpenConext\EngineBlockBundle\Security\Http\EntryPoint\JsonBasicAuthenticationEntryPoint:
6069
arguments:
6170
- 'engine-api.%domain%'
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2010 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace OpenConext\EngineBlock\Logger\Processor;
20+
21+
use Monolog\LogRecord;
22+
use Monolog\Processor\ProcessorInterface;
23+
use OpenConext\EngineBlock\Request\CorrelationId;
24+
25+
final class CorrelationIdProcessor implements ProcessorInterface
26+
{
27+
public function __construct(private readonly CorrelationId $correlationId)
28+
{
29+
}
30+
31+
public function __invoke(LogRecord $record): LogRecord
32+
{
33+
$record->extra['correlation_id'] = $this->correlationId->get();
34+
35+
return $record;
36+
}
37+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2010 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace OpenConext\EngineBlock\Request;
20+
21+
final class CorrelationId
22+
{
23+
private ?string $correlationId = null;
24+
25+
public function get(): ?string
26+
{
27+
return $this->correlationId;
28+
}
29+
30+
public function set(string $correlationId): void
31+
{
32+
$this->correlationId = $correlationId;
33+
}
34+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2010 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace OpenConext\EngineBlock\Request;
20+
21+
use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException;
22+
use Symfony\Component\HttpFoundation\RequestStack;
23+
24+
/**
25+
* Symfony service that owns all three correlation ID session operations:
26+
*
27+
* mint() — generate a new ID for a SAML request (back-button safe)
28+
* link() — copy an existing ID to a new SAML request ID (SP→IdP handoff)
29+
* resolve() — look up the ID and push it into the CorrelationId value holder
30+
*
31+
* Uses the Symfony session bag under the key 'CorrelationIds'.
32+
* Registered as a shared service so it is instantiated once per HTTP request.
33+
*
34+
* Note: session entries are never explicitly evicted after a flow completes.
35+
* Each authentication adds up to two entries (SP + IdP request ID). This is
36+
* intentional and consistent with AuthnRequestSessionRepository's behaviour;
37+
* SAML sessions are short-lived so unbounded growth is not a concern in practice.
38+
*/
39+
final class CorrelationIdRepository
40+
{
41+
private const SESSION_KEY = 'CorrelationIds';
42+
43+
public function __construct(
44+
private readonly CorrelationId $correlationId,
45+
private readonly RequestStack $requestStack,
46+
) {
47+
}
48+
49+
/**
50+
* Generates and stores a correlation ID for $requestId if none exists yet.
51+
* Safe to call multiple times for the same ID (back-button guard).
52+
*/
53+
public function mint(string $requestId): void
54+
{
55+
try {
56+
$session = $this->requestStack->getSession();
57+
} catch (SessionNotFoundException) {
58+
return;
59+
}
60+
61+
$ids = $session->get(self::SESSION_KEY, []);
62+
63+
if (!isset($ids[$requestId])) {
64+
$ids[$requestId] = bin2hex(random_bytes(16));
65+
$session->set(self::SESSION_KEY, $ids);
66+
}
67+
}
68+
69+
/**
70+
* Copies the correlation ID from $sourceRequestId to $targetRequestId.
71+
* Called when EngineBlock creates its own AuthnRequest to send to the IdP,
72+
* so that the IdP leg can be traced back to the original SP flow.
73+
*/
74+
public function link(string $targetRequestId, string $sourceRequestId): void
75+
{
76+
try {
77+
$session = $this->requestStack->getSession();
78+
} catch (SessionNotFoundException) {
79+
return;
80+
}
81+
82+
$ids = $session->get(self::SESSION_KEY, []);
83+
84+
if (!array_key_exists($sourceRequestId, $ids)) {
85+
return;
86+
}
87+
88+
$ids[$targetRequestId] = $ids[$sourceRequestId];
89+
$session->set(self::SESSION_KEY, $ids);
90+
}
91+
92+
/**
93+
* Looks up the correlation ID for $requestId and pushes it into the
94+
* CorrelationId DI service so all subsequent log entries carry it.
95+
* No-op when $requestId is null or not found.
96+
*/
97+
public function resolve(?string $requestId): void
98+
{
99+
if ($requestId === null) {
100+
return;
101+
}
102+
103+
try {
104+
$session = $this->requestStack->getSession();
105+
} catch (SessionNotFoundException) {
106+
return;
107+
}
108+
109+
$cid = $session->get(self::SESSION_KEY, [])[$requestId] ?? null;
110+
111+
if ($cid !== null) {
112+
$this->correlationId->set($cid);
113+
}
114+
}
115+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2010 SURFnet B.V.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace OpenConext\EngineBlock\Logger\Processor;
20+
21+
use DateTimeImmutable;
22+
use Monolog\Level;
23+
use Monolog\LogRecord;
24+
use OpenConext\EngineBlock\Request\CorrelationId;
25+
use PHPUnit\Framework\Attributes\Group;
26+
use PHPUnit\Framework\Attributes\Test;
27+
use PHPUnit\Framework\TestCase;
28+
29+
class CorrelationIdProcessorTest extends TestCase
30+
{
31+
#[Group('EngineBlock')]
32+
#[Group('Logger')]
33+
#[Test]
34+
public function correlation_id_is_added_to_the_record()
35+
{
36+
$correlationId = new CorrelationId();
37+
$correlationId->set('test-correlation-id');
38+
39+
$processor = new CorrelationIdProcessor($correlationId);
40+
$record = new LogRecord(
41+
datetime: new DateTimeImmutable(),
42+
channel: 'test',
43+
level: Level::Debug,
44+
message: 'test message',
45+
context: [],
46+
extra: [],
47+
);
48+
49+
$processedRecord = ($processor)($record);
50+
51+
$this->assertEquals('test-correlation-id', $processedRecord->extra['correlation_id']);
52+
}
53+
54+
#[Group('EngineBlock')]
55+
#[Group('Logger')]
56+
#[Test]
57+
public function correlation_id_is_null_when_not_set()
58+
{
59+
$correlationId = new CorrelationId();
60+
61+
$processor = new CorrelationIdProcessor($correlationId);
62+
$record = new LogRecord(
63+
datetime: new DateTimeImmutable(),
64+
channel: 'test',
65+
level: Level::Debug,
66+
message: 'test message',
67+
context: [],
68+
extra: [],
69+
);
70+
71+
$processedRecord = ($processor)($record);
72+
73+
$this->assertNull($processedRecord->extra['correlation_id']);
74+
}
75+
}

0 commit comments

Comments
 (0)