Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 38c8a484 authored by Akhil's avatar Akhil 🙂
Browse files

Merge branch 'dev/user-missin-common' into 'main'

Sync Missing Users to Common Database

See merge request !202
parents df4b42e6 49d7f97f
Loading
Loading
Loading
Loading
Loading
+28 −0
Original line number Diff line number Diff line
@@ -61,6 +61,34 @@ occ ecloud-accounts:fix-missing-emails --users=user1 --users=user2 --dry-run
4. Sets only the email address in NextCloud (quota remains unchanged)
5. Provides detailed output of the process and any errors encountered

## Sync Missing Users to Common Database

- This app provides a command to sync users that exist in NextCloud but are missing from the common database
- The command fetches user metadata from LDAP and adds missing users to the common database

### Usage

```bash
# Sync all missing users (dry run first)
occ ecloud-accounts:sync-missing-users-to-common --dry-run

# Sync all missing users
occ ecloud-accounts:sync-missing-users-to-common

# Sync specific users (multiple --users arguments)
occ ecloud-accounts:sync-missing-users-to-common --users=user1 --users=user2 --users=user3

# Sync specific users with dry run and custom IP
occ ecloud-accounts:sync-missing-users-to-common --users=user1 --users=user2 --dry-run --ip-address=192.168.1.100
```

### What it does

1. Queries NextCloud database to find all users
2. Checks which users are missing from the common database
3. For each missing user, fetches metadata from LDAP (displayName, recoveryMailAddress, etc.)
4. Adds the user to the common database with the fetched metadata

## Support

Please open issues here : https://gitlab.e.foundation/e/backlog/issues
+2 −1
Original line number Diff line number Diff line
@@ -10,7 +10,7 @@
    <description><![CDATA[in /e/OS cloud, nextcloud accounts are linked to mail accounts. This app ensures both are coordinated: it sets the e-mail address, quota and storage of the user upon creation.
    It also completes the account deletion by cleaning other parts of the /e/OS cloud setup to ensure no more data is retained when a user requests an account deletion.
    This app uses the UserDeletedEvent to invoke scripts in the docker-welcome container of /e/OS cloud setup]]></description>
    <version>10.0.6</version>
    <version>10.1.0</version>
    <licence>agpl</licence>
    <author mail="dev@murena.com" homepage="https://murena.com/">Murena SAS</author>
    <namespace>EcloudAccounts</namespace>
@@ -29,5 +29,6 @@
        <command>OCA\EcloudAccounts\Command\MigrateWebmailAddressbooks</command>
        <command>OCA\EcloudAccounts\Command\MapActiveAttributetoLDAP</command>
        <command>OCA\EcloudAccounts\Command\FixMissingEmails</command>
        <command>OCA\EcloudAccounts\Command\SyncMissingUsersToCommon</command>
    </commands>
</info>
+229 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace OCA\EcloudAccounts\Command;

use Exception;
use OCA\EcloudAccounts\AppInfo\Application;
use OCA\EcloudAccounts\Service\LDAPConnectionService;
use OCA\EcloudAccounts\Service\UserService;
use OCP\IUser;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class SyncMissingUsersToCommon extends Command {
	private LDAPConnectionService $ldapConnectionService;
	private UserService $userService;
	private IUserManager $userManager;

	public function __construct(
		LDAPConnectionService $ldapConnectionService,
		UserService $userService,
		IUserManager $userManager
	) {
		parent::__construct();
		$this->ldapConnectionService = $ldapConnectionService;
		$this->userService = $userService;
		$this->userManager = $userManager;
	}

	protected function configure(): void {
		$this
			->setName(Application::APP_ID . ':sync-missing-users-to-common')
			->setDescription('Syncs missing users from NextCloud to common database by fetching metadata from LDAP')
			->addOption(
				'users',
				null,
				InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
				'Users to sync (can be specified multiple times, e.g., --users=user1 --users=user2)',
				[]
			)
			->addOption(
				'dry-run',
				null,
				InputOption::VALUE_NONE,
				'Show what would be done without making changes'
			)
			->addOption(
				'ip-address',
				null,
				InputOption::VALUE_OPTIONAL,
				'IP address to use for all users (store blank if not set)',
				null
			);
	}

	protected function execute(InputInterface $input, OutputInterface $output): int {
		try {
			$isDryRun = $input->getOption('dry-run');
			$usernames = $input->getOption('users');
			$ipAddress = $this->normalizeIpAddress($input->getOption('ip-address'));

			if ($isDryRun) {
				$output->writeln('<info>DRY RUN MODE - No changes will be made</info>');
			}

			$stats = $this->processUsers($usernames, $ipAddress, $isDryRun, $output);
			$this->displayFinalResults($stats, $output);
			
			return 0;
		} catch (Exception $e) {
			$output->writeln(sprintf('<error>Error: %s</error>', $e->getMessage()));
			return 1;
		}
	}

	/**
	 * Normalize IP address input - convert null to empty string
	 */
	private function normalizeIpAddress(?string $ipAddress): string {
		return $ipAddress ?? '';
	}

	/**
	 * Process users based on whether specific users are provided or all users
	 */
	private function processUsers(array $usernames, string $ipAddress, bool $isDryRun, OutputInterface $output): array {
		$stats = [
			'processed' => 0,
			'success' => 0,
			'errors' => 0
		];

		if (!empty($usernames)) {
			$this->processSpecificUsers($usernames, $ipAddress, $isDryRun, $output, $stats);
		} else {
			$this->processAllUsers($ipAddress, $isDryRun, $output, $stats);
		}

		return $stats;
	}

	/**
	 * Process a list of specific users
	 */
	private function processSpecificUsers(array $usernames, string $ipAddress, bool $isDryRun, OutputInterface $output, array &$stats): void {
		$output->writeln(sprintf('<info>Processing %d specific users</info>', count($usernames)));
		
		foreach ($usernames as $username) {
			$this->processSingleUser($username, $ipAddress, $isDryRun, $output, $stats);
		}
	}

	/**
	 * Process all users from NextCloud
	 */
	private function processAllUsers(string $ipAddress, bool $isDryRun, OutputInterface $output, array &$stats): void {
		$allUsers = [];
		$this->userManager->callForAllUsers(function (IUser $user) use (&$allUsers) {
			$allUsers[] = $user;
		});
		$totalUsers = count($allUsers);
		$output->writeln(sprintf('<info>Processing all users from NextCloud (total: %d)</info>', $totalUsers));

		$lastProcessed = 0;
		foreach ($allUsers as $user) {
			$before = $stats['processed'];
			$this->processSingleUser($user->getUID(), $ipAddress, $isDryRun, $output, $stats);
			if ($stats['processed'] > 0 && $stats['processed'] % 100 === 0 && $stats['processed'] !== $lastProcessed) {
				$output->writeln(sprintf('<info>Progress: %d processed, %d success, %d errors</info>',
					$stats['processed'], $stats['success'], $stats['errors']));
				$lastProcessed = $stats['processed'];
			}
		}
	}

	/**
	 * Process a single user
	 */
	private function processSingleUser(string $username, string $ipAddress, bool $isDryRun, OutputInterface $output, array &$stats): void {
		$user = $this->userManager->get($username);
		if (!$user) {
			$output->writeln(sprintf('<error>User not found: %s</error>', $username));
			$stats['errors']++;
			return;
		}

		// Strip legacy domain from username before checking in common DB
		$usernameWithoutDomain = $this->userService->stripLegacyDomainFromUsername($username);
		if ($this->userService->isUsernameTaken($usernameWithoutDomain)) {
			return; // Skip if already exists
		}

		try {
			$this->syncUserToCommon($username, $ipAddress, $isDryRun, $output);
			$stats['success']++;
		} catch (Exception $e) {
			$output->writeln(sprintf('<error>Error syncing user %s: %s</error>', $username, $e->getMessage()));
			$stats['errors']++;
		}
		$stats['processed']++;
	}

	/**
	 * Display final results
	 */
	private function displayFinalResults(array $stats, OutputInterface $output): void {
		$output->writeln(sprintf('<info>Final result: %d users processed, %d success, %d errors.</info>',
			$stats['processed'], $stats['success'], $stats['errors']));
	}

	/**
	 * Sync a specific user to the common database
	 *
	 * @param string $username The username to sync
	 * @param string $ipAddress The IP address to use
	 * @param bool $isDryRun Whether this is a dry run
	 * @param OutputInterface $output The output interface for writing messages
	 * @return void
	 */
	private function syncUserToCommon(string $username, string $ipAddress, bool $isDryRun, OutputInterface $output): void {
		$output->writeln(sprintf('Processing user: %s', $username));

		// Check if user exists and is on LDAP backend
		$user = $this->userManager->get($username);
		if (!$user) {
			throw new Exception("User not found in NextCloud");
		}

		if (!$this->ldapConnectionService->isUserOnLDAPBackend($user)) {
			throw new Exception("User is not on LDAP backend");
		}

		// Get user metadata from LDAP
		$userMetadata = $this->ldapConnectionService->getUserMetadata($username);

		// Use usernameWithoutDomain from LDAP metadata
		$usernameWithoutDomain = $userMetadata['usernameWithoutDomain'];

		// Convert LDAP createTimestamp to MySQL datetime format
		$ldapTimestamp = $userMetadata['createTimestamp'] ?? null;
		$createdAt = $ldapTimestamp;

		if ($ldapTimestamp) {
			$dateTime = \DateTime::createFromFormat('YmdHis\Z', $ldapTimestamp, new \DateTimeZone('UTC'));
			if ($dateTime !== false) {
				$createdAt = $dateTime->format('Y-m-d H:i:s'); // Format compatible with MySQL
			}
		}

		$output->writeln(sprintf('  Found user in LDAP: %s (created: %s)', $usernameWithoutDomain, $createdAt));

		if (!$isDryRun) {
			// Add user to common database
			$this->userService->addUsernameToCommonDataStore(
				$usernameWithoutDomain,
				$ipAddress,
				$userMetadata['recoveryMailAddress'],
				$createdAt
			);
			$output->writeln(sprintf('  <info>✓ User synced successfully to common database: %s</info>', $usernameWithoutDomain));
		} else {
			$output->writeln(sprintf('  <comment>Would sync user to common database with recovery email: %s and created_at: %s</comment>', $userMetadata['recoveryMailAddress'], $createdAt));
		}
	}
}
+48 −0
Original line number Diff line number Diff line
@@ -161,4 +161,52 @@ class LDAPConnectionService {

		return $mailAddress;
	}

	/**
	 * Get comprehensive user metadata from LDAP
	 *
	 * @param string $username The username to search for
	 * @return array User metadata including displayName, recoveryMailAddress, etc.
	 * @throws Exception If LDAP is not enabled or user not found
	 */
	public function getUserMetadata(string $username): array {
		if (!$this->isLDAPEnabled()) {
			throw new Exception('LDAP backend is not enabled');
		}

		$conn = $this->getLDAPConnection();
		$userDn = $this->username2dn($username);

		if ($userDn === false) {
			throw new Exception('Could not find DN for username: ' . $username);
		}

		$attributes = [
			'createTimestamp',
			'usernameWithoutDomain',
			'recoveryMailAddress'
		];

		$result = ldap_read($conn, $userDn, '(objectClass=*)', $attributes);
		
		if (!$result) {
			$this->closeLDAPConnection($conn);
			throw new Exception('Could not read user entry from LDAP for username: ' . $username);
		}

		$entries = ldap_get_entries($conn, $result);
		$this->closeLDAPConnection($conn);

		if ($entries['count'] === 0) {
			throw new Exception('User not found in LDAP: ' . $username);
		}

		$entry = $entries[0];
		
		return [
			'createTimestamp' => $entry['createtimestamp'][0] ?? '',
			'usernameWithoutDomain' => $entry['usernamewithoutdomain'][0],
			'recoveryMailAddress' => $entry['recoverymailaddress'][0] ?? ''
		];
	}
}
+14 −1
Original line number Diff line number Diff line
@@ -439,10 +439,11 @@ class UserService {
	 * @param string $username The username to add to the common data store.
	 * @param string $ipAddress IP Address of user
	 * @param string $recoveryEmail A recovery Email of user
	 * @param string|null $createdAt The creation timestamp (optional, defaults to current time)
	 *
	 * @throws AddUsernameToCommonStoreException If an error occurs while adding the username to the common data store.
	 */
	public function addUsernameToCommonDataStore(string $username, string $ipAddress, string $recoveryEmail) : void {
	public function addUsernameToCommonDataStore(string $username, string $ipAddress, string $recoveryEmail, ?string $createdAt = null) : void {
		$commonServicesURL = $this->apiConfig['commonServicesURL'];
		$commonApiVersion = $this->apiConfig['commonApiVersion'];

@@ -458,6 +459,11 @@ class UserService {
			'recoveryEmail' => $recoveryEmail
		];

		// Add createdAt parameter if provided, otherwise let the API use current time
		if ($createdAt !== null) {
			$params['created_at'] = $createdAt;
		}

		$token = $this->apiConfig['commonServicesToken'];
		$headers = [
			"Authorization: Bearer $token"
@@ -484,4 +490,11 @@ class UserService {
	private function getDefaultQuota() {
		return $this->config->getSystemValueInt('default_quota_in_megabytes', 1024);
	}
	/**
	 * Remove legacy domain from username if present
	 */
	public function stripLegacyDomainFromUsername(string $username): string {
		$legacyDomain = $this->config->getSystemValue('legacy_domain', '');
		return str_ireplace('@' . $legacyDomain, '', $username);
	}
}