diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index 16ede429..c17543d2 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,1379 +1,1378 @@ close(); self::$ldap = null; } } /** * Create a domain in LDAP. * * @param \App\Domain $domain The domain to create. * * @throws \Exception */ public static function createDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $mgmtRootDN = \config('ldap.admin.root_dn'); $domainBaseDN = self::baseDN($domain->namespace); $aci = [ '(targetattr = "*")' . '(version 3.0; acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; self::setDomainAttributes($domain, $entry); if (!$ldap->get_entry($dn)) { self::addEntry( $ldap, $dn, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } // create ou, roles, ous $entry = [ 'description' => $domain->namespace, 'objectclass' => [ 'top', 'organizationalunit' ], 'ou' => $domain->namespace, ]; $entry['aci'] = array( '(targetattr = "*")' . '(version 3.0;acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search,write)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', '(target = "ldap:///ou=*,' . $domainBaseDN . '")' . '(targetattr="objectclass || aci || ou")' . '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")' . '(version 3.0;acl "Allow Domain First User Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")' . '(version 3.0;acl "Allow Domain Role Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); if (!$ldap->get_entry($domainBaseDN)) { self::addEntry( $ldap, $domainBaseDN, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { $itemDN = self::baseDN($domain->namespace, $item); if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } foreach (['kolab-admin'] as $item) { $itemDN = "cn={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } // TODO: Assign kolab-admin role to the owner? if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a group in LDAP. * * @param \App\Group $group The group to create. * * @throws \Exception */ public static function createGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $group->email, 2)[1]; $cn = $ldap->quote_string($group->name); $dn = "cn={$cn}," . self::baseDN($domainName, 'Groups'); $entry = [ 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], ]; self::setGroupAttributes($ldap, $group, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a resource in LDAP. * * @param \App\Resource $resource The resource to create. * * @throws \Exception */ public static function createResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $resource->email, 2)[1]; $cn = $ldap->quote_string($resource->name); $dn = "cn={$cn}," . self::baseDN($domainName, 'Resources'); $entry = [ 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', ]; self::setResourceAttributes($ldap, $resource, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to create. * * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $folder->email, 2)[1]; $cn = $ldap->quote_string($folder->name); $dn = "cn={$cn}," . self::baseDN($domainName, 'Shared Folders'); $entry = [ 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], ]; self::setSharedFolderAttributes($ldap, $folder, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should * have Context-Based Access Controls before the job is queued though, probably. * * Use one of three modes; * * 1) The authenticated user account. * * * Only valid if the authenticated user is a domain admin. * * We don't know the originating user here. * * We certainly don't have its password anymore. * * 2) The hosted kolab account. * * 3) The Directory Manager account. * * @param \App\User $user The user account to create. * * @throws \Exception */ public static function createUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $entry = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [] ]; if (!self::getUserEntry($ldap, $user->email, $dn)) { if (empty($dn)) { self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"); } self::setUserAttributes($user, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to delete * * @throws \Exception */ public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainBaseDN = self::baseDN($domain->namespace); if ($ldap->get_entry($domainBaseDN)) { $result = $ldap->delete_entry_recursive($domainBaseDN); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } if ($ldap_domain = $ldap->find_domain($domain->namespace)) { if ($ldap->get_entry($ldap_domain['dn'])) { $result = $ldap->delete_entry($ldap_domain['dn']); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a group from LDAP. * * @param \App\Group $group The group to delete. * * @throws \Exception */ public static function deleteGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getGroupEntry($ldap, $group->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a resource from LDAP. * * @param \App\Resource $resource The resource to delete. * * @throws \Exception */ public static function deleteResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getResourceEntry($ldap, $resource->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a shared folder from LDAP. * * @param \App\SharedFolder $folder The shared folder to delete. * * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a user from LDAP. * * @param \App\User $user The user account to delete. * * @throws \Exception */ public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getUserEntry($ldap, $user->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Get a domain data from LDAP. * * @param string $namespace The domain name * * @return array|false|null * @throws \Exception */ public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($namespace); if ($ldapDomain) { $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } return $domain ?? null; } /** * Get a group data from LDAP. * * @param string $email The group email. * * @return array|false|null * @throws \Exception */ public static function getGroup(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $group = self::getGroupEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $group; } /** * Get a resource data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getResource(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $resource = self::getResourceEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $resource; } /** * Get a shared folder data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getSharedFolder(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $folder = self::getSharedFolderEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $folder; } /** * Get a user data from LDAP. * * @param string $email The user email. * * @return array|false|null * @throws \Exception */ public static function getUser(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $user = self::getUserEntry($ldap, $email, $dn, true); if (empty(self::$ldap)) { $ldap->close(); } return $user; } /** * Update a domain in LDAP. * * @param \App\Domain $domain The domain to update. * * @throws \Exception */ public static function updateDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($domain->namespace); if (!$ldapDomain) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (domain not found)" ); } $oldEntry = $ldap->get_entry($ldapDomain['dn']); $newEntry = $oldEntry; self::setDomainAttributes($domain, $newEntry); if (array_key_exists('inetdomainstatus', $newEntry)) { $newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus']; } $result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a group in LDAP. * * @param \App\Group $group The group to update * * @throws \Exception */ public static function updateGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } self::setGroupAttributes($ldap, $group, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a resource in LDAP. * * @param \App\Resource $resource The resource to update * * @throws \Exception */ public static function updateResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (resource not found)" ); } self::setResourceAttributes($ldap, $resource, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to update * * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (folder not found)" ); } self::setSharedFolderAttributes($ldap, $folder, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @throws \Exception */ public static function updateUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true); if (!$oldEntry) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (user not found)" ); } self::setUserAttributes($user, $newEntry); if (array_key_exists('objectclass', $newEntry)) { if (!in_array('inetuser', $newEntry['objectclass'])) { $newEntry['objectclass'][] = 'inetuser'; } } if (array_key_exists('inetuserstatus', $newEntry)) { $newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus']; } if (array_key_exists('mailquota', $newEntry)) { $newEntry['mailquota'] = (string) $newEntry['mailquota']; } $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { if (self::$ldap) { return self::$ldap; } $ldap = new \Net_LDAP3($config); $connected = $ldap->connect(); if (!$connected) { throw new \Exception("Failed to connect to LDAP"); } $bound = $ldap->bind( \config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw") ); if (!$bound) { throw new \Exception("Failed to bind to LDAP"); } return $ldap; } /** * Set domain attributes */ private static function setDomainAttributes(Domain $domain, array &$entry) { $entry['inetdomainstatus'] = $domain->status; } /** * Convert group member addresses in to valid entries. */ private static function setGroupAttributes($ldap, Group $group, &$entry) { $settings = $group->getSettings(['sender_policy']); $entry['kolaballowsmtpsender'] = json_decode($settings['sender_policy'] ?: '[]', true); $entry['cn'] = $group->name; $entry['uniquemember'] = []; $groupDomain = explode('@', $group->email, 2)[1]; $domainBaseDN = self::baseDN($groupDomain); $validMembers = []; foreach ($group->members as $member) { list($local, $domainName) = explode('@', $member); $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it if ($domainName == $groupDomain && !$memberEntry) { continue; } // add the member if not in the local domain if (!$memberEntry) { $memberEntry = [ 'cn' => $member, 'mail' => $member, 'objectclass' => [ 'top', 'inetorgperson', 'organizationalperson', 'person' ], 'sn' => 'unknown' ]; $ldap->add_entry($memberDN, $memberEntry); } $entry['uniquemember'][] = $memberDN; $validMembers[] = $member; } // Update members in sql (some might have been removed), // skip model events to not invoke another update job if ($group->members !== $validMembers) { $group->members = $validMembers; Group::withoutEvents(function () use ($group) { $group->save(); }); } } /** * Set common resource attributes */ private static function setResourceAttributes($ldap, Resource $resource, &$entry) { $entry['cn'] = $resource->name; $entry['owner'] = null; $entry['kolabinvitationpolicy'] = null; + $entry['acl'] = ''; $settings = $resource->getSettings(['invitation_policy', 'folder']); $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; // Here's how Wallace's resources module works: // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, // and mail sent to the owner to accept/decline the request. // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, // event saved, and notification (not confirmation) mail sent to the owner. // - if there's no owner (policy irrelevant): an accept response is sent, event saved. // - if policy is ACT_REJECT: a decline response is sent // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. // For now we ignore the notifications feature if (!empty($settings['invitation_policy'])) { if ($settings['invitation_policy'] === 'accept') { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } elseif ($settings['invitation_policy'] === 'reject') { $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { if (self::getUserEntry($ldap, $m[1], $userDN)) { $entry['owner'] = $userDN; + $entry['acl'] = $m[1] . ', full'; $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; } else { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } - - // TODO: Set folder ACL so the owner can write to it - // TODO: Do we need to add lrs for anyone? } } } /** * Set common shared folder attributes */ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) { $settings = $folder->getSettings(['acl', 'folder']); $entry['cn'] = $folder->name; $entry['kolabfoldertype'] = $folder->type; $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $settings = $user->getSettings(['first_name', 'last_name', 'organization']); $firstName = $settings['first_name']; $lastName = $settings['last_name']; $cn = "unknown"; $displayname = ""; if ($firstName) { if ($lastName) { $cn = "{$firstName} {$lastName}"; $displayname = "{$lastName}, {$firstName}"; } else { $lastName = "unknown"; $cn = "{$firstName}"; $displayname = "{$firstName}"; } } else { $firstName = ""; if ($lastName) { $cn = "{$lastName}"; $displayname = "{$lastName}"; } else { $lastName = "unknown"; } } $entry['cn'] = $cn; $entry['displayname'] = $displayname; $entry['givenname'] = $firstName; $entry['sn'] = $lastName; $entry['userpassword'] = $user->password_ldap; $entry['inetuserstatus'] = $user->status; $entry['o'] = $settings['organization']; $entry['mailquota'] = 0; $entry['alias'] = $user->aliases->pluck('alias')->toArray(); $roles = []; foreach ($user->entitlements as $entitlement) { \Log::debug("Examining {$entitlement->sku->title}"); switch ($entitlement->sku->title) { case "mailbox": break; case "storage": $entry['mailquota'] += 1048576; break; default: $roles[] = $entitlement->sku->title; break; } } $hostedRootDN = \config('ldap.hosted.root_dn'); $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } if (in_array("activesync", $roles)) { $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; } if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } } /** * Get LDAP configuration for specified access level */ private static function getConfig(string $privilege) { $config = [ 'domain_base_dn' => \config('ldap.domain_base_dn'), 'domain_filter' => \config('ldap.domain_filter'), 'domain_name_attribute' => \config('ldap.domain_name_attribute'), 'hosts' => \config('ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Get group entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Group email (mail) * @param string $dn Reference to group DN * * @return null|array Group entry, False on error, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($domainName, 'Groups'); $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; // For groups we're using search() instead of get_entry() because // a group name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a resource entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the resource DN * * @return null|array Resource entry, NULL if not found */ private static function getResourceEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($domainName, 'Resources'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', - 'kolabfoldertype', 'kolabinvitationpolicy', 'owner']; + 'kolabfoldertype', 'kolabinvitationpolicy', 'owner', 'acl']; // For resources we're using search() instead of get_entry() because // a resource name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a shared folder entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the shared folder DN * * @return null|array Shared folder entry, NULL if not found */ private static function getSharedFolderEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($domainName, 'Shared Folders'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl']; // For shared folders we're using search() instead of get_entry() because // a folder name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get user entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email User email (uid) * @param string $dn Reference to user DN * @param bool $full Get extra attributes, e.g. nsroledn * * @return ?array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { $domainName = explode('@', $email, 2)[1]; $dn = "uid={$email}," . self::baseDN($domainName, 'People'); $entry = $ldap->get_entry($dn); if ($entry && $full) { if (!array_key_exists('nsroledn', $entry)) { $roles = $ldap->get_entry_attributes($dn, ['nsroledn']); if (!empty($roles)) { $entry['nsroledn'] = (array) $roles['nsroledn']; } } } return $entry ?: null; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( ( $level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE ) && !\config('app.debug') ) { return; } switch ($level) { case LOG_CRIT: $function = 'critical'; break; case LOG_EMERG: $function = 'emergency'; break; case LOG_ERR: $function = 'error'; break; case LOG_ALERT: $function = 'alert'; break; case LOG_WARNING: $function = 'warning'; break; case LOG_INFO: $function = 'info'; break; case LOG_DEBUG: $function = 'debug'; break; case LOG_NOTICE: $function = 'notice'; break; default: $function = 'info'; } if (is_array($msg)) { $msg = implode("\n", $msg); } $msg = '[LDAP] ' . $msg; \Log::{$function}($msg); } /** * A wrapper for Net_LDAP3::add_entry() with error handler * * @param \Net_LDAP3 $ldap Ldap connection * @param string $dn Entry DN * @param array $entry Entry attributes * @param ?string $errorMsg A message to throw as an exception on error * * @throws \Exception */ private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null) { // try/catch because Laravel converts warnings into exceptions // and we want more human-friendly error message than that try { $result = $ldap->add_entry($dn, $entry); } catch (\Exception $e) { $result = false; } if (!$result) { if (!$errorMsg) { $errorMsg = "LDAP Error (" . __LINE__ . ")"; } if (isset($e)) { $errorMsg .= ": " . $e->getMessage(); } self::throwException($ldap, $errorMsg); } } /** * Find a single entry in LDAP by using search. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $base_dn Base DN * @param string $filter Search filter * @param array $attrs Result attributes * @param string $dn Reference to a DN of the found entry * * @return null|array LDAP entry, NULL if not found */ private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) { $result = $ldap->search($base_dn, $filter, 'sub', $attrs); if ($result && $result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = $entries[$dn]; $entry['dn'] = $dn; return $entry; } return null; } /** * Throw exception and close the connection when needed * * @param \Net_LDAP3 $ldap Ldap connection * @param string $message Exception message * * @throws \Exception */ private static function throwException($ldap, string $message): void { if (empty(self::$ldap) && !empty($ldap)) { $ldap->close(); } throw new \Exception($message); } /** * Create a base DN string for specified object * * @param string $domainName Domain namespace * @param ?string $ouName Optional name of the sub-tree (OU) * * @return string Full base DN */ private static function baseDN(string $domainName, string $ouName = null): string { $hostedRootDN = \config('ldap.hosted.root_dn'); $dn = "ou={$domainName},{$hostedRootDN}"; if ($ouName) { $dn = "ou={$ouName},{$dn}"; } return $dn; } } diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php index 5c0bdbe2..5b0c49b9 100644 --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -1,624 +1,642 @@ ldap_config = [ 'ldap.hosts' => \config('ldap.hosts'), ]; $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members } /** * {@inheritDoc} */ public function tearDown(): void { \config($this->ldap_config); $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members parent::tearDown(); } /** * Test handling connection errors * * @group ldap */ public function testConnectException(): void { \config(['ldap.hosts' => 'non-existing.host']); $this->expectException(\Exception::class); LDAP::connect(); } /** * Test creating/updating/deleting a domain record * * @group ldap */ public function testDomain(): void { Queue::fake(); $domain = $this->getTestDomain('testldap.com', [ 'type' => Domain::TYPE_EXTERNAL, 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, ]); // Create the domain LDAP::createDomain($domain); $ldap_domain = LDAP::getDomain($domain->namespace); $expected = [ 'associateddomain' => $domain->namespace, 'inetdomainstatus' => $domain->status, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); } // TODO: Test other attributes, aci, roles/ous // Update the domain $domain->status |= User::STATUS_LDAP_READY; LDAP::updateDomain($domain); $expected['inetdomainstatus'] = $domain->status; $ldap_domain = LDAP::getDomain($domain->namespace); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); } // Delete the domain LDAP::deleteDomain($domain); $this->assertSame(null, LDAP::getDomain($domain->namespace)); } /** * Test creating/updating/deleting a group record * * @group ldap */ public function testGroup(): void { Queue::fake(); $root_dn = \config('ldap.hosted.root_dn'); $group = $this->getTestGroup('group@kolab.org', [ 'members' => ['member1@testldap.com', 'member2@testldap.com'] ]); $group->setSetting('sender_policy', '["test.com"]'); // Create the group LDAP::createGroup($group); $ldap_group = LDAP::getGroup($group->email); $expected = [ 'cn' => 'group', 'dn' => 'cn=group,ou=Groups,ou=kolab.org,' . $root_dn, 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], 'kolaballowsmtpsender' => 'test.com', 'uniquemember' => [ 'uid=member1@testldap.com,ou=People,ou=kolab.org,' . $root_dn, 'uid=member2@testldap.com,ou=People,ou=kolab.org,' . $root_dn, ], ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } // Update members $group->members = ['member3@testldap.com']; $group->save(); $group->setSetting('sender_policy', '["test.com","-"]'); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = ['test.com', '-']; $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // Update members (add non-existing local member, expect it to be aot-removed from the group) // Update group name and sender_policy $group->members = ['member3@testldap.com', 'member-local@kolab.org']; $group->name = 'Te(=ść)1'; $group->save(); $group->setSetting('sender_policy', null); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = null; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Groups,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // We called save() twice, and setSettings() three times, // this is making sure that there's no job executed by the LDAP backend Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 5); // Delete the group LDAP::deleteGroup($group); $this->assertSame(null, LDAP::getGroup($group->email)); } /** * Test creating/updating/deleting a resource record * * @group ldap */ public function testResource(): void { Queue::fake(); $root_dn = \config('ldap.hosted.root_dn'); $resource = $this->getTestResource('test-resource@kolab.org', ['name' => 'Test1']); $resource->setSetting('invitation_policy', null); // Make sure the resource does not exist // LDAP::deleteResource($resource); // Create the resource LDAP::createResource($resource); $ldap_resource = LDAP::getResource($resource->email); $expected = [ 'cn' => 'Test1', 'dn' => 'cn=Test1,ou=Resources,ou=kolab.org,' . $root_dn, 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', 'kolabtargetfolder' => 'shared/Resources/Test1@kolab.org', 'kolabinvitationpolicy' => null, 'owner' => null, + 'acl' => null, ]; foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Update resource name and invitation_policy $resource->name = 'Te(=ść)1'; $resource->save(); $resource->setSetting('invitation_policy', 'manual:john@kolab.org'); LDAP::updateResource($resource); $expected['kolabtargetfolder'] = 'shared/Resources/Te(=ść)1@kolab.org'; $expected['kolabinvitationpolicy'] = 'ACT_MANUAL'; $expected['owner'] = 'uid=john@kolab.org,ou=People,ou=kolab.org,' . $root_dn; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Resources,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; + $expected['acl'] = 'john@kolab.org, full'; + + $ldap_resource = LDAP::getResource($resource->email); + + foreach ($expected as $attr => $value) { + $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; + $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); + } + + // Remove the invitation policy + $resource->setSetting('invitation_policy', '[]'); + + LDAP::updateResource($resource); + + $expected['acl'] = null; + $expected['kolabinvitationpolicy'] = null; + $expected['owner'] = null; $ldap_resource = LDAP::getResource($resource->email); foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Delete the resource LDAP::deleteResource($resource); $this->assertSame(null, LDAP::getResource($resource->email)); } /** * Test creating/updating/deleting a shared folder record * * @group ldap */ public function testSharedFolder(): void { Queue::fake(); $root_dn = \config('ldap.hosted.root_dn'); $folder = $this->getTestSharedFolder('test-folder@kolab.org', ['type' => 'event']); $folder->setSetting('acl', null); // Make sure the shared folder does not exist // LDAP::deleteSharedFolder($folder); // Create the shared folder LDAP::createSharedFolder($folder); $ldap_folder = LDAP::getSharedFolder($folder->email); $expected = [ 'cn' => 'test-folder', 'dn' => 'cn=test-folder,ou=Shared Folders,ou=kolab.org,' . $root_dn, 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', 'kolabtargetfolder' => 'shared/test-folder@kolab.org', 'acl' => null, ]; foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); } // Update folder name and acl $folder->name = 'Te(=ść)1'; $folder->save(); $folder->setSetting('acl', '["john@kolab.org, read-write","anyone, read-only"]'); LDAP::updateSharedFolder($folder); $expected['kolabtargetfolder'] = 'shared/Te(=ść)1@kolab.org'; $expected['acl'] = ['john@kolab.org, read-write', 'anyone, read-only']; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Shared Folders,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $ldap_folder = LDAP::getSharedFolder($folder->email); foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); } // Delete the resource LDAP::deleteSharedFolder($folder); $this->assertSame(null, LDAP::getSharedFolder($folder->email)); } /** * Test creating/editing/deleting a user record * * @group ldap */ public function testUser(): void { Queue::fake(); $user = $this->getTestUser('user-ldap-test@' . \config('app.domain')); LDAP::createUser($user); $ldap_user = LDAP::getUser($user->email); $expected = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person', 'organizationalPerson', ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [ 'cn=imap-user,' . \config('ldap.hosted.root_dn') ], 'cn' => 'unknown', 'displayname' => '', 'givenname' => '', 'sn' => 'unknown', 'inetuserstatus' => $user->status, 'mailquota' => null, 'o' => '', 'alias' => null, ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Add aliases, and change some user settings, and entitlements $user->setSettings([ 'first_name' => 'Firstname', 'last_name' => 'Lastname', 'organization' => 'Org', 'country' => 'PL', ]); $user->status |= User::STATUS_IMAP_READY; $user->save(); $aliases = ['t1-' . $user->email, 't2-' . $user->email]; $user->setAliases($aliases); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package_kolab); LDAP::updateUser($user->fresh()); $expected['alias'] = $aliases; $expected['o'] = 'Org'; $expected['displayname'] = 'Lastname, Firstname'; $expected['givenname'] = 'Firstname'; $expected['cn'] = 'Firstname Lastname'; $expected['sn'] = 'Lastname'; $expected['inetuserstatus'] = $user->status; $expected['mailquota'] = 5242880; $expected['nsroledn'] = null; $ldap_user = LDAP::getUser($user->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Update entitlements $sku_activesync = \App\Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $sku_groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $user->assignSku($sku_activesync, 1); Entitlement::where(['sku_id' => $sku_groupware->id, 'entitleable_id' => $user->id])->delete(); LDAP::updateUser($user->fresh()); $expected_roles = [ 'activesync-user', 'imap-user' ]; $ldap_user = LDAP::getUser($user->email); $this->assertCount(2, $ldap_user['nsroledn']); $ldap_roles = array_map( function ($role) { if (preg_match('/^cn=([a-z0-9-]+)/', $role, $m)) { return $m[1]; } else { return $role; } }, $ldap_user['nsroledn'] ); $this->assertSame($expected_roles, $ldap_roles); // Delete the user LDAP::deleteUser($user); $this->assertSame(null, LDAP::getUser($user->email)); } /** * Test handling errors on a resource creation * * @group ldap */ public function testCreateResourceException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create resource/'); $resource = new Resource([ 'email' => 'test-non-existing-ldap@non-existing.org', 'name' => 'Test', 'status' => Resource::STATUS_ACTIVE, ]); LDAP::createResource($resource); } /** * Test handling errors on a group creation * * @group ldap */ public function testCreateGroupException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create group/'); $group = new Group([ 'name' => 'test', 'email' => 'test@testldap.com', 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, ]); LDAP::createGroup($group); } /** * Test handling errors on a shared folder creation * * @group ldap */ public function testCreateSharedFolderException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create shared folder/'); $folder = new SharedFolder([ 'email' => 'test-non-existing-ldap@non-existing.org', 'name' => 'Test', 'status' => SharedFolder::STATUS_ACTIVE, ]); LDAP::createSharedFolder($folder); } /** * Test handling errors on user creation * * @group ldap */ public function testCreateUserException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create user/'); $user = new User([ 'email' => 'test-non-existing-ldap@non-existing.org', 'status' => User::STATUS_ACTIVE, ]); LDAP::createUser($user); } /** * Test handling update of a non-existing domain * * @group ldap */ public function testUpdateDomainException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/domain not found/'); $domain = new Domain([ 'namespace' => 'testldap.com', 'type' => Domain::TYPE_EXTERNAL, 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, ]); LDAP::updateDomain($domain); } /** * Test handling update of a non-existing group * * @group ldap */ public function testUpdateGroupException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/group not found/'); $group = new Group([ 'name' => 'test', 'email' => 'test@testldap.com', 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, ]); LDAP::updateGroup($group); } /** * Test handling update of a non-existing resource * * @group ldap */ public function testUpdateResourceException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/resource not found/'); $resource = new Resource([ 'email' => 'test-resource@kolab.org', ]); LDAP::updateResource($resource); } /** * Test handling update of a non-existing shared folder * * @group ldap */ public function testUpdateSharedFolderException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/folder not found/'); $folder = new SharedFolder([ 'email' => 'test-folder-unknown@kolab.org', ]); LDAP::updateSharedFolder($folder); } /** * Test handling update of a non-existing user * * @group ldap */ public function testUpdateUserException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/user not found/'); $user = new User([ 'email' => 'test-non-existing-ldap@kolab.org', 'status' => User::STATUS_ACTIVE, ]); LDAP::updateUser($user); } }