diff --git a/docs/Server/Access-Control.md b/docs/Server/Access-Control.md index 87694127..de362a84 100644 --- a/docs/Server/Access-Control.md +++ b/docs/Server/Access-Control.md @@ -2,7 +2,9 @@ Access Control ================ * [Overview](#overview) -* [Default Behaviour: SimpleAccessControl](#default-behaviour-simpleaccesscontrol) +* [Default Behaviour: Secure Default](#default-behaviour-secure-default) + * [Administrators](#administrators) + * [Manager (Break-Glass Super-User)](#manager-break-glass-super-user) * [Rule-Based Access Control](#rule-based-access-control) * [Rule Evaluation Order](#rule-evaluation-order) * [Default Effect](#default-effect) @@ -10,30 +12,87 @@ Access Control * [Target Reference](#target-reference) * [Attribute Rules](#attribute-rules) * [Control Rules](#control-rules) +* [Extended Operation Rules](#extended-operation-rules) * [Custom Access Control](#custom-access-control) ## Overview -Access control operates at three levels: +Access control operates at four levels: - **Operation level**: Checked before each operation executes. Denial sends `INSUFFICIENT_ACCESS_RIGHTS` to the client. Covers the following operations: Search, Add, Modify, Delete, ModifyDn, Compare, and PasswordModify. - **Attribute level**: Checked for each attribute involved in Compare, Add, and Modify operations. Also applied to - each Search result entry: disallowed attributes are stripped before the entry is sent; if the entry itself is - denied at operation level, it is suppressed entirely from results (not sent to the client). -- **Control level**: Checked for *privileged* request controls (Relax Rules by default; configurable via - `ServerOptions::setPrivilegedControls()`). The control is inert unless an explicit grant permits the bound identity to - use it. See [Control Rules](#control-rules). + each Search result entry. Disallowed attributes are stripped before the entry is sent. If the entry itself is + denied at operation level, it is suppressed entirely from results. +- **Control level**: Checked for privileged request controls (Relax Rules by default, configurable via + `ServerOptions::setPrivilegedControls()`). See [Control Rules](#control-rules). +- **Extended operation level**: Checked for privileged extended operations, configurable via + `ServerOptions::setPrivilegedExtendedOps()`. See [Extended Operation Rules](#extended-operation-rules). Rules are bundled in an `AclRules` object configured via `ServerOptions::setAclRules()`. See [Configuration](Configuration.md). Bind, WhoAmI, and StartTLS are handled before access control and are always permitted. -## Default Behaviour: SimpleAccessControl +## Default Behaviour: Secure Default -When no rules are configured, the server uses `SimpleAccessControl`: all operations are denied for anonymous clients -and allowed for authenticated clients. No attribute filtering is applied. +With no access control configured, the server applies a secure default (`AclRules::secureDefault()`). It configures: + +- Anonymous clients are denied. +- Authenticated clients may perform general operations. +- `userPassword` can be changed only by the entry owner and the administrator, and is never returned in search results. +- Password Modify (RFC 3062) is limited to self and the administrator. +- Privileged controls and extended operations are limited to the administrator. + +To keep this protection and add your own rules, start from the secure default and compose on top with the `with*` +methods. This is the recommended way to extend access control, for example to grant a privileged control: + +```php +AclRules::secureDefault() + ->withControlRules(ControlRule::allow( + Subject::dn('cn=replica,dc=example,dc=com'), + Target::subtree('dc=example,dc=com'), + Control::OID_SYNC_REQUEST, + )); +``` + +Building from `new AclRules()` instead gives a blank policy with no credential protection, so a `userPassword` rule is +then yours to add. Composing on `secureDefault()` is the safe default. To apply just the credential protection to a +policy you build from scratch, use `AclRules::withCredentialProtection()`: + +```php +(new AclRules()) + ->withOperationRules(/* your rules */) + ->withCredentialProtection(Subject::group('cn=admins,ou=groups,dc=example,dc=com')); +``` + +### Administrators + +`setAdministrators()` designates a directory administrator that the secure default grants password-reset and +privileged-operation rights. Point it at a DN or a group. + +```php +$options->setAdministrators(Subject::dn('uid=admin,ou=people,dc=example,dc=com')); +$options->setAdministrators(Subject::group('cn=admins,ou=groups,dc=example,dc=com')); +``` + +Group membership is read from the group entry in the directory. With no administrator set, only self-service password +change works. Administrative resets then require a [manager](#manager-break-glass-super-user). + +### Manager (Break-Glass Super-User) + +`setManager()` configures an optional super-user that is not a directory entry. It is recognized at bind, bypasses +access control, and is exempt from password-policy lockout. The password is stored hashed and rotated through config. + +```php +$options->setManager(new ManagerIdentity( + new Dn('cn=manager'), + '{SSHA}...hashed password...', +)); +``` + +There is no default manager, and no name or password ships. Use it for recovery, not routine administration. On a +read-only replica it bypasses access control for reads, but writes are still referred to the provider. ## Rule-Based Access Control @@ -229,6 +288,23 @@ use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule; A client attaches the control with `Controls::relaxRules()`, e.g. `$client->create($entry, Controls::relaxRules())`. +## Extended Operation Rules + +Privileged extended operations are gated per identity with `ExtendedOperationRule`s, keyed on the request OID (empty OID +list matches all). They are denied by default. The set of gated OIDs is configured with +`ServerOptions::setPrivilegedExtendedOps()`. + +```php +use FreeDSx\Ldap\Server\AccessControl\Rule\ExtendedOperationRule; + +(new AclRules())->withExtendedOperationRules( + ExtendedOperationRule::allow( + Subject::group('cn=admins,ou=groups,dc=example,dc=com'), + '1.3.6.1.4.1....', + ), +); +``` + ## Custom Access Control For cases where the built-in rule system is insufficient, implement `AccessControlInterface` and pass it via @@ -239,8 +315,9 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Operation\OperationType; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; -use FreeDSx\Ldap\Server\AccessControl\OperationType; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; use FreeDSx\Ldap\Server\Token\TokenInterface; class MyAccessControl implements AccessControlInterface @@ -262,6 +339,7 @@ class MyAccessControl implements AccessControlInterface TokenInterface $token, Dn $dn, string $attribute, + AttributeAccess $access, ): void { // Throw OperationException to deny access to the attribute. } @@ -274,6 +352,13 @@ class MyAccessControl implements AccessControlInterface // Throw OperationException to deny use of a privileged control. } + public function authorizeExtendedOperation( + TokenInterface $token, + string $oid, + ): void { + // Throw OperationException to deny use of a privileged extended operation. + } + /** * Return null to suppress the entry from search results entirely. */ diff --git a/docs/Server/Replication.md b/docs/Server/Replication.md index e0302874..5eb90ac7 100644 --- a/docs/Server/Replication.md +++ b/docs/Server/Replication.md @@ -92,6 +92,11 @@ synced, is your normal configuration. See [Access Control](Access-Control.md). If you need to change which controls require an explicit grant, use `ServerOptions::setPrivilegedControls(...)`. +A sync consumer receives full entries, including attributes that are hidden from ordinary searches such as +`userPassword`, because a replica has to be a faithful copy. The sync control is the privileged gate that decides this, +so grant it only to identities you trust with the whole directory. Attribute read rules like the secure default's +`userPassword` restriction still apply to normal searches by that identity; they do not trim what replication sends. + ## Poll vs Listen The consumer chooses how it reads; the provider supports both: diff --git a/src/FreeDSx/Ldap/LdapServer.php b/src/FreeDSx/Ldap/LdapServer.php index d1a3251c..b13cf14b 100644 --- a/src/FreeDSx/Ldap/LdapServer.php +++ b/src/FreeDSx/Ldap/LdapServer.php @@ -25,7 +25,7 @@ use FreeDSx\Ldap\Operation\Request\AddRequest; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Server\AccessControl\BackendAwareInterface; -use FreeDSx\Ldap\Server\AccessControl\RuleBasedAccessControl; +use FreeDSx\Ldap\Server\AccessControl\PrivilegedBypassAccessControl; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\Export\DirectoryDumper; use FreeDSx\Ldap\Server\Backend\Storage\Export\DumpOptions; @@ -196,7 +196,10 @@ private function init(): void { $this->requireBackendUnlessProxy(); $this->requireSharedStorageForForkingReplica(); - $this->options->setAccessControl($this->resolveAccessControl()); + // Wrap once so a privileged manager token bypasses whichever policy resolved, and inject the backend into it. + $this->options->setAccessControl($this->injectBackendIfNeeded( + new PrivilegedBypassAccessControl($this->resolveAccessControl()), + )); } private function requireBackendUnlessProxy(): void @@ -246,13 +249,7 @@ private function resolveAccessControl(): AccessControlInterface return $this->injectBackendIfNeeded($this->accessControl); } - $aclRules = $this->options->getAclRules(); - - if ($aclRules->isEmpty()) { - return $this->options->getAccessControl(); - } - - return $this->injectBackendIfNeeded(new RuleBasedAccessControl($aclRules)); + return $this->injectBackendIfNeeded($this->options->getAccessControl()); } private function injectBackendIfNeeded(AccessControlInterface $acl): AccessControlInterface diff --git a/src/FreeDSx/Ldap/Server/AccessControl/AccessControlInterface.php b/src/FreeDSx/Ldap/Server/AccessControl/AccessControlInterface.php index 08d2e4cb..be301e58 100644 --- a/src/FreeDSx/Ldap/Server/AccessControl/AccessControlInterface.php +++ b/src/FreeDSx/Ldap/Server/AccessControl/AccessControlInterface.php @@ -17,6 +17,7 @@ use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\OperationType; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; use FreeDSx\Ldap\Server\Token\TokenInterface; /** @@ -38,7 +39,7 @@ public function authorizeOperation( ): void; /** - * Assert that $token may access $attribute on $dn (gates Compare, Add, and Modify operations). + * Assert that $token may access $attribute on $dn for the given direction (Add and Modify are writes, Compare reads). * * @throws OperationException with ResultCode::INSUFFICIENT_ACCESS_RIGHTS on denial */ @@ -46,6 +47,7 @@ public function authorizeAttribute( TokenInterface $token, Dn $dn, string $attribute, + AttributeAccess $access, ): void; /** @@ -59,6 +61,16 @@ public function authorizeControl( string $controlOid, ): void; + /** + * Assert that the token may invoke the privileged extended operation identified by the OID (deny-by-default). + * + * @throws OperationException with ResultCode::INSUFFICIENT_ACCESS_RIGHTS on denial + */ + public function authorizeExtendedOperation( + TokenInterface $token, + string $oid, + ): void; + /** * Coarse, target-independent gate: whether $token could use the control against a target in general. * @@ -76,4 +88,12 @@ public function filterEntry( TokenInterface $token, Entry $entry, ): ?Entry; + + /** + * Whether $token may see $entry at all, without stripping any attributes (the entry-level gate replication uses). + */ + public function isEntryVisible( + TokenInterface $token, + Entry $entry, + ): bool; } diff --git a/src/FreeDSx/Ldap/Server/AccessControl/AclRules.php b/src/FreeDSx/Ldap/Server/AccessControl/AclRules.php index 778b14db..e641411e 100644 --- a/src/FreeDSx/Ldap/Server/AccessControl/AclRules.php +++ b/src/FreeDSx/Ldap/Server/AccessControl/AclRules.php @@ -13,10 +13,15 @@ namespace FreeDSx\Ldap\Server\AccessControl; +use FreeDSx\Ldap\Operation\OperationType; use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeRule; use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule; use FreeDSx\Ldap\Server\AccessControl\Rule\Effect; +use FreeDSx\Ldap\Server\AccessControl\Rule\ExtendedOperationRule; use FreeDSx\Ldap\Server\AccessControl\Rule\OperationRule; +use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; +use FreeDSx\Ldap\Server\AccessControl\Subject\SubjectMatcherInterface; +use FreeDSx\Ldap\Server\AccessControl\Target\AnyTargetMatcher; /** * The rule sets and defaults that configure {@see RuleBasedAccessControl}. @@ -31,25 +36,61 @@ * @param OperationRule[] $operations Evaluated in order; first match wins. * @param AttributeRule[] $attributes Evaluated per attribute in order; first match wins. * @param ControlRule[] $controls Evaluated per control in order; first match wins. + * @param ExtendedOperationRule[] $extendedOps Evaluated per extended operation in order; first match wins. * @param Effect $defaultOperationEffect Applied when no operation rule matches. * @param Effect $defaultControlEffect Applied when no control rule matches (controls are gated, so Deny). + * @param Effect $defaultExtendedOpEffect Applied when no extended-operation rule matches (gated, so Deny). */ - public function __construct( + private function __construct( public array $operations = [], public array $attributes = [], public array $controls = [], + public array $extendedOps = [], public Effect $defaultOperationEffect = Effect::Deny, public Effect $defaultControlEffect = Effect::Deny, + public Effect $defaultExtendedOpEffect = Effect::Deny, ) {} + /** + * A blank ruleset (deny-by-default) to build up with the with* methods. + * + * Unlike secureDefault() it adds no credential protection, so any userPassword rule is yours to add. + * + * @param OperationRule[] $operations + * @param AttributeRule[] $attributes + * @param ControlRule[] $controls + * @param ExtendedOperationRule[] $extendedOps + */ + public static function fromEmpty( + array $operations = [], + array $attributes = [], + array $controls = [], + array $extendedOps = [], + Effect $defaultOperationEffect = Effect::Deny, + Effect $defaultControlEffect = Effect::Deny, + Effect $defaultExtendedOpEffect = Effect::Deny, + ): self { + return new self( + $operations, + $attributes, + $controls, + $extendedOps, + $defaultOperationEffect, + $defaultControlEffect, + $defaultExtendedOpEffect, + ); + } + public function withOperationRules(OperationRule ...$operations): self { return new self( $operations, $this->attributes, $this->controls, + $this->extendedOps, $this->defaultOperationEffect, $this->defaultControlEffect, + $this->defaultExtendedOpEffect, ); } @@ -59,8 +100,10 @@ public function withAttributeRules(AttributeRule ...$attributes): self $this->operations, $attributes, $this->controls, + $this->extendedOps, $this->defaultOperationEffect, $this->defaultControlEffect, + $this->defaultExtendedOpEffect, ); } @@ -70,8 +113,23 @@ public function withControlRules(ControlRule ...$controls): self $this->operations, $this->attributes, $controls, + $this->extendedOps, $this->defaultOperationEffect, $this->defaultControlEffect, + $this->defaultExtendedOpEffect, + ); + } + + public function withExtendedOperationRules(ExtendedOperationRule ...$extendedOps): self + { + return new self( + $this->operations, + $this->attributes, + $this->controls, + $extendedOps, + $this->defaultOperationEffect, + $this->defaultControlEffect, + $this->defaultExtendedOpEffect, ); } @@ -81,8 +139,10 @@ public function withDefaultOperationEffect(Effect $effect): self $this->operations, $this->attributes, $this->controls, + $this->extendedOps, $effect, $this->defaultControlEffect, + $this->defaultExtendedOpEffect, ); } @@ -92,15 +152,106 @@ public function withDefaultControlEffect(Effect $effect): self $this->operations, $this->attributes, $this->controls, + $this->extendedOps, $this->defaultOperationEffect, $effect, + $this->defaultExtendedOpEffect, ); } - public function isEmpty(): bool + public function withDefaultExtendedOpEffect(Effect $effect): self { - return $this->operations === [] - && $this->attributes === [] - && $this->controls === []; + return new self( + $this->operations, + $this->attributes, + $this->controls, + $this->extendedOps, + $this->defaultOperationEffect, + $this->defaultControlEffect, + $effect, + ); + } + + /** + * The secure default. + * + * @see self::withCredentialProtection() + */ + public static function secureDefault(?SubjectMatcherInterface $administrators = null): self + { + $base = new self(operations: [OperationRule::allow(Subject::authenticated())]); + + return $base->withCredentialProtection($administrators); + } + + /** + * Prepend the credential-protection rules to this rule set so they take precedence (first match wins): + * + * - userPassword is writable by self and the administrator, and readable by no one. + * - PasswordModify is permitted for self and the administrator, denied to everyone else. + * - Privileged controls are allowed to the administrator (otherwise the default deny applies). + * - Privileged extended operations are allowed to the administrator (otherwise the default deny applies). + */ + public function withCredentialProtection(?SubjectMatcherInterface $administrators = null): self + { + $anyTarget = new AnyTargetMatcher(); + + $passwordModify = [ + OperationRule::allow( + Subject::self(), + $anyTarget, + OperationType::PasswordModify, + ), + ]; + $userPassword = [ + AttributeRule::allow( + Subject::self(), + $anyTarget, + 'userPassword', + )->forWrite(), + ]; + $controls = []; + $extendedOps = []; + + if ($administrators !== null) { + $passwordModify[] = OperationRule::allow( + $administrators, + $anyTarget, + OperationType::PasswordModify, + ); + $userPassword[] = AttributeRule::allow( + $administrators, + $anyTarget, + 'userPassword', + )->forWrite(); + $controls[] = ControlRule::allow($administrators); + $extendedOps[] = ExtendedOperationRule::allow($administrators); + } + + $passwordModify[] = OperationRule::deny( + Subject::anyone(), + $anyTarget, + OperationType::PasswordModify, + ); + $userPassword[] = AttributeRule::deny( + Subject::anyone(), + $anyTarget, + 'userPassword', + )->forWrite(); + $userPassword[] = AttributeRule::deny( + Subject::anyone(), + $anyTarget, + 'userPassword', + )->forRead(); + + return new self( + operations: [...$passwordModify, ...$this->operations], + attributes: [...$userPassword, ...$this->attributes], + controls: [...$controls, ...$this->controls], + extendedOps: [...$extendedOps, ...$this->extendedOps], + defaultOperationEffect: $this->defaultOperationEffect, + defaultControlEffect: $this->defaultControlEffect, + defaultExtendedOpEffect: $this->defaultExtendedOpEffect, + ); } } diff --git a/src/FreeDSx/Ldap/Server/AccessControl/PrivilegedBypassAccessControl.php b/src/FreeDSx/Ldap/Server/AccessControl/PrivilegedBypassAccessControl.php new file mode 100644 index 00000000..5707d5ef --- /dev/null +++ b/src/FreeDSx/Ldap/Server/AccessControl/PrivilegedBypassAccessControl.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\AccessControl; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Operation\OperationType; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; +use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; +use FreeDSx\Ldap\Server\Token\PrivilegedTokenInterface; +use FreeDSx\Ldap\Server\Token\TokenInterface; + +/** + * Wraps an access-control policy so a privileged (manager) token bypasses every check. + * + * @author Chad Sikorra + */ +final readonly class PrivilegedBypassAccessControl implements AccessControlInterface, BackendAwareInterface +{ + public function __construct(private AccessControlInterface $inner) {} + + public function setBackend(LdapBackendInterface $backend): void + { + if ($this->inner instanceof BackendAwareInterface) { + $this->inner->setBackend($backend); + } + } + + public function authorizeOperation( + OperationType $operation, + TokenInterface $token, + Dn $dn, + ): void { + if ($token instanceof PrivilegedTokenInterface) { + return; + } + + $this->inner->authorizeOperation( + $operation, + $token, + $dn, + ); + } + + public function authorizeAttribute( + TokenInterface $token, + Dn $dn, + string $attribute, + AttributeAccess $access, + ): void { + if ($token instanceof PrivilegedTokenInterface) { + return; + } + + $this->inner->authorizeAttribute( + $token, + $dn, + $attribute, + $access, + ); + } + + public function authorizeControl( + TokenInterface $token, + Dn $dn, + string $controlOid, + ): void { + if ($token instanceof PrivilegedTokenInterface) { + return; + } + + $this->inner->authorizeControl( + $token, + $dn, + $controlOid, + ); + } + + public function authorizeExtendedOperation( + TokenInterface $token, + string $oid, + ): void { + if ($token instanceof PrivilegedTokenInterface) { + return; + } + + $this->inner->authorizeExtendedOperation( + $token, + $oid, + ); + } + + public function mayUseControl( + TokenInterface $token, + string $controlOid, + ): bool { + return $token instanceof PrivilegedTokenInterface + || $this->inner->mayUseControl( + $token, + $controlOid, + ); + } + + public function filterEntry( + TokenInterface $token, + Entry $entry, + ): ?Entry { + if ($token instanceof PrivilegedTokenInterface) { + return $entry; + } + + return $this->inner->filterEntry( + $token, + $entry, + ); + } + + public function isEntryVisible( + TokenInterface $token, + Entry $entry, + ): bool { + if ($token instanceof PrivilegedTokenInterface) { + return true; + } + + return $this->inner->isEntryVisible( + $token, + $entry, + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/AccessControl/Rule/AttributeAccess.php b/src/FreeDSx/Ldap/Server/AccessControl/Rule/AttributeAccess.php new file mode 100644 index 00000000..78b50ae6 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/AccessControl/Rule/AttributeAccess.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\AccessControl\Rule; + +/** + * The access direction an attribute rule applies to. + * + * A request is always Read or Write, a rule may be Both. + * + * @author Chad Sikorra + */ +enum AttributeAccess +{ + case Read; + + case Write; + + case Both; + + /** + * Whether a rule with this scope applies to the requested access direction. + */ + public function includes(self $requested): bool + { + return $this === self::Both + || $this === $requested; + } +} diff --git a/src/FreeDSx/Ldap/Server/AccessControl/Rule/AttributeRule.php b/src/FreeDSx/Ldap/Server/AccessControl/Rule/AttributeRule.php index b2e5753d..c4f6c02d 100644 --- a/src/FreeDSx/Ldap/Server/AccessControl/Rule/AttributeRule.php +++ b/src/FreeDSx/Ldap/Server/AccessControl/Rule/AttributeRule.php @@ -18,7 +18,7 @@ use FreeDSx\Ldap\Server\AccessControl\Target\TargetMatcherInterface; /** - * An ordered access control rule evaluated by filterEntry() per attribute. + * An ordered access control rule gating an attribute's read (search filtering, Compare) and write (Add, Modify) access. * * An empty $attributes list matches all attributes. Attribute names are stored lowercase. * @@ -36,6 +36,7 @@ public function __construct( public SubjectMatcherInterface $subject, public TargetMatcherInterface $target, public array $attributes, + public AttributeAccess $access = AttributeAccess::Both, ) {} public static function allow( @@ -63,4 +64,31 @@ public static function deny( array_map('strtolower', $attributes), ); } + + /** + * Narrow this rule to read access (search result filtering and Compare). + */ + public function forRead(): self + { + return $this->withAccess(AttributeAccess::Read); + } + + /** + * Narrow this rule to write access (Add and Modify). + */ + public function forWrite(): self + { + return $this->withAccess(AttributeAccess::Write); + } + + private function withAccess(AttributeAccess $access): self + { + return new self( + $this->effect, + $this->subject, + $this->target, + $this->attributes, + $access, + ); + } } diff --git a/src/FreeDSx/Ldap/Server/AccessControl/Rule/ExtendedOperationRule.php b/src/FreeDSx/Ldap/Server/AccessControl/Rule/ExtendedOperationRule.php new file mode 100644 index 00000000..abc11f1f --- /dev/null +++ b/src/FreeDSx/Ldap/Server/AccessControl/Rule/ExtendedOperationRule.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\AccessControl\Rule; + +use FreeDSx\Ldap\Server\AccessControl\Subject\SubjectMatcherInterface; + +/** + * An ordered access control rule gating use of a privileged extended operation by OID. + * + * @api + * + * @author Chad Sikorra + */ +final readonly class ExtendedOperationRule +{ + /** + * @param string[] $extendedOpOids Extended operation request OIDs; empty matches all extended operations. + */ + public function __construct( + public Effect $effect, + public SubjectMatcherInterface $subject, + public array $extendedOpOids, + ) {} + + public static function allow( + SubjectMatcherInterface $subject, + string ...$extendedOpOids, + ): self { + return new self( + Effect::Allow, + $subject, + $extendedOpOids, + ); + } + + public static function deny( + SubjectMatcherInterface $subject, + string ...$extendedOpOids, + ): self { + return new self( + Effect::Deny, + $subject, + $extendedOpOids, + ); + } +} diff --git a/src/FreeDSx/Ldap/Server/AccessControl/RuleBasedAccessControl.php b/src/FreeDSx/Ldap/Server/AccessControl/RuleBasedAccessControl.php index 2b4715b3..1e2e5708 100644 --- a/src/FreeDSx/Ldap/Server/AccessControl/RuleBasedAccessControl.php +++ b/src/FreeDSx/Ldap/Server/AccessControl/RuleBasedAccessControl.php @@ -18,8 +18,10 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\OperationType; use FreeDSx\Ldap\Operation\ResultCode; +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\Rule\ExtendedOperationRule; use FreeDSx\Ldap\Server\AccessControl\Rule\OperationRule; use FreeDSx\Ldap\Server\Backend\LdapBackendInterface; use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; @@ -32,7 +34,12 @@ */ final readonly class RuleBasedAccessControl implements AccessControlInterface, BackendAwareInterface { - public function __construct(private AclRules $rules = new AclRules()) {} + private AclRules $rules; + + public function __construct(?AclRules $rules = null) + { + $this->rules = $rules ?? AclRules::secureDefault(); + } public function setBackend(LdapBackendInterface $backend): void { @@ -40,6 +47,7 @@ public function setBackend(LdapBackendInterface $backend): void ...$this->rules->operations, ...$this->rules->attributes, ...$this->rules->controls, + ...$this->rules->extendedOps, ]; foreach ($allRules as $rule) { @@ -69,11 +77,13 @@ public function authorizeAttribute( TokenInterface $token, Dn $dn, string $attribute, + AttributeAccess $access, ): void { $effect = $this->resolveAttributeEffect( $token, $dn, strtolower($attribute), + $access, ); if ($effect === Effect::Deny) { @@ -94,6 +104,18 @@ public function authorizeControl( } } + /** + * @throws OperationException + */ + public function authorizeExtendedOperation( + TokenInterface $token, + string $oid, + ): void { + if (!$this->isExtendedOperationAllowed($oid, $token)) { + $this->deny(); + } + } + public function mayUseControl( TokenInterface $token, string $controlOid, @@ -125,7 +147,7 @@ public function filterEntry( ): ?Entry { $dn = $entry->getDn(); - if (!$this->isAllowed(OperationType::Search, $token, $dn)) { + if (!$this->isEntryVisible($token, $entry)) { return null; } @@ -141,6 +163,7 @@ public function filterEntry( $token, $dn, strtolower($attribute->getName()), + AttributeAccess::Read, ); if ($effect !== Effect::Deny) { @@ -158,6 +181,17 @@ public function filterEntry( ); } + public function isEntryVisible( + TokenInterface $token, + Entry $entry, + ): bool { + return $this->isAllowed( + OperationType::Search, + $token, + $entry->getDn(), + ); + } + private function isAllowed( OperationType $operation, TokenInterface $token, @@ -204,6 +238,40 @@ private function controlMatches( || in_array($controlOid, $rule->controlOids, true); } + /** + * Extended operations are target-independent, so the subject is matched against the token's own resolved DN. + */ + private function isExtendedOperationAllowed( + string $oid, + TokenInterface $token, + ): bool { + if (!$token instanceof AuthenticatedTokenInterface) { + return false; + } + + foreach ($this->rules->extendedOps as $rule) { + if (!$this->extendedOperationMatches($rule, $oid)) { + continue; + } + + if (!$rule->subject->matches($token, $token->getResolvedDn())) { + continue; + } + + return $rule->effect === Effect::Allow; + } + + return $this->rules->defaultExtendedOpEffect === Effect::Allow; + } + + private function extendedOperationMatches( + ExtendedOperationRule $rule, + string $oid, + ): bool { + return $rule->extendedOpOids === [] + || in_array($oid, $rule->extendedOpOids, true); + } + /** * Returns the effect of the first rule whose selector, target, and subject all match; otherwise $default. * @@ -244,8 +312,13 @@ private function resolveAttributeEffect( TokenInterface $token, Dn $dn, string $attrName, + AttributeAccess $access, ): ?Effect { foreach ($this->rules->attributes as $rule) { + if (!$rule->access->includes($access)) { + continue; + } + if (!$rule->target->matches($dn)) { continue; } diff --git a/src/FreeDSx/Ldap/Server/AccessControl/SimpleAccessControl.php b/src/FreeDSx/Ldap/Server/AccessControl/SimpleAccessControl.php index 29c811e6..962be5bb 100644 --- a/src/FreeDSx/Ldap/Server/AccessControl/SimpleAccessControl.php +++ b/src/FreeDSx/Ldap/Server/AccessControl/SimpleAccessControl.php @@ -18,6 +18,7 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\OperationType; use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; use FreeDSx\Ldap\Server\Token\AnonToken; use FreeDSx\Ldap\Server\Token\TokenInterface; @@ -48,6 +49,7 @@ public function authorizeAttribute( TokenInterface $token, Dn $dn, string $attribute, + AttributeAccess $access, ): void {} /** @@ -66,6 +68,21 @@ public function authorizeControl( ); } + /** + * Privileged extended operations require an explicit grant the simple policy cannot express, so deny. + * + * @throws OperationException + */ + public function authorizeExtendedOperation( + TokenInterface $token, + string $oid, + ): void { + throw new OperationException( + 'Access denied.', + ResultCode::INSUFFICIENT_ACCESS_RIGHTS, + ); + } + /** * The simple policy cannot grant any control, so none is ever usable. */ @@ -82,4 +99,14 @@ public function filterEntry( ): Entry { return $entry; } + + /** + * Anonymous is denied everywhere. Any authenticated identity sees every entry (this policy strips nothing). + */ + public function isEntryVisible( + TokenInterface $token, + Entry $entry, + ): bool { + return !$token instanceof AnonToken; + } } diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/ManagerAwareAuthenticator.php b/src/FreeDSx/Ldap/Server/Backend/Auth/ManagerAwareAuthenticator.php new file mode 100644 index 00000000..84e25995 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/ManagerAwareAuthenticator.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Auth; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\ResultCode; +use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; +use FreeDSx\Ldap\Server\Token\ManagerToken; +use FreeDSx\Sasl\Mechanism\MechanismName; +use SensitiveParameter; +use Throwable; + +/** + * Recognizes the configured manager super-user at bind time, ahead of the backend and password policy. + * + * @author Chad Sikorra + */ +final readonly class ManagerAwareAuthenticator implements PasswordAuthenticatableInterface +{ + public function __construct( + private PasswordAuthenticatableInterface $inner, + private ManagerIdentity $manager, + private PasswordHashService $hashService, + ) {} + + /** + * @throws OperationException + */ + public function authenticate( + string $name, + #[SensitiveParameter] + string $password, + ): AuthenticatedTokenInterface { + if (!$this->isManager($name)) { + return $this->inner->authenticate( + $name, + $password, + ); + } + + // The manager DN is owned by the config identity: a wrong password fails outright, never falling through + // to a colliding directory entry, and never touching password-policy lockout. + if (!$this->hashService->verify($password, $this->manager->hashedPassword())) { + throw new OperationException( + 'Invalid credentials.', + ResultCode::INVALID_CREDENTIALS, + ); + } + + return new ManagerToken($this->manager->dn()); + } + + public function getSaslIdentity( + string $username, + MechanismName $mechanism, + ): ?SaslIdentity { + return $this->inner->getSaslIdentity( + $username, + $mechanism, + ); + } + + private function isManager(string $name): bool + { + try { + return $this->manager->matches(new Dn($name)); + } catch (Throwable) { + return false; + } + } +} diff --git a/src/FreeDSx/Ldap/Server/Backend/Auth/ManagerIdentity.php b/src/FreeDSx/Ldap/Server/Backend/Auth/ManagerIdentity.php new file mode 100644 index 00000000..c2ebed32 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Auth/ManagerIdentity.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Auth; + +use FreeDSx\Ldap\Entry\Dn; + +/** + * The configured manager super-user identity: a DN and its hashed password. + * + * @author Chad Sikorra + */ +final readonly class ManagerIdentity +{ + public function __construct( + private Dn $dn, + private string $hashedPassword, + ) {} + + public function dn(): Dn + { + return $this->dn; + } + + public function hashedPassword(): string + { + return $this->hashedPassword; + } + + public function matches(Dn $dn): bool + { + return $this->dn->normalize()->toString() === $dn->normalize()->toString(); + } +} diff --git a/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php b/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php index 2dd1d252..15926856 100644 --- a/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php +++ b/src/FreeDSx/Ldap/Server/Logging/OperationAuditor.php @@ -138,6 +138,36 @@ public function recordSearchFailure( ); } + public function recordCompareFailure( + LdapMessageRequest $message, + OperationException $exception, + TokenInterface $token, + ): void { + $event = ServerEvent::fromOperationException( + $exception, + ServerEvent::AuthorizationDeniedRead, + ); + + $request = $message->getRequest(); + + if ($event === null || !$request instanceof CompareRequest) { + return; + } + + $this->eventLogger->recordFailure( + $event, + $exception, + [ + EventContext::TARGET => [ + EventContext::DN => $request->getDn()->toString(), + EventContext::ATTRIBUTE => $request->getFilter()->getAttribute(), + ], + ], + subject: $token, + message: $message, + ); + } + public function recordPasswordModifySuccess( LdapMessageRequest $message, Dn $targetDn, diff --git a/src/FreeDSx/Ldap/Server/Middleware/OperationAuditMiddleware.php b/src/FreeDSx/Ldap/Server/Middleware/OperationAuditMiddleware.php index c88d1cbe..2998010f 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/OperationAuditMiddleware.php +++ b/src/FreeDSx/Ldap/Server/Middleware/OperationAuditMiddleware.php @@ -15,6 +15,7 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Exception\SchemaRuleException; +use FreeDSx\Ldap\Operation\Request\CompareRequest; use FreeDSx\Ldap\Operation\Request\SearchRequest; use FreeDSx\Ldap\Server\Logging\OperationAuditor; use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareHandlerInterface; @@ -83,6 +84,16 @@ private function recordFailure( return; } + if ($context->message->getRequest() instanceof CompareRequest) { + $this->auditor->recordCompareFailure( + $context->message, + $e, + $context->tokenOrFail(), + ); + + return; + } + $this->auditor->recordFailure( $context->message, $e, diff --git a/src/FreeDSx/Ldap/Server/Middleware/OperationAuthorizationMiddleware.php b/src/FreeDSx/Ldap/Server/Middleware/OperationAuthorizationMiddleware.php index 7ddaa944..229916bd 100644 --- a/src/FreeDSx/Ldap/Server/Middleware/OperationAuthorizationMiddleware.php +++ b/src/FreeDSx/Ldap/Server/Middleware/OperationAuthorizationMiddleware.php @@ -19,6 +19,7 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\Request\AddRequest; use FreeDSx\Ldap\Operation\Request\CompareRequest; +use FreeDSx\Ldap\Operation\Request\ExtendedRequest; use FreeDSx\Ldap\Operation\Request\ModifyDnRequest; use FreeDSx\Ldap\Operation\Request\ModifyRequest; use FreeDSx\Ldap\Operation\Request\RequestInterface; @@ -28,6 +29,7 @@ use FreeDSx\Ldap\Protocol\LdapMessageRequest; use FreeDSx\Ldap\Protocol\ServerProtocolHandler\ServerMonitorHandler; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; use FreeDSx\Ldap\Server\AccessControl\OperationTargetDn; use FreeDSx\Ldap\Operation\OperationType; use FreeDSx\Ldap\Server\Middleware\Pipeline\MiddlewareHandlerInterface; @@ -46,11 +48,13 @@ { /** * @param list $privilegedControls Control OIDs that require an explicit ControlRule grant before use. + * @param list $privilegedExtendedOps Extended operation OIDs that require an explicit grant before use. */ public function __construct( private HandlerRouteResolverInterface $routeResolver, private AccessControlInterface $accessControl, private array $privilegedControls = [Control::OID_RELAX_RULES], + private array $privilegedExtendedOps = [], ) {} /** @@ -60,6 +64,8 @@ public function process( ServerRequestContext $context, MiddlewareHandlerInterface $next, ): OperationResult { + $this->authorizePrivilegedExtendedOperation($context); + $routeId = $this->routeResolver->routeIdFor( $context->message->getRequest(), $context->message->controls(), @@ -91,6 +97,27 @@ public function process( return $next->handle($context); } + /** + * @throws OperationException + */ + private function authorizePrivilegedExtendedOperation(ServerRequestContext $context): void + { + $request = $context->message->getRequest(); + + if (!$request instanceof ExtendedRequest) { + return; + } + + if (!in_array($request->getName(), $this->privilegedExtendedOps, true)) { + return; + } + + $this->accessControl->authorizeExtendedOperation( + $context->tokenOrFail(), + $request->getName(), + ); + } + /** * @throws OperationException */ @@ -162,6 +189,7 @@ private function authorizeDispatch( $token, $request->getDn(), $request->getFilter()->getAttribute(), + AttributeAccess::Read, ); return; @@ -249,6 +277,7 @@ private function authorizeWriteAttributes( $token, $dn, $attribute->getName(), + AttributeAccess::Write, ); } @@ -262,6 +291,7 @@ private function authorizeWriteAttributes( $token, $dn, $change->getAttribute()->getName(), + AttributeAccess::Write, ); } } diff --git a/src/FreeDSx/Ldap/Server/PasswordModify/PasswordModifyService.php b/src/FreeDSx/Ldap/Server/PasswordModify/PasswordModifyService.php index dcf40a4d..61abd180 100644 --- a/src/FreeDSx/Ldap/Server/PasswordModify/PasswordModifyService.php +++ b/src/FreeDSx/Ldap/Server/PasswordModify/PasswordModifyService.php @@ -23,6 +23,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Operation\OperationType; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; use FreeDSx\Ldap\Server\Backend\Write\Command\UpdateCommand; use FreeDSx\Ldap\Server\Backend\Write\WriteContext; @@ -168,6 +169,7 @@ private function authorizeRequest( $token, $targetDn, 'userPassword', + AttributeAccess::Write, ); } diff --git a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php index 34946902..5138729d 100644 --- a/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php +++ b/src/FreeDSx/Ldap/Server/ServerProtocolFactory.php @@ -36,6 +36,7 @@ use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicyComponentFactory; use FreeDSx\Ldap\Protocol\ServerAuthorization; use FreeDSx\Ldap\Protocol\ServerProtocolHandler; +use FreeDSx\Ldap\Server\Backend\Auth\ManagerAwareAuthenticator; use FreeDSx\Ldap\Server\Backend\Auth\PasswordPolicyAwareAuthenticator; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Sasl\External\SubjectDnCredentialMapper; @@ -125,6 +126,16 @@ public function make( ); } + $manager = $this->options->getManager(); + if ($manager !== null) { + // Outermost: the manager is recognized before the backend and password policy, so it is lockout-exempt. + $passwordAuthenticator = new ManagerAwareAuthenticator( + $passwordAuthenticator, + $manager, + $this->hashService, + ); + } + if (!$this->metricsRecorder instanceof NullMetricsRecorder) { $interceptors[] = new MetricsResponseInterceptor($this->metricsRecorder); } @@ -256,6 +267,7 @@ public function make( $this->routeResolver, $this->options->getAccessControl(), $this->options->getPrivilegedControls(), + $this->options->getPrivilegedExtendedOps(), ), new AssertionMiddleware(new AssertionEvaluator( $this->options->getFilterEvaluator(), diff --git a/src/FreeDSx/Ldap/Server/Token/ManagerToken.php b/src/FreeDSx/Ldap/Server/Token/ManagerToken.php new file mode 100644 index 00000000..2028f289 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Token/ManagerToken.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 FreeDSx\Ldap\Server\Token; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Protocol\Authorization\AuthzId; +use FreeDSx\Ldap\Server\Utility\Uuid; + +/** + * The configured manager super-user: an authenticated, privileged identity never subject to must-change. + * + * @author Chad Sikorra + */ +final readonly class ManagerToken implements AuthenticatedTokenInterface, PrivilegedTokenInterface +{ + private string $id; + + private AuthzId $authzId; + + public function __construct( + private Dn $dn, + private int $version = 3, + ) { + $this->id = Uuid::v4(); + $this->authzId = AuthzId::fromDn($dn); + } + + public function getId(): string + { + return $this->id; + } + + public function getUsername(): string + { + return $this->dn->toString(); + } + + public function getAuthzId(): AuthzId + { + return $this->authzId; + } + + public function getVersion(): int + { + return $this->version; + } + + public function getAuthorizingDn(): ?Dn + { + return null; + } + + public function getResolvedDn(): Dn + { + return $this->dn; + } + + public function mustChangePassword(): bool + { + return false; + } + + public function markMustChangePassword(): void {} + + public function clearMustChangePassword(): void {} +} diff --git a/src/FreeDSx/Ldap/Server/Token/PrivilegedTokenInterface.php b/src/FreeDSx/Ldap/Server/Token/PrivilegedTokenInterface.php new file mode 100644 index 00000000..d7c0c26b --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Token/PrivilegedTokenInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Token; + +/** + * Marks a token whose identity bypasses access control and password-policy enforcement (the manager super-user). + * + * @author Chad Sikorra + */ +interface PrivilegedTokenInterface extends TokenInterface {} diff --git a/src/FreeDSx/Ldap/ServerOptions.php b/src/FreeDSx/Ldap/ServerOptions.php index 06ca9157..07af462e 100644 --- a/src/FreeDSx/Ldap/ServerOptions.php +++ b/src/FreeDSx/Ldap/ServerOptions.php @@ -24,6 +24,7 @@ use FreeDSx\Ldap\Server\PasswordPolicy\PasswordPolicy; use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\BindNameResolverInterface; use FreeDSx\Ldap\Server\Sasl\External\ExternalCredentialMapperInterface; +use FreeDSx\Ldap\Server\Backend\Auth\ManagerIdentity; use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashScheme; use FreeDSx\Ldap\Server\PasswordPolicy\QualityCheck\DefaultPasswordQualityChecker; @@ -35,7 +36,8 @@ use FreeDSx\Ldap\Server\Backend\Storage\Journal\ChangeJournalConfig; use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; use FreeDSx\Ldap\Server\AccessControl\AclRules; -use FreeDSx\Ldap\Server\AccessControl\SimpleAccessControl; +use FreeDSx\Ldap\Server\AccessControl\RuleBasedAccessControl; +use FreeDSx\Ldap\Server\AccessControl\Subject\SubjectMatcherInterface; use FreeDSx\Ldap\Server\Backend\Write\WriteHandlerInterface; use FreeDSx\Ldap\Server\Configuration\ConfigReloaderInterface; use FreeDSx\Ldap\Server\Logging\EventLogPolicy; @@ -159,6 +161,10 @@ final class ServerOptions private ?PasswordAuthenticatableInterface $passwordAuthenticator = null; + private ?ManagerIdentity $manager = null; + + private ?SubjectMatcherInterface $administrators = null; + private ?BindNameResolverInterface $identityResolver = null; private ?ExternalCredentialMapperInterface $externalCredentialMapper = null; @@ -188,6 +194,11 @@ final class ServerOptions Control::OID_SYNC_REQUEST, ]; + /** + * @var list + */ + private array $privilegedExtendedOps = []; + private ?PasswordPolicy $passwordPolicy = null; private ?Dn $defaultPasswordPolicyDn = null; @@ -543,6 +554,36 @@ public function setPasswordAuthenticator(?PasswordAuthenticatableInterface $pass return $this; } + public function getManager(): ?ManagerIdentity + { + return $this->manager; + } + + /** + * The config-resident manager super-user (break-glass): bypasses access control and password-policy lockout. + */ + public function setManager(?ManagerIdentity $manager): self + { + $this->manager = $manager; + + return $this; + } + + public function getAdministrators(): ?SubjectMatcherInterface + { + return $this->administrators; + } + + /** + * The directory-resident administrator subject (a DN or group) granted password-reset and privileged-op rights. + */ + public function setAdministrators(?SubjectMatcherInterface $administrators): self + { + $this->administrators = $administrators; + + return $this; + } + public function getIdentityResolver(): ?BindNameResolverInterface { return $this->identityResolver; @@ -644,7 +685,9 @@ public function setSchemaValidationMode(SchemaValidationMode $mode): self public function getAccessControl(): AccessControlInterface { - return $this->accessControl ??= new SimpleAccessControl(); + return $this->accessControl ??= new RuleBasedAccessControl( + $this->getAclRules(), + ); } public function setAccessControl(AccessControlInterface $accessControl): self @@ -663,7 +706,7 @@ public function setAclRules(AclRules $aclRules): self public function getAclRules(): AclRules { - return $this->aclRules ??= new AclRules(); + return $this->aclRules ??= AclRules::secureDefault($this->administrators); } /** @@ -686,6 +729,26 @@ public function setPrivilegedControls(string ...$controlOids): self return $this; } + /** + * Extended operation OIDs that require an explicit ExtendedOperationRule grant (deny-by-default). + * + * @return list + */ + public function getPrivilegedExtendedOps(): array + { + return $this->privilegedExtendedOps; + } + + /** + * @param list $oids + */ + public function setPrivilegedExtendedOps(array $oids): self + { + $this->privilegedExtendedOps = array_values($oids); + + return $this; + } + /** * In-memory fallback policy applied to users that do not resolve a pwdPolicy entry from the DIT. */ diff --git a/src/FreeDSx/Ldap/Sync/Provider/SyncResultProjector.php b/src/FreeDSx/Ldap/Sync/Provider/SyncResultProjector.php index 9dda8bab..b9f6bc2a 100644 --- a/src/FreeDSx/Ldap/Sync/Provider/SyncResultProjector.php +++ b/src/FreeDSx/Ldap/Sync/Provider/SyncResultProjector.php @@ -30,7 +30,9 @@ use FreeDSx\Ldap\Server\Utility\Uuid; /** - * Turns a live entry or a journal change into an RFC 4533 sync result, applying read-side ACL and filter matching. + * Turns a live entry or a journal change into an RFC 4533 sync result, gating on entry-level visibility and the filter. + * + * A replica must be a faithful copy, so a visible entry is shipped whole; that is why the control itself is privileged. * * @author Chad Sikorra */ @@ -49,16 +51,11 @@ public function projectSearched( Entry $entry, TokenInterface $token, ): ?SyncResult { - $visible = $this->accessControl->filterEntry( - $token, - $entry, - ); - - if ($visible === null) { + if (!$this->accessControl->isEntryVisible($token, $entry)) { return null; } - return $this->addResult($visible); + return $this->addResult($entry); } /** @@ -69,20 +66,15 @@ public function projectFetched( SearchRequest $request, TokenInterface $token, ): ?SyncResult { - $visible = $this->accessControl->filterEntry( - $token, - $entry, - ); - - if ($visible === null) { + if (!$this->accessControl->isEntryVisible($token, $entry)) { return null; } - if (!$this->filterEvaluator->evaluate($visible, $request->getFilter())) { + if (!$this->filterEvaluator->evaluate($entry, $request->getFilter())) { return null; } - return $this->addResult($visible); + return $this->addResult($entry); } /** @@ -151,13 +143,8 @@ private function wasVisible( return false; } - $visible = $this->accessControl->filterEntry( - $token, - $preImage, - ); - - return $visible !== null - && $this->filterEvaluator->evaluate($visible, $request->getFilter()); + return $this->accessControl->isEntryVisible($token, $preImage) + && $this->filterEvaluator->evaluate($preImage, $request->getFilter()); } /** diff --git a/tests/integration/LdapPasswordModifyServerTest.php b/tests/integration/LdapPasswordModifyServerTest.php index 37faf68b..a3a9b224 100644 --- a/tests/integration/LdapPasswordModifyServerTest.php +++ b/tests/integration/LdapPasswordModifyServerTest.php @@ -17,6 +17,7 @@ use FreeDSx\Ldap\Operation\Request\PasswordModifyRequest; use FreeDSx\Ldap\Operation\Response\PasswordModifyResponse; use FreeDSx\Ldap\Operation\ResultCode; +use Tests\Support\FreeDSx\Ldap\LdapBackendStorageCommand; final class LdapPasswordModifyServerTest extends ServerTestCase { @@ -30,7 +31,13 @@ public function setUp(): void parent::setUp(); - $this->createServerProcess('tcp', ['--storage=json']); + $this->createServerProcess( + 'tcp', + [ + '--storage=json', + '--manager', + ], + ); } public function testAnonymousIsRejected(): void @@ -60,18 +67,9 @@ public function testSelfServicePasswordChange(): void self::USER_DN, 'newpass123', ); - - $entry = $verifyClient->read( - self::USER_DN, - ['userPassword'], - ); $verifyClient->unbind(); - $this->assertNotNull($entry); - $this->assertStringStartsWith( - '{BCRYPT}', - (string) $entry->get('userPassword')?->firstValue(), - ); + $this->assertStoredPasswordIsHashed(); } public function testServerGeneratedPassword(): void @@ -104,18 +102,9 @@ public function testServerGeneratedPassword(): void self::USER_DN, $generated, ); - - $entry = $verifyClient->read( - self::USER_DN, - ['userPassword'], - ); $verifyClient->unbind(); - $this->assertNotNull($entry); - $this->assertStringStartsWith( - '{BCRYPT}', - (string) $entry->get('userPassword')?->firstValue(), - ); + $this->assertStoredPasswordIsHashed(); } public function testExplicitIdentityPasswordChange(): void @@ -134,18 +123,9 @@ public function testExplicitIdentityPasswordChange(): void self::USER_DN, 'resetpass', ); - - $entry = $verifyClient->read( - self::USER_DN, - ['userPassword'], - ); $verifyClient->unbind(); - $this->assertNotNull($entry); - $this->assertStringStartsWith( - '{BCRYPT}', - (string) $entry->get('userPassword')?->firstValue(), - ); + $this->assertStoredPasswordIsHashed(); } public function testWrongOldPasswordReturnsInvalidCredentials(): void @@ -162,4 +142,25 @@ public function testWrongOldPasswordReturnsInvalidCredentials(): void new PasswordModifyRequest(null, 'wrongpassword', 'newpass'), ); } + + private function assertStoredPasswordIsHashed(): void + { + $manager = $this->buildClient('tcp'); + $manager->bind( + LdapBackendStorageCommand::MANAGER_DN, + LdapBackendStorageCommand::MANAGER_PASSWORD, + ); + + $entry = $manager->read( + self::USER_DN, + ['userPassword'], + ); + $manager->unbind(); + + $this->assertNotNull($entry); + $this->assertStringStartsWith( + '{BCRYPT}', + (string) $entry->get('userPassword')?->firstValue(), + ); + } } diff --git a/tests/integration/Security/DefaultAccessControlEnforcementTest.php b/tests/integration/Security/DefaultAccessControlEnforcementTest.php new file mode 100644 index 00000000..490f16d9 --- /dev/null +++ b/tests/integration/Security/DefaultAccessControlEnforcementTest.php @@ -0,0 +1,213 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Integration\FreeDSx\Ldap\Security; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; +use FreeDSx\Ldap\Server\AccessControl\AclRules; +use FreeDSx\Ldap\Server\AccessControl\PrivilegedBypassAccessControl; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; +use FreeDSx\Ldap\Server\AccessControl\RuleBasedAccessControl; +use FreeDSx\Ldap\Server\Backend\Auth\ManagerAwareAuthenticator; +use FreeDSx\Ldap\Server\Backend\Auth\ManagerIdentity; +use FreeDSx\Ldap\Server\Backend\Auth\NameResolver\DnBindNameResolver; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticator; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; +use FreeDSx\Ldap\Server\Backend\Storage\WritableStorageBackend; +use FreeDSx\Ldap\Server\Token\AuthenticatedTokenInterface; +use FreeDSx\Ldap\Server\Token\ManagerToken; +use PHPUnit\Framework\TestCase; + +/** + * Composes the real manager authenticator with the bypass-wrapped secure-default ACL, as the server wires them. + */ +final class DefaultAccessControlEnforcementTest extends TestCase +{ + private const MANAGER_DN = 'cn=manager'; + + private const PASSWORD = '12345'; + + private const HASH = '{SHA}jLIjfQZ5yojbZGTqxg2pY0VROWQ='; + + private const USER_DN = 'cn=user,dc=foo,dc=bar'; + + private const OTHER_DN = 'cn=other,dc=foo,dc=bar'; + + private PasswordAuthenticatableInterface $authenticator; + + private AccessControlInterface $accessControl; + + protected function setUp(): void + { + $backend = new WritableStorageBackend(new InMemoryStorage([ + Entry::fromArray( + 'dc=foo,dc=bar', + [ + 'objectClass' => ['domain'], + 'dc' => ['foo'], + ], + ), + Entry::fromArray( + self::USER_DN, + [ + 'objectClass' => ['inetOrgPerson'], + 'cn' => ['user'], + 'sn' => ['User'], + 'userPassword' => [self::HASH], + ], + ), + Entry::fromArray( + self::OTHER_DN, + [ + 'objectClass' => ['inetOrgPerson'], + 'cn' => ['other'], + 'sn' => ['Other'], + 'userPassword' => [self::HASH], + ], + ), + ])); + + $this->authenticator = new ManagerAwareAuthenticator( + new PasswordAuthenticator( + new DnBindNameResolver(), + $backend, + ), + new ManagerIdentity( + new Dn(self::MANAGER_DN), + self::HASH, + ), + new PasswordHashService(), + ); + $this->accessControl = new PrivilegedBypassAccessControl( + new RuleBasedAccessControl(AclRules::secureDefault()), + ); + } + + public function test_manager_binds_to_a_privileged_token_that_bypasses_the_credential_acl(): void + { + $manager = $this->authenticator->authenticate( + self::MANAGER_DN, + self::PASSWORD, + ); + self::assertInstanceOf( + ManagerToken::class, + $manager, + ); + + // The manager may write any user's userPassword despite the secure-default credential restriction. + $this->accessControl->authorizeAttribute( + $manager, + new Dn(self::OTHER_DN), + 'userPassword', + AttributeAccess::Write, + ); + + $this->addToAssertionCount(1); + } + + public function test_normal_user_is_bound_by_the_credential_acl(): void + { + $user = $this->authenticator->authenticate( + self::USER_DN, + self::PASSWORD, + ); + + // Self may write its own password. + $this->accessControl->authorizeAttribute( + $user, + new Dn(self::USER_DN), + 'userPassword', + AttributeAccess::Write, + ); + + // But not another user's. + $this->expectException(OperationException::class); + $this->accessControl->authorizeAttribute( + $user, + new Dn(self::OTHER_DN), + 'userPassword', + AttributeAccess::Write, + ); + } + + public function test_manager_read_keeps_userPassword_that_a_normal_user_loses(): void + { + $manager = $this->authenticator->authenticate( + self::MANAGER_DN, + self::PASSWORD, + ); + $user = $this->normalUser(); + + $entry = Entry::fromArray( + self::OTHER_DN, + [ + 'cn' => ['other'], + 'userPassword' => [self::HASH], + ], + ); + + self::assertNotNull( + $this->accessControl->filterEntry($manager, $entry)?->get('userPassword'), + ); + self::assertNull( + $this->accessControl->filterEntry($user, $entry)?->get('userPassword'), + ); + } + + public function test_userPassword_is_write_only_for_self(): void + { + $user = $this->normalUser(); + $ownDn = new Dn(self::USER_DN); + $ownEntry = Entry::fromArray( + self::USER_DN, + [ + 'cn' => ['user'], + 'userPassword' => [self::HASH], + ], + ); + + // Self may write its own password. + $this->accessControl->authorizeAttribute( + $user, + $ownDn, + 'userPassword', + AttributeAccess::Write, + ); + + // But self cannot read it, on search or via Compare. + self::assertNull( + $this->accessControl->filterEntry($user, $ownEntry)?->get('userPassword'), + ); + + $this->expectException(OperationException::class); + $this->accessControl->authorizeAttribute( + $user, + $ownDn, + 'userPassword', + AttributeAccess::Read, + ); + } + + private function normalUser(): AuthenticatedTokenInterface + { + return $this->authenticator->authenticate( + self::USER_DN, + self::PASSWORD, + ); + } +} diff --git a/tests/support/LdapAclCommand.php b/tests/support/LdapAclCommand.php index 7d071ea0..ac85daa7 100644 --- a/tests/support/LdapAclCommand.php +++ b/tests/support/LdapAclCommand.php @@ -96,7 +96,7 @@ protected function execute( ->setSocketAcceptTimeout(0.1) ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)) ->setAclRules( - (new AclRules()) + (AclRules::fromEmpty()) ->withOperationRules( OperationRule::allow( Subject::group('cn=admins,dc=foo,dc=bar'), diff --git a/tests/support/LdapBackendStorageCommand.php b/tests/support/LdapBackendStorageCommand.php index 5b795a13..db1cde0b 100644 --- a/tests/support/LdapBackendStorageCommand.php +++ b/tests/support/LdapBackendStorageCommand.php @@ -9,11 +9,10 @@ use FreeDSx\Ldap\Entry\Dn; use FreeDSx\Ldap\Entry\Entry; use FreeDSx\Ldap\LdapServer; -use FreeDSx\Ldap\Server\AccessControl\AclRules; use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule; -use FreeDSx\Ldap\Server\AccessControl\Rule\OperationRule; 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\Backend\Storage\Adapter\InMemoryStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\JsonFileStorage; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\PdoConfig; @@ -34,6 +33,10 @@ final class LdapBackendStorageCommand extends Command { use ConsoleOptionsTrait; + public const MANAGER_DN = 'cn=manager'; + + public const MANAGER_PASSWORD = 'manager-pass'; + protected function configure(): void { $this @@ -93,6 +96,12 @@ protected function configure(): void InputOption::VALUE_NONE, 'Grant cn=user the Proxied Authorization control for identities under ou=people', ) + ->addOption( + 'manager', + null, + InputOption::VALUE_NONE, + 'Configure a break-glass manager identity (cn=manager) so tests can read userPassword back', + ) ->addOption( 'monitor', null, @@ -209,29 +218,38 @@ protected function execute( ->setOnServerReady(fn() => fwrite(STDOUT, 'server starting...' . PHP_EOL)); if ($input->getOption('allow-relax')) { + // Compose on the current rules (the secure default): grant the relax control. $serverOptions->setAclRules( - (new AclRules()) - ->withOperationRules(OperationRule::allow(Subject::authenticated())) - ->withControlRules(ControlRule::allow( + $serverOptions->getAclRules()->withControlRules( + ControlRule::allow( Subject::authenticated(), Target::any(), Control::OID_RELAX_RULES, - )), + ), + ), ); } if ($input->getOption('allow-proxy')) { + // Compose on the current rules (the secure default): grant cn=user proxied-auth over ou=people. $serverOptions->setAclRules( - (new AclRules()) - ->withOperationRules(OperationRule::allow(Subject::authenticated())) - ->withControlRules(ControlRule::allow( + $serverOptions->getAclRules()->withControlRules( + ControlRule::allow( Subject::dn('cn=user,dc=foo,dc=bar'), Target::subtree('ou=people,dc=foo,dc=bar'), Control::OID_PROXY_AUTHORIZATION, - )), + ), + ), ); } + if ($input->getOption('manager')) { + $serverOptions->setManager(new ManagerIdentity( + new Dn(self::MANAGER_DN), + '{SHA}' . base64_encode(sha1(self::MANAGER_PASSWORD, true)), + )); + } + $server = new LdapServer($serverOptions); if ($storage === 'memory') { diff --git a/tests/support/LdapServerCommand.php b/tests/support/LdapServerCommand.php index 5ca2aa7b..2a50229f 100644 --- a/tests/support/LdapServerCommand.php +++ b/tests/support/LdapServerCommand.php @@ -9,9 +9,7 @@ use FreeDSx\Ldap\Ldif\Loader\FileLdifLoader; use FreeDSx\Ldap\Control\Control; use FreeDSx\Ldap\Ldif\Output\FileLdifOutput; -use FreeDSx\Ldap\Server\AccessControl\AclRules; use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule; -use FreeDSx\Ldap\Server\AccessControl\Rule\OperationRule; use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; use FreeDSx\Ldap\Server\AccessControl\Target\Target; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\InMemoryStorage; @@ -279,26 +277,28 @@ protected function execute( } if ($external && $input->getOption('external-allow-proxy') === true) { + // Compose on the current rules (the secure default): grant the EXTERNAL cert identity proxied-auth. $options->setAclRules( - (new AclRules()) - ->withOperationRules(OperationRule::allow(Subject::authenticated())) - ->withControlRules(ControlRule::allow( + $options->getAclRules()->withControlRules( + ControlRule::allow( Subject::dn('cn=extuser,dc=foo,dc=bar'), Target::subtree('dc=foo,dc=bar'), Control::OID_PROXY_AUTHORIZATION, - )), + ), + ), ); } if ($input->getOption('allow-sync') === true) { + // Compose on the current rules (the secure default) and add the one sync-specific grant. $options->setAclRules( - (new AclRules()) - ->withOperationRules(OperationRule::allow(Subject::authenticated())) - ->withControlRules(ControlRule::allow( - Subject::authenticated(), + $options->getAclRules()->withControlRules( + ControlRule::allow( + Subject::dn('cn=user,dc=foo,dc=bar'), Target::subtree('dc=foo,dc=bar'), Control::OID_SYNC_REQUEST, - )), + ), + ), ); } diff --git a/tests/unit/Protocol/ServerProtocolHandler/ServerSyncHandlerTest.php b/tests/unit/Protocol/ServerProtocolHandler/ServerSyncHandlerTest.php index 81e820fa..b0fd8aa9 100644 --- a/tests/unit/Protocol/ServerProtocolHandler/ServerSyncHandlerTest.php +++ b/tests/unit/Protocol/ServerProtocolHandler/ServerSyncHandlerTest.php @@ -119,12 +119,12 @@ protected function setUp(): void $this->journal = new InMemoryChangeJournal(new ReplicaId(self::ORIGIN)); $this->accessControl - ->method('filterEntry') - ->willReturnCallback(fn(TokenInterface $token, Entry $entry): ?Entry => in_array( + ->method('isEntryVisible') + ->willReturnCallback(fn(TokenInterface $token, Entry $entry): bool => !in_array( $entry->getDn()->toString(), $this->hiddenDns, true, - ) ? null : $entry); + )); $this->filterEvaluator ->method('evaluate') ->willReturnCallback(fn(): bool => $this->filterMatches); diff --git a/tests/unit/Server/AccessControl/AclRulesTest.php b/tests/unit/Server/AccessControl/AclRulesTest.php new file mode 100644 index 00000000..0cdd9922 --- /dev/null +++ b/tests/unit/Server/AccessControl/AclRulesTest.php @@ -0,0 +1,204 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\AccessControl; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Operation\OperationType; +use FreeDSx\Ldap\Server\AccessControl\AclRules; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; +use FreeDSx\Ldap\Server\AccessControl\RuleBasedAccessControl; +use FreeDSx\Ldap\Server\AccessControl\Subject\Subject; +use FreeDSx\Ldap\Server\Token\AnonToken; +use FreeDSx\Ldap\Server\Token\BindToken; +use FreeDSx\Ldap\Server\Token\TokenInterface; +use PHPUnit\Framework\TestCase; + +final class AclRulesTest extends TestCase +{ + private const USER_DN = 'cn=user,dc=foo,dc=bar'; + + private const OTHER_DN = 'cn=other,dc=foo,dc=bar'; + + private const ADMIN_DN = 'cn=admin,dc=foo,dc=bar'; + + private RuleBasedAccessControl $subject; + + protected function setUp(): void + { + $this->subject = new RuleBasedAccessControl( + AclRules::secureDefault(Subject::dn(self::ADMIN_DN)), + ); + } + + public function test_secureDefault_lets_self_write_its_own_userPassword(): void + { + $this->subject->authorizeAttribute( + $this->user(), + new Dn(self::USER_DN), + 'userPassword', + AttributeAccess::Write, + ); + + $this->addToAssertionCount(1); + } + + public function test_secureDefault_denies_writing_another_userPassword(): void + { + $this->expectException(OperationException::class); + + $this->subject->authorizeAttribute( + $this->user(), + new Dn(self::OTHER_DN), + 'userPassword', + AttributeAccess::Write, + ); + } + + public function test_secureDefault_lets_admin_write_another_userPassword(): void + { + $this->subject->authorizeAttribute( + $this->admin(), + new Dn(self::OTHER_DN), + 'userPassword', + AttributeAccess::Write, + ); + + $this->addToAssertionCount(1); + } + + public function test_secureDefault_restricts_passwordModify_to_self_or_admin(): void + { + $this->subject->authorizeOperation( + OperationType::PasswordModify, + $this->user(), + new Dn(self::USER_DN), + ); + $this->subject->authorizeOperation( + OperationType::PasswordModify, + $this->admin(), + new Dn(self::OTHER_DN), + ); + + $this->expectException(OperationException::class); + $this->subject->authorizeOperation( + OperationType::PasswordModify, + $this->user(), + new Dn(self::OTHER_DN), + ); + } + + public function test_secureDefault_allows_authenticated_general_operations(): void + { + $this->subject->authorizeOperation( + OperationType::Search, + $this->user(), + new Dn(self::OTHER_DN), + ); + $this->subject->authorizeOperation( + OperationType::Modify, + $this->user(), + new Dn(self::OTHER_DN), + ); + + $this->addToAssertionCount(1); + } + + public function test_secureDefault_denies_anonymous(): void + { + $this->expectException(OperationException::class); + + $this->subject->authorizeOperation( + OperationType::Search, + new AnonToken(), + new Dn(self::OTHER_DN), + ); + } + + public function test_secureDefault_restricts_privileged_extended_ops_to_admin(): void + { + $this->subject->authorizeExtendedOperation( + $this->admin(), + '1.3.6.1.4.1.1.1', + ); + + $this->expectException(OperationException::class); + $this->subject->authorizeExtendedOperation( + $this->user(), + '1.3.6.1.4.1.1.1', + ); + } + + public function test_secureDefault_strips_userPassword_from_another_users_read(): void + { + $filtered = $this->subject->filterEntry( + $this->user(), + $this->entry(self::OTHER_DN), + ); + + self::assertNotNull($filtered); + self::assertNull($filtered->get('userPassword')); + self::assertNotNull($filtered->get('cn')); + } + + public function test_secureDefault_strips_userPassword_from_self_read(): void + { + $filtered = $this->subject->filterEntry( + $this->user(), + $this->entry(self::USER_DN), + ); + + self::assertNotNull($filtered); + self::assertNull($filtered->get('userPassword')); + } + + public function test_secureDefault_lets_self_write_but_not_read_its_userPassword(): void + { + $this->subject->authorizeAttribute( + $this->user(), + new Dn(self::USER_DN), + 'userPassword', + AttributeAccess::Write, + ); + + $this->expectException(OperationException::class); + $this->subject->authorizeAttribute( + $this->user(), + new Dn(self::USER_DN), + 'userPassword', + AttributeAccess::Read, + ); + } + + private function user(): TokenInterface + { + return BindToken::fromDn(self::USER_DN); + } + + private function admin(): TokenInterface + { + return BindToken::fromDn(self::ADMIN_DN); + } + + private function entry(string $dn): Entry + { + return new Entry( + new Dn($dn), + new Attribute('cn', 'x'), + new Attribute('userPassword', '{SHA}secret'), + ); + } +} diff --git a/tests/unit/Server/AccessControl/PrivilegedBypassAccessControlTest.php b/tests/unit/Server/AccessControl/PrivilegedBypassAccessControlTest.php new file mode 100644 index 00000000..b8d8bd1c --- /dev/null +++ b/tests/unit/Server/AccessControl/PrivilegedBypassAccessControlTest.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\AccessControl; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Operation\OperationType; +use FreeDSx\Ldap\Server\AccessControl\AccessControlInterface; +use FreeDSx\Ldap\Server\AccessControl\PrivilegedBypassAccessControl; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; +use FreeDSx\Ldap\Server\Token\BindToken; +use FreeDSx\Ldap\Server\Token\ManagerToken; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +final class PrivilegedBypassAccessControlTest extends TestCase +{ + private AccessControlInterface&MockObject $inner; + + private PrivilegedBypassAccessControl $subject; + + private ManagerToken $manager; + + private BindToken $user; + + protected function setUp(): void + { + $this->inner = $this->createMock(AccessControlInterface::class); + $this->subject = new PrivilegedBypassAccessControl($this->inner); + $this->manager = new ManagerToken(new Dn('cn=manager')); + $this->user = BindToken::fromDn('cn=user,dc=foo,dc=bar'); + } + + public function test_privileged_token_bypasses_every_authorization(): void + { + $this->inner + ->expects(self::never()) + ->method('authorizeOperation'); + $this->inner + ->expects(self::never()) + ->method('authorizeAttribute'); + $this->inner + ->expects(self::never()) + ->method('authorizeControl'); + $this->inner + ->expects(self::never()) + ->method('authorizeExtendedOperation'); + + $dn = new Dn('cn=other,dc=foo,dc=bar'); + $this->subject->authorizeOperation( + OperationType::Modify, + $this->manager, + $dn, + ); + $this->subject->authorizeAttribute( + $this->manager, + $dn, + 'userPassword', + AttributeAccess::Write, + ); + $this->subject->authorizeControl( + $this->manager, + $dn, + '1.2.3', + ); + $this->subject->authorizeExtendedOperation( + $this->manager, + '1.2.3.4', + ); + + $this->addToAssertionCount(1); + } + + public function test_privileged_token_may_use_any_control_and_keeps_the_whole_entry(): void + { + $this->inner + ->expects(self::never()) + ->method('mayUseControl'); + $this->inner + ->expects(self::never()) + ->method('filterEntry'); + $this->inner + ->expects(self::never()) + ->method('isEntryVisible'); + + $entry = new Entry(new Dn('cn=other,dc=foo,dc=bar')); + + self::assertTrue($this->subject->mayUseControl( + $this->manager, + '1.2.3', + )); + self::assertSame( + $entry, + $this->subject->filterEntry( + $this->manager, + $entry, + ), + ); + self::assertTrue($this->subject->isEntryVisible( + $this->manager, + $entry, + )); + } + + public function test_non_privileged_token_delegates_to_the_inner_policy(): void + { + $this->inner + ->expects(self::once()) + ->method('authorizeExtendedOperation') + ->with( + $this->user, + '1.2.3.4', + ); + + $this->subject->authorizeExtendedOperation( + $this->user, + '1.2.3.4', + ); + } + + public function test_non_privileged_entry_visibility_delegates_to_the_inner_policy(): void + { + $entry = new Entry(new Dn('cn=other,dc=foo,dc=bar')); + + $this->inner + ->expects(self::once()) + ->method('isEntryVisible') + ->with( + $this->user, + $entry, + ) + ->willReturn(false); + + self::assertFalse($this->subject->isEntryVisible( + $this->user, + $entry, + )); + } +} diff --git a/tests/unit/Server/AccessControl/RuleBasedAccessControlTest.php b/tests/unit/Server/AccessControl/RuleBasedAccessControlTest.php index 1016d826..8f5cb291 100644 --- a/tests/unit/Server/AccessControl/RuleBasedAccessControlTest.php +++ b/tests/unit/Server/AccessControl/RuleBasedAccessControlTest.php @@ -20,6 +20,7 @@ use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Server\AccessControl\AclRules; use FreeDSx\Ldap\Operation\OperationType; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeRule; use FreeDSx\Ldap\Server\AccessControl\Rule\ControlRule; use FreeDSx\Ldap\Server\AccessControl\Rule\Effect; @@ -54,7 +55,7 @@ public function test_it_should_allow_when_first_matching_rule_is_allow(): void { $this->expectNotToPerformAssertions(); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [ OperationRule::allow(new AnySubjectMatcher()), ], @@ -72,7 +73,7 @@ public function test_it_should_deny_when_first_matching_rule_is_deny(): void $this->expectException(OperationException::class); $this->expectExceptionCode(ResultCode::INSUFFICIENT_ACCESS_RIGHTS); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [ OperationRule::deny(new AnySubjectMatcher()), ], @@ -90,7 +91,7 @@ public function test_it_should_deny_when_no_rules_match_and_default_effect_is_de $this->expectException(OperationException::class); $this->expectExceptionCode(ResultCode::INSUFFICIENT_ACCESS_RIGHTS); - $subject = new RuleBasedAccessControl(new AclRules(defaultOperationEffect: Effect::Deny)); + $subject = new RuleBasedAccessControl(AclRules::fromEmpty(defaultOperationEffect: Effect::Deny)); $subject->authorizeOperation( OperationType::Search, @@ -103,7 +104,7 @@ public function test_it_should_allow_when_no_rules_match_and_default_effect_is_a { $this->expectNotToPerformAssertions(); - $subject = new RuleBasedAccessControl(new AclRules(defaultOperationEffect: Effect::Allow)); + $subject = new RuleBasedAccessControl(AclRules::fromEmpty(defaultOperationEffect: Effect::Allow)); $subject->authorizeOperation( OperationType::Search, @@ -116,7 +117,7 @@ public function test_it_should_skip_rule_when_operation_type_does_not_match(): v { $this->expectException(OperationException::class); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [ OperationRule::allow( new AnySubjectMatcher(), @@ -138,7 +139,7 @@ public function test_it_should_apply_rule_when_operation_type_matches(): void { $this->expectNotToPerformAssertions(); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [ OperationRule::allow( new AnySubjectMatcher(), @@ -159,7 +160,7 @@ public function test_it_should_apply_rule_with_empty_operations_list_to_all_oper { $this->expectNotToPerformAssertions(); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [ OperationRule::allow(new AnySubjectMatcher()), ], @@ -178,7 +179,7 @@ public function test_it_should_use_first_match_when_deny_rule_precedes_allow_rul { $this->expectException(OperationException::class); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [ OperationRule::deny(new AnySubjectMatcher()), OperationRule::allow(new AnySubjectMatcher()), @@ -194,7 +195,7 @@ public function test_it_should_use_first_match_when_deny_rule_precedes_allow_rul public function test_may_use_control_is_true_when_an_allow_rule_matches_the_subject(): void { - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( controls: [ ControlRule::allow( new AnySubjectMatcher(), @@ -212,7 +213,7 @@ public function test_may_use_control_is_true_when_an_allow_rule_matches_the_subj public function test_may_use_control_is_false_when_no_rule_grants_the_control(): void { - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( controls: [ ControlRule::allow( new AnySubjectMatcher(), @@ -230,7 +231,7 @@ public function test_may_use_control_is_false_when_no_rule_grants_the_control(): public function test_may_use_control_is_false_for_an_unauthenticated_token(): void { - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( controls: [ ControlRule::allow( new AnySubjectMatcher(), @@ -250,7 +251,7 @@ public function test_filter_entry_returns_same_instance_when_no_attribute_rules( { $entry = Entry::create('dc=foo,dc=bar', ['cn' => 'foo']); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [OperationRule::allow(new AnySubjectMatcher())], )); @@ -269,7 +270,7 @@ public function test_filter_entry_removes_attribute_when_deny_rule_matches(): vo ['cn' => 'foo', 'userpassword' => 'secret'], ); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [OperationRule::allow(new AnySubjectMatcher())], attributes: [ AttributeRule::deny( @@ -300,7 +301,7 @@ public function test_filter_entry_keeps_attribute_when_allow_rule_precedes_deny_ ['userpassword' => 'secret'], ); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [OperationRule::allow(new AnySubjectMatcher())], attributes: [ AttributeRule::allow( @@ -335,7 +336,7 @@ public function test_filter_entry_keeps_attribute_when_no_rule_matches(): void ['cn' => 'foo'], ); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [OperationRule::allow(new AnySubjectMatcher())], attributes: [ AttributeRule::deny( @@ -362,7 +363,7 @@ public function test_filter_entry_returns_same_instance_when_all_attributes_kept ['cn' => 'foo'], ); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [OperationRule::allow(new AnySubjectMatcher())], attributes: [ AttributeRule::allow( @@ -388,7 +389,7 @@ public function test_filter_entry_empty_attributes_list_on_deny_rule_matches_all ['cn' => 'foo', 'sn' => 'bar'], ); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [OperationRule::allow(new AnySubjectMatcher())], attributes: [ AttributeRule::deny(new AnySubjectMatcher()), @@ -411,7 +412,7 @@ public function test_filter_entry_returns_null_when_search_operation_is_denied_o ['cn' => 'foo'], ); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [ OperationRule::deny( new AnySubjectMatcher(), @@ -429,16 +430,54 @@ public function test_filter_entry_returns_null_when_search_operation_is_denied_o self::assertNull($result); } + public function test_entry_is_visible_when_search_is_allowed_without_stripping_attributes(): void + { + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( + operations: [OperationRule::allow(new AnySubjectMatcher())], + attributes: [ + AttributeRule::deny( + new AnySubjectMatcher(), + new AnyTargetMatcher(), + 'userPassword', + )->forRead(), + ], + )); + + self::assertTrue($subject->isEntryVisible( + $this->bindToken, + Entry::create('dc=foo,dc=bar', ['cn' => 'foo']), + )); + } + + public function test_entry_is_not_visible_when_search_is_denied_on_entry_dn(): void + { + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( + operations: [ + OperationRule::deny( + new AnySubjectMatcher(), + new AnyTargetMatcher(), + OperationType::Search, + ), + ], + )); + + self::assertFalse($subject->isEntryVisible( + $this->bindToken, + Entry::create('dc=foo,dc=bar', ['cn' => 'foo']), + )); + } + public function test_authorize_attribute_access_does_not_throw_when_no_deny_rule_matches(): void { $this->expectNotToPerformAssertions(); - $subject = new RuleBasedAccessControl(); + $subject = new RuleBasedAccessControl(AclRules::fromEmpty()); $subject->authorizeAttribute( $this->bindToken, $this->dn, 'userPassword', + AttributeAccess::Write, ); } @@ -447,7 +486,7 @@ public function test_authorize_attribute_access_throws_when_deny_rule_matches(): $this->expectException(OperationException::class); $this->expectExceptionCode(ResultCode::INSUFFICIENT_ACCESS_RIGHTS); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( attributes: [ AttributeRule::deny( new AnySubjectMatcher(), @@ -461,6 +500,7 @@ public function test_authorize_attribute_access_throws_when_deny_rule_matches(): $this->bindToken, $this->dn, 'userPassword', + AttributeAccess::Write, ); } @@ -469,7 +509,7 @@ public function test_authorize_control_denies_by_default(): void $this->expectException(OperationException::class); $this->expectExceptionCode(ResultCode::INSUFFICIENT_ACCESS_RIGHTS); - $subject = new RuleBasedAccessControl(); + $subject = new RuleBasedAccessControl(AclRules::fromEmpty()); $subject->authorizeControl( $this->bindToken, @@ -482,7 +522,7 @@ public function test_authorize_control_allows_when_control_rule_grants_the_oid() { $this->expectNotToPerformAssertions(); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( controls: [ ControlRule::allow( new AnySubjectMatcher(), @@ -504,7 +544,7 @@ public function test_authorize_control_denies_when_rule_grants_a_different_oid() $this->expectException(OperationException::class); $this->expectExceptionCode(ResultCode::INSUFFICIENT_ACCESS_RIGHTS); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( controls: [ ControlRule::allow( new AnySubjectMatcher(), @@ -526,7 +566,7 @@ public function test_authorize_control_denies_when_deny_rule_matches(): void $this->expectException(OperationException::class); $this->expectExceptionCode(ResultCode::INSUFFICIENT_ACCESS_RIGHTS); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( controls: [ ControlRule::deny(new AnySubjectMatcher()), ], @@ -554,7 +594,7 @@ public function test_set_backend_propagates_to_backend_aware_subjects(): void ->method('setBackend') ->with($mockBackend); - $subject = new RuleBasedAccessControl(new AclRules( + $subject = new RuleBasedAccessControl(AclRules::fromEmpty( operations: [OperationRule::allow($mockBackendAwareSubject)], )); diff --git a/tests/unit/Server/AccessControl/SimpleAccessControlTest.php b/tests/unit/Server/AccessControl/SimpleAccessControlTest.php index b0741ab8..f62ad049 100644 --- a/tests/unit/Server/AccessControl/SimpleAccessControlTest.php +++ b/tests/unit/Server/AccessControl/SimpleAccessControlTest.php @@ -18,6 +18,7 @@ use FreeDSx\Ldap\Exception\OperationException; use FreeDSx\Ldap\Operation\ResultCode; use FreeDSx\Ldap\Operation\OperationType; +use FreeDSx\Ldap\Server\AccessControl\Rule\AttributeAccess; use FreeDSx\Ldap\Server\AccessControl\SimpleAccessControl; use FreeDSx\Ldap\Server\Token\AnonToken; use FreeDSx\Ldap\Server\Token\BindToken; @@ -165,6 +166,24 @@ public function test_filter_entry_returns_entry_unchanged_for_anonymous(): void ); } + public function test_entry_is_visible_for_an_authenticated_identity(): void + { + self::assertTrue($this->subject->isEntryVisible( + BindToken::fromDn( + 'cn=admin,dc=foo,dc=bar', + ), + Entry::create('dc=foo,dc=bar', ['cn' => 'foo']), + )); + } + + public function test_entry_is_not_visible_for_anonymous(): void + { + self::assertFalse($this->subject->isEntryVisible( + new AnonToken(), + Entry::create('dc=foo,dc=bar', ['cn' => 'foo']), + )); + } + public function test_authorize_attribute_access_is_a_no_op(): void { $this->expectNotToPerformAssertions(); @@ -175,6 +194,7 @@ public function test_authorize_attribute_access_is_a_no_op(): void ), $this->dn, 'userPassword', + AttributeAccess::Write, ); } } diff --git a/tests/unit/Server/Backend/Auth/ManagerAwareAuthenticatorTest.php b/tests/unit/Server/Backend/Auth/ManagerAwareAuthenticatorTest.php new file mode 100644 index 00000000..c4aa5c4e --- /dev/null +++ b/tests/unit/Server/Backend/Auth/ManagerAwareAuthenticatorTest.php @@ -0,0 +1,108 @@ + + * + * 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\Backend\Auth; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Exception\OperationException; +use FreeDSx\Ldap\Server\Backend\Auth\ManagerAwareAuthenticator; +use FreeDSx\Ldap\Server\Backend\Auth\ManagerIdentity; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordAuthenticatableInterface; +use FreeDSx\Ldap\Server\Backend\Auth\PasswordHashService; +use FreeDSx\Ldap\Server\Token\BindToken; +use FreeDSx\Ldap\Server\Token\ManagerToken; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +final class ManagerAwareAuthenticatorTest extends TestCase +{ + private const MANAGER_DN = 'cn=manager'; + + private const MANAGER_PASSWORD = '12345'; + + private const MANAGER_HASH = '{SHA}jLIjfQZ5yojbZGTqxg2pY0VROWQ='; + + private PasswordAuthenticatableInterface&MockObject $inner; + + private ManagerAwareAuthenticator $subject; + + protected function setUp(): void + { + $this->inner = $this->createMock(PasswordAuthenticatableInterface::class); + $this->subject = new ManagerAwareAuthenticator( + $this->inner, + new ManagerIdentity( + new Dn(self::MANAGER_DN), + self::MANAGER_HASH, + ), + new PasswordHashService(), + ); + } + + public function test_manager_with_correct_password_returns_a_privileged_token(): void + { + $this->inner + ->expects(self::never()) + ->method('authenticate'); + + $token = $this->subject->authenticate( + self::MANAGER_DN, + self::MANAGER_PASSWORD, + ); + + self::assertInstanceOf( + ManagerToken::class, + $token, + ); + self::assertSame( + self::MANAGER_DN, + $token->getResolvedDn()->toString(), + ); + } + + public function test_manager_with_wrong_password_is_denied_without_delegating(): void + { + $this->inner + ->expects(self::never()) + ->method('authenticate'); + $this->expectException(OperationException::class); + + $this->subject->authenticate( + self::MANAGER_DN, + 'wrong', + ); + } + + public function test_non_manager_name_delegates_to_the_inner_authenticator(): void + { + $token = BindToken::fromDn('cn=user,dc=foo,dc=bar'); + $this->inner + ->expects(self::once()) + ->method('authenticate') + ->with( + 'cn=user,dc=foo,dc=bar', + 'secret', + ) + ->willReturn($token); + + $result = $this->subject->authenticate( + 'cn=user,dc=foo,dc=bar', + 'secret', + ); + + self::assertSame( + $token, + $result, + ); + } +} diff --git a/tests/unit/Server/Logging/OperationAuditorTest.php b/tests/unit/Server/Logging/OperationAuditorTest.php index 645c2f32..356df157 100644 --- a/tests/unit/Server/Logging/OperationAuditorTest.php +++ b/tests/unit/Server/Logging/OperationAuditorTest.php @@ -263,6 +263,36 @@ public function test_record_search_failure_discriminates_a_read_denial(): void ); } + public function test_record_compare_failure_is_a_read_denial_carrying_the_attribute(): void + { + $request = new CompareRequest( + self::TARGET_DN, + new EqualityFilter('userPassword', 'secret'), + ); + + $this->subject->recordCompareFailure( + self::wrap($request), + new OperationException( + 'Denied', + ResultCode::INSUFFICIENT_ACCESS_RIGHTS, + ), + $this->token, + ); + + $record = $this->onlyRecord(); + self::assertSame( + 'authz.denied.read', + $record['message'], + ); + self::assertSame( + [ + EventContext::DN => self::TARGET_DN, + EventContext::ATTRIBUTE => 'userPassword', + ], + $record['context'][EventContext::TARGET], + ); + } + public function test_record_password_modify_success_carries_target(): void { $this->subject->recordPasswordModifySuccess( diff --git a/tests/unit/Sync/Provider/SyncPersistStreamerTest.php b/tests/unit/Sync/Provider/SyncPersistStreamerTest.php index b8065d54..26b908dd 100644 --- a/tests/unit/Sync/Provider/SyncPersistStreamerTest.php +++ b/tests/unit/Sync/Provider/SyncPersistStreamerTest.php @@ -108,8 +108,8 @@ protected function setUp(): void $accessControl = $this->createMock(AccessControlInterface::class); $accessControl - ->method('filterEntry') - ->willReturnArgument(1); + ->method('isEntryVisible') + ->willReturn(true); $filterEvaluator = $this->createMock(FilterEvaluatorInterface::class); $filterEvaluator ->method('evaluate') diff --git a/tests/unit/Sync/Provider/SyncResultProjectorTest.php b/tests/unit/Sync/Provider/SyncResultProjectorTest.php index 76a846e4..0f4964dd 100644 --- a/tests/unit/Sync/Provider/SyncResultProjectorTest.php +++ b/tests/unit/Sync/Provider/SyncResultProjectorTest.php @@ -56,8 +56,8 @@ protected function setUp(): void $this->token = $this->createMock(TokenInterface::class); $this->accessControl - ->method('filterEntry') - ->willReturnCallback(fn(TokenInterface $token, Entry $entry): ?Entry => $this->hidden ? null : $entry); + ->method('isEntryVisible') + ->willReturnCallback(fn(TokenInterface $token, Entry $entry): bool => !$this->hidden); $this->filterEvaluator ->method('evaluate') ->willReturnCallback(fn(): bool => $this->filterMatches); @@ -86,6 +86,29 @@ public function test_a_visible_searched_entry_becomes_an_add(): void ); } + public function test_a_visible_entry_is_shipped_whole_including_sensitive_attributes(): void + { + $entry = Entry::create( + 'cn=a,dc=example,dc=com', + [ + 'cn' => 'x', + 'userPassword' => '{BCRYPT}$2y$10$abcdefghijklmnopqrstuv', + 'entryUUID' => self::UUID, + ], + ); + + $result = $this->subject->projectSearched( + $entry, + $this->token, + ); + + self::assertNotNull($result); + self::assertSame( + '{BCRYPT}$2y$10$abcdefghijklmnopqrstuv', + $result->entry->getEntry()->get('userPassword')?->firstValue(), + ); + } + public function test_a_searched_entry_hidden_by_acl_is_null(): void { $this->hidden = true;