diff --git a/src/FreeDSx/Ldap/Container.php b/src/FreeDSx/Ldap/Container.php index af8f172d..15ce210f 100644 --- a/src/FreeDSx/Ldap/Container.php +++ b/src/FreeDSx/Ldap/Container.php @@ -55,6 +55,8 @@ use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\SafeModifyConstraint; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyComponentFactory; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\InMemoryReplicaPasswordStateStore; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\PasswordModify\PasswordModifyTargetResolver; @@ -226,6 +228,10 @@ className: ClockInterface::class, className: PasswordPolicyEngine::class, factory: $this->makePasswordPolicyEngine(...), ); + $this->registerFactory( + className: ReplicaPasswordStateStoreInterface::class, + factory: $this->makeReplicaPasswordStateStore(...), + ); $this->registerFactory( className: ServerProtocolHandlerFactory::class, factory: $this->makeServerProtocolHandlerFactory(...), @@ -395,9 +401,20 @@ private function makeServerProtocolFactory(): ServerProtocolFactory metricsRecorder: $this->get(MetricsRecorderInterface::class), metricsSnapshots: $this->get(MetricsSnapshotProvider::class), operationRollup: $this->makeOperationRollup(), + replicaPasswordStateStore: $this->get(ServerOptions::class)->isReadOnly() + ? $this->get(ReplicaPasswordStateStoreInterface::class) + : null, ); } + /** + * The replica-local password-policy state store, backing always-enforced lockout on a read-only replica. + */ + private function makeReplicaPasswordStateStore(): ReplicaPasswordStateStoreInterface + { + return new InMemoryReplicaPasswordStateStore(); + } + private function makeServerProtocolFactoryInterface(): ServerProtocolFactoryInterface { if ($this->has(ProxyOptions::class)) { diff --git a/src/FreeDSx/Ldap/Exception/SchemaRuleException.php b/src/FreeDSx/Ldap/Exception/SchemaRuleException.php index 976e4137..f1a6452b 100644 --- a/src/FreeDSx/Ldap/Exception/SchemaRuleException.php +++ b/src/FreeDSx/Ldap/Exception/SchemaRuleException.php @@ -13,7 +13,7 @@ namespace FreeDSx\Ldap\Exception; -use FreeDSx\Ldap\Server\Backend\Write\SchemaViolations; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolations; /** * A rejected schema violation that carries the violations collected during the write so they can be audited. diff --git a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php index 87c4afaf..acb2d5d8 100644 --- a/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php +++ b/src/FreeDSx/Ldap/Protocol/ServerProtocolHandler/ServerDispatchHandler.php @@ -28,7 +28,7 @@ use FreeDSx\Ldap\Operation\OperationType; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; -use FreeDSx\Ldap\Server\Backend\Write\SchemaViolations; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolations; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteCommandFactory; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php index acfdb2b8..02f250b7 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/WritableStorageBackend.php @@ -38,7 +38,7 @@ use FreeDSx\Ldap\Schema\SchemaValidationMode; use FreeDSx\Ldap\Schema\Schema; use FreeDSx\Ldap\Schema\Validation\SchemaValidator; -use FreeDSx\Ldap\Server\Backend\Write\SchemaViolationDisposition; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolationDisposition; use FreeDSx\Ldap\Server\Backend\Write\WritableLdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/PasswordPolicyWriteHandler.php b/src/FreeDSx/Ldap/Server/Backend/Write/PasswordPolicyWriteHandler.php index f0927ea8..bdf2480a 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/PasswordPolicyWriteHandler.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/PasswordPolicyWriteHandler.php @@ -20,6 +20,7 @@ use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriterInterface; use FreeDSx\Ldap\Server\PasswordPolicy\Attempt\PasswordModifyAttempt; use FreeDSx\Ldap\Server\PasswordPolicy\Guard\PasswordPolicyChangeGuard; use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/SchemaViolation.php b/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolation.php similarity index 91% rename from src/FreeDSx/Ldap/Server/Backend/Write/SchemaViolation.php rename to src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolation.php index 21d53d81..0be3bd3e 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/SchemaViolation.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolation.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write; +namespace FreeDSx\Ldap\Server\Backend\Write\Schema; use FreeDSx\Ldap\Exception\OperationException; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/SchemaViolationDisposition.php b/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolationDisposition.php similarity index 92% rename from src/FreeDSx/Ldap/Server/Backend/Write/SchemaViolationDisposition.php rename to src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolationDisposition.php index 2bd9030d..d8e35459 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/SchemaViolationDisposition.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolationDisposition.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write; +namespace FreeDSx\Ldap\Server\Backend\Write\Schema; /** * How a schema violation was handled. diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/SchemaViolations.php b/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolations.php similarity index 94% rename from src/FreeDSx/Ldap/Server/Backend/Write/SchemaViolations.php rename to src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolations.php index 2e45ae6a..07bce939 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/SchemaViolations.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/Schema/SchemaViolations.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write; +namespace FreeDSx\Ldap\Server\Backend\Write\Schema; use FreeDSx\Ldap\Exception\OperationException; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/LocalStateSystemChangeWriter.php b/src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/LocalStateSystemChangeWriter.php new file mode 100644 index 00000000..f2f23517 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/LocalStateSystemChangeWriter.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Write\SystemChange; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; + +/** + * Records password-policy bind state to a replica-local store instead of the replicated entry. + * + * @author Chad Sikorra + */ +final readonly class LocalStateSystemChangeWriter implements SystemChangeWriterInterface +{ + public function __construct(private ReplicaPasswordStateStoreInterface $store) {} + + public function write( + Dn $dn, + OperationalChanges $changes, + ): void { + $this->store->apply( + $dn, + $changes, + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/NullSystemChangeWriter.php b/src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/NullSystemChangeWriter.php similarity index 92% rename from src/FreeDSx/Ldap/Server/Backend/Write/NullSystemChangeWriter.php rename to src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/NullSystemChangeWriter.php index a6f70bc7..c7e93bda 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/NullSystemChangeWriter.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/NullSystemChangeWriter.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write; +namespace FreeDSx\Ldap\Server\Backend\Write\SystemChange; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/SystemChangeWriter.php b/src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/SystemChangeWriter.php similarity index 88% rename from src/FreeDSx/Ldap/Server/Backend/Write/SystemChangeWriter.php rename to src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/SystemChangeWriter.php index d514c919..2e7bff6e 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/SystemChangeWriter.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/SystemChangeWriter.php @@ -11,12 +11,14 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write; +namespace FreeDSx\Ldap\Server\Backend\Write\SystemChange; use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; +use FreeDSx\Ldap\Server\Backend\Write\WriteContext; +use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; use FreeDSx\Ldap\Server\Token\SystemToken; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/SystemChangeWriterInterface.php b/src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/SystemChangeWriterInterface.php similarity index 92% rename from src/FreeDSx/Ldap/Server/Backend/Write/SystemChangeWriterInterface.php rename to src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/SystemChangeWriterInterface.php index ba8cbcb0..851fa3e3 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/SystemChangeWriterInterface.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/SystemChangeWriterInterface.php @@ -11,7 +11,7 @@ * file that was distributed with this source code. */ -namespace FreeDSx\Ldap\Server\Backend\Write; +namespace FreeDSx\Ldap\Server\Backend\Write\SystemChange; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Exception\OperationException; diff --git a/src/FreeDSx/Ldap/Server/Backend/Write/WriteContext.php b/src/FreeDSx/Ldap/Server/Backend/Write/WriteContext.php index c2fcb3a5..b6096d8b 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Write/WriteContext.php +++ b/src/FreeDSx/Ldap/Server/Backend/Write/WriteContext.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Control\ControlBag; use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolations; use FreeDSx\Ldap\Server\Token\TokenInterface; /** diff --git a/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php b/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php index df7b5d7d..2dd1d252 100644 --- a/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php +++ b/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php @@ -22,7 +22,7 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Server\AccessControl\OperationTargetDn; use FreeDSx\Ldap\Operation\OperationType; -use FreeDSx\Ldap\Server\Backend\Write\SchemaViolations; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolations; use FreeDSx\Ldap\Server\Token\TokenInterface; /** diff --git a/src/FreeDSx/Ldap/Server/Operation/WriteOperationResult.php b/src/FreeDSx/Ldap/Server/Operation/WriteOperationResult.php index 661e9251..5c0f2bab 100644 --- a/src/FreeDSx/Ldap/Server/Operation/WriteOperationResult.php +++ b/src/FreeDSx/Ldap/Server/Operation/WriteOperationResult.php @@ -15,7 +15,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; -use FreeDSx\Ldap\Server\Backend\Write\SchemaViolations; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolations; use FreeDSx\Ldap\Server\Logging\OperationAuditor; use FreeDSx\Ldap\Server\Token\TokenInterface; diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/EntryBindStrategy.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/EntryBindStrategy.php new file mode 100644 index 00000000..7fdda298 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/EntryBindStrategy.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy; + +use FreeDSx\Ldap\Server\PasswordPolicy\Attempt\PasswordBindAttempt; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\PasswordPolicyOutcome; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; +use FreeDSx\Ldap\Server\PasswordPolicy\UserPasswordState; + +/** + * Evaluates every bind operation against the authoritative entry state (the primary / writable server). + * + * @author Chad Sikorra + */ +final readonly class EntryBindStrategy implements PasswordPolicyBindStrategyInterface +{ + public function __construct(private PasswordPolicyEngine $engine) {} + + public function preBindOutcome(PasswordBindAttempt $attempt): PasswordPolicyOutcome + { + return $this->engine->evaluatePreBind( + $attempt->state, + $attempt->policy, + ); + } + + public function failureState(PasswordBindAttempt $attempt): UserPasswordState + { + return $attempt->state; + } + + public function successState(PasswordBindAttempt $attempt): UserPasswordState + { + return $attempt->state; + } +} diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/PasswordPolicyBindStrategyInterface.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/PasswordPolicyBindStrategyInterface.php new file mode 100644 index 00000000..c981af1f --- /dev/null +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/PasswordPolicyBindStrategyInterface.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\PasswordPolicy\Guard\BindStrategy; + +use FreeDSx\Ldap\Server\PasswordPolicy\Attempt\PasswordBindAttempt; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\PasswordPolicyOutcome; +use FreeDSx\Ldap\Server\PasswordPolicy\UserPasswordState; + +/** + * Supplies the password-policy state each bind operation is evaluated and recorded against. + * + * @author Chad Sikorra + */ +interface PasswordPolicyBindStrategyInterface +{ + /** + * The pre-bind lockout decision (worst-outcome across every state that governs the bind). + */ + public function preBindOutcome(PasswordBindAttempt $attempt): PasswordPolicyOutcome; + + /** + * The state a failed bind is counted and recorded against. + */ + public function failureState(PasswordBindAttempt $attempt): UserPasswordState; + + /** + * The state a successful bind is evaluated and cleared against. + */ + public function successState(PasswordBindAttempt $attempt): UserPasswordState; +} diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/ReplicaBindStrategy.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/ReplicaBindStrategy.php new file mode 100644 index 00000000..e2c7010a --- /dev/null +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/ReplicaBindStrategy.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy; + +use FreeDSx\Ldap\Server\PasswordPolicy\Attempt\PasswordBindAttempt; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\PasswordPolicyOutcome; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; +use FreeDSx\Ldap\Server\PasswordPolicy\UserPasswordState; + +/** + * Evaluates the worst of the replicated entry state and the replica-local bind state on a read-only replica. + * + * @author Chad Sikorra + */ +final readonly class ReplicaBindStrategy implements PasswordPolicyBindStrategyInterface +{ + public function __construct( + private PasswordPolicyEngine $engine, + private ReplicaPasswordStateStoreInterface $store, + ) {} + + /** + * Deny on the primary's entry decision (validity / idle / lock), otherwise on the replica-local failure lock. + */ + public function preBindOutcome(PasswordBindAttempt $attempt): PasswordPolicyOutcome + { + $entryOutcome = $this->engine->evaluatePreBind( + $attempt->state, + $attempt->policy, + ); + + if ($entryOutcome->denied) { + return $entryOutcome; + } + + return $this->engine->evaluateLocalLockout( + $this->localState($attempt), + $attempt->policy, + ); + } + + public function failureState(PasswordBindAttempt $attempt): UserPasswordState + { + return $this->localState($attempt); + } + + /** + * Combine primary expiry/validity with replica-local volatile state so success clears local failures and grace. + */ + public function successState(PasswordBindAttempt $attempt): UserPasswordState + { + $entry = $attempt->state; + $local = $this->localState($attempt); + + return new UserPasswordState( + changedAt: $entry->changedAt, + accountLockedAt: $local->accountLockedAt, + permanentlyLocked: $local->permanentlyLocked, + failureTimes: $local->failureTimes, + graceUseTimes: $local->graceUseTimes, + mustChange: $entry->mustChange, + policySubentry: $entry->policySubentry, + startTime: $entry->startTime, + endTime: $entry->endTime, + lastSuccess: $local->lastSuccess, + ); + } + + private function localState(PasswordBindAttempt $attempt): UserPasswordState + { + return $this->store + ->load($attempt->dn) + ->toUserPasswordState($attempt->dn); + } +} diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/PasswordPolicyBindGuard.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/PasswordPolicyBindGuard.php index 6cc46a57..435f1788 100644 --- a/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/PasswordPolicyBindGuard.php +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/PasswordPolicyBindGuard.php @@ -15,23 +15,26 @@ use FreeDSx\Ldap\Control\PwdPolicyError; use FreeDSx\Ldap\Exception\OperationException; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriterInterface; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriterInterface; use FreeDSx\Ldap\Server\Clock\Sleeper\SleeperInterface; use FreeDSx\Ldap\Server\Logging\EventContext; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Logging\ServerEvent; use FreeDSx\Ldap\Server\PasswordPolicy\Attempt\PasswordBindAttempt; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\PasswordPolicyOutcome; +use FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy\PasswordPolicyBindStrategyInterface; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyContext; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; -use FreeDSx\Ldap\Server\PasswordPolicy\Decision\PasswordPolicyOutcome; +use FreeDSx\Ldap\Server\PasswordPolicy\UserPasswordState; /** - * Applies the engine's bind-time decisions. + * Applies the engine's bind-time decisions against the state supplied by the configured bind strategy. */ final readonly class PasswordPolicyBindGuard { public function __construct( private PasswordPolicyEngine $engine, + private PasswordPolicyBindStrategyInterface $strategy, private SystemChangeWriterInterface $writer, private PasswordPolicyContext $context, private EventLogger $eventLogger, @@ -43,10 +46,7 @@ public function __construct( */ public function preBind(PasswordBindAttempt $attempt): void { - $outcome = $this->engine->evaluatePreBind( - $attempt->state, - $attempt->policy, - ); + $outcome = $this->strategy->preBindOutcome($attempt); if (!$outcome->denied) { return; } @@ -64,13 +64,12 @@ public function preBind(PasswordBindAttempt $attempt): void } /** - * Surfaces a lockout outcome but never throws (the caller re-throws the credential error), then applies the - * configured pwdMinDelay/pwdMaxDelay response delay. + * Records a failed bind but never throws (the caller re-throws the credential error), then applies the response delay. */ public function recordFailure(PasswordBindAttempt $attempt): void { $recorded = $this->engine->recordBindFailure( - $attempt->state, + $this->strategy->failureState($attempt), $attempt->policy, ); $this->writer->write( @@ -94,8 +93,9 @@ public function recordFailure(PasswordBindAttempt $attempt): void */ public function recordSuccess(PasswordBindAttempt $attempt): void { + $state = $this->strategy->successState($attempt); $recorded = $this->engine->recordBindSuccess( - $attempt->state, + $state, $attempt->policy, ); $outcome = $recorded->outcome; @@ -120,17 +120,19 @@ public function recordSuccess(PasswordBindAttempt $attempt): void $this->context->setOutcome($outcome); $this->emitSuccessEvents( $attempt, + $state, $outcome, ); } private function emitSuccessEvents( PasswordBindAttempt $attempt, + UserPasswordState $state, PasswordPolicyOutcome $outcome, ): void { $subject = $this->subjectFor($attempt); - if ($attempt->state->isLocked() && !$attempt->state->permanentlyLocked) { + if ($state->isLocked() && !$state->permanentlyLocked) { $this->eventLogger->record( ServerEvent::PasswordPolicyAccountUnlocked, $subject, diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyComponentFactory.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyComponentFactory.php index 559102a6..ac980cba 100644 --- a/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyComponentFactory.php +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyComponentFactory.php @@ -15,9 +15,9 @@ use FreeDSx\Ldap\Exception\RuntimeException; use FreeDSx\Ldap\Server\Backend\Write\PasswordPolicyWriteHandler; -use FreeDSx\Ldap\Server\Backend\Write\NullSystemChangeWriter; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriterInterface; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\NullSystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriterInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\HandlerFactoryInterface; diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyEngine.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyEngine.php index 004fc6bc..b9a36c9b 100644 --- a/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyEngine.php +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyEngine.php @@ -51,6 +51,18 @@ public function evaluatePreBind( : PasswordPolicyOutcome::allow()); } + /** + * Failure-driven lockout check only, for replica-local state that carries no primary-owned validity/idle policy. + */ + public function evaluateLocalLockout( + UserPasswordState $state, + PasswordPolicy $policy, + ): PasswordPolicyOutcome { + return $this->isLockoutEffective($state, $policy) + ? self::denyLocked() + : PasswordPolicyOutcome::allow(); + } + /** * Record a failed bind: append the current time to pwdFailureTime, and trip the lockout if the retained failure count meets pwdMaxFailure. * diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStore.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStore.php new file mode 100644 index 00000000..c7a6cea1 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStore.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\PasswordPolicy\Replica; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; + +/** + * Holds replica-observed password-policy state in memory. + * + * @author Chad Sikorra + */ +final class InMemoryReplicaPasswordStateStore implements ReplicaPasswordStateStoreInterface +{ + /** + * @var array + */ + private array $states = []; + + public function load(Dn $dn): ReplicaPasswordState + { + return $this->states[$this->key($dn)] + ?? ReplicaPasswordState::empty(); + } + + public function apply( + Dn $dn, + OperationalChanges $changes, + ): void { + if ($changes->isEmpty()) { + return; + } + + $key = $this->key($dn); + $state = ($this->states[$key] ?? ReplicaPasswordState::empty()) + ->withChanges($changes); + + if ($state->isEmpty()) { + unset($this->states[$key]); + + return; + } + + $this->states[$key] = $state; + } + + private function key(Dn $dn): string + { + return $dn->normalize()->toString(); + } +} diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordState.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordState.php new file mode 100644 index 00000000..15d764ac --- /dev/null +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordState.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\PasswordPolicy\Replica; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; +use FreeDSx\Ldap\Server\PasswordPolicy\UserPasswordState; + +/** + * Replica-owned volatile password-policy attributes for one subject, held locally. + * + * @author Chad Sikorra + */ +final readonly class ReplicaPasswordState +{ + /** + * @var array keyed by lowercased attribute name + */ + private array $attributes; + + /** + * @param list $attributes + */ + public function __construct(array $attributes = []) + { + $keyed = []; + + foreach ($attributes as $attribute) { + $keyed[strtolower($attribute->getName())] = $attribute; + } + + $this->attributes = $keyed; + } + + public static function empty(): self + { + return new self(); + } + + public function isEmpty(): bool + { + return $this->attributes === []; + } + + /** + * Apply engine-emitted operational deltas (replace / reset) and return the resulting state. + */ + public function withChanges(OperationalChanges $changes): self + { + $attributes = $this->attributes; + + foreach ($changes->changes as $change) { + $attribute = $change->getAttribute(); + $key = strtolower($attribute->getName()); + + if ($change->isReset() || $attribute->getValues() === []) { + unset($attributes[$key]); + + continue; + } + + $attributes[$key] = $attribute; + } + + return new self(array_values($attributes)); + } + + /** + * Project this local state onto a partial entry so the shared UserPasswordState parser can read it. + */ + public function toUserPasswordState(Dn $dn): UserPasswordState + { + return UserPasswordState::fromEntry(Entry::raw( + $dn, + array_values($this->attributes), + )); + } +} diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordStateStoreInterface.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordStateStoreInterface.php new file mode 100644 index 00000000..f90cc316 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordStateStoreInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\PasswordPolicy\Replica; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; + +/** + * Persists replica-observed password-policy bind state locally, separate from replicated entries. + * + * @author Chad Sikorra + */ +interface ReplicaPasswordStateStoreInterface +{ + /** + * The locally tracked state for a subject, or an empty state when none has been recorded. + */ + public function load(Dn $dn): ReplicaPasswordState; + + /** + * Apply engine-emitted operational deltas to the subject's locally tracked state. + */ + public function apply( + Dn $dn, + OperationalChanges $changes, + ): void; +} diff --git a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php index e55586ac..34946902 100644 --- a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php +++ b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php @@ -41,9 +41,8 @@ use FreeDSx\Ldap\Server\Sasl\External\SubjectDnCredentialMapper; use FreeDSx\Ldap\Server\Backend\Auth\SaslBindPolicyEnforcer; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\Write\NullSystemChangeWriter; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriterInterface; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\LocalStateSystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriter; use FreeDSx\Ldap\Server\Logging\ConnectionContext; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Logging\OperationAuditor; @@ -70,7 +69,10 @@ use FreeDSx\Ldap\Server\Clock\Sleeper\BlockingSleeper; use FreeDSx\Ldap\Server\Clock\Sleeper\CoroutineSleeper; use FreeDSx\Ldap\Server\Clock\Sleeper\SleeperInterface; +use FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy\EntryBindStrategy; +use FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy\ReplicaBindStrategy; use FreeDSx\Ldap\Server\PasswordPolicy\Guard\PasswordPolicyBindGuard; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; use FreeDSx\Ldap\Protocol\Queue\Response\MetricsResponseInterceptor; use FreeDSx\Ldap\Protocol\Queue\Response\PasswordPolicyResponseInterceptor; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyContext; @@ -98,6 +100,7 @@ public function __construct( private readonly MetricsRecorderInterface $metricsRecorder = new NullMetricsRecorder(), private readonly MetricsSnapshotProvider $metricsSnapshots = new InMemoryMetricsRecorder(), private readonly ?OperationRollupCoordinator $operationRollup = null, + private readonly ?ReplicaPasswordStateStoreInterface $replicaPasswordStateStore = null, ) {} public function make( @@ -309,13 +312,26 @@ private function makePasswordPolicyResolver(LdapBackendInterface $backend): Pass ); } + /** + * Builds the bind guard: replica-local worst-outcome state on a read-only replica, authoritative entry state otherwise. + */ private function makeBindGuard( PasswordPolicyContext $policyContext, EventLogger $eventLogger, ): PasswordPolicyBindGuard { + $store = $this->replicaPasswordStateStore; + + $strategy = $store !== null + ? new ReplicaBindStrategy($this->passwordPolicyEngine, $store) + : new EntryBindStrategy($this->passwordPolicyEngine); + $writer = $store !== null + ? new LocalStateSystemChangeWriter($store) + : new SystemChangeWriter($this->handlerFactory->makeWriteDispatcher()); + return new PasswordPolicyBindGuard( $this->passwordPolicyEngine, - $this->systemChangeWriter(), + $strategy, + $writer, $policyContext, $eventLogger, $this->makeSleeper(), @@ -329,15 +345,6 @@ private function makeSleeper(): SleeperInterface : new BlockingSleeper(); } - private function systemChangeWriter(): SystemChangeWriterInterface - { - if ($this->options->isReadOnly()) { - return new NullSystemChangeWriter(); - } - - return new SystemChangeWriter($this->handlerFactory->makeWriteDispatcher()); - } - /** * @param string[] $saslMechanisms */ diff --git a/tests/integration/Security/PasswordPolicyBindEnforcementTest.php b/tests/integration/Security/PasswordPolicyBindEnforcementTest.php index 55205ac6..ceada5f2 100644 --- a/tests/integration/Security/PasswordPolicyBindEnforcementTest.php +++ b/tests/integration/Security/PasswordPolicyBindEnforcementTest.php @@ -26,13 +26,14 @@ use FreeDSx\Ldap\Server\Backend\Auth\PasswordPolicyAwareAuthenticator; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriter; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Logging\EventLogPolicy; use FreeDSx\Ldap\Server\Logging\ServerEvent; use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeConstraintChain; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; +use FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy\EntryBindStrategy; use FreeDSx\Ldap\Server\PasswordPolicy\Guard\PasswordPolicyBindGuard; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyContext; use FreeDSx\Ldap\Server\Clock\Sleeper\BlockingSleeper; @@ -529,6 +530,7 @@ private function authenticatorFor( ); $guard = new PasswordPolicyBindGuard( $engine, + new EntryBindStrategy($engine), new SystemChangeWriter(new WriteOperationDispatcher($this->backend)), $this->context, new EventLogger( diff --git a/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php b/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php index bb467c72..385d14e1 100644 --- a/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php +++ b/tests/integration/Security/PasswordPolicyPlainModifyEnforcementTest.php @@ -33,7 +33,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; use FreeDSx\Ldap\Server\Backend\Write\PasswordPolicyWriteHandler; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriter; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerDispatchHandler; use FreeDSx\Ldap\Server\Logging\EventLogger; diff --git a/tests/unit/Server/Backend/Auth/PasswordPolicyAwareAuthenticatorTest.php b/tests/unit/Server/Backend/Auth/PasswordPolicyAwareAuthenticatorTest.php index a0cd1452..b6a65888 100644 --- a/tests/unit/Server/Backend/Auth/PasswordPolicyAwareAuthenticatorTest.php +++ b/tests/unit/Server/Backend/Auth/PasswordPolicyAwareAuthenticatorTest.php @@ -22,11 +22,12 @@ use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordPolicyAwareAuthenticator; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriter; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeConstraintChain; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; +use FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy\EntryBindStrategy; use FreeDSx\Ldap\Server\PasswordPolicy\Guard\PasswordPolicyBindGuard; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyContext; use FreeDSx\Ldap\Server\Clock\Sleeper\BlockingSleeper; @@ -206,6 +207,7 @@ private function authenticator(?PasswordPolicy $policy): PasswordPolicyAwareAuth ); $guard = new PasswordPolicyBindGuard( $engine, + new EntryBindStrategy($engine), new SystemChangeWriter(new WriteOperationDispatcher($this->writeHandler)), $this->context, new EventLogger(null), diff --git a/tests/unit/Server/Backend/Auth/SaslBindPolicyEnforcerTest.php b/tests/unit/Server/Backend/Auth/SaslBindPolicyEnforcerTest.php index 64949704..a7e9a312 100644 --- a/tests/unit/Server/Backend/Auth/SaslBindPolicyEnforcerTest.php +++ b/tests/unit/Server/Backend/Auth/SaslBindPolicyEnforcerTest.php @@ -24,11 +24,12 @@ use FreeDSx\Ldap\Server\Backend\Auth\SaslBindPolicyEnforcer; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriter; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Logging\EventLogPolicy; use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeConstraintChain; +use FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy\EntryBindStrategy; use FreeDSx\Ldap\Server\PasswordPolicy\Guard\PasswordPolicyBindGuard; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyContext; @@ -184,11 +185,13 @@ private function enforcer( ), ])); + $engine = new PasswordPolicyEngine( + clock: $this->clock, + changeConstraints: new PasswordChangeConstraintChain([]), + ); $guard = new PasswordPolicyBindGuard( - new PasswordPolicyEngine( - clock: $this->clock, - changeConstraints: new PasswordChangeConstraintChain([]), - ), + $engine, + new EntryBindStrategy($engine), new SystemChangeWriter(new WriteOperationDispatcher($this->backend)), $this->context, new EventLogger(null, EventLogPolicy::all()), diff --git a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php index 13f68fe0..d6779029 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/WritableStorageBackendTest.php @@ -49,8 +49,8 @@ use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\MoveCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; -use FreeDSx\Ldap\Server\Backend\Write\SchemaViolationDisposition; -use FreeDSx\Ldap\Server\Backend\Write\SchemaViolations; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolationDisposition; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolations; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Backend\Write\WriteRequestInterface; use FreeDSx\Ldap\Server\Token\AnonToken; diff --git a/tests/unit/Server/Backend/Write/PasswordPolicyWriteHandlerTest.php b/tests/unit/Server/Backend/Write/PasswordPolicyWriteHandlerTest.php index d64867d9..3f3b4b52 100644 --- a/tests/unit/Server/Backend/Write/PasswordPolicyWriteHandlerTest.php +++ b/tests/unit/Server/Backend/Write/PasswordPolicyWriteHandlerTest.php @@ -27,7 +27,7 @@ use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; use FreeDSx\Ldap\Server\Backend\Write\Command\DeleteCommand; use FreeDSx\Ldap\Server\Backend\Write\PasswordPolicyWriteHandler; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriter; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Logging\EventLogger; diff --git a/tests/unit/Server/Backend/Write/SystemChangeWriterTest.php b/tests/unit/Server/Backend/Write/SystemChangeWriterTest.php index ab6c3ae7..afcbb0a3 100644 --- a/tests/unit/Server/Backend/Write/SystemChangeWriterTest.php +++ b/tests/unit/Server/Backend/Write/SystemChangeWriterTest.php @@ -16,7 +16,7 @@ use FreeDSx\Ldap\Entry\Change; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriter; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; use FreeDSx\Ldap\Server\Token\SystemToken; diff --git a/tests/unit/Server/Logging/OperationAuditorTest.php b/tests/unit/Server/Logging/OperationAuditorTest.php index 8b4f3e2d..645c2f32 100644 --- a/tests/unit/Server/Logging/OperationAuditorTest.php +++ b/tests/unit/Server/Logging/OperationAuditorTest.php @@ -31,8 +31,8 @@ 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\Backend\Write\Schema\SchemaViolationDisposition; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolations; use FreeDSx\Ldap\Server\Logging\EventContext; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Logging\EventLogPolicy; diff --git a/tests/unit/Server/Middleware/OperationAuditMiddlewareTest.php b/tests/unit/Server/Middleware/OperationAuditMiddlewareTest.php index cda2498a..cce64f4e 100644 --- a/tests/unit/Server/Middleware/OperationAuditMiddlewareTest.php +++ b/tests/unit/Server/Middleware/OperationAuditMiddlewareTest.php @@ -22,8 +22,8 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Search\Filters; -use FreeDSx\Ldap\Server\Backend\Write\SchemaViolationDisposition; -use FreeDSx\Ldap\Server\Backend\Write\SchemaViolations; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolationDisposition; +use FreeDSx\Ldap\Server\Backend\Write\Schema\SchemaViolations; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Logging\EventLogPolicy; use FreeDSx\Ldap\Server\Logging\OperationAuditor; diff --git a/tests/unit/Server/PasswordPolicy/Guard/BindStrategy/ReplicaBindStrategyTest.php b/tests/unit/Server/PasswordPolicy/Guard/BindStrategy/ReplicaBindStrategyTest.php new file mode 100644 index 00000000..2cb32414 --- /dev/null +++ b/tests/unit/Server/PasswordPolicy/Guard/BindStrategy/ReplicaBindStrategyTest.php @@ -0,0 +1,185 @@ + + * + * 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\PasswordPolicy\Guard\BindStrategy; + +use FreeDSx\Ldap\Control\PwdPolicyError; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\LocalStateSystemChangeWriter; +use FreeDSx\Ldap\Server\Logging\EventLogger; +use FreeDSx\Ldap\Server\Logging\EventLogPolicy; +use FreeDSx\Ldap\Server\PasswordPolicy\Attempt\PasswordBindAttempt; +use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeConstraintChain; +use FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy\ReplicaBindStrategy; +use FreeDSx\Ldap\Server\PasswordPolicy\Guard\PasswordPolicyBindGuard; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyContext; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\InMemoryReplicaPasswordStateStore; +use FreeDSx\Ldap\Server\PasswordPolicy\Rules\PasswordLockoutRules; +use FreeDSx\Ldap\Server\PasswordPolicy\UserPasswordState; +use PHPUnit\Framework\TestCase; +use Tests\Support\FreeDSx\Ldap\Clock\FrozenClock; +use Tests\Support\FreeDSx\Ldap\Logging\RecordingLogger; +use Tests\Support\FreeDSx\Ldap\Server\Clock\RecordingSleeper; + +final class ReplicaBindStrategyTest extends TestCase +{ + private const NOW = '2026-05-20T12:00:00Z'; + + private const DN = 'cn=foo,dc=example,dc=com'; + + private InMemoryReplicaPasswordStateStore $store; + + private PasswordPolicyContext $context; + + private RecordingSleeper $sleeper; + + private PasswordPolicyBindGuard $subject; + + protected function setUp(): void + { + $this->store = new InMemoryReplicaPasswordStateStore(); + $this->context = new PasswordPolicyContext(); + $this->sleeper = new RecordingSleeper(); + + $engine = new PasswordPolicyEngine( + clock: FrozenClock::fromString(self::NOW), + changeConstraints: new PasswordChangeConstraintChain([]), + ); + $this->subject = new PasswordPolicyBindGuard( + $engine, + new ReplicaBindStrategy( + $engine, + $this->store, + ), + new LocalStateSystemChangeWriter($this->store), + $this->context, + new EventLogger( + new RecordingLogger(), + EventLogPolicy::all(), + ), + $this->sleeper, + ); + } + + public function test_local_failures_accumulate_and_lock_with_the_primary_never_writing(): void + { + $policy = $this->lockoutPolicy(2); + + $this->subject->recordFailure($this->attempt(new UserPasswordState(), $policy)); + $this->subject->recordFailure($this->attempt(new UserPasswordState(), $policy)); + + self::assertTrue( + $this->localState()->isLocked(), + 'The replica must lock locally once pwdMaxFailure is reached.', + ); + + try { + $this->subject->preBind($this->attempt(new UserPasswordState(), $policy)); + self::fail('A locally locked account must be denied even with a clean replicated entry.'); + } catch (OperationException $e) { + self::assertSame( + ResultCode::INVALID_CREDENTIALS, + $e->getCode(), + ); + } + + self::assertSame( + PwdPolicyError::ACCOUNT_LOCKED, + $this->context->getOutcome()?->errorCode, + ); + } + + public function test_preBind_denies_a_replicated_entry_lock_with_no_local_state(): void + { + $this->expectException(OperationException::class); + + $this->subject->preBind($this->attempt(new UserPasswordState(permanentlyLocked: true))); + } + + public function test_failure_is_persisted_to_the_local_store(): void + { + $this->subject->recordFailure($this->attempt( + new UserPasswordState(), + $this->lockoutPolicy(3), + )); + + self::assertFalse( + $this->store->load(new Dn(self::DN))->isEmpty(), + 'A replica-observed failure must be recorded to the local store.', + ); + } + + public function test_success_clears_local_failures_and_stamps_last_success(): void + { + $this->subject->recordFailure($this->attempt( + new UserPasswordState(), + $this->lockoutPolicy(3), + )); + + $this->subject->recordSuccess($this->attempt(new UserPasswordState())); + + $local = $this->localState(); + self::assertSame( + [], + $local->failureTimes, + ); + self::assertNotNull($local->lastSuccess); + } + + public function test_below_threshold_failure_does_not_lock(): void + { + $this->subject->recordFailure($this->attempt( + new UserPasswordState(), + $this->lockoutPolicy(3), + )); + + self::assertFalse($this->localState()->isLocked()); + self::assertNull($this->context->getOutcome()); + } + + private function localState(): UserPasswordState + { + return $this->store + ->load(new Dn(self::DN)) + ->toUserPasswordState(new Dn(self::DN)); + } + + private function attempt( + UserPasswordState $state, + PasswordPolicy $policy = new PasswordPolicy(), + ): PasswordBindAttempt { + return new PasswordBindAttempt( + name: 'foo', + dn: new Dn(self::DN), + state: $state, + policy: $policy, + ); + } + + /** + * @param int<0, max> $maxFailure + */ + private function lockoutPolicy(int $maxFailure): PasswordPolicy + { + return new PasswordPolicy( + lockout: new PasswordLockoutRules( + enabled: true, + maxFailure: $maxFailure, + ), + ); + } +} diff --git a/tests/unit/Server/PasswordPolicy/Guard/PasswordPolicyBindGuardTest.php b/tests/unit/Server/PasswordPolicy/Guard/PasswordPolicyBindGuardTest.php index 83ef03f3..69825c79 100644 --- a/tests/unit/Server/PasswordPolicy/Guard/PasswordPolicyBindGuardTest.php +++ b/tests/unit/Server/PasswordPolicy/Guard/PasswordPolicyBindGuardTest.php @@ -21,12 +21,13 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Schema\Definition\PasswordPolicyOid; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; -use FreeDSx\Ldap\Server\Backend\Write\SystemChangeWriter; +use FreeDSx\Ldap\Server\Backend\Write\SystemChange\SystemChangeWriter; use FreeDSx\Ldap\Server\Backend\Write\WriteOperationDispatcher; use FreeDSx\Ldap\Server\Logging\EventLogger; use FreeDSx\Ldap\Server\Logging\EventLogPolicy; use FreeDSx\Ldap\Server\Logging\ServerEvent; use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeConstraintChain; +use FreeDSx\Ldap\Server\PasswordPolicy\Guard\BindStrategy\EntryBindStrategy; use FreeDSx\Ldap\Server\PasswordPolicy\Guard\PasswordPolicyBindGuard; use FreeDSx\Ldap\Server\PasswordPolicy\Attempt\PasswordBindAttempt; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; @@ -73,6 +74,7 @@ protected function setUp(): void ); $this->subject = new PasswordPolicyBindGuard( $engine, + new EntryBindStrategy($engine), new SystemChangeWriter(new WriteOperationDispatcher($this->writeHandler)), $this->context, new EventLogger( diff --git a/tests/unit/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStoreTest.php b/tests/unit/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStoreTest.php new file mode 100644 index 00000000..a50e1f8a --- /dev/null +++ b/tests/unit/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStoreTest.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 Tests\Unit\FreeDSx\Ldap\Server\PasswordPolicy\Replica; + +use FreeDSx\Ldap\Entry\Change; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Schema\Definition\PasswordPolicyOid; +use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; +use FreeDSx\Ldap\Server\PasswordPolicy\Replica\InMemoryReplicaPasswordStateStore; +use PHPUnit\Framework\TestCase; + +final class InMemoryReplicaPasswordStateStoreTest extends TestCase +{ + private const DN = 'cn=foo,dc=example,dc=com'; + + private InMemoryReplicaPasswordStateStore $subject; + + protected function setUp(): void + { + $this->subject = new InMemoryReplicaPasswordStateStore(); + } + + public function test_load_is_empty_when_nothing_was_recorded(): void + { + self::assertTrue($this->subject->load(new Dn(self::DN))->isEmpty()); + } + + public function test_apply_persists_replace_changes(): void + { + $this->subject->apply( + new Dn(self::DN), + OperationalChanges::of(Change::replace( + PasswordPolicyOid::NAME_PWD_ACCOUNT_LOCKED_TIME, + '20260520120000Z', + )), + ); + + self::assertTrue( + $this->subject + ->load(new Dn(self::DN)) + ->toUserPasswordState(new Dn(self::DN)) + ->isLocked(), + ); + } + + public function test_apply_reset_removes_a_previously_stored_attribute(): void + { + $dn = new Dn(self::DN); + $this->subject->apply( + $dn, + OperationalChanges::of(Change::replace( + PasswordPolicyOid::NAME_PWD_FAILURE_TIME, + '20260520120000Z', + )), + ); + + $this->subject->apply( + $dn, + OperationalChanges::of(Change::reset(PasswordPolicyOid::NAME_PWD_FAILURE_TIME)), + ); + + self::assertTrue($this->subject->load($dn)->isEmpty()); + } + + public function test_state_is_keyed_by_the_canonical_dn(): void + { + $this->subject->apply( + new Dn('CN=Foo,DC=Example,DC=Com'), + OperationalChanges::of(Change::replace( + PasswordPolicyOid::NAME_PWD_ACCOUNT_LOCKED_TIME, + '20260520120000Z', + )), + ); + + self::assertFalse($this->subject->load(new Dn(self::DN))->isEmpty()); + } +}