diff --git a/docs/Server/Password-Policy.md b/docs/Server/Password-Policy.md index 730855ba..edf43df2 100644 --- a/docs/Server/Password-Policy.md +++ b/docs/Server/Password-Policy.md @@ -217,6 +217,9 @@ recent window. A locked bind returns `INVALID_CREDENTIALS` with the `accountLocked` error. +In a replicated setup, lockout can apply across the whole cluster so failures against a replica count too. See +[Password Policy](Replication.md#password-policy) under Replication. + ### Failed Bind Delay When both `pwdMinDelay` and `pwdMaxDelay` are set to positive values, the server delays the response to a failed diff --git a/docs/Server/Replication.md b/docs/Server/Replication.md index 5eb90ac7..500d641d 100644 --- a/docs/Server/Replication.md +++ b/docs/Server/Replication.md @@ -237,7 +237,27 @@ Because the two are separate, configure and review both. A replica does not inhe ### Password Policy -A replica enforces the password-policy state it receives from the provider (lockout, expiry, grace) but records none of -its own, so bind failures against a replica are not counted toward lockout there. Account lockout is not a real-time -defense on a replica; put replicas behind connection rate limiting and/or deploy them in already isolated or trusted -areas. +With a password policy enabled, a replica applies the provider's lockout, expiry, and grace state, and bind failures +against the replica count toward the account's lockout across the whole cluster. A brute-force attempt spread over +several replicas is caught the same as one against a single server. + +To turn this on: + +- Enable a password policy on both the provider and the replica with `setPasswordPolicy(...)`. +- On the provider, grant the replica's bind identity with `withReplicaGrants(...)`. It sets up everything a replica + identity needs, so use it in place of the sync-only grant from the Quick Start. +- Give the replica PDO storage (SQLite or MySQL). JSON-file storage is not yet supported for replica password-policy + state or forwarding. + +```php +use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; +use FreeDSx\Ldap\Server\AccessControl\Target\Target; + +$aclRules = $myAclRules->withReplicaGrants( + Subject::dn('cn=replica,dc=example,dc=com'), + Target::subtree('dc=example,dc=com'), +); +``` + +Cluster-wide lockout is not instantaneous, so keep replicas behind connection rate limiting. If the provider is not a +FreeDSx server, the replica still enforces the lockout state it receives but does not contribute its own. diff --git a/src/FreeDSx/Ldap/Container.php b/src/FreeDSx/Ldap/Container.php index d75ad893..afdf7e87 100644 --- a/src/FreeDSx/Ldap/Container.php +++ b/src/FreeDSx/Ldap/Container.php @@ -64,6 +64,7 @@ use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\SafeModifyConstraint; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyComponentFactory; use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyEngine; +use FreeDSx\Ldap\Server\PasswordPolicy\UniquePolicyTimeFactory; use FreeDSx\Ldap\Server\PasswordPolicy\Replica\InMemoryReplicaPasswordStateStore; use FreeDSx\Ldap\Server\PasswordPolicy\Replica\ReplicaPasswordStateStoreInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; @@ -343,6 +344,10 @@ private function makePasswordPolicyEngine(): PasswordPolicyEngine return new PasswordPolicyEngine( clock: $clock, changeConstraints: $chain, + uniqueTimes: new UniquePolicyTimeFactory( + $clock, + $options->getChangeJournalConfig()->origin, + ), ); } @@ -721,6 +726,7 @@ function (): void { periodicTasks: $periodicTasks, longLivedTasks: $longLivedTasks, logger: $options->getLogger(), + gracefulStopSeconds: $options->getShutdownTimeout(), ); } diff --git a/src/FreeDSx/Ldap/Operation/Request/ForwardPasswordPolicyStateRequest.php b/src/FreeDSx/Ldap/Operation/Request/ForwardPasswordPolicyStateRequest.php index ae3d47b4..55ff58a7 100644 --- a/src/FreeDSx/Ldap/Operation/Request/ForwardPasswordPolicyStateRequest.php +++ b/src/FreeDSx/Ldap/Operation/Request/ForwardPasswordPolicyStateRequest.php @@ -102,7 +102,8 @@ public function toAsn1(): SequenceType Asn1::octetString($this->dn->toString()), Asn1::octetString($this->entryUuid), Asn1::setOf(...array_map( - static fn(DateTimeImmutable $time): OctetStringType => Asn1::octetString(GeneralizedTime::format($time)), + static fn(DateTimeImmutable $time): OctetStringType + => Asn1::octetString(GeneralizedTime::formatWithFraction($time)), $this->failureTimes, )), ); diff --git a/src/FreeDSx/Ldap/Schema/Definition/GeneralizedTime.php b/src/FreeDSx/Ldap/Schema/Definition/GeneralizedTime.php index d670d94e..e1f3ffd2 100644 --- a/src/FreeDSx/Ldap/Schema/Definition/GeneralizedTime.php +++ b/src/FreeDSx/Ldap/Schema/Definition/GeneralizedTime.php @@ -78,6 +78,17 @@ public static function format(DateTimeImmutable $instant): string ->format('YmdHis') . 'Z'; } + /** + * Render with a fractional-seconds field, for multi-valued attributes that must hold unique values such as + * pwdFailureTime and pwdGraceUseTime; {@see format()} stays second-resolution for canonical single-valued stamps. + */ + public static function formatWithFraction(DateTimeImmutable $instant): string + { + return $instant + ->setTimezone(new DateTimeZone('UTC')) + ->format('YmdHis.u') . 'Z'; + } + /** * @param array $components named capture groups */ diff --git a/src/FreeDSx/Ldap/Server/AccessControl/AclRules.php b/src/FreeDSx/Ldap/Server/AccessControl/AclRules.php index e641411e..9d97ce28 100644 --- a/src/FreeDSx/Ldap/Server/AccessControl/AclRules.php +++ b/src/FreeDSx/Ldap/Server/AccessControl/AclRules.php @@ -13,7 +13,9 @@ namespace FreeDSx\Ldap\Server\AccessControl; +use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Operation\OperationType; +use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeRule; use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule; use FreeDSx\Ldap\Server\AccessControl\Rule\Effect; @@ -22,6 +24,7 @@ use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; use FreeDSx\Ldap\Server\AccessControl\Subject\SubjectMatcherInterface; use FreeDSx\Ldap\Server\AccessControl\Target\AnyTargetMatcher; +use FreeDSx\Ldap\Server\AccessControl\Target\TargetMatcherInterface; /** * The rule sets and defaults that configure {@see RuleBasedAccessControl}. @@ -133,6 +136,38 @@ public function withExtendedOperationRules(ExtendedOperationRule ...$extendedOps ); } + /** + * Grant a replica's bind identity the two privileged capabilities it needs: the content-sync control over $target, + * and the password-policy forward extended operation. + */ + public function withReplicaGrants( + SubjectMatcherInterface $replica, + TargetMatcherInterface $target = new AnyTargetMatcher(), + ): self { + return new self( + $this->operations, + $this->attributes, + [ + ...$this->controls, + ControlRule::allow( + $replica, + $target, + Control::OID_SYNC_REQUEST, + ), + ], + [ + ...$this->extendedOps, + ExtendedOperationRule::allow( + $replica, + ExtendedRequest::OID_PPOLICY_STATE_FORWARD, + ), + ], + $this->defaultOperationEffect, + $this->defaultControlEffect, + $this->defaultExtendedOpEffect, + ); + } + public function withDefaultOperationEffect(Effect $effect): self { return new self( diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php index ae9a6781..f0c209cf 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Dialect/SqliteDialect.php @@ -53,12 +53,19 @@ public function rollBack(PDO $pdo): void $pdo->exec('ROLLBACK'); } + /** + * Upserts an entry in place via ON CONFLICT rather than INSERT OR REPLACE, because REPLACE deletes the row first and + * fires ON DELETE CASCADE, which would wipe child rows such as the replica password-policy state. + */ public function queryUpsert(): string { return <<transactor + ->pdo() + ->prepare('DELETE FROM ' . self::TABLE . ' WHERE lc_dn = ?') + ->execute([$this->key($dn)]); + } + private function loadRecord(Dn $dn): ReplicaForwardState { $statement = $this->transactor diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyEngine.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyEngine.php index 44aface1..3d8b9332 100644 --- a/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyEngine.php +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/PasswordPolicyEngine.php @@ -19,6 +19,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Schema\Definition\GeneralizedTime; use FreeDSx\Ldap\Schema\Definition\PasswordPolicyOid; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId; use FreeDSx\Ldap\Server\Clock\ClockInterface; use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeAttempt; use FreeDSx\Ldap\Server\PasswordPolicy\Constraint\PasswordChangeConstraintChain; @@ -32,10 +33,17 @@ */ final readonly class PasswordPolicyEngine { + private UniquePolicyTimeFactory $uniqueTimes; + public function __construct( private ClockInterface $clock, private PasswordChangeConstraintChain $changeConstraints, - ) {} + ?UniquePolicyTimeFactory $uniqueTimes = null, + ) { + // The Container injects the configured factory; the fallback reuses this engine's clock so tests stay in sync. + $this->uniqueTimes = $uniqueTimes + ?? new UniquePolicyTimeFactory($this->clock, new ReplicaId('local')); + } /** * Lockout check applied before the inner authenticator verifies credentials. @@ -80,7 +88,7 @@ public function recordBindFailure( $priorFailures, $policy, ); - $retained[] = $now; + $retained[] = $this->uniqueTimes->next($retained); $changes = [Change::replace( PasswordPolicyOid::NAME_PWD_FAILURE_TIME, @@ -294,7 +302,7 @@ private function unionTimes( $byValue = []; foreach ([...$current, ...$forwarded] as $time) { - $byValue[GeneralizedTime::format($time)] = $time; + $byValue[GeneralizedTime::formatWithFraction($time)] = $time; } return array_values($byValue); @@ -596,7 +604,7 @@ private function buildSuccessChanges( $changes[] = Change::reset(PasswordPolicyOid::NAME_PWD_ACCOUNT_LOCKED_TIME); } if ($isExpired) { - $graceTimes = [...$state->graceUseTimes, $now]; + $graceTimes = [...$state->graceUseTimes, $this->uniqueTimes->next($state->graceUseTimes)]; $changes[] = Change::replace( PasswordPolicyOid::NAME_PWD_GRACE_USE_TIME, ...self::formatTimes($graceTimes), @@ -731,7 +739,7 @@ private function secondsSinceLock(UserPasswordState $state): int private static function formatTimes(array $instants): array { return array_values(array_map( - static fn(DateTimeImmutable $t): string => GeneralizedTime::format($t), + static fn(DateTimeImmutable $t): string => GeneralizedTime::formatWithFraction($t), $instants, )); } diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStore.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStore.php index 0cda3620..0fd26b7d 100644 --- a/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStore.php +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStore.php @@ -106,6 +106,11 @@ public function discardIfSuperseded( } } + public function discard(Dn $dn): void + { + unset($this->records[$this->key($dn)]); + } + private function key(Dn $dn): string { return $dn->normalize()->toString(); diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordStateStoreInterface.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordStateStoreInterface.php index acf2f621..4694bbee 100644 --- a/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordStateStoreInterface.php +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/Replica/ReplicaPasswordStateStoreInterface.php @@ -63,4 +63,9 @@ public function discardIfSuperseded( Dn $dn, UserPasswordState $authoritative, ): void; + + /** + * Drop a subject's local state outright, used when the subject is deleted on the primary; a no-op when none exists. + */ + public function discard(Dn $dn): void; } diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/UniquePolicyTimeFactory.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/UniquePolicyTimeFactory.php new file mode 100644 index 00000000..6b03d016 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/UniquePolicyTimeFactory.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\PasswordPolicy; + +use DateTimeImmutable; +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId; +use FreeDSx\Ldap\Server\Clock\ClockInterface; + +use function crc32; +use function intdiv; + +/** + * Stamps pwdFailureTime / pwdGraceUseTime values that stay unique within and across replicas, as draft-behera password + * policy recommends, by packing a replica hash and an intra-second counter into the microsecond field. + * + * @internal + * @author Chad Sikorra + */ +readonly class UniquePolicyTimeFactory +{ + /** + * The high 4 digits of the microsecond field, so cross-replica hash collisions land at about one in ten thousand. + */ + private const BAND_MODULUS = 10000; + + /** + * The low 2 digits of the microsecond field; failures stop accruing once an account locks, so it never nears 100. + */ + private const COUNTER_MODULUS = 100; + + /** + * The replica's fixed slice of the microsecond field; a hash collision only under-counts by one for a shared + * subject in the same second, which is fail-safe and within the spec's "local matter" latitude. + */ + private int $replicaBand; + + public function __construct( + private ClockInterface $clock, + ReplicaId $replicaId, + ) { + $this->replicaBand = (int) (crc32((string) $replicaId) % self::BAND_MODULUS); + } + + /** + * The current time stamped unique against $existing, which the caller must read under the same lock that persists + * the result so the counter cannot race. + * + * @param list $existing + */ + public function next(array $existing): DateTimeImmutable + { + $whole = $this->clock + ->now() + ->setTime( + (int) $this->clock->now()->format('H'), + (int) $this->clock->now()->format('i'), + (int) $this->clock->now()->format('s'), + ); + + $microseconds = $this->replicaBand * self::COUNTER_MODULUS + + $this->sameBandCount($existing, $whole) % self::COUNTER_MODULUS; + + return $whole->setTime( + (int) $whole->format('H'), + (int) $whole->format('i'), + (int) $whole->format('s'), + $microseconds, + ); + } + + /** + * @param list $existing + */ + private function sameBandCount( + array $existing, + DateTimeImmutable $whole, + ): int { + $second = $whole->format('YmdHis'); + $count = 0; + + foreach ($existing as $time) { + if ($time->format('YmdHis') !== $second) { + continue; + } + + if (intdiv((int) $time->format('u'), self::COUNTER_MODULUS) === $this->replicaBand) { + $count++; + } + } + + return $count; + } +} diff --git a/src/FreeDSx/Ldap/Server/PasswordPolicy/UserPasswordState.php b/src/FreeDSx/Ldap/Server/PasswordPolicy/UserPasswordState.php index 135e8ae1..373f92bf 100644 --- a/src/FreeDSx/Ldap/Server/PasswordPolicy/UserPasswordState.php +++ b/src/FreeDSx/Ldap/Server/PasswordPolicy/UserPasswordState.php @@ -110,15 +110,14 @@ public function isSupersededBy(self $authoritative): bool return true; } - if ($this->isLocked()) { - return false; - } - $newestFailure = $this->newestFailureTime(); if ($newestFailure === null) { - return true; + // No local failures to weigh a reset against + // Drop only when there is also no local lock left to enforce. + return !$this->isLocked(); } + // A success or credential change past our newest failure means the primary reset the counter and any lock. return self::isAfter( $authoritative->lastSuccess, $newestFailure, diff --git a/src/FreeDSx/Ldap/Server/Process/BackgroundTask/PcntlBackgroundTasks.php b/src/FreeDSx/Ldap/Server/Process/BackgroundTask/PcntlBackgroundTasks.php index 65359799..77d292b3 100644 --- a/src/FreeDSx/Ldap/Server/Process/BackgroundTask/PcntlBackgroundTasks.php +++ b/src/FreeDSx/Ldap/Server/Process/BackgroundTask/PcntlBackgroundTasks.php @@ -21,6 +21,7 @@ use function pcntl_waitpid; use function posix_kill; use function sprintf; +use function usleep; use const SIGTERM; use const WNOHANG; @@ -52,6 +53,7 @@ public function __construct( private readonly array $periodicTasks, private readonly array $longLivedTasks, private readonly ?LoggerInterface $logger = null, + private readonly int $gracefulStopSeconds = 0, ) { $this->childStart = static function (): void {}; } @@ -76,14 +78,82 @@ public function tick(): void public function stop(): void { + $pids = []; + foreach ($this->longLivedTasks as $index => $task) { - $this->endChild($this->longLivedPids[$index] ?? null); + $pid = $this->longLivedPids[$index] ?? null; + + if ($pid !== null) { + $pids[] = $pid; + } + $this->longLivedPids[$index] = null; } + foreach ($this->periodicState as $state) { - $this->endChild($state->pid); + if ($state->pid !== null) { + $pids[] = $state->pid; + } + $state->pid = null; } + + $this->endChildren($pids); + } + + /** + * Signal every task to stop, wait up to the graceful budget for them to exit, then force-kill any stragglers. + * + * @param list $pids + */ + private function endChildren(array $pids): void + { + foreach ($pids as $pid) { + posix_kill( + $pid, + SIGTERM, + ); + } + + $deadline = microtime(true) + $this->gracefulStopSeconds; + $pending = $pids; + while ($pending !== [] && microtime(true) < $deadline) { + $pending = $this->reapExited($pending); + if ($pending !== []) { + usleep(10000); + } + } + + foreach ($pending as $pid) { + posix_kill( + $pid, + SIGKILL, + ); + $status = 0; + pcntl_waitpid( + $pid, + $status, + ); + } + } + + /** + * @param list $pids + * @return list those not yet reaped + */ + private function reapExited(array $pids): array + { + $pending = []; + + foreach ($pids as $pid) { + $status = 0; + + if (pcntl_waitpid($pid, $status, WNOHANG) === 0) { + $pending[] = $pid; + } + } + + return $pending; } /** @@ -161,21 +231,4 @@ private function pidExited(?int $pid): bool return $result === -1 || $result > 0; } - - private function endChild(?int $pid): void - { - if ($pid === null) { - return; - } - - posix_kill( - $pid, - SIGTERM, - ); - $status = 0; - pcntl_waitpid( - $pid, - $status, - ); - } } diff --git a/src/FreeDSx/Ldap/Sync/Consumer/ReconcilingChangeApplier.php b/src/FreeDSx/Ldap/Sync/Consumer/ReconcilingChangeApplier.php index 6c2a7671..96a43551 100644 --- a/src/FreeDSx/Ldap/Sync/Consumer/ReconcilingChangeApplier.php +++ b/src/FreeDSx/Ldap/Sync/Consumer/ReconcilingChangeApplier.php @@ -43,14 +43,24 @@ public function apply( ): void { $this->baseApplier->apply($result, $session); - // Only an add or modify carries authoritative state that can supersede local accumulation; present is a no-op - // and a delete drops the subject, whose local state cascades with the entry row. - if (!$result->isAdd() && !$result->isModify()) { + // A present marker changes nothing. + if ($result->isPresent()) { + return; + } + + $dn = $result->getEntry() + ->getDn() + ->normalize(); + + // Drops it outright on delete, whether the underlying storage already does. + if ($result->isDelete()) { + $this->passwordStateStore->discard($dn); + return; } $this->passwordStateStore->discardIfSuperseded( - $result->getEntry()->getDn()->normalize(), + $dn, UserPasswordState::fromEntry($result->getEntry()), ); } diff --git a/tests/integration/Sync/SyncReplForwardTestCase.php b/tests/integration/Sync/SyncReplForwardTestCase.php new file mode 100644 index 00000000..90d438df --- /dev/null +++ b/tests/integration/Sync/SyncReplForwardTestCase.php @@ -0,0 +1,234 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Sync; + +use FreeDSx\Ldap\ClientOptions; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\BindException; +use FreeDSx\Ldap\LdapClient; +use FreeDSx\Ldap\Operation\Request\PasswordModifyRequest; +use Tests\Integration\FreeDSx\Ldap\ServerTestCase; +use Tests\Support\FreeDSx\Ldap\LdapServerCommand; +use Throwable; + +/** + * A read-only replica that forwards its ppolicy bind-state to the provider it mirrors over RFC 4533. + */ +abstract class SyncReplForwardTestCase extends ServerTestCase +{ + private const PROVIDER_PORT = 10391; + + private const PWD_LOCKED_TIME = 'pwdAccountLockedTime'; + + public function setUp(): void + { + $this->setServerMode('ldap-replica'); + + parent::setUp(); + } + + public function test_replica_bind_failures_roll_up_to_a_global_lock_on_the_provider(): void + { + $dn = 'cn=lockme,ou=people,dc=foo,dc=bar'; + self::assertNotNull($this->waitForReplica($dn)); + self::assertFalse($this->providerHasLock($dn)); + + // Two failed binds on the replica reach the local threshold and queue the failures for forward. + $this->tryReplicaBind($dn, 'wrong'); + $this->tryReplicaBind($dn, 'wrong'); + + // The forwarded failures roll up on the provider and lock the account globally. + self::assertTrue( + $this->pollUntil(fn(): bool => $this->providerHasLock($dn)), + 'The replica failures should forward and lock the account on the provider.', + ); + } + + public function test_a_password_reset_on_the_provider_retires_the_replica_local_lock(): void + { + $dn = 'cn=resetme,ou=people,dc=foo,dc=bar'; + self::assertNotNull($this->waitForReplica($dn)); + + $this->tryReplicaBind($dn, 'wrong'); + $this->tryReplicaBind($dn, 'wrong'); + + // The replica enforces its own lock across connections before anything replicates back. + self::assertFalse($this->replicaBindSucceeds($dn, '12345')); + + // Let the forward fully apply so the reset below does not race an in-flight forward. + self::assertTrue( + $this->pollUntil(fn(): bool => $this->providerHasLock($dn)), + 'The account should first lock on the provider via forward.', + ); + + // An admin reset on the provider advances pwdChangedTime and clears the lockout. + $this->resetPasswordOnProvider($dn, 'newpass'); + self::assertTrue( + $this->pollUntil(fn(): bool => !$this->providerHasLock($dn)), + 'The provider reset should clear the lock.', + ); + + // The reset replicates back; the replica supersedes its local lock and accepts the new password. + self::assertTrue( + $this->pollUntil(fn(): bool => $this->replicaBindSucceeds($dn, 'newpass')), + 'The replica should retire its local lock once the reset replicates.', + ); + } + + private function providerHasLock(string $dn): bool + { + $manager = $this->providerClient(); + + try { + $manager->bind( + LdapServerCommand::MANAGER_DN, + LdapServerCommand::MANAGER_PASSWORD, + ); + $entry = $manager->read( + $dn, + [self::PWD_LOCKED_TIME], + ); + + return $entry?->get(self::PWD_LOCKED_TIME) !== null; + } catch (Throwable) { + return false; + } finally { + $this->quietUnbind($manager); + } + } + + private function resetPasswordOnProvider( + string $dn, + string $newPassword, + ): void { + $manager = $this->providerClient(); + + try { + $manager->bind( + LdapServerCommand::MANAGER_DN, + LdapServerCommand::MANAGER_PASSWORD, + ); + $manager->sendAndReceive(new PasswordModifyRequest( + $dn, + null, + $newPassword, + )); + } finally { + $this->quietUnbind($manager); + } + } + + private function replicaBindSucceeds( + string $dn, + string $password, + ): bool { + $client = $this->buildClient('tcp'); + + try { + $client->bind( + $dn, + $password, + ); + + return true; + } catch (BindException) { + return false; + } finally { + $this->quietUnbind($client); + } + } + + private function tryReplicaBind( + string $dn, + string $password, + ): void { + $this->replicaBindSucceeds($dn, $password); + } + + private function waitForReplica( + string $dn, + float $timeoutSeconds = 15.0, + ): ?Entry { + $deadline = microtime(true) + $timeoutSeconds; + + do { + $entry = $this->tryReadFromReplica($dn); + + if ($entry !== null) { + return $entry; + } + + usleep(100_000); + } while (microtime(true) < $deadline); + + return null; + } + + private function tryReadFromReplica(string $dn): ?Entry + { + $client = $this->buildClient('tcp'); + + try { + $client->bind( + 'cn=user,dc=foo,dc=bar', + '12345', + ); + + return $client->read($dn); + } catch (Throwable) { + return null; + } finally { + $this->quietUnbind($client); + } + } + + /** + * @param callable(): bool $condition + */ + private function pollUntil( + callable $condition, + float $timeoutSeconds = 25.0, + ): bool { + $deadline = microtime(true) + $timeoutSeconds; + + do { + if ($condition()) { + return true; + } + + usleep(200_000); + } while (microtime(true) < $deadline); + + return false; + } + + private function providerClient(): LdapClient + { + return $this->getClient( + (new ClientOptions()) + ->setPort(self::PROVIDER_PORT) + ->setServers(['127.0.0.1']) + ->setSslValidateCert(false), + ); + } + + private function quietUnbind(LdapClient $client): void + { + try { + $client->unbind(); + } catch (Throwable) { + // The connection may already be gone after a failed or locked bind. + } + } +} diff --git a/tests/integration/Sync/SyncReplNativeTest.php b/tests/integration/Sync/SyncReplNativeTest.php index e6a7cb4d..0a16a607 100644 --- a/tests/integration/Sync/SyncReplNativeTest.php +++ b/tests/integration/Sync/SyncReplNativeTest.php @@ -74,6 +74,7 @@ public function testItStreamsEveryEntryOnAFreshRefreshOnlyPoll(): void 'cn=bob,ou=people,dc=foo,dc=bar', 'cn=carol,ou=people,dc=foo,dc=bar', 'cn=lockme,ou=people,dc=foo,dc=bar', + 'cn=resetme,ou=people,dc=foo,dc=bar', 'cn=user,dc=foo,dc=bar', 'dc=foo,dc=bar', 'ou=people,dc=foo,dc=bar', diff --git a/tests/integration/Sync/SyncReplPcntlForwardTest.php b/tests/integration/Sync/SyncReplPcntlForwardTest.php new file mode 100644 index 00000000..1701aa82 --- /dev/null +++ b/tests/integration/Sync/SyncReplPcntlForwardTest.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 Tests\Integration\FreeDSx\Ldap\Sync; + +/** + * A ppolicy-forwarding replica under the PCNTL runner. + */ +final class SyncReplPcntlForwardTest extends SyncReplForwardTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + if (!extension_loaded('pcntl')) { + return; + } + + static::initSharedServer( + 'ldap-replica', + 'tcp', + ['--forward'], + ); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + static::tearDownSharedServer(); + } +} diff --git a/tests/resources/seed/sync-seed.ldif b/tests/resources/seed/sync-seed.ldif index 3607f3a1..ce08972d 100644 --- a/tests/resources/seed/sync-seed.ldif +++ b/tests/resources/seed/sync-seed.ldif @@ -35,3 +35,9 @@ objectClass: inetOrgPerson cn: lockme sn: Lockme userPassword: {SHA}jLIjfQZ5yojbZGTqxg2pY0VROWQ= + +dn: cn=resetme,ou=people,dc=foo,dc=bar +objectClass: inetOrgPerson +cn: resetme +sn: Resetme +userPassword: {SHA}jLIjfQZ5yojbZGTqxg2pY0VROWQ= diff --git a/tests/support/LdapReplicaCommand.php b/tests/support/LdapReplicaCommand.php index e92a6543..8b5e0d42 100644 --- a/tests/support/LdapReplicaCommand.php +++ b/tests/support/LdapReplicaCommand.php @@ -42,7 +42,8 @@ protected function configure(): void ->addOption('port', null, InputOption::VALUE_REQUIRED, 'The replica listen port.', '10389') ->addOption('provider-port', null, InputOption::VALUE_REQUIRED, 'The provider listen port.', '10391') ->addOption('runner', null, InputOption::VALUE_REQUIRED, 'The server runner (pcntl/swoole).', 'pcntl') - ->addOption('storage', null, InputOption::VALUE_REQUIRED, 'Process-shared storage (json/sqlite).', 'sqlite'); + ->addOption('storage', null, InputOption::VALUE_REQUIRED, 'Process-shared storage (json/sqlite).', 'sqlite') + ->addOption('forward', null, InputOption::VALUE_NONE, 'Enable ppolicy-state forwarding to the provider.'); } protected function execute( @@ -71,6 +72,7 @@ protected function execute( $provider = $this->startProvider( $providerPort, $runner, + $input->getOption('forward') === true, ); register_shutdown_function(static fn() => $provider->stop()); @@ -98,6 +100,7 @@ protected function execute( ), )) ->setSocketAcceptTimeout(0.1) + ->setShutdownTimeout(0) ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)), ); @@ -109,8 +112,9 @@ protected function execute( private function startProvider( int $providerPort, string $runner, + bool $forward, ): Process { - $provider = new Process([ + $args = [ 'php', '-dpcov.enabled=0', __DIR__ . '/../bin/ldap-server.php', @@ -120,7 +124,14 @@ private function startProvider( '--storage=sqlite', '--seed=' . self::SEED, '--allow-sync', - ]); + ]; + + if ($forward) { + $args[] = '--allow-ppolicy-forward'; + $args[] = '--manager'; + } + + $provider = new Process($args); $provider->start(); $deadline = microtime(true) + 10.0; diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index 64e79332..c26489d6 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -4,14 +4,20 @@ namespace Tests\Support\FreeDSx\Ldap; +use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Ldif\Output\FileLdifOutput; +use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule; +use FreeDSx\Ldap\Server\AccessControl\Rule\ExtendedOperationRule; use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; use FreeDSx\Ldap\Server\AccessControl\Target\Target; +use FreeDSx\Ldap\Server\Backend\Auth\ManagerIdentity; +use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; +use FreeDSx\Ldap\Server\PasswordPolicy\Rules\PasswordLockoutRules; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRule; use FreeDSx\Ldap\Server\SearchLimit\SearchLimitRules; @@ -36,6 +42,10 @@ final class LdapServerCommand extends Command { use ConsoleOptionsTrait; + public const MANAGER_DN = 'cn=manager'; + + public const MANAGER_PASSWORD = 'manager-pass'; + private const SSL_KEY = __DIR__ . '/../resources/cert/slapd.key'; private const SSL_CERT = __DIR__ . '/../resources/cert/slapd.crt'; @@ -135,6 +145,18 @@ protected function configure(): void InputOption::VALUE_NONE, 'Grant authenticated identities the (privileged) content-sync control over dc=foo,dc=bar', ) + ->addOption( + 'allow-ppolicy-forward', + null, + InputOption::VALUE_NONE, + 'Enable password policy with lockout and grant cn=user the privileged ppolicy-state forward extended op', + ) + ->addOption( + 'manager', + null, + InputOption::VALUE_NONE, + 'Configure a break-glass manager identity (cn=manager) so tests can reset passwords', + ) ->addOption( 'seed', null, @@ -242,6 +264,7 @@ protected function execute( ->setMaxSearchLookthrough((int) $this->getStringOption($input, 'max-search-lookthrough')) ->setMaxSearchPagedLookthrough((int) $this->getStringOption($input, 'max-search-paged-lookthrough')) ->setSyncEnabled(true) + ->setShutdownTimeout(0) ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)); $authenticatedLookthrough = (int) $this->getStringOption($input, 'authenticated-lookthrough'); @@ -302,6 +325,31 @@ protected function execute( ); } + if ($input->getOption('allow-ppolicy-forward') === true) { + $options + ->setPasswordPolicy(new PasswordPolicy( + lockout: new PasswordLockoutRules( + enabled: true, + maxFailure: 2, + ), + )) + ->setAclRules( + $options->getAclRules()->withExtendedOperationRules( + ExtendedOperationRule::allow( + Subject::dn('cn=user,dc=foo,dc=bar'), + ExtendedRequest::OID_PPOLICY_STATE_FORWARD, + ), + ), + ); + } + + if ($input->getOption('manager') === true) { + $options->setManager(new ManagerIdentity( + new Dn(self::MANAGER_DN), + '{SHA}' . base64_encode(sha1(self::MANAGER_PASSWORD, true)), + )); + } + $server->getOptions()->setStorage($storage); $loadData = function () use ($server, $storage, $seedFile, $entries, $changesFile, $dumpFile): void { diff --git a/tests/unit/Operation/Request/ForwardPasswordPolicyStateRequestTest.php b/tests/unit/Operation/Request/ForwardPasswordPolicyStateRequestTest.php index 2719015e..6e8571e0 100644 --- a/tests/unit/Operation/Request/ForwardPasswordPolicyStateRequestTest.php +++ b/tests/unit/Operation/Request/ForwardPasswordPolicyStateRequestTest.php @@ -94,7 +94,7 @@ public function test_it_generates_the_expected_asn1(): void Asn1::context(1, Asn1::octetString($encoder->encode(Asn1::sequence( Asn1::octetString('cn=user,dc=foo,dc=bar'), Asn1::octetString('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'), - Asn1::setOf(Asn1::octetString(GeneralizedTime::format($this->time))), + Asn1::setOf(Asn1::octetString(GeneralizedTime::formatWithFraction($this->time))), Asn1::context(0, Asn1::octetString(GeneralizedTime::format($this->success))), )))), )), @@ -117,7 +117,7 @@ public function test_it_omits_last_success_from_asn1_when_absent(): void Asn1::context(1, Asn1::octetString($encoder->encode(Asn1::sequence( Asn1::octetString('cn=user,dc=foo,dc=bar'), Asn1::octetString('uuid'), - Asn1::setOf(Asn1::octetString(GeneralizedTime::format($this->time))), + Asn1::setOf(Asn1::octetString(GeneralizedTime::formatWithFraction($this->time))), )))), )), $request->toAsn1(), diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandlerTest.php index a53a8b0f..c8d07b68 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerPasswordPolicyForwardHandlerTest.php @@ -91,7 +91,7 @@ public function test_it_unions_forwarded_failures_via_an_atomic_update(): void $changes[0]->getAttribute()->getName(), ); self::assertSame( - ['20260520115900Z'], + ['20260520115900.000000Z'], $changes[0]->getAttribute()->getValues(), ); } diff --git a/tests/unit/Server/AccessControl/AclRulesTest.php b/tests/unit/Server/AccessControl/AclRulesTest.php index 0cdd9922..c893f071 100644 --- a/tests/unit/Server/AccessControl/AclRulesTest.php +++ b/tests/unit/Server/AccessControl/AclRulesTest.php @@ -13,13 +13,17 @@ namespace Tests\Unit\FreeDSx\Ldap\Server\AccessControl; +use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Entry\Attribute; use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\OperationType; +use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Server\AccessControl\AclRules; use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; +use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule; +use FreeDSx\Ldap\Server\AccessControl\Rule\Effect; use FreeDSx\Ldap\Server\AccessControl\RuleBasedAccessControl; use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; use FreeDSx\Ldap\Server\Token\AnonToken; @@ -56,6 +60,47 @@ public function test_secureDefault_lets_self_write_its_own_userPassword(): void $this->addToAssertionCount(1); } + public function test_withReplicaGrants_allows_the_sync_control_and_forward_op(): void + { + $rules = AclRules::fromEmpty()->withReplicaGrants(Subject::dn('cn=replica,dc=foo,dc=bar')); + + $control = $rules->controls[0]; + self::assertSame( + Effect::Allow, + $control->effect, + ); + self::assertContains( + Control::OID_SYNC_REQUEST, + $control->controlOids, + ); + + $extendedOp = $rules->extendedOps[0]; + self::assertSame( + Effect::Allow, + $extendedOp->effect, + ); + self::assertContains( + ExtendedRequest::OID_PPOLICY_STATE_FORWARD, + $extendedOp->extendedOpOids, + ); + } + + public function test_withReplicaGrants_appends_to_existing_control_rules(): void + { + $existing = ControlRule::allow(Subject::dn(self::ADMIN_DN)); + $rules = AclRules::fromEmpty(controls: [$existing]) + ->withReplicaGrants(Subject::dn('cn=replica,dc=foo,dc=bar')); + + self::assertSame( + $existing, + $rules->controls[0], + ); + self::assertCount( + 2, + $rules->controls, + ); + } + public function test_secureDefault_denies_writing_another_userPassword(): void { $this->expectException(OperationException::class); diff --git a/tests/unit/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStoreTest.php b/tests/unit/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStoreTest.php index 4277287e..412ecdd6 100644 --- a/tests/unit/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStoreTest.php +++ b/tests/unit/Server/Backend/Storage/Adapter/PdoReplicaPasswordStateStoreTest.php @@ -226,6 +226,22 @@ public function test_discard_is_a_noop_for_an_unknown_subject(): void self::assertTrue($this->subject->load(new Dn(self::DN))->isEmpty()); } + public function test_discard_removes_state_unconditionally(): void + { + $this->applyFailure('20260520120000Z'); + + $this->subject->discard(new Dn(self::DN)); + + self::assertTrue($this->subject->load(new Dn(self::DN))->isEmpty()); + } + + public function test_discard_of_an_unknown_subject_is_a_noop(): void + { + $this->subject->discard(new Dn(self::DN)); + + self::assertTrue($this->subject->load(new Dn(self::DN))->isEmpty()); + } + private function applyFailure(string $time): void { $this->applyChanges( diff --git a/tests/unit/Server/PasswordPolicy/PasswordPolicyEngineTest.php b/tests/unit/Server/PasswordPolicy/PasswordPolicyEngineTest.php index 3ca73c18..31ae5fa0 100644 --- a/tests/unit/Server/PasswordPolicy/PasswordPolicyEngineTest.php +++ b/tests/unit/Server/PasswordPolicy/PasswordPolicyEngineTest.php @@ -31,7 +31,9 @@ use FreeDSx\Ldap\Server\PasswordPolicy\Decision\OperationalChanges; use FreeDSx\Ldap\Server\PasswordPolicy\Rules\PasswordLockoutRules; use FreeDSx\Ldap\Server\PasswordPolicy\Rules\PasswordQualityRules; +use FreeDSx\Ldap\Server\PasswordPolicy\UniquePolicyTimeFactory; use FreeDSx\Ldap\Server\PasswordPolicy\UserPasswordState; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Tests\Support\FreeDSx\Ldap\Clock\FrozenClock; use Tests\Support\FreeDSx\Ldap\Server\PasswordPolicy\RecordingPasswordChangeConstraint; @@ -42,14 +44,24 @@ final class PasswordPolicyEngineTest extends TestCase private FrozenClock $clock; + private UniquePolicyTimeFactory&MockObject $uniqueTimes; + private PasswordPolicyEngine $subject; protected function setUp(): void { $this->clock = FrozenClock::fromString(self::NOW); + $this->uniqueTimes = $this->createMock(UniquePolicyTimeFactory::class); + + // Return the plain frozen instant so generated failure/grace values are the second stamped with .000000. + $this->uniqueTimes + ->method('next') + ->willReturnCallback(fn(): DateTimeImmutable => $this->clock->now()); + $this->subject = new PasswordPolicyEngine( clock: $this->clock, changeConstraints: new PasswordChangeConstraintChain([]), + uniqueTimes: $this->uniqueTimes, ); } @@ -184,7 +196,7 @@ public function test_recordBindFailure_appends_failure_time(): void PasswordPolicyOid::NAME_PWD_FAILURE_TIME, ); self::assertSame( - [GeneralizedTime::format($this->clock->now())], + [GeneralizedTime::formatWithFraction($this->clock->now())], $change->getAttribute()->getValues(), ); } @@ -214,8 +226,8 @@ public function test_recordBindFailure_trims_failures_outside_interval(): void ); self::assertSame( [ - GeneralizedTime::format($recent), - GeneralizedTime::format($this->clock->now()), + GeneralizedTime::formatWithFraction($recent), + GeneralizedTime::formatWithFraction($this->clock->now()), ], $change->getAttribute()->getValues(), ); @@ -509,7 +521,7 @@ public function test_recordBindSuccess_expired_within_grace_returns_remaining(): PasswordPolicyOid::NAME_PWD_GRACE_USE_TIME, ); self::assertSame( - [GeneralizedTime::format($this->clock->now())], + [GeneralizedTime::formatWithFraction($this->clock->now())], $graceChange->getAttribute()->getValues(), ); } @@ -921,6 +933,7 @@ private function engineWith(PasswordChangeConstraint $constraint): PasswordPolic return new PasswordPolicyEngine( clock: $this->clock, changeConstraints: new PasswordChangeConstraintChain([$constraint]), + uniqueTimes: $this->uniqueTimes, ); } diff --git a/tests/unit/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStoreTest.php b/tests/unit/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStoreTest.php index dee39878..003723b7 100644 --- a/tests/unit/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStoreTest.php +++ b/tests/unit/Server/PasswordPolicy/Replica/InMemoryReplicaPasswordStateStoreTest.php @@ -225,6 +225,22 @@ public function test_discard_is_a_noop_for_an_unknown_subject(): void self::assertTrue($this->subject->load(new Dn(self::DN))->isEmpty()); } + public function test_discard_removes_state_unconditionally(): void + { + $this->applyFailure('20260520120000Z'); + + $this->subject->discard(new Dn(self::DN)); + + self::assertTrue($this->subject->load(new Dn(self::DN))->isEmpty()); + } + + public function test_discard_of_an_unknown_subject_is_a_noop(): void + { + $this->subject->discard(new Dn(self::DN)); + + self::assertTrue($this->subject->load(new Dn(self::DN))->isEmpty()); + } + private function applyFailure(string $time): void { $this->applyChanges( diff --git a/tests/unit/Server/PasswordPolicy/UniquePolicyTimeFactoryTest.php b/tests/unit/Server/PasswordPolicy/UniquePolicyTimeFactoryTest.php new file mode 100644 index 00000000..81ac04e7 --- /dev/null +++ b/tests/unit/Server/PasswordPolicy/UniquePolicyTimeFactoryTest.php @@ -0,0 +1,77 @@ + + * + * 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; + +use FreeDSx\Ldap\Server\Backend\Storage\Journal\ReplicaId; +use FreeDSx\Ldap\Server\PasswordPolicy\UniquePolicyTimeFactory; +use PHPUnit\Framework\TestCase; +use Tests\Support\FreeDSx\Ldap\Clock\FrozenClock; + +final class UniquePolicyTimeFactoryTest extends TestCase +{ + private const NOW = '2026-05-20T12:00:00Z'; + + public function test_it_stamps_the_current_whole_second(): void + { + $stamped = $this->factory('r1')->next([]); + + self::assertSame( + '20260520120000', + $stamped->format('YmdHis'), + ); + } + + public function test_successive_stamps_in_the_same_second_are_distinct(): void + { + $factory = $this->factory('r1'); + + $first = $factory->next([]); + $second = $factory->next([$first]); + + self::assertNotEquals( + $first->format('u'), + $second->format('u'), + ); + } + + public function test_different_replicas_stamp_distinct_values_in_the_same_second(): void + { + $fromA = $this->factory('replica-a')->next([]); + $fromB = $this->factory('replica-b')->next([]); + + self::assertNotEquals( + $fromA->format('u'), + $fromB->format('u'), + ); + } + + public function test_a_foreign_replicas_prior_failure_does_not_advance_the_counter(): void + { + $foreign = $this->factory('other')->next([]); + $factory = $this->factory('r1'); + + self::assertEquals( + $factory->next([]), + $factory->next([$foreign]), + ); + } + + private function factory(string $replicaId): UniquePolicyTimeFactory + { + return new UniquePolicyTimeFactory( + FrozenClock::fromString(self::NOW), + new ReplicaId($replicaId), + ); + } +} diff --git a/tests/unit/Server/PasswordPolicy/UserPasswordStateTest.php b/tests/unit/Server/PasswordPolicy/UserPasswordStateTest.php index b0761f57..aea573ee 100644 --- a/tests/unit/Server/PasswordPolicy/UserPasswordStateTest.php +++ b/tests/unit/Server/PasswordPolicy/UserPasswordStateTest.php @@ -185,4 +185,77 @@ public function test_from_entry_accepts_non_canonical_generalized_time(): void $state->changedAt?->format('c'), ); } + + public function test_is_superseded_by_an_authoritative_lock(): void + { + $local = new UserPasswordState(failureTimes: [$this->at('20250105080000Z')]); + + self::assertTrue($local->isSupersededBy(new UserPasswordState(accountLockedAt: $this->at('20250105080100Z')))); + } + + public function test_local_lock_is_superseded_by_a_newer_credential_change(): void + { + // A password reset on the primary (pwdChangedTime past the failures) clears an independent replica-local lock. + $local = new UserPasswordState( + accountLockedAt: $this->at('20250105080000Z'), + failureTimes: [$this->at('20250105080000Z')], + ); + + self::assertTrue($local->isSupersededBy(new UserPasswordState(changedAt: $this->at('20250105090000Z')))); + } + + public function test_local_lock_is_superseded_by_a_newer_success(): void + { + $local = new UserPasswordState( + accountLockedAt: $this->at('20250105080000Z'), + failureTimes: [$this->at('20250105080000Z')], + ); + + self::assertTrue($local->isSupersededBy(new UserPasswordState(lastSuccess: $this->at('20250105090000Z')))); + } + + public function test_local_lock_is_kept_when_the_entry_reflects_no_reset(): void + { + $local = new UserPasswordState( + accountLockedAt: $this->at('20250105080000Z'), + failureTimes: [$this->at('20250105080000Z')], + ); + + // Bare pwdChangedTime that predates the failures is not a reset. + self::assertFalse($local->isSupersededBy(new UserPasswordState(changedAt: $this->at('20250101120000Z')))); + } + + public function test_sub_threshold_failures_are_kept_until_reset(): void + { + $local = new UserPasswordState(failureTimes: [$this->at('20250105080000Z')]); + + self::assertFalse($local->isSupersededBy(new UserPasswordState())); + } + + public function test_sub_threshold_failures_are_superseded_by_a_newer_success(): void + { + $local = new UserPasswordState(failureTimes: [$this->at('20250105080000Z')]); + + self::assertTrue($local->isSupersededBy(new UserPasswordState(lastSuccess: $this->at('20250105080100Z')))); + } + + public function test_a_success_predating_the_failure_does_not_supersede(): void + { + $local = new UserPasswordState(failureTimes: [$this->at('20250105080000Z')]); + + self::assertFalse($local->isSupersededBy(new UserPasswordState(lastSuccess: $this->at('20250105075900Z')))); + } + + public function test_empty_local_state_is_always_superseded(): void + { + self::assertTrue((new UserPasswordState())->isSupersededBy(new UserPasswordState())); + } + + private function at(string $generalizedTime): DateTimeImmutable + { + return new DateTimeImmutable( + $generalizedTime, + new DateTimeZone('UTC'), + ); + } } diff --git a/tests/unit/Sync/Consumer/ReconcilingChangeApplierTest.php b/tests/unit/Sync/Consumer/ReconcilingChangeApplierTest.php index c4b5c333..d969efce 100644 --- a/tests/unit/Sync/Consumer/ReconcilingChangeApplierTest.php +++ b/tests/unit/Sync/Consumer/ReconcilingChangeApplierTest.php @@ -129,6 +129,9 @@ public function test_a_present_marker_does_not_reconcile(): void $this->passwordStateStore ->expects(self::never()) ->method('discardIfSuperseded'); + $this->passwordStateStore + ->expects(self::never()) + ->method('discard'); $this->subject->apply( $this->syncResult( @@ -139,11 +142,15 @@ public function test_a_present_marker_does_not_reconcile(): void ); } - public function test_a_delete_does_not_reconcile_and_leaves_orphan_cleanup_to_storage(): void + public function test_a_delete_discards_the_local_state_outright(): void { $this->passwordStateStore ->expects(self::never()) ->method('discardIfSuperseded'); + $this->passwordStateStore + ->expects(self::once()) + ->method('discard') + ->with(self::callback(static fn(Dn $dn): bool => $dn->toString() === (new Dn(self::DN))->normalize()->toString())); $this->subject->apply( $this->syncResult(