/** * User rights management API * * @copyright Copyright (C) 2010-2017 Combodo SARL * @license http://opensource.org/licenses/AGPL-3.0 */ class UserRightException extends CoreException { } define('UR_ALLOWED_NO', 0); define('UR_ALLOWED_YES', 1); define('UR_ALLOWED_DEPENDS', 2); define('UR_ACTION_READ', 1); // View an object define('UR_ACTION_MODIFY', 2); // Create/modify an object/attribute define('UR_ACTION_DELETE', 3); // Delete an object define('UR_ACTION_BULK_READ', 4); // Export multiple objects define('UR_ACTION_BULK_MODIFY', 5); // Create/modify multiple objects define('UR_ACTION_BULK_DELETE', 6); // Delete multiple objects define('UR_ACTION_CREATE', 7); // Instantiate an object define('UR_ACTION_APPLICATION_DEFINED', 10000); // Application specific actions (CSV import, View schema...) /** * User management module API * * @package iTopORM */ abstract class UserRightsAddOnAPI { abstract public function CreateAdministrator($sAdminUser, $sAdminPwd, $sLanguage = 'EN US'); // could be used during initial installation abstract public function Init(); // loads data (possible optimizations) // Used to build select queries showing only objects visible for the given user abstract public function GetSelectFilter($sLogin, $sClass, $aSettings = array()); // returns a filter object abstract public function IsActionAllowed($oUser, $sClass, $iActionCode, /*dbObjectSet*/ $oInstanceSet = null); abstract public function IsStimulusAllowed($oUser, $sClass, $sStimulusCode, /*dbObjectSet*/ $oInstanceSet = null); abstract public function IsActionAllowedOnAttribute($oUser, $sClass, $sAttCode, $iActionCode, /*dbObjectSet*/ $oInstanceSet = null); abstract public function IsAdministrator($oUser); abstract public function IsPortalUser($oUser); abstract public function FlushPrivileges(); /** * Default behavior for addons that do not support profiles * * @param $oUser User * @return array */ public function ListProfiles($oUser) { return array(); } /** * ... */ public function MakeSelectFilter($sClass, $aAllowedOrgs, $aSettings = array(), $sAttCode = null) { if ($sAttCode == null) { $sAttCode = $this->GetOwnerOrganizationAttCode($sClass); } if (empty($sAttCode)) { return $oFilter = new DBObjectSearch($sClass); } $oExpression = new FieldExpression($sAttCode, $sClass); $oFilter = new DBObjectSearch($sClass); $oListExpr = ListExpression::FromScalars($aAllowedOrgs); $oCondition = new BinaryExpression($oExpression, 'IN', $oListExpr); $oFilter->AddConditionExpression($oCondition); if ($this->HasSharing()) { if (($sAttCode == 'id') && isset($aSettings['bSearchMode']) && $aSettings['bSearchMode']) { // Querying organizations (or derived) // and the expected list of organizations will be used as a search criteria // Therefore the query can also return organization having objects shared with the allowed organizations // // 1) build the list of organizations sharing something with the allowed organizations // Organization <== sharing_org_id == SharedObject having org_id IN {user orgs} $oShareSearch = new DBObjectSearch('SharedObject'); $oOrgField = new FieldExpression('org_id', 'SharedObject'); $oShareSearch->AddConditionExpression(new BinaryExpression($oOrgField, 'IN', $oListExpr)); $oSearchSharers = new DBObjectSearch('Organization'); $oSearchSharers->AllowAllData(); $oSearchSharers->AddCondition_ReferencedBy($oShareSearch, 'sharing_org_id'); $aSharers = array(); foreach($oSearchSharers->ToDataArray(array('id')) as $aRow) { $aSharers[] = $aRow['id']; } // 2) Enlarge the overall results: ... OR id IN(id1, id2, id3) if (count($aSharers) > 0) { $oSharersList = ListExpression::FromScalars($aSharers); $oFilter->MergeConditionExpression(new BinaryExpression($oExpression, 'IN', $oSharersList)); } } $aShareProperties = SharedObject::GetSharedClassProperties($sClass); if ($aShareProperties) { $sShareClass = $aShareProperties['share_class']; $sShareAttCode = $aShareProperties['attcode']; $oSearchShares = new DBObjectSearch($sShareClass); $oSearchShares->AllowAllData(); $sHierarchicalKeyCode = MetaModel::IsHierarchicalClass('Organization'); $oOrgField = new FieldExpression('org_id', $sShareClass); $oSearchShares->AddConditionExpression(new BinaryExpression($oOrgField, 'IN', $oListExpr)); $aShared = array(); foreach($oSearchShares->ToDataArray(array($sShareAttCode)) as $aRow) { $aShared[] = $aRow[$sShareAttCode]; } if (count($aShared) > 0) { $oObjId = new FieldExpression('id', $sClass); $oSharedIdList = ListExpression::FromScalars($aShared); $oFilter->MergeConditionExpression(new BinaryExpression($oObjId, 'IN', $oSharedIdList)); } } } // if HasSharing return $oFilter; } } require_once(APPROOT.'/application/cmdbabstract.class.inc.php'); abstract class User extends cmdbAbstractObject { public static function Init() { $aParams = array ( "category" => "core,grant_by_profile,silo", "key_type" => "autoincrement", "name_attcode" => "login", "state_attcode" => "", "reconc_keys" => array(), "db_table" => "priv_user", "db_key_field" => "id", "db_finalclass_field" => "", "display_template" => "", ); MetaModel::Init_Params($aParams); //MetaModel::Init_InheritAttributes(); MetaModel::Init_AddAttribute(new AttributeExternalKey("contactid", array("targetclass"=>"Person", "allowed_values"=>null, "sql"=>"contactid", "is_null_allowed"=>true, "on_target_delete"=>DEL_MANUAL, "depends_on"=>array()))); MetaModel::Init_AddAttribute(new AttributeExternalField("last_name", array("allowed_values"=>null, "extkey_attcode"=> 'contactid', "target_attcode"=>"name"))); MetaModel::Init_AddAttribute(new AttributeExternalField("first_name", array("allowed_values"=>null, "extkey_attcode"=> 'contactid', "target_attcode"=>"first_name"))); MetaModel::Init_AddAttribute(new AttributeExternalField("email", array("allowed_values"=>null, "extkey_attcode"=> 'contactid', "target_attcode"=>"email"))); MetaModel::Init_AddAttribute(new AttributeExternalField("org_id", array("allowed_values"=>null, "extkey_attcode"=> 'contactid', "target_attcode"=>"org_id"))); MetaModel::Init_AddAttribute(new AttributeString("login", array("allowed_values"=>null, "sql"=>"login", "default_value"=>null, "is_null_allowed"=>false, "depends_on"=>array()))); MetaModel::Init_AddAttribute(new AttributeApplicationLanguage("language", array("sql"=>"language", "default_value"=>"EN US", "is_null_allowed"=>false, "depends_on"=>array()))); MetaModel::Init_AddAttribute(new AttributeEnum("status", array("allowed_values" => new ValueSetEnum('enabled,disabled'), "sql"=>"status", "default_value"=>"enabled", "is_null_allowed"=>false, "depends_on"=>array()))); MetaModel::Init_AddAttribute(new AttributeLinkedSetIndirect("profile_list", array("linked_class"=>"URP_UserProfile", "ext_key_to_me"=>"userid", "ext_key_to_remote"=>"profileid", "allowed_values"=>null, "count_min"=>1, "count_max"=>0, "depends_on"=>array()))); MetaModel::Init_AddAttribute(new AttributeLinkedSetIndirect("allowed_org_list", array("linked_class"=>"URP_UserOrg", "ext_key_to_me"=>"userid", "ext_key_to_remote"=>"allowed_org_id", "allowed_values"=>null, "count_min"=>1, "count_max"=>0, "depends_on"=>array()))); // Display lists MetaModel::Init_SetZListItems('details', array('contactid', 'org_id', 'email', 'login', 'language', 'status', 'profile_list', 'allowed_org_list')); // Unused as it's an abstract class ! MetaModel::Init_SetZListItems('list', array('finalclass', 'first_name', 'last_name', 'status', 'org_id')); // Attributes to be displayed for a list // Search criteria MetaModel::Init_SetZListItems('standard_search', array('login', 'contactid', 'email', 'language', 'status', 'org_id')); // Criteria of the std search form MetaModel::Init_SetZListItems('default_search', array('login', 'contactid', 'org_id')); // Default criteria of the search banner } abstract public function CheckCredentials($sPassword); abstract public function TrustWebServerContext(); abstract public function CanChangePassword(); abstract public function ChangePassword($sOldPassword, $sNewPassword); /* * Compute a name in best effort mode */ public function GetFriendlyName() { if (!MetaModel::IsValidAttCode(get_class($this), 'contactid')) { return $this->Get('login'); } if ($this->Get('contactid') != 0) { $sFirstName = $this->Get('first_name'); $sLastName = $this->Get('last_name'); $sEmail = $this->Get('email'); if (strlen($sFirstName) > 0) { return "$sFirstName $sLastName"; } elseif (strlen($sEmail) > 0) { return "$sLastName <$sEmail>"; } else { return $sLastName; } } return $this->Get('login'); } protected $oContactObject; /** * Fetch and memorize the associated contact (if any) */ public function GetContactObject() { if (is_null($this->oContactObject)) { if (MetaModel::IsValidAttCode(get_class($this), 'contactid') && ($this->Get('contactid') != 0)) { $this->oContactObject = MetaModel::GetObject('Contact', $this->Get('contactid')); } } return $this->oContactObject; } /** * Overload the standard behavior. * * @throws \CoreException */ public function DoCheckToWrite() { parent::DoCheckToWrite(); // Note: This MUST be factorized later: declare unique keys (set of columns) in the data model $aChanges = $this->ListChanges(); if (array_key_exists('login', $aChanges)) { if (strcasecmp($this->Get('login'), $this->GetOriginal('login')) !== 0) { $sNewLogin = $aChanges['login']; $oSearch = DBObjectSearch::FromOQL_AllData("SELECT User WHERE login = :newlogin"); if (!$this->IsNew()) { $oSearch->AddCondition('id', $this->GetKey(), '!='); } $oSet = new DBObjectSet($oSearch, array(), array('newlogin' => $sNewLogin)); if ($oSet->Count() > 0) { $this->m_aCheckIssues[] = Dict::Format('Class:User/Error:LoginMustBeUnique', $sNewLogin); } } } // Check that this user has at least one profile assigned when profiles have changed if (array_key_exists('profile_list', $aChanges)) { $oSet = $this->Get('profile_list'); if ($oSet->Count() == 0) { $this->m_aCheckIssues[] = Dict::S('Class:User/Error:AtLeastOneProfileIsNeeded'); } } // Only administrators can manage administrators if (UserRights::IsAdministrator($this) && !UserRights::IsAdministrator()) { $this->m_aCheckIssues[] = Dict::S('UI:Login:Error:AccessRestricted'); } if (!UserRights::IsAdministrator()) { $oUser = UserRights::GetUserObject(); $oAddon = UserRights::GetModuleInstance(); if (!is_null($oUser) && method_exists($oAddon, 'GetUserOrgs')) { if ((empty($this->GetOriginal('contactid')) && !($this->IsNew())) || empty($this->Get('contactid'))) { $this->m_aCheckIssues[] = Dict::S('Class:User/Error:PersonIsMandatory'); } else { $aOrgs = $oAddon->GetUserOrgs($oUser, ''); if (count($aOrgs) > 0) { // Check that the modified User belongs to one of our organization if (!in_array($this->GetOriginal('org_id'), $aOrgs) && !in_array($this->Get('org_id'), $aOrgs)) { $this->m_aCheckIssues[] = Dict::S('Class:User/Error:UserOrganizationNotAllowed'); } // Check users with restricted organizations when allowed organizations have changed if ($this->IsNew() || array_key_exists('allowed_org_list', $aChanges)) { $oSet = $this->get('allowed_org_list'); if ($oSet->Count() == 0) { $this->m_aCheckIssues[] = Dict::S('Class:User/Error:AtLeastOneOrganizationIsNeeded'); } else { $aModifiedLinks = $oSet->ListModifiedLinks(); foreach ($aModifiedLinks as $oLink) { if (!in_array($oLink->Get('allowed_org_id'), $aOrgs)) { $this->m_aCheckIssues[] = Dict::S('Class:User/Error:OrganizationNotAllowed'); } } } } } } } } } function GetGrantAsHtml($sClass, $iAction) { if (UserRights::IsActionAllowed($sClass, $iAction, null, $this)) { return ''.Dict::S('UI:UserManagement:ActionAllowed:Yes').''; } else { return ''.Dict::S('UI:UserManagement:ActionAllowed:No').''; } } function DoShowGrantSumary($oPage, $sClassCategory) { if (UserRights::IsAdministrator($this)) { // Looks dirty, but ok that's THE ONE $oPage->p(Dict::S('UI:UserManagement:AdminProfile+')); return; } $oKPI = new ExecutionKPI(); $aDisplayData = array(); foreach (MetaModel::GetClasses($sClassCategory) as $sClass) { $aClassStimuli = MetaModel::EnumStimuli($sClass); if (count($aClassStimuli) > 0) { $aStimuli = array(); foreach ($aClassStimuli as $sStimulusCode => $oStimulus) { if (UserRights::IsStimulusAllowed($sClass, $sStimulusCode, null, $this)) { $aStimuli[] = ''.htmlentities($oStimulus->GetLabel(), ENT_QUOTES, 'UTF-8').''; } } $sStimuli = implode(', ', $aStimuli); } else { $sStimuli = ''.Dict::S('UI:UserManagement:NoLifeCycleApplicable').''; } $aDisplayData[] = array( 'class' => MetaModel::GetName($sClass), 'read' => $this->GetGrantAsHtml($sClass, UR_ACTION_READ), 'bulkread' => $this->GetGrantAsHtml($sClass, UR_ACTION_BULK_READ), 'write' => $this->GetGrantAsHtml($sClass, UR_ACTION_MODIFY), 'bulkwrite' => $this->GetGrantAsHtml($sClass, UR_ACTION_BULK_MODIFY), 'delete' => $this->GetGrantAsHtml($sClass, UR_ACTION_DELETE), 'bulkdelete' => $this->GetGrantAsHtml($sClass, UR_ACTION_BULK_DELETE), 'stimuli' => $sStimuli, ); } $oKPI->ComputeAndReport('Computation of user rights'); $aDisplayConfig = array(); $aDisplayConfig['class'] = array('label' => Dict::S('UI:UserManagement:Class'), 'description' => Dict::S('UI:UserManagement:Class+')); $aDisplayConfig['read'] = array('label' => Dict::S('UI:UserManagement:Action:Read'), 'description' => Dict::S('UI:UserManagement:Action:Read+')); $aDisplayConfig['bulkread'] = array('label' => Dict::S('UI:UserManagement:Action:BulkRead'), 'description' => Dict::S('UI:UserManagement:Action:BulkRead+')); $aDisplayConfig['write'] = array('label' => Dict::S('UI:UserManagement:Action:Modify'), 'description' => Dict::S('UI:UserManagement:Action:Modify+')); $aDisplayConfig['bulkwrite'] = array('label' => Dict::S('UI:UserManagement:Action:BulkModify'), 'description' => Dict::S('UI:UserManagement:Action:BulkModify+')); $aDisplayConfig['delete'] = array('label' => Dict::S('UI:UserManagement:Action:Delete'), 'description' => Dict::S('UI:UserManagement:Action:Delete+')); $aDisplayConfig['bulkdelete'] = array('label' => Dict::S('UI:UserManagement:Action:BulkDelete'), 'description' => Dict::S('UI:UserManagement:Action:BulkDelete+')); $aDisplayConfig['stimuli'] = array('label' => Dict::S('UI:UserManagement:Action:Stimuli'), 'description' => Dict::S('UI:UserManagement:Action:Stimuli+')); $oPage->table($aDisplayConfig, $aDisplayData); } function DisplayBareRelations(WebPage $oPage, $bEditMode = false) { parent::DisplayBareRelations($oPage, $bEditMode); if (!$bEditMode) { $oPage->SetCurrentTab(Dict::S('UI:UserManagement:GrantMatrix')); $this->DoShowGrantSumary($oPage, 'bizmodel,grant_by_profile'); // debug if (false) { $oPage->SetCurrentTab('More on user rigths (dev only)'); $oPage->add("
sAuthentication = $sAuthentication
\n"; assert(false); // should never happen } $oSearch = DBObjectSearch::FromOQL("SELECT $sBaseClass WHERE login = :login"); if (!$bAllowDisabledUsers) { $oSearch->AddCondition('status', 'enabled'); } $oSet = new DBObjectSet($oSearch, array(), array('login' => $sLogin)); $oUser = $oSet->fetch(); self::$m_aCacheUsers[$sAuthentication][$sLogin] = $oUser; } $oUser = self::$m_aCacheUsers[$sAuthentication][$sLogin]; } return $oUser; } public static function MakeSelectFilter($sClass, $aAllowedOrgs, $aSettings = array(), $sAttCode = null) { return self::$m_oAddOn->MakeSelectFilter($sClass, $aAllowedOrgs, $aSettings, $sAttCode); } public static function _InitSessionCache() { // Cache data about the current user into the session if (isset($_SESSION)) { $_SESSION['profile_list'] = self::ListProfiles(); } $oConfig = MetaModel::GetConfig(); $bSessionIdRegeneration = $oConfig->Get('regenerate_session_id_enabled'); if ($bSessionIdRegeneration) { // Protection against session fixation/injection: generate a new session id. // Alas a PHP bug (technically a bug in the memcache session handler, https://bugs.php.net/bug.php?id=71187) // causes session_regenerate_id to fail with a catchable fatal error in PHP 7.0 if the session handler is memcache(d). // The bug has been fixed in PHP 7.2, but in case session_regenerate_id() // fails we just silently ignore the error and keep the same session id... $old_error_handler = set_error_handler(array(__CLASS__, 'VoidErrorHandler')); session_regenerate_id(); if ($old_error_handler !== null) { set_error_handler($old_error_handler); } } } public static function _ResetSessionCache() { if (isset($_SESSION['profile_list'])) { unset($_SESSION['profile_list']); } if (isset($_SESSION['archive_allowed'])) { unset($_SESSION['archive_allowed']); } } /** * Fake error handler to silently discard fatal errors * @param int $iErrNo * @param string $sErrStr * @param string $sErrFile * @param int $iErrLine * @return boolean */ public static function VoidErrorHandler($iErrno, $sErrStr, $sErrFile, $iErrLine) { return true; // Ignore the error } /** * @return null|array The last login/result (null if none has failed) the array has this structure : array('sName' => $sName, 'bSuccess' => $bSuccess); */ public static function GetLastLoginStatus() { return self::$m_sLastLoginStatus; } } /** * Helper class to get the number/list of items for which a given action is allowed/possible */ class ActionChecker { var $oFilter; var $iActionCode; var $iAllowedCount = null; var $aAllowedIDs = null; public function __construct(DBSearch $oFilter, $iActionCode) { $this->oFilter = $oFilter; $this->iActionCode = $iActionCode; $this->iAllowedCount = null; $this->aAllowedIDs = null; } /** * returns the number of objects for which the action is allowed * @return integer The number of "allowed" objects 0..N */ public function GetAllowedCount() { if ($this->iAllowedCount == null) $this->CheckObjects(); return $this->iAllowedCount; } /** * If IsAllowed returned UR_ALLOWED_DEPENDS, this methods returns * an array of ObjKey => Status (true|false) * @return array */ public function GetAllowedIDs() { if ($this->aAllowedIDs == null) $this->IsAllowed(); return $this->aAllowedIDs; } /** * Check if the speficied stimulus is allowed for the set of objects * @return UR_ALLOWED_YES, UR_ALLOWED_NO or UR_ALLOWED_DEPENDS */ public function IsAllowed() { $sClass = $this->oFilter->GetClass(); $oSet = new DBObjectSet($this->oFilter); $iActionAllowed = UserRights::IsActionAllowed($sClass, $this->iActionCode, $oSet); if ($iActionAllowed == UR_ALLOWED_DEPENDS) { // Check for each object if the action is allowed or not $this->aAllowedIDs = array(); $oSet->Rewind(); $this->iAllowedCount = 0; while($oObj = $oSet->Fetch()) { $oObjSet = DBObjectSet::FromArray($sClass, array($oObj)); if (UserRights::IsActionAllowed($sClass, $this->iActionCode, $oObjSet) == UR_ALLOWED_NO) { $this->aAllowedIDs[$oObj->GetKey()] = false; } else { // Assume UR_ALLOWED_YES, since there is just one object ! $this->aAllowedIDs[$oObj->GetKey()] = true; $this->iAllowedCount++; } } } else if ($iActionAllowed == UR_ALLOWED_YES) { $this->iAllowedCount = $oSet->Count(); $this->aAllowedIDs = array(); // Optimization: not filled when Ok for all objects } else // UR_ALLOWED_NO { $this->iAllowedCount = 0; $this->aAllowedIDs = array(); } return $iActionAllowed; } } /** * Helper class to get the number/list of items for which a given stimulus can be applied (allowed & possible) */ class StimulusChecker extends ActionChecker { var $sState = null; public function __construct(DBSearch $oFilter, $sState, $iStimulusCode) { parent::__construct($oFilter, $iStimulusCode); $this->sState = $sState; } /** * Check if the speficied stimulus is allowed for the set of objects * @return UR_ALLOWED_YES, UR_ALLOWED_NO or UR_ALLOWED_DEPENDS */ public function IsAllowed() { $sClass = $this->oFilter->GetClass(); if (MetaModel::IsAbstract($sClass)) return UR_ALLOWED_NO; // Safeguard, not implemented if the base class of the set is abstract ! $oSet = new DBObjectSet($this->oFilter); $iActionAllowed = UserRights::IsStimulusAllowed($sClass, $this->iActionCode, $oSet); if ($iActionAllowed == UR_ALLOWED_NO) { $this->iAllowedCount = 0; $this->aAllowedIDs = array(); } else // Even if UR_ALLOWED_YES, we need to check if each object is in the appropriate state { // Hmmm, may not be needed right now because we limit the "multiple" action to object in // the same state... may be useful later on if we want to extend this behavior... // Check for each object if the action is allowed or not $this->aAllowedIDs = array(); $oSet->Rewind(); $iAllowedCount = 0; $iActionAllowed = UR_ALLOWED_DEPENDS; while($oObj = $oSet->Fetch()) { $aTransitions = $oObj->EnumTransitions(); if (array_key_exists($this->iActionCode, $aTransitions)) { // Temporary optimization possible: since the current implementation // of IsActionAllowed does not perform a 'per instance' check, we could // skip this second validation phase and assume it would return UR_ALLOWED_YES $oObjSet = DBObjectSet::FromArray($sClass, array($oObj)); if (!UserRights::IsStimulusAllowed($sClass, $this->iActionCode, $oObjSet)) { $this->aAllowedIDs[$oObj->GetKey()] = false; } else { // Assume UR_ALLOWED_YES, since there is just one object ! $this->aAllowedIDs[$oObj->GetKey()] = true; $this->iState = $oObj->GetState(); $this->iAllowedCount++; } } else { $this->aAllowedIDs[$oObj->GetKey()] = false; } } } if ($this->iAllowedCount == $oSet->Count()) { $iActionAllowed = UR_ALLOWED_YES; } if ($this->iAllowedCount == 0) { $iActionAllowed = UR_ALLOWED_NO; } return $iActionAllowed; } public function GetState() { return $this->iState; } }