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
9 changes: 9 additions & 0 deletions resources/ldap-schema/core.ldif
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ attributeTypes: ( 2.5.21.7 NAME 'nameForms' DESC 'name forms' EQUALITY 2.5.13.30
attributeTypes: ( 2.5.21.1 NAME 'dITStructureRules' DESC 'DIT structure rules' EQUALITY 2.5.13.29 SYNTAX 1.3.6.1.4.1.1466.115.121.1.17 NO-USER-MODIFICATION USAGE directoryOperation )
attributeTypes: ( 2.5.18.5 NAME 'administrativeRole' DESC 'administrative roles the entry is an administrative point for' EQUALITY 2.5.13.0 SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE directoryOperation )
attributeTypes: ( 2.5.18.6 NAME 'subtreeSpecification' DESC 'the portion of a subtree a subentry applies to' SYNTAX 1.3.6.1.4.1.1466.115.121.1.45 SINGLE-VALUE USAGE directoryOperation )
attributeTypes: ( 1.3.6.1.4.1.1466.101.120.6 NAME 'altServer' DESC 'URIs of other servers replicating the same information' SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 USAGE dSAOperation )
attributeTypes: ( 1.3.6.1.4.1.1466.101.120.5 NAME 'namingContexts' DESC 'naming contexts the server masters or shadows' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 USAGE dSAOperation )
attributeTypes: ( 1.3.6.1.4.1.1466.101.120.13 NAME 'supportedControl' DESC 'request controls the server recognizes' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE dSAOperation )
attributeTypes: ( 1.3.6.1.4.1.1466.101.120.7 NAME 'supportedExtension' DESC 'extended operations the server recognizes' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE dSAOperation )
attributeTypes: ( 1.3.6.1.4.1.4203.1.3.5 NAME 'supportedFeatures' DESC 'elective features the server supports' EQUALITY 2.5.13.0 SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE dSAOperation )
attributeTypes: ( 1.3.6.1.4.1.1466.101.120.15 NAME 'supportedLDAPVersion' DESC 'LDAP versions the server implements' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 USAGE dSAOperation )
attributeTypes: ( 1.3.6.1.4.1.1466.101.120.14 NAME 'supportedSASLMechanisms' DESC 'SASL mechanisms the server recognizes' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 USAGE dSAOperation )
attributeTypes: ( 1.3.6.1.1.4 NAME 'vendorName' DESC 'name of the server implementor' EQUALITY 1.3.6.1.4.1.1466.109.114.1 SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE NO-USER-MODIFICATION USAGE dSAOperation )
attributeTypes: ( 1.3.6.1.1.5 NAME 'vendorVersion' DESC 'version of the server implementation' EQUALITY 1.3.6.1.4.1.1466.109.114.1 SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE NO-USER-MODIFICATION USAGE dSAOperation )
objectClasses: ( 2.5.6.0 NAME 'top' DESC 'top of the class hierarchy' ABSTRACT MUST objectClass )
objectClasses: ( 2.5.6.1 NAME 'alias' DESC 'an alias entry pointing at another entry' SUP 2.5.6.0 STRUCTURAL MUST aliasedObjectName )
objectClasses: ( 2.5.6.6 NAME 'person' DESC 'natural persons' SUP 2.5.6.0 STRUCTURAL MUST ( sn $ cn ) MAY ( userPassword $ telephoneNumber $ seeAlso $ description ) )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ private function makeGeneratedEntryResponder(Container $container): GeneratedEnt
return new GeneratedEntryResponder(
$container->get(AccessControlInterface::class),
$container->get(FilterEvaluatorInterface::class),
$container->get(ServerOptions::class)->getSchema(),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
/**
* Projects an entry onto a search request's attribute selection list (RFC 4511 §4.5.1.8, RFC 3673).
*
* - empty list / "*" = all attributes already on the entry
* - empty list / "*" = all user attributes (operational ones only when also named)
* - "+" = all operational attributes (classified via the supplied schema)
* - "1.1" = no attributes (DN only)
* - explicit names = only those
* - explicit names = only those, whatever their usage
*
* An attribute the schema does not define counts as a user attribute, so unknown ones keep flowing.
*
* @author Chad Sikorra <Chad.Sikorra@gmail.com>
*/
Expand All @@ -37,12 +39,19 @@ final class AttributeProjection
*/
private array $operationalByName = [];

/**
* Per-instance memo of the selection decision keyed on attribute description.
*
* @var array<string, bool>
*/
private array $includeByDescription = [];

/**
* @param string[] $names
*/
private function __construct(
private readonly array $names,
private readonly bool $returnAll,
private readonly bool $wantsUser,
private readonly bool $wantsOperational,
private readonly bool $returnNone,
private readonly bool $typesOnly,
Expand Down Expand Up @@ -74,14 +83,11 @@ public static function forRequest(

public function project(Entry $entry): Entry
{
if ($this->isPassThrough()) {
return $entry;
}

$attributes = $entry->getAttributes();
$filteredAttributes = [];

if (!$this->returnNone) {
foreach ($entry->getAttributes() as $attribute) {
foreach ($attributes as $attribute) {
if (!$this->shouldInclude($attribute)) {
continue;
}
Expand All @@ -92,29 +98,32 @@ public function project(Entry $entry): Entry
}
}

// Nothing was withheld, so the entry already is its own projection.
if (!$this->typesOnly && count($filteredAttributes) === count($attributes)) {
return $entry;
}

return Entry::raw(
$entry->getDn(),
$filteredAttributes,
);
}

private function isPassThrough(): bool
private function shouldInclude(Attribute $attribute): bool
{
return $this->names === [] && !$this->typesOnly;
return $this->includeByDescription[$attribute->getDescription()]
??= $this->decideInclude($attribute);
}

private function shouldInclude(Attribute $attribute): bool
private function decideInclude(Attribute $attribute): bool
{
if ($this->returnAll) {
return true;
}

if (in_array(strtolower($attribute->getDescription()), $this->names, true)) {
if ($this->names !== [] && in_array(strtolower($attribute->getDescription()), $this->names, true)) {
return true;
}

return $this->wantsOperational
&& $this->isOperational($attribute);
return $this->isOperational($attribute)
? $this->wantsOperational
: $this->wantsUser;
}

private function isOperational(Attribute $attribute): bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use FreeDSx\Ldap\Operation\ResultCode;
use FreeDSx\Ldap\Protocol\LdapMessageRequest;
use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream;
use FreeDSx\Ldap\Schema\Schema;
use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface;
use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface;
use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult;
Expand All @@ -37,6 +38,7 @@
public function __construct(
private AccessControlInterface $accessControl,
private FilterEvaluatorInterface $filterEvaluator,
private Schema $schema,
) {}

/**
Expand Down Expand Up @@ -77,6 +79,37 @@ public function matches(
);
}

/**
* The whole response path for a synthesized entry: read policy, then the filter, then attribute selection.
*
* Prefer this over calling the steps directly, so every generated entry answers the request the same way.
*/
public function respondWith(
LdapMessageRequest $message,
Entry $entry,
TokenInterface $token,
): ResponseStream {
$readable = $this->readable(
$entry,
$token,
);

if (!$this->matches($message, $readable)) {
return $this->reply(
$message,
null,
);
}

return $this->reply(
$message,
$this->project(
$message,
$readable,
),
);
}

/**
* Replies with the entry, or with an empty result when it is null.
*/
Expand All @@ -94,4 +127,24 @@ public function reply(
...$responses,
);
}

/**
* Narrows the entry to the request's attribute selection.
*/
private function project(
LdapMessageRequest $message,
Entry $entry,
): Entry {
$request = $message->getRequest();

if (!$request instanceof SearchRequest) {
return $entry;
}

return AttributeProjection::forRequest(
$request->getAttributes(),
$request->getAttributesOnly(),
$this->schema,
)->project($entry);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,14 @@ public function handleRequest(
LdapMessageRequest $message,
TokenInterface $token,
): ResponseStream {
$entry = $this->responder->readable(
return $this->responder->respondWith(
$message,
Entry::fromArray(
self::DN,
$this->attributes($this->snapshots->snapshot()),
),
$token,
);

return $this->responder->reply(
$message,
$this->responder->matches($message, $entry) ? $entry : null,
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
use FreeDSx\Ldap\Control\Control;
use FreeDSx\Ldap\Entry\Entry;
use FreeDSx\Ldap\Operation\Request\ExtendedRequest;
use FreeDSx\Ldap\Operation\Request\SearchRequest;
use FreeDSx\Ldap\Protocol\LdapMessageRequest;
use FreeDSx\Ldap\Protocol\Queue\Response\ResponseStream;
use FreeDSx\Ldap\Schema\Definition\ObjectClassOid;
Expand All @@ -25,8 +24,6 @@
use FreeDSx\Ldap\Server\Token\TokenInterface;
use FreeDSx\Ldap\ServerOptions;

use function count;

/**
* Handles RootDSE based search requests.
*
Expand Down Expand Up @@ -74,6 +71,7 @@ public function handleRequest(
Control::OID_POST_READ,
Control::OID_SUBTREE_DELETE,
Control::OID_SUBENTRIES,
Control::OID_PWD_POLICY,
],
'supportedExtension' => [
ExtendedRequest::OID_WHOAMI,
Expand Down Expand Up @@ -113,58 +111,11 @@ public function handleRequest(
$entry->set('altServer', (string) $this->options->getDseAltServer());
}

$entry = $this->responder->readable(
$entry,
$token,
);

// Stripping and matching both precede attribute selection, since selecting must not change what matched.
if (!$this->responder->matches($message, $entry)) {
return $this->responder->reply(
$message,
null,
);
}

/** @var SearchRequest $request */
$request = $message->getRequest();
$this->filterEntryAttributes($request, $entry);

return $this->responder->reply(
// Every attribute here is operational, so "+" is the only wildcard that selects them (RFC 4512 section 5.1).
return $this->responder->respondWith(
$message,
$entry,
$token,
);
}

/**
* Filters attributes from an entry to return only what was requested.
*/
private function filterEntryAttributes(
SearchRequest $request,
Entry $entry,
): void {
if (count($request->getAttributes()) !== 0) {
foreach ($entry->getAttributes() as $dseAttr) {
$found = false;
foreach ($request->getAttributes() as $attribute) {
if ($attribute->equals($dseAttr)) {
$found = true;
break;
}
}
if ($found === true && $request->getAttributesOnly()) {
$dseAttr->reset();
}
if ($found === false) {
$entry->reset($dseAttr);
$entry->changes()->reset();
}
}
}
if ($request->getAttributesOnly()) {
foreach ($entry->getAttributes() as $attribute) {
$attribute->reset();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ private function getPagingControlFromMessage(LdapMessageRequest $message): Pagin
);
}

// The size is constrained to (0..maxInt), and a negative one would otherwise mean an unbounded page.
if ($pagingControl->getSize() < 0) {
throw new OperationException(
'The paged results size must not be negative.',
ResultCode::PROTOCOL_ERROR,
);
}

return $pagingControl;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,12 @@ public function handleRequest(
]),
);

$entry = $this->responder->readable(
// The schema definitions are operational, so a client that does not ask for them gets the naming attributes only.
return $this->responder->respondWith(
$message,
$entry,
$token,
);

return $this->responder->reply(
$message,
$this->responder->matches($message, $entry) ? $entry : null,
);
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/FreeDSx/Ldap/Server/Middleware/ServerControlRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ final class ServerControlRegistry
/**
* Controls accepted on every handler that runs the check. Proxied authorization is global because the
* RFC 4370 eligibility gate runs upstream in ProxiedAuthorizationResolver, not here. ManageDsaIT is global
* because it is recognized server-wide and treated as inert (no referral entries to reinterpret).
* because it is recognized server-wide and treated as inert (no referral entries to reinterpret). The password
* policy request control is global because it may accompany any request, and its criticality may be TRUE.
*/
private const GLOBAL_CONTROLS = [
Control::OID_PROXY_AUTHORIZATION,
Control::OID_MANAGE_DSA_IT,
Control::OID_PWD_POLICY,
];

/**
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/LdapSaslServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public function testSaslScramSha256FailsWithInvalidCredentials(): void

public function testRootDseAdvertisesSaslMechanisms(): void
{
$rootDse = $this->ldapClient()->read('');
$rootDse = $this->ldapClient()->read('', ['+']);

$this->assertNotNull($rootDse);

Expand Down
Loading
Loading