From 857cdd9c27adffc710de95dd09a360dd2b507809 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 27 Jun 2026 22:06:11 -0400 Subject: [PATCH] Add the auditing change journal and redaction / sink. Part 4 of replication / sync / changelogs. --- .../Storage/Journal/Audit/AuditRedaction.php | 71 ++++++++++ .../Journal/Audit/AuditSinkInterface.php | 28 ++++ .../Journal/Audit/AuditingChangeJournal.php | 72 ++++++++++ .../Journal/Audit/JsonLinesAuditSink.php | 71 ++++++++++ .../Ldap/Server/Logging/EventLogger.php | 15 +-- .../Ldap/Server/Logging/ExceptionLogging.php | 44 +++++++ .../Journal/Audit/CapturingAuditSink.php | 33 +++++ .../Journal/Audit/FailingAuditSink.php | 34 +++++ .../Journal/Audit/AuditRedactionTest.php | 57 ++++++++ .../Audit/AuditingChangeJournalTest.php | 108 +++++++++++++++ .../Journal/Audit/JsonLinesAuditSinkTest.php | 123 ++++++++++++++++++ .../Server/Logging/ExceptionLoggingTest.php | 57 ++++++++ 12 files changed, 702 insertions(+), 11 deletions(-) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditRedaction.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditSinkInterface.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditingChangeJournal.php create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/JsonLinesAuditSink.php create mode 100644 src/FreeDSx/Ldap/Server/Logging/ExceptionLogging.php create mode 100644 tests/support/Journal/Audit/CapturingAuditSink.php create mode 100644 tests/support/Journal/Audit/FailingAuditSink.php create mode 100644 tests/unit/Server/Backend/Storage/Journal/Audit/AuditRedactionTest.php create mode 100644 tests/unit/Server/Backend/Storage/Journal/Audit/AuditingChangeJournalTest.php create mode 100644 tests/unit/Server/Backend/Storage/Journal/Audit/JsonLinesAuditSinkTest.php create mode 100644 tests/unit/Server/Logging/ExceptionLoggingTest.php diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditRedaction.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditRedaction.php new file mode 100644 index 00000000..0085e1a9 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditRedaction.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Schema\Definition\AttributeTypeOid; +use FreeDSx\Ldap\Schema\Definition\PasswordPolicyOid; + +/** + * Drops sensitive attributes from a pre-image before it leaves the server to an audit sink. + * + * @author Chad Sikorra + */ +final readonly class AuditRedaction +{ + /** + * @var list excluded attribute names, lower-cased for case-insensitive matching + */ + private array $excluded; + + /** + * @param list $attributes + */ + public function __construct(array $attributes) + { + $this->excluded = array_values(array_map( + strtolower(...), + $attributes, + )); + } + + /** + * Excludes the attributes that carry password material: userPassword and the pwdHistory record. + */ + public static function default(): self + { + return new self([ + AttributeTypeOid::NAME_USER_PASSWORD, + PasswordPolicyOid::NAME_PWD_HISTORY, + ]); + } + + /** + * The entry's attributes as a map, with excluded attributes removed. + * + * @return array> + */ + public function apply(Entry $entry): array + { + $attributes = []; + + foreach ($entry->toArray() as $name => $values) { + if (!in_array(strtolower($name), $this->excluded, true)) { + $attributes[$name] = array_values($values); + } + } + + return $attributes; + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditSinkInterface.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditSinkInterface.php new file mode 100644 index 00000000..00f57f81 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditSinkInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit; + +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; + +/** + * Durable destination for committed change records (file, syslog, SIEM). + * + * @api + * + * @author Chad Sikorra + */ +interface AuditSinkInterface +{ + public function write(ChangeRecord $record): void; +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditingChangeJournal.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditingChangeJournal.php new file mode 100644 index 00000000..84b373af --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/AuditingChangeJournal.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit; + +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeJournalInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionPolicy; +use FreeDSx\Ldap\Server\Logging\ExceptionLogging; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Throwable; + +/** + * Decorates a journal to tee each appended record to an audit sink; read/prune pass through. + * + * @api + * + * @author Chad Sikorra + */ +final class AuditingChangeJournal implements ChangeJournalInterface +{ + public function __construct( + private readonly ChangeJournalInterface $journal, + private readonly AuditSinkInterface $sink, + private readonly LoggerInterface $logger = new NullLogger(), + ) {} + + public function append(PendingChange $change): ChangeRecord + { + $record = $this->journal->append($change); + + // The journal already holds the record, so a failed export is logged, not fatal: the write + // still succeeds and the record can be re-exported from the journal later. + try { + $this->sink->write($record); + } catch (Throwable $e) { + $this->logger->error( + 'Failed to write a change record to the audit sink.', + ExceptionLogging::makeLogContext($e), + ); + } + + return $record; + } + + public function read(int $afterSeq = 0): iterable + { + return $this->journal->read($afterSeq); + } + + public function latestSeq(): int + { + return $this->journal->latestSeq(); + } + + public function prune(RetentionPolicy $policy): int + { + return $this->journal->prune($policy); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/JsonLinesAuditSink.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/JsonLinesAuditSink.php new file mode 100644 index 00000000..06be96ac --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Journal/Audit/JsonLinesAuditSink.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit; + +use DateTimeInterface; +use FreeDSx\Ldap\Exception\RuntimeException; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; + +/** + * Appends each record as one JSON line, redacting the pre-image; appends are lock-guarded for multi-process safety. + * + * @api + * + * @author Chad Sikorra + */ +final readonly class JsonLinesAuditSink implements AuditSinkInterface +{ + private AuditRedaction $redaction; + + public function __construct( + private string $path, + ?AuditRedaction $redaction = null, + ) { + $this->redaction = $redaction ?? AuditRedaction::default(); + } + + public function write(ChangeRecord $record): void + { + $line = json_encode( + $this->toArray($record), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + ) . "\n"; + + if (file_put_contents($this->path, $line, FILE_APPEND | LOCK_EX) === false) { + throw new RuntimeException('Failed to write a record to the audit sink.'); + } + } + + /** + * @return array + */ + private function toArray(ChangeRecord $record): array + { + $change = $record->change; + + return [ + 'seq' => $record->seq, + 'origin' => (string) $record->origin, + 'created_at' => $record->createdAt->format(DateTimeInterface::ATOM), + 'change_type' => $change->changeType->value, + 'dn' => $change->dn->toString(), + 'entry_uuid' => $change->entryUuid, + 'authz_id' => $change->authzId->toString(), + 'previous_dn' => $change->previousDn?->toString(), + 'pre_image' => $change->preImage !== null + ? $this->redaction->apply($change->preImage) + : null, + ]; + } +} diff --git a/src/FreeDSx/Ldap/Server/Logging/EventLogger.php b/src/FreeDSx/Ldap/Server/Logging/EventLogger.php index 34e3c185..0a96d0df 100644 --- a/src/FreeDSx/Ldap/Server/Logging/EventLogger.php +++ b/src/FreeDSx/Ldap/Server/Logging/EventLogger.php @@ -64,17 +64,10 @@ public function exceptionContextFor(?Throwable $cause): array return []; } - $context = [ - EventContext::EXCEPTION_CLASS => $cause::class, - EventContext::EXCEPTION_MESSAGE => $cause->getMessage(), - EventContext::EXCEPTION_ORIGIN => $cause->getFile() . ':' . $cause->getLine(), - ]; - - if ($this->policy->includesExceptionTraces()) { - $context[EventContext::EXCEPTION_TRACE] = $cause->getTraceAsString(); - } - - return $context; + return ExceptionLogging::makeLogContext( + $cause, + $this->policy->includesExceptionTraces(), + ); } /** diff --git a/src/FreeDSx/Ldap/Server/Logging/ExceptionLogging.php b/src/FreeDSx/Ldap/Server/Logging/ExceptionLogging.php new file mode 100644 index 00000000..3eec9c72 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Logging/ExceptionLogging.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Logging; + +use Throwable; + +/** + * Exception logging helper methods. + * + * @author Chad Sikorra + */ +final class ExceptionLogging +{ + /** + * @return array + */ + public static function makeLogContext( + Throwable $exception, + bool $includeTrace = false, + ): array { + $context = [ + EventContext::EXCEPTION_CLASS => $exception::class, + EventContext::EXCEPTION_MESSAGE => $exception->getMessage(), + EventContext::EXCEPTION_ORIGIN => $exception->getFile() . ':' . $exception->getLine(), + ]; + + if ($includeTrace) { + $context[EventContext::EXCEPTION_TRACE] = $exception->getTraceAsString(); + } + + return $context; + } +} diff --git a/tests/support/Journal/Audit/CapturingAuditSink.php b/tests/support/Journal/Audit/CapturingAuditSink.php new file mode 100644 index 00000000..110fef4b --- /dev/null +++ b/tests/support/Journal/Audit/CapturingAuditSink.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Support\FreeDSx\Ldap\Journal\Audit; + +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit\AuditSinkInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; + +/** + * Audit sink that retains every written record for assertions. + */ +final class CapturingAuditSink implements AuditSinkInterface +{ + /** + * @var list + */ + public array $written = []; + + public function write(ChangeRecord $record): void + { + $this->written[] = $record; + } +} diff --git a/tests/support/Journal/Audit/FailingAuditSink.php b/tests/support/Journal/Audit/FailingAuditSink.php new file mode 100644 index 00000000..a5a7bbe0 --- /dev/null +++ b/tests/support/Journal/Audit/FailingAuditSink.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Support\FreeDSx\Ldap\Journal\Audit; + +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit\AuditSinkInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use RuntimeException; +use Throwable; + +/** + * Audit sink that always throws, for exercising sink-failure handling. + */ +final class FailingAuditSink implements AuditSinkInterface +{ + public function __construct( + private readonly Throwable $error = new RuntimeException('the audit sink is unavailable'), + ) {} + + public function write(ChangeRecord $record): void + { + throw $this->error; + } +} diff --git a/tests/unit/Server/Backend/Storage/Journal/Audit/AuditRedactionTest.php b/tests/unit/Server/Backend/Storage/Journal/Audit/AuditRedactionTest.php new file mode 100644 index 00000000..42b5601a --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Journal/Audit/AuditRedactionTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit\AuditRedaction; +use PHPUnit\Framework\TestCase; + +final class AuditRedactionTest extends TestCase +{ + public function test_it_drops_excluded_attributes_case_insensitively(): void + { + $redaction = new AuditRedaction(['userPassword']); + $entry = new Entry( + new Dn('cn=a,dc=example,dc=com'), + new Attribute('cn', 'a'), + new Attribute('UserPassword', 'secret'), + ); + + $result = $redaction->apply($entry); + + self::assertSame( + ['cn' => ['a']], + $result, + ); + } + + public function test_the_default_drops_password_bearing_attributes(): void + { + $entry = new Entry( + new Dn('cn=a,dc=example,dc=com'), + new Attribute('cn', 'a'), + new Attribute('userPassword', 'secret'), + new Attribute('pwdHistory', '20250515120000Z#1.3.6#6#secret'), + ); + + $result = AuditRedaction::default()->apply($entry); + + self::assertSame( + ['cn' => ['a']], + $result, + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Journal/Audit/AuditingChangeJournalTest.php b/tests/unit/Server/Backend/Storage/Journal/Audit/AuditingChangeJournalTest.php new file mode 100644 index 00000000..17298e35 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Journal/Audit/AuditingChangeJournalTest.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit\AuditingChangeJournal; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\InMemoryChangeJournal; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\RetentionPolicy; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Tests\Support\FreeDSx\Ldap\Journal\Audit\CapturingAuditSink; +use Tests\Support\FreeDSx\Ldap\Journal\Audit\FailingAuditSink; + +final class AuditingChangeJournalTest extends TestCase +{ + public function test_it_tees_each_appended_record_to_the_sink(): void + { + $sink = new CapturingAuditSink(); + $journal = new AuditingChangeJournal( + new InMemoryChangeJournal(), + $sink, + ); + + $record = $journal->append($this->change('cn=a,dc=example,dc=com')); + + self::assertCount( + 1, + $sink->written, + ); + self::assertSame( + $record, + $sink->written[0], + ); + } + + public function test_a_sink_failure_is_logged_and_the_record_is_still_journaled(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once()) + ->method('error'); + $journal = new AuditingChangeJournal( + new InMemoryChangeJournal(), + new FailingAuditSink(), + $logger, + ); + + $record = $journal->append($this->change('cn=a,dc=example,dc=com')); + + self::assertSame( + 1, + $record->seq, + ); + self::assertCount( + 1, + iterator_to_array($journal->read()), + ); + } + + public function test_read_latest_seq_and_prune_delegate_to_the_inner_journal(): void + { + $journal = new AuditingChangeJournal( + new InMemoryChangeJournal(), + new CapturingAuditSink(), + ); + $journal->append($this->change('cn=a,dc=example,dc=com')); + $journal->append($this->change('cn=b,dc=example,dc=com')); + + self::assertSame( + 2, + $journal->latestSeq(), + ); + self::assertCount( + 1, + iterator_to_array($journal->read(1)), + ); + + $journal->prune(new RetentionPolicy(maxRecords: 1)); + + self::assertCount( + 1, + iterator_to_array($journal->read()), + ); + } + + private function change(string $dn): PendingChange + { + return new PendingChange( + changeType: ChangeType::Add, + dn: new Dn($dn), + entryUuid: '11111111-1111-4111-8111-111111111111', + authzId: AuthzId::anonymous(), + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Journal/Audit/JsonLinesAuditSinkTest.php b/tests/unit/Server/Backend/Storage/Journal/Audit/JsonLinesAuditSinkTest.php new file mode 100644 index 00000000..6a672727 --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Journal/Audit/JsonLinesAuditSinkTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit; + +use DateTimeImmutable; +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Audit\JsonLinesAuditSink; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeRecord; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\ChangeType; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\Change\PendingChange; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId; +use PHPUnit\Framework\TestCase; + +final class JsonLinesAuditSinkTest extends TestCase +{ + private string $path; + + private JsonLinesAuditSink $subject; + + protected function setUp(): void + { + $path = tempnam(sys_get_temp_dir(), 'audit-test-'); + self::assertIsString($path); + $this->path = $path; + $this->subject = new JsonLinesAuditSink($this->path); + } + + protected function tearDown(): void + { + @unlink($this->path); + } + + public function test_it_writes_a_record_as_a_json_line_with_a_redacted_pre_image(): void + { + $this->subject->write($this->deleteRecord()); + + self::assertSame( + [ + 'seq' => 7, + 'origin' => 'node-a', + 'created_at' => '2025-05-15T12:00:00+00:00', + 'change_type' => 'delete', + 'dn' => 'cn=a,dc=example,dc=com', + 'entry_uuid' => '11111111-1111-4111-8111-111111111111', + 'authz_id' => 'dn:cn=admin,dc=example,dc=com', + 'previous_dn' => null, + 'pre_image' => ['cn' => ['a']], + ], + $this->firstLine(), + ); + } + + public function test_it_appends_one_line_per_record(): void + { + $this->subject->write($this->deleteRecord()); + $this->subject->write($this->deleteRecord()); + + self::assertCount( + 2, + file( + $this->path, + FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES, + ) ?: [], + ); + } + + /** + * @return array + */ + private function firstLine(): array + { + $lines = file( + $this->path, + FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES, + ) ?: []; + + if ($lines === []) { + return []; + } + + $decoded = json_decode( + $lines[0], + true, + flags: JSON_THROW_ON_ERROR, + ); + + return is_array($decoded) ? $decoded : []; + } + + private function deleteRecord(): ChangeRecord + { + return new ChangeRecord( + seq: 7, + origin: new ReplicaId('node-a'), + createdAt: new DateTimeImmutable('2025-05-15T12:00:00+00:00'), + change: new PendingChange( + changeType: ChangeType::Delete, + dn: new Dn('cn=a,dc=example,dc=com'), + entryUuid: '11111111-1111-4111-8111-111111111111', + authzId: AuthzId::fromDn(new Dn('cn=admin,dc=example,dc=com')), + preImage: new Entry( + new Dn('cn=a,dc=example,dc=com'), + new Attribute('cn', 'a'), + new Attribute('userPassword', 'secret'), + ), + ), + ); + } +} diff --git a/tests/unit/Server/Logging/ExceptionLoggingTest.php b/tests/unit/Server/Logging/ExceptionLoggingTest.php new file mode 100644 index 00000000..8feef984 --- /dev/null +++ b/tests/unit/Server/Logging/ExceptionLoggingTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Logging; + +use FreeDSx\Ldap\Server\Logging\EventContext; +use FreeDSx\Ldap\Server\Logging\ExceptionLogging; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +final class ExceptionLoggingTest extends TestCase +{ + public function test_it_includes_class_message_and_origin_without_a_trace(): void + { + $context = ExceptionLogging::makeLogContext(new RuntimeException('boom')); + + self::assertSame( + RuntimeException::class, + $context[EventContext::EXCEPTION_CLASS], + ); + self::assertSame( + 'boom', + $context[EventContext::EXCEPTION_MESSAGE], + ); + self::assertArrayHasKey( + EventContext::EXCEPTION_ORIGIN, + $context, + ); + self::assertArrayNotHasKey( + EventContext::EXCEPTION_TRACE, + $context, + ); + } + + public function test_it_adds_the_trace_when_requested(): void + { + $context = ExceptionLogging::makeLogContext( + new RuntimeException('boom'), + true, + ); + + self::assertArrayHasKey( + EventContext::EXCEPTION_TRACE, + $context, + ); + } +}