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
107 changes: 75 additions & 32 deletions docs/Server/General-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ General LDAP Server Usage
* [MysqlStorage](#mysqlstorage)
* [Proxy Backend](#proxy-backend)
* [Custom Filter Evaluation](#custom-filter-evaluation)
* [Loading LDIF Data](#loading-ldif-data)
* [Seeding Initial Entries](#seeding-initial-entries)
* [Replaying LDIF Changelogs](#replaying-ldif-changelogs)
* [Authentication](#authentication)
* [Default Authentication](#default-authentication)
* [Custom Bind Name Resolution](#custom-bind-name-resolution)
Expand Down Expand Up @@ -607,38 +610,6 @@ final class PostgresStorage implements PdoStorageFactoryInterface
}
```

#### Seeding Initial Entries

`LdapImporter` writes a batch of entries in one atomic transaction. Use it to populate a persistent storage backend
before `$server->run()`. The same pattern works for every adapter.

```php
use FreeDSx\Ldap\Entry\Attribute;
use FreeDSx\Ldap\Entry\Dn;
use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\LdapServer;
use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage;
use FreeDSx\Ldap\Server\Backend\Storage\LdapImporter;

$storage = SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite');

(new LdapImporter($storage))->importEntries([
new Entry(new Dn('dc=example,dc=com'), new Attribute('dc', 'example')),
new Entry(
new Dn('cn=admin,dc=example,dc=com'),
new Attribute('cn', 'admin'),
),
]);

(new LdapServer())->useStorage($storage)->run();
```

`importEntries()` is an upsert: existing entries at the same DN are replaced. Re-running is safe, but any changes made
between imports are overwritten — guard the call yourself if you only want to seed on first run.

For Swoole factories (`::forSwoole()`), wrap the `importEntries()` call in `Swoole\Coroutine\run()` so the adapter's
coroutine-scoped connection is available during import.

### Proxy Backend

`ProxyBackend` implements `WritableLdapBackendInterface` by forwarding all operations to an upstream LDAP server.
Expand Down Expand Up @@ -706,6 +677,78 @@ class MySqlFilterEvaluator implements FilterEvaluatorInterface
}
```

## Loading LDIF Data

### Seeding Initial Entries

`LdapServer::seed()` bulk-imports RFC 2849 LDIF content records into the storage configured via `useStorage()` in one
atomic transaction, with schema validation and operational-attribute stamping (`createTimestamp`, `entryUUID`, etc.)
applied. Use it to populate a persistent storage backend before `$server->run()`.

```php
use FreeDSx\Ldap\Entry\Dn;
use FreeDSx\Ldap\LdapServer;
use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader;
use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage;

$server = (new LdapServer())
->useStorage(SqliteStorage::forPcntl('/var/lib/myapp/ldap.sqlite'))
->seed(
new FileLdifLoader('/etc/myapp/initial-data.ldif'),
new Dn('cn=admin,dc=example,dc=com'),
);

$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.

For Swoole factories (`::forSwoole()`), call `seed()` inside `Swoole\Coroutine\run()` so the adapter's
coroutine-scoped connection is available during import.

### Replaying LDIF Changelogs

`LdapServer::applyChanges()` replays an LDIF changelog through the live write path. Use it for applying diffs, migrations,
or administrative changes after the initial directory is populated.

```ldif
version: 1

dn: cn=alice,dc=example,dc=com
changetype: modify
replace: sn
sn: Anderson
-

dn: cn=bob,dc=example,dc=com
changetype: delete

dn: cn=carol,dc=example,dc=com
changetype: modrdn
newrdn: cn=carolyn
deleteoldrdn: 1
```

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

(new LdapServer())
->useStorage($storage)
->seed(new FileLdifLoader('/etc/myapp/initial-data.ldif'))
->applyChanges(new FileLdifLoader('/etc/myapp/changes-today.ldif'))
->run();
```

Unlike `seed()`, `applyChanges()` dispatches each request through the same write path the live server uses for client
requests. Supported changetypes: `add`, `delete`, `modify` (`add:`/`delete:`/`replace:` mod-specs), and `modrdn`/`moddn`
(rename or move; supports optional `newsuperior:` for moving across subtrees).

## Authentication

The `PasswordAuthenticatableInterface` covers all bind types through two methods:
Expand Down
49 changes: 44 additions & 5 deletions src/FreeDSx/Ldap/LdapServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use FreeDSx\Ldap\Server\Backend\Storage\LdapImporter;
use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend;
use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface;
use FreeDSx\Ldap\Server\Backend\Write\WriteRequestReplayer;
use FreeDSx\Ldap\Server\RequestHandler\ProxyHandler;
use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface;
use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface;
Expand Down Expand Up @@ -148,10 +149,9 @@ public function useStorage(EntryStorageInterface $storage): self
}

/**
* Convenience method to bulk-load LDIF entries into the storage configured via {@see useStorage()}.
* Bulk-loads LDIF content records into the storage configured via {@see useStorage()} as one atomic batch.
*
* The LDIF source is pluggable via {@see LdifLoaderInterface} (a file, a string, a database, etc.). Entries are
* validated and stamped with operational attributes the same way the live write path would.
* Use {@see applyChanges()} instead to replay a changelog (modify/delete/rename) through the live write path.
*
* @param Dn $creatorDn DN recorded as creatorsName/modifiersName on imported entries; defaults to the anonymous (empty) DN.
* @throws LdifParseException when the LDIF cannot be parsed
Expand All @@ -169,14 +169,53 @@ public function seed(
throw new RuntimeException('seed() requires a storage backend configured via useStorage().');
}

$entries = (new LdifParser())
$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($entries->toArray());
))->importEntries($changes->entries());

return $this;
}

/**
* Replays an LDIF changelog against the configured backend via the live write path.
*
* Use {@see seed()} instead for bulk initial provisioning of content records straight to storage.
*
* @throws LdifParseException when the LDIF cannot be parsed
* @throws RuntimeException when no writable backend is configured
* @throws OperationException when a write fails (no such entry, schema violation, etc.)
*/
public function applyChanges(LdifLoaderInterface $loader): self
{
$backend = $this->options->getBackend();

if (!$backend instanceof WriteHandlerInterface) {
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);

return $this;
}
Expand Down
139 changes: 139 additions & 0 deletions src/FreeDSx/Ldap/Ldif/LdifChanges.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?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\Ldif;

use ArrayIterator;
use Countable;
use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\Operation\Request\AddRequest;
use FreeDSx\Ldap\Operation\Request\DeleteRequest;
use FreeDSx\Ldap\Operation\Request\ModifyDnRequest;
use FreeDSx\Ldap\Operation\Request\ModifyRequest;
use FreeDSx\Ldap\Operation\Request\RequestInterface;
use IteratorAggregate;
use Traversable;

use function array_filter;
use function array_map;
use function array_values;
use function count;

/**
* The full outcome of an LDIF parse: write requests in original record order.
*
* @implements IteratorAggregate<RequestInterface>
*
* @author Chad Sikorra <Chad.Sikorra@gmail.com>
*/
final readonly class LdifChanges implements Countable, IteratorAggregate
{
/**
* @var array<RequestInterface>
*/
private array $requests;

public function __construct(RequestInterface ...$requests)
{
$this->requests = $requests;
}

/**
* @return RequestInterface[]
*/
public function toArray(): array
{
return $this->requests;
}

public function count(): int
{
return count($this->requests);
}

/**
* @return Traversable<RequestInterface>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->requests);
}

/**
* @return list<AddRequest>
*/
public function adds(): array
{
return array_values(array_filter(
$this->requests,
fn(RequestInterface $r): bool => $r instanceof AddRequest,
));
}

/**
* @return list<ModifyRequest>
*/
public function modifies(): array
{
return array_values(array_filter(
$this->requests,
fn(RequestInterface $r): bool => $r instanceof ModifyRequest,
));
}

/**
* @return list<DeleteRequest>
*/
public function deletes(): array
{
return array_values(array_filter(
$this->requests,
fn(RequestInterface $r): bool => $r instanceof DeleteRequest,
));
}

/**
* @return list<ModifyDnRequest>
*/
public function modifyDns(): array
{
return array_values(array_filter(
$this->requests,
fn(RequestInterface $r): bool => $r instanceof ModifyDnRequest,
));
}

public function isAddOnly(): bool
{
foreach ($this->requests as $request) {
if (!($request instanceof AddRequest)) {
return false;
}
}

return true;
}

/**
* Extracts the Entry from every AddRequest, ignoring any non-add requests.
*
* @return list<Entry>
*/
public function entries(): array
{
return array_map(
fn(AddRequest $r): Entry => $r->getEntry(),
$this->adds(),
);
}
}
Loading
Loading