From 15edf14d0a979cac288a0a11e76df7cd46ec5a76 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sat, 11 Jul 2026 16:04:46 -0400 Subject: [PATCH] Add some of the core logic for replica password policy handling. A replica should maintain local state for password policy to correctly enforce lockouts without having to rely on upstream replication to enforce it. Upstream will eventually converge (and we will forward the events upstream). But the replica must not give malicious actors unlimited password attempts. --- src/FreeDSx/Ldap/Container.php | 17 ++ .../Ldap/Exception/SchemaRuleException.php | 2 +- .../ServerDispatchHandler.php | 2 +- .../Storage/WritableStorageBackend.php | 2 +- .../Write/PasswordPolicyWriteHandler.php | 1 + .../Write/{ => Schema}/SchemaViolation.php | 2 +- .../SchemaViolationDisposition.php | 2 +- .../Write/{ => Schema}/SchemaViolations.php | 2 +- .../LocalStateSystemChangeWriter.php | 38 ++++ .../NullSystemChangeWriter.php | 2 +- .../{ => SystemChange}/SystemChangeWriter.php | 4 +- .../SystemChangeWriterInterface.php | 2 +- .../Server/Backend/Write/WriteContext.php | 1 + .../Ldap/Server/Logging/OperationAuditor.php | 2 +- .../Server/Operation/WriteOperationResult.php | 2 +- .../Guard/BindStrategy/EntryBindStrategy.php | 47 +++++ .../PasswordPolicyBindStrategyInterface.php | 41 ++++ .../BindStrategy/ReplicaBindStrategy.php | 87 ++++++++ .../Guard/PasswordPolicyBindGuard.php | 26 +-- .../PasswordPolicyComponentFactory.php | 6 +- .../PasswordPolicy/PasswordPolicyEngine.php | 12 ++ .../InMemoryReplicaPasswordStateStore.php | 62 ++++++ .../Replica/ReplicaPasswordState.php | 91 +++++++++ .../ReplicaPasswordStateStoreInterface.php | 38 ++++ .../Ldap/Server/ServerProtocolFactory.php | 33 ++-- .../PasswordPolicyBindEnforcementTest.php | 4 +- ...sswordPolicyPlainModifyEnforcementTest.php | 2 +- .../PasswordPolicyAwareAuthenticatorTest.php | 4 +- .../Auth/SaslBindPolicyEnforcerTest.php | 13 +- .../Adapter/WritableStorageBackendTest.php | 4 +- .../Write/PasswordPolicyWriteHandlerTest.php | 2 +- .../Backend/Write/SystemChangeWriterTest.php | 2 +- .../Server/Logging/OperationAuditorTest.php | 4 +- .../OperationAuditMiddlewareTest.php | 4 +- .../BindStrategy/ReplicaBindStrategyTest.php | 185 ++++++++++++++++++ .../Guard/PasswordPolicyBindGuardTest.php | 4 +- .../InMemoryReplicaPasswordStateStoreTest.php | 88 +++++++++ 37 files changed, 784 insertions(+), 56 deletions(-) rename src/FreeDSx/Ldap/Server/Backend/Write/{ => Schema}/SchemaViolation.php (91%) rename src/FreeDSx/Ldap/Server/Backend/Write/{ => Schema}/SchemaViolationDisposition.php (92%) rename src/FreeDSx/Ldap/Server/Backend/Write/{ => Schema}/SchemaViolations.php (94%) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Write/SystemChange/LocalStateSystemChangeWriter.php rename src/FreeDSx/Ldap/Server/Backend/Write/{ => SystemChange}/NullSystemChangeWriter.php (92%) rename src/FreeDSx/Ldap/Server/Backend/Write/{ => SystemChange}/SystemChangeWriter.php (88%) rename src/FreeDSx/Ldap/Server/Backend/Write/{ => SystemChange}/SystemChangeWriterInterface.php (92%) create mode 100644 src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/EntryBindStrategy.php create mode 100644 src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/PasswordPolicyBindStrategyInterface.php create mode 100644 src/FreeDSx/Ldap/Server/PasswordPolicy/Guard/BindStrategy/ReplicaBindStrategy.php create mode 100644 src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStore.php create mode 100644 src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordState.php create mode 100644 src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordStateStoreInterface.php create mode 100644 tests/unit/Server/PasswordPolicy/Guard/BindStrategy/ReplicaBindStrategyTest.php create mode 100644 tests/unit/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStoreTest.php 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()); + } +}