Skip to content

Commit

Permalink
Merge pull request #4706 from 3liz/backport-4559-to-release_3_8
Browse files Browse the repository at this point in the history
[Backport release_3_8] Improve user/group filter to allow multiple users/groups
  • Loading branch information
mdouchin authored Aug 23, 2024
2 parents ad24bdc + af9156b commit fc77a2f
Show file tree
Hide file tree
Showing 15 changed files with 1,298 additions and 488 deletions.
39 changes: 14 additions & 25 deletions lizmap/modules/lizmap/lib/Form/QgisForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -1794,41 +1794,30 @@ protected function filterDataByLogin($layername)
return null;
}

// Optionnaly add a filter parameter
$lproj = $this->layer->getProject();
$loginFilteredConfig = $lproj->getLoginFilteredConfig($layername);
// Optionally add a filter parameter
$layerProject = $this->layer->getProject();
$loginFilteredConfig = $layerProject->getLoginFilteredConfig($layername);

if ($loginFilteredConfig) {
// Get filter type
$type = 'groups';
$attribute = $loginFilteredConfig->filterAttribute;

// check filter type
if (property_exists($loginFilteredConfig, 'filterPrivate')
&& $loginFilteredConfig->filterPrivate == 'True') {
&& $loginFilteredConfig->filterPrivate == 'True') {
$type = 'login';
}

// Check if a user is authenticated
$isConnected = $this->appContext->userIsConnected();
$cnx = $this->appContext->getDbConnection($this->layer->getId());
if ($isConnected) {
$user = $this->appContext->getUserSession();
$login = $user->login;
if ($type == 'login') {
$where = ' "'.$attribute."\" IN ( '".$login."' , 'all' )";
} else {
$userGroups = $this->appContext->aclUserPublicGroupsId();
// Set XML Filter if getFeature request
$flatGroups = implode("' , '", $userGroups);
$where = ' "'.$attribute."\" IN ( '".$flatGroups."' , 'all' )";
}
} else {
// The user is not authenticated: only show data with attribute = 'all'
$where = ' "'.$attribute.'" = '.$cnx->quote('all');
// Filter attribute
$attribute = $loginFilteredConfig->filterAttribute;

// SQL filter
$layerFilter = $layerProject->getLoginFilter($layername);
if (empty($layerFilter)) {
return null;
}

// Set filter when multiple layers concerned
return array(
'where' => $where,
'where' => $layerFilter['filter'],
'type' => $type,
'attribute' => $attribute,
);
Expand Down
87 changes: 82 additions & 5 deletions lizmap/modules/lizmap/lib/Project/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -1223,10 +1223,19 @@ public function getLoginFilteredConfig($layerName, $edition = false)
* Get login filters, get expressions for layers based on login filtered
* config.
*
* NOTE: We could delegate this completely to the lizmap_server plugin
* for all requests. The only request needed to have the SQL filter
* is the WFS GetFeature request, if the layer is a PostgreSQL layer.
* It this particular case, we could use a similar approach
* that the one use with qgisVectorLayer::requestPolygonFilter
* which calls the server plugin with SERVICE=Lizmap&REQUEST=GetSubsetString
*
* @param string[] $layers : layers' name list
* @param bool $edition : get login filters for edition
*
* @return array
* @return array Array containing layers names as key and filter configuration
* and SQL filters as values. Array might be empty if no filter
* is configured for the layer.
*/
public function getLoginFilters($layers, $edition = false)
{
Expand Down Expand Up @@ -1258,22 +1267,90 @@ public function getLoginFilters($layers, $edition = false)
// attribute to filter
$attribute = $loginFilteredConfig->filterAttribute;

// Quoted attribute with double-quotes
$cnx = $this->appContext->getDbConnection();
$quotedField = $cnx->encloseName($attribute);

// Get QGIS vector layer provider
/** @var null|\qgisVectorLayer $qgisLayer The QGIS vector layer instance */
$qgisLayer = $this->qgis->getLayer($layerByTypeName->id, $this);
$provider = 'unknown';
if ($qgisLayer) {
$provider = $qgisLayer->getProvider();
}

// default no user connected
$filter = "\"{$attribute}\" = 'all'";
$filter = "{$quotedField} = 'all'";

// For PostgreSQL layers, allow multiple values in the filter field
// E.g. "groupe_a,other_group"
if ($provider == 'postgres'
&& property_exists($loginFilteredConfig, 'allow_multiple_acl_values')
&& $loginFilteredConfig->allow_multiple_acl_values
) {
$filter .= " OR {$quotedField} LIKE 'all,%'";
$filter .= " OR {$quotedField} LIKE '%,all'";
$filter .= " OR {$quotedField} LIKE '%,all,%'";
}

// A user is connected
if ($this->appContext->userIsConnected()) {
// Get the user
$user = $this->appContext->getUserSession();
$login = $user->login;

// List of values for expression
$values = array();
if (property_exists($loginFilteredConfig, 'filterPrivate')
&& $this->optionToBoolean($loginFilteredConfig->filterPrivate)
) {
$filter = "\"{$attribute}\" IN ( '".$login."' , 'all' )";
// If filter is private use user_login
$values[] = $login;
} else {
// Else use user groups
$userGroups = $this->appContext->aclUserPublicGroupsId();
$flatGroups = implode("' , '", $userGroups);
$filter = "\"{$attribute}\" IN ( '".$flatGroups."' , 'all' )";
$values = $userGroups;
}

// Add all to values
$values[] = 'all';
$allValuesFilters = array();

// For each value (group, all, login, etc.), create a filter
// combining all the possibility: equality & LIKE
foreach ($values as $value) {
$valueFilters = array();
// Quote the value with single quotes
$quotedValue = $cnx->quote($value);

// equality
$valueFilters[] = "{$quotedField} = {$quotedValue}";

// For PostgreSQL layers, allow multiple values in the filter field
// E.g. "groupe_a,other_group"
if ($provider == 'postgres'
&& property_exists($loginFilteredConfig, 'allow_multiple_acl_values')
&& $loginFilteredConfig->allow_multiple_acl_values
) {
// begins with value & comma
$quotedLikeValue = $cnx->quote("{$value},%");
$valueFilters[] = "{$quotedField} LIKE {$quotedLikeValue}";

// ends with comma & value
$quotedLikeValue = $cnx->quote("%,{$value}");
$valueFilters[] = "{$quotedField} LIKE {$quotedLikeValue}";

// value between two commas
$quotedLikeValue = $cnx->quote("%,{$value},%");
$valueFilters[] = "{$quotedField} LIKE {$quotedLikeValue}";
}

// Build the filter for this value
$allValuesFilters[] = implode(' OR ', $valueFilters);
}

// Build filter for all values
$filter = implode(' OR ', $allValuesFilters);
}

$filters[$layerName] = array_merge(
Expand Down
4 changes: 2 additions & 2 deletions lizmap/modules/lizmap/lib/Request/WFSRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -643,13 +643,13 @@ protected function parseExpFilter($cnx, $params)
if (strpos($validFilter, '$id') !== false) {
$key = $this->datasource->key;
if (count(explode(',', $key)) == 1) {
return ' AND '.str_replace('$id ', $cnx->encloseName($key).' ', $validFilter);
return ' AND ( '.str_replace('$id ', $cnx->encloseName($key).' ', $validFilter).' ) ';
}

return false;
}

return ' AND '.$validFilter;
return ' AND ( '.$validFilter.' ) ';
}

return '';
Expand Down
10 changes: 10 additions & 0 deletions tests/end2end/cypress/integration/requests-metadata-ghaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('Request JSON metadata', function () {
"__anonymous",
"admins",
"group_a",
"group_b",
"publishers"
].sort()
);
Expand All @@ -49,6 +50,7 @@ describe('Request JSON metadata', function () {
"__anonymous",
"admins",
"group_a",
"group_b",
"publishers"
].sort()
);
Expand All @@ -72,6 +74,9 @@ describe('Request JSON metadata', function () {
"group_a": {
"label": "group_a"
},
"group_b": {
"label": "group_b"
},
"intranet": {
"label": "Intranet demos group"
},
Expand Down Expand Up @@ -145,6 +150,7 @@ describe('Request JSON metadata', function () {
"__anonymous",
"admins",
"group_a",
"group_b",
"publishers"
].sort()
);
Expand All @@ -153,6 +159,7 @@ describe('Request JSON metadata', function () {
"__anonymous",
"admins",
"group_a",
"group_b",
"publishers"
].sort()
);
Expand All @@ -167,6 +174,9 @@ describe('Request JSON metadata', function () {
"group_a": {
"label": "group_a"
},
"group_b": {
"label": "group_b"
},
"intranet": {
"label": "Intranet demos group"
},
Expand Down
99 changes: 75 additions & 24 deletions tests/end2end/playwright/filter-layer-by-user.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,34 @@ test.describe('Filter layer data by user - not connected', () => {
await page.locator('#dock-close').click();
});

test('GetMap', async ({ page }) => {
// Hide all elements but #newOlMap and its children
await page.$eval("*", el => el.style.visibility = 'hidden');
await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');
// DISABLED BECAUSE IT IS NOT RELIABLE, MAINTAINABLE AND CAUSES HEADACHE ;-)
// Instead, we use a WMS GetFeatureInfo in JSON format below
// test('GetMap', async ({ page }) => {
// // Hide all elements but #newOlMap and its children
// await page.$eval("*", el => el.style.visibility = 'hidden');
// await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');

// expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_not_connected.png', {
// maxDiffPixels: 500
// });
// });

test('WMS GetFeatureInfo JSON', async ({ page }) => {

const getFeatureInfo = await page.evaluate(async () => {
return await fetch("/index.php/lizmap/service?repository=testsrepository&project=filter_layer_by_user&SERVICE=WMS&REQUEST=GetFeatureInfo&VERSION=1.3.0&CRS=EPSG%3A2154&INFO_FORMAT=application%2Fjson&QUERY_LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&STYLE=default%2Cdefault%2Cdefault&FEATURE_COUNT=10&FILTER=green_filter_layer_by_user_edition_only:\"gid\" > 0")
.then(r => r.ok ? r.json() : Promise.reject(r))
})

// check features
expect(getFeatureInfo.features).toHaveLength(4)
// check a specific feature
const feature = getFeatureInfo.features[0]
expect(feature.id).not.toBeUndefined()

expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_not_connected.png', {
maxDiffPixels: 500
});
});

test('Popup', async ({ page }) => {
test('Popup with map click', async ({ page }) => {
let getFeatureInfoRequestPromise = page.waitForRequest(request => request.method() === 'POST' && request.postData()?.includes('GetFeatureInfo') === true);

// blue_filter_layer_by_user
Expand Down Expand Up @@ -133,17 +150,34 @@ test.describe('Filter layer data by user - user in group a', () => {
await page.locator('#dock-close').click();
});

test('GetMap', async ({ page }) => {
// Hide all elements but #newOlMap and its children
await page.$eval("*", el => el.style.visibility = 'hidden');
await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');
// DISABLED BECAUSE IT IS NOT RELIABLE, MAINTAINABLE AND CAUSES HEADACHE ;-)
// Instead, we use a WMS GetFeatureInfo in JSON format below
// test('GetMap', async ({ page }) => {
// // Hide all elements but #newOlMap and its children
// await page.$eval("*", el => el.style.visibility = 'hidden');
// await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');

// expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_connected_as_user_in_group_a.png', {
// maxDiffPixels: 500
// });
// });

test('WMS GetFeatureInfo JSON', async ({ page }) => {

const getFeatureInfo = await page.evaluate(async () => {
return await fetch("/index.php/lizmap/service?repository=testsrepository&project=filter_layer_by_user&SERVICE=WMS&REQUEST=GetFeatureInfo&VERSION=1.3.0&CRS=EPSG%3A2154&INFO_FORMAT=application%2Fjson&QUERY_LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&STYLE=default%2Cdefault%2Cdefault&FEATURE_COUNT=10&FILTER=green_filter_layer_by_user_edition_only:\"gid\" > 0")
.then(r => r.ok ? r.json() : Promise.reject(r))
})

// check features
expect(getFeatureInfo.features).toHaveLength(5)
// check a specific feature
const feature = getFeatureInfo.features[0]
expect(feature.id).not.toBeUndefined()

expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_connected_as_user_in_group_a.png', {
maxDiffPixels: 500
});
});

test('Popup', async ({ page }) => {
test('Popup with map click', async ({ page }) => {
let getFeatureInfoRequestPromise = page.waitForRequest(request => request.method() === 'POST' && request.postData()?.includes('GetFeatureInfo') === true);

// blue_filter_layer_by_user
Expand Down Expand Up @@ -258,17 +292,34 @@ test.describe('Filter layer data by user - admin', () => {
await page.locator('#dock-close').click();
});

test('GetMap', async ({ page }) => {
// Hide all elements but #newOlMap and its children
await page.$eval("*", el => el.style.visibility = 'hidden');
await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');
// DISABLED BECAUSE IT IS NOT RELIABLE, MAINTAINABLE AND CAUSES HEADACHE ;-)
// Instead, we use a WMS GetFeatureInfo in JSON format below
// test('GetMap', async ({ page }) => {
// // Hide all elements but #newOlMap and its children
// await page.$eval("*", el => el.style.visibility = 'hidden');
// await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');

// expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_connected_as_admin.png', {
// maxDiffPixels: 500
// });
// });

test('WMS GetFeatureInfo JSON', async ({ page }) => {

const getFeatureInfo = await page.evaluate(async () => {
return await fetch("/index.php/lizmap/service?repository=testsrepository&project=filter_layer_by_user&SERVICE=WMS&REQUEST=GetFeatureInfo&VERSION=1.3.0&CRS=EPSG%3A2154&INFO_FORMAT=application%2Fjson&QUERY_LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&STYLE=default%2Cdefault%2Cdefault&FEATURE_COUNT=10&FILTER=green_filter_layer_by_user_edition_only:\"gid\" > 0")
.then(r => r.ok ? r.json() : Promise.reject(r))
})

// check features
expect(getFeatureInfo.features).toHaveLength(7)
// check a specific feature
const feature = getFeatureInfo.features[0]
expect(feature.id).not.toBeUndefined()

expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_connected_as_admin.png', {
maxDiffPixels: 500
});
});

test('Popup', async ({ page }) => {
test('Popup with map click', async ({ page }) => {
let getFeatureInfoRequestPromise = page.waitForRequest(request => request.method() === 'POST' && request.postData()?.includes('GetFeatureInfo') === true);

// blue_filter_layer_by_user
Expand Down
Loading

0 comments on commit fc77a2f

Please sign in to comment.