diff --git a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php index be6e61b2..f6b7d86d 100644 --- a/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php +++ b/src/FreeDSx/Ldap/Protocol/Factory/ProtocolHandlerProvider.php @@ -86,7 +86,6 @@ private function getPasswordModifyHandler(): ServerProtocolHandler\ServerPasswor ), passwordPolicyContext: $this->passwordPolicyContext, ), - eventLogger: $this->eventLogger, passwordPolicyContext: $this->passwordPolicyContext, ); } @@ -117,7 +116,6 @@ private function getSearchHandler(): ServerProtocolHandler\ServerSearchHandler accessControl: $this->options->getAccessControl(), schema: $this->options->getSchema(), limits: $this->options->makeSearchLimits(), - eventLogger: $this->eventLogger, ); } @@ -158,7 +156,6 @@ private function getPagingHandler(): ServerProtocolHandler\ServerPagingHandler requestHistory: $this->requestHistory, schema: $this->options->getSchema(), limits: $this->options->makeSearchLimits(), - eventLogger: $this->eventLogger, ); } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php index 09d7fec0..ee946560 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPagingHandler.php @@ -25,15 +25,14 @@ use FreeDSx\Ldap\Protocol\Queue\ServerQueue; use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; -use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Paging\PagingRequest; use FreeDSx\Ldap\Server\Paging\PagingRequestComparator; use FreeDSx\Ldap\Server\Paging\PagingResponse; use FreeDSx\Ldap\Server\RequestHistory; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; -use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; use FreeDSx\Ldap\Server\Operation\OperationResult; +use FreeDSx\Ldap\Server\Operation\SearchOperationResult; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; use Generator; @@ -58,7 +57,6 @@ public function __construct( private readonly Schema $schema, private readonly PagingRequestComparator $requestComparator = new PagingRequestComparator(), private readonly SearchLimits $limits = new SearchLimits(), - private readonly EventLogger $eventLogger = new EventLogger(null), ) {} /** @@ -74,6 +72,8 @@ public function handleRequest( $response = null; $controls = []; + $entriesReturned = 0; + $failure = null; try { $this->assertBaseDnProvided($searchRequest); $response = $this->handlePaging( @@ -99,7 +99,9 @@ public function handleRequest( : $pagingRequest->getNextCookie(), ); } + $entriesReturned = $response->getEntries()->count(); } catch (OperationException $e) { + $failure = $e; $matchedDn = $this->filterMatchedDn( $e->getMatchedDn(), $token, @@ -114,19 +116,6 @@ public function handleRequest( $e->getMessage(), ); $controls[] = new PagingControl(0, ''); - $this->eventLogger->recordSearchFailure( - $message, - $e, - $token, - ); - } - - if ($response !== null) { - $this->eventLogger->recordSearchSuccess( - $message, - $response->getEntries()->count(), - $token, - ); } $sortControl = $this->sortingControl($message); @@ -159,9 +148,15 @@ public function handleRequest( ...$controls, ); - return $response !== null - ? OperationOutcomeResult::succeeded() - : OperationOutcomeResult::failed(); + return $failure !== null + ? SearchOperationResult::failure( + $message, + $failure, + ) + : SearchOperationResult::success( + $message, + $entriesReturned, + ); } /** diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordModifyHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordModifyHandler.php index 9cb22ebd..71103e03 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordModifyHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerPasswordModifyHandler.php @@ -15,7 +15,6 @@ use FreeDSx\Asn1\Exception\EncoderException; use FreeDSx\Ldap\Control\Control; -use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\LdapResult; use FreeDSx\Ldap\Operation\Request\ExtendedRequest; @@ -26,11 +25,8 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\LdapMessageResponse; use FreeDSx\Ldap\Protocol\Queue\ServerQueue; -use FreeDSx\Ldap\Server\Logging\EventContext; -use FreeDSx\Ldap\Server\Logging\EventLogger; -use FreeDSx\Ldap\Server\Logging\ServerEvent; -use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; use FreeDSx\Ldap\Server\Operation\OperationResult; +use FreeDSx\Ldap\Server\Operation\PasswordModifyOperationResult; use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyResult; use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyService; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyContext; @@ -47,7 +43,6 @@ public function __construct( private ServerQueue $queue, private PasswordModifyService $service, - private EventLogger $eventLogger = new EventLogger(null), private ResponseFactory $responseFactory = new ResponseFactory(), private ?PasswordPolicyContext $passwordPolicyContext = null, ) {} @@ -83,26 +78,18 @@ public function handleRequest( null, ...($control === null ? [] : [$control]), )); - $this->recordFailure( + + return PasswordModifyOperationResult::failure( + $message, $e, - $token, $targetDn, - $message, ); - - return OperationOutcomeResult::failed(); } - $this->eventLogger->record( - ServerEvent::PasswordModifySuccess, - [ - EventContext::TARGET => [EventContext::DN => $targetDn->toString()], - ], - subject: $token, - message: $message, + return PasswordModifyOperationResult::success( + $message, + $targetDn, ); - - return OperationOutcomeResult::succeeded(); } /** @@ -151,35 +138,4 @@ private function passwordPolicyControl(): ?Control return $control; } - - private function recordFailure( - OperationException $exception, - TokenInterface $token, - ?Dn $targetDn, - LdapMessageRequest $message, - ): void { - $event = ServerEvent::fromOperationException( - $exception, - ServerEvent::AuthorizationDeniedWrite, - ServerEvent::PasswordModifyFailed, - ); - - if ($event === null) { - return; - } - - $context = []; - - if ($targetDn !== null) { - $context[EventContext::TARGET] = [EventContext::DN => $targetDn->toString()]; - } - - $this->eventLogger->recordFailure( - $event, - $exception, - $context, - subject: $token, - message: $message, - ); - } } diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php index 2687a595..bf740020 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerSearchHandler.php @@ -27,9 +27,8 @@ use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; -use FreeDSx\Ldap\Server\Logging\EventLogger; -use FreeDSx\Ldap\Server\Operation\OperationOutcomeResult; use FreeDSx\Ldap\Server\Operation\OperationResult; +use FreeDSx\Ldap\Server\Operation\SearchOperationResult; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; use Generator; @@ -54,7 +53,6 @@ public function __construct( private readonly AccessControlInterface $accessControl, private readonly Schema $schema, private readonly SearchLimits $limits = new SearchLimits(), - private readonly EventLogger $eventLogger = new EventLogger(null), ) {} /** @@ -66,7 +64,7 @@ public function handleRequest( ): OperationResult { $request = $this->getSearchRequestFromMessage($message); $state = new SearchResultState(); - $isSuccessful = true; + $failure = null; try { $this->assertBaseDnProvided($request); @@ -94,7 +92,7 @@ public function handleRequest( $state, ); } catch (OperationException $e) { - $isSuccessful = false; + $failure = $e; $matchedDn = $this->filterMatchedDn( $e->getMatchedDn(), $token, @@ -108,11 +106,6 @@ public function handleRequest( : '', $e->getMessage(), ); - $this->eventLogger->recordSearchFailure( - $message, - $e, - $token, - ); } $sortControl = $this->sortingControl($message); @@ -127,17 +120,15 @@ public function handleRequest( ...$responseControls, ); - if ($isSuccessful) { - $this->eventLogger->recordSearchSuccess( + return $failure !== null + ? SearchOperationResult::failure( + $message, + $failure, + ) + : SearchOperationResult::success( $message, $state->entriesReturned, - $token, ); - } - - return $isSuccessful - ? OperationOutcomeResult::succeeded() - : OperationOutcomeResult::failed(); } /** diff --git a/src/FreeDSx/Ldap/Server/AccessControl/OperationTargetDn.php b/src/FreeDSx/Ldap/Server/AccessControl/OperationTargetDn.php new file mode 100644 index 00000000..c0601b42 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/AccessControl/OperationTargetDn.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\AccessControl; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Operation\Request\AddRequest; +use FreeDSx\Ldap\Operation\Request\CompareRequest; +use FreeDSx\Ldap\Operation\Request\DeleteRequest; +use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; +use FreeDSx\Ldap\Operation\Request\ModifyRequest; +use FreeDSx\Ldap\Operation\Request\RequestInterface; + +/** + * Resolves the primary target DN an operation request acts on. + * + * @author Chad Sikorra + */ +final class OperationTargetDn +{ + public static function of(RequestInterface $request): ?Dn + { + return match (true) { + $request instanceof AddRequest => $request->getEntry()->getDn(), + $request instanceof ModifyRequest, + $request instanceof DeleteRequest, + $request instanceof ModifyDnRequest, + $request instanceof CompareRequest => $request->getDn(), + default => null, + }; + } +} diff --git a/src/FreeDSx/Ldap/Server/Logging/EventLogger.php b/src/FreeDSx/Ldap/Server/Logging/EventLogger.php index 3a441794..5bb2e24f 100644 --- a/src/FreeDSx/Ldap/Server/Logging/EventLogger.php +++ b/src/FreeDSx/Ldap/Server/Logging/EventLogger.php @@ -14,7 +14,6 @@ namespace FreeDSx\Ldap\Server\Logging; use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -150,68 +149,6 @@ public function recordFailure( ); } - public function recordSearchSuccess( - LdapMessageRequest $message, - int $entriesReturned, - TokenInterface $token, - ): void { - $request = $message->getRequest(); - - if (!$request instanceof SearchRequest) { - return; - } - - $this->record( - ServerEvent::SearchAuthorized, - [ - EventContext::ENTRIES_RETURNED => $entriesReturned, - EventContext::TARGET => self::searchTarget($request), - ], - subject: $token, - message: $message, - ); - } - - public function recordSearchFailure( - LdapMessageRequest $message, - OperationException $exception, - TokenInterface $token, - ): void { - $event = ServerEvent::fromOperationException( - $exception, - ServerEvent::AuthorizationDeniedRead, - ); - - if ($event === null) { - return; - } - - $request = $message->getRequest(); - - if (!$request instanceof SearchRequest) { - return; - } - - $this->recordFailure( - $event, - $exception, - [EventContext::TARGET => self::searchTarget($request)], - subject: $token, - message: $message, - ); - } - - /** - * @return array - */ - private static function searchTarget(SearchRequest $request): array - { - return [ - EventContext::BASE_DN => (string) $request->getBaseDn(), - EventContext::SCOPE => $request->getScope(), - ]; - } - /** * @return array */ diff --git a/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php b/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php index 7a24a465..b53a0d90 100644 --- a/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php +++ b/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php @@ -15,19 +15,18 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Operation\Request\AddRequest; use FreeDSx\Ldap\Operation\Request\CompareRequest; -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 FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Protocol\LdapMessageRequest; +use FreeDSx\Ldap\Server\AccessControl\OperationTargetDn; use FreeDSx\Ldap\Server\AccessControl\OperationType; use FreeDSx\Ldap\Server\Backend\Write\SchemaViolations; use FreeDSx\Ldap\Server\Token\TokenInterface; /** - * Builds the per-operation event shape for dispatched operations and routes it through {@see EventLogger}. + * Builds the per-operation audit event shape and routes it through {@see EventLogger}. * * @author Chad Sikorra */ @@ -88,6 +87,103 @@ public function recordCompareCompleted( ); } + public function recordSearchSuccess( + LdapMessageRequest $message, + int $entriesReturned, + TokenInterface $token, + ): void { + $request = $message->getRequest(); + + if (!$request instanceof SearchRequest) { + return; + } + + $this->eventLogger->record( + ServerEvent::SearchAuthorized, + [ + EventContext::ENTRIES_RETURNED => $entriesReturned, + EventContext::TARGET => $this->searchTarget($request), + ], + subject: $token, + message: $message, + ); + } + + public function recordSearchFailure( + LdapMessageRequest $message, + OperationException $exception, + TokenInterface $token, + ): void { + $event = ServerEvent::fromOperationException( + $exception, + ServerEvent::AuthorizationDeniedRead, + ); + + if ($event === null) { + return; + } + + $request = $message->getRequest(); + + if (!$request instanceof SearchRequest) { + return; + } + + $this->eventLogger->recordFailure( + $event, + $exception, + [EventContext::TARGET => $this->searchTarget($request)], + subject: $token, + message: $message, + ); + } + + public function recordPasswordModifySuccess( + LdapMessageRequest $message, + Dn $targetDn, + TokenInterface $token, + ): void { + $this->eventLogger->record( + ServerEvent::PasswordModifySuccess, + [ + EventContext::TARGET => [EventContext::DN => $targetDn->toString()], + ], + subject: $token, + message: $message, + ); + } + + public function recordPasswordModifyFailure( + LdapMessageRequest $message, + OperationException $exception, + ?Dn $targetDn, + TokenInterface $token, + ): void { + $event = ServerEvent::fromOperationException( + $exception, + ServerEvent::AuthorizationDeniedWrite, + ServerEvent::PasswordModifyFailed, + ); + + if ($event === null) { + return; + } + + $context = []; + + if ($targetDn !== null) { + $context[EventContext::TARGET] = [EventContext::DN => $targetDn->toString()]; + } + + $this->eventLogger->recordFailure( + $event, + $exception, + $context, + subject: $token, + message: $message, + ); + } + public function recordFailure( LdapMessageRequest $message, OperationException $exception, @@ -154,6 +250,17 @@ private function writeEventContext( ); } + /** + * @return array + */ + private function searchTarget(SearchRequest $request): array + { + return [ + EventContext::BASE_DN => (string) $request->getBaseDn(), + EventContext::SCOPE => $request->getScope(), + ]; + } + /** * @return array */ @@ -163,7 +270,7 @@ private function writeTargetFor(RequestInterface $request): array return $this->modifyDnTarget($request); } - return [EventContext::DN => $this->dnFor($request)?->toString() ?? '']; + return [EventContext::DN => OperationTargetDn::of($request)?->toString() ?? '']; } /** @@ -183,16 +290,4 @@ private function modifyDnTarget(ModifyDnRequest $request): array return $target; } - - private function dnFor(RequestInterface $request): ?Dn - { - return match (true) { - $request instanceof AddRequest => $request->getEntry()->getDn(), - $request instanceof ModifyRequest, - $request instanceof DeleteRequest, - $request instanceof ModifyDnRequest, - $request instanceof CompareRequest => $request->getDn(), - default => null, - }; - } } diff --git a/src/FreeDSx/Ldap/Server/Middleware/OperationAuthorizationMiddleware.php b/src/FreeDSx/Ldap/Server/Middleware/OperationAuthorizationMiddleware.php index 3db4fa00..b28ad5e7 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/OperationAuthorizationMiddleware.php +++ b/src/FreeDSx/Ldap/Server/Middleware/OperationAuthorizationMiddleware.php @@ -15,11 +15,9 @@ use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Control\ControlBag; -use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\Request\AddRequest; use FreeDSx\Ldap\Operation\Request\CompareRequest; -use FreeDSx\Ldap\Operation\Request\DeleteRequest; use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; use FreeDSx\Ldap\Operation\Request\ModifyRequest; use FreeDSx\Ldap\Operation\Request\RequestInterface; @@ -28,6 +26,7 @@ use FreeDSx\Ldap\Protocol\Factory\HandlerRouteResolverInterface; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; +use FreeDSx\Ldap\Server\AccessControl\OperationTargetDn; use FreeDSx\Ldap\Server\AccessControl\OperationType; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Logging\OperationAuditor; @@ -50,14 +49,14 @@ */ private const PRIVILEGED_CONTROLS = [Control::OID_RELAX_RULES]; - private OperationAuditor $writeAuditRecorder; + private OperationAuditor $auditor; public function __construct( private HandlerRouteResolverInterface $routeResolver, private AccessControlInterface $accessControl, - private EventLogger $eventLogger = new EventLogger(null), + EventLogger $eventLogger = new EventLogger(null), ) { - $this->writeAuditRecorder = new OperationAuditor($eventLogger); + $this->auditor = new OperationAuditor($eventLogger); } /** @@ -113,7 +112,7 @@ private function authorizeSearch( $baseDn, ); } catch (OperationException $e) { - $this->eventLogger->recordSearchFailure( + $this->auditor->recordSearchFailure( $message, $e, $token, @@ -158,7 +157,7 @@ private function authorizeDispatch( $token, ); } catch (OperationException $e) { - $this->writeAuditRecorder->recordFailure( + $this->auditor->recordFailure( $message, $e, $token, @@ -190,7 +189,7 @@ private function authorizeRequest( return; } - $dn = $this->dnFor($request); + $dn = OperationTargetDn::of($request); if ($dn === null) { return; @@ -271,7 +270,7 @@ private function authorizeControls( ControlBag $controls, TokenInterface $token, ): void { - $dn = $this->dnFor($request); + $dn = OperationTargetDn::of($request); if ($dn === null) { return; @@ -289,16 +288,4 @@ private function authorizeControls( ); } } - - private function dnFor(RequestInterface $request): ?Dn - { - return match (true) { - $request instanceof AddRequest => $request->getEntry()->getDn(), - $request instanceof ModifyRequest, - $request instanceof DeleteRequest, - $request instanceof ModifyDnRequest, - $request instanceof CompareRequest => $request->getDn(), - default => null, - }; - } } diff --git a/src/FreeDSx/Ldap/Server/Operation/PasswordModifyOperationResult.php b/src/FreeDSx/Ldap/Server/Operation/PasswordModifyOperationResult.php new file mode 100644 index 00000000..b3dbbce2 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Operation/PasswordModifyOperationResult.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Operation; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Protocol\LdapMessageRequest; +use FreeDSx\Ldap\Server\Logging\OperationAuditor; +use FreeDSx\Ldap\Server\Token\TokenInterface; + +/** + * The outcome of a password modify, carrying the resolved target when known. + * + * @internal + * @author Chad Sikorra + */ +final readonly class PasswordModifyOperationResult implements AuditableResult +{ + private function __construct( + private LdapMessageRequest $message, + private ?Dn $targetDn = null, + private ?OperationException $failure = null, + ) {} + + public static function success( + LdapMessageRequest $message, + Dn $targetDn, + ): self { + return new self( + $message, + $targetDn, + ); + } + + public static function failure( + LdapMessageRequest $message, + OperationException $exception, + ?Dn $targetDn = null, + ): self { + return new self( + $message, + $targetDn, + $exception, + ); + } + + public function outcome(): OperationOutcome + { + return $this->failure === null + ? OperationOutcome::Succeeded + : OperationOutcome::Failed; + } + + public function record( + OperationAuditor $auditor, + TokenInterface $token, + ): void { + if ($this->failure !== null) { + $auditor->recordPasswordModifyFailure( + $this->message, + $this->failure, + $this->targetDn, + $token, + ); + + return; + } + + if ($this->targetDn !== null) { + $auditor->recordPasswordModifySuccess( + $this->message, + $this->targetDn, + $token, + ); + } + } +} diff --git a/src/FreeDSx/Ldap/Server/Operation/SearchOperationResult.php b/src/FreeDSx/Ldap/Server/Operation/SearchOperationResult.php new file mode 100644 index 00000000..501f2eba --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Operation/SearchOperationResult.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Operation; + +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Protocol\LdapMessageRequest; +use FreeDSx\Ldap\Server\Logging\OperationAuditor; +use FreeDSx\Ldap\Server\Token\TokenInterface; + +/** + * The outcome of a search, carrying the entries returned on success. + * + * @internal + * @author Chad Sikorra + */ +final readonly class SearchOperationResult implements AuditableResult +{ + private function __construct( + private LdapMessageRequest $message, + private int $entriesReturned = 0, + private ?OperationException $failure = null, + ) {} + + public static function success( + LdapMessageRequest $message, + int $entriesReturned, + ): self { + return new self( + $message, + $entriesReturned, + ); + } + + public static function failure( + LdapMessageRequest $message, + OperationException $exception, + ): self { + return new self( + $message, + failure: $exception, + ); + } + + public function outcome(): OperationOutcome + { + return $this->failure === null + ? OperationOutcome::Succeeded + : OperationOutcome::Failed; + } + + public function record( + OperationAuditor $auditor, + TokenInterface $token, + ): void { + if ($this->failure !== null) { + $auditor->recordSearchFailure( + $this->message, + $this->failure, + $token, + ); + + return; + } + + $auditor->recordSearchSuccess( + $this->message, + $this->entriesReturned, + $token, + ); + } +} diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php index 1565961f..28ca8b12 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPagingHandlerTest.php @@ -38,6 +38,8 @@ use FreeDSx\Ldap\Server\Paging\PagingRequest; use FreeDSx\Ldap\Server\RequestHistory; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; +use FreeDSx\Ldap\Server\Operation\OperationOutcome; +use FreeDSx\Ldap\Server\Operation\SearchOperationResult; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; use Generator; @@ -158,7 +160,7 @@ public function test_it_should_call_the_backend_search_on_paging_start_and_retur ->with(self::isInstanceOf(SearchRequest::class)) ->willReturn(new EntryStream($this->makeGenerator($entry1, $entry2))); - $this->subject->handleRequest( + $result = $this->subject->handleRequest( $message, $this->mockToken, ); @@ -172,6 +174,11 @@ public function test_it_should_call_the_backend_search_on_paging_start_and_retur ); // Generator was exhausted with only 2 entries, so paging is complete (cookie=''). self::assertSame('', $this->donePagingControl()->getCookie()); + self::assertInstanceOf(SearchOperationResult::class, $result); + self::assertSame( + OperationOutcome::Succeeded, + $result->outcome(), + ); } public function test_it_should_store_the_generator_and_return_a_cookie_when_more_entries_remain(): void @@ -309,7 +316,7 @@ public function test_it_sends_an_operations_error_when_the_paging_generator_has_ ->expects(self::never()) ->method('search'); - $this->subject->handleRequest( + $result = $this->subject->handleRequest( $message, $this->mockToken, ); @@ -319,6 +326,11 @@ public function test_it_sends_an_operations_error_when_the_paging_generator_has_ self::assertInstanceOf(SearchResultDone::class, $done); self::assertSame(ResultCode::OPERATIONS_ERROR, $done->getResultCode()); self::assertSame('', $this->donePagingControl()->getCookie()); + self::assertInstanceOf(SearchOperationResult::class, $result); + self::assertSame( + OperationOutcome::Failed, + $result->outcome(), + ); } public function test_it_throws_an_exception_if_the_paging_cookie_does_not_exist(): void diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordModifyHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordModifyHandlerTest.php index fea2678f..71b9d702 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordModifyHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordModifyHandlerTest.php @@ -28,6 +28,8 @@ use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; +use FreeDSx\Ldap\Server\Operation\OperationOutcome; +use FreeDSx\Ldap\Server\Operation\PasswordModifyOperationResult; use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyService; use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyTargetResolver; use FreeDSx\Ldap\Server\Token\AnonToken; @@ -97,13 +99,19 @@ public function test_self_service_change_sends_success_response(): void && $r->getResponse()->getGeneratedPassword() === null, )); - $this->subject->handleRequest( + $result = $this->subject->handleRequest( new LdapMessageRequest( 1, new PasswordModifyRequest(null, '12345', 'newpass'), ), $this->userToken, ); + + self::assertInstanceOf(PasswordModifyOperationResult::class, $result); + self::assertSame( + OperationOutcome::Succeeded, + $result->outcome(), + ); } public function test_server_generated_password_is_returned_in_response(): void @@ -162,12 +170,18 @@ public function test_an_operation_error_is_sent_as_a_standard_response(): void && $r->getResponse()->getResultCode() === ResultCode::INVALID_CREDENTIALS, )); - $this->subject->handleRequest( + $result = $this->subject->handleRequest( new LdapMessageRequest( 1, new PasswordModifyRequest(null, 'wrongpass', 'newpass'), ), $this->userToken, ); + + self::assertInstanceOf(PasswordModifyOperationResult::class, $result); + self::assertSame( + OperationOutcome::Failed, + $result->outcome(), + ); } } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php index b5a2b0ac..567c13fc 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSearchHandlerTest.php @@ -38,6 +38,8 @@ use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Storage\EntryStream; use FreeDSx\Ldap\Server\Backend\Storage\FilterEvaluatorInterface; +use FreeDSx\Ldap\Server\Operation\OperationOutcome; +use FreeDSx\Ldap\Server\Operation\SearchOperationResult; use FreeDSx\Ldap\Server\SearchLimits; use FreeDSx\Ldap\Server\Token\TokenInterface; use Generator; @@ -138,7 +140,7 @@ public function test_it_should_send_entries_from_the_backend_to_the_client(): vo ->method('evaluate') ->willReturn(true); - $this->subject->handleRequest( + $result = $this->subject->handleRequest( $search, $this->mockToken, ); @@ -151,6 +153,11 @@ public function test_it_should_send_entries_from_the_backend_to_the_client(): vo new SearchResultDone(0, 'dc=foo,dc=bar'), ), ]); + self::assertInstanceOf(SearchOperationResult::class, $result); + self::assertSame( + OperationOutcome::Succeeded, + $result->outcome(), + ); } public function test_entry_stripped_by_acl_is_excluded_when_it_no_longer_matches_filter(): void @@ -214,7 +221,7 @@ public function test_it_should_send_a_SearchResultDone_with_an_operation_excepti ), ); - $this->subject->handleRequest( + $result = $this->subject->handleRequest( $search, $this->mockToken, ); @@ -229,6 +236,11 @@ public function test_it_should_send_a_SearchResultDone_with_an_operation_excepti ), ), ]); + self::assertInstanceOf(SearchOperationResult::class, $result); + self::assertSame( + OperationOutcome::Failed, + $result->outcome(), + ); } public function test_it_should_return_size_limit_exceeded_with_partial_results_when_limit_is_hit(): void diff --git a/tests/unit/Server/AccessControl/OperationTargetDnTest.php b/tests/unit/Server/AccessControl/OperationTargetDnTest.php new file mode 100644 index 00000000..36b5366f --- /dev/null +++ b/tests/unit/Server/AccessControl/OperationTargetDnTest.php @@ -0,0 +1,75 @@ + + * + * 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\AccessControl; + +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Operation\Request\AddRequest; +use FreeDSx\Ldap\Operation\Request\CompareRequest; +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 FreeDSx\Ldap\Operation\Request\SearchRequest; +use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\AccessControl\OperationTargetDn; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class OperationTargetDnTest extends TestCase +{ + #[DataProvider('provideRequests')] + public function test_it_resolves_the_target_dn( + RequestInterface $request, + ?string $expected, + ): void { + self::assertSame( + $expected, + OperationTargetDn::of($request)?->toString(), + ); + } + + /** + * @return array + */ + public static function provideRequests(): array + { + return [ + 'add uses the entry dn' => [ + new AddRequest(Entry::create('cn=add,dc=foo,dc=bar')), + 'cn=add,dc=foo,dc=bar', + ], + 'modify uses the request dn' => [ + new ModifyRequest('cn=mod,dc=foo,dc=bar', Change::replace('cn', 'x')), + 'cn=mod,dc=foo,dc=bar', + ], + 'delete uses the request dn' => [ + new DeleteRequest('cn=del,dc=foo,dc=bar'), + 'cn=del,dc=foo,dc=bar', + ], + 'modify dn uses the source dn' => [ + new ModifyDnRequest('cn=ren,dc=foo,dc=bar', 'cn=new', true), + 'cn=ren,dc=foo,dc=bar', + ], + 'compare uses the request dn' => [ + new CompareRequest('cn=cmp,dc=foo,dc=bar', Filters::equal('cn', 'x')), + 'cn=cmp,dc=foo,dc=bar', + ], + 'search has no single target' => [ + (new SearchRequest(Filters::present('cn')))->base('dc=foo,dc=bar'), + null, + ], + ]; + } +} diff --git a/tests/unit/Server/Logging/OperationAuditorTest.php b/tests/unit/Server/Logging/OperationAuditorTest.php index 6ad4bf2d..87c1de60 100644 --- a/tests/unit/Server/Logging/OperationAuditorTest.php +++ b/tests/unit/Server/Logging/OperationAuditorTest.php @@ -24,10 +24,13 @@ use FreeDSx\Ldap\Operation\Request\DeleteRequest; use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; use FreeDSx\Ldap\Operation\Request\ModifyRequest; +use FreeDSx\Ldap\Operation\Request\PasswordModifyRequest; use FreeDSx\Ldap\Operation\Request\RequestInterface; +use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Search\Filter\EqualityFilter; +use FreeDSx\Ldap\Search\Filters; use FreeDSx\Ldap\Server\Backend\Write\SchemaViolationDisposition; use FreeDSx\Ldap\Server\Backend\Write\SchemaViolations; use FreeDSx\Ldap\Server\Logging\EventContext; @@ -210,6 +213,118 @@ public function test_record_compare_completed_carries_attribute_and_match(): voi ); } + public function test_record_search_success_carries_entries_and_target(): void + { + $request = (new SearchRequest(Filters::present('cn')))->base('dc=example,dc=com'); + + $this->subject->recordSearchSuccess( + self::wrap($request), + 42, + $this->token, + ); + + $record = $this->onlyRecord(); + self::assertSame( + 'search.authorized', + $record['message'], + ); + self::assertSame( + 42, + $record['context'][EventContext::ENTRIES_RETURNED], + ); + self::assertSame( + [ + EventContext::BASE_DN => 'dc=example,dc=com', + EventContext::SCOPE => $request->getScope(), + ], + $record['context'][EventContext::TARGET], + ); + } + + public function test_record_search_failure_discriminates_a_read_denial(): void + { + $request = (new SearchRequest(Filters::present('cn')))->base('dc=example,dc=com'); + + $this->subject->recordSearchFailure( + self::wrap($request), + new OperationException( + 'Denied', + ResultCode::INSUFFICIENT_ACCESS_RIGHTS, + ), + $this->token, + ); + + $record = $this->onlyRecord(); + self::assertSame( + 'authz.denied.read', + $record['message'], + ); + self::assertSame( + ResultCode::INSUFFICIENT_ACCESS_RIGHTS, + $record['context'][EventContext::RESULT_CODE], + ); + } + + public function test_record_password_modify_success_carries_target(): void + { + $this->subject->recordPasswordModifySuccess( + self::wrap(new PasswordModifyRequest(self::TARGET_DN)), + new Dn(self::TARGET_DN), + $this->token, + ); + + $record = $this->onlyRecord(); + self::assertSame( + 'password_modify.success', + $record['message'], + ); + self::assertSame( + [EventContext::DN => self::TARGET_DN], + $record['context'][EventContext::TARGET], + ); + } + + public function test_record_password_modify_failure_falls_back_to_password_modify_failed(): void + { + $this->subject->recordPasswordModifyFailure( + self::wrap(new PasswordModifyRequest(self::TARGET_DN)), + new OperationException( + 'Boom', + ResultCode::OPERATIONS_ERROR, + ), + new Dn(self::TARGET_DN), + $this->token, + ); + + $record = $this->onlyRecord(); + self::assertSame( + 'password_modify.failed', + $record['message'], + ); + self::assertSame( + [EventContext::DN => self::TARGET_DN], + $record['context'][EventContext::TARGET], + ); + } + + public function test_record_password_modify_failure_omits_target_when_unresolved(): void + { + $this->subject->recordPasswordModifyFailure( + self::wrap(new PasswordModifyRequest()), + new OperationException( + 'Boom', + ResultCode::OPERATIONS_ERROR, + ), + null, + $this->token, + ); + + self::assertArrayNotHasKey( + EventContext::TARGET, + $this->onlyRecord()['context'], + ); + } + public function test_control_oids_lists_attached_controls(): void { $message = new LdapMessageRequest(