Compare commits

..

5 Commits

Author SHA1 Message Date
Stephen Abello
82baa2e5cb Update js/forms-json-utils.js
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-01 15:38:45 +02:00
Stephen Abello
7cd7ba74d4 Bring back lost methods 2026-04-01 15:34:04 +02:00
Stephen Abello
b23b336d60 N°8758 - Fix mandatory caselog in transition requiring double confirmation 2026-04-01 15:26:10 +02:00
Stephen Abello
e467ca83cf N°8532 - Apply filters on all DBSearch classes (#848)
Co-authored-by: Molkobain <lajarige.guillaume@free.fr>
Co-authored-by: Thomas Casteleyn <thomas.casteleyn@super-visions.com>
2026-03-31 15:41:28 +02:00
Stephen Abello
7791585387 N°9231 - Make OrmDocument apply same safety to attachments and regular documents (#860) 2026-03-30 15:25:52 +02:00
13 changed files with 613 additions and 199 deletions

View File

@@ -1730,6 +1730,14 @@ class Config
'source_of_value' => '',
'show_in_conf_sample' => false,
],
'security.disable_joined_classes_filter' => [
'type' => 'bool',
'description' => 'If true, scope filters aren\'t applied to joined classes or union classes not directly listed in the SELECT clause.',
'default' => true,
'value' => true,
'source_of_value' => '',
'show_in_conf_sample' => false,
],
'security.hide_administrators' => [
'type' => 'bool',
'description' => 'If true, non-administrator users will not be able to see the administrator accounts, the Administrator profile and the links between the administrator accounts and their profiles.',

View File

@@ -1925,4 +1925,37 @@ class DBObjectSearch extends DBSearch
{
return $this->GetCriteria()->ListParameters();
}
/**
* @inheritDoc
* @return DBObjectSearch
*/
protected function ApplyDataFilters(): DBObjectSearch
{
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
return $this;
}
$oSearch = $this;
$aClassesToFilter = $this->GetSelectedClasses();
// Opt-in for joined classes filtering, otherwise only filter the selected class(es)
if (MetaModel::GetConfig()->Get('security.disable_joined_classes_filter') === false) {
$aClassesToFilter = $this->GetJoinedClasses();
}
// Apply filter (this is similar to the one in DBSearch but the factorization could make it less readable)
foreach ($aClassesToFilter as $sClassAlias => $sClass) {
$oVisibleObjects = UserRights::GetSelectFilter($sClass, $this->GetModifierProperties('UserRightsGetSelectFilter'));
if ($oVisibleObjects === false) {
$oVisibleObjects = DBObjectSearch::FromEmptySet($sClass);
}
if (is_object($oVisibleObjects)) {
$oVisibleObjects->AllowAllData();
$oSearch = $oSearch->Filter($sClassAlias, $oVisibleObjects);
$oSearch->SetDataFiltered();
}
}
return $oSearch;
}
}

View File

@@ -1122,21 +1122,7 @@ abstract class DBSearch
*/
protected function GetSQLQuery($aOrderBy, $aArgs, $aAttToLoad, $aExtendedDataSpec, $iLimitCount, $iLimitStart, $bGetCount, $aGroupByExpr = null, $aSelectExpr = null)
{
$oSearch = $this;
if (!$this->IsAllDataAllowed() && !$this->IsDataFiltered()) {
foreach ($this->GetSelectedClasses() as $sClassAlias => $sClass) {
$oVisibleObjects = UserRights::GetSelectFilter($sClass, $this->GetModifierProperties('UserRightsGetSelectFilter'));
if ($oVisibleObjects === false) {
// Make sure this is a valid search object, saying NO for all
$oVisibleObjects = DBObjectSearch::FromEmptySet($sClass);
}
if (is_object($oVisibleObjects)) {
$oVisibleObjects->AllowAllData();
$oSearch = $oSearch->Filter($sClassAlias, $oVisibleObjects);
$oSearch->SetDataFiltered();
}
}
}
$oSearch = $this->ApplyDataFilters();
if (is_array($aGroupByExpr)) {
foreach ($aGroupByExpr as $sAlias => $oGroupByExp) {
@@ -1608,4 +1594,33 @@ abstract class DBSearch
* @return array{\VariableExpression}
*/
abstract public function GetExpectedArguments(): array;
/**
* Apply data filters to the search, if needed
*
* @return DBSearch
* @throws CoreException
*/
protected function ApplyDataFilters(): DBSearch
{
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
return $this;
}
$oSearch = $this;
$aClassesToFilter = $this->GetSelectedClasses();
foreach ($aClassesToFilter as $sClassAlias => $sClass) {
$oVisibleObjects = UserRights::GetSelectFilter($sClass, $this->GetModifierProperties('UserRightsGetSelectFilter'));
if ($oVisibleObjects === false) {
$oVisibleObjects = DBObjectSearch::FromEmptySet($sClass);
}
if (is_object($oVisibleObjects)) {
$oVisibleObjects->AllowAllData();
$oSearch = $oSearch->Filter($sClassAlias, $oVisibleObjects);
$oSearch->SetDataFiltered();
}
}
return $oSearch;
}
}

View File

@@ -673,4 +673,30 @@ class DBUnionSearch extends DBSearch
return $aVariableCriteria;
}
/**
* @inheritDoc
* @return DBUnionSearch
*/
protected function ApplyDataFilters(): DBUnionSearch
{
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
return $this;
}
// Opt-in for joined classes filtering, otherwise fallback on DBSearch filtering
if (MetaModel::GetConfig()->Get('security.disable_joined_classes_filter') === true) {
return parent::ApplyDataFilters();
}
// Apply filters per sub-search
$aFilteredSearches = [];
foreach ($this->GetSearches() as $oSubSearch) {
// Recursively call ApplyDataFilters on sub-searches
$aFilteredSearches[] = $oSubSearch->ApplyDataFilters();
}
$oSearch = new DBUnionSearch($aFilteredSearches);
return $oSearch;
}
}

View File

@@ -350,15 +350,18 @@ class ormDocument
if (!is_object($oObj)) {
// If access to the document is not granted, check if the access to the host object is allowed
$oObj = MetaModel::GetObject($sClass, $id, false, true);
$bHasHostRights = false;
if ($oObj instanceof Attachment) {
$sItemClass = $oObj->Get('item_class');
$sItemId = $oObj->Get('item_id');
$oHost = MetaModel::GetObject($sItemClass, $sItemId, false, false);
if (!is_object($oHost)) {
$oObj = null;
if (is_object($oHost)) {
$bHasHostRights = true;
}
}
if (!is_object($oObj)) {
// We could neither read the object nor get a host object matching our rights
if ($bHasHostRights !== true) {
throw new Exception("Invalid id ($id) for class '$sClass' - the object does not exist or you are not allowed to view it");
}
}

View File

@@ -301,88 +301,112 @@ function ValidateField(sFieldId, sPattern, bMandatory, sFormId, nullValue, origi
return true; // Do not stop propagation ??
}
function ValidateCKEditField(sFieldId, sPattern, bMandatory, sFormId, nullValue, originalValue)
function EvaluateCKEditorValidation(oOptions)
{
let oField = $('#'+sFieldId);
let oField = $('#'+oOptions.sFieldId);
if (oField.length === 0) {
return false;
}
let oCKEditor = CombodoCKEditorHandler.GetInstanceSynchronous('#'+sFieldId);
let oCKEditor = CombodoCKEditorHandler.GetInstanceSynchronous('#'+oOptions.sFieldId);
let bValid = true;
let sExplain = '';
let sTextContent;
let sTextOriginalContents;
var bValid;
var sExplain = '';
if (oField.prop('disabled')) {
bValid = true; // disabled fields are not checked
} else {
// If the CKEditor is not yet loaded, we need to wait for it to be ready
// but as we need this function to be synchronous, we need to call it again when the CKEditor is ready
if (oCKEditor === undefined){
CombodoCKEditorHandler.GetInstance('#'+sFieldId).then((oCKEditor) => {
ValidateCKEditField(sFieldId, sPattern, bMandatory, sFormId, nullValue, originalValue);
CombodoCKEditorHandler.GetInstance('#'+oOptions.sFieldId).then((oCKEditor) => {
oOptions.onRetry();
});
return;
return false;
}
let sTextContent;
let sFormattedContent = oCKEditor.getData();
// Get the contents without the tags
// Check if we have a formatted content that is HTML, otherwise we just have plain text, and we can use it directly
let sFormattedContent = oCKEditor.getData();
sTextContent = $(sFormattedContent).length > 0 ? $(sFormattedContent).text() : sFormattedContent;
if (sTextContent === '') {
if (sTextContent === '')
{
// No plain text, maybe there is just an image
let oImg = $(sFormattedContent).find("img");
if (oImg.length !== 0) {
let oImg = $(sFormattedContent).find('img');
if (oImg.length !== 0)
{
sTextContent = 'image';
}
}
// Get the original value without the tags
let oFormattedOriginalContents = (originalValue !== undefined) ? $('<div></div>').html(originalValue) : undefined;
let sTextOriginalContents = (oFormattedOriginalContents !== undefined) ? oFormattedOriginalContents.text() : undefined;
let oFormattedOriginalContents = (oOptions.sOriginalValue !== undefined) ? $('<div></div>').html(oOptions.sOriginalValue) : undefined;
sTextOriginalContents = (oFormattedOriginalContents !== undefined) ? oFormattedOriginalContents.text() : undefined;
if (bMandatory && (sTextContent === nullValue)) {
bValid = false;
sExplain = Dict.S('UI:ValueMustBeSet');
} else if ((sTextOriginalContents !== undefined) && (sTextContent === sTextOriginalContents)) {
bValid = false;
if (sTextOriginalContents === nullValue) {
sExplain = Dict.S('UI:ValueMustBeSet');
} else {
// Note: value change check is not working well yet as the HTML to Text conversion is not exactly the same when done from the PHP value or the CKEditor value.
sExplain = Dict.S('UI:ValueMustBeChanged');
}
} else {
bValid = true;
if (oOptions.validate !== undefined) {
let oValidation = oOptions.validate(sTextContent, sTextOriginalContents);
bValid = oValidation.bValid;
sExplain = oValidation.sExplain;
}
// Put and event to check the field when the content changes, remove the event right after as we'll call this same function again, and we don't want to call the event more than once (especially not ^2 times on each call)
// Put an event to check the field when the content changes, remove the event right after as we'll call this same function again, and we don't want to call the event more than once (especially not ^2 times on each call)
oCKEditor.model.document.once('change:data', (event) => {
ValidateCKEditField(sFieldId, sPattern, bMandatory, sFormId, nullValue, originalValue);
oOptions.onChange();
});
}
ReportFieldValidationStatus(sFieldId, sFormId, bValid, sExplain);
ReportFieldValidationStatus(oOptions.sFieldId, oOptions.sFormId, bValid, sExplain);
return bValid;
}
function ValidateCKEditField(sFieldId, sPattern, bMandatory, sFormId, nullValue, originalValue)
{
return EvaluateCKEditorValidation({
sFieldId: sFieldId,
sFormId: sFormId,
sOriginalValue: originalValue,
onRetry: function() {
ValidateCKEditField(sFieldId, sPattern, bMandatory, sFormId, nullValue, originalValue);
},
onChange: function() {
ValidateCKEditField(sFieldId, sPattern, bMandatory, sFormId, nullValue, originalValue);
},
validate: function(sTextContent, sTextOriginalContents) {
var bValid;
var sExplain = '';
if (bMandatory && (sTextContent === nullValue)) {
bValid = false;
sExplain = Dict.S('UI:ValueMustBeSet');
} else if ((sTextOriginalContents !== undefined) && (sTextContent === sTextOriginalContents)) {
bValid = false;
if (sTextOriginalContents === nullValue) {
sExplain = Dict.S('UI:ValueMustBeSet');
} else {
// Note: value change check is not working well yet as the HTML to Text conversion is not exactly the same when done from the PHP value or the CKEditor value.
sExplain = Dict.S('UI:ValueMustBeChanged');
}
} else {
bValid = true;
}
return {bValid: bValid, sExplain: sExplain};
}
});
}
function ResetPwd(id)
{
// Reset the values of the password fields
$('#'+id).val('*****');
$('#'+id+'_confirm').val('*****');
// And reset the flag, to tell it that the password remains unchanged
$('#'+id+'_changed').val(0);
// Visual feedback, None when it's Ok
$('#v_'+id).html('');
// Reset the values of the password fields
$('#'+id).val('*****');
$('#'+id+'_confirm').val('*****');
// And reset the flag, to tell it that the password remains unchanged
$('#'+id+'_changed').val(0);
// Visual feedback, None when it's Ok
$('#v_'+id).html('');
}
// Called whenever the content of a one way encrypted password changes
function PasswordFieldChanged(id)
{
// Set the flag, to tell that the password changed
$('#'+id+'_changed').val(1);
// Set the flag, to tell that the password changed
$('#'+id+'_changed').val(1);
}
// Special validation function for one way encrypted password fields
@@ -415,37 +439,48 @@ function ValidatePasswordField(id, sFormId)
// to determine if the field is empty or not
function ValidateCaseLogField(sFieldId, bMandatory, sFormId, nullValue, originalValue)
{
var bValid = true;
var sExplain = '';
var sTextContent;
if ($('#'+sFieldId).prop('disabled'))
{
bValid = true; // disabled fields are not checked
}
else
{
// Get the contents (with tags)
// Note: For CaseLog we can't retrieve the formatted contents from CKEditor (unlike in ValidateCKEditorField() method) because of the place holder.
sTextContent = $('#' + sFieldId).val();
var count = $('#'+sFieldId+'_count').val();
return EvaluateCKEditorValidation({
sFieldId: sFieldId,
sFormId: sFormId,
sOriginalValue: originalValue,
onRetry: function() {
ValidateCaseLogField(sFieldId, bMandatory, sFormId, nullValue, originalValue);
},
onChange: function() {
ValidateCaseLogField(sFieldId, bMandatory, sFormId, nullValue, originalValue);
},
validate: function(sTextContent, sTextOriginalContents) {
var bValid;
var sExplain = '';
// CaseLog is special: history count matters when deciding if the field is empty
var count = $('#'+sFieldId+'_count').val();
if (bMandatory && (count == 0) && (sTextContent == nullValue))
{
// No previous entry and no content typed
bValid = false;
sExplain = Dict.S('UI:ValueMustBeSet');
if (bMandatory && (count == 0) && (sTextContent === nullValue))
{
// No previous entry and no content typed
bValid = false;
sExplain = Dict.S('UI:ValueMustBeSet');
}
else if ((sTextOriginalContents !== undefined) && (sTextContent === sTextOriginalContents))
{
bValid = false;
if (sTextOriginalContents === nullValue)
{
sExplain = Dict.S('UI:ValueMustBeSet');
}
else
{
// Note: value change check is not working well yet as the HTML to Text conversion is not exactly the same when done from the PHP value or the CKEditor value.
sExplain = Dict.S('UI:ValueMustBeChanged');
}
}
else
{
bValid = true;
}
return {bValid: bValid, sExplain: sExplain};
}
else if ((originalValue != undefined) && (sTextContent == originalValue))
{
bValid = false;
sExplain = Dict.S('UI:ValueMustBeChanged');
}
}
ReportFieldValidationStatus(sFieldId, sFormId, bValid, '' /* sExplain */);
// We need to check periodically as CKEditor doesn't trigger our events. More details in UIHTMLEditorWidget::Display() @ line 92
setTimeout(function(){ValidateCaseLogField(sFieldId, bMandatory, sFormId, nullValue, originalValue);}, 500);
});
}
// Validate the inputs depending on the current setting

View File

@@ -193,7 +193,6 @@ return array(
'Combodo\\iTop\\Application\\Helper\\CKEditorHelper' => $baseDir . '/sources/Application/Helper/CKEditorHelper.php',
'Combodo\\iTop\\Application\\Helper\\ExportHelper' => $baseDir . '/sources/Application/Helper/ExportHelper.php',
'Combodo\\iTop\\Application\\Helper\\FormHelper' => $baseDir . '/sources/Application/Helper/FormHelper.php',
'Combodo\\iTop\\Application\\Helper\\ImportHelper' => $baseDir . '/sources/Application/Helper/ImportHelper.php',
'Combodo\\iTop\\Application\\Helper\\Session' => $baseDir . '/sources/Application/Helper/Session.php',
'Combodo\\iTop\\Application\\Helper\\WebResourcesHelper' => $baseDir . '/sources/Application/Helper/WebResourcesHelper.php',
'Combodo\\iTop\\Application\\Newsroom\\iTopNewsroomProvider' => $baseDir . '/sources/Application/Newsroom/iTopNewsroomProvider.php',

View File

@@ -548,7 +548,6 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
'Combodo\\iTop\\Application\\Helper\\CKEditorHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/CKEditorHelper.php',
'Combodo\\iTop\\Application\\Helper\\ExportHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/ExportHelper.php',
'Combodo\\iTop\\Application\\Helper\\FormHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/FormHelper.php',
'Combodo\\iTop\\Application\\Helper\\ImportHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/ImportHelper.php',
'Combodo\\iTop\\Application\\Helper\\Session' => __DIR__ . '/../..' . '/sources/Application/Helper/Session.php',
'Combodo\\iTop\\Application\\Helper\\WebResourcesHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/WebResourcesHelper.php',
'Combodo\\iTop\\Application\\Newsroom\\iTopNewsroomProvider' => __DIR__ . '/../..' . '/sources/Application/Newsroom/iTopNewsroomProvider.php',

View File

@@ -5,11 +5,9 @@
* @license http://opensource.org/licenses/AGPL-3.0
*/
use Combodo\iTop\Application\Helper\ImportHelper;
use Combodo\iTop\Application\UI\Base\Component\Alert\AlertUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Button\ButtonUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\DataTable\DataTableUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Input\Select\Select;
use Combodo\iTop\Application\UI\Base\Component\Input\Select\SelectOptionUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Input\Select\SelectUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Input\TextArea;
@@ -389,14 +387,6 @@ EOF
}
break;
case 'display_classes_select':
$oPage = new AjaxPage("");
$sClassName = utils::ReadPostedParam('class_name', '', utils::ENUM_SANITIZATION_FILTER_CLASS);
$bAdvanced = utils::ReadPostedParam('advanced', 'false');
$oClassesSelect = ImportHelper::GetClassesSelectUIBlock('class_name', $sClassName, UR_ACTION_BULK_MODIFY, $bAdvanced === 'true');
$oPage->AddSubBlock($oClassesSelect);
break;
case 'get_csv_template':
$sClassName = utils::ReadParam('class_name');
$sFormat = utils::ReadParam('format', 'csv');

View File

@@ -5,7 +5,6 @@
* @license http://opensource.org/licenses/AGPL-3.0
*/
use Combodo\iTop\Application\Helper\ImportHelper;
use Combodo\iTop\Application\UI\Base\Component\Alert\AlertUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Button\ButtonUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\CollapsibleSection\CollapsibleSectionUIBlockFactory;
@@ -15,6 +14,7 @@ use Combodo\iTop\Application\UI\Base\Component\Form\FormUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Html\Html;
use Combodo\iTop\Application\UI\Base\Component\Input\FileSelect\FileSelectUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Input\InputUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Input\Select\Select;
use Combodo\iTop\Application\UI\Base\Component\Input\Select\SelectOptionUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Input\Select\SelectUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Input\TextArea;
@@ -30,6 +30,7 @@ use Combodo\iTop\Application\WebPage\AjaxPage;
use Combodo\iTop\Application\WebPage\ErrorPage;
use Combodo\iTop\Application\WebPage\iTopWebPage;
use Combodo\iTop\Application\WebPage\WebPage;
use Combodo\iTop\Renderer\BlockRenderer;
use Combodo\iTop\Service\Import\CSVImportPageProcessor;
try {
@@ -51,6 +52,67 @@ try {
$oPage = new iTopWebPage(Dict::S('UI:Title:BulkImport'));
$oPage->SetBreadCrumbEntry('ui-tool-bulkimport', Dict::S('Menu:CSVImportMenu'), Dict::S('UI:Title:BulkImport+'), '', 'fas fa-file-import', iTopWebPage::ENUM_BREADCRUMB_ENTRY_ICON_TYPE_CSS_CLASSES);
/**
* Helper function to build a select from the list of valid classes for a given action
*
* @deprecated 3.0.0 use GetClassesSelectUIBlock
*
* @param $sDefaultValue
* @param integer $iWidthPx The width (in pixels) of the drop-down list
* @param integer $iActionCode The ActionCode (from UserRights) to check for authorization for the classes
*
* @param string $sName The name of the select in the HTML form
*
* @return string The HTML fragment corresponding to the select tag
*/
function GetClassesSelect($sName, $sDefaultValue, $iWidthPx, $iActionCode = null)
{
DeprecatedCallsLog::NotifyDeprecatedPhpMethod('use GetClassesSelectUIBlock');
$oSelectBlock = GetClassesSelectUIBlock($sName, $sDefaultValue, $iActionCode);
return BlockRenderer::RenderBlockTemplates($oSelectBlock);
}
/**
* Helper function to build a select from the list of valid classes for a given action
*
* @param string $sName The name of the select in the HTML form
* @param $sDefaultValue
* @param integer $iWidthPx The width (in pixels) of the drop-down list
* @param integer $iActionCode The ActionCode (from UserRights) to check for authorization for the classes
*
* @return \Combodo\iTop\Application\UI\Base\Component\Input\Select\
*/
function GetClassesSelectUIBlock(string $sName, $sDefaultValue, int $iActionCode, bool $bAdvanced = false): Select
{
$oSelectBlock = SelectUIBlockFactory::MakeForSelect($sName, 'select_'.$sName);
$oOption = SelectOptionUIBlockFactory::MakeForSelectOption("", Dict::S('UI:CSVImport:ClassesSelectOne'), false);
$oSelectBlock->AddSubBlock($oOption);
$aValidClasses = [];
$aClassCategories = ['bizmodel', 'addon/authentication'];
if ($bAdvanced) {
$aClassCategories[] = 'grant_by_profile';
}
if (UserRights::IsAdministrator()) {
$aClassCategories[] = 'application';
}
foreach ($aClassCategories as $sClassCategory) {
foreach (MetaModel::GetClasses($sClassCategory) as $sClassName) {
if ((is_null($iActionCode) || UserRights::IsActionAllowed($sClassName, $iActionCode)) &&
(!MetaModel::IsAbstract($sClassName))) {
$sDisplayName = ($bAdvanced) ? MetaModel::GetName($sClassName)." ($sClassName)" : MetaModel::GetName($sClassName);
$aValidClasses[$sDisplayName] = SelectOptionUIBlockFactory::MakeForSelectOption($sClassName, $sDisplayName, ($sClassName == $sDefaultValue));
}
}
}
ksort($aValidClasses);
foreach ($aValidClasses as $sValue => $oBlock) {
$oSelectBlock->AddSubBlock($oBlock);
}
return $oSelectBlock;
}
/**
* Helper to 'check' an input in an HTML form if the current value equals the value given
*
@@ -289,7 +351,7 @@ try {
$oClassesSelect->AddSubBlock($oDefaultSelect);
$aSynchroUpdate = utils::ReadParam('synchro_update', []);
} else {
$oClassesSelect = ImportHelper::GetClassesSelectUIBlock('class_name', $sClassName, UR_ACTION_BULK_MODIFY, (bool)$bAdvanced);
$oClassesSelect = GetClassesSelectUIBlock('class_name', $sClassName, UR_ACTION_BULK_MODIFY, (bool)$bAdvanced);
}
$oPanel = TitleUIBlockFactory::MakeForPage(Dict::S('UI:Title:CSVImportStep3'));
$oPage->AddSubBlock($oPanel);
@@ -313,9 +375,11 @@ try {
$oAdvancedMode->GetInput()->SetIsChecked(($bAdvanced == 1));
$oAdvancedMode->SetBeforeInput(false);
$oAdvancedMode->GetInput()->AddCSSClass('ibo-input-checkbox');
$oAdvancedMode->SetDescription(utils::EscapeHtml(Dict::S('UI:CSVImport:AdvancedMode+')));
$oMulticolumn->AddColumn(ColumnUIBlockFactory::MakeForBlock($oAdvancedMode));
$oDivAdvancedHelp = UIContentBlockUIBlockFactory::MakeStandard("advanced_help")->AddCSSClass('ibo-is-hidden');
$oForm->AddSubBlock($oDivAdvancedHelp);
$oDivMapping = UIContentBlockUIBlockFactory::MakeStandard("mapping")->AddCSSClass('mt-5');
$oMessage = AlertUIBlockFactory::MakeForInformation(Dict::S('UI:CSVImport:SelectAClassFirst'))->SetIsClosable(false)->SetIsCollapsible(false);
$oDivMapping->AddSubBlock($oMessage);
@@ -352,7 +416,7 @@ try {
$oPage->add_ready_script(
<<<EOF
$('#select_class_name').on('change', function(ev) { DoMapping(); } );
$('#advanced').on('click', function(ev) { DoAdvanced(); } );
$('#advanced').on('click', function(ev) { DoReload(); } );
EOF
);
if ($sClassName != '') {
@@ -365,13 +429,15 @@ EOF
}
$oPage->add_script(
<<<JS
<<<EOF
var aDefaultKeys = new Array();
var aReadOnlyKeys = new Array();
function DoAdvanced()
function DoReload()
{
UpdateClassesSelect();
$('input[name=step]').val(3);
$('#wizForm').removeAttr('onsubmit'); // No need to perform validation checks when going back
$('#wizForm').submit();
}
function CSVGoBack()
@@ -396,7 +462,14 @@ EOF
{
var class_name = $('select[name=class_name]').val();
var advanced = $('input[name=advanced]:checked').val();
if (advanced != 1)
{
$('#advanced_help').hide();
}
else
{
$('#advanced_help').show();
}
if (class_name != '')
{
var separator = $('input[name=separator]').val();
@@ -444,26 +517,6 @@ EOF
}
}
function UpdateClassesSelect()
{
const aParams = {
operation: 'display_classes_select',
class_name: $('#select_class_name').val(),
advanced: $('#advanced').is(':checked'),
};
$('#select_class_name').block();
$.post(GetAbsoluteUrlAppRoot()+'pages/ajax.csvimport.php',
aParams,
function(data) {
$('#select_class_name').replaceWith($(data));
$('#select_class_name').on('change', function(ev) { DoMapping(); } );
DoMapping();
}
);
}
function CheckValues()
{
// Reset the highlight in case the check has already been executed with failure
@@ -594,7 +647,7 @@ EOF
}
}
}
JS
EOF
);
}
@@ -1012,7 +1065,7 @@ EOF
}*/
//Tab:Template
$oTabTemplate = $oTabContainer->AddTab('tabsTemplate', Dict::S('UI:CSVImport:Tab:Templates'));
$oFieldTemplate = FieldUIBlockFactory::MakeFromObject(Dict::S('UI:CSVImport:PickClassForTemplate'), ImportHelper::GetClassesSelectUIBlock('template_class', '', UR_ACTION_BULK_MODIFY));
$oFieldTemplate = FieldUIBlockFactory::MakeFromObject(Dict::S('UI:CSVImport:PickClassForTemplate'), GetClassesSelectUIBlock('template_class', '', UR_ACTION_BULK_MODIFY));
$oTabTemplate->AddSubBlock($oFieldTemplate);
$oDivTemplate = UIContentBlockUIBlockFactory::MakeStandard("template")->AddCSSClass("ibo-is-visible");
$oTabTemplate->AddSubBlock($oDivTemplate);

View File

@@ -1,65 +0,0 @@
<?php
namespace Combodo\iTop\Application\Helper;
use Combodo\iTop\Application\UI\Base\Component\Input\Select\Select;
use Combodo\iTop\Application\UI\Base\Component\Input\Select\SelectOptionUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Input\SelectUIBlockFactory;
use CoreException;
use Dict;
use DictExceptionMissingString;
use MetaModel;
use UserRights;
/**
* Class
* ImportHelper
*
* @internal
* @since 3.2.3
* @package Combodo\iTop\Application\Helper
*/
class ImportHelper
{
/**
* Get classes select UI block.
*
* @param string $sName
* @param $sDefaultValue
* @param int $iActionCode
* @param bool $bAdvanced
*
* @return Select
* @throws CoreException
* @throws DictExceptionMissingString
*/
public static function GetClassesSelectUIBlock(string $sName, $sDefaultValue, int $iActionCode, bool $bAdvanced = false): Select
{
$oSelectBlock = SelectUIBlockFactory::MakeForSelect($sName, 'select_'.$sName);
$oOption = SelectOptionUIBlockFactory::MakeForSelectOption("", Dict::S('UI:CSVImport:ClassesSelectOne'), false);
$oSelectBlock->AddSubBlock($oOption);
$aValidClasses = [];
$aClassCategories = ['bizmodel', 'addon/authentication'];
if ($bAdvanced) {
$aClassCategories[] = 'grant_by_profile';
}
if (UserRights::IsAdministrator()) {
$aClassCategories[] = 'application';
}
foreach ($aClassCategories as $sClassCategory) {
foreach (MetaModel::GetClasses($sClassCategory) as $sClassName) {
if ((is_null($iActionCode) || UserRights::IsActionAllowed($sClassName, $iActionCode)) &&
(!MetaModel::IsAbstract($sClassName))) {
$sDisplayName = ($bAdvanced) ? MetaModel::GetName($sClassName)." ($sClassName)" : MetaModel::GetName($sClassName);
$aValidClasses[$sDisplayName] = SelectOptionUIBlockFactory::MakeForSelectOption($sClassName, $sDisplayName, ($sClassName == $sDefaultValue));
}
}
}
ksort($aValidClasses);
foreach ($aValidClasses as $sValue => $oBlock) {
$oSelectBlock->AddSubBlock($oBlock);
}
return $oSelectBlock;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace Combodo\iTop\Test\UnitTest\Core;
use CMDBSource;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use DBObjectSearch;
use DBObjectSet;
use DBSearch;
use lnkFunctionalCIToTicket;
use MetaModel;
use ormLinkSet;
use UserRequest;
use UserRights;
class DBSearchFilterJoinTest extends ItopDataTestCase
{
private const RESTRICTED_PROFILE = 'Configuration Manager';
private $aData = [];
protected function setUp(): void
{
parent::setUp();
$this->RequireOnceItopFile('application/startup.inc.php');
$this->aData = $this->CreateDBSearchFilterTestData();
DBSearch::EnableQueryCache(false, false);
$this->LoginRestrictedUser($this->aData['allowed_org_id'], self::RESTRICTED_PROFILE);
}
protected function tearDown(): void
{
parent::tearDown();
}
/**
* @dataProvider JoinedAndNestedOqlProvider
*/
public function testDBSearchFilterAppliedToJoinsWhenEnabled(string $sOql, int $iExpectedCount): void
{
$this->EnableJoinFilterConfig(true);
$oSearch = DBObjectSearch::FromOQL($sOql, ['denied_org' => $this->aData['denied_org_name'], 'allowed_org' => $this->aData['allowed_org_name']]);
$oSet = new \DBObjectSet($oSearch);
CMDBSource::TestQuery($oSearch->MakeSelectQuery());
$this->assertEquals($iExpectedCount, $oSet->Count());
}
/**
* @dataProvider JoinedAndNestedOqlProvider
*/
public function testDBSearchFilterAppliedToJoinsWhenDisabled(string $sOql, int $iExpectedCount, int $iExpectedDisabledCount): void
{
$this->EnableJoinFilterConfig(false);
$oSearch = DBObjectSearch::FromOQL($sOql, ['denied_org' => $this->aData['denied_org_name'], 'allowed_org' => $this->aData['allowed_org_name']]);
$oSet = new \DBObjectSet($oSearch);
CMDBSource::TestQuery($oSearch->MakeSelectQuery());
$this->assertEquals($iExpectedDisabledCount, $oSet->Count());
}
/**
* @dataProvider JoinedAndNestedOqlProvider
*/
public function testAllowAllDataBypassesDBSearchFilterWhenEnabled(string $sOql, int $iExpectedCount, int $iExpectedDisabledCount): void
{
$this->EnableJoinFilterConfig(true);
$oSearch = DBObjectSearch::FromOQL($sOql, ['denied_org' => $this->aData['denied_org_name'], 'allowed_org' => $this->aData['allowed_org_name']]);
$oSearch->AllowAllData();
$oSet = new \DBObjectSet($oSearch);
CMDBSource::TestQuery($oSearch->MakeSelectQuery());
$this->assertEquals($iExpectedDisabledCount, $oSet->Count());
}
/**
* @dataProvider JoinedAndNestedOqlProvider
*/
public function testAllowAllDataBypassesDBSearchFilterWhenDisabled(string $sOql, int $iExpectedCount, int $iExpectedDisabledCount): void
{
$this->EnableJoinFilterConfig(false);
$oSearch = DBObjectSearch::FromOQL($sOql, ['denied_org' => $this->aData['denied_org_name'], 'allowed_org' => $this->aData['allowed_org_name']]);
$oSearch->AllowAllData();
$oSet = new \DBObjectSet($oSearch);
CMDBSource::TestQuery($oSearch->MakeSelectQuery());
$this->assertEquals($iExpectedDisabledCount, $oSet->Count());
}
public function JoinedAndNestedOqlProvider(): array
{
return [
'join-filter-on-org' => [
'oql' => "SELECT OSF FROM OSFamily AS OSF JOIN VirtualMachine AS VM ON VM.osfamily_id = OSF.id JOIN Organization AS O ON VM.org_id = O.id WHERE O.name = :denied_org",
'expected_filtered_count' => 0,
'expected_unfiltered_count' => 1,
],
'nested-in-select' => [
'oql' => "SELECT OSF FROM OSFamily AS OSF WHERE OSF.id IN (SELECT OSF1 FROM OSFamily AS OSF1 JOIN VirtualMachine AS VM ON VM.osfamily_id = OSF1.id JOIN Organization AS O ON VM.org_id = O.id WHERE O.name = :denied_org)",
'expected_filtered_count' => 0,
'expected_unfiltered_count' => 1,
],
'userrequest-join-person-org' => [
'oql' => "SELECT OSF FROM OSFamily AS OSF JOIN VirtualMachine AS VM ON VM.osfamily_id = OSF.id JOIN lnkFunctionalCIToTicket AS L ON L.functionalci_id = VM.id JOIN UserRequest AS UR ON L.ticket_id = UR.id JOIN Person AS P ON UR.caller_id = P.id JOIN Organization AS O ON P.org_id = O.id WHERE O.name = :denied_org",
'expected_filtered_count' => 0,
'expected_unfiltered_count' => 1,
],
'union-join-filter-on-org' => [
'oql' => "SELECT OSF FROM OSFamily AS OSF JOIN VirtualMachine AS VM ON VM.osfamily_id = OSF.id JOIN Organization AS O ON VM.org_id = O.id WHERE O.name = :denied_org UNION SELECT OSF2 FROM OSFamily AS OSF2 JOIN VirtualMachine AS VM2 ON VM2.osfamily_id = OSF2.id JOIN Organization AS O2 ON VM2.org_id = O2.id WHERE O2.name = :allowed_org",
'expected_filtered_count' => 1,
'expected_unfiltered_count' => 2,
],
];
}
private function EnableJoinFilterConfig(bool $bEnabled): void
{
$oConfig = MetaModel::GetConfig();
$oConfig->Set('security.disable_joined_classes_filter', !$bEnabled);
}
private function CreateDBSearchFilterTestData(): array
{
$sSuffix = 'DBSearchFilterJoinTest';
$sAllowedOrgName = 'DBSearchFilterAllowedOrg-'.$sSuffix;
$iAllowedOrgId = $this->GivenObjectInDB('Organization', [
'name' => $sAllowedOrgName,
]);
$this->debug("Org allowed id: $iAllowedOrgId");
$sDeniedOrgName = 'DBSearchFilterDeniedOrg-'.$sSuffix;
$iDeniedOrgId = $this->GivenObjectInDB('Organization', [
'name' => $sDeniedOrgName,
]);
$this->debug("Org denied id: $iDeniedOrgId");
$iDeniedOsFamilyId = $this->GivenObjectInDB('OSFamily', [
'name' => 'DBSearchFilterOsFamilyDenied-'.$sSuffix,
]);
$iAllowedOsFamilyId = $this->GivenObjectInDB('OSFamily', [
'name' => 'DBSearchFilterOsFamilyAllowed-'.$sSuffix,
]);
$iDeniedVMId = $this->GivenObjectInDB('VirtualMachine', [
'name' => 'DBSearchFilterVmDenied-'.$sSuffix,
'org_id' => $iDeniedOrgId,
'osfamily_id' => $iDeniedOsFamilyId,
'virtualhost_id' => 1,
]);
$iVirtualHostId = $this->GivenObjectInDB('Hypervisor', [
'name' => 'DBSearchFilterVHost-'.$sSuffix,
'org_id' => $iAllowedOrgId,
]);
$this->GivenObjectInDB('VirtualMachine', [
'name' => 'DBSearchFilterVmAllowed-'.$sSuffix,
'org_id' => $iAllowedOrgId,
'osfamily_id' => $iAllowedOsFamilyId,
'virtualhost_id' => $iVirtualHostId,
]);
$oDeniedPerson = $this->CreatePerson('Denied-'.$sSuffix, $iDeniedOrgId);
$oUserRequest = $this->CreateUserRequest('Denied'.$sSuffix, [
'caller_id' => $oDeniedPerson->GetKey(),
'org_id' => $iDeniedOrgId,
]);
// Add Virtual Machine to UserRequest lnk
$oLinkSet = new ormLinkSet(UserRequest::class, 'functionalcis_list', DBObjectSet::FromScratch(lnkFunctionalCIToTicket::class));
$oLink = MetaModel::NewObject(lnkFunctionalCIToTicket::class, ['functionalci_id' => $iDeniedVMId]);
$oLinkSet->AddItem($oLink);
$oUserRequest->Set('functionalcis_list', $oLinkSet);
$oUserRequest->DBUpdate();
return [
'allowed_org_id' => $iAllowedOrgId,
'allowed_org_name' => $sAllowedOrgName,
'denied_org_name' => $sDeniedOrgName,
];
}
private function LoginRestrictedUser(int $iAllowedOrgId, string $sProfileName): void
{
$sLogin = $this->GivenUserRestrictedToAnOrganizationInDB($iAllowedOrgId, self::$aURP_Profiles[$sProfileName]);
UserRights::Login($sLogin);
}
}

View File

@@ -7,14 +7,36 @@
namespace Combodo\iTop\Test\UnitTest\Core;
use Combodo\iTop\Application\WebPage\CaptureWebPage;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use ormDocument;
use UserRights;
/**
* Tests of the ormDocument class
*/
class ormDocumentTest extends ItopDataTestCase
{
private const RESTRICTED_PROFILE = 'Configuration Manager';
private int $iUserOrg;
private int $iOrgDifferentFromUser;
protected function setUp(): void
{
parent::setUp();
$this->iUserOrg = $this->GivenObjectInDB('Organization', [
'name' => 'UserOrg',
]);
$this->iOrgDifferentFromUser = $this->GivenObjectInDB('Organization', [
'name' => 'OrgDifferentFromUser',
]);
$this->LoginRestrictedUser($this->iUserOrg, self::RESTRICTED_PROFILE);
$this->ResetMetaModelQueyCacheGetObject();
}
/**
* @inheritDoc
*/
@@ -139,4 +161,107 @@ class ormDocumentTest extends ItopDataTestCase
],
];
}
/**
* Test that DownloadDocument enforces rights for documents
*
* @dataProvider DownloadDocumentRightsProvider
*/
public function testDownloadDocumentDifferentOrg(string $sTargetClass, string $sAttCode, string $sData, string $sFileName, ?string $sHostClass)
{
$iDeniedDocumentId = $this->CreateDownloadTargetInOrg($sTargetClass, $sAttCode, $this->iOrgDifferentFromUser, $sData, $sFileName, $sHostClass);
$oPageDenied = new CaptureWebPage();
ormDocument::DownloadDocument($oPageDenied, $sTargetClass, $iDeniedDocumentId, $sAttCode);
$sDeniedHtml = (string) $oPageDenied->GetHtml();
$this->assertStringContainsString(
'the object does not exist or you are not allowed to view it',
$sDeniedHtml,
'Expected error message when rights are missing.'
);
$this->assertStringNotContainsString($sData, $sDeniedHtml, 'Unexpected file data present when rights are missing.');
}
/**
* Test that DownloadDocument allows to retrieve document with the same org (or host object org)
*
* @dataProvider DownloadDocumentRightsProvider
*/
public function testDownloadDocumentSameOrg(string $sTargetClass, string $sAttCode, string $sData, string $sFileName, ?string $sHostClass)
{
$iAllowedDocumentId = $this->CreateDownloadTargetInOrg($sTargetClass, $sAttCode, $this->iUserOrg, $sData, $sFileName, $sHostClass);
$oPageAllowed = new CaptureWebPage();
ormDocument::DownloadDocument($oPageAllowed, $sTargetClass, $iAllowedDocumentId, $sAttCode);
$sAllowedHtml = (string) $oPageAllowed->GetHtml();
$this->assertStringContainsString($sData, $sAllowedHtml, 'Expected file data present when rights are sufficient.');
$this->assertStringNotContainsString('the object does not exist or you are not allowed to view it', $sAllowedHtml, 'Unexpected error message when rights are sufficient.');
}
public function DownloadDocumentRightsProvider(): array
{
return [
'DocumentFile' => [
'class' => 'DocumentFile',
'data_attribute_id' => 'file',
'data' => 'document_data',
'file_name' => 'document.txt',
'host_class' => null],
'Attachment' => [
'class' => 'Attachment',
'data_attribute_id' => 'contents',
'data' => 'attachment_data',
'file_name' => 'attachment.txt',
'host_class' => 'UserRequest'],
];
}
/**
* Helper to avoid duplicating object creation in tests
* Created objects and host objects depending on the Document class
* @param string $sTargetClass
* @param string $sAttCode
* @param int $iOrgId
* @param string $sData
* @param string $sFileName
* @param string|null $sHostClass
* @return int
* @throws \Exception
*/
private function CreateDownloadTargetInOrg(string $sTargetClass, string $sAttCode, int $iOrgId, string $sData, string $sFileName, ?string $sHostClass): int
{
if ($sTargetClass === 'DocumentFile') {
return $this->GivenObjectInDB($sTargetClass, [
'name' => 'UnitTestDocFile_'.uniqid(),
'org_id' => $iOrgId,
$sAttCode => new ormDocument($sData, 'text/plain', $sFileName),
]);
}
if ($sTargetClass === 'Attachment') {
$iHostId = $this->GivenObjectInDB($sHostClass, [
'title' => 'UnitTestUserRequest_'.uniqid(),
'org_id' => $iOrgId,
'description' => 'A user request for testing attachment download rights',
]);
return $this->GivenObjectInDB('Attachment', [
'item_class' => $sHostClass,
'item_id' => $iHostId,
$sAttCode => new ormDocument($sData, 'text/plain', $sFileName),
]);
}
throw new \Exception("Unsupported target class: $sTargetClass");
}
private function LoginRestrictedUser(int $iAllowedOrgId, string $sProfileName): void
{
if (UserRights::IsLoggedIn()) {
UserRights::Logoff();
}
$sLogin = $this->GivenUserRestrictedToAnOrganizationInDB($iAllowedOrgId, self::$aURP_Profiles[$sProfileName]);
UserRights::Login($sLogin);
}
}