Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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 <Chad.Sikorra@gmail.com>
*/
final readonly class AuditRedaction
{
/**
* @var list<string> excluded attribute names, lower-cased for case-insensitive matching
*/
private array $excluded;

/**
* @param list<string> $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<string, list<string>>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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 <Chad.Sikorra@gmail.com>
*/
interface AuditSinkInterface
{
public function write(ChangeRecord $record): void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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 <Chad.Sikorra@gmail.com>
*/
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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 <Chad.Sikorra@gmail.com>
*/
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<string, mixed>
*/
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,
];
}
}
15 changes: 4 additions & 11 deletions src/FreeDSx/Ldap/Server/Logging/EventLogger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
}

/**
Expand Down
44 changes: 44 additions & 0 deletions src/FreeDSx/Ldap/Server/Logging/ExceptionLogging.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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 <Chad.Sikorra@gmail.com>
*/
final class ExceptionLogging
{
/**
* @return array<string, string>
*/
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;
}
}
33 changes: 33 additions & 0 deletions tests/support/Journal/Audit/CapturingAuditSink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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<ChangeRecord>
*/
public array $written = [];

public function write(ChangeRecord $record): void
{
$this->written[] = $record;
}
}
34 changes: 34 additions & 0 deletions tests/support/Journal/Audit/FailingAuditSink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx LDAP package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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;
}
}
Loading
Loading