From 7cc204f2ba6884f041fdce8d9239832be4cbd103 Mon Sep 17 00:00:00 2001 From: Nicolas Le Goff Date: Tue, 23 Dec 2014 18:25:50 +0100 Subject: [PATCH] Apply ACL restriction filters for a search query --- .../Elastic/ElasticSearchEngine.php | 265 ++++++++++++------ .../Elastic/ElasticsearchRecordHydrator.php | 2 +- .../Elastic/Indexer/RecordIndexer.php | 17 +- .../SearchEngine/Elastic/RecordFetcher.php | 4 +- .../SearchEngine/Elastic/RecordHelper.php | 5 + .../SearchEngine/SearchEngineOptions.php | 22 +- lib/classes/databox/status.php | 74 ++--- templates/web/prod/index.html.twig | 28 +- 8 files changed, 240 insertions(+), 177 deletions(-) diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticSearchEngine.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticSearchEngine.php index 557659dc85..16894ef447 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticSearchEngine.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticSearchEngine.php @@ -11,7 +11,6 @@ namespace Alchemy\Phrasea\SearchEngine\Elastic; -use Alchemy\Phrasea\SearchEngine\Elastic\ElasticsearchRecordHydrator; use Alchemy\Phrasea\SearchEngine\Elastic\Indexer\RecordIndexer; use Alchemy\Phrasea\SearchEngine\Elastic\Indexer\TermIndexer; use Alchemy\Phrasea\SearchEngine\Elastic\Search\SearchQuery; @@ -26,6 +25,10 @@ use Elasticsearch\Client; class ElasticSearchEngine implements SearchEngineInterface { + const FLAG_ALLOW_BOTH = 'allow_both'; + const FLAG_SET_ONLY = 'set_only'; + const FLAG_UNSET_ONLY = 'unset_only'; + private $app; /** @var Client */ private $client; @@ -438,7 +441,7 @@ class ElasticSearchEngine implements SearchEngineInterface return $params; } - private function createRecordQueryParams($ESquery, SearchEngineOptions $options, \record_adapter $record = null) + private function createRecordQueryParams($ESQuery, SearchEngineOptions $options, \record_adapter $record = null) { $params = [ 'index' => $this->indexName, @@ -448,86 +451,54 @@ class ElasticSearchEngine implements SearchEngineInterface ] ]; - $filters = $this->createFilters($options); + $query_filters = $this->createQueryFilters($options); + $acl_filters = $this->createACLFilters(); - if ($record) { - $filters[] = [ - 'term' => [ - '_id' => sprintf('%s-%s', $record->get_sbas_id(), $record->get_record_id()), - ] - ]; + $ESQuery = ['filtered' => ['query' => $ESQuery]]; - $fields = []; - - foreach ($record->get_databox()->get_meta_structure() as $dbField) { - $fields['caption.'.$dbField->get_name()] = new \stdClass(); - } - - $params['body']['highlight'] = [ - "pre_tags" => ["[[em]]"], - "post_tags" => ["[[/em]]"], - "fields" => $fields, - ]; + if (count($query_filters) > 0) { + $ESQuery['filtered']['filter']['bool']['must'][] = $query_filters; } - if (count($filters) > 0) { - $ESquery = [ - 'filtered' => [ - 'query' => $ESquery, - 'filter' => [ - 'and' => $filters - ] - ] - ]; + if (count($acl_filters) > 0) { + $ESQuery['filtered']['filter']['bool']['must'][] = $acl_filters; } - $params['body']['query'] = $ESquery; + $params['body']['query'] = $ESQuery; return $params; } - private function createFilters(SearchEngineOptions $options) + private function createACLFilters() + { + // No ACLs if no user + if (false === $this->app['authentication']->isAuthenticated()) { + return []; + } + + $acl = $this->app['acl']->get($this->app['authentication']->getUser()); + + $grantedCollections = array_keys($acl->get_granted_base(['actif'])); + + if (count($grantedCollections) === 0) { + return ['bool' => ['must_not' => ['match_all' => new \stdClass()]]]; + } + + $flagNamesMap = $this->getFlagsKey($this->app['phraseanet.appbox']); + // Get flags rules + $flagRules = $this->getFlagsRules($acl, $grantedCollections); + // Get intersection between collection ACLs and collection chosen by end user + $aclRules = $this->getACLsByCollection($flagRules, $flagNamesMap); + + return $this->buildACLsFilters($aclRules); + } + + private function createQueryFilters(SearchEngineOptions $options) { $filters = []; - $status_opts = $options->getStatus(); - foreach ($options->getDataboxes() as $databox) { - foreach ($databox->get_statusbits() as $bit => $status) { - if (!array_key_exists($bit, $status_opts)) { - continue; - } - if (!array_key_exists($databox->get_sbas_id(), $status_opts[$bit])) { - continue; - } - - $key = RecordIndexer::normalizeFlagKey($status['labelon']); - $filters[] = [ - 'term' => [ - sprintf('flags.%s', $key) => (bool) $status_opts[$bit][$databox->get_sbas_id()], - ] - ]; - } - } - - $base_ids = []; - foreach ($options->getCollections() as $collection) { - $base_ids[] = $collection->get_base_id(); - } - - if (count($base_ids) > 0) { - $filters[] = [ - 'terms' => [ - 'base_id' => array_values(array_filter($base_ids)) - ] - ]; - } - - $filters[] = [ - 'term' => [ - 'record_type' => $options->getSearchType() === SearchEngineOptions::RECORD_RECORD ? - SearchEngineInterface::GEM_TYPE_RECORD : SearchEngineInterface::GEM_TYPE_STORY, - ] - ]; + $filters[]['term']['record_type'] = $options->getSearchType() === SearchEngineOptions::RECORD_RECORD ? + SearchEngineInterface::GEM_TYPE_RECORD : SearchEngineInterface::GEM_TYPE_STORY; if ($options->getDateFields() && ($options->getMaxDate() || $options->getMinDate())) { $range = []; @@ -539,20 +510,40 @@ class ElasticSearchEngine implements SearchEngineInterface } foreach ($options->getDateFields() as $dateField) { - $filters[] = [ - 'range' => [ - 'caption.'.$dateField->get_name() => $range - ] - ]; + $filters[]['range']['caption.'.$dateField->get_name()] = $range; } } if ($options->getRecordType()) { - $filters[] = [ - 'term' => [ - 'phrasea_type' => $options->getRecordType(), - ] - ]; + $filters[]['term']['phrasea_type'] = $options->getRecordType(); + } + + if (count($options->getCollections()) > 0) { + $filters[]['terms']['base_id'] = array_map(function($collection) { + return $collection->get_base_id(); + }, $options->getCollections()); + } + + if (count($options->getStatus()) > 0) { + $status_filters = []; + $flagNamesMap = $this->getFlagsKey($this->app['phraseanet.appbox']); + + foreach ($options->getStatus() as $databoxId => $status) { + $status_filter = $databox_status =[]; + $status_filter[] = ['term' => ['databox_id' => $databoxId]]; + foreach ($status as $n => $v) { + if (!isset($flagNamesMap[$databoxId][$n])) { + continue; + } + + $label = $flagNamesMap[$databoxId][$n]; + $databox_status[] = ['term' => [sprintf('flags.%s', $label) => (bool) $v]]; + }; + $status_filter[] = $databox_status; + + $status_filters[] = ['bool' => ['must' => $status_filter]]; + } + $filters[]['bool']['should'] = $status_filters; } return $filters; @@ -610,4 +601,120 @@ class ElasticSearchEngine implements SearchEngineInterface return $fieldsExpended; } + + private function getFlagsKey(\appbox $appbox) + { + $flags = []; + foreach ($appbox->get_databoxes() as $databox) { + $databoxId = $databox->get_sbas_id(); + $status = $databox->get_statusbits(); + foreach($status as $bit => $stat) { + $flags[$databoxId][$bit] = RecordHelper::normalizeFlagKey($stat['labelon']); + } + } + + return $flags; + } + + private function getFlagsRules(\ACL $acl, array $collections) + { + $rules = []; + foreach ($collections as $collectionId) { + $databoxId = \phrasea::sbasFromBas($this->app, $collectionId); + foreach (range(0, 31) as $bit) { + $rules[$databoxId][$collectionId][$bit] = $this->computeAccess( + $acl->get_mask_xor($collectionId), + $acl->get_mask_and($collectionId), + $bit + ); + } + } + + return $rules; + } + + /** + * Truth table for status rights + * + * +-----------+ + * | and | xor | + * +-----------+ + * | 0 | 0 | -> BOTH STATES ARE CHECKED + * +-----------+ + * | 1 | 0 | -> UNSET STATE IS CHECKED + * +-----------+ + * | 0 | 1 | -> UNSET STATE IS CHECKED (not possible) + * +-----------+ + * | 1 | 1 | -> SET STATE IS CHECKED + * +-----------+ + * + */ + private function computeAccess($and, $xor, $bit) + { + $xorBit = \databox_status::bitIsSet($xor, $bit); + $andBit = \databox_status::bitIsSet($and, $bit); + + if (!$xorBit && !$andBit) { + return self::FLAG_ALLOW_BOTH; + } + + if ($xorBit && $andBit) { + return self::FLAG_SET_ONLY; + } + + // otherwise there is a restriction for this status when it is not raised + return self::FLAG_UNSET_ONLY; + } + + private function getACLsByCollection(array $flagACLs, array $flagNamesMap) + { + $rules = []; + + foreach ($flagACLs as $databoxId => $bases) { + foreach ($bases as $baseId => $bit) { + $rules[$baseId] = []; + foreach ($bit as $n => $rule) { + if (!isset($flagNamesMap[$databoxId][$n])) { + continue; + } + + $label = $flagNamesMap[$databoxId][$n]; + $rules[$baseId][$label] = $rule; + } + } + } + + return $rules; + } + + private function buildACLsFilters(array $aclRules) + { + $filters = []; + + foreach ($aclRules as $baseId => $flagsRules) { + $ruleFilter = $baseFilter = []; + + // filter on base + $baseFilter['term']['base_id'] = $baseId; + $ruleFilter['bool']['must'][] = $baseFilter; + + // filter by flags + foreach ($flagsRules as $flagName => $flagRule) { + // only add filter if one of the status state is not allowed / allowed + if ($flagRule === self::FLAG_ALLOW_BOTH) { + continue; + } + $flagFilter = []; + + $flagField = sprintf('flags.%s', $flagName); + $flagFilter['term'][$flagField] = $flagRule === self::FLAG_SET_ONLY ? true : false; + + $ruleFilter['bool']['must'][] = $flagFilter; + } + + $filters[] = $ruleFilter; + } + + return $filters; + } } diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticsearchRecordHydrator.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticsearchRecordHydrator.php index ea75f32a71..5202d1c38f 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticsearchRecordHydrator.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/ElasticsearchRecordHydrator.php @@ -36,7 +36,7 @@ class ElasticsearchRecordHydrator $updatedOn = igorw\get_in($data, ['updated_on']); $record->setUpdated($updatedOn ? new \DateTime($updatedOn) : $updatedOn); $record->setUuid(igorw\get_in($data, ['uuid'], '')); - $record->setStatus(igorw\get_in($data, ['bin_status'], str_repeat('0', 32))); + $record->setStatus(igorw\get_in($data, ['flags_bitmask'], 0)); $record->setTitles(new ArrayCollection((array) igorw\get_in($data, ['title'], []))); $record->setCaption(new ArrayCollection((array) igorw\get_in($data, ['caption'], []))); $record->setExif(new ArrayCollection((array) igorw\get_in($data, ['exif'], []))); diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Indexer/RecordIndexer.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Indexer/RecordIndexer.php index 105f5f9f2d..d54384db3e 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Indexer/RecordIndexer.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Indexer/RecordIndexer.php @@ -330,7 +330,7 @@ class RecordIndexer foreach ($this->appbox->get_databoxes() as $databox) { foreach ($databox->get_statusbits() as $bit => $status) { - $key = self::normalizeFlagKey($status['labelon']); + $key = RecordHelper::normalizeFlagKey($status['labelon']); // We only add to mapping new statuses if (!$mapping->has($key)) { $mapping->add($key, 'boolean'); @@ -341,11 +341,6 @@ class RecordIndexer return $mapping; } - public static function normalizeFlagKey($key) - { - return StringUtils::slugify($key, '_'); - } - /** * Inspired by ESRecordSerializer * @@ -356,14 +351,12 @@ class RecordIndexer { $dateFields = $this->elasticSearchEngine->getAvailableDateFields(); $structure = $this->getFieldsStructure(); + $databox = $this->appbox->get_databox($record['databox_id']); - foreach ($this->appbox->get_databoxes() as $databox) { - foreach ($databox->get_statusbits() as $bit => $status) { - $key = self::normalizeFlagKey($status['labelon']); - $position = 31-$bit; + foreach ($databox->get_statusbits() as $bit => $status) { + $key = RecordHelper::normalizeFlagKey($status['labelon']); - $record['flags'][$key] = isset($record['bin_status']{$position}) ? (bool) $record['bin_status']{$position} : null; - } + $record['flags'][$key] = \databox_status::bitIsSet($record['flags_bitmask'], $bit); } foreach ($dateFields as $field) { diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordFetcher.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordFetcher.php index 073658b39b..91c41b9771 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordFetcher.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordFetcher.php @@ -124,7 +124,7 @@ class RecordFetcher SELECT r.record_id , r.coll_id as collection_id , r.uuid - , LPAD(BIN(r.status), 32, "0") as bin_status + , r.status as flags_bitmask , r.sha256 -- TODO rename in "hash" , r.originalname as original_name , r.mime @@ -152,7 +152,7 @@ SQL; SELECT r.record_id , r.coll_id as collection_id , r.uuid - , LPAD(BIN(r.status), 32, "0") as bin_status + , r.status as flags_bitmask , r.sha256 -- TODO rename in "hash" , r.originalname as original_name , r.mime diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordHelper.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordHelper.php index 4b48cfb28b..2453a06f8e 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordHelper.php +++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordHelper.php @@ -63,4 +63,9 @@ class RecordHelper return $this->collectionMap; } + + public static function normalizeFlagKey($key) + { + return StringUtils::slugify($key, '_'); + } } diff --git a/lib/Alchemy/Phrasea/SearchEngine/SearchEngineOptions.php b/lib/Alchemy/Phrasea/SearchEngine/SearchEngineOptions.php index fbb1945b55..39a49761ea 100644 --- a/lib/Alchemy/Phrasea/SearchEngine/SearchEngineOptions.php +++ b/lib/Alchemy/Phrasea/SearchEngine/SearchEngineOptions.php @@ -319,26 +319,9 @@ class SearchEngineOptions * @param array $status * @return SearchEngineOptions */ - public function setStatus(Array $status) + public function setStatus(array $status) { - $tmp = []; - foreach ($status as $n => $options) { - if (count($options) > 1) { - continue; - } - if (isset($options['on'])) { - foreach ($options['on'] as $sbas_id) { - $tmp[$n][$sbas_id] = 1; - } - } - if (isset($options['off'])) { - foreach ($options['off'] as $sbas_id) { - $tmp[$n][$sbas_id] = 0; - } - } - } - - $this->status = $tmp; + $this->status = $status; return $this; } @@ -662,6 +645,7 @@ class SearchEngineOptions $options->allowBusinessFieldsOn($BF); } + $status = is_array($request->get('status')) ? $request->get('status') : []; $fields = is_array($request->get('fields')) ? $request->get('fields') : []; diff --git a/lib/classes/databox/status.php b/lib/classes/databox/status.php index 76e2b6e4f5..7d736e9e38 100644 --- a/lib/classes/databox/status.php +++ b/lib/classes/databox/status.php @@ -10,6 +10,7 @@ */ use Alchemy\Phrasea\Application; +use Alchemy\Phrasea\SearchEngine\Elastic\RecordHelper; use MediaAlchemyst\Specification\Image as ImageSpecification; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\Exception\FileException; @@ -155,13 +156,10 @@ class databox_status public static function getSearchStatus(Application $app) { - $statuses = []; + $statuses = $see_all = []; + $databoxes = $app['acl']->get($app['authentication']->getUser())->get_granted_sbas(); - $sbas_ids = $app['acl']->get($app['authentication']->getUser())->get_granted_sbas(); - - $see_all = []; - - foreach ($sbas_ids as $databox) { + foreach ($databoxes as $databox) { $see_all[$databox->get_sbas_id()] = false; foreach ($databox->get_collections() as $collection) { @@ -170,6 +168,7 @@ class databox_status break; } } + try { $statuses[$databox->get_sbas_id()] = $databox->get_statusbits(); } catch (\Exception $e) { @@ -179,55 +178,25 @@ class databox_status $stats = []; - foreach ($statuses as $sbas_id => $status) { + foreach ($statuses as $databox_id => $status) { + $canSeeAll = isset($see_all[$databox_id]) ? $see_all[$databox_id] : false; + $canSeeThis = $app['acl']->get($app['authentication']->getUser())->has_right_on_sbas($databox_id, 'bas_modify_struct'); - $see_this = isset($see_all[$sbas_id]) ? $see_all[$sbas_id] : false; - - if ($app['acl']->get($app['authentication']->getUser())->has_right_on_sbas($sbas_id, 'bas_modify_struct')) { - $see_this = true; - } + $canAccess = $canSeeAll || $canSeeThis; foreach ($status as $bit => $props) { - - if ($props['searchable'] == 0 && ! $see_this) + if (!$props['searchable'] && !$canAccess) { continue; - - $set = false; - if (isset($stats[$bit])) { - foreach ($stats[$bit] as $k => $s_desc) { - if (mb_strtolower($s_desc['labelon']) == mb_strtolower($props['labelon']) - && mb_strtolower($s_desc['labeloff']) == mb_strtolower($props['labeloff'])) { - $stats[$bit][$k]['sbas'][] = $sbas_id; - $set = true; - } - } - if (! $set) { - $stats[$bit][] = [ - 'sbas' => [$sbas_id], - 'labeloff' => $props['labeloff'], - 'labelon' => $props['labelon'], - 'labels_on_i18n' => $props['labels_on_i18n'], - 'labels_off_i18n' => $props['labels_off_i18n'], - 'imgoff' => $props['img_off'], - 'imgon' => $props['img_on'] - ]; - $set = true; - } - } - - if (! $set) { - $stats[$bit] = [ - [ - 'sbas' => [$sbas_id], - 'labeloff' => $props['labeloff'], - 'labelon' => $props['labelon'], - 'labels_on_i18n' => $props['labels_on_i18n'], - 'labels_off_i18n' => $props['labels_off_i18n'], - 'imgoff' => $props['img_off'], - 'imgon' => $props['img_on'] - ] - ]; } + $stats[$databox_id][$bit] = array( + 'name' => RecordHelper::normalizeFlagKey($props['labelon']), + 'labeloff' => $props['labeloff'], + 'labelon' => $props['labelon'], + 'labels_on_i18n' => $props['labels_on_i18n'], + 'labels_off_i18n' => $props['labels_off_i18n'], + 'imgoff' => $props['img_off'], + 'imgon' => $props['img_on'] + ); } } @@ -636,4 +605,9 @@ class databox_status { self::$_status = self::$_statuses = []; } + + public static function bitIsSet($bitMask, $nthBit) + { + return (bool) ($bitMask & (1 << $nthBit)); + } } diff --git a/templates/web/prod/index.html.twig b/templates/web/prod/index.html.twig index be779f77cb..ff93c1106e 100644 --- a/templates/web/prod/index.html.twig +++ b/templates/web/prod/index.html.twig @@ -373,29 +373,29 @@
{{ 'Status des documents a rechercher' | trans }} - {% for n, stat in search_status %} - {% for s in stat %} + {% for sbas_id, bits in search_status %} + {% for n, bit in bits %}
- {% if s['imgoff'] %} - + {% if bit['imgoff'] %} + {% endif %} - {% if s['imgoff'] %} - + {% if bit['imgon'] %} + {% endif %}