Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .drone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,7 @@ services:
matrix:
TESTS: acceptance
openldap:
image: nextcloudci/openldap:openldap-6
image: nextcloudci/openldap:openldap-7
environment:
- SLAPD_DOMAIN=nextcloud.ci
- SLAPD_ORGANIZATION=Nextcloud
Expand Down
3 changes: 3 additions & 0 deletions apps/user_ldap/lib/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
* @property string ldapQuotaAttribute
* @property string ldapQuotaDefault
* @property string ldapEmailAttribute
* @property bool|string ldapNestedGroups
* @property string[] ldapBaseGroups
* @property string ldapGroupFilter
*/
class Connection extends LDAPUtility {
private $ldapConnectionRes = null;
Expand Down
140 changes: 86 additions & 54 deletions apps/user_ldap/lib/Group_LDAP.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ class Group_LDAP extends BackendUtility implements \OCP\GroupInterface, IGroupLD
*/
protected $cachedGroupsByMember;

/**
* @var string[] $cachedNestedGroups array of groups with gid (DN) as key
*/
protected $cachedNestedGroups;

/** @var GroupPluginManager */
protected $groupPluginManager;

Expand All @@ -71,6 +76,7 @@ public function __construct(Access $access, GroupPluginManager $groupPluginManag

$this->cachedGroupMembers = new CappedMemoryCache();
$this->cachedGroupsByMember = new CappedMemoryCache();
$this->cachedNestedGroups = new CappedMemoryCache();
$this->groupPluginManager = $groupPluginManager;
}

Expand Down Expand Up @@ -212,12 +218,12 @@ public function getDynamicGroupMembers($dnGroup) {
*/
private function _groupMembers($dnGroup, &$seen = null) {
if ($seen === null) {
$seen = array();
$seen = [];
}
$allMembers = array();
$allMembers = [];
if (array_key_exists($dnGroup, $seen)) {
// avoid loops
return array();
return [];
}
// used extensively in cron job, caching makes sense for nested groups
$cacheKey = '_groupMembers'.$dnGroup;
Expand All @@ -226,19 +232,12 @@ private function _groupMembers($dnGroup, &$seen = null) {
return $groupMembers;
}
$seen[$dnGroup] = 1;
$members = $this->access->readAttribute($dnGroup, $this->access->connection->ldapGroupMemberAssocAttr,
$this->access->connection->ldapGroupFilter);
$members = $this->access->readAttribute($dnGroup, $this->access->connection->ldapGroupMemberAssocAttr);
if (is_array($members)) {
foreach ($members as $member) {
$allMembers[$member] = 1;
$nestedGroups = $this->access->connection->ldapNestedGroups;
if (!empty($nestedGroups)) {
$subMembers = $this->_groupMembers($member, $seen);
if ($subMembers) {
$allMembers += $subMembers;
}
}
}
$fetcher = function($memberDN, &$seen) {
return $this->_groupMembers($memberDN, $seen);
};
$allMembers = $this->walkNestedGroups($dnGroup, $fetcher, $members);
}

$allMembers += $this->getDynamicGroupMembers($dnGroup);
Expand All @@ -251,30 +250,69 @@ private function _groupMembers($dnGroup, &$seen = null) {
* @param string $DN
* @param array|null &$seen
* @return array
* @throws \OC\ServerNotAvailableException
*/
private function _getGroupDNsFromMemberOf($DN, &$seen = null) {
if ($seen === null) {
$seen = array();
}
if (array_key_exists($DN, $seen)) {
// avoid loops
return array();
}
$seen[$DN] = 1;
private function _getGroupDNsFromMemberOf($DN) {
$groups = $this->access->readAttribute($DN, 'memberOf');
if (!is_array($groups)) {
return array();
return [];
}

$fetcher = function($groupDN) {
if (isset($this->cachedNestedGroups[$groupDN])) {
$nestedGroups = $this->cachedNestedGroups[$groupDN];
} else {
$nestedGroups = $this->access->readAttribute($groupDN, 'memberOf');
if (!is_array($nestedGroups)) {
$nestedGroups = [];
}
$this->cachedNestedGroups[$groupDN] = $nestedGroups;
}
return $nestedGroups;
};

$groups = $this->walkNestedGroups($DN, $fetcher, $groups);
return $this->access->groupsMatchFilter($groups);
}

/**
* @param string $dn
* @param \Closure $fetcher args: string $dn, array $seen, returns: string[] of dns
* @param array $list
* @return array
*/
private function walkNestedGroups(string $dn, \Closure $fetcher, array $list): array {
$nesting = (int) $this->access->connection->ldapNestedGroups;
// depending on the input, we either have a list of DNs or a list of LDAP records
// also, the output expects either DNs or records. Testing the first element should suffice.
$recordMode = is_array($list) && isset($list[0]) && is_array($list[0]) && isset($list[0]['dn'][0]);

if ($nesting !== 1) {
if($recordMode) {
// the keys are numeric, but should hold the DN
return array_reduce($list, function ($transformed, $record) use ($dn) {
if($record['dn'][0] != $dn) {
$transformed[$record['dn'][0]] = $record;
}
return $transformed;
}, []);
}
return $list;
}
$groups = $this->access->groupsMatchFilter($groups);
$allGroups = $groups;
$nestedGroups = $this->access->connection->ldapNestedGroups;
if ((int)$nestedGroups === 1) {
foreach ($groups as $group) {
$subGroups = $this->_getGroupDNsFromMemberOf($group, $seen);
$allGroups = array_merge($allGroups, $subGroups);

$seen = [];
while ($record = array_pop($list)) {
$recordDN = $recordMode ? $record['dn'][0] : $record;
if ($recordDN === $dn || array_key_exists($recordDN, $seen)) {
// Prevent loops
continue;
}
$fetched = $fetcher($record, $seen);
$list = array_merge($list, $fetched);
$seen[$recordDN] = $record;
}
return $allGroups;

return $recordMode ? $seen : array_keys($seen);
}

/**
Expand Down Expand Up @@ -737,34 +775,28 @@ public function getUserGroups($uid) {
*/
private function getGroupsByMember($dn, &$seen = null) {
if ($seen === null) {
$seen = array();
$seen = [];
}
$allGroups = array();
if (array_key_exists($dn, $seen)) {
// avoid loops
return array();
return [];
}
$allGroups = [];
$seen[$dn] = true;
$filter = $this->access->combineFilterWithAnd(array(
$this->access->connection->ldapGroupFilter,
$this->access->connection->ldapGroupMemberAssocAttr.'='.$dn
));
$filter = $this->access->connection->ldapGroupMemberAssocAttr.'='.$dn;
$groups = $this->access->fetchListOfGroups($filter,
array($this->access->connection->ldapGroupDisplayName, 'dn'));
[$this->access->connection->ldapGroupDisplayName, 'dn']);
if (is_array($groups)) {
foreach ($groups as $groupobj) {
$groupDN = $groupobj['dn'][0];
$allGroups[$groupDN] = $groupobj;
$nestedGroups = $this->access->connection->ldapNestedGroups;
if (!empty($nestedGroups)) {
$supergroups = $this->getGroupsByMember($groupDN, $seen);
if (is_array($supergroups) && (count($supergroups)>0)) {
$allGroups = array_merge($allGroups, $supergroups);
}
$fetcher = function ($dn, &$seen) {
if(is_array($dn) && isset($dn['dn'][0])) {
$dn = $dn['dn'][0];
}
}
return $this->getGroupsByMember($dn, $seen);
};
$allGroups = $this->walkNestedGroups($dn, $fetcher, $groups);
}
return $allGroups;
$visibleGroups = $this->access->groupsMatchFilter(array_keys($allGroups));
return array_intersect_key($allGroups, array_flip($visibleGroups));
}

/**
Expand Down Expand Up @@ -811,7 +843,7 @@ public function usersInGroup($gid, $search = '', $limit = -1, $offset = 0) {

$primaryUsers = $this->getUsersInPrimaryGroup($groupDN, $search, $limit, $offset);
$posixGroupUsers = $this->getUsersInGidNumber($groupDN, $search, $limit, $offset);
$members = array_keys($this->_groupMembers($groupDN));
$members = $this->_groupMembers($groupDN);
if(!$members && empty($posixGroupUsers) && empty($primaryUsers)) {
//in case users could not be retrieved, return empty result set
$this->access->connection->writeToCache($cacheKey, []);
Expand Down Expand Up @@ -886,7 +918,7 @@ public function countUsersInGroup($gid, $search = '') {
return false;
}

$members = array_keys($this->_groupMembers($groupDN));
$members = $this->_groupMembers($groupDN);
$primaryUserCount = $this->countUsersInPrimaryGroup($groupDN, '');
if(!$members && $primaryUserCount === 0) {
//in case users could not be retrieved, return empty result set
Expand Down
57 changes: 32 additions & 25 deletions apps/user_ldap/tests/Group_LDAPTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,27 @@ private function enableGroups($access) {
public function testCountEmptySearchString() {
$access = $this->getAccessMock();
$pluginManager = $this->getPluginManagerMock();
$groupDN = 'cn=group,dc=foo,dc=bar';

$this->enableGroups($access);

$access->expects($this->any())
->method('groupname2dn')
->will($this->returnValue('cn=group,dc=foo,dc=bar'));
->will($this->returnValue($groupDN));

$access->expects($this->any())
->method('readAttribute')
->will($this->returnValue(array('u11', 'u22', 'u33', 'u34')));
->willReturnCallback(function($dn) use ($groupDN) {
if($dn === $groupDN) {
return [
'uid=u11,ou=users,dc=foo,dc=bar',
'uid=u22,ou=users,dc=foo,dc=bar',
'uid=u33,ou=users,dc=foo,dc=bar',
'uid=u34,ou=users,dc=foo,dc=bar'
];
}
return [];
});

// for primary groups
$access->expects($this->once())
Expand All @@ -132,7 +143,7 @@ public function testCountWithSearchString() {

$access->expects($this->any())
->method('fetchListOfUsers')
->will($this->returnValue(array()));
->will($this->returnValue([]));

$access->expects($this->any())
->method('readAttribute')
Expand All @@ -145,7 +156,7 @@ public function testCountWithSearchString() {
if(strpos($name, 'u') === 0) {
return strpos($name, '3');
}
return array('u11', 'u22', 'u33', 'u34');
return ['u11', 'u22', 'u33', 'u34'];
}));

$access->expects($this->any())
Expand Down Expand Up @@ -625,7 +636,7 @@ public function testGetUserGroupsMemberOf() {
->method('dn2groupname')
->will($this->returnArgument(0));

$access->expects($this->exactly(3))
$access->expects($this->exactly(1))
->method('groupsMatchFilter')
->will($this->returnArgument(0));

Expand Down Expand Up @@ -659,14 +670,15 @@ public function testGetUserGroupsMemberOfDisabled() {
$access->expects($this->once())
->method('username2dn')
->will($this->returnValue($dn));

$access->expects($this->never())
->method('readAttribute')
->with($dn, 'memberOf');

$access->expects($this->once())
->method('nextcloudGroupNames')
->will($this->returnValue([]));
$access->expects($this->any())
->method('groupsMatchFilter')
->willReturnArgument(0);

$groupBackend = new GroupLDAP($access, $pluginManager);
$groupBackend->getUserGroups('userX');
Expand All @@ -680,12 +692,15 @@ public function testGetGroupsByMember() {
$access->connection->expects($this->any())
->method('__get')
->will($this->returnCallback(function($name) {
if($name === 'useMemberOfToDetectMembership') {
return 0;
} else if($name === 'ldapDynamicGroupMemberURL') {
return '';
} else if($name === 'ldapNestedGroups') {
return false;
switch($name) {
case 'useMemberOfToDetectMembership':
return 0;
case 'ldapDynamicGroupMemberURL':
return '';
case 'ldapNestedGroups':
return false;
case 'ldapGroupMemberAssocAttr':
return 'member';
}
return 1;
}));
Expand Down Expand Up @@ -716,10 +731,12 @@ public function testGetGroupsByMember() {
->method('nextcloudGroupNames')
->with([$group1, $group2])
->will($this->returnValue(['group1', 'group2']));

$access->expects($this->once())
->method('fetchListOfGroups')
->will($this->returnValue([$group1, $group2]));
$access->expects($this->any())
->method('groupsMatchFilter')
->willReturnArgument(0);

$groupBackend = new GroupLDAP($access, $pluginManager);
$groups = $groupBackend->getUserGroups('userX');
Expand Down Expand Up @@ -999,14 +1016,6 @@ public function groupMemberProvider() {
$groups1,
['cn=Birds,' . $base => $groups1]
],
[ #2 – test uids with nested groups
'cn=Birds,' . $base,
$expGroups2,
[
'cn=Birds,' . $base => $groups1,
'8427' => $groups2Nested, // simplified - nested groups would work with DNs
],
],
];
}

Expand Down Expand Up @@ -1045,9 +1054,7 @@ public function testGroupMembers($groupDN, $expectedMembers, $groupsInfo = null)
$ldap = new GroupLDAP($access, $pluginManager);
$resultingMembers = $this->invokePrivate($ldap, '_groupMembers', [$groupDN]);

$expected = array_keys(array_flip($expectedMembers));

$this->assertEquals($expected, array_keys($resultingMembers), '', 0.0, 10, true);
$this->assertEquals($expectedMembers, $resultingMembers, '', 0.0, 10, true);
}

}
8 changes: 8 additions & 0 deletions build/integration/features/bootstrap/LDAPContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

class LDAPContext implements Context {
use BasicStructure;
use CommandLine;

protected $configID;

Expand All @@ -37,6 +38,8 @@ public function teardown() {
if($this->configID === null) {
return;
}
$this->disableLDAPConfiguration(); # via occ in case of big config issues
$this->asAn('admin');
$this->sendingTo('DELETE', $this->apiUrl . '/' . $this->configID);
}

Expand Down Expand Up @@ -196,4 +199,9 @@ public function theRecordFieldsShouldMatch(TableNode $expectations) {
$backend = (string)simplexml_load_string($this->response->getBody())->data[0]->backend;
Assert::assertEquals('LDAP', $backend);
}

public function disableLDAPConfiguration() {
$configKey = $this->configID . 'ldap_configuration_active';
$this->invokingTheCommand('config:app:set user_ldap ' . $configKey . ' --value="0"');
}
}
Loading