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
87 changes: 82 additions & 5 deletions docs/Server/General-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ General LDAP Server Usage
* [MysqlStorage](#mysqlstorage)
* [Proxy Backend](#proxy-backend)
* [Custom Filter Evaluation](#custom-filter-evaluation)
* [Loading LDIF Data](#loading-ldif-data)
* [LDIF Data](#ldif-data)
* [Seeding Initial Entries](#seeding-initial-entries)
* [Replaying LDIF Changelogs](#replaying-ldif-changelogs)
* [Dumping the Directory](#dumping-the-directory)
* [Inspecting Parsed LDIF](#inspecting-parsed-ldif)
* [Authentication](#authentication)
* [Default Authentication](#default-authentication)
* [Custom Bind Name Resolution](#custom-bind-name-resolution)
Expand Down Expand Up @@ -677,7 +679,12 @@ class MySqlFilterEvaluator implements FilterEvaluatorInterface
}
```

## Loading LDIF Data
## LDIF Data

`seed()`, `applyChanges()`, and `dump()` all stream. LDIF input is always taken through `LdifLoaderInterface`
such as `FileLdifLoader` for a path, `StringLdifLoader` for an in-memory string, or your own implementation for any
other source (database, remote URL, gzip stream, etc.). LDIF output uses the parallel `LdifOutputInterface` such as
`FileLdifOutput` and `StringLdifOutput`.

### Seeding Initial Entries

Expand All @@ -704,9 +711,8 @@ $server->run();
The optional second argument is the creator DN, stamped as `creatorsName`/`modifiersName` on each imported entry —
defaults to the empty (anonymous) DN.

`seed()` accepts only content records (entries without `changetype:`). LDIF sources are pluggable via `LdifLoaderInterface`:
use `FileLdifLoader`, `StringLdifLoader`, or implement your own (e.g. fetching from a database or remote URL). The
operation itself is an upsert that overwrites.
`seed()` accepts only content records (entries without `changetype:`) and requires depth-first input (parents first,
then children entries). LDIF produced by `dump()` is already in this order. The operation itself is an upsert that overwrites.

For Swoole factories (`::forSwoole()`), call `seed()` inside `Swoole\Coroutine\run()` so the adapter's
coroutine-scoped connection is available during import.
Expand Down Expand Up @@ -749,6 +755,77 @@ Unlike `seed()`, `applyChanges()` dispatches each request through the same write
requests. Supported changetypes: `add`, `delete`, `modify` (`add:`/`delete:`/`replace:` mod-specs), and `modrdn`/`moddn`
(rename or move; supports optional `newsuperior:` for moving across subtrees).

### Dumping the Directory

`LdapServer::dump()` streams the configured storage backend's entries to an LDIF output as RFC 2849 content records.
Operational attributes (`entryUUID`, `createTimestamp`, etc.) are preserved. So `dump()` then `seed()` restores the
entries exactly as they were.

```php
use FreeDSx\Ldap\LdapServer;
use FreeDSx\Ldap\Ldif\Output\FileLdifOutput;
use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage;

(new LdapServer())
->useStorage(SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite'))
->dump(new FileLdifOutput('/var/backups/ldap-snapshot.ldif'));
```

For in-memory use (logging, tests, piping over the network) use `StringLdifOutput`, which collects the chunks and is
both `Stringable` and exposes `getLdif()`:

```php
use FreeDSx\Ldap\Ldif\Output\StringLdifOutput;

$output = new StringLdifOutput();
$server->dump($output);

echo $output; // or $output->getLdif()
```

Use `DumpOptions` to filter the dump by any filter you want. Useful for partial backups or extracting a single OU:

```php
use FreeDSx\Ldap\Entry\Dn;
use FreeDSx\Ldap\Search\Filters;
use FreeDSx\Ldap\Server\Backend\Storage\Export\DumpOptions;

$options = (new DumpOptions())
->setBaseDn(new Dn('ou=people,dc=example,dc=com'))
->setFilter(Filters::equal('objectClass', 'inetOrgPerson'));

$server->dump(
new FileLdifOutput('/tmp/people.ldif'),
$options,
);
```

### Inspecting Parsed LDIF

For one-off tooling that needs to inspect a parsed LDIF before applying it (counting records, filtering by changetype,
etc) `LdifChanges` is a buffered collection with type filters:

```php
use FreeDSx\Ldap\Ldif\LdifChanges;
use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader;

$changes = LdifChanges::fromLoader(new FileLdifLoader('/path/to/changes.ldif'));

foreach ($changes->entries() as $entry) {
// each AddRequest's Entry
}

$changes->count(); // total changes
$changes->adds(); // AddRequest[]
$changes->modifies(); // ModifyRequest[]
$changes->deletes(); // DeleteRequest[]
$changes->modifyDns(); // ModifyDnRequest[]
```

`LdifChanges::fromString($ldif)` is the same flow for an in-memory string. The collection materializes every request,
so prefer the streaming `seed()`/`applyChanges()`/`dump()` methods normal data paths; `LdifChanges` is best suited to
small change sets.

## Authentication

The `PasswordAuthenticatableInterface` covers all bind types through two methods:
Expand Down
41 changes: 23 additions & 18 deletions src/FreeDSx/Ldap/LdapServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@
namespace FreeDSx\Ldap;

use FreeDSx\Ldap\Entry\Dn;
use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\Exception\InvalidArgumentException;
use FreeDSx\Ldap\Exception\LdifParseException;
use FreeDSx\Ldap\Exception\OperationException;
use FreeDSx\Ldap\Exception\RuntimeException;
use FreeDSx\Ldap\Ldif\LdifParser;
use FreeDSx\Ldap\Ldif\Loader\LdifLoaderInterface;
use FreeDSx\Ldap\Ldif\Output\LdifOutputInterface;
use FreeDSx\Ldap\Operation\Request\AddRequest;
use FreeDSx\Ldap\Schema\SchemaValidationMode;
use FreeDSx\Ldap\Schema\Validation\SchemaValidator;
use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface;
Expand All @@ -42,6 +44,7 @@
use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface;
use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface;
use FreeDSx\Socket\Exception\ConnectionException;
use Generator;
use Psr\Log\LoggerInterface;

/**
Expand Down Expand Up @@ -172,25 +175,34 @@ public function seed(
throw new RuntimeException('seed() requires a storage backend configured via useStorage().');
}

$changes = (new LdifParser())
->parse($loader->load());

if (!$changes->isAddOnly()) {
throw new RuntimeException(
'seed() only accepts content records (adds). Use applyChanges() for modify/delete/rename.',
);
}

(new LdapImporter(
$backend->getStorage(),
$backend->getOperationalAttributeGenerator(),
$backend->getSchemaValidator(),
$creatorDn,
))->importEntries($changes->entries());
))->importEntries($this->streamSeedEntries($loader));

return $this;
}

/**
* @return Generator<Entry>
* @throws RuntimeException when the LDIF contains a non-add change record
* @throws LdifParseException
*/
private function streamSeedEntries(LdifLoaderInterface $loader): Generator
{
foreach ((new LdifParser())->parse($loader) as $request) {
if (!$request instanceof AddRequest) {
throw new RuntimeException(
'seed() only accepts content records (adds). Use applyChanges() for modify/delete/rename.',
);
}

yield $request->getEntry();
}
}

/**
* Replays an LDIF changelog against the configured backend via the live write path.
*
Expand All @@ -208,17 +220,10 @@ public function applyChanges(LdifLoaderInterface $loader): self
throw new RuntimeException('applyChanges() requires a writable backend.');
}

$changes = (new LdifParser())
->parse($loader->load());

if (count($changes) === 0) {
return $this;
}

(new WriteRequestReplayer(
$backend,
$this->options->getWriteHandlers(),
))->apply($changes);
))->apply((new LdifParser())->parse($loader));

return $this;
}
Expand Down
25 changes: 25 additions & 0 deletions src/FreeDSx/Ldap/Ldif/LdifChanges.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use ArrayIterator;
use Countable;
use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\Ldif\Loader\LdifLoaderInterface;
use FreeDSx\Ldap\Ldif\Loader\StringLdifLoader;
use FreeDSx\Ldap\Operation\Request\AddRequest;
use FreeDSx\Ldap\Operation\Request\DeleteRequest;
use FreeDSx\Ldap\Operation\Request\ModifyDnRequest;
Expand Down Expand Up @@ -48,6 +50,29 @@ public function __construct(RequestInterface ...$requests)
$this->requests = $requests;
}

/**
* Buffers a loader's parsed records into a collection. For streaming, iterate {@see LdifParser::parse()} directly.
*/
public static function fromLoader(
LdifLoaderInterface $loader,
LdifParser $parser = new LdifParser(),
): self {
return new self(...$parser->parse($loader));
}

/**
* Convenience for buffering an in-memory LDIF string via {@see StringLdifLoader}.
*/
public static function fromString(
string $ldif,
LdifParser $parser = new LdifParser(),
): self {
return self::fromLoader(
new StringLdifLoader($ldif),
$parser,
);
}

/**
* @return RequestInterface[]
*/
Expand Down
38 changes: 29 additions & 9 deletions src/FreeDSx/Ldap/Ldif/LdifParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@

use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\Exception\LdifParseException;
use FreeDSx\Ldap\Ldif\Loader\LdifLoaderInterface;
use FreeDSx\Ldap\Ldif\Loader\StringLdifLoader;
use FreeDSx\Ldap\Ldif\Parser\LdifChangeRecordParser;
use FreeDSx\Ldap\Ldif\Parser\LdifLineCursor;
use FreeDSx\Ldap\Operation\Request\AddRequest;
use FreeDSx\Ldap\Operation\Request\RequestInterface;
use FreeDSx\Ldap\Operations;
use Generator;

use function count;
use function sprintf;

/**
Expand All @@ -42,12 +44,15 @@ public function __construct(
) {}

/**
* Streams parsed write requests from an LDIF source.
*
* @return Generator<RequestInterface>
* @throws LdifParseException
*/
public function parse(string $ldif): LdifChanges
public function parse(LdifLoaderInterface $loader): Generator
{
$cursor = LdifLineCursor::forInput($ldif);
$requests = [];
$cursor = LdifLineCursor::forLoader($loader);
$recordsSeen = 0;

while (!$cursor->atEnd()) {
$line = $cursor->current();
Expand All @@ -64,15 +69,27 @@ public function parse(string $ldif): LdifChanges
$key = $cursor->keyOf($line);

if ($key === self::DN) {
$requests[] = $this->parseRecord($cursor);
yield $this->parseRecord($cursor);

$recordsSeen++;
} elseif ($key === self::VERSION) {
$this->assertVersion($cursor, count($requests));
$this->assertVersion($cursor, $recordsSeen);
} else {
$cursor->error('Expected a "dn:" line to begin a record');
}
}
}

return new LdifChanges(...$requests);
/**
* Convenience for parsing an in-memory LDIF string.
*
* @return Generator<RequestInterface>
* @throws LdifParseException
*/
public static function parseString(string $ldif): Generator
{
return (new self())
->parse(new StringLdifLoader($ldif));
}

/**
Expand Down Expand Up @@ -114,8 +131,8 @@ private function parseContentRecord(
$directive = $cursor->readDirective();

if ($directive->is(self::CHANGETYPE)) {
$cursor->errorAt(
$directive->position,
$cursor->errorFor(
$directive,
'"changetype:" must be the first directive after dn',
);
}
Expand Down Expand Up @@ -153,10 +170,12 @@ private function assertVersion(
int $recordsSeen,
): void {
$at = $cursor->position();
$sourceLine = $cursor->current();

if ($recordsSeen !== 0) {
$cursor->errorAt(
$at,
$sourceLine,
'The version directive must appear before any records',
);
}
Expand All @@ -166,6 +185,7 @@ private function assertVersion(
if ($version !== '1') {
$cursor->errorAt(
$at,
$sourceLine,
sprintf(
'Unsupported LDIF version "%s"',
$version,
Expand Down
Loading
Loading