diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -29,6 +29,31 @@ */ protected $dangerous = false; + + /** + * Shortcut to creating a progress bar of a particular format with a particular message. + * + * @param int $count Number of progress steps + * @param string $message The description + * + * @return \Symfony\Component\Console\Helper\ProgressBar + */ + protected function createProgressBar($count, $message = null) + { + $bar = $this->output->createProgressBar($count); + $bar->setFormat( + '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' + ); + + if ($message) { + $bar->setMessage("{$message}..."); + } + + $bar->start(); + + return $bar; + } + /** * Find the domain. * diff --git a/src/app/Console/Commands/Data/Import/IP4NetsCommand.php b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php --- a/src/app/Console/Commands/Data/Import/IP4NetsCommand.php +++ b/src/app/Console/Commands/Data/Import/IP4NetsCommand.php @@ -57,11 +57,7 @@ continue; } - $bar = \App\Utils::createProgressBar( - $this->output, - $numLines, - "Importing IPv4 Networks from {$file}" - ); + $bar = $this->createProgressBar($numLines, "Importing IPv4 Networks from {$file}"); $fp = fopen($file, 'r'); diff --git a/src/app/Console/Commands/Data/Import/IP6NetsCommand.php b/src/app/Console/Commands/Data/Import/IP6NetsCommand.php --- a/src/app/Console/Commands/Data/Import/IP6NetsCommand.php +++ b/src/app/Console/Commands/Data/Import/IP6NetsCommand.php @@ -57,11 +57,7 @@ continue; } - $bar = \App\Utils::createProgressBar( - $this->output, - $numLines, - "Importing IPv6 Networks from {$file}" - ); + $bar = $this->createProgressBar($numLines, "Importing IPv6 Networks from {$file}"); $fp = fopen($file, 'r'); diff --git a/src/app/Console/Commands/Data/Import/LdifCommand.php b/src/app/Console/Commands/Data/Import/LdifCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Data/Import/LdifCommand.php @@ -0,0 +1,1000 @@ +bigIncrements('id'); + $table->text('dn')->index(); + $table->string('type')->nullable()->index(); + $table->text('data')->nullable(); + $table->text('error')->nullable(); + $table->text('warning')->nullable(); + } + ); + + // Import data from the file to the temp table + $this->loadFromFile(); + + // Check for errors in the data, print them and abort (if not using --force) + if ($this->printErrors()) { + return 1; + } + + // Prepare packages/skus information + $this->preparePackagesAndSkus(); + + // Import the account owner first + $this->importOwner(); + + // Import domains first + $this->importDomains(); + + // Import other objects + $this->importUsers(); + $this->importSharedFolders(); + $this->importResources(); + $this->importGroups(); + + // Print warnings collected in the whole process + $this->printWarnings(); + + // Finally, drop the temp table + Schema::dropIfExists(self::$table); + } + + /** + * Check if a domain exists + */ + protected function domainExists($domain): bool + { + return in_array($domain, $this->domains); + } + + /** + * Load data from the LDIF file into the temp table + */ + protected function loadFromFile(): void + { + $file = $this->argument('file'); + + $numLines = \App\Utils::countLines($file); + + $bar = $this->createProgressBar($numLines, "Parsing input file"); + + $fh = fopen($file, 'r'); + + $inserts = []; + $entry = []; + $lastAttr = null; + + $insertFunc = function ($limit = 0) use (&$entry, &$inserts) { + if (!empty($entry)) { + if ($entry = $this->parseLDAPEntry($entry)) { + $inserts[] = $entry; + } + $entry = []; + } + + if (count($inserts) > $limit) { + DB::table(self::$table)->insert($inserts); + $inserts = []; + } + }; + + while (!feof($fh)) { + $line = rtrim(fgets($fh)); + + $bar->advance(); + + if (trim($line) === '' || $line[0] === '#') { + continue; + } + + if (substr($line, 0, 3) == 'dn:') { + $insertFunc(20); + $entry['dn'] = strtolower(substr($line, 4)); + $lastAttr = 'dn'; + } elseif (substr($line, 0, 1) == ' ') { + if (is_array($entry[$lastAttr])) { + $elemNum = count($entry[$lastAttr]) - 1; + $entry[$lastAttr][$elemNum] .= ltrim($line); + } else { + $entry[$lastAttr] .= ltrim($line); + } + } else { + list ($attr, $remainder) = explode(':', $line, 2); + $attr = strtolower($attr); + + if ($remainder[0] === ':') { + $remainder = base64_decode(substr($remainder, 2)); + } else { + $remainder = ltrim($remainder); + } + + if (array_key_exists($attr, $entry)) { + if (!is_array($entry[$attr])) { + $entry[$attr] = [$entry[$attr]]; + } + + $entry[$attr][] = $remainder; + } else { + $entry[$attr] = $remainder; + } + + $lastAttr = $attr; + } + } + + $insertFunc(); + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import domains from the temp table + */ + protected function importDomains(): void + { + $domains = DB::table(self::$table)->where('type', 'domain')->whereNull('error')->get(); + + $bar = $this->createProgressBar(count($domains), "Importing domains"); + + foreach ($domains as $_domain) { + $bar->advance(); + + $data = json_decode($_domain->data); + + $domain = \App\Domain::withTrashed()->where('namespace', $data->namespace)->first(); + + if ($domain) { + $this->setImportWarning($_domain->id, "Domain already exists"); + continue; + } + + $domain = \App\Domain::create([ + 'namespace' => $data->namespace, + 'type' => \App\Domain::TYPE_EXTERNAL, + ]); + + // Entitlements + $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet); + + $this->domains[] = $domain->namespace; + + if (!empty($data->aliases)) { + foreach ($data->aliases as $alias) { + $alias = strtolower($alias); + $domain = \App\Domain::withTrashed()->where('namespace', $alias)->first(); + + if ($domain) { + $this->setImportWarning($_domain->id, "Domain already exists"); + continue; + } + + $domain = \App\Domain::create([ + 'namespace' => $alias, + 'type' => \App\Domain::TYPE_EXTERNAL, + ]); + + // Entitlements + $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet); + + $this->domains[] = $domain->namespace; + } + } + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import groups from the temp table + */ + protected function importGroups(): void + { + $groups = DB::table(self::$table)->where('type', 'group')->whereNull('error')->get(); + + $bar = $this->createProgressBar(count($groups), "Importing groups"); + + foreach ($groups as $_group) { + $bar->advance(); + + $data = json_decode($_group->data); + + // Collect group member email addresses + $members = $this->resolveUserDNs($data->members); + + if (empty($members)) { + $this->setImportWarning($_group->id, "Members resolve to an empty array"); + continue; + } + + $group = \App\Group::withTrashed()->where('email', $data->email)->first(); + + if ($group) { + $this->setImportWarning($_group->id, "Group already exists"); + continue; + } + + // Make sure the domain exists + if (!$this->domainExists($data->domain)) { + $this->setImportWarning($_group->id, "Domain not found"); + continue; + } + + $group = \App\Group::create([ + 'name' => $data->name, + 'email' => $data->email, + 'members' => $members, + ]); + + $group->assignToWallet($this->wallet); + + // Sender policy + if (!empty($data->sender_policy)) { + $group->setSetting('sender_policy', json_encode($data->sender_policy)); + } + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import resources from the temp table + */ + protected function importResources(): void + { + $resources = DB::table(self::$table)->where('type', 'resource')->whereNull('error')->get(); + + $bar = $this->createProgressBar(count($resources), "Importing resources"); + + foreach ($resources as $_resource) { + $bar->advance(); + + $data = json_decode($_resource->data); + + $resource = \App\Resource::withTrashed() + ->where('name', $data->name) + ->where('email', 'like', '%@' . $data->domain) + ->first(); + + if ($resource) { + $this->setImportWarning($_resource->id, "Resource already exists"); + continue; + } + + // Resource invitation policy + if (!empty($data->invitation_policy) && $data->invitation_policy == 'manual') { + $members = empty($data->owner) ? [] : $this->resolveUserDNs([$data->owner]); + + if (empty($members)) { + $this->setImportWarning($_resource->id, "Failed to resolve the resource owner"); + $data->invitation_policy = null; + } else { + $data->invitation_policy = 'manual:' . $members[0]; + } + } + + // Make sure the domain exists + if (!$this->domainExists($data->domain)) { + $this->setImportWarning($_resource->id, "Domain not found"); + continue; + } + + $resource = new \App\Resource(); + $resource->name = $data->name; + $resource->domain = $data->domain; + $resource->save(); + + $resource->assignToWallet($this->wallet); + + // Invitation policy + if (!empty($data->invitation_policy)) { + $resource->setSetting('invitation_policy', $data->invitation_policy); + } + + // Target folder + if (!empty($data->folder)) { + $resource->setSetting('folder', $data->folder); + } + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import shared folders from the temp table + */ + protected function importSharedFolders(): void + { + $folders = DB::table(self::$table)->where('type', 'sharedFolder')->whereNull('error')->get(); + + $bar = $this->createProgressBar(count($folders), "Importing shared folders"); + + foreach ($folders as $_folder) { + $bar->advance(); + + $data = json_decode($_folder->data); + + $folder = \App\SharedFolder::withTrashed() + ->where('name', $data->name) + ->where('email', 'like', '%@' . $data->domain) + ->first(); + + if ($folder) { + $this->setImportWarning($_folder->id, "Folder already exists"); + continue; + } + + // Make sure the domain exists + if (!$this->domainExists($data->domain)) { + $this->setImportWarning($_folder->id, "Domain not found"); + continue; + } + + $folder = new \App\SharedFolder(); + $folder->name = $data->name; + $folder->type = $data->type ?? 'mail'; + $folder->domain = $data->domain; + $folder->save(); + + $folder->assignToWallet($this->wallet); + + // Invitation policy + if (!empty($data->acl)) { + $folder->setSetting('acl', json_encode($data->acl)); + } + + // Target folder + if (!empty($data->folder)) { + $folder->setSetting('folder', $data->folder); + } + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import users from the temp table + */ + protected function importUsers(): void + { + $users = DB::table(self::$table)->where('type', 'user')->whereNull('error'); + + // Skip the (already imported) account owner + if ($this->ownerDN) { + $users->whereNotIn('dn', [$this->ownerDN]); + } + + // Import aliases of the owner, we got from importOwner() call + if (!empty($this->aliases) && $this->wallet) { + $this->setUserAliases($this->wallet->owner, $this->aliases); + } + + $bar = $this->createProgressBar($users->count(), "Importing users"); + + foreach ($users->cursor() as $_user) { + $bar->advance(); + + $this->importSingleUser($_user); + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import the account owner (or find it among the existing accounts) + */ + protected function importOwner(): void + { + // The owner email not found in the import data, try existing users + $user = $this->getUser($this->argument('owner')); + + if (!$user && $this->ownerDN) { + // The owner email found in the import data + $bar = $this->createProgressBar(1, "Importing account owner"); + + $user = DB::table(self::$table)->where('dn', $this->ownerDN)->first(); + $user = $this->importSingleUser($user); + + // TODO: We should probably make sure the user's domain is to be imported too + // and/or create it automatically. + + $bar->advance(); + $bar->finish(); + + $this->info("DONE"); + } + + if (!$user) { + $this->error("Unable to find the specified account owner"); + exit(1); + } + + $this->wallet = $user->wallets->first(); + } + + /** + * A helper that imports a single user record + */ + protected function importSingleUser($ldap_user) + { + $data = json_decode($ldap_user->data); + + $user = \App\User::withTrashed()->where('email', $data->email)->first(); + + if ($user) { + $this->setImportWarning($ldap_user->id, "User already exists"); + return; + } + + // Make sure the domain exists + if ($this->wallet && !$this->domainExists($data->domain)) { + $this->setImportWarning($ldap_user->id, "Domain not found"); + return; + } + + $user = \App\User::create(['email' => $data->email]); + + // Entitlements + $user->assignPackageAndWallet($this->packages['user'], $this->wallet ?: $user->wallets()->first()); + + if (!empty($data->quota)) { + $quota = ceil($data->quota / 1024 / 1024) - $this->packages['quota']; + if ($quota > 0) { + $user->assignSku($this->packages['storage'], $quota); + } + } + + // User settings + if (!empty($data->settings)) { + $settings = []; + foreach ($data->settings as $key => $value) { + $settings[] = [ + 'user_id' => $user->id, + 'key' => $key, + 'value' => $value, + ]; + } + + DB::table('user_settings')->insert($settings); + } + + // Update password + if ($data->password != $user->password_ldap) { + \App\User::where('id', $user->id)->update(['password_ldap' => $data->password]); + } + + // Import aliases + if (!empty($data->aliases)) { + if (!$this->wallet) { + // This is the account owner creation, at this point we likely do not have + // domain records yet, save the aliases to be inserted later (in importUsers()) + $this->aliases = $data->aliases; + } else { + $this->setUserAliases($user, $data->aliases); + } + } + + return $user; + } + + /** + * Convert LDAP entry into an object supported by the migration tool + * + * @param array $entry LDAP entry attributes + * + * @return array Record data for inserting to the temp table + */ + protected function parseLDAPEntry(array $entry): array + { + $type = null; + $data = null; + $error = null; + + $ouTypeMap = [ + 'Shared Folders' => 'sharedfolder', + 'Resources' => 'resource', + 'Groups' => 'group', + 'People' => 'user', + 'Domains' => 'domain', + ]; + + foreach ($ouTypeMap as $ou => $_type) { + if (stripos($entry['dn'], ",ou={$ou}")) { + $type = $_type; + break; + } + } + + if (!$type) { + $error = "Unknown record type"; + } + + if (empty($error)) { + $method = 'parseLDAP' . ucfirst($type); + list($data, $error) = $this->{$method}($entry); + + if (empty($data['domain']) && !empty($data['email'])) { + $data['domain'] = explode('@', $data['email'])[1]; + } + } + + return [ + 'dn' => $entry['dn'], + 'type' => $type, + 'data' => json_encode($data), + 'error' => $error, + ]; + } + + /** + * Convert LDAP domain data into Kolab4 "format" + */ + protected function parseLDAPDomain($entry) + { + $error = null; + $result = []; + + if (empty($entry['associateddomain'])) { + $error = "Missing 'associatedDomain' attribute"; + } elseif (!empty($entry['inetdomainstatus']) && $entry['inetdomainstatus'] == 'deleted') { + $error = "Domain deleted"; + } else { + $result['namespace'] = strtolower($this->attrStringValue($entry, 'associateddomain')); + + if (is_array($entry['associateddomain']) && count($entry['associateddomain']) > 1) { + $result['aliases'] = array_slice($entry['associateddomain'], 1); + } + + // TODO: inetdomainstatus = suspended ??? + } + + return [$result, $error]; + } + + /** + * Convert LDAP group data into Kolab4 "format" + */ + protected function parseLDAPGroup($entry) + { + $error = null; + $result = []; + + if (empty($entry['cn'])) { + $error = "Missing 'cn' attribute"; + } elseif (empty($entry['mail'])) { + $error = "Missing 'mail' attribute"; + } elseif (empty($entry['uniquemember'])) { + $error = "Missing 'uniqueMember' attribute"; + } else { + $result['name'] = $this->attrStringValue($entry, 'cn'); + $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); + $result['members'] = $this->attrArrayValue($entry, 'uniquemember'); + + if (!empty($entry['kolaballowsmtpsender'])) { + $policy = $this->attrArrayValue($entry, 'kolaballowsmtpsender'); + $result['sender_policy'] = $this->parseSenderPolicy($policy); + } + } + + return [$result, $error]; + } + + /** + * Convert LDAP resource data into Kolab4 "format" + */ + protected function parseLDAPResource($entry) + { + $error = null; + $result = []; + + if (empty($entry['cn'])) { + $error = "Missing 'cn' attribute"; + } elseif (empty($entry['mail'])) { + $error = "Missing 'mail' attribute"; + } else { + $result['name'] = $this->attrStringValue($entry, 'cn'); + $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); + + if (!empty($entry['kolabtargetfolder'])) { + $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder'); + } + + if (!empty($entry['owner'])) { + $result['owner'] = $this->attrStringValue($entry, 'owner'); + } + + if (!empty($entry['kolabinvitationpolicy'])) { + $policy = $this->attrArrayValue($entry, 'kolabinvitationpolicy'); + $result['invitation_policy'] = $this->parseInvitationPolicy($policy); + } + } + + return [$result, $error]; + } + + /** + * Convert LDAP shared folder data into Kolab4 "format" + */ + protected function parseLDAPSharedFolder($entry) + { + $error = null; + $result = []; + + if (empty($entry['cn'])) { + $error = "Missing 'cn' attribute"; + } elseif (empty($entry['mail'])) { + $error = "Missing 'mail' attribute"; + } else { + $result['name'] = $this->attrStringValue($entry, 'cn'); + $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); + + if (!empty($entry['kolabfoldertype'])) { + $result['type'] = $this->attrStringValue($entry, 'kolabfoldertype'); + } + + if (!empty($entry['kolabtargetfolder'])) { + $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder'); + } + + if (!empty($entry['acl'])) { + $result['acl'] = $this->parseACL($this->attrArrayValue($entry, 'acl')); + } + } + + return [$result, $error]; + } + + /** + * Convert LDAP user data into Kolab4 "format" + */ + protected function parseLDAPUser($entry) + { + $error = null; + $result = []; + + $settingAttrs = [ + 'givenname' => 'first_name', + 'sn' => 'last_name', + 'telephonenumber' => 'phone', + 'mailalternateaddress' => 'external_email', + 'mobile' => 'phone', + 'o' => 'organization', + // 'address' => 'billing_address' + ]; + + if (empty($entry['mail'])) { + $error = "Missing 'mail' attribute"; + } else { + $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); + $result['settings'] = []; + $result['aliases'] = []; + + foreach ($settingAttrs as $attr => $setting) { + if (!empty($entry[$attr])) { + $result['settings'][$setting] = $this->attrStringValue($entry, $attr); + } + } + + if (!empty($entry['alias'])) { + $result['aliases'] = $this->attrArrayValue($entry, 'alias'); + } + + if (!empty($entry['userpassword'])) { + $result['password'] = $this->attrStringValue($entry, 'userpassword'); + } + + if (!empty($entry['mailquota'])) { + $result['quota'] = $this->attrStringValue($entry, 'mailquota'); + } + + if ($result['email'] == $this->argument('owner')) { + $this->ownerDN = $entry['dn']; + } + } + + return [$result, $error]; + } + + /** + * Print import errors + */ + protected function printErrors(): bool + { + if ($this->option('force')) { + return false; + } + + $errors = DB::table(self::$table)->whereNotNull('error')->orderBy('id') + ->get() + ->map(function ($record) { + $this->error("ERROR {$record->dn}: {$record->error}"); + return $record->id; + }) + ->all(); + + return !empty($errors); + } + + /** + * Print import warnings (for records that do not have an error specified) + */ + protected function printWarnings(): void + { + DB::table(self::$table)->whereNotNull('warning')->whereNull('error')->orderBy('id') + ->each(function ($record) { + $this->warn("WARNING {$record->dn}: {$record->warning}"); + return $record->id; + }); + } + + /** + * Convert ldap attribute value to an array + */ + protected static function attrArrayValue($entry, $attribute) + { + return is_array($entry[$attribute]) ? $entry[$attribute] : [$entry[$attribute]]; + } + + /** + * Convert ldap attribute to a string + */ + protected static function attrStringValue($entry, $attribute) + { + return is_array($entry[$attribute]) ? $entry[$attribute][0] : $entry[$attribute]; + } + + /** + * Resolve a list of user DNs into email addresses. Makes sure + * the returned addresses exist in Kolab4 database. + */ + protected function resolveUserDNs($user_dns): array + { + // Get email addresses from the import data + $users = DB::table(self::$table)->whereIn('dn', $user_dns) + ->where('type', 'user') + ->whereNull('error') + ->get() + ->map(function ($user) { + $mdata = json_decode($user->data); + return $mdata->email; + }) + // Make sure to skip these with unknown domains + ->filter(function ($email) { + return $this->domainExists(explode('@', $email)[1]); + }) + ->all(); + + // Get email addresses for existing Kolab4 users + if (!empty($users)) { + $users = \App\User::whereIn('email', $users)->get()->pluck('email')->all(); + } + + return $users; + } + + /** + * Validate/convert acl to Kolab4 format + */ + protected static function parseACL(array $acl): array + { + $map = [ + 'lrswipkxtecdn' => 'full', + 'lrs' => 'read-only', + 'read' => 'read-only', + 'lrswitedn' => 'read-write', + ]; + + $supportedRights = ['full', 'read-only', 'read-write']; + + foreach ($acl as $idx => $entry) { + $parts = explode(',', $entry); + $entry = null; + + if (count($parts) == 2) { + $label = trim($parts[0]); + $rights = trim($parts[1]); + $rights = $map[$rights] ?? $rights; + + if (in_array($rights, $supportedRights) && ($label === 'anyone' || strpos($label, '@'))) { + $entry = "{$label}, {$rights}"; + } + + // TODO: Throw an error or log a warning on unsupported acl entry? + } + + $acl[$idx] = $entry; + } + + return array_values(array_filter($acl)); + } + + /** + * Validate/convert invitation policy to Kolab4 format + */ + protected static function parseInvitationPolicy(array $policies): ?string + { + foreach ($policies as $policy) { + if ($policy == 'ACT_MANUAL') { + // 'owner' attribute handling in another place + return 'manual'; + } + + if ($policy == 'ACT_ACCEPT_AND_NOTIFY') { + break; // use the default 'accept' (null) policy + } + + if ($policy == 'ACT_REJECT') { + return 'reject'; + } + } + + return null; + } + + /** + * Validate/convert sender policy to Kolab4 format + */ + protected static function parseSenderPolicy(array $rules): array + { + foreach ($rules as $idx => $rule) { + $entry = trim($rule); + $rule = null; + + // 'deny' rules aren't supported + if (isset($entry[0]) && $entry[0] !== '-') { + $rule = $entry; + } + + $rules[$idx] = $rule; + } + + $rules = array_values(array_filter($rules)); + + if (!empty($rules) && $rules[count($rules) - 1] != '-') { + $rules[] = '-'; + } + + return $rules; + } + + /** + * Get/prepare packages/skus information + */ + protected function preparePackagesAndSkus(): void + { + // Find the tenant + if (empty($this->ownerDN)) { + if ($user = $this->getUser($this->argument('owner'))) { + $tenant_id = $user->tenant_id; + } + } + + // TODO: Tenant id could be a command option + + if (empty($tenant_id)) { + $tenant_id = \config('app.tenant_id'); + } + + // TODO: We should probably make package titles configurable with command options + + $this->packages = [ + 'user' => \App\Package::where('title', 'kolab')->where('tenant_id', $tenant_id)->first(), + 'domain' => \App\Package::where('title', 'domain-hosting')->where('tenant_id', $tenant_id)->first(), + ]; + + // Count storage skus + $sku = $this->packages['user']->skus()->where('title', 'storage')->first(); + + $this->packages['quota'] = $sku ? $sku->pivot->qty : 0; + $this->packages['storage'] = \App\Sku::where('title', 'storage')->where('tenant_id', $tenant_id)->first(); + } + + /** + * Set aliases for the user + */ + protected function setUserAliases(\App\User $user, array $aliases = []) + { + if (!empty($aliases)) { + // Some users might have alias entry with their main address, remove it + $aliases = array_map('strtolower', $aliases); + $aliases = array_diff(array_unique($aliases), [$user->email]); + + // Remove aliases for domains that do not exist + if (!empty($aliases)) { + $aliases = array_filter( + $aliases, + function ($alias) { + return $this->domainExists(explode('@', $alias)[1]); + } + ); + } + + if (!empty($aliases)) { + $user->setAliases($aliases); + } + } + } + + /** + * Set error message for specified import data record + */ + protected static function setImportError($id, $error): void + { + DB::table(self::$table)->where('id', $id)->update(['error' => $error]); + } + + /** + * Set warning message for specified import data record + */ + protected static function setImportWarning($id, $warning): void + { + DB::table(self::$table)->where('id', $id)->update(['warning' => $warning]); + } +} diff --git a/src/app/Console/Commands/MigratePrices.php b/src/app/Console/Commands/MigratePrices.php --- a/src/app/Console/Commands/MigratePrices.php +++ b/src/app/Console/Commands/MigratePrices.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use Illuminate\Console\Command; +use App\Console\Command; class MigratePrices extends Command { @@ -33,7 +33,7 @@ private function updateSKUs() { - $bar = \App\Utils::createProgressBar($this->output, 8, "Updating SKUs"); + $bar = $this->createProgressBar(8, "Updating SKUs"); // 1. Set the list price for the SKU 'mailbox' to 500. $bar->advance(); @@ -88,7 +88,7 @@ { $users = \App\User::all(); - $bar = \App\Utils::createProgressBar($this->output, count($users), "Updating entitlements"); + $bar = $this->createProgressBar(count($users), "Updating entitlements"); $groupware_sku = \App\Sku::where('title', 'groupware')->first(); $activesync_sku = \App\Sku::where('title', 'activesync')->first(); diff --git a/src/app/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php --- a/src/app/Observers/ResourceObserver.php +++ b/src/app/Observers/ResourceObserver.php @@ -16,7 +16,7 @@ public function creating(Resource $resource): void { if (empty($resource->email)) { - if (!isset($resource->name)) { + if (!isset($resource->domain)) { throw new \Exception("Missing 'domain' property for a new resource"); } diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php --- a/src/app/Observers/SharedFolderObserver.php +++ b/src/app/Observers/SharedFolderObserver.php @@ -20,7 +20,7 @@ } if (empty($folder->email)) { - if (!isset($folder->name)) { + if (!isset($folder->domain)) { throw new \Exception("Missing 'domain' property for a new shared folder"); } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -24,6 +24,7 @@ * @property string $email * @property int $id * @property string $password + * @property string $password_ldap * @property int $status * @property int $tenant_id */ diff --git a/src/app/Utils.php b/src/app/Utils.php --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -66,32 +66,6 @@ } /** - * Shortcut to creating a progress bar of a particular format with a particular message. - * - * @param \Illuminate\Console\OutputStyle $output Console output object - * @param int $count Number of progress steps - * @param string $message The description - * - * @return \Symfony\Component\Console\Helper\ProgressBar - */ - public static function createProgressBar($output, $count, $message = null) - { - $bar = $output->createProgressBar($count); - - $bar->setFormat( - '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' - ); - - if ($message) { - $bar->setMessage($message . " ..."); - } - - $bar->start(); - - return $bar; - } - - /** * Return the number of days in the month prior to this one. * * @return int diff --git a/src/tests/Feature/Console/Data/Import/LdifTest.php b/src/tests/Feature/Console/Data/Import/LdifTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Data/Import/LdifTest.php @@ -0,0 +1,432 @@ +deleteTestUser('owner@kolab3.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('owner@kolab3.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + $code = \Artisan::call("data:import:ldif tests/data/kolab3.ldif owner@kolab3.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + + $this->assertStringNotContainsString("Importing", $output); + $this->assertStringNotContainsString("WARNING", $output); + $this->assertStringContainsString( + "ERROR cn=error,ou=groups,ou=kolab3.com,dc=hosted,dc=com: Missing 'mail' attribute", + $output + ); + $this->assertStringContainsString( + "ERROR cn=error,ou=resources,ou=kolab3.com,dc=hosted,dc=com: Missing 'mail' attribute", + $output + ); + + $code = \Artisan::call("data:import:ldif tests/data/kolab3.ldif owner@kolab3.com --force"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertStringContainsString("Importing domains... DONE", $output); + $this->assertStringContainsString("Importing users... DONE", $output); + $this->assertStringContainsString("Importing resources... DONE", $output); + $this->assertStringContainsString("Importing shared folders... DONE", $output); + $this->assertStringContainsString("Importing groups... DONE", $output); + $this->assertStringNotContainsString("ERROR", $output); + $this->assertStringContainsString( + "WARNING cn=unknowndomain,ou=groups,ou=kolab3.org,dc=hosted,dc=com: Domain not found", + $output + ); + + $owner = \App\User::where('email', 'owner@kolab3.com')->first(); + + $this->assertNull($owner->password); + $this->assertSame( + '{SSHA512}g74+SECTLsM1x0aYkSrTG9sOFzEp8wjCflhshr2DjE7mi1G3iNb4ClH3ljorPRlTgZ105PsQGEpNtNr+XRjigg==', + $owner->password_ldap + ); + + // User settings + $this->assertSame('Aleksander', $owner->getSetting('first_name')); + $this->assertSame('Machniak', $owner->getSetting('last_name')); + $this->assertSame('123456789', $owner->getSetting('phone')); + $this->assertSame('external@gmail.com', $owner->getSetting('external_email')); + $this->assertSame('Organization AG', $owner->getSetting('organization')); + + // User aliases + $aliases = $owner->aliases()->orderBy('alias')->pluck('alias')->all(); + $this->assertSame(['alias@kolab3-alias.com', 'alias@kolab3.com'], $aliases); + + // Wallet, entitlements + $wallet = $owner->wallets->first(); + + $this->assertEntitlements($owner, [ + 'groupware', + 'mailbox', + 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', + ]); + + // Users + $this->assertSame(2, $owner->users(false)->count()); + $user = $owner->users(false)->where('email', 'user@kolab3.com')->first(); + + // User settings + $this->assertSame('Jane', $user->getSetting('first_name')); + $this->assertSame('Doe', $user->getSetting('last_name')); + $this->assertSame('1234567890', $user->getSetting('phone')); + $this->assertSame('ext@gmail.com', $user->getSetting('external_email')); + $this->assertSame('Org AG', $user->getSetting('organization')); + + // User aliases + $aliases = $user->aliases()->orderBy('alias')->pluck('alias')->all(); + $this->assertSame(['alias2@kolab3.com'], $aliases); + + $this->assertEntitlements($user, [ + 'groupware', + 'mailbox', + 'storage', 'storage', 'storage', 'storage', 'storage', + ]); + + // Domains + $domains = $owner->domains(false, false)->orderBy('namespace')->get(); + + $this->assertCount(2, $domains); + $this->assertSame('kolab3-alias.com', $domains[0]->namespace); + $this->assertSame('kolab3.com', $domains[1]->namespace); + $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[0]->type); + $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[1]->type); + + $this->assertEntitlements($domains[0], ['domain-hosting']); + $this->assertEntitlements($domains[1], ['domain-hosting']); + + // Shared folders + $folders = $owner->sharedFolders(false)->orderBy('email')->get(); + + $this->assertCount(2, $folders); + $this->assertMatchesRegularExpression('/^event-[0-9]+@kolab3\.com$/', $folders[0]->email); + $this->assertMatchesRegularExpression('/^mail-[0-9]+@kolab3\.com$/', $folders[1]->email); + $this->assertSame('Folder2', $folders[0]->name); + $this->assertSame('Folder1', $folders[1]->name); + $this->assertSame('event', $folders[0]->type); + $this->assertSame('mail', $folders[1]->type); + $this->assertSame('["anyone, read-only"]', $folders[0]->getSetting('acl')); + $this->assertSame('shared/Folder2@kolab3.com', $folders[0]->getSetting('folder')); + $this->assertSame('["anyone, read-write","owner@kolab3.com, full"]', $folders[1]->getSetting('acl')); + $this->assertSame('shared/Folder1@kolab3.com', $folders[1]->getSetting('folder')); + + // Groups + $groups = $owner->groups(false)->orderBy('email')->get(); + + $this->assertCount(1, $groups); + $this->assertSame('Group', $groups[0]->name); + $this->assertSame('group@kolab3.com', $groups[0]->email); + $this->assertSame(['owner@kolab3.com', 'user@kolab3.com'], $groups[0]->members); + $this->assertSame('["sender@gmail.com","-"]', $groups[0]->getSetting('sender_policy')); + + // Resources + $resources = $owner->resources(false)->orderBy('email')->get(); + + $this->assertCount(1, $resources); + $this->assertSame('Resource', $resources[0]->name); + $this->assertMatchesRegularExpression('/^resource-[0-9]+@kolab3\.com$/', $resources[0]->email); + $this->assertSame('shared/Resource@kolab3.com', $resources[0]->getSetting('folder')); + $this->assertSame('manual:user@kolab3.com', $resources[0]->getSetting('invitation_policy')); + } + + /** + * Test parseACL() method + */ + public function testParseACL(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $result = $this->invokeMethod($command, 'parseACL', [[]]); + $this->assertSame([], $result); + + $acl = [ + 'anyone, read-write', + 'read-only@kolab3.com, read-only', + 'read-only@kolab3.com, read', + 'full@kolab3.com,full', + 'lrswipkxtecdn@kolab3.com, lrswipkxtecdn', // full + 'lrs@kolab3.com, lrs', // read-only + 'lrswitedn@kolab3.com, lrswitedn', // read-write + // unsupported: + 'anonymous, read-only', + 'group:test, lrs', + 'test@kolab3.com, lrspkxtdn', + ]; + + $expected = [ + 'anyone, read-write', + 'read-only@kolab3.com, read-only', + 'read-only@kolab3.com, read-only', + 'full@kolab3.com, full', + 'lrswipkxtecdn@kolab3.com, full', + 'lrs@kolab3.com, read-only', + 'lrswitedn@kolab3.com, read-write', + ]; + + $result = $this->invokeMethod($command, 'parseACL', [$acl]); + $this->assertSame($expected, $result); + } + + /** + * Test parseInvitationPolicy() method + */ + public function testParseInvitationPolicy(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [[]]); + $this->assertSame(null, $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['UNKNOWN']]); + $this->assertSame(null, $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT']]); + $this->assertSame(null, $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_MANUAL']]); + $this->assertSame('manual', $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_REJECT']]); + $this->assertSame('reject', $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT_AND_NOTIFY', 'ACT_REJECT']]); + $this->assertSame(null, $result); + } + + /** + * Test parseSenderPolicy() method + */ + public function testParseSenderPolicy(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $result = $this->invokeMethod($command, 'parseSenderPolicy', [[]]); + $this->assertSame([], $result); + + $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test']]); + $this->assertSame(['test', '-'], $result); + + $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test', '-test2', 'test3', '']]); + $this->assertSame(['test', 'test3', '-'], $result); + } + + /** + * Test parseLDAPDomain() method + */ + public function testParseLDAPDomain(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $entry = []; + $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Missing 'associatedDomain' attribute", $result[1]); + + $entry = ['associateddomain' => 'test.com']; + $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]); + $this->assertSame(['namespace' => 'test.com'], $result[0]); + $this->assertSame(null, $result[1]); + + $entry = ['associateddomain' => 'test.com', 'inetdomainstatus' => 'deleted']; + $result = $this->invokeMethod($command, 'parseLDAPDomain', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Domain deleted", $result[1]); + } + + /** + * Test parseLDAPGroup() method + */ + public function testParseLDAPGroup(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $entry = []; + $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Missing 'cn' attribute", $result[1]); + + $entry = ['cn' => 'Test']; + $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Missing 'mail' attribute", $result[1]); + + $entry = ['cn' => 'Test', 'mail' => 'test@domain.tld']; + $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Missing 'uniqueMember' attribute", $result[1]); + + $entry = [ + 'cn' => 'Test', + 'mail' => 'Test@domain.tld', + 'uniquemember' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com', + 'kolaballowsmtpsender' => ['sender1@gmail.com', 'sender2@gmail.com'], + ]; + + $expected = [ + 'name' => 'Test', + 'email' => 'test@domain.tld', + 'members' => ['uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com'], + 'sender_policy' => ['sender1@gmail.com', 'sender2@gmail.com', '-'], + ]; + + $result = $this->invokeMethod($command, 'parseLDAPGroup', [$entry]); + $this->assertSame($expected, $result[0]); + $this->assertSame(null, $result[1]); + } + + /** + * Test parseLDAPResource() method + */ + public function testParseLDAPResource(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $entry = []; + $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Missing 'cn' attribute", $result[1]); + + $entry = ['cn' => 'Test']; + $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Missing 'mail' attribute", $result[1]); + + $entry = [ + 'cn' => 'Test', + 'mail' => 'Test@domain.tld', + 'owner' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com', + 'kolabtargetfolder' => 'Folder', + 'kolabinvitationpolicy' => 'ACT_REJECT' + ]; + + $expected = [ + 'name' => 'Test', + 'email' => 'test@domain.tld', + 'folder' => 'Folder', + 'owner' => 'uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com', + 'invitation_policy' => 'reject', + ]; + + $result = $this->invokeMethod($command, 'parseLDAPResource', [$entry]); + $this->assertSame($expected, $result[0]); + $this->assertSame(null, $result[1]); + } + + /** + * Test parseLDAPSharedFolder() method + */ + public function testParseLDAPSharedFolder(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $entry = []; + $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Missing 'cn' attribute", $result[1]); + + $entry = ['cn' => 'Test']; + $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Missing 'mail' attribute", $result[1]); + + $entry = [ + 'cn' => 'Test', + 'mail' => 'Test@domain.tld', + 'kolabtargetfolder' => 'Folder', + 'kolabfoldertype' => 'event', + 'acl' => 'anyone, read-write', + ]; + + $expected = [ + 'name' => 'Test', + 'email' => 'test@domain.tld', + 'type' => 'event', + 'folder' => 'Folder', + 'acl' => ['anyone, read-write'], + ]; + + $result = $this->invokeMethod($command, 'parseLDAPSharedFolder', [$entry]); + $this->assertSame($expected, $result[0]); + $this->assertSame(null, $result[1]); + } + + /** + * Test parseLDAPUser() method + */ + public function testParseLDAPUser(): void + { + // Note: If we do not initialize the command input we'll get an error + $args = [ + 'file' => 'test.ldif', + 'owner' => 'test@domain.tld', + ]; + + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + $command->setInput(new \Symfony\Component\Console\Input\ArrayInput($args, $command->getDefinition())); + + $entry = ['cn' => 'Test']; + $result = $this->invokeMethod($command, 'parseLDAPUser', [$entry]); + $this->assertSame([], $result[0]); + $this->assertSame("Missing 'mail' attribute", $result[1]); + + $entry = [ + 'dn' => 'user dn', + 'givenname' => 'Given', + 'mail' => 'Test@domain.tld', + 'sn' => 'Surname', + 'telephonenumber' => '123', + 'o' => 'Org', + 'mailalternateaddress' => 'test@ext.com', + 'alias' => ['test1@domain.tld', 'test2@domain.tld'], + 'userpassword' => 'pass', + 'mailquota' => '12345678', + ]; + + $expected = [ + 'email' => 'test@domain.tld', + 'settings' => [ + 'first_name' => 'Given', + 'last_name' => 'Surname', + 'phone' => '123', + 'external_email' => 'test@ext.com', + 'organization' => 'Org', + ], + 'aliases' => ['test1@domain.tld', 'test2@domain.tld'], + 'password' => 'pass', + 'quota' => '12345678', + ]; + + $result = $this->invokeMethod($command, 'parseLDAPUser', [$entry]); + $this->assertSame($expected, $result[0]); + $this->assertSame(null, $result[1]); + $this->assertSame($entry['dn'], $this->getObjectProperty($command, 'ownerDN')); + } +} diff --git a/src/tests/data/kolab3.ldif b/src/tests/data/kolab3.ldif new file mode 100644 --- /dev/null +++ b/src/tests/data/kolab3.ldif @@ -0,0 +1,119 @@ +dn: associateddomain=kolab3.com,ou=Domains,dc=hosted,dc=com +objectClass: top +objectClass: domainrelatedobject +objectClass: inetdomain +inetDomainBaseDN: ou=kolab3.com,dc=hosted,dc=com +associatedDomain: kolab3.com +associatedDomain: kolab3-alias.com + +dn: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +cn: Aleksander Machniak +displayName: Machniak, Aleksander +givenName: Aleksander +sn: Machniak +o: Organization AG +l:: U2llbWlhbm93aWNlIMWabMSFc2tpZQ== +mobile: 123456789 +c: PL +objectClass: top +objectClass: inetorgperson +objectClass: kolabinetorgperson +objectClass: organizationalperson +objectClass: mailrecipient +objectClass: country +objectClass: person +mail: owner@kolab3.com +alias: alias@kolab3.com +alias: alias@kolab3-alias.com +mailAlternateAddress: external@gmail.com +mailHost: imap.hosted.com +mailQuota: 8388608 +uid: owner@kolab3.com +userPassword:: e1NTSEE1MTJ9Zzc0K1NFQ1RMc00xeDBhWWtTclRHOXNPRnpFcDh3akNmbGhzaHIyRGpFN21pMUczaU5iNENsSDNsam9yUFJsVGdaMTA1UHNRR0VwTnROcitYUmppZ2c9PQ== +nsUniqueID: 229dc10c-1b6a11f7-b7c1edc1-0e0f46c4 +createtimestamp: 20170407081419Z +modifytimestamp: 20200915082359Z + +dn: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +cn: Jane Doe +displayName: Doe, Jane +givenName: Jane +sn: Doe +o: Org AG +telephoneNumber: 1234567890 +objectClass: top +objectClass: inetorgperson +objectClass: kolabinetorgperson +objectClass: organizationalperson +objectClass: mailrecipient +objectClass: country +objectClass: person +mail: user@kolab3.com +alias: alias2@kolab3.com +mailAlternateAddress: ext@gmail.com +mailHost: imap.hosted.com +mailQuota: 2097152 +uid: user@kolab3.com +userPassword:: e1NTSEE1MTJ9Zzc0K1NFQ1RMc00xeDBhWWtTclRHOXNPRnpFcDh3akNmbGhzaHIyRGpFN21pMUczaU5iNENsSDNsam9yUFJsVGdaMTA1UHNRR0VwTnROcitYUmppZ2c9PQ== +nsUniqueID: 229dc20c-1b6a11f7-b7c1edc1-0e0f46c4 + +dn: cn=Group,ou=Groups,ou=kolab3.com,dc=hosted,dc=com +cn: Group +mail: group@kolab3.com +objectClass: top +objectClass: groupofuniquenames +objectClass: kolabgroupofuniquenames +uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +uniqueMember: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +kolabAllowSMTPRecipient: recipient@kolab.org +kolabAllowSMTPSender: sender@gmail.com + +dn: cn=Error,ou=Groups,ou=kolab3.com,dc=hosted,dc=com +cn: Error +uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com + +dn: cn=UnknownDomain,ou=Groups,ou=kolab3.org,dc=hosted,dc=com +cn: UnknownDomain +mail: unknowndomain@kolab3.org +objectClass: top +objectClass: groupofuniquenames +objectClass: kolabgroupofuniquenames +uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +uniqueMember: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com + +dn: cn=Resource,ou=Resources,ou=kolab3.com,dc=hosted,dc=com +cn: Resource +mail: resource-car-resource@kolab3.com +objectClass: top +objectClass: kolabsharedfolder +objectClass: kolabresource +objectClass: mailrecipient +owner: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +kolabAllowSMTPRecipient: recipient@kolab.org +kolabAllowSMTPSender: sender@gmail.com +kolabInvitationPolicy: ACT_MANUAL +kolabTargetFolder: shared/Resource@kolab3.com + +dn: cn=Error,ou=Resources,ou=kolab3.com,dc=hosted,dc=com +cn: Error + +dn: cn=Folder1,ou=Shared Folders,ou=kolab3.com,dc=hosted,dc=com +cn: Folder1 +objectClass: kolabsharedfolder +objectClass: mailrecipient +objectClass: top +kolabFolderType: mail +kolabTargetFolder: shared/Folder1@kolab3.com +mail: folder1@kolab3.com +acl: anyone, read-write +acl: owner@kolab3.com, full + +dn: cn=Folder2,ou=Shared Folders,ou=kolab3.com,dc=hosted,dc=com +cn: Folder2 +objectClass: kolabsharedfolder +objectClass: mailrecipient +objectClass: top +kolabFolderType: event +kolabTargetFolder: shared/Folder2@kolab3.com +mail: folder2@kolab3.com +acl: anyone, read-only