From 8fb14b5ffa3450b2c2201ff9bb42c589e4a4afb7 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Wed, 16 Jun 2021 14:39:03 +0200 Subject: [PATCH] Add provisioning for aliases Signed-off-by: Daniel Kesselberg --- lib/Controller/AliasesController.php | 9 +- lib/Controller/SettingsController.php | 9 +- lib/Db/Alias.php | 12 +- lib/Db/AliasMapper.php | 45 ++++- lib/Db/Provisioning.php | 14 ++ lib/Db/ProvisioningMapper.php | 23 ++- .../Version1101Date20210616141806.php | 34 ++++ lib/Service/AliasesService.php | 44 +++-- lib/Service/Provisioning/Manager.php | 167 ++++++++++++++---- lib/Settings/AdminSettings.php | 9 + src/components/AccountSettings.vue | 21 ++- src/components/AliasForm.vue | 158 +++++++++++++++++ src/components/AliasSettings.vue | 123 ++++++------- .../settings/ProvisioningSettings.vue | 41 +++++ src/service/AliasService.js | 63 +++++-- src/store/actions.js | 35 +++- src/store/mutations.js | 13 +- .../Unit/Controller/AliasesControllerTest.php | 77 +++++++- tests/Unit/Service/AliasesServiceTest.php | 117 +++++++----- 19 files changed, 806 insertions(+), 208 deletions(-) create mode 100644 lib/Migration/Version1101Date20210616141806.php create mode 100644 src/components/AliasForm.vue diff --git a/lib/Controller/AliasesController.php b/lib/Controller/AliasesController.php index 8c1b49ddcf..add3efb5f5 100644 --- a/lib/Controller/AliasesController.php +++ b/lib/Controller/AliasesController.php @@ -72,8 +72,8 @@ public function show() { * @NoAdminRequired * @TrapError */ - public function update() { - throw new NotImplemented(); + public function update(int $id, string $alias, string $aliasName): JSONResponse { + return new JSONResponse($this->aliasService->update($this->currentUserId, $id, $alias, $aliasName)); } /** @@ -84,7 +84,7 @@ public function update() { * @return JSONResponse */ public function destroy(int $id): JSONResponse { - return new JSONResponse($this->aliasService->delete($id, $this->currentUserId)); + return new JSONResponse($this->aliasService->delete($this->currentUserId, $id)); } /** @@ -116,7 +116,6 @@ public function create(int $accountId, string $alias, string $aliasName): JSONRe * @throws DoesNotExistException */ public function updateSignature(int $id, string $signature = null): JSONResponse { - $this->aliasService->updateSignature($this->currentUserId, $id, $signature); - return new JSONResponse(); + return new JSONResponse($this->aliasService->updateSignature($this->currentUserId, $id, $signature)); } } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 2081ea5bb3..dd1a19dc4f 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -60,6 +60,8 @@ public function createProvisioning(array $data): JSONResponse { $this->provisioningManager->newProvisioning($data); } catch (ValidationException $e) { return HttpJsonResponse::fail([$e->getFields()]); + } catch (\Exception $e) { + return HttpJsonResponse::fail([$e->getMessage()]); } return new JSONResponse([]); @@ -67,12 +69,11 @@ public function createProvisioning(array $data): JSONResponse { public function updateProvisioning(int $id, array $data): JSONResponse { try { - $this->provisioningManager->updateProvisioning(array_merge( - $data, - ['id' => $id] - )); + $this->provisioningManager->updateProvisioning(array_merge($data, ['id' => $id])); } catch (ValidationException $e) { return HttpJsonResponse::fail([$e->getFields()]); + } catch (\Exception $e) { + return HttpJsonResponse::fail([$e->getMessage()]); } return new JSONResponse([]); diff --git a/lib/Db/Alias.php b/lib/Db/Alias.php index e1727a83c2..8126826785 100644 --- a/lib/Db/Alias.php +++ b/lib/Db/Alias.php @@ -24,7 +24,6 @@ namespace OCA\Mail\Db; use JsonSerializable; - use OCP\AppFramework\Db\Entity; /** @@ -36,6 +35,8 @@ * @method string getAlias() * @method void setSignature(string|null $signature) * @method string|null getSignature() + * @method void setProvisioningId(int $provisioningId) + * @method int|null getProvisioningId() */ class Alias extends Entity implements JsonSerializable { @@ -51,10 +52,18 @@ class Alias extends Entity implements JsonSerializable { /** @var string|null */ protected $signature; + /** @var int|null */ + protected $provisioningId; + public function __construct() { $this->addType('accountId', 'int'); $this->addType('name', 'string'); $this->addType('alias', 'string'); + $this->addType('provisioningId', 'int'); + } + + public function isProvisioned(): bool { + return $this->getProvisioningId() !== null; } public function jsonSerialize(): array { @@ -63,6 +72,7 @@ public function jsonSerialize(): array { 'name' => $this->getName(), 'alias' => $this->getAlias(), 'signature' => $this->getSignature(), + 'provisioned' => $this->isProvisioned(), ]; } } diff --git a/lib/Db/AliasMapper.php b/lib/Db/AliasMapper.php index bf2e17b5df..ad95fc5b6e 100644 --- a/lib/Db/AliasMapper.php +++ b/lib/Db/AliasMapper.php @@ -41,7 +41,7 @@ public function __construct(IDBConnection $db) { */ public function find(int $aliasId, string $currentUserId): Alias { $qb = $this->db->getQueryBuilder(); - $qb->select('aliases.*') + $qb->select('aliases.*', 'accounts.provisioning_id') ->from($this->getTableName(), 'aliases') ->join('aliases', 'mail_accounts', 'accounts', $qb->expr()->eq('aliases.account_id', 'accounts.id')) ->where( @@ -54,6 +54,24 @@ public function find(int $aliasId, string $currentUserId): Alias { return $this->findEntity($qb); } + /** + * @throws DoesNotExistException + */ + public function findByAlias(string $alias, string $currentUserId): Alias { + $qb = $this->db->getQueryBuilder(); + $qb->select('aliases.*', 'accounts.provisioning_id') + ->from($this->getTableName(), 'aliases') + ->join('aliases', 'mail_accounts', 'accounts', $qb->expr()->eq('aliases.account_id', 'accounts.id')) + ->where( + $qb->expr()->andX( + $qb->expr()->eq('accounts.user_id', $qb->createNamedParameter($currentUserId)), + $qb->expr()->eq('aliases.alias', $qb->createNamedParameter($alias)) + ) + ); + + return $this->findEntity($qb); + } + /** * @param int $accountId * @param string $currentUserId @@ -62,7 +80,7 @@ public function find(int $aliasId, string $currentUserId): Alias { */ public function findAll(int $accountId, string $currentUserId): array { $qb = $this->db->getQueryBuilder(); - $qb->select('aliases.*') + $qb->select('aliases.*', 'accounts.provisioning_id') ->from($this->getTableName(), 'aliases') ->join('aliases', 'mail_accounts', 'accounts', $qb->expr()->eq('aliases.account_id', 'accounts.id')) ->where( @@ -89,6 +107,29 @@ public function deleteAll($accountId) { $query->execute(); } + /** + * Delete all provisioned aliases for the given uid + * + * Exception for Nextcloud 20: \Doctrine\DBAL\DBALException + * Exception for Nextcloud 21 and newer: \OCP\DB\Exception + * + * @TODO: Change throws to \OCP\DB\Exception once Mail does not support Nextcloud 20. + * + * @throws \Exception + */ + public function deleteProvisionedAliasesByUid(string $uid): void { + $qb = $this->db->getQueryBuilder(); + + $qb->delete($this->getTableName(), 'aliases') + ->join('aliases', 'mail_accounts', 'accounts', 'accounts.id = aliases.account_id') + ->where( + $qb->expr()->eq('accounts.user_id', $qb->createNamedParameter($uid)), + $qb->expr()->isNotNull('provisioning_id') + ); + + $qb->execute(); + } + public function deleteOrphans(): void { $qb1 = $this->db->getQueryBuilder(); $idsQuery = $qb1->select('a.id') diff --git a/lib/Db/Provisioning.php b/lib/Db/Provisioning.php index 97d3c7a81d..b29afa91c1 100644 --- a/lib/Db/Provisioning.php +++ b/lib/Db/Provisioning.php @@ -60,6 +60,12 @@ * @method void setSieveSslMode(?string $sieveSslMode) * @method string|null getSieveUser() * @method void setSieveUser(?string $sieveUser) + * @method array getAliases() + * @method void setAliases(array $aliases) + * @method bool getLdapAliasesProvisioning() + * @method void setLdapAliasesProvisioning(bool $ldapAliasesProvisioning) + * @method string|null getLdapAliasesAttribute() + * @method void setLdapAliasesAttribute(?string $ldapAliasesAttribute) */ class Provisioning extends Entity implements JsonSerializable { public const WILDCARD = '*'; @@ -79,13 +85,18 @@ class Provisioning extends Entity implements JsonSerializable { protected $sieveHost; protected $sievePort; protected $sieveSslMode; + protected $aliases = []; + protected $ldapAliasesProvisioning; + protected $ldapAliasesAttribute; public function __construct() { $this->addType('imapPort', 'integer'); $this->addType('smtpPort', 'integer'); $this->addType('sieveEnabled', 'boolean'); $this->addType('sievePort', 'integer'); + $this->addType('ldapAliasesProvisioning', 'boolean'); } + /** * @return array */ @@ -107,6 +118,9 @@ public function jsonSerialize() { 'sieveHost' => $this->getSieveHost(), 'sievePort' => $this->getSievePort(), 'sieveSslMode' => $this->getSieveSslMode(), + 'aliases' => $this->getAliases(), + 'ldapAliasesProvisioning' => $this->getLdapAliasesProvisioning(), + 'ldapAliasesAttribute' => $this->getLdapAliasesAttribute(), ]; } diff --git a/lib/Db/ProvisioningMapper.php b/lib/Db/ProvisioningMapper.php index 09eec6281d..0daca261f2 100644 --- a/lib/Db/ProvisioningMapper.php +++ b/lib/Db/ProvisioningMapper.php @@ -51,11 +51,11 @@ public function __construct(IDBConnection $db, LoggerInterface $logger) { * * @return Provisioning[] */ - public function getAll() : array { + public function getAll(): array { $qb = $this->db->getQueryBuilder(); $qb = $qb->select('*') - ->from($this->getTableName()) - ->orderBy('provisioning_domain', 'desc'); + ->from($this->getTableName()) + ->orderBy('provisioning_domain', 'desc'); try { return $this->findEntities($qb); } catch (DoesNotExistException $e) { @@ -69,7 +69,7 @@ public function getAll() : array { * @return Provisioning * @throws ValidationException */ - public function validate(array $data) : Provisioning { + public function validate(array $data): Provisioning { $exception = new ValidationException(); if (!isset($data['provisioningDomain']) || $data['provisioningDomain'] === '') { @@ -103,11 +103,17 @@ public function validate(array $data) : Provisioning { $exception->setField('smtpSslMode', false); } + $ldapAliasesProvisioning = (bool)($data['ldapAliasesProvisioning'] ?? false); + $ldapAliasesAttribute = $data['ldapAliasesAttribute'] ?? ''; + + if ($ldapAliasesProvisioning && empty($ldapAliasesAttribute)) { + $exception->setField('ldapAliasesAttribute', false); + } + if (!empty($exception->getFields())) { throw $exception; } - $provisioning = new Provisioning(); $provisioning->setId($data['id'] ?? null); $provisioning->setProvisioningDomain($data['provisioningDomain']); @@ -127,14 +133,17 @@ public function validate(array $data) : Provisioning { $provisioning->setSievePort($data['sievePort'] ?? null); $provisioning->setSieveSslMode($data['sieveSslMode'] ?? ''); + $provisioning->setLdapAliasesProvisioning($ldapAliasesProvisioning); + $provisioning->setLdapAliasesAttribute($ldapAliasesAttribute); + return $provisioning; } public function get(int $id): ?Provisioning { $qb = $this->db->getQueryBuilder(); $qb = $qb->select('*') - ->from($this->getTableName()) - ->where($qb->expr()->eq('id', $qb->createNamedParameter($id), IQueryBuilder::PARAM_INT)); + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id), IQueryBuilder::PARAM_INT)); try { return $this->findEntity($qb); } catch (DoesNotExistException $e) { diff --git a/lib/Migration/Version1101Date20210616141806.php b/lib/Migration/Version1101Date20210616141806.php new file mode 100644 index 0000000000..0687b90919 --- /dev/null +++ b/lib/Migration/Version1101Date20210616141806.php @@ -0,0 +1,34 @@ +getTable('mail_provisionings'); + $provisioningTable->addColumn('ldap_aliases_provisioning', 'boolean', [ + 'notnull' => false, + 'default' => false + ]); + $provisioningTable->addColumn('ldap_aliases_attribute', 'string', [ + 'notnull' => false, + 'length' => 255, + 'default' => '', + ]); + + return $schema; + } +} diff --git a/lib/Service/AliasesService.php b/lib/Service/AliasesService.php index db21e13888..7d308b97cd 100644 --- a/lib/Service/AliasesService.php +++ b/lib/Service/AliasesService.php @@ -26,6 +26,7 @@ use OCA\Mail\Db\Alias; use OCA\Mail\Db\AliasMapper; use OCA\Mail\Db\MailAccountMapper; +use OCA\Mail\Exception\ClientException; use OCP\AppFramework\Db\DoesNotExistException; class AliasesService { @@ -81,15 +82,17 @@ public function create(string $userId, int $accountId, string $alias, string $al } /** - * @param int $aliasId - * @param String $currentUserId - * @return Alias + * @throws ClientException * @throws DoesNotExistException */ - public function delete(int $aliasId, string $currentUserId): Alias { - $alias = $this->aliasMapper->find($aliasId, $currentUserId); - $this->aliasMapper->delete($alias); - return $alias; + public function delete(string $userId, int $aliasId): Alias { + $entity = $this->aliasMapper->find($aliasId, $userId); + + if ($entity->isProvisioned()) { + throw new ClientException('Deleting a provisioned alias is not allowed.'); + } + + return $this->aliasMapper->delete($entity); } /** @@ -104,17 +107,30 @@ public function deleteAll($accountId): void { $this->aliasMapper->deleteAll($accountId); } + /** + * Update alias and name + * + * @throws DoesNotExistException + */ + public function update(string $userId, int $aliasId, string $alias, string $aliasName): Alias { + $entity = $this->aliasMapper->find($aliasId, $userId); + + if (!$entity->isProvisioned()) { + $entity->setAlias($alias); + } + $entity->setName($aliasName); + + return $this->aliasMapper->update($entity); + } + /** * Update signature for alias * - * @param string $userId - * @param int $aliasId - * @param string|null $signature * @throws DoesNotExistException */ - public function updateSignature(string $userId, int $aliasId, string $signature = null): void { - $alias = $this->find($aliasId, $userId); - $alias->setSignature($signature); - $this->aliasMapper->update($alias); + public function updateSignature(string $userId, int $aliasId, string $signature = null): Alias { + $entity = $this->find($aliasId, $userId); + $entity->setSignature($signature); + return $this->aliasMapper->update($entity); } } diff --git a/lib/Service/Provisioning/Manager.php b/lib/Service/Provisioning/Manager.php index 8cc9c2e060..6f06c330e8 100644 --- a/lib/Service/Provisioning/Manager.php +++ b/lib/Service/Provisioning/Manager.php @@ -24,6 +24,8 @@ namespace OCA\Mail\Service\Provisioning; use Horde_Mail_Rfc822_Address; +use OCA\Mail\Db\Alias; +use OCA\Mail\Db\AliasMapper; use OCA\Mail\Db\MailAccount; use OCA\Mail\Db\MailAccountMapper; use OCA\Mail\Db\Provisioning; @@ -33,6 +35,8 @@ use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\IUser; use OCP\IUserManager; +use OCP\LDAP\ILDAPProvider; +use OCP\LDAP\ILDAPProviderFactory; use OCP\Security\ICrypto; use Psr\Log\LoggerInterface; @@ -50,6 +54,12 @@ class Manager { /** @var ICrypto */ private $crypto; + /** @var ILDAPProviderFactory */ + private $ldapProviderFactory; + + /** @var AliasMapper */ + private $aliasMapper; + /** @var LoggerInterface */ private $logger; @@ -57,11 +67,15 @@ public function __construct(IUserManager $userManager, ProvisioningMapper $provisioningMapper, MailAccountMapper $mailAccountMapper, ICrypto $crypto, + ILDAPProviderFactory $ldapProviderFactory, + AliasMapper $aliasMapper, LoggerInterface $logger) { $this->userManager = $userManager; $this->provisioningMapper = $provisioningMapper; $this->mailAccountMapper = $mailAccountMapper; $this->crypto = $crypto; + $this->ldapProviderFactory = $ldapProviderFactory; + $this->aliasMapper = $aliasMapper; $this->logger = $logger; } @@ -84,6 +98,75 @@ public function provision(): int { return $cnt; } + /** + * Delete orphaned aliases for the given account. + * + * A alias is orphaned if not listed in newAliases anymore + * (=> the provisioning configuration does contain it anymore) + * + * Exception for Nextcloud 20: \Doctrine\DBAL\DBALException + * Exception for Nextcloud 21 and newer: \OCP\DB\Exception + * + * @TODO: Change throws to \OCP\DB\Exception once Mail requires Nextcloud 21 or above + * + * @throws \Exception + */ + private function deleteOrphanedAliases(string $userId, int $accountId, array $newAliases): void { + $existingAliases = $this->aliasMapper->findAll($accountId, $userId); + foreach ($existingAliases as $existingAlias) { + if (!in_array($existingAlias->getAlias(), $newAliases, true)) { + $this->aliasMapper->delete($existingAlias); + } + } + } + + /** + * Create new aliases for the given account. + * + * Exception for Nextcloud 20: \Doctrine\DBAL\DBALException + * Exception for Nextcloud 21 and newer: \OCP\DB\Exception + * + * @TODO: Change throws to \OCP\DB\Exception once Mail requires Nextcloud 21 or above + * + * @throws \Exception + */ + private function createNewAliases(string $userId, int $accountId, array $newAliases, string $displayName): void { + foreach ($newAliases as $newAlias) { + try { + $this->aliasMapper->findByAlias($newAlias, $userId); + } catch (DoesNotExistException $e) { + $alias = new Alias(); + $alias->setAccountId($accountId); + $alias->setName($displayName); + $alias->setAlias($newAlias); + $this->aliasMapper->insert($alias); + } + } + } + + /** + * @throws \Exception if user id was not found in LDAP + * + * @TODO: Remove psalm-suppress once Mail requires Nextcloud 22 or above + */ + public function ldapAliasesIntegration(Provisioning $provisioning, IUser $user): Provisioning { + if ($user->getBackendClassName() !== 'LDAP' || $provisioning->getLdapAliasesProvisioning() === false || empty($provisioning->getLdapAliasesAttribute())) { + return $provisioning; + } + + /** @psalm-suppress UndefinedInterfaceMethod */ + if ($this->ldapProviderFactory->isAvailable() === false) { + $this->logger->debug('Request to provision mail aliases but ldap not available'); + return $provisioning; + } + + $ldapProvider = $this->ldapProviderFactory->getLDAPProvider(); + /** @psalm-suppress UndefinedInterfaceMethod */ + $provisioning->setAliases($ldapProvider->getMultiValueUserAttribute($user->getUID(), $provisioning->getLdapAliasesAttribute())); + + return $provisioning; + } + /** * @param Provisioning[] $provisionings */ @@ -96,57 +179,69 @@ public function provisionSingleUser(array $provisionings, IUser $user): bool { try { // TODO: match by UID only, catch multiple objects returned below and delete all those accounts - $existing = $this->mailAccountMapper->findProvisionedAccount($user); + $mailAccount = $this->mailAccountMapper->findProvisionedAccount($user); - $this->mailAccountMapper->update( - $this->updateAccount($user, $existing, $provisioning) + $mailAccount = $this->mailAccountMapper->update( + $this->updateAccount($user, $mailAccount, $provisioning) ); - return true; - } catch (DoesNotExistException $e) { + } catch (DoesNotExistException | MultipleObjectsReturnedException $e) { + if ($e instanceof MultipleObjectsReturnedException) { + // This is unlikely to happen but not impossible. + // Let's wipe any existing accounts and start fresh + $this->aliasMapper->deleteProvisionedAliasesByUid($user->getUID()); + $this->mailAccountMapper->deleteProvisionedAccountsByUid($user->getUID()); + } + // Fine, then we create a new one - $new = new MailAccount(); - $new->setUserId($user->getUID()); + $mailAccount = new MailAccount(); + $mailAccount->setUserId($user->getUID()); - $this->mailAccountMapper->insert( - $this->updateAccount($user, $new, $provisioning) + $mailAccount = $this->mailAccountMapper->insert( + $this->updateAccount($user, $mailAccount, $provisioning) ); - return true; - } catch (MultipleObjectsReturnedException $e) { - // This is unlikely to happen but not impossible. - // Let's wipe any existing accounts and start fresh - $this->mailAccountMapper->deleteProvisionedAccountsByUid($user->getUID()); + } - $new = new MailAccount(); - $new->setUserId($user->getUID()); + // @TODO: Remove method_exists once Mail requires Nextcloud 22 or above + if (method_exists(ILDAPProvider::class, 'getMultiValueUserAttribute')) { + try { + $provisioning = $this->ldapAliasesIntegration($provisioning, $user); + } catch (\Throwable $e) { + $this->logger->warning('Request to provision mail aliases failed', ['exception' => $e]); + // return here to avoid provisioning of aliases. + return true; + } - $this->mailAccountMapper->insert( - $this->updateAccount($user, $new, $provisioning) - ); - return true; + try { + $this->deleteOrphanedAliases($user->getUID(), $mailAccount->getId(), $provisioning->getAliases()); + } catch (\Throwable $e) { + $this->logger->warning('Deleting orphaned aliases failed', ['exception' => $e]); + } + + try { + $this->createNewAliases($user->getUID(), $mailAccount->getId(), $provisioning->getAliases(), $user->getDisplayName()); + } catch (\Throwable $e) { + $this->logger->warning('Creating new aliases failed', ['exception' => $e]); + } } - return false; + + return true; } + /** + * @throws ValidationException + * @throws \Exception + */ public function newProvisioning(array $data): void { - try { - $provisioning = $this->provisioningMapper->validate( - $data - ); - } catch (ValidationException $e) { - throw $e; - } + $provisioning = $this->provisioningMapper->validate($data); $this->provisioningMapper->insert($provisioning); } + /** + * @throws ValidationException + * @throws \Exception + */ public function updateProvisioning(array $data): void { - try { - $provisioning = $this->provisioningMapper->validate( - $data - ); - } catch (ValidationException $e) { - throw $e; - } - + $provisioning = $this->provisioningMapper->validate($data); $this->provisioningMapper->update($provisioning); } diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index 2a8d0fd4e5..428201244d 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -29,6 +29,7 @@ use OCA\Mail\Service\Provisioning\Manager as ProvisioningManager; use OCP\AppFramework\Http\TemplateResponse; use OCP\IInitialStateService; +use OCP\LDAP\ILDAPProvider; use OCP\Settings\ISettings; class AdminSettings implements ISettings { @@ -52,6 +53,14 @@ public function getForm() { $this->provisioningManager->getConfigs() ); + $this->initialStateService->provideLazyInitialState( + Application::APP_ID, + 'ldap_aliases_integration', + function () { + return method_exists(ILDAPProvider::class, 'getMultiValueUserAttribute'); + } + ); + return new TemplateResponse(Application::APP_ID, 'settings-admin'); } diff --git a/src/components/AccountSettings.vue b/src/components/AccountSettings.vue index c6503a03e5..0af5beb708 100644 --- a/src/components/AccountSettings.vue +++ b/src/components/AccountSettings.vue @@ -28,13 +28,15 @@ :show-navigation="true"> - {{ displayName }} <{{ email }}> - - + +

@@ -178,6 +180,11 @@ export default { diff --git a/src/components/AliasSettings.vue b/src/components/AliasSettings.vue index c7fd79025e..7f579d0108 100644 --- a/src/components/AliasSettings.vue +++ b/src/components/AliasSettings.vue @@ -20,44 +20,40 @@