Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions apps/user_ldap/ajax/clearMappings.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use OCA\User_LDAP\Mapping\GroupMapping;
use OCA\User_LDAP\Mapping\UserMapping;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IDBConnection;
use OCP\IUserManager;
use OCP\Server;
use OCP\User\Events\BeforeUserIdUnassignedEvent;
Expand Down Expand Up @@ -40,7 +39,7 @@ function (string $uid) use ($dispatcher): void {
}
);
} elseif ($subject === 'group') {
$mapping = new GroupMapping(Server::get(IDBConnection::class));
$mapping = Server::get(GroupMapping::class);
$result = $mapping->clear();
}

Expand Down
119 changes: 99 additions & 20 deletions apps/user_ldap/lib/Mapping/AbstractMapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
use Doctrine\DBAL\Exception;
use OCP\DB\IPreparedStatement;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IDBConnection;
use OCP\Server;
use Psr\Log\LoggerInterface;
Expand All @@ -27,16 +30,81 @@ abstract class AbstractMapping {
*/
abstract protected function getTableName(bool $includePrefix = true);

/**
* A month worth of cache time for as good as never changing mapping data.
* Implemented when it was found that name-to-DN lookups are quite frequent.
*/
protected const LOCAL_CACHE_TTL = 2592000;

/**
* A week worth of cache time for rarely changing user count data.
*/
protected const LOCAL_USER_COUNT_TTL = 604800;

/**
* By default, the local cache is only used up to a certain amount of objects.
* This constant holds this number. The amount of entries would amount up to
* 1 MiB (worst case) per mappings table.
* Setting `use_local_mapping_cache` for `user_ldap` to `yes` or `no`
* deliberately enables or disables this mechanism.
*/
protected const LOCAL_CACHE_OBJECT_THRESHOLD = 2000;

protected ?ICache $localNameToDnCache = null;

/** @var array caches Names (value) by DN (key) */
protected array $cache = [];

/**
* @param IDBConnection $dbc
*/
public function __construct(
protected IDBConnection $dbc,
protected ICacheFactory $cacheFactory,
protected IAppConfig $config,
protected bool $isCLI,
) {
$this->initLocalCache();
}

/** @var array caches Names (value) by DN (key) */
protected $cache = [];
protected function initLocalCache(): void {
if ($this->isCLI || !$this->cacheFactory->isLocalCacheAvailable()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not in cli?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For APCu does not have a shared cache. There is no benefit over the runtime cache that was already present.

return;
}

$useLocalCache = $this->config->getValueString('user_ldap', 'use_local_mapping_cache', 'auto', false);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Register it in the Lexicon?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I'll check. I saw lexicon flying by, but don't really know what it does yet.

image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a first glance, I suppose Lexicon support would be a follow-PR as we have other switches as well.

if ($useLocalCache !== 'yes' && $useLocalCache !== 'auto') {
return;
}

$section = \str_contains($this->getTableName(), 'user') ? 'u/' : 'g/';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$section = \str_contains($this->getTableName(), 'user') ? 'u/' : 'g/';
$section = $this->getTableName();

Why make it more complicated than this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why make it more complicated than this?

It is shorter and thus may save allocation of bytes in APCU.

$this->localNameToDnCache = $this->cacheFactory->createLocal('ldap/map/' . $section);

// We use the cache as well to store whether it shall be used. If the
// answer was no, we unset it again.
if ($useLocalCache === 'auto' && !$this->useCacheByUserCount()) {
$this->localNameToDnCache = null;
}
}

protected function useCacheByUserCount(): bool {
$use = $this->localNameToDnCache->get('use');
if ($use !== null) {
return $use;
}

$qb = $this->dbc->getQueryBuilder();
$q = $qb->selectAlias($qb->createFunction('COUNT(owncloud_name)'), 'count')
->from($this->getTableName());
$q->setMaxResults(self::LOCAL_CACHE_OBJECT_THRESHOLD + 1);
$result = $q->executeQuery();
$row = $result->fetch();
$result->closeCursor();

$use = (int)$row['count'] <= self::LOCAL_CACHE_OBJECT_THRESHOLD;
$this->localNameToDnCache->set('use', $use, self::LOCAL_USER_COUNT_TTL);
return $use;
}

/**
* checks whether a provided string represents an existing table col
Expand Down Expand Up @@ -71,14 +139,13 @@ protected function getXbyY($fetchCol, $compareCol, $search) {
//having SQL injection at all.
throw new \Exception('Invalid Column Name');
}
$query = $this->dbc->prepare('
SELECT `' . $fetchCol . '`
FROM `' . $this->getTableName() . '`
WHERE `' . $compareCol . '` = ?
');
$qb = $this->dbc->getQueryBuilder();
$qb->select($fetchCol)
->from($this->getTableName())
->where($qb->expr()->eq($compareCol, $qb->createNamedParameter($search)));

try {
$res = $query->execute([$search]);
$res = $qb->executeQuery();
$data = $res->fetchOne();
$res->closeCursor();
return $data;
Expand Down Expand Up @@ -107,17 +174,20 @@ protected function modify(IPreparedStatement $statement, $parameters) {

/**
* Gets the LDAP DN based on the provided name.
* Replaces Access::ocname2dn
*
* @param string $name
* @return string|false
*/
public function getDNByName($name) {
$dn = array_search($name, $this->cache);
if ($dn === false && ($dn = $this->getXbyY('ldap_dn', 'owncloud_name', $name)) !== false) {
$this->cache[$dn] = $name;
public function getDNByName(string $name): string|false {
$dn = array_search($name, $this->cache, true);
if ($dn === false) {
$dn = $this->localNameToDnCache?->get($name);
if ($dn === null) {
$dn = $this->getXbyY('ldap_dn', 'owncloud_name', $name);
if ($dn !== false) {
$this->cache[$dn] = $name;
}
$this->localNameToDnCache?->set($name, $dn, self::LOCAL_CACHE_TTL);
}
}
return $dn;
return $dn ?? false;
}

/**
Expand All @@ -136,9 +206,15 @@ public function setDNbyUUID($fdn, $uuid) {
');

$r = $this->modify($statement, [$this->getDNHash($fdn), $fdn, $uuid]);

if ($r && is_string($oldDn) && isset($this->cache[$oldDn])) {
$this->cache[$fdn] = $this->cache[$oldDn];
if ($r) {
if (is_string($oldDn) && isset($this->cache[$oldDn])) {
$userId = $this->cache[$oldDn];
}
$userId = $userId ?? $this->getNameByUUID($uuid);
if ($userId) {
$this->cache[$fdn] = $userId;
$this->localNameToDnCache?->set($userId, $fdn, self::LOCAL_CACHE_TTL);
}
unset($this->cache[$oldDn]);
}

Expand Down Expand Up @@ -351,6 +427,7 @@ public function map($fdn, $name, $uuid) {
$result = $this->dbc->insertIfNotExist($this->getTableName(), $row);
if ((bool)$result === true) {
$this->cache[$fdn] = $name;
$this->localNameToDnCache?->set($name, $fdn, self::LOCAL_CACHE_TTL);
}
// insertIfNotExist returns values as int
return (bool)$result;
Expand All @@ -374,6 +451,7 @@ public function unmap($name) {
if ($dn !== false) {
unset($this->cache[$dn]);
}
$this->localNameToDnCache?->remove($name);

return $this->modify($statement, [$name]);
}
Expand All @@ -389,6 +467,7 @@ public function clear() {
->getTruncateTableSQL('`' . $this->getTableName() . '`');
try {
$this->dbc->executeQuery($sql);
$this->localNameToDnCache?->clear();

return true;
} catch (Exception $e) {
Expand Down
7 changes: 6 additions & 1 deletion apps/user_ldap/lib/Mapping/UserMapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
namespace OCA\User_LDAP\Mapping;

use OCP\HintException;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IDBConnection;
use OCP\IRequest;
use OCP\Server;
Expand All @@ -24,9 +26,12 @@ class UserMapping extends AbstractMapping {

public function __construct(
IDBConnection $dbc,
ICacheFactory $cacheFactory,
IAppConfig $config,
bool $isCLI,
private IAssertion $assertion,
) {
parent::__construct($dbc);
parent::__construct($dbc, $cacheFactory, $config, $isCLI);
}

/**
Expand Down
13 changes: 12 additions & 1 deletion apps/user_ldap/lib/User_LDAP.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ public function canChangeAvatar($uid) {
public function loginName2UserName($loginName, bool $forceLdapRefetch = false) {
$cacheKey = 'loginName2UserName-' . $loginName;
$username = $this->access->connection->getFromCache($cacheKey);
$knownDn = $username ? $this->access->username2dn($username) : false;

$ignoreCache = ($username === false && $forceLdapRefetch);
$ignoreCache = (($username === false || $knownDn === false) && $forceLdapRefetch);
if ($username !== null && !$ignoreCache) {
return $username;
}
Expand Down Expand Up @@ -138,6 +139,16 @@ public function checkPassword($uid, $password) {
return false;
}
$dn = $this->access->username2dn($username);
if ($dn === false) {
$this->logger->warning(
'LDAP Login: Found user {user}, but no assigned DN',
[
'app' => 'user_ldap',
'user' => $username,
]
);
return false;
}
$user = $this->access->userManager->get($dn);

if (!$user instanceof User) {
Expand Down
19 changes: 16 additions & 3 deletions apps/user_ldap/tests/Mapping/AbstractMappingTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,31 @@
namespace OCA\User_LDAP\Tests\Mapping;

use OCA\User_LDAP\Mapping\AbstractMapping;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IDBConnection;
use OCP\Server;
use PHPUnit\Framework\MockObject\MockObject;

abstract class AbstractMappingTestCase extends \Test\TestCase {
abstract public function getMapper(IDBConnection $dbMock);

private ICacheFactory&MockObject $cacheFactoryMock;

private IAppConfig&MockObject $configMock;

abstract public function getMapper(IDBConnection $dbMock, ICacheFactory $cacheFactory, IAppConfig $appConfig): AbstractMapping;

protected function setUp(): void {
$this->cacheFactoryMock = $this->createMock(ICacheFactory::class);
$this->configMock = $this->createMock(IAppConfig::class);
}

/**
* kiss test on isColNameValid
*/
public function testIsColNameValid(): void {
$dbMock = $this->createMock(IDBConnection::class);
$mapper = $this->getMapper($dbMock);
$mapper = $this->getMapper($dbMock, $this->cacheFactoryMock, $this->configMock);

$this->assertTrue($mapper->isColNameValid('ldap_dn'));
$this->assertFalse($mapper->isColNameValid('foobar'));
Expand Down Expand Up @@ -71,7 +84,7 @@ protected function mapEntries(AbstractMapping $mapper, array $data): void {
*/
private function initTest(): array {
$dbc = Server::get(IDBConnection::class);
$mapper = $this->getMapper($dbc);
$mapper = $this->getMapper($dbc, $this->cacheFactoryMock, $this->configMock);
$data = $this->getTestData();
// make sure DB is pristine, then fill it with test entries
$mapper->clear();
Expand Down
6 changes: 4 additions & 2 deletions apps/user_ldap/tests/Mapping/GroupMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
namespace OCA\User_LDAP\Tests\Mapping;

use OCA\User_LDAP\Mapping\GroupMapping;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IDBConnection;

/**
Expand All @@ -19,7 +21,7 @@
* @package OCA\User_LDAP\Tests\Mapping
*/
class GroupMappingTest extends AbstractMappingTestCase {
public function getMapper(IDBConnection $dbMock) {
return new GroupMapping($dbMock);
public function getMapper(IDBConnection $dbMock, ICacheFactory $cacheFactory, IAppConfig $appConfig): GroupMapping {
return new GroupMapping($dbMock, $cacheFactory, $appConfig, true);
}
}
6 changes: 4 additions & 2 deletions apps/user_ldap/tests/Mapping/UserMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
namespace OCA\User_LDAP\Tests\Mapping;

use OCA\User_LDAP\Mapping\UserMapping;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IDBConnection;
use OCP\Support\Subscription\IAssertion;

Expand All @@ -20,7 +22,7 @@
* @package OCA\User_LDAP\Tests\Mapping
*/
class UserMappingTest extends AbstractMappingTestCase {
public function getMapper(IDBConnection $dbMock) {
return new UserMapping($dbMock, $this->createMock(IAssertion::class));
public function getMapper(IDBConnection $dbMock, ICacheFactory $cacheFactory, IAppConfig $appConfig): UserMapping {
return new UserMapping($dbMock, $cacheFactory, $appConfig, true, $this->createMock(IAssertion::class));
}
}
Loading