diff options
Diffstat (limited to 'library/Icinga/Authentication/UserGroup')
4 files changed, 1515 insertions, 0 deletions
diff --git a/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php new file mode 100644 index 0000000..5299bbb --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/DbUserGroupBackend.php @@ -0,0 +1,325 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\UserGroup; + +use Exception; +use Icinga\Data\Filter\Filter; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Exception\NotFoundError; +use Icinga\Repository\DbRepository; +use Icinga\Repository\RepositoryQuery; +use Icinga\User; + +class DbUserGroupBackend extends DbRepository implements Inspectable, UserGroupBackendInterface +{ + /** + * The query columns being provided + * + * @var array + */ + protected $queryColumns = array( + 'group' => array( + 'group_id' => 'g.id', + 'group' => 'g.name COLLATE utf8mb4_general_ci', + 'group_name' => 'g.name', + 'parent' => 'g.parent', + 'created_at' => 'UNIX_TIMESTAMP(g.ctime)', + 'last_modified' => 'UNIX_TIMESTAMP(g.mtime)' + ), + 'group_membership' => array( + 'group_id' => 'gm.group_id', + 'user' => 'gm.username COLLATE utf8mb4_general_ci', + 'user_name' => 'gm.username', + 'created_at' => 'UNIX_TIMESTAMP(gm.ctime)', + 'last_modified' => 'UNIX_TIMESTAMP(gm.mtime)' + ) + ); + + /** + * The table aliases being applied + * + * @var array + */ + protected $tableAliases = array( + 'group' => 'g', + 'group_membership' => 'gm' + ); + + /** + * The statement columns being provided + * + * @var array + */ + protected $statementColumns = array( + 'group' => array( + 'group_id' => 'id', + 'group_name' => 'name', + 'parent' => 'parent', + 'created_at' => 'ctime', + 'last_modified' => 'mtime' + ), + 'group_membership' => array( + 'group_id' => 'group_id', + 'group_name' => 'group_id', + 'user_name' => 'username', + 'created_at' => 'ctime', + 'last_modified' => 'mtime' + ) + ); + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $blacklistedQueryColumns = array('group', 'user'); + + /** + * The search columns being provided + * + * @var array + */ + protected $searchColumns = array('group', 'user'); + + /** + * The value conversion rules to apply on a query or statement + * + * @var array + */ + protected $conversionRules = array( + 'group' => array( + 'parent' => 'group_id' + ), + 'group_membership' => array( + 'group_name' => 'group_id' + ) + ); + + /** + * Initialize this database user group backend + */ + protected function init() + { + if (! $this->ds->getTablePrefix()) { + $this->ds->setTablePrefix('icingaweb_'); + } + } + + /** + * Initialize this repository's filter columns + * + * @return array + */ + protected function initializeFilterColumns() + { + $userLabel = t('Username') . ' ' . t('(Case insensitive)'); + $groupLabel = t('User Group') . ' ' . t('(Case insensitive)'); + return array( + $userLabel => 'user', + t('Username') => 'user_name', + $groupLabel => 'group', + t('User Group') => 'group_name', + t('Parent') => 'parent', + t('Created At') => 'created_at', + t('Last modified') => 'last_modified' + ); + } + + /** + * Insert a table row with the given data + * + * @param string $table + * @param array $bind + */ + public function insert($table, array $bind, array $types = array()) + { + $bind['created_at'] = date('Y-m-d H:i:s'); + parent::insert($table, $bind); + } + + /** + * Update table rows with the given data, optionally limited by using a filter + * + * @param string $table + * @param array $bind + * @param Filter $filter + */ + public function update($table, array $bind, Filter $filter = null, array $types = array()) + { + $bind['last_modified'] = date('Y-m-d H:i:s'); + parent::update($table, $bind, $filter); + } + + /** + * Delete table rows, optionally limited by using a filter + * + * @param string $table + * @param Filter $filter + */ + public function delete($table, Filter $filter = null) + { + if ($table === 'group') { + parent::delete('group_membership', $filter); + $idQuery = $this->select(array('group_id')); + if ($filter !== null) { + $idQuery->applyFilter($filter); + } + + $this->update('group', array('parent' => null), Filter::where('parent', $idQuery->fetchColumn())); + } + + parent::delete($table, $filter); + } + + /** + * Return the groups the given user is a member of + * + * @param User $user + * + * @return array + */ + public function getMemberships(User $user) + { + $groupQuery = $this->ds + ->select() + ->from( + array('g' => $this->prependTablePrefix('group')), + array( + 'group_name' => 'g.name', + 'parent_name' => 'gg.name' + ) + )->joinLeft( + array('gg' => $this->prependTablePrefix('group')), + 'g.parent = gg.id', + array() + ); + + $groups = array(); + foreach ($groupQuery as $group) { + $groups[$group->group_name] = $group->parent_name; + } + + $membershipQuery = $this + ->select() + ->from('group_membership', array('group_name')) + ->where('user_name', $user->getUsername()); + + $memberships = array(); + foreach ($membershipQuery as $membership) { + $memberships[] = $membership->group_name; + $parent = $groups[$membership->group_name]; + while ($parent !== null) { + $memberships[] = $parent; + // Usually a parent is an existing group, but since we do not have a constraint on our table.. + $parent = isset($groups[$parent]) ? $groups[$parent] : null; + } + } + + return $memberships; + } + + /** + * Return the name of the backend that is providing the given user + * + * @param string $username Currently unused + * + * @return null|string The name of the backend or null in case this information is not available + */ + public function getUserBackendName($username) + { + return null; // TODO(10373): Store this to the database when inserting and fetch it here + } + + /** + * Join group into group_membership + * + * @param RepositoryQuery $query + */ + protected function joinGroup(RepositoryQuery $query) + { + $query->getQuery()->join( + $this->requireTable('group'), + 'gm.group_id = g.id', + array() + ); + } + + /** + * Join group_membership into group + * + * @param RepositoryQuery $query + */ + protected function joinGroupMembership(RepositoryQuery $query) + { + $query->getQuery()->joinLeft( + $this->requireTable('group_membership'), + 'g.id = gm.group_id', + array() + )->group('g.id'); + } + + /** + * Fetch and return the corresponding id for the given group's name + * + * @param string|array $groupName + * + * @return int + * + * @throws NotFoundError + */ + protected function persistGroupId($groupName) + { + if (empty($groupName) || is_numeric($groupName)) { + return $groupName; + } + + if (is_array($groupName)) { + if (is_numeric($groupName[0])) { + return $groupName; // In case the array contains mixed types... + } + + $groupIds = $this->ds + ->select() + ->from($this->prependTablePrefix('group'), array('id')) + ->where('name', $groupName) + ->fetchColumn(); + if (empty($groupIds)) { + throw new NotFoundError('No groups found matching one of: %s', implode(', ', $groupName)); + } + + return $groupIds; + } + + $groupId = $this->ds + ->select() + ->from($this->prependTablePrefix('group'), array('id')) + ->where('name', $groupName) + ->fetchOne(); + if ($groupId === false) { + throw new NotFoundError('Group "%s" does not exist', $groupName); + } + + return $groupId; + } + + /** + * Inspect this object to gain extended information about its health + * + * @return Inspection The inspection result + */ + public function inspect() + { + $insp = new Inspection('Db User Group Backend'); + $insp->write($this->ds->inspect()); + + try { + $insp->write(sprintf('%s group(s)', $this->select()->count())); + } catch (Exception $e) { + $insp->error(sprintf('Query failed: %s', $e->getMessage())); + } + + return $insp; + } +} diff --git a/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php new file mode 100644 index 0000000..e78242e --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/LdapUserGroupBackend.php @@ -0,0 +1,945 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\UserGroup; + +use Exception; +use Icinga\Authentication\User\UserBackend; +use Icinga\Authentication\User\LdapUserBackend; +use Icinga\Application\Logger; +use Icinga\Data\ConfigObject; +use Icinga\Data\Inspectable; +use Icinga\Data\Inspection; +use Icinga\Exception\AuthenticationException; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\ProgrammingError; +use Icinga\Exception\QueryException; +use Icinga\Protocol\Ldap\LdapException; +use Icinga\Protocol\Ldap\LdapUtils; +use Icinga\Repository\LdapRepository; +use Icinga\Repository\RepositoryQuery; +use Icinga\User; + +class LdapUserGroupBackend extends LdapRepository implements Inspectable, UserGroupBackendInterface +{ + /** + * The user backend being associated with this user group backend + * + * @var LdapUserBackend + */ + protected $userBackend; + + /** + * The base DN to use for a user query + * + * @var string + */ + protected $userBaseDn; + + /** + * The base DN to use for a group query + * + * @var string + */ + protected $groupBaseDn; + + /** + * The objectClass where look for users + * + * @var string + */ + protected $userClass; + + /** + * The objectClass where look for groups + * + * @var string + */ + protected $groupClass; + + /** + * The attribute name where to find a user's name + * + * @var string + */ + protected $userNameAttribute; + + /** + * The attribute name where to find a group's name + * + * @var string + */ + protected $groupNameAttribute; + + /** + * The attribute name where to find a group's member + * + * @var string + */ + protected $groupMemberAttribute; + + /** + * Whether the attribute name where to find a group's member holds ambiguous values + * + * @var bool + */ + protected $ambiguousMemberAttribute; + + /** + * The custom LDAP filter to apply on a user query + * + * @var string + */ + protected $userFilter; + + /** + * The custom LDAP filter to apply on a group query + * + * @var string + */ + protected $groupFilter; + + /** + * ActiveDirectory nested group on the user? + * + * @var bool + */ + protected $nestedGroupSearch; + + /** + * The domain the backend is responsible for + * + * @var string + */ + protected $domain; + + /** + * The columns which are not permitted to be queried + * + * @var array + */ + protected $blacklistedQueryColumns = array('group', 'user'); + + /** + * The search columns being provided + * + * @var array + */ + protected $searchColumns = array('group', 'user'); + + /** + * The default sort rules to be applied on a query + * + * @var array + */ + protected $sortRules = array( + 'group_name' => array( + 'order' => 'asc' + ) + ); + + /** + * Set the user backend to be associated with this user group backend + * + * @param LdapUserBackend $backend + * + * @return $this + */ + public function setUserBackend(LdapUserBackend $backend) + { + $this->userBackend = $backend; + return $this; + } + + /** + * Return the user backend being associated with this user group backend + * + * @return LdapUserBackend + */ + public function getUserBackend() + { + return $this->userBackend; + } + + /** + * Set the base DN to use for a user query + * + * @param string $baseDn + * + * @return $this + */ + public function setUserBaseDn($baseDn) + { + if ($baseDn && ($baseDn = trim($baseDn))) { + $this->userBaseDn = $baseDn; + } + + return $this; + } + + /** + * Return the base DN to use for a user query + * + * @return string + */ + public function getUserBaseDn() + { + return $this->userBaseDn; + } + + /** + * Set the base DN to use for a group query + * + * @param string $baseDn + * + * @return $this + */ + public function setGroupBaseDn($baseDn) + { + if ($baseDn && ($baseDn = trim($baseDn))) { + $this->groupBaseDn = $baseDn; + } + + return $this; + } + + /** + * Return the base DN to use for a group query + * + * @return string + */ + public function getGroupBaseDn() + { + return $this->groupBaseDn; + } + + /** + * Set the objectClass where to look for users + * + * @param string $userClass + * + * @return $this + */ + public function setUserClass($userClass) + { + $this->userClass = $this->getNormedAttribute($userClass); + return $this; + } + + /** + * Return the objectClass where to look for users + * + * @return string + */ + public function getUserClass() + { + return $this->userClass; + } + + /** + * Set the objectClass where to look for groups + * + * @param string $groupClass + * + * @return $this + */ + public function setGroupClass($groupClass) + { + $this->groupClass = $this->getNormedAttribute($groupClass); + return $this; + } + + /** + * Return the objectClass where to look for groups + * + * @return string + */ + public function getGroupClass() + { + return $this->groupClass; + } + + /** + * Set the attribute name where to find a user's name + * + * @param string $userNameAttribute + * + * @return $this + */ + public function setUserNameAttribute($userNameAttribute) + { + $this->userNameAttribute = $this->getNormedAttribute($userNameAttribute); + return $this; + } + + /** + * Return the attribute name where to find a user's name + * + * @return string + */ + public function getUserNameAttribute() + { + return $this->userNameAttribute; + } + + /** + * Set the attribute name where to find a group's name + * + * @param string $groupNameAttribute + * + * @return $this + */ + public function setGroupNameAttribute($groupNameAttribute) + { + $this->groupNameAttribute = $this->getNormedAttribute($groupNameAttribute); + return $this; + } + + /** + * Return the attribute name where to find a group's name + * + * @return string + */ + public function getGroupNameAttribute() + { + return $this->groupNameAttribute; + } + + /** + * Set the attribute name where to find a group's member + * + * @param string $groupMemberAttribute + * + * @return $this + */ + public function setGroupMemberAttribute($groupMemberAttribute) + { + $this->groupMemberAttribute = $this->getNormedAttribute($groupMemberAttribute); + return $this; + } + + /** + * Return the attribute name where to find a group's member + * + * @return string + */ + public function getGroupMemberAttribute() + { + return $this->groupMemberAttribute; + } + + /** + * Set the custom LDAP filter to apply on a user query + * + * @param string $filter + * + * @return $this + */ + public function setUserFilter($filter) + { + if ($filter && ($filter = trim($filter))) { + if ($filter[0] === '(') { + $filter = substr($filter, 1, -1); + } + + $this->userFilter = $filter; + } + + return $this; + } + + /** + * Return the custom LDAP filter to apply on a user query + * + * @return string + */ + public function getUserFilter() + { + return $this->userFilter; + } + + /** + * Set the custom LDAP filter to apply on a group query + * + * @param string $filter + * + * @return $this + */ + public function setGroupFilter($filter) + { + if ($filter && ($filter = trim($filter))) { + $this->groupFilter = $filter; + } + + return $this; + } + + /** + * Return the custom LDAP filter to apply on a group query + * + * @return string + */ + public function getGroupFilter() + { + return $this->groupFilter; + } + + /** + * Set nestedGroupSearch for the group query + * + * @param bool $enable + * + * @return $this + */ + public function setNestedGroupSearch($enable = true) + { + $this->nestedGroupSearch = $enable; + return $this; + } + + /** + * Get nestedGroupSearch for the group query + * + * @return bool + */ + public function getNestedGroupSearch() + { + return $this->nestedGroupSearch; + } + + /** + * Get the domain the backend is responsible for + * + * If the LDAP group backend is linked with a LDAP user backend, + * the domain of the user backend will be returned. + * + * @return string + */ + public function getDomain() + { + return $this->userBackend !== null ? $this->userBackend->getDomain() : $this->domain; + } + + /** + * Set the domain the backend is responsible for + * + * If the LDAP group backend is linked with a LDAP user backend, + * the domain of the user backend will be used nonetheless. + * + * @param string $domain + * + * @return $this + */ + public function setDomain($domain) + { + if ($domain && ($domain = trim($domain))) { + $this->domain = $domain; + } + + return $this; + } + + /** + * Return whether the attribute name where to find a group's member holds ambiguous values + * + * This tries to detect if the member attribute of groups contain: + * + * full DN -> distinguished name of another object + * other -> ambiguous field referencing the member by userNameAttribute + * + * @return bool + * + * @throws ProgrammingError In case either $this->groupClass or $this->groupMemberAttribute + * has not been set yet + */ + protected function isMemberAttributeAmbiguous() + { + if ($this->ambiguousMemberAttribute === null) { + if ($this->groupClass === null) { + throw new ProgrammingError( + 'It is required to set the objectClass where to look for groups first' + ); + } elseif ($this->groupMemberAttribute === null) { + throw new ProgrammingError( + 'It is required to set a attribute name where to find a group\'s members first' + ); + } + + $sampleValues = $this->ds + ->select() + ->from($this->groupClass, array($this->groupMemberAttribute)) + ->where($this->groupMemberAttribute, '*') + ->limit(Logger::getInstance()->getLevel() === Logger::DEBUG ? 3 : 1) + ->setUnfoldAttribute($this->groupMemberAttribute) + ->setBase($this->groupBaseDn) + ->fetchAll(); + + Logger::debug('Ambiguity query returned %d results', count($sampleValues)); + + $i = 0; + $sampleValue = null; + foreach ($sampleValues as $key => $value) { + if ($sampleValue === null) { + $sampleValue = $value; + } + + Logger::debug('Result %d: %s (%s)', ++$i, $value, $key); + } + + if (is_object($sampleValue) && isset($sampleValue->{$this->groupMemberAttribute})) { + $this->ambiguousMemberAttribute = ! LdapUtils::isDn($sampleValue->{$this->groupMemberAttribute}); + + Logger::debug( + 'Ambiguity check came to the conclusion that the member attribute %s ambiguous. Tested sample: %s', + $this->ambiguousMemberAttribute ? 'is' : 'is not', + $sampleValue->{$this->groupMemberAttribute} + ); + } else { + Logger::warning( + 'Ambiguity query returned zero or invalid results. Sample value is `%s`', + print_r($sampleValue, true) + ); + } + } + + return $this->ambiguousMemberAttribute; + } + + /** + * Initialize this repository's virtual tables + * + * @return array + * + * @throws ProgrammingError In case $this->groupClass has not been set yet + */ + protected function initializeVirtualTables() + { + if ($this->groupClass === null) { + throw new ProgrammingError('It is required to set the object class where to find groups first'); + } + + return array( + 'group' => $this->groupClass, + 'group_membership' => $this->groupClass + ); + } + + /** + * Initialize this repository's query columns + * + * @return array + * + * @throws ProgrammingError In case either $this->groupNameAttribute or + * $this->groupMemberAttribute has not been set yet + */ + protected function initializeQueryColumns() + { + if ($this->groupNameAttribute === null) { + throw new ProgrammingError('It is required to set a attribute name where to find a group\'s name first'); + } + if ($this->groupMemberAttribute === null) { + throw new ProgrammingError('It is required to set a attribute name where to find a group\'s members first'); + } + + if ($this->ds->getCapabilities()->isActiveDirectory()) { + $createdAtAttribute = 'whenCreated'; + $lastModifiedAttribute = 'whenChanged'; + } else { + $createdAtAttribute = 'createTimestamp'; + $lastModifiedAttribute = 'modifyTimestamp'; + } + + $columns = array( + 'group' => $this->groupNameAttribute, + 'group_name' => $this->groupNameAttribute, + 'user' => $this->groupMemberAttribute, + 'user_name' => $this->groupMemberAttribute, + 'created_at' => $createdAtAttribute, + 'last_modified' => $lastModifiedAttribute + ); + return array('group' => $columns, 'group_membership' => $columns); + } + + /** + * Initialize this repository's filter columns + * + * @return array + */ + protected function initializeFilterColumns() + { + return array( + t('Username') => 'user_name', + t('User Group') => 'group_name', + t('Created At') => 'created_at', + t('Last modified') => 'last_modified' + ); + } + + /** + * Initialize this repository's conversion rules + * + * @return array + */ + protected function initializeConversionRules() + { + $rules = array( + 'group' => array( + 'created_at' => 'generalized_time', + 'last_modified' => 'generalized_time' + ), + 'group_membership' => array( + 'created_at' => 'generalized_time', + 'last_modified' => 'generalized_time' + ) + ); + if (! $this->isMemberAttributeAmbiguous()) { + $rules['group_membership']['user_name'] = 'user_name'; + $rules['group_membership']['user'] = 'user_name'; + $rules['group']['user_name'] = 'user_name'; + $rules['group']['user'] = 'user_name'; + } + + return $rules; + } + + /** + * Return the distinguished name for the given uid or gid + * + * @param string $name + * + * @return string + */ + protected function persistUserName($name) + { + try { + $userDn = $this->ds + ->select() + ->from($this->userClass, array()) + ->where($this->userNameAttribute, $name) + ->setBase($this->userBaseDn) + ->setUsePagedResults(false) + ->fetchDn(); + if ($userDn) { + return $userDn; + } + + $groupDn = $this->ds + ->select() + ->from($this->groupClass, array()) + ->where($this->groupNameAttribute, $name) + ->setBase($this->groupBaseDn) + ->setUsePagedResults(false) + ->fetchDn(); + if ($groupDn) { + return $groupDn; + } + } catch (LdapException $_) { + // pass + } + + Logger::debug('Unable to persist uid or gid "%s" in repository "%s". No DN found.', $name, $this->getName()); + return $name; + } + + /** + * Return the uid for the given distinguished name + * + * @param string $username + * + * @return string + */ + protected function retrieveUserName($dn) + { + return $this->ds + ->select() + ->from('*', array($this->userNameAttribute)) + ->setUnfoldAttribute($this->userNameAttribute) + ->setBase($dn) + ->fetchOne(); + } + + /** + * Validate that the requested table exists + * + * @param string $table The table to validate + * @param RepositoryQuery $query An optional query to pass as context + * + * @return string + * + * @throws ProgrammingError In case the given table does not exist + */ + public function requireTable($table, RepositoryQuery $query = null) + { + if ($query !== null) { + $query->getQuery()->setBase($this->groupBaseDn); + if ($table === 'group' && $this->groupFilter) { + $query->getQuery()->setNativeFilter($this->groupFilter); + } + } + + return parent::requireTable($table, $query); + } + + /** + * Validate that the given column is a valid query target and return it or the actual name if it's an alias + * + * @param string $table The table where to look for the column or alias + * @param string $name The name or alias of the column to validate + * @param RepositoryQuery $query An optional query to pass as context + * + * @return string The given column's name + * + * @throws QueryException In case the given column is not a valid query column + */ + public function requireQueryColumn($table, $name, RepositoryQuery $query = null) + { + $column = parent::requireQueryColumn($table, $name, $query); + if (($name === 'user_name' || $name === 'group_name') && $query !== null) { + $query->getQuery()->setUnfoldAttribute($name); + } + + return $column; + } + + /** + * Return the groups the given user is a member of + * + * @param User $user + * + * @return array + */ + public function getMemberships(User $user) + { + $domain = $this->getDomain(); + + if ($domain !== null) { + if (! $user->hasDomain() || strtolower($user->getDomain()) !== strtolower($domain)) { + return array(); + } + + $username = $user->getLocalUsername(); + } else { + $username = $user->getUsername(); + } + + if ($this->isMemberAttributeAmbiguous()) { + $queryValue = $username; + } elseif (($queryValue = $user->getAdditional('ldap_dn')) === null) { + $userQuery = $this->ds + ->select() + ->from($this->userClass) + ->where($this->userNameAttribute, $username) + ->setBase($this->userBaseDn) + ->setUsePagedResults(false); + if ($this->userFilter) { + $userQuery->setNativeFilter($this->userFilter); + } + + if (($queryValue = $userQuery->fetchDn()) === null) { + return array(); + } + } + + if ($this->nestedGroupSearch) { + $groupMemberAttribute = $this->groupMemberAttribute . ':1.2.840.113556.1.4.1941:'; + } else { + $groupMemberAttribute = $this->groupMemberAttribute; + } + + $groupQuery = $this->ds + ->select() + ->from($this->groupClass, array($this->groupNameAttribute)) + ->setUnfoldAttribute($this->groupNameAttribute) + ->where($groupMemberAttribute, $queryValue) + ->setBase($this->groupBaseDn); + if ($this->groupFilter) { + $groupQuery->setNativeFilter($this->groupFilter); + } + + $groups = array(); + foreach ($groupQuery as $row) { + $groups[] = $row->{$this->groupNameAttribute}; + if ($domain !== null) { + $groups[] = $row->{$this->groupNameAttribute} . "@$domain"; + } + } + + return $groups; + } + + /** + * Return the name of the backend that is providing the given user + * + * @param string $username Unused + * + * @return null|string The name of the backend or null in case this information is not available + */ + public function getUserBackendName($username) + { + $userBackend = $this->getUserBackend(); + if ($userBackend !== null) { + return $userBackend->getName(); + } + } + + /** + * Apply the given configuration on this backend + * + * @param ConfigObject $config + * + * @return $this + * + * @throws ConfigurationError In case a linked user backend does not exist or is invalid + */ + public function setConfig(ConfigObject $config) + { + if ($config->backend === 'ldap') { + $defaults = $this->getOpenLdapDefaults(); + } elseif ($config->backend === 'msldap') { + $defaults = $this->getActiveDirectoryDefaults(); + } else { + $defaults = new ConfigObject(); + } + + if ($config->user_backend && $config->user_backend !== 'none') { + $userBackend = UserBackend::create($config->user_backend); + if (! $userBackend instanceof LdapUserBackend) { + throw new ConfigurationError('User backend "%s" is not of type LDAP', $config->user_backend); + } + + if ($this->ds->getHostname() !== $userBackend->getDataSource()->getHostname() + || $this->ds->getPort() !== $userBackend->getDataSource()->getPort() + ) { + // TODO(jom): Elaborate whether it makes sense to link directories on different hosts + throw new ConfigurationError( + 'It is required that a linked user backend refers to the ' + . 'same directory as it\'s user group backend counterpart' + ); + } + + $this->setUserBackend($userBackend); + $defaults->merge(array( + 'user_base_dn' => $userBackend->getBaseDn(), + 'user_class' => $userBackend->getUserClass(), + 'user_name_attribute' => $userBackend->getUserNameAttribute(), + 'user_filter' => $userBackend->getFilter(), + 'domain' => $userBackend->getDomain() + )); + } + + return $this + ->setGroupBaseDn($config->base_dn) + ->setUserBaseDn($config->get('user_base_dn', $defaults->get('user_base_dn', $this->getGroupBaseDn()))) + ->setGroupClass($config->get('group_class', $defaults->group_class)) + ->setUserClass($config->get('user_class', $defaults->user_class)) + ->setGroupNameAttribute($config->get('group_name_attribute', $defaults->group_name_attribute)) + ->setUserNameAttribute($config->get('user_name_attribute', $defaults->user_name_attribute)) + ->setGroupMemberAttribute($config->get('group_member_attribute', $defaults->group_member_attribute)) + ->setGroupFilter($config->group_filter) + ->setUserFilter($config->user_filter) + ->setNestedGroupSearch((bool) $config->get('nested_group_search', $defaults->nested_group_search)) + ->setDomain($defaults->get('domain', $config->domain)); + } + + /** + * Return the configuration defaults for an OpenLDAP environment + * + * @return ConfigObject + */ + public function getOpenLdapDefaults() + { + return new ConfigObject(array( + 'group_class' => 'group', + 'user_class' => 'inetOrgPerson', + 'group_name_attribute' => 'gid', + 'user_name_attribute' => 'uid', + 'group_member_attribute' => 'member', + 'nested_group_search' => '0' + )); + } + + /** + * Return the configuration defaults for an ActiveDirectory environment + * + * @return ConfigObject + */ + public function getActiveDirectoryDefaults() + { + return new ConfigObject(array( + 'group_class' => 'group', + 'user_class' => 'user', + 'group_name_attribute' => 'sAMAccountName', + 'user_name_attribute' => 'sAMAccountName', + 'group_member_attribute' => 'member', + 'nested_group_search' => '0' + )); + } + + /** + * Inspect if this LDAP User Group Backend is working as expected by probing the backend + * + * Try to bind to the backend and fetch a single group to check if: + * <ul> + * <li>Connection credentials are correct and the bind is possible</li> + * <li>At least one group exists</li> + * <li>The specified groupClass has the property specified by groupNameAttribute</li> + * </ul> + * + * @return Inspection Inspection result + */ + public function inspect() + { + $result = new Inspection('Ldap User Group Backend'); + + // inspect the used connection to get more diagnostic info in case the connection is not working + $result->write($this->ds->inspect()); + + try { + try { + $groupQuery = $this->ds + ->select() + ->from($this->groupClass, array($this->groupNameAttribute)) + ->setBase($this->groupBaseDn); + + if ($this->groupFilter) { + $groupQuery->setNativeFilter($this->groupFilter); + } + + $res = $groupQuery->fetchRow(); + } catch (LdapException $e) { + throw new AuthenticationException('Connection not possible', $e); + } + + $result->write('Searching for: ' . sprintf( + 'objectClass "%s" in DN "%s" (Filter: %s)', + $this->groupClass, + $this->groupBaseDn ?: $this->ds->getDn(), + $this->groupFilter ?: 'None' + )); + + if ($res === false) { + throw new AuthenticationException('Error, no groups found in backend'); + } + + $result->write(sprintf('%d groups found in backend', $groupQuery->count())); + + if (! isset($res->{$this->groupNameAttribute})) { + throw new AuthenticationException( + 'GroupNameAttribute "%s" not existing in objectClass "%s"', + $this->groupNameAttribute, + $this->groupClass + ); + } + } catch (AuthenticationException $e) { + if (($previous = $e->getPrevious()) !== null) { + $result->error($previous->getMessage()); + } else { + $result->error($e->getMessage()); + } + } catch (Exception $e) { + $result->error(sprintf('Unable to validate backend: %s', $e->getMessage())); + } + + return $result; + } +} diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackend.php b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php new file mode 100644 index 0000000..7f0bfcc --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/UserGroupBackend.php @@ -0,0 +1,189 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\UserGroup; + +use Icinga\Application\Logger; +use Icinga\Application\Icinga; +use Icinga\Data\ConfigObject; +use Icinga\Data\ResourceFactory; +use Icinga\Exception\ConfigurationError; + +/** + * Factory for user group backends + */ +class UserGroupBackend +{ + /** + * The default user group backend types provided by Icinga Web 2 + * + * @var array + */ + protected static $defaultBackends = array( + 'db', + 'ldap', + 'msldap' + ); + + /** + * The registered custom user group backends with their identifier as key and class name as value + * + * @var array + */ + protected static $customBackends; + + /** + * Register all custom user group backends from all loaded modules + */ + public static function registerCustomUserGroupBackends() + { + if (static::$customBackends !== null) { + return; + } + + static::$customBackends = array(); + $providedBy = array(); + foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $module) { + foreach ($module->getUserGroupBackends() as $identifier => $className) { + if (array_key_exists($identifier, $providedBy)) { + Logger::warning( + 'Cannot register user group backend of type "%s" provided by module "%s".' + . ' The type is already provided by module "%s"', + $identifier, + $module->getName(), + $providedBy[$identifier] + ); + } elseif (in_array($identifier, static::$defaultBackends)) { + Logger::warning( + 'Cannot register user group backend of type "%s" provided by module "%s".' + . ' The type is a default type provided by Icinga Web 2', + $identifier, + $module->getName() + ); + } else { + $providedBy[$identifier] = $module->getName(); + static::$customBackends[$identifier] = $className; + } + } + } + } + + /** + * Get config forms of all custom user group backends + */ + public static function getCustomBackendConfigForms() + { + $customBackendConfigForms = []; + static::registerCustomUserGroupBackends(); + foreach (self::$customBackends as $customBackendType => $customBackendClass) { + if (method_exists($customBackendClass, 'getConfigurationFormClass')) { + $customBackendConfigForms[$customBackendType] = $customBackendClass::getConfigurationFormClass(); + } + } + + return $customBackendConfigForms; + } + + /** + * Return the class for the given custom user group backend + * + * @param string $identifier The identifier of the custom user group backend + * + * @return string|null The name of the class or null in case there was no + * backend found with the given identifier + * + * @throws ConfigurationError In case the class associated to the given identifier does not exist + */ + protected static function getCustomUserGroupBackend($identifier) + { + static::registerCustomUserGroupBackends(); + if (array_key_exists($identifier, static::$customBackends)) { + $className = static::$customBackends[$identifier]; + if (! class_exists($className)) { + throw new ConfigurationError( + 'Cannot utilize user group backend of type "%s". Class "%s" does not exist', + $identifier, + $className + ); + } + + return $className; + } + } + + /** + * Create and return a user group backend with the given name and given configuration applied to it + * + * @param string $name + * @param ConfigObject $backendConfig + * + * @return UserGroupBackendInterface + * + * @throws ConfigurationError + */ + public static function create($name, ConfigObject $backendConfig) + { + if ($backendConfig->name !== null) { + $name = $backendConfig->name; + } + + if (! ($backendType = strtolower($backendConfig->backend))) { + throw new ConfigurationError( + 'Configuration for user group backend "%s" is missing the \'backend\' directive', + $name + ); + } + if (in_array($backendType, static::$defaultBackends)) { + // The default backend check is the first one because of performance reasons: + // Do not attempt to load a custom user group backend unless it's actually required + } elseif (($customClass = static::getCustomUserGroupBackend($backendType)) !== null) { + $backend = new $customClass($backendConfig); + if (! is_a($backend, 'Icinga\Authentication\UserGroup\UserGroupBackendInterface')) { + throw new ConfigurationError( + 'Cannot utilize user group backend of type "%s".' + . ' Class "%s" does not implement UserGroupBackendInterface', + $backendType, + $customClass + ); + } + + $backend->setName($name); + return $backend; + } else { + throw new ConfigurationError( + 'Configuration for user group backend "%s" defines an invalid backend type.' + . ' Backend type "%s" is not supported', + $name, + $backendType + ); + } + + if ($backendConfig->resource === null) { + throw new ConfigurationError( + 'Configuration for user group backend "%s" is missing the \'resource\' directive', + $name + ); + } + + $resourceConfig = ResourceFactory::getResourceConfig($backendConfig->resource); + if ($backendType === 'db' && $resourceConfig->db === 'mysql') { + $resourceConfig->charset = 'utf8mb4'; + } + + $backend = null; + $resource = ResourceFactory::createResource($resourceConfig); + switch ($backendType) { + case 'db': + $backend = new DbUserGroupBackend($resource); + break; + case 'ldap': + case 'msldap': + $backend = new LdapUserGroupBackend($resource); + $backend->setConfig($backendConfig); + break; + } + + $backend->setName($name); + return $backend; + } +} diff --git a/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php new file mode 100644 index 0000000..cc9438f --- /dev/null +++ b/library/Icinga/Authentication/UserGroup/UserGroupBackendInterface.php @@ -0,0 +1,56 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Authentication\UserGroup; + +use Icinga\User; + +/** + * Interface for user group backends + */ +interface UserGroupBackendInterface +{ + /** + * Set this backend's name + * + * @param string $name + * + * @return $this + */ + public function setName($name); + + /** + * Return this backend's name + * + * @return string + */ + public function getName(); + + /** + * Return the groups the given user is a member of + * + * @param User $user + * + * @return array + */ + public function getMemberships(User $user); + + /** + * Return the name of the backend that is providing the given user + * + * @param string $username + * + * @return null|string The name of the backend or null in case this information is not available + */ + public function getUserBackendName($username); + + /** + * Return this backend's configuration form class path + * + * This is not part of the interface to not break existing implementations. + * If you need a custom backend form, implement this method. + * + * @return string + */ + //public static function getConfigurationFormClass(); +} |