nextcloud-server/apps/user_ldap/lib/Mapping/AbstractMapping.php

530 lines
14 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\User_LDAP\Mapping;
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;
/**
* Class AbstractMapping
*
* @package OCA\User_LDAP\Mapping
*/
abstract class AbstractMapping {
/**
* returns the DB table name which holds the mappings
*
* @return string
*/
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();
}
protected function initLocalCache(): void {
if ($this->isCLI || !$this->cacheFactory->isLocalCacheAvailable()) {
return;
}
$useLocalCache = $this->config->getValueString('user_ldap', 'use_local_mapping_cache', 'auto', false);
if ($useLocalCache !== 'yes' && $useLocalCache !== 'auto') {
return;
}
$section = \str_contains($this->getTableName(), 'user') ? 'u/' : 'g/';
$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->fetchAssociative();
$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
*
* @param string $col
* @return bool
*/
public function isColNameValid($col) {
switch ($col) {
case 'ldap_dn':
case 'ldap_dn_hash':
case 'owncloud_name':
case 'directory_uuid':
return true;
default:
return false;
}
}
/**
* Gets the value of one column based on a provided value of another column
*
* @param string $fetchCol
* @param string $compareCol
* @param string $search
* @return string|false
* @throws \Exception
*/
protected function getXbyY($fetchCol, $compareCol, $search) {
if (!$this->isColNameValid($fetchCol)) {
//this is used internally only, but we don't want to risk
//having SQL injection at all.
throw new \Exception('Invalid Column Name');
}
$qb = $this->dbc->getQueryBuilder();
$qb->select($fetchCol)
->from($this->getTableName())
->where($qb->expr()->eq($compareCol, $qb->createNamedParameter($search)));
try {
$res = $qb->executeQuery();
$data = $res->fetchOne();
$res->closeCursor();
return $data;
} catch (Exception $e) {
return false;
}
}
/**
* Performs a DELETE or UPDATE query to the database.
*
* @param IPreparedStatement $statement
* @param array $parameters
* @return bool true if at least one row was modified, false otherwise
*/
protected function modify(IPreparedStatement $statement, $parameters) {
try {
$result = $statement->execute($parameters);
$updated = $result->rowCount() > 0;
$result->closeCursor();
return $updated;
} catch (Exception $e) {
return false;
}
}
/**
* Gets the LDAP DN based on the provided 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 ?? false;
}
/**
* Updates the DN based on the given UUID
*
* @param string $fdn
* @param string $uuid
* @return bool
*/
public function setDNbyUUID($fdn, $uuid) {
$oldDn = $this->getDnByUUID($uuid);
$statement = $this->dbc->prepare('
UPDATE `' . $this->getTableName() . '`
SET `ldap_dn_hash` = ?, `ldap_dn` = ?
WHERE `directory_uuid` = ?
');
$r = $this->modify($statement, [$this->getDNHash($fdn), $fdn, $uuid]);
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]);
}
return $r;
}
/**
* Updates the UUID based on the given DN
*
* required by Migration/UUIDFix
*
* @param $uuid
* @param $fdn
* @return bool
*/
public function setUUIDbyDN($uuid, $fdn): bool {
$statement = $this->dbc->prepare('
UPDATE `' . $this->getTableName() . '`
SET `directory_uuid` = ?
WHERE `ldap_dn_hash` = ?
');
unset($this->cache[$fdn]);
return $this->modify($statement, [$uuid, $this->getDNHash($fdn)]);
}
/**
* Get the hash to store in database column ldap_dn_hash for a given dn
*/
protected function getDNHash(string $fdn): string {
return hash('sha256', $fdn, false);
}
/**
* Gets the name based on the provided LDAP DN.
*
* @param string $fdn
* @return string|false
*/
public function getNameByDN($fdn) {
if (!isset($this->cache[$fdn])) {
$this->cache[$fdn] = $this->getXbyY('owncloud_name', 'ldap_dn_hash', $this->getDNHash($fdn));
}
return $this->cache[$fdn];
}
/**
* @param array<string> $hashList
*/
protected function prepareListOfIdsQuery(array $hashList): IQueryBuilder {
$qb = $this->dbc->getQueryBuilder();
$qb->select('owncloud_name', 'ldap_dn_hash', 'ldap_dn')
->from($this->getTableName(false))
->where($qb->expr()->in('ldap_dn_hash', $qb->createNamedParameter($hashList, IQueryBuilder::PARAM_STR_ARRAY)));
return $qb;
}
protected function collectResultsFromListOfIdsQuery(IQueryBuilder $qb, array &$results): void {
$stmt = $qb->executeQuery();
while ($entry = $stmt->fetchAssociative()) {
$results[$entry['ldap_dn']] = $entry['owncloud_name'];
$this->cache[$entry['ldap_dn']] = $entry['owncloud_name'];
}
$stmt->closeCursor();
}
/**
* @param array<string> $fdns
* @return array<string,string>
*/
public function getListOfIdsByDn(array $fdns): array {
$totalDBParamLimit = 65000;
$sliceSize = 1000;
$maxSlices = $this->dbc->getDatabaseProvider() === IDBConnection::PLATFORM_SQLITE ? 9 : $totalDBParamLimit / $sliceSize;
$results = [];
$slice = 1;
$fdns = array_map([$this, 'getDNHash'], $fdns);
$fdnsSlice = count($fdns) > $sliceSize ? array_slice($fdns, 0, $sliceSize) : $fdns;
$qb = $this->prepareListOfIdsQuery($fdnsSlice);
while (isset($fdnsSlice[999])) {
// Oracle does not allow more than 1000 values in the IN list,
// but allows slicing
$slice++;
$fdnsSlice = array_slice($fdns, $sliceSize * ($slice - 1), $sliceSize);
/** @see https://github.com/vimeo/psalm/issues/4995 */
/** @psalm-suppress TypeDoesNotContainType */
if (!isset($qb)) {
$qb = $this->prepareListOfIdsQuery($fdnsSlice);
continue;
}
if (!empty($fdnsSlice)) {
$qb->orWhere($qb->expr()->in('ldap_dn_hash', $qb->createNamedParameter($fdnsSlice, IQueryBuilder::PARAM_STR_ARRAY)));
}
if ($slice % $maxSlices === 0) {
$this->collectResultsFromListOfIdsQuery($qb, $results);
unset($qb);
}
}
if (isset($qb)) {
$this->collectResultsFromListOfIdsQuery($qb, $results);
}
return $results;
}
/**
* Searches mapped names by the giving string in the name column
*
* @return string[]
*/
public function getNamesBySearch(string $search, string $prefixMatch = '', string $postfixMatch = ''): array {
$statement = $this->dbc->prepare('
SELECT `owncloud_name`
FROM `' . $this->getTableName() . '`
WHERE `owncloud_name` LIKE ?
');
try {
$res = $statement->execute([$prefixMatch . $this->dbc->escapeLikeParameter($search) . $postfixMatch]);
} catch (Exception $e) {
return [];
}
$names = [];
while ($row = $res->fetchAssociative()) {
$names[] = $row['owncloud_name'];
}
return $names;
}
/**
* Gets the name based on the provided LDAP UUID.
*
* @param string $uuid
* @return string|false
*/
public function getNameByUUID($uuid) {
return $this->getXbyY('owncloud_name', 'directory_uuid', $uuid);
}
public function getDnByUUID($uuid) {
return $this->getXbyY('ldap_dn', 'directory_uuid', $uuid);
}
/**
* Gets the UUID based on the provided LDAP DN
*
* @param string $dn
* @return false|string
* @throws \Exception
*/
public function getUUIDByDN($dn) {
return $this->getXbyY('directory_uuid', 'ldap_dn_hash', $this->getDNHash($dn));
}
public function getList(int $offset = 0, ?int $limit = null, bool $invalidatedOnly = false): array {
$select = $this->dbc->getQueryBuilder();
$select->selectAlias('ldap_dn', 'dn')
->selectAlias('owncloud_name', 'name')
->selectAlias('directory_uuid', 'uuid')
->from($this->getTableName())
->setMaxResults($limit)
->setFirstResult($offset);
if ($invalidatedOnly) {
$select->where($select->expr()->like('directory_uuid', $select->createNamedParameter('invalidated_%')));
}
$result = $select->executeQuery();
$entries = $result->fetchAllAssociative();
$result->closeCursor();
return $entries;
}
/**
* attempts to map the given entry
*
* @param string $fdn fully distinguished name (from LDAP)
* @param string $name
* @param string $uuid a unique identifier as used in LDAP
* @return bool
*/
public function map($fdn, $name, $uuid) {
if (mb_strlen($fdn) > 4000) {
Server::get(LoggerInterface::class)->error(
'Cannot map, because the DN exceeds 4000 characters: {dn}',
[
'app' => 'user_ldap',
'dn' => $fdn,
]
);
return false;
}
$row = [
'ldap_dn_hash' => $this->getDNHash($fdn),
'ldap_dn' => $fdn,
'owncloud_name' => $name,
'directory_uuid' => $uuid
];
try {
$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;
} catch (\Exception $e) {
return false;
}
}
/**
* removes a mapping based on the owncloud_name of the entry
*
* @param string $name
* @return bool
*/
public function unmap($name) {
$statement = $this->dbc->prepare('
DELETE FROM `' . $this->getTableName() . '`
WHERE `owncloud_name` = ?');
$dn = array_search($name, $this->cache);
if ($dn !== false) {
unset($this->cache[$dn]);
}
$this->localNameToDnCache?->remove($name);
return $this->modify($statement, [$name]);
}
/**
* Truncates the mapping table
*
* @return bool
*/
public function clear() {
$sql = $this->dbc
->getDatabasePlatform()
->getTruncateTableSQL('`' . $this->getTableName() . '`');
try {
$this->dbc->executeQuery($sql);
$this->localNameToDnCache?->clear();
return true;
} catch (Exception $e) {
return false;
}
}
/**
* clears the mapping table one by one and executing a callback with
* each row's id (=owncloud_name col)
*
* @param callable $preCallback
* @param callable $postCallback
* @return bool true on success, false when at least one row was not
* deleted
*/
public function clearCb(callable $preCallback, callable $postCallback): bool {
$picker = $this->dbc->getQueryBuilder();
$picker->select('owncloud_name')
->from($this->getTableName());
$cursor = $picker->executeQuery();
$result = true;
while (($id = $cursor->fetchOne()) !== false) {
$preCallback($id);
if ($isUnmapped = $this->unmap($id)) {
$postCallback($id);
}
$result = $result && $isUnmapped;
}
$cursor->closeCursor();
return $result;
}
/**
* returns the number of entries in the mappings table
*
* @return int
*/
public function count(): int {
$query = $this->dbc->getQueryBuilder();
$query->select($query->func()->count('ldap_dn_hash'))
->from($this->getTableName());
$res = $query->executeQuery();
$count = $res->fetchOne();
$res->closeCursor();
return (int)$count;
}
public function countInvalidated(): int {
$query = $this->dbc->getQueryBuilder();
$query->select($query->func()->count('ldap_dn_hash'))
->from($this->getTableName())
->where($query->expr()->like('directory_uuid', $query->createNamedParameter('invalidated_%')));
$res = $query->executeQuery();
$count = $res->fetchOne();
$res->closeCursor();
return (int)$count;
}
}