Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[stable15] resolve user and groups in nested groups first before filtering the results #14591

Merged
merged 8 commits into from
Mar 8, 2019
Merged
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