Apply ACL restriction filters for a search query

This commit is contained in:
Nicolas Le Goff
2014-12-23 18:25:50 +01:00
parent 9fff4fdbc0
commit 7cc204f2ba
8 changed files with 240 additions and 177 deletions

View File

@@ -11,7 +11,6 @@
namespace Alchemy\Phrasea\SearchEngine\Elastic; namespace Alchemy\Phrasea\SearchEngine\Elastic;
use Alchemy\Phrasea\SearchEngine\Elastic\ElasticsearchRecordHydrator;
use Alchemy\Phrasea\SearchEngine\Elastic\Indexer\RecordIndexer; use Alchemy\Phrasea\SearchEngine\Elastic\Indexer\RecordIndexer;
use Alchemy\Phrasea\SearchEngine\Elastic\Indexer\TermIndexer; use Alchemy\Phrasea\SearchEngine\Elastic\Indexer\TermIndexer;
use Alchemy\Phrasea\SearchEngine\Elastic\Search\SearchQuery; use Alchemy\Phrasea\SearchEngine\Elastic\Search\SearchQuery;
@@ -26,6 +25,10 @@ use Elasticsearch\Client;
class ElasticSearchEngine implements SearchEngineInterface class ElasticSearchEngine implements SearchEngineInterface
{ {
const FLAG_ALLOW_BOTH = 'allow_both';
const FLAG_SET_ONLY = 'set_only';
const FLAG_UNSET_ONLY = 'unset_only';
private $app; private $app;
/** @var Client */ /** @var Client */
private $client; private $client;
@@ -438,7 +441,7 @@ class ElasticSearchEngine implements SearchEngineInterface
return $params; return $params;
} }
private function createRecordQueryParams($ESquery, SearchEngineOptions $options, \record_adapter $record = null) private function createRecordQueryParams($ESQuery, SearchEngineOptions $options, \record_adapter $record = null)
{ {
$params = [ $params = [
'index' => $this->indexName, '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) { $ESQuery = ['filtered' => ['query' => $ESQuery]];
$filters[] = [
'term' => [
'_id' => sprintf('%s-%s', $record->get_sbas_id(), $record->get_record_id()),
]
];
$fields = []; if (count($query_filters) > 0) {
$ESQuery['filtered']['filter']['bool']['must'][] = $query_filters;
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($filters) > 0) { if (count($acl_filters) > 0) {
$ESquery = [ $ESQuery['filtered']['filter']['bool']['must'][] = $acl_filters;
'filtered' => [
'query' => $ESquery,
'filter' => [
'and' => $filters
]
]
];
} }
$params['body']['query'] = $ESquery; $params['body']['query'] = $ESQuery;
return $params; 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 = []; $filters = [];
$status_opts = $options->getStatus(); $filters[]['term']['record_type'] = $options->getSearchType() === SearchEngineOptions::RECORD_RECORD ?
foreach ($options->getDataboxes() as $databox) { SearchEngineInterface::GEM_TYPE_RECORD : SearchEngineInterface::GEM_TYPE_STORY;
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,
]
];
if ($options->getDateFields() && ($options->getMaxDate() || $options->getMinDate())) { if ($options->getDateFields() && ($options->getMaxDate() || $options->getMinDate())) {
$range = []; $range = [];
@@ -539,20 +510,40 @@ class ElasticSearchEngine implements SearchEngineInterface
} }
foreach ($options->getDateFields() as $dateField) { foreach ($options->getDateFields() as $dateField) {
$filters[] = [ $filters[]['range']['caption.'.$dateField->get_name()] = $range;
'range' => [
'caption.'.$dateField->get_name() => $range
]
];
} }
} }
if ($options->getRecordType()) { if ($options->getRecordType()) {
$filters[] = [ $filters[]['term']['phrasea_type'] = $options->getRecordType();
'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; return $filters;
@@ -610,4 +601,120 @@ class ElasticSearchEngine implements SearchEngineInterface
return $fieldsExpended; 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;
}
} }

View File

@@ -36,7 +36,7 @@ class ElasticsearchRecordHydrator
$updatedOn = igorw\get_in($data, ['updated_on']); $updatedOn = igorw\get_in($data, ['updated_on']);
$record->setUpdated($updatedOn ? new \DateTime($updatedOn) : $updatedOn); $record->setUpdated($updatedOn ? new \DateTime($updatedOn) : $updatedOn);
$record->setUuid(igorw\get_in($data, ['uuid'], '')); $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->setTitles(new ArrayCollection((array) igorw\get_in($data, ['title'], [])));
$record->setCaption(new ArrayCollection((array) igorw\get_in($data, ['caption'], []))); $record->setCaption(new ArrayCollection((array) igorw\get_in($data, ['caption'], [])));
$record->setExif(new ArrayCollection((array) igorw\get_in($data, ['exif'], []))); $record->setExif(new ArrayCollection((array) igorw\get_in($data, ['exif'], [])));

View File

@@ -330,7 +330,7 @@ class RecordIndexer
foreach ($this->appbox->get_databoxes() as $databox) { foreach ($this->appbox->get_databoxes() as $databox) {
foreach ($databox->get_statusbits() as $bit => $status) { foreach ($databox->get_statusbits() as $bit => $status) {
$key = self::normalizeFlagKey($status['labelon']); $key = RecordHelper::normalizeFlagKey($status['labelon']);
// We only add to mapping new statuses // We only add to mapping new statuses
if (!$mapping->has($key)) { if (!$mapping->has($key)) {
$mapping->add($key, 'boolean'); $mapping->add($key, 'boolean');
@@ -341,11 +341,6 @@ class RecordIndexer
return $mapping; return $mapping;
} }
public static function normalizeFlagKey($key)
{
return StringUtils::slugify($key, '_');
}
/** /**
* Inspired by ESRecordSerializer * Inspired by ESRecordSerializer
* *
@@ -356,14 +351,12 @@ class RecordIndexer
{ {
$dateFields = $this->elasticSearchEngine->getAvailableDateFields(); $dateFields = $this->elasticSearchEngine->getAvailableDateFields();
$structure = $this->getFieldsStructure(); $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) {
foreach ($databox->get_statusbits() as $bit => $status) { $key = RecordHelper::normalizeFlagKey($status['labelon']);
$key = self::normalizeFlagKey($status['labelon']);
$position = 31-$bit;
$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) { foreach ($dateFields as $field) {

View File

@@ -124,7 +124,7 @@ class RecordFetcher
SELECT r.record_id SELECT r.record_id
, r.coll_id as collection_id , r.coll_id as collection_id
, r.uuid , r.uuid
, LPAD(BIN(r.status), 32, "0") as bin_status , r.status as flags_bitmask
, r.sha256 -- TODO rename in "hash" , r.sha256 -- TODO rename in "hash"
, r.originalname as original_name , r.originalname as original_name
, r.mime , r.mime
@@ -152,7 +152,7 @@ SQL;
SELECT r.record_id SELECT r.record_id
, r.coll_id as collection_id , r.coll_id as collection_id
, r.uuid , r.uuid
, LPAD(BIN(r.status), 32, "0") as bin_status , r.status as flags_bitmask
, r.sha256 -- TODO rename in "hash" , r.sha256 -- TODO rename in "hash"
, r.originalname as original_name , r.originalname as original_name
, r.mime , r.mime

View File

@@ -63,4 +63,9 @@ class RecordHelper
return $this->collectionMap; return $this->collectionMap;
} }
public static function normalizeFlagKey($key)
{
return StringUtils::slugify($key, '_');
}
} }

View File

@@ -319,26 +319,9 @@ class SearchEngineOptions
* @param array $status * @param array $status
* @return SearchEngineOptions * @return SearchEngineOptions
*/ */
public function setStatus(Array $status) public function setStatus(array $status)
{ {
$tmp = []; $this->status = $status;
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;
return $this; return $this;
} }
@@ -662,6 +645,7 @@ class SearchEngineOptions
$options->allowBusinessFieldsOn($BF); $options->allowBusinessFieldsOn($BF);
} }
$status = is_array($request->get('status')) ? $request->get('status') : []; $status = is_array($request->get('status')) ? $request->get('status') : [];
$fields = is_array($request->get('fields')) ? $request->get('fields') : []; $fields = is_array($request->get('fields')) ? $request->get('fields') : [];

View File

@@ -10,6 +10,7 @@
*/ */
use Alchemy\Phrasea\Application; use Alchemy\Phrasea\Application;
use Alchemy\Phrasea\SearchEngine\Elastic\RecordHelper;
use MediaAlchemyst\Specification\Image as ImageSpecification; use MediaAlchemyst\Specification\Image as ImageSpecification;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\Exception\FileException;
@@ -155,13 +156,10 @@ class databox_status
public static function getSearchStatus(Application $app) 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(); foreach ($databoxes as $databox) {
$see_all = [];
foreach ($sbas_ids as $databox) {
$see_all[$databox->get_sbas_id()] = false; $see_all[$databox->get_sbas_id()] = false;
foreach ($databox->get_collections() as $collection) { foreach ($databox->get_collections() as $collection) {
@@ -170,6 +168,7 @@ class databox_status
break; break;
} }
} }
try { try {
$statuses[$databox->get_sbas_id()] = $databox->get_statusbits(); $statuses[$databox->get_sbas_id()] = $databox->get_statusbits();
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -179,55 +178,25 @@ class databox_status
$stats = []; $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; $canAccess = $canSeeAll || $canSeeThis;
if ($app['acl']->get($app['authentication']->getUser())->has_right_on_sbas($sbas_id, 'bas_modify_struct')) {
$see_this = true;
}
foreach ($status as $bit => $props) { foreach ($status as $bit => $props) {
if (!$props['searchable'] && !$canAccess) {
if ($props['searchable'] == 0 && ! $see_this)
continue; 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 = []; self::$_status = self::$_statuses = [];
} }
public static function bitIsSet($bitMask, $nthBit)
{
return (bool) ($bitMask & (1 << $nthBit));
}
} }

View File

@@ -373,29 +373,29 @@
<div class="status_filter"> <div class="status_filter">
<span>{{ 'Status des documents a rechercher' | trans }}</span> <span>{{ 'Status des documents a rechercher' | trans }}</span>
<table style="width: 100%;"> <table style="width: 100%;">
{% for n, stat in search_status %} {% for sbas_id, bits in search_status %}
{% for s in stat %} {% for n, bit in bits %}
<tr> <tr>
<td> <td>
{% if s['imgoff'] %} {% if bit['imgoff'] %}
<img src="{{s['imgoff']}}" title="{{s['labels_off_i18n'][app['locale']]}}" /> <img src="{{bit['imgoff']}}" title="{{bit['labels_off_i18n'][app['locale']]}}" />
{% endif %} {% endif %}
<label class="checkbox inline"> <label class="checkbox inline">
<input onchange="checkFilters(true);" class="field_switch field_{{s['sbas']|join(' field_')}}" <input onchange="checkFilters(true);" class="field_switch field_{{sbas_id}}"
type="checkbox" value="{{s['sbas']|join(' field_')}}" type="checkbox" value="0"
n="{{n}}" name="status[{{n}}][off][]" /> n="{{n}}" name="status[{{sbas_id}}][{{n}}]" />
{{s['labels_off_i18n'][app['locale']]}} {{bit['labels_off_i18n'][app['locale']]}}
</label> </label>
</td> </td>
<td> <td>
{% if s['imgoff'] %} {% if bit['imgon'] %}
<img src="{{s['imgon']}}" title="{{s['labels_on_i18n'][app['locale']]}}" /> <img src="{{bit['imgon']}}" title="{{bit['labels_on_i18n'][app['locale']]}}" />
{% endif %} {% endif %}
<label class="checkbox inline"> <label class="checkbox inline">
<input onchange="checkFilters(true);" class="field_switch field_{{s['sbas']|join(' field_')}}" <input onchange="checkFilters(true);" class="field_switch field_{{sbas_id}}"
type="checkbox" value="{{s['sbas']|join(' field_')}}" type="checkbox" value="1"
n="{{n}}" name="status[{{n}}][on][]"/> n="{{n}}" name="status[{{sbas_id}}][{{n}}]"/>
{{s['labels_on_i18n'][app['locale']]}} {{bit['labels_on_i18n'][app['locale']]}}
</label> </label>
</td> </td>
</tr> </tr>