diff --git a/docs/Server/Configuration.md b/docs/Server/Configuration.md index 687ff8c4..ae2edbbf 100644 --- a/docs/Server/Configuration.md +++ b/docs/Server/Configuration.md @@ -42,6 +42,8 @@ LDAP Server Configuration * [ServerOptions:setMaxSearchTimeLimit](#setmaxsearchtimelimit) * [ServerOptions:setMaxSearchPageSize](#setmaxsearchpagesize) * [ServerOptions:setMaxSearchLookthrough](#setmaxsearchlookthrough) + * [ServerOptions:setMaxSearchPagedLookthrough](#setmaxsearchpagedlookthrough) + * [ServerOptions:setSearchLimitRules](#setsearchlimitrules) * [SASL Options](#sasl-options) * [ServerOptions:setSaslMechanisms](#setsaslmechanisms) @@ -601,7 +603,54 @@ scans many entries to return few. Raise it above the largest legitimate subtree > It applies only to filters evaluated in PHP (array/JSON backends, and SQL backends when the filter cannot be pushed to the > index); indexed equality and prefix filters are bounded by the database and are not counted. Paged searches are subject to -> this limit cumulatively across all pages. +> the lookthrough limit cumulatively across all pages (see `setMaxSearchPagedLookthrough` to set a separate cap for paging). + +------------------ +#### setMaxSearchPagedLookthrough + +Set a lookthrough cap applied to paged searches, counted cumulatively across all pages. Paging is the standard way to retrieve +large result sets, so this lets you allow large paged enumerations without loosening the regular `setMaxSearchLookthrough` +for ordinary searches. A value of `0` falls back to the regular lookthrough limit. + +**Default**: `0` (use the regular lookthrough limit) + +------------------ +#### setSearchLimitRules + +Per-identity search limits: an ordered list of `(subject, limits)` rules, evaluated first-match-wins. The first rule whose +subject matches the bound identity supplies that request's limits; identities matching no rule get the global limits above. + +```php +use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRule; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRules; +use FreeDSx\Ldap\Server\SearchLimits; + +$rules = new SearchLimitRules([ + SearchLimitRule::for( + Subject::anonymous(), + new SearchLimits( + maxSearchSize: 100, + maxSearchLookthrough: 1000, + ), + ), + SearchLimitRule::for( + Subject::authenticated(), + new SearchLimits( + maxSearchSize: 1000, + maxSearchLookthrough: 20000, + ), + ), +]); + +$options->setSearchLimitRules($rules); +``` + +> Subjects reuse the access-control matchers (`Subject::anonymous()`, `Subject::authenticated()`, `Subject::dn()`, +> `Subject::group()`), so you can, for example, give anonymous binds a tight lookthrough while authenticated identities +> get a larger one. + +**Default**: none (all identities use the global limits) ## Monitoring diff --git a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php index 8f78f266..873f1c26 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php @@ -29,6 +29,7 @@ use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyComponentFactory; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyContext; use FreeDSx\Ldap\Server\RequestHistory; +use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\ServerOptions; /** @@ -57,6 +58,7 @@ public function __construct( public function get( RequestInterface $request, ControlBag $controls, + ?SearchLimits $searchLimits = null, ): ServerProtocolHandlerInterface { return match ($this->routeResolver->routeIdFor($request, $controls)) { HandlerId::Abandon => new ServerProtocolHandler\ServerAbandonHandler(), @@ -68,8 +70,8 @@ public function get( HandlerId::RootDse => $this->getRootDseHandler(), HandlerId::Subschema => $this->getSubschemaHandler(), HandlerId::Monitor => $this->getMonitorHandler(), - HandlerId::Paging => $this->getPagingHandler(), - HandlerId::Search => $this->getSearchHandler(), + HandlerId::Paging => $this->getPagingHandler($searchLimits), + HandlerId::Search => $this->getSearchHandler($searchLimits), HandlerId::Unbind => new ServerProtocolHandler\ServerUnbindHandler($this->queue), HandlerId::Dispatch => $this->getDispatchHandler(), }; @@ -119,7 +121,7 @@ private function getMonitorHandler(): ServerProtocolHandler\ServerMonitorHandler ); } - private function getSearchHandler(): ServerProtocolHandler\ServerSearchHandler + private function getSearchHandler(?SearchLimits $searchLimits): ServerProtocolHandler\ServerSearchHandler { return new ServerProtocolHandler\ServerSearchHandler( queue: $this->queue, @@ -127,7 +129,7 @@ private function getSearchHandler(): ServerProtocolHandler\ServerSearchHandler filterEvaluator: $this->options->getFilterEvaluator(), accessControl: $this->options->getAccessControl(), schema: $this->options->getSchema(), - limits: $this->options->makeSearchLimits(), + limits: $searchLimits ?? $this->options->makeSearchLimits(), ); } @@ -159,7 +161,7 @@ private function getRootDseHandler(): ServerProtocolHandler\ServerRootDseHandler ); } - private function getPagingHandler(): ServerProtocolHandler\ServerPagingHandler + private function getPagingHandler(?SearchLimits $searchLimits): ServerProtocolHandler\ServerPagingHandler { return new ServerProtocolHandler\ServerPagingHandler( queue: $this->queue, @@ -168,7 +170,7 @@ private function getPagingHandler(): ServerProtocolHandler\ServerPagingHandler accessControl: $this->options->getAccessControl(), requestHistory: $this->requestHistory, schema: $this->options->getSchema(), - limits: $this->options->makeSearchLimits(), + limits: $searchLimits ?? $this->options->makeSearchLimits(), ); } } diff --git a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProviderInterface.php b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProviderInterface.php index 518e113a..56f0c19d 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProviderInterface.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProviderInterface.php @@ -16,6 +16,7 @@ use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Operation\Request\RequestInterface; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerProtocolHandlerInterface; +use FreeDSx\Ldap\Server\SearchLimits; /** * Builds the protocol handler for a resolved request route. @@ -28,5 +29,6 @@ interface ProtocolHandlerProviderInterface public function get( RequestInterface $request, ControlBag $controls, + ?SearchLimits $searchLimits = null, ): ServerProtocolHandlerInterface; } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php index 35059ba8..90d88dff 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php @@ -191,9 +191,14 @@ private function handlePagingStart( ): PagingResponse { $searchRequest = $pagingRequest->getSearchRequest(); + // Paged searches use the paged lookthrough limit (falls back to the regular one when unset). $result = $this->backend->search( $searchRequest, $pagingRequest->controls(), + new SearchLimits( + maxSearchTimeLimit: $this->limits->maxSearchTimeLimit, + maxSearchLookthrough: $this->limits->effectivePagedLookthrough(), + ), ); $generator = $result->entries; diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php index 5785ac03..a9923dc5 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php @@ -67,6 +67,7 @@ public function handleRequest( $backendResult = $this->backend->search( $request, $this->controlsForBackend($message), + $this->limits, ); $projection = AttributeProjection::forRequest( diff --git a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php index 44f80247..fc75b71b 100644 --- a/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/LdapBackendInterface.php @@ -20,6 +20,7 @@ use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; +use FreeDSx\Ldap\Server\SearchLimits; /** * Read-side backend contract that read-only consumers depend on. @@ -28,9 +29,13 @@ */ interface LdapBackendInterface { + /** + * @param ?SearchLimits $effectiveLimits Per-request effective limits (time/lookthrough); null uses backend defaults. + */ public function search( SearchRequest $request, ControlBag $controls = new ControlBag(), + ?SearchLimits $effectiveLimits = null, ): EntryStream; /** diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php b/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php index 8733c3e5..fb3241e7 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/SearchStreamBuilder.php @@ -36,9 +36,11 @@ public function __construct( private FilterEvaluatorInterface $filterEvaluator = new FilterEvaluator(), ) {} - public function effectiveTimeLimit(int $requestLimit): int - { - $serverMax = $this->limits->maxSearchTimeLimit; + public function effectiveTimeLimit( + int $requestLimit, + ?SearchLimits $effectiveLimits = null, + ): int { + $serverMax = ($effectiveLimits ?? $this->limits)->maxSearchTimeLimit; if ($serverMax === 0) { return $requestLimit; @@ -71,6 +73,7 @@ public function buildForBaseObject( public function buildForList( EntryStream $stream, SearchRequest $request, + ?SearchLimits $effectiveLimits = null, ): EntryStream { $generator = $this->wrapWithTimeLimitHandling($stream->entries); @@ -78,6 +81,7 @@ public function buildForList( $generator = $this->wrapWithFilterEvaluation( $generator, $request->getFilter(), + $effectiveLimits, ); } @@ -129,8 +133,9 @@ private function injectHasSubordinates(Entry $entry): Entry private function wrapWithFilterEvaluation( Generator $generator, FilterInterface $filter, + ?SearchLimits $effectiveLimits = null, ): Generator { - $lookthrough = $this->limits->maxSearchLookthrough; + $lookthrough = ($effectiveLimits ?? $this->limits)->maxSearchLookthrough; $examined = 0; foreach ($generator as $entry) { diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index 03d63d9b..b6422a1d 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -147,7 +147,9 @@ public function compare( public function search( SearchRequest $request, ControlBag $controls = new ControlBag(), + ?SearchLimits $effectiveLimits = null, ): EntryStream { + $limits = $effectiveLimits ?? $this->limits; $baseDn = $request->getBaseDn() ?? new Dn(''); $normBase = $baseDn->normalize(); @@ -181,12 +183,12 @@ public function search( baseDn: $normBase, subtree: $subtree, filter: $request->getFilter(), - timeLimit: $this->searchStream->effectiveTimeLimit($request->getTimeLimit()), + timeLimit: $this->searchStream->effectiveTimeLimit($request->getTimeLimit(), $limits), sizeLimit: $request->getSizeLimit(), sortKeys: $sortingControl instanceof SortingControl ? $sortingControl->getSortKeys() : [], - lookthroughLimit: $this->limits->maxSearchLookthrough, + lookthroughLimit: $limits->maxSearchLookthrough, ); try { @@ -201,6 +203,7 @@ public function search( return $this->searchStream->buildForList( $stream, $request, + $limits, ); } diff --git a/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php b/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php index 86320c08..7842e64e 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php +++ b/src/FreeDSx/Ldap/Server/Middleware/Pipeline/HandlerInvoker.php @@ -31,6 +31,7 @@ public function handle(ServerRequestContext $context): OperationResult $handler = $this->protocolHandlerProvider->get( $context->message->getRequest(), $context->message->controls(), + $context->searchLimits(), ); return $handler->handleRequest( diff --git a/src/FreeDSx/Ldap/Server/Middleware/Pipeline/ServerRequestContext.php b/src/FreeDSx/Ldap/Server/Middleware/Pipeline/ServerRequestContext.php index 202a1671..8e64471f 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/Pipeline/ServerRequestContext.php +++ b/src/FreeDSx/Ldap/Server/Middleware/Pipeline/ServerRequestContext.php @@ -16,6 +16,7 @@ use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Server\Logging\ConnectionContext; +use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; /** @@ -30,6 +31,7 @@ public function __construct( public LdapMessageRequest $message, private ?TokenInterface $token = null, public ConnectionContext $connectionContext = new ConnectionContext(), + private ?SearchLimits $searchLimits = null, ) {} /** @@ -50,12 +52,31 @@ public function tokenOrFail(): TokenInterface return $this->token ?? throw new RuntimeException('No token has been resolved for this request.'); } + /** + * The per-identity search limits, or null before resolution runs. + */ + public function searchLimits(): ?SearchLimits + { + return $this->searchLimits; + } + public function withToken(TokenInterface $token): self { return new self( $this->message, $token, $this->connectionContext, + $this->searchLimits, + ); + } + + public function withSearchLimits(SearchLimits $searchLimits): self + { + return new self( + $this->message, + $this->token, + $this->connectionContext, + $searchLimits, ); } } diff --git a/src/FreeDSx/Ldap/Server/Middleware/ResourceLimitMiddleware.php b/src/FreeDSx/Ldap/Server/Middleware/ResourceLimitMiddleware.php new file mode 100644 index 00000000..5bbfa508 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Middleware/ResourceLimitMiddleware.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Middleware; + +use FreeDSx\Ldap\Server\Operation\OperationResult; +use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareHandlerInterface; +use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareInterface; +use FreeDSx\Ldap\Server\Middleware\Pipeline\ServerRequestContext; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitResolverInterface; + +/** + * Resolves the per-identity search limits for the request and attaches them to the context. + */ +final readonly class ResourceLimitMiddleware implements MiddlewareInterface +{ + public function __construct( + private SearchLimitResolverInterface $searchLimitResolver, + ) {} + + public function process( + ServerRequestContext $context, + MiddlewareHandlerInterface $next, + ): OperationResult { + $limits = $this->searchLimitResolver->resolve($context->tokenOrFail()); + + return $next->handle($context->withSearchLimits($limits)); + } +} diff --git a/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitResolver.php b/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitResolver.php new file mode 100644 index 00000000..24f5ae18 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitResolver.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\SearchLimit; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\AccessControl\BackendAwareInterface; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\SearchLimits; +use FreeDSx\Ldap\Server\Token\TokenInterface; + +/** + * Resolves per-identity search limits via ordered rules, first match wins, falling back to the global default. + */ +final class SearchLimitResolver implements SearchLimitResolverInterface, BackendAwareInterface +{ + /** + * Limit rules carry no target entry, so subject matchers that need one receive the bound DN (or root for anon). + */ + private const NO_TARGET = ''; + + public function __construct( + private readonly SearchLimitRules $rules, + private readonly SearchLimits $default, + ) {} + + public function setBackend(LdapBackendInterface $backend): void + { + foreach ($this->rules->rules as $rule) { + if ($rule->subject instanceof BackendAwareInterface) { + $rule->subject->setBackend($backend); + } + } + } + + public function resolve(TokenInterface $token): SearchLimits + { + $targetDn = new Dn(self::NO_TARGET); + + foreach ($this->rules->rules as $rule) { + if ($rule->subject->matches($token, $targetDn)) { + return $rule->limits; + } + } + + return $this->default; + } +} diff --git a/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitResolverInterface.php b/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitResolverInterface.php new file mode 100644 index 00000000..f7fb9478 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitResolverInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\SearchLimit; + +use FreeDSx\Ldap\Server\SearchLimits; +use FreeDSx\Ldap\Server\Token\TokenInterface; + +/** + * Resolves the effective search limits for a bound identity. + */ +interface SearchLimitResolverInterface +{ + public function resolve(TokenInterface $token): SearchLimits; +} diff --git a/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitRule.php b/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitRule.php new file mode 100644 index 00000000..c51591ce --- /dev/null +++ b/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitRule.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\SearchLimit; + +use FreeDSx\Ldap\Server\AccessControl\Subject\SubjectMatcherInterface; +use FreeDSx\Ldap\Server\SearchLimits; + +/** + * Pairs a subject with the search limits applied to identities it matches. + * + * @api + */ +final readonly class SearchLimitRule +{ + public function __construct( + public SubjectMatcherInterface $subject, + public SearchLimits $limits, + ) {} + + public static function for( + SubjectMatcherInterface $subject, + SearchLimits $limits, + ): self { + return new self( + $subject, + $limits, + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitRules.php b/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitRules.php new file mode 100644 index 00000000..02c9b285 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/SearchLimit/SearchLimitRules.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\SearchLimit; + +/** + * Ordered per-identity search-limit rules. + * + * Identities matching none fall back to the global limits. + * + * @api + */ +final readonly class SearchLimitRules +{ + /** + * @param SearchLimitRule[] $rules Evaluated in order; first match wins. + */ + public function __construct( + public array $rules = [], + ) {} + + public function withRules(SearchLimitRule ...$rules): self + { + return new self($rules); + } + + public function isEmpty(): bool + { + return $this->rules === []; + } +} diff --git a/src/FreeDSx/Ldap/Server/SearchLimits.php b/src/FreeDSx/Ldap/Server/SearchLimits.php index 04279351..368257c0 100644 --- a/src/FreeDSx/Ldap/Server/SearchLimits.php +++ b/src/FreeDSx/Ldap/Server/SearchLimits.php @@ -23,5 +23,16 @@ public function __construct( public int $maxSearchTimeLimit = 0, public int $maxSearchPageSize = 0, public int $maxSearchLookthrough = 0, + public int $maxSearchPagedLookthrough = 0, ) {} + + /** + * Effective lookthrough for paged searches: the paged limit when set, otherwise the regular lookthrough. + */ + public function effectivePagedLookthrough(): int + { + return $this->maxSearchPagedLookthrough > 0 + ? $this->maxSearchPagedLookthrough + : $this->maxSearchLookthrough; + } } diff --git a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php index 1ac34e8d..e4b94617 100644 --- a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php +++ b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php @@ -55,7 +55,9 @@ use FreeDSx\Ldap\Server\Middleware\OperationAuthorizationMiddleware; use FreeDSx\Ldap\Server\Middleware\OperationErrorMiddleware; use FreeDSx\Ldap\Server\Middleware\RequestValidationMiddleware; +use FreeDSx\Ldap\Server\Middleware\ResourceLimitMiddleware; use FreeDSx\Ldap\Server\Middleware\Pipeline\HandlerInvoker; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitResolver; use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareChain; use FreeDSx\Ldap\Server\PasswordPolicy\Guard\PasswordPolicyBindGuard; use FreeDSx\Ldap\Protocol\Queue\Response\MetricsResponseInterceptor; @@ -165,6 +167,12 @@ public function make( ), ); + $searchLimitResolver = new SearchLimitResolver( + $this->options->getSearchLimitRules(), + $this->options->makeSearchLimits(), + ); + $searchLimitResolver->setBackend($backend); + $handlerProvider = new ProtocolHandlerProvider( routeResolver: $this->routeResolver, handlerFactory: $this->handlerFactory, @@ -200,6 +208,8 @@ public function make( $dispatchAuthorizer, $policyContext, ), + // The token is resolved at this point, so per-identity limits can be attached. + new ResourceLimitMiddleware($searchLimitResolver), new OperationErrorMiddleware( $serverQueue, $backend, diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 816460fe..4a59a50a 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -38,6 +38,7 @@ use FreeDSx\Ldap\Server\Metrics\MetricsRecorderInterface; use FreeDSx\Ldap\Server\Metrics\Recorder\NullMetricsRecorder; use FreeDSx\Ldap\Server\RequestHandler\RootDseHandlerInterface; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRules; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; use FreeDSx\Ldap\Server\TlsVersion; @@ -207,6 +208,10 @@ final class ServerOptions private int $maxSearchLookthrough = 5000; + private int $maxSearchPagedLookthrough = 0; + + private ?SearchLimitRules $searchLimitRules = null; + private ?Closure $onServerReady = null; private ?ConfigReloaderInterface $configReloader = null; @@ -886,6 +891,35 @@ public function setMaxSearchLookthrough(int $maxSearchLookthrough): self return $this; } + /** + * Lookthrough cap for paged searches. + * + * A zero value falls back to the regular lookthrough. + */ + public function getMaxSearchPagedLookthrough(): int + { + return $this->maxSearchPagedLookthrough; + } + + public function setMaxSearchPagedLookthrough(int $maxSearchPagedLookthrough): self + { + $this->maxSearchPagedLookthrough = $maxSearchPagedLookthrough; + + return $this; + } + + public function setSearchLimitRules(SearchLimitRules $searchLimitRules): self + { + $this->searchLimitRules = $searchLimitRules; + + return $this; + } + + public function getSearchLimitRules(): SearchLimitRules + { + return $this->searchLimitRules ??= new SearchLimitRules(); + } + public function makeSearchLimits(): SearchLimits { return new SearchLimits( @@ -893,6 +927,7 @@ public function makeSearchLimits(): SearchLimits maxSearchTimeLimit: $this->maxSearchTimeLimit, maxSearchPageSize: $this->maxSearchPageSize, maxSearchLookthrough: $this->maxSearchLookthrough, + maxSearchPagedLookthrough: $this->maxSearchPagedLookthrough, ); } diff --git a/tests/integration/LdapServerTest.php b/tests/integration/LdapServerTest.php index fb9918bc..b6b19c43 100644 --- a/tests/integration/LdapServerTest.php +++ b/tests/integration/LdapServerTest.php @@ -320,6 +320,68 @@ public function testItCanHandlingPaging(): void $this->assertCount(5000, $allEntries); } + public function testPagedSearchHonorsAGenerousSeparatePagedLookthroughLimit(): void + { + $this->stopServer(); + $this->createServerProcess('tcp', [ + '--entries=5000', + '--max-search-lookthrough=10', + '--max-search-paged-lookthrough=100000', + ]); + $this->authenticate(); + + $search = Operations::search(Filters::raw('(foo=*)'))->base('dc=foo,dc=bar'); + $paging = $this->ldapClient()->paging($search); + + $count = 0; + while ($paging->hasEntries()) { + $count += count($paging->getEntries(500)->toArray()); + } + + $this->assertSame( + 5000, + $count, + ); + } + + public function testPagedSearchFallsBackToRegularLookthroughWhenPagedUnset(): void + { + $this->stopServer(); + $this->createServerProcess('tcp', [ + '--entries=5000', + '--max-search-lookthrough=10', + '--max-search-paged-lookthrough=0', + ]); + $this->authenticate(); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED); + + $search = Operations::search(Filters::raw('(foo=*)'))->base('dc=foo,dc=bar'); + $paging = $this->ldapClient()->paging($search); + while ($paging->hasEntries()) { + $paging->getEntries(500); + } + } + + public function testPerIdentityRuleAppliesAuthenticatedLookthrough(): void + { + $this->stopServer(); + $this->createServerProcess('tcp', [ + '--entries=50', + '--max-search-lookthrough=5000', + '--authenticated-lookthrough=5', + ]); + $this->authenticate(); + + $this->expectException(OperationException::class); + $this->expectExceptionCode(ResultCode::ADMIN_LIMIT_EXCEEDED); + + $this->ldapClient()->search( + Operations::search(Filters::raw('(foo=*)'))->base('dc=foo,dc=bar')->useSubtreeScope(), + ); + } + public function testItDoesASearchWhenPagingIsNotMarkedAsCritical(): void { $this->authenticate(); diff --git a/tests/support/Backend/RecordingLdapBackend.php b/tests/support/Backend/RecordingLdapBackend.php index e4013f8b..2bc20cb6 100644 --- a/tests/support/Backend/RecordingLdapBackend.php +++ b/tests/support/Backend/RecordingLdapBackend.php @@ -20,6 +20,7 @@ use FreeDSx\Ldap\Search\Filter\EqualityFilter; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; +use FreeDSx\Ldap\Server\SearchLimits; use Generator; /** @@ -53,6 +54,7 @@ public function getCallCount(string $dn): int public function search( SearchRequest $request, ControlBag $controls = new ControlBag(), + ?SearchLimits $effectiveLimits = null, ): EntryStream { return new EntryStream((static function (): Generator { yield from []; diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index 643e6ed2..fbb0ed9b 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -8,7 +8,11 @@ use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; use FreeDSx\Ldap\Ldif\Output\FileLdifOutput; +use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRule; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRules; +use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\MysqlStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqliteStorage; @@ -73,6 +77,20 @@ protected function configure(): void 'Maximum entries examined per search before adminLimitExceeded (0 = no limit)', '5000', ) + ->addOption( + 'max-search-paged-lookthrough', + null, + InputOption::VALUE_REQUIRED, + 'Lookthrough cap for paged searches (0 = fall back to the regular lookthrough)', + '0', + ) + ->addOption( + 'authenticated-lookthrough', + null, + InputOption::VALUE_REQUIRED, + 'When > 0, a per-identity rule giving authenticated identities this lookthrough', + '0', + ) ->addOption( 'sasl', null, @@ -169,8 +187,20 @@ protected function execute( ->setAllowAnonymous($allowAnonymous) ->setSocketAcceptTimeout(0.1) ->setMaxSearchLookthrough((int) $this->getStringOption($input, 'max-search-lookthrough')) + ->setMaxSearchPagedLookthrough((int) $this->getStringOption($input, 'max-search-paged-lookthrough')) ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)); + $authenticatedLookthrough = (int) $this->getStringOption($input, 'authenticated-lookthrough'); + if ($authenticatedLookthrough > 0) { + $rules = (new SearchLimitRules())->withRules( + SearchLimitRule::for( + Subject::authenticated(), + new SearchLimits(maxSearchLookthrough: $authenticatedLookthrough), + ), + ); + $options->setSearchLimitRules($rules); + } + if ($reloadFlagFile !== '') { $options->setConfigReloader(new FileFlagConfigReloader($reloadFlagFile)); } diff --git a/tests/unit/Server/SearchLimit/SearchLimitResolverTest.php b/tests/unit/Server/SearchLimit/SearchLimitResolverTest.php new file mode 100644 index 00000000..d96ca2f8 --- /dev/null +++ b/tests/unit/Server/SearchLimit/SearchLimitResolverTest.php @@ -0,0 +1,105 @@ + + * + * 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\SearchLimit; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitResolver; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRule; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRules; +use FreeDSx\Ldap\Server\SearchLimits; +use FreeDSx\Ldap\Server\Token\AnonToken; +use FreeDSx\Ldap\Server\Token\BindToken; +use PHPUnit\Framework\TestCase; + +final class SearchLimitResolverTest extends TestCase +{ + private SearchLimits $default; + + protected function setUp(): void + { + $this->default = new SearchLimits(maxSearchSize: 1000); + } + + public function test_it_returns_the_default_when_no_rule_matches(): void + { + $resolver = new SearchLimitResolver( + new SearchLimitRules(), + $this->default, + ); + + self::assertSame( + $this->default, + $resolver->resolve(new AnonToken()), + ); + } + + public function test_it_returns_the_first_matching_rule_limits(): void + { + $authLimits = new SearchLimits(maxSearchSize: 50); + $resolver = new SearchLimitResolver( + (new SearchLimitRules())->withRules( + SearchLimitRule::for(Subject::authenticated(), $authLimits), + ), + $this->default, + ); + + self::assertSame( + $authLimits, + $resolver->resolve($this->authenticatedToken()), + ); + } + + public function test_anonymous_falls_through_authenticated_rule_to_the_default(): void + { + $resolver = new SearchLimitResolver( + (new SearchLimitRules())->withRules( + SearchLimitRule::for(Subject::authenticated(), new SearchLimits(maxSearchSize: 50)), + ), + $this->default, + ); + + self::assertSame( + $this->default, + $resolver->resolve(new AnonToken()), + ); + } + + public function test_first_matching_rule_wins(): void + { + $first = new SearchLimits(maxSearchSize: 10); + $second = new SearchLimits(maxSearchSize: 20); + $resolver = new SearchLimitResolver( + (new SearchLimitRules())->withRules( + SearchLimitRule::for(Subject::authenticated(), $first), + SearchLimitRule::for(Subject::dn('cn=user,dc=foo,dc=bar'), $second), + ), + $this->default, + ); + + self::assertSame( + $first, + $resolver->resolve($this->authenticatedToken()), + ); + } + + private function authenticatedToken(): BindToken + { + return new BindToken( + 'cn=user,dc=foo,dc=bar', + '12345', + new Dn('cn=user,dc=foo,dc=bar'), + ); + } +} diff --git a/tests/unit/Server/SearchLimitsTest.php b/tests/unit/Server/SearchLimitsTest.php new file mode 100644 index 00000000..2eeb2320 --- /dev/null +++ b/tests/unit/Server/SearchLimitsTest.php @@ -0,0 +1,46 @@ + + * + * 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; + +use FreeDSx\Ldap\Server\SearchLimits; +use PHPUnit\Framework\TestCase; + +final class SearchLimitsTest extends TestCase +{ + public function test_effective_paged_lookthrough_uses_the_paged_value_when_set(): void + { + $limits = new SearchLimits( + maxSearchLookthrough: 5000, + maxSearchPagedLookthrough: 100000, + ); + + self::assertSame( + 100000, + $limits->effectivePagedLookthrough(), + ); + } + + public function test_effective_paged_lookthrough_falls_back_to_the_regular_value(): void + { + $limits = new SearchLimits( + maxSearchLookthrough: 5000, + maxSearchPagedLookthrough: 0, + ); + + self::assertSame( + 5000, + $limits->effectivePagedLookthrough(), + ); + } +} diff --git a/tests/unit/ServerOptionsTest.php b/tests/unit/ServerOptionsTest.php index 0b7fc061..5985c7b4 100644 --- a/tests/unit/ServerOptionsTest.php +++ b/tests/unit/ServerOptionsTest.php @@ -31,6 +31,9 @@ use FreeDSx\Ldap\Server\Metrics\Recorder\InMemoryMetricsRecorder; use FreeDSx\Ldap\Server\Metrics\Recorder\NullMetricsRecorder; use FreeDSx\Ldap\Server\ServerRunner\ServerRunnerInterface; +use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRule; +use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRules; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\TlsVersion; use FreeDSx\Ldap\ServerOptions; @@ -742,13 +745,50 @@ public function test_it_can_set_max_search_lookthrough(): void ); } + public function test_max_search_paged_lookthrough_defaults_to_0(): void + { + self::assertSame( + 0, + $this->subject->getMaxSearchPagedLookthrough(), + ); + } + + public function test_it_can_set_max_search_paged_lookthrough(): void + { + $this->subject->setMaxSearchPagedLookthrough(100000); + + self::assertSame( + 100000, + $this->subject->getMaxSearchPagedLookthrough(), + ); + } + + public function test_search_limit_rules_default_to_empty(): void + { + self::assertTrue($this->subject->getSearchLimitRules()->isEmpty()); + } + + public function test_it_can_set_search_limit_rules(): void + { + $rules = (new SearchLimitRules())->withRules( + SearchLimitRule::for(Subject::anonymous(), new SearchLimits(maxSearchSize: 10)), + ); + $this->subject->setSearchLimitRules($rules); + + self::assertSame( + $rules, + $this->subject->getSearchLimitRules(), + ); + } + public function test_make_search_limits_reflects_current_options(): void { $this->subject ->setMaxSearchSize(500) ->setMaxSearchTimeLimit(60) ->setMaxSearchPageSize(250) - ->setMaxSearchLookthrough(5000); + ->setMaxSearchLookthrough(5000) + ->setMaxSearchPagedLookthrough(100000); self::assertEquals( new SearchLimits( @@ -756,6 +796,7 @@ public function test_make_search_limits_reflects_current_options(): void maxSearchTimeLimit: 60, maxSearchPageSize: 250, maxSearchLookthrough: 5000, + maxSearchPagedLookthrough: 100000, ), $this->subject->makeSearchLimits(), );